diff --git a/MyTimeline.py b/MyTimeline.py index 0daaae7..f20b59f 100644 --- a/MyTimeline.py +++ b/MyTimeline.py @@ -29,6 +29,7 @@ MyTimeline View - A vertical timeline showing all events in the database # ------------------------------------------------------------------------- import cairo import colorsys +import datetime import logging import math from dataclasses import dataclass @@ -104,10 +105,16 @@ 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 # Font Constants FONT_FAMILY = "Sans" @@ -551,7 +558,7 @@ class MyTimelineView(NavigationView): Gtk.Widget: The main container widget with toolbar and drawing area. """ # Main container - main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5) + main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=TOOLBAR_SPACING) # Toolbar with zoom controls toolbar = Gtk.Toolbar() @@ -565,8 +572,8 @@ class MyTimelineView(NavigationView): # Zoom label self.zoom_label = Gtk.Label(label="100%") - self.zoom_label.set_margin_start(10) - self.zoom_label.set_margin_end(10) + 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) @@ -796,14 +803,14 @@ class MyTimelineView(NavigationView): 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) + 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=5) + 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")) @@ -837,8 +844,8 @@ class MyTimelineView(NavigationView): box.pack_start(category_checkbox, False, False, 0) # Create container for event types in this category - category_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) - category_box.set_margin_start(20) + category_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) @@ -877,11 +884,11 @@ class MyTimelineView(NavigationView): 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) + 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())) @@ -906,11 +913,11 @@ class MyTimelineView(NavigationView): 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) + 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 = {} @@ -1022,7 +1029,6 @@ class MyTimelineView(NavigationView): calendar_box.set_homogeneous(True) # Year range for genealogical data - import datetime current_year = datetime.date.today().year min_year = MIN_GENEALOGICAL_YEAR max_year = current_year + 10 @@ -1155,10 +1161,10 @@ class MyTimelineView(NavigationView): family_name = self._get_family_display_name(family) # Create container for family members - members_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=3) - members_box.set_margin_start(20) - members_box.set_margin_top(5) - members_box.set_margin_bottom(5) + 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 = [] @@ -1224,7 +1230,7 @@ class MyTimelineView(NavigationView): container.show_all() except (AttributeError, KeyError) as e: - logger.warning(f"Error updating person filter: {e}", exc_info=True) + logger.warning(f"Error updating person filter widgets in filter dialog: {e}", exc_info=True) def _update_date_range_widgets(self) -> None: """ @@ -1256,7 +1262,6 @@ class MyTimelineView(NavigationView): self._update_year_spin_button('date_to_year_spin', to_year, self._on_to_year_changed) else: # Reset to current date - import datetime now = datetime.date.today() from_calendar.select_month(now.month - 1, now.year) to_calendar.select_month(now.month - 1, now.year) @@ -1342,49 +1347,42 @@ class MyTimelineView(NavigationView): # For now, we'll always use calendar dates if they're valid # The "Clear Dates" button will reset the explicit flag - # Get selected dates from calendars - # Gtk.Calendar.get_date() returns (year, month, day) where month is 0-11 - from_year, from_month, from_day = from_calendar.get_date() - to_year, to_month, to_day = to_calendar.get_date() + # Get Date objects from calendar selections + from_date = self._create_date_from_calendar(from_calendar) + to_date = self._create_date_from_calendar(to_calendar) - try: - # Create Date objects from calendar selections - # Note: month from calendar is 0-11, Date.set_yr_mon_day expects 1-12 - from_date = Date() - from_date.set_yr_mon_day(from_year, from_month + 1, from_day) - min_sort = from_date.get_sort_value() - - to_date = Date() - to_date.set_yr_mon_day(to_year, to_month + 1, to_day) - max_sort = to_date.get_sort_value() - - # Validate date range - if min_sort > max_sort: - # Show error message - if hasattr(self, 'date_validation_label'): - self.date_validation_label.set_markup( - f"{_('Error: From date must be before To date')}" - ) - self.date_range_filter = None - self.date_range_explicit = False - return - else: - # Clear error message - if hasattr(self, 'date_validation_label'): - self.date_validation_label.set_text("") - - # Set filter - user has selected dates in calendars - self.date_range_filter = (min_sort, max_sort) - self.date_range_explicit = True - - except (ValueError, AttributeError, TypeError) as e: - logger.warning(f"Error parsing date range: {e}", exc_info=True) - self.date_range_filter = None - self.date_range_explicit = False + if from_date is None or to_date is None: + # Show error message for invalid dates if hasattr(self, 'date_validation_label'): self.date_validation_label.set_markup( - f"{_('Error: Invalid date')}" + f"{_('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 + if hasattr(self, 'date_validation_label'): + self.date_validation_label.set_markup( + f"{_('Error: From date must be before To date')}" + ) + self.date_range_filter = None + self.date_range_explicit = False + return + else: + # Clear error message + if hasattr(self, 'date_validation_label'): + self.date_validation_label.set_text("") + + # Set filter - user has selected dates in calendars + self.date_range_filter = (min_sort, max_sort) + self.date_range_explicit = True else: # No calendar widgets, clear filter self.date_range_filter = None @@ -1435,6 +1433,56 @@ class MyTimelineView(NavigationView): 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. @@ -1442,14 +1490,7 @@ class MyTimelineView(NavigationView): Args: calendar: The calendar widget that was selected. """ - # Update year spin button to match calendar - if 'date_from_year_spin' in self._filter_widgets: - year, month, day = calendar.get_date() - year_spin = self._filter_widgets['date_from_year_spin'] - year_spin.handler_block_by_func(self._on_from_year_changed) - year_spin.set_value(year) - year_spin.handler_unblock_by_func(self._on_from_year_changed) - + self._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: @@ -1459,14 +1500,7 @@ class MyTimelineView(NavigationView): Args: calendar: The calendar widget that was selected. """ - # Update year spin button to match calendar - if 'date_to_year_spin' in self._filter_widgets: - year, month, day = calendar.get_date() - year_spin = self._filter_widgets['date_to_year_spin'] - year_spin.handler_block_by_func(self._on_to_year_changed) - year_spin.set_value(year) - year_spin.handler_unblock_by_func(self._on_to_year_changed) - + self._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: @@ -1476,13 +1510,7 @@ class MyTimelineView(NavigationView): Args: calendar: The calendar widget that changed. """ - # Update year spin button to match calendar - if 'date_from_year_spin' in self._filter_widgets: - year, month, day = calendar.get_date() - year_spin = self._filter_widgets['date_from_year_spin'] - year_spin.handler_block_by_func(self._on_from_year_changed) - year_spin.set_value(year) - year_spin.handler_unblock_by_func(self._on_from_year_changed) + 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: """ @@ -1491,13 +1519,7 @@ class MyTimelineView(NavigationView): Args: calendar: The calendar widget that changed. """ - # Update year spin button to match calendar - if 'date_to_year_spin' in self._filter_widgets: - year, month, day = calendar.get_date() - year_spin = self._filter_widgets['date_to_year_spin'] - year_spin.handler_block_by_func(self._on_to_year_changed) - year_spin.set_value(year) - year_spin.handler_unblock_by_func(self._on_to_year_changed) + 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: """ @@ -1506,14 +1528,9 @@ class MyTimelineView(NavigationView): Args: spin_button: The year spin button that changed. """ - if 'date_from_calendar' in self._filter_widgets: - calendar = self._filter_widgets['date_from_calendar'] - new_year = int(spin_button.get_value()) - current_year, current_month, current_day = calendar.get_date() - # Update calendar to new year, keeping same month and day - calendar.select_month(current_month, new_year) - # Trigger validation - self._validate_date_range() + 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: """ @@ -1522,14 +1539,9 @@ class MyTimelineView(NavigationView): Args: spin_button: The year spin button that changed. """ - if 'date_to_calendar' in self._filter_widgets: - calendar = self._filter_widgets['date_to_calendar'] - new_year = int(spin_button.get_value()) - current_year, current_month, current_day = calendar.get_date() - # Update calendar to new year, keeping same month and day - calendar.select_month(current_month, new_year) - # Trigger validation - self._validate_date_range() + 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: """ @@ -1544,7 +1556,6 @@ class MyTimelineView(NavigationView): # Reset to current date (calendars always show a date) # Get current date and set calendars to it - import datetime now = datetime.date.today() from_calendar.select_month(now.month - 1, now.year) # month is 0-11 from_calendar.select_day(now.day) @@ -1682,6 +1693,8 @@ class MyTimelineView(NavigationView): 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.""" @@ -1798,7 +1811,7 @@ class MyTimelineView(NavigationView): except (AttributeError, KeyError): continue except (AttributeError, KeyError) as e: - logger.warning(f"Error building event-to-person index: {e}", exc_info=True) + logger.warning(f"Error building event-to-person index from database: {e}", exc_info=True) def _find_person_for_event(self, event: 'Event') -> Optional['Person']: """ @@ -1893,7 +1906,7 @@ class MyTimelineView(NavigationView): self.all_events.append(timeline_event) except (AttributeError, KeyError) as e: - logger.warning(f"Error collecting events: {e}", exc_info=True) + logger.warning(f"Error collecting events from database: {e}", exc_info=True) return # Sort events by date @@ -2395,6 +2408,19 @@ class MyTimelineView(NavigationView): 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() + if self.drawing_area: + self.drawing_area.queue_draw() + def on_zoom_in(self, widget: Gtk.Widget) -> None: """ Zoom in. @@ -2403,11 +2429,8 @@ class MyTimelineView(NavigationView): widget: The widget that triggered the zoom (unused). """ if self.zoom_level < self.max_zoom: - self.zoom_level = min(self.zoom_level + self.zoom_step, self.max_zoom) - self.update_zoom_display() - self._recalculate_timeline_height() # Only recalculate height, not events - if self.drawing_area: - self.drawing_area.queue_draw() + 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: """ @@ -2417,11 +2440,8 @@ class MyTimelineView(NavigationView): widget: The widget that triggered the zoom (unused). """ if self.zoom_level > self.min_zoom: - self.zoom_level = max(self.zoom_level - self.zoom_step, self.min_zoom) - self.update_zoom_display() - self._recalculate_timeline_height() # Only recalculate height, not events - if self.drawing_area: - self.drawing_area.queue_draw() + 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: """ @@ -2430,11 +2450,7 @@ class MyTimelineView(NavigationView): Args: widget: The widget that triggered the reset (unused). """ - self.zoom_level = 1.0 - self.update_zoom_display() - self._recalculate_timeline_height() # Only recalculate height, not events - if self.drawing_area: - self.drawing_area.queue_draw() + self._update_zoom(1.0) def on_scroll(self, widget: Gtk.Widget, event: Gdk.Event) -> bool: """ @@ -2695,7 +2711,7 @@ class MyTimelineView(NavigationView): if place: return place.get_title() except (AttributeError, KeyError) as e: - logger.debug(f"Error accessing place for event: {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: @@ -2711,7 +2727,7 @@ class MyTimelineView(NavigationView): """ person_name = name_displayer.display(person) tooltip_text = f"{person_name}\n" - tooltip_text += "─" * 30 + "\n" + tooltip_text += "─" * TOOLTIP_SEPARATOR_LENGTH + "\n" # Sort by date person_events_sorted = sorted(person_events, key=lambda x: x.date_sort) @@ -3237,7 +3253,7 @@ class MyTimelineView(NavigationView): if max_year is None or year > max_year: max_year = year except (AttributeError, ValueError) as e: - logger.debug(f"Error accessing year from date: {e}") + logger.debug(f"Error extracting year from date for timeline year marker: {e}") return (min_year, max_year)