# -*- 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 all events in the database """ # ------------------------------------------------------------------------- # # Python modules # # ------------------------------------------------------------------------- import cairo import colorsys import datetime import logging import math from dataclasses import dataclass from typing import Optional, List, Tuple, Any, Set, Dict, TYPE_CHECKING, Union, Callable if TYPE_CHECKING: from gramps.gen.lib import Event, Person, Family from gi.repository import Gtk from gi.repository import Gdk from gi.repository import GdkPixbuf 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 EventBookmarks _ = 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 YEAR_MARKER_STEP_DIVISOR = 10 # Divisor for calculating year marker step (every Nth year) TOOLTIP_DELAY = 500 # milliseconds # Drawing Area Event Masks DRAWING_AREA_EVENT_MASKS = ( Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.BUTTON_RELEASE_MASK | Gdk.EventMask.POINTER_MOTION_MASK | Gdk.EventMask.LEAVE_NOTIFY_MASK | Gdk.EventMask.SCROLL_MASK ) TOOLTIP_MAX_WIDTH = 500 LABEL_BACKGROUND_PADDING = 8 LABEL_BACKGROUND_RADIUS = 5 DEFAULT_TIMELINE_HEIGHT = 1000 # Default height, will be recalculated DEFAULT_TIMELINE_WIDTH = 800 DEFAULT_DRAWING_AREA_HEIGHT = 600 DEFAULT_DRAWING_AREA_WIDTH = 800 EMPTY_TIMELINE_HEIGHT = 600 # Height when no events FILTER_DIALOG_WIDTH = 600 FILTER_DIALOG_HEIGHT = 700 MIN_GENEALOGICAL_YEAR = 1000 # Minimum year for genealogical data DATE_SORT_YEAR_MULTIPLIER = 10000 # Year component in date sort value DATE_SORT_MONTH_MULTIPLIER = 100 # Month component in date sort value # UI Layout Constants FILTER_PAGE_MARGIN = 10 # Margin for filter page containers FILTER_PAGE_SPACING = 10 # Spacing in filter page containers FILTER_PAGE_VERTICAL_SPACING = 5 # Vertical spacing within filter pages FILTER_CATEGORY_SPACING = 2 # Spacing within category containers FILTER_INDENT_MARGIN = 20 # Indent margin for nested elements CALENDAR_CONTAINER_MARGIN = 5 # Margin for calendar containers CALENDAR_CONTAINER_SPACING = 5 # Spacing in calendar containers CALENDAR_HORIZONTAL_SPACING = 15 # Spacing between calendar frames YEAR_SELECTOR_SPACING = 5 # Spacing in year selector boxes TOOLBAR_SPACING = 5 # Spacing in toolbar TOOLBAR_ITEM_MARGIN = 10 # Margin for toolbar items TOOLTIP_SEPARATOR_LENGTH = 30 # Length of tooltip separator line TOOLTIP_OFFSET = 15 # Offset for tooltip position to avoid cursor TOOLTIP_LABEL_MARGIN = 5 # Margin for tooltip label TOOLTIP_MAX_WIDTH_CHARS = 40 # Maximum width in characters for tooltip label TOOLTIP_BORDER_WIDTH = 8 # Border width for tooltip window # Portrait Display Constants PORTRAIT_SIZE_TIMELINE = 24 # Size of timeline portraits PORTRAIT_SIZE_TOOLTIP = 120 # Size of tooltip portraits PORTRAIT_MARGIN = 5 # Margin around portraits PORTRAIT_GAP = 12 # Gap in pixels between event marker edge and portrait edge # 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 DEFAULT_EVENT_SHAPE = 'square' # Default shape for events without specific shape mapping # Mouse Button Constants MOUSE_BUTTON_LEFT = 1 # Left mouse button ZOOM_PERCENTAGE_MULTIPLIER = 100 # Multiplier to convert zoom level (0.0-1.0) to percentage 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 TIMELINE_SHADOW_OFFSET = 2 # Shadow offset for timeline axis # Drawing Color Constants BACKGROUND_GRADIENT_START = (0.98, 0.98, 0.99) # Very light gray-blue BACKGROUND_GRADIENT_END = (0.95, 0.96, 0.98) # Slightly darker TIMELINE_AXIS_GRADIENT_START = (0.3, 0.3, 0.3) # Dark gray TIMELINE_AXIS_GRADIENT_END = (0.5, 0.5, 0.5) # Medium gray TIMELINE_SHADOW_OPACITY = 0.2 # Shadow opacity for timeline axis # 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 CONNECTION_LINE_SPACING = 25 # Pixels between vertical lines for different persons TIMELINE_LEFT_SPACING = 20 # Spacing between rightmost connection line and timeline CONNECTION_LINE_MIN_DISTANCE = EVENT_MARKER_SIZE * 2 # Minimum distance between events to draw vertical connector line # Color Generation Constants PERSON_COLOR_SATURATION = 0.7 # High saturation for vibrant colors PERSON_COLOR_LIGHTNESS = 0.5 # Medium lightness for good visibility PERSON_COLOR_ALPHA = 0.75 # Semi-transparent HSV_HUE_MAX_DEGREES = 360 # Maximum value for hue in HSV color space (degrees) # Marker State Colors SELECTED_MARKER_COLOR = (0.2, 0.4, 0.9) # Blue highlight for selected person's events # Default Colors DEFAULT_EVENT_COLOR = (0.5, 0.5, 0.5) # Gray for unknown event types YEAR_MARKER_COLOR = (0.5, 0.5, 0.5) # Gray for year marker tick marks COLOR_BLACK = (0.0, 0.0, 0.0) # Black color used in multiple drawing operations # Label Drawing Constants LABEL_TEXT_COLOR = (0.1, 0.1, 0.1) # Dark gray text color for event labels LABEL_VERTICAL_OFFSET = 10 # Vertical offset to center label on marker LABEL_HOVER_BACKGROUND_COLOR = (1.0, 1.0, 1.0, 0.8) # Semi-transparent white background for hovered labels LABEL_HOVER_BORDER_COLOR = (0.7, 0.7, 0.7, 0.5) # Light gray border for hovered labels # Marker Drawing Constants MARKER_BORDER_WIDTH = 1 # Line width for marker borders # Year Marker Constants YEAR_MARKER_LABEL_OFFSET = 20 # Horizontal offset for year marker labels from timeline # 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', } # Event category mapping for filtering (using EventType objects as keys, like EVENT_COLORS and EVENT_SHAPES) # Note: EventType members are already integers in Gramps, not enum objects with .value EVENT_CATEGORIES = { # Life Events EventType.BIRTH: "Life Events", EventType.DEATH: "Life Events", EventType.BURIAL: "Life Events", EventType.CREMATION: "Life Events", EventType.ADOPT: "Life Events", # Family Events EventType.MARRIAGE: "Family Events", EventType.DIVORCE: "Family Events", EventType.ENGAGEMENT: "Family Events", EventType.MARR_SETTL: "Family Events", EventType.MARR_LIC: "Family Events", EventType.MARR_CONTR: "Family Events", EventType.MARR_BANNS: "Family Events", EventType.DIV_FILING: "Family Events", EventType.ANNULMENT: "Family Events", EventType.MARR_ALT: "Family Events", # Religious Events EventType.BAPTISM: "Religious Events", EventType.ADULT_CHRISTEN: "Religious Events", EventType.CONFIRMATION: "Religious Events", EventType.CHRISTEN: "Religious Events", EventType.FIRST_COMMUN: "Religious Events", EventType.BLESS: "Religious Events", EventType.BAR_MITZVAH: "Religious Events", EventType.BAS_MITZVAH: "Religious Events", EventType.RELIGION: "Religious Events", EventType.ORDINATION: "Religious Events", # Vocational Events EventType.OCCUPATION: "Vocational Events", EventType.RETIREMENT: "Vocational Events", EventType.ELECTED: "Vocational Events", EventType.MILITARY_SERV: "Vocational Events", # Academic Events EventType.EDUCATION: "Academic Events", EventType.GRADUATION: "Academic Events", EventType.DEGREE: "Academic Events", # Travel Events EventType.EMIGRATION: "Travel Events", EventType.IMMIGRATION: "Travel Events", EventType.NATURALIZATION: "Travel Events", # Legal Events EventType.PROBATE: "Legal Events", EventType.WILL: "Legal Events", # Residence Events EventType.RESIDENCE: "Residence Events", EventType.CENSUS: "Residence Events", EventType.PROPERTY: "Residence Events", # Other Events EventType.CAUSE_DEATH: "Other Events", EventType.MED_INFO: "Other Events", EventType.NOB_TITLE: "Other Events", EventType.NUM_MARRIAGES: "Other Events", EventType.UNKNOWN: "Other Events", EventType.CUSTOM: "Other Events", } # ------------------------------------------------------------------------- # # Data Classes # # ------------------------------------------------------------------------- @dataclass class TimelineEvent: """Represents an event in the timeline with all necessary data.""" date_sort: int date_obj: Date event: 'Event' # gramps.gen.lib.Event person: Optional['Person'] # gramps.gen.lib.Person event_type: EventType y_pos: float = 0.0 # ------------------------------------------------------------------------- # # MyTimelineView # # ------------------------------------------------------------------------- class MyTimelineView(NavigationView): """ View for displaying a vertical timeline of all events in the database. Shows all events with modern design and interactivity, allowing selection and filtering of events by type, category, and person. """ def __init__(self, pdata: Any, dbstate: Any, uistate: Any, nav_group: int = 0) -> None: """ 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, EventBookmarks, nav_group ) self.dbstate = dbstate self.uistate = uistate # Current event handle (for selection/highlighting) self.active_event_handle = None self.events: List[TimelineEvent] = [] # List of TimelineEvent objects # UI components self.scrolledwindow = None self.drawing_area = None self.timeline_height = DEFAULT_TIMELINE_HEIGHT 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_handles: Set[str] = set() # Set of selected person handles self.person_colors: Dict[str, Tuple[float, float, float, float]] = {} # Person handle -> RGBA color 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 self._event_to_person_cache: Dict[str, Optional['Person']] = {} # Event handle -> Person object (or None) self._event_type_normalization_cache: Dict[int, int] = {} # Cache for event type normalization (key: hash/id, value: normalized int) self._normalized_active_event_types: Optional[Set[int]] = None # Pre-computed normalized active types # Filter state self.filter_enabled: bool = False self.active_event_types: Set[EventType] = set() # Empty = all enabled self.person_filter: Optional[Set[str]] = None # Set of person handles to include, None = all self.category_filter: Optional[Set[str]] = None # Set of event categories to include, None = all self.all_events: List[TimelineEvent] = [] # Store all events before filtering # Filter UI components self.filter_button = None self.filter_dialog = None self._filter_widgets = {} # 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 "Event" for this view. """ return "Event" def _queue_draw(self) -> None: """ Safely queue a redraw of the drawing area if it exists. """ if self.drawing_area: self.drawing_area.queue_draw() def change_page(self) -> None: """ Called when the page changes. Updates the view to show all events and highlight the active event if selected. """ NavigationView.change_page(self) active_handle = self.get_active() if active_handle: self.goto_handle(active_handle) else: # If no event is selected, ensure timeline is displayed if not self.events and self.dbstate.is_open(): self.collect_events() self._queue_draw() 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. """ # Refresh timeline since family updates may affect event display self.collect_events() self._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. """ # Refresh timeline since person updates may affect event display self.collect_events() self._queue_draw() 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 when events are updated self.collect_events() self._queue_draw() # If the active event was updated, ensure it's still highlighted if self.active_event_handle in handle_list: self._scroll_to_event(self.active_event_handle) def change_db(self, db: Any) -> None: """ Called when the database changes. Args: db: The new database object. """ self.active_event_handle = None self.all_events = [] self.events = [] # Clear selected persons and colors self.selected_person_handles.clear() self.person_colors.clear() # Clear event-to-person cache self._event_to_person_cache.clear() # 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) self._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=TOOLBAR_SPACING) # 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(TOOLBAR_ITEM_MARGIN) self.zoom_label.set_margin_end(TOOLBAR_ITEM_MARGIN) 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) # Filter button self.filter_button = Gtk.ToolButton(icon_name="view-filter-symbolic") self.filter_button.set_tooltip_text(_("Filter Events")) self.filter_button.connect("clicked", self.on_filter_button_clicked) toolbar.insert(self.filter_button, 5) 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(DEFAULT_DRAWING_AREA_WIDTH, DEFAULT_DRAWING_AREA_HEIGHT) self.drawing_area.connect("draw", self.on_draw) self.drawing_area.add_events(DRAWING_AREA_EVENT_MASKS) # 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 on_filter_button_clicked(self, button: Gtk.ToolButton) -> None: """ Handle filter button click - show filter dialog. Args: button: The filter button that was clicked. """ if self.filter_dialog is None: self.filter_dialog = self._build_filter_dialog() # Update filter dialog state self._update_filter_dialog_state() # Show dialog response = self.filter_dialog.run() if response == Gtk.ResponseType.APPLY: self._apply_filter_dialog_settings() self.filter_dialog.hide() def _calculate_group_state(self, child_checkboxes: List[Gtk.CheckButton]) -> str: """ Calculate the state of a group based on child checkboxes. Args: child_checkboxes: List of child checkboxes in the group. Returns: str: 'all' if all selected, 'none' if none selected, 'some' if partially selected. """ if not child_checkboxes: return 'none' active_count = sum(1 for cb in child_checkboxes if cb.get_active()) if active_count == 0: return 'none' elif active_count == len(child_checkboxes): return 'all' else: return 'some' def _update_group_checkbox_state(self, group_checkbox: Gtk.CheckButton, child_checkboxes: List[Gtk.CheckButton], updating_flag: Optional[List[bool]] = None) -> None: """ Update a group checkbox state based on child checkboxes. Args: group_checkbox: The parent group checkbox to update. child_checkboxes: List of child checkboxes. updating_flag: Optional list with single boolean to prevent recursion. If provided, will be set to True during update to prevent the group checkbox's toggle handler from firing. """ # Set updating flag to prevent recursion if provided if updating_flag is not None: updating_flag[0] = True state = self._calculate_group_state(child_checkboxes) if state == 'all': group_checkbox.set_active(True) group_checkbox.set_inconsistent(False) elif state == 'none': group_checkbox.set_active(False) group_checkbox.set_inconsistent(False) else: # 'some' group_checkbox.set_inconsistent(True) # Clear updating flag after update if updating_flag is not None: updating_flag[0] = False def _make_group_toggle_handler(self, child_checkboxes: List[Gtk.CheckButton], updating_flag: List[bool]) -> Callable[[Gtk.Widget], None]: """ Create a handler for group checkbox toggle that toggles all children. Args: child_checkboxes: List of child checkboxes to toggle. updating_flag: List with single boolean to prevent recursion. Returns: Callable handler function. """ def handler(widget: Gtk.Widget) -> None: if updating_flag[0]: return # Handle inconsistent state - make it consistent if widget.get_inconsistent(): widget.set_inconsistent(False) widget.set_active(True) updating_flag[0] = True # Toggle all children is_active = widget.get_active() for child_cb in child_checkboxes: child_cb.set_active(is_active) updating_flag[0] = False return handler def _make_child_toggle_handler(self, group_checkbox: Gtk.CheckButton, child_checkboxes: List[Gtk.CheckButton], updating_flag: List[bool]) -> Callable[[Gtk.Widget], None]: """ Create a handler for child checkbox toggle that updates group state. Args: group_checkbox: The parent group checkbox to update. child_checkboxes: List of all child checkboxes. updating_flag: List with single boolean to prevent recursion. Returns: Callable handler function. """ def handler(widget: Gtk.Widget) -> None: if updating_flag[0]: return self._update_group_checkbox_state(group_checkbox, child_checkboxes, updating_flag) return handler def _build_filter_dialog(self) -> Gtk.Dialog: """ Build the filter dialog with all filter controls. Returns: Gtk.Dialog: The filter dialog widget. """ dialog = Gtk.Dialog(title=_("Filter Events"), parent=self.uistate.window) dialog.set_default_size(FILTER_DIALOG_WIDTH, FILTER_DIALOG_HEIGHT) dialog.add_button(_("Clear All"), Gtk.ResponseType.REJECT) dialog.add_button(_("Close"), Gtk.ResponseType.CLOSE) dialog.add_button(_("Apply"), Gtk.ResponseType.APPLY) # Connect to clear button dialog.connect("response", self._on_filter_dialog_response) # Main content area content_area = dialog.get_content_area() content_area.set_spacing(FILTER_PAGE_SPACING) content_area.set_margin_start(FILTER_PAGE_MARGIN) content_area.set_margin_end(FILTER_PAGE_MARGIN) content_area.set_margin_top(FILTER_PAGE_MARGIN) content_area.set_margin_bottom(FILTER_PAGE_MARGIN) # Notebook for organizing filters notebook = Gtk.Notebook() content_area.pack_start(notebook, True, True, 0) # Store widget references for later access self._filter_widgets = {} # Event Type Filter Page event_type_page = self._build_event_type_filter_page() notebook.append_page(event_type_page, Gtk.Label(label=_("Event Types"))) # Category Filter Page category_page = self._build_category_filter_page() notebook.append_page(category_page, Gtk.Label(label=_("Categories"))) # Person Filter Page person_page = self._build_person_filter_page() notebook.append_page(person_page, Gtk.Label(label=_("Persons"))) content_area.show_all() return dialog def _build_event_type_filter_page(self) -> Gtk.Widget: """ Build the event type filter page with checkboxes for each event type. Returns: Gtk.Widget: The event type filter page. """ scrolled = Gtk.ScrolledWindow() scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=FILTER_PAGE_VERTICAL_SPACING) box.set_margin_start(FILTER_PAGE_MARGIN) box.set_margin_end(FILTER_PAGE_MARGIN) box.set_margin_top(FILTER_PAGE_MARGIN) box.set_margin_bottom(FILTER_PAGE_MARGIN) # Select All / Deselect All buttons button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=FILTER_PAGE_VERTICAL_SPACING) select_all_btn = Gtk.Button(label=_("Select All")) select_all_btn.connect("clicked", self._on_select_all_event_types) deselect_all_btn = Gtk.Button(label=_("Deselect All")) deselect_all_btn.connect("clicked", self._on_deselect_all_event_types) button_box.pack_start(select_all_btn, False, False, 0) button_box.pack_start(deselect_all_btn, False, False, 0) box.pack_start(button_box, False, False, 0) # Group event types by category event_type_checkboxes = {} category_boxes = {} category_checkboxes = {} category_event_types = {} # Map category to list of event types category_updating_flags = {} # Map category to updating flag list # First pass: collect event types by category for event_type_obj in EVENT_COLORS: if event_type_obj not in EVENT_CATEGORIES: continue category = EVENT_CATEGORIES[event_type_obj] if category not in category_event_types: category_event_types[category] = [] category_event_types[category].append(event_type_obj) # Second pass: create UI with category checkboxes for category in sorted(category_event_types): event_types_in_category = category_event_types[category] # Create category checkbox with three-state support category_checkbox = Gtk.CheckButton(label=category) category_checkboxes[category] = category_checkbox box.pack_start(category_checkbox, False, False, 0) # Create container for event types in this category category_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=FILTER_CATEGORY_SPACING) category_box.set_margin_start(FILTER_INDENT_MARGIN) category_boxes[category] = category_box box.pack_start(category_box, False, False, 0) # Create checkboxes for each event type in this category child_checkboxes = [] for event_type_obj in sorted(event_types_in_category, key=lambda x: self._get_event_type_display_name(x)): event_type_name = self._get_event_type_display_name(event_type_obj) checkbox = Gtk.CheckButton(label=event_type_name) event_type_checkboxes[event_type_obj] = checkbox child_checkboxes.append(checkbox) category_box.pack_start(checkbox, False, False, 0) # Flag to prevent recursion between category and child checkboxes updating_category = [False] category_updating_flags[category] = updating_category # Connect category checkbox to toggle all children category_checkbox.connect("toggled", self._make_group_toggle_handler(child_checkboxes, updating_category)) # Connect child checkboxes to update category checkbox state for child_cb in child_checkboxes: child_cb.connect("toggled", self._make_child_toggle_handler(category_checkbox, child_checkboxes, updating_category)) self._filter_widgets['event_type_checkboxes'] = event_type_checkboxes self._filter_widgets['category_checkboxes'] = category_checkboxes self._filter_widgets['category_event_types'] = category_event_types self._filter_widgets['category_updating_flags'] = category_updating_flags scrolled.add(box) return scrolled def _build_category_filter_page(self) -> Gtk.Widget: """ Build the category filter page with checkboxes for each category. Returns: Gtk.Widget: The category filter page. """ box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=FILTER_PAGE_VERTICAL_SPACING) box.set_margin_start(FILTER_PAGE_MARGIN) box.set_margin_end(FILTER_PAGE_MARGIN) box.set_margin_top(FILTER_PAGE_MARGIN) box.set_margin_bottom(FILTER_PAGE_MARGIN) # Get unique categories categories = sorted(set(EVENT_CATEGORIES.values())) category_checkboxes = {} for category in categories: checkbox = Gtk.CheckButton(label=category) category_checkboxes[category] = checkbox box.pack_start(checkbox, False, False, 0) self._filter_widgets['category_checkboxes'] = category_checkboxes return box def _build_person_filter_page(self) -> Gtk.Widget: """ Build the person filter page with a treeview showing families and their members. Returns: Gtk.Widget: The person filter page. """ scrolled = Gtk.ScrolledWindow() scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=FILTER_PAGE_VERTICAL_SPACING) box.set_margin_start(FILTER_PAGE_MARGIN) box.set_margin_end(FILTER_PAGE_MARGIN) box.set_margin_top(FILTER_PAGE_MARGIN) box.set_margin_bottom(FILTER_PAGE_MARGIN) info_label = Gtk.Label(label=_("Select families and their members to include in the timeline.")) info_label.set_line_wrap(True) box.pack_start(info_label, False, False, 0) # Create TreeStore with columns: # 0: person_active (bool), 1: person_inconsistent (bool), 2: name (str), # 3: person_handle (str/None), 4: family_handle (str/None), 5: role (str/None), # 6: descendants_active (bool), 7: descendants_inconsistent (bool) tree_store = Gtk.TreeStore(bool, bool, str, str, str, str, bool, bool) self._filter_widgets['person_tree_store'] = tree_store # Create TreeView tree_view = Gtk.TreeView(model=tree_store) tree_view.set_headers_visible(False) self._filter_widgets['person_tree_view'] = tree_view # Create person checkbox column with CellRendererToggle person_toggle_renderer = Gtk.CellRendererToggle() person_toggle_renderer.set_property('activatable', True) person_toggle_column = Gtk.TreeViewColumn() person_toggle_column.pack_start(person_toggle_renderer, False) person_toggle_column.add_attribute(person_toggle_renderer, 'active', 0) person_toggle_column.add_attribute(person_toggle_renderer, 'inconsistent', 1) person_toggle_renderer.connect('toggled', self._on_person_tree_checkbox_toggled) tree_view.append_column(person_toggle_column) # Create descendants checkbox column with CellRendererToggle descendants_toggle_renderer = Gtk.CellRendererToggle() descendants_toggle_renderer.set_property('activatable', True) descendants_toggle_column = Gtk.TreeViewColumn() descendants_toggle_column.pack_start(descendants_toggle_renderer, False) descendants_toggle_column.add_attribute(descendants_toggle_renderer, 'active', 6) descendants_toggle_column.add_attribute(descendants_toggle_renderer, 'inconsistent', 7) # Use cell data function to set activatable based on whether row has descendants descendants_toggle_column.set_cell_data_func(descendants_toggle_renderer, self._descendants_checkbox_data_func) descendants_toggle_renderer.connect('toggled', self._on_descendants_checkbox_toggled) tree_view.append_column(descendants_toggle_column) # Create name column with CellRendererText text_renderer = Gtk.CellRendererText() name_column = Gtk.TreeViewColumn() name_column.pack_start(text_renderer, True) name_column.add_attribute(text_renderer, 'text', 2) tree_view.append_column(name_column) # Store mapping of person handle to list of TreeIters where they appear self._filter_widgets['person_to_iters'] = {} # Store mapping of family handle to TreeIter self._filter_widgets['family_to_iter'] = {} # Store updating flags per family self._filter_widgets['family_updating_flags'] = {} box.pack_start(tree_view, True, True, 0) scrolled.add(box) return scrolled def _descendants_checkbox_data_func(self, column: Gtk.TreeViewColumn, renderer: Gtk.CellRendererToggle, model: Gtk.TreeModel, iter_obj: Gtk.TreeIter, data: Any) -> None: """ Cell data function for descendants checkbox to make it invisible when row has no descendants. Args: column: The tree view column. renderer: The cell renderer. model: The tree model. iter_obj: The tree iter for the row. data: User data (unused). """ # Check if this row has descendants has_descendants = model.iter_has_child(iter_obj) # Make checkbox invisible if there are no descendants, visible and activatable if there are renderer.set_property('visible', has_descendants) renderer.set_property('activatable', has_descendants) renderer.set_property('sensitive', has_descendants) def _calculate_family_state(self, tree_store: Gtk.TreeStore, family_iter: Gtk.TreeIter) -> str: """ Calculate the state of a family based on child person rows in TreeStore. Recursively checks all descendants. Args: tree_store: The TreeStore containing the data. family_iter: The TreeIter for the family row. Returns: str: 'all' if all descendants selected, 'none' if none selected, 'some' if partially selected. """ def count_descendants(parent_iter: Optional[Gtk.TreeIter]) -> Tuple[int, int]: """ Recursively count active and total descendants. Returns: Tuple[int, int]: (active_count, total_count) """ child_iter = tree_store.iter_children(parent_iter) if parent_iter else tree_store.iter_children(family_iter) if child_iter is None: return (0, 0) active_count = 0 total_count = 0 while child_iter is not None: # Count this row if it's a person row (has person_handle) person_handle = tree_store.get_value(child_iter, 3) if person_handle and person_handle != '': total_count += 1 if tree_store.get_value(child_iter, 0): # Column 0 is active state active_count += 1 # Recursively count descendants if tree_store.iter_has_child(child_iter): child_active, child_total = count_descendants(child_iter) active_count += child_active total_count += child_total child_iter = tree_store.iter_next(child_iter) return (active_count, total_count) active_count, total_count = count_descendants(None) if total_count == 0: return 'none' elif active_count == 0: return 'none' elif active_count == total_count: return 'all' else: return 'some' def _calculate_person_state(self, tree_store: Gtk.TreeStore, person_iter: Gtk.TreeIter) -> str: """ Calculate the state of a person based on descendant rows in TreeStore. Recursively checks all descendants. Args: tree_store: The TreeStore containing the data. person_iter: The TreeIter for the person row. Returns: str: 'all' if all descendants selected, 'none' if none selected, 'some' if partially selected. """ def count_descendants(parent_iter: Gtk.TreeIter) -> Tuple[int, int]: """ Recursively count active and total descendants. Returns: Tuple[int, int]: (active_count, total_count) """ child_iter = tree_store.iter_children(parent_iter) if child_iter is None: return (0, 0) active_count = 0 total_count = 0 while child_iter is not None: # Count this row if it's a person row (has person_handle) person_handle = tree_store.get_value(child_iter, 3) if person_handle and person_handle != '': total_count += 1 if tree_store.get_value(child_iter, 0): # Column 0 is active state active_count += 1 # Recursively count descendants if tree_store.iter_has_child(child_iter): child_active, child_total = count_descendants(child_iter) active_count += child_active total_count += child_total child_iter = tree_store.iter_next(child_iter) return (active_count, total_count) active_count, total_count = count_descendants(person_iter) if total_count == 0: return 'none' elif active_count == 0: return 'none' elif active_count == total_count: return 'all' else: return 'some' def _calculate_descendants_state(self, tree_store: Gtk.TreeStore, person_iter: Gtk.TreeIter) -> str: """ Calculate the state of descendants checkbox based on descendant person checkboxes. Recursively checks all descendants' person checkboxes (column 0). Args: tree_store: The TreeStore containing the data. person_iter: The TreeIter for the person row. Returns: str: 'all' if all descendants' person checkboxes are checked, 'none' if none checked, 'some' if partially checked. """ def count_descendants(parent_iter: Gtk.TreeIter) -> Tuple[int, int]: """ Recursively count active and total descendants based on their person checkboxes (column 0). Returns: Tuple[int, int]: (active_count, total_count) """ child_iter = tree_store.iter_children(parent_iter) if child_iter is None: return (0, 0) active_count = 0 total_count = 0 while child_iter is not None: # Count this row if it's a person row (has person_handle) person_handle = tree_store.get_value(child_iter, 3) if person_handle and person_handle != '': total_count += 1 if tree_store.get_value(child_iter, 0): # Column 0 is person checkbox active state active_count += 1 # Recursively count descendants if tree_store.iter_has_child(child_iter): child_active, child_total = count_descendants(child_iter) active_count += child_active total_count += child_total child_iter = tree_store.iter_next(child_iter) return (active_count, total_count) active_count, total_count = count_descendants(person_iter) if total_count == 0: return 'none' elif active_count == 0: return 'none' elif active_count == total_count: return 'all' else: return 'some' def _update_descendants_checkbox_state(self, tree_store: Gtk.TreeStore, person_iter: Gtk.TreeIter, person_handle: str) -> None: """ Update a person row's descendants checkbox state based on descendant person checkboxes. Args: tree_store: The TreeStore containing the data. person_iter: The TreeIter for the person row. person_handle: The handle of the person. """ # Check if person has descendants if not tree_store.iter_has_child(person_iter): # No descendants, set to False tree_store.set_value(person_iter, 6, False) # descendants_active = False tree_store.set_value(person_iter, 7, False) # descendants_inconsistent = False return state = self._calculate_descendants_state(tree_store, person_iter) if state == 'all': tree_store.set_value(person_iter, 6, True) # descendants_active = True tree_store.set_value(person_iter, 7, False) # descendants_inconsistent = False elif state == 'none': tree_store.set_value(person_iter, 6, False) # descendants_active = False tree_store.set_value(person_iter, 7, False) # descendants_inconsistent = False else: # 'some' tree_store.set_value(person_iter, 6, False) # descendants_active = False (inconsistent state) tree_store.set_value(person_iter, 7, True) # descendants_inconsistent = True def _update_person_checkbox_state(self, tree_store: Gtk.TreeStore, person_iter: Gtk.TreeIter, person_handle: str) -> None: """ Update a person row's checkbox state based on descendant rows. Args: tree_store: The TreeStore containing the data. person_iter: The TreeIter for the person row. person_handle: The handle of the person. """ # Check if person has descendants if not tree_store.iter_has_child(person_iter): # No descendants, so no inconsistent state needed return state = self._calculate_person_state(tree_store, person_iter) if state == 'all': tree_store.set_value(person_iter, 0, True) # active = True tree_store.set_value(person_iter, 1, False) # inconsistent = False elif state == 'none': tree_store.set_value(person_iter, 0, False) # active = False tree_store.set_value(person_iter, 1, False) # inconsistent = False else: # 'some' tree_store.set_value(person_iter, 1, True) # inconsistent = True def _update_family_checkbox_state(self, tree_store: Gtk.TreeStore, family_iter: Gtk.TreeIter, family_handle: str, updating_flag: Optional[List[bool]] = None) -> None: """ Update a family row's checkbox state based on child person rows. Args: tree_store: The TreeStore containing the data. family_iter: The TreeIter for the family row. family_handle: The handle of the family. updating_flag: Optional list with single boolean to prevent recursion. """ # Set updating flag to prevent recursion if provided if updating_flag is not None: updating_flag[0] = True state = self._calculate_family_state(tree_store, family_iter) if state == 'all': tree_store.set_value(family_iter, 0, True) # active = True tree_store.set_value(family_iter, 1, False) # inconsistent = False elif state == 'none': tree_store.set_value(family_iter, 0, False) # active = False tree_store.set_value(family_iter, 1, False) # inconsistent = False else: # 'some' tree_store.set_value(family_iter, 1, True) # inconsistent = True # Clear updating flag after update if updating_flag is not None: updating_flag[0] = False def _on_person_tree_checkbox_toggled(self, renderer: Gtk.CellRendererToggle, path: str) -> None: """ Handle person checkbox toggle in the person treeview. Args: renderer: The CellRendererToggle that was toggled. path: String path of the row that was toggled. """ tree_store = self._filter_widgets.get('person_tree_store') if not tree_store: return tree_path = Gtk.TreePath.new_from_string(path) tree_iter = tree_store.get_iter(tree_path) if not tree_iter: return # Get row data person_handle = tree_store.get_value(tree_iter, 3) # Column 3: person_handle family_handle = tree_store.get_value(tree_iter, 4) # Column 4: family_handle # All rows should be person rows now (no family rows at top level) if person_handle and person_handle != '': # This is a person row self._on_person_checkbox_toggled(tree_store, tree_iter, person_handle, family_handle) def _on_descendants_checkbox_toggled(self, renderer: Gtk.CellRendererToggle, path: str) -> None: """ Handle descendants checkbox toggle in the person treeview. Only toggles descendants, not the person itself. Args: renderer: The CellRendererToggle that was toggled. path: String path of the row that was toggled. """ tree_store = self._filter_widgets.get('person_tree_store') if not tree_store: return tree_path = Gtk.TreePath.new_from_string(path) tree_iter = tree_store.get_iter(tree_path) if not tree_iter: return # Check if row has descendants - if not, do nothing if not tree_store.iter_has_child(tree_iter): return # Get row data person_handle = tree_store.get_value(tree_iter, 3) # Column 3: person_handle family_handle = tree_store.get_value(tree_iter, 4) # Column 4: family_handle # Only handle person rows (not family rows) if person_handle and person_handle != '': self._on_descendants_checkbox_toggled_for_person(tree_store, tree_iter, person_handle, family_handle) def _on_descendants_checkbox_toggled_for_person(self, tree_store: Gtk.TreeStore, person_iter: Gtk.TreeIter, person_handle: str, family_handle: str) -> None: """ Handle descendants checkbox toggle for a person - toggle only descendants, not the person itself. Args: tree_store: The TreeStore containing the data. person_iter: The TreeIter for the person row. person_handle: The handle of the person. family_handle: The handle of the family. """ # Get current descendants checkbox state is_inconsistent = tree_store.get_value(person_iter, 7) # Column 7: descendants_inconsistent current_active = tree_store.get_value(person_iter, 6) # Column 6: descendants_active # If inconsistent, make it consistent by checking all descendants if is_inconsistent: new_active = True else: new_active = not current_active # Recursively update all descendants' person checkboxes (column 0), but NOT the person itself def update_descendants_only_recursive(parent_iter: Gtk.TreeIter) -> None: """Recursively update only descendants' person checkboxes.""" child_iter = tree_store.iter_children(parent_iter) while child_iter is not None: child_person_handle = tree_store.get_value(child_iter, 3) if child_person_handle and child_person_handle != '': # Update this descendant's person checkbox (column 0) tree_store.set_value(child_iter, 0, new_active) tree_store.set_value(child_iter, 1, False) # Clear person inconsistent state # Also update this person in all other families where they appear child_person_iters = self._filter_widgets.get('person_to_iters', {}).get(child_person_handle, []) for other_iter in child_person_iters: if other_iter != child_iter: tree_store.set_value(other_iter, 0, new_active) tree_store.set_value(other_iter, 1, False) # Clear person inconsistent state # Recursively update descendants if tree_store.iter_has_child(child_iter): update_descendants_only_recursive(child_iter) child_iter = tree_store.iter_next(child_iter) # Update descendants for all appearances of this person person_iters = self._filter_widgets.get('person_to_iters', {}).get(person_handle, []) for iter_obj in person_iters: if tree_store.iter_has_child(iter_obj): update_descendants_only_recursive(iter_obj) # Update descendants checkbox states for all appearances of this person for iter_obj in person_iters: self._update_descendants_checkbox_state(tree_store, iter_obj, person_handle) # Update descendants checkbox states for all ancestors def update_ancestors_descendants(iter_obj: Gtk.TreeIter) -> None: """Update descendants checkbox states for all ancestors.""" parent_iter = tree_store.iter_parent(iter_obj) while parent_iter is not None: parent_person_handle = tree_store.get_value(parent_iter, 3) if parent_person_handle and parent_person_handle != '': # Update this ancestor's descendants checkbox state self._update_descendants_checkbox_state(tree_store, parent_iter, parent_person_handle) # Also update in all other appearances parent_person_iters = self._filter_widgets.get('person_to_iters', {}).get(parent_person_handle, []) for other_iter in parent_person_iters: if other_iter != parent_iter: self._update_descendants_checkbox_state(tree_store, other_iter, parent_person_handle) parent_iter = tree_store.iter_parent(parent_iter) # Update ancestors for all appearances for iter_obj in person_iters: update_ancestors_descendants(iter_obj) # Update person checkbox states for ancestors (since descendants changed) for iter_obj in person_iters: update_ancestors_descendants(iter_obj) # Also update person checkbox states parent_iter = tree_store.iter_parent(iter_obj) while parent_iter is not None: parent_person_handle = tree_store.get_value(parent_iter, 3) if parent_person_handle and parent_person_handle != '': self._update_person_checkbox_state(tree_store, parent_iter, parent_person_handle) parent_person_iters = self._filter_widgets.get('person_to_iters', {}).get(parent_person_handle, []) for other_iter in parent_person_iters: if other_iter != parent_iter: self._update_person_checkbox_state(tree_store, other_iter, parent_person_handle) parent_iter = tree_store.iter_parent(parent_iter) # Update family checkbox states families_to_update = set() for iter_obj in person_iters: fam_handle = tree_store.get_value(iter_obj, 4) if fam_handle: families_to_update.add(fam_handle) for fam_handle in families_to_update: family_iter = self._filter_widgets.get('family_to_iter', {}).get(fam_handle) if family_iter: updating_flag = self._filter_widgets.get('family_updating_flags', {}).get(fam_handle) if updating_flag is None: updating_flag = [False] if 'family_updating_flags' not in self._filter_widgets: self._filter_widgets['family_updating_flags'] = {} self._filter_widgets['family_updating_flags'][fam_handle] = updating_flag self._update_family_checkbox_state(tree_store, family_iter, fam_handle, updating_flag) def _on_family_checkbox_toggled(self, tree_store: Gtk.TreeStore, family_iter: Gtk.TreeIter, family_handle: str) -> None: """ Handle family checkbox toggle - update all child person checkboxes. Args: tree_store: The TreeStore containing the data. family_iter: The TreeIter for the family row. family_handle: The handle of the family. """ updating_flag = self._filter_widgets.get('family_updating_flags', {}).get(family_handle) if updating_flag and updating_flag[0]: return # Get current state is_inconsistent = tree_store.get_value(family_iter, 1) # Column 1: inconsistent current_active = tree_store.get_value(family_iter, 0) # Column 0: active # If inconsistent, make it consistent by checking all if is_inconsistent: new_active = True else: new_active = not current_active # Set updating flag if updating_flag is None: updating_flag = [False] if 'family_updating_flags' not in self._filter_widgets: self._filter_widgets['family_updating_flags'] = {} self._filter_widgets['family_updating_flags'][family_handle] = updating_flag updating_flag[0] = True # Recursively update all descendant person rows in this family def update_descendants_recursive(parent_iter: Optional[Gtk.TreeIter]) -> None: """Recursively update all descendants.""" child_iter = tree_store.iter_children(parent_iter) if parent_iter else tree_store.iter_children(family_iter) while child_iter is not None: person_handle = tree_store.get_value(child_iter, 3) if person_handle and person_handle != '': tree_store.set_value(child_iter, 0, new_active) tree_store.set_value(child_iter, 1, False) # Clear inconsistent state # Also update this person in all other families where they appear person_iters = self._filter_widgets.get('person_to_iters', {}).get(person_handle, []) for other_iter in person_iters: if other_iter != child_iter: tree_store.set_value(other_iter, 0, new_active) tree_store.set_value(other_iter, 1, False) # Clear inconsistent state # Update the family checkbox state for the other family other_family_handle = tree_store.get_value(other_iter, 4) if other_family_handle and other_family_handle != family_handle: other_family_iter = self._filter_widgets.get('family_to_iter', {}).get(other_family_handle) if other_family_iter: other_updating_flag = self._filter_widgets.get('family_updating_flags', {}).get(other_family_handle) self._update_family_checkbox_state(tree_store, other_family_iter, other_family_handle, other_updating_flag) # Recursively update descendants if tree_store.iter_has_child(child_iter): update_descendants_recursive(child_iter) child_iter = tree_store.iter_next(child_iter) update_descendants_recursive(None) # Update person and descendants checkbox states for all persons in this family (to reflect descendant states) def update_person_states_recursive(parent_iter: Optional[Gtk.TreeIter]) -> None: """Recursively update person and descendants checkbox states.""" child_iter = tree_store.iter_children(parent_iter) if parent_iter else tree_store.iter_children(family_iter) while child_iter is not None: person_handle = tree_store.get_value(child_iter, 3) if person_handle and person_handle != '': # Update this person's checkbox state self._update_person_checkbox_state(tree_store, child_iter, person_handle) # Update this person's descendants checkbox state self._update_descendants_checkbox_state(tree_store, child_iter, person_handle) # Also update in all other appearances person_iters = self._filter_widgets.get('person_to_iters', {}).get(person_handle, []) for other_iter in person_iters: if other_iter != child_iter: self._update_person_checkbox_state(tree_store, other_iter, person_handle) self._update_descendants_checkbox_state(tree_store, other_iter, person_handle) # Recursively update descendants if tree_store.iter_has_child(child_iter): update_person_states_recursive(child_iter) child_iter = tree_store.iter_next(child_iter) update_person_states_recursive(None) # Update this family's checkbox state self._update_family_checkbox_state(tree_store, family_iter, family_handle, updating_flag) updating_flag[0] = False def _on_person_checkbox_toggled(self, tree_store: Gtk.TreeStore, person_iter: Gtk.TreeIter, person_handle: str, family_handle: str) -> None: """ Handle person checkbox toggle - update this person in all families and update family states. Args: tree_store: The TreeStore containing the data. person_iter: The TreeIter for the person row. family_handle: The handle of the family this person belongs to. person_handle: The handle of the person. """ # Get current state is_inconsistent = tree_store.get_value(person_iter, 1) # Column 1: inconsistent current_active = tree_store.get_value(person_iter, 0) # Column 0: active # If inconsistent, make it consistent by checking all if is_inconsistent: new_active = True else: new_active = not current_active # Update this person in ALL families where they appear (ONLY the person, not descendants) person_iters = self._filter_widgets.get('person_to_iters', {}).get(person_handle, []) for iter_obj in person_iters: tree_store.set_value(iter_obj, 0, new_active) tree_store.set_value(iter_obj, 1, False) # Clear person inconsistent state # Update descendants checkbox states for this person (since person checkbox changed) for iter_obj in person_iters: self._update_descendants_checkbox_state(tree_store, iter_obj, person_handle) # Update person and descendants checkbox states for all ancestors (parents up the tree) def update_ancestors(iter_obj: Gtk.TreeIter) -> None: """Update checkbox states for all ancestors.""" parent_iter = tree_store.iter_parent(iter_obj) while parent_iter is not None: parent_person_handle = tree_store.get_value(parent_iter, 3) if parent_person_handle and parent_person_handle != '': # Update this ancestor's person checkbox state self._update_person_checkbox_state(tree_store, parent_iter, parent_person_handle) # Update this ancestor's descendants checkbox state self._update_descendants_checkbox_state(tree_store, parent_iter, parent_person_handle) # Also update in all other appearances parent_person_iters = self._filter_widgets.get('person_to_iters', {}).get(parent_person_handle, []) for other_iter in parent_person_iters: if other_iter != parent_iter: self._update_person_checkbox_state(tree_store, other_iter, parent_person_handle) self._update_descendants_checkbox_state(tree_store, other_iter, parent_person_handle) parent_iter = tree_store.iter_parent(parent_iter) # Update ancestors for all appearances for iter_obj in person_iters: update_ancestors(iter_obj) # Update all family checkbox states for families this person belongs to families_to_update = set() for iter_obj in person_iters: fam_handle = tree_store.get_value(iter_obj, 4) if fam_handle: families_to_update.add(fam_handle) # Update each family's checkbox state for fam_handle in families_to_update: family_iter = self._filter_widgets.get('family_to_iter', {}).get(fam_handle) if family_iter: updating_flag = self._filter_widgets.get('family_updating_flags', {}).get(fam_handle) if updating_flag is None: updating_flag = [False] if 'family_updating_flags' not in self._filter_widgets: self._filter_widgets['family_updating_flags'] = {} self._filter_widgets['family_updating_flags'][fam_handle] = updating_flag self._update_family_checkbox_state(tree_store, family_iter, fam_handle, updating_flag) def _update_event_type_widgets(self) -> None: """ Update event type filter widgets to reflect current filter state. """ if 'event_type_checkboxes' not in self._filter_widgets: return # Update event type checkboxes for event_type, checkbox in self._filter_widgets['event_type_checkboxes'].items(): if not self.active_event_types: checkbox.set_active(True) # All selected when filter is off else: checkbox.set_active(event_type in self.active_event_types) # Update category checkboxes based on their children's states if 'category_checkboxes' in self._filter_widgets and 'category_event_types' in self._filter_widgets: category_updating_flags = self._filter_widgets.get('category_updating_flags', {}) for category, category_checkbox in self._filter_widgets['category_checkboxes'].items(): # Get child checkboxes for this category event_types_in_category = self._filter_widgets['category_event_types'].get(category, []) child_checkboxes = [ self._filter_widgets['event_type_checkboxes'][et] for et in event_types_in_category if et in self._filter_widgets['event_type_checkboxes'] ] # Get the updating flag for this category to prevent recursion updating_flag = category_updating_flags.get(category) # Update category checkbox state based on children self._update_group_checkbox_state(category_checkbox, child_checkboxes, updating_flag) def _is_root_family(self, family: 'Family') -> bool: """ Check if a family is a root family (neither parent has a parent family). Args: family: The family to check. Returns: bool: True if this is a root family, False otherwise. """ father_handle = family.get_father_handle() mother_handle = family.get_mother_handle() # Check father if father_handle: try: father = self.dbstate.db.get_person_from_handle(father_handle) if father and father.get_parent_family_handle_list(): return False # Father has a parent family except (AttributeError, KeyError): pass # Check mother if mother_handle: try: mother = self.dbstate.db.get_person_from_handle(mother_handle) if mother and mother.get_parent_family_handle_list(): return False # Mother has a parent family except (AttributeError, KeyError): pass # Neither parent has a parent family, or parents don't exist return True def _add_person_with_descendants(self, tree_store: Gtk.TreeStore, parent_iter: Optional[Gtk.TreeIter], person_handle: str, role_label: str, family_handle: str) -> Optional[Gtk.TreeIter]: """ Recursively add a person and all their descendants to the TreeStore. Args: tree_store: The TreeStore to add to. parent_iter: The parent TreeIter (None for top-level). person_handle: The handle of the person to add. role_label: The role label (e.g., 'Father', 'Mother', 'Child'). family_handle: The handle of the family this person belongs to in this context. Returns: Optional[Gtk.TreeIter]: The TreeIter for the added person row, or None if person not found. """ if not person_handle: return None try: person = self.dbstate.db.get_person_from_handle(person_handle) if not person: return None person_name = self._get_person_display_name(person_handle) if not person_name: return None # Determine if person should be checked is_checked = True if not self.person_filter else person_handle in self.person_filter # Add person as a row person_iter = tree_store.append(parent_iter, [ is_checked, # person_active (column 0) False, # person_inconsistent (column 1) - will be updated if person has descendants f"{role_label}: {person_name}", # name (column 2) person_handle, # person_handle (column 3) family_handle, # family_handle (column 4) role_label, # role (column 5) False, # descendants_active (column 6) - will be updated after adding descendants False # descendants_inconsistent (column 7) - will be updated after adding descendants ]) # Track this person's appearance in the tree if person_handle not in self._filter_widgets['person_to_iters']: self._filter_widgets['person_to_iters'][person_handle] = [] self._filter_widgets['person_to_iters'][person_handle].append(person_iter) # Find all families where this person is a parent parent_family_handles = person.get_family_handle_list() # Add children from each family where this person is a parent for parent_family_handle in parent_family_handles: try: parent_family = self.dbstate.db.get_family_from_handle(parent_family_handle) if not parent_family: continue # Get the spouse (other parent) spouse_handle = None if person_handle == parent_family.get_father_handle(): spouse_handle = parent_family.get_mother_handle() elif person_handle == parent_family.get_mother_handle(): spouse_handle = parent_family.get_father_handle() # Add spouse if exists if spouse_handle: spouse_name = self._get_person_display_name(spouse_handle) if spouse_name: spouse_role = _('Spouse') # Add spouse as a row under this person spouse_checked = True if not self.person_filter else spouse_handle in self.person_filter spouse_iter = tree_store.append(person_iter, [ spouse_checked, # person_active (column 0) False, # person_inconsistent (column 1) f"{spouse_role}: {spouse_name}", # name (column 2) spouse_handle, # person_handle (column 3) parent_family_handle, # family_handle (column 4) spouse_role, # role (column 5) False, # descendants_active (column 6) False # descendants_inconsistent (column 7) ]) # Track spouse appearance if spouse_handle not in self._filter_widgets['person_to_iters']: self._filter_widgets['person_to_iters'][spouse_handle] = [] self._filter_widgets['person_to_iters'][spouse_handle].append(spouse_iter) # Recursively add children from this family for child_ref in parent_family.get_child_ref_list(): child_handle = child_ref.ref # Recursively add child and their descendants self._add_person_with_descendants( tree_store, person_iter, child_handle, _('Child'), parent_family_handle ) except (AttributeError, KeyError): continue # Update person checkbox state based on descendants self._update_person_checkbox_state(tree_store, person_iter, person_handle) # Update descendants checkbox state based on descendant person checkboxes self._update_descendants_checkbox_state(tree_store, person_iter, person_handle) return person_iter except (AttributeError, KeyError): return None def _update_person_filter_widgets(self) -> None: """ Update person filter widgets to reflect current filter state. Populates the TreeStore with families and their members. """ tree_store = self._filter_widgets.get('person_tree_store') if not tree_store: return if not self.dbstate.is_open(): return # Clear existing data tree_store.clear() self._filter_widgets['person_to_iters'] = {} self._filter_widgets['family_to_iter'] = {} self._filter_widgets['family_updating_flags'] = {} try: # Find all persons without parents (root persons) root_persons = set() processed_persons = set() # First, collect all persons and identify root persons for person_handle in self.dbstate.db.get_person_handles(): try: person = self.dbstate.db.get_person_from_handle(person_handle) if person: # Check if person has no parent families parent_families = person.get_parent_family_handle_list() if not parent_families: root_persons.add(person_handle) processed_persons.add(person_handle) except (AttributeError, KeyError): continue # Add root persons as top-level rows with all their descendants for person_handle in root_persons: try: person = self.dbstate.db.get_person_from_handle(person_handle) if not person: continue # Get the first family where this person is a parent (for family_handle context) # If person has no families as parent, use None parent_families = person.get_family_handle_list() family_handle = parent_families[0] if parent_families else None # Determine role label - if no family, just use person name role_label = _('Person') # Add person with all descendants as top-level row self._add_person_with_descendants( tree_store, None, person_handle, role_label, family_handle or '' ) except (AttributeError, KeyError): continue # Final pass: Update all person and descendants checkbox states (to handle ancestors) def update_all_person_states(parent_iter: Optional[Gtk.TreeIter] = None) -> None: """Recursively update all person and descendants checkbox states.""" child_iter = tree_store.iter_children(parent_iter) if parent_iter else tree_store.get_iter_first() while child_iter is not None: person_handle = tree_store.get_value(child_iter, 3) if person_handle and person_handle != '': # Update this person's checkbox state self._update_person_checkbox_state(tree_store, child_iter, person_handle) # Update this person's descendants checkbox state self._update_descendants_checkbox_state(tree_store, child_iter, person_handle) # Also update in all other appearances person_iters = self._filter_widgets.get('person_to_iters', {}).get(person_handle, []) for other_iter in person_iters: if other_iter != child_iter: self._update_person_checkbox_state(tree_store, other_iter, person_handle) self._update_descendants_checkbox_state(tree_store, other_iter, person_handle) # Recursively process children if tree_store.iter_has_child(child_iter): update_all_person_states(child_iter) child_iter = tree_store.iter_next(child_iter) update_all_person_states() except (AttributeError, KeyError) as e: logger.warning(f"Error updating person filter widgets in filter dialog: {e}", exc_info=True) def _update_filter_dialog_state(self) -> None: """ Update the filter dialog widgets to reflect current filter state. """ if not hasattr(self, '_filter_widgets'): return # Update each filter type's widgets self._update_event_type_widgets() self._update_person_filter_widgets() def _on_filter_dialog_response(self, dialog: Gtk.Dialog, response_id: int) -> None: """ Handle filter dialog response. Args: dialog: The filter dialog. response_id: The response ID (APPLY, CLOSE, REJECT). """ if response_id == Gtk.ResponseType.REJECT: # Clear all filters self.filter_enabled = False self.active_event_types = set() self._update_normalized_active_event_types() # Invalidate cache self.person_filter = None self.category_filter = None self.apply_filters() self._update_filter_button_state() def _apply_filter_dialog_settings(self) -> None: """ Apply filter settings from the dialog to the filter state. """ # Update event type filter if 'event_type_checkboxes' in self._filter_widgets: active_types = set() total_types = len(self._filter_widgets['event_type_checkboxes']) for event_type, checkbox in self._filter_widgets['event_type_checkboxes'].items(): if checkbox.get_active(): active_types.add(event_type) # Logic: # - If all types are selected: active_event_types = empty set (meaning "all enabled", no filtering) # - If some types are unselected: active_event_types = set of selected types (filter to show only these) # - If no types are selected: active_event_types = empty set, but filter_enabled will be False # (unless other filters are active), so no events will show (correct behavior) if len(active_types) == total_types: # All types are selected - no filtering needed self.active_event_types = set() else: # Some or no types are selected - filter to only show selected types # (If active_types is empty, this means no events will match, which is correct) self.active_event_types = active_types self._update_normalized_active_event_types() # Update cache # Update category filter if 'category_checkboxes' in self._filter_widgets: active_categories = set() for category, checkbox in self._filter_widgets['category_checkboxes'].items(): if checkbox.get_active(): active_categories.add(category) all_categories = set(self._filter_widgets['category_checkboxes']) self.category_filter = active_categories if active_categories != all_categories else None # Update person filter from TreeStore tree_store = self._filter_widgets.get('person_tree_store') if tree_store: active_persons = set() all_persons = set() # Track which persons are included via descendants checkboxes persons_included_via_descendants = set() # Iterate through all rows in TreeStore def iterate_tree(parent_iter: Optional[Gtk.TreeIter] = None, parent_descendants_checked: bool = False) -> None: """ Recursively iterate through TreeStore to collect person states. Args: parent_iter: The parent iter (None for root). parent_descendants_checked: Whether parent's descendants checkbox is checked. """ child_iter = tree_store.iter_children(parent_iter) if parent_iter else tree_store.get_iter_first() while child_iter is not None: person_handle = tree_store.get_value(child_iter, 3) # Column 3: person_handle person_active = tree_store.get_value(child_iter, 0) # Column 0: person_active descendants_active = tree_store.get_value(child_iter, 6) # Column 6: descendants_active descendants_inconsistent = tree_store.get_value(child_iter, 7) # Column 7: descendants_inconsistent # If this is a person row (not a family row) if person_handle and person_handle != '': all_persons.add(person_handle) # Person is included if: # 1. Person checkbox is checked, OR # 2. Parent's descendants checkbox is checked (includes this person), OR # 3. Any ancestor's descendants checkbox is checked is_included = person_active or parent_descendants_checked if is_included: active_persons.add(person_handle) # If descendants checkbox is checked or inconsistent, mark descendants as included if descendants_active or descendants_inconsistent: persons_included_via_descendants.add(person_handle) # Determine if descendants checkbox is checked for this row descendants_checked = descendants_active or descendants_inconsistent # If this person is included via parent's descendants checkbox, also check descendants if parent_descendants_checked and person_handle and person_handle != '': descendants_checked = True # Recursively process children if tree_store.iter_has_child(child_iter): iterate_tree(child_iter, descendants_checked) child_iter = tree_store.iter_next(child_iter) iterate_tree() # Also include all descendants of persons with checked descendants checkboxes def include_descendants(person_handle: str, visited: Set[str]) -> None: """Recursively include all descendants of a person.""" if person_handle in visited: return visited.add(person_handle) # Find all appearances of this person in the tree person_iters = self._filter_widgets.get('person_to_iters', {}).get(person_handle, []) for iter_obj in person_iters: child_iter = tree_store.iter_children(iter_obj) while child_iter is not None: child_person_handle = tree_store.get_value(child_iter, 3) if child_person_handle and child_person_handle != '': active_persons.add(child_person_handle) # Recursively include their descendants include_descendants(child_person_handle, visited) child_iter = tree_store.iter_next(child_iter) visited = set() for person_handle in persons_included_via_descendants: include_descendants(person_handle, visited) # Set person_filter: None if all persons are selected, otherwise set of selected persons self.person_filter = active_persons if active_persons != all_persons else None # Enable filter if any filter is active self.filter_enabled = ( self.active_event_types or self.person_filter is not None or self.category_filter is not None ) # Apply filters self.apply_filters() self._update_filter_button_state() def _update_filter_button_state(self) -> None: """ Update the filter button visual state to indicate if filters are active. """ if self.filter_button: if self.filter_enabled: # Highlight button when filters are active self.filter_button.set_tooltip_text(_("Filter Events (Active)")) else: self.filter_button.set_tooltip_text(_("Filter Events")) def _on_select_all_event_types(self, button: Gtk.Button) -> None: """ Select all event type checkboxes. Args: button: The button that was clicked. """ if 'event_type_checkboxes' in self._filter_widgets: for checkbox in self._filter_widgets['event_type_checkboxes'].values(): checkbox.set_active(True) def _on_deselect_all_event_types(self, button: Gtk.Button) -> None: """ Deselect all event type checkboxes. Args: button: The button that was clicked. """ if 'event_type_checkboxes' in self._filter_widgets: for checkbox in self._filter_widgets['event_type_checkboxes'].values(): checkbox.set_active(False) def build_tree(self) -> None: """ Rebuilds the current display. Called when the view becomes visible. """ # Collect all events if not already done if not self.all_events and self.dbstate.is_open(): self.collect_events() # Highlight active event if one is selected active_handle = self.get_active() if active_handle: self.goto_handle(active_handle) else: self._queue_draw() def goto_handle(self, handle: str) -> None: """ Called when the active event changes. Args: handle: The handle of the event to highlight/select. """ if handle == self.active_event_handle: return self.active_event_handle = handle # Ensure events are collected if not self.all_events: self.collect_events() else: # Just refresh display to highlight the selected event self._queue_draw() # Scroll to the selected event if possible self._scroll_to_event(handle) def _scroll_to_event(self, event_handle: str) -> None: """ Scroll the timeline to show the selected event. Args: event_handle: The handle of the event to scroll to. """ if not self.scrolledwindow or not event_handle: return # Find the event in our event list event_index = None for i, timeline_event in enumerate(self.events): if timeline_event.event.get_handle() == event_handle: event_index = i break if event_index is None: return # Calculate the y position of the event if event_index < len(self.events): event_y = TIMELINE_MARGIN_TOP + (event_index * EVENT_SPACING) # Get the adjustment and scroll to the event vadj = self.scrolledwindow.get_vadjustment() if vadj: # Center the event in the viewport viewport_height = self.scrolledwindow.get_allocation().height scroll_to = max(0, event_y - (viewport_height / 2)) vadj.set_value(scroll_to) 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 self._event_to_person_cache.clear() # Clear event-to-person cache # Clear event type normalization cache to prevent stale data self._event_type_normalization_cache.clear() 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(EMPTY_TIMELINE_HEIGHT * self.zoom_level) if self.drawing_area: self.drawing_area.set_size_request(DEFAULT_DRAWING_AREA_WIDTH, self.timeline_height) def _create_timeline_event(self, event: 'Event', person_obj: Optional['Person'] = None, y_pos: float = 0.0) -> Optional[TimelineEvent]: """ Create a TimelineEvent from an event object. Args: event: The event object. person_obj: Optional person object associated with this event. y_pos: Initial Y position (default 0.0). Returns: Optional[TimelineEvent]: The created TimelineEvent, or None if invalid. """ try: if not event: return None date_obj = event.get_date_object() if not date_obj: return None return TimelineEvent( date_sort=date_obj.get_sort_value(), date_obj=date_obj, event=event, person=person_obj, event_type=event.get_type(), y_pos=y_pos ) except (AttributeError, KeyError, ValueError) as e: logger.debug(f"Skipping invalid event: {e}") return None def _copy_timeline_event_with_y_pos(self, event_data: TimelineEvent, y_pos: float) -> TimelineEvent: """ Create a copy of a TimelineEvent with a new Y position. Args: event_data: The original TimelineEvent. y_pos: The new Y position. Returns: TimelineEvent: A new TimelineEvent with the updated Y position. """ return 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 ) def _process_event(self, event: 'Event', person_obj: Optional['Person'] = None) -> Optional[TimelineEvent]: """ Process a single event and create a TimelineEvent. Args: event: The event object to process. person_obj: Optional person object associated with this event. Returns: Optional[TimelineEvent]: The created TimelineEvent, or None if invalid. """ return self._create_timeline_event(event, person_obj) def _build_event_to_person_index(self) -> None: """ Build a reverse index mapping event handles to person objects. This is much more efficient than searching through all persons for each event. """ if not self.dbstate.is_open(): return self._event_to_person_cache.clear() try: # Iterate through all persons once and build the index for person_handle in self.dbstate.db.get_person_handles(): try: person = self.dbstate.db.get_person_from_handle(person_handle) if not person: continue # Check primary events first (these take precedence) self._index_event_refs(person.get_primary_event_ref_list(), person) # Check all events (for events not in primary list) self._index_event_refs(person.get_event_ref_list(), person) except (AttributeError, KeyError): continue except (AttributeError, KeyError) as e: logger.warning(f"Error building event-to-person index from database: {e}", exc_info=True) def _index_event_refs(self, event_ref_list: List[Any], person: 'Person') -> None: """ Index event references from a list, mapping event handles to person. Args: event_ref_list: List of event references from a person object. person: The person object to associate with the events. """ for event_ref in event_ref_list: event_handle = event_ref.ref # Only store if not already mapped (to preserve priority of primary events) if event_handle not in self._event_to_person_cache: self._event_to_person_cache[event_handle] = person def _find_person_for_event(self, event: 'Event') -> Optional['Person']: """ Find a primary person associated with an event using the cached index. Args: event: The event object to find a person for. Returns: Optional person object, or None if not found or if event is family-only. Note: This method now uses the cached index built by _build_event_to_person_index(). If the cache is empty, returns None. For best performance, ensure _build_event_to_person_index() is called before using this method. """ if not event: return None event_handle = event.get_handle() return self._event_to_person_cache.get(event_handle) def _get_person_display_name(self, person_handle: Optional[str]) -> Optional[str]: """ Get display name for a person by handle. Args: person_handle: The person handle. Returns: Optional[str]: Display name or None if person not found or handle is None. """ if not person_handle: return None try: person = self.dbstate.db.get_person_from_handle(person_handle) if person: return name_displayer.display(person) except (AttributeError, KeyError): pass return None def _get_family_display_name(self, family: 'Family') -> str: """ Get a display name for a family showing parent names. Args: family: The family object. Returns: str: Display name for the family. """ if not family: return _("Unknown Family") father_name = self._get_person_display_name(family.get_father_handle()) mother_name = self._get_person_display_name(family.get_mother_handle()) if father_name and mother_name: return f"{father_name} & {mother_name}" elif father_name: return f"{father_name} & {_('Unknown')}" elif mother_name: return f"{_('Unknown')} & {mother_name}" else: return _("Unknown Family") def collect_events(self) -> None: """Collect all events from the database.""" self.all_events = [] self.events = [] self._event_to_person_cache.clear() if not self.dbstate.is_open(): return try: # Build event-to-person reverse index for efficient lookup self._build_event_to_person_index() # Iterate through all events in the database for event in self.dbstate.db.iter_events(): # Get associated person from cache event_handle = event.get_handle() person_obj = self._event_to_person_cache.get(event_handle) # Process the event timeline_event = self._process_event(event, person_obj) if timeline_event: self.all_events.append(timeline_event) except (AttributeError, KeyError) as e: logger.warning(f"Error collecting events from database: {e}", exc_info=True) return # Sort events by date self.all_events.sort(key=lambda x: x.date_sort) # Apply filters self.events = self._apply_filters(self.all_events) # Invalidate cache when events change self._invalidate_cache() # Calculate timeline height self._calculate_timeline_height() # Update filter button state self._update_filter_button_state() def _update_normalized_active_event_types(self) -> None: """ Update the pre-computed normalized active event types set. Call this whenever active_event_types changes. """ if not self.active_event_types: self._normalized_active_event_types = None else: # Normalize all event types and create a set # Ensure we have at least one normalized type normalized_set = {self._normalize_event_type(et) for et in self.active_event_types} if normalized_set: self._normalized_active_event_types = normalized_set else: # If normalization failed for all types, something is wrong # Set to None to indicate no filtering (show all events) self._normalized_active_event_types = None def _apply_event_type_filter(self, event: TimelineEvent) -> bool: """ Check if event passes event type filter. Args: event: The event to check. Returns: bool: True if event passes filter, False otherwise. """ # If no event type filter is active (empty set means "all enabled"), pass all events if not self.active_event_types: return True # Use pre-computed normalized set if available, otherwise compute it if self._normalized_active_event_types is None: self._update_normalized_active_event_types() # If normalized set is still None or empty, something went wrong - pass all events if self._normalized_active_event_types is None or not self._normalized_active_event_types: return True # Normalize event.event_type and compare with normalized active_event_types event_type_normalized = self._normalize_event_type(event.event_type) return event_type_normalized in self._normalized_active_event_types def _apply_category_filter(self, event: TimelineEvent) -> bool: """ Check if event passes category filter. Args: event: The event to check. Returns: bool: True if event passes filter, False otherwise. """ if not self.category_filter: return True category = self._get_event_category(event.event_type) return category in self.category_filter def _apply_filters(self, events: List[TimelineEvent]) -> List[TimelineEvent]: """ Apply all active filters to events. Args: events: List of TimelineEvent objects to filter. Returns: List[TimelineEvent]: Filtered list of events. """ if not self.filter_enabled: return events filtered = [] for event in events: # Check event type filter if not self._apply_event_type_filter(event): continue # Check person filter person_handle = event.person.get_handle() if event.person else None if not self._is_person_included(person_handle): continue # Check category filter if not self._apply_category_filter(event): continue filtered.append(event) return filtered def _normalize_event_type(self, event_type: EventType) -> int: """ Normalize EventType to integer for comparison. Uses caching to avoid repeated conversions. Args: event_type: The event type (may be EventType object or integer). Returns: int: The integer value of the event type. """ # Normalize to integer first (fast operation) try: if isinstance(event_type, int): normalized = event_type elif hasattr(event_type, 'value'): normalized = event_type.value else: normalized = int(event_type) except (TypeError, ValueError, AttributeError): normalized = 0 # Default to 0 if conversion fails # Use normalized value as cache key (ensures same values share same key) # Check cache - if we've seen this normalized value before, we already know it # This cache is mainly useful for avoiding repeated int() calls on same objects if normalized not in self._event_type_normalization_cache: self._event_type_normalization_cache[normalized] = normalized return normalized def _is_event_type_enabled(self, event_type: EventType) -> bool: """ Check if an event type is enabled in the filter. Args: event_type: The event type to check. Returns: bool: True if event type is enabled (or no filter active), False otherwise. """ if not self.active_event_types: return True # Use pre-computed normalized set if available, otherwise compute it if self._normalized_active_event_types is None: self._update_normalized_active_event_types() # Normalize event type and compare with normalized active types normalized_type = self._normalize_event_type(event_type) return normalized_type in self._normalized_active_event_types def _is_person_included(self, person_handle: Optional[str]) -> bool: """ Check if a person is included in the filter. Args: person_handle: The person handle to check (None for family events). Returns: bool: True if person is included (or no filter active), False otherwise. """ if self.person_filter is None: return True # Family events (person_handle is None) are included if person_filter is not None # but we might want to exclude them - for now, include them if person_handle is None: return True return person_handle in self.person_filter def _get_events_for_person(self, person_handle: str, events_list: List['TimelineEvent']) -> List['TimelineEvent']: """ Filter events list to only include events for a specific person. Args: person_handle: The handle of the person to filter events for. events_list: List of timeline events to filter. Returns: List[TimelineEvent]: Filtered list of events for the specified person. """ return [ event for event in events_list if self._person_matches_handle(event.person, person_handle) ] def _get_event_type_display_name(self, event_type: EventType) -> str: """ Get human-readable display name for an event type. Args: event_type: The event type (integer or EventType object). Returns: str: Human-readable name for the event type. """ # Normalize to integer first event_type_value = self._normalize_event_type(event_type) # Map EventType integer values to their human-readable names event_type_names = { EventType.BIRTH: _("Birth"), EventType.DEATH: _("Death"), EventType.BURIAL: _("Burial"), EventType.CREMATION: _("Cremation"), EventType.ADOPT: _("Adoption"), EventType.MARRIAGE: _("Marriage"), EventType.DIVORCE: _("Divorce"), EventType.ENGAGEMENT: _("Engagement"), EventType.MARR_SETTL: _("Marriage Settlement"), EventType.MARR_LIC: _("Marriage License"), EventType.MARR_CONTR: _("Marriage Contract"), EventType.MARR_BANNS: _("Marriage Banns"), EventType.DIV_FILING: _("Divorce Filing"), EventType.ANNULMENT: _("Annulment"), EventType.MARR_ALT: _("Marriage (Alternative)"), EventType.BAPTISM: _("Baptism"), EventType.ADULT_CHRISTEN: _("Adult Christening"), EventType.CONFIRMATION: _("Confirmation"), EventType.CHRISTEN: _("Christening"), EventType.FIRST_COMMUN: _("First Communion"), EventType.BLESS: _("Blessing"), EventType.BAR_MITZVAH: _("Bar Mitzvah"), EventType.BAS_MITZVAH: _("Bat Mitzvah"), EventType.RELIGION: _("Religion"), EventType.ORDINATION: _("Ordination"), EventType.OCCUPATION: _("Occupation"), EventType.RETIREMENT: _("Retirement"), EventType.ELECTED: _("Elected"), EventType.MILITARY_SERV: _("Military Service"), EventType.EDUCATION: _("Education"), EventType.GRADUATION: _("Graduation"), EventType.DEGREE: _("Degree"), EventType.EMIGRATION: _("Emigration"), EventType.IMMIGRATION: _("Immigration"), EventType.NATURALIZATION: _("Naturalization"), EventType.PROBATE: _("Probate"), EventType.WILL: _("Will"), EventType.RESIDENCE: _("Residence"), EventType.CENSUS: _("Census"), EventType.PROPERTY: _("Property"), EventType.CAUSE_DEATH: _("Cause of Death"), EventType.MED_INFO: _("Medical Information"), EventType.NOB_TITLE: _("Nobility Title"), EventType.NUM_MARRIAGES: _("Number of Marriages"), EventType.UNKNOWN: _("Unknown"), EventType.CUSTOM: _("Custom"), } return event_type_names.get(event_type_value, _("Unknown Event")) def _get_event_category(self, event_type: EventType) -> str: """ Get the category for an event type. Args: event_type: The event type (may be EventType object or integer). Returns: str: The category name, or "Other Events" if not found. """ # Normalize EventType to integer for dictionary lookup event_type_value = self._normalize_event_type(event_type) return EVENT_CATEGORIES.get(event_type_value, "Other Events") def apply_filters(self) -> None: """ Apply current filters and update the view. Public method to trigger filter application. """ if self.all_events: self.events = self._apply_filters(self.all_events) self._invalidate_cache() self._calculate_timeline_height() self._queue_draw() 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 _person_matches_handle(self, person: Optional['Person'], handle: str) -> bool: """ Check if a person's handle matches the given handle. Args: person: The person object (may be None). handle: The handle to match against. Returns: bool: True if person exists and handle matches, False otherwise. """ return person is not None and person.get_handle() == handle def _get_person_color(self, person_handle: str) -> Tuple[float, float, float, float]: """ Get or generate a distinct color for a person handle. Uses HSL color space to generate evenly distributed colors. Args: person_handle: The handle of the person. Returns: Tuple[float, float, float, float]: RGBA color tuple (values 0-1). """ # If color already assigned, return it if person_handle in self.person_colors: return self.person_colors[person_handle] # Generate color using HSV: vary hue, keep saturation and lightness constant # Use hash of handle for consistent color even when selection order changes handle_hash = hash(person_handle) hue = (abs(handle_hash) % HSV_HUE_MAX_DEGREES) / float(HSV_HUE_MAX_DEGREES) # 0-1 range # Convert HSV to RGB (HSV is Hue, Saturation, Value) r, g, b = colorsys.hsv_to_rgb(hue, PERSON_COLOR_SATURATION, PERSON_COLOR_LIGHTNESS) color = (r, g, b, PERSON_COLOR_ALPHA) # Store the color self.person_colors[person_handle] = color return color def _get_person_vertical_line_x(self, person_index: int) -> float: """ Calculate the X position for a person's vertical connection line. Args: person_index: The index of the person in the sorted selection (0-based). Returns: float: The X coordinate for the vertical line. """ return CONNECTION_VERTICAL_LINE_X + (person_index * CONNECTION_LINE_SPACING) def _calculate_max_connection_line_x(self) -> float: """ Calculate the maximum (rightmost) X position of connection lines. Returns: float: The X coordinate of the rightmost connection line, or 0 if no persons selected. """ if not self.selected_person_handles: return 0 # The rightmost line is at the highest index max_index = len(self.selected_person_handles) - 1 return CONNECTION_VERTICAL_LINE_X + (max_index * CONNECTION_LINE_SPACING) def _calculate_timeline_x(self) -> float: """ Calculate the dynamic timeline X position based on connection lines. The timeline should be positioned to the right of the rightmost connection line with appropriate spacing. If no connection lines exist, use the default margin. Returns: float: The X coordinate for the timeline. """ max_connection_x = self._calculate_max_connection_line_x() if max_connection_x == 0: # No connection lines, use default margin return TIMELINE_MARGIN_LEFT # Position timeline to the right of connection lines with spacing timeline_x = max_connection_x + TIMELINE_LEFT_SPACING # Ensure minimum timeline X to prevent overlap with year labels # Year labels need space on the left (around 130-150 pixels) min_timeline_x = TIMELINE_MARGIN_LEFT return max(timeline_x, min_timeline_x) 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: 'Event', person: Optional['Person'], 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(f"{FONT_FAMILY} {FONT_SIZE_NORMAL}") ) 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 (optimized: find max y_pos to place after) adjusted_y = event_data.y_pos for prev_event in adjusted_events: # Check if labels would overlap prev_y = prev_event.y_pos if adjusted_y < prev_y + MIN_LABEL_SPACING: # Adjust downward to be below the previous event adjusted_y = prev_y + 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 = self._copy_timeline_event_with_y_pos(event_data, adjusted_y) adjusted_events.append(adjusted_event) return adjusted_events 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(EMPTY_TIMELINE_HEIGHT * self.zoom_level) if self.drawing_area: self.drawing_area.set_size_request(DEFAULT_DRAWING_AREA_WIDTH, self.timeline_height) # Invalidate cache when zoom changes self._adjusted_events_cache = None self._cache_key = None def _update_zoom(self, level: float) -> None: """ Update zoom level and refresh the display. Args: level: The new zoom level to set. """ self.zoom_level = level self.update_zoom_display() self._recalculate_timeline_height() self._queue_draw() 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: new_level = min(self.zoom_level + self.zoom_step, self.max_zoom) self._update_zoom(new_level) 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: new_level = max(self.zoom_level - self.zoom_step, self.min_zoom) self._update_zoom(new_level) def on_zoom_reset(self, widget: Gtk.Widget) -> None: """ Reset zoom to 100%. Args: widget: The widget that triggered the reset (unused). """ self._update_zoom(1.0) 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'): zoom_percentage = int(self.zoom_level * ZOOM_PERCENTAGE_MULTIPLIER) self.zoom_label.set_text(f"{zoom_percentage}%") 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 == MOUSE_BUTTON_LEFT: # 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] event_handle = clicked_event_data.event.get_handle() # Select/highlight this event if self.active_event_handle == event_handle: # Deselect if clicking same event self.active_event_handle = None else: # Select this event self.active_event_handle = event_handle # Navigate to this event in the view self.uistate.set_active(event_handle, "Event") # Toggle person selection (always toggle, even if event is same) if clicked_event_data.person: person_handle = clicked_event_data.person.get_handle() if person_handle in self.selected_person_handles: # Remove from selection self.selected_person_handles.remove(person_handle) # Clean up color if no longer selected if person_handle in self.person_colors: del self.person_colors[person_handle] else: # Add to selection self.selected_person_handles.add(person_handle) # Color will be assigned when drawing self._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: # Try to remove the timeout source # Note: If the timeout already fired, it's automatically removed and this will log a warning # but we clear the ID anyway to prevent future attempts try: GLib.source_remove(self.tooltip_timeout_id) except (AttributeError, TypeError): # Source ID is invalid, ignore pass finally: # Always clear the ID to prevent trying to remove an invalid source later 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._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: # Try to remove the timeout source # Note: If the timeout already fired, it's automatically removed and this will log a warning # but we clear the ID anyway to prevent future attempts try: GLib.source_remove(self.tooltip_timeout_id) except (AttributeError, TypeError): # Source ID is invalid, ignore pass finally: # Always clear the ID to prevent trying to remove an invalid source later self.tooltip_timeout_id = None # Hide tooltip if self.tooltip_window: self.tooltip_window.destroy() self.tooltip_window = None self._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 = self._copy_timeline_event_with_y_pos(event_data, 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 # Calculate dynamic timeline X position based on connection lines timeline_x = self._calculate_timeline_x() 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 _get_place_name_for_event(self, event: 'Event') -> Optional[str]: """ Get the place name for an event, with error handling. Args: event: The event object. Returns: Optional[str]: Place name if available, None otherwise. """ place_handle = event.get_place_handle() if not place_handle: return None try: place = self.dbstate.db.get_place_from_handle(place_handle) if place: return place.get_title() except (AttributeError, KeyError) as e: logger.debug(f"Error accessing place information for event in tooltip: {e}") return None def _get_person_portrait_path(self, person: Optional['Person']) -> Optional[str]: """ Get the file path for a person's portrait from their media_list. Args: person: The person object. Returns: Optional[str]: File path to portrait if available, None otherwise. """ if not person or not self.dbstate.is_open(): return None try: media_list = person.get_media_list() if not media_list: return None # Get the first media reference (primary portrait) media_ref = media_list[0] media_handle = media_ref.get_reference_handle() # Get the media object from database media_obj = self.dbstate.db.get_media_from_handle(media_handle) if not media_obj: return None # Get the file path path = media_obj.get_path() if not path: return None # Resolve relative paths using Gramps media path resolution # Gramps stores paths relative to the database directory # We need to resolve them to absolute paths try: from gramps.gen.utils.file import media_path_full full_path = media_path_full(self.dbstate.db, path) return full_path except (ImportError, AttributeError): # Fallback: try to use path as-is or resolve relative to current directory import os if os.path.isabs(path): return path if os.path.exists(path) else None # Try relative to current directory if os.path.exists(path): return os.path.abspath(path) return None except (AttributeError, KeyError, IndexError) as e: logger.debug(f"Error accessing portrait for person: {e}") return None def _load_portrait_image(self, file_path: str, size: int) -> Optional[GdkPixbuf.Pixbuf]: """ Load and scale a portrait image. Args: file_path: Path to the image file. size: Target size (width and height) for the image. Returns: Optional[GdkPixbuf.Pixbuf]: Scaled pixbuf if successful, None otherwise. """ if not file_path: return None try: # Load the image pixbuf = GdkPixbuf.Pixbuf.new_from_file(file_path) # Scale to target size maintaining aspect ratio scaled_pixbuf = pixbuf.scale_simple(size, size, GdkPixbuf.InterpType.BILINEAR) return scaled_pixbuf except (GLib.GError, Exception) as e: logger.debug(f"Error loading portrait image from {file_path}: {e}") return None def _format_person_tooltip(self, person: '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 += "─" * TOOLTIP_SEPARATOR_LENGTH + "\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_name = self._get_place_name_for_event(event_data.event) if place_name: tooltip_text += f" šŸ“ {place_name}\n" return tooltip_text def _format_single_event_tooltip(self, event: '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_name = self._get_place_name_for_event(event) if place_name: tooltip_text += f"\nšŸ“ {place_name}" # 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(TOOLTIP_BORDER_WIDTH) 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. """ # Clear timeout_id since this callback firing means the timeout was removed # This prevents trying to remove it again later self.tooltip_timeout_id = None 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 = self._get_events_for_person(person_handle, self.events) 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") # Create horizontal box for portrait and text hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=PORTRAIT_MARGIN) hbox.set_margin_start(TOOLTIP_LABEL_MARGIN) hbox.set_margin_end(TOOLTIP_LABEL_MARGIN) hbox.set_margin_top(TOOLTIP_LABEL_MARGIN) hbox.set_margin_bottom(TOOLTIP_LABEL_MARGIN) # Add portrait if person has one if event_data.person: portrait_path = self._get_person_portrait_path(event_data.person) if portrait_path: pixbuf = self._load_portrait_image(portrait_path, PORTRAIT_SIZE_TOOLTIP) if pixbuf: portrait_image = Gtk.Image.new_from_pixbuf(pixbuf) hbox.pack_start(portrait_image, False, False, 0) # Add text label label = Gtk.Label() label.set_markup(tooltip_text) label.set_line_wrap(True) label.set_max_width_chars(TOOLTIP_MAX_WIDTH_CHARS) label.set_halign(Gtk.Align.START) label.set_valign(Gtk.Align.START) hbox.pack_start(label, True, True, 0) frame.add(hbox) tooltip_window.add(frame) tooltip_window.show_all() # Position tooltip (offset to avoid cursor) tooltip_window.move(int(x_root) + TOOLTIP_OFFSET, int(y_root) + TOOLTIP_OFFSET) 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, *BACKGROUND_GRADIENT_START) pattern.add_color_stop_rgb(1, *BACKGROUND_GRADIENT_END) 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(*COLOR_BLACK, TIMELINE_SHADOW_OPACITY) context.set_line_width(TIMELINE_LINE_WIDTH) context.move_to(timeline_x + TIMELINE_SHADOW_OFFSET, y_start + TIMELINE_SHADOW_OFFSET) context.line_to(timeline_x + TIMELINE_SHADOW_OFFSET, y_end + TIMELINE_SHADOW_OFFSET) context.stroke() # Draw main line with gradient pattern = cairo.LinearGradient(timeline_x, y_start, timeline_x, y_end) pattern.add_color_stop_rgb(0, *TIMELINE_AXIS_GRADIENT_START) pattern.add_color_stop_rgb(1, *TIMELINE_AXIS_GRADIENT_END) 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 is the active/selected event is_selected = (self.active_event_handle is not None and event_data.event.get_handle() == self.active_event_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 portrait if person has one (between timeline and label) if event_data.person: # Position portrait with gap after event marker edge portrait_x = timeline_x + EVENT_MARKER_SIZE / 2 + PORTRAIT_GAP + PORTRAIT_SIZE_TIMELINE / 2 self.draw_portrait(context, portrait_x, event_data.y_pos, event_data.person, PORTRAIT_SIZE_TIMELINE) # Draw event label label_x = timeline_x + LABEL_X_OFFSET # Adjust label position if portrait is present if event_data.person: label_x = timeline_x + EVENT_MARKER_SIZE / 2 + PORTRAIT_GAP + PORTRAIT_SIZE_TIMELINE + PORTRAIT_MARGIN + 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() # Calculate dynamic timeline X position based on connection lines timeline_x = self._calculate_timeline_x() 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 visual connections for selected person (from selected event) # Draw before events so lines appear below event markers if self.selected_person_handles: self.draw_person_connections(context, events_with_y_pos, timeline_x, timeline_y_start, timeline_y_end) # Draw events self._draw_events(context, events_with_y_pos, timeline_x) # 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(*COLOR_BLACK, 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 = self._normalize_event_type(event_type) # Get color and shape color = EVENT_COLORS.get(event_type_value, DEFAULT_EVENT_COLOR) shape = EVENT_SHAPES.get(event_type_value, DEFAULT_EVENT_SHAPE) 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(*COLOR_BLACK, BORDER_OPACITY) context.set_line_width(MARKER_BORDER_WIDTH) 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, shape_drawers[DEFAULT_EVENT_SHAPE]) drawer(context, x, y, size) def draw_event_label(self, context: cairo.Context, x: float, y: float, date_obj: Date, event: 'Event', person: Optional['Person'], 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(*LABEL_HOVER_BACKGROUND_COLOR) context.fill() # Draw border context.set_source_rgba(*LABEL_HOVER_BORDER_COLOR) context.set_line_width(MARKER_BORDER_WIDTH) context.stroke() # Draw text context.set_source_rgb(*LABEL_TEXT_COLOR) context.move_to(x, y - LABEL_VERTICAL_OFFSET) PangoCairo.show_layout(context, layout) context.restore() def draw_portrait(self, context: cairo.Context, x: float, y: float, person: Optional['Person'], size: int = PORTRAIT_SIZE_TIMELINE) -> None: """ Draw a circular portrait for a person. Args: context: Cairo drawing context. x: X coordinate for portrait center. y: Y coordinate for portrait center. person: The person object (None if no person or no portrait). size: Size of the portrait (diameter). """ if not person: return # Get portrait path and load image portrait_path = self._get_person_portrait_path(person) if not portrait_path: return pixbuf = self._load_portrait_image(portrait_path, size) if not pixbuf: return context.save() # Create a circular clipping path radius = size / 2 context.arc(x, y, radius, 0, 2 * math.pi) context.clip() # Use Gdk to set the pixbuf as source for Cairo # This is the proper way to draw a GdkPixbuf with Cairo Gdk.cairo_set_source_pixbuf(context, pixbuf, x - radius, y - radius) context.paint() context.restore() # Draw a border around the portrait context.save() context.set_source_rgba(0.0, 0.0, 0.0, 0.3) # Semi-transparent black border context.set_line_width(1.5) context.arc(x, y, radius, 0, 2 * math.pi) context.stroke() 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 extracting year from date for timeline year marker: {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(*YEAR_MARKER_COLOR) 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(*COLOR_BLACK) context.move_to(timeline_x - YEAR_MARKER_LABEL_OFFSET - 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 Nth year based on range) # Ensure year_step is at least 1 to avoid division by zero issues year_step = max(1, (max_year - min_year) // YEAR_MARKER_STEP_DIVISOR) 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 selected persons. Each person gets their own colored vertical line at a unique X position. 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_handles: return context.save() context.set_line_width(CONNECTION_LINE_WIDTH) context.set_line_cap(cairo.LINE_CAP_ROUND) context.set_line_join(cairo.LINE_JOIN_ROUND) # Sort person handles for consistent ordering (affects X position) sorted_person_handles = sorted(self.selected_person_handles) # Draw connections for each selected person for person_index, person_handle in enumerate(sorted_person_handles): # Get color for this person person_color = self._get_person_color(person_handle) # Calculate X position for this person's vertical line vertical_line_x = self._get_person_vertical_line_x(person_index) # Find all events for this person person_events = self._get_events_for_person(person_handle, events_with_y_pos) if not person_events: continue # Sort by Y position person_events.sort(key=lambda x: x.y_pos) # Set color for this person's lines context.set_source_rgba(*person_color) # Draw vertical connector line on the left side if len(person_events) > 1: # Use first and last elements since list is sorted by y_pos min_y = person_events[0].y_pos max_y = person_events[-1].y_pos # Draw vertical line connecting all events if max_y - min_y > CONNECTION_LINE_MIN_DISTANCE: 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 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"