From 1b6d76583c3493eead99aa3fb86fcd3fef5d9ba2 Mon Sep 17 00:00:00 2001 From: Daniel Viegas Date: Sat, 29 Nov 2025 01:39:37 +0100 Subject: [PATCH] Add comprehensive event filtering to MyTimeline view - Add filter dialog with tabs for event types, categories, persons, and date range - Implement EVENT_CATEGORIES mapping for event categorization - Add filter state management (event types, date range, person, category filters) - Fix EventType normalization for dictionary lookups and comparisons - Add _normalize_event_type() helper method for consistent EventType handling - Update event collection to store all events and apply filters - Add filter button to toolbar with active state indication - Support multiple filter types that can be combined - Fix event collection bug (events now stored in all_events before filtering) --- MyTimeline.py | 691 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 687 insertions(+), 4 deletions(-) diff --git a/MyTimeline.py b/MyTimeline.py index c1371bc..4314bdc 100644 --- a/MyTimeline.py +++ b/MyTimeline.py @@ -31,7 +31,7 @@ import cairo import logging import math from dataclasses import dataclass -from typing import Optional, List, Tuple, Any +from typing import Optional, List, Tuple, Any, Set, Dict from gi.repository import Gtk from gi.repository import Gdk @@ -248,6 +248,74 @@ EVENT_SHAPES = { 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", +} + # ------------------------------------------------------------------------- # @@ -321,6 +389,19 @@ class MyTimelineView(NavigationView): self._cached_min_date: Optional[int] = None self._cached_max_date: Optional[int] = None + # Filter state + self.filter_enabled: bool = False + self.active_event_types: Set[EventType] = set() # Empty = all enabled + self.date_range_filter: Optional[Tuple[int, int]] = None # (min_date, max_date) in sort values + self.person_filter: Optional[Set[str]] = None # Set of person handles to include, None = all + self.category_filter: Optional[Set[str]] = None # Set of event categories to include, None = all + self.all_events: List[TimelineEvent] = [] # Store all events before filtering + + # Filter UI components + self.filter_button = None + self.filter_dialog = None + self._filter_widgets = {} + # Initialize temporary surface for text measurement (used in find_event_at_position) self._temp_surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, 1, 1) @@ -412,6 +493,7 @@ class MyTimelineView(NavigationView): db: The new database object. """ self.active_family_handle = None + self.all_events = [] self.events = [] # Connect to new database signals @@ -465,6 +547,12 @@ class MyTimelineView(NavigationView): 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 @@ -495,6 +583,457 @@ class MyTimelineView(NavigationView): return main_box + def on_filter_button_clicked(self, button: Gtk.ToolButton) -> None: + """ + Handle filter button click - show filter dialog. + + Args: + button: The filter button that was clicked. + """ + if self.filter_dialog is None: + self.filter_dialog = self._build_filter_dialog() + + # Update filter dialog state + self._update_filter_dialog_state() + + # Show dialog + response = self.filter_dialog.run() + if response == Gtk.ResponseType.APPLY: + self._apply_filter_dialog_settings() + elif response == Gtk.ResponseType.CLOSE: + pass # Just close + + self.filter_dialog.hide() + + def _build_filter_dialog(self) -> Gtk.Dialog: + """ + Build the filter dialog with all filter controls. + + Returns: + Gtk.Dialog: The filter dialog widget. + """ + dialog = Gtk.Dialog(title=_("Filter Events"), parent=self.uistate.window) + dialog.set_default_size(600, 700) + dialog.add_button(_("Clear All"), Gtk.ResponseType.REJECT) + dialog.add_button(_("Close"), Gtk.ResponseType.CLOSE) + dialog.add_button(_("Apply"), Gtk.ResponseType.APPLY) + + # Connect to clear button + dialog.connect("response", self._on_filter_dialog_response) + + # Main content area + content_area = dialog.get_content_area() + content_area.set_spacing(10) + content_area.set_margin_start(10) + content_area.set_margin_end(10) + content_area.set_margin_top(10) + content_area.set_margin_bottom(10) + + # Notebook for organizing filters + notebook = Gtk.Notebook() + content_area.pack_start(notebook, True, True, 0) + + # Store widget references for later access + self._filter_widgets = {} + + # Event Type Filter Page + event_type_page = self._build_event_type_filter_page() + notebook.append_page(event_type_page, Gtk.Label(label=_("Event Types"))) + + # Category Filter Page + category_page = self._build_category_filter_page() + notebook.append_page(category_page, Gtk.Label(label=_("Categories"))) + + # Person Filter Page + person_page = self._build_person_filter_page() + notebook.append_page(person_page, Gtk.Label(label=_("Persons"))) + + # Date Range Filter Page + date_page = self._build_date_range_filter_page() + notebook.append_page(date_page, Gtk.Label(label=_("Date Range"))) + + content_area.show_all() + return dialog + + def _build_event_type_filter_page(self) -> Gtk.Widget: + """ + Build the event type filter page with checkboxes for each event type. + + Returns: + Gtk.Widget: The event type filter page. + """ + scrolled = Gtk.ScrolledWindow() + scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5) + box.set_margin_start(10) + box.set_margin_end(10) + box.set_margin_top(10) + box.set_margin_bottom(10) + + # Select All / Deselect All buttons + button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) + select_all_btn = Gtk.Button(label=_("Select All")) + select_all_btn.connect("clicked", self._on_select_all_event_types) + deselect_all_btn = Gtk.Button(label=_("Deselect All")) + deselect_all_btn.connect("clicked", self._on_deselect_all_event_types) + button_box.pack_start(select_all_btn, False, False, 0) + button_box.pack_start(deselect_all_btn, False, False, 0) + box.pack_start(button_box, False, False, 0) + + # Group event types by category + event_type_checkboxes = {} + category_boxes = {} + + # Iterate over event types from EVENT_COLORS (which uses EventType integers as keys) + # EventType members are already integers in Gramps + for event_type_obj in EVENT_COLORS.keys(): + # EventType is already an integer, use it directly + if event_type_obj not in EVENT_CATEGORIES: + continue + + category = EVENT_CATEGORIES[event_type_obj] + if category not in category_boxes: + # Create category section + category_label = Gtk.Label(label=f"{category}") + category_label.set_use_markup(True) + category_label.set_halign(Gtk.Align.START) + box.pack_start(category_label, False, False, 0) + + category_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + category_box.set_margin_start(20) + category_boxes[category] = category_box + box.pack_start(category_box, False, False, 0) + + # Create checkbox for event type + checkbox = Gtk.CheckButton(label=str(event_type_obj)) + event_type_checkboxes[event_type_obj] = checkbox + category_boxes[category].pack_start(checkbox, False, False, 0) + + self._filter_widgets['event_type_checkboxes'] = event_type_checkboxes + + scrolled.add(box) + return scrolled + + def _build_category_filter_page(self) -> Gtk.Widget: + """ + Build the category filter page with checkboxes for each category. + + Returns: + Gtk.Widget: The category filter page. + """ + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5) + box.set_margin_start(10) + box.set_margin_end(10) + box.set_margin_top(10) + box.set_margin_bottom(10) + + # Get unique categories + categories = sorted(set(EVENT_CATEGORIES.values())) + + category_checkboxes = {} + for category in categories: + checkbox = Gtk.CheckButton(label=category) + category_checkboxes[category] = checkbox + box.pack_start(checkbox, False, False, 0) + + self._filter_widgets['category_checkboxes'] = category_checkboxes + + return box + + def _build_person_filter_page(self) -> Gtk.Widget: + """ + Build the person filter page with checkboxes for each family member. + + Returns: + Gtk.Widget: The person filter page. + """ + scrolled = Gtk.ScrolledWindow() + scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5) + box.set_margin_start(10) + box.set_margin_end(10) + box.set_margin_top(10) + box.set_margin_bottom(10) + + # Person checkboxes container - will be populated when dialog is shown + person_checkboxes = {} + self._filter_widgets['person_checkboxes'] = person_checkboxes + self._filter_widgets['person_container'] = box + + info_label = Gtk.Label(label=_("Select family members to include in the timeline.")) + info_label.set_line_wrap(True) + box.pack_start(info_label, False, False, 0) + + scrolled.add(box) + return scrolled + + def _build_date_range_filter_page(self) -> Gtk.Widget: + """ + Build the date range filter page with date entry fields. + + Returns: + Gtk.Widget: The date range filter page. + """ + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + box.set_margin_start(10) + box.set_margin_end(10) + box.set_margin_top(10) + box.set_margin_bottom(10) + + info_label = Gtk.Label(label=_("Enter date range to filter events. Leave empty to show all dates.")) + info_label.set_line_wrap(True) + box.pack_start(info_label, False, False, 0) + + # From date + from_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) + from_label = Gtk.Label(label=_("From:")) + from_label.set_size_request(80, -1) + from_entry = Gtk.Entry() + from_entry.set_placeholder_text(_("YYYY-MM-DD or YYYY")) + from_box.pack_start(from_label, False, False, 0) + from_box.pack_start(from_entry, True, True, 0) + box.pack_start(from_box, False, False, 0) + + # To date + to_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) + to_label = Gtk.Label(label=_("To:")) + to_label.set_size_request(80, -1) + to_entry = Gtk.Entry() + to_entry.set_placeholder_text(_("YYYY-MM-DD or YYYY")) + to_box.pack_start(to_label, False, False, 0) + to_box.pack_start(to_entry, True, True, 0) + box.pack_start(to_box, False, False, 0) + + self._filter_widgets['date_from_entry'] = from_entry + self._filter_widgets['date_to_entry'] = to_entry + + return box + + def _update_filter_dialog_state(self) -> None: + """ + Update the filter dialog widgets to reflect current filter state. + """ + if not hasattr(self, '_filter_widgets'): + return + + # Update event type checkboxes + if 'event_type_checkboxes' in self._filter_widgets: + for event_type, checkbox in self._filter_widgets['event_type_checkboxes'].items(): + if not self.active_event_types: + checkbox.set_active(True) # All selected when filter is off + else: + checkbox.set_active(event_type in self.active_event_types) + + # Update category checkboxes + if 'category_checkboxes' in self._filter_widgets: + for category, checkbox in self._filter_widgets['category_checkboxes'].items(): + if not self.category_filter: + checkbox.set_active(True) # All selected when filter is off + else: + checkbox.set_active(category in self.category_filter) + + # Update person checkboxes + if 'person_checkboxes' in self._filter_widgets and 'person_container' in self._filter_widgets: + # Clear existing person checkboxes + container = self._filter_widgets['person_container'] + for checkbox in list(self._filter_widgets['person_checkboxes'].values()): + container.remove(checkbox) + checkbox.destroy() + self._filter_widgets['person_checkboxes'].clear() + + # Add current family members + if self.active_family_handle: + try: + family = self.dbstate.db.get_family_from_handle(self.active_family_handle) + if family: + # Father + father_handle = family.get_father_handle() + if father_handle: + father = self.dbstate.db.get_person_from_handle(father_handle) + if father: + checkbox = Gtk.CheckButton(label=name_displayer.display(father)) + checkbox.set_active(True if not self.person_filter else father_handle in self.person_filter) + self._filter_widgets['person_checkboxes'][father_handle] = checkbox + container.pack_start(checkbox, False, False, 0) + + # Mother + mother_handle = family.get_mother_handle() + if mother_handle: + mother = self.dbstate.db.get_person_from_handle(mother_handle) + if mother: + checkbox = Gtk.CheckButton(label=name_displayer.display(mother)) + checkbox.set_active(True if not self.person_filter else mother_handle in self.person_filter) + self._filter_widgets['person_checkboxes'][mother_handle] = checkbox + container.pack_start(checkbox, False, False, 0) + + # Children + for child_ref in family.get_child_ref_list(): + child = self.dbstate.db.get_person_from_handle(child_ref.ref) + if child: + checkbox = Gtk.CheckButton(label=name_displayer.display(child)) + checkbox.set_active(True if not self.person_filter else child_ref.ref in self.person_filter) + self._filter_widgets['person_checkboxes'][child_ref.ref] = checkbox + container.pack_start(checkbox, False, False, 0) + + container.show_all() + except (AttributeError, KeyError) as e: + logger.warning(f"Error updating person filter: {e}", exc_info=True) + + # Update date range entries + if 'date_from_entry' in self._filter_widgets and 'date_to_entry' in self._filter_widgets: + from_entry = self._filter_widgets['date_from_entry'] + to_entry = self._filter_widgets['date_to_entry'] + if self.date_range_filter: + min_date, max_date = self.date_range_filter + # Convert sort values back to dates for display (simplified) + from_entry.set_text("") + to_entry.set_text("") + else: + from_entry.set_text("") + to_entry.set_text("") + + def _on_filter_dialog_response(self, dialog: Gtk.Dialog, response_id: int) -> None: + """ + Handle filter dialog response. + + Args: + dialog: The filter dialog. + response_id: The response ID (APPLY, CLOSE, REJECT). + """ + if response_id == Gtk.ResponseType.REJECT: + # Clear all filters + self.filter_enabled = False + self.active_event_types = set() + self.date_range_filter = None + self.person_filter = None + self.category_filter = None + self.apply_filters() + self._update_filter_button_state() + + def _apply_filter_dialog_settings(self) -> None: + """ + Apply filter settings from the dialog to the filter state. + """ + # Update event type filter + if 'event_type_checkboxes' in self._filter_widgets: + active_types = set() + for event_type, checkbox in self._filter_widgets['event_type_checkboxes'].items(): + if checkbox.get_active(): + active_types.add(event_type) + self.active_event_types = active_types if len(active_types) < len(self._filter_widgets['event_type_checkboxes']) else set() + + # Update category filter + if 'category_checkboxes' in self._filter_widgets: + active_categories = set() + for category, checkbox in self._filter_widgets['category_checkboxes'].items(): + if checkbox.get_active(): + active_categories.add(category) + all_categories = set(self._filter_widgets['category_checkboxes'].keys()) + self.category_filter = active_categories if active_categories != all_categories else None + + # Update person filter + if 'person_checkboxes' in self._filter_widgets: + active_persons = set() + for person_handle, checkbox in self._filter_widgets['person_checkboxes'].items(): + if checkbox.get_active(): + active_persons.add(person_handle) + all_persons = set(self._filter_widgets['person_checkboxes'].keys()) + self.person_filter = active_persons if active_persons != all_persons else None + + # Update date range filter + if 'date_from_entry' in self._filter_widgets and 'date_to_entry' in self._filter_widgets: + from_text = self._filter_widgets['date_from_entry'].get_text().strip() + to_text = self._filter_widgets['date_to_entry'].get_text().strip() + + if from_text or to_text: + # Parse dates using Gramps Date objects + try: + min_sort = None + max_sort = None + + if from_text: + # Try to parse the date string + # Support formats: YYYY, YYYY-MM, YYYY-MM-DD + parts = from_text.split('-') + year = int(parts[0]) + month = int(parts[1]) if len(parts) > 1 else 1 + day = int(parts[2]) if len(parts) > 2 else 1 + + from_date = Date() + from_date.set_yr_mon_day(year, month, day) + min_sort = from_date.get_sort_value() + + if to_text: + # Try to parse the date string + parts = to_text.split('-') + year = int(parts[0]) + month = int(parts[1]) if len(parts) > 1 else 12 + day = int(parts[2]) if len(parts) > 2 else 31 + + to_date = Date() + to_date.set_yr_mon_day(year, month, day) + max_sort = to_date.get_sort_value() + + # If only one date is provided, set reasonable defaults + if min_sort is None: + min_sort = 0 + if max_sort is None: + max_sort = 99999999 + + self.date_range_filter = (min_sort, max_sort) if (from_text or to_text) else None + except (ValueError, AttributeError, TypeError) as e: + logger.warning(f"Error parsing date range: {e}", exc_info=True) + self.date_range_filter = None + else: + self.date_range_filter = None + + # Enable filter if any filter is active + self.filter_enabled = ( + self.active_event_types or + self.date_range_filter is not None or + self.person_filter is not None or + self.category_filter is not None + ) + + # Apply filters + self.apply_filters() + self._update_filter_button_state() + + def _update_filter_button_state(self) -> None: + """ + Update the filter button visual state to indicate if filters are active. + """ + if self.filter_button: + if self.filter_enabled: + # Highlight button when filters are active + self.filter_button.set_tooltip_text(_("Filter Events (Active)")) + else: + self.filter_button.set_tooltip_text(_("Filter Events")) + + def _on_select_all_event_types(self, button: Gtk.Button) -> None: + """ + Select all event type checkboxes. + + Args: + button: The button that was clicked. + """ + if 'event_type_checkboxes' in self._filter_widgets: + for checkbox in self._filter_widgets['event_type_checkboxes'].values(): + checkbox.set_active(True) + + def _on_deselect_all_event_types(self, button: Gtk.Button) -> None: + """ + Deselect all event type checkboxes. + + Args: + button: The button that was clicked. + """ + if 'event_type_checkboxes' in self._filter_widgets: + for checkbox in self._filter_widgets['event_type_checkboxes'].values(): + checkbox.set_active(False) + def build_tree(self) -> None: """ Rebuilds the current display. Called when the view becomes visible. @@ -579,7 +1118,7 @@ class MyTimelineView(NavigationView): for event_ref in event_refs: timeline_event = self._process_event_ref(event_ref, person_obj) if timeline_event: - self.events.append(timeline_event) + self.all_events.append(timeline_event) def _collect_family_member_events(self, handle: Optional[str], role: str) -> None: """ @@ -609,7 +1148,7 @@ class MyTimelineView(NavigationView): for event_ref in family.get_event_ref_list(): timeline_event = self._process_event_ref(event_ref, None) if timeline_event: - self.events.append(timeline_event) + self.all_events.append(timeline_event) def _invalidate_cache(self) -> None: """Invalidate all caches when events change.""" @@ -636,6 +1175,7 @@ class MyTimelineView(NavigationView): def collect_events(self) -> None: """Collect all events for the active family.""" + self.all_events = [] self.events = [] if not self.active_family_handle: @@ -664,13 +1204,156 @@ class MyTimelineView(NavigationView): self._collect_family_member_events(child_ref.ref, "child") # Sort events by date - self.events.sort(key=lambda x: x.date_sort) + self.all_events.sort(key=lambda x: x.date_sort) + + # Apply filters + self.events = self._apply_filters(self.all_events) # Invalidate cache when events change self._invalidate_cache() # Calculate timeline height self._calculate_timeline_height() + + # Update filter button state + self._update_filter_button_state() + + def _apply_filters(self, events: List[TimelineEvent]) -> List[TimelineEvent]: + """ + Apply all active filters to events. + + Args: + events: List of TimelineEvent objects to filter. + + Returns: + List[TimelineEvent]: Filtered list of events. + """ + if not self.filter_enabled: + return events + + filtered = [] + for event in events: + # Check event type filter + if self.active_event_types: + # Normalize event.event_type and compare with normalized active_event_types + event_type_normalized = self._normalize_event_type(event.event_type) + active_types_normalized = {self._normalize_event_type(et) for et in self.active_event_types} + if event_type_normalized not in active_types_normalized: + continue + + # Check date range filter + if not self._is_date_in_range(event.date_sort): + continue + + # Check person filter + person_handle = event.person.get_handle() if event.person else None + if not self._is_person_included(person_handle): + continue + + # Check category filter + category = self._get_event_category(event.event_type) + if self.category_filter and category not in self.category_filter: + continue + + filtered.append(event) + + return filtered + + def _normalize_event_type(self, event_type: EventType) -> int: + """ + Normalize EventType to integer for comparison. + + Args: + event_type: The event type (may be EventType object or integer). + + Returns: + int: The integer value of the event type. + """ + try: + if isinstance(event_type, int): + return event_type + elif hasattr(event_type, 'value'): + return event_type.value + else: + return int(event_type) + except (TypeError, ValueError, AttributeError): + return 0 # Default to 0 if conversion fails + + def _is_event_type_enabled(self, event_type: EventType) -> bool: + """ + Check if an event type is enabled in the filter. + + Args: + event_type: The event type to check. + + Returns: + bool: True if event type is enabled (or no filter active), False otherwise. + """ + if not self.active_event_types: + return True + # Normalize both for comparison + normalized_type = self._normalize_event_type(event_type) + normalized_active = {self._normalize_event_type(et) for et in self.active_event_types} + return normalized_type in normalized_active + + def _is_date_in_range(self, date_sort: int) -> bool: + """ + Check if a date is within the filter range. + + Args: + date_sort: The date sort value to check. + + Returns: + bool: True if date is in range (or no filter active), False otherwise. + """ + if not self.date_range_filter: + return True + min_date, max_date = self.date_range_filter + return min_date <= date_sort <= max_date + + def _is_person_included(self, person_handle: Optional[str]) -> bool: + """ + Check if a person is included in the filter. + + Args: + person_handle: The person handle to check (None for family events). + + Returns: + bool: True if person is included (or no filter active), False otherwise. + """ + if self.person_filter is None: + return True + # Family events (person_handle is None) are included if person_filter is not None + # but we might want to exclude them - for now, include them + if person_handle is None: + return True + return person_handle in self.person_filter + + def _get_event_category(self, event_type: EventType) -> str: + """ + Get the category for an event type. + + Args: + event_type: The event type (may be EventType object or integer). + + Returns: + str: The category name, or "Other Events" if not found. + """ + # Normalize EventType to integer for dictionary lookup + event_type_value = self._normalize_event_type(event_type) + return EVENT_CATEGORIES.get(event_type_value, "Other Events") + + def apply_filters(self) -> None: + """ + Apply current filters and update the view. + Public method to trigger filter application. + """ + if self.all_events: + self.events = self._apply_filters(self.all_events) + self._invalidate_cache() + self._calculate_timeline_height() + if self.drawing_area: + self.drawing_area.queue_draw() def _calculate_date_range(self) -> Tuple[int, int, int]: """