# -*- 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 logging import math from dataclasses import dataclass from typing import Optional, List, Tuple, Any 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 Layout Constants TIMELINE_MARGIN_LEFT = 150 TIMELINE_MARGIN_RIGHT = 50 TIMELINE_MARGIN_TOP = 50 TIMELINE_MARGIN_BOTTOM = 50 TIMELINE_LINE_WIDTH = 3 # Event Display Constants EVENT_MARKER_SIZE = 10 EVENT_SPACING = 80 LABEL_X_OFFSET = 25 MIN_LABEL_SPACING = 30 LABEL_PADDING = 16 MARKER_CLICK_PADDING = 10 CLICKABLE_AREA_WIDTH = 600 CLICKABLE_AREA_HEIGHT = 30 # UI Constants YEAR_LABEL_WIDTH = 100 TOOLTIP_DELAY = 500 # milliseconds TOOLTIP_MAX_WIDTH = 500 LABEL_BACKGROUND_PADDING = 8 LABEL_BACKGROUND_RADIUS = 5 # Font Constants FONT_FAMILY = "Sans" FONT_SIZE_NORMAL = 11 FONT_SIZE_SMALL = 9 FONT_SIZE_LARGE = 24 # Visual Effect Constants MARKER_HOVER_SIZE_MULTIPLIER = 1.3 MARKER_SELECTED_SIZE_MULTIPLIER = 1.2 GRADIENT_BRIGHTNESS_OFFSET = 0.2 GRADIENT_DARKNESS_OFFSET = 0.1 SHADOW_OFFSET_X = 1 SHADOW_OFFSET_Y = 1 SHADOW_OPACITY = 0.3 BORDER_OPACITY = 0.3 # Connection Line Constants CONNECTION_LINE_COLOR = (0.2, 0.5, 1.0, 0.75) # Brighter, more opaque blue CONNECTION_LINE_WIDTH = 3.5 CONNECTION_VERTICAL_LINE_X = 5 # Left of year markers # Marker State Colors SELECTED_MARKER_COLOR = (0.2, 0.4, 0.9) # Blue highlight for selected person's events # Logger logger = logging.getLogger(__name__) # 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', } # ------------------------------------------------------------------------- # # Data Classes # # ------------------------------------------------------------------------- @dataclass class TimelineEvent: """Represents an event in the timeline with all necessary data.""" date_sort: int date_obj: Date event: 'Any' # gramps.gen.lib.Event person: Optional['Any'] # gramps.gen.lib.Person event_type: EventType y_pos: float = 0.0 # ------------------------------------------------------------------------- # # 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): """ Initialize the MyTimeline view. Args: pdata: Plugin data object. dbstate: Database state object. uistate: UI state object. nav_group: Navigation group identifier. """ 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[TimelineEvent] = [] # List of TimelineEvent objects # 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.selected_person_handle = None self.mouse_x = 0 self.mouse_y = 0 # Performance cache self._adjusted_events_cache: Optional[List[TimelineEvent]] = None self._cache_key: Optional[int] = None self._cached_date_range: Optional[int] = None self._cached_min_date: Optional[int] = None self._cached_max_date: Optional[int] = None # Initialize temporary surface for text measurement (used in find_event_at_position) self._temp_surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, 1, 1) # 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) -> str: """ Return the navigation type for this view. Returns: str: The navigation type, always "Family" for this view. """ return "Family" def change_page(self) -> None: """ Called when the page changes. Updates the view to show the active family's timeline. """ NavigationView.change_page(self) active_handle = self.get_active() if active_handle: self.goto_handle(active_handle) def family_updated(self, handle_list: List[str]) -> None: """ Called when a family is updated. Args: handle_list: List of family handles that were 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: List[str]) -> None: """ Called when a person is updated. Args: handle_list: List of person handles that were 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 (AttributeError, KeyError) as e: # Skip if family handle is invalid logger.warning(f"Error accessing family in person_updated: {e}", exc_info=True) def event_updated(self, handle_list: List[str]) -> None: """ Called when an event is updated. Args: handle_list: List of event handles that were 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) -> None: """ Called when the database changes. Args: db: The new database object. """ 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) -> Gtk.Widget: """ Build the interface and return the container. Returns: Gtk.Widget: The main container widget with toolbar and drawing area. """ # 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) -> None: """ 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: str) -> None: """ Called when the active family changes. Args: handle: The handle of the family to display. """ if handle == self.active_family_handle: return self.active_family_handle = handle self.collect_events() if self.drawing_area: self.drawing_area.queue_draw() def _collect_person_event_refs(self, person) -> List: """ Collect event references from a person. Args: person: The person object to collect events from. Returns: List: List of event references. """ if not person: return [] try: return person.get_event_ref_list() except (AttributeError, KeyError) as e: logger.warning(f"Error accessing event references for person: {e}", exc_info=True) return [] def _process_event_ref(self, event_ref, person_obj: Optional[Any]) -> Optional[TimelineEvent]: """ Process a single event reference and create a TimelineEvent. Args: event_ref: The event reference to process. person_obj: The person object associated with this event (None for family events). Returns: Optional[TimelineEvent]: The created TimelineEvent, or None if invalid. """ 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() return TimelineEvent( date_sort=date_obj.get_sort_value(), date_obj=date_obj, event=event, person=person_obj, event_type=event_type, y_pos=0.0 ) except (AttributeError, KeyError, ValueError) as e: logger.debug(f"Skipping invalid event reference: {e}") return None def _collect_person_events(self, person, person_obj) -> None: """ Collect all events from a person and add them to self.events. Args: person: The person object to collect events from. person_obj: The person object to associate with events. """ if not person: return event_refs = self._collect_person_event_refs(person) for event_ref in event_refs: timeline_event = self._process_event_ref(event_ref, person_obj) if timeline_event: self.events.append(timeline_event) def _collect_family_member_events(self, handle: Optional[str], role: str) -> None: """ Collect events for a family member (father, mother, or child). Args: handle: The person handle to collect events from. role: Role name for logging purposes ('father', 'mother', 'child'). """ if not handle: return try: person = self.dbstate.db.get_person_from_handle(handle) if person: self._collect_person_events(person, person) except (AttributeError, KeyError) as e: logger.debug(f"Error accessing {role}: {e}") def _collect_family_events(self, family) -> None: """ Collect family-level events (marriage, divorce, etc.). Args: family: The family object to collect events from. """ for event_ref in family.get_event_ref_list(): timeline_event = self._process_event_ref(event_ref, None) if timeline_event: self.events.append(timeline_event) def _invalidate_cache(self) -> None: """Invalidate all caches when events change.""" self._adjusted_events_cache = None self._cache_key = None self._cached_date_range = None self._cached_min_date = None self._cached_max_date = None def _calculate_timeline_height(self) -> None: """Calculate and set 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 collect_events(self) -> None: """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 (AttributeError, KeyError) as e: logger.warning(f"Error accessing family: {e}", exc_info=True) return if not family: return # Get family events (marriage, divorce, etc.) self._collect_family_events(family) # Get father's events self._collect_family_member_events(family.get_father_handle(), "father") # Get mother's events self._collect_family_member_events(family.get_mother_handle(), "mother") # Get children's events for child_ref in family.get_child_ref_list(): self._collect_family_member_events(child_ref.ref, "child") # Sort events by date self.events.sort(key=lambda x: x.date_sort) # Invalidate cache when events change self._invalidate_cache() # Calculate timeline height self._calculate_timeline_height() def _calculate_date_range(self) -> Tuple[int, int, int]: """ Calculate date range from events. Returns: Tuple[int, int, int]: (min_date, max_date, date_range) """ if not self.events: return (0, 0, 1) if (self._cached_min_date is not None and self._cached_max_date is not None and self._cached_date_range is not None): return (self._cached_min_date, self._cached_max_date, self._cached_date_range) min_date = min(event.date_sort for event in self.events) max_date = max(event.date_sort for event in self.events) date_range = max_date - min_date if max_date != min_date else 1 self._cached_min_date = min_date self._cached_max_date = max_date self._cached_date_range = date_range return (min_date, max_date, date_range) def _calculate_y_position(self, date_sort: int, min_date: int, date_range: int, timeline_y_start: float, timeline_y_end: float) -> float: """ Calculate Y position for an event based on date. Args: date_sort: The sort value of the event date. min_date: The minimum date sort value. date_range: The range of dates (max - min). timeline_y_start: The Y coordinate of the timeline start. timeline_y_end: The Y coordinate of the timeline end. Returns: float: The calculated Y position. """ return timeline_y_start + ( (date_sort - min_date) / date_range ) * (timeline_y_end - timeline_y_start) def _get_event_label_text(self, event, person: Optional[Any], event_type: EventType) -> str: """ Generate label text for an event. Centralized logic. Args: event: The event object. person: The person associated with the event (None for family events). event_type: The type of event. Returns: str: The formatted label text. """ date_str = get_date(event) event_type_str = str(event_type) if person: person_name = name_displayer.display(person) return f"{date_str} - {event_type_str} - {person_name}" else: return f"{date_str} - {event_type_str}" def _calculate_adjusted_positions(self, context: cairo.Context, events_with_y_pos: List[TimelineEvent], timeline_y_start: float, timeline_y_end: float) -> List[TimelineEvent]: """ Calculate adjusted Y positions with collision detection. Reusable by both drawing and click detection. Args: context: Cairo drawing context for text measurement. events_with_y_pos: List of TimelineEvent objects with initial Y positions. timeline_y_start: The Y coordinate of the timeline start. timeline_y_end: The Y coordinate of the timeline end. Returns: List[TimelineEvent]: List of TimelineEvent objects with adjusted Y positions. """ 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 = [] for event_data in events_with_y_pos: # Calculate label height using centralized text generation label_text = self._get_event_label_text(event_data.event, event_data.person, event_data.event_type) layout.set_text(label_text, -1) text_width, text_height = layout.get_pixel_size() label_height = text_height + LABEL_PADDING # Check for overlap with previous events adjusted_y = event_data.y_pos for prev_event in adjusted_events: # Check if labels would overlap if abs(adjusted_y - prev_event.y_pos) < MIN_LABEL_SPACING: # Adjust downward adjusted_y = prev_event.y_pos + MIN_LABEL_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_event = TimelineEvent( date_sort=event_data.date_sort, date_obj=event_data.date_obj, event=event_data.event, person=event_data.person, event_type=event_data.event_type, y_pos=adjusted_y ) adjusted_events.append(adjusted_event) return adjusted_events def detect_label_overlaps(self, context: cairo.Context, events_with_y_pos: List[TimelineEvent], timeline_y_start: float, timeline_y_end: float) -> List[TimelineEvent]: """ Detect and adjust Y positions to prevent label overlaps. Args: context: Cairo drawing context. events_with_y_pos: List of TimelineEvent objects with initial Y positions. timeline_y_start: The Y coordinate of the timeline start. timeline_y_end: The Y coordinate of the timeline end. Returns: List[TimelineEvent]: List of TimelineEvent objects with adjusted Y positions. """ # Use shared collision detection method return self._calculate_adjusted_positions(context, events_with_y_pos, timeline_y_start, timeline_y_end) def _recalculate_timeline_height(self) -> None: """ Recalculate timeline height based on current events and zoom level. """ 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) # Invalidate cache when zoom changes self._adjusted_events_cache = None self._cache_key = None def on_zoom_in(self, widget: Gtk.Widget) -> None: """ Zoom in. Args: widget: The widget that triggered the zoom (unused). """ 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._recalculate_timeline_height() # Only recalculate height, not events if self.drawing_area: self.drawing_area.queue_draw() def on_zoom_out(self, widget: Gtk.Widget) -> None: """ Zoom out. Args: widget: The widget that triggered the zoom (unused). """ 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._recalculate_timeline_height() # Only recalculate height, not events if self.drawing_area: self.drawing_area.queue_draw() def on_zoom_reset(self, widget: Gtk.Widget) -> None: """ Reset zoom to 100%. Args: widget: The widget that triggered the reset (unused). """ self.zoom_level = 1.0 self.update_zoom_display() self._recalculate_timeline_height() # Only recalculate height, not events if self.drawing_area: self.drawing_area.queue_draw() def on_scroll(self, widget: Gtk.Widget, event: Gdk.Event) -> bool: """ Handle scroll events for zooming with Ctrl+scroll. Args: widget: The widget that received the scroll event. event: The scroll event. Returns: bool: True if the event was handled, False otherwise. """ 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) -> None: """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: Gtk.Widget, event: Gdk.Event) -> bool: """ Handle mouse button press events. Args: widget: The widget that received the button press event. event: The button press event. Returns: bool: False to allow other handlers to process the event. """ 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: clicked_event_data = self.events[clicked_index] # Clicking anywhere on the event line selects the person (like selecting the event) if clicked_event_data.person: person_handle = clicked_event_data.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 self.drawing_area.queue_draw() return False def on_motion_notify(self, widget: Gtk.Widget, event: Gdk.Event) -> bool: """ Handle mouse motion events for hover detection. Args: widget: The widget that received the motion event. event: The motion event. Returns: bool: False to allow other handlers to process the event. """ 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: Gtk.Widget, event: Gdk.Event) -> bool: """ Handle mouse leave events. Args: widget: The widget that received the leave event. event: The leave event. Returns: bool: False to allow other handlers to process the event. """ 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 _get_cache_key(self, timeline_y_start: float, timeline_y_end: float) -> int: """ Generate cache key for adjusted events. Args: timeline_y_start: The Y coordinate of the timeline start. timeline_y_end: The Y coordinate of the timeline end. Returns: int: A hash-based cache key. """ return hash((len(self.events), timeline_y_start, timeline_y_end)) def _get_adjusted_events(self, context: cairo.Context, timeline_y_start: float, timeline_y_end: float) -> List[TimelineEvent]: """ Get adjusted events with collision detection, using cache if available. Args: context: Cairo drawing context for text measurement. timeline_y_start: The Y coordinate of the timeline start. timeline_y_end: The Y coordinate of the timeline end. Returns: List[TimelineEvent]: List of TimelineEvent objects with adjusted Y positions. """ # Check if cache is valid using hash-based key cache_key = self._get_cache_key(timeline_y_start, timeline_y_end) if (self._adjusted_events_cache is not None and self._cache_key == cache_key): return self._adjusted_events_cache # Calculate date range if not self.events: return [] min_date, max_date, date_range = self._calculate_date_range() # Calculate initial Y positions events_with_y_pos = [] for event_data in self.events: y_pos = self._calculate_y_position( event_data.date_sort, min_date, date_range, timeline_y_start, timeline_y_end ) event_with_y = TimelineEvent( date_sort=event_data.date_sort, date_obj=event_data.date_obj, event=event_data.event, person=event_data.person, event_type=event_data.event_type, y_pos=y_pos ) events_with_y_pos.append(event_with_y) # Apply collision detection using shared method adjusted_events = self._calculate_adjusted_positions( context, events_with_y_pos, timeline_y_start, timeline_y_end ) # Cache the results self._adjusted_events_cache = adjusted_events self._cache_key = cache_key return adjusted_events def find_event_at_position(self, x: float, y: float) -> Optional[int]: """ Find which event is at the given position. Args: x: X coordinate in widget space. y: Y coordinate in widget space. Returns: Optional[int]: Index of the event at the position, or None if no event found. """ if not self.events or not self.drawing_area: 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 height = self.drawing_area.get_allocated_height() / self.zoom_level timeline_x = TIMELINE_MARGIN_LEFT timeline_y_start = TIMELINE_MARGIN_TOP timeline_y_end = height - TIMELINE_MARGIN_BOTTOM # Get adjusted events using cache (create temporary context for text measurement) # For click detection, we need a context for text measurement temp_context = cairo.Context(self._temp_surface) adjusted_events = self._get_adjusted_events(temp_context, timeline_y_start, timeline_y_end) # Check each event using adjusted positions marker_size = EVENT_MARKER_SIZE label_x = timeline_x + LABEL_X_OFFSET for i, event_data in enumerate(adjusted_events): # Check if click is in the event's area (marker + label) if (scaled_x >= timeline_x - marker_size - MARKER_CLICK_PADDING and scaled_x <= label_x + CLICKABLE_AREA_WIDTH and abs(scaled_y - event_data.y_pos) < CLICKABLE_AREA_HEIGHT / 2): return i return None def _format_person_tooltip(self, person, person_events: List[TimelineEvent]) -> str: """ Format tooltip for person with multiple events. Args: person: The person object. person_events: List of TimelineEvent objects for this person. Returns: str: Formatted tooltip text. """ person_name = name_displayer.display(person) tooltip_text = f"{person_name}\n" tooltip_text += "─" * 30 + "\n" # Sort by date person_events_sorted = sorted(person_events, key=lambda x: x.date_sort) # List all events for this person (date first) for event_data in person_events_sorted: date_str = get_date(event_data.event) event_type_str = str(event_data.event_type) tooltip_text += f"{date_str} - {event_type_str}\n" # Add place if available place_handle = event_data.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" šŸ“ {place_name}\n" except (AttributeError, KeyError) as e: logger.debug(f"Error accessing place in tooltip: {e}") return tooltip_text def _format_single_event_tooltip(self, event, event_type: EventType) -> str: """ Format tooltip for single event. Args: event: The event object. event_type: The type of event. Returns: str: Formatted tooltip text. """ 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 (AttributeError, KeyError) as e: logger.debug(f"Error accessing place in tooltip: {e}") # Get description description = event.get_description() if description: tooltip_text += f"\n{description}" return tooltip_text def _get_or_create_tooltip_window(self) -> Gtk.Window: """ Get or create tooltip window (reuse if exists). Returns: Gtk.Window: The tooltip window. """ if not hasattr(self, 'tooltip_window') or self.tooltip_window is None: 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) return self.tooltip_window def show_tooltip(self, event_index: int, x_root: float, y_root: float) -> bool: """ Show tooltip for an event, including all events for that person. Args: event_index: Index of the event in self.events. x_root: X coordinate in root window space. y_root: Y coordinate in root window space. Returns: bool: False to indicate the timeout should not be repeated. """ if event_index is None or event_index >= len(self.events): return False event_data = self.events[event_index] # If event has a person, show all events for that person if event_data.person: person_handle = event_data.person.get_handle() # Find all events for this person person_events = [ evt for evt in self.events if evt.person and evt.person.get_handle() == person_handle ] tooltip_text = self._format_person_tooltip(event_data.person, person_events) else: # Family event (no person) - show single event info tooltip_text = self._format_single_event_tooltip(event_data.event, event_data.event_type) # Get or create tooltip window tooltip_window = self._get_or_create_tooltip_window() # Clear existing content for child in tooltip_window.get_children(): tooltip_window.remove(child) # 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) tooltip_window.add(frame) tooltip_window.show_all() # Position tooltip (offset to avoid cursor) tooltip_window.move(int(x_root) + 15, int(y_root) + 15) return False def _draw_background(self, context: cairo.Context, width: float, height: float) -> None: """ Draw background gradient. Args: context: Cairo drawing context. width: Widget width. height: Widget height. """ 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() def _draw_no_events_message(self, context: cairo.Context, width: float, height: float) -> None: """ Draw "No events" message. Args: context: Cairo drawing context. width: Widget width. height: Widget height. """ context.set_source_rgb(0.6, 0.6, 0.6) context.select_font_face(FONT_FAMILY, cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL) context.set_font_size(FONT_SIZE_LARGE) 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) def _draw_timeline_axis(self, context: cairo.Context, timeline_x: float, y_start: float, y_end: float) -> None: """ Draw timeline axis with shadow. Args: context: Cairo drawing context. timeline_x: X coordinate of the timeline. y_start: Y coordinate of the timeline start. y_end: Y coordinate of the timeline end. """ # 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, y_start + 2) context.line_to(timeline_x + 2, y_end + 2) context.stroke() # Draw main line with gradient pattern = cairo.LinearGradient(timeline_x, y_start, timeline_x, 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, y_start) context.line_to(timeline_x, y_end) context.stroke() def _draw_events(self, context: cairo.Context, events_with_y_pos: List[TimelineEvent], timeline_x: float) -> None: """ Draw all events. Args: context: Cairo drawing context. events_with_y_pos: List of TimelineEvent objects with Y positions. timeline_x: X coordinate of the timeline. """ for i, event_data in enumerate(events_with_y_pos): # Check if this event is hovered is_hovered = (i == self.hovered_event_index) # Check if this event belongs to selected person is_selected = (self.selected_person_handle is not None and event_data.person and event_data.person.get_handle() == self.selected_person_handle) # Draw event marker with modern styling self.draw_event_marker(context, timeline_x, event_data.y_pos, event_data.event_type, is_hovered, is_selected) # Draw event label label_x = timeline_x + LABEL_X_OFFSET self.draw_event_label( context, label_x, event_data.y_pos, event_data.date_obj, event_data.event, event_data.person, event_data.event_type, is_hovered ) def on_draw(self, widget: Gtk.Widget, context: cairo.Context) -> bool: """ Draw the timeline. Args: widget: The drawing area widget. context: Cairo drawing context. Returns: bool: True to indicate the draw was handled. """ # 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 # Draw background self._draw_background(context, width, height) if not self.events: # Draw "No events" message self._draw_no_events_message(context, width, height) context.restore() return True # Calculate date range min_date, max_date, date_range = self._calculate_date_range() # Draw timeline axis timeline_x = TIMELINE_MARGIN_LEFT timeline_y_start = TIMELINE_MARGIN_TOP timeline_y_end = height - TIMELINE_MARGIN_BOTTOM self._draw_timeline_axis(context, timeline_x, timeline_y_start, timeline_y_end) # Get adjusted events with collision detection (uses cache) events_with_y_pos = self._get_adjusted_events(context, timeline_y_start, timeline_y_end) # Draw events self._draw_events(context, events_with_y_pos, timeline_x) # 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() return True def _draw_marker_shadow(self, context: cairo.Context, x: float, y: float, size: float, shape: str) -> None: """ Draw shadow for marker. Args: context: Cairo drawing context. x: X coordinate of marker center. y: Y coordinate of marker center. size: Size of the marker. shape: Shape type ('triangle', 'circle', etc.). """ context.set_source_rgba(0.0, 0.0, 0.0, SHADOW_OPACITY) context.translate(SHADOW_OFFSET_X, SHADOW_OFFSET_Y) self._draw_shape(context, x, y, size, shape) context.fill() context.translate(-SHADOW_OFFSET_X, -SHADOW_OFFSET_Y) def _draw_marker_gradient(self, context: cairo.Context, x: float, y: float, size: float, color: Tuple[float, float, float], shape: str) -> None: """ Draw gradient fill for marker. Args: context: Cairo drawing context. x: X coordinate of marker center. y: Y coordinate of marker center. size: Size of the marker. color: RGB color tuple (0.0-1.0). shape: Shape type ('triangle', 'circle', etc.). """ pattern = cairo.RadialGradient(x - size/2, y - size/2, 0, x, y, size) r, g, b = color pattern.add_color_stop_rgb(0, min(1.0, r + GRADIENT_BRIGHTNESS_OFFSET), min(1.0, g + GRADIENT_BRIGHTNESS_OFFSET), min(1.0, b + GRADIENT_BRIGHTNESS_OFFSET)) pattern.add_color_stop_rgb(1, max(0.0, r - GRADIENT_DARKNESS_OFFSET), max(0.0, g - GRADIENT_DARKNESS_OFFSET), max(0.0, b - GRADIENT_DARKNESS_OFFSET)) context.set_source(pattern) self._draw_shape(context, x, y, size, shape) context.fill() def draw_event_marker(self, context: cairo.Context, x: float, y: float, event_type: EventType, is_hovered: bool = False, is_selected: bool = False) -> None: """ Draw a marker for an event with modern styling. Args: context: Cairo drawing context. x: X coordinate for marker center. y: Y coordinate for marker center. event_type: Type of event (determines color and shape). is_hovered: Whether marker is currently hovered. is_selected: Whether marker belongs to selected person. """ 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 *= MARKER_HOVER_SIZE_MULTIPLIER elif is_selected: marker_size *= MARKER_SELECTED_SIZE_MULTIPLIER # Use highlight color if selected if is_selected: color = SELECTED_MARKER_COLOR # Draw shadow self._draw_marker_shadow(context, x, y, marker_size, shape) # Draw main shape with gradient self._draw_marker_gradient(context, x, y, marker_size, color, shape) # Draw border context.set_source_rgba(0.0, 0.0, 0.0, BORDER_OPACITY) context.set_line_width(1) self._draw_shape(context, x, y, marker_size, shape) context.stroke() context.restore() def _draw_triangle(self, context: cairo.Context, x: float, y: float, size: float) -> None: """Draw a triangle shape.""" context.move_to(x, y - size) context.line_to(x - size, y + size) context.line_to(x + size, y + size) context.close_path() def _draw_circle(self, context: cairo.Context, x: float, y: float, size: float) -> None: """Draw a circle shape.""" context.arc(x, y, size, 0, 2 * math.pi) def _draw_diamond(self, context: cairo.Context, x: float, y: float, size: float) -> None: """Draw a diamond shape.""" 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() def _draw_square(self, context: cairo.Context, x: float, y: float, size: float) -> None: """Draw a square shape.""" context.rectangle(x - size, y - size, size * 2, size * 2) def _draw_star(self, context: cairo.Context, x: float, y: float, size: float) -> None: """Draw a 5-pointed star shape.""" points = 5 outer_radius = size inner_radius = size * 0.4 for i in range(points * 2): angle = (i * math.pi) / points - math.pi / 2 radius = outer_radius if i % 2 == 0 else 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() def _draw_hexagon(self, context: cairo.Context, x: float, y: float, size: float) -> None: """Draw a hexagon shape.""" 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_shape(self, context: cairo.Context, x: float, y: float, size: float, shape: str) -> None: """ Draw a shape at the given position. Args: context: Cairo drawing context. x: X coordinate of shape center. y: Y coordinate of shape center. size: Size of the shape. shape: Shape type ('triangle', 'circle', 'diamond', 'square', 'star', 'hexagon'). """ shape_drawers = { 'triangle': self._draw_triangle, 'circle': self._draw_circle, 'diamond': self._draw_diamond, 'square': self._draw_square, 'star': self._draw_star, 'hexagon': self._draw_hexagon, } drawer = shape_drawers.get(shape, self._draw_square) # Default to square drawer(context, x, y, size) def draw_event_label(self, context: cairo.Context, x: float, y: float, date_obj: Date, event, person: Optional[Any], event_type: EventType, is_hovered: bool = False) -> None: """ Draw the label for an event with modern styling. Args: context: Cairo drawing context. x: X coordinate for label start. y: Y coordinate for label center. date_obj: Date object for the event. event: The event object. person: The person associated with the event (None for family events). event_type: The type of event. is_hovered: Whether the label is currently hovered. """ context.save() # Create Pango layout for text layout = PangoCairo.create_layout(context) # Use modern font font_desc = Pango.font_description_from_string(f"{FONT_FAMILY} {FONT_SIZE_NORMAL}") if is_hovered: font_desc.set_weight(Pango.Weight.BOLD) layout.set_font_description(font_desc) # Build label text using centralized method label_text = self._get_event_label_text(event, person, event_type) layout.set_markup(label_text, -1) layout.set_width(-1) # No width limit # Draw background for hovered events if is_hovered: text_width, text_height = layout.get_pixel_size() padding = LABEL_BACKGROUND_PADDING # 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 = LABEL_BACKGROUND_RADIUS 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 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 _find_year_range(self) -> Tuple[Optional[int], Optional[int]]: """ Find the minimum and maximum years from all events. Returns: Tuple[Optional[int], Optional[int]]: (min_year, max_year) or (None, None) if no valid years found. """ min_year = None max_year = None for event_data in self.events: try: year = event_data.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 (AttributeError, ValueError) as e: logger.debug(f"Error accessing year from date: {e}") return (min_year, max_year) def _calculate_year_y_position(self, year: int, min_date: int, max_date: int, y_start: float, y_end: float) -> float: """ Calculate Y position for a year marker. Args: year: The year to calculate position for. min_date: Minimum date sort value. max_date: Maximum date sort value. y_start: Y coordinate of the timeline start. y_end: Y coordinate of the timeline end. Returns: float: The Y position for the year marker. """ year_date = Date() year_date.set_yr_mon_day(year, 1, 1) year_sort = year_date.get_sort_value() if min_date == max_date: return (y_start + y_end) / 2 else: return y_start + ( (year_sort - min_date) / (max_date - min_date) ) * (y_end - y_start) def _draw_year_marker(self, context: cairo.Context, timeline_x: float, year: int, y_pos: float) -> None: """ Draw a single year marker (tick mark and label). Args: context: Cairo drawing context. timeline_x: X coordinate of the timeline. year: The year to draw. y_pos: Y position for the marker. """ # Draw tick mark context.set_source_rgb(0.5, 0.5, 0.5) # Gray context.set_line_width(1) 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(f"{FONT_FAMILY} {FONT_SIZE_SMALL}") ) 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) def draw_year_markers(self, context: cairo.Context, timeline_x: float, y_start: float, y_end: float, min_date: int, max_date: int) -> None: """ Draw year markers on the left side of the timeline. Args: context: Cairo drawing context. timeline_x: X coordinate of the timeline. y_start: Y coordinate of the timeline start. y_end: Y coordinate of the timeline end. min_date: Minimum date sort value. max_date: Maximum date sort value. """ context.save() # Find min and max years from events min_year, max_year = self._find_year_range() 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 for year in range(min_year, max_year + 1, year_step): # Calculate Y position y_pos = self._calculate_year_y_position(year, min_date, max_date, y_start, y_end) # Only draw if within visible range if y_pos < y_start or y_pos > y_end: continue # Draw the year marker self._draw_year_marker(context, timeline_x, year, y_pos) context.restore() def draw_person_connections(self, context: cairo.Context, events_with_y_pos: List[TimelineEvent], timeline_x: float, timeline_y_start: float, timeline_y_end: float) -> None: """ Draw visual connections between all events of the selected person. Args: context: Cairo drawing context. events_with_y_pos: List of TimelineEvent objects with Y positions. timeline_x: X coordinate of the timeline. timeline_y_start: Y coordinate of the timeline start. timeline_y_end: Y coordinate of the timeline end. """ if not self.selected_person_handle: return # Find all events for the selected person person_events = [ event_data for event_data in events_with_y_pos if event_data.person and event_data.person.get_handle() == self.selected_person_handle ] if len(person_events) < 1: return # Sort by Y position person_events.sort(key=lambda x: x.y_pos) context.save() # Position vertical line to the left of year markers # Year labels are positioned at timeline_x - 20 - text_width (around x=90-130) # Position vertical line at CONNECTION_VERTICAL_LINE_X to be clearly left of all year markers vertical_line_x = CONNECTION_VERTICAL_LINE_X # Draw connecting lines - more visible with brighter color and increased opacity context.set_source_rgba(*CONNECTION_LINE_COLOR) context.set_line_width(CONNECTION_LINE_WIDTH) context.set_line_cap(cairo.LINE_CAP_ROUND) context.set_line_join(cairo.LINE_JOIN_ROUND) # Draw vertical connector line on the left side if len(person_events) > 1: y_positions = [event_data.y_pos for event_data in person_events] min_y = min(y_positions) max_y = max(y_positions) # Draw vertical line connecting all events if max_y - min_y > EVENT_MARKER_SIZE * 2: context.set_source_rgba(*CONNECTION_LINE_COLOR) context.set_line_width(CONNECTION_LINE_WIDTH) context.move_to(vertical_line_x, min_y) context.line_to(vertical_line_x, max_y) context.stroke() # Draw horizontal lines connecting vertical line to each event marker context.set_source_rgba(*CONNECTION_LINE_COLOR) context.set_line_width(CONNECTION_LINE_WIDTH) for event_data in person_events: # Draw horizontal line from vertical line to event marker context.move_to(vertical_line_x, event_data.y_pos) context.line_to(timeline_x, event_data.y_pos) context.stroke() context.restore() def get_stock(self) -> str: """ Return the stock icon name. Returns: str: The stock icon name for this view. """ return "gramps-family"