Add year selector controls to date range chooser

- Add Gtk.SpinButton widgets above each calendar for quick year selection
- Implement bidirectional synchronization between year selectors and calendars
- Set year range from 1000 to current year + 10 for genealogical data
- Update clear date range and filter state management to handle year selectors
- Improve UX by allowing direct year input instead of incrementing one year at a time
This commit is contained in:
Daniel Viegas 2025-11-29 02:37:31 +01:00
parent 9976a95b97
commit 2874fe35d1

View File

@ -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("<span color='red'></span>")
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
# 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
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
# 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()
from_date = Date()
from_date.set_yr_mon_day(year, month, day)
min_sort = from_date.get_sort_value()
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()
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(to_year, to_month + 1, to_day)
max_sort = to_date.get_sort_value()
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)
# Validate date range
if min_sort > max_sort:
# Show error message
if hasattr(self, 'date_validation_label'):
self.date_validation_label.set_markup(
f"<span color='red'>{_('Error: From date must be before To date')}</span>"
)
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"<span color='red'>{_('Error: Invalid date')}</span>"
)
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"<span color='red'>{_('Warning: From date is after To date')}</span>"
)
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