Files
luk/src/utils/search.py
Bendt 25385c6482 Add search functionality to Mail TUI with / keybinding
- Add reusable SearchScreen modal and ClearableSearchInput widget
- Implement filter_by_query in MessageStore for client-side filtering
- Search matches subject, sender name/email, recipient name/email
- Press / to open search, Escape to clear search filter
- Shows search query in list subtitle when filter is active
2025-12-19 11:01:05 -05:00

180 lines
4.9 KiB
Python

"""Reusable search input screen for TUI apps.
A modal input dialog that can be used for search across all apps.
"""
from typing import Optional
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Vertical, Horizontal
from textual.screen import ModalScreen
from textual.widgets import Input, Static, Label, Button
class SearchScreen(ModalScreen[str | None]):
"""A modal screen for search input.
Returns the search query string on submit, or None on cancel.
"""
DEFAULT_CSS = """
SearchScreen {
align: center middle;
}
SearchScreen > Vertical {
width: 60;
height: auto;
border: solid $primary;
background: $surface;
padding: 1 2;
}
SearchScreen > Vertical > Label {
margin-bottom: 1;
}
SearchScreen > Vertical > Input {
margin-bottom: 1;
}
SearchScreen > Vertical > Horizontal {
height: auto;
align: right middle;
}
SearchScreen > Vertical > Horizontal > Button {
margin-left: 1;
}
"""
BINDINGS = [
Binding("escape", "cancel", "Cancel", show=True),
]
def __init__(
self,
title: str = "Search",
placeholder: str = "Enter search query...",
initial_value: str = "",
name: Optional[str] = None,
id: Optional[str] = None,
classes: Optional[str] = None,
) -> None:
super().__init__(name=name, id=id, classes=classes)
self._title = title
self._placeholder = placeholder
self._initial_value = initial_value
def compose(self) -> ComposeResult:
with Vertical():
yield Label(self._title)
yield Input(
placeholder=self._placeholder,
value=self._initial_value,
id="search-input",
)
with Horizontal():
yield Button("Search", variant="primary", id="search-btn")
yield Button("Cancel", variant="default", id="cancel-btn")
def on_mount(self) -> None:
"""Focus the input on mount."""
self.query_one("#search-input", Input).focus()
def on_input_submitted(self, event: Input.Submitted) -> None:
"""Handle Enter key in input."""
self.dismiss(event.value)
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses."""
if event.button.id == "search-btn":
query = self.query_one("#search-input", Input).value
self.dismiss(query)
elif event.button.id == "cancel-btn":
self.dismiss(None)
def action_cancel(self) -> None:
"""Cancel the search."""
self.dismiss(None)
class ClearableSearchInput(Static):
"""A search input widget with clear button for use in sidebars/headers.
Emits SearchInput.Submitted message when user submits a query.
Emits SearchInput.Cleared message when user clears the search.
"""
DEFAULT_CSS = """
ClearableSearchInput {
height: 3;
padding: 0 1;
}
ClearableSearchInput > Horizontal {
height: auto;
}
ClearableSearchInput > Horizontal > Input {
width: 1fr;
}
ClearableSearchInput > Horizontal > Button {
width: 3;
min-width: 3;
}
"""
from textual.message import Message
class Submitted(Message):
"""Search query was submitted."""
def __init__(self, query: str) -> None:
super().__init__()
self.query = query
class Cleared(Message):
"""Search was cleared."""
pass
def __init__(
self,
placeholder: str = "Search...",
name: Optional[str] = None,
id: Optional[str] = None,
classes: Optional[str] = None,
) -> None:
super().__init__(name=name, id=id, classes=classes)
self._placeholder = placeholder
def compose(self) -> ComposeResult:
with Horizontal():
yield Input(placeholder=self._placeholder, id="search-input")
yield Button("X", id="clear-btn", variant="error")
def on_input_submitted(self, event: Input.Submitted) -> None:
"""Handle search submission."""
self.post_message(self.Submitted(event.value))
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle clear button."""
if event.button.id == "clear-btn":
input_widget = self.query_one("#search-input", Input)
input_widget.value = ""
input_widget.focus()
self.post_message(self.Cleared())
@property
def value(self) -> str:
"""Get the current search value."""
return self.query_one("#search-input", Input).value
@value.setter
def value(self, new_value: str) -> None:
"""Set the search value."""
self.query_one("#search-input", Input).value = new_value