Add date picker for search date/before/after keywords

- Add DatePickerModal using MonthCalendar widget from calendar app
- Detect when user types 'date ', 'before ', or 'after ' and show picker
- Insert selected date (YYYY-MM-DD format) into search input
- Support keyboard navigation (left/right for months, Enter to select)
- Today button for quick selection of current date
This commit is contained in:
Bendt
2025-12-19 15:45:15 -05:00
parent d4b09e5338
commit 560bc1d3bd

View File

@@ -4,8 +4,10 @@ Provides a search input docked to the top of the window with:
- Live search with 1 second debounce
- Cancel button to restore previous state
- Help button showing Himalaya search syntax
- Date picker for date/before/after keywords
"""
from datetime import date
from typing import Optional, List, Dict, Any
from textual.app import ComposeResult
@@ -19,6 +21,8 @@ from textual.widget import Widget
from textual.widgets import Button, Input, Label, Static
from textual.suggester import SuggestFromList
from src.calendar.widgets.MonthCalendar import MonthCalendar
# Himalaya search keywords for autocomplete
HIMALAYA_KEYWORDS = [
"from ",
@@ -132,6 +136,94 @@ class SearchHelpModal(ModalScreen[None]):
self.dismiss(None)
class DatePickerModal(ModalScreen[Optional[date]]):
"""Modal with a calendar for selecting a date."""
DEFAULT_CSS = """
DatePickerModal {
align: center middle;
}
DatePickerModal > Vertical {
width: 30;
height: auto;
border: solid $primary;
background: $surface;
padding: 1 2;
}
DatePickerModal > Vertical > Label {
width: 100%;
text-align: center;
margin-bottom: 1;
}
DatePickerModal > Vertical > Horizontal {
height: auto;
align: center middle;
margin-top: 1;
}
DatePickerModal > Vertical > Horizontal > Button {
margin: 0 1;
}
"""
BINDINGS = [
Binding("escape", "cancel", "Cancel"),
Binding("left", "prev_month", "Previous month", show=False),
Binding("right", "next_month", "Next month", show=False),
Binding("enter", "select_date", "Select date", show=False),
]
def __init__(self, keyword: str = "date") -> None:
super().__init__()
self.keyword = keyword
def compose(self) -> ComposeResult:
with Vertical():
yield Label(f"Select date for '{self.keyword}':", id="picker-title")
yield MonthCalendar(id="date-picker-calendar")
with Horizontal():
yield Button("Today", variant="default", id="today-btn")
yield Button("Select", variant="primary", id="select-btn")
yield Button("Cancel", variant="warning", id="cancel-btn")
def on_month_calendar_date_selected(
self, event: MonthCalendar.DateSelected
) -> None:
"""Handle date selection from calendar click."""
self.dismiss(event.date)
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "select-btn":
calendar = self.query_one("#date-picker-calendar", MonthCalendar)
self.dismiss(calendar.selected_date)
elif event.button.id == "today-btn":
calendar = self.query_one("#date-picker-calendar", MonthCalendar)
today = date.today()
calendar.selected_date = today
calendar.display_month = today.replace(day=1)
calendar.refresh()
elif event.button.id == "cancel-btn":
self.dismiss(None)
def action_cancel(self) -> None:
self.dismiss(None)
def action_prev_month(self) -> None:
calendar = self.query_one("#date-picker-calendar", MonthCalendar)
calendar.prev_month()
def action_next_month(self) -> None:
calendar = self.query_one("#date-picker-calendar", MonthCalendar)
calendar.next_month()
def action_select_date(self) -> None:
calendar = self.query_one("#date-picker-calendar", MonthCalendar)
self.dismiss(calendar.selected_date)
class SearchPanel(Widget):
"""Docked search panel with live search capability."""
@@ -216,6 +308,7 @@ class SearchPanel(Widget):
super().__init__(name=name, id=id, classes=classes)
self._debounce_timer: Optional[Timer] = None
self._last_query: str = ""
self._pending_date_keyword: Optional[str] = None # Track keyword awaiting date
def compose(self) -> ComposeResult:
with Horizontal():
@@ -304,6 +397,34 @@ class SearchPanel(Widget):
self.is_searching = True
self.post_message(self.SearchRequested(query))
def _check_date_keyword(self, value: str) -> Optional[str]:
"""Check if the input ends with a date keyword that needs a date picker.
Returns the keyword (date/before/after) if found, None otherwise.
"""
value_lower = value.lower()
for keyword in ("date ", "before ", "after "):
if value_lower.endswith(keyword):
return keyword.strip()
return None
def _show_date_picker(self, keyword: str) -> None:
"""Show the date picker modal for the given keyword."""
self._pending_date_keyword = keyword
def on_date_selected(selected_date: Optional[date]) -> None:
if selected_date:
# Insert the date into the search input
input_widget = self.query_one("#search-input", Input)
date_str = selected_date.strftime("%Y-%m-%d")
input_widget.value = input_widget.value + date_str
input_widget.cursor_position = len(input_widget.value)
self._pending_date_keyword = None
# Refocus the input
self.query_one("#search-input", Input).focus()
self.app.push_screen(DatePickerModal(keyword), on_date_selected)
def on_input_changed(self, event: Input.Changed) -> None:
"""Handle input changes with debounce."""
if event.input.id != "search-input":
@@ -316,6 +437,12 @@ class SearchPanel(Widget):
if not event.value.strip():
return
# Check for date keywords and show picker
date_keyword = self._check_date_keyword(event.value)
if date_keyword:
self._show_date_picker(date_keyword)
return
# Set up new debounce timer (1 second)
self._debounce_timer = self.set_timer(1.0, self._trigger_search)