From 560bc1d3bd2b7cbd603f219cba84512ee0803059 Mon Sep 17 00:00:00 2001 From: Bendt Date: Fri, 19 Dec 2025 15:45:15 -0500 Subject: [PATCH] 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 --- src/mail/screens/SearchPanel.py | 127 ++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/src/mail/screens/SearchPanel.py b/src/mail/screens/SearchPanel.py index 7ee5cb7..c1df5da 100644 --- a/src/mail/screens/SearchPanel.py +++ b/src/mail/screens/SearchPanel.py @@ -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)