# -*- coding: utf-8 -*- # # Gramps - a GTK+/GNOME based genealogy program # # Copyright (C) 2024 # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program; if not, see . # """ MyTimeline View - A vertical timeline showing family events """ # ------------------------------------------------------------------------- # # Python modules # # ------------------------------------------------------------------------- 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 # ------------------------------------------------------------------------- # # Gramps modules # # ------------------------------------------------------------------------- from gramps.gen.const import GRAMPS_LOCALE as glocale from gramps.gen.lib import EventType, Date from gramps.gen.utils.db import ( get_birth_or_fallback, get_death_or_fallback, get_marriage_or_fallback, ) from gramps.gen.datehandler import get_date from gramps.gen.display.name import displayer as name_displayer from gramps.gui.views.navigationview import NavigationView from gramps.gui.views.bookmarks import FamilyBookmarks from gramps.gen.utils.libformatting import FormattingHelper _ = glocale.translation.sgettext # ------------------------------------------------------------------------- # # Constants # # ------------------------------------------------------------------------- TIMELINE_MARGIN_LEFT = 150 TIMELINE_MARGIN_RIGHT = 50 TIMELINE_MARGIN_TOP = 50 TIMELINE_MARGIN_BOTTOM = 50 TIMELINE_LINE_WIDTH = 3 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', } # ------------------------------------------------------------------------- # # MyTimelineView # # ------------------------------------------------------------------------- class MyTimelineView(NavigationView): """ View for displaying a vertical timeline of family events. Shows all events for family members with modern design and interactivity. """ def __init__(self, pdata, dbstate, uistate, nav_group=0): NavigationView.__init__( self, _("MyTimeline"), pdata, dbstate, uistate, FamilyBookmarks, nav_group ) self.dbstate = dbstate self.uistate = uistate self.format_helper = FormattingHelper(self.dbstate, self.uistate) # Current family handle self.active_family_handle = None 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.selected_person_handle = None self.mouse_x = 0 self.mouse_y = 0 # Connect to database changes self.dbstate.connect("database-changed", self.change_db) # Connect to family updates if self.dbstate.is_open(): self.dbstate.db.connect("family-update", self.family_updated) self.dbstate.db.connect("person-update", self.person_updated) self.dbstate.db.connect("event-update", self.event_updated) def navigation_type(self): """Return the navigation type for this view.""" return "Family" def change_page(self): """Called when the page changes.""" NavigationView.change_page(self) active_handle = self.get_active() if active_handle: self.goto_handle(active_handle) def family_updated(self, handle_list): """Called when a family is updated.""" if self.active_family_handle in handle_list: self.collect_events() if self.drawing_area: self.drawing_area.queue_draw() def person_updated(self, handle_list): """Called when a person is updated.""" # Check if any updated person is related to current family if self.active_family_handle: try: family = self.dbstate.db.get_family_from_handle(self.active_family_handle) if family: father_handle = family.get_father_handle() mother_handle = family.get_mother_handle() child_handles = [ref.ref for ref in family.get_child_ref_list()] if ( (father_handle and father_handle in handle_list) or (mother_handle and mother_handle in handle_list) or any(h in handle_list for h in child_handles) ): self.collect_events() if self.drawing_area: self.drawing_area.queue_draw() except: pass def event_updated(self, handle_list): """Called when an event is updated.""" # Re-collect events if we have an active family if self.active_family_handle: self.collect_events() if self.drawing_area: self.drawing_area.queue_draw() def change_db(self, db): """Called when the database changes.""" self.active_family_handle = None self.events = [] # Connect to new database signals if db and db.is_open(): db.connect("family-update", self.family_updated) db.connect("person-update", self.person_updated) db.connect("event-update", self.event_updated) if self.drawing_area: self.drawing_area.queue_draw() 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 ) self.drawing_area = Gtk.DrawingArea() 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.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) 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.""" active_handle = self.get_active() if active_handle: self.goto_handle(active_handle) def goto_handle(self, handle): """Called when the active family changes.""" if handle == self.active_family_handle: return self.active_family_handle = handle self.expanded_event_index = None self.collect_events() if self.drawing_area: self.drawing_area.queue_draw() def collect_events(self): """Collect all events for the active family.""" self.events = [] if not self.active_family_handle: return try: family = self.dbstate.db.get_family_from_handle(self.active_family_handle) except: return if not family: return # Get family events (marriage, divorce, etc.) for event_ref in family.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, None, # No person for family events event_type, False, # expanded 0, # y_pos (will be calculated during draw) ) ) except: pass # 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: collect_person_events(father, father) except: pass # 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: collect_person_events(mother, mother) except: pass # 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: 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 and zoom if self.events: 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 = int(600 * self.zoom_level) if self.drawing_area: self.drawing_area.set_size_request(800, self.timeline_height) def detect_label_overlaps(self, context, events_with_y_pos, timeline_y_start, timeline_y_end): """Detect and adjust Y positions to prevent label overlaps.""" if not events_with_y_pos: return events_with_y_pos # Create a temporary layout to measure text layout = PangoCairo.create_layout(context) layout.set_font_description(Pango.font_description_from_string("Sans 11")) adjusted_events = [] min_spacing = 30 # Minimum spacing between labels in pixels for i, event_data in enumerate(events_with_y_pos): date_sort, date_obj, event, person, event_type, expanded, y_pos = event_data # Calculate label height if person: person_name = name_displayer.display(person) date_str = get_date(event) event_type_str = str(event_type) label_text = f"{date_str} - {event_type_str} - {person_name}" else: date_str = get_date(event) event_type_str = str(event_type) label_text = f"{date_str} - {event_type_str}" layout.set_text(label_text, -1) text_width, text_height = layout.get_pixel_size() label_height = text_height + 16 # Add padding # Check for overlap with previous events adjusted_y = y_pos for prev_data in adjusted_events: prev_y_pos = prev_data[6] # Get y_pos from previous event # Check if labels would overlap if abs(adjusted_y - prev_y_pos) < min_spacing: # Adjust downward adjusted_y = prev_y_pos + min_spacing # Ensure adjusted position is within bounds adjusted_y = max(timeline_y_start, min(adjusted_y, timeline_y_end)) # Create new event data with adjusted Y position adjusted_events.append((date_sort, date_obj, event, person, event_type, expanded, adjusted_y)) return adjusted_events 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: # Convert mouse coordinates to drawing coordinates (account for zoom) scaled_x = event.x / self.zoom_level date_sort, date_obj, clicked_event, clicked_person, event_type, expanded, _y_pos = self.events[clicked_index] # Check if click is on marker area (left side) or label area (right side) timeline_x = TIMELINE_MARGIN_LEFT marker_area_width = EVENT_MARKER_SIZE + 20 # Allow person selection from anywhere on the line # Clicking anywhere on the event line selects the person if clicked_person: person_handle = clicked_person.get_handle() if self.selected_person_handle == person_handle: # Deselect if clicking same person self.selected_person_handle = None else: # Select this person self.selected_person_handle = person_handle else: # No person for this event, deselect self.selected_person_handle = None # Also toggle expansion if clicking on label area (right side) if scaled_x > timeline_x + marker_area_width: 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 # Convert mouse coordinates to drawing coordinates (account for zoom) scaled_x = x / self.zoom_level scaled_y = y / self.zoom_level # Get widget dimensions in drawing coordinates width = self.drawing_area.get_allocated_width() / self.zoom_level height = self.drawing_area.get_allocated_height() / self.zoom_level # 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 # Calculate initial Y positions (same as in on_draw) events_with_y_pos = [] for i, event_data in enumerate(self.events): date_sort, date_obj, event, person, event_type, expanded, _ = event_data y_pos = TIMELINE_MARGIN_TOP + ( (date_sort - min_date) / date_range ) * (height - TIMELINE_MARGIN_TOP - TIMELINE_MARGIN_BOTTOM) events_with_y_pos.append((i, y_pos, event_data)) # Check each event using adjusted positions # We need to simulate the collision detection, but for simplicity, # we'll check against the calculated positions and use a wider tolerance for i, y_pos, event_data in events_with_y_pos: date_sort, date_obj, event, person, event_type, expanded, _ = event_data # Calculate clickable area - wider to include label area marker_size = EVENT_MARKER_SIZE label_x = timeline_x + 25 # Estimate label width (we'll use a reasonable default) # For whole-line selection, check if click is in the event's horizontal band clickable_width = 600 # Reasonable width for label area clickable_height = max(marker_size * 2, 30) # At least marker size or 30px # Check if click is in the event's area (marker + label) if (scaled_x >= timeline_x - marker_size - 10 and scaled_x <= label_x + clickable_width and abs(scaled_y - y_pos) < clickable_height / 2): return i return None def show_tooltip(self, event_index, x_root, y_root): """Show tooltip for an event, including all events for that person.""" 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] # If event has a person, show all events for that person if person: person_handle = person.get_handle() person_name = name_displayer.display(person) # Find all events for this person person_events = [] for evt_data in self.events: evt_date_sort, evt_date_obj, evt_event, evt_person, evt_event_type, evt_expanded, evt_y_pos = evt_data if evt_person and evt_person.get_handle() == person_handle: person_events.append((evt_date_sort, evt_date_obj, evt_event, evt_event_type)) # Sort by date person_events.sort(key=lambda x: x[0]) # Build tooltip text with person name as header tooltip_text = f"{person_name}\n" tooltip_text += "─" * 30 + "\n" # List all events for this person (date first) for evt_date_sort, evt_date_obj, evt_event, evt_event_type in person_events: evt_date_str = get_date(evt_event) evt_event_type_str = str(evt_event_type) tooltip_text += f"{evt_date_str} - {evt_event_type_str}\n" # Add place if available evt_place_handle = evt_event.get_place_handle() if evt_place_handle: try: evt_place = self.dbstate.db.get_place_from_handle(evt_place_handle) if evt_place: evt_place_name = evt_place.get_title() tooltip_text += f" šŸ“ {evt_place_name}\n" except: pass else: # Family event (no person) - show single event info (date first) date_str = get_date(event) event_type_str = str(event_type) tooltip_text = f"{date_str}\n{event_type_str}" # 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.""" # 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 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 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 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 # Draw timeline axis with shadow timeline_x = TIMELINE_MARGIN_LEFT timeline_y_start = TIMELINE_MARGIN_TOP timeline_y_end = height - TIMELINE_MARGIN_BOTTOM # 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() # Calculate initial Y positions based on dates events_with_y_pos = [] 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) events_with_y_pos.append((date_sort, date_obj, event, person, event_type, expanded, y_pos)) # Detect and fix label overlaps events_with_y_pos = self.detect_label_overlaps(context, events_with_y_pos, timeline_y_start, timeline_y_end) # Draw events for i, event_data in enumerate(events_with_y_pos): date_sort, date_obj, event, person, event_type, expanded, y_pos = event_data # Check if this event is hovered or expanded is_hovered = (i == self.hovered_event_index) is_expanded = (i == self.expanded_event_index) # Check if this event belongs to selected person is_selected = (self.selected_person_handle is not None and person and person.get_handle() == self.selected_person_handle) # Draw event marker with modern styling self.draw_event_marker(context, timeline_x, y_pos, event_type, is_hovered, is_selected) # Draw event label label_x = timeline_x + 25 self.draw_event_label( context, label_x, y_pos, date_obj, event, person, event_type, is_hovered, is_expanded ) # Draw visual connections for selected person if self.selected_person_handle is not None: self.draw_person_connections(context, events_with_y_pos, timeline_x, timeline_y_start, timeline_y_end) # Draw year markers on the left self.draw_year_markers(context, timeline_x, timeline_y_start, timeline_y_end, min_date, max_date) context.restore() def draw_event_marker(self, context, x, y, event_type, is_hovered=False, is_selected=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 or selected if is_hovered: marker_size *= 1.3 elif is_selected: marker_size *= 1.2 # Use highlight color if selected if is_selected: color = (0.2, 0.4, 0.9) # Blue highlight for selected person's events # 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) # 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) 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: 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_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.1, 0.1, 0.1) # Dark gray text context.move_to(x, y - 10) # Center vertically on marker PangoCairo.show_layout(context, layout) context.restore() def draw_year_markers(self, context, timeline_x, y_start, y_end, min_date, max_date): """Draw year markers on the left side of the timeline.""" context.save() # Find min and max years from events min_year = None max_year = None 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: if min_year is None or year < min_year: min_year = year if max_year is None or year > max_year: max_year = year except: pass if min_year is None or max_year is None: context.restore() return # Draw markers for major years (every 10 years or so) year_step = max(1, (max_year - min_year) // 10) if year_step == 0: year_step = 1 context.set_source_rgb(0.5, 0.5, 0.5) # Gray context.set_line_width(1) for year in range(min_year, max_year + 1, year_step): # Calculate Y position year_date = Date() year_date.set_yr_mon_day(year, 1, 1) year_sort = year_date.get_sort_value() if min_date == max_date: y_pos = (y_start + y_end) / 2 else: y_pos = y_start + ( (year_sort - min_date) / (max_date - min_date) ) * (y_end - y_start) # Only draw if within visible range if y_pos < y_start or y_pos > y_end: continue # Draw tick mark context.move_to(timeline_x - 10, y_pos) context.line_to(timeline_x, y_pos) context.stroke() # Draw year label layout = PangoCairo.create_layout(context) layout.set_font_description( Pango.font_description_from_string("Sans 9") ) layout.set_text(str(year), -1) # Get text size in pixels text_width, text_height = layout.get_pixel_size() context.set_source_rgb(0.0, 0.0, 0.0) # Black context.move_to(timeline_x - 20 - text_width, y_pos - text_height / 2) PangoCairo.show_layout(context, layout) context.restore() def draw_person_connections(self, context, events_with_y_pos, timeline_x, timeline_y_start, timeline_y_end): """Draw visual connections between all events of the selected person.""" if not self.selected_person_handle: return # Find all events for the selected person person_events = [] for event_data in events_with_y_pos: date_sort, date_obj, event, person, event_type, expanded, y_pos = event_data if person and person.get_handle() == self.selected_person_handle: person_events.append((y_pos, event_data)) if len(person_events) < 1: return # Sort by Y position person_events.sort(key=lambda x: x[0]) context.save() # Draw connecting lines - more visible with brighter color and increased opacity context.set_source_rgba(0.2, 0.5, 1.0, 0.75) # Brighter, more opaque blue context.set_line_width(3.5) # Increased from 2 context.set_line_cap(cairo.LINE_CAP_ROUND) context.set_line_join(cairo.LINE_JOIN_ROUND) # Draw lines from timeline axis to each event marker for y_pos, event_data in person_events: context.move_to(timeline_x, y_pos) context.line_to(timeline_x + EVENT_MARKER_SIZE * 1.5, y_pos) context.stroke() # Draw vertical connector line on the left side if len(person_events) > 1: y_positions = [y for y, _event_data in person_events] min_y = min(y_positions) max_y = max(y_positions) # Draw a more visible vertical line connecting the range if max_y - min_y > EVENT_MARKER_SIZE * 2: context.set_source_rgba(0.2, 0.5, 1.0, 0.6) # Slightly less opaque but still visible context.set_line_width(2.5) # Thicker than before context.move_to(timeline_x - 15, min_y) context.line_to(timeline_x - 15, max_y) context.stroke() context.restore() def get_stock(self): """Return the stock icon name.""" return "gramps-family"