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

1"""Common base for the non-modal side drawers.""" 

2 

3from __future__ import annotations 

4 

5from typing import Protocol, runtime_checkable 

6 

7from textual.await_remove import AwaitRemove 

8from textual.containers import Vertical 

9from textual.widget import Widget 

10 

11 

12@runtime_checkable 

13class _ModeFocusHost(Protocol): 

14 """A screen that names its own focus target for whatever mode it is in.""" 

15 

16 def default_focus_target(self) -> Widget: ... 

17 

18 

19class Drawer(Vertical): 

20 """A non-modal side drawer that owns the keyboard while focus is inside it. 

21 

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 """ 

27 

28 def __init__(self, *, id: str) -> None: 

29 super().__init__(id=id) 

30 self._return_focus: Widget | None = None 

31 

32 def on_compose(self) -> None: 

33 """Remember the focus from before the drawer opened; children mount after this. 

34 

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 

41 

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() 

57 

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 

62 

63 

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)