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,169 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from hatui.runtime.bindings import resolve_path
|
|
7
|
+
from hatui.runtime.formatters import apply_formatter, apply_template
|
|
8
|
+
|
|
9
|
+
_MISSING = object()
|
|
10
|
+
|
|
11
|
+
def resolve_value_spec(
|
|
12
|
+
spec: Any,
|
|
13
|
+
data: dict[str, Any],
|
|
14
|
+
*,
|
|
15
|
+
current: Any = None,
|
|
16
|
+
default: Any = None,
|
|
17
|
+
string_mode: str = "path",
|
|
18
|
+
) -> Any:
|
|
19
|
+
if isinstance(spec, Mapping):
|
|
20
|
+
value = _resolve_mapping_value(
|
|
21
|
+
spec,
|
|
22
|
+
data,
|
|
23
|
+
current=current,
|
|
24
|
+
default=default,
|
|
25
|
+
string_mode=string_mode,
|
|
26
|
+
)
|
|
27
|
+
operations = spec.get("operations", [])
|
|
28
|
+
if operations:
|
|
29
|
+
value = apply_operations(value, operations, data)
|
|
30
|
+
return value
|
|
31
|
+
|
|
32
|
+
if isinstance(spec, str):
|
|
33
|
+
resolved = resolve_path(data, spec, _MISSING)
|
|
34
|
+
if resolved is not _MISSING:
|
|
35
|
+
return resolved
|
|
36
|
+
if string_mode == "literal_fallback":
|
|
37
|
+
return spec
|
|
38
|
+
if string_mode == "literal":
|
|
39
|
+
return spec
|
|
40
|
+
return default
|
|
41
|
+
|
|
42
|
+
if spec is None:
|
|
43
|
+
return default
|
|
44
|
+
|
|
45
|
+
return spec
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def resolve_mapping(
|
|
49
|
+
mapping: Mapping[str, Any],
|
|
50
|
+
data: dict[str, Any],
|
|
51
|
+
*,
|
|
52
|
+
current: Any = None,
|
|
53
|
+
string_mode: str = "path",
|
|
54
|
+
) -> dict[str, Any]:
|
|
55
|
+
return {
|
|
56
|
+
key: resolve_value_spec(source, data, current=current, string_mode=string_mode)
|
|
57
|
+
for key, source in mapping.items()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def resolve_items(
|
|
62
|
+
items: list[Any],
|
|
63
|
+
data: dict[str, Any],
|
|
64
|
+
*,
|
|
65
|
+
current: Any = None,
|
|
66
|
+
string_mode: str = "literal_fallback",
|
|
67
|
+
) -> list[Any]:
|
|
68
|
+
resolved: list[Any] = []
|
|
69
|
+
for item in items:
|
|
70
|
+
if isinstance(item, Mapping):
|
|
71
|
+
resolved.append(resolve_mapping(item, data, current=current, string_mode=string_mode))
|
|
72
|
+
else:
|
|
73
|
+
resolved.append(resolve_value_spec(item, data, current=current, string_mode=string_mode))
|
|
74
|
+
return resolved
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def render_template(template: str, mapping: Mapping[str, Any], data: dict[str, Any], *, current: Any = None) -> str:
|
|
78
|
+
values = resolve_mapping(mapping, data, current=current, string_mode="path")
|
|
79
|
+
return template.format(**values)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def apply_operations(value: Any, operations: list[Any], data: dict[str, Any]) -> Any:
|
|
83
|
+
current = value
|
|
84
|
+
for operation in operations:
|
|
85
|
+
if not isinstance(operation, Mapping):
|
|
86
|
+
continue
|
|
87
|
+
name = operation.get("name")
|
|
88
|
+
if name == "path":
|
|
89
|
+
current = resolve_path(current, operation.get("path"), operation.get("default"))
|
|
90
|
+
elif name == "default":
|
|
91
|
+
if current is None:
|
|
92
|
+
current = operation.get("value")
|
|
93
|
+
elif name == "formatter":
|
|
94
|
+
formatter = operation.get("formatter")
|
|
95
|
+
if formatter is None:
|
|
96
|
+
formatter = {key: item for key, item in operation.items() if key != "name"}
|
|
97
|
+
current = apply_formatter(current, formatter)
|
|
98
|
+
elif name == "template":
|
|
99
|
+
current = apply_template(current, operation.get("template"))
|
|
100
|
+
elif name == "sort":
|
|
101
|
+
current = _sort_values(current, operation)
|
|
102
|
+
elif name == "reverse":
|
|
103
|
+
if isinstance(current, list):
|
|
104
|
+
current = list(reversed(current))
|
|
105
|
+
elif name == "slice":
|
|
106
|
+
if isinstance(current, (list, str)):
|
|
107
|
+
start = operation.get("start")
|
|
108
|
+
end = operation.get("end")
|
|
109
|
+
current = current[slice(start, end)]
|
|
110
|
+
elif name == "take":
|
|
111
|
+
if isinstance(current, list):
|
|
112
|
+
current = current[: int(operation.get("count", 0))]
|
|
113
|
+
elif name == "join":
|
|
114
|
+
separator = operation.get("separator", ", ")
|
|
115
|
+
if isinstance(current, list):
|
|
116
|
+
current = separator.join(str(item) for item in current)
|
|
117
|
+
elif name == "mapping":
|
|
118
|
+
current = resolve_mapping(operation.get("mapping", {}), data, current=current, string_mode="path")
|
|
119
|
+
elif name == "template_map":
|
|
120
|
+
current = render_template(
|
|
121
|
+
operation.get("template", ""),
|
|
122
|
+
operation.get("mapping", {}),
|
|
123
|
+
data,
|
|
124
|
+
current=current,
|
|
125
|
+
)
|
|
126
|
+
elif name == "items":
|
|
127
|
+
current = resolve_items(operation.get("items", []), data, current=current, string_mode="literal_fallback")
|
|
128
|
+
return current
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _resolve_mapping_value(
|
|
132
|
+
spec: Mapping[str, Any],
|
|
133
|
+
data: dict[str, Any],
|
|
134
|
+
*,
|
|
135
|
+
current: Any,
|
|
136
|
+
default: Any,
|
|
137
|
+
string_mode: str,
|
|
138
|
+
) -> Any:
|
|
139
|
+
if "value" in spec:
|
|
140
|
+
value = spec.get("value")
|
|
141
|
+
elif spec.get("current") is True:
|
|
142
|
+
value = current
|
|
143
|
+
elif "path" in spec or "source" in spec:
|
|
144
|
+
value = resolve_path(data, spec.get("path") or spec.get("source"), spec.get("default", default))
|
|
145
|
+
elif "mapping" in spec and "template" in spec:
|
|
146
|
+
value = render_template(spec.get("template", ""), spec.get("mapping", {}), data, current=current)
|
|
147
|
+
elif "mapping" in spec:
|
|
148
|
+
value = resolve_mapping(spec.get("mapping", {}), data, current=current, string_mode=string_mode)
|
|
149
|
+
elif "items" in spec:
|
|
150
|
+
value = resolve_items(spec.get("items", []), data, current=current, string_mode="literal_fallback")
|
|
151
|
+
else:
|
|
152
|
+
value = default
|
|
153
|
+
|
|
154
|
+
if "formatter" in spec:
|
|
155
|
+
value = apply_formatter(value, spec.get("formatter"))
|
|
156
|
+
if "template" in spec and "mapping" not in spec:
|
|
157
|
+
value = apply_template(value, spec.get("template"))
|
|
158
|
+
return value
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _sort_values(value: Any, operation: Mapping[str, Any]) -> Any:
|
|
162
|
+
if not isinstance(value, list):
|
|
163
|
+
return value
|
|
164
|
+
|
|
165
|
+
reverse = bool(operation.get("reverse", False))
|
|
166
|
+
key_path = operation.get("path")
|
|
167
|
+
if key_path:
|
|
168
|
+
return sorted(value, key=lambda item: resolve_path(item, key_path), reverse=reverse)
|
|
169
|
+
return sorted(value, reverse=reverse)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Runtime utilities for declarative Hatui apps."""
|
|
2
|
+
|
|
3
|
+
from hatui.runtime.action_registry import ActionRegistry, create_default_action_registry
|
|
4
|
+
from hatui.runtime.bindings import resolve_path, set_path
|
|
5
|
+
from hatui.runtime.formatters import apply_formatter
|
|
6
|
+
from hatui.runtime.loader import ScreenSpecLoader
|
|
7
|
+
from hatui.runtime.provider_manager import ProviderManager
|
|
8
|
+
from hatui.runtime.registries import ProviderRegistry, WidgetRegistry
|
|
9
|
+
from hatui.runtime.router import Router
|
|
10
|
+
from hatui.runtime.store import Store
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"ActionRegistry",
|
|
14
|
+
"ProviderManager",
|
|
15
|
+
"ProviderRegistry",
|
|
16
|
+
"Router",
|
|
17
|
+
"ScreenSpecLoader",
|
|
18
|
+
"Store",
|
|
19
|
+
"WidgetRegistry",
|
|
20
|
+
"apply_formatter",
|
|
21
|
+
"create_default_action_registry",
|
|
22
|
+
"resolve_path",
|
|
23
|
+
"set_path",
|
|
24
|
+
]
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Callable
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
ActionHandler = Callable[[Any, dict[str, Any], Any], bool]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(slots=True)
|
|
11
|
+
class ActionRegistry:
|
|
12
|
+
handlers: dict[str, ActionHandler]
|
|
13
|
+
|
|
14
|
+
def __init__(self):
|
|
15
|
+
self.handlers = {}
|
|
16
|
+
|
|
17
|
+
def register(self, action: str, handler: ActionHandler) -> None:
|
|
18
|
+
self.handlers[action] = handler
|
|
19
|
+
|
|
20
|
+
def dispatch(self, action: str, source, payload: dict[str, Any], context) -> bool:
|
|
21
|
+
handler = self.handlers.get(action)
|
|
22
|
+
if handler is None:
|
|
23
|
+
return bool(source.handle_action(action, payload, context))
|
|
24
|
+
return bool(handler(source, payload, context))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def create_default_action_registry() -> ActionRegistry:
|
|
28
|
+
registry = ActionRegistry()
|
|
29
|
+
registry.register("focus_next", _focus_next)
|
|
30
|
+
registry.register("focus_prev", _focus_prev)
|
|
31
|
+
registry.register("focus_first", _focus_first)
|
|
32
|
+
registry.register("focus_last", _focus_last)
|
|
33
|
+
registry.register("focus_widget", _focus_widget)
|
|
34
|
+
registry.register("route_set", _route_set)
|
|
35
|
+
registry.register("route_next", _route_next)
|
|
36
|
+
registry.register("route_prev", _route_prev)
|
|
37
|
+
registry.register("route_push", _route_push)
|
|
38
|
+
registry.register("route_pop", _route_pop)
|
|
39
|
+
registry.register("route_pop_focus_widget", _route_pop_focus_widget)
|
|
40
|
+
registry.register("store_set", _store_set)
|
|
41
|
+
registry.register("store_toggle", _store_toggle)
|
|
42
|
+
return registry
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _interaction(source):
|
|
46
|
+
root = source.root
|
|
47
|
+
interaction = getattr(root, "interaction", None)
|
|
48
|
+
if interaction is None:
|
|
49
|
+
raise RuntimeError("Root widget missing interaction controller")
|
|
50
|
+
return interaction
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _focus_next(source, payload: dict[str, Any], context) -> bool:
|
|
54
|
+
return _interaction(source).focus_next(context)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _focus_prev(source, payload: dict[str, Any], context) -> bool:
|
|
58
|
+
return _interaction(source).focus_prev(context)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _focus_first(source, payload: dict[str, Any], context) -> bool:
|
|
62
|
+
return _interaction(source).focus_first(context)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _focus_last(source, payload: dict[str, Any], context) -> bool:
|
|
66
|
+
return _interaction(source).focus_last(context)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _focus_widget(source, payload: dict[str, Any], context) -> bool:
|
|
70
|
+
return _interaction(source).focus_widget(payload.get("target"), context)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _route_set(source, payload: dict[str, Any], context) -> bool:
|
|
74
|
+
return _interaction(source).route_set(payload.get("route"), context)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _route_next(source, payload: dict[str, Any], context) -> bool:
|
|
78
|
+
return _interaction(source).route_next(context)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _route_prev(source, payload: dict[str, Any], context) -> bool:
|
|
82
|
+
return _interaction(source).route_prev(context)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _route_push(source, payload: dict[str, Any], context) -> bool:
|
|
86
|
+
return _interaction(source).route_push(payload.get("route"), context)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _route_pop(source, payload: dict[str, Any], context) -> bool:
|
|
90
|
+
return _interaction(source).route_pop(context)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _route_pop_focus_widget(source, payload: dict[str, Any], context) -> bool:
|
|
94
|
+
return _interaction(source).route_pop_focus_widget(payload.get("target"), context)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _store_set(source, payload: dict[str, Any], context) -> bool:
|
|
98
|
+
return _interaction(source).store_set(payload.get("path"), payload.get("value"), context)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _store_toggle(source, payload: dict[str, Any], context) -> bool:
|
|
102
|
+
return _interaction(source).store_toggle(payload.get("path"), context)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from collections.abc import Mapping
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def resolve_path(data: Any, path: str | None, default: Any = None) -> Any:
|
|
6
|
+
if path is None or path == "":
|
|
7
|
+
return default
|
|
8
|
+
|
|
9
|
+
current = data
|
|
10
|
+
for part in path.split("."):
|
|
11
|
+
if isinstance(current, Mapping):
|
|
12
|
+
if part not in current:
|
|
13
|
+
return default
|
|
14
|
+
current = current[part]
|
|
15
|
+
continue
|
|
16
|
+
|
|
17
|
+
if isinstance(current, list):
|
|
18
|
+
try:
|
|
19
|
+
index = int(part)
|
|
20
|
+
except ValueError:
|
|
21
|
+
return default
|
|
22
|
+
if index < 0 or index >= len(current):
|
|
23
|
+
return default
|
|
24
|
+
current = current[index]
|
|
25
|
+
continue
|
|
26
|
+
|
|
27
|
+
return default
|
|
28
|
+
return current
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def set_path(data: dict[str, Any], path: str, value: Any):
|
|
32
|
+
parts = path.split(".")
|
|
33
|
+
current: dict[str, Any] = data
|
|
34
|
+
for part in parts[:-1]:
|
|
35
|
+
next_value = current.get(part)
|
|
36
|
+
if not isinstance(next_value, dict):
|
|
37
|
+
next_value = {}
|
|
38
|
+
current[part] = next_value
|
|
39
|
+
current = next_value
|
|
40
|
+
current[parts[-1]] = value
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from hatui.runtime.loader import ScreenSpecLoader
|
|
9
|
+
from hatui.runtime.provider_manager import ProviderManager
|
|
10
|
+
from hatui.runtime.render_policy import RenderPolicy
|
|
11
|
+
from hatui.runtime.router import Router
|
|
12
|
+
from hatui.runtime.store import Store
|
|
13
|
+
from hatui.widgets.root_widget import RootWidget
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class BootstrapResult:
|
|
18
|
+
store: Store
|
|
19
|
+
router: Router
|
|
20
|
+
root_widget: RootWidget
|
|
21
|
+
provider_manager: ProviderManager
|
|
22
|
+
loaded_files: list[Path]
|
|
23
|
+
dev_spec: dict[str, Any]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def merge_state(base: dict[str, Any], incoming: dict[str, Any]) -> dict[str, Any]:
|
|
27
|
+
merged = deepcopy(base)
|
|
28
|
+
for key, value in incoming.items():
|
|
29
|
+
if key not in merged:
|
|
30
|
+
merged[key] = deepcopy(value)
|
|
31
|
+
continue
|
|
32
|
+
if isinstance(merged[key], dict) and isinstance(value, dict):
|
|
33
|
+
merged[key] = merge_state(dict(merged[key]), value)
|
|
34
|
+
else:
|
|
35
|
+
merged[key] = deepcopy(value)
|
|
36
|
+
return merged
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_runtime(
|
|
40
|
+
*,
|
|
41
|
+
spec_path: Path,
|
|
42
|
+
loader: ScreenSpecLoader,
|
|
43
|
+
preserved_state: dict[str, Any] | None = None,
|
|
44
|
+
preserved_stack: list[str] | None = None,
|
|
45
|
+
preserved_focus: str | None = None,
|
|
46
|
+
preserved_focus_map: dict[str, str] | None = None,
|
|
47
|
+
glyph_mode: str = "unicode",
|
|
48
|
+
) -> BootstrapResult:
|
|
49
|
+
spec = loader.load_spec(spec_path)
|
|
50
|
+
theme = loader.load_theme(spec)
|
|
51
|
+
initial_state = loader.load_state(spec)
|
|
52
|
+
router_spec = loader.load_router(spec)
|
|
53
|
+
dev_spec = loader.load_dev(spec)
|
|
54
|
+
screen = loader.load_screen(spec)
|
|
55
|
+
providers = loader.load_providers(spec)
|
|
56
|
+
|
|
57
|
+
merged_state = merge_state(initial_state, preserved_state or {})
|
|
58
|
+
store = Store(initial_state=merged_state)
|
|
59
|
+
router = Router(
|
|
60
|
+
routes=list(router_spec.get("routes", []) or []),
|
|
61
|
+
initial=router_spec.get("initial"),
|
|
62
|
+
)
|
|
63
|
+
if preserved_stack:
|
|
64
|
+
stack = [route for route in preserved_stack if router.has_route(route)]
|
|
65
|
+
if not stack and router.current:
|
|
66
|
+
stack = [router.current]
|
|
67
|
+
if stack:
|
|
68
|
+
router.stack = stack
|
|
69
|
+
|
|
70
|
+
root_widget = RootWidget(
|
|
71
|
+
"root",
|
|
72
|
+
children=[screen],
|
|
73
|
+
theme=theme,
|
|
74
|
+
store=store,
|
|
75
|
+
router=router,
|
|
76
|
+
render_policy=RenderPolicy(glyph_mode=glyph_mode),
|
|
77
|
+
)
|
|
78
|
+
if preserved_focus_map:
|
|
79
|
+
root_widget.state["last_focused_by_route"] = dict(preserved_focus_map)
|
|
80
|
+
if preserved_focus:
|
|
81
|
+
root_widget.context.focused_widget = preserved_focus
|
|
82
|
+
root_widget.focus_first(root_widget.context)
|
|
83
|
+
if preserved_focus:
|
|
84
|
+
root_widget.context.focused_widget = preserved_focus
|
|
85
|
+
root_widget.sync_focus(root_widget.context)
|
|
86
|
+
|
|
87
|
+
return BootstrapResult(
|
|
88
|
+
store=store,
|
|
89
|
+
router=router,
|
|
90
|
+
root_widget=root_widget,
|
|
91
|
+
provider_manager=ProviderManager(providers),
|
|
92
|
+
loaded_files=list(loader.last_loaded_files),
|
|
93
|
+
dev_spec=dev_spec,
|
|
94
|
+
)
|
hatui/runtime/cli.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from contextlib import nullcontext
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from hatui.demo_assets import bundled_demo_spec_path
|
|
9
|
+
from hatui.runtime.render_policy import RenderPolicy
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
13
|
+
parser = argparse.ArgumentParser(prog="hatui")
|
|
14
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
15
|
+
|
|
16
|
+
run_parser = subparsers.add_parser("run")
|
|
17
|
+
run_parser.add_argument("spec", nargs="?", help="path to YAML spec")
|
|
18
|
+
run_parser.add_argument("--watch", action="store_true", help="reload spec when watched files change")
|
|
19
|
+
|
|
20
|
+
validate_parser = subparsers.add_parser("validate")
|
|
21
|
+
validate_parser.add_argument("spec", nargs="?", help="path to YAML spec")
|
|
22
|
+
|
|
23
|
+
preview_parser = subparsers.add_parser("preview")
|
|
24
|
+
preview_parser.add_argument("spec", nargs="?", help="path to YAML spec")
|
|
25
|
+
preview_parser.add_argument("--route")
|
|
26
|
+
preview_parser.add_argument("--width", type=int, default=100)
|
|
27
|
+
preview_parser.add_argument("--height", type=int, default=32)
|
|
28
|
+
preview_parser.add_argument("--frames", type=int, default=1)
|
|
29
|
+
return parser
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def normalize_argv(argv: list[str] | None = None) -> list[str]:
|
|
33
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
34
|
+
if argv and argv[0] not in {"run", "validate", "preview"}:
|
|
35
|
+
return ["run", *argv]
|
|
36
|
+
return argv
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def resolve_spec_context(spec_arg: str | None):
|
|
40
|
+
if spec_arg:
|
|
41
|
+
return nullcontext(Path(spec_arg)), False
|
|
42
|
+
return bundled_demo_spec_path(), True
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def preferred_glyph_mode(stream=None) -> str:
|
|
46
|
+
stream = sys.stdout if stream is None else stream
|
|
47
|
+
encoding = getattr(stream, "encoding", None) or "utf-8"
|
|
48
|
+
sample = "┌█▁░▸●╱"
|
|
49
|
+
try:
|
|
50
|
+
sample.encode(encoding)
|
|
51
|
+
except UnicodeEncodeError:
|
|
52
|
+
return "ascii"
|
|
53
|
+
return "unicode"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def write_cli_text(text: str) -> None:
|
|
57
|
+
encoding = sys.stdout.encoding or "utf-8"
|
|
58
|
+
try:
|
|
59
|
+
sys.stdout.write(text)
|
|
60
|
+
except UnicodeEncodeError:
|
|
61
|
+
fallback = RenderPolicy("ascii").translate(text)
|
|
62
|
+
if hasattr(sys.stdout, "buffer"):
|
|
63
|
+
sys.stdout.buffer.write(fallback.encode(encoding, errors="replace"))
|
|
64
|
+
else:
|
|
65
|
+
sys.stdout.write(fallback)
|
|
66
|
+
sys.stdout.flush()
|