init
This commit is contained in:
commit
ac93e6074b
38 changed files with 7162 additions and 0 deletions
0
src/common_cents/widgets/__init__.py
Normal file
0
src/common_cents/widgets/__init__.py
Normal file
315
src/common_cents/widgets/autocomplete.py
Normal file
315
src/common_cents/widgets/autocomplete.py
Normal 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()
|
||||
16
src/common_cents/widgets/footer.py
Normal file
16
src/common_cents/widgets/footer.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue