This commit is contained in:
Joe Arndt 2026-05-06 18:25:17 -05:00
commit ac93e6074b
38 changed files with 7162 additions and 0 deletions

View file

View file

@ -0,0 +1,315 @@
from textual import events, on
from textual.app import ComposeResult
from textual.binding import Binding
from textual.css.query import NoMatches
from textual.message import Message
from textual.widget import Widget
from textual.widgets import Input, OptionList
class _FilterInput(Input):
"""Input that translates Down/Escape into custom messages, and posts a focus signal.
Note: do not name nested Message classes after any defined on Input
(Blurred, Submitted, Changed) that shadows the parent's class and
breaks Textual's internal post_message calls.
"""
class DownPressed(Message):
pass
class EnterPressed(Message):
pass
class FilterFocused(Message):
pass
class EscapePressed(Message):
pass
BINDINGS = [
Binding("down", "down_pressed", show=False),
Binding("enter", "enter_pressed", show=False),
Binding("escape", "escape_pressed", show=False),
]
def action_down_pressed(self) -> None:
self.post_message(self.DownPressed())
def action_enter_pressed(self) -> None:
self.post_message(self.EnterPressed())
def action_escape_pressed(self) -> None:
self.post_message(self.EscapePressed())
def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None:
# Only consume Escape when our enclosing AutocompleteInput's dropdown is
# visible. When the dropdown is hidden, returning False disables the
# binding so the keystroke bubbles up to the screen's cancel binding.
# Defer to super() for everything else — returning None would mark
# actions as "disabled+visible" and break Input's backspace/enter/etc.
if action == "escape_pressed":
parent = self.parent
while parent is not None and not isinstance(parent, AutocompleteInput):
parent = parent.parent
if parent is None:
return False
try:
return bool(parent.query_one(_SuggestionList).display)
except NoMatches:
return False
return super().check_action(action, parameters)
def on_focus(self) -> None:
self.post_message(self.FilterFocused())
class _SuggestionList(OptionList):
"""OptionList that reports dismissal."""
BINDINGS = [Binding("escape", "dismiss_list", show=False)]
class Dismissed(Message):
pass
def action_dismiss_list(self) -> None:
self.post_message(self.Dismissed())
class AutocompleteInput(Widget):
"""
Text input with a live-filtered suggestion dropdown.
- Type to filter; the top match is auto-highlighted.
- Enter accepts the highlighted suggestion (focus stays on the input).
- Tab accepts the highlighted suggestion and advances focus to the next field.
- Down moves focus into the list (re-opening it if hidden); Enter from the list also selects.
- Escape closes the dropdown.
- The dropdown closes when focus leaves the widget entirely.
- `set_options()` refreshes a visible dropdown.
- `is_known_value` exposes whether the current value matches an option.
- Posts AutocompleteInput.Changed when the user types.
- Posts AutocompleteInput.Submitted on every commit (Enter / Tab / option click),
with `is_known` indicating whether the value matches a known option.
"""
DEFAULT_CSS = """
AutocompleteInput {
height: auto;
width: 1fr;
}
AutocompleteInput Input {
width: 1fr;
}
AutocompleteInput _SuggestionList {
width: 1fr;
height: auto;
display: none;
overlay: screen;
constrain: none inside;
border: tall $border-blurred;
background: $surface;
}
"""
BINDINGS = [
Binding("tab", "tab_accept", show=False),
]
class Changed(Message):
def __init__(self, autocomplete: "AutocompleteInput", value: str) -> None:
super().__init__()
self.autocomplete = autocomplete
self.value = value
@property
def control(self) -> "AutocompleteInput":
return self.autocomplete
class Submitted(Message):
"""Posted when the user commits a value (Enter, Tab, or click)."""
def __init__(
self, autocomplete: "AutocompleteInput", value: str, is_known: bool
) -> None:
super().__init__()
self.autocomplete = autocomplete
self.value = value
self.is_known = is_known
@property
def control(self) -> "AutocompleteInput":
return self.autocomplete
def __init__(
self,
options: list[str] | None = None,
placeholder: str = "",
value: str = "",
id: str | None = None,
max_visible: int = 6,
) -> None:
super().__init__(id=id)
self._all_options: list[str] = list(options or [])
self._placeholder = placeholder
self._initial_value = value
self._matches: list[str] = []
self._max_visible = max_visible
self._suppress_refresh = 0
def compose(self) -> ComposeResult:
yield _FilterInput(placeholder=self._placeholder, value=self._initial_value)
sl = _SuggestionList()
sl.styles.max_height = self._max_visible
yield sl
def on_mount(self) -> None:
if self._initial_value:
inp = self.query_one(_FilterInput)
inp.cursor_position = len(self._initial_value)
@property
def value(self) -> str:
return self.query_one(_FilterInput).value
@value.setter
def value(self, val: str) -> None:
inp = self.query_one(_FilterInput)
if inp.value != val:
self._suppress_refresh += 1
inp.value = val
inp.cursor_position = len(val)
@property
def is_known_value(self) -> bool:
v = self.value.strip().lower()
return any(o.lower() == v for o in self._all_options)
def set_options(self, options: list[str]) -> None:
self._all_options = list(options)
sl = self.query_one(_SuggestionList)
if sl.display:
self._refresh_matches(self.value)
def _rank_key(self, option: str, query: str) -> tuple[int, int, str]:
o = option.lower()
if not query:
return (0, 0, o)
q = query.lower()
if o.startswith(q):
return (0, 0, o)
return (1, o.find(q), o)
def _refresh_matches(self, text: str) -> None:
sl = self.query_one(_SuggestionList)
query = text.strip()
if query:
candidates = [o for o in self._all_options if query.lower() in o.lower()]
else:
candidates = list(self._all_options)
candidates.sort(key=lambda o: self._rank_key(o, query))
self._matches = candidates
sl.clear_options()
if self._matches:
for match in self._matches:
sl.add_option(match)
sl.display = True
sl.highlighted = 0
else:
sl.display = False
def on_input_changed(self, event: Input.Changed) -> None:
if event.input is not self.query_one(_FilterInput):
return
event.stop()
if self._suppress_refresh > 0:
self._suppress_refresh -= 1
return
self._refresh_matches(event.value)
self.post_message(self.Changed(self, event.value))
@on(_FilterInput.FilterFocused)
def _on_filter_focused(self) -> None:
if not self._all_options:
return
text = self.query_one(_FilterInput).value.strip()
if not text:
self._refresh_matches("")
@on(_FilterInput.DownPressed)
def _focus_suggestions(self) -> None:
sl = self.query_one(_SuggestionList)
if not sl.display and self._all_options:
self._refresh_matches(self.value)
if sl.display:
sl.focus()
@on(_FilterInput.EnterPressed)
def _on_input_enter(self) -> None:
sl = self.query_one(_SuggestionList)
if sl.display and self._matches:
idx = sl.highlighted if sl.highlighted is not None else 0
if 0 <= idx < len(self._matches):
self._accept(self._matches[idx])
return
self._post_submitted()
def _post_submitted(self) -> None:
val = self.value.strip()
if not val:
return
self.post_message(self.Submitted(self, val, self.is_known_value))
@on(_FilterInput.EscapePressed)
def _on_input_escape(self) -> None:
sl = self.query_one(_SuggestionList)
if sl.display:
sl.display = False
def on_descendant_blur(self, event: events.DescendantBlur) -> None:
self.call_after_refresh(self._maybe_close_on_blur)
def _maybe_close_on_blur(self) -> None:
if not self.is_mounted:
return
focused = self.app.focused
if focused is None or not self._contains_widget(focused):
for sl in self.query(_SuggestionList):
sl.display = False
def _contains_widget(self, widget) -> bool:
node = widget
while node is not None:
if node is self:
return True
node = node.parent
return False
def action_tab_accept(self) -> None:
sl = self.query_one(_SuggestionList)
if sl.display and self._matches:
idx = sl.highlighted if sl.highlighted is not None else 0
if 0 <= idx < len(self._matches):
self._accept(self._matches[idx])
self.screen.focus_next()
def _accept(self, value: str) -> None:
inp = self.query_one(_FilterInput)
if inp.value != value:
self._suppress_refresh += 1
inp.value = value
inp.cursor_position = len(value)
self.query_one(_SuggestionList).display = False
self._post_submitted()
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
idx = event.option_index
if 0 <= idx < len(self._matches):
self._accept(self._matches[idx])
self.query_one(_FilterInput).focus()
event.stop()
@on(_SuggestionList.Dismissed)
def _on_list_dismissed(self) -> None:
self.query_one(_SuggestionList).display = False
self.query_one(_FilterInput).focus()

View file

@ -0,0 +1,16 @@
from textual.widgets import Footer
class CenteredFooter(Footer):
def on_mount(self) -> None:
self.call_after_refresh(self._center)
def on_resize(self) -> None:
self._center()
def _center(self) -> None:
screen_w = self.app.size.width
footer_w = self.size.width
if footer_w > 0 and screen_w > 0:
left = max(0, (screen_w - footer_w) // 2)
self.styles.margin = (0, 0, 0, left)