- 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
180 lines
4.9 KiB
Python
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
|