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.
- hatui/__init__.py +11 -0
- hatui/__main__.py +5 -0
- hatui/app.py +200 -0
- hatui/core/__init__.py +1 -0
- hatui/core/actions.py +46 -0
- hatui/core/context.py +33 -0
- hatui/core/input_manager.py +167 -0
- hatui/core/screen_buffer.py +150 -0
- hatui/core/style.py +381 -0
- hatui/core/terminal_env.py +67 -0
- hatui/core/util.py +0 -0
- hatui/core/widget.py +203 -0
- hatui/demo/__init__.py +1 -0
- hatui/demo/screens/dashboard/modals/debug.yaml +72 -0
- hatui/demo/screens/dashboard/modals/help.yaml +43 -0
- hatui/demo/screens/dashboard/providers/phase8.yaml +429 -0
- hatui/demo/screens/dashboard/providers.yaml +487 -0
- hatui/demo/screens/dashboard/screen.yaml +64 -0
- hatui/demo/screens/dashboard/tabs/analysis.yaml +65 -0
- hatui/demo/screens/dashboard/tabs/forensics.yaml +71 -0
- hatui/demo/screens/dashboard/tabs/hacker.yaml +80 -0
- hatui/demo/screens/dashboard/tabs/logs.yaml +39 -0
- hatui/demo/screens/dashboard/tabs/overview.yaml +180 -0
- hatui/demo/screens/dashboard/tabs/visuals.yaml +84 -0
- hatui/demo/screens/dashboard.yaml +166 -0
- hatui/demo_assets.py +16 -0
- hatui/providers/__init__.py +54 -0
- hatui/providers/base.py +61 -0
- hatui/providers/builtins.py +546 -0
- hatui/providers/helpers.py +169 -0
- hatui/runtime/__init__.py +24 -0
- hatui/runtime/action_registry.py +102 -0
- hatui/runtime/bindings.py +40 -0
- hatui/runtime/bootstrap.py +94 -0
- hatui/runtime/cli.py +66 -0
- hatui/runtime/defaults.py +219 -0
- hatui/runtime/engine.py +135 -0
- hatui/runtime/formatters.py +65 -0
- hatui/runtime/loader.py +140 -0
- hatui/runtime/provider_manager.py +138 -0
- hatui/runtime/registries.py +65 -0
- hatui/runtime/render_policy.py +144 -0
- hatui/runtime/router.py +74 -0
- hatui/runtime/store.py +29 -0
- hatui/runtime/validation.py +433 -0
- hatui/runtime/watcher.py +167 -0
- hatui/widgets/__init__.py +89 -0
- hatui/widgets/alert_stack_widget.py +64 -0
- hatui/widgets/alert_widget.py +83 -0
- hatui/widgets/banner_widget.py +76 -0
- hatui/widgets/border_widget.py +93 -0
- hatui/widgets/box_widget.py +81 -0
- hatui/widgets/center_widget.py +29 -0
- hatui/widgets/chart_widget.py +222 -0
- hatui/widgets/code_block_widget.py +91 -0
- hatui/widgets/column_widget.py +48 -0
- hatui/widgets/diff_viewer_widget.py +88 -0
- hatui/widgets/divider_widget.py +66 -0
- hatui/widgets/event_feed_widget.py +72 -0
- hatui/widgets/flow_widget.py +77 -0
- hatui/widgets/gauge_widget.py +109 -0
- hatui/widgets/heatmap_widget.py +153 -0
- hatui/widgets/hex_dump_widget.py +91 -0
- hatui/widgets/histogram_widget.py +96 -0
- hatui/widgets/inspector_widget.py +85 -0
- hatui/widgets/kv_inspector_widget.py +134 -0
- hatui/widgets/label_widget.py +99 -0
- hatui/widgets/list_widget.py +185 -0
- hatui/widgets/log_widget.py +106 -0
- hatui/widgets/menu_widget.py +31 -0
- hatui/widgets/metric_grid_widget.py +103 -0
- hatui/widgets/mini_chart_widget.py +79 -0
- hatui/widgets/modal_host_widget.py +85 -0
- hatui/widgets/modal_widget.py +112 -0
- hatui/widgets/paragraph_widget.py +94 -0
- hatui/widgets/progress_bar_widget.py +124 -0
- hatui/widgets/root_services.py +267 -0
- hatui/widgets/root_widget.py +130 -0
- hatui/widgets/row_widget.py +48 -0
- hatui/widgets/scroll_widget.py +224 -0
- hatui/widgets/selection.py +52 -0
- hatui/widgets/signal_strip_widget.py +79 -0
- hatui/widgets/sparkline_widget.py +84 -0
- hatui/widgets/stat_widget.py +107 -0
- hatui/widgets/status_matrix_widget.py +234 -0
- hatui/widgets/status_strip_widget.py +85 -0
- hatui/widgets/table_widget.py +239 -0
- hatui/widgets/tabs_widget.py +188 -0
- hatui/widgets/text_widget.py +64 -0
- hatui/widgets/timeline_widget.py +81 -0
- hatui/widgets/tree_widget.py +341 -0
- hatui/widgets/visualization.py +110 -0
- hatui_kit-0.1.0.dist-info/METADATA +154 -0
- hatui_kit-0.1.0.dist-info/RECORD +97 -0
- hatui_kit-0.1.0.dist-info/WHEEL +4 -0
- hatui_kit-0.1.0.dist-info/entry_points.txt +2 -0
- hatui_kit-0.1.0.dist-info/licenses/LICENSE +21 -0
hatui/__init__.py
ADDED
hatui/__main__.py
ADDED
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)
|