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
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
|
|
5
|
+
from hatui.providers import (
|
|
6
|
+
ClockProvider,
|
|
7
|
+
CommandOutputProvider,
|
|
8
|
+
ComposeProvider,
|
|
9
|
+
ConstantProvider,
|
|
10
|
+
DemoLogsProvider,
|
|
11
|
+
EnvProvider,
|
|
12
|
+
EventStreamProvider,
|
|
13
|
+
FileProvider,
|
|
14
|
+
FrameProvider,
|
|
15
|
+
GridHistoryProvider,
|
|
16
|
+
HostInfoProvider,
|
|
17
|
+
HttpJsonProvider,
|
|
18
|
+
NormalizeStateProvider,
|
|
19
|
+
RandomProvider,
|
|
20
|
+
RecordsProvider,
|
|
21
|
+
RollingWindowProvider,
|
|
22
|
+
BucketProvider,
|
|
23
|
+
SystemStatsProvider,
|
|
24
|
+
TemplateProvider,
|
|
25
|
+
ThresholdProvider,
|
|
26
|
+
TransformProvider,
|
|
27
|
+
WaveformProvider,
|
|
28
|
+
)
|
|
29
|
+
from hatui.runtime.registries import ProviderRegistry, WidgetRegistry
|
|
30
|
+
from hatui.widgets import (
|
|
31
|
+
AlertWidget,
|
|
32
|
+
AlertStackWidget,
|
|
33
|
+
BannerWidget,
|
|
34
|
+
BorderWidget,
|
|
35
|
+
BoxWidget,
|
|
36
|
+
CenterWidget,
|
|
37
|
+
ChartWidget,
|
|
38
|
+
CodeBlockWidget,
|
|
39
|
+
ColumnWidget,
|
|
40
|
+
DividerWidget,
|
|
41
|
+
DiffViewerWidget,
|
|
42
|
+
EventFeedWidget,
|
|
43
|
+
FlowWidget,
|
|
44
|
+
GaugeWidget,
|
|
45
|
+
HeatmapWidget,
|
|
46
|
+
HistogramWidget,
|
|
47
|
+
HexDumpWidget,
|
|
48
|
+
InspectorWidget,
|
|
49
|
+
KVInspectorWidget,
|
|
50
|
+
LabelWidget,
|
|
51
|
+
ListWidget,
|
|
52
|
+
LogWidget,
|
|
53
|
+
MenuWidget,
|
|
54
|
+
MetricGridWidget,
|
|
55
|
+
MiniChartWidget,
|
|
56
|
+
ModalHostWidget,
|
|
57
|
+
ModalWidget,
|
|
58
|
+
ParagraphWidget,
|
|
59
|
+
ProgressBarWidget,
|
|
60
|
+
RowWidget,
|
|
61
|
+
ScrollWidget,
|
|
62
|
+
SparklineWidget,
|
|
63
|
+
StatusMatrixWidget,
|
|
64
|
+
StatusStripWidget,
|
|
65
|
+
StatWidget,
|
|
66
|
+
SignalStripWidget,
|
|
67
|
+
TableWidget,
|
|
68
|
+
TabsWidget,
|
|
69
|
+
TextWidget,
|
|
70
|
+
TimelineWidget,
|
|
71
|
+
TreeWidget,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
GENERIC_WIDGET_KEYS = {"focusable", "selectable", "focus_fg_color", "focus_bg_color", "keybindings"}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _clean(spec: dict, excluded: set[str]) -> dict:
|
|
78
|
+
return {key: deepcopy(value) for key, value in spec.items() if key not in excluded}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _build_single_child(spec: dict, loader, widget_cls):
|
|
82
|
+
kwargs = _clean(spec, {"type", "weight", "child", *GENERIC_WIDGET_KEYS})
|
|
83
|
+
child = loader.build_widget(spec["child"]) if "child" in spec else None
|
|
84
|
+
return widget_cls(child=child, **kwargs)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _build_center(spec: dict, loader):
|
|
88
|
+
kwargs = _clean(spec, {"type", "weight", "child", *GENERIC_WIDGET_KEYS})
|
|
89
|
+
child = loader.build_widget(spec["child"]) if "child" in spec else None
|
|
90
|
+
children = [child] if child is not None else []
|
|
91
|
+
return CenterWidget(children=children, **kwargs)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _build_row(spec: dict, loader):
|
|
95
|
+
children = []
|
|
96
|
+
for child_spec in spec.get("children", []):
|
|
97
|
+
child = loader.build_widget(child_spec)
|
|
98
|
+
children.append((child, child_spec.get("weight", 1)))
|
|
99
|
+
kwargs = _clean(spec, {"type", "weight", "children", *GENERIC_WIDGET_KEYS})
|
|
100
|
+
return RowWidget(children=children, **kwargs)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _build_column(spec: dict, loader):
|
|
104
|
+
children = []
|
|
105
|
+
for child_spec in spec.get("children", []):
|
|
106
|
+
child = loader.build_widget(child_spec)
|
|
107
|
+
children.append((child, child_spec.get("weight", 1)))
|
|
108
|
+
kwargs = _clean(spec, {"type", "weight", "children", *GENERIC_WIDGET_KEYS})
|
|
109
|
+
return ColumnWidget(children=children, **kwargs)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _build_tabs(spec: dict, loader):
|
|
113
|
+
tabs = []
|
|
114
|
+
for tab_spec in spec.get("tabs", []):
|
|
115
|
+
title = tab_spec["title"]
|
|
116
|
+
route = tab_spec.get("route", title.lower())
|
|
117
|
+
child_spec = tab_spec.get("child") or tab_spec.get("screen")
|
|
118
|
+
if child_spec is None:
|
|
119
|
+
raise ValueError("Tab spec requires 'child' or 'screen'")
|
|
120
|
+
tabs.append((title, loader.build_widget(child_spec), route))
|
|
121
|
+
kwargs = _clean(spec, {"type", "weight", "tabs", *GENERIC_WIDGET_KEYS})
|
|
122
|
+
return TabsWidget(tabs=tabs, **kwargs)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _build_modal_host(spec: dict, loader):
|
|
126
|
+
child_spec = spec.get("child") or spec.get("screen")
|
|
127
|
+
child = loader.build_widget(child_spec) if child_spec is not None else None
|
|
128
|
+
modals = []
|
|
129
|
+
for modal_spec in spec.get("modals", []):
|
|
130
|
+
route = modal_spec.get("route")
|
|
131
|
+
child_spec = modal_spec.get("child") or modal_spec.get("screen") or modal_spec.get("modal")
|
|
132
|
+
if not route or child_spec is None:
|
|
133
|
+
raise ValueError("Modal host entries require 'route' and 'child'/'screen'/'modal'")
|
|
134
|
+
modals.append((route, loader.build_widget(child_spec)))
|
|
135
|
+
kwargs = _clean(spec, {"type", "weight", "child", "screen", "modals", *GENERIC_WIDGET_KEYS})
|
|
136
|
+
return ModalHostWidget(child=child, modals=modals, **kwargs)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _build_plain(spec: dict, loader, widget_cls):
|
|
140
|
+
kwargs = _clean(spec, {"type", "weight", *GENERIC_WIDGET_KEYS})
|
|
141
|
+
return widget_cls(**kwargs)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def create_widget_registry() -> WidgetRegistry:
|
|
145
|
+
registry = WidgetRegistry()
|
|
146
|
+
registry.register("row", _build_row, widget_cls=RowWidget, allowed_keys={"children"}, required_keys={"children"})
|
|
147
|
+
registry.register("column", _build_column, widget_cls=ColumnWidget, allowed_keys={"children"}, required_keys={"children"})
|
|
148
|
+
registry.register("tabs", _build_tabs, widget_cls=TabsWidget, allowed_keys={"tabs"}, required_keys={"tabs"})
|
|
149
|
+
registry.register("modal_host", _build_modal_host, widget_cls=ModalHostWidget, allowed_keys={"child", "screen", "modals"}, required_keys={"modals"})
|
|
150
|
+
registry.register("box", lambda spec, loader: _build_single_child(spec, loader, BoxWidget), widget_cls=BoxWidget, allowed_keys={"child"})
|
|
151
|
+
registry.register("border", lambda spec, loader: _build_single_child(spec, loader, BorderWidget), widget_cls=BorderWidget, allowed_keys={"child"})
|
|
152
|
+
registry.register("center", _build_center, widget_cls=CenterWidget, allowed_keys={"child"})
|
|
153
|
+
registry.register("scroll", lambda spec, loader: _build_single_child(spec, loader, ScrollWidget), widget_cls=ScrollWidget, allowed_keys={"child"})
|
|
154
|
+
registry.register("modal", lambda spec, loader: _build_single_child(spec, loader, ModalWidget), widget_cls=ModalWidget, allowed_keys={"child"})
|
|
155
|
+
|
|
156
|
+
registry.register("text", lambda spec, loader: _build_plain(spec, loader, TextWidget), widget_cls=TextWidget)
|
|
157
|
+
registry.register("label", lambda spec, loader: _build_plain(spec, loader, LabelWidget), widget_cls=LabelWidget)
|
|
158
|
+
registry.register("list", lambda spec, loader: _build_plain(spec, loader, ListWidget), widget_cls=ListWidget)
|
|
159
|
+
registry.register("tree", lambda spec, loader: _build_plain(spec, loader, TreeWidget), widget_cls=TreeWidget)
|
|
160
|
+
registry.register("menu", lambda spec, loader: _build_plain(spec, loader, MenuWidget), widget_cls=ListWidget)
|
|
161
|
+
registry.register("paragraph", lambda spec, loader: _build_plain(spec, loader, ParagraphWidget), widget_cls=ParagraphWidget)
|
|
162
|
+
registry.register("banner", lambda spec, loader: _build_plain(spec, loader, BannerWidget), widget_cls=BannerWidget)
|
|
163
|
+
registry.register("alert", lambda spec, loader: _build_plain(spec, loader, AlertWidget), widget_cls=AlertWidget)
|
|
164
|
+
registry.register("status_strip", lambda spec, loader: _build_plain(spec, loader, StatusStripWidget), widget_cls=StatusStripWidget)
|
|
165
|
+
registry.register("signal_strip", lambda spec, loader: _build_plain(spec, loader, SignalStripWidget), widget_cls=SignalStripWidget)
|
|
166
|
+
registry.register("status_matrix", lambda spec, loader: _build_plain(spec, loader, StatusMatrixWidget), widget_cls=StatusMatrixWidget)
|
|
167
|
+
registry.register("alert_stack", lambda spec, loader: _build_plain(spec, loader, AlertStackWidget), widget_cls=ListWidget, allowed_keys={"source_width"})
|
|
168
|
+
registry.register("kv_inspector", lambda spec, loader: _build_plain(spec, loader, KVInspectorWidget), widget_cls=KVInspectorWidget)
|
|
169
|
+
|
|
170
|
+
registry.register("stat", lambda spec, loader: _build_plain(spec, loader, StatWidget), widget_cls=StatWidget)
|
|
171
|
+
registry.register("progress_bar", lambda spec, loader: _build_plain(spec, loader, ProgressBarWidget), widget_cls=ProgressBarWidget)
|
|
172
|
+
registry.register("sparkline", lambda spec, loader: _build_plain(spec, loader, SparklineWidget), widget_cls=SparklineWidget)
|
|
173
|
+
registry.register("histogram", lambda spec, loader: _build_plain(spec, loader, HistogramWidget), widget_cls=HistogramWidget)
|
|
174
|
+
registry.register("mini_chart", lambda spec, loader: _build_plain(spec, loader, MiniChartWidget), widget_cls=MiniChartWidget)
|
|
175
|
+
registry.register("metric_grid", lambda spec, loader: _build_plain(spec, loader, MetricGridWidget), widget_cls=MetricGridWidget)
|
|
176
|
+
registry.register("line_chart", lambda spec, loader: _build_plain({**spec, "mode": "line"}, loader, ChartWidget), widget_cls=ChartWidget)
|
|
177
|
+
registry.register("area_chart", lambda spec, loader: _build_plain({**spec, "mode": "area"}, loader, ChartWidget), widget_cls=ChartWidget)
|
|
178
|
+
registry.register("bar_chart", lambda spec, loader: _build_plain({**spec, "mode": "bar"}, loader, ChartWidget), widget_cls=ChartWidget)
|
|
179
|
+
registry.register("gauge", lambda spec, loader: _build_plain(spec, loader, GaugeWidget), widget_cls=GaugeWidget)
|
|
180
|
+
registry.register("heatmap", lambda spec, loader: _build_plain(spec, loader, HeatmapWidget), widget_cls=HeatmapWidget)
|
|
181
|
+
registry.register("timeline", lambda spec, loader: _build_plain(spec, loader, TimelineWidget), widget_cls=TimelineWidget)
|
|
182
|
+
registry.register("event_feed", lambda spec, loader: _build_plain(spec, loader, EventFeedWidget), widget_cls=EventFeedWidget)
|
|
183
|
+
registry.register("inspector", lambda spec, loader: _build_plain(spec, loader, InspectorWidget), widget_cls=InspectorWidget)
|
|
184
|
+
registry.register("diff_viewer", lambda spec, loader: _build_plain(spec, loader, DiffViewerWidget), widget_cls=DiffViewerWidget)
|
|
185
|
+
registry.register("flow", lambda spec, loader: _build_plain(spec, loader, FlowWidget), widget_cls=FlowWidget)
|
|
186
|
+
|
|
187
|
+
registry.register("log", lambda spec, loader: _build_plain(spec, loader, LogWidget), widget_cls=LogWidget)
|
|
188
|
+
registry.register("table", lambda spec, loader: _build_plain(spec, loader, TableWidget), widget_cls=TableWidget)
|
|
189
|
+
registry.register("code_block", lambda spec, loader: _build_plain(spec, loader, CodeBlockWidget), widget_cls=CodeBlockWidget)
|
|
190
|
+
registry.register("hex_dump", lambda spec, loader: _build_plain(spec, loader, HexDumpWidget), widget_cls=HexDumpWidget)
|
|
191
|
+
registry.register("divider", lambda spec, loader: _build_plain(spec, loader, DividerWidget), widget_cls=DividerWidget)
|
|
192
|
+
return registry
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def create_provider_registry() -> ProviderRegistry:
|
|
196
|
+
registry = ProviderRegistry()
|
|
197
|
+
registry.register("constant", ConstantProvider)
|
|
198
|
+
registry.register("clock", ClockProvider)
|
|
199
|
+
registry.register("frame", FrameProvider)
|
|
200
|
+
registry.register("random", RandomProvider)
|
|
201
|
+
registry.register("waveform", WaveformProvider)
|
|
202
|
+
registry.register("demo_logs", DemoLogsProvider)
|
|
203
|
+
registry.register("env", EnvProvider)
|
|
204
|
+
registry.register("file", FileProvider)
|
|
205
|
+
registry.register("host_info", HostInfoProvider)
|
|
206
|
+
registry.register("system_stats", SystemStatsProvider)
|
|
207
|
+
registry.register("command_output", CommandOutputProvider)
|
|
208
|
+
registry.register("http_json", HttpJsonProvider)
|
|
209
|
+
registry.register("transform", TransformProvider)
|
|
210
|
+
registry.register("template", TemplateProvider)
|
|
211
|
+
registry.register("compose", ComposeProvider)
|
|
212
|
+
registry.register("records", RecordsProvider)
|
|
213
|
+
registry.register("rolling_window", RollingWindowProvider)
|
|
214
|
+
registry.register("threshold", ThresholdProvider)
|
|
215
|
+
registry.register("event_stream", EventStreamProvider)
|
|
216
|
+
registry.register("grid_history", GridHistoryProvider)
|
|
217
|
+
registry.register("normalize_state", NormalizeStateProvider)
|
|
218
|
+
registry.register("bucket", BucketProvider)
|
|
219
|
+
return registry
|
hatui/runtime/engine.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from hatui.core.input_manager import InputManager
|
|
8
|
+
from hatui.core.screen_buffer import ScreenBuffer
|
|
9
|
+
from hatui.core.terminal_env import TerminalEnvironment
|
|
10
|
+
from hatui.runtime.bindings import set_path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class FrameStats:
|
|
15
|
+
provider_ms: float = 0.0
|
|
16
|
+
widget_update_ms: float = 0.0
|
|
17
|
+
focus_sync_ms: float = 0.0
|
|
18
|
+
allocate_ms: float = 0.0
|
|
19
|
+
layout_ms: float = 0.0
|
|
20
|
+
paint_ms: float = 0.0
|
|
21
|
+
frame_ms: float = 0.0
|
|
22
|
+
|
|
23
|
+
def as_dict(self) -> dict[str, float]:
|
|
24
|
+
return {
|
|
25
|
+
"provider_ms": self.provider_ms,
|
|
26
|
+
"widget_update_ms": self.widget_update_ms,
|
|
27
|
+
"focus_sync_ms": self.focus_sync_ms,
|
|
28
|
+
"allocate_ms": self.allocate_ms,
|
|
29
|
+
"layout_ms": self.layout_ms,
|
|
30
|
+
"paint_ms": self.paint_ms,
|
|
31
|
+
"frame_ms": self.frame_ms,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AppEngine:
|
|
36
|
+
def __init__(self):
|
|
37
|
+
width, height = self.get_terminal_size()
|
|
38
|
+
self.screen_buffer = ScreenBuffer(width=width, height=height)
|
|
39
|
+
self.environment = TerminalEnvironment()
|
|
40
|
+
self.input_manager = InputManager()
|
|
41
|
+
self.stats = FrameStats()
|
|
42
|
+
self._last_frame_time = time.monotonic()
|
|
43
|
+
self._start_time = self._last_frame_time
|
|
44
|
+
self._providers_active = False
|
|
45
|
+
|
|
46
|
+
@staticmethod
|
|
47
|
+
def get_terminal_size() -> tuple[int, int]:
|
|
48
|
+
size = shutil.get_terminal_size(fallback=(100, 32))
|
|
49
|
+
return max(size.columns, 1), max(size.lines, 1)
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def elapsed_time(self) -> float:
|
|
53
|
+
return time.monotonic() - self._start_time
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def providers_active(self) -> bool:
|
|
57
|
+
return self._providers_active
|
|
58
|
+
|
|
59
|
+
def resize_to_terminal(self) -> None:
|
|
60
|
+
width, height = self.get_terminal_size()
|
|
61
|
+
if (width, height) != (self.screen_buffer.width, self.screen_buffer.height):
|
|
62
|
+
self.screen_buffer.resize(width, height)
|
|
63
|
+
|
|
64
|
+
def activate_providers(self, app) -> None:
|
|
65
|
+
if self._providers_active:
|
|
66
|
+
return
|
|
67
|
+
app.provider_manager.setup(app.root_widget.context)
|
|
68
|
+
self._providers_active = True
|
|
69
|
+
|
|
70
|
+
def deactivate_providers(self, app) -> None:
|
|
71
|
+
if not self._providers_active:
|
|
72
|
+
return
|
|
73
|
+
app.provider_manager.teardown(app.root_widget.context)
|
|
74
|
+
self._providers_active = False
|
|
75
|
+
|
|
76
|
+
def dispatch_input(self, app) -> None:
|
|
77
|
+
key_event = self.input_manager.poll_input(timeout=0.05)
|
|
78
|
+
if key_event is None:
|
|
79
|
+
return
|
|
80
|
+
key, modifiers = key_event
|
|
81
|
+
app.root_widget.context.last_key = key
|
|
82
|
+
app.root_widget.context.last_modifiers = list(modifiers)
|
|
83
|
+
app.root_widget.dispatch_key_event(key, modifiers, app.root_widget.context)
|
|
84
|
+
|
|
85
|
+
def render_frame(self, app, *, width: int | None = None, height: int | None = None, flush: bool = False):
|
|
86
|
+
frame_started = time.perf_counter()
|
|
87
|
+
if width is not None and height is not None and (width, height) != (self.screen_buffer.width, self.screen_buffer.height):
|
|
88
|
+
self.screen_buffer.resize(max(width, 1), max(height, 1))
|
|
89
|
+
|
|
90
|
+
self._update_runtime(app)
|
|
91
|
+
|
|
92
|
+
allocate_started = time.perf_counter()
|
|
93
|
+
app.root_widget.allocate(self.screen_buffer.width, self.screen_buffer.height)
|
|
94
|
+
self.stats.allocate_ms = round((time.perf_counter() - allocate_started) * 1000.0, 3)
|
|
95
|
+
|
|
96
|
+
layout_started = time.perf_counter()
|
|
97
|
+
app.root_widget.layout(0, 0, app.root_widget.context)
|
|
98
|
+
self.stats.layout_ms = round((time.perf_counter() - layout_started) * 1000.0, 3)
|
|
99
|
+
|
|
100
|
+
self.screen_buffer.clear()
|
|
101
|
+
paint_started = time.perf_counter()
|
|
102
|
+
app.root_widget.paint(self.screen_buffer, app.root_widget.context)
|
|
103
|
+
self.stats.paint_ms = round((time.perf_counter() - paint_started) * 1000.0, 3)
|
|
104
|
+
self.stats.frame_ms = round((time.perf_counter() - frame_started) * 1000.0, 3)
|
|
105
|
+
|
|
106
|
+
app.root_widget.publish_debug_snapshot(app.root_widget.context)
|
|
107
|
+
set_path(app.root_widget.context.data, "_debug.stats", app.debug_snapshot())
|
|
108
|
+
|
|
109
|
+
if flush:
|
|
110
|
+
self.screen_buffer.flush()
|
|
111
|
+
return self.screen_buffer
|
|
112
|
+
|
|
113
|
+
def _update_runtime(self, app) -> None:
|
|
114
|
+
now = time.monotonic()
|
|
115
|
+
context = app.root_widget.context
|
|
116
|
+
context.delta_time = now - self._last_frame_time
|
|
117
|
+
context.elapsed_time = now - self._start_time
|
|
118
|
+
context.frame += 1
|
|
119
|
+
context.terminal_width = self.screen_buffer.width
|
|
120
|
+
context.terminal_height = self.screen_buffer.height
|
|
121
|
+
context.widget_width = self.screen_buffer.width
|
|
122
|
+
context.widget_height = self.screen_buffer.height
|
|
123
|
+
|
|
124
|
+
provider_started = time.perf_counter()
|
|
125
|
+
app.provider_manager.update(context.delta_time, context)
|
|
126
|
+
self.stats.provider_ms = round((time.perf_counter() - provider_started) * 1000.0, 3)
|
|
127
|
+
|
|
128
|
+
widget_started = time.perf_counter()
|
|
129
|
+
app.root_widget.update(context.delta_time, context)
|
|
130
|
+
self.stats.widget_update_ms = round((time.perf_counter() - widget_started) * 1000.0, 3)
|
|
131
|
+
|
|
132
|
+
focus_started = time.perf_counter()
|
|
133
|
+
app.root_widget.sync_focus(context)
|
|
134
|
+
self.stats.focus_sync_ms = round((time.perf_counter() - focus_started) * 1000.0, 3)
|
|
135
|
+
self._last_frame_time = now
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from typing import Any, Callable
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def _format_percent(value: Any, precision: int = 0) -> str:
|
|
5
|
+
return f"{float(value) * 100:.{precision}f}%"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _format_fixed(value: Any, precision: int = 2) -> str:
|
|
9
|
+
return f"{float(value):.{precision}f}"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _format_upper(value: Any) -> str:
|
|
13
|
+
return str(value).upper()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _format_lower(value: Any) -> str:
|
|
17
|
+
return str(value).lower()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _format_join(value: Any, separator: str = ", ") -> str:
|
|
21
|
+
if not isinstance(value, list):
|
|
22
|
+
return str(value)
|
|
23
|
+
return separator.join(str(item) for item in value)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _format_bytes(value: Any) -> str:
|
|
27
|
+
size = float(value)
|
|
28
|
+
units = ["B", "KB", "MB", "GB", "TB"]
|
|
29
|
+
unit = units[0]
|
|
30
|
+
for unit in units:
|
|
31
|
+
if abs(size) < 1024 or unit == units[-1]:
|
|
32
|
+
break
|
|
33
|
+
size /= 1024
|
|
34
|
+
return f"{size:.1f} {unit}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
FORMATTERS: dict[str, Callable[..., str]] = {
|
|
38
|
+
"str": lambda value, **_: str(value),
|
|
39
|
+
"upper": lambda value, **_: _format_upper(value),
|
|
40
|
+
"lower": lambda value, **_: _format_lower(value),
|
|
41
|
+
"percent": lambda value, precision=0, **_: _format_percent(value, precision=precision),
|
|
42
|
+
"fixed": lambda value, precision=2, **_: _format_fixed(value, precision=precision),
|
|
43
|
+
"join": lambda value, separator=", ", **_: _format_join(value, separator=separator),
|
|
44
|
+
"bytes": lambda value, **_: _format_bytes(value),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def apply_formatter(value: Any, spec: str | dict | None = None) -> Any:
|
|
49
|
+
if spec is None:
|
|
50
|
+
return value
|
|
51
|
+
if isinstance(spec, str):
|
|
52
|
+
formatter = FORMATTERS.get(spec)
|
|
53
|
+
return formatter(value) if formatter else value
|
|
54
|
+
|
|
55
|
+
formatter = FORMATTERS.get(spec.get("name", ""))
|
|
56
|
+
if formatter is None:
|
|
57
|
+
return value
|
|
58
|
+
kwargs = {key: item for key, item in spec.items() if key != "name"}
|
|
59
|
+
return formatter(value, **kwargs)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def apply_template(value: Any, template: str | None = None) -> Any:
|
|
63
|
+
if template is None:
|
|
64
|
+
return value
|
|
65
|
+
return template.format(value=value)
|
hatui/runtime/loader.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
from hatui.core.style import Theme, build_theme
|
|
10
|
+
from hatui.runtime.registries import ProviderRegistry, WidgetRegistry
|
|
11
|
+
from hatui.runtime.validation import SpecValidator
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ScreenSpecLoader:
|
|
15
|
+
def __init__(self, widget_registry: WidgetRegistry, provider_registry: ProviderRegistry):
|
|
16
|
+
self.widget_registry = widget_registry
|
|
17
|
+
self.provider_registry = provider_registry
|
|
18
|
+
self.validator = SpecValidator(widget_registry, provider_registry)
|
|
19
|
+
self.last_loaded_files: list[Path] = []
|
|
20
|
+
|
|
21
|
+
def load_spec(self, spec_path: str | Path) -> dict[str, Any]:
|
|
22
|
+
path = Path(spec_path).resolve()
|
|
23
|
+
spec = self._load_yaml(path)
|
|
24
|
+
loaded_files = {path}
|
|
25
|
+
resolved = self._resolve_includes(spec, path.parent, chain=(path,), loaded_files=loaded_files)
|
|
26
|
+
if not isinstance(resolved, dict):
|
|
27
|
+
raise ValueError("Top-level spec must resolve to an object")
|
|
28
|
+
self.validator.raise_for_errors(resolved, spec_path=path)
|
|
29
|
+
self.last_loaded_files = sorted(loaded_files)
|
|
30
|
+
return resolved
|
|
31
|
+
|
|
32
|
+
def validate_spec(self, spec_path: str | Path) -> list:
|
|
33
|
+
path = Path(spec_path).resolve()
|
|
34
|
+
spec = self._load_yaml(path)
|
|
35
|
+
loaded_files = {path}
|
|
36
|
+
resolved = self._resolve_includes(spec, path.parent, chain=(path,), loaded_files=loaded_files)
|
|
37
|
+
self.last_loaded_files = sorted(loaded_files)
|
|
38
|
+
return self.validator.validate(resolved, spec_path=path)
|
|
39
|
+
|
|
40
|
+
def load_theme(self, spec: dict[str, Any]) -> Theme:
|
|
41
|
+
theme_spec = spec.get("theme", {})
|
|
42
|
+
return build_theme(theme_spec)
|
|
43
|
+
|
|
44
|
+
def load_state(self, spec: dict[str, Any]) -> dict[str, Any]:
|
|
45
|
+
state = spec.get("state", {})
|
|
46
|
+
if not isinstance(state, dict):
|
|
47
|
+
raise ValueError("Top-level 'state' must be an object")
|
|
48
|
+
return state
|
|
49
|
+
|
|
50
|
+
def load_router(self, spec: dict[str, Any]) -> dict[str, Any]:
|
|
51
|
+
router = spec.get("router", {})
|
|
52
|
+
if router is None:
|
|
53
|
+
return {}
|
|
54
|
+
if not isinstance(router, dict):
|
|
55
|
+
raise ValueError("Top-level 'router' must be an object")
|
|
56
|
+
return router
|
|
57
|
+
|
|
58
|
+
def load_dev(self, spec: dict[str, Any]) -> dict[str, Any]:
|
|
59
|
+
dev = spec.get("dev", {})
|
|
60
|
+
if dev is None:
|
|
61
|
+
return {}
|
|
62
|
+
if not isinstance(dev, dict):
|
|
63
|
+
raise ValueError("Top-level 'dev' must be an object")
|
|
64
|
+
return dev
|
|
65
|
+
|
|
66
|
+
def load_providers(self, spec: dict[str, Any]) -> list[Any]:
|
|
67
|
+
providers = []
|
|
68
|
+
for provider_spec in spec.get("providers", []):
|
|
69
|
+
provider_type = provider_spec.get("type")
|
|
70
|
+
if not provider_type:
|
|
71
|
+
raise ValueError("Provider spec missing 'type'")
|
|
72
|
+
providers.append(self.provider_registry.create(provider_type, provider_spec))
|
|
73
|
+
return providers
|
|
74
|
+
|
|
75
|
+
def load_screen(self, spec: dict[str, Any]):
|
|
76
|
+
screen_spec = spec.get("screen")
|
|
77
|
+
if not isinstance(screen_spec, dict):
|
|
78
|
+
raise ValueError("Spec missing top-level 'screen' object")
|
|
79
|
+
return self.build_widget(screen_spec)
|
|
80
|
+
|
|
81
|
+
def build_widget(self, spec: dict[str, Any]):
|
|
82
|
+
widget_type = spec.get("type")
|
|
83
|
+
if not widget_type:
|
|
84
|
+
raise ValueError("Widget spec missing 'type'")
|
|
85
|
+
widget = self.widget_registry.create(widget_type, spec, self)
|
|
86
|
+
widget.configure_interaction(spec)
|
|
87
|
+
weight = spec.get("weight")
|
|
88
|
+
if weight is not None:
|
|
89
|
+
widget.set_layout_weight(weight)
|
|
90
|
+
return widget
|
|
91
|
+
|
|
92
|
+
def _load_yaml(self, path: Path) -> Any:
|
|
93
|
+
with path.open("r", encoding="utf-8") as handle:
|
|
94
|
+
return yaml.safe_load(handle) or {}
|
|
95
|
+
|
|
96
|
+
def _resolve_includes(self, node: Any, base_dir: Path, *, chain: tuple[Path, ...], loaded_files: set[Path]) -> Any:
|
|
97
|
+
if isinstance(node, list):
|
|
98
|
+
resolved_items = []
|
|
99
|
+
for item in node:
|
|
100
|
+
resolved = self._resolve_includes(item, base_dir, chain=chain, loaded_files=loaded_files)
|
|
101
|
+
if isinstance(resolved, list):
|
|
102
|
+
resolved_items.extend(resolved)
|
|
103
|
+
else:
|
|
104
|
+
resolved_items.append(resolved)
|
|
105
|
+
return resolved_items
|
|
106
|
+
|
|
107
|
+
if not isinstance(node, dict):
|
|
108
|
+
return node
|
|
109
|
+
|
|
110
|
+
include_path = node.get("include")
|
|
111
|
+
if include_path is not None:
|
|
112
|
+
include_file = (base_dir / include_path).resolve()
|
|
113
|
+
if include_file in chain:
|
|
114
|
+
joined = " -> ".join(str(path) for path in (*chain, include_file))
|
|
115
|
+
raise ValueError(f"Circular include detected: {joined}")
|
|
116
|
+
|
|
117
|
+
loaded_files.add(include_file)
|
|
118
|
+
included = self._load_yaml(include_file)
|
|
119
|
+
included = self._resolve_includes(
|
|
120
|
+
included,
|
|
121
|
+
include_file.parent,
|
|
122
|
+
chain=(*chain, include_file),
|
|
123
|
+
loaded_files=loaded_files,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
overrides = {key: value for key, value in node.items() if key != "include"}
|
|
127
|
+
if not overrides:
|
|
128
|
+
return deepcopy(included)
|
|
129
|
+
if not isinstance(included, dict):
|
|
130
|
+
raise ValueError(f"Include overrides require an object include: {include_file}")
|
|
131
|
+
|
|
132
|
+
merged = deepcopy(included)
|
|
133
|
+
for key, value in overrides.items():
|
|
134
|
+
merged[key] = self._resolve_includes(value, base_dir, chain=chain, loaded_files=loaded_files)
|
|
135
|
+
return merged
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
key: self._resolve_includes(value, base_dir, chain=chain, loaded_files=loaded_files)
|
|
139
|
+
for key, value in node.items()
|
|
140
|
+
}
|