- Replace len() > 0 with direct boolean check for better Pythonic code - Optimize dictionary iteration by removing unnecessary .keys() calls These changes improve code readability and follow Python best practices by using more idiomatic patterns.
3489 lines
136 KiB
Python
3489 lines
136 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 colorsys
|
|
import datetime
|
|
import logging
|
|
import math
|
|
from dataclasses import dataclass
|
|
from typing import Optional, List, Tuple, Any, Set, Dict, TYPE_CHECKING, Union, Callable
|
|
|
|
if TYPE_CHECKING:
|
|
from gramps.gen.lib import Event, Person, Family
|
|
|
|
from gi.repository import Gtk
|
|
from gi.repository import Gdk
|
|
from gi.repository import 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
|
|
DEFAULT_TIMELINE_HEIGHT = 1000 # Default height, will be recalculated
|
|
DEFAULT_TIMELINE_WIDTH = 800
|
|
DEFAULT_DRAWING_AREA_HEIGHT = 600
|
|
DEFAULT_DRAWING_AREA_WIDTH = 800
|
|
EMPTY_TIMELINE_HEIGHT = 600 # Height when no events
|
|
FILTER_DIALOG_WIDTH = 600
|
|
FILTER_DIALOG_HEIGHT = 700
|
|
MIN_GENEALOGICAL_YEAR = 1000 # Minimum year for genealogical data
|
|
DATE_SORT_YEAR_MULTIPLIER = 10000 # Year component in date sort value
|
|
DATE_SORT_MONTH_MULTIPLIER = 100 # Month component in date sort value
|
|
|
|
# UI Layout Constants
|
|
FILTER_PAGE_MARGIN = 10 # Margin for filter page containers
|
|
FILTER_PAGE_SPACING = 10 # Spacing in filter page containers
|
|
FILTER_PAGE_VERTICAL_SPACING = 5 # Vertical spacing within filter pages
|
|
FILTER_CATEGORY_SPACING = 2 # Spacing within category containers
|
|
FILTER_INDENT_MARGIN = 20 # Indent margin for nested elements
|
|
CALENDAR_CONTAINER_MARGIN = 5 # Margin for calendar containers
|
|
CALENDAR_CONTAINER_SPACING = 5 # Spacing in calendar containers
|
|
CALENDAR_HORIZONTAL_SPACING = 15 # Spacing between calendar frames
|
|
YEAR_SELECTOR_SPACING = 5 # Spacing in year selector boxes
|
|
TOOLBAR_SPACING = 5 # Spacing in toolbar
|
|
TOOLBAR_ITEM_MARGIN = 10 # Margin for toolbar items
|
|
TOOLTIP_SEPARATOR_LENGTH = 30 # Length of tooltip separator line
|
|
TOOLTIP_OFFSET = 15 # Offset for tooltip position to avoid cursor
|
|
TOOLTIP_LABEL_MARGIN = 5 # Margin for tooltip label
|
|
TOOLTIP_MAX_WIDTH_CHARS = 40 # Maximum width in characters for tooltip label
|
|
TOOLTIP_BORDER_WIDTH = 8 # Border width for tooltip window
|
|
|
|
# Font Constants
|
|
FONT_FAMILY = "Sans"
|
|
FONT_SIZE_NORMAL = 11
|
|
FONT_SIZE_SMALL = 9
|
|
FONT_SIZE_LARGE = 24
|
|
|
|
# Visual Effect Constants
|
|
MARKER_HOVER_SIZE_MULTIPLIER = 1.3
|
|
MARKER_SELECTED_SIZE_MULTIPLIER = 1.2
|
|
DEFAULT_EVENT_SHAPE = 'square' # Default shape for events without specific shape mapping
|
|
|
|
# Mouse Button Constants
|
|
MOUSE_BUTTON_LEFT = 1 # Left mouse button
|
|
ZOOM_PERCENTAGE_MULTIPLIER = 100 # Multiplier to convert zoom level (0.0-1.0) to percentage
|
|
GRADIENT_BRIGHTNESS_OFFSET = 0.2
|
|
GRADIENT_DARKNESS_OFFSET = 0.1
|
|
SHADOW_OFFSET_X = 1
|
|
SHADOW_OFFSET_Y = 1
|
|
SHADOW_OPACITY = 0.3
|
|
BORDER_OPACITY = 0.3
|
|
TIMELINE_SHADOW_OFFSET = 2 # Shadow offset for timeline axis
|
|
|
|
# Drawing Color Constants
|
|
BACKGROUND_GRADIENT_START = (0.98, 0.98, 0.99) # Very light gray-blue
|
|
BACKGROUND_GRADIENT_END = (0.95, 0.96, 0.98) # Slightly darker
|
|
TIMELINE_AXIS_GRADIENT_START = (0.3, 0.3, 0.3) # Dark gray
|
|
TIMELINE_AXIS_GRADIENT_END = (0.5, 0.5, 0.5) # Medium gray
|
|
TIMELINE_SHADOW_OPACITY = 0.2 # Shadow opacity for timeline axis
|
|
|
|
# Connection Line Constants
|
|
CONNECTION_LINE_COLOR = (0.2, 0.5, 1.0, 0.75) # Brighter, more opaque blue
|
|
CONNECTION_LINE_WIDTH = 3.5
|
|
CONNECTION_VERTICAL_LINE_X = 5 # Left of year markers
|
|
CONNECTION_LINE_SPACING = 25 # Pixels between vertical lines for different persons
|
|
TIMELINE_LEFT_SPACING = 20 # Spacing between rightmost connection line and timeline
|
|
CONNECTION_LINE_MIN_DISTANCE = EVENT_MARKER_SIZE * 2 # Minimum distance between events to draw vertical connector line
|
|
|
|
# Color Generation Constants
|
|
PERSON_COLOR_SATURATION = 0.7 # High saturation for vibrant colors
|
|
PERSON_COLOR_LIGHTNESS = 0.5 # Medium lightness for good visibility
|
|
PERSON_COLOR_ALPHA = 0.75 # Semi-transparent
|
|
HSV_HUE_MAX_DEGREES = 360 # Maximum value for hue in HSV color space (degrees)
|
|
|
|
# Marker State Colors
|
|
SELECTED_MARKER_COLOR = (0.2, 0.4, 0.9) # Blue highlight for selected person's events
|
|
|
|
# Default Colors
|
|
DEFAULT_EVENT_COLOR = (0.5, 0.5, 0.5) # Gray for unknown event types
|
|
YEAR_MARKER_COLOR = (0.5, 0.5, 0.5) # Gray for year marker tick marks
|
|
COLOR_BLACK = (0.0, 0.0, 0.0) # Black color used in multiple drawing operations
|
|
|
|
# Label Drawing Constants
|
|
LABEL_TEXT_COLOR = (0.1, 0.1, 0.1) # Dark gray text color for event labels
|
|
LABEL_VERTICAL_OFFSET = 10 # Vertical offset to center label on marker
|
|
LABEL_HOVER_BACKGROUND_COLOR = (1.0, 1.0, 1.0, 0.8) # Semi-transparent white background for hovered labels
|
|
LABEL_HOVER_BORDER_COLOR = (0.7, 0.7, 0.7, 0.5) # Light gray border for hovered labels
|
|
|
|
# Marker Drawing Constants
|
|
MARKER_BORDER_WIDTH = 1 # Line width for marker borders
|
|
|
|
# Year Marker Constants
|
|
YEAR_MARKER_LABEL_OFFSET = 20 # Horizontal offset for year marker labels from timeline
|
|
|
|
# Logger
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Event type categories and color mapping
|
|
EVENT_COLORS = {
|
|
# Life Events - Green/Red spectrum
|
|
EventType.BIRTH: (0.2, 0.8, 0.3), # Bright green
|
|
EventType.DEATH: (0.9, 0.2, 0.2), # Bright red
|
|
EventType.BURIAL: (0.7, 0.3, 0.3), # Dark red
|
|
EventType.CREMATION: (0.8, 0.4, 0.4), # Light red
|
|
EventType.ADOPT: (0.3, 0.7, 0.4), # Green
|
|
|
|
# Family Events - Blue/Purple spectrum
|
|
EventType.MARRIAGE: (0.2, 0.4, 0.9), # Blue
|
|
EventType.DIVORCE: (0.6, 0.3, 0.7), # Purple
|
|
EventType.ENGAGEMENT: (0.4, 0.5, 0.9), # Light blue
|
|
EventType.MARR_SETTL: (0.3, 0.4, 0.8), # Dark blue
|
|
EventType.MARR_LIC: (0.4, 0.5, 0.85), # Medium blue
|
|
EventType.MARR_CONTR: (0.35, 0.45, 0.85), # Medium blue
|
|
EventType.MARR_BANNS: (0.45, 0.55, 0.9), # Light blue
|
|
EventType.DIV_FILING: (0.65, 0.35, 0.75), # Light purple
|
|
EventType.ANNULMENT: (0.7, 0.4, 0.8), # Light purple
|
|
EventType.MARR_ALT: (0.5, 0.5, 0.9), # Medium blue
|
|
|
|
# Religious Events - Gold/Yellow spectrum
|
|
EventType.BAPTISM: (0.95, 0.85, 0.2), # Gold
|
|
EventType.ADULT_CHRISTEN: (0.9, 0.8, 0.3), # Light gold
|
|
EventType.CONFIRMATION: (0.95, 0.9, 0.4), # Yellow
|
|
EventType.CHRISTEN: (0.9, 0.85, 0.35), # Gold
|
|
EventType.FIRST_COMMUN: (0.95, 0.88, 0.3), # Gold
|
|
EventType.BLESS: (0.92, 0.87, 0.4), # Light gold
|
|
EventType.BAR_MITZVAH: (0.93, 0.83, 0.3), # Gold
|
|
EventType.BAS_MITZVAH: (0.94, 0.84, 0.3), # Gold
|
|
EventType.RELIGION: (0.9, 0.8, 0.5), # Light gold
|
|
EventType.ORDINATION: (0.88, 0.75, 0.3), # Dark gold
|
|
|
|
# Vocational Events - Orange spectrum
|
|
EventType.OCCUPATION: (0.95, 0.6, 0.2), # Orange
|
|
EventType.RETIREMENT: (0.9, 0.55, 0.25), # Dark orange
|
|
EventType.ELECTED: (0.95, 0.65, 0.3), # Light orange
|
|
EventType.MILITARY_SERV: (0.85, 0.5, 0.2), # Dark orange
|
|
|
|
# Academic Events - Teal spectrum
|
|
EventType.EDUCATION: (0.2, 0.7, 0.7), # Teal
|
|
EventType.GRADUATION: (0.3, 0.8, 0.8), # Bright teal
|
|
EventType.DEGREE: (0.25, 0.75, 0.75), # Medium teal
|
|
|
|
# Travel Events - Cyan spectrum
|
|
EventType.EMIGRATION: (0.2, 0.7, 0.9), # Cyan
|
|
EventType.IMMIGRATION: (0.3, 0.8, 0.95), # Bright cyan
|
|
EventType.NATURALIZATION: (0.25, 0.75, 0.9), # Medium cyan
|
|
|
|
# Legal Events - Brown spectrum
|
|
EventType.PROBATE: (0.6, 0.4, 0.2), # Brown
|
|
EventType.WILL: (0.65, 0.45, 0.25), # Light brown
|
|
|
|
# Residence Events - Indigo spectrum
|
|
EventType.RESIDENCE: (0.4, 0.3, 0.7), # Indigo
|
|
EventType.CENSUS: (0.5, 0.4, 0.8), # Light indigo
|
|
EventType.PROPERTY: (0.45, 0.35, 0.75), # Medium indigo
|
|
|
|
# Other Events - Gray spectrum
|
|
EventType.CAUSE_DEATH: (0.5, 0.5, 0.5), # Gray
|
|
EventType.MED_INFO: (0.6, 0.6, 0.6), # Light gray
|
|
EventType.NOB_TITLE: (0.55, 0.55, 0.55), # Medium gray
|
|
EventType.NUM_MARRIAGES: (0.5, 0.5, 0.5), # Gray
|
|
EventType.UNKNOWN: (0.4, 0.4, 0.4), # Dark gray
|
|
EventType.CUSTOM: (0.45, 0.45, 0.45), # Medium gray
|
|
}
|
|
|
|
# Event shape types: 'triangle', 'circle', 'diamond', 'square', 'star', 'hexagon'
|
|
EVENT_SHAPES = {
|
|
# Life Events
|
|
EventType.BIRTH: 'triangle',
|
|
EventType.DEATH: 'circle',
|
|
EventType.BURIAL: 'circle',
|
|
EventType.CREMATION: 'circle',
|
|
EventType.ADOPT: 'triangle',
|
|
|
|
# Family Events
|
|
EventType.MARRIAGE: 'diamond',
|
|
EventType.DIVORCE: 'square',
|
|
EventType.ENGAGEMENT: 'diamond',
|
|
EventType.MARR_SETTL: 'diamond',
|
|
EventType.MARR_LIC: 'diamond',
|
|
EventType.MARR_CONTR: 'diamond',
|
|
EventType.MARR_BANNS: 'diamond',
|
|
EventType.DIV_FILING: 'square',
|
|
EventType.ANNULMENT: 'square',
|
|
EventType.MARR_ALT: 'diamond',
|
|
|
|
# Religious Events
|
|
EventType.BAPTISM: 'star',
|
|
EventType.ADULT_CHRISTEN: 'star',
|
|
EventType.CONFIRMATION: 'star',
|
|
EventType.CHRISTEN: 'star',
|
|
EventType.FIRST_COMMUN: 'star',
|
|
EventType.BLESS: 'star',
|
|
EventType.BAR_MITZVAH: 'star',
|
|
EventType.BAS_MITZVAH: 'star',
|
|
EventType.RELIGION: 'star',
|
|
EventType.ORDINATION: 'star',
|
|
|
|
# Vocational Events
|
|
EventType.OCCUPATION: 'hexagon',
|
|
EventType.RETIREMENT: 'hexagon',
|
|
EventType.ELECTED: 'hexagon',
|
|
EventType.MILITARY_SERV: 'hexagon',
|
|
|
|
# Academic Events
|
|
EventType.EDUCATION: 'square',
|
|
EventType.GRADUATION: 'square',
|
|
EventType.DEGREE: 'square',
|
|
|
|
# Travel Events
|
|
EventType.EMIGRATION: 'triangle',
|
|
EventType.IMMIGRATION: 'triangle',
|
|
EventType.NATURALIZATION: 'triangle',
|
|
|
|
# Legal Events
|
|
EventType.PROBATE: 'square',
|
|
EventType.WILL: 'square',
|
|
|
|
# Residence Events
|
|
EventType.RESIDENCE: 'square',
|
|
EventType.CENSUS: 'square',
|
|
EventType.PROPERTY: 'square',
|
|
|
|
# Other Events
|
|
EventType.CAUSE_DEATH: 'circle',
|
|
EventType.MED_INFO: 'circle',
|
|
EventType.NOB_TITLE: 'square',
|
|
EventType.NUM_MARRIAGES: 'square',
|
|
EventType.UNKNOWN: 'square',
|
|
EventType.CUSTOM: 'square',
|
|
}
|
|
|
|
# Event category mapping for filtering (using EventType objects as keys, like EVENT_COLORS and EVENT_SHAPES)
|
|
# Note: EventType members are already integers in Gramps, not enum objects with .value
|
|
EVENT_CATEGORIES = {
|
|
# Life Events
|
|
EventType.BIRTH: "Life Events",
|
|
EventType.DEATH: "Life Events",
|
|
EventType.BURIAL: "Life Events",
|
|
EventType.CREMATION: "Life Events",
|
|
EventType.ADOPT: "Life Events",
|
|
|
|
# Family Events
|
|
EventType.MARRIAGE: "Family Events",
|
|
EventType.DIVORCE: "Family Events",
|
|
EventType.ENGAGEMENT: "Family Events",
|
|
EventType.MARR_SETTL: "Family Events",
|
|
EventType.MARR_LIC: "Family Events",
|
|
EventType.MARR_CONTR: "Family Events",
|
|
EventType.MARR_BANNS: "Family Events",
|
|
EventType.DIV_FILING: "Family Events",
|
|
EventType.ANNULMENT: "Family Events",
|
|
EventType.MARR_ALT: "Family Events",
|
|
|
|
# Religious Events
|
|
EventType.BAPTISM: "Religious Events",
|
|
EventType.ADULT_CHRISTEN: "Religious Events",
|
|
EventType.CONFIRMATION: "Religious Events",
|
|
EventType.CHRISTEN: "Religious Events",
|
|
EventType.FIRST_COMMUN: "Religious Events",
|
|
EventType.BLESS: "Religious Events",
|
|
EventType.BAR_MITZVAH: "Religious Events",
|
|
EventType.BAS_MITZVAH: "Religious Events",
|
|
EventType.RELIGION: "Religious Events",
|
|
EventType.ORDINATION: "Religious Events",
|
|
|
|
# Vocational Events
|
|
EventType.OCCUPATION: "Vocational Events",
|
|
EventType.RETIREMENT: "Vocational Events",
|
|
EventType.ELECTED: "Vocational Events",
|
|
EventType.MILITARY_SERV: "Vocational Events",
|
|
|
|
# Academic Events
|
|
EventType.EDUCATION: "Academic Events",
|
|
EventType.GRADUATION: "Academic Events",
|
|
EventType.DEGREE: "Academic Events",
|
|
|
|
# Travel Events
|
|
EventType.EMIGRATION: "Travel Events",
|
|
EventType.IMMIGRATION: "Travel Events",
|
|
EventType.NATURALIZATION: "Travel Events",
|
|
|
|
# Legal Events
|
|
EventType.PROBATE: "Legal Events",
|
|
EventType.WILL: "Legal Events",
|
|
|
|
# Residence Events
|
|
EventType.RESIDENCE: "Residence Events",
|
|
EventType.CENSUS: "Residence Events",
|
|
EventType.PROPERTY: "Residence Events",
|
|
|
|
# Other Events
|
|
EventType.CAUSE_DEATH: "Other Events",
|
|
EventType.MED_INFO: "Other Events",
|
|
EventType.NOB_TITLE: "Other Events",
|
|
EventType.NUM_MARRIAGES: "Other Events",
|
|
EventType.UNKNOWN: "Other Events",
|
|
EventType.CUSTOM: "Other Events",
|
|
}
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
#
|
|
# Data Classes
|
|
#
|
|
# -------------------------------------------------------------------------
|
|
@dataclass
|
|
class TimelineEvent:
|
|
"""Represents an event in the timeline with all necessary data."""
|
|
date_sort: int
|
|
date_obj: Date
|
|
event: 'Event' # gramps.gen.lib.Event
|
|
person: Optional['Person'] # gramps.gen.lib.Person
|
|
event_type: EventType
|
|
y_pos: float = 0.0
|
|
|
|
|
|
# -------------------------------------------------------------------------
|
|
#
|
|
# MyTimelineView
|
|
#
|
|
# -------------------------------------------------------------------------
|
|
class MyTimelineView(NavigationView):
|
|
"""
|
|
View for displaying a vertical timeline of all events in the database.
|
|
Shows all events with modern design and interactivity, allowing selection
|
|
and filtering of events by type, category, person, and date range.
|
|
"""
|
|
|
|
def __init__(self, pdata: Any, dbstate: Any, uistate: Any, nav_group: int = 0) -> None:
|
|
"""
|
|
Initialize the MyTimeline view.
|
|
|
|
Args:
|
|
pdata: Plugin data object.
|
|
dbstate: Database state object.
|
|
uistate: UI state object.
|
|
nav_group: Navigation group identifier.
|
|
"""
|
|
NavigationView.__init__(
|
|
self, _("MyTimeline"), pdata, dbstate, uistate, EventBookmarks, nav_group
|
|
)
|
|
|
|
self.dbstate = dbstate
|
|
self.uistate = uistate
|
|
|
|
# Current event handle (for selection/highlighting)
|
|
self.active_event_handle = None
|
|
self.events: List[TimelineEvent] = [] # List of TimelineEvent objects
|
|
|
|
# UI components
|
|
self.scrolledwindow = None
|
|
self.drawing_area = None
|
|
self.timeline_height = DEFAULT_TIMELINE_HEIGHT
|
|
self.zoom_level = 1.0 # Zoom level (1.0 = 100%)
|
|
self.min_zoom = 0.5
|
|
self.max_zoom = 3.0
|
|
self.zoom_step = 0.1
|
|
|
|
# Interaction state
|
|
self.hovered_event_index = None
|
|
self.tooltip_timeout_id = None
|
|
self.tooltip_window = None
|
|
self.selected_person_handles: Set[str] = set() # Set of selected person handles
|
|
self.person_colors: Dict[str, Tuple[float, float, float, float]] = {} # Person handle -> RGBA color
|
|
self.mouse_x = 0
|
|
self.mouse_y = 0
|
|
|
|
# Performance cache
|
|
self._adjusted_events_cache: Optional[List[TimelineEvent]] = None
|
|
self._cache_key: Optional[int] = None
|
|
self._cached_date_range: Optional[int] = None
|
|
self._cached_min_date: Optional[int] = None
|
|
self._cached_max_date: Optional[int] = None
|
|
self._event_to_person_cache: Dict[str, Optional['Person']] = {} # Event handle -> Person object (or None)
|
|
self._event_type_normalization_cache: Dict[EventType, int] = {} # Cache for event type normalization
|
|
self._normalized_active_event_types: Optional[Set[int]] = None # Pre-computed normalized active types
|
|
|
|
# Filter state
|
|
self.filter_enabled: bool = False
|
|
self.active_event_types: Set[EventType] = set() # Empty = all enabled
|
|
self.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 _queue_draw(self) -> None:
|
|
"""
|
|
Safely queue a redraw of the drawing area if it exists.
|
|
"""
|
|
if self.drawing_area:
|
|
self.drawing_area.queue_draw()
|
|
|
|
def change_page(self) -> None:
|
|
"""
|
|
Called when the page changes.
|
|
|
|
Updates the view to show all events and highlight the active event if selected.
|
|
"""
|
|
NavigationView.change_page(self)
|
|
active_handle = self.get_active()
|
|
if active_handle:
|
|
self.goto_handle(active_handle)
|
|
else:
|
|
# If no event is selected, ensure timeline is displayed
|
|
if not self.events and self.dbstate.is_open():
|
|
self.collect_events()
|
|
self._queue_draw()
|
|
|
|
def family_updated(self, handle_list: List[str]) -> None:
|
|
"""
|
|
Called when a family is updated.
|
|
|
|
Args:
|
|
handle_list: List of family handles that were updated.
|
|
"""
|
|
# Refresh timeline since family updates may affect event display
|
|
self.collect_events()
|
|
self._queue_draw()
|
|
|
|
def person_updated(self, handle_list: List[str]) -> None:
|
|
"""
|
|
Called when a person is updated.
|
|
|
|
Args:
|
|
handle_list: List of person handles that were updated.
|
|
"""
|
|
# Refresh timeline since person updates may affect event display
|
|
self.collect_events()
|
|
self._queue_draw()
|
|
|
|
def event_updated(self, handle_list: List[str]) -> None:
|
|
"""
|
|
Called when an event is updated.
|
|
|
|
Args:
|
|
handle_list: List of event handles that were updated.
|
|
"""
|
|
# Re-collect events when events are updated
|
|
self.collect_events()
|
|
self._queue_draw()
|
|
|
|
# If the active event was updated, ensure it's still highlighted
|
|
if self.active_event_handle in handle_list:
|
|
self._scroll_to_event(self.active_event_handle)
|
|
|
|
def change_db(self, db: Any) -> None:
|
|
"""
|
|
Called when the database changes.
|
|
|
|
Args:
|
|
db: The new database object.
|
|
"""
|
|
self.active_event_handle = None
|
|
self.all_events = []
|
|
self.events = []
|
|
# Clear selected persons and colors
|
|
self.selected_person_handles.clear()
|
|
self.person_colors.clear()
|
|
# Clear event-to-person cache
|
|
self._event_to_person_cache.clear()
|
|
|
|
# Connect to new database signals
|
|
if db and db.is_open():
|
|
db.connect("family-update", self.family_updated)
|
|
db.connect("person-update", self.person_updated)
|
|
db.connect("event-update", self.event_updated)
|
|
|
|
self._queue_draw()
|
|
|
|
def build_widget(self) -> Gtk.Widget:
|
|
"""
|
|
Build the interface and return the container.
|
|
|
|
Returns:
|
|
Gtk.Widget: The main container widget with toolbar and drawing area.
|
|
"""
|
|
# Main container
|
|
main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=TOOLBAR_SPACING)
|
|
|
|
# Toolbar with zoom controls
|
|
toolbar = Gtk.Toolbar()
|
|
toolbar.set_style(Gtk.ToolbarStyle.ICONS)
|
|
|
|
# Zoom out button
|
|
zoom_out_btn = Gtk.ToolButton(icon_name="zoom-out-symbolic")
|
|
zoom_out_btn.set_tooltip_text(_("Zoom Out"))
|
|
zoom_out_btn.connect("clicked", self.on_zoom_out)
|
|
toolbar.insert(zoom_out_btn, 0)
|
|
|
|
# Zoom label
|
|
self.zoom_label = Gtk.Label(label="100%")
|
|
self.zoom_label.set_margin_start(TOOLBAR_ITEM_MARGIN)
|
|
self.zoom_label.set_margin_end(TOOLBAR_ITEM_MARGIN)
|
|
zoom_item = Gtk.ToolItem()
|
|
zoom_item.add(self.zoom_label)
|
|
toolbar.insert(zoom_item, 1)
|
|
|
|
# Zoom in button
|
|
zoom_in_btn = Gtk.ToolButton(icon_name="zoom-in-symbolic")
|
|
zoom_in_btn.set_tooltip_text(_("Zoom In"))
|
|
zoom_in_btn.connect("clicked", self.on_zoom_in)
|
|
toolbar.insert(zoom_in_btn, 2)
|
|
|
|
# Reset zoom button
|
|
zoom_reset_btn = Gtk.ToolButton(icon_name="zoom-fit-best-symbolic")
|
|
zoom_reset_btn.set_tooltip_text(_("Reset Zoom"))
|
|
zoom_reset_btn.connect("clicked", self.on_zoom_reset)
|
|
toolbar.insert(zoom_reset_btn, 3)
|
|
|
|
toolbar.insert(Gtk.SeparatorToolItem(), 4)
|
|
|
|
# Filter button
|
|
self.filter_button = Gtk.ToolButton(icon_name="view-filter-symbolic")
|
|
self.filter_button.set_tooltip_text(_("Filter Events"))
|
|
self.filter_button.connect("clicked", self.on_filter_button_clicked)
|
|
toolbar.insert(self.filter_button, 5)
|
|
|
|
main_box.pack_start(toolbar, False, False, 0)
|
|
|
|
# Scrolled window
|
|
self.scrolledwindow = Gtk.ScrolledWindow()
|
|
self.scrolledwindow.set_policy(
|
|
Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC
|
|
)
|
|
|
|
self.drawing_area = Gtk.DrawingArea()
|
|
self.drawing_area.set_size_request(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 _make_group_toggle_handler(self, child_checkboxes: List[Gtk.CheckButton],
|
|
updating_flag: List[bool]) -> Callable[[Gtk.Widget], None]:
|
|
"""
|
|
Create a handler for group checkbox toggle that toggles all children.
|
|
|
|
Args:
|
|
child_checkboxes: List of child checkboxes to toggle.
|
|
updating_flag: List with single boolean to prevent recursion.
|
|
|
|
Returns:
|
|
Callable handler function.
|
|
"""
|
|
def handler(widget: Gtk.Widget) -> None:
|
|
if updating_flag[0]:
|
|
return
|
|
|
|
# Handle inconsistent state - make it consistent
|
|
if widget.get_inconsistent():
|
|
widget.set_inconsistent(False)
|
|
widget.set_active(True)
|
|
|
|
updating_flag[0] = True
|
|
# Toggle all children
|
|
is_active = widget.get_active()
|
|
for child_cb in child_checkboxes:
|
|
child_cb.set_active(is_active)
|
|
updating_flag[0] = False
|
|
|
|
return handler
|
|
|
|
def _make_child_toggle_handler(self, group_checkbox: Gtk.CheckButton,
|
|
child_checkboxes: List[Gtk.CheckButton],
|
|
updating_flag: List[bool]) -> Callable[[Gtk.Widget], None]:
|
|
"""
|
|
Create a handler for child checkbox toggle that updates group state.
|
|
|
|
Args:
|
|
group_checkbox: The parent group checkbox to update.
|
|
child_checkboxes: List of all child checkboxes.
|
|
updating_flag: List with single boolean to prevent recursion.
|
|
|
|
Returns:
|
|
Callable handler function.
|
|
"""
|
|
def handler(widget: Gtk.Widget) -> None:
|
|
if updating_flag[0]:
|
|
return
|
|
self._update_group_checkbox_state(group_checkbox, child_checkboxes)
|
|
|
|
return handler
|
|
|
|
def _build_filter_dialog(self) -> Gtk.Dialog:
|
|
"""
|
|
Build the filter dialog with all filter controls.
|
|
|
|
Returns:
|
|
Gtk.Dialog: The filter dialog widget.
|
|
"""
|
|
dialog = Gtk.Dialog(title=_("Filter Events"), parent=self.uistate.window)
|
|
dialog.set_default_size(FILTER_DIALOG_WIDTH, FILTER_DIALOG_HEIGHT)
|
|
dialog.add_button(_("Clear All"), Gtk.ResponseType.REJECT)
|
|
dialog.add_button(_("Close"), Gtk.ResponseType.CLOSE)
|
|
dialog.add_button(_("Apply"), Gtk.ResponseType.APPLY)
|
|
|
|
# Connect to clear button
|
|
dialog.connect("response", self._on_filter_dialog_response)
|
|
|
|
# Main content area
|
|
content_area = dialog.get_content_area()
|
|
content_area.set_spacing(FILTER_PAGE_SPACING)
|
|
content_area.set_margin_start(FILTER_PAGE_MARGIN)
|
|
content_area.set_margin_end(FILTER_PAGE_MARGIN)
|
|
content_area.set_margin_top(FILTER_PAGE_MARGIN)
|
|
content_area.set_margin_bottom(FILTER_PAGE_MARGIN)
|
|
|
|
# Notebook for organizing filters
|
|
notebook = Gtk.Notebook()
|
|
content_area.pack_start(notebook, True, True, 0)
|
|
|
|
# Store widget references for later access
|
|
self._filter_widgets = {}
|
|
|
|
# Event Type Filter Page
|
|
event_type_page = self._build_event_type_filter_page()
|
|
notebook.append_page(event_type_page, Gtk.Label(label=_("Event Types")))
|
|
|
|
# Category Filter Page
|
|
category_page = self._build_category_filter_page()
|
|
notebook.append_page(category_page, Gtk.Label(label=_("Categories")))
|
|
|
|
# Person Filter Page
|
|
person_page = self._build_person_filter_page()
|
|
notebook.append_page(person_page, Gtk.Label(label=_("Persons")))
|
|
|
|
# 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=FILTER_PAGE_VERTICAL_SPACING)
|
|
box.set_margin_start(FILTER_PAGE_MARGIN)
|
|
box.set_margin_end(FILTER_PAGE_MARGIN)
|
|
box.set_margin_top(FILTER_PAGE_MARGIN)
|
|
box.set_margin_bottom(FILTER_PAGE_MARGIN)
|
|
|
|
# Select All / Deselect All buttons
|
|
button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=FILTER_PAGE_VERTICAL_SPACING)
|
|
select_all_btn = Gtk.Button(label=_("Select All"))
|
|
select_all_btn.connect("clicked", self._on_select_all_event_types)
|
|
deselect_all_btn = Gtk.Button(label=_("Deselect All"))
|
|
deselect_all_btn.connect("clicked", self._on_deselect_all_event_types)
|
|
button_box.pack_start(select_all_btn, False, False, 0)
|
|
button_box.pack_start(deselect_all_btn, False, False, 0)
|
|
box.pack_start(button_box, False, False, 0)
|
|
|
|
# Group event types by category
|
|
event_type_checkboxes = {}
|
|
category_boxes = {}
|
|
category_checkboxes = {}
|
|
category_event_types = {} # Map category to list of event types
|
|
|
|
# First pass: collect event types by category
|
|
for event_type_obj in EVENT_COLORS:
|
|
if event_type_obj not in EVENT_CATEGORIES:
|
|
continue
|
|
category = EVENT_CATEGORIES[event_type_obj]
|
|
if category not in category_event_types:
|
|
category_event_types[category] = []
|
|
category_event_types[category].append(event_type_obj)
|
|
|
|
# Second pass: create UI with category checkboxes
|
|
for category in sorted(category_event_types):
|
|
event_types_in_category = category_event_types[category]
|
|
|
|
# Create category checkbox with three-state support
|
|
category_checkbox = Gtk.CheckButton(label=category)
|
|
category_checkboxes[category] = category_checkbox
|
|
box.pack_start(category_checkbox, False, False, 0)
|
|
|
|
# Create container for event types in this category
|
|
category_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=FILTER_CATEGORY_SPACING)
|
|
category_box.set_margin_start(FILTER_INDENT_MARGIN)
|
|
category_boxes[category] = category_box
|
|
box.pack_start(category_box, False, False, 0)
|
|
|
|
# Create checkboxes for each event type in this category
|
|
child_checkboxes = []
|
|
for event_type_obj in sorted(event_types_in_category, key=lambda x: self._get_event_type_display_name(x)):
|
|
event_type_name = self._get_event_type_display_name(event_type_obj)
|
|
checkbox = Gtk.CheckButton(label=event_type_name)
|
|
event_type_checkboxes[event_type_obj] = checkbox
|
|
child_checkboxes.append(checkbox)
|
|
category_box.pack_start(checkbox, False, False, 0)
|
|
|
|
# Flag to prevent recursion between category and child checkboxes
|
|
updating_category = [False]
|
|
|
|
# Connect category checkbox to toggle all children
|
|
category_checkbox.connect("toggled",
|
|
self._make_group_toggle_handler(child_checkboxes, updating_category))
|
|
|
|
# Connect child checkboxes to update category checkbox state
|
|
for child_cb in child_checkboxes:
|
|
child_cb.connect("toggled",
|
|
self._make_child_toggle_handler(category_checkbox, child_checkboxes, updating_category))
|
|
|
|
self._filter_widgets['event_type_checkboxes'] = event_type_checkboxes
|
|
self._filter_widgets['category_checkboxes'] = category_checkboxes
|
|
self._filter_widgets['category_event_types'] = category_event_types
|
|
|
|
scrolled.add(box)
|
|
return scrolled
|
|
|
|
def _build_category_filter_page(self) -> Gtk.Widget:
|
|
"""
|
|
Build the category filter page with checkboxes for each category.
|
|
|
|
Returns:
|
|
Gtk.Widget: The category filter page.
|
|
"""
|
|
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=FILTER_PAGE_VERTICAL_SPACING)
|
|
box.set_margin_start(FILTER_PAGE_MARGIN)
|
|
box.set_margin_end(FILTER_PAGE_MARGIN)
|
|
box.set_margin_top(FILTER_PAGE_MARGIN)
|
|
box.set_margin_bottom(FILTER_PAGE_MARGIN)
|
|
|
|
# Get unique categories
|
|
categories = sorted(set(EVENT_CATEGORIES.values()))
|
|
|
|
category_checkboxes = {}
|
|
for category in categories:
|
|
checkbox = Gtk.CheckButton(label=category)
|
|
category_checkboxes[category] = checkbox
|
|
box.pack_start(checkbox, False, False, 0)
|
|
|
|
self._filter_widgets['category_checkboxes'] = category_checkboxes
|
|
|
|
return box
|
|
|
|
def _build_person_filter_page(self) -> Gtk.Widget:
|
|
"""
|
|
Build the person filter page with 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=FILTER_PAGE_VERTICAL_SPACING)
|
|
box.set_margin_start(FILTER_PAGE_MARGIN)
|
|
box.set_margin_end(FILTER_PAGE_MARGIN)
|
|
box.set_margin_top(FILTER_PAGE_MARGIN)
|
|
box.set_margin_bottom(FILTER_PAGE_MARGIN)
|
|
|
|
# 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 _create_date_calendar_widget(self, label: str, year_changed_handler: Callable,
|
|
date_selected_handler: Callable,
|
|
month_changed_handler: Callable,
|
|
calendar_key: str, spin_key: str,
|
|
current_year: int, min_year: int, max_year: int) -> Tuple[Gtk.Frame, Gtk.Calendar, Gtk.SpinButton]:
|
|
"""
|
|
Create a date calendar widget with year selector.
|
|
|
|
Args:
|
|
label: Label for the frame.
|
|
year_changed_handler: Handler for year spin button changes.
|
|
date_selected_handler: Handler for calendar date selection.
|
|
month_changed_handler: Handler for calendar month changes.
|
|
calendar_key: Key to store calendar widget in _filter_widgets.
|
|
spin_key: Key to store spin button widget in _filter_widgets.
|
|
current_year: Initial year value.
|
|
min_year: Minimum year for the spin button.
|
|
max_year: Maximum year for the spin button.
|
|
|
|
Returns:
|
|
Tuple containing (frame, calendar, spin_button).
|
|
"""
|
|
frame = Gtk.Frame(label=label)
|
|
frame.set_label_align(0.5, 0.5)
|
|
|
|
container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=CALENDAR_CONTAINER_SPACING)
|
|
container.set_margin_start(CALENDAR_CONTAINER_MARGIN)
|
|
container.set_margin_end(CALENDAR_CONTAINER_MARGIN)
|
|
container.set_margin_top(CALENDAR_CONTAINER_MARGIN)
|
|
container.set_margin_bottom(CALENDAR_CONTAINER_MARGIN)
|
|
|
|
# Year selector
|
|
year_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=YEAR_SELECTOR_SPACING)
|
|
year_label = Gtk.Label(label=_("Year:"))
|
|
year_adjustment = Gtk.Adjustment(
|
|
value=current_year,
|
|
lower=min_year,
|
|
upper=max_year,
|
|
step_increment=1,
|
|
page_increment=10
|
|
)
|
|
year_spin = Gtk.SpinButton()
|
|
year_spin.set_adjustment(year_adjustment)
|
|
year_spin.set_numeric(True)
|
|
year_spin.set_update_policy(Gtk.SpinButtonUpdatePolicy.IF_VALID)
|
|
year_spin.set_width_chars(6)
|
|
year_spin.connect("value-changed", year_changed_handler)
|
|
|
|
year_box.pack_start(year_label, False, False, 0)
|
|
year_box.pack_start(year_spin, False, False, 0)
|
|
container.pack_start(year_box, False, False, 0)
|
|
|
|
# Calendar
|
|
calendar = Gtk.Calendar()
|
|
calendar.set_display_options(
|
|
Gtk.CalendarDisplayOptions.SHOW_HEADING |
|
|
Gtk.CalendarDisplayOptions.SHOW_DAY_NAMES |
|
|
Gtk.CalendarDisplayOptions.SHOW_WEEK_NUMBERS
|
|
)
|
|
calendar.connect("day-selected", date_selected_handler)
|
|
calendar.connect("month-changed", month_changed_handler)
|
|
container.pack_start(calendar, True, True, 0)
|
|
|
|
frame.add(container)
|
|
|
|
# Store widgets
|
|
self._filter_widgets[calendar_key] = calendar
|
|
self._filter_widgets[spin_key] = year_spin
|
|
|
|
return frame, calendar, year_spin
|
|
|
|
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=FILTER_PAGE_SPACING)
|
|
box.set_margin_start(FILTER_PAGE_MARGIN)
|
|
box.set_margin_end(FILTER_PAGE_MARGIN)
|
|
box.set_margin_top(FILTER_PAGE_MARGIN)
|
|
box.set_margin_bottom(FILTER_PAGE_MARGIN)
|
|
|
|
info_label = Gtk.Label(label=_("Select 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=CALENDAR_HORIZONTAL_SPACING)
|
|
calendar_box.set_homogeneous(True)
|
|
|
|
# Year range for genealogical data
|
|
current_year = datetime.date.today().year
|
|
min_year = MIN_GENEALOGICAL_YEAR
|
|
max_year = current_year + 10
|
|
|
|
# Create From date calendar widget
|
|
from_frame, from_calendar, from_year_spin = self._create_date_calendar_widget(
|
|
label=_("From Date"),
|
|
year_changed_handler=self._on_from_year_changed,
|
|
date_selected_handler=self._on_from_date_selected,
|
|
month_changed_handler=self._on_from_calendar_changed,
|
|
calendar_key='date_from_calendar',
|
|
spin_key='date_from_year_spin',
|
|
current_year=current_year,
|
|
min_year=min_year,
|
|
max_year=max_year
|
|
)
|
|
calendar_box.pack_start(from_frame, True, True, 0)
|
|
|
|
# Create To date calendar widget
|
|
to_frame, to_calendar, to_year_spin = self._create_date_calendar_widget(
|
|
label=_("To Date"),
|
|
year_changed_handler=self._on_to_year_changed,
|
|
date_selected_handler=self._on_to_date_selected,
|
|
month_changed_handler=self._on_to_calendar_changed,
|
|
calendar_key='date_to_calendar',
|
|
spin_key='date_to_year_spin',
|
|
current_year=current_year,
|
|
min_year=min_year,
|
|
max_year=max_year
|
|
)
|
|
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)
|
|
|
|
scrolled.add(box)
|
|
return scrolled
|
|
|
|
def _set_validation_message(self, message: str) -> None:
|
|
"""
|
|
Set the date validation label message.
|
|
|
|
Args:
|
|
message: Message text to display (empty string to clear).
|
|
"""
|
|
if hasattr(self, 'date_validation_label'):
|
|
if message:
|
|
self.date_validation_label.set_markup(
|
|
f"<span color='red'>{message}</span>"
|
|
)
|
|
else:
|
|
self.date_validation_label.set_text("")
|
|
|
|
def _update_year_spin_button(self, spin_key: str, year: int, handler: Callable) -> None:
|
|
"""
|
|
Update a year spin button value, blocking signals during update.
|
|
|
|
Args:
|
|
spin_key: Key to find the spin button in _filter_widgets.
|
|
year: Year value to set.
|
|
handler: Handler function to block/unblock during update.
|
|
"""
|
|
if spin_key in self._filter_widgets:
|
|
year_spin = self._filter_widgets[spin_key]
|
|
year_spin.handler_block_by_func(handler)
|
|
year_spin.set_value(year)
|
|
year_spin.handler_unblock_by_func(handler)
|
|
|
|
def _update_event_type_widgets(self) -> None:
|
|
"""
|
|
Update event type filter widgets to reflect current filter state.
|
|
"""
|
|
if 'event_type_checkboxes' not in self._filter_widgets:
|
|
return
|
|
|
|
# Update event type checkboxes
|
|
for event_type, checkbox in self._filter_widgets['event_type_checkboxes'].items():
|
|
if not self.active_event_types:
|
|
checkbox.set_active(True) # All selected when filter is off
|
|
else:
|
|
checkbox.set_active(event_type in self.active_event_types)
|
|
|
|
# Update category checkboxes based on their children's states
|
|
if 'category_checkboxes' in self._filter_widgets and 'category_event_types' in self._filter_widgets:
|
|
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)
|
|
|
|
def _update_person_filter_widgets(self) -> None:
|
|
"""
|
|
Update person filter widgets to reflect current filter state.
|
|
"""
|
|
if 'person_checkboxes' not in self._filter_widgets or 'person_container' not in self._filter_widgets:
|
|
return
|
|
|
|
# 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 not self.dbstate.is_open():
|
|
return
|
|
|
|
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=FILTER_CATEGORY_SPACING + 1)
|
|
members_box.set_margin_start(FILTER_INDENT_MARGIN)
|
|
members_box.set_margin_top(CALENDAR_CONTAINER_MARGIN)
|
|
members_box.set_margin_bottom(CALENDAR_CONTAINER_MARGIN)
|
|
|
|
# Collect all child checkboxes for this family
|
|
child_checkboxes = []
|
|
|
|
# Helper function to add person checkbox
|
|
def add_person_checkbox(person_handle: Optional[str], role_label: str) -> None:
|
|
"""Add a checkbox for a person if handle is valid."""
|
|
if not person_handle:
|
|
return
|
|
person_name = self._get_person_display_name(person_handle)
|
|
if person_name:
|
|
label_text = f" {role_label}: {person_name}"
|
|
checkbox = Gtk.CheckButton(label=label_text)
|
|
checkbox.set_active(True if not self.person_filter else person_handle in self.person_filter)
|
|
self._filter_widgets['person_checkboxes'][person_handle] = checkbox
|
|
child_checkboxes.append(checkbox)
|
|
members_box.pack_start(checkbox, False, False, 0)
|
|
|
|
# Add father checkbox
|
|
add_person_checkbox(family.get_father_handle(), _('Father'))
|
|
|
|
# Add mother checkbox
|
|
add_person_checkbox(family.get_mother_handle(), _('Mother'))
|
|
|
|
# Add children checkboxes
|
|
for child_ref in family.get_child_ref_list():
|
|
add_person_checkbox(child_ref.ref, _('Child'))
|
|
|
|
# Only add expander if there are members to show
|
|
if members_box.get_children():
|
|
# 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
|
|
family_checkbox.connect("toggled",
|
|
self._make_group_toggle_handler(child_checkboxes, updating_family))
|
|
|
|
# Connect child checkboxes to update family checkbox
|
|
for child_cb in child_checkboxes:
|
|
child_cb.connect("toggled",
|
|
self._make_child_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 widgets in filter dialog: {e}", exc_info=True)
|
|
|
|
def _update_date_range_widgets(self) -> None:
|
|
"""
|
|
Update date range filter widgets to reflect current filter state.
|
|
"""
|
|
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']
|
|
|
|
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 * DATE_SORT_YEAR_MULTIPLIER + month * DATE_SORT_MONTH_MULTIPLIER + day
|
|
from_year = min_sort // DATE_SORT_YEAR_MULTIPLIER
|
|
to_year = max_sort // DATE_SORT_YEAR_MULTIPLIER
|
|
|
|
# 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
|
|
self._update_year_spin_button('date_from_year_spin', from_year, self._on_from_year_changed)
|
|
self._update_year_spin_button('date_to_year_spin', to_year, self._on_to_year_changed)
|
|
else:
|
|
# Reset to current date
|
|
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
|
|
self._update_year_spin_button('date_from_year_spin', now.year, self._on_from_year_changed)
|
|
self._update_year_spin_button('date_to_year_spin', now.year, self._on_to_year_changed)
|
|
|
|
# Clear validation message
|
|
self._set_validation_message("")
|
|
|
|
def _update_filter_dialog_state(self) -> None:
|
|
"""
|
|
Update the filter dialog widgets to reflect current filter state.
|
|
"""
|
|
if not hasattr(self, '_filter_widgets'):
|
|
return
|
|
|
|
# Update each filter type's widgets
|
|
self._update_event_type_widgets()
|
|
self._update_person_filter_widgets()
|
|
self._update_date_range_widgets()
|
|
|
|
def _on_filter_dialog_response(self, dialog: Gtk.Dialog, response_id: int) -> None:
|
|
"""
|
|
Handle filter dialog response.
|
|
|
|
Args:
|
|
dialog: The filter dialog.
|
|
response_id: The response ID (APPLY, CLOSE, REJECT).
|
|
"""
|
|
if response_id == Gtk.ResponseType.REJECT:
|
|
# Clear all filters
|
|
self.filter_enabled = False
|
|
self.active_event_types = set()
|
|
self._update_normalized_active_event_types() # Invalidate cache
|
|
self.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()
|
|
self._update_normalized_active_event_types() # Update cache
|
|
|
|
# Update category filter
|
|
if 'category_checkboxes' in self._filter_widgets:
|
|
active_categories = set()
|
|
for category, checkbox in self._filter_widgets['category_checkboxes'].items():
|
|
if checkbox.get_active():
|
|
active_categories.add(category)
|
|
all_categories = set(self._filter_widgets['category_checkboxes'].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 Date objects from calendar selections
|
|
from_date = self._create_date_from_calendar(from_calendar)
|
|
to_date = self._create_date_from_calendar(to_calendar)
|
|
|
|
if from_date is None or to_date is None:
|
|
# Show error message for invalid dates
|
|
self._set_validation_message(_('Error: Invalid date in date range filter'))
|
|
logger.warning("Error parsing date range in filter dialog: invalid date from calendar")
|
|
self.date_range_filter = None
|
|
self.date_range_explicit = False
|
|
return
|
|
|
|
min_sort = from_date.get_sort_value()
|
|
max_sort = to_date.get_sort_value()
|
|
|
|
# Validate date range
|
|
if min_sort > max_sort:
|
|
# Show error message
|
|
self._set_validation_message(_('Error: From date must be before To date'))
|
|
self.date_range_filter = None
|
|
self.date_range_explicit = False
|
|
return
|
|
else:
|
|
# Clear error message
|
|
self._set_validation_message("")
|
|
|
|
# Set filter - user has selected dates in calendars
|
|
self.date_range_filter = (min_sort, max_sort)
|
|
self.date_range_explicit = True
|
|
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 _sync_year_spin_from_calendar(self, calendar: Gtk.Calendar, spin_key: str, handler: Callable) -> None:
|
|
"""
|
|
Update year spin button to match calendar date.
|
|
|
|
Args:
|
|
calendar: The calendar widget.
|
|
spin_key: Key to find the spin button in _filter_widgets.
|
|
handler: Handler function to block/unblock during update.
|
|
"""
|
|
if spin_key in self._filter_widgets:
|
|
year, month, day = calendar.get_date()
|
|
year_spin = self._filter_widgets[spin_key]
|
|
year_spin.handler_block_by_func(handler)
|
|
year_spin.set_value(year)
|
|
year_spin.handler_unblock_by_func(handler)
|
|
|
|
def _sync_calendar_from_year_spin(self, calendar_key: str, new_year: int) -> None:
|
|
"""
|
|
Update calendar to match year spin button value.
|
|
|
|
Args:
|
|
calendar_key: Key to find the calendar in _filter_widgets.
|
|
new_year: The new year value to set.
|
|
"""
|
|
if calendar_key in self._filter_widgets:
|
|
calendar = self._filter_widgets[calendar_key]
|
|
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)
|
|
|
|
def _create_date_from_calendar(self, calendar: Gtk.Calendar) -> Optional[Date]:
|
|
"""
|
|
Create a Date object from calendar selection.
|
|
|
|
Args:
|
|
calendar: The calendar widget.
|
|
|
|
Returns:
|
|
Optional[Date]: Date object if successful, None otherwise.
|
|
"""
|
|
try:
|
|
year, month, day = calendar.get_date()
|
|
# Note: month from calendar is 0-11, Date.set_yr_mon_day expects 1-12
|
|
date_obj = Date()
|
|
date_obj.set_yr_mon_day(year, month + 1, day)
|
|
return date_obj
|
|
except (ValueError, AttributeError, TypeError) as e:
|
|
logger.debug(f"Error creating date from calendar: {e}")
|
|
return None
|
|
|
|
def _on_from_date_selected(self, calendar: Gtk.Calendar) -> None:
|
|
"""
|
|
Handle From date calendar selection.
|
|
|
|
Args:
|
|
calendar: The calendar widget that was selected.
|
|
"""
|
|
self._sync_year_spin_from_calendar(calendar, 'date_from_year_spin', 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.
|
|
"""
|
|
self._sync_year_spin_from_calendar(calendar, 'date_to_year_spin', 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.
|
|
"""
|
|
self._sync_year_spin_from_calendar(calendar, 'date_from_year_spin', 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.
|
|
"""
|
|
self._sync_year_spin_from_calendar(calendar, 'date_to_year_spin', 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.
|
|
"""
|
|
new_year = int(spin_button.get_value())
|
|
self._sync_calendar_from_year_spin('date_from_calendar', new_year)
|
|
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.
|
|
"""
|
|
new_year = int(spin_button.get_value())
|
|
self._sync_calendar_from_year_spin('date_to_calendar', new_year)
|
|
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
|
|
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
|
|
self._set_validation_message("")
|
|
|
|
# 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()
|
|
|
|
# Use helper method to create dates
|
|
from_date = self._create_date_from_calendar(from_calendar)
|
|
to_date = self._create_date_from_calendar(to_calendar)
|
|
|
|
if from_date is None or to_date is None:
|
|
return
|
|
|
|
try:
|
|
from_sort = from_date.get_sort_value()
|
|
to_sort = to_date.get_sort_value()
|
|
|
|
if from_sort > to_sort:
|
|
self._set_validation_message(_('Warning: From date is after To date'))
|
|
else:
|
|
self._set_validation_message("")
|
|
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)
|
|
else:
|
|
self._queue_draw()
|
|
|
|
def goto_handle(self, handle: str) -> None:
|
|
"""
|
|
Called when the active event changes.
|
|
|
|
Args:
|
|
handle: The handle of the event to highlight/select.
|
|
"""
|
|
if handle == self.active_event_handle:
|
|
return
|
|
|
|
self.active_event_handle = handle
|
|
|
|
# Ensure events are collected
|
|
if not self.all_events:
|
|
self.collect_events()
|
|
else:
|
|
# Just refresh display to highlight the selected event
|
|
self._queue_draw()
|
|
|
|
# Scroll to the selected event if possible
|
|
self._scroll_to_event(handle)
|
|
|
|
def _scroll_to_event(self, event_handle: str) -> None:
|
|
"""
|
|
Scroll the timeline to show the selected event.
|
|
|
|
Args:
|
|
event_handle: The handle of the event to scroll to.
|
|
"""
|
|
if not self.scrolledwindow or not event_handle:
|
|
return
|
|
|
|
# Find the event in our event list
|
|
event_index = None
|
|
for i, timeline_event in enumerate(self.events):
|
|
if timeline_event.event.get_handle() == event_handle:
|
|
event_index = i
|
|
break
|
|
|
|
if event_index is None:
|
|
return
|
|
|
|
# Calculate the y position of the event
|
|
if event_index < len(self.events):
|
|
event_y = TIMELINE_MARGIN_TOP + (event_index * EVENT_SPACING)
|
|
|
|
# Get the adjustment and scroll to the event
|
|
vadj = self.scrolledwindow.get_vadjustment()
|
|
if vadj:
|
|
# Center the event in the viewport
|
|
viewport_height = self.scrolledwindow.get_allocation().height
|
|
scroll_to = max(0, event_y - (viewport_height / 2))
|
|
vadj.set_value(scroll_to)
|
|
|
|
def _invalidate_cache(self) -> None:
|
|
"""Invalidate all caches when events change."""
|
|
self._adjusted_events_cache = None
|
|
self._cache_key = None
|
|
self._cached_date_range = None
|
|
self._cached_min_date = None
|
|
self._cached_max_date = None
|
|
self._event_to_person_cache.clear() # Clear event-to-person cache
|
|
# Clear event type normalization cache to prevent stale data
|
|
self._event_type_normalization_cache.clear()
|
|
|
|
def _calculate_timeline_height(self) -> None:
|
|
"""Calculate and set timeline height based on number of events and zoom."""
|
|
if self.events:
|
|
base_height = (
|
|
TIMELINE_MARGIN_TOP
|
|
+ len(self.events) * EVENT_SPACING
|
|
+ TIMELINE_MARGIN_BOTTOM
|
|
)
|
|
self.timeline_height = int(base_height * self.zoom_level)
|
|
else:
|
|
self.timeline_height = int(EMPTY_TIMELINE_HEIGHT * self.zoom_level)
|
|
|
|
if self.drawing_area:
|
|
self.drawing_area.set_size_request(DEFAULT_DRAWING_AREA_WIDTH, self.timeline_height)
|
|
|
|
def _create_timeline_event(self, event: 'Event', person_obj: Optional['Person'] = None, y_pos: float = 0.0) -> Optional[TimelineEvent]:
|
|
"""
|
|
Create a TimelineEvent from an event object.
|
|
|
|
Args:
|
|
event: The event object.
|
|
person_obj: Optional person object associated with this event.
|
|
y_pos: Initial Y position (default 0.0).
|
|
|
|
Returns:
|
|
Optional[TimelineEvent]: The created TimelineEvent, or None if invalid.
|
|
"""
|
|
try:
|
|
if not event:
|
|
return None
|
|
|
|
date_obj = event.get_date_object()
|
|
if not date_obj:
|
|
return None
|
|
|
|
return TimelineEvent(
|
|
date_sort=date_obj.get_sort_value(),
|
|
date_obj=date_obj,
|
|
event=event,
|
|
person=person_obj,
|
|
event_type=event.get_type(),
|
|
y_pos=y_pos
|
|
)
|
|
except (AttributeError, KeyError, ValueError) as e:
|
|
logger.debug(f"Skipping invalid event: {e}")
|
|
return None
|
|
|
|
def _copy_timeline_event_with_y_pos(self, event_data: TimelineEvent, y_pos: float) -> TimelineEvent:
|
|
"""
|
|
Create a copy of a TimelineEvent with a new Y position.
|
|
|
|
Args:
|
|
event_data: The original TimelineEvent.
|
|
y_pos: The new Y position.
|
|
|
|
Returns:
|
|
TimelineEvent: A new TimelineEvent with the updated Y position.
|
|
"""
|
|
return TimelineEvent(
|
|
date_sort=event_data.date_sort,
|
|
date_obj=event_data.date_obj,
|
|
event=event_data.event,
|
|
person=event_data.person,
|
|
event_type=event_data.event_type,
|
|
y_pos=y_pos
|
|
)
|
|
|
|
def _process_event(self, event: 'Event', person_obj: Optional['Person'] = None) -> Optional[TimelineEvent]:
|
|
"""
|
|
Process a single event and create a TimelineEvent.
|
|
|
|
Args:
|
|
event: The event object to process.
|
|
person_obj: Optional person object associated with this event.
|
|
|
|
Returns:
|
|
Optional[TimelineEvent]: The created TimelineEvent, or None if invalid.
|
|
"""
|
|
return self._create_timeline_event(event, person_obj)
|
|
|
|
def _build_event_to_person_index(self) -> None:
|
|
"""
|
|
Build a reverse index mapping event handles to person objects.
|
|
This is much more efficient than searching through all persons for each event.
|
|
"""
|
|
if not self.dbstate.is_open():
|
|
return
|
|
|
|
self._event_to_person_cache.clear()
|
|
|
|
try:
|
|
# Iterate through all persons once and build the index
|
|
for person_handle in self.dbstate.db.get_person_handles():
|
|
try:
|
|
person = self.dbstate.db.get_person_from_handle(person_handle)
|
|
if not person:
|
|
continue
|
|
|
|
# Check primary events first (these take precedence)
|
|
self._index_event_refs(person.get_primary_event_ref_list(), person)
|
|
|
|
# Check all events (for events not in primary list)
|
|
self._index_event_refs(person.get_event_ref_list(), person)
|
|
|
|
except (AttributeError, KeyError):
|
|
continue
|
|
except (AttributeError, KeyError) as e:
|
|
logger.warning(f"Error building event-to-person index from database: {e}", exc_info=True)
|
|
|
|
def _index_event_refs(self, event_ref_list: List[Any], person: 'Person') -> None:
|
|
"""
|
|
Index event references from a list, mapping event handles to person.
|
|
|
|
Args:
|
|
event_ref_list: List of event references from a person object.
|
|
person: The person object to associate with the events.
|
|
"""
|
|
for event_ref in event_ref_list:
|
|
event_handle = event_ref.ref
|
|
# Only store if not already mapped (to preserve priority of primary events)
|
|
if event_handle not in self._event_to_person_cache:
|
|
self._event_to_person_cache[event_handle] = person
|
|
|
|
def _find_person_for_event(self, event: 'Event') -> Optional['Person']:
|
|
"""
|
|
Find a primary person associated with an event using the cached index.
|
|
|
|
Args:
|
|
event: The event object to find a person for.
|
|
|
|
Returns:
|
|
Optional person object, or None if not found or if event is family-only.
|
|
|
|
Note:
|
|
This method now uses the cached index built by _build_event_to_person_index().
|
|
If the cache is empty, returns None. For best performance, ensure
|
|
_build_event_to_person_index() is called before using this method.
|
|
"""
|
|
if not event:
|
|
return None
|
|
|
|
event_handle = event.get_handle()
|
|
return self._event_to_person_cache.get(event_handle)
|
|
|
|
def _get_person_display_name(self, person_handle: Optional[str]) -> Optional[str]:
|
|
"""
|
|
Get display name for a person by handle.
|
|
|
|
Args:
|
|
person_handle: The person handle.
|
|
|
|
Returns:
|
|
Optional[str]: Display name or None if person not found or handle is None.
|
|
"""
|
|
if not person_handle:
|
|
return None
|
|
|
|
try:
|
|
person = self.dbstate.db.get_person_from_handle(person_handle)
|
|
if person:
|
|
return name_displayer.display(person)
|
|
except (AttributeError, KeyError):
|
|
pass
|
|
|
|
return None
|
|
|
|
def _get_family_display_name(self, family: 'Family') -> str:
|
|
"""
|
|
Get a display name for a family showing parent names.
|
|
|
|
Args:
|
|
family: The family object.
|
|
|
|
Returns:
|
|
str: Display name for the family.
|
|
"""
|
|
if not family:
|
|
return _("Unknown Family")
|
|
|
|
father_name = self._get_person_display_name(family.get_father_handle())
|
|
mother_name = self._get_person_display_name(family.get_mother_handle())
|
|
|
|
if father_name and mother_name:
|
|
return f"{father_name} & {mother_name}"
|
|
elif father_name:
|
|
return f"{father_name} & {_('Unknown')}"
|
|
elif mother_name:
|
|
return f"{_('Unknown')} & {mother_name}"
|
|
else:
|
|
return _("Unknown Family")
|
|
|
|
def collect_events(self) -> None:
|
|
"""Collect all events from the database."""
|
|
self.all_events = []
|
|
self.events = []
|
|
self._event_to_person_cache.clear()
|
|
|
|
if not self.dbstate.is_open():
|
|
return
|
|
|
|
try:
|
|
# Build event-to-person reverse index for efficient lookup
|
|
self._build_event_to_person_index()
|
|
|
|
# Iterate through all events in the database
|
|
for event in self.dbstate.db.iter_events():
|
|
# Get associated person from cache
|
|
event_handle = event.get_handle()
|
|
person_obj = self._event_to_person_cache.get(event_handle)
|
|
|
|
# Process the event
|
|
timeline_event = self._process_event(event, person_obj)
|
|
if timeline_event:
|
|
self.all_events.append(timeline_event)
|
|
|
|
except (AttributeError, KeyError) as e:
|
|
logger.warning(f"Error collecting events from database: {e}", exc_info=True)
|
|
return
|
|
|
|
# Sort events by date
|
|
self.all_events.sort(key=lambda x: x.date_sort)
|
|
|
|
# Apply filters
|
|
self.events = self._apply_filters(self.all_events)
|
|
|
|
# Invalidate cache when events change
|
|
self._invalidate_cache()
|
|
|
|
# Calculate timeline height
|
|
self._calculate_timeline_height()
|
|
|
|
# Update filter button state
|
|
self._update_filter_button_state()
|
|
|
|
def _update_normalized_active_event_types(self) -> None:
|
|
"""
|
|
Update the pre-computed normalized active event types set.
|
|
Call this whenever active_event_types changes.
|
|
"""
|
|
if not self.active_event_types:
|
|
self._normalized_active_event_types = None
|
|
else:
|
|
self._normalized_active_event_types = {
|
|
self._normalize_event_type(et) for et in self.active_event_types
|
|
}
|
|
|
|
def _apply_event_type_filter(self, event: TimelineEvent) -> bool:
|
|
"""
|
|
Check if event passes event type filter.
|
|
|
|
Args:
|
|
event: The event to check.
|
|
|
|
Returns:
|
|
bool: True if event passes filter, False otherwise.
|
|
"""
|
|
if not self.active_event_types:
|
|
return True
|
|
# Use pre-computed normalized set if available, otherwise compute it
|
|
if self._normalized_active_event_types is None:
|
|
self._update_normalized_active_event_types()
|
|
# Normalize event.event_type and compare with normalized active_event_types
|
|
event_type_normalized = self._normalize_event_type(event.event_type)
|
|
return event_type_normalized in self._normalized_active_event_types
|
|
|
|
def _apply_category_filter(self, event: TimelineEvent) -> bool:
|
|
"""
|
|
Check if event passes category filter.
|
|
|
|
Args:
|
|
event: The event to check.
|
|
|
|
Returns:
|
|
bool: True if event passes filter, False otherwise.
|
|
"""
|
|
if not self.category_filter:
|
|
return True
|
|
category = self._get_event_category(event.event_type)
|
|
return category in self.category_filter
|
|
|
|
def _apply_filters(self, events: List[TimelineEvent]) -> List[TimelineEvent]:
|
|
"""
|
|
Apply all active filters to events.
|
|
|
|
Args:
|
|
events: List of TimelineEvent objects to filter.
|
|
|
|
Returns:
|
|
List[TimelineEvent]: Filtered list of events.
|
|
"""
|
|
if not self.filter_enabled:
|
|
return events
|
|
|
|
filtered = []
|
|
for event in events:
|
|
# Check event type filter
|
|
if not self._apply_event_type_filter(event):
|
|
continue
|
|
|
|
# Check 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
|
|
if not self._apply_category_filter(event):
|
|
continue
|
|
|
|
filtered.append(event)
|
|
|
|
return filtered
|
|
|
|
def _normalize_event_type(self, event_type: EventType) -> int:
|
|
"""
|
|
Normalize EventType to integer for comparison.
|
|
Uses caching to avoid repeated conversions.
|
|
|
|
Args:
|
|
event_type: The event type (may be EventType object or integer).
|
|
|
|
Returns:
|
|
int: The integer value of the event type.
|
|
"""
|
|
# Check cache first
|
|
if event_type in self._event_type_normalization_cache:
|
|
return self._event_type_normalization_cache[event_type]
|
|
|
|
try:
|
|
if isinstance(event_type, int):
|
|
normalized = event_type
|
|
elif hasattr(event_type, 'value'):
|
|
normalized = event_type.value
|
|
else:
|
|
normalized = int(event_type)
|
|
except (TypeError, ValueError, AttributeError):
|
|
normalized = 0 # Default to 0 if conversion fails
|
|
|
|
# Cache the result
|
|
self._event_type_normalization_cache[event_type] = normalized
|
|
return normalized
|
|
|
|
def _is_event_type_enabled(self, event_type: EventType) -> bool:
|
|
"""
|
|
Check if an event type is enabled in the filter.
|
|
|
|
Args:
|
|
event_type: The event type to check.
|
|
|
|
Returns:
|
|
bool: True if event type is enabled (or no filter active), False otherwise.
|
|
"""
|
|
if not self.active_event_types:
|
|
return True
|
|
# Use pre-computed normalized set if available, otherwise compute it
|
|
if self._normalized_active_event_types is None:
|
|
self._update_normalized_active_event_types()
|
|
# Normalize event type and compare with normalized active types
|
|
normalized_type = self._normalize_event_type(event_type)
|
|
return normalized_type in self._normalized_active_event_types
|
|
|
|
def _is_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_events_for_person(self, person_handle: str, events_list: List['TimelineEvent']) -> List['TimelineEvent']:
|
|
"""
|
|
Filter events list to only include events for a specific person.
|
|
|
|
Args:
|
|
person_handle: The handle of the person to filter events for.
|
|
events_list: List of timeline events to filter.
|
|
|
|
Returns:
|
|
List[TimelineEvent]: Filtered list of events for the specified person.
|
|
"""
|
|
return [
|
|
event for event in events_list
|
|
if self._person_matches_handle(event.person, person_handle)
|
|
]
|
|
|
|
def _get_event_type_display_name(self, event_type: EventType) -> str:
|
|
"""
|
|
Get human-readable display name for an event type.
|
|
|
|
Args:
|
|
event_type: The event type (integer or EventType object).
|
|
|
|
Returns:
|
|
str: Human-readable name for the event type.
|
|
"""
|
|
# Normalize to integer first
|
|
event_type_value = self._normalize_event_type(event_type)
|
|
|
|
# Map EventType integer values to their human-readable names
|
|
event_type_names = {
|
|
EventType.BIRTH: _("Birth"),
|
|
EventType.DEATH: _("Death"),
|
|
EventType.BURIAL: _("Burial"),
|
|
EventType.CREMATION: _("Cremation"),
|
|
EventType.ADOPT: _("Adoption"),
|
|
EventType.MARRIAGE: _("Marriage"),
|
|
EventType.DIVORCE: _("Divorce"),
|
|
EventType.ENGAGEMENT: _("Engagement"),
|
|
EventType.MARR_SETTL: _("Marriage Settlement"),
|
|
EventType.MARR_LIC: _("Marriage License"),
|
|
EventType.MARR_CONTR: _("Marriage Contract"),
|
|
EventType.MARR_BANNS: _("Marriage Banns"),
|
|
EventType.DIV_FILING: _("Divorce Filing"),
|
|
EventType.ANNULMENT: _("Annulment"),
|
|
EventType.MARR_ALT: _("Marriage (Alternative)"),
|
|
EventType.BAPTISM: _("Baptism"),
|
|
EventType.ADULT_CHRISTEN: _("Adult Christening"),
|
|
EventType.CONFIRMATION: _("Confirmation"),
|
|
EventType.CHRISTEN: _("Christening"),
|
|
EventType.FIRST_COMMUN: _("First Communion"),
|
|
EventType.BLESS: _("Blessing"),
|
|
EventType.BAR_MITZVAH: _("Bar Mitzvah"),
|
|
EventType.BAS_MITZVAH: _("Bat Mitzvah"),
|
|
EventType.RELIGION: _("Religion"),
|
|
EventType.ORDINATION: _("Ordination"),
|
|
EventType.OCCUPATION: _("Occupation"),
|
|
EventType.RETIREMENT: _("Retirement"),
|
|
EventType.ELECTED: _("Elected"),
|
|
EventType.MILITARY_SERV: _("Military Service"),
|
|
EventType.EDUCATION: _("Education"),
|
|
EventType.GRADUATION: _("Graduation"),
|
|
EventType.DEGREE: _("Degree"),
|
|
EventType.EMIGRATION: _("Emigration"),
|
|
EventType.IMMIGRATION: _("Immigration"),
|
|
EventType.NATURALIZATION: _("Naturalization"),
|
|
EventType.PROBATE: _("Probate"),
|
|
EventType.WILL: _("Will"),
|
|
EventType.RESIDENCE: _("Residence"),
|
|
EventType.CENSUS: _("Census"),
|
|
EventType.PROPERTY: _("Property"),
|
|
EventType.CAUSE_DEATH: _("Cause of Death"),
|
|
EventType.MED_INFO: _("Medical Information"),
|
|
EventType.NOB_TITLE: _("Nobility Title"),
|
|
EventType.NUM_MARRIAGES: _("Number of Marriages"),
|
|
EventType.UNKNOWN: _("Unknown"),
|
|
EventType.CUSTOM: _("Custom"),
|
|
}
|
|
|
|
return event_type_names.get(event_type_value, _("Unknown Event"))
|
|
|
|
def _get_event_category(self, event_type: EventType) -> str:
|
|
"""
|
|
Get the category for an event type.
|
|
|
|
Args:
|
|
event_type: The event type (may be EventType object or integer).
|
|
|
|
Returns:
|
|
str: The category name, or "Other Events" if not found.
|
|
"""
|
|
# Normalize EventType to integer for dictionary lookup
|
|
event_type_value = self._normalize_event_type(event_type)
|
|
return EVENT_CATEGORIES.get(event_type_value, "Other Events")
|
|
|
|
def apply_filters(self) -> None:
|
|
"""
|
|
Apply current filters and update the view.
|
|
Public method to trigger filter application.
|
|
"""
|
|
if self.all_events:
|
|
self.events = self._apply_filters(self.all_events)
|
|
self._invalidate_cache()
|
|
self._calculate_timeline_height()
|
|
self._queue_draw()
|
|
|
|
def _calculate_date_range(self) -> Tuple[int, int, int]:
|
|
"""
|
|
Calculate date range from events.
|
|
|
|
Returns:
|
|
Tuple[int, int, int]: (min_date, max_date, date_range)
|
|
"""
|
|
if not self.events:
|
|
return (0, 0, 1)
|
|
|
|
if (self._cached_min_date is not None and
|
|
self._cached_max_date is not None and
|
|
self._cached_date_range is not None):
|
|
return (self._cached_min_date, self._cached_max_date, self._cached_date_range)
|
|
|
|
min_date = min(event.date_sort for event in self.events)
|
|
max_date = max(event.date_sort for event in self.events)
|
|
date_range = max_date - min_date if max_date != min_date else 1
|
|
|
|
self._cached_min_date = min_date
|
|
self._cached_max_date = max_date
|
|
self._cached_date_range = date_range
|
|
|
|
return (min_date, max_date, date_range)
|
|
|
|
def _person_matches_handle(self, person: Optional['Person'], handle: str) -> bool:
|
|
"""
|
|
Check if a person's handle matches the given handle.
|
|
|
|
Args:
|
|
person: The person object (may be None).
|
|
handle: The handle to match against.
|
|
|
|
Returns:
|
|
bool: True if person exists and handle matches, False otherwise.
|
|
"""
|
|
return person is not None and person.get_handle() == handle
|
|
|
|
def _get_person_color(self, person_handle: str) -> Tuple[float, float, float, float]:
|
|
"""
|
|
Get or generate a distinct color for a person handle.
|
|
Uses HSL color space to generate evenly distributed colors.
|
|
|
|
Args:
|
|
person_handle: The handle of the person.
|
|
|
|
Returns:
|
|
Tuple[float, float, float, float]: RGBA color tuple (values 0-1).
|
|
"""
|
|
# If color already assigned, return it
|
|
if person_handle in self.person_colors:
|
|
return self.person_colors[person_handle]
|
|
|
|
# Generate color using HSV: vary hue, keep saturation and lightness constant
|
|
# Use hash of handle for consistent color even when selection order changes
|
|
handle_hash = hash(person_handle)
|
|
hue = (abs(handle_hash) % HSV_HUE_MAX_DEGREES) / float(HSV_HUE_MAX_DEGREES) # 0-1 range
|
|
|
|
# Convert HSV to RGB (HSV is Hue, Saturation, Value)
|
|
r, g, b = colorsys.hsv_to_rgb(hue, PERSON_COLOR_SATURATION, PERSON_COLOR_LIGHTNESS)
|
|
color = (r, g, b, PERSON_COLOR_ALPHA)
|
|
|
|
# Store the color
|
|
self.person_colors[person_handle] = color
|
|
return color
|
|
|
|
def _get_person_vertical_line_x(self, person_index: int) -> float:
|
|
"""
|
|
Calculate the X position for a person's vertical connection line.
|
|
|
|
Args:
|
|
person_index: The index of the person in the sorted selection (0-based).
|
|
|
|
Returns:
|
|
float: The X coordinate for the vertical line.
|
|
"""
|
|
return CONNECTION_VERTICAL_LINE_X + (person_index * CONNECTION_LINE_SPACING)
|
|
|
|
def _calculate_max_connection_line_x(self) -> float:
|
|
"""
|
|
Calculate the maximum (rightmost) X position of connection lines.
|
|
|
|
Returns:
|
|
float: The X coordinate of the rightmost connection line, or 0 if no persons selected.
|
|
"""
|
|
if not self.selected_person_handles:
|
|
return 0
|
|
|
|
# The rightmost line is at the highest index
|
|
max_index = len(self.selected_person_handles) - 1
|
|
return CONNECTION_VERTICAL_LINE_X + (max_index * CONNECTION_LINE_SPACING)
|
|
|
|
def _calculate_timeline_x(self) -> float:
|
|
"""
|
|
Calculate the dynamic timeline X position based on connection lines.
|
|
|
|
The timeline should be positioned to the right of the rightmost connection line
|
|
with appropriate spacing. If no connection lines exist, use the default margin.
|
|
|
|
Returns:
|
|
float: The X coordinate for the timeline.
|
|
"""
|
|
max_connection_x = self._calculate_max_connection_line_x()
|
|
|
|
if max_connection_x == 0:
|
|
# No connection lines, use default margin
|
|
return TIMELINE_MARGIN_LEFT
|
|
|
|
# Position timeline to the right of connection lines with spacing
|
|
timeline_x = max_connection_x + TIMELINE_LEFT_SPACING
|
|
|
|
# Ensure minimum timeline X to prevent overlap with year labels
|
|
# Year labels need space on the left (around 130-150 pixels)
|
|
min_timeline_x = TIMELINE_MARGIN_LEFT
|
|
return max(timeline_x, min_timeline_x)
|
|
|
|
def _calculate_y_position(self, date_sort: int, min_date: int, date_range: int,
|
|
timeline_y_start: float, timeline_y_end: float) -> float:
|
|
"""
|
|
Calculate Y position for an event based on date.
|
|
|
|
Args:
|
|
date_sort: The sort value of the event date.
|
|
min_date: The minimum date sort value.
|
|
date_range: The range of dates (max - min).
|
|
timeline_y_start: The Y coordinate of the timeline start.
|
|
timeline_y_end: The Y coordinate of the timeline end.
|
|
|
|
Returns:
|
|
float: The calculated Y position.
|
|
"""
|
|
return timeline_y_start + (
|
|
(date_sort - min_date) / date_range
|
|
) * (timeline_y_end - timeline_y_start)
|
|
|
|
def _get_event_label_text(self, event: 'Event', person: Optional['Person'], event_type: EventType) -> str:
|
|
"""
|
|
Generate label text for an event. Centralized logic.
|
|
|
|
Args:
|
|
event: The event object.
|
|
person: The person associated with the event (None for family events).
|
|
event_type: The type of event.
|
|
|
|
Returns:
|
|
str: The formatted label text.
|
|
"""
|
|
date_str = get_date(event)
|
|
event_type_str = str(event_type)
|
|
|
|
if person:
|
|
person_name = name_displayer.display(person)
|
|
return f"{date_str} - {event_type_str} - {person_name}"
|
|
else:
|
|
return f"{date_str} - {event_type_str}"
|
|
|
|
def _calculate_adjusted_positions(self, context: cairo.Context,
|
|
events_with_y_pos: List[TimelineEvent],
|
|
timeline_y_start: float,
|
|
timeline_y_end: float) -> List[TimelineEvent]:
|
|
"""
|
|
Calculate adjusted Y positions with collision detection.
|
|
Reusable by both drawing and click detection.
|
|
|
|
Args:
|
|
context: Cairo drawing context for text measurement.
|
|
events_with_y_pos: List of TimelineEvent objects with initial Y positions.
|
|
timeline_y_start: The Y coordinate of the timeline start.
|
|
timeline_y_end: The Y coordinate of the timeline end.
|
|
|
|
Returns:
|
|
List[TimelineEvent]: List of TimelineEvent objects with adjusted Y positions.
|
|
"""
|
|
if not events_with_y_pos:
|
|
return events_with_y_pos
|
|
|
|
# Create a temporary layout to measure text
|
|
layout = PangoCairo.create_layout(context)
|
|
layout.set_font_description(
|
|
Pango.font_description_from_string(f"{FONT_FAMILY} {FONT_SIZE_NORMAL}")
|
|
)
|
|
|
|
adjusted_events = []
|
|
|
|
for event_data in events_with_y_pos:
|
|
# Calculate label height using centralized text generation
|
|
label_text = self._get_event_label_text(event_data.event, event_data.person, event_data.event_type)
|
|
layout.set_text(label_text, -1)
|
|
text_width, text_height = layout.get_pixel_size()
|
|
label_height = text_height + LABEL_PADDING
|
|
|
|
# Check for overlap with previous events (optimized: find max y_pos to place after)
|
|
adjusted_y = event_data.y_pos
|
|
for prev_event in adjusted_events:
|
|
# Check if labels would overlap
|
|
prev_y = prev_event.y_pos
|
|
if adjusted_y < prev_y + MIN_LABEL_SPACING:
|
|
# Adjust downward to be below the previous event
|
|
adjusted_y = prev_y + MIN_LABEL_SPACING
|
|
|
|
# Ensure adjusted position is within bounds
|
|
adjusted_y = max(timeline_y_start, min(adjusted_y, timeline_y_end))
|
|
|
|
# Create new event data with adjusted Y position
|
|
adjusted_event = self._copy_timeline_event_with_y_pos(event_data, adjusted_y)
|
|
adjusted_events.append(adjusted_event)
|
|
|
|
return adjusted_events
|
|
|
|
def _recalculate_timeline_height(self) -> None:
|
|
"""
|
|
Recalculate timeline height based on current events and zoom level.
|
|
"""
|
|
if self.events:
|
|
base_height = (
|
|
TIMELINE_MARGIN_TOP
|
|
+ len(self.events) * EVENT_SPACING
|
|
+ TIMELINE_MARGIN_BOTTOM
|
|
)
|
|
self.timeline_height = int(base_height * self.zoom_level)
|
|
else:
|
|
self.timeline_height = int(EMPTY_TIMELINE_HEIGHT * self.zoom_level)
|
|
|
|
if self.drawing_area:
|
|
self.drawing_area.set_size_request(DEFAULT_DRAWING_AREA_WIDTH, self.timeline_height)
|
|
|
|
# Invalidate cache when zoom changes
|
|
self._adjusted_events_cache = None
|
|
self._cache_key = None
|
|
|
|
def _update_zoom(self, level: float) -> None:
|
|
"""
|
|
Update zoom level and refresh the display.
|
|
|
|
Args:
|
|
level: The new zoom level to set.
|
|
"""
|
|
self.zoom_level = level
|
|
self.update_zoom_display()
|
|
self._recalculate_timeline_height()
|
|
self._queue_draw()
|
|
|
|
def on_zoom_in(self, widget: Gtk.Widget) -> None:
|
|
"""
|
|
Zoom in.
|
|
|
|
Args:
|
|
widget: The widget that triggered the zoom (unused).
|
|
"""
|
|
if self.zoom_level < self.max_zoom:
|
|
new_level = min(self.zoom_level + self.zoom_step, self.max_zoom)
|
|
self._update_zoom(new_level)
|
|
|
|
def on_zoom_out(self, widget: Gtk.Widget) -> None:
|
|
"""
|
|
Zoom out.
|
|
|
|
Args:
|
|
widget: The widget that triggered the zoom (unused).
|
|
"""
|
|
if self.zoom_level > self.min_zoom:
|
|
new_level = max(self.zoom_level - self.zoom_step, self.min_zoom)
|
|
self._update_zoom(new_level)
|
|
|
|
def on_zoom_reset(self, widget: Gtk.Widget) -> None:
|
|
"""
|
|
Reset zoom to 100%.
|
|
|
|
Args:
|
|
widget: The widget that triggered the reset (unused).
|
|
"""
|
|
self._update_zoom(1.0)
|
|
|
|
def on_scroll(self, widget: Gtk.Widget, event: Gdk.Event) -> bool:
|
|
"""
|
|
Handle scroll events for zooming with Ctrl+scroll.
|
|
|
|
Args:
|
|
widget: The widget that received the scroll event.
|
|
event: The scroll event.
|
|
|
|
Returns:
|
|
bool: True if the event was handled, False otherwise.
|
|
"""
|
|
if event.state & Gdk.ModifierType.CONTROL_MASK:
|
|
if event.direction == Gdk.ScrollDirection.UP:
|
|
self.on_zoom_in(widget)
|
|
elif event.direction == Gdk.ScrollDirection.DOWN:
|
|
self.on_zoom_out(widget)
|
|
return True
|
|
return False
|
|
|
|
def update_zoom_display(self) -> None:
|
|
"""Update the zoom level display."""
|
|
if hasattr(self, 'zoom_label'):
|
|
zoom_percentage = int(self.zoom_level * ZOOM_PERCENTAGE_MULTIPLIER)
|
|
self.zoom_label.set_text(f"{zoom_percentage}%")
|
|
|
|
def on_button_press(self, widget: Gtk.Widget, event: Gdk.Event) -> bool:
|
|
"""
|
|
Handle mouse button press events.
|
|
|
|
Args:
|
|
widget: The widget that received the button press event.
|
|
event: The button press event.
|
|
|
|
Returns:
|
|
bool: False to allow other handlers to process the event.
|
|
"""
|
|
if event.button == MOUSE_BUTTON_LEFT:
|
|
# Find which event was clicked
|
|
clicked_index = self.find_event_at_position(event.x, event.y)
|
|
if clicked_index is not None:
|
|
clicked_event_data = self.events[clicked_index]
|
|
event_handle = clicked_event_data.event.get_handle()
|
|
|
|
# Select/highlight this event
|
|
if self.active_event_handle == event_handle:
|
|
# Deselect if clicking same event
|
|
self.active_event_handle = None
|
|
else:
|
|
# Select this event
|
|
self.active_event_handle = event_handle
|
|
# Navigate to this event in the view
|
|
self.uistate.set_active(event_handle, "Event")
|
|
|
|
# Toggle person selection (always toggle, even if event is same)
|
|
if clicked_event_data.person:
|
|
person_handle = clicked_event_data.person.get_handle()
|
|
if person_handle in self.selected_person_handles:
|
|
# Remove from selection
|
|
self.selected_person_handles.remove(person_handle)
|
|
# Clean up color if no longer selected
|
|
if person_handle in self.person_colors:
|
|
del self.person_colors[person_handle]
|
|
else:
|
|
# Add to selection
|
|
self.selected_person_handles.add(person_handle)
|
|
# Color will be assigned when drawing
|
|
|
|
self._queue_draw()
|
|
return False
|
|
|
|
def on_motion_notify(self, widget: Gtk.Widget, event: Gdk.Event) -> bool:
|
|
"""
|
|
Handle mouse motion events for hover detection.
|
|
|
|
Args:
|
|
widget: The widget that received the motion event.
|
|
event: The motion event.
|
|
|
|
Returns:
|
|
bool: False to allow other handlers to process the event.
|
|
"""
|
|
self.mouse_x = event.x
|
|
self.mouse_y = event.y
|
|
|
|
# Find which event is under the cursor
|
|
hovered_index = self.find_event_at_position(event.x, event.y)
|
|
|
|
if hovered_index != self.hovered_event_index:
|
|
self.hovered_event_index = hovered_index
|
|
|
|
# Cancel existing tooltip timeout
|
|
if self.tooltip_timeout_id:
|
|
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._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._queue_draw()
|
|
return False
|
|
|
|
def _get_cache_key(self, timeline_y_start: float, timeline_y_end: float) -> int:
|
|
"""
|
|
Generate cache key for adjusted events.
|
|
|
|
Args:
|
|
timeline_y_start: The Y coordinate of the timeline start.
|
|
timeline_y_end: The Y coordinate of the timeline end.
|
|
|
|
Returns:
|
|
int: A hash-based cache key.
|
|
"""
|
|
return hash((len(self.events), timeline_y_start, timeline_y_end))
|
|
|
|
def _get_adjusted_events(self, context: cairo.Context,
|
|
timeline_y_start: float,
|
|
timeline_y_end: float) -> List[TimelineEvent]:
|
|
"""
|
|
Get adjusted events with collision detection, using cache if available.
|
|
|
|
Args:
|
|
context: Cairo drawing context for text measurement.
|
|
timeline_y_start: The Y coordinate of the timeline start.
|
|
timeline_y_end: The Y coordinate of the timeline end.
|
|
|
|
Returns:
|
|
List[TimelineEvent]: List of TimelineEvent objects with adjusted Y positions.
|
|
"""
|
|
# Check if cache is valid using hash-based key
|
|
cache_key = self._get_cache_key(timeline_y_start, timeline_y_end)
|
|
if (self._adjusted_events_cache is not None and
|
|
self._cache_key == cache_key):
|
|
return self._adjusted_events_cache
|
|
|
|
# Calculate date range
|
|
if not self.events:
|
|
return []
|
|
|
|
min_date, max_date, date_range = self._calculate_date_range()
|
|
|
|
# Calculate initial Y positions
|
|
events_with_y_pos = []
|
|
for event_data in self.events:
|
|
y_pos = self._calculate_y_position(
|
|
event_data.date_sort, min_date, date_range,
|
|
timeline_y_start, timeline_y_end
|
|
)
|
|
event_with_y = self._copy_timeline_event_with_y_pos(event_data, y_pos)
|
|
events_with_y_pos.append(event_with_y)
|
|
|
|
# Apply collision detection using shared method
|
|
adjusted_events = self._calculate_adjusted_positions(
|
|
context, events_with_y_pos, timeline_y_start, timeline_y_end
|
|
)
|
|
|
|
# Cache the results
|
|
self._adjusted_events_cache = adjusted_events
|
|
self._cache_key = cache_key
|
|
|
|
return adjusted_events
|
|
|
|
def find_event_at_position(self, x: float, y: float) -> Optional[int]:
|
|
"""
|
|
Find which event is at the given position.
|
|
|
|
Args:
|
|
x: X coordinate in widget space.
|
|
y: Y coordinate in widget space.
|
|
|
|
Returns:
|
|
Optional[int]: Index of the event at the position, or None if no event found.
|
|
"""
|
|
if not self.events or not self.drawing_area:
|
|
return None
|
|
|
|
# Convert mouse coordinates to drawing coordinates (account for zoom)
|
|
scaled_x = x / self.zoom_level
|
|
scaled_y = y / self.zoom_level
|
|
|
|
# Get widget dimensions in drawing coordinates
|
|
height = self.drawing_area.get_allocated_height() / self.zoom_level
|
|
|
|
# Calculate dynamic timeline X position based on connection lines
|
|
timeline_x = self._calculate_timeline_x()
|
|
timeline_y_start = TIMELINE_MARGIN_TOP
|
|
timeline_y_end = height - TIMELINE_MARGIN_BOTTOM
|
|
|
|
# Get adjusted events using cache (create temporary context for text measurement)
|
|
# For click detection, we need a context for text measurement
|
|
temp_context = cairo.Context(self._temp_surface)
|
|
|
|
adjusted_events = self._get_adjusted_events(temp_context, timeline_y_start, timeline_y_end)
|
|
|
|
# Check each event using adjusted positions
|
|
marker_size = EVENT_MARKER_SIZE
|
|
label_x = timeline_x + LABEL_X_OFFSET
|
|
|
|
for i, event_data in enumerate(adjusted_events):
|
|
# Check if click is in the event's area (marker + label)
|
|
if (scaled_x >= timeline_x - marker_size - MARKER_CLICK_PADDING and
|
|
scaled_x <= label_x + CLICKABLE_AREA_WIDTH and
|
|
abs(scaled_y - event_data.y_pos) < CLICKABLE_AREA_HEIGHT / 2):
|
|
return i
|
|
|
|
return None
|
|
|
|
def _get_place_name_for_event(self, event: 'Event') -> Optional[str]:
|
|
"""
|
|
Get the place name for an event, with error handling.
|
|
|
|
Args:
|
|
event: The event object.
|
|
|
|
Returns:
|
|
Optional[str]: Place name if available, None otherwise.
|
|
"""
|
|
place_handle = event.get_place_handle()
|
|
if not place_handle:
|
|
return None
|
|
|
|
try:
|
|
place = self.dbstate.db.get_place_from_handle(place_handle)
|
|
if place:
|
|
return place.get_title()
|
|
except (AttributeError, KeyError) as e:
|
|
logger.debug(f"Error accessing place information for event in tooltip: {e}")
|
|
return None
|
|
|
|
def _format_person_tooltip(self, person: 'Person', person_events: List[TimelineEvent]) -> str:
|
|
"""
|
|
Format tooltip for person with multiple events.
|
|
|
|
Args:
|
|
person: The person object.
|
|
person_events: List of TimelineEvent objects for this person.
|
|
|
|
Returns:
|
|
str: Formatted tooltip text.
|
|
"""
|
|
person_name = name_displayer.display(person)
|
|
tooltip_text = f"<b>{person_name}</b>\n"
|
|
tooltip_text += "─" * TOOLTIP_SEPARATOR_LENGTH + "\n"
|
|
|
|
# Sort by date
|
|
person_events_sorted = sorted(person_events, key=lambda x: x.date_sort)
|
|
|
|
# List all events for this person (date first)
|
|
for event_data in person_events_sorted:
|
|
date_str = get_date(event_data.event)
|
|
event_type_str = str(event_data.event_type)
|
|
tooltip_text += f"{date_str} - {event_type_str}\n"
|
|
|
|
# Add place if available
|
|
place_name = self._get_place_name_for_event(event_data.event)
|
|
if place_name:
|
|
tooltip_text += f" 📍 {place_name}\n"
|
|
|
|
return tooltip_text
|
|
|
|
def _format_single_event_tooltip(self, event: 'Event', event_type: EventType) -> str:
|
|
"""
|
|
Format tooltip for single event.
|
|
|
|
Args:
|
|
event: The event object.
|
|
event_type: The type of event.
|
|
|
|
Returns:
|
|
str: Formatted tooltip text.
|
|
"""
|
|
date_str = get_date(event)
|
|
event_type_str = str(event_type)
|
|
|
|
tooltip_text = f"<b>{date_str}</b>\n{event_type_str}"
|
|
|
|
# Get place information
|
|
place_name = self._get_place_name_for_event(event)
|
|
if place_name:
|
|
tooltip_text += f"\n📍 {place_name}"
|
|
|
|
# Get description
|
|
description = event.get_description()
|
|
if description:
|
|
tooltip_text += f"\n{description}"
|
|
|
|
return tooltip_text
|
|
|
|
def _get_or_create_tooltip_window(self) -> Gtk.Window:
|
|
"""
|
|
Get or create tooltip window (reuse if exists).
|
|
|
|
Returns:
|
|
Gtk.Window: The tooltip window.
|
|
"""
|
|
if not hasattr(self, 'tooltip_window') or self.tooltip_window is None:
|
|
self.tooltip_window = Gtk.Window(type=Gtk.WindowType.POPUP)
|
|
self.tooltip_window.set_border_width(TOOLTIP_BORDER_WIDTH)
|
|
self.tooltip_window.set_decorated(False)
|
|
|
|
# Get parent window for proper display
|
|
toplevel = self.drawing_area.get_toplevel()
|
|
if isinstance(toplevel, Gtk.Window):
|
|
self.tooltip_window.set_transient_for(toplevel)
|
|
return self.tooltip_window
|
|
|
|
def show_tooltip(self, event_index: int, x_root: float, y_root: float) -> bool:
|
|
"""
|
|
Show tooltip for an event, including all events for that person.
|
|
|
|
Args:
|
|
event_index: Index of the event in self.events.
|
|
x_root: X coordinate in root window space.
|
|
y_root: Y coordinate in root window space.
|
|
|
|
Returns:
|
|
bool: False to indicate the timeout should not be repeated.
|
|
"""
|
|
if event_index is None or event_index >= len(self.events):
|
|
return False
|
|
|
|
event_data = self.events[event_index]
|
|
|
|
# If event has a person, show all events for that person
|
|
if event_data.person:
|
|
person_handle = event_data.person.get_handle()
|
|
|
|
# Find all events for this person
|
|
person_events = self._get_events_for_person(person_handle, self.events)
|
|
|
|
tooltip_text = self._format_person_tooltip(event_data.person, person_events)
|
|
else:
|
|
# Family event (no person) - show single event info
|
|
tooltip_text = self._format_single_event_tooltip(event_data.event, event_data.event_type)
|
|
|
|
# Get or create tooltip window
|
|
tooltip_window = self._get_or_create_tooltip_window()
|
|
|
|
# Clear existing content
|
|
for child in tooltip_window.get_children():
|
|
tooltip_window.remove(child)
|
|
|
|
# Create container with background
|
|
frame = Gtk.Frame()
|
|
frame.set_shadow_type(Gtk.ShadowType.OUT)
|
|
frame.get_style_context().add_class("tooltip")
|
|
|
|
label = Gtk.Label()
|
|
label.set_markup(tooltip_text)
|
|
label.set_line_wrap(True)
|
|
label.set_max_width_chars(TOOLTIP_MAX_WIDTH_CHARS)
|
|
label.set_margin_start(TOOLTIP_LABEL_MARGIN)
|
|
label.set_margin_end(TOOLTIP_LABEL_MARGIN)
|
|
label.set_margin_top(TOOLTIP_LABEL_MARGIN)
|
|
label.set_margin_bottom(TOOLTIP_LABEL_MARGIN)
|
|
|
|
frame.add(label)
|
|
tooltip_window.add(frame)
|
|
tooltip_window.show_all()
|
|
|
|
# Position tooltip (offset to avoid cursor)
|
|
tooltip_window.move(int(x_root) + TOOLTIP_OFFSET, int(y_root) + TOOLTIP_OFFSET)
|
|
|
|
return False
|
|
|
|
def _draw_background(self, context: cairo.Context, width: float, height: float) -> None:
|
|
"""
|
|
Draw background gradient.
|
|
|
|
Args:
|
|
context: Cairo drawing context.
|
|
width: Widget width.
|
|
height: Widget height.
|
|
"""
|
|
pattern = cairo.LinearGradient(0, 0, 0, height)
|
|
pattern.add_color_stop_rgb(0, *BACKGROUND_GRADIENT_START)
|
|
pattern.add_color_stop_rgb(1, *BACKGROUND_GRADIENT_END)
|
|
context.set_source(pattern)
|
|
context.paint()
|
|
|
|
def _draw_no_events_message(self, context: cairo.Context, width: float, height: float) -> None:
|
|
"""
|
|
Draw "No events" message.
|
|
|
|
Args:
|
|
context: Cairo drawing context.
|
|
width: Widget width.
|
|
height: Widget height.
|
|
"""
|
|
context.set_source_rgb(0.6, 0.6, 0.6)
|
|
context.select_font_face(FONT_FAMILY, cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL)
|
|
context.set_font_size(FONT_SIZE_LARGE)
|
|
text = _("No events found")
|
|
(x_bearing, y_bearing, text_width, text_height, x_advance, y_advance) = context.text_extents(text)
|
|
context.move_to((width - text_width) / 2, height / 2)
|
|
context.show_text(text)
|
|
|
|
def _draw_timeline_axis(self, context: cairo.Context, timeline_x: float,
|
|
y_start: float, y_end: float) -> None:
|
|
"""
|
|
Draw timeline axis with shadow.
|
|
|
|
Args:
|
|
context: Cairo drawing context.
|
|
timeline_x: X coordinate of the timeline.
|
|
y_start: Y coordinate of the timeline start.
|
|
y_end: Y coordinate of the timeline end.
|
|
"""
|
|
# Draw shadow
|
|
context.set_source_rgba(*COLOR_BLACK, TIMELINE_SHADOW_OPACITY)
|
|
context.set_line_width(TIMELINE_LINE_WIDTH)
|
|
context.move_to(timeline_x + TIMELINE_SHADOW_OFFSET, y_start + TIMELINE_SHADOW_OFFSET)
|
|
context.line_to(timeline_x + TIMELINE_SHADOW_OFFSET, y_end + TIMELINE_SHADOW_OFFSET)
|
|
context.stroke()
|
|
|
|
# Draw main line with gradient
|
|
pattern = cairo.LinearGradient(timeline_x, y_start, timeline_x, y_end)
|
|
pattern.add_color_stop_rgb(0, *TIMELINE_AXIS_GRADIENT_START)
|
|
pattern.add_color_stop_rgb(1, *TIMELINE_AXIS_GRADIENT_END)
|
|
context.set_source(pattern)
|
|
context.set_line_width(TIMELINE_LINE_WIDTH)
|
|
context.move_to(timeline_x, y_start)
|
|
context.line_to(timeline_x, y_end)
|
|
context.stroke()
|
|
|
|
def _draw_events(self, context: cairo.Context, events_with_y_pos: List[TimelineEvent],
|
|
timeline_x: float) -> None:
|
|
"""
|
|
Draw all events.
|
|
|
|
Args:
|
|
context: Cairo drawing context.
|
|
events_with_y_pos: List of TimelineEvent objects with Y positions.
|
|
timeline_x: X coordinate of the timeline.
|
|
"""
|
|
for i, event_data in enumerate(events_with_y_pos):
|
|
# Check if this event is hovered
|
|
is_hovered = (i == self.hovered_event_index)
|
|
|
|
# Check if this event is the active/selected event
|
|
is_selected = (self.active_event_handle is not None and
|
|
event_data.event.get_handle() == self.active_event_handle)
|
|
|
|
# Draw event marker with modern styling
|
|
self.draw_event_marker(context, timeline_x, event_data.y_pos,
|
|
event_data.event_type, is_hovered, is_selected)
|
|
|
|
# Draw 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()
|
|
|
|
# Calculate dynamic timeline X position based on connection lines
|
|
timeline_x = self._calculate_timeline_x()
|
|
timeline_y_start = TIMELINE_MARGIN_TOP
|
|
timeline_y_end = height - TIMELINE_MARGIN_BOTTOM
|
|
|
|
self._draw_timeline_axis(context, timeline_x, timeline_y_start, timeline_y_end)
|
|
|
|
# Get adjusted events with collision detection (uses cache)
|
|
events_with_y_pos = self._get_adjusted_events(context, timeline_y_start, timeline_y_end)
|
|
|
|
# Draw events
|
|
self._draw_events(context, events_with_y_pos, timeline_x)
|
|
|
|
# Draw visual connections for selected person (from selected event)
|
|
if self.selected_person_handles:
|
|
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(*COLOR_BLACK, SHADOW_OPACITY)
|
|
context.translate(SHADOW_OFFSET_X, SHADOW_OFFSET_Y)
|
|
self._draw_shape(context, x, y, size, shape)
|
|
context.fill()
|
|
context.translate(-SHADOW_OFFSET_X, -SHADOW_OFFSET_Y)
|
|
|
|
def _draw_marker_gradient(self, context: cairo.Context, x: float, y: float,
|
|
size: float, color: Tuple[float, float, float],
|
|
shape: str) -> None:
|
|
"""
|
|
Draw gradient fill for marker.
|
|
|
|
Args:
|
|
context: Cairo drawing context.
|
|
x: X coordinate of marker center.
|
|
y: Y coordinate of marker center.
|
|
size: Size of the marker.
|
|
color: RGB color tuple (0.0-1.0).
|
|
shape: Shape type ('triangle', 'circle', etc.).
|
|
"""
|
|
pattern = cairo.RadialGradient(x - size/2, y - size/2, 0, x, y, size)
|
|
r, g, b = color
|
|
pattern.add_color_stop_rgb(0,
|
|
min(1.0, r + GRADIENT_BRIGHTNESS_OFFSET),
|
|
min(1.0, g + GRADIENT_BRIGHTNESS_OFFSET),
|
|
min(1.0, b + GRADIENT_BRIGHTNESS_OFFSET))
|
|
pattern.add_color_stop_rgb(1,
|
|
max(0.0, r - GRADIENT_DARKNESS_OFFSET),
|
|
max(0.0, g - GRADIENT_DARKNESS_OFFSET),
|
|
max(0.0, b - GRADIENT_DARKNESS_OFFSET))
|
|
context.set_source(pattern)
|
|
self._draw_shape(context, x, y, size, shape)
|
|
context.fill()
|
|
|
|
def draw_event_marker(self, context: cairo.Context, x: float, y: float,
|
|
event_type: EventType, is_hovered: bool = False,
|
|
is_selected: bool = False) -> None:
|
|
"""
|
|
Draw a marker for an event with modern styling.
|
|
|
|
Args:
|
|
context: Cairo drawing context.
|
|
x: X coordinate for marker center.
|
|
y: Y coordinate for marker center.
|
|
event_type: Type of event (determines color and shape).
|
|
is_hovered: Whether marker is currently hovered.
|
|
is_selected: Whether marker belongs to selected person.
|
|
"""
|
|
context.save()
|
|
|
|
# Get integer value from EventType object for dictionary lookup
|
|
event_type_value = self._normalize_event_type(event_type)
|
|
|
|
# Get color and shape
|
|
color = EVENT_COLORS.get(event_type_value, DEFAULT_EVENT_COLOR)
|
|
shape = EVENT_SHAPES.get(event_type_value, DEFAULT_EVENT_SHAPE)
|
|
marker_size = EVENT_MARKER_SIZE
|
|
|
|
# Increase size if hovered or selected
|
|
if is_hovered:
|
|
marker_size *= MARKER_HOVER_SIZE_MULTIPLIER
|
|
elif is_selected:
|
|
marker_size *= MARKER_SELECTED_SIZE_MULTIPLIER
|
|
|
|
# Use highlight color if selected
|
|
if is_selected:
|
|
color = SELECTED_MARKER_COLOR
|
|
|
|
# Draw shadow
|
|
self._draw_marker_shadow(context, x, y, marker_size, shape)
|
|
|
|
# Draw main shape with gradient
|
|
self._draw_marker_gradient(context, x, y, marker_size, color, shape)
|
|
|
|
# Draw border
|
|
context.set_source_rgba(*COLOR_BLACK, BORDER_OPACITY)
|
|
context.set_line_width(MARKER_BORDER_WIDTH)
|
|
self._draw_shape(context, x, y, marker_size, shape)
|
|
context.stroke()
|
|
|
|
context.restore()
|
|
|
|
def _draw_triangle(self, context: cairo.Context, x: float, y: float, size: float) -> None:
|
|
"""Draw a triangle shape."""
|
|
context.move_to(x, y - size)
|
|
context.line_to(x - size, y + size)
|
|
context.line_to(x + size, y + size)
|
|
context.close_path()
|
|
|
|
def _draw_circle(self, context: cairo.Context, x: float, y: float, size: float) -> None:
|
|
"""Draw a circle shape."""
|
|
context.arc(x, y, size, 0, 2 * math.pi)
|
|
|
|
def _draw_diamond(self, context: cairo.Context, x: float, y: float, size: float) -> None:
|
|
"""Draw a diamond shape."""
|
|
context.move_to(x, y - size)
|
|
context.line_to(x + size, y)
|
|
context.line_to(x, y + size)
|
|
context.line_to(x - size, y)
|
|
context.close_path()
|
|
|
|
def _draw_square(self, context: cairo.Context, x: float, y: float, size: float) -> None:
|
|
"""Draw a square shape."""
|
|
context.rectangle(x - size, y - size, size * 2, size * 2)
|
|
|
|
def _draw_star(self, context: cairo.Context, x: float, y: float, size: float) -> None:
|
|
"""Draw a 5-pointed star shape."""
|
|
points = 5
|
|
outer_radius = size
|
|
inner_radius = size * 0.4
|
|
for i in range(points * 2):
|
|
angle = (i * math.pi) / points - math.pi / 2
|
|
radius = outer_radius if i % 2 == 0 else inner_radius
|
|
px = x + radius * math.cos(angle)
|
|
py = y + radius * math.sin(angle)
|
|
if i == 0:
|
|
context.move_to(px, py)
|
|
else:
|
|
context.line_to(px, py)
|
|
context.close_path()
|
|
|
|
def _draw_hexagon(self, context: cairo.Context, x: float, y: float, size: float) -> None:
|
|
"""Draw a hexagon shape."""
|
|
for i in range(6):
|
|
angle = (i * math.pi) / 3
|
|
px = x + size * math.cos(angle)
|
|
py = y + size * math.sin(angle)
|
|
if i == 0:
|
|
context.move_to(px, py)
|
|
else:
|
|
context.line_to(px, py)
|
|
context.close_path()
|
|
|
|
def _draw_shape(self, context: cairo.Context, x: float, y: float,
|
|
size: float, shape: str) -> None:
|
|
"""
|
|
Draw a shape at the given position.
|
|
|
|
Args:
|
|
context: Cairo drawing context.
|
|
x: X coordinate of shape center.
|
|
y: Y coordinate of shape center.
|
|
size: Size of the shape.
|
|
shape: Shape type ('triangle', 'circle', 'diamond', 'square', 'star', 'hexagon').
|
|
"""
|
|
shape_drawers = {
|
|
'triangle': self._draw_triangle,
|
|
'circle': self._draw_circle,
|
|
'diamond': self._draw_diamond,
|
|
'square': self._draw_square,
|
|
'star': self._draw_star,
|
|
'hexagon': self._draw_hexagon,
|
|
}
|
|
|
|
drawer = shape_drawers.get(shape, shape_drawers[DEFAULT_EVENT_SHAPE])
|
|
drawer(context, x, y, size)
|
|
|
|
def draw_event_label(self, context: cairo.Context, x: float, y: float,
|
|
date_obj: Date, event: 'Event', person: Optional['Person'],
|
|
event_type: EventType, is_hovered: bool = False) -> None:
|
|
"""
|
|
Draw the label for an event with modern styling.
|
|
|
|
Args:
|
|
context: Cairo drawing context.
|
|
x: X coordinate for label start.
|
|
y: Y coordinate for label center.
|
|
date_obj: Date object for the event.
|
|
event: The event object.
|
|
person: The person associated with the event (None for family events).
|
|
event_type: The type of event.
|
|
is_hovered: Whether the label is currently hovered.
|
|
"""
|
|
context.save()
|
|
|
|
# Create Pango layout for text
|
|
layout = PangoCairo.create_layout(context)
|
|
|
|
# Use modern font
|
|
font_desc = Pango.font_description_from_string(f"{FONT_FAMILY} {FONT_SIZE_NORMAL}")
|
|
if is_hovered:
|
|
font_desc.set_weight(Pango.Weight.BOLD)
|
|
layout.set_font_description(font_desc)
|
|
|
|
# Build label text using centralized method
|
|
label_text = self._get_event_label_text(event, person, event_type)
|
|
|
|
layout.set_markup(label_text, -1)
|
|
layout.set_width(-1) # No width limit
|
|
|
|
# Draw background for hovered events
|
|
if is_hovered:
|
|
text_width, text_height = layout.get_pixel_size()
|
|
padding = LABEL_BACKGROUND_PADDING
|
|
|
|
# Draw rounded rectangle background
|
|
rect_x = x - padding
|
|
rect_y = y - text_height / 2 - padding
|
|
rect_width = text_width + padding * 2
|
|
rect_height = text_height + padding * 2
|
|
|
|
# Rounded rectangle
|
|
radius = LABEL_BACKGROUND_RADIUS
|
|
context.arc(rect_x + radius, rect_y + radius, radius, math.pi, 3 * math.pi / 2)
|
|
context.arc(rect_x + rect_width - radius, rect_y + radius, radius, 3 * math.pi / 2, 0)
|
|
context.arc(rect_x + rect_width - radius, rect_y + rect_height - radius, radius, 0, math.pi / 2)
|
|
context.arc(rect_x + radius, rect_y + rect_height - radius, radius, math.pi / 2, math.pi)
|
|
context.close_path()
|
|
|
|
# Fill with semi-transparent background
|
|
context.set_source_rgba(*LABEL_HOVER_BACKGROUND_COLOR)
|
|
context.fill()
|
|
|
|
# Draw border
|
|
context.set_source_rgba(*LABEL_HOVER_BORDER_COLOR)
|
|
context.set_line_width(MARKER_BORDER_WIDTH)
|
|
context.stroke()
|
|
|
|
# Draw text
|
|
context.set_source_rgb(*LABEL_TEXT_COLOR)
|
|
context.move_to(x, y - LABEL_VERTICAL_OFFSET)
|
|
PangoCairo.show_layout(context, layout)
|
|
|
|
context.restore()
|
|
|
|
def _find_year_range(self) -> Tuple[Optional[int], Optional[int]]:
|
|
"""
|
|
Find the minimum and maximum years from all events.
|
|
|
|
Returns:
|
|
Tuple[Optional[int], Optional[int]]: (min_year, max_year) or (None, None) if no valid years found.
|
|
"""
|
|
min_year = None
|
|
max_year = None
|
|
for event_data in self.events:
|
|
try:
|
|
year = event_data.date_obj.get_year()
|
|
if year and year != 0:
|
|
if min_year is None or year < min_year:
|
|
min_year = year
|
|
if max_year is None or year > max_year:
|
|
max_year = year
|
|
except (AttributeError, ValueError) as e:
|
|
logger.debug(f"Error extracting year from date for timeline year marker: {e}")
|
|
|
|
return (min_year, max_year)
|
|
|
|
def _calculate_year_y_position(self, year: int, min_date: int, max_date: int,
|
|
y_start: float, y_end: float) -> float:
|
|
"""
|
|
Calculate Y position for a year marker.
|
|
|
|
Args:
|
|
year: The year to calculate position for.
|
|
min_date: Minimum date sort value.
|
|
max_date: Maximum date sort value.
|
|
y_start: Y coordinate of the timeline start.
|
|
y_end: Y coordinate of the timeline end.
|
|
|
|
Returns:
|
|
float: The Y position for the year marker.
|
|
"""
|
|
year_date = Date()
|
|
year_date.set_yr_mon_day(year, 1, 1)
|
|
year_sort = year_date.get_sort_value()
|
|
|
|
if min_date == max_date:
|
|
return (y_start + y_end) / 2
|
|
else:
|
|
return y_start + (
|
|
(year_sort - min_date) / (max_date - min_date)
|
|
) * (y_end - y_start)
|
|
|
|
def _draw_year_marker(self, context: cairo.Context, timeline_x: float,
|
|
year: int, y_pos: float) -> None:
|
|
"""
|
|
Draw a single year marker (tick mark and label).
|
|
|
|
Args:
|
|
context: Cairo drawing context.
|
|
timeline_x: X coordinate of the timeline.
|
|
year: The year to draw.
|
|
y_pos: Y position for the marker.
|
|
"""
|
|
# Draw tick mark
|
|
context.set_source_rgb(*YEAR_MARKER_COLOR)
|
|
context.set_line_width(1)
|
|
context.move_to(timeline_x - 10, y_pos)
|
|
context.line_to(timeline_x, y_pos)
|
|
context.stroke()
|
|
|
|
# Draw year label
|
|
layout = PangoCairo.create_layout(context)
|
|
layout.set_font_description(
|
|
Pango.font_description_from_string(f"{FONT_FAMILY} {FONT_SIZE_SMALL}")
|
|
)
|
|
layout.set_text(str(year), -1)
|
|
|
|
# Get text size in pixels
|
|
text_width, text_height = layout.get_pixel_size()
|
|
|
|
context.set_source_rgb(*COLOR_BLACK)
|
|
context.move_to(timeline_x - YEAR_MARKER_LABEL_OFFSET - text_width, y_pos - text_height / 2)
|
|
PangoCairo.show_layout(context, layout)
|
|
|
|
def draw_year_markers(self, context: cairo.Context, timeline_x: float,
|
|
y_start: float, y_end: float, min_date: int,
|
|
max_date: int) -> None:
|
|
"""
|
|
Draw year markers on the left side of the timeline.
|
|
|
|
Args:
|
|
context: Cairo drawing context.
|
|
timeline_x: X coordinate of the timeline.
|
|
y_start: Y coordinate of the timeline start.
|
|
y_end: Y coordinate of the timeline end.
|
|
min_date: Minimum date sort value.
|
|
max_date: Maximum date sort value.
|
|
"""
|
|
context.save()
|
|
|
|
# Find min and max years from events
|
|
min_year, max_year = self._find_year_range()
|
|
|
|
if min_year is None or max_year is None:
|
|
context.restore()
|
|
return
|
|
|
|
# Draw markers for major years (every 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 selected persons.
|
|
Each person gets their own colored vertical line at a unique X position.
|
|
|
|
Args:
|
|
context: Cairo drawing context.
|
|
events_with_y_pos: List of TimelineEvent objects with Y positions.
|
|
timeline_x: X coordinate of the timeline.
|
|
timeline_y_start: Y coordinate of the timeline start.
|
|
timeline_y_end: Y coordinate of the timeline end.
|
|
"""
|
|
if not self.selected_person_handles:
|
|
return
|
|
|
|
context.save()
|
|
context.set_line_width(CONNECTION_LINE_WIDTH)
|
|
context.set_line_cap(cairo.LINE_CAP_ROUND)
|
|
context.set_line_join(cairo.LINE_JOIN_ROUND)
|
|
|
|
# Sort person handles for consistent ordering (affects X position)
|
|
sorted_person_handles = sorted(self.selected_person_handles)
|
|
|
|
# Draw connections for each selected person
|
|
for person_index, person_handle in enumerate(sorted_person_handles):
|
|
# Get color for this person
|
|
person_color = self._get_person_color(person_handle)
|
|
|
|
# Calculate X position for this person's vertical line
|
|
vertical_line_x = self._get_person_vertical_line_x(person_index)
|
|
|
|
# Find all events for this person
|
|
person_events = self._get_events_for_person(person_handle, events_with_y_pos)
|
|
|
|
if not person_events:
|
|
continue
|
|
|
|
# Sort by Y position
|
|
person_events.sort(key=lambda x: x.y_pos)
|
|
|
|
# Set color for this person's lines
|
|
context.set_source_rgba(*person_color)
|
|
|
|
# Draw vertical connector line on the left side
|
|
if len(person_events) > 1:
|
|
# Use first and last elements since list is sorted by y_pos
|
|
min_y = person_events[0].y_pos
|
|
max_y = person_events[-1].y_pos
|
|
|
|
# Draw vertical line connecting all events
|
|
if max_y - min_y > CONNECTION_LINE_MIN_DISTANCE:
|
|
context.move_to(vertical_line_x, min_y)
|
|
context.line_to(vertical_line_x, max_y)
|
|
context.stroke()
|
|
|
|
# Draw horizontal lines connecting vertical line to each event marker
|
|
for event_data in person_events:
|
|
# Draw horizontal line from vertical line to event marker
|
|
context.move_to(vertical_line_x, event_data.y_pos)
|
|
context.line_to(timeline_x, event_data.y_pos)
|
|
context.stroke()
|
|
|
|
context.restore()
|
|
|
|
def get_stock(self) -> str:
|
|
"""
|
|
Return the stock icon name.
|
|
|
|
Returns:
|
|
str: The stock icon name for this view.
|
|
"""
|
|
return "gramps-family"
|