- Added helper methods for three-state checkbox management (_calculate_group_state, _update_group_checkbox_state) - Replaced category labels with three-state checkboxes in Event Types tab - Added three-state checkboxes to family expander labels in Persons tab - Implemented bidirectional state synchronization between group and child checkboxes - Group checkboxes show checked (all), unchecked (none), or inconsistent (some) states
3274 lines
129 KiB
Python
3274 lines
129 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 all events in the database
|
|
"""
|
|
|
|
# -------------------------------------------------------------------------
|
|
#
|
|
# Python modules
|
|
#
|
|
# -------------------------------------------------------------------------
|
|
import cairo
|
|
import logging
|
|
|
|
logger = logging.getLogger("plugin.mytimeline")
|
|
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 EventBookmarks
|
|
|
|
_ = glocale.translation.sgettext
|
|
|
|
# -------------------------------------------------------------------------
|
|
#
|
|
# Constants
|
|
#
|
|
# -------------------------------------------------------------------------
|
|
# Timeline Layout Constants
|
|
TIMELINE_MARGIN_LEFT = 150
|
|
TIMELINE_MARGIN_RIGHT = 50
|
|
TIMELINE_MARGIN_TOP = 50
|
|
TIMELINE_MARGIN_BOTTOM = 50
|
|
TIMELINE_LINE_WIDTH = 3
|
|
|
|
# Event Display Constants
|
|
EVENT_MARKER_SIZE = 10
|
|
EVENT_SPACING = 80
|
|
LABEL_X_OFFSET = 25
|
|
MIN_LABEL_SPACING = 30
|
|
LABEL_PADDING = 16
|
|
MARKER_CLICK_PADDING = 10
|
|
CLICKABLE_AREA_WIDTH = 600
|
|
CLICKABLE_AREA_HEIGHT = 30
|
|
|
|
# UI Constants
|
|
YEAR_LABEL_WIDTH = 100
|
|
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 all events in the database.
|
|
Shows all events with modern design and interactivity, allowing selection
|
|
and filtering of events by type, category, person, and date range.
|
|
"""
|
|
|
|
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, EventBookmarks, nav_group
|
|
)
|
|
|
|
self.dbstate = dbstate
|
|
self.uistate = uistate
|
|
|
|
# Current event handle (for selection/highlighting)
|
|
self.active_event_handle = None
|
|
self.events: List[TimelineEvent] = [] # List of TimelineEvent objects
|
|
|
|
# UI components
|
|
self.scrolledwindow = None
|
|
self.drawing_area = None
|
|
self.timeline_height = 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.date_range_explicit: bool = False # Track if date range was explicitly set
|
|
self.person_filter: Optional[Set[str]] = None # Set of person handles to include, None = all
|
|
self.category_filter: Optional[Set[str]] = None # Set of event categories to include, None = all
|
|
self.all_events: List[TimelineEvent] = [] # Store all events before filtering
|
|
|
|
# Filter UI components
|
|
self.filter_button = None
|
|
self.filter_dialog = None
|
|
self._filter_widgets = {}
|
|
|
|
# Initialize temporary surface for text measurement (used in find_event_at_position)
|
|
self._temp_surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, 1, 1)
|
|
|
|
# Connect to database changes
|
|
self.dbstate.connect("database-changed", self.change_db)
|
|
# Connect to family updates
|
|
if self.dbstate.is_open():
|
|
self.dbstate.db.connect("family-update", self.family_updated)
|
|
self.dbstate.db.connect("person-update", self.person_updated)
|
|
self.dbstate.db.connect("event-update", self.event_updated)
|
|
|
|
def navigation_type(self) -> str:
|
|
"""
|
|
Return the navigation type for this view.
|
|
|
|
Returns:
|
|
str: The navigation type, always "Event" for this view.
|
|
"""
|
|
return "Event"
|
|
|
|
def change_page(self) -> None:
|
|
"""
|
|
Called when the page changes.
|
|
|
|
Updates the view to show all events and highlight the active event if selected.
|
|
"""
|
|
NavigationView.change_page(self)
|
|
active_handle = self.get_active()
|
|
if active_handle:
|
|
self.goto_handle(active_handle)
|
|
else:
|
|
# If no event is selected, ensure timeline is displayed
|
|
if not self.events and self.dbstate.is_open():
|
|
self.collect_events()
|
|
if self.drawing_area:
|
|
self.drawing_area.queue_draw()
|
|
|
|
def family_updated(self, handle_list: List[str]) -> None:
|
|
"""
|
|
Called when a family is updated.
|
|
|
|
Args:
|
|
handle_list: List of family handles that were updated.
|
|
"""
|
|
# Refresh timeline since family updates may affect event display
|
|
self.collect_events()
|
|
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.
|
|
"""
|
|
# Refresh timeline since person updates may affect event display
|
|
self.collect_events()
|
|
if self.drawing_area:
|
|
self.drawing_area.queue_draw()
|
|
|
|
def event_updated(self, handle_list: List[str]) -> None:
|
|
"""
|
|
Called when an event is updated.
|
|
|
|
Args:
|
|
handle_list: List of event handles that were updated.
|
|
"""
|
|
# Re-collect events when events are updated
|
|
self.collect_events()
|
|
if self.drawing_area:
|
|
self.drawing_area.queue_draw()
|
|
|
|
# If the active event was updated, ensure it's still highlighted
|
|
if self.active_event_handle in handle_list:
|
|
self._scroll_to_event(self.active_event_handle)
|
|
|
|
def change_db(self, db) -> None:
|
|
"""
|
|
Called when the database changes.
|
|
|
|
Args:
|
|
db: The new database object.
|
|
"""
|
|
self.active_event_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 _calculate_group_state(self, child_checkboxes: List[Gtk.CheckButton]) -> str:
|
|
"""
|
|
Calculate the state of a group based on child checkboxes.
|
|
|
|
Args:
|
|
child_checkboxes: List of child checkboxes in the group.
|
|
|
|
Returns:
|
|
str: 'all' if all selected, 'none' if none selected, 'some' if partially selected.
|
|
"""
|
|
if not child_checkboxes:
|
|
return 'none'
|
|
|
|
active_count = sum(1 for cb in child_checkboxes if cb.get_active())
|
|
|
|
if active_count == 0:
|
|
return 'none'
|
|
elif active_count == len(child_checkboxes):
|
|
return 'all'
|
|
else:
|
|
return 'some'
|
|
|
|
def _update_group_checkbox_state(self, group_checkbox: Gtk.CheckButton,
|
|
child_checkboxes: List[Gtk.CheckButton]) -> None:
|
|
"""
|
|
Update a group checkbox state based on child checkboxes.
|
|
|
|
Args:
|
|
group_checkbox: The parent group checkbox to update.
|
|
child_checkboxes: List of child checkboxes.
|
|
"""
|
|
state = self._calculate_group_state(child_checkboxes)
|
|
|
|
if state == 'all':
|
|
group_checkbox.set_active(True)
|
|
group_checkbox.set_inconsistent(False)
|
|
elif state == 'none':
|
|
group_checkbox.set_active(False)
|
|
group_checkbox.set_inconsistent(False)
|
|
else: # 'some'
|
|
group_checkbox.set_inconsistent(True)
|
|
|
|
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 = {}
|
|
category_checkboxes = {}
|
|
category_event_types = {} # Map category to list of event types
|
|
|
|
# First pass: collect event types by category
|
|
for event_type_obj in EVENT_COLORS.keys():
|
|
if event_type_obj not in EVENT_CATEGORIES:
|
|
continue
|
|
category = EVENT_CATEGORIES[event_type_obj]
|
|
if category not in category_event_types:
|
|
category_event_types[category] = []
|
|
category_event_types[category].append(event_type_obj)
|
|
|
|
# Second pass: create UI with category checkboxes
|
|
for category in sorted(category_event_types.keys()):
|
|
event_types_in_category = category_event_types[category]
|
|
|
|
# Create category checkbox with three-state support
|
|
category_checkbox = Gtk.CheckButton(label=category)
|
|
category_checkboxes[category] = category_checkbox
|
|
box.pack_start(category_checkbox, False, False, 0)
|
|
|
|
# Create container for event types in this category
|
|
category_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
|
|
category_box.set_margin_start(20)
|
|
category_boxes[category] = category_box
|
|
box.pack_start(category_box, False, False, 0)
|
|
|
|
# Create checkboxes for each event type in this category
|
|
child_checkboxes = []
|
|
for event_type_obj in sorted(event_types_in_category, key=lambda x: self._get_event_type_display_name(x)):
|
|
event_type_name = self._get_event_type_display_name(event_type_obj)
|
|
checkbox = Gtk.CheckButton(label=event_type_name)
|
|
event_type_checkboxes[event_type_obj] = checkbox
|
|
child_checkboxes.append(checkbox)
|
|
category_box.pack_start(checkbox, False, False, 0)
|
|
|
|
# Flag to prevent recursion between category and child checkboxes
|
|
updating_category = [False]
|
|
|
|
# Connect category checkbox to toggle all children
|
|
def make_category_toggle_handler(cb_list, updating_flag):
|
|
def handler(widget):
|
|
if updating_flag[0]:
|
|
return
|
|
|
|
# Handle inconsistent state - make it consistent
|
|
if widget.get_inconsistent():
|
|
widget.set_inconsistent(False)
|
|
widget.set_active(True)
|
|
|
|
updating_flag[0] = True
|
|
# Toggle all children
|
|
is_active = widget.get_active()
|
|
for child_cb in cb_list:
|
|
child_cb.set_active(is_active)
|
|
updating_flag[0] = False
|
|
return handler
|
|
|
|
# Connect child checkboxes to update category checkbox state
|
|
def make_child_toggle_handler(cat_cb, children, updating_flag):
|
|
def handler(widget):
|
|
if updating_flag[0]:
|
|
return
|
|
self._update_group_checkbox_state(cat_cb, children)
|
|
return handler
|
|
|
|
# Connect category checkbox
|
|
category_checkbox.connect("toggled",
|
|
make_category_toggle_handler(child_checkboxes, updating_category))
|
|
|
|
# Connect child checkboxes
|
|
for child_cb in child_checkboxes:
|
|
child_cb.connect("toggled",
|
|
make_child_toggle_handler(category_checkbox, child_checkboxes, updating_category))
|
|
|
|
self._filter_widgets['event_type_checkboxes'] = event_type_checkboxes
|
|
self._filter_widgets['category_checkboxes'] = category_checkboxes
|
|
self._filter_widgets['category_event_types'] = category_event_types
|
|
|
|
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 expandable families showing their members.
|
|
|
|
Returns:
|
|
Gtk.Widget: The person filter page.
|
|
"""
|
|
scrolled = Gtk.ScrolledWindow()
|
|
scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
|
|
|
|
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=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
|
|
# Store expanders by family handle
|
|
self._filter_widgets['family_expanders'] = {}
|
|
|
|
info_label = Gtk.Label(label=_("Select families and their members to include in the timeline."))
|
|
info_label.set_line_wrap(True)
|
|
box.pack_start(info_label, False, False, 0)
|
|
|
|
scrolled.add(box)
|
|
return scrolled
|
|
|
|
def _build_date_range_filter_page(self) -> Gtk.Widget:
|
|
"""
|
|
Build the date range filter page with date chooser widgets.
|
|
|
|
Returns:
|
|
Gtk.Widget: The date range filter page.
|
|
"""
|
|
scrolled = Gtk.ScrolledWindow()
|
|
scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
|
|
|
|
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=_("Select date range to filter events. Leave unselected to show all dates."))
|
|
info_label.set_line_wrap(True)
|
|
box.pack_start(info_label, False, False, 0)
|
|
|
|
# Calendar container
|
|
calendar_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=15)
|
|
calendar_box.set_homogeneous(True)
|
|
|
|
# Year range for genealogical data (1000 to current year + 10)
|
|
import datetime
|
|
current_year = datetime.date.today().year
|
|
min_year = 1000
|
|
max_year = current_year + 10
|
|
|
|
# From date calendar with year selector
|
|
from_frame = Gtk.Frame(label=_("From Date"))
|
|
from_frame.set_label_align(0.5, 0.5)
|
|
from_calendar_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5)
|
|
from_calendar_container.set_margin_start(5)
|
|
from_calendar_container.set_margin_end(5)
|
|
from_calendar_container.set_margin_top(5)
|
|
from_calendar_container.set_margin_bottom(5)
|
|
|
|
# Year selector for From date
|
|
from_year_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
|
|
from_year_label = Gtk.Label(label=_("Year:"))
|
|
from_year_adjustment = Gtk.Adjustment(
|
|
value=current_year,
|
|
lower=min_year,
|
|
upper=max_year,
|
|
step_increment=1,
|
|
page_increment=10
|
|
)
|
|
from_year_spin = Gtk.SpinButton()
|
|
from_year_spin.set_adjustment(from_year_adjustment)
|
|
from_year_spin.set_numeric(True)
|
|
from_year_spin.set_update_policy(Gtk.SpinButtonUpdatePolicy.IF_VALID)
|
|
from_year_spin.set_width_chars(6)
|
|
from_year_spin.connect("value-changed", self._on_from_year_changed)
|
|
from_year_box.pack_start(from_year_label, False, False, 0)
|
|
from_year_box.pack_start(from_year_spin, False, False, 0)
|
|
from_calendar_container.pack_start(from_year_box, False, False, 0)
|
|
|
|
from_calendar = Gtk.Calendar()
|
|
from_calendar.set_display_options(
|
|
Gtk.CalendarDisplayOptions.SHOW_HEADING |
|
|
Gtk.CalendarDisplayOptions.SHOW_DAY_NAMES |
|
|
Gtk.CalendarDisplayOptions.SHOW_WEEK_NUMBERS
|
|
)
|
|
from_calendar.connect("day-selected", self._on_from_date_selected)
|
|
from_calendar.connect("month-changed", self._on_from_calendar_changed)
|
|
from_calendar_container.pack_start(from_calendar, True, True, 0)
|
|
from_frame.add(from_calendar_container)
|
|
calendar_box.pack_start(from_frame, True, True, 0)
|
|
|
|
# To date calendar with year selector
|
|
to_frame = Gtk.Frame(label=_("To Date"))
|
|
to_frame.set_label_align(0.5, 0.5)
|
|
to_calendar_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5)
|
|
to_calendar_container.set_margin_start(5)
|
|
to_calendar_container.set_margin_end(5)
|
|
to_calendar_container.set_margin_top(5)
|
|
to_calendar_container.set_margin_bottom(5)
|
|
|
|
# Year selector for To date
|
|
to_year_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
|
|
to_year_label = Gtk.Label(label=_("Year:"))
|
|
to_year_adjustment = Gtk.Adjustment(
|
|
value=current_year,
|
|
lower=min_year,
|
|
upper=max_year,
|
|
step_increment=1,
|
|
page_increment=10
|
|
)
|
|
to_year_spin = Gtk.SpinButton()
|
|
to_year_spin.set_adjustment(to_year_adjustment)
|
|
to_year_spin.set_numeric(True)
|
|
to_year_spin.set_update_policy(Gtk.SpinButtonUpdatePolicy.IF_VALID)
|
|
to_year_spin.set_width_chars(6)
|
|
to_year_spin.connect("value-changed", self._on_to_year_changed)
|
|
to_year_box.pack_start(to_year_label, False, False, 0)
|
|
to_year_box.pack_start(to_year_spin, False, False, 0)
|
|
to_calendar_container.pack_start(to_year_box, False, False, 0)
|
|
|
|
to_calendar = Gtk.Calendar()
|
|
to_calendar.set_display_options(
|
|
Gtk.CalendarDisplayOptions.SHOW_HEADING |
|
|
Gtk.CalendarDisplayOptions.SHOW_DAY_NAMES |
|
|
Gtk.CalendarDisplayOptions.SHOW_WEEK_NUMBERS
|
|
)
|
|
to_calendar.connect("day-selected", self._on_to_date_selected)
|
|
to_calendar.connect("month-changed", self._on_to_calendar_changed)
|
|
to_calendar_container.pack_start(to_calendar, True, True, 0)
|
|
to_frame.add(to_calendar_container)
|
|
calendar_box.pack_start(to_frame, True, True, 0)
|
|
|
|
box.pack_start(calendar_box, True, True, 0)
|
|
|
|
# Clear button
|
|
clear_button = Gtk.Button(label=_("Clear Dates"))
|
|
clear_button.connect("clicked", self._on_clear_date_range)
|
|
button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
|
button_box.pack_start(clear_button, False, False, 0)
|
|
box.pack_start(button_box, False, False, 0)
|
|
|
|
# Validation label
|
|
self.date_validation_label = Gtk.Label()
|
|
self.date_validation_label.set_line_wrap(True)
|
|
self.date_validation_label.set_markup("<span color='red'></span>")
|
|
box.pack_start(self.date_validation_label, False, False, 0)
|
|
|
|
self._filter_widgets['date_from_calendar'] = from_calendar
|
|
self._filter_widgets['date_to_calendar'] = to_calendar
|
|
self._filter_widgets['date_from_year_spin'] = from_year_spin
|
|
self._filter_widgets['date_to_year_spin'] = to_year_spin
|
|
|
|
scrolled.add(box)
|
|
return scrolled
|
|
|
|
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 based on their children's states
|
|
if 'category_checkboxes' in self._filter_widgets and 'category_event_types' in self._filter_widgets:
|
|
for category, category_checkbox in self._filter_widgets['category_checkboxes'].items():
|
|
# Get child checkboxes for this category
|
|
event_types_in_category = self._filter_widgets['category_event_types'].get(category, [])
|
|
child_checkboxes = [
|
|
self._filter_widgets['event_type_checkboxes'][et]
|
|
for et in event_types_in_category
|
|
if et in self._filter_widgets['event_type_checkboxes']
|
|
]
|
|
# Update category checkbox state based on children
|
|
self._update_group_checkbox_state(category_checkbox, child_checkboxes)
|
|
|
|
# Update person checkboxes with families
|
|
if 'person_checkboxes' in self._filter_widgets and 'person_container' in self._filter_widgets:
|
|
# Clear existing person checkboxes and family expanders
|
|
container = self._filter_widgets['person_container']
|
|
|
|
# Remove all existing expanders
|
|
if 'family_expanders' in self._filter_widgets:
|
|
for expander in list(self._filter_widgets['family_expanders'].values()):
|
|
container.remove(expander)
|
|
expander.destroy()
|
|
self._filter_widgets['family_expanders'].clear()
|
|
|
|
# Remove all existing checkboxes
|
|
for checkbox in list(self._filter_widgets['person_checkboxes'].values()):
|
|
container.remove(checkbox)
|
|
checkbox.destroy()
|
|
self._filter_widgets['person_checkboxes'].clear()
|
|
|
|
# Collect all families and create expanders
|
|
if self.dbstate.is_open():
|
|
try:
|
|
# Initialize family_expanders if not exists
|
|
if 'family_expanders' not in self._filter_widgets:
|
|
self._filter_widgets['family_expanders'] = {}
|
|
|
|
# Iterate through all families
|
|
for family in self.dbstate.db.iter_families():
|
|
family_handle = family.get_handle()
|
|
|
|
# Get family display name
|
|
family_name = self._get_family_display_name(family)
|
|
|
|
# Create container for family members
|
|
members_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=3)
|
|
members_box.set_margin_start(20)
|
|
members_box.set_margin_top(5)
|
|
members_box.set_margin_bottom(5)
|
|
|
|
# Collect all child checkboxes for this family
|
|
child_checkboxes = []
|
|
|
|
# Add father checkbox
|
|
father_handle = family.get_father_handle()
|
|
if father_handle:
|
|
try:
|
|
father = self.dbstate.db.get_person_from_handle(father_handle)
|
|
if father:
|
|
father_label = f" {_('Father')}: {name_displayer.display(father)}"
|
|
checkbox = Gtk.CheckButton(label=father_label)
|
|
checkbox.set_active(True if not self.person_filter else father_handle in self.person_filter)
|
|
self._filter_widgets['person_checkboxes'][father_handle] = checkbox
|
|
child_checkboxes.append(checkbox)
|
|
members_box.pack_start(checkbox, False, False, 0)
|
|
except (AttributeError, KeyError):
|
|
pass
|
|
|
|
# Add mother checkbox
|
|
mother_handle = family.get_mother_handle()
|
|
if mother_handle:
|
|
try:
|
|
mother = self.dbstate.db.get_person_from_handle(mother_handle)
|
|
if mother:
|
|
mother_label = f" {_('Mother')}: {name_displayer.display(mother)}"
|
|
checkbox = Gtk.CheckButton(label=mother_label)
|
|
checkbox.set_active(True if not self.person_filter else mother_handle in self.person_filter)
|
|
self._filter_widgets['person_checkboxes'][mother_handle] = checkbox
|
|
child_checkboxes.append(checkbox)
|
|
members_box.pack_start(checkbox, False, False, 0)
|
|
except (AttributeError, KeyError):
|
|
pass
|
|
|
|
# Add children checkboxes
|
|
for child_ref in family.get_child_ref_list():
|
|
child_handle = child_ref.ref
|
|
try:
|
|
child = self.dbstate.db.get_person_from_handle(child_handle)
|
|
if child:
|
|
child_label = f" {_('Child')}: {name_displayer.display(child)}"
|
|
checkbox = Gtk.CheckButton(label=child_label)
|
|
checkbox.set_active(True if not self.person_filter else child_handle in self.person_filter)
|
|
self._filter_widgets['person_checkboxes'][child_handle] = checkbox
|
|
child_checkboxes.append(checkbox)
|
|
members_box.pack_start(checkbox, False, False, 0)
|
|
except (AttributeError, KeyError):
|
|
pass
|
|
|
|
# Only add expander if there are members to show
|
|
if len(members_box.get_children()) > 0:
|
|
# Create family checkbox with three-state support
|
|
family_checkbox = Gtk.CheckButton(label=family_name)
|
|
|
|
# Create expander with checkbox as label
|
|
expander = Gtk.Expander()
|
|
# Set the checkbox as the label widget
|
|
expander.set_label_widget(family_checkbox)
|
|
expander.set_expanded(False)
|
|
|
|
# Store family checkbox
|
|
if 'family_checkboxes' not in self._filter_widgets:
|
|
self._filter_widgets['family_checkboxes'] = {}
|
|
self._filter_widgets['family_checkboxes'][family_handle] = family_checkbox
|
|
|
|
# Flag to prevent recursion
|
|
updating_family = [False]
|
|
|
|
# Connect family checkbox to toggle all members
|
|
def make_family_toggle_handler(cb_list, updating_flag):
|
|
def handler(widget):
|
|
if updating_flag[0]:
|
|
return
|
|
# Handle inconsistent state
|
|
if widget.get_inconsistent():
|
|
widget.set_inconsistent(False)
|
|
widget.set_active(True)
|
|
updating_flag[0] = True
|
|
is_active = widget.get_active()
|
|
for child_cb in cb_list:
|
|
child_cb.set_active(is_active)
|
|
updating_flag[0] = False
|
|
return handler
|
|
|
|
# Connect child checkboxes to update family checkbox
|
|
def make_family_member_toggle_handler(fam_cb, children, updating_flag):
|
|
def handler(widget):
|
|
if updating_flag[0]:
|
|
return
|
|
self._update_group_checkbox_state(fam_cb, children)
|
|
return handler
|
|
|
|
# Connect family checkbox
|
|
family_checkbox.connect("toggled",
|
|
make_family_toggle_handler(child_checkboxes, updating_family))
|
|
|
|
# Connect child checkboxes
|
|
for child_cb in child_checkboxes:
|
|
child_cb.connect("toggled",
|
|
make_family_member_toggle_handler(family_checkbox, child_checkboxes, updating_family))
|
|
|
|
# Initialize family checkbox state
|
|
self._update_group_checkbox_state(family_checkbox, child_checkboxes)
|
|
|
|
expander.add(members_box)
|
|
self._filter_widgets['family_expanders'][family_handle] = expander
|
|
container.pack_start(expander, 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 calendars and year selectors
|
|
if 'date_from_calendar' in self._filter_widgets and 'date_to_calendar' in self._filter_widgets:
|
|
from_calendar = self._filter_widgets['date_from_calendar']
|
|
to_calendar = self._filter_widgets['date_to_calendar']
|
|
|
|
if self.date_range_filter and self.date_range_explicit:
|
|
min_sort, max_sort = self.date_range_filter
|
|
# Convert sort values back to dates for calendar display
|
|
# Approximate conversion: extract year from sort value
|
|
# Sort value is roughly: year * 10000 + month * 100 + day
|
|
from_year = min_sort // 10000
|
|
to_year = max_sort // 10000
|
|
|
|
# Set calendar years (approximate)
|
|
current_from_year, current_from_month, current_from_day = from_calendar.get_date()
|
|
current_to_year, current_to_month, current_to_day = to_calendar.get_date()
|
|
|
|
from_calendar.select_month(current_from_month, from_year)
|
|
to_calendar.select_month(current_to_month, to_year)
|
|
|
|
# Update year spin buttons
|
|
if 'date_from_year_spin' in self._filter_widgets:
|
|
from_year_spin = self._filter_widgets['date_from_year_spin']
|
|
from_year_spin.handler_block_by_func(self._on_from_year_changed)
|
|
from_year_spin.set_value(from_year)
|
|
from_year_spin.handler_unblock_by_func(self._on_from_year_changed)
|
|
|
|
if 'date_to_year_spin' in self._filter_widgets:
|
|
to_year_spin = self._filter_widgets['date_to_year_spin']
|
|
to_year_spin.handler_block_by_func(self._on_to_year_changed)
|
|
to_year_spin.set_value(to_year)
|
|
to_year_spin.handler_unblock_by_func(self._on_to_year_changed)
|
|
else:
|
|
# Reset to current date
|
|
import datetime
|
|
now = datetime.date.today()
|
|
from_calendar.select_month(now.month - 1, now.year)
|
|
to_calendar.select_month(now.month - 1, now.year)
|
|
|
|
# Reset year spin buttons
|
|
if 'date_from_year_spin' in self._filter_widgets:
|
|
from_year_spin = self._filter_widgets['date_from_year_spin']
|
|
from_year_spin.handler_block_by_func(self._on_from_year_changed)
|
|
from_year_spin.set_value(now.year)
|
|
from_year_spin.handler_unblock_by_func(self._on_from_year_changed)
|
|
|
|
if 'date_to_year_spin' in self._filter_widgets:
|
|
to_year_spin = self._filter_widgets['date_to_year_spin']
|
|
to_year_spin.handler_block_by_func(self._on_to_year_changed)
|
|
to_year_spin.set_value(now.year)
|
|
to_year_spin.handler_unblock_by_func(self._on_to_year_changed)
|
|
|
|
# Clear validation message
|
|
if hasattr(self, 'date_validation_label'):
|
|
self.date_validation_label.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.date_range_explicit = False
|
|
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 from calendar widgets
|
|
if 'date_from_calendar' in self._filter_widgets and 'date_to_calendar' in self._filter_widgets:
|
|
from_calendar = self._filter_widgets['date_from_calendar']
|
|
to_calendar = self._filter_widgets['date_to_calendar']
|
|
|
|
# Check if dates were explicitly set (stored in widget data)
|
|
# We'll use a simple approach: if user clicked calendars, use the dates
|
|
# For now, we'll always use calendar dates if they're valid
|
|
# The "Clear Dates" button will reset the explicit flag
|
|
|
|
# Get selected dates from calendars
|
|
# Gtk.Calendar.get_date() returns (year, month, day) where month is 0-11
|
|
from_year, from_month, from_day = from_calendar.get_date()
|
|
to_year, to_month, to_day = to_calendar.get_date()
|
|
|
|
try:
|
|
# Create Date objects from calendar selections
|
|
# Note: month from calendar is 0-11, Date.set_yr_mon_day expects 1-12
|
|
from_date = Date()
|
|
from_date.set_yr_mon_day(from_year, from_month + 1, from_day)
|
|
min_sort = from_date.get_sort_value()
|
|
|
|
to_date = Date()
|
|
to_date.set_yr_mon_day(to_year, to_month + 1, to_day)
|
|
max_sort = to_date.get_sort_value()
|
|
|
|
# Validate date range
|
|
if min_sort > max_sort:
|
|
# Show error message
|
|
if hasattr(self, 'date_validation_label'):
|
|
self.date_validation_label.set_markup(
|
|
f"<span color='red'>{_('Error: From date must be before To date')}</span>"
|
|
)
|
|
self.date_range_filter = None
|
|
self.date_range_explicit = False
|
|
return
|
|
else:
|
|
# Clear error message
|
|
if hasattr(self, 'date_validation_label'):
|
|
self.date_validation_label.set_text("")
|
|
|
|
# Set filter - user has selected dates in calendars
|
|
self.date_range_filter = (min_sort, max_sort)
|
|
self.date_range_explicit = True
|
|
|
|
except (ValueError, AttributeError, TypeError) as e:
|
|
logger.warning(f"Error parsing date range: {e}", exc_info=True)
|
|
self.date_range_filter = None
|
|
self.date_range_explicit = False
|
|
if hasattr(self, 'date_validation_label'):
|
|
self.date_validation_label.set_markup(
|
|
f"<span color='red'>{_('Error: Invalid date')}</span>"
|
|
)
|
|
else:
|
|
# No calendar widgets, clear filter
|
|
self.date_range_filter = None
|
|
self.date_range_explicit = False
|
|
|
|
# Enable filter if any filter is active
|
|
self.filter_enabled = (
|
|
self.active_event_types or
|
|
(self.date_range_filter is not None and self.date_range_explicit) 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 _on_from_date_selected(self, calendar: Gtk.Calendar) -> None:
|
|
"""
|
|
Handle From date calendar selection.
|
|
|
|
Args:
|
|
calendar: The calendar widget that was selected.
|
|
"""
|
|
# Update year spin button to match calendar
|
|
if 'date_from_year_spin' in self._filter_widgets:
|
|
year, month, day = calendar.get_date()
|
|
year_spin = self._filter_widgets['date_from_year_spin']
|
|
year_spin.handler_block_by_func(self._on_from_year_changed)
|
|
year_spin.set_value(year)
|
|
year_spin.handler_unblock_by_func(self._on_from_year_changed)
|
|
|
|
self._validate_date_range()
|
|
|
|
def _on_to_date_selected(self, calendar: Gtk.Calendar) -> None:
|
|
"""
|
|
Handle To date calendar selection.
|
|
|
|
Args:
|
|
calendar: The calendar widget that was selected.
|
|
"""
|
|
# Update year spin button to match calendar
|
|
if 'date_to_year_spin' in self._filter_widgets:
|
|
year, month, day = calendar.get_date()
|
|
year_spin = self._filter_widgets['date_to_year_spin']
|
|
year_spin.handler_block_by_func(self._on_to_year_changed)
|
|
year_spin.set_value(year)
|
|
year_spin.handler_unblock_by_func(self._on_to_year_changed)
|
|
|
|
self._validate_date_range()
|
|
|
|
def _on_from_calendar_changed(self, calendar: Gtk.Calendar) -> None:
|
|
"""
|
|
Handle From calendar month/year change.
|
|
|
|
Args:
|
|
calendar: The calendar widget that changed.
|
|
"""
|
|
# Update year spin button to match calendar
|
|
if 'date_from_year_spin' in self._filter_widgets:
|
|
year, month, day = calendar.get_date()
|
|
year_spin = self._filter_widgets['date_from_year_spin']
|
|
year_spin.handler_block_by_func(self._on_from_year_changed)
|
|
year_spin.set_value(year)
|
|
year_spin.handler_unblock_by_func(self._on_from_year_changed)
|
|
|
|
def _on_to_calendar_changed(self, calendar: Gtk.Calendar) -> None:
|
|
"""
|
|
Handle To calendar month/year change.
|
|
|
|
Args:
|
|
calendar: The calendar widget that changed.
|
|
"""
|
|
# Update year spin button to match calendar
|
|
if 'date_to_year_spin' in self._filter_widgets:
|
|
year, month, day = calendar.get_date()
|
|
year_spin = self._filter_widgets['date_to_year_spin']
|
|
year_spin.handler_block_by_func(self._on_to_year_changed)
|
|
year_spin.set_value(year)
|
|
year_spin.handler_unblock_by_func(self._on_to_year_changed)
|
|
|
|
def _on_from_year_changed(self, spin_button: Gtk.SpinButton) -> None:
|
|
"""
|
|
Handle From year spin button change.
|
|
|
|
Args:
|
|
spin_button: The year spin button that changed.
|
|
"""
|
|
if 'date_from_calendar' in self._filter_widgets:
|
|
calendar = self._filter_widgets['date_from_calendar']
|
|
new_year = int(spin_button.get_value())
|
|
current_year, current_month, current_day = calendar.get_date()
|
|
# Update calendar to new year, keeping same month and day
|
|
calendar.select_month(current_month, new_year)
|
|
# Trigger validation
|
|
self._validate_date_range()
|
|
|
|
def _on_to_year_changed(self, spin_button: Gtk.SpinButton) -> None:
|
|
"""
|
|
Handle To year spin button change.
|
|
|
|
Args:
|
|
spin_button: The year spin button that changed.
|
|
"""
|
|
if 'date_to_calendar' in self._filter_widgets:
|
|
calendar = self._filter_widgets['date_to_calendar']
|
|
new_year = int(spin_button.get_value())
|
|
current_year, current_month, current_day = calendar.get_date()
|
|
# Update calendar to new year, keeping same month and day
|
|
calendar.select_month(current_month, new_year)
|
|
# Trigger validation
|
|
self._validate_date_range()
|
|
|
|
def _on_clear_date_range(self, button: Gtk.Button) -> None:
|
|
"""
|
|
Clear the date range selection.
|
|
|
|
Args:
|
|
button: The clear button that was clicked.
|
|
"""
|
|
if 'date_from_calendar' in self._filter_widgets and 'date_to_calendar' in self._filter_widgets:
|
|
from_calendar = self._filter_widgets['date_from_calendar']
|
|
to_calendar = self._filter_widgets['date_to_calendar']
|
|
|
|
# Reset to current date (calendars always show a date)
|
|
# Get current date and set calendars to it
|
|
import datetime
|
|
now = datetime.date.today()
|
|
from_calendar.select_month(now.month - 1, now.year) # month is 0-11
|
|
from_calendar.select_day(now.day)
|
|
to_calendar.select_month(now.month - 1, now.year)
|
|
to_calendar.select_day(now.day)
|
|
|
|
# Reset year spin buttons
|
|
if 'date_from_year_spin' in self._filter_widgets:
|
|
from_year_spin = self._filter_widgets['date_from_year_spin']
|
|
from_year_spin.handler_block_by_func(self._on_from_year_changed)
|
|
from_year_spin.set_value(now.year)
|
|
from_year_spin.handler_unblock_by_func(self._on_from_year_changed)
|
|
|
|
if 'date_to_year_spin' in self._filter_widgets:
|
|
to_year_spin = self._filter_widgets['date_to_year_spin']
|
|
to_year_spin.handler_block_by_func(self._on_to_year_changed)
|
|
to_year_spin.set_value(now.year)
|
|
to_year_spin.handler_unblock_by_func(self._on_to_year_changed)
|
|
|
|
# Clear validation message
|
|
if hasattr(self, 'date_validation_label'):
|
|
self.date_validation_label.set_text("")
|
|
|
|
# Mark that date range should not be applied
|
|
self.date_range_explicit = False
|
|
|
|
def _validate_date_range(self) -> None:
|
|
"""
|
|
Validate that From date is not after To date.
|
|
"""
|
|
if 'date_from_calendar' not in self._filter_widgets or 'date_to_calendar' not in self._filter_widgets:
|
|
return
|
|
|
|
from_calendar = self._filter_widgets['date_from_calendar']
|
|
to_calendar = self._filter_widgets['date_to_calendar']
|
|
|
|
from_year, from_month, from_day = from_calendar.get_date()
|
|
to_year, to_month, to_day = to_calendar.get_date()
|
|
|
|
try:
|
|
from_date = Date()
|
|
from_date.set_yr_mon_day(from_year, from_month + 1, from_day)
|
|
from_sort = from_date.get_sort_value()
|
|
|
|
to_date = Date()
|
|
to_date.set_yr_mon_day(to_year, to_month + 1, to_day)
|
|
to_sort = to_date.get_sort_value()
|
|
|
|
if from_sort > to_sort:
|
|
if hasattr(self, 'date_validation_label'):
|
|
self.date_validation_label.set_markup(
|
|
f"<span color='red'>{_('Warning: From date is after To date')}</span>"
|
|
)
|
|
else:
|
|
if hasattr(self, 'date_validation_label'):
|
|
self.date_validation_label.set_text("")
|
|
except (ValueError, AttributeError, TypeError):
|
|
pass
|
|
|
|
def build_tree(self) -> None:
|
|
"""
|
|
Rebuilds the current display. Called when the view becomes visible.
|
|
"""
|
|
# Collect all events if not already done
|
|
if not self.all_events and self.dbstate.is_open():
|
|
self.collect_events()
|
|
|
|
# Highlight active event if one is selected
|
|
active_handle = self.get_active()
|
|
if active_handle:
|
|
self.goto_handle(active_handle)
|
|
elif self.drawing_area:
|
|
self.drawing_area.queue_draw()
|
|
|
|
def goto_handle(self, handle: str) -> None:
|
|
"""
|
|
Called when the active event changes.
|
|
|
|
Args:
|
|
handle: The handle of the event to highlight/select.
|
|
"""
|
|
if handle == self.active_event_handle:
|
|
return
|
|
|
|
self.active_event_handle = handle
|
|
|
|
# Ensure events are collected
|
|
if not self.all_events:
|
|
self.collect_events()
|
|
else:
|
|
# Just refresh display to highlight the selected event
|
|
if self.drawing_area:
|
|
self.drawing_area.queue_draw()
|
|
|
|
# Scroll to the selected event if possible
|
|
self._scroll_to_event(handle)
|
|
|
|
def _scroll_to_event(self, event_handle: str) -> None:
|
|
"""
|
|
Scroll the timeline to show the selected event.
|
|
|
|
Args:
|
|
event_handle: The handle of the event to scroll to.
|
|
"""
|
|
if not self.scrolledwindow or not event_handle:
|
|
return
|
|
|
|
# Find the event in our event list
|
|
event_index = None
|
|
for i, timeline_event in enumerate(self.events):
|
|
if timeline_event.event.get_handle() == event_handle:
|
|
event_index = i
|
|
break
|
|
|
|
if event_index is None:
|
|
return
|
|
|
|
# Calculate the y position of the event
|
|
if event_index < len(self.events):
|
|
event_y = TIMELINE_MARGIN_TOP + (event_index * EVENT_SPACING)
|
|
|
|
# Get the adjustment and scroll to the event
|
|
vadj = self.scrolledwindow.get_vadjustment()
|
|
if vadj:
|
|
# Center the event in the viewport
|
|
viewport_height = self.scrolledwindow.get_allocation().height
|
|
scroll_to = max(0, event_y - (viewport_height / 2))
|
|
vadj.set_value(scroll_to)
|
|
|
|
def _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 _process_event(self, event, person_obj: Optional[Any] = None) -> Optional[TimelineEvent]:
|
|
"""
|
|
Process a single event and create a TimelineEvent.
|
|
|
|
Args:
|
|
event: The event object to process.
|
|
person_obj: Optional person object associated with this event.
|
|
|
|
Returns:
|
|
Optional[TimelineEvent]: The created TimelineEvent, or None if invalid.
|
|
"""
|
|
try:
|
|
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: {e}")
|
|
return None
|
|
|
|
def _find_person_for_event(self, event) -> Optional[Any]:
|
|
"""
|
|
Find a primary person associated with an event by searching through people.
|
|
|
|
Args:
|
|
event: The event object to find a person for.
|
|
|
|
Returns:
|
|
Optional person object, or None if not found or if event is family-only.
|
|
"""
|
|
if not event:
|
|
return None
|
|
|
|
event_handle = event.get_handle()
|
|
|
|
# Search through people to find who references this event
|
|
# For performance, we'll search through a limited set or use event references
|
|
try:
|
|
# Try to find a person who has this event as a primary event
|
|
for person_handle in self.dbstate.db.get_person_handles():
|
|
try:
|
|
person = self.dbstate.db.get_person_from_handle(person_handle)
|
|
if person:
|
|
# Check primary events first
|
|
for event_ref in person.get_primary_event_ref_list():
|
|
if event_ref.ref == event_handle:
|
|
return person
|
|
# Check all events
|
|
for event_ref in person.get_event_ref_list():
|
|
if event_ref.ref == event_handle:
|
|
return person
|
|
except (AttributeError, KeyError):
|
|
continue
|
|
except (AttributeError, KeyError):
|
|
pass
|
|
|
|
return None
|
|
|
|
def _get_family_display_name(self, family) -> str:
|
|
"""
|
|
Get a display name for a family showing parent names.
|
|
|
|
Args:
|
|
family: The family object.
|
|
|
|
Returns:
|
|
str: Display name for the family.
|
|
"""
|
|
if not family:
|
|
return _("Unknown Family")
|
|
|
|
father_handle = family.get_father_handle()
|
|
mother_handle = family.get_mother_handle()
|
|
|
|
father_name = None
|
|
mother_name = None
|
|
|
|
if father_handle:
|
|
try:
|
|
father = self.dbstate.db.get_person_from_handle(father_handle)
|
|
if father:
|
|
father_name = name_displayer.display(father)
|
|
except (AttributeError, KeyError):
|
|
pass
|
|
|
|
if mother_handle:
|
|
try:
|
|
mother = self.dbstate.db.get_person_from_handle(mother_handle)
|
|
if mother:
|
|
mother_name = name_displayer.display(mother)
|
|
except (AttributeError, KeyError):
|
|
pass
|
|
|
|
if father_name and mother_name:
|
|
return f"{father_name} & {mother_name}"
|
|
elif father_name:
|
|
return f"{father_name} & {_('Unknown')}"
|
|
elif mother_name:
|
|
return f"{_('Unknown')} & {mother_name}"
|
|
else:
|
|
return _("Unknown Family")
|
|
|
|
def collect_events(self) -> None:
|
|
"""Collect all events from the database."""
|
|
self.all_events = []
|
|
self.events = []
|
|
|
|
if not self.dbstate.is_open():
|
|
return
|
|
|
|
try:
|
|
# Iterate through all events in the database
|
|
for event in self.dbstate.db.iter_events():
|
|
# Try to find an associated person for this event
|
|
person_obj = self._find_person_for_event(event)
|
|
|
|
# Process the event
|
|
timeline_event = self._process_event(event, person_obj)
|
|
if timeline_event:
|
|
self.all_events.append(timeline_event)
|
|
|
|
except (AttributeError, KeyError) as e:
|
|
logger.warning(f"Error collecting events: {e}", exc_info=True)
|
|
return
|
|
|
|
# Sort events by date
|
|
self.all_events.sort(key=lambda x: x.date_sort)
|
|
|
|
# Apply filters
|
|
self.events = self._apply_filters(self.all_events)
|
|
|
|
# Invalidate cache when events change
|
|
self._invalidate_cache()
|
|
|
|
# Calculate timeline height
|
|
self._calculate_timeline_height()
|
|
|
|
# Update filter button state
|
|
self._update_filter_button_state()
|
|
|
|
def _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 or not self.date_range_explicit:
|
|
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_type_display_name(self, event_type: EventType) -> str:
|
|
"""
|
|
Get human-readable display name for an event type.
|
|
|
|
Args:
|
|
event_type: The event type (integer or EventType object).
|
|
|
|
Returns:
|
|
str: Human-readable name for the event type.
|
|
"""
|
|
# Normalize to integer first
|
|
event_type_value = self._normalize_event_type(event_type)
|
|
|
|
# Map EventType integer values to their human-readable names
|
|
event_type_names = {
|
|
EventType.BIRTH: _("Birth"),
|
|
EventType.DEATH: _("Death"),
|
|
EventType.BURIAL: _("Burial"),
|
|
EventType.CREMATION: _("Cremation"),
|
|
EventType.ADOPT: _("Adoption"),
|
|
EventType.MARRIAGE: _("Marriage"),
|
|
EventType.DIVORCE: _("Divorce"),
|
|
EventType.ENGAGEMENT: _("Engagement"),
|
|
EventType.MARR_SETTL: _("Marriage Settlement"),
|
|
EventType.MARR_LIC: _("Marriage License"),
|
|
EventType.MARR_CONTR: _("Marriage Contract"),
|
|
EventType.MARR_BANNS: _("Marriage Banns"),
|
|
EventType.DIV_FILING: _("Divorce Filing"),
|
|
EventType.ANNULMENT: _("Annulment"),
|
|
EventType.MARR_ALT: _("Marriage (Alternative)"),
|
|
EventType.BAPTISM: _("Baptism"),
|
|
EventType.ADULT_CHRISTEN: _("Adult Christening"),
|
|
EventType.CONFIRMATION: _("Confirmation"),
|
|
EventType.CHRISTEN: _("Christening"),
|
|
EventType.FIRST_COMMUN: _("First Communion"),
|
|
EventType.BLESS: _("Blessing"),
|
|
EventType.BAR_MITZVAH: _("Bar Mitzvah"),
|
|
EventType.BAS_MITZVAH: _("Bat Mitzvah"),
|
|
EventType.RELIGION: _("Religion"),
|
|
EventType.ORDINATION: _("Ordination"),
|
|
EventType.OCCUPATION: _("Occupation"),
|
|
EventType.RETIREMENT: _("Retirement"),
|
|
EventType.ELECTED: _("Elected"),
|
|
EventType.MILITARY_SERV: _("Military Service"),
|
|
EventType.EDUCATION: _("Education"),
|
|
EventType.GRADUATION: _("Graduation"),
|
|
EventType.DEGREE: _("Degree"),
|
|
EventType.EMIGRATION: _("Emigration"),
|
|
EventType.IMMIGRATION: _("Immigration"),
|
|
EventType.NATURALIZATION: _("Naturalization"),
|
|
EventType.PROBATE: _("Probate"),
|
|
EventType.WILL: _("Will"),
|
|
EventType.RESIDENCE: _("Residence"),
|
|
EventType.CENSUS: _("Census"),
|
|
EventType.PROPERTY: _("Property"),
|
|
EventType.CAUSE_DEATH: _("Cause of Death"),
|
|
EventType.MED_INFO: _("Medical Information"),
|
|
EventType.NOB_TITLE: _("Nobility Title"),
|
|
EventType.NUM_MARRIAGES: _("Number of Marriages"),
|
|
EventType.UNKNOWN: _("Unknown"),
|
|
EventType.CUSTOM: _("Custom"),
|
|
}
|
|
|
|
return event_type_names.get(event_type_value, _("Unknown Event"))
|
|
|
|
def _get_event_category(self, event_type: EventType) -> str:
|
|
"""
|
|
Get the category for an event type.
|
|
|
|
Args:
|
|
event_type: The event type (may be EventType object or integer).
|
|
|
|
Returns:
|
|
str: The category name, or "Other Events" if not found.
|
|
"""
|
|
# Normalize EventType to integer for dictionary lookup
|
|
event_type_value = self._normalize_event_type(event_type)
|
|
return EVENT_CATEGORIES.get(event_type_value, "Other Events")
|
|
|
|
def apply_filters(self) -> None:
|
|
"""
|
|
Apply current filters and update the view.
|
|
Public method to trigger filter application.
|
|
"""
|
|
if self.all_events:
|
|
self.events = self._apply_filters(self.all_events)
|
|
self._invalidate_cache()
|
|
self._calculate_timeline_height()
|
|
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]
|
|
event_handle = clicked_event_data.event.get_handle()
|
|
|
|
# Select/highlight this event
|
|
if self.active_event_handle == event_handle:
|
|
# Deselect if clicking same event
|
|
self.active_event_handle = None
|
|
self.selected_person_handle = None
|
|
else:
|
|
# Select this event
|
|
self.active_event_handle = event_handle
|
|
# Set selected person based on this event's person
|
|
if clicked_event_data.person:
|
|
self.selected_person_handle = clicked_event_data.person.get_handle()
|
|
else:
|
|
self.selected_person_handle = None
|
|
# Navigate to this event in the view
|
|
self.uistate.set_active(event_handle, "Event")
|
|
|
|
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 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 is the active/selected event
|
|
is_selected = (self.active_event_handle is not None and
|
|
event_data.event.get_handle() == self.active_event_handle)
|
|
|
|
# Draw event marker with modern styling
|
|
self.draw_event_marker(context, timeline_x, event_data.y_pos,
|
|
event_data.event_type, is_hovered, is_selected)
|
|
|
|
# Draw 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 (from selected event)
|
|
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"
|