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:
@@ -4,8 +4,10 @@ Provides a search input docked to the top of the window with:
|
|||||||
- Live search with 1 second debounce
|
- Live search with 1 second debounce
|
||||||
- Cancel button to restore previous state
|
- Cancel button to restore previous state
|
||||||
- Help button showing Himalaya search syntax
|
- 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 typing import Optional, List, Dict, Any
|
||||||
|
|
||||||
from textual.app import ComposeResult
|
from textual.app import ComposeResult
|
||||||
@@ -19,6 +21,8 @@ from textual.widget import Widget
|
|||||||
from textual.widgets import Button, Input, Label, Static
|
from textual.widgets import Button, Input, Label, Static
|
||||||
from textual.suggester import SuggestFromList
|
from textual.suggester import SuggestFromList
|
||||||
|
|
||||||
|
from src.calendar.widgets.MonthCalendar import MonthCalendar
|
||||||
|
|
||||||
# Himalaya search keywords for autocomplete
|
# Himalaya search keywords for autocomplete
|
||||||
HIMALAYA_KEYWORDS = [
|
HIMALAYA_KEYWORDS = [
|
||||||
"from ",
|
"from ",
|
||||||
@@ -132,6 +136,94 @@ class SearchHelpModal(ModalScreen[None]):
|
|||||||
self.dismiss(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):
|
class SearchPanel(Widget):
|
||||||
"""Docked search panel with live search capability."""
|
"""Docked search panel with live search capability."""
|
||||||
|
|
||||||
@@ -216,6 +308,7 @@ class SearchPanel(Widget):
|
|||||||
super().__init__(name=name, id=id, classes=classes)
|
super().__init__(name=name, id=id, classes=classes)
|
||||||
self._debounce_timer: Optional[Timer] = None
|
self._debounce_timer: Optional[Timer] = None
|
||||||
self._last_query: str = ""
|
self._last_query: str = ""
|
||||||
|
self._pending_date_keyword: Optional[str] = None # Track keyword awaiting date
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
with Horizontal():
|
with Horizontal():
|
||||||
@@ -304,6 +397,34 @@ class SearchPanel(Widget):
|
|||||||
self.is_searching = True
|
self.is_searching = True
|
||||||
self.post_message(self.SearchRequested(query))
|
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:
|
def on_input_changed(self, event: Input.Changed) -> None:
|
||||||
"""Handle input changes with debounce."""
|
"""Handle input changes with debounce."""
|
||||||
if event.input.id != "search-input":
|
if event.input.id != "search-input":
|
||||||
@@ -316,6 +437,12 @@ class SearchPanel(Widget):
|
|||||||
if not event.value.strip():
|
if not event.value.strip():
|
||||||
return
|
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)
|
# Set up new debounce timer (1 second)
|
||||||
self._debounce_timer = self.set_timer(1.0, self._trigger_search)
|
self._debounce_timer = self.set_timer(1.0, self._trigger_search)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user