Coverage for src/lilbee/cli/tui/widgets/drawer.py: 100%
27 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-28 17:20 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-28 17:20 +0000
1"""Common base for the non-modal side drawers."""
3from __future__ import annotations
5from typing import Protocol, runtime_checkable
7from textual.await_remove import AwaitRemove
8from textual.containers import Vertical
9from textual.widget import Widget
12@runtime_checkable
13class _ModeFocusHost(Protocol):
14 """A screen that names its own focus target for whatever mode it is in."""
16 def default_focus_target(self) -> Widget: ...
19class Drawer(Vertical):
20 """A non-modal side drawer that owns the keyboard while focus is inside it.
22 The chat screen's vim mode treats esc / enter / i / a / o as conversation keys
23 and swallows them. A drawer's own controls need those keys, so the chat screen
24 asks whether focus sits under a Drawer rather than naming each drawer class:
25 a new drawer inherits the exemption instead of silently eating its own enter.
26 """
28 def __init__(self, *, id: str) -> None:
29 super().__init__(id=id)
30 self._return_focus: Widget | None = None
32 def on_compose(self) -> None:
33 """Remember the focus from before the drawer opened; children mount after this.
35 Focus inside another drawer is looked past to that drawer's own return target,
36 since that drawer can close first.
37 """
38 focused = self.screen.focused
39 holder = drawer_holding(focused)
40 self._return_focus = focused if holder is None else holder._return_focus
42 def remove(self) -> AwaitRemove:
43 """Close the drawer, handing focus it holds back to where it was before it
44 opened, or to the host screen's own current-mode target when that widget
45 can no longer take it (removed, disabled, or not focusable in this mode).
46 """
47 if self.has_focus_within:
48 previous = self._return_focus
49 target = (
50 previous
51 if previous is not None and previous.is_attached and previous.focusable
52 else self._fallback_focus_target()
53 )
54 if target is not None:
55 self.screen.set_focus(target)
56 return super().remove()
58 def _fallback_focus_target(self) -> Widget | None:
59 """The host screen's own focus target, when it names one."""
60 host = self.screen
61 return host.default_focus_target() if isinstance(host, _ModeFocusHost) else None
64def drawer_holding(widget: Widget | None) -> Drawer | None:
65 """The drawer that contains *widget*, or None when it sits outside every drawer."""
66 if widget is None:
67 return None
68 return next((node for node in widget.ancestors_with_self if isinstance(node, Drawer)), None)