# -*- coding: utf-8 -*-
#
# Gramps - a GTK+/GNOME based genealogy program
#
# Copyright (C) 2024
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, see .
#
"""
MyTimeline View - A vertical timeline showing family events
"""
# -------------------------------------------------------------------------
#
# Python modules
#
# -------------------------------------------------------------------------
import cairo
import logging
import math
from dataclasses import dataclass
from typing import Optional, List, Tuple, Any, Set, Dict
from gi.repository import Gtk
from gi.repository import Gdk
from gi.repository import GLib
from gi.repository import Pango
from gi.repository import PangoCairo
# -------------------------------------------------------------------------
#
# Gramps modules
#
# -------------------------------------------------------------------------
from gramps.gen.const import GRAMPS_LOCALE as glocale
from gramps.gen.lib import EventType, Date
from gramps.gen.utils.db import (
get_birth_or_fallback,
get_death_or_fallback,
get_marriage_or_fallback,
)
from gramps.gen.datehandler import get_date
from gramps.gen.display.name import displayer as name_displayer
from gramps.gui.views.navigationview import NavigationView
from gramps.gui.views.bookmarks import FamilyBookmarks
_ = glocale.translation.sgettext
# -------------------------------------------------------------------------
#
# Constants
#
# -------------------------------------------------------------------------
# Timeline Layout Constants
TIMELINE_MARGIN_LEFT = 150
TIMELINE_MARGIN_RIGHT = 50
TIMELINE_MARGIN_TOP = 50
TIMELINE_MARGIN_BOTTOM = 50
TIMELINE_LINE_WIDTH = 3
# Event Display Constants
EVENT_MARKER_SIZE = 10
EVENT_SPACING = 80
LABEL_X_OFFSET = 25
MIN_LABEL_SPACING = 30
LABEL_PADDING = 16
MARKER_CLICK_PADDING = 10
CLICKABLE_AREA_WIDTH = 600
CLICKABLE_AREA_HEIGHT = 30
# UI Constants
YEAR_LABEL_WIDTH = 100
TOOLTIP_DELAY = 500 # milliseconds
TOOLTIP_MAX_WIDTH = 500
LABEL_BACKGROUND_PADDING = 8
LABEL_BACKGROUND_RADIUS = 5
# Font Constants
FONT_FAMILY = "Sans"
FONT_SIZE_NORMAL = 11
FONT_SIZE_SMALL = 9
FONT_SIZE_LARGE = 24
# Visual Effect Constants
MARKER_HOVER_SIZE_MULTIPLIER = 1.3
MARKER_SELECTED_SIZE_MULTIPLIER = 1.2
GRADIENT_BRIGHTNESS_OFFSET = 0.2
GRADIENT_DARKNESS_OFFSET = 0.1
SHADOW_OFFSET_X = 1
SHADOW_OFFSET_Y = 1
SHADOW_OPACITY = 0.3
BORDER_OPACITY = 0.3
# Connection Line Constants
CONNECTION_LINE_COLOR = (0.2, 0.5, 1.0, 0.75) # Brighter, more opaque blue
CONNECTION_LINE_WIDTH = 3.5
CONNECTION_VERTICAL_LINE_X = 5 # Left of year markers
# Marker State Colors
SELECTED_MARKER_COLOR = (0.2, 0.4, 0.9) # Blue highlight for selected person's events
# Logger
logger = logging.getLogger(__name__)
# Event type categories and color mapping
EVENT_COLORS = {
# Life Events - Green/Red spectrum
EventType.BIRTH: (0.2, 0.8, 0.3), # Bright green
EventType.DEATH: (0.9, 0.2, 0.2), # Bright red
EventType.BURIAL: (0.7, 0.3, 0.3), # Dark red
EventType.CREMATION: (0.8, 0.4, 0.4), # Light red
EventType.ADOPT: (0.3, 0.7, 0.4), # Green
# Family Events - Blue/Purple spectrum
EventType.MARRIAGE: (0.2, 0.4, 0.9), # Blue
EventType.DIVORCE: (0.6, 0.3, 0.7), # Purple
EventType.ENGAGEMENT: (0.4, 0.5, 0.9), # Light blue
EventType.MARR_SETTL: (0.3, 0.4, 0.8), # Dark blue
EventType.MARR_LIC: (0.4, 0.5, 0.85), # Medium blue
EventType.MARR_CONTR: (0.35, 0.45, 0.85), # Medium blue
EventType.MARR_BANNS: (0.45, 0.55, 0.9), # Light blue
EventType.DIV_FILING: (0.65, 0.35, 0.75), # Light purple
EventType.ANNULMENT: (0.7, 0.4, 0.8), # Light purple
EventType.MARR_ALT: (0.5, 0.5, 0.9), # Medium blue
# Religious Events - Gold/Yellow spectrum
EventType.BAPTISM: (0.95, 0.85, 0.2), # Gold
EventType.ADULT_CHRISTEN: (0.9, 0.8, 0.3), # Light gold
EventType.CONFIRMATION: (0.95, 0.9, 0.4), # Yellow
EventType.CHRISTEN: (0.9, 0.85, 0.35), # Gold
EventType.FIRST_COMMUN: (0.95, 0.88, 0.3), # Gold
EventType.BLESS: (0.92, 0.87, 0.4), # Light gold
EventType.BAR_MITZVAH: (0.93, 0.83, 0.3), # Gold
EventType.BAS_MITZVAH: (0.94, 0.84, 0.3), # Gold
EventType.RELIGION: (0.9, 0.8, 0.5), # Light gold
EventType.ORDINATION: (0.88, 0.75, 0.3), # Dark gold
# Vocational Events - Orange spectrum
EventType.OCCUPATION: (0.95, 0.6, 0.2), # Orange
EventType.RETIREMENT: (0.9, 0.55, 0.25), # Dark orange
EventType.ELECTED: (0.95, 0.65, 0.3), # Light orange
EventType.MILITARY_SERV: (0.85, 0.5, 0.2), # Dark orange
# Academic Events - Teal spectrum
EventType.EDUCATION: (0.2, 0.7, 0.7), # Teal
EventType.GRADUATION: (0.3, 0.8, 0.8), # Bright teal
EventType.DEGREE: (0.25, 0.75, 0.75), # Medium teal
# Travel Events - Cyan spectrum
EventType.EMIGRATION: (0.2, 0.7, 0.9), # Cyan
EventType.IMMIGRATION: (0.3, 0.8, 0.95), # Bright cyan
EventType.NATURALIZATION: (0.25, 0.75, 0.9), # Medium cyan
# Legal Events - Brown spectrum
EventType.PROBATE: (0.6, 0.4, 0.2), # Brown
EventType.WILL: (0.65, 0.45, 0.25), # Light brown
# Residence Events - Indigo spectrum
EventType.RESIDENCE: (0.4, 0.3, 0.7), # Indigo
EventType.CENSUS: (0.5, 0.4, 0.8), # Light indigo
EventType.PROPERTY: (0.45, 0.35, 0.75), # Medium indigo
# Other Events - Gray spectrum
EventType.CAUSE_DEATH: (0.5, 0.5, 0.5), # Gray
EventType.MED_INFO: (0.6, 0.6, 0.6), # Light gray
EventType.NOB_TITLE: (0.55, 0.55, 0.55), # Medium gray
EventType.NUM_MARRIAGES: (0.5, 0.5, 0.5), # Gray
EventType.UNKNOWN: (0.4, 0.4, 0.4), # Dark gray
EventType.CUSTOM: (0.45, 0.45, 0.45), # Medium gray
}
# Event shape types: 'triangle', 'circle', 'diamond', 'square', 'star', 'hexagon'
EVENT_SHAPES = {
# Life Events
EventType.BIRTH: 'triangle',
EventType.DEATH: 'circle',
EventType.BURIAL: 'circle',
EventType.CREMATION: 'circle',
EventType.ADOPT: 'triangle',
# Family Events
EventType.MARRIAGE: 'diamond',
EventType.DIVORCE: 'square',
EventType.ENGAGEMENT: 'diamond',
EventType.MARR_SETTL: 'diamond',
EventType.MARR_LIC: 'diamond',
EventType.MARR_CONTR: 'diamond',
EventType.MARR_BANNS: 'diamond',
EventType.DIV_FILING: 'square',
EventType.ANNULMENT: 'square',
EventType.MARR_ALT: 'diamond',
# Religious Events
EventType.BAPTISM: 'star',
EventType.ADULT_CHRISTEN: 'star',
EventType.CONFIRMATION: 'star',
EventType.CHRISTEN: 'star',
EventType.FIRST_COMMUN: 'star',
EventType.BLESS: 'star',
EventType.BAR_MITZVAH: 'star',
EventType.BAS_MITZVAH: 'star',
EventType.RELIGION: 'star',
EventType.ORDINATION: 'star',
# Vocational Events
EventType.OCCUPATION: 'hexagon',
EventType.RETIREMENT: 'hexagon',
EventType.ELECTED: 'hexagon',
EventType.MILITARY_SERV: 'hexagon',
# Academic Events
EventType.EDUCATION: 'square',
EventType.GRADUATION: 'square',
EventType.DEGREE: 'square',
# Travel Events
EventType.EMIGRATION: 'triangle',
EventType.IMMIGRATION: 'triangle',
EventType.NATURALIZATION: 'triangle',
# Legal Events
EventType.PROBATE: 'square',
EventType.WILL: 'square',
# Residence Events
EventType.RESIDENCE: 'square',
EventType.CENSUS: 'square',
EventType.PROPERTY: 'square',
# Other Events
EventType.CAUSE_DEATH: 'circle',
EventType.MED_INFO: 'circle',
EventType.NOB_TITLE: 'square',
EventType.NUM_MARRIAGES: 'square',
EventType.UNKNOWN: 'square',
EventType.CUSTOM: 'square',
}
# 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: 'Any' # gramps.gen.lib.Event
person: Optional['Any'] # gramps.gen.lib.Person
event_type: EventType
y_pos: float = 0.0
# -------------------------------------------------------------------------
#
# MyTimelineView
#
# -------------------------------------------------------------------------
class MyTimelineView(NavigationView):
"""
View for displaying a vertical timeline of family events.
Shows all events for family members with modern design and interactivity.
"""
def __init__(self, pdata, dbstate, uistate, nav_group=0):
"""
Initialize the MyTimeline view.
Args:
pdata: Plugin data object.
dbstate: Database state object.
uistate: UI state object.
nav_group: Navigation group identifier.
"""
NavigationView.__init__(
self, _("MyTimeline"), pdata, dbstate, uistate, FamilyBookmarks, nav_group
)
self.dbstate = dbstate
self.uistate = uistate
# Current family handle
self.active_family_handle = None
self.events: List[TimelineEvent] = [] # List of TimelineEvent objects
# UI components
self.scrolledwindow = None
self.drawing_area = None
self.timeline_height = 1000 # Default height, will be recalculated
self.zoom_level = 1.0 # Zoom level (1.0 = 100%)
self.min_zoom = 0.5
self.max_zoom = 3.0
self.zoom_step = 0.1
# Interaction state
self.hovered_event_index = None
self.tooltip_timeout_id = None
self.tooltip_window = None
self.selected_person_handle = None
self.mouse_x = 0
self.mouse_y = 0
# Performance cache
self._adjusted_events_cache: Optional[List[TimelineEvent]] = None
self._cache_key: Optional[int] = None
self._cached_date_range: Optional[int] = None
self._cached_min_date: Optional[int] = None
self._cached_max_date: Optional[int] = None
# Filter state
self.filter_enabled: bool = False
self.active_event_types: Set[EventType] = set() # Empty = all enabled
self.date_range_filter: Optional[Tuple[int, int]] = None # (min_date, max_date) in sort values
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 "Family" for this view.
"""
return "Family"
def change_page(self) -> None:
"""
Called when the page changes.
Updates the view to show the active family's timeline.
"""
NavigationView.change_page(self)
active_handle = self.get_active()
if active_handle:
self.goto_handle(active_handle)
def family_updated(self, handle_list: List[str]) -> None:
"""
Called when a family is updated.
Args:
handle_list: List of family handles that were updated.
"""
if self.active_family_handle in handle_list:
self.collect_events()
if self.drawing_area:
self.drawing_area.queue_draw()
def person_updated(self, handle_list: List[str]) -> None:
"""
Called when a person is updated.
Args:
handle_list: List of person handles that were updated.
"""
# Check if any updated person is related to current family
if self.active_family_handle:
try:
family = self.dbstate.db.get_family_from_handle(self.active_family_handle)
if family:
father_handle = family.get_father_handle()
mother_handle = family.get_mother_handle()
child_handles = [ref.ref for ref in family.get_child_ref_list()]
if (
(father_handle and father_handle in handle_list)
or (mother_handle and mother_handle in handle_list)
or any(h in handle_list for h in child_handles)
):
self.collect_events()
if self.drawing_area:
self.drawing_area.queue_draw()
except (AttributeError, KeyError) as e:
# Skip if family handle is invalid
logger.warning(f"Error accessing family in person_updated: {e}", exc_info=True)
def event_updated(self, handle_list: List[str]) -> None:
"""
Called when an event is updated.
Args:
handle_list: List of event handles that were updated.
"""
# Re-collect events if we have an active family
if self.active_family_handle:
self.collect_events()
if self.drawing_area:
self.drawing_area.queue_draw()
def change_db(self, db) -> None:
"""
Called when the database changes.
Args:
db: The new database object.
"""
self.active_family_handle = None
self.all_events = []
self.events = []
# Connect to new database signals
if db and db.is_open():
db.connect("family-update", self.family_updated)
db.connect("person-update", self.person_updated)
db.connect("event-update", self.event_updated)
if self.drawing_area:
self.drawing_area.queue_draw()
def build_widget(self) -> Gtk.Widget:
"""
Build the interface and return the container.
Returns:
Gtk.Widget: The main container widget with toolbar and drawing area.
"""
# Main container
main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5)
# Toolbar with zoom controls
toolbar = Gtk.Toolbar()
toolbar.set_style(Gtk.ToolbarStyle.ICONS)
# Zoom out button
zoom_out_btn = Gtk.ToolButton(icon_name="zoom-out-symbolic")
zoom_out_btn.set_tooltip_text(_("Zoom Out"))
zoom_out_btn.connect("clicked", self.on_zoom_out)
toolbar.insert(zoom_out_btn, 0)
# Zoom label
self.zoom_label = Gtk.Label(label="100%")
self.zoom_label.set_margin_start(10)
self.zoom_label.set_margin_end(10)
zoom_item = Gtk.ToolItem()
zoom_item.add(self.zoom_label)
toolbar.insert(zoom_item, 1)
# Zoom in button
zoom_in_btn = Gtk.ToolButton(icon_name="zoom-in-symbolic")
zoom_in_btn.set_tooltip_text(_("Zoom In"))
zoom_in_btn.connect("clicked", self.on_zoom_in)
toolbar.insert(zoom_in_btn, 2)
# Reset zoom button
zoom_reset_btn = Gtk.ToolButton(icon_name="zoom-fit-best-symbolic")
zoom_reset_btn.set_tooltip_text(_("Reset Zoom"))
zoom_reset_btn.connect("clicked", self.on_zoom_reset)
toolbar.insert(zoom_reset_btn, 3)
toolbar.insert(Gtk.SeparatorToolItem(), 4)
# 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(800, 600)
self.drawing_area.connect("draw", self.on_draw)
self.drawing_area.add_events(
Gdk.EventMask.BUTTON_PRESS_MASK
| Gdk.EventMask.BUTTON_RELEASE_MASK
| Gdk.EventMask.POINTER_MOTION_MASK
| Gdk.EventMask.LEAVE_NOTIFY_MASK
| Gdk.EventMask.SCROLL_MASK
)
# Connect mouse events
self.drawing_area.connect("button-press-event", self.on_button_press)
self.drawing_area.connect("motion-notify-event", self.on_motion_notify)
self.drawing_area.connect("leave-notify-event", self.on_leave_notify)
self.drawing_area.connect("scroll-event", self.on_scroll)
self.scrolledwindow.add(self.drawing_area)
main_box.pack_start(self.scrolledwindow, True, True, 0)
return main_box
def 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()
elif response == Gtk.ResponseType.CLOSE:
pass # Just close
self.filter_dialog.hide()
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(600, 700)
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(10)
content_area.set_margin_start(10)
content_area.set_margin_end(10)
content_area.set_margin_top(10)
content_area.set_margin_bottom(10)
# 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")))
# Date Range Filter Page
date_page = self._build_date_range_filter_page()
notebook.append_page(date_page, Gtk.Label(label=_("Date Range")))
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=5)
box.set_margin_start(10)
box.set_margin_end(10)
box.set_margin_top(10)
box.set_margin_bottom(10)
# Select All / Deselect All buttons
button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
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 = {}
# Iterate over event types from EVENT_COLORS (which uses EventType integers as keys)
# EventType members are already integers in Gramps
for event_type_obj in EVENT_COLORS.keys():
# EventType is already an integer, use it directly
if event_type_obj not in EVENT_CATEGORIES:
continue
category = EVENT_CATEGORIES[event_type_obj]
if category not in category_boxes:
# Create category section
category_label = Gtk.Label(label=f"{category}")
category_label.set_use_markup(True)
category_label.set_halign(Gtk.Align.START)
box.pack_start(category_label, False, False, 0)
category_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
category_box.set_margin_start(20)
category_boxes[category] = category_box
box.pack_start(category_box, False, False, 0)
# Create checkbox for event type
checkbox = Gtk.CheckButton(label=str(event_type_obj))
event_type_checkboxes[event_type_obj] = checkbox
category_boxes[category].pack_start(checkbox, False, False, 0)
self._filter_widgets['event_type_checkboxes'] = event_type_checkboxes
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=5)
box.set_margin_start(10)
box.set_margin_end(10)
box.set_margin_top(10)
box.set_margin_bottom(10)
# 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 checkboxes for each family member.
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=5)
box.set_margin_start(10)
box.set_margin_end(10)
box.set_margin_top(10)
box.set_margin_bottom(10)
# Person checkboxes container - will be populated when dialog is shown
person_checkboxes = {}
self._filter_widgets['person_checkboxes'] = person_checkboxes
self._filter_widgets['person_container'] = box
info_label = Gtk.Label(label=_("Select family members to include in the timeline."))
info_label.set_line_wrap(True)
box.pack_start(info_label, False, False, 0)
scrolled.add(box)
return scrolled
def _build_date_range_filter_page(self) -> Gtk.Widget:
"""
Build the date range filter page with date entry fields.
Returns:
Gtk.Widget: The date range filter page.
"""
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
box.set_margin_start(10)
box.set_margin_end(10)
box.set_margin_top(10)
box.set_margin_bottom(10)
info_label = Gtk.Label(label=_("Enter date range to filter events. Leave empty to show all dates."))
info_label.set_line_wrap(True)
box.pack_start(info_label, False, False, 0)
# From date
from_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
from_label = Gtk.Label(label=_("From:"))
from_label.set_size_request(80, -1)
from_entry = Gtk.Entry()
from_entry.set_placeholder_text(_("YYYY-MM-DD or YYYY"))
from_box.pack_start(from_label, False, False, 0)
from_box.pack_start(from_entry, True, True, 0)
box.pack_start(from_box, False, False, 0)
# To date
to_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
to_label = Gtk.Label(label=_("To:"))
to_label.set_size_request(80, -1)
to_entry = Gtk.Entry()
to_entry.set_placeholder_text(_("YYYY-MM-DD or YYYY"))
to_box.pack_start(to_label, False, False, 0)
to_box.pack_start(to_entry, True, True, 0)
box.pack_start(to_box, False, False, 0)
self._filter_widgets['date_from_entry'] = from_entry
self._filter_widgets['date_to_entry'] = to_entry
return box
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 event type checkboxes
if 'event_type_checkboxes' in self._filter_widgets:
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
if 'category_checkboxes' in self._filter_widgets:
for category, checkbox in self._filter_widgets['category_checkboxes'].items():
if not self.category_filter:
checkbox.set_active(True) # All selected when filter is off
else:
checkbox.set_active(category in self.category_filter)
# Update person checkboxes
if 'person_checkboxes' in self._filter_widgets and 'person_container' in self._filter_widgets:
# Clear existing person checkboxes
container = self._filter_widgets['person_container']
for checkbox in list(self._filter_widgets['person_checkboxes'].values()):
container.remove(checkbox)
checkbox.destroy()
self._filter_widgets['person_checkboxes'].clear()
# Add current family members
if self.active_family_handle:
try:
family = self.dbstate.db.get_family_from_handle(self.active_family_handle)
if family:
# Father
father_handle = family.get_father_handle()
if father_handle:
father = self.dbstate.db.get_person_from_handle(father_handle)
if father:
checkbox = Gtk.CheckButton(label=name_displayer.display(father))
checkbox.set_active(True if not self.person_filter else father_handle in self.person_filter)
self._filter_widgets['person_checkboxes'][father_handle] = checkbox
container.pack_start(checkbox, False, False, 0)
# Mother
mother_handle = family.get_mother_handle()
if mother_handle:
mother = self.dbstate.db.get_person_from_handle(mother_handle)
if mother:
checkbox = Gtk.CheckButton(label=name_displayer.display(mother))
checkbox.set_active(True if not self.person_filter else mother_handle in self.person_filter)
self._filter_widgets['person_checkboxes'][mother_handle] = checkbox
container.pack_start(checkbox, False, False, 0)
# Children
for child_ref in family.get_child_ref_list():
child = self.dbstate.db.get_person_from_handle(child_ref.ref)
if child:
checkbox = Gtk.CheckButton(label=name_displayer.display(child))
checkbox.set_active(True if not self.person_filter else child_ref.ref in self.person_filter)
self._filter_widgets['person_checkboxes'][child_ref.ref] = checkbox
container.pack_start(checkbox, False, False, 0)
container.show_all()
except (AttributeError, KeyError) as e:
logger.warning(f"Error updating person filter: {e}", exc_info=True)
# Update date range entries
if 'date_from_entry' in self._filter_widgets and 'date_to_entry' in self._filter_widgets:
from_entry = self._filter_widgets['date_from_entry']
to_entry = self._filter_widgets['date_to_entry']
if self.date_range_filter:
min_date, max_date = self.date_range_filter
# Convert sort values back to dates for display (simplified)
from_entry.set_text("")
to_entry.set_text("")
else:
from_entry.set_text("")
to_entry.set_text("")
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.date_range_filter = None
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()
for event_type, checkbox in self._filter_widgets['event_type_checkboxes'].items():
if checkbox.get_active():
active_types.add(event_type)
self.active_event_types = active_types if len(active_types) < len(self._filter_widgets['event_type_checkboxes']) else set()
# 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'].keys())
self.category_filter = active_categories if active_categories != all_categories else None
# Update person filter
if 'person_checkboxes' in self._filter_widgets:
active_persons = set()
for person_handle, checkbox in self._filter_widgets['person_checkboxes'].items():
if checkbox.get_active():
active_persons.add(person_handle)
all_persons = set(self._filter_widgets['person_checkboxes'].keys())
self.person_filter = active_persons if active_persons != all_persons else None
# Update date range filter
if 'date_from_entry' in self._filter_widgets and 'date_to_entry' in self._filter_widgets:
from_text = self._filter_widgets['date_from_entry'].get_text().strip()
to_text = self._filter_widgets['date_to_entry'].get_text().strip()
if from_text or to_text:
# Parse dates using Gramps Date objects
try:
min_sort = None
max_sort = None
if from_text:
# Try to parse the date string
# Support formats: YYYY, YYYY-MM, YYYY-MM-DD
parts = from_text.split('-')
year = int(parts[0])
month = int(parts[1]) if len(parts) > 1 else 1
day = int(parts[2]) if len(parts) > 2 else 1
from_date = Date()
from_date.set_yr_mon_day(year, month, day)
min_sort = from_date.get_sort_value()
if to_text:
# Try to parse the date string
parts = to_text.split('-')
year = int(parts[0])
month = int(parts[1]) if len(parts) > 1 else 12
day = int(parts[2]) if len(parts) > 2 else 31
to_date = Date()
to_date.set_yr_mon_day(year, month, day)
max_sort = to_date.get_sort_value()
# If only one date is provided, set reasonable defaults
if min_sort is None:
min_sort = 0
if max_sort is None:
max_sort = 99999999
self.date_range_filter = (min_sort, max_sort) if (from_text or to_text) else None
except (ValueError, AttributeError, TypeError) as e:
logger.warning(f"Error parsing date range: {e}", exc_info=True)
self.date_range_filter = None
else:
self.date_range_filter = None
# Enable filter if any filter is active
self.filter_enabled = (
self.active_event_types or
self.date_range_filter is not None 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.
"""
active_handle = self.get_active()
if active_handle:
self.goto_handle(active_handle)
def goto_handle(self, handle: str) -> None:
"""
Called when the active family changes.
Args:
handle: The handle of the family to display.
"""
if handle == self.active_family_handle:
return
self.active_family_handle = handle
self.collect_events()
if self.drawing_area:
self.drawing_area.queue_draw()
def _collect_person_event_refs(self, person) -> List:
"""
Collect event references from a person.
Args:
person: The person object to collect events from.
Returns:
List: List of event references.
"""
if not person:
return []
try:
return person.get_event_ref_list()
except (AttributeError, KeyError) as e:
logger.warning(f"Error accessing event references for person: {e}", exc_info=True)
return []
def _process_event_ref(self, event_ref, person_obj: Optional[Any]) -> Optional[TimelineEvent]:
"""
Process a single event reference and create a TimelineEvent.
Args:
event_ref: The event reference to process.
person_obj: The person object associated with this event (None for family events).
Returns:
Optional[TimelineEvent]: The created TimelineEvent, or None if invalid.
"""
try:
event = self.dbstate.db.get_event_from_handle(event_ref.ref)
if event and event.get_date_object():
date_obj = event.get_date_object()
event_type = event.get_type()
return TimelineEvent(
date_sort=date_obj.get_sort_value(),
date_obj=date_obj,
event=event,
person=person_obj,
event_type=event_type,
y_pos=0.0
)
except (AttributeError, KeyError, ValueError) as e:
logger.debug(f"Skipping invalid event reference: {e}")
return None
def _collect_person_events(self, person, person_obj) -> None:
"""
Collect all events from a person and add them to self.events.
Args:
person: The person object to collect events from.
person_obj: The person object to associate with events.
"""
if not person:
return
event_refs = self._collect_person_event_refs(person)
for event_ref in event_refs:
timeline_event = self._process_event_ref(event_ref, person_obj)
if timeline_event:
self.all_events.append(timeline_event)
def _collect_family_member_events(self, handle: Optional[str], role: str) -> None:
"""
Collect events for a family member (father, mother, or child).
Args:
handle: The person handle to collect events from.
role: Role name for logging purposes ('father', 'mother', 'child').
"""
if not handle:
return
try:
person = self.dbstate.db.get_person_from_handle(handle)
if person:
self._collect_person_events(person, person)
except (AttributeError, KeyError) as e:
logger.debug(f"Error accessing {role}: {e}")
def _collect_family_events(self, family) -> None:
"""
Collect family-level events (marriage, divorce, etc.).
Args:
family: The family object to collect events from.
"""
for event_ref in family.get_event_ref_list():
timeline_event = self._process_event_ref(event_ref, None)
if timeline_event:
self.all_events.append(timeline_event)
def _invalidate_cache(self) -> None:
"""Invalidate all caches when events change."""
self._adjusted_events_cache = None
self._cache_key = None
self._cached_date_range = None
self._cached_min_date = None
self._cached_max_date = None
def _calculate_timeline_height(self) -> None:
"""Calculate and set timeline height based on number of events and zoom."""
if self.events:
base_height = (
TIMELINE_MARGIN_TOP
+ len(self.events) * EVENT_SPACING
+ TIMELINE_MARGIN_BOTTOM
)
self.timeline_height = int(base_height * self.zoom_level)
else:
self.timeline_height = int(600 * self.zoom_level)
if self.drawing_area:
self.drawing_area.set_size_request(800, self.timeline_height)
def collect_events(self) -> None:
"""Collect all events for the active family."""
self.all_events = []
self.events = []
if not self.active_family_handle:
return
try:
family = self.dbstate.db.get_family_from_handle(self.active_family_handle)
except (AttributeError, KeyError) as e:
logger.warning(f"Error accessing family: {e}", exc_info=True)
return
if not family:
return
# Get family events (marriage, divorce, etc.)
self._collect_family_events(family)
# Get father's events
self._collect_family_member_events(family.get_father_handle(), "father")
# Get mother's events
self._collect_family_member_events(family.get_mother_handle(), "mother")
# Get children's events
for child_ref in family.get_child_ref_list():
self._collect_family_member_events(child_ref.ref, "child")
# Sort events by date
self.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 _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 self.active_event_types:
# Normalize event.event_type and compare with normalized active_event_types
event_type_normalized = self._normalize_event_type(event.event_type)
active_types_normalized = {self._normalize_event_type(et) for et in self.active_event_types}
if event_type_normalized not in active_types_normalized:
continue
# Check date range filter
if not self._is_date_in_range(event.date_sort):
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
category = self._get_event_category(event.event_type)
if self.category_filter and category not in self.category_filter:
continue
filtered.append(event)
return filtered
def _normalize_event_type(self, event_type: EventType) -> int:
"""
Normalize EventType to integer for comparison.
Args:
event_type: The event type (may be EventType object or integer).
Returns:
int: The integer value of the event type.
"""
try:
if isinstance(event_type, int):
return event_type
elif hasattr(event_type, 'value'):
return event_type.value
else:
return int(event_type)
except (TypeError, ValueError, AttributeError):
return 0 # Default to 0 if conversion fails
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
# Normalize both for comparison
normalized_type = self._normalize_event_type(event_type)
normalized_active = {self._normalize_event_type(et) for et in self.active_event_types}
return normalized_type in normalized_active
def _is_date_in_range(self, date_sort: int) -> bool:
"""
Check if a date is within the filter range.
Args:
date_sort: The date sort value to check.
Returns:
bool: True if date is in range (or no filter active), False otherwise.
"""
if not self.date_range_filter:
return True
min_date, max_date = self.date_range_filter
return min_date <= date_sort <= max_date
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_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()
if self.drawing_area:
self.drawing_area.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[Any], 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 _calculate_y_position(self, date_sort: int, min_date: int, date_range: int,
timeline_y_start: float, timeline_y_end: float) -> float:
"""
Calculate Y position for an event based on date.
Args:
date_sort: The sort value of the event date.
min_date: The minimum date sort value.
date_range: The range of dates (max - min).
timeline_y_start: The Y coordinate of the timeline start.
timeline_y_end: The Y coordinate of the timeline end.
Returns:
float: The calculated Y position.
"""
return timeline_y_start + (
(date_sort - min_date) / date_range
) * (timeline_y_end - timeline_y_start)
def _get_event_label_text(self, event, person: Optional[Any], event_type: EventType) -> str:
"""
Generate label text for an event. Centralized logic.
Args:
event: The event object.
person: The person associated with the event (None for family events).
event_type: The type of event.
Returns:
str: The formatted label text.
"""
date_str = get_date(event)
event_type_str = str(event_type)
if person:
person_name = name_displayer.display(person)
return f"{date_str} - {event_type_str} - {person_name}"
else:
return f"{date_str} - {event_type_str}"
def _calculate_adjusted_positions(self, context: cairo.Context,
events_with_y_pos: List[TimelineEvent],
timeline_y_start: float,
timeline_y_end: float) -> List[TimelineEvent]:
"""
Calculate adjusted Y positions with collision detection.
Reusable by both drawing and click detection.
Args:
context: Cairo drawing context for text measurement.
events_with_y_pos: List of TimelineEvent objects with initial Y positions.
timeline_y_start: The Y coordinate of the timeline start.
timeline_y_end: The Y coordinate of the timeline end.
Returns:
List[TimelineEvent]: List of TimelineEvent objects with adjusted Y positions.
"""
if not events_with_y_pos:
return events_with_y_pos
# Create a temporary layout to measure text
layout = PangoCairo.create_layout(context)
layout.set_font_description(
Pango.font_description_from_string(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
adjusted_y = event_data.y_pos
for prev_event in adjusted_events:
# Check if labels would overlap
if abs(adjusted_y - prev_event.y_pos) < MIN_LABEL_SPACING:
# Adjust downward
adjusted_y = prev_event.y_pos + MIN_LABEL_SPACING
# Ensure adjusted position is within bounds
adjusted_y = max(timeline_y_start, min(adjusted_y, timeline_y_end))
# Create new event data with adjusted Y position
adjusted_event = TimelineEvent(
date_sort=event_data.date_sort,
date_obj=event_data.date_obj,
event=event_data.event,
person=event_data.person,
event_type=event_data.event_type,
y_pos=adjusted_y
)
adjusted_events.append(adjusted_event)
return adjusted_events
def detect_label_overlaps(self, context: cairo.Context,
events_with_y_pos: List[TimelineEvent],
timeline_y_start: float,
timeline_y_end: float) -> List[TimelineEvent]:
"""
Detect and adjust Y positions to prevent label overlaps.
Args:
context: Cairo drawing context.
events_with_y_pos: List of TimelineEvent objects with initial Y positions.
timeline_y_start: The Y coordinate of the timeline start.
timeline_y_end: The Y coordinate of the timeline end.
Returns:
List[TimelineEvent]: List of TimelineEvent objects with adjusted Y positions.
"""
# Use shared collision detection method
return self._calculate_adjusted_positions(context, events_with_y_pos, timeline_y_start, timeline_y_end)
def _recalculate_timeline_height(self) -> None:
"""
Recalculate timeline height based on current events and zoom level.
"""
if self.events:
base_height = (
TIMELINE_MARGIN_TOP
+ len(self.events) * EVENT_SPACING
+ TIMELINE_MARGIN_BOTTOM
)
self.timeline_height = int(base_height * self.zoom_level)
else:
self.timeline_height = int(600 * self.zoom_level)
if self.drawing_area:
self.drawing_area.set_size_request(800, self.timeline_height)
# Invalidate cache when zoom changes
self._adjusted_events_cache = None
self._cache_key = None
def on_zoom_in(self, widget: Gtk.Widget) -> None:
"""
Zoom in.
Args:
widget: The widget that triggered the zoom (unused).
"""
if self.zoom_level < self.max_zoom:
self.zoom_level = min(self.zoom_level + self.zoom_step, self.max_zoom)
self.update_zoom_display()
self._recalculate_timeline_height() # Only recalculate height, not events
if self.drawing_area:
self.drawing_area.queue_draw()
def on_zoom_out(self, widget: Gtk.Widget) -> None:
"""
Zoom out.
Args:
widget: The widget that triggered the zoom (unused).
"""
if self.zoom_level > self.min_zoom:
self.zoom_level = max(self.zoom_level - self.zoom_step, self.min_zoom)
self.update_zoom_display()
self._recalculate_timeline_height() # Only recalculate height, not events
if self.drawing_area:
self.drawing_area.queue_draw()
def on_zoom_reset(self, widget: Gtk.Widget) -> None:
"""
Reset zoom to 100%.
Args:
widget: The widget that triggered the reset (unused).
"""
self.zoom_level = 1.0
self.update_zoom_display()
self._recalculate_timeline_height() # Only recalculate height, not events
if self.drawing_area:
self.drawing_area.queue_draw()
def on_scroll(self, widget: Gtk.Widget, event: Gdk.Event) -> bool:
"""
Handle scroll events for zooming with Ctrl+scroll.
Args:
widget: The widget that received the scroll event.
event: The scroll event.
Returns:
bool: True if the event was handled, False otherwise.
"""
if event.state & Gdk.ModifierType.CONTROL_MASK:
if event.direction == Gdk.ScrollDirection.UP:
self.on_zoom_in(widget)
elif event.direction == Gdk.ScrollDirection.DOWN:
self.on_zoom_out(widget)
return True
return False
def update_zoom_display(self) -> None:
"""Update the zoom level display."""
if hasattr(self, 'zoom_label'):
self.zoom_label.set_text(f"{int(self.zoom_level * 100)}%")
def on_button_press(self, widget: Gtk.Widget, event: Gdk.Event) -> bool:
"""
Handle mouse button press events.
Args:
widget: The widget that received the button press event.
event: The button press event.
Returns:
bool: False to allow other handlers to process the event.
"""
if event.button == 1: # Left click
# Find which event was clicked
clicked_index = self.find_event_at_position(event.x, event.y)
if clicked_index is not None:
clicked_event_data = self.events[clicked_index]
# Clicking anywhere on the event line selects the person (like selecting the event)
if clicked_event_data.person:
person_handle = clicked_event_data.person.get_handle()
if self.selected_person_handle == person_handle:
# Deselect if clicking same person
self.selected_person_handle = None
else:
# Select this person
self.selected_person_handle = person_handle
else:
# No person for this event, deselect
self.selected_person_handle = None
self.drawing_area.queue_draw()
return False
def on_motion_notify(self, widget: Gtk.Widget, event: Gdk.Event) -> bool:
"""
Handle mouse motion events for hover detection.
Args:
widget: The widget that received the motion event.
event: The motion event.
Returns:
bool: False to allow other handlers to process the event.
"""
self.mouse_x = event.x
self.mouse_y = event.y
# Find which event is under the cursor
hovered_index = self.find_event_at_position(event.x, event.y)
if hovered_index != self.hovered_event_index:
self.hovered_event_index = hovered_index
# Cancel existing tooltip timeout
if self.tooltip_timeout_id:
GLib.source_remove(self.tooltip_timeout_id)
self.tooltip_timeout_id = None
# Hide existing tooltip
if self.tooltip_window:
self.tooltip_window.destroy()
self.tooltip_window = None
# Schedule new tooltip
if hovered_index is not None:
self.tooltip_timeout_id = GLib.timeout_add(
TOOLTIP_DELAY, self.show_tooltip, hovered_index, event.x_root, event.y_root
)
self.drawing_area.queue_draw()
return False
def on_leave_notify(self, widget: Gtk.Widget, event: Gdk.Event) -> bool:
"""
Handle mouse leave events.
Args:
widget: The widget that received the leave event.
event: The leave event.
Returns:
bool: False to allow other handlers to process the event.
"""
self.hovered_event_index = None
# Cancel tooltip timeout
if self.tooltip_timeout_id:
GLib.source_remove(self.tooltip_timeout_id)
self.tooltip_timeout_id = None
# Hide tooltip
if self.tooltip_window:
self.tooltip_window.destroy()
self.tooltip_window = None
self.drawing_area.queue_draw()
return False
def _get_cache_key(self, timeline_y_start: float, timeline_y_end: float) -> int:
"""
Generate cache key for adjusted events.
Args:
timeline_y_start: The Y coordinate of the timeline start.
timeline_y_end: The Y coordinate of the timeline end.
Returns:
int: A hash-based cache key.
"""
return hash((len(self.events), timeline_y_start, timeline_y_end))
def _get_adjusted_events(self, context: cairo.Context,
timeline_y_start: float,
timeline_y_end: float) -> List[TimelineEvent]:
"""
Get adjusted events with collision detection, using cache if available.
Args:
context: Cairo drawing context for text measurement.
timeline_y_start: The Y coordinate of the timeline start.
timeline_y_end: The Y coordinate of the timeline end.
Returns:
List[TimelineEvent]: List of TimelineEvent objects with adjusted Y positions.
"""
# Check if cache is valid using hash-based key
cache_key = self._get_cache_key(timeline_y_start, timeline_y_end)
if (self._adjusted_events_cache is not None and
self._cache_key == cache_key):
return self._adjusted_events_cache
# Calculate date range
if not self.events:
return []
min_date, max_date, date_range = self._calculate_date_range()
# Calculate initial Y positions
events_with_y_pos = []
for event_data in self.events:
y_pos = self._calculate_y_position(
event_data.date_sort, min_date, date_range,
timeline_y_start, timeline_y_end
)
event_with_y = TimelineEvent(
date_sort=event_data.date_sort,
date_obj=event_data.date_obj,
event=event_data.event,
person=event_data.person,
event_type=event_data.event_type,
y_pos=y_pos
)
events_with_y_pos.append(event_with_y)
# Apply collision detection using shared method
adjusted_events = self._calculate_adjusted_positions(
context, events_with_y_pos, timeline_y_start, timeline_y_end
)
# Cache the results
self._adjusted_events_cache = adjusted_events
self._cache_key = cache_key
return adjusted_events
def find_event_at_position(self, x: float, y: float) -> Optional[int]:
"""
Find which event is at the given position.
Args:
x: X coordinate in widget space.
y: Y coordinate in widget space.
Returns:
Optional[int]: Index of the event at the position, or None if no event found.
"""
if not self.events or not self.drawing_area:
return None
# Convert mouse coordinates to drawing coordinates (account for zoom)
scaled_x = x / self.zoom_level
scaled_y = y / self.zoom_level
# Get widget dimensions in drawing coordinates
height = self.drawing_area.get_allocated_height() / self.zoom_level
timeline_x = TIMELINE_MARGIN_LEFT
timeline_y_start = TIMELINE_MARGIN_TOP
timeline_y_end = height - TIMELINE_MARGIN_BOTTOM
# Get adjusted events using cache (create temporary context for text measurement)
# For click detection, we need a context for text measurement
temp_context = cairo.Context(self._temp_surface)
adjusted_events = self._get_adjusted_events(temp_context, timeline_y_start, timeline_y_end)
# Check each event using adjusted positions
marker_size = EVENT_MARKER_SIZE
label_x = timeline_x + LABEL_X_OFFSET
for i, event_data in enumerate(adjusted_events):
# Check if click is in the event's area (marker + label)
if (scaled_x >= timeline_x - marker_size - MARKER_CLICK_PADDING and
scaled_x <= label_x + CLICKABLE_AREA_WIDTH and
abs(scaled_y - event_data.y_pos) < CLICKABLE_AREA_HEIGHT / 2):
return i
return None
def _format_person_tooltip(self, person, person_events: List[TimelineEvent]) -> str:
"""
Format tooltip for person with multiple events.
Args:
person: The person object.
person_events: List of TimelineEvent objects for this person.
Returns:
str: Formatted tooltip text.
"""
person_name = name_displayer.display(person)
tooltip_text = f"{person_name}\n"
tooltip_text += "ā" * 30 + "\n"
# Sort by date
person_events_sorted = sorted(person_events, key=lambda x: x.date_sort)
# List all events for this person (date first)
for event_data in person_events_sorted:
date_str = get_date(event_data.event)
event_type_str = str(event_data.event_type)
tooltip_text += f"{date_str} - {event_type_str}\n"
# Add place if available
place_handle = event_data.event.get_place_handle()
if place_handle:
try:
place = self.dbstate.db.get_place_from_handle(place_handle)
if place:
place_name = place.get_title()
tooltip_text += f" š {place_name}\n"
except (AttributeError, KeyError) as e:
logger.debug(f"Error accessing place in tooltip: {e}")
return tooltip_text
def _format_single_event_tooltip(self, event, event_type: EventType) -> str:
"""
Format tooltip for single event.
Args:
event: The event object.
event_type: The type of event.
Returns:
str: Formatted tooltip text.
"""
date_str = get_date(event)
event_type_str = str(event_type)
tooltip_text = f"{date_str}\n{event_type_str}"
# Get place information
place_handle = event.get_place_handle()
if place_handle:
try:
place = self.dbstate.db.get_place_from_handle(place_handle)
if place:
place_name = place.get_title()
tooltip_text += f"\nš {place_name}"
except (AttributeError, KeyError) as e:
logger.debug(f"Error accessing place in tooltip: {e}")
# Get description
description = event.get_description()
if description:
tooltip_text += f"\n{description}"
return tooltip_text
def _get_or_create_tooltip_window(self) -> Gtk.Window:
"""
Get or create tooltip window (reuse if exists).
Returns:
Gtk.Window: The tooltip window.
"""
if not hasattr(self, 'tooltip_window') or self.tooltip_window is None:
self.tooltip_window = Gtk.Window(type=Gtk.WindowType.POPUP)
self.tooltip_window.set_border_width(8)
self.tooltip_window.set_decorated(False)
# Get parent window for proper display
toplevel = self.drawing_area.get_toplevel()
if isinstance(toplevel, Gtk.Window):
self.tooltip_window.set_transient_for(toplevel)
return self.tooltip_window
def show_tooltip(self, event_index: int, x_root: float, y_root: float) -> bool:
"""
Show tooltip for an event, including all events for that person.
Args:
event_index: Index of the event in self.events.
x_root: X coordinate in root window space.
y_root: Y coordinate in root window space.
Returns:
bool: False to indicate the timeout should not be repeated.
"""
if event_index is None or event_index >= len(self.events):
return False
event_data = self.events[event_index]
# If event has a person, show all events for that person
if event_data.person:
person_handle = event_data.person.get_handle()
# Find all events for this person
person_events = [
evt for evt in self.events
if self._person_matches_handle(evt.person, person_handle)
]
tooltip_text = self._format_person_tooltip(event_data.person, person_events)
else:
# Family event (no person) - show single event info
tooltip_text = self._format_single_event_tooltip(event_data.event, event_data.event_type)
# Get or create tooltip window
tooltip_window = self._get_or_create_tooltip_window()
# Clear existing content
for child in tooltip_window.get_children():
tooltip_window.remove(child)
# Create container with background
frame = Gtk.Frame()
frame.set_shadow_type(Gtk.ShadowType.OUT)
frame.get_style_context().add_class("tooltip")
label = Gtk.Label()
label.set_markup(tooltip_text)
label.set_line_wrap(True)
label.set_max_width_chars(40)
label.set_margin_start(5)
label.set_margin_end(5)
label.set_margin_top(5)
label.set_margin_bottom(5)
frame.add(label)
tooltip_window.add(frame)
tooltip_window.show_all()
# Position tooltip (offset to avoid cursor)
tooltip_window.move(int(x_root) + 15, int(y_root) + 15)
return False
def _draw_background(self, context: cairo.Context, width: float, height: float) -> None:
"""
Draw background gradient.
Args:
context: Cairo drawing context.
width: Widget width.
height: Widget height.
"""
pattern = cairo.LinearGradient(0, 0, 0, height)
pattern.add_color_stop_rgb(0, 0.98, 0.98, 0.99) # Very light gray-blue
pattern.add_color_stop_rgb(1, 0.95, 0.96, 0.98) # Slightly darker
context.set_source(pattern)
context.paint()
def _draw_no_events_message(self, context: cairo.Context, width: float, height: float) -> None:
"""
Draw "No events" message.
Args:
context: Cairo drawing context.
width: Widget width.
height: Widget height.
"""
context.set_source_rgb(0.6, 0.6, 0.6)
context.select_font_face(FONT_FAMILY, cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL)
context.set_font_size(FONT_SIZE_LARGE)
text = _("No events found")
(x_bearing, y_bearing, text_width, text_height, x_advance, y_advance) = context.text_extents(text)
context.move_to((width - text_width) / 2, height / 2)
context.show_text(text)
def _draw_timeline_axis(self, context: cairo.Context, timeline_x: float,
y_start: float, y_end: float) -> None:
"""
Draw timeline axis with shadow.
Args:
context: Cairo drawing context.
timeline_x: X coordinate of the timeline.
y_start: Y coordinate of the timeline start.
y_end: Y coordinate of the timeline end.
"""
# Draw shadow
context.set_source_rgba(0.0, 0.0, 0.0, 0.2)
context.set_line_width(TIMELINE_LINE_WIDTH)
context.move_to(timeline_x + 2, y_start + 2)
context.line_to(timeline_x + 2, y_end + 2)
context.stroke()
# Draw main line with gradient
pattern = cairo.LinearGradient(timeline_x, y_start, timeline_x, y_end)
pattern.add_color_stop_rgb(0, 0.3, 0.3, 0.3)
pattern.add_color_stop_rgb(1, 0.5, 0.5, 0.5)
context.set_source(pattern)
context.set_line_width(TIMELINE_LINE_WIDTH)
context.move_to(timeline_x, y_start)
context.line_to(timeline_x, y_end)
context.stroke()
def _draw_events(self, context: cairo.Context, events_with_y_pos: List[TimelineEvent],
timeline_x: float) -> None:
"""
Draw all events.
Args:
context: Cairo drawing context.
events_with_y_pos: List of TimelineEvent objects with Y positions.
timeline_x: X coordinate of the timeline.
"""
for i, event_data in enumerate(events_with_y_pos):
# Check if this event is hovered
is_hovered = (i == self.hovered_event_index)
# Check if this event belongs to selected person
is_selected = (self.selected_person_handle is not None and
self._person_matches_handle(event_data.person, self.selected_person_handle))
# Draw event marker with modern styling
self.draw_event_marker(context, timeline_x, event_data.y_pos,
event_data.event_type, is_hovered, is_selected)
# Draw event label
label_x = timeline_x + LABEL_X_OFFSET
self.draw_event_label(
context, label_x, event_data.y_pos, event_data.date_obj,
event_data.event, event_data.person, event_data.event_type, is_hovered
)
def on_draw(self, widget: Gtk.Widget, context: cairo.Context) -> bool:
"""
Draw the timeline.
Args:
widget: The drawing area widget.
context: Cairo drawing context.
Returns:
bool: True to indicate the draw was handled.
"""
# Apply zoom transformation
context.save()
context.scale(self.zoom_level, self.zoom_level)
# Get widget dimensions (adjusted for zoom)
width = widget.get_allocated_width() / self.zoom_level
height = widget.get_allocated_height() / self.zoom_level
# Draw background
self._draw_background(context, width, height)
if not self.events:
# Draw "No events" message
self._draw_no_events_message(context, width, height)
context.restore()
return True
# Calculate date range
min_date, max_date, date_range = self._calculate_date_range()
# Draw timeline axis
timeline_x = TIMELINE_MARGIN_LEFT
timeline_y_start = TIMELINE_MARGIN_TOP
timeline_y_end = height - TIMELINE_MARGIN_BOTTOM
self._draw_timeline_axis(context, timeline_x, timeline_y_start, timeline_y_end)
# Get adjusted events with collision detection (uses cache)
events_with_y_pos = self._get_adjusted_events(context, timeline_y_start, timeline_y_end)
# Draw events
self._draw_events(context, events_with_y_pos, timeline_x)
# Draw visual connections for selected person
if self.selected_person_handle is not None:
self.draw_person_connections(context, events_with_y_pos, timeline_x,
timeline_y_start, timeline_y_end)
# Draw year markers on the left
self.draw_year_markers(context, timeline_x, timeline_y_start, timeline_y_end,
min_date, max_date)
context.restore()
return True
def _draw_marker_shadow(self, context: cairo.Context, x: float, y: float,
size: float, shape: str) -> None:
"""
Draw shadow for marker.
Args:
context: Cairo drawing context.
x: X coordinate of marker center.
y: Y coordinate of marker center.
size: Size of the marker.
shape: Shape type ('triangle', 'circle', etc.).
"""
context.set_source_rgba(0.0, 0.0, 0.0, SHADOW_OPACITY)
context.translate(SHADOW_OFFSET_X, SHADOW_OFFSET_Y)
self._draw_shape(context, x, y, size, shape)
context.fill()
context.translate(-SHADOW_OFFSET_X, -SHADOW_OFFSET_Y)
def _draw_marker_gradient(self, context: cairo.Context, x: float, y: float,
size: float, color: Tuple[float, float, float],
shape: str) -> None:
"""
Draw gradient fill for marker.
Args:
context: Cairo drawing context.
x: X coordinate of marker center.
y: Y coordinate of marker center.
size: Size of the marker.
color: RGB color tuple (0.0-1.0).
shape: Shape type ('triangle', 'circle', etc.).
"""
pattern = cairo.RadialGradient(x - size/2, y - size/2, 0, x, y, size)
r, g, b = color
pattern.add_color_stop_rgb(0,
min(1.0, r + GRADIENT_BRIGHTNESS_OFFSET),
min(1.0, g + GRADIENT_BRIGHTNESS_OFFSET),
min(1.0, b + GRADIENT_BRIGHTNESS_OFFSET))
pattern.add_color_stop_rgb(1,
max(0.0, r - GRADIENT_DARKNESS_OFFSET),
max(0.0, g - GRADIENT_DARKNESS_OFFSET),
max(0.0, b - GRADIENT_DARKNESS_OFFSET))
context.set_source(pattern)
self._draw_shape(context, x, y, size, shape)
context.fill()
def draw_event_marker(self, context: cairo.Context, x: float, y: float,
event_type: EventType, is_hovered: bool = False,
is_selected: bool = False) -> None:
"""
Draw a marker for an event with modern styling.
Args:
context: Cairo drawing context.
x: X coordinate for marker center.
y: Y coordinate for marker center.
event_type: Type of event (determines color and shape).
is_hovered: Whether marker is currently hovered.
is_selected: Whether marker belongs to selected person.
"""
context.save()
# Get integer value from EventType object for dictionary lookup
event_type_value = event_type.value if hasattr(event_type, 'value') else int(event_type)
# Get color and shape
color = EVENT_COLORS.get(event_type_value, (0.5, 0.5, 0.5))
shape = EVENT_SHAPES.get(event_type_value, 'square')
marker_size = EVENT_MARKER_SIZE
# Increase size if hovered or selected
if is_hovered:
marker_size *= MARKER_HOVER_SIZE_MULTIPLIER
elif is_selected:
marker_size *= MARKER_SELECTED_SIZE_MULTIPLIER
# Use highlight color if selected
if is_selected:
color = SELECTED_MARKER_COLOR
# Draw shadow
self._draw_marker_shadow(context, x, y, marker_size, shape)
# Draw main shape with gradient
self._draw_marker_gradient(context, x, y, marker_size, color, shape)
# Draw border
context.set_source_rgba(0.0, 0.0, 0.0, BORDER_OPACITY)
context.set_line_width(1)
self._draw_shape(context, x, y, marker_size, shape)
context.stroke()
context.restore()
def _draw_triangle(self, context: cairo.Context, x: float, y: float, size: float) -> None:
"""Draw a triangle shape."""
context.move_to(x, y - size)
context.line_to(x - size, y + size)
context.line_to(x + size, y + size)
context.close_path()
def _draw_circle(self, context: cairo.Context, x: float, y: float, size: float) -> None:
"""Draw a circle shape."""
context.arc(x, y, size, 0, 2 * math.pi)
def _draw_diamond(self, context: cairo.Context, x: float, y: float, size: float) -> None:
"""Draw a diamond shape."""
context.move_to(x, y - size)
context.line_to(x + size, y)
context.line_to(x, y + size)
context.line_to(x - size, y)
context.close_path()
def _draw_square(self, context: cairo.Context, x: float, y: float, size: float) -> None:
"""Draw a square shape."""
context.rectangle(x - size, y - size, size * 2, size * 2)
def _draw_star(self, context: cairo.Context, x: float, y: float, size: float) -> None:
"""Draw a 5-pointed star shape."""
points = 5
outer_radius = size
inner_radius = size * 0.4
for i in range(points * 2):
angle = (i * math.pi) / points - math.pi / 2
radius = outer_radius if i % 2 == 0 else inner_radius
px = x + radius * math.cos(angle)
py = y + radius * math.sin(angle)
if i == 0:
context.move_to(px, py)
else:
context.line_to(px, py)
context.close_path()
def _draw_hexagon(self, context: cairo.Context, x: float, y: float, size: float) -> None:
"""Draw a hexagon shape."""
for i in range(6):
angle = (i * math.pi) / 3
px = x + size * math.cos(angle)
py = y + size * math.sin(angle)
if i == 0:
context.move_to(px, py)
else:
context.line_to(px, py)
context.close_path()
def _draw_shape(self, context: cairo.Context, x: float, y: float,
size: float, shape: str) -> None:
"""
Draw a shape at the given position.
Args:
context: Cairo drawing context.
x: X coordinate of shape center.
y: Y coordinate of shape center.
size: Size of the shape.
shape: Shape type ('triangle', 'circle', 'diamond', 'square', 'star', 'hexagon').
"""
shape_drawers = {
'triangle': self._draw_triangle,
'circle': self._draw_circle,
'diamond': self._draw_diamond,
'square': self._draw_square,
'star': self._draw_star,
'hexagon': self._draw_hexagon,
}
drawer = shape_drawers.get(shape, self._draw_square) # Default to square
drawer(context, x, y, size)
def draw_event_label(self, context: cairo.Context, x: float, y: float,
date_obj: Date, event, person: Optional[Any],
event_type: EventType, is_hovered: bool = False) -> None:
"""
Draw the label for an event with modern styling.
Args:
context: Cairo drawing context.
x: X coordinate for label start.
y: Y coordinate for label center.
date_obj: Date object for the event.
event: The event object.
person: The person associated with the event (None for family events).
event_type: The type of event.
is_hovered: Whether the label is currently hovered.
"""
context.save()
# Create Pango layout for text
layout = PangoCairo.create_layout(context)
# Use modern font
font_desc = Pango.font_description_from_string(f"{FONT_FAMILY} {FONT_SIZE_NORMAL}")
if is_hovered:
font_desc.set_weight(Pango.Weight.BOLD)
layout.set_font_description(font_desc)
# Build label text using centralized method
label_text = self._get_event_label_text(event, person, event_type)
layout.set_markup(label_text, -1)
layout.set_width(-1) # No width limit
# Draw background for hovered events
if is_hovered:
text_width, text_height = layout.get_pixel_size()
padding = LABEL_BACKGROUND_PADDING
# Draw rounded rectangle background
rect_x = x - padding
rect_y = y - text_height / 2 - padding
rect_width = text_width + padding * 2
rect_height = text_height + padding * 2
# Rounded rectangle
radius = LABEL_BACKGROUND_RADIUS
context.arc(rect_x + radius, rect_y + radius, radius, math.pi, 3 * math.pi / 2)
context.arc(rect_x + rect_width - radius, rect_y + radius, radius, 3 * math.pi / 2, 0)
context.arc(rect_x + rect_width - radius, rect_y + rect_height - radius, radius, 0, math.pi / 2)
context.arc(rect_x + radius, rect_y + rect_height - radius, radius, math.pi / 2, math.pi)
context.close_path()
# Fill with semi-transparent background
context.set_source_rgba(1.0, 1.0, 1.0, 0.8)
context.fill()
# Draw border
context.set_source_rgba(0.7, 0.7, 0.7, 0.5)
context.set_line_width(1)
context.stroke()
# Draw text
context.set_source_rgb(0.1, 0.1, 0.1) # Dark gray text
context.move_to(x, y - 10) # Center vertically on marker
PangoCairo.show_layout(context, layout)
context.restore()
def _find_year_range(self) -> Tuple[Optional[int], Optional[int]]:
"""
Find the minimum and maximum years from all events.
Returns:
Tuple[Optional[int], Optional[int]]: (min_year, max_year) or (None, None) if no valid years found.
"""
min_year = None
max_year = None
for event_data in self.events:
try:
year = event_data.date_obj.get_year()
if year and year != 0:
if min_year is None or year < min_year:
min_year = year
if max_year is None or year > max_year:
max_year = year
except (AttributeError, ValueError) as e:
logger.debug(f"Error accessing year from date: {e}")
return (min_year, max_year)
def _calculate_year_y_position(self, year: int, min_date: int, max_date: int,
y_start: float, y_end: float) -> float:
"""
Calculate Y position for a year marker.
Args:
year: The year to calculate position for.
min_date: Minimum date sort value.
max_date: Maximum date sort value.
y_start: Y coordinate of the timeline start.
y_end: Y coordinate of the timeline end.
Returns:
float: The Y position for the year marker.
"""
year_date = Date()
year_date.set_yr_mon_day(year, 1, 1)
year_sort = year_date.get_sort_value()
if min_date == max_date:
return (y_start + y_end) / 2
else:
return y_start + (
(year_sort - min_date) / (max_date - min_date)
) * (y_end - y_start)
def _draw_year_marker(self, context: cairo.Context, timeline_x: float,
year: int, y_pos: float) -> None:
"""
Draw a single year marker (tick mark and label).
Args:
context: Cairo drawing context.
timeline_x: X coordinate of the timeline.
year: The year to draw.
y_pos: Y position for the marker.
"""
# Draw tick mark
context.set_source_rgb(0.5, 0.5, 0.5) # Gray
context.set_line_width(1)
context.move_to(timeline_x - 10, y_pos)
context.line_to(timeline_x, y_pos)
context.stroke()
# Draw year label
layout = PangoCairo.create_layout(context)
layout.set_font_description(
Pango.font_description_from_string(f"{FONT_FAMILY} {FONT_SIZE_SMALL}")
)
layout.set_text(str(year), -1)
# Get text size in pixels
text_width, text_height = layout.get_pixel_size()
context.set_source_rgb(0.0, 0.0, 0.0) # Black
context.move_to(timeline_x - 20 - text_width, y_pos - text_height / 2)
PangoCairo.show_layout(context, layout)
def draw_year_markers(self, context: cairo.Context, timeline_x: float,
y_start: float, y_end: float, min_date: int,
max_date: int) -> None:
"""
Draw year markers on the left side of the timeline.
Args:
context: Cairo drawing context.
timeline_x: X coordinate of the timeline.
y_start: Y coordinate of the timeline start.
y_end: Y coordinate of the timeline end.
min_date: Minimum date sort value.
max_date: Maximum date sort value.
"""
context.save()
# Find min and max years from events
min_year, max_year = self._find_year_range()
if min_year is None or max_year is None:
context.restore()
return
# Draw markers for major years (every 10 years or so)
year_step = max(1, (max_year - min_year) // 10)
if year_step == 0:
year_step = 1
for year in range(min_year, max_year + 1, year_step):
# Calculate Y position
y_pos = self._calculate_year_y_position(year, min_date, max_date, y_start, y_end)
# Only draw if within visible range
if y_pos < y_start or y_pos > y_end:
continue
# Draw the year marker
self._draw_year_marker(context, timeline_x, year, y_pos)
context.restore()
def draw_person_connections(self, context: cairo.Context,
events_with_y_pos: List[TimelineEvent],
timeline_x: float, timeline_y_start: float,
timeline_y_end: float) -> None:
"""
Draw visual connections between all events of the selected person.
Args:
context: Cairo drawing context.
events_with_y_pos: List of TimelineEvent objects with Y positions.
timeline_x: X coordinate of the timeline.
timeline_y_start: Y coordinate of the timeline start.
timeline_y_end: Y coordinate of the timeline end.
"""
if not self.selected_person_handle:
return
# Find all events for the selected person
person_events = [
event_data for event_data in events_with_y_pos
if self._person_matches_handle(event_data.person, self.selected_person_handle)
]
if len(person_events) < 1:
return
# Sort by Y position
person_events.sort(key=lambda x: x.y_pos)
context.save()
# Position vertical line to the left of year markers
# Year labels are positioned at timeline_x - 20 - text_width (around x=90-130)
# Position vertical line at CONNECTION_VERTICAL_LINE_X to be clearly left of all year markers
vertical_line_x = CONNECTION_VERTICAL_LINE_X
# Draw connecting lines - more visible with brighter color and increased opacity
context.set_source_rgba(*CONNECTION_LINE_COLOR)
context.set_line_width(CONNECTION_LINE_WIDTH)
context.set_line_cap(cairo.LINE_CAP_ROUND)
context.set_line_join(cairo.LINE_JOIN_ROUND)
# Draw vertical connector line on the left side
if len(person_events) > 1:
y_positions = [event_data.y_pos for event_data in person_events]
min_y = min(y_positions)
max_y = max(y_positions)
# Draw vertical line connecting all events
if max_y - min_y > EVENT_MARKER_SIZE * 2:
context.set_source_rgba(*CONNECTION_LINE_COLOR)
context.set_line_width(CONNECTION_LINE_WIDTH)
context.move_to(vertical_line_x, min_y)
context.line_to(vertical_line_x, max_y)
context.stroke()
# Draw horizontal lines connecting vertical line to each event marker
context.set_source_rgba(*CONNECTION_LINE_COLOR)
context.set_line_width(CONNECTION_LINE_WIDTH)
for event_data in person_events:
# Draw horizontal line from vertical line to event marker
context.move_to(vertical_line_x, event_data.y_pos)
context.line_to(timeline_x, event_data.y_pos)
context.stroke()
context.restore()
def get_stock(self) -> str:
"""
Return the stock icon name.
Returns:
str: The stock icon name for this view.
"""
return "gramps-family"