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)
This commit is contained in:
Daniel Viegas 2025-11-29 01:39:37 +01:00
parent 891a9664d8
commit 1b6d76583c

View File

@ -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"<b>{category}</b>")
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,7 +1204,10 @@ 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()
@ -672,6 +1215,146 @@ class MyTimelineView(NavigationView):
# Calculate timeline height
self._calculate_timeline_height()
# Update filter button state
self._update_filter_button_state()
def _apply_filters(self, events: List[TimelineEvent]) -> List[TimelineEvent]:
"""
Apply all active filters to events.
Args:
events: List of TimelineEvent objects to filter.
Returns:
List[TimelineEvent]: Filtered list of events.
"""
if not self.filter_enabled:
return events
filtered = []
for event in events:
# Check event type filter
if self.active_event_types:
# Normalize event.event_type and compare with normalized active_event_types
event_type_normalized = self._normalize_event_type(event.event_type)
active_types_normalized = {self._normalize_event_type(et) for et in self.active_event_types}
if event_type_normalized not in active_types_normalized:
continue
# Check date range filter
if not self._is_date_in_range(event.date_sort):
continue
# Check person filter
person_handle = event.person.get_handle() if event.person else None
if not self._is_person_included(person_handle):
continue
# Check category filter
category = self._get_event_category(event.event_type)
if self.category_filter and category not in self.category_filter:
continue
filtered.append(event)
return filtered
def _normalize_event_type(self, event_type: EventType) -> int:
"""
Normalize EventType to integer for comparison.
Args:
event_type: The event type (may be EventType object or integer).
Returns:
int: The integer value of the event type.
"""
try:
if isinstance(event_type, int):
return event_type
elif hasattr(event_type, 'value'):
return event_type.value
else:
return int(event_type)
except (TypeError, ValueError, AttributeError):
return 0 # Default to 0 if conversion fails
def _is_event_type_enabled(self, event_type: EventType) -> bool:
"""
Check if an event type is enabled in the filter.
Args:
event_type: The event type to check.
Returns:
bool: True if event type is enabled (or no filter active), False otherwise.
"""
if not self.active_event_types:
return True
# Normalize both for comparison
normalized_type = self._normalize_event_type(event_type)
normalized_active = {self._normalize_event_type(et) for et in self.active_event_types}
return normalized_type in normalized_active
def _is_date_in_range(self, date_sort: int) -> bool:
"""
Check if a date is within the filter range.
Args:
date_sort: The date sort value to check.
Returns:
bool: True if date is in range (or no filter active), False otherwise.
"""
if not self.date_range_filter:
return True
min_date, max_date = self.date_range_filter
return min_date <= date_sort <= max_date
def _is_person_included(self, person_handle: Optional[str]) -> bool:
"""
Check if a person is included in the filter.
Args:
person_handle: The person handle to check (None for family events).
Returns:
bool: True if person is included (or no filter active), False otherwise.
"""
if self.person_filter is None:
return True
# Family events (person_handle is None) are included if person_filter is not None
# but we might want to exclude them - for now, include them
if person_handle is None:
return True
return person_handle in self.person_filter
def _get_event_category(self, event_type: EventType) -> str:
"""
Get the category for an event type.
Args:
event_type: The event type (may be EventType object or integer).
Returns:
str: The category name, or "Other Events" if not found.
"""
# Normalize EventType to integer for dictionary lookup
event_type_value = self._normalize_event_type(event_type)
return EVENT_CATEGORIES.get(event_type_value, "Other Events")
def apply_filters(self) -> None:
"""
Apply current filters and update the view.
Public method to trigger filter application.
"""
if self.all_events:
self.events = self._apply_filters(self.all_events)
self._invalidate_cache()
self._calculate_timeline_height()
if self.drawing_area:
self.drawing_area.queue_draw()
def _calculate_date_range(self) -> Tuple[int, int, int]:
"""
Calculate date range from events.