Coverage for src / lilbee / cli / tui / widgets / scope_chip.py: 100%
78 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-05-15 20:55 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-05-15 20:55 +0000
1"""Scope chip: search-only filter with three side-by-side pills."""
3from __future__ import annotations
5from pathlib import Path
6from typing import TYPE_CHECKING, ClassVar
8if TYPE_CHECKING:
9 from lilbee.cli.tui.app import LilbeeApp
11from textual import events
12from textual.app import ComposeResult
13from textual.binding import Binding, BindingType
14from textual.containers import Horizontal
15from textual.widget import Widget
16from textual.widgets import Static
18from lilbee.cli.tui import messages as msg
19from lilbee.core.config import cfg
20from lilbee.core.config.enums import ChatMode
21from lilbee.data.store import SearchScope
23_CSS_FILE = Path(__file__).parent / "scope_chip.tcss"
25_HIDDEN_CLASS = "-hidden"
26_ACTIVE_CLASS = "-active"
27_SCOPE_PILL_CLASS = "scope-pill"
29_SCOPE_BOTH_PILL_ID = "scope-pill-both"
30_SCOPE_WIKI_PILL_ID = "scope-pill-wiki"
31_SCOPE_RAW_PILL_ID = "scope-pill-raw"
33# Pill id -> scope value, used for click routing.
34_PILL_TO_SCOPE: dict[str, SearchScope] = {
35 _SCOPE_BOTH_PILL_ID: SearchScope.BOTH,
36 _SCOPE_WIKI_PILL_ID: SearchScope.WIKI,
37 _SCOPE_RAW_PILL_ID: SearchScope.RAW,
38}
40# Cycle order: Both -> Wiki -> Raw -> Both.
41_SCOPE_CYCLE: tuple[SearchScope, ...] = (
42 SearchScope.BOTH,
43 SearchScope.WIKI,
44 SearchScope.RAW,
45)
48class ScopePill(Static, can_focus=True):
49 """Single focusable scope pill; Enter / Space activates the parent scope."""
51 BINDINGS: ClassVar[list[BindingType]] = [
52 Binding("enter", "select", "Pick", show=False),
53 Binding("space", "select", "Pick", show=False),
54 ]
56 def action_select(self) -> None:
57 """Tell the enclosing ScopeChip to switch to this pill's scope."""
58 chip = next(
59 (n for n in self.ancestors_with_self if isinstance(n, ScopeChip)),
60 None,
61 )
62 if chip is None or self.id is None:
63 return
64 target = _PILL_TO_SCOPE.get(self.id)
65 if target is not None:
66 chip._set_scope(target)
69class ScopeChip(Widget):
70 """Three-pill search filter; visible when cfg.chat_mode=='search' and cfg.wiki.
72 Scope is **session-only** state held on ``self._scope``; it is not
73 persisted to ``cfg``. ``ChatScreen`` reads ``chip.scope`` at submit
74 time to derive ``chunk_type``.
75 """
77 app: LilbeeApp # type: ignore[assignment]
79 DEFAULT_CSS: ClassVar[str] = _CSS_FILE.read_text(encoding="utf-8")
81 def __init__(
82 self,
83 *,
84 name: str | None = None,
85 id: str | None = None,
86 classes: str | None = None,
87 ) -> None:
88 super().__init__(name=name, id=id, classes=classes)
89 self._scope: SearchScope = SearchScope.BOTH
91 def compose(self) -> ComposeResult:
92 with Horizontal():
93 yield ScopePill(msg.SCOPE_PILL_BOTH, id=_SCOPE_BOTH_PILL_ID, classes=_SCOPE_PILL_CLASS)
94 yield ScopePill(msg.SCOPE_PILL_WIKI, id=_SCOPE_WIKI_PILL_ID, classes=_SCOPE_PILL_CLASS)
95 yield ScopePill(msg.SCOPE_PILL_RAW, id=_SCOPE_RAW_PILL_ID, classes=_SCOPE_PILL_CLASS)
97 @property
98 def scope(self) -> SearchScope:
99 """Current scope selection; consumed by ChatScreen for chunk_type."""
100 return self._scope
102 def on_mount(self) -> None:
103 self._refresh_visibility()
104 self._refresh()
105 self.app.settings_changed_signal.subscribe(self, self._on_settings_changed)
107 def _refresh_visibility(self) -> None:
108 active = cfg.chat_mode == ChatMode.SEARCH.value and cfg.wiki
109 self.set_class(not active, _HIDDEN_CLASS)
111 def _on_settings_changed(self, payload: tuple[str, object]) -> None:
112 key, _value = payload
113 if key in {"chat_mode", "wiki"}:
114 self._refresh_visibility()
116 def _refresh(self) -> None:
117 """Toggle the ``-active`` class on each pill based on ``self._scope``."""
118 for pill_id, scope in _PILL_TO_SCOPE.items():
119 pill = self.query_one(f"#{pill_id}", ScopePill)
120 pill.set_class(scope is self._scope, _ACTIVE_CLASS)
122 def _set_scope(self, target: SearchScope) -> None:
123 """Apply *target* and repaint if it differs from the current scope."""
124 if self._scope is target:
125 return
126 self._scope = target
127 self._refresh()
129 def cycle_scope(self) -> SearchScope:
130 """Advance to the next scope in the cycle; return the new scope."""
131 idx = _SCOPE_CYCLE.index(self._scope)
132 self._set_scope(_SCOPE_CYCLE[(idx + 1) % len(_SCOPE_CYCLE)])
133 return self._scope
135 def on_click(self, event: events.Click) -> None:
136 """Route a child-pill click into the scope it represents."""
137 widget = event.widget
138 if widget is None:
139 return
140 target = _PILL_TO_SCOPE.get(widget.id or "")
141 if target is None:
142 return
143 event.stop()
144 self._set_scope(target)