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