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
@@ -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
@@ -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)
@@ -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
+ }