zettcode-tui 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. zettcode/tui/__init__.py +422 -0
  2. zettcode/tui/_compat.py +32 -0
  3. zettcode/tui/capabilities.py +107 -0
  4. zettcode/tui/clipboard.py +91 -0
  5. zettcode/tui/console.py +229 -0
  6. zettcode/tui/core/__init__.py +63 -0
  7. zettcode/tui/core/app.py +658 -0
  8. zettcode/tui/core/events.py +134 -0
  9. zettcode/tui/core/focus.py +85 -0
  10. zettcode/tui/core/geometry.py +212 -0
  11. zettcode/tui/core/host.py +66 -0
  12. zettcode/tui/core/keymap.py +218 -0
  13. zettcode/tui/core/scheduler.py +105 -0
  14. zettcode/tui/core/screen.py +80 -0
  15. zettcode/tui/core/theme.py +248 -0
  16. zettcode/tui/core/widget.py +204 -0
  17. zettcode/tui/gallery.py +588 -0
  18. zettcode/tui/glyphs.py +73 -0
  19. zettcode/tui/input/__init__.py +18 -0
  20. zettcode/tui/input/bridge.py +46 -0
  21. zettcode/tui/input/decoder.py +234 -0
  22. zettcode/tui/input/events.py +64 -0
  23. zettcode/tui/input/keys.py +289 -0
  24. zettcode/tui/input/reader.py +147 -0
  25. zettcode/tui/layout/__init__.py +24 -0
  26. zettcode/tui/layout/box.py +153 -0
  27. zettcode/tui/layout/overlay.py +164 -0
  28. zettcode/tui/layout/scroll.py +477 -0
  29. zettcode/tui/layout/solver.py +95 -0
  30. zettcode/tui/layout/spacing.py +137 -0
  31. zettcode/tui/py.typed +0 -0
  32. zettcode/tui/render/__init__.py +71 -0
  33. zettcode/tui/render/ansi.py +371 -0
  34. zettcode/tui/render/canvas.py +153 -0
  35. zettcode/tui/render/code.py +456 -0
  36. zettcode/tui/render/color.py +177 -0
  37. zettcode/tui/render/renderer.py +151 -0
  38. zettcode/tui/render/rich_text.py +80 -0
  39. zettcode/tui/render/style.py +121 -0
  40. zettcode/tui/render/sweep.py +87 -0
  41. zettcode/tui/render/text.py +319 -0
  42. zettcode/tui/runner.py +316 -0
  43. zettcode/tui/terminal.py +222 -0
  44. zettcode/tui/testing/__init__.py +6 -0
  45. zettcode/tui/testing/harness.py +220 -0
  46. zettcode/tui/testing/snapshot.py +64 -0
  47. zettcode/tui/theme_file.py +300 -0
  48. zettcode/tui/themes/ayu-mirage.toml +44 -0
  49. zettcode/tui/themes/catppuccin-latte.toml +44 -0
  50. zettcode/tui/themes/catppuccin-mocha.toml +44 -0
  51. zettcode/tui/themes/dracula.toml +44 -0
  52. zettcode/tui/themes/github-dimmed.toml +44 -0
  53. zettcode/tui/themes/github-light.toml +44 -0
  54. zettcode/tui/themes/gruvbox-dark.toml +44 -0
  55. zettcode/tui/themes/gruvbox-light.toml +44 -0
  56. zettcode/tui/themes/kanagawa.toml +44 -0
  57. zettcode/tui/themes/nord.toml +44 -0
  58. zettcode/tui/themes/one-dark.toml +44 -0
  59. zettcode/tui/themes/rose-pine-dawn.toml +44 -0
  60. zettcode/tui/themes/rose-pine.toml +44 -0
  61. zettcode/tui/themes/solarized-dark.toml +44 -0
  62. zettcode/tui/themes/solarized-light.toml +44 -0
  63. zettcode/tui/themes/tokyo-night.toml +44 -0
  64. zettcode/tui/widgets/__init__.py +121 -0
  65. zettcode/tui/widgets/collapsible.py +116 -0
  66. zettcode/tui/widgets/completion.py +211 -0
  67. zettcode/tui/widgets/console.py +442 -0
  68. zettcode/tui/widgets/dialog.py +165 -0
  69. zettcode/tui/widgets/diff.py +236 -0
  70. zettcode/tui/widgets/list.py +293 -0
  71. zettcode/tui/widgets/list_page.py +121 -0
  72. zettcode/tui/widgets/markdown.py +657 -0
  73. zettcode/tui/widgets/progress.py +166 -0
  74. zettcode/tui/widgets/rich_text.py +84 -0
  75. zettcode/tui/widgets/status.py +106 -0
  76. zettcode/tui/widgets/table.py +101 -0
  77. zettcode/tui/widgets/tasks.py +130 -0
  78. zettcode/tui/widgets/text.py +111 -0
  79. zettcode/tui/widgets/textarea.py +671 -0
  80. zettcode/tui/widgets/toast.py +137 -0
  81. zettcode_tui-0.4.0.dist-info/METADATA +20 -0
  82. zettcode_tui-0.4.0.dist-info/RECORD +83 -0
  83. zettcode_tui-0.4.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,44 @@
1
+ description = "ink blue and faded gold"
2
+ base = "dark"
3
+
4
+ [ui]
5
+ background = "#1f1f28"
6
+ surface = "#252535"
7
+ surface_alt = "#2a2a37"
8
+ surface_side = "#323547"
9
+ surface_peer = "#383548"
10
+ text = "#dcd7ba"
11
+ muted = "#8b8a83"
12
+ subtle = "#c8c093"
13
+ accent = "#7e9cd8"
14
+ accent_bright = "#7fb4ca"
15
+ warning = "#e6c384"
16
+ error = "#e46876"
17
+ border = "#363646"
18
+ focus = "#7fb4ca"
19
+ selection = "#2d4f67"
20
+ diff_added = "#98bb6c"
21
+ diff_removed = "#e46876"
22
+
23
+ [tools]
24
+ read = "#e6c384"
25
+ search = "#7e9cd8"
26
+ image = "#7e9cd8"
27
+ write = "#ffa066"
28
+ delete = "#e46876"
29
+ shell = "#7e9cd8"
30
+ plan = "#7e9cd8"
31
+ subagent = "#7aa89f"
32
+ message = "#d27e99"
33
+
34
+ [code]
35
+ inline = "#98bb6c"
36
+ text = "#dcd7ba"
37
+ keyword = "#957fb8"
38
+ string = "#98bb6c"
39
+ comment = "#8b8a83"
40
+ number = "#ffa066"
41
+ function = "#7e9cd8"
42
+ builtin = "#957fb8"
43
+ operator = "#c8c093"
44
+ punctuator = "#c8c093"
@@ -0,0 +1,44 @@
1
+ description = "arctic blue-grey, very low saturation"
2
+ base = "dark"
3
+
4
+ [ui]
5
+ background = "#2e3440"
6
+ surface = "#343b49"
7
+ surface_alt = "#3b4252"
8
+ surface_side = "#424c5d"
9
+ surface_peer = "#4b4c5e"
10
+ text = "#eceff4"
11
+ muted = "#9ca6b8"
12
+ subtle = "#d8dee9"
13
+ accent = "#88c0d0"
14
+ accent_bright = "#8fbcbb"
15
+ warning = "#ebcb8b"
16
+ error = "#ca7d84"
17
+ border = "#4c566a"
18
+ focus = "#8fbcbb"
19
+ selection = "#434c5e"
20
+ diff_added = "#a3be8c"
21
+ diff_removed = "#ca7d84"
22
+
23
+ [tools]
24
+ read = "#ebcb8b"
25
+ search = "#81a1c1"
26
+ image = "#81a1c1"
27
+ write = "#d08770"
28
+ delete = "#ca7d84"
29
+ shell = "#81a1c1"
30
+ plan = "#81a1c1"
31
+ subagent = "#8fbcbb"
32
+ message = "#b48ead"
33
+
34
+ [code]
35
+ inline = "#a3be8c"
36
+ text = "#eceff4"
37
+ keyword = "#81a1c1"
38
+ string = "#a3be8c"
39
+ comment = "#9ca6b8"
40
+ number = "#d08770"
41
+ function = "#81a1c1"
42
+ builtin = "#b48ead"
43
+ operator = "#d8dee9"
44
+ punctuator = "#d8dee9"
@@ -0,0 +1,44 @@
1
+ description = "Atom's neutral blue-grey classic"
2
+ base = "dark"
3
+
4
+ [ui]
5
+ background = "#282c34"
6
+ surface = "#2c313a"
7
+ surface_alt = "#333842"
8
+ surface_side = "#384453"
9
+ surface_peer = "#464056"
10
+ text = "#c4c9d2"
11
+ muted = "#959aa2"
12
+ subtle = "#acb2bf"
13
+ accent = "#61afef"
14
+ accent_bright = "#56b6c2"
15
+ warning = "#e5c07b"
16
+ error = "#e06c75"
17
+ border = "#3e4451"
18
+ focus = "#56b6c2"
19
+ selection = "#3e4451"
20
+ diff_added = "#98c379"
21
+ diff_removed = "#e06c75"
22
+
23
+ [tools]
24
+ read = "#e5c07b"
25
+ search = "#61afef"
26
+ image = "#61afef"
27
+ write = "#d19a66"
28
+ delete = "#e06c75"
29
+ shell = "#61afef"
30
+ plan = "#61afef"
31
+ subagent = "#56b6c2"
32
+ message = "#c678dd"
33
+
34
+ [code]
35
+ inline = "#98c379"
36
+ text = "#c4c9d2"
37
+ keyword = "#c678dd"
38
+ string = "#98c379"
39
+ comment = "#959aa2"
40
+ number = "#d19a66"
41
+ function = "#61afef"
42
+ builtin = "#c678dd"
43
+ operator = "#acb2bf"
44
+ punctuator = "#acb2bf"
@@ -0,0 +1,44 @@
1
+ description = "warm paper with violet and rose"
2
+ base = "light"
3
+
4
+ [ui]
5
+ background = "#faf4ed"
6
+ surface = "#fffaf3"
7
+ surface_alt = "#f2e9e1"
8
+ surface_side = "#dedcd8"
9
+ surface_peer = "#e5dbda"
10
+ text = "#4c486a"
11
+ muted = "#726e7c"
12
+ subtle = "#5b586e"
13
+ accent = "#826e98"
14
+ accent_bright = "#a76562"
15
+ warning = "#9e6a23"
16
+ error = "#ab5e74"
17
+ border = "#dfdad9"
18
+ focus = "#a76562"
19
+ selection = "#dfdad9"
20
+ diff_added = "#497e87"
21
+ diff_removed = "#ab5e74"
22
+
23
+ [tools]
24
+ read = "#9a6e3c"
25
+ search = "#497e87"
26
+ image = "#497e87"
27
+ write = "#9e6a23"
28
+ delete = "#ab5e74"
29
+ shell = "#497e87"
30
+ plan = "#497e87"
31
+ subagent = "#497e87"
32
+ message = "#ab5e74"
33
+
34
+ [code]
35
+ inline = "#497e87"
36
+ text = "#4c486a"
37
+ keyword = "#286983"
38
+ string = "#497e87"
39
+ comment = "#726e7c"
40
+ number = "#a76562"
41
+ function = "#286983"
42
+ builtin = "#826e98"
43
+ operator = "#5b586e"
44
+ punctuator = "#5b586e"
@@ -0,0 +1,44 @@
1
+ description = "dark violet with rose gold"
2
+ base = "dark"
3
+
4
+ [ui]
5
+ background = "#191724"
6
+ surface = "#1f1d2e"
7
+ surface_alt = "#26233a"
8
+ surface_side = "#272b42"
9
+ surface_peer = "#3b3450"
10
+ text = "#e0def4"
11
+ muted = "#87849b"
12
+ subtle = "#9e9ab5"
13
+ accent = "#c4a7e7"
14
+ accent_bright = "#ebbcba"
15
+ warning = "#f6c177"
16
+ error = "#eb6f92"
17
+ border = "#403d52"
18
+ focus = "#ebbcba"
19
+ selection = "#403d52"
20
+ diff_added = "#9ccfd8"
21
+ diff_removed = "#eb6f92"
22
+
23
+ [tools]
24
+ read = "#f6c177"
25
+ search = "#9ccfd8"
26
+ image = "#9ccfd8"
27
+ write = "#ebbcba"
28
+ delete = "#eb6f92"
29
+ shell = "#9ccfd8"
30
+ plan = "#9ccfd8"
31
+ subagent = "#9ccfd8"
32
+ message = "#eb6f92"
33
+
34
+ [code]
35
+ inline = "#9ccfd8"
36
+ text = "#e0def4"
37
+ keyword = "#46829a"
38
+ string = "#9ccfd8"
39
+ comment = "#87849b"
40
+ number = "#ebbcba"
41
+ function = "#46829a"
42
+ builtin = "#c4a7e7"
43
+ operator = "#9e9ab5"
44
+ punctuator = "#9e9ab5"
@@ -0,0 +1,44 @@
1
+ description = "deep teal, low contrast"
2
+ base = "dark"
3
+
4
+ [ui]
5
+ background = "#002b36"
6
+ surface = "#073642"
7
+ surface_alt = "#0b3d4b"
8
+ surface_side = "#0e4558"
9
+ surface_peer = "#18445b"
10
+ text = "#c4cbcb"
11
+ muted = "#8c9ca2"
12
+ subtle = "#a8b4b6"
13
+ accent = "#268bd2"
14
+ accent_bright = "#2aa198"
15
+ warning = "#b58900"
16
+ error = "#e25653"
17
+ border = "#14505f"
18
+ focus = "#2aa198"
19
+ selection = "#0f4c5c"
20
+ diff_added = "#859900"
21
+ diff_removed = "#e25653"
22
+
23
+ [tools]
24
+ read = "#b58900"
25
+ search = "#268bd2"
26
+ image = "#268bd2"
27
+ write = "#d36639"
28
+ delete = "#e25653"
29
+ shell = "#268bd2"
30
+ plan = "#268bd2"
31
+ subagent = "#2aa198"
32
+ message = "#da5495"
33
+
34
+ [code]
35
+ inline = "#859900"
36
+ text = "#c4cbcb"
37
+ keyword = "#859900"
38
+ string = "#859900"
39
+ comment = "#8c9ca2"
40
+ number = "#d36639"
41
+ function = "#268bd2"
42
+ builtin = "#7b7fca"
43
+ operator = "#a8b4b6"
44
+ punctuator = "#a8b4b6"
@@ -0,0 +1,44 @@
1
+ description = "beige with teal-blue accents"
2
+ base = "light"
3
+
4
+ [ui]
5
+ background = "#fdf6e3"
6
+ surface = "#fffbf0"
7
+ surface_alt = "#eee8d5"
8
+ surface_side = "#dadfd5"
9
+ surface_peer = "#ddd9d3"
10
+ text = "#3e4d52"
11
+ muted = "#677171"
12
+ subtle = "#4c5c62"
13
+ accent = "#227dbd"
14
+ accent_bright = "#23857d"
15
+ warning = "#957100"
16
+ error = "#dc322f"
17
+ border = "#ddd6c1"
18
+ focus = "#23857d"
19
+ selection = "#e4ddc8"
20
+ diff_added = "#6e7e00"
21
+ diff_removed = "#dc322f"
22
+
23
+ [tools]
24
+ read = "#957100"
25
+ search = "#227dbd"
26
+ image = "#227dbd"
27
+ write = "#cb4b16"
28
+ delete = "#dc322f"
29
+ shell = "#227dbd"
30
+ plan = "#227dbd"
31
+ subagent = "#23857d"
32
+ message = "#d33682"
33
+
34
+ [code]
35
+ inline = "#6e7e00"
36
+ text = "#3e4d52"
37
+ keyword = "#6e7e00"
38
+ string = "#6e7e00"
39
+ comment = "#677171"
40
+ number = "#cb4b16"
41
+ function = "#227dbd"
42
+ builtin = "#6c71c4"
43
+ operator = "#4c5c62"
44
+ punctuator = "#4c5c62"
@@ -0,0 +1,44 @@
1
+ description = "deep indigo, cool neon blues"
2
+ base = "dark"
3
+
4
+ [ui]
5
+ background = "#1a1b26"
6
+ surface = "#1f2335"
7
+ surface_alt = "#292e42"
8
+ surface_side = "#313a54"
9
+ surface_peer = "#3c3c5a"
10
+ text = "#c0caf5"
11
+ muted = "#858cab"
12
+ subtle = "#a9b1d6"
13
+ accent = "#7aa2f7"
14
+ accent_bright = "#7dcfff"
15
+ warning = "#e0af68"
16
+ error = "#f7768e"
17
+ border = "#3b4261"
18
+ focus = "#7dcfff"
19
+ selection = "#2f3b66"
20
+ diff_added = "#9ece6a"
21
+ diff_removed = "#f7768e"
22
+
23
+ [tools]
24
+ read = "#e0af68"
25
+ search = "#7aa2f7"
26
+ image = "#7aa2f7"
27
+ write = "#ff9e64"
28
+ delete = "#f7768e"
29
+ shell = "#7aa2f7"
30
+ plan = "#7aa2f7"
31
+ subagent = "#73daca"
32
+ message = "#bb9af7"
33
+
34
+ [code]
35
+ inline = "#9ece6a"
36
+ text = "#c0caf5"
37
+ keyword = "#bb9af7"
38
+ string = "#9ece6a"
39
+ comment = "#858cab"
40
+ number = "#ff9e64"
41
+ function = "#7aa2f7"
42
+ builtin = "#bb9af7"
43
+ operator = "#a9b1d6"
44
+ punctuator = "#a9b1d6"
@@ -0,0 +1,121 @@
1
+ """Reusable widgets built on the core runtime and the layout containers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ from typing import TYPE_CHECKING
7
+
8
+ #: Where each public name lives; read by :func:`__getattr__` on first use.
9
+ _EXPORTS = {
10
+ "Collapsible": ".collapsible",
11
+ "Column": ".table",
12
+ "Completer": ".completion",
13
+ "CompletionItem": ".completion",
14
+ "CompletionPopup": ".completion",
15
+ "ConsolePanel": ".console",
16
+ "Dialog": ".dialog",
17
+ "DialogAction": ".dialog",
18
+ "DiffLine": ".diff",
19
+ "DiffSegment": ".diff",
20
+ "DiffView": ".diff",
21
+ "ListItem": ".list",
22
+ "ListPage": ".list_page",
23
+ "ListView": ".list",
24
+ "Markdown": ".markdown",
25
+ "MarkdownSource": ".markdown",
26
+ "MarkdownView": ".markdown",
27
+ "ProgressBar": ".progress",
28
+ "RichText": ".rich_text",
29
+ "Rule": ".text",
30
+ "Spinner": ".progress",
31
+ "StatusBar": ".status",
32
+ "Table": ".table",
33
+ "TaskPanel": ".tasks",
34
+ "Text": ".text",
35
+ "TextArea": ".textarea",
36
+ "Toast": ".toast",
37
+ "build_unified": ".diff",
38
+ "layout_input": ".textarea",
39
+ "next_word": ".textarea",
40
+ "parse_unified": ".diff",
41
+ "previous_word": ".textarea",
42
+ "render_markdown": ".markdown",
43
+ "word_diff": ".diff",
44
+ }
45
+
46
+
47
+ if TYPE_CHECKING: # pragma: no cover - for type checkers, not the runtime
48
+ from .collapsible import Collapsible
49
+ from .completion import Completer, CompletionItem, CompletionPopup
50
+ from .console import ConsolePanel
51
+ from .dialog import Dialog, DialogAction
52
+ from .diff import DiffLine, DiffSegment, DiffView, build_unified, parse_unified, word_diff
53
+ from .list import ListItem, ListView
54
+ from .list_page import ListPage
55
+ from .markdown import Markdown, MarkdownSource, MarkdownView, render_markdown
56
+ from .progress import ProgressBar, Spinner
57
+ from .rich_text import RichText
58
+ from .status import StatusBar
59
+ from .table import Column, Table
60
+ from .tasks import TaskPanel
61
+ from .text import Rule, Text
62
+ from .textarea import TextArea, layout_input, next_word, previous_word
63
+ from .toast import Toast
64
+
65
+
66
+ def __getattr__(name: str) -> object:
67
+ """Import the module a public name lives in, the first time it is asked for.
68
+
69
+ This package is a facade over several subpackages, and a caller that wants
70
+ one name should not pay for the rest of them — the TUI alone holds the
71
+ widget library, the Markdown parser, the diff viewer, and the syntax
72
+ scanners. ``__all__`` is still the public surface.
73
+ """
74
+ try:
75
+ module = _EXPORTS[name]
76
+ except KeyError:
77
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None
78
+ return getattr(importlib.import_module(module, __name__), name)
79
+
80
+
81
+ def __dir__() -> list[str]:
82
+ """Return the names this package publishes."""
83
+ return sorted(__all__)
84
+
85
+
86
+ __all__ = [
87
+ "Collapsible",
88
+ "Column",
89
+ "Completer",
90
+ "CompletionItem",
91
+ "CompletionPopup",
92
+ "ConsolePanel",
93
+ "Dialog",
94
+ "DialogAction",
95
+ "DiffLine",
96
+ "DiffSegment",
97
+ "DiffView",
98
+ "ListItem",
99
+ "ListView",
100
+ "ListPage",
101
+ "Markdown",
102
+ "MarkdownSource",
103
+ "MarkdownView",
104
+ "ProgressBar",
105
+ "RichText",
106
+ "Rule",
107
+ "Spinner",
108
+ "StatusBar",
109
+ "Table",
110
+ "TaskPanel",
111
+ "Text",
112
+ "TextArea",
113
+ "Toast",
114
+ "build_unified",
115
+ "layout_input",
116
+ "next_word",
117
+ "parse_unified",
118
+ "previous_word",
119
+ "render_markdown",
120
+ "word_diff",
121
+ ]
@@ -0,0 +1,116 @@
1
+ """A header that expands to reveal one child."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+
7
+ from zettcode.tui import EXPANDED, MARKER
8
+
9
+ from ..core.events import KeyEvent, MouseAction, MouseEvent
10
+ from ..core.geometry import Constraints, Rect, Size
11
+ from ..core.host import Host
12
+ from ..core.widget import Widget
13
+ from ..render import Canvas, Style, truncate
14
+
15
+
16
+ class Collapsible(Widget):
17
+ """Show a title row and, when expanded, a body beneath it.
18
+
19
+ Shape::
20
+
21
+ > Thinking <- collapsed: U+25B8, one row only
22
+ v Thinking <- expanded: U+25BE
23
+ Checked the renderer. <- body, directly beneath the header
24
+
25
+ Expanding changes the widget's height, so toggling calls
26
+ ``host.request_layout()``; the body is only a child while it is expanded,
27
+ which keeps it out of layout and hit testing when collapsed.
28
+ """
29
+
30
+ MARKERS = (MARKER, EXPANDED)
31
+
32
+ def __init__(
33
+ self,
34
+ title: str = "",
35
+ body: Widget | None = None,
36
+ *,
37
+ expanded: bool = False,
38
+ on_toggle: Callable[[bool], None] | None = None,
39
+ ) -> None:
40
+ """Configure the title, the body, and the initial disclosure state.
41
+
42
+ Args:
43
+ title: Header text drawn after the disclosure marker.
44
+ body: Child revealed below the header while expanded; ``None`` makes
45
+ the widget a pure header.
46
+ expanded: Whether the body starts visible.
47
+ on_toggle: Called with the new state after every toggle.
48
+ """
49
+ super().__init__()
50
+ self.title = title
51
+ self.body = body
52
+ self.expanded = expanded
53
+ self.on_toggle = on_toggle
54
+
55
+ @property
56
+ def focusable(self) -> bool:
57
+ """Take focus so Enter and Space can toggle the header."""
58
+ return True
59
+
60
+ @property
61
+ def children(self) -> tuple[Widget, ...]:
62
+ """Expose the body only while it is expanded."""
63
+ return (self.body,) if self.body is not None and self.expanded else ()
64
+
65
+ def toggle(self) -> None:
66
+ """Flip the disclosure state and tell the owner about it."""
67
+ self.expanded = not self.expanded
68
+ if self.on_toggle is not None:
69
+ self.on_toggle(self.expanded)
70
+
71
+ def measure(self, constraints: Constraints) -> Size:
72
+ """Ask for the header row, plus the body while it is expanded."""
73
+ if self.body is None or not self.expanded:
74
+ return constraints.constrain(Size(len(self.title) + 2, 1))
75
+ inner = self.body.measure(constraints)
76
+ return constraints.constrain(Size(max(len(self.title) + 2, inner.width), 1 + inner.height))
77
+
78
+ def layout(self, rect: Rect) -> None:
79
+ """Reserve the first row for the header and give the rest to the body."""
80
+ super().layout(rect)
81
+ if self.body is not None and self.expanded:
82
+ self.body.layout(Rect(rect.x, rect.y + 1, rect.width, max(0, rect.height - 1)))
83
+
84
+ def render(self, canvas: Canvas) -> None:
85
+ """Paint the header marker and title, then the body when expanded."""
86
+ if self.rect.empty:
87
+ return
88
+ theme = self.theme
89
+ marker = self.MARKERS[1] if self.expanded else self.MARKERS[0]
90
+ canvas.draw_text(
91
+ self.rect.x,
92
+ self.rect.y,
93
+ truncate(f"{marker} {self.title}", self.rect.width),
94
+ Style(foreground=theme.accent, bold=True),
95
+ max_width=self.rect.width,
96
+ )
97
+ if self.body is not None and self.expanded:
98
+ self.body.render(canvas)
99
+
100
+ def handle(self, event, host: Host) -> bool:
101
+ """Toggle on a click on the header row, or on Enter and Space."""
102
+ if isinstance(event, MouseEvent):
103
+ if self.rect.contains(event.x, event.y) and event.y == self.rect.y:
104
+ if event.action is MouseAction.DOWN:
105
+ host.focus(self)
106
+ self.toggle()
107
+ host.request_layout()
108
+ return True
109
+ return False
110
+ if not isinstance(event, KeyEvent) or host.focused_widget() is not self:
111
+ return False
112
+ if event.key in ("enter", "space"):
113
+ self.toggle()
114
+ host.request_layout()
115
+ return True
116
+ return False