hatui-kit 0.1.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 (97) hide show
  1. hatui/__init__.py +11 -0
  2. hatui/__main__.py +5 -0
  3. hatui/app.py +200 -0
  4. hatui/core/__init__.py +1 -0
  5. hatui/core/actions.py +46 -0
  6. hatui/core/context.py +33 -0
  7. hatui/core/input_manager.py +167 -0
  8. hatui/core/screen_buffer.py +150 -0
  9. hatui/core/style.py +381 -0
  10. hatui/core/terminal_env.py +67 -0
  11. hatui/core/util.py +0 -0
  12. hatui/core/widget.py +203 -0
  13. hatui/demo/__init__.py +1 -0
  14. hatui/demo/screens/dashboard/modals/debug.yaml +72 -0
  15. hatui/demo/screens/dashboard/modals/help.yaml +43 -0
  16. hatui/demo/screens/dashboard/providers/phase8.yaml +429 -0
  17. hatui/demo/screens/dashboard/providers.yaml +487 -0
  18. hatui/demo/screens/dashboard/screen.yaml +64 -0
  19. hatui/demo/screens/dashboard/tabs/analysis.yaml +65 -0
  20. hatui/demo/screens/dashboard/tabs/forensics.yaml +71 -0
  21. hatui/demo/screens/dashboard/tabs/hacker.yaml +80 -0
  22. hatui/demo/screens/dashboard/tabs/logs.yaml +39 -0
  23. hatui/demo/screens/dashboard/tabs/overview.yaml +180 -0
  24. hatui/demo/screens/dashboard/tabs/visuals.yaml +84 -0
  25. hatui/demo/screens/dashboard.yaml +166 -0
  26. hatui/demo_assets.py +16 -0
  27. hatui/providers/__init__.py +54 -0
  28. hatui/providers/base.py +61 -0
  29. hatui/providers/builtins.py +546 -0
  30. hatui/providers/helpers.py +169 -0
  31. hatui/runtime/__init__.py +24 -0
  32. hatui/runtime/action_registry.py +102 -0
  33. hatui/runtime/bindings.py +40 -0
  34. hatui/runtime/bootstrap.py +94 -0
  35. hatui/runtime/cli.py +66 -0
  36. hatui/runtime/defaults.py +219 -0
  37. hatui/runtime/engine.py +135 -0
  38. hatui/runtime/formatters.py +65 -0
  39. hatui/runtime/loader.py +140 -0
  40. hatui/runtime/provider_manager.py +138 -0
  41. hatui/runtime/registries.py +65 -0
  42. hatui/runtime/render_policy.py +144 -0
  43. hatui/runtime/router.py +74 -0
  44. hatui/runtime/store.py +29 -0
  45. hatui/runtime/validation.py +433 -0
  46. hatui/runtime/watcher.py +167 -0
  47. hatui/widgets/__init__.py +89 -0
  48. hatui/widgets/alert_stack_widget.py +64 -0
  49. hatui/widgets/alert_widget.py +83 -0
  50. hatui/widgets/banner_widget.py +76 -0
  51. hatui/widgets/border_widget.py +93 -0
  52. hatui/widgets/box_widget.py +81 -0
  53. hatui/widgets/center_widget.py +29 -0
  54. hatui/widgets/chart_widget.py +222 -0
  55. hatui/widgets/code_block_widget.py +91 -0
  56. hatui/widgets/column_widget.py +48 -0
  57. hatui/widgets/diff_viewer_widget.py +88 -0
  58. hatui/widgets/divider_widget.py +66 -0
  59. hatui/widgets/event_feed_widget.py +72 -0
  60. hatui/widgets/flow_widget.py +77 -0
  61. hatui/widgets/gauge_widget.py +109 -0
  62. hatui/widgets/heatmap_widget.py +153 -0
  63. hatui/widgets/hex_dump_widget.py +91 -0
  64. hatui/widgets/histogram_widget.py +96 -0
  65. hatui/widgets/inspector_widget.py +85 -0
  66. hatui/widgets/kv_inspector_widget.py +134 -0
  67. hatui/widgets/label_widget.py +99 -0
  68. hatui/widgets/list_widget.py +185 -0
  69. hatui/widgets/log_widget.py +106 -0
  70. hatui/widgets/menu_widget.py +31 -0
  71. hatui/widgets/metric_grid_widget.py +103 -0
  72. hatui/widgets/mini_chart_widget.py +79 -0
  73. hatui/widgets/modal_host_widget.py +85 -0
  74. hatui/widgets/modal_widget.py +112 -0
  75. hatui/widgets/paragraph_widget.py +94 -0
  76. hatui/widgets/progress_bar_widget.py +124 -0
  77. hatui/widgets/root_services.py +267 -0
  78. hatui/widgets/root_widget.py +130 -0
  79. hatui/widgets/row_widget.py +48 -0
  80. hatui/widgets/scroll_widget.py +224 -0
  81. hatui/widgets/selection.py +52 -0
  82. hatui/widgets/signal_strip_widget.py +79 -0
  83. hatui/widgets/sparkline_widget.py +84 -0
  84. hatui/widgets/stat_widget.py +107 -0
  85. hatui/widgets/status_matrix_widget.py +234 -0
  86. hatui/widgets/status_strip_widget.py +85 -0
  87. hatui/widgets/table_widget.py +239 -0
  88. hatui/widgets/tabs_widget.py +188 -0
  89. hatui/widgets/text_widget.py +64 -0
  90. hatui/widgets/timeline_widget.py +81 -0
  91. hatui/widgets/tree_widget.py +341 -0
  92. hatui/widgets/visualization.py +110 -0
  93. hatui_kit-0.1.0.dist-info/METADATA +154 -0
  94. hatui_kit-0.1.0.dist-info/RECORD +97 -0
  95. hatui_kit-0.1.0.dist-info/WHEEL +4 -0
  96. hatui_kit-0.1.0.dist-info/entry_points.txt +2 -0
  97. hatui_kit-0.1.0.dist-info/licenses/LICENSE +21 -0
hatui/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ """Hatui package."""
2
+
3
+ __all__ = ["HatuiApp"]
4
+
5
+
6
+ def __getattr__(name: str):
7
+ if name == "HatuiApp":
8
+ from hatui.app import HatuiApp
9
+
10
+ return HatuiApp
11
+ raise AttributeError(name)
hatui/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from hatui.app import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ main()
hatui/app.py ADDED
@@ -0,0 +1,200 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from copy import deepcopy
5
+ from pathlib import Path
6
+
7
+ from hatui.runtime.bootstrap import build_runtime
8
+ from hatui.runtime.cli import build_parser, normalize_argv, preferred_glyph_mode, resolve_spec_context, write_cli_text
9
+ from hatui.runtime.defaults import create_provider_registry, create_widget_registry
10
+ from hatui.runtime.engine import AppEngine
11
+ from hatui.runtime.loader import ScreenSpecLoader
12
+ from hatui.runtime.watcher import SpecWatcher
13
+
14
+
15
+ class HatuiApp:
16
+ def __init__(
17
+ self,
18
+ spec_path: str | Path,
19
+ *,
20
+ widget_registry=None,
21
+ provider_registry=None,
22
+ watch: bool = False,
23
+ bundled_demo: bool = False,
24
+ glyph_mode: str = "unicode",
25
+ ):
26
+ self.spec_path = Path(spec_path)
27
+ self.is_bundled_demo = bundled_demo
28
+ self.glyph_mode = glyph_mode
29
+ self.widget_registry = widget_registry or create_widget_registry()
30
+ self.provider_registry = provider_registry or create_provider_registry()
31
+ self.loader = ScreenSpecLoader(self.widget_registry, self.provider_registry)
32
+ self.engine = AppEngine()
33
+ self.watcher = SpecWatcher(cli_watch=watch, allow_watch=not bundled_demo)
34
+ self.loaded_files: list[Path] = []
35
+ self._bootstrap_runtime()
36
+
37
+ @property
38
+ def screen_buffer(self):
39
+ return self.engine.screen_buffer
40
+
41
+ @property
42
+ def input_manager(self):
43
+ return self.engine.input_manager
44
+
45
+ @property
46
+ def environment(self):
47
+ return self.engine.environment
48
+
49
+ def _bootstrap_runtime(
50
+ self,
51
+ *,
52
+ preserved_state: dict | None = None,
53
+ preserved_stack: list[str] | None = None,
54
+ preserved_focus: str | None = None,
55
+ preserved_focus_map: dict[str, str] | None = None,
56
+ ) -> None:
57
+ runtime = build_runtime(
58
+ spec_path=self.spec_path,
59
+ loader=self.loader,
60
+ preserved_state=preserved_state,
61
+ preserved_stack=preserved_stack,
62
+ preserved_focus=preserved_focus,
63
+ preserved_focus_map=preserved_focus_map,
64
+ glyph_mode=self.glyph_mode,
65
+ )
66
+ self.store = runtime.store
67
+ self.router = runtime.router
68
+ self.root_widget = runtime.root_widget
69
+ self.provider_manager = runtime.provider_manager
70
+ self.loaded_files = runtime.loaded_files
71
+ self.watcher.configure(
72
+ spec_path=self.spec_path,
73
+ loaded_files=self.loaded_files,
74
+ dev_spec=runtime.dev_spec,
75
+ )
76
+
77
+ def debug_snapshot(self) -> dict[str, object]:
78
+ watch = self.watcher.snapshot()
79
+ return {
80
+ "watch_enabled": watch.enabled,
81
+ "watch_interval_s": watch.interval_s,
82
+ "watch_debounce_s": watch.debounce_s,
83
+ "watched_files": watch.watched_files,
84
+ "watched_file_count": len(watch.watched_files),
85
+ "pending_reload": watch.pending_reload,
86
+ "pending_changed_files": watch.pending_changed_files,
87
+ "reload_count": watch.reload_count,
88
+ "last_reload_at": watch.last_reload_at,
89
+ "last_reload_error": watch.last_reload_error,
90
+ **self.engine.stats.as_dict(),
91
+ }
92
+
93
+ def render_frame(self, *, width: int | None = None, height: int | None = None, flush: bool = False):
94
+ return self.engine.render_frame(self, width=width, height=height, flush=flush)
95
+
96
+ def preview(self, *, width: int = 100, height: int = 32, frames: int = 1, route: str | None = None) -> str:
97
+ if route:
98
+ self.root_widget.route_set(route, self.root_widget.context)
99
+ self.engine.activate_providers(self)
100
+ try:
101
+ for _ in range(max(frames, 1)):
102
+ self.render_frame(width=width, height=height, flush=False)
103
+ return self.screen_buffer.to_plain_text()
104
+ finally:
105
+ self.engine.deactivate_providers(self)
106
+
107
+ def reload(self) -> None:
108
+ previous_state = deepcopy(self.store.state)
109
+ previous_stack = list(self.router.stack)
110
+ previous_focus = self.root_widget.context.focused_widget
111
+ previous_focus_map = dict(self.root_widget.state.get("last_focused_by_route", {}))
112
+ old_store = self.store
113
+ old_router = self.router
114
+ old_root_widget = self.root_widget
115
+ old_provider_manager = self.provider_manager
116
+ old_loaded_files = list(self.loaded_files)
117
+ watcher_state = self.watcher.capture_state()
118
+ providers_active = self.engine.providers_active
119
+ self.engine.deactivate_providers(self)
120
+ try:
121
+ self._bootstrap_runtime(
122
+ preserved_state=previous_state,
123
+ preserved_stack=previous_stack,
124
+ preserved_focus=previous_focus,
125
+ preserved_focus_map=previous_focus_map,
126
+ )
127
+ if providers_active:
128
+ self.engine.activate_providers(self)
129
+ self.watcher.mark_reload_success(round(self.engine.elapsed_time, 3))
130
+ except Exception:
131
+ self.store = old_store
132
+ self.router = old_router
133
+ self.root_widget = old_root_widget
134
+ self.provider_manager = old_provider_manager
135
+ self.loaded_files = old_loaded_files
136
+ self.watcher.restore_state(watcher_state)
137
+ if providers_active:
138
+ self.engine.activate_providers(self)
139
+ raise
140
+
141
+ def run(self):
142
+ with self.environment.manage():
143
+ self.engine.activate_providers(self)
144
+ try:
145
+ while True:
146
+ self.engine.dispatch_input(self)
147
+ self.engine.resize_to_terminal()
148
+ if self.watcher.should_reload():
149
+ try:
150
+ self.reload()
151
+ except Exception as exc:
152
+ self.watcher.mark_reload_error(str(exc))
153
+ self.render_frame(flush=True)
154
+ except KeyboardInterrupt:
155
+ pass
156
+ finally:
157
+ self.engine.deactivate_providers(self)
158
+
159
+
160
+ def main():
161
+ argv = normalize_argv()
162
+ parser = build_parser()
163
+ args = parser.parse_args(argv)
164
+
165
+ command = args.command or "run"
166
+ spec_arg = getattr(args, "spec", None)
167
+ spec_context, is_bundled_demo = resolve_spec_context(spec_arg)
168
+
169
+ with spec_context as spec_path:
170
+ if command == "validate":
171
+ loader = ScreenSpecLoader(create_widget_registry(), create_provider_registry())
172
+ messages = loader.validate_spec(spec_path)
173
+ errors = [message for message in messages if message.level == "error"]
174
+ if errors:
175
+ for message in errors:
176
+ print(message.format(), file=sys.stderr)
177
+ raise SystemExit(1)
178
+ print(f"Valid Hatui spec: {spec_path}")
179
+ return
180
+
181
+ app = HatuiApp(
182
+ spec_path=spec_path,
183
+ watch=bool(getattr(args, "watch", False)),
184
+ bundled_demo=is_bundled_demo,
185
+ glyph_mode=preferred_glyph_mode(),
186
+ )
187
+ if command == "preview":
188
+ preview_text = app.preview(
189
+ width=max(args.width, 1),
190
+ height=max(args.height, 1),
191
+ frames=max(args.frames, 1),
192
+ route=args.route,
193
+ )
194
+ write_cli_text(preview_text)
195
+ return
196
+ app.run()
197
+
198
+
199
+ if __name__ == "__main__":
200
+ main()
hatui/core/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Core module, containing base classes and utilities for the application."""
hatui/core/actions.py ADDED
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+
7
+ @dataclass(slots=True)
8
+ class KeyBinding:
9
+ key: str
10
+ modifiers: tuple[str, ...] = ()
11
+ action: str = ""
12
+ payload: dict[str, Any] = field(default_factory=dict)
13
+
14
+ @property
15
+ def chord(self) -> str:
16
+ if not self.modifiers:
17
+ return self.key
18
+ return "+".join([*self.modifiers, self.key])
19
+
20
+
21
+ def normalize_chord(key: str, modifiers: list[str] | tuple[str, ...] | None = None) -> str:
22
+ parts = [
23
+ part.strip().lower()
24
+ for part in (modifiers or [])
25
+ if part and part.strip().lower() != "arrow"
26
+ ]
27
+ key_name = key.strip().lower()
28
+ if key_name:
29
+ parts.append(key_name)
30
+ return "+".join(parts)
31
+
32
+
33
+ def parse_keybinding(spec: str | dict[str, Any]) -> KeyBinding:
34
+ if isinstance(spec, str):
35
+ return KeyBinding(key=spec.strip().lower())
36
+
37
+ chord = str(spec.get("key", "")).strip().lower()
38
+ if not chord:
39
+ raise ValueError("Keybinding spec missing 'key'")
40
+
41
+ parts = [part for part in chord.split("+") if part]
42
+ key = parts[-1]
43
+ modifiers = tuple(parts[:-1])
44
+ action = str(spec.get("action", "")).strip()
45
+ payload = dict(spec.get("payload", {}) or {})
46
+ return KeyBinding(key=key, modifiers=modifiers, action=action, payload=payload)
hatui/core/context.py ADDED
@@ -0,0 +1,33 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Any
3
+
4
+ @dataclass
5
+ class Context:
6
+ """
7
+ A class to represent the context of the application.
8
+ """
9
+ name: str
10
+ version: str
11
+ data: dict[str, Any] = field(default_factory=dict)
12
+
13
+ # Terminal properties
14
+ terminal_width: int = None
15
+ terminal_height:int = None
16
+ delta_time: float = 0.0
17
+ elapsed_time: float = 0.0
18
+ frame: int = 0
19
+ focused_widget: str | None = None
20
+ last_key: str | None = None
21
+ last_modifiers: list[str] = field(default_factory=list)
22
+ render_policy: Any = None
23
+
24
+
25
+ @dataclass
26
+ class Constraints:
27
+ """
28
+ A class to represent constraints for the application.
29
+ """
30
+ max_width: int = None
31
+ max_height: int = None
32
+ min_width: int = None
33
+ min_height: int = None
@@ -0,0 +1,167 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Callable
5
+ import os
6
+ import select
7
+ import sys
8
+
9
+
10
+ @dataclass
11
+ class InputEvent:
12
+ event_type: str
13
+ key: str
14
+ modifiers: list[str]
15
+ callback: Callable
16
+
17
+
18
+ class InputManager:
19
+ def __init__(self):
20
+ self.events: list[InputEvent] = []
21
+ self.is_windows = os.name == "nt"
22
+
23
+ def on(self, event_type: str, key: str, modifiers: list[str], callback: Callable):
24
+ self.events.append(InputEvent(event_type, key, modifiers, callback))
25
+
26
+ def emit_event(self, event_type: str, key: str, modifiers: list[str]):
27
+ for event in self.events:
28
+ if event.event_type == event_type and event.key == key and event.modifiers == modifiers:
29
+ event.callback()
30
+
31
+ def poll_input(self, timeout: float = 0.05) -> tuple[str, list[str]] | None:
32
+ raw = self._read_input(timeout)
33
+ if raw is None:
34
+ return None
35
+ key, modifiers = self._parse_key(raw)
36
+ self.emit_event("keypress", key, modifiers)
37
+ return key, modifiers
38
+
39
+ def _read_input(self, timeout: float) -> str | None:
40
+ if self.is_windows:
41
+ return self._read_input_windows(timeout)
42
+ return self._read_input_posix(timeout)
43
+
44
+ def _read_input_posix(self, timeout: float) -> str | None:
45
+ fd = sys.stdin.fileno()
46
+ ready, _, _ = select.select([fd], [], [], timeout)
47
+ if not ready:
48
+ return None
49
+
50
+ first = os.read(fd, 1).decode(errors="ignore")
51
+ if first != "\x1b":
52
+ return first
53
+ return first + self._read_ready_chars(fd, 0.02, settle_timeout=0.005)
54
+
55
+ def _read_ready_chars(self, fd: int, timeout: float, settle_timeout: float = 0.01) -> str:
56
+ chars: list[str] = []
57
+ ready, _, _ = select.select([fd], [], [], timeout)
58
+ while ready:
59
+ chars.append(os.read(fd, 32).decode(errors="ignore"))
60
+ ready, _, _ = select.select([fd], [], [], settle_timeout)
61
+ return "".join(chars)
62
+
63
+ def _read_input_windows(self, timeout: float) -> str | None:
64
+ import time
65
+ import msvcrt
66
+
67
+ deadline = time.monotonic() + max(timeout, 0.0)
68
+ while time.monotonic() < deadline:
69
+ if not msvcrt.kbhit():
70
+ time.sleep(min(0.005, timeout))
71
+ continue
72
+ first = msvcrt.getwch()
73
+ if first in ("\x00", "\xe0"):
74
+ second = msvcrt.getwch()
75
+ return self._windows_extended_key(first + second)
76
+ return first
77
+ return None
78
+
79
+ def _windows_extended_key(self, raw: str) -> str:
80
+ extended = {
81
+ "\xe0H": "\x1b[A",
82
+ "\xe0P": "\x1b[B",
83
+ "\xe0M": "\x1b[C",
84
+ "\xe0K": "\x1b[D",
85
+ "\x00H": "\x1b[A",
86
+ "\x00P": "\x1b[B",
87
+ "\x00M": "\x1b[C",
88
+ "\x00K": "\x1b[D",
89
+ "\x00\x0f": "\x1b[Z",
90
+ }
91
+ return extended.get(raw, raw)
92
+
93
+ def _parse_key(self, raw: str) -> tuple[str, list[str]]:
94
+ if raw == "\x03":
95
+ raise KeyboardInterrupt("Ctrl+C pressed")
96
+
97
+ arrow_map = {
98
+ "\x1b[A": "up",
99
+ "\x1b[B": "down",
100
+ "\x1b[C": "right",
101
+ "\x1b[D": "left",
102
+ "\x1bOA": "up",
103
+ "\x1bOB": "down",
104
+ "\x1bOC": "right",
105
+ "\x1bOD": "left",
106
+ }
107
+ if raw in arrow_map:
108
+ return arrow_map[raw], []
109
+
110
+ if raw == "\x1b[Z":
111
+ return "tab", ["shift"]
112
+
113
+ if raw == "\t":
114
+ return "tab", []
115
+
116
+ if raw in ("\r", "\n"):
117
+ return "enter", []
118
+
119
+ if raw in ("\x7f", "\b", "\x08"):
120
+ return "backspace", []
121
+
122
+ if raw == " ":
123
+ return "space", []
124
+
125
+ if len(raw) == 2 and raw.startswith("\x1b") and raw[1].isprintable():
126
+ key = raw[1].lower()
127
+ modifiers = ["alt"]
128
+ if raw[1].isalpha() and raw[1].isupper():
129
+ modifiers.append("shift")
130
+ return key, modifiers
131
+
132
+ if len(raw) == 1 and 0 < ord(raw) < 27:
133
+ return chr(ord(raw) + 96), ["ctrl"]
134
+
135
+ if raw == "\x1b":
136
+ return "escape", []
137
+
138
+ if raw.startswith("\x1b[1;") and raw.endswith(("A", "B", "C", "D")):
139
+ return self._parse_csi_arrow(raw)
140
+
141
+ if raw.startswith("\x1b"):
142
+ return "escape", []
143
+
144
+ modifiers: list[str] = []
145
+ if raw.isalpha() and raw.isupper():
146
+ modifiers.append("shift")
147
+ return raw.lower(), modifiers
148
+
149
+ def _parse_csi_arrow(self, raw: str) -> tuple[str, list[str]]:
150
+ modifier_map = {
151
+ "2": ["shift"],
152
+ "3": ["alt"],
153
+ "4": ["shift", "alt"],
154
+ "5": ["ctrl"],
155
+ "6": ["shift", "ctrl"],
156
+ "7": ["alt", "ctrl"],
157
+ "8": ["shift", "alt", "ctrl"],
158
+ }
159
+ suffix = raw[-1]
160
+ key = {
161
+ "A": "up",
162
+ "B": "down",
163
+ "C": "right",
164
+ "D": "left",
165
+ }[suffix]
166
+ code = raw[4:-1]
167
+ return key, modifier_map.get(code, [])
@@ -0,0 +1,150 @@
1
+ import sys
2
+ from contextlib import contextmanager
3
+ from dataclasses import dataclass, field
4
+
5
+ from hatui.core.style import Style, ansi_sequence
6
+
7
+
8
+ @dataclass
9
+ class Cell:
10
+ """
11
+ A class to represent a single cell in the screen buffer, aka a single character.
12
+ """
13
+ char: str = ' '
14
+ style: Style = field(default_factory=Style)
15
+
16
+
17
+ DEFAULT_STYLE = Style()
18
+
19
+ class ScreenBuffer:
20
+ """
21
+ A class to represent the 2D screen buffer
22
+ """
23
+ def __init__(self, width: int, height: int):
24
+ self.width = width
25
+ self.height = height
26
+ self.buffer = [[Cell() for _ in range(width)] for _ in range(height)]
27
+ self._clip_stack: list[tuple[int, int, int, int]] = []
28
+ self._style_cache: dict[tuple[str, str], Style] = {("default", "default"): DEFAULT_STYLE}
29
+
30
+ def resize(self, width: int, height: int):
31
+ """
32
+ Resize the screen buffer to match the current terminal dimensions.
33
+ """
34
+ self.width = width
35
+ self.height = height
36
+ self.buffer = [[Cell() for _ in range(width)] for _ in range(height)]
37
+ self._clip_stack.clear()
38
+
39
+ def clear(self):
40
+ """
41
+ Clear the screen buffer by resetting all cells to default values.
42
+ """
43
+ for y in range(self.height):
44
+ for x in range(self.width):
45
+ cell = self.buffer[y][x]
46
+ cell.char = " "
47
+ cell.style = DEFAULT_STYLE
48
+
49
+ def _normalized_clip(self, x: int, y: int, width: int, height: int) -> tuple[int, int, int, int]:
50
+ left = max(x, 0)
51
+ top = max(y, 0)
52
+ right = min(x + max(width, 0), self.width)
53
+ bottom = min(y + max(height, 0), self.height)
54
+ if right < left:
55
+ right = left
56
+ if bottom < top:
57
+ bottom = top
58
+ return left, top, right, bottom
59
+
60
+ def push_clip(self, x: int, y: int, width: int, height: int):
61
+ clip = self._normalized_clip(x, y, width, height)
62
+ if self._clip_stack:
63
+ left, top, right, bottom = self._clip_stack[-1]
64
+ clip = (
65
+ max(clip[0], left),
66
+ max(clip[1], top),
67
+ min(clip[2], right),
68
+ min(clip[3], bottom),
69
+ )
70
+ self._clip_stack.append(clip)
71
+
72
+ def pop_clip(self):
73
+ if self._clip_stack:
74
+ self._clip_stack.pop()
75
+
76
+ @contextmanager
77
+ def clip(self, x: int, y: int, width: int, height: int):
78
+ self.push_clip(x, y, width, height)
79
+ try:
80
+ yield
81
+ finally:
82
+ self.pop_clip()
83
+
84
+ def _within_clip(self, x: int, y: int) -> bool:
85
+ if not self._clip_stack:
86
+ return True
87
+ left, top, right, bottom = self._clip_stack[-1]
88
+ return left <= x < right and top <= y < bottom
89
+
90
+ def _resolve_style(self, fg_color: str, bg_color: str) -> Style:
91
+ key = (fg_color, bg_color)
92
+ cached = self._style_cache.get(key)
93
+ if cached is None:
94
+ cached = Style(fg_color=fg_color, bg_color=bg_color)
95
+ self._style_cache[key] = cached
96
+ return cached
97
+
98
+ def write(self, x: int, y: int, char: str, fg_color: str = 'default', bg_color: str = 'default', *, style: Style | None = None):
99
+ """
100
+ Write a character to the screen buffer at the specified coordinates.
101
+ """
102
+ if 0 <= x < self.width and 0 <= y < self.height and self._within_clip(x, y):
103
+ cell = self.buffer[y][x]
104
+ cell.char = char
105
+ cell.style = style or self._resolve_style(fg_color, bg_color)
106
+
107
+ def write_text(self, x: int, y: int, text: str, fg_color: str = 'default', bg_color: str = 'default', *, style: Style | None = None):
108
+ """
109
+ Write a string of text to the screen buffer starting at the specified coordinates.
110
+ """
111
+ resolved_style = style or self._resolve_style(fg_color, bg_color)
112
+ for i, char in enumerate(text):
113
+ self.write(x + i, y, char, fg_color, bg_color, style=resolved_style)
114
+
115
+ def write_text_clipped(self, x: int, y: int, text: str, width: int, fg_color: str = "default", bg_color: str = "default", *, style: Style | None = None):
116
+ self.write_text(x, y, str(text)[: max(width, 0)], fg_color, bg_color, style=style)
117
+
118
+ def fill_rect(self, x: int, y: int, width: int, height: int, char: str = " ", fg_color: str = "default", bg_color: str = "default", *, style: Style | None = None):
119
+ resolved_style = style or self._resolve_style(fg_color, bg_color)
120
+ fill = char[:1] if char else " "
121
+ line = fill * max(width, 0)
122
+ for row in range(max(height, 0)):
123
+ self.write_text(x, y + row, line, fg_color, bg_color, style=resolved_style)
124
+
125
+ def fill_row(self, x: int, y: int, width: int, fg_color: str = "default", bg_color: str = "default", *, style: Style | None = None):
126
+ self.fill_rect(x, y, width, 1, " ", fg_color, bg_color, style=style)
127
+
128
+ def flush(self):
129
+ """
130
+ Flush the screen buffer to the terminal.
131
+ """
132
+ segments = ["\033[H"]
133
+ for row_index, row in enumerate(self.buffer):
134
+ line = [f"\033[{row_index + 1};1H"]
135
+ active_style = None
136
+ for cell in row:
137
+ style = ansi_sequence(cell.style)
138
+ if style != active_style:
139
+ line.append(style)
140
+ active_style = style
141
+ line.append(cell.char)
142
+ line.append("\033[0m")
143
+ segments.append("".join(line))
144
+
145
+ frame = "".join(segments) + "\033[0m"
146
+ sys.stdout.write(frame)
147
+ sys.stdout.flush()
148
+
149
+ def to_plain_text(self) -> str:
150
+ return "\n".join("".join(cell.char for cell in row).rstrip() for row in self.buffer)