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
This commit is contained in:
179
src/utils/search.py
Normal file
179
src/utils/search.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user