- Add comprehensive docstring for __init__ method - Initialize _temp_surface in __init__ instead of lazy initialization - Extract font constants (FONT_FAMILY, FONT_SIZE_NORMAL, FONT_SIZE_SMALL, FONT_SIZE_LARGE) - Replace all hardcoded font strings with constants - All methods now have docstrings (100% coverage) Benefits: - Better initialization clarity - Centralized font configuration - Complete documentation coverage - More maintainable code
1845 lines
68 KiB
Python
1845 lines
68 KiB
Python
# -*- 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 <https://www.gnu.org/licenses/>.
|
|
#
|
|
|
|
"""
|
|
MyTimeline View - A vertical timeline showing family events
|
|
"""
|
|
|
|
# -------------------------------------------------------------------------
|
|
#
|
|
# Python modules
|
|
#
|
|
# -------------------------------------------------------------------------
|
|
import cairo
|
|
import logging
|
|
import math
|
|
from dataclasses import dataclass
|
|
from typing import Optional, List, Tuple, Any
|
|
|
|
from gi.repository import Gtk
|
|
from gi.repository import Gdk
|
|
from gi.repository import GLib
|
|
from gi.repository import Pango
|
|
from gi.repository import PangoCairo
|
|
|
|
# -------------------------------------------------------------------------
|
|
#
|
|
# Gramps modules
|
|
#
|
|
# -------------------------------------------------------------------------
|
|
from gramps.gen.const import GRAMPS_LOCALE as glocale
|
|
from gramps.gen.lib import EventType, Date
|
|
from gramps.gen.utils.db import (
|
|
get_birth_or_fallback,
|
|
get_death_or_fallback,
|
|
get_marriage_or_fallback,
|
|
)
|
|
from gramps.gen.datehandler import get_date
|
|
from gramps.gen.display.name import displayer as name_displayer
|
|
from gramps.gui.views.navigationview import NavigationView
|
|
from gramps.gui.views.bookmarks import FamilyBookmarks
|
|
from gramps.gen.utils.libformatting import FormattingHelper
|
|
|
|
_ = glocale.translation.sgettext
|
|
|
|
# -------------------------------------------------------------------------
|
|
#
|
|
# Constants
|
|
#
|
|
# -------------------------------------------------------------------------
|
|
# Timeline Layout Constants
|
|
TIMELINE_MARGIN_LEFT = 150
|
|
TIMELINE_MARGIN_RIGHT = 50
|
|
TIMELINE_MARGIN_TOP = 50
|
|
TIMELINE_MARGIN_BOTTOM = 50
|
|
TIMELINE_LINE_WIDTH = 3
|
|
|
|
# Event Display Constants
|
|
EVENT_MARKER_SIZE = 10
|
|
EVENT_SPACING = 80
|
|
LABEL_X_OFFSET = 25
|
|
MIN_LABEL_SPACING = 30
|
|
LABEL_PADDING = 16
|
|
MARKER_CLICK_PADDING = 10
|
|
CLICKABLE_AREA_WIDTH = 600
|
|
CLICKABLE_AREA_HEIGHT = 30
|
|
|
|
# UI Constants
|
|
YEAR_LABEL_WIDTH = 100
|
|
TOOLTIP_DELAY = 500 # milliseconds
|
|
TOOLTIP_MAX_WIDTH = 500
|
|
LABEL_BACKGROUND_PADDING = 8
|
|
LABEL_BACKGROUND_RADIUS = 5
|
|
|
|
# Font Constants
|
|
FONT_FAMILY = "Sans"
|
|
FONT_SIZE_NORMAL = 11
|
|
FONT_SIZE_SMALL = 9
|
|
FONT_SIZE_LARGE = 24
|
|
|
|
# Visual Effect Constants
|
|
MARKER_HOVER_SIZE_MULTIPLIER = 1.3
|
|
MARKER_SELECTED_SIZE_MULTIPLIER = 1.2
|
|
GRADIENT_BRIGHTNESS_OFFSET = 0.2
|
|
GRADIENT_DARKNESS_OFFSET = 0.1
|
|
SHADOW_OFFSET_X = 1
|
|
SHADOW_OFFSET_Y = 1
|
|
SHADOW_OPACITY = 0.3
|
|
BORDER_OPACITY = 0.3
|
|
|
|
# Connection Line Constants
|
|
CONNECTION_LINE_COLOR = (0.2, 0.5, 1.0, 0.75) # Brighter, more opaque blue
|
|
CONNECTION_LINE_WIDTH = 3.5
|
|
CONNECTION_VERTICAL_LINE_X = 5 # Left of year markers
|
|
|
|
# Marker State Colors
|
|
SELECTED_MARKER_COLOR = (0.2, 0.4, 0.9) # Blue highlight for selected person's events
|
|
|
|
# Logger
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Event type categories and color mapping
|
|
EVENT_COLORS = {
|
|
# Life Events - Green/Red spectrum
|
|
EventType.BIRTH: (0.2, 0.8, 0.3), # Bright green
|
|
EventType.DEATH: (0.9, 0.2, 0.2), # Bright red
|
|
EventType.BURIAL: (0.7, 0.3, 0.3), # Dark red
|
|
EventType.CREMATION: (0.8, 0.4, 0.4), # Light red
|
|
EventType.ADOPT: (0.3, 0.7, 0.4), # Green
|
|
|
|
# Family Events - Blue/Purple spectrum
|
|
EventType.MARRIAGE: (0.2, 0.4, 0.9), # Blue
|
|
EventType.DIVORCE: (0.6, 0.3, 0.7), # Purple
|
|
EventType.ENGAGEMENT: (0.4, 0.5, 0.9), # Light blue
|
|
EventType.MARR_SETTL: (0.3, 0.4, 0.8), # Dark blue
|
|
EventType.MARR_LIC: (0.4, 0.5, 0.85), # Medium blue
|
|
EventType.MARR_CONTR: (0.35, 0.45, 0.85), # Medium blue
|
|
EventType.MARR_BANNS: (0.45, 0.55, 0.9), # Light blue
|
|
EventType.DIV_FILING: (0.65, 0.35, 0.75), # Light purple
|
|
EventType.ANNULMENT: (0.7, 0.4, 0.8), # Light purple
|
|
EventType.MARR_ALT: (0.5, 0.5, 0.9), # Medium blue
|
|
|
|
# Religious Events - Gold/Yellow spectrum
|
|
EventType.BAPTISM: (0.95, 0.85, 0.2), # Gold
|
|
EventType.ADULT_CHRISTEN: (0.9, 0.8, 0.3), # Light gold
|
|
EventType.CONFIRMATION: (0.95, 0.9, 0.4), # Yellow
|
|
EventType.CHRISTEN: (0.9, 0.85, 0.35), # Gold
|
|
EventType.FIRST_COMMUN: (0.95, 0.88, 0.3), # Gold
|
|
EventType.BLESS: (0.92, 0.87, 0.4), # Light gold
|
|
EventType.BAR_MITZVAH: (0.93, 0.83, 0.3), # Gold
|
|
EventType.BAS_MITZVAH: (0.94, 0.84, 0.3), # Gold
|
|
EventType.RELIGION: (0.9, 0.8, 0.5), # Light gold
|
|
EventType.ORDINATION: (0.88, 0.75, 0.3), # Dark gold
|
|
|
|
# Vocational Events - Orange spectrum
|
|
EventType.OCCUPATION: (0.95, 0.6, 0.2), # Orange
|
|
EventType.RETIREMENT: (0.9, 0.55, 0.25), # Dark orange
|
|
EventType.ELECTED: (0.95, 0.65, 0.3), # Light orange
|
|
EventType.MILITARY_SERV: (0.85, 0.5, 0.2), # Dark orange
|
|
|
|
# Academic Events - Teal spectrum
|
|
EventType.EDUCATION: (0.2, 0.7, 0.7), # Teal
|
|
EventType.GRADUATION: (0.3, 0.8, 0.8), # Bright teal
|
|
EventType.DEGREE: (0.25, 0.75, 0.75), # Medium teal
|
|
|
|
# Travel Events - Cyan spectrum
|
|
EventType.EMIGRATION: (0.2, 0.7, 0.9), # Cyan
|
|
EventType.IMMIGRATION: (0.3, 0.8, 0.95), # Bright cyan
|
|
EventType.NATURALIZATION: (0.25, 0.75, 0.9), # Medium cyan
|
|
|
|
# Legal Events - Brown spectrum
|
|
EventType.PROBATE: (0.6, 0.4, 0.2), # Brown
|
|
EventType.WILL: (0.65, 0.45, 0.25), # Light brown
|
|
|
|
# Residence Events - Indigo spectrum
|
|
EventType.RESIDENCE: (0.4, 0.3, 0.7), # Indigo
|
|
EventType.CENSUS: (0.5, 0.4, 0.8), # Light indigo
|
|
EventType.PROPERTY: (0.45, 0.35, 0.75), # Medium indigo
|
|
|
|
# Other Events - Gray spectrum
|
|
EventType.CAUSE_DEATH: (0.5, 0.5, 0.5), # Gray
|
|
EventType.MED_INFO: (0.6, 0.6, 0.6), # Light gray
|
|
EventType.NOB_TITLE: (0.55, 0.55, 0.55), # Medium gray
|
|
EventType.NUM_MARRIAGES: (0.5, 0.5, 0.5), # Gray
|
|
EventType.UNKNOWN: (0.4, 0.4, 0.4), # Dark gray
|
|
EventType.CUSTOM: (0.45, 0.45, 0.45), # Medium gray
|
|
}
|
|
|
|
# Event shape types: 'triangle', 'circle', 'diamond', 'square', 'star', 'hexagon'
|
|
EVENT_SHAPES = {
|
|
# Life Events
|
|
EventType.BIRTH: 'triangle',
|
|
EventType.DEATH: 'circle',
|
|
EventType.BURIAL: 'circle',
|
|
EventType.CREMATION: 'circle',
|
|
EventType.ADOPT: 'triangle',
|
|
|
|
# Family Events
|
|
EventType.MARRIAGE: 'diamond',
|
|
EventType.DIVORCE: 'square',
|
|
EventType.ENGAGEMENT: 'diamond',
|
|
EventType.MARR_SETTL: 'diamond',
|
|
EventType.MARR_LIC: 'diamond',
|
|
EventType.MARR_CONTR: 'diamond',
|
|
EventType.MARR_BANNS: 'diamond',
|
|
EventType.DIV_FILING: 'square',
|
|
EventType.ANNULMENT: 'square',
|
|
EventType.MARR_ALT: 'diamond',
|
|
|
|
# Religious Events
|
|
EventType.BAPTISM: 'star',
|
|
EventType.ADULT_CHRISTEN: 'star',
|
|
EventType.CONFIRMATION: 'star',
|
|
EventType.CHRISTEN: 'star',
|
|
EventType.FIRST_COMMUN: 'star',
|
|
EventType.BLESS: 'star',
|
|
EventType.BAR_MITZVAH: 'star',
|
|
EventType.BAS_MITZVAH: 'star',
|
|
EventType.RELIGION: 'star',
|
|
EventType.ORDINATION: 'star',
|
|
|
|
# Vocational Events
|
|
EventType.OCCUPATION: 'hexagon',
|
|
EventType.RETIREMENT: 'hexagon',
|
|
EventType.ELECTED: 'hexagon',
|
|
EventType.MILITARY_SERV: 'hexagon',
|
|
|
|
# Academic Events
|
|
EventType.EDUCATION: 'square',
|
|
EventType.GRADUATION: 'square',
|
|
EventType.DEGREE: 'square',
|
|
|
|
# Travel Events
|
|
EventType.EMIGRATION: 'triangle',
|
|
EventType.IMMIGRATION: 'triangle',
|
|
EventType.NATURALIZATION: 'triangle',
|
|
|
|
# Legal Events
|
|
EventType.PROBATE: 'square',
|
|
EventType.WILL: 'square',
|
|
|
|
# Residence Events
|
|
EventType.RESIDENCE: 'square',
|
|
EventType.CENSUS: 'square',
|
|
EventType.PROPERTY: 'square',
|
|
|
|
# Other Events
|
|
EventType.CAUSE_DEATH: 'circle',
|
|
EventType.MED_INFO: 'circle',
|
|
EventType.NOB_TITLE: 'square',
|
|
EventType.NUM_MARRIAGES: 'square',
|
|
EventType.UNKNOWN: 'square',
|
|
EventType.CUSTOM: 'square',
|
|
}
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
#
|
|
# Data Classes
|
|
#
|
|
# -------------------------------------------------------------------------
|
|
@dataclass
|
|
class TimelineEvent:
|
|
"""Represents an event in the timeline with all necessary data."""
|
|
date_sort: int
|
|
date_obj: Date
|
|
event: 'Any' # gramps.gen.lib.Event
|
|
person: Optional['Any'] # gramps.gen.lib.Person
|
|
event_type: EventType
|
|
y_pos: float = 0.0
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
#
|
|
# MyTimelineView
|
|
#
|
|
# -------------------------------------------------------------------------
|
|
class MyTimelineView(NavigationView):
|
|
"""
|
|
View for displaying a vertical timeline of family events.
|
|
Shows all events for family members with modern design and interactivity.
|
|
"""
|
|
|
|
def __init__(self, pdata, dbstate, uistate, nav_group=0):
|
|
"""
|
|
Initialize the MyTimeline view.
|
|
|
|
Args:
|
|
pdata: Plugin data object.
|
|
dbstate: Database state object.
|
|
uistate: UI state object.
|
|
nav_group: Navigation group identifier.
|
|
"""
|
|
NavigationView.__init__(
|
|
self, _("MyTimeline"), pdata, dbstate, uistate, FamilyBookmarks, nav_group
|
|
)
|
|
|
|
self.dbstate = dbstate
|
|
self.uistate = uistate
|
|
self.format_helper = FormattingHelper(self.dbstate, self.uistate)
|
|
|
|
# Current family handle
|
|
self.active_family_handle = None
|
|
self.events: List[TimelineEvent] = [] # List of TimelineEvent objects
|
|
|
|
# UI components
|
|
self.scrolledwindow = None
|
|
self.drawing_area = None
|
|
self.timeline_height = 1000 # Default height, will be recalculated
|
|
self.zoom_level = 1.0 # Zoom level (1.0 = 100%)
|
|
self.min_zoom = 0.5
|
|
self.max_zoom = 3.0
|
|
self.zoom_step = 0.1
|
|
|
|
# Interaction state
|
|
self.hovered_event_index = None
|
|
self.tooltip_timeout_id = None
|
|
self.tooltip_window = None
|
|
self.selected_person_handle = None
|
|
self.mouse_x = 0
|
|
self.mouse_y = 0
|
|
|
|
# Performance cache
|
|
self._adjusted_events_cache: Optional[List[TimelineEvent]] = None
|
|
self._cache_key: Optional[int] = None
|
|
self._cached_date_range: Optional[int] = None
|
|
self._cached_min_date: Optional[int] = None
|
|
self._cached_max_date: Optional[int] = None
|
|
|
|
# Initialize temporary surface for text measurement (used in find_event_at_position)
|
|
self._temp_surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, 1, 1)
|
|
|
|
# Connect to database changes
|
|
self.dbstate.connect("database-changed", self.change_db)
|
|
# Connect to family updates
|
|
if self.dbstate.is_open():
|
|
self.dbstate.db.connect("family-update", self.family_updated)
|
|
self.dbstate.db.connect("person-update", self.person_updated)
|
|
self.dbstate.db.connect("event-update", self.event_updated)
|
|
|
|
def navigation_type(self) -> str:
|
|
"""
|
|
Return the navigation type for this view.
|
|
|
|
Returns:
|
|
str: The navigation type, always "Family" for this view.
|
|
"""
|
|
return "Family"
|
|
|
|
def change_page(self) -> None:
|
|
"""
|
|
Called when the page changes.
|
|
|
|
Updates the view to show the active family's timeline.
|
|
"""
|
|
NavigationView.change_page(self)
|
|
active_handle = self.get_active()
|
|
if active_handle:
|
|
self.goto_handle(active_handle)
|
|
|
|
def family_updated(self, handle_list: List[str]) -> None:
|
|
"""
|
|
Called when a family is updated.
|
|
|
|
Args:
|
|
handle_list: List of family handles that were updated.
|
|
"""
|
|
if self.active_family_handle in handle_list:
|
|
self.collect_events()
|
|
if self.drawing_area:
|
|
self.drawing_area.queue_draw()
|
|
|
|
def person_updated(self, handle_list: List[str]) -> None:
|
|
"""
|
|
Called when a person is updated.
|
|
|
|
Args:
|
|
handle_list: List of person handles that were updated.
|
|
"""
|
|
# Check if any updated person is related to current family
|
|
if self.active_family_handle:
|
|
try:
|
|
family = self.dbstate.db.get_family_from_handle(self.active_family_handle)
|
|
if family:
|
|
father_handle = family.get_father_handle()
|
|
mother_handle = family.get_mother_handle()
|
|
child_handles = [ref.ref for ref in family.get_child_ref_list()]
|
|
if (
|
|
(father_handle and father_handle in handle_list)
|
|
or (mother_handle and mother_handle in handle_list)
|
|
or any(h in handle_list for h in child_handles)
|
|
):
|
|
self.collect_events()
|
|
if self.drawing_area:
|
|
self.drawing_area.queue_draw()
|
|
except (AttributeError, KeyError) as e:
|
|
# Skip if family handle is invalid
|
|
logger.warning(f"Error accessing family in person_updated: {e}", exc_info=True)
|
|
|
|
def event_updated(self, handle_list: List[str]) -> None:
|
|
"""
|
|
Called when an event is updated.
|
|
|
|
Args:
|
|
handle_list: List of event handles that were updated.
|
|
"""
|
|
# Re-collect events if we have an active family
|
|
if self.active_family_handle:
|
|
self.collect_events()
|
|
if self.drawing_area:
|
|
self.drawing_area.queue_draw()
|
|
|
|
def change_db(self, db) -> None:
|
|
"""
|
|
Called when the database changes.
|
|
|
|
Args:
|
|
db: The new database object.
|
|
"""
|
|
self.active_family_handle = None
|
|
self.events = []
|
|
|
|
# Connect to new database signals
|
|
if db and db.is_open():
|
|
db.connect("family-update", self.family_updated)
|
|
db.connect("person-update", self.person_updated)
|
|
db.connect("event-update", self.event_updated)
|
|
|
|
if self.drawing_area:
|
|
self.drawing_area.queue_draw()
|
|
|
|
def build_widget(self) -> Gtk.Widget:
|
|
"""
|
|
Build the interface and return the container.
|
|
|
|
Returns:
|
|
Gtk.Widget: The main container widget with toolbar and drawing area.
|
|
"""
|
|
# Main container
|
|
main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5)
|
|
|
|
# Toolbar with zoom controls
|
|
toolbar = Gtk.Toolbar()
|
|
toolbar.set_style(Gtk.ToolbarStyle.ICONS)
|
|
|
|
# Zoom out button
|
|
zoom_out_btn = Gtk.ToolButton(icon_name="zoom-out-symbolic")
|
|
zoom_out_btn.set_tooltip_text(_("Zoom Out"))
|
|
zoom_out_btn.connect("clicked", self.on_zoom_out)
|
|
toolbar.insert(zoom_out_btn, 0)
|
|
|
|
# Zoom label
|
|
self.zoom_label = Gtk.Label(label="100%")
|
|
self.zoom_label.set_margin_start(10)
|
|
self.zoom_label.set_margin_end(10)
|
|
zoom_item = Gtk.ToolItem()
|
|
zoom_item.add(self.zoom_label)
|
|
toolbar.insert(zoom_item, 1)
|
|
|
|
# Zoom in button
|
|
zoom_in_btn = Gtk.ToolButton(icon_name="zoom-in-symbolic")
|
|
zoom_in_btn.set_tooltip_text(_("Zoom In"))
|
|
zoom_in_btn.connect("clicked", self.on_zoom_in)
|
|
toolbar.insert(zoom_in_btn, 2)
|
|
|
|
# Reset zoom button
|
|
zoom_reset_btn = Gtk.ToolButton(icon_name="zoom-fit-best-symbolic")
|
|
zoom_reset_btn.set_tooltip_text(_("Reset Zoom"))
|
|
zoom_reset_btn.connect("clicked", self.on_zoom_reset)
|
|
toolbar.insert(zoom_reset_btn, 3)
|
|
|
|
toolbar.insert(Gtk.SeparatorToolItem(), 4)
|
|
|
|
main_box.pack_start(toolbar, False, False, 0)
|
|
|
|
# Scrolled window
|
|
self.scrolledwindow = Gtk.ScrolledWindow()
|
|
self.scrolledwindow.set_policy(
|
|
Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC
|
|
)
|
|
|
|
self.drawing_area = Gtk.DrawingArea()
|
|
self.drawing_area.set_size_request(800, 600)
|
|
self.drawing_area.connect("draw", self.on_draw)
|
|
self.drawing_area.add_events(
|
|
Gdk.EventMask.BUTTON_PRESS_MASK
|
|
| Gdk.EventMask.BUTTON_RELEASE_MASK
|
|
| Gdk.EventMask.POINTER_MOTION_MASK
|
|
| Gdk.EventMask.LEAVE_NOTIFY_MASK
|
|
| Gdk.EventMask.SCROLL_MASK
|
|
)
|
|
|
|
# Connect mouse events
|
|
self.drawing_area.connect("button-press-event", self.on_button_press)
|
|
self.drawing_area.connect("motion-notify-event", self.on_motion_notify)
|
|
self.drawing_area.connect("leave-notify-event", self.on_leave_notify)
|
|
self.drawing_area.connect("scroll-event", self.on_scroll)
|
|
|
|
self.scrolledwindow.add(self.drawing_area)
|
|
main_box.pack_start(self.scrolledwindow, True, True, 0)
|
|
|
|
return main_box
|
|
|
|
def build_tree(self) -> None:
|
|
"""
|
|
Rebuilds the current display. Called when the view becomes visible.
|
|
"""
|
|
active_handle = self.get_active()
|
|
if active_handle:
|
|
self.goto_handle(active_handle)
|
|
|
|
def goto_handle(self, handle: str) -> None:
|
|
"""
|
|
Called when the active family changes.
|
|
|
|
Args:
|
|
handle: The handle of the family to display.
|
|
"""
|
|
if handle == self.active_family_handle:
|
|
return
|
|
|
|
self.active_family_handle = handle
|
|
self.collect_events()
|
|
if self.drawing_area:
|
|
self.drawing_area.queue_draw()
|
|
|
|
def _collect_person_event_refs(self, person) -> List:
|
|
"""
|
|
Collect event references from a person.
|
|
|
|
Args:
|
|
person: The person object to collect events from.
|
|
|
|
Returns:
|
|
List: List of event references.
|
|
"""
|
|
if not person:
|
|
return []
|
|
try:
|
|
return person.get_event_ref_list()
|
|
except (AttributeError, KeyError) as e:
|
|
logger.warning(f"Error accessing event references for person: {e}", exc_info=True)
|
|
return []
|
|
|
|
def _process_event_ref(self, event_ref, person_obj: Optional[Any]) -> Optional[TimelineEvent]:
|
|
"""
|
|
Process a single event reference and create a TimelineEvent.
|
|
|
|
Args:
|
|
event_ref: The event reference to process.
|
|
person_obj: The person object associated with this event (None for family events).
|
|
|
|
Returns:
|
|
Optional[TimelineEvent]: The created TimelineEvent, or None if invalid.
|
|
"""
|
|
try:
|
|
event = self.dbstate.db.get_event_from_handle(event_ref.ref)
|
|
if event and event.get_date_object():
|
|
date_obj = event.get_date_object()
|
|
event_type = event.get_type()
|
|
return TimelineEvent(
|
|
date_sort=date_obj.get_sort_value(),
|
|
date_obj=date_obj,
|
|
event=event,
|
|
person=person_obj,
|
|
event_type=event_type,
|
|
y_pos=0.0
|
|
)
|
|
except (AttributeError, KeyError, ValueError) as e:
|
|
logger.debug(f"Skipping invalid event reference: {e}")
|
|
return None
|
|
|
|
def _collect_person_events(self, person, person_obj) -> None:
|
|
"""
|
|
Collect all events from a person and add them to self.events.
|
|
|
|
Args:
|
|
person: The person object to collect events from.
|
|
person_obj: The person object to associate with events.
|
|
"""
|
|
if not person:
|
|
return
|
|
|
|
event_refs = self._collect_person_event_refs(person)
|
|
for event_ref in event_refs:
|
|
timeline_event = self._process_event_ref(event_ref, person_obj)
|
|
if timeline_event:
|
|
self.events.append(timeline_event)
|
|
|
|
def _collect_family_member_events(self, handle: Optional[str], role: str) -> None:
|
|
"""
|
|
Collect events for a family member (father, mother, or child).
|
|
|
|
Args:
|
|
handle: The person handle to collect events from.
|
|
role: Role name for logging purposes ('father', 'mother', 'child').
|
|
"""
|
|
if not handle:
|
|
return
|
|
|
|
try:
|
|
person = self.dbstate.db.get_person_from_handle(handle)
|
|
if person:
|
|
self._collect_person_events(person, person)
|
|
except (AttributeError, KeyError) as e:
|
|
logger.debug(f"Error accessing {role}: {e}")
|
|
|
|
def _collect_family_events(self, family) -> None:
|
|
"""
|
|
Collect family-level events (marriage, divorce, etc.).
|
|
|
|
Args:
|
|
family: The family object to collect events from.
|
|
"""
|
|
for event_ref in family.get_event_ref_list():
|
|
timeline_event = self._process_event_ref(event_ref, None)
|
|
if timeline_event:
|
|
self.events.append(timeline_event)
|
|
|
|
def _invalidate_cache(self) -> None:
|
|
"""Invalidate all caches when events change."""
|
|
self._adjusted_events_cache = None
|
|
self._cache_key = None
|
|
self._cached_date_range = None
|
|
self._cached_min_date = None
|
|
self._cached_max_date = None
|
|
|
|
def _calculate_timeline_height(self) -> None:
|
|
"""Calculate and set timeline height based on number of events and zoom."""
|
|
if self.events:
|
|
base_height = (
|
|
TIMELINE_MARGIN_TOP
|
|
+ len(self.events) * EVENT_SPACING
|
|
+ TIMELINE_MARGIN_BOTTOM
|
|
)
|
|
self.timeline_height = int(base_height * self.zoom_level)
|
|
else:
|
|
self.timeline_height = int(600 * self.zoom_level)
|
|
|
|
if self.drawing_area:
|
|
self.drawing_area.set_size_request(800, self.timeline_height)
|
|
|
|
def collect_events(self) -> None:
|
|
"""Collect all events for the active family."""
|
|
self.events = []
|
|
|
|
if not self.active_family_handle:
|
|
return
|
|
|
|
try:
|
|
family = self.dbstate.db.get_family_from_handle(self.active_family_handle)
|
|
except (AttributeError, KeyError) as e:
|
|
logger.warning(f"Error accessing family: {e}", exc_info=True)
|
|
return
|
|
|
|
if not family:
|
|
return
|
|
|
|
# Get family events (marriage, divorce, etc.)
|
|
self._collect_family_events(family)
|
|
|
|
# Get father's events
|
|
self._collect_family_member_events(family.get_father_handle(), "father")
|
|
|
|
# Get mother's events
|
|
self._collect_family_member_events(family.get_mother_handle(), "mother")
|
|
|
|
# Get children's events
|
|
for child_ref in family.get_child_ref_list():
|
|
self._collect_family_member_events(child_ref.ref, "child")
|
|
|
|
# Sort events by date
|
|
self.events.sort(key=lambda x: x.date_sort)
|
|
|
|
# Invalidate cache when events change
|
|
self._invalidate_cache()
|
|
|
|
# Calculate timeline height
|
|
self._calculate_timeline_height()
|
|
|
|
def _calculate_date_range(self) -> Tuple[int, int, int]:
|
|
"""
|
|
Calculate date range from events.
|
|
|
|
Returns:
|
|
Tuple[int, int, int]: (min_date, max_date, date_range)
|
|
"""
|
|
if not self.events:
|
|
return (0, 0, 1)
|
|
|
|
if (self._cached_min_date is not None and
|
|
self._cached_max_date is not None and
|
|
self._cached_date_range is not None):
|
|
return (self._cached_min_date, self._cached_max_date, self._cached_date_range)
|
|
|
|
min_date = min(event.date_sort for event in self.events)
|
|
max_date = max(event.date_sort for event in self.events)
|
|
date_range = max_date - min_date if max_date != min_date else 1
|
|
|
|
self._cached_min_date = min_date
|
|
self._cached_max_date = max_date
|
|
self._cached_date_range = date_range
|
|
|
|
return (min_date, max_date, date_range)
|
|
|
|
def _calculate_y_position(self, date_sort: int, min_date: int, date_range: int,
|
|
timeline_y_start: float, timeline_y_end: float) -> float:
|
|
"""
|
|
Calculate Y position for an event based on date.
|
|
|
|
Args:
|
|
date_sort: The sort value of the event date.
|
|
min_date: The minimum date sort value.
|
|
date_range: The range of dates (max - min).
|
|
timeline_y_start: The Y coordinate of the timeline start.
|
|
timeline_y_end: The Y coordinate of the timeline end.
|
|
|
|
Returns:
|
|
float: The calculated Y position.
|
|
"""
|
|
return timeline_y_start + (
|
|
(date_sort - min_date) / date_range
|
|
) * (timeline_y_end - timeline_y_start)
|
|
|
|
def _get_event_label_text(self, event, person: Optional[Any], event_type: EventType) -> str:
|
|
"""
|
|
Generate label text for an event. Centralized logic.
|
|
|
|
Args:
|
|
event: The event object.
|
|
person: The person associated with the event (None for family events).
|
|
event_type: The type of event.
|
|
|
|
Returns:
|
|
str: The formatted label text.
|
|
"""
|
|
date_str = get_date(event)
|
|
event_type_str = str(event_type)
|
|
|
|
if person:
|
|
person_name = name_displayer.display(person)
|
|
return f"{date_str} - {event_type_str} - {person_name}"
|
|
else:
|
|
return f"{date_str} - {event_type_str}"
|
|
|
|
def _calculate_adjusted_positions(self, context: cairo.Context,
|
|
events_with_y_pos: List[TimelineEvent],
|
|
timeline_y_start: float,
|
|
timeline_y_end: float) -> List[TimelineEvent]:
|
|
"""
|
|
Calculate adjusted Y positions with collision detection.
|
|
Reusable by both drawing and click detection.
|
|
|
|
Args:
|
|
context: Cairo drawing context for text measurement.
|
|
events_with_y_pos: List of TimelineEvent objects with initial Y positions.
|
|
timeline_y_start: The Y coordinate of the timeline start.
|
|
timeline_y_end: The Y coordinate of the timeline end.
|
|
|
|
Returns:
|
|
List[TimelineEvent]: List of TimelineEvent objects with adjusted Y positions.
|
|
"""
|
|
if not events_with_y_pos:
|
|
return events_with_y_pos
|
|
|
|
# Create a temporary layout to measure text
|
|
layout = PangoCairo.create_layout(context)
|
|
layout.set_font_description(Pango.font_description_from_string("Sans 11"))
|
|
|
|
adjusted_events = []
|
|
|
|
for event_data in events_with_y_pos:
|
|
# Calculate label height using centralized text generation
|
|
label_text = self._get_event_label_text(event_data.event, event_data.person, event_data.event_type)
|
|
layout.set_text(label_text, -1)
|
|
text_width, text_height = layout.get_pixel_size()
|
|
label_height = text_height + LABEL_PADDING
|
|
|
|
# Check for overlap with previous events
|
|
adjusted_y = event_data.y_pos
|
|
for prev_event in adjusted_events:
|
|
# Check if labels would overlap
|
|
if abs(adjusted_y - prev_event.y_pos) < MIN_LABEL_SPACING:
|
|
# Adjust downward
|
|
adjusted_y = prev_event.y_pos + MIN_LABEL_SPACING
|
|
|
|
# Ensure adjusted position is within bounds
|
|
adjusted_y = max(timeline_y_start, min(adjusted_y, timeline_y_end))
|
|
|
|
# Create new event data with adjusted Y position
|
|
adjusted_event = TimelineEvent(
|
|
date_sort=event_data.date_sort,
|
|
date_obj=event_data.date_obj,
|
|
event=event_data.event,
|
|
person=event_data.person,
|
|
event_type=event_data.event_type,
|
|
y_pos=adjusted_y
|
|
)
|
|
adjusted_events.append(adjusted_event)
|
|
|
|
return adjusted_events
|
|
|
|
def detect_label_overlaps(self, context: cairo.Context,
|
|
events_with_y_pos: List[TimelineEvent],
|
|
timeline_y_start: float,
|
|
timeline_y_end: float) -> List[TimelineEvent]:
|
|
"""
|
|
Detect and adjust Y positions to prevent label overlaps.
|
|
|
|
Args:
|
|
context: Cairo drawing context.
|
|
events_with_y_pos: List of TimelineEvent objects with initial Y positions.
|
|
timeline_y_start: The Y coordinate of the timeline start.
|
|
timeline_y_end: The Y coordinate of the timeline end.
|
|
|
|
Returns:
|
|
List[TimelineEvent]: List of TimelineEvent objects with adjusted Y positions.
|
|
"""
|
|
# Use shared collision detection method
|
|
return self._calculate_adjusted_positions(context, events_with_y_pos, timeline_y_start, timeline_y_end)
|
|
|
|
def _recalculate_timeline_height(self) -> None:
|
|
"""
|
|
Recalculate timeline height based on current events and zoom level.
|
|
"""
|
|
if self.events:
|
|
base_height = (
|
|
TIMELINE_MARGIN_TOP
|
|
+ len(self.events) * EVENT_SPACING
|
|
+ TIMELINE_MARGIN_BOTTOM
|
|
)
|
|
self.timeline_height = int(base_height * self.zoom_level)
|
|
else:
|
|
self.timeline_height = int(600 * self.zoom_level)
|
|
|
|
if self.drawing_area:
|
|
self.drawing_area.set_size_request(800, self.timeline_height)
|
|
|
|
# Invalidate cache when zoom changes
|
|
self._adjusted_events_cache = None
|
|
self._cache_key = None
|
|
|
|
def on_zoom_in(self, widget: Gtk.Widget) -> None:
|
|
"""
|
|
Zoom in.
|
|
|
|
Args:
|
|
widget: The widget that triggered the zoom (unused).
|
|
"""
|
|
if self.zoom_level < self.max_zoom:
|
|
self.zoom_level = min(self.zoom_level + self.zoom_step, self.max_zoom)
|
|
self.update_zoom_display()
|
|
self._recalculate_timeline_height() # Only recalculate height, not events
|
|
if self.drawing_area:
|
|
self.drawing_area.queue_draw()
|
|
|
|
def on_zoom_out(self, widget: Gtk.Widget) -> None:
|
|
"""
|
|
Zoom out.
|
|
|
|
Args:
|
|
widget: The widget that triggered the zoom (unused).
|
|
"""
|
|
if self.zoom_level > self.min_zoom:
|
|
self.zoom_level = max(self.zoom_level - self.zoom_step, self.min_zoom)
|
|
self.update_zoom_display()
|
|
self._recalculate_timeline_height() # Only recalculate height, not events
|
|
if self.drawing_area:
|
|
self.drawing_area.queue_draw()
|
|
|
|
def on_zoom_reset(self, widget: Gtk.Widget) -> None:
|
|
"""
|
|
Reset zoom to 100%.
|
|
|
|
Args:
|
|
widget: The widget that triggered the reset (unused).
|
|
"""
|
|
self.zoom_level = 1.0
|
|
self.update_zoom_display()
|
|
self._recalculate_timeline_height() # Only recalculate height, not events
|
|
if self.drawing_area:
|
|
self.drawing_area.queue_draw()
|
|
|
|
def on_scroll(self, widget: Gtk.Widget, event: Gdk.Event) -> bool:
|
|
"""
|
|
Handle scroll events for zooming with Ctrl+scroll.
|
|
|
|
Args:
|
|
widget: The widget that received the scroll event.
|
|
event: The scroll event.
|
|
|
|
Returns:
|
|
bool: True if the event was handled, False otherwise.
|
|
"""
|
|
if event.state & Gdk.ModifierType.CONTROL_MASK:
|
|
if event.direction == Gdk.ScrollDirection.UP:
|
|
self.on_zoom_in(widget)
|
|
elif event.direction == Gdk.ScrollDirection.DOWN:
|
|
self.on_zoom_out(widget)
|
|
return True
|
|
return False
|
|
|
|
def update_zoom_display(self) -> None:
|
|
"""Update the zoom level display."""
|
|
if hasattr(self, 'zoom_label'):
|
|
self.zoom_label.set_text(f"{int(self.zoom_level * 100)}%")
|
|
|
|
def on_button_press(self, widget: Gtk.Widget, event: Gdk.Event) -> bool:
|
|
"""
|
|
Handle mouse button press events.
|
|
|
|
Args:
|
|
widget: The widget that received the button press event.
|
|
event: The button press event.
|
|
|
|
Returns:
|
|
bool: False to allow other handlers to process the event.
|
|
"""
|
|
if event.button == 1: # Left click
|
|
# Find which event was clicked
|
|
clicked_index = self.find_event_at_position(event.x, event.y)
|
|
if clicked_index is not None:
|
|
clicked_event_data = self.events[clicked_index]
|
|
|
|
# Clicking anywhere on the event line selects the person (like selecting the event)
|
|
if clicked_event_data.person:
|
|
person_handle = clicked_event_data.person.get_handle()
|
|
if self.selected_person_handle == person_handle:
|
|
# Deselect if clicking same person
|
|
self.selected_person_handle = None
|
|
else:
|
|
# Select this person
|
|
self.selected_person_handle = person_handle
|
|
else:
|
|
# No person for this event, deselect
|
|
self.selected_person_handle = None
|
|
|
|
self.drawing_area.queue_draw()
|
|
return False
|
|
|
|
def on_motion_notify(self, widget: Gtk.Widget, event: Gdk.Event) -> bool:
|
|
"""
|
|
Handle mouse motion events for hover detection.
|
|
|
|
Args:
|
|
widget: The widget that received the motion event.
|
|
event: The motion event.
|
|
|
|
Returns:
|
|
bool: False to allow other handlers to process the event.
|
|
"""
|
|
self.mouse_x = event.x
|
|
self.mouse_y = event.y
|
|
|
|
# Find which event is under the cursor
|
|
hovered_index = self.find_event_at_position(event.x, event.y)
|
|
|
|
if hovered_index != self.hovered_event_index:
|
|
self.hovered_event_index = hovered_index
|
|
|
|
# Cancel existing tooltip timeout
|
|
if self.tooltip_timeout_id:
|
|
GLib.source_remove(self.tooltip_timeout_id)
|
|
self.tooltip_timeout_id = None
|
|
|
|
# Hide existing tooltip
|
|
if self.tooltip_window:
|
|
self.tooltip_window.destroy()
|
|
self.tooltip_window = None
|
|
|
|
# Schedule new tooltip
|
|
if hovered_index is not None:
|
|
self.tooltip_timeout_id = GLib.timeout_add(
|
|
TOOLTIP_DELAY, self.show_tooltip, hovered_index, event.x_root, event.y_root
|
|
)
|
|
|
|
self.drawing_area.queue_draw()
|
|
|
|
return False
|
|
|
|
def on_leave_notify(self, widget: Gtk.Widget, event: Gdk.Event) -> bool:
|
|
"""
|
|
Handle mouse leave events.
|
|
|
|
Args:
|
|
widget: The widget that received the leave event.
|
|
event: The leave event.
|
|
|
|
Returns:
|
|
bool: False to allow other handlers to process the event.
|
|
"""
|
|
self.hovered_event_index = None
|
|
|
|
# Cancel tooltip timeout
|
|
if self.tooltip_timeout_id:
|
|
GLib.source_remove(self.tooltip_timeout_id)
|
|
self.tooltip_timeout_id = None
|
|
|
|
# Hide tooltip
|
|
if self.tooltip_window:
|
|
self.tooltip_window.destroy()
|
|
self.tooltip_window = None
|
|
|
|
self.drawing_area.queue_draw()
|
|
return False
|
|
|
|
def _get_cache_key(self, timeline_y_start: float, timeline_y_end: float) -> int:
|
|
"""
|
|
Generate cache key for adjusted events.
|
|
|
|
Args:
|
|
timeline_y_start: The Y coordinate of the timeline start.
|
|
timeline_y_end: The Y coordinate of the timeline end.
|
|
|
|
Returns:
|
|
int: A hash-based cache key.
|
|
"""
|
|
return hash((len(self.events), timeline_y_start, timeline_y_end))
|
|
|
|
def _get_adjusted_events(self, context: cairo.Context,
|
|
timeline_y_start: float,
|
|
timeline_y_end: float) -> List[TimelineEvent]:
|
|
"""
|
|
Get adjusted events with collision detection, using cache if available.
|
|
|
|
Args:
|
|
context: Cairo drawing context for text measurement.
|
|
timeline_y_start: The Y coordinate of the timeline start.
|
|
timeline_y_end: The Y coordinate of the timeline end.
|
|
|
|
Returns:
|
|
List[TimelineEvent]: List of TimelineEvent objects with adjusted Y positions.
|
|
"""
|
|
# Check if cache is valid using hash-based key
|
|
cache_key = self._get_cache_key(timeline_y_start, timeline_y_end)
|
|
if (self._adjusted_events_cache is not None and
|
|
self._cache_key == cache_key):
|
|
return self._adjusted_events_cache
|
|
|
|
# Calculate date range
|
|
if not self.events:
|
|
return []
|
|
|
|
min_date, max_date, date_range = self._calculate_date_range()
|
|
|
|
# Calculate initial Y positions
|
|
events_with_y_pos = []
|
|
for event_data in self.events:
|
|
y_pos = self._calculate_y_position(
|
|
event_data.date_sort, min_date, date_range,
|
|
timeline_y_start, timeline_y_end
|
|
)
|
|
event_with_y = TimelineEvent(
|
|
date_sort=event_data.date_sort,
|
|
date_obj=event_data.date_obj,
|
|
event=event_data.event,
|
|
person=event_data.person,
|
|
event_type=event_data.event_type,
|
|
y_pos=y_pos
|
|
)
|
|
events_with_y_pos.append(event_with_y)
|
|
|
|
# Apply collision detection using shared method
|
|
adjusted_events = self._calculate_adjusted_positions(
|
|
context, events_with_y_pos, timeline_y_start, timeline_y_end
|
|
)
|
|
|
|
# Cache the results
|
|
self._adjusted_events_cache = adjusted_events
|
|
self._cache_key = cache_key
|
|
|
|
return adjusted_events
|
|
|
|
def find_event_at_position(self, x: float, y: float) -> Optional[int]:
|
|
"""
|
|
Find which event is at the given position.
|
|
|
|
Args:
|
|
x: X coordinate in widget space.
|
|
y: Y coordinate in widget space.
|
|
|
|
Returns:
|
|
Optional[int]: Index of the event at the position, or None if no event found.
|
|
"""
|
|
if not self.events or not self.drawing_area:
|
|
return None
|
|
|
|
# Convert mouse coordinates to drawing coordinates (account for zoom)
|
|
scaled_x = x / self.zoom_level
|
|
scaled_y = y / self.zoom_level
|
|
|
|
# Get widget dimensions in drawing coordinates
|
|
height = self.drawing_area.get_allocated_height() / self.zoom_level
|
|
|
|
timeline_x = TIMELINE_MARGIN_LEFT
|
|
timeline_y_start = TIMELINE_MARGIN_TOP
|
|
timeline_y_end = height - TIMELINE_MARGIN_BOTTOM
|
|
|
|
# Get adjusted events using cache (create temporary context for text measurement)
|
|
# For click detection, we need a context for text measurement
|
|
temp_context = cairo.Context(self._temp_surface)
|
|
|
|
adjusted_events = self._get_adjusted_events(temp_context, timeline_y_start, timeline_y_end)
|
|
|
|
# Check each event using adjusted positions
|
|
marker_size = EVENT_MARKER_SIZE
|
|
label_x = timeline_x + LABEL_X_OFFSET
|
|
|
|
for i, event_data in enumerate(adjusted_events):
|
|
# Check if click is in the event's area (marker + label)
|
|
if (scaled_x >= timeline_x - marker_size - MARKER_CLICK_PADDING and
|
|
scaled_x <= label_x + CLICKABLE_AREA_WIDTH and
|
|
abs(scaled_y - event_data.y_pos) < CLICKABLE_AREA_HEIGHT / 2):
|
|
return i
|
|
|
|
return None
|
|
|
|
def _format_person_tooltip(self, person, person_events: List[TimelineEvent]) -> str:
|
|
"""
|
|
Format tooltip for person with multiple events.
|
|
|
|
Args:
|
|
person: The person object.
|
|
person_events: List of TimelineEvent objects for this person.
|
|
|
|
Returns:
|
|
str: Formatted tooltip text.
|
|
"""
|
|
person_name = name_displayer.display(person)
|
|
tooltip_text = f"<b>{person_name}</b>\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"<b>{date_str}</b>\n{event_type_str}"
|
|
|
|
# Get place information
|
|
place_handle = event.get_place_handle()
|
|
if place_handle:
|
|
try:
|
|
place = self.dbstate.db.get_place_from_handle(place_handle)
|
|
if place:
|
|
place_name = place.get_title()
|
|
tooltip_text += f"\n📍 {place_name}"
|
|
except (AttributeError, KeyError) as e:
|
|
logger.debug(f"Error accessing place in tooltip: {e}")
|
|
|
|
# Get description
|
|
description = event.get_description()
|
|
if description:
|
|
tooltip_text += f"\n{description}"
|
|
|
|
return tooltip_text
|
|
|
|
def _get_or_create_tooltip_window(self) -> Gtk.Window:
|
|
"""
|
|
Get or create tooltip window (reuse if exists).
|
|
|
|
Returns:
|
|
Gtk.Window: The tooltip window.
|
|
"""
|
|
if not hasattr(self, 'tooltip_window') or self.tooltip_window is None:
|
|
self.tooltip_window = Gtk.Window(type=Gtk.WindowType.POPUP)
|
|
self.tooltip_window.set_border_width(8)
|
|
self.tooltip_window.set_decorated(False)
|
|
|
|
# Get parent window for proper display
|
|
toplevel = self.drawing_area.get_toplevel()
|
|
if isinstance(toplevel, Gtk.Window):
|
|
self.tooltip_window.set_transient_for(toplevel)
|
|
return self.tooltip_window
|
|
|
|
def show_tooltip(self, event_index: int, x_root: float, y_root: float) -> bool:
|
|
"""
|
|
Show tooltip for an event, including all events for that person.
|
|
|
|
Args:
|
|
event_index: Index of the event in self.events.
|
|
x_root: X coordinate in root window space.
|
|
y_root: Y coordinate in root window space.
|
|
|
|
Returns:
|
|
bool: False to indicate the timeout should not be repeated.
|
|
"""
|
|
if event_index is None or event_index >= len(self.events):
|
|
return False
|
|
|
|
event_data = self.events[event_index]
|
|
|
|
# If event has a person, show all events for that person
|
|
if event_data.person:
|
|
person_handle = event_data.person.get_handle()
|
|
|
|
# Find all events for this person
|
|
person_events = [
|
|
evt for evt in self.events
|
|
if evt.person and evt.person.get_handle() == person_handle
|
|
]
|
|
|
|
tooltip_text = self._format_person_tooltip(event_data.person, person_events)
|
|
else:
|
|
# Family event (no person) - show single event info
|
|
tooltip_text = self._format_single_event_tooltip(event_data.event, event_data.event_type)
|
|
|
|
# Get or create tooltip window
|
|
tooltip_window = self._get_or_create_tooltip_window()
|
|
|
|
# Clear existing content
|
|
for child in tooltip_window.get_children():
|
|
tooltip_window.remove(child)
|
|
|
|
# Create container with background
|
|
frame = Gtk.Frame()
|
|
frame.set_shadow_type(Gtk.ShadowType.OUT)
|
|
frame.get_style_context().add_class("tooltip")
|
|
|
|
label = Gtk.Label()
|
|
label.set_markup(tooltip_text)
|
|
label.set_line_wrap(True)
|
|
label.set_max_width_chars(40)
|
|
label.set_margin_start(5)
|
|
label.set_margin_end(5)
|
|
label.set_margin_top(5)
|
|
label.set_margin_bottom(5)
|
|
|
|
frame.add(label)
|
|
tooltip_window.add(frame)
|
|
tooltip_window.show_all()
|
|
|
|
# Position tooltip (offset to avoid cursor)
|
|
tooltip_window.move(int(x_root) + 15, int(y_root) + 15)
|
|
|
|
return False
|
|
|
|
def _draw_background(self, context: cairo.Context, width: float, height: float) -> None:
|
|
"""
|
|
Draw background gradient.
|
|
|
|
Args:
|
|
context: Cairo drawing context.
|
|
width: Widget width.
|
|
height: Widget height.
|
|
"""
|
|
pattern = cairo.LinearGradient(0, 0, 0, height)
|
|
pattern.add_color_stop_rgb(0, 0.98, 0.98, 0.99) # Very light gray-blue
|
|
pattern.add_color_stop_rgb(1, 0.95, 0.96, 0.98) # Slightly darker
|
|
context.set_source(pattern)
|
|
context.paint()
|
|
|
|
def _draw_no_events_message(self, context: cairo.Context, width: float, height: float) -> None:
|
|
"""
|
|
Draw "No events" message.
|
|
|
|
Args:
|
|
context: Cairo drawing context.
|
|
width: Widget width.
|
|
height: Widget height.
|
|
"""
|
|
context.set_source_rgb(0.6, 0.6, 0.6)
|
|
context.select_font_face(FONT_FAMILY, cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL)
|
|
context.set_font_size(FONT_SIZE_LARGE)
|
|
text = _("No events found")
|
|
(x_bearing, y_bearing, text_width, text_height, x_advance, y_advance) = context.text_extents(text)
|
|
context.move_to((width - text_width) / 2, height / 2)
|
|
context.show_text(text)
|
|
|
|
def _draw_timeline_axis(self, context: cairo.Context, timeline_x: float,
|
|
y_start: float, y_end: float) -> None:
|
|
"""
|
|
Draw timeline axis with shadow.
|
|
|
|
Args:
|
|
context: Cairo drawing context.
|
|
timeline_x: X coordinate of the timeline.
|
|
y_start: Y coordinate of the timeline start.
|
|
y_end: Y coordinate of the timeline end.
|
|
"""
|
|
# Draw shadow
|
|
context.set_source_rgba(0.0, 0.0, 0.0, 0.2)
|
|
context.set_line_width(TIMELINE_LINE_WIDTH)
|
|
context.move_to(timeline_x + 2, y_start + 2)
|
|
context.line_to(timeline_x + 2, y_end + 2)
|
|
context.stroke()
|
|
|
|
# Draw main line with gradient
|
|
pattern = cairo.LinearGradient(timeline_x, y_start, timeline_x, y_end)
|
|
pattern.add_color_stop_rgb(0, 0.3, 0.3, 0.3)
|
|
pattern.add_color_stop_rgb(1, 0.5, 0.5, 0.5)
|
|
context.set_source(pattern)
|
|
context.set_line_width(TIMELINE_LINE_WIDTH)
|
|
context.move_to(timeline_x, y_start)
|
|
context.line_to(timeline_x, y_end)
|
|
context.stroke()
|
|
|
|
def _draw_events(self, context: cairo.Context, events_with_y_pos: List[TimelineEvent],
|
|
timeline_x: float) -> None:
|
|
"""
|
|
Draw all events.
|
|
|
|
Args:
|
|
context: Cairo drawing context.
|
|
events_with_y_pos: List of TimelineEvent objects with Y positions.
|
|
timeline_x: X coordinate of the timeline.
|
|
"""
|
|
for i, event_data in enumerate(events_with_y_pos):
|
|
# Check if this event is hovered
|
|
is_hovered = (i == self.hovered_event_index)
|
|
|
|
# Check if this event belongs to selected person
|
|
is_selected = (self.selected_person_handle is not None and
|
|
event_data.person and
|
|
event_data.person.get_handle() == self.selected_person_handle)
|
|
|
|
# Draw event marker with modern styling
|
|
self.draw_event_marker(context, timeline_x, event_data.y_pos,
|
|
event_data.event_type, is_hovered, is_selected)
|
|
|
|
# Draw event label
|
|
label_x = timeline_x + LABEL_X_OFFSET
|
|
self.draw_event_label(
|
|
context, label_x, event_data.y_pos, event_data.date_obj,
|
|
event_data.event, event_data.person, event_data.event_type, is_hovered
|
|
)
|
|
|
|
def on_draw(self, widget: Gtk.Widget, context: cairo.Context) -> bool:
|
|
"""
|
|
Draw the timeline.
|
|
|
|
Args:
|
|
widget: The drawing area widget.
|
|
context: Cairo drawing context.
|
|
|
|
Returns:
|
|
bool: True to indicate the draw was handled.
|
|
"""
|
|
# Apply zoom transformation
|
|
context.save()
|
|
context.scale(self.zoom_level, self.zoom_level)
|
|
|
|
# Get widget dimensions (adjusted for zoom)
|
|
width = widget.get_allocated_width() / self.zoom_level
|
|
height = widget.get_allocated_height() / self.zoom_level
|
|
|
|
# Draw background
|
|
self._draw_background(context, width, height)
|
|
|
|
if not self.events:
|
|
# Draw "No events" message
|
|
self._draw_no_events_message(context, width, height)
|
|
context.restore()
|
|
return True
|
|
|
|
# Calculate date range
|
|
min_date, max_date, date_range = self._calculate_date_range()
|
|
|
|
# Draw timeline axis
|
|
timeline_x = TIMELINE_MARGIN_LEFT
|
|
timeline_y_start = TIMELINE_MARGIN_TOP
|
|
timeline_y_end = height - TIMELINE_MARGIN_BOTTOM
|
|
|
|
self._draw_timeline_axis(context, timeline_x, timeline_y_start, timeline_y_end)
|
|
|
|
# Get adjusted events with collision detection (uses cache)
|
|
events_with_y_pos = self._get_adjusted_events(context, timeline_y_start, timeline_y_end)
|
|
|
|
# Draw events
|
|
self._draw_events(context, events_with_y_pos, timeline_x)
|
|
|
|
# Draw visual connections for selected person
|
|
if self.selected_person_handle is not None:
|
|
self.draw_person_connections(context, events_with_y_pos, timeline_x,
|
|
timeline_y_start, timeline_y_end)
|
|
|
|
# Draw year markers on the left
|
|
self.draw_year_markers(context, timeline_x, timeline_y_start, timeline_y_end,
|
|
min_date, max_date)
|
|
|
|
context.restore()
|
|
return True
|
|
|
|
def _draw_marker_shadow(self, context: cairo.Context, x: float, y: float,
|
|
size: float, shape: str) -> None:
|
|
"""
|
|
Draw shadow for marker.
|
|
|
|
Args:
|
|
context: Cairo drawing context.
|
|
x: X coordinate of marker center.
|
|
y: Y coordinate of marker center.
|
|
size: Size of the marker.
|
|
shape: Shape type ('triangle', 'circle', etc.).
|
|
"""
|
|
context.set_source_rgba(0.0, 0.0, 0.0, SHADOW_OPACITY)
|
|
context.translate(SHADOW_OFFSET_X, SHADOW_OFFSET_Y)
|
|
self._draw_shape(context, x, y, size, shape)
|
|
context.fill()
|
|
context.translate(-SHADOW_OFFSET_X, -SHADOW_OFFSET_Y)
|
|
|
|
def _draw_marker_gradient(self, context: cairo.Context, x: float, y: float,
|
|
size: float, color: Tuple[float, float, float],
|
|
shape: str) -> None:
|
|
"""
|
|
Draw gradient fill for marker.
|
|
|
|
Args:
|
|
context: Cairo drawing context.
|
|
x: X coordinate of marker center.
|
|
y: Y coordinate of marker center.
|
|
size: Size of the marker.
|
|
color: RGB color tuple (0.0-1.0).
|
|
shape: Shape type ('triangle', 'circle', etc.).
|
|
"""
|
|
pattern = cairo.RadialGradient(x - size/2, y - size/2, 0, x, y, size)
|
|
r, g, b = color
|
|
pattern.add_color_stop_rgb(0,
|
|
min(1.0, r + GRADIENT_BRIGHTNESS_OFFSET),
|
|
min(1.0, g + GRADIENT_BRIGHTNESS_OFFSET),
|
|
min(1.0, b + GRADIENT_BRIGHTNESS_OFFSET))
|
|
pattern.add_color_stop_rgb(1,
|
|
max(0.0, r - GRADIENT_DARKNESS_OFFSET),
|
|
max(0.0, g - GRADIENT_DARKNESS_OFFSET),
|
|
max(0.0, b - GRADIENT_DARKNESS_OFFSET))
|
|
context.set_source(pattern)
|
|
self._draw_shape(context, x, y, size, shape)
|
|
context.fill()
|
|
|
|
def draw_event_marker(self, context: cairo.Context, x: float, y: float,
|
|
event_type: EventType, is_hovered: bool = False,
|
|
is_selected: bool = False) -> None:
|
|
"""
|
|
Draw a marker for an event with modern styling.
|
|
|
|
Args:
|
|
context: Cairo drawing context.
|
|
x: X coordinate for marker center.
|
|
y: Y coordinate for marker center.
|
|
event_type: Type of event (determines color and shape).
|
|
is_hovered: Whether marker is currently hovered.
|
|
is_selected: Whether marker belongs to selected person.
|
|
"""
|
|
context.save()
|
|
|
|
# Get integer value from EventType object for dictionary lookup
|
|
event_type_value = event_type.value if hasattr(event_type, 'value') else int(event_type)
|
|
|
|
# Get color and shape
|
|
color = EVENT_COLORS.get(event_type_value, (0.5, 0.5, 0.5))
|
|
shape = EVENT_SHAPES.get(event_type_value, 'square')
|
|
marker_size = EVENT_MARKER_SIZE
|
|
|
|
# Increase size if hovered or selected
|
|
if is_hovered:
|
|
marker_size *= MARKER_HOVER_SIZE_MULTIPLIER
|
|
elif is_selected:
|
|
marker_size *= MARKER_SELECTED_SIZE_MULTIPLIER
|
|
|
|
# Use highlight color if selected
|
|
if is_selected:
|
|
color = SELECTED_MARKER_COLOR
|
|
|
|
# Draw shadow
|
|
self._draw_marker_shadow(context, x, y, marker_size, shape)
|
|
|
|
# Draw main shape with gradient
|
|
self._draw_marker_gradient(context, x, y, marker_size, color, shape)
|
|
|
|
# Draw border
|
|
context.set_source_rgba(0.0, 0.0, 0.0, BORDER_OPACITY)
|
|
context.set_line_width(1)
|
|
self._draw_shape(context, x, y, marker_size, shape)
|
|
context.stroke()
|
|
|
|
context.restore()
|
|
|
|
def _draw_triangle(self, context: cairo.Context, x: float, y: float, size: float) -> None:
|
|
"""Draw a triangle shape."""
|
|
context.move_to(x, y - size)
|
|
context.line_to(x - size, y + size)
|
|
context.line_to(x + size, y + size)
|
|
context.close_path()
|
|
|
|
def _draw_circle(self, context: cairo.Context, x: float, y: float, size: float) -> None:
|
|
"""Draw a circle shape."""
|
|
context.arc(x, y, size, 0, 2 * math.pi)
|
|
|
|
def _draw_diamond(self, context: cairo.Context, x: float, y: float, size: float) -> None:
|
|
"""Draw a diamond shape."""
|
|
context.move_to(x, y - size)
|
|
context.line_to(x + size, y)
|
|
context.line_to(x, y + size)
|
|
context.line_to(x - size, y)
|
|
context.close_path()
|
|
|
|
def _draw_square(self, context: cairo.Context, x: float, y: float, size: float) -> None:
|
|
"""Draw a square shape."""
|
|
context.rectangle(x - size, y - size, size * 2, size * 2)
|
|
|
|
def _draw_star(self, context: cairo.Context, x: float, y: float, size: float) -> None:
|
|
"""Draw a 5-pointed star shape."""
|
|
points = 5
|
|
outer_radius = size
|
|
inner_radius = size * 0.4
|
|
for i in range(points * 2):
|
|
angle = (i * math.pi) / points - math.pi / 2
|
|
radius = outer_radius if i % 2 == 0 else inner_radius
|
|
px = x + radius * math.cos(angle)
|
|
py = y + radius * math.sin(angle)
|
|
if i == 0:
|
|
context.move_to(px, py)
|
|
else:
|
|
context.line_to(px, py)
|
|
context.close_path()
|
|
|
|
def _draw_hexagon(self, context: cairo.Context, x: float, y: float, size: float) -> None:
|
|
"""Draw a hexagon shape."""
|
|
for i in range(6):
|
|
angle = (i * math.pi) / 3
|
|
px = x + size * math.cos(angle)
|
|
py = y + size * math.sin(angle)
|
|
if i == 0:
|
|
context.move_to(px, py)
|
|
else:
|
|
context.line_to(px, py)
|
|
context.close_path()
|
|
|
|
def _draw_shape(self, context: cairo.Context, x: float, y: float,
|
|
size: float, shape: str) -> None:
|
|
"""
|
|
Draw a shape at the given position.
|
|
|
|
Args:
|
|
context: Cairo drawing context.
|
|
x: X coordinate of shape center.
|
|
y: Y coordinate of shape center.
|
|
size: Size of the shape.
|
|
shape: Shape type ('triangle', 'circle', 'diamond', 'square', 'star', 'hexagon').
|
|
"""
|
|
shape_drawers = {
|
|
'triangle': self._draw_triangle,
|
|
'circle': self._draw_circle,
|
|
'diamond': self._draw_diamond,
|
|
'square': self._draw_square,
|
|
'star': self._draw_star,
|
|
'hexagon': self._draw_hexagon,
|
|
}
|
|
|
|
drawer = shape_drawers.get(shape, self._draw_square) # Default to square
|
|
drawer(context, x, y, size)
|
|
|
|
def draw_event_label(self, context: cairo.Context, x: float, y: float,
|
|
date_obj: Date, event, person: Optional[Any],
|
|
event_type: EventType, is_hovered: bool = False) -> None:
|
|
"""
|
|
Draw the label for an event with modern styling.
|
|
|
|
Args:
|
|
context: Cairo drawing context.
|
|
x: X coordinate for label start.
|
|
y: Y coordinate for label center.
|
|
date_obj: Date object for the event.
|
|
event: The event object.
|
|
person: The person associated with the event (None for family events).
|
|
event_type: The type of event.
|
|
is_hovered: Whether the label is currently hovered.
|
|
"""
|
|
context.save()
|
|
|
|
# Create Pango layout for text
|
|
layout = PangoCairo.create_layout(context)
|
|
|
|
# Use modern font
|
|
font_desc = Pango.font_description_from_string(f"{FONT_FAMILY} {FONT_SIZE_NORMAL}")
|
|
if is_hovered:
|
|
font_desc.set_weight(Pango.Weight.BOLD)
|
|
layout.set_font_description(font_desc)
|
|
|
|
# Build label text using centralized method
|
|
label_text = self._get_event_label_text(event, person, event_type)
|
|
|
|
layout.set_markup(label_text, -1)
|
|
layout.set_width(-1) # No width limit
|
|
|
|
# Draw background for hovered events
|
|
if is_hovered:
|
|
text_width, text_height = layout.get_pixel_size()
|
|
padding = LABEL_BACKGROUND_PADDING
|
|
|
|
# Draw rounded rectangle background
|
|
rect_x = x - padding
|
|
rect_y = y - text_height / 2 - padding
|
|
rect_width = text_width + padding * 2
|
|
rect_height = text_height + padding * 2
|
|
|
|
# Rounded rectangle
|
|
radius = LABEL_BACKGROUND_RADIUS
|
|
context.arc(rect_x + radius, rect_y + radius, radius, math.pi, 3 * math.pi / 2)
|
|
context.arc(rect_x + rect_width - radius, rect_y + radius, radius, 3 * math.pi / 2, 0)
|
|
context.arc(rect_x + rect_width - radius, rect_y + rect_height - radius, radius, 0, math.pi / 2)
|
|
context.arc(rect_x + radius, rect_y + rect_height - radius, radius, math.pi / 2, math.pi)
|
|
context.close_path()
|
|
|
|
# Fill with semi-transparent background
|
|
context.set_source_rgba(1.0, 1.0, 1.0, 0.8)
|
|
context.fill()
|
|
|
|
# Draw border
|
|
context.set_source_rgba(0.7, 0.7, 0.7, 0.5)
|
|
context.set_line_width(1)
|
|
context.stroke()
|
|
|
|
# Draw text
|
|
context.set_source_rgb(0.1, 0.1, 0.1) # Dark gray text
|
|
context.move_to(x, y - 10) # Center vertically on marker
|
|
PangoCairo.show_layout(context, layout)
|
|
|
|
context.restore()
|
|
|
|
def _find_year_range(self) -> Tuple[Optional[int], Optional[int]]:
|
|
"""
|
|
Find the minimum and maximum years from all events.
|
|
|
|
Returns:
|
|
Tuple[Optional[int], Optional[int]]: (min_year, max_year) or (None, None) if no valid years found.
|
|
"""
|
|
min_year = None
|
|
max_year = None
|
|
for event_data in self.events:
|
|
try:
|
|
year = event_data.date_obj.get_year()
|
|
if year and year != 0:
|
|
if min_year is None or year < min_year:
|
|
min_year = year
|
|
if max_year is None or year > max_year:
|
|
max_year = year
|
|
except (AttributeError, ValueError) as e:
|
|
logger.debug(f"Error accessing year from date: {e}")
|
|
|
|
return (min_year, max_year)
|
|
|
|
def _calculate_year_y_position(self, year: int, min_date: int, max_date: int,
|
|
y_start: float, y_end: float) -> float:
|
|
"""
|
|
Calculate Y position for a year marker.
|
|
|
|
Args:
|
|
year: The year to calculate position for.
|
|
min_date: Minimum date sort value.
|
|
max_date: Maximum date sort value.
|
|
y_start: Y coordinate of the timeline start.
|
|
y_end: Y coordinate of the timeline end.
|
|
|
|
Returns:
|
|
float: The Y position for the year marker.
|
|
"""
|
|
year_date = Date()
|
|
year_date.set_yr_mon_day(year, 1, 1)
|
|
year_sort = year_date.get_sort_value()
|
|
|
|
if min_date == max_date:
|
|
return (y_start + y_end) / 2
|
|
else:
|
|
return y_start + (
|
|
(year_sort - min_date) / (max_date - min_date)
|
|
) * (y_end - y_start)
|
|
|
|
def _draw_year_marker(self, context: cairo.Context, timeline_x: float,
|
|
year: int, y_pos: float) -> None:
|
|
"""
|
|
Draw a single year marker (tick mark and label).
|
|
|
|
Args:
|
|
context: Cairo drawing context.
|
|
timeline_x: X coordinate of the timeline.
|
|
year: The year to draw.
|
|
y_pos: Y position for the marker.
|
|
"""
|
|
# Draw tick mark
|
|
context.set_source_rgb(0.5, 0.5, 0.5) # Gray
|
|
context.set_line_width(1)
|
|
context.move_to(timeline_x - 10, y_pos)
|
|
context.line_to(timeline_x, y_pos)
|
|
context.stroke()
|
|
|
|
# Draw year label
|
|
layout = PangoCairo.create_layout(context)
|
|
layout.set_font_description(
|
|
Pango.font_description_from_string(f"{FONT_FAMILY} {FONT_SIZE_SMALL}")
|
|
)
|
|
layout.set_text(str(year), -1)
|
|
|
|
# Get text size in pixels
|
|
text_width, text_height = layout.get_pixel_size()
|
|
|
|
context.set_source_rgb(0.0, 0.0, 0.0) # Black
|
|
context.move_to(timeline_x - 20 - text_width, y_pos - text_height / 2)
|
|
PangoCairo.show_layout(context, layout)
|
|
|
|
def draw_year_markers(self, context: cairo.Context, timeline_x: float,
|
|
y_start: float, y_end: float, min_date: int,
|
|
max_date: int) -> None:
|
|
"""
|
|
Draw year markers on the left side of the timeline.
|
|
|
|
Args:
|
|
context: Cairo drawing context.
|
|
timeline_x: X coordinate of the timeline.
|
|
y_start: Y coordinate of the timeline start.
|
|
y_end: Y coordinate of the timeline end.
|
|
min_date: Minimum date sort value.
|
|
max_date: Maximum date sort value.
|
|
"""
|
|
context.save()
|
|
|
|
# Find min and max years from events
|
|
min_year, max_year = self._find_year_range()
|
|
|
|
if min_year is None or max_year is None:
|
|
context.restore()
|
|
return
|
|
|
|
# Draw markers for major years (every 10 years or so)
|
|
year_step = max(1, (max_year - min_year) // 10)
|
|
if year_step == 0:
|
|
year_step = 1
|
|
|
|
for year in range(min_year, max_year + 1, year_step):
|
|
# Calculate Y position
|
|
y_pos = self._calculate_year_y_position(year, min_date, max_date, y_start, y_end)
|
|
|
|
# Only draw if within visible range
|
|
if y_pos < y_start or y_pos > y_end:
|
|
continue
|
|
|
|
# Draw the year marker
|
|
self._draw_year_marker(context, timeline_x, year, y_pos)
|
|
|
|
context.restore()
|
|
|
|
def draw_person_connections(self, context: cairo.Context,
|
|
events_with_y_pos: List[TimelineEvent],
|
|
timeline_x: float, timeline_y_start: float,
|
|
timeline_y_end: float) -> None:
|
|
"""
|
|
Draw visual connections between all events of the selected person.
|
|
|
|
Args:
|
|
context: Cairo drawing context.
|
|
events_with_y_pos: List of TimelineEvent objects with Y positions.
|
|
timeline_x: X coordinate of the timeline.
|
|
timeline_y_start: Y coordinate of the timeline start.
|
|
timeline_y_end: Y coordinate of the timeline end.
|
|
"""
|
|
if not self.selected_person_handle:
|
|
return
|
|
|
|
# Find all events for the selected person
|
|
person_events = [
|
|
event_data for event_data in events_with_y_pos
|
|
if event_data.person and
|
|
event_data.person.get_handle() == self.selected_person_handle
|
|
]
|
|
|
|
if len(person_events) < 1:
|
|
return
|
|
|
|
# Sort by Y position
|
|
person_events.sort(key=lambda x: x.y_pos)
|
|
|
|
context.save()
|
|
|
|
# Position vertical line to the left of year markers
|
|
# Year labels are positioned at timeline_x - 20 - text_width (around x=90-130)
|
|
# Position vertical line at CONNECTION_VERTICAL_LINE_X to be clearly left of all year markers
|
|
vertical_line_x = CONNECTION_VERTICAL_LINE_X
|
|
|
|
# Draw connecting lines - more visible with brighter color and increased opacity
|
|
context.set_source_rgba(*CONNECTION_LINE_COLOR)
|
|
context.set_line_width(CONNECTION_LINE_WIDTH)
|
|
context.set_line_cap(cairo.LINE_CAP_ROUND)
|
|
context.set_line_join(cairo.LINE_JOIN_ROUND)
|
|
|
|
# Draw vertical connector line on the left side
|
|
if len(person_events) > 1:
|
|
y_positions = [event_data.y_pos for event_data in person_events]
|
|
min_y = min(y_positions)
|
|
max_y = max(y_positions)
|
|
|
|
# Draw vertical line connecting all events
|
|
if max_y - min_y > EVENT_MARKER_SIZE * 2:
|
|
context.set_source_rgba(*CONNECTION_LINE_COLOR)
|
|
context.set_line_width(CONNECTION_LINE_WIDTH)
|
|
context.move_to(vertical_line_x, min_y)
|
|
context.line_to(vertical_line_x, max_y)
|
|
context.stroke()
|
|
|
|
# Draw horizontal lines connecting vertical line to each event marker
|
|
context.set_source_rgba(*CONNECTION_LINE_COLOR)
|
|
context.set_line_width(CONNECTION_LINE_WIDTH)
|
|
for event_data in person_events:
|
|
# Draw horizontal line from vertical line to event marker
|
|
context.move_to(vertical_line_x, event_data.y_pos)
|
|
context.line_to(timeline_x, event_data.y_pos)
|
|
context.stroke()
|
|
|
|
context.restore()
|
|
|
|
def get_stock(self) -> str:
|
|
"""
|
|
Return the stock icon name.
|
|
|
|
Returns:
|
|
str: The stock icon name for this view.
|
|
"""
|
|
return "gramps-family"
|