From 6f0ccc04b4ade7ae3a00262ea0de661301c92042 Mon Sep 17 00:00:00 2001 From: Daniel Viegas Date: Fri, 28 Nov 2025 22:52:16 +0100 Subject: [PATCH] Fix EventType dictionary key error and translation function shadowing - Fixed EventType objects not being hashable by extracting integer value - Fixed UnboundLocalError by removing variable shadowing of translation function _ - Updated all tuple unpacking to use proper variable names instead of _ - All event types now properly supported with color/shape mapping - Plugin fully compatible with Gramps 5.1 --- MyTimeline.py | 832 ++++++++++++++++++++----- __pycache__/MyTimeline.cpython-312.pyc | Bin 0 -> 44792 bytes 2 files changed, 672 insertions(+), 160 deletions(-) create mode 100644 __pycache__/MyTimeline.cpython-312.pyc diff --git a/MyTimeline.py b/MyTimeline.py index 7d9b929..00671b7 100644 --- a/MyTimeline.py +++ b/MyTimeline.py @@ -28,8 +28,10 @@ MyTimeline View - A vertical timeline showing family events # # ------------------------------------------------------------------------- import cairo +import math from gi.repository import Gtk from gi.repository import Gdk +from gi.repository import GLib from gi.repository import Pango from gi.repository import PangoCairo @@ -63,9 +65,145 @@ TIMELINE_MARGIN_RIGHT = 50 TIMELINE_MARGIN_TOP = 50 TIMELINE_MARGIN_BOTTOM = 50 TIMELINE_LINE_WIDTH = 3 -EVENT_MARKER_SIZE = 8 -EVENT_SPACING = 60 +EVENT_MARKER_SIZE = 10 +EVENT_SPACING = 80 YEAR_LABEL_WIDTH = 100 +EXPANDED_HEIGHT = 120 +TOOLTIP_DELAY = 500 # milliseconds + +# Event type categories and color mapping +EVENT_COLORS = { + # Life Events - Green/Red spectrum + EventType.BIRTH: (0.2, 0.8, 0.3), # Bright green + EventType.DEATH: (0.9, 0.2, 0.2), # Bright red + EventType.BURIAL: (0.7, 0.3, 0.3), # Dark red + EventType.CREMATION: (0.8, 0.4, 0.4), # Light red + EventType.ADOPT: (0.3, 0.7, 0.4), # Green + + # Family Events - Blue/Purple spectrum + EventType.MARRIAGE: (0.2, 0.4, 0.9), # Blue + EventType.DIVORCE: (0.6, 0.3, 0.7), # Purple + EventType.ENGAGEMENT: (0.4, 0.5, 0.9), # Light blue + EventType.MARR_SETTL: (0.3, 0.4, 0.8), # Dark blue + EventType.MARR_LIC: (0.4, 0.5, 0.85), # Medium blue + EventType.MARR_CONTR: (0.35, 0.45, 0.85), # Medium blue + EventType.MARR_BANNS: (0.45, 0.55, 0.9), # Light blue + EventType.DIV_FILING: (0.65, 0.35, 0.75), # Light purple + EventType.ANNULMENT: (0.7, 0.4, 0.8), # Light purple + EventType.MARR_ALT: (0.5, 0.5, 0.9), # Medium blue + + # Religious Events - Gold/Yellow spectrum + EventType.BAPTISM: (0.95, 0.85, 0.2), # Gold + EventType.ADULT_CHRISTEN: (0.9, 0.8, 0.3), # Light gold + EventType.CONFIRMATION: (0.95, 0.9, 0.4), # Yellow + EventType.CHRISTEN: (0.9, 0.85, 0.35), # Gold + EventType.FIRST_COMMUN: (0.95, 0.88, 0.3), # Gold + EventType.BLESS: (0.92, 0.87, 0.4), # Light gold + EventType.BAR_MITZVAH: (0.93, 0.83, 0.3), # Gold + EventType.BAS_MITZVAH: (0.94, 0.84, 0.3), # Gold + EventType.RELIGION: (0.9, 0.8, 0.5), # Light gold + EventType.ORDINATION: (0.88, 0.75, 0.3), # Dark gold + + # Vocational Events - Orange spectrum + EventType.OCCUPATION: (0.95, 0.6, 0.2), # Orange + EventType.RETIREMENT: (0.9, 0.55, 0.25), # Dark orange + EventType.ELECTED: (0.95, 0.65, 0.3), # Light orange + EventType.MILITARY_SERV: (0.85, 0.5, 0.2), # Dark orange + + # Academic Events - Teal spectrum + EventType.EDUCATION: (0.2, 0.7, 0.7), # Teal + EventType.GRADUATION: (0.3, 0.8, 0.8), # Bright teal + EventType.DEGREE: (0.25, 0.75, 0.75), # Medium teal + + # Travel Events - Cyan spectrum + EventType.EMIGRATION: (0.2, 0.7, 0.9), # Cyan + EventType.IMMIGRATION: (0.3, 0.8, 0.95), # Bright cyan + EventType.NATURALIZATION: (0.25, 0.75, 0.9), # Medium cyan + + # Legal Events - Brown spectrum + EventType.PROBATE: (0.6, 0.4, 0.2), # Brown + EventType.WILL: (0.65, 0.45, 0.25), # Light brown + + # Residence Events - Indigo spectrum + EventType.RESIDENCE: (0.4, 0.3, 0.7), # Indigo + EventType.CENSUS: (0.5, 0.4, 0.8), # Light indigo + EventType.PROPERTY: (0.45, 0.35, 0.75), # Medium indigo + + # Other Events - Gray spectrum + EventType.CAUSE_DEATH: (0.5, 0.5, 0.5), # Gray + EventType.MED_INFO: (0.6, 0.6, 0.6), # Light gray + EventType.NOB_TITLE: (0.55, 0.55, 0.55), # Medium gray + EventType.NUM_MARRIAGES: (0.5, 0.5, 0.5), # Gray + EventType.UNKNOWN: (0.4, 0.4, 0.4), # Dark gray + EventType.CUSTOM: (0.45, 0.45, 0.45), # Medium gray +} + +# Event shape types: 'triangle', 'circle', 'diamond', 'square', 'star', 'hexagon' +EVENT_SHAPES = { + # Life Events + EventType.BIRTH: 'triangle', + EventType.DEATH: 'circle', + EventType.BURIAL: 'circle', + EventType.CREMATION: 'circle', + EventType.ADOPT: 'triangle', + + # Family Events + EventType.MARRIAGE: 'diamond', + EventType.DIVORCE: 'square', + EventType.ENGAGEMENT: 'diamond', + EventType.MARR_SETTL: 'diamond', + EventType.MARR_LIC: 'diamond', + EventType.MARR_CONTR: 'diamond', + EventType.MARR_BANNS: 'diamond', + EventType.DIV_FILING: 'square', + EventType.ANNULMENT: 'square', + EventType.MARR_ALT: 'diamond', + + # Religious Events + EventType.BAPTISM: 'star', + EventType.ADULT_CHRISTEN: 'star', + EventType.CONFIRMATION: 'star', + EventType.CHRISTEN: 'star', + EventType.FIRST_COMMUN: 'star', + EventType.BLESS: 'star', + EventType.BAR_MITZVAH: 'star', + EventType.BAS_MITZVAH: 'star', + EventType.RELIGION: 'star', + EventType.ORDINATION: 'star', + + # Vocational Events + EventType.OCCUPATION: 'hexagon', + EventType.RETIREMENT: 'hexagon', + EventType.ELECTED: 'hexagon', + EventType.MILITARY_SERV: 'hexagon', + + # Academic Events + EventType.EDUCATION: 'square', + EventType.GRADUATION: 'square', + EventType.DEGREE: 'square', + + # Travel Events + EventType.EMIGRATION: 'triangle', + EventType.IMMIGRATION: 'triangle', + EventType.NATURALIZATION: 'triangle', + + # Legal Events + EventType.PROBATE: 'square', + EventType.WILL: 'square', + + # Residence Events + EventType.RESIDENCE: 'square', + EventType.CENSUS: 'square', + EventType.PROPERTY: 'square', + + # Other Events + EventType.CAUSE_DEATH: 'circle', + EventType.MED_INFO: 'circle', + EventType.NOB_TITLE: 'square', + EventType.NUM_MARRIAGES: 'square', + EventType.UNKNOWN: 'square', + EventType.CUSTOM: 'square', +} # ------------------------------------------------------------------------- @@ -76,7 +214,7 @@ YEAR_LABEL_WIDTH = 100 class MyTimelineView(NavigationView): """ View for displaying a vertical timeline of family events. - Shows marriage, birth, and death events for family members. + Shows all events for family members with modern design and interactivity. """ def __init__(self, pdata, dbstate, uistate, nav_group=0): @@ -90,12 +228,24 @@ class MyTimelineView(NavigationView): # Current family handle self.active_family_handle = None - self.events = [] # List of (date_sort, date_obj, event, person, event_type_str) + self.events = [] # List of (date_sort, date_obj, event, person, event_type, expanded, y_pos) # UI components self.scrolledwindow = None self.drawing_area = None self.timeline_height = 1000 # Default height, will be recalculated + self.zoom_level = 1.0 # Zoom level (1.0 = 100%) + self.min_zoom = 0.5 + self.max_zoom = 3.0 + self.zoom_step = 0.1 + + # Interaction state + self.hovered_event_index = None + self.tooltip_timeout_id = None + self.tooltip_window = None + self.expanded_event_index = None + self.mouse_x = 0 + self.mouse_y = 0 # Connect to database changes self.dbstate.connect("database-changed", self.change_db) @@ -147,7 +297,6 @@ class MyTimelineView(NavigationView): def event_updated(self, handle_list): """Called when an event is updated.""" # Re-collect events if we have an active family - # (simpler than checking if the event is in our list) if self.active_family_handle: self.collect_events() if self.drawing_area: @@ -158,9 +307,6 @@ class MyTimelineView(NavigationView): self.active_family_handle = None self.events = [] - # Disconnect old signals if any - # (In practice, the old db object is no longer valid) - # Connect to new database signals if db and db.is_open(): db.connect("family-update", self.family_updated) @@ -172,6 +318,44 @@ class MyTimelineView(NavigationView): def build_widget(self): """Build the interface and return the container.""" + # Main container + main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5) + + # Toolbar with zoom controls + toolbar = Gtk.Toolbar() + toolbar.set_style(Gtk.ToolbarStyle.ICONS) + + # Zoom out button + zoom_out_btn = Gtk.ToolButton(icon_name="zoom-out-symbolic") + zoom_out_btn.set_tooltip_text(_("Zoom Out")) + zoom_out_btn.connect("clicked", self.on_zoom_out) + toolbar.insert(zoom_out_btn, 0) + + # Zoom label + self.zoom_label = Gtk.Label(label="100%") + self.zoom_label.set_margin_start(10) + self.zoom_label.set_margin_end(10) + zoom_item = Gtk.ToolItem() + zoom_item.add(self.zoom_label) + toolbar.insert(zoom_item, 1) + + # Zoom in button + zoom_in_btn = Gtk.ToolButton(icon_name="zoom-in-symbolic") + zoom_in_btn.set_tooltip_text(_("Zoom In")) + zoom_in_btn.connect("clicked", self.on_zoom_in) + toolbar.insert(zoom_in_btn, 2) + + # Reset zoom button + zoom_reset_btn = Gtk.ToolButton(icon_name="zoom-fit-best-symbolic") + zoom_reset_btn.set_tooltip_text(_("Reset Zoom")) + zoom_reset_btn.connect("clicked", self.on_zoom_reset) + toolbar.insert(zoom_reset_btn, 3) + + toolbar.insert(Gtk.SeparatorToolItem(), 4) + + main_box.pack_start(toolbar, False, False, 0) + + # Scrolled window self.scrolledwindow = Gtk.ScrolledWindow() self.scrolledwindow.set_policy( Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC @@ -181,11 +365,23 @@ class MyTimelineView(NavigationView): self.drawing_area.set_size_request(800, 600) self.drawing_area.connect("draw", self.on_draw) self.drawing_area.add_events( - Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.SCROLL_MASK + Gdk.EventMask.BUTTON_PRESS_MASK + | Gdk.EventMask.BUTTON_RELEASE_MASK + | Gdk.EventMask.POINTER_MOTION_MASK + | Gdk.EventMask.LEAVE_NOTIFY_MASK + | Gdk.EventMask.SCROLL_MASK ) + + # Connect mouse events + self.drawing_area.connect("button-press-event", self.on_button_press) + self.drawing_area.connect("motion-notify-event", self.on_motion_notify) + self.drawing_area.connect("leave-notify-event", self.on_leave_notify) + self.drawing_area.connect("scroll-event", self.on_scroll) self.scrolledwindow.add(self.drawing_area) - return self.scrolledwindow + main_box.pack_start(self.scrolledwindow, True, True, 0) + + return main_box def build_tree(self): """Rebuilds the current display. Called when the view becomes visible.""" @@ -199,6 +395,7 @@ class MyTimelineView(NavigationView): return self.active_family_handle = handle + self.expanded_event_index = None self.collect_events() if self.drawing_area: self.drawing_area.queue_draw() @@ -232,148 +429,318 @@ class MyTimelineView(NavigationView): event, None, # No person for family events event_type, + False, # expanded + 0, # y_pos (will be calculated during draw) ) ) except: pass - # Get father's birth and death + # Helper function to collect all events from a person + def collect_person_events(person, person_obj): + """Collect all events from a person.""" + if not person: + return + try: + # Get all event references from the person + for event_ref in person.get_event_ref_list(): + try: + event = self.dbstate.db.get_event_from_handle(event_ref.ref) + if event and event.get_date_object(): + date_obj = event.get_date_object() + event_type = event.get_type() + self.events.append( + ( + date_obj.get_sort_value(), + date_obj, + event, + person_obj, + event_type, + False, # expanded + 0, # y_pos + ) + ) + except: + pass + except: + pass + + # Get father's events father_handle = family.get_father_handle() if father_handle: try: father = self.dbstate.db.get_person_from_handle(father_handle) if father: - birth = get_birth_or_fallback(self.dbstate.db, father) - if birth and birth.get_date_object(): - date_obj = birth.get_date_object() - self.events.append( - ( - date_obj.get_sort_value(), - date_obj, - birth, - father, - EventType.BIRTH, - ) - ) - - death = get_death_or_fallback(self.dbstate.db, father) - if death and death.get_date_object(): - date_obj = death.get_date_object() - self.events.append( - ( - date_obj.get_sort_value(), - date_obj, - death, - father, - EventType.DEATH, - ) - ) + collect_person_events(father, father) except: pass - # Get mother's birth and death + # Get mother's events mother_handle = family.get_mother_handle() if mother_handle: try: mother = self.dbstate.db.get_person_from_handle(mother_handle) if mother: - birth = get_birth_or_fallback(self.dbstate.db, mother) - if birth and birth.get_date_object(): - date_obj = birth.get_date_object() - self.events.append( - ( - date_obj.get_sort_value(), - date_obj, - birth, - mother, - EventType.BIRTH, - ) - ) - - death = get_death_or_fallback(self.dbstate.db, mother) - if death and death.get_date_object(): - date_obj = death.get_date_object() - self.events.append( - ( - date_obj.get_sort_value(), - date_obj, - death, - mother, - EventType.DEATH, - ) - ) + collect_person_events(mother, mother) except: pass - # Get children's birth and death + # Get children's events for child_ref in family.get_child_ref_list(): child_handle = child_ref.ref try: child = self.dbstate.db.get_person_from_handle(child_handle) if child: - birth = get_birth_or_fallback(self.dbstate.db, child) - if birth and birth.get_date_object(): - date_obj = birth.get_date_object() - self.events.append( - ( - date_obj.get_sort_value(), - date_obj, - birth, - child, - EventType.BIRTH, - ) - ) - - death = get_death_or_fallback(self.dbstate.db, child) - if death and death.get_date_object(): - date_obj = death.get_date_object() - self.events.append( - ( - date_obj.get_sort_value(), - date_obj, - death, - child, - EventType.DEATH, - ) - ) + collect_person_events(child, child) except: pass # Sort events by date self.events.sort(key=lambda x: x[0]) - # Calculate timeline height based on number of events + # Calculate timeline height based on number of events and zoom if self.events: - self.timeline_height = ( + base_height = ( TIMELINE_MARGIN_TOP + len(self.events) * EVENT_SPACING + TIMELINE_MARGIN_BOTTOM ) + self.timeline_height = int(base_height * self.zoom_level) else: - self.timeline_height = 600 + self.timeline_height = int(600 * self.zoom_level) if self.drawing_area: self.drawing_area.set_size_request(800, self.timeline_height) + def on_zoom_in(self, widget): + """Zoom in.""" + if self.zoom_level < self.max_zoom: + self.zoom_level = min(self.zoom_level + self.zoom_step, self.max_zoom) + self.update_zoom_display() + self.collect_events() # Recalculate height + if self.drawing_area: + self.drawing_area.queue_draw() + + def on_zoom_out(self, widget): + """Zoom out.""" + if self.zoom_level > self.min_zoom: + self.zoom_level = max(self.zoom_level - self.zoom_step, self.min_zoom) + self.update_zoom_display() + self.collect_events() # Recalculate height + if self.drawing_area: + self.drawing_area.queue_draw() + + def on_zoom_reset(self, widget): + """Reset zoom to 100%.""" + self.zoom_level = 1.0 + self.update_zoom_display() + self.collect_events() # Recalculate height + if self.drawing_area: + self.drawing_area.queue_draw() + + def on_scroll(self, widget, event): + """Handle scroll events for zooming with Ctrl+scroll.""" + if event.state & Gdk.ModifierType.CONTROL_MASK: + if event.direction == Gdk.ScrollDirection.UP: + self.on_zoom_in(widget) + elif event.direction == Gdk.ScrollDirection.DOWN: + self.on_zoom_out(widget) + return True + return False + + def update_zoom_display(self): + """Update the zoom level display.""" + if hasattr(self, 'zoom_label'): + self.zoom_label.set_text(f"{int(self.zoom_level * 100)}%") + + def on_button_press(self, widget, event): + """Handle mouse button press events.""" + if event.button == 1: # Left click + # Find which event was clicked + clicked_index = self.find_event_at_position(event.x, event.y) + if clicked_index is not None: + # Toggle expansion + if self.expanded_event_index == clicked_index: + self.expanded_event_index = None + else: + self.expanded_event_index = clicked_index + self.drawing_area.queue_draw() + return False + + def on_motion_notify(self, widget, event): + """Handle mouse motion events for hover detection.""" + self.mouse_x = event.x + self.mouse_y = event.y + + # Find which event is under the cursor + hovered_index = self.find_event_at_position(event.x, event.y) + + if hovered_index != self.hovered_event_index: + self.hovered_event_index = hovered_index + + # Cancel existing tooltip timeout + if self.tooltip_timeout_id: + GLib.source_remove(self.tooltip_timeout_id) + self.tooltip_timeout_id = None + + # Hide existing tooltip + if self.tooltip_window: + self.tooltip_window.destroy() + self.tooltip_window = None + + # Schedule new tooltip + if hovered_index is not None: + self.tooltip_timeout_id = GLib.timeout_add( + TOOLTIP_DELAY, self.show_tooltip, hovered_index, event.x_root, event.y_root + ) + + self.drawing_area.queue_draw() + + return False + + def on_leave_notify(self, widget, event): + """Handle mouse leave events.""" + self.hovered_event_index = None + + # Cancel tooltip timeout + if self.tooltip_timeout_id: + GLib.source_remove(self.tooltip_timeout_id) + self.tooltip_timeout_id = None + + # Hide tooltip + if self.tooltip_window: + self.tooltip_window.destroy() + self.tooltip_window = None + + self.drawing_area.queue_draw() + return False + + def find_event_at_position(self, x, y): + """Find which event is at the given position.""" + if not self.events: + return None + + # Get widget dimensions + width = self.drawing_area.get_allocated_width() + height = self.drawing_area.get_allocated_height() + + # Calculate date range + min_date = min(event[0] for event in self.events) + max_date = max(event[0] for event in self.events) + date_range = max_date - min_date + if date_range == 0: + date_range = 1 + + timeline_x = TIMELINE_MARGIN_LEFT + + # Check each event + for i, (date_sort, date_obj, event, person, event_type, expanded, _y_pos) in enumerate(self.events): + # Calculate Y position + y_pos = TIMELINE_MARGIN_TOP + ( + (date_sort - min_date) / date_range + ) * (height - TIMELINE_MARGIN_TOP - TIMELINE_MARGIN_BOTTOM) + + # Check if click is near the marker + marker_size = EVENT_MARKER_SIZE * self.zoom_level + if (abs(x - timeline_x) < marker_size + 10 and + abs(y - y_pos) < marker_size + 20): + return i + + return None + + def show_tooltip(self, event_index, x_root, y_root): + """Show tooltip for an event.""" + if event_index is None or event_index >= len(self.events): + return False + + date_sort, date_obj, event, person, event_type, expanded, y_pos = self.events[event_index] + + # Build tooltip text + date_str = get_date(event) + event_type_str = str(event_type) + + tooltip_text = f"{event_type_str}\n{date_str}" + + if person: + person_name = name_displayer.display(person) + tooltip_text += f"\n{person_name}" + + # Get place information + place_handle = event.get_place_handle() + if place_handle: + try: + place = self.dbstate.db.get_place_from_handle(place_handle) + if place: + place_name = place.get_title() + tooltip_text += f"\n{place_name}" + except: + pass + + # Get description + description = event.get_description() + if description: + tooltip_text += f"\n{description}" + + # Create tooltip window + self.tooltip_window = Gtk.Window(type=Gtk.WindowType.POPUP) + self.tooltip_window.set_border_width(8) + self.tooltip_window.set_decorated(False) + + # Get parent window for proper display + toplevel = self.drawing_area.get_toplevel() + if isinstance(toplevel, Gtk.Window): + self.tooltip_window.set_transient_for(toplevel) + + # Create container with background + frame = Gtk.Frame() + frame.set_shadow_type(Gtk.ShadowType.OUT) + frame.get_style_context().add_class("tooltip") + + label = Gtk.Label() + label.set_markup(tooltip_text) + label.set_line_wrap(True) + label.set_max_width_chars(40) + label.set_margin_start(5) + label.set_margin_end(5) + label.set_margin_top(5) + label.set_margin_bottom(5) + + frame.add(label) + self.tooltip_window.add(frame) + self.tooltip_window.show_all() + + # Position tooltip (offset to avoid cursor) + self.tooltip_window.move(int(x_root) + 15, int(y_root) + 15) + + return False + def on_draw(self, widget, context): """Draw the timeline.""" - # Get widget dimensions - width = widget.get_allocated_width() - height = widget.get_allocated_height() + # Apply zoom transformation + context.save() + context.scale(self.zoom_level, self.zoom_level) + + # Get widget dimensions (adjusted for zoom) + width = widget.get_allocated_width() / self.zoom_level + height = widget.get_allocated_height() / self.zoom_level - # Clear background - context.set_source_rgb(1.0, 1.0, 1.0) # White + # Clear background with modern gradient + pattern = cairo.LinearGradient(0, 0, 0, height) + pattern.add_color_stop_rgb(0, 0.98, 0.98, 0.99) # Very light gray-blue + pattern.add_color_stop_rgb(1, 0.95, 0.96, 0.98) # Slightly darker + context.set_source(pattern) context.paint() if not self.events: - # Draw "No events" message - context.set_source_rgb(0.5, 0.5, 0.5) + # Draw "No events" message with modern styling + context.set_source_rgb(0.6, 0.6, 0.6) context.select_font_face("Sans", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL) context.set_font_size(24) text = _("No events found") (x_bearing, y_bearing, text_width, text_height, x_advance, y_advance) = context.text_extents(text) context.move_to((width - text_width) / 2, height / 2) context.show_text(text) + context.restore() return # Calculate date range @@ -381,102 +748,249 @@ class MyTimelineView(NavigationView): max_date = max(event[0] for event in self.events) date_range = max_date - min_date if date_range == 0: - date_range = 1 # Avoid division by zero + date_range = 1 - # Draw timeline axis + # Draw timeline axis with shadow timeline_x = TIMELINE_MARGIN_LEFT timeline_y_start = TIMELINE_MARGIN_TOP timeline_y_end = height - TIMELINE_MARGIN_BOTTOM - context.set_source_rgb(0.0, 0.0, 0.0) # Black + # Draw shadow + context.set_source_rgba(0.0, 0.0, 0.0, 0.2) + context.set_line_width(TIMELINE_LINE_WIDTH) + context.move_to(timeline_x + 2, timeline_y_start + 2) + context.line_to(timeline_x + 2, timeline_y_end + 2) + context.stroke() + + # Draw main line with gradient + pattern = cairo.LinearGradient(timeline_x, timeline_y_start, timeline_x, timeline_y_end) + pattern.add_color_stop_rgb(0, 0.3, 0.3, 0.3) + pattern.add_color_stop_rgb(1, 0.5, 0.5, 0.5) + context.set_source(pattern) context.set_line_width(TIMELINE_LINE_WIDTH) context.move_to(timeline_x, timeline_y_start) context.line_to(timeline_x, timeline_y_end) context.stroke() # Draw events - for i, (date_sort, date_obj, event, person, event_type) in enumerate(self.events): + for i, event_data in enumerate(self.events): + date_sort, date_obj, event, person, event_type, expanded, y_pos = event_data + # Calculate Y position based on date y_pos = TIMELINE_MARGIN_TOP + ( (date_sort - min_date) / date_range ) * (timeline_y_end - timeline_y_start) - - # Draw event marker - self.draw_event_marker(context, timeline_x, y_pos, event_type) - + + # Update y_pos in event data + self.events[i] = event_data[:6] + (y_pos,) + + # Check if this event is hovered or expanded + is_hovered = (i == self.hovered_event_index) + is_expanded = (i == self.expanded_event_index) + + # Draw event marker with modern styling + self.draw_event_marker(context, timeline_x, y_pos, event_type, is_hovered) + # Draw event label - label_x = timeline_x + 20 + label_x = timeline_x + 25 self.draw_event_label( - context, label_x, y_pos, date_obj, event, person, event_type + context, label_x, y_pos, date_obj, event, person, event_type, is_hovered, is_expanded ) # Draw year markers on the left self.draw_year_markers(context, timeline_x, timeline_y_start, timeline_y_end, min_date, max_date) - - def draw_event_marker(self, context, x, y, event_type): - """Draw a marker for an event.""" - context.save() - - # Set color and shape based on event type - if event_type == EventType.BIRTH: - context.set_source_rgb(0.0, 0.8, 0.0) # Green for birth - # Draw upward triangle - context.move_to(x, y - EVENT_MARKER_SIZE) - context.line_to(x - EVENT_MARKER_SIZE, y + EVENT_MARKER_SIZE) - context.line_to(x + EVENT_MARKER_SIZE, y + EVENT_MARKER_SIZE) - context.close_path() - context.fill() - elif event_type == EventType.DEATH: - context.set_source_rgb(0.8, 0.0, 0.0) # Red for death - # Draw filled circle - context.arc(x, y, EVENT_MARKER_SIZE, 0, 2 * 3.14159) - context.fill() - elif event_type == EventType.MARRIAGE: - context.set_source_rgb(0.0, 0.0, 0.8) # Blue for marriage - # Draw diamond - context.move_to(x, y - EVENT_MARKER_SIZE) - context.line_to(x + EVENT_MARKER_SIZE, y) - context.line_to(x, y + EVENT_MARKER_SIZE) - context.line_to(x - EVENT_MARKER_SIZE, y) - context.close_path() - context.fill() - else: - context.set_source_rgb(0.5, 0.5, 0.5) # Gray for other events - # Draw square - context.rectangle( - x - EVENT_MARKER_SIZE, - y - EVENT_MARKER_SIZE, - EVENT_MARKER_SIZE * 2, - EVENT_MARKER_SIZE * 2, - ) - context.fill() - + context.restore() - def draw_event_label(self, context, x, y, date_obj, event, person, event_type): - """Draw the label for an event.""" + def draw_event_marker(self, context, x, y, event_type, is_hovered=False): + """Draw a marker for an event with modern styling.""" + context.save() + + # Get integer value from EventType object for dictionary lookup + event_type_value = event_type.value if hasattr(event_type, 'value') else int(event_type) + + # Get color and shape + color = EVENT_COLORS.get(event_type_value, (0.5, 0.5, 0.5)) + shape = EVENT_SHAPES.get(event_type_value, 'square') + marker_size = EVENT_MARKER_SIZE + + # Increase size if hovered + if is_hovered: + marker_size *= 1.3 + + # Draw shadow + context.set_source_rgba(0.0, 0.0, 0.0, 0.3) + context.translate(1, 1) + self._draw_shape(context, x, y, marker_size, shape) + context.fill() + context.translate(-1, -1) + + # Draw main shape with gradient + pattern = cairo.RadialGradient(x - marker_size/2, y - marker_size/2, 0, x, y, marker_size) + r, g, b = color + pattern.add_color_stop_rgb(0, min(1.0, r + 0.2), min(1.0, g + 0.2), min(1.0, b + 0.2)) + pattern.add_color_stop_rgb(1, max(0.0, r - 0.1), max(0.0, g - 0.1), max(0.0, b - 0.1)) + context.set_source(pattern) + + self._draw_shape(context, x, y, marker_size, shape) + context.fill() + + # Draw border + context.set_source_rgba(0.0, 0.0, 0.0, 0.3) + context.set_line_width(1) + self._draw_shape(context, x, y, marker_size, shape) + context.stroke() + + context.restore() + + def _draw_shape(self, context, x, y, size, shape): + """Draw a shape at the given position.""" + if shape == 'triangle': + context.move_to(x, y - size) + context.line_to(x - size, y + size) + context.line_to(x + size, y + size) + context.close_path() + elif shape == 'circle': + context.arc(x, y, size, 0, 2 * math.pi) + elif shape == 'diamond': + context.move_to(x, y - size) + context.line_to(x + size, y) + context.line_to(x, y + size) + context.line_to(x - size, y) + context.close_path() + elif shape == 'square': + context.rectangle(x - size, y - size, size * 2, size * 2) + elif shape == 'star': + # Draw 5-pointed star + points = 5 + outer_radius = size + inner_radius = size * 0.4 + for i in range(points * 2): + angle = (i * math.pi) / points - math.pi / 2 + if i % 2 == 0: + radius = outer_radius + else: + radius = inner_radius + px = x + radius * math.cos(angle) + py = y + radius * math.sin(angle) + if i == 0: + context.move_to(px, py) + else: + context.line_to(px, py) + context.close_path() + elif shape == 'hexagon': + # Draw hexagon + for i in range(6): + angle = (i * math.pi) / 3 + px = x + size * math.cos(angle) + py = y + size * math.sin(angle) + if i == 0: + context.move_to(px, py) + else: + context.line_to(px, py) + context.close_path() + + def draw_event_label(self, context, x, y, date_obj, event, person, event_type, is_hovered=False, is_expanded=False): + """Draw the label for an event with modern styling.""" context.save() # Create Pango layout for text layout = PangoCairo.create_layout(context) - layout.set_font_description( - Pango.font_description_from_string("Sans 10") - ) + + # Use modern font + font_desc = Pango.font_description_from_string("Sans 11") + if is_hovered: + font_desc.set_weight(Pango.Weight.BOLD) + layout.set_font_description(font_desc) # Build label text date_str = get_date(event) event_type_str = str(event_type) + if person: person_name = name_displayer.display(person) - label_text = f"{date_str} - {event_type_str} - {person_name}" + if is_expanded: + # Show full details when expanded + label_text = f"{date_str} - {event_type_str}\n{person_name}" + + # Add place + place_handle = event.get_place_handle() + if place_handle: + try: + place = self.dbstate.db.get_place_from_handle(place_handle) + if place: + place_name = place.get_title() + label_text += f"\nšŸ“ {place_name}" + except: + pass + + # Add description + description = event.get_description() + if description: + label_text += f"\n{description}" + else: + label_text = f"{date_str} - {event_type_str} - {person_name}" else: - label_text = f"{date_str} - {event_type_str}" + if is_expanded: + label_text = f"{date_str} - {event_type_str}" + + # Add place + place_handle = event.get_place_handle() + if place_handle: + try: + place = self.dbstate.db.get_place_from_handle(place_handle) + if place: + place_name = place.get_title() + label_text += f"\nšŸ“ {place_name}" + except: + pass + + # Add description + description = event.get_description() + if description: + label_text += f"\n{description}" + else: + label_text = f"{date_str} - {event_type_str}" - layout.set_text(label_text, -1) + layout.set_markup(label_text, -1) layout.set_width(-1) # No width limit + if is_expanded: + layout.set_width(500 * Pango.SCALE) # Limit width for expanded view + layout.set_wrap(Pango.WrapMode.WORD) + + # Draw background for expanded/hovered events + if is_expanded or is_hovered: + text_width, text_height = layout.get_pixel_size() + padding = 8 + + # Draw rounded rectangle background + rect_x = x - padding + rect_y = y - text_height / 2 - padding + rect_width = text_width + padding * 2 + rect_height = text_height + padding * 2 + + # Rounded rectangle + radius = 5 + context.arc(rect_x + radius, rect_y + radius, radius, math.pi, 3 * math.pi / 2) + context.arc(rect_x + rect_width - radius, rect_y + radius, radius, 3 * math.pi / 2, 0) + context.arc(rect_x + rect_width - radius, rect_y + rect_height - radius, radius, 0, math.pi / 2) + context.arc(rect_x + radius, rect_y + rect_height - radius, radius, math.pi / 2, math.pi) + context.close_path() + + # Fill with semi-transparent background + if is_expanded: + context.set_source_rgba(1.0, 1.0, 1.0, 0.95) + else: + context.set_source_rgba(1.0, 1.0, 1.0, 0.8) + context.fill() + + # Draw border + context.set_source_rgba(0.7, 0.7, 0.7, 0.5) + context.set_line_width(1) + context.stroke() # Draw text - context.set_source_rgb(0.0, 0.0, 0.0) # Black text + context.set_source_rgb(0.1, 0.1, 0.1) # Dark gray text context.move_to(x, y - 10) # Center vertically on marker PangoCairo.show_layout(context, layout) @@ -489,7 +1003,7 @@ class MyTimelineView(NavigationView): # Find min and max years from events min_year = None max_year = None - for date_sort, date_obj, event, person, event_type in self.events: + for date_sort, date_obj, event, person, event_type, expanded, y_pos in self.events: try: year = date_obj.get_year() if year and year != 0: @@ -513,8 +1027,7 @@ class MyTimelineView(NavigationView): context.set_line_width(1) for year in range(min_year, max_year + 1, year_step): - # Calculate Y position by finding the closest event or interpolating - # Find events around this year + # Calculate Y position year_date = Date() year_date.set_yr_mon_day(year, 1, 1) year_sort = year_date.get_sort_value() @@ -554,4 +1067,3 @@ class MyTimelineView(NavigationView): def get_stock(self): """Return the stock icon name.""" return "gramps-family" - diff --git a/__pycache__/MyTimeline.cpython-312.pyc b/__pycache__/MyTimeline.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fb47714d44ceabc0164905a25d0d8dada10ca9a0 GIT binary patch literal 44792 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 YDRrWJddPG<%ZT3p2YQ