diff --git a/MyTimeline.py b/MyTimeline.py index 8ef31e4..39ad5db 100644 --- a/MyTimeline.py +++ b/MyTimeline.py @@ -393,6 +393,7 @@ class MyTimelineView(NavigationView): 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 @@ -772,45 +773,138 @@ class MyTimelineView(NavigationView): def _build_date_range_filter_page(self) -> Gtk.Widget: """ - Build the date range filter page with date entry fields. + Build the date range filter page with date chooser widgets. Returns: Gtk.Widget: The date range filter page. """ + scrolled = Gtk.ScrolledWindow() + scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) box.set_margin_start(10) box.set_margin_end(10) box.set_margin_top(10) box.set_margin_bottom(10) - info_label = Gtk.Label(label=_("Enter date range to filter events. Leave empty to show all dates.")) + 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) - # 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) + # Calendar container + calendar_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=15) + calendar_box.set_homogeneous(True) - # 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) + # Year range for genealogical data (1000 to current year + 10) + import datetime + current_year = datetime.date.today().year + min_year = 1000 + max_year = current_year + 10 - self._filter_widgets['date_from_entry'] = from_entry - self._filter_widgets['date_to_entry'] = to_entry + # From date calendar with year selector + from_frame = Gtk.Frame(label=_("From Date")) + from_frame.set_label_align(0.5, 0.5) + from_calendar_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5) + from_calendar_container.set_margin_start(5) + from_calendar_container.set_margin_end(5) + from_calendar_container.set_margin_top(5) + from_calendar_container.set_margin_bottom(5) - return box + # Year selector for From date + from_year_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) + from_year_label = Gtk.Label(label=_("Year:")) + from_year_adjustment = Gtk.Adjustment( + value=current_year, + lower=min_year, + upper=max_year, + step_increment=1, + page_increment=10 + ) + from_year_spin = Gtk.SpinButton() + from_year_spin.set_adjustment(from_year_adjustment) + from_year_spin.set_numeric(True) + from_year_spin.set_update_policy(Gtk.SpinButtonUpdatePolicy.IF_VALID) + from_year_spin.set_width_chars(6) + from_year_spin.connect("value-changed", self._on_from_year_changed) + from_year_box.pack_start(from_year_label, False, False, 0) + from_year_box.pack_start(from_year_spin, False, False, 0) + from_calendar_container.pack_start(from_year_box, False, False, 0) + + from_calendar = Gtk.Calendar() + from_calendar.set_display_options( + Gtk.CalendarDisplayOptions.SHOW_HEADING | + Gtk.CalendarDisplayOptions.SHOW_DAY_NAMES | + Gtk.CalendarDisplayOptions.SHOW_WEEK_NUMBERS + ) + from_calendar.connect("day-selected", self._on_from_date_selected) + from_calendar.connect("month-changed", self._on_from_calendar_changed) + from_calendar_container.pack_start(from_calendar, True, True, 0) + from_frame.add(from_calendar_container) + calendar_box.pack_start(from_frame, True, True, 0) + + # To date calendar with year selector + to_frame = Gtk.Frame(label=_("To Date")) + to_frame.set_label_align(0.5, 0.5) + to_calendar_container = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5) + to_calendar_container.set_margin_start(5) + to_calendar_container.set_margin_end(5) + to_calendar_container.set_margin_top(5) + to_calendar_container.set_margin_bottom(5) + + # Year selector for To date + to_year_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) + to_year_label = Gtk.Label(label=_("Year:")) + to_year_adjustment = Gtk.Adjustment( + value=current_year, + lower=min_year, + upper=max_year, + step_increment=1, + page_increment=10 + ) + to_year_spin = Gtk.SpinButton() + to_year_spin.set_adjustment(to_year_adjustment) + to_year_spin.set_numeric(True) + to_year_spin.set_update_policy(Gtk.SpinButtonUpdatePolicy.IF_VALID) + to_year_spin.set_width_chars(6) + to_year_spin.connect("value-changed", self._on_to_year_changed) + to_year_box.pack_start(to_year_label, False, False, 0) + to_year_box.pack_start(to_year_spin, False, False, 0) + to_calendar_container.pack_start(to_year_box, False, False, 0) + + to_calendar = Gtk.Calendar() + to_calendar.set_display_options( + Gtk.CalendarDisplayOptions.SHOW_HEADING | + Gtk.CalendarDisplayOptions.SHOW_DAY_NAMES | + Gtk.CalendarDisplayOptions.SHOW_WEEK_NUMBERS + ) + to_calendar.connect("day-selected", self._on_to_date_selected) + to_calendar.connect("month-changed", self._on_to_calendar_changed) + to_calendar_container.pack_start(to_calendar, True, True, 0) + to_frame.add(to_calendar_container) + calendar_box.pack_start(to_frame, True, True, 0) + + box.pack_start(calendar_box, True, True, 0) + + # Clear button + clear_button = Gtk.Button(label=_("Clear Dates")) + clear_button.connect("clicked", self._on_clear_date_range) + button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + button_box.pack_start(clear_button, False, False, 0) + box.pack_start(button_box, False, False, 0) + + # Validation label + self.date_validation_label = Gtk.Label() + self.date_validation_label.set_line_wrap(True) + self.date_validation_label.set_markup("") + box.pack_start(self.date_validation_label, False, False, 0) + + self._filter_widgets['date_from_calendar'] = from_calendar + self._filter_widgets['date_to_calendar'] = to_calendar + self._filter_widgets['date_from_year_spin'] = from_year_spin + self._filter_widgets['date_to_year_spin'] = to_year_spin + + scrolled.add(box) + return scrolled def _update_filter_dialog_state(self) -> None: """ @@ -882,18 +976,61 @@ class MyTimelineView(NavigationView): 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("") + # Update date range calendars and year selectors + if 'date_from_calendar' in self._filter_widgets and 'date_to_calendar' in self._filter_widgets: + from_calendar = self._filter_widgets['date_from_calendar'] + to_calendar = self._filter_widgets['date_to_calendar'] + + if self.date_range_filter and self.date_range_explicit: + min_sort, max_sort = self.date_range_filter + # Convert sort values back to dates for calendar display + # Approximate conversion: extract year from sort value + # Sort value is roughly: year * 10000 + month * 100 + day + from_year = min_sort // 10000 + to_year = max_sort // 10000 + + # Set calendar years (approximate) + current_from_year, current_from_month, current_from_day = from_calendar.get_date() + current_to_year, current_to_month, current_to_day = to_calendar.get_date() + + from_calendar.select_month(current_from_month, from_year) + to_calendar.select_month(current_to_month, to_year) + + # Update year spin buttons + if 'date_from_year_spin' in self._filter_widgets: + from_year_spin = self._filter_widgets['date_from_year_spin'] + from_year_spin.handler_block_by_func(self._on_from_year_changed) + from_year_spin.set_value(from_year) + from_year_spin.handler_unblock_by_func(self._on_from_year_changed) + + if 'date_to_year_spin' in self._filter_widgets: + to_year_spin = self._filter_widgets['date_to_year_spin'] + to_year_spin.handler_block_by_func(self._on_to_year_changed) + to_year_spin.set_value(to_year) + to_year_spin.handler_unblock_by_func(self._on_to_year_changed) else: - from_entry.set_text("") - to_entry.set_text("") + # Reset to current date + import datetime + now = datetime.date.today() + from_calendar.select_month(now.month - 1, now.year) + to_calendar.select_month(now.month - 1, now.year) + + # Reset year spin buttons + if 'date_from_year_spin' in self._filter_widgets: + from_year_spin = self._filter_widgets['date_from_year_spin'] + from_year_spin.handler_block_by_func(self._on_from_year_changed) + from_year_spin.set_value(now.year) + from_year_spin.handler_unblock_by_func(self._on_from_year_changed) + + if 'date_to_year_spin' in self._filter_widgets: + to_year_spin = self._filter_widgets['date_to_year_spin'] + to_year_spin.handler_block_by_func(self._on_to_year_changed) + to_year_spin.set_value(now.year) + to_year_spin.handler_unblock_by_func(self._on_to_year_changed) + + # Clear validation message + if hasattr(self, 'date_validation_label'): + self.date_validation_label.set_text("") def _on_filter_dialog_response(self, dialog: Gtk.Dialog, response_id: int) -> None: """ @@ -908,6 +1045,7 @@ class MyTimelineView(NavigationView): self.filter_enabled = False self.active_event_types = set() self.date_range_filter = None + self.date_range_explicit = False self.person_filter = None self.category_filter = None self.apply_filters() @@ -943,57 +1081,68 @@ class MyTimelineView(NavigationView): 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() + # 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'] - 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) + # Check if dates were explicitly set (stored in widget data) + # We'll use a simple approach: if user clicked calendars, use the dates + # For now, we'll always use calendar dates if they're valid + # The "Clear Dates" button will reset the explicit flag + + # Get selected dates from calendars + # Gtk.Calendar.get_date() returns (year, month, day) where month is 0-11 + from_year, from_month, from_day = from_calendar.get_date() + to_year, to_month, to_day = to_calendar.get_date() + + try: + # Create Date objects from calendar selections + # Note: month from calendar is 0-11, Date.set_yr_mon_day expects 1-12 + from_date = Date() + from_date.set_yr_mon_day(from_year, from_month + 1, from_day) + min_sort = from_date.get_sort_value() + + to_date = Date() + to_date.set_yr_mon_day(to_year, to_month + 1, to_day) + max_sort = to_date.get_sort_value() + + # Validate date range + if min_sort > max_sort: + # Show error message + if hasattr(self, 'date_validation_label'): + self.date_validation_label.set_markup( + f"{_('Error: From date must be before To date')}" + ) self.date_range_filter = None - else: + self.date_range_explicit = False + return + else: + # Clear error message + if hasattr(self, 'date_validation_label'): + self.date_validation_label.set_text("") + + # Set filter - user has selected dates in calendars + self.date_range_filter = (min_sort, max_sort) + self.date_range_explicit = True + + except (ValueError, AttributeError, TypeError) as e: + logger.warning(f"Error parsing date range: {e}", exc_info=True) self.date_range_filter = None + self.date_range_explicit = False + if hasattr(self, 'date_validation_label'): + self.date_validation_label.set_markup( + f"{_('Error: Invalid date')}" + ) + 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 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 ) @@ -1035,6 +1184,175 @@ class MyTimelineView(NavigationView): for checkbox in self._filter_widgets['event_type_checkboxes'].values(): checkbox.set_active(False) + def _on_from_date_selected(self, calendar: Gtk.Calendar) -> None: + """ + Handle From date calendar selection. + + Args: + calendar: The calendar widget that was selected. + """ + # Update year spin button to match calendar + if 'date_from_year_spin' in self._filter_widgets: + year, month, day = calendar.get_date() + year_spin = self._filter_widgets['date_from_year_spin'] + year_spin.handler_block_by_func(self._on_from_year_changed) + year_spin.set_value(year) + year_spin.handler_unblock_by_func(self._on_from_year_changed) + + self._validate_date_range() + + def _on_to_date_selected(self, calendar: Gtk.Calendar) -> None: + """ + Handle To date calendar selection. + + Args: + calendar: The calendar widget that was selected. + """ + # Update year spin button to match calendar + if 'date_to_year_spin' in self._filter_widgets: + year, month, day = calendar.get_date() + year_spin = self._filter_widgets['date_to_year_spin'] + year_spin.handler_block_by_func(self._on_to_year_changed) + year_spin.set_value(year) + year_spin.handler_unblock_by_func(self._on_to_year_changed) + + self._validate_date_range() + + def _on_from_calendar_changed(self, calendar: Gtk.Calendar) -> None: + """ + Handle From calendar month/year change. + + Args: + calendar: The calendar widget that changed. + """ + # Update year spin button to match calendar + if 'date_from_year_spin' in self._filter_widgets: + year, month, day = calendar.get_date() + year_spin = self._filter_widgets['date_from_year_spin'] + year_spin.handler_block_by_func(self._on_from_year_changed) + year_spin.set_value(year) + year_spin.handler_unblock_by_func(self._on_from_year_changed) + + def _on_to_calendar_changed(self, calendar: Gtk.Calendar) -> None: + """ + Handle To calendar month/year change. + + Args: + calendar: The calendar widget that changed. + """ + # Update year spin button to match calendar + if 'date_to_year_spin' in self._filter_widgets: + year, month, day = calendar.get_date() + year_spin = self._filter_widgets['date_to_year_spin'] + year_spin.handler_block_by_func(self._on_to_year_changed) + year_spin.set_value(year) + year_spin.handler_unblock_by_func(self._on_to_year_changed) + + def _on_from_year_changed(self, spin_button: Gtk.SpinButton) -> None: + """ + Handle From year spin button change. + + Args: + spin_button: The year spin button that changed. + """ + if 'date_from_calendar' in self._filter_widgets: + calendar = self._filter_widgets['date_from_calendar'] + new_year = int(spin_button.get_value()) + current_year, current_month, current_day = calendar.get_date() + # Update calendar to new year, keeping same month and day + calendar.select_month(current_month, new_year) + # Trigger validation + self._validate_date_range() + + def _on_to_year_changed(self, spin_button: Gtk.SpinButton) -> None: + """ + Handle To year spin button change. + + Args: + spin_button: The year spin button that changed. + """ + if 'date_to_calendar' in self._filter_widgets: + calendar = self._filter_widgets['date_to_calendar'] + new_year = int(spin_button.get_value()) + current_year, current_month, current_day = calendar.get_date() + # Update calendar to new year, keeping same month and day + calendar.select_month(current_month, new_year) + # Trigger validation + self._validate_date_range() + + def _on_clear_date_range(self, button: Gtk.Button) -> None: + """ + Clear the date range selection. + + Args: + button: The clear button that was clicked. + """ + if 'date_from_calendar' in self._filter_widgets and 'date_to_calendar' in self._filter_widgets: + from_calendar = self._filter_widgets['date_from_calendar'] + to_calendar = self._filter_widgets['date_to_calendar'] + + # Reset to current date (calendars always show a date) + # Get current date and set calendars to it + import datetime + now = datetime.date.today() + from_calendar.select_month(now.month - 1, now.year) # month is 0-11 + from_calendar.select_day(now.day) + to_calendar.select_month(now.month - 1, now.year) + to_calendar.select_day(now.day) + + # Reset year spin buttons + if 'date_from_year_spin' in self._filter_widgets: + from_year_spin = self._filter_widgets['date_from_year_spin'] + from_year_spin.handler_block_by_func(self._on_from_year_changed) + from_year_spin.set_value(now.year) + from_year_spin.handler_unblock_by_func(self._on_from_year_changed) + + if 'date_to_year_spin' in self._filter_widgets: + to_year_spin = self._filter_widgets['date_to_year_spin'] + to_year_spin.handler_block_by_func(self._on_to_year_changed) + to_year_spin.set_value(now.year) + to_year_spin.handler_unblock_by_func(self._on_to_year_changed) + + # Clear validation message + if hasattr(self, 'date_validation_label'): + self.date_validation_label.set_text("") + + # Mark that date range should not be applied + self.date_range_explicit = False + + def _validate_date_range(self) -> None: + """ + Validate that From date is not after To date. + """ + if 'date_from_calendar' not in self._filter_widgets or 'date_to_calendar' not in self._filter_widgets: + return + + from_calendar = self._filter_widgets['date_from_calendar'] + to_calendar = self._filter_widgets['date_to_calendar'] + + from_year, from_month, from_day = from_calendar.get_date() + to_year, to_month, to_day = to_calendar.get_date() + + try: + from_date = Date() + from_date.set_yr_mon_day(from_year, from_month + 1, from_day) + from_sort = from_date.get_sort_value() + + to_date = Date() + to_date.set_yr_mon_day(to_year, to_month + 1, to_day) + to_sort = to_date.get_sort_value() + + if from_sort > to_sort: + if hasattr(self, 'date_validation_label'): + self.date_validation_label.set_markup( + f"{_('Warning: From date is after To date')}" + ) + else: + if hasattr(self, 'date_validation_label'): + self.date_validation_label.set_text("") + except (ValueError, AttributeError, TypeError): + pass + def build_tree(self) -> None: """ Rebuilds the current display. Called when the view becomes visible. @@ -1307,7 +1625,7 @@ class MyTimelineView(NavigationView): Returns: bool: True if date is in range (or no filter active), False otherwise. """ - if not self.date_range_filter: + 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