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,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()