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,138 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import deque
4
+ from time import perf_counter
5
+ from typing import Any
6
+
7
+ from hatui.providers.base import SKIP_RESULT, VALUE_RESULT
8
+ from hatui.runtime.bindings import set_path
9
+
10
+
11
+ class ProviderManager:
12
+ def __init__(self, providers: list[Any]):
13
+ self.providers = self._order_providers(providers)
14
+
15
+ def setup(self, context):
16
+ provider_status = context.data.setdefault("_providers", {})
17
+ for provider in self.providers:
18
+ provider_status.setdefault(provider.name, self._status_template(provider))
19
+ provider.setup(context)
20
+
21
+ def update(self, delta_time: float, context):
22
+ provider_status = context.data.setdefault("_providers", {})
23
+ for provider in self.providers:
24
+ status = provider_status.setdefault(provider.name, self._status_template(provider))
25
+ blocked_by = self._blocked_dependencies(provider, provider_status)
26
+ if blocked_by:
27
+ status["status"] = "blocked"
28
+ status["blocked_by"] = blocked_by
29
+ status["error"] = None
30
+ status["last_reason"] = "dependency_error"
31
+ status["blocked"] += 1
32
+ self._write_metadata(provider, context, status)
33
+ continue
34
+
35
+ started = perf_counter()
36
+ try:
37
+ result = provider.update(delta_time, context)
38
+ status["blocked_by"] = []
39
+ status["last_duration_ms"] = round((perf_counter() - started) * 1000.0, 3)
40
+
41
+ if result.state == SKIP_RESULT:
42
+ status["status"] = "waiting"
43
+ status["error"] = None
44
+ status["last_reason"] = result.reason
45
+ status["skips"] += 1
46
+ self._write_metadata(provider, context, status)
47
+ continue
48
+
49
+ if result.state != VALUE_RESULT:
50
+ raise ValueError(f"Unknown provider result state: {result.state}")
51
+
52
+ status["status"] = "ok"
53
+ status["error"] = None
54
+ status["last_reason"] = None
55
+ status["attempts"] += 1
56
+ status["successes"] += 1
57
+ status["last_run_at"] = round(context.elapsed_time, 3)
58
+ status["last_success_at"] = round(context.elapsed_time, 3)
59
+ if provider.target and result.value is not None:
60
+ set_path(context.data, provider.target, result.value)
61
+ self._write_metadata(provider, context, status)
62
+ except Exception as exc:
63
+ status["status"] = "error"
64
+ status["error"] = str(exc)
65
+ status["last_reason"] = "exception"
66
+ status["attempts"] += 1
67
+ status["errors"] += 1
68
+ status["last_run_at"] = round(context.elapsed_time, 3)
69
+ status["last_duration_ms"] = round((perf_counter() - started) * 1000.0, 3)
70
+ self._write_metadata(provider, context, status)
71
+
72
+ def teardown(self, context):
73
+ for provider in self.providers:
74
+ provider.teardown(context)
75
+
76
+ def _order_providers(self, providers: list[Any]) -> list[Any]:
77
+ by_name: dict[str, Any] = {}
78
+ for provider in providers:
79
+ if provider.name in by_name:
80
+ raise ValueError(f"Duplicate provider name: {provider.name}")
81
+ by_name[provider.name] = provider
82
+
83
+ indegree = {provider.name: 0 for provider in providers}
84
+ outgoing = {provider.name: [] for provider in providers}
85
+ for provider in providers:
86
+ for dependency in provider.depends_on:
87
+ if dependency not in by_name:
88
+ raise ValueError(f"Provider '{provider.name}' depends on unknown provider '{dependency}'")
89
+ indegree[provider.name] += 1
90
+ outgoing[dependency].append(provider.name)
91
+
92
+ queue = deque(provider.name for provider in providers if indegree[provider.name] == 0)
93
+ ordered_names: list[str] = []
94
+ while queue:
95
+ name = queue.popleft()
96
+ ordered_names.append(name)
97
+ for dependent in outgoing[name]:
98
+ indegree[dependent] -= 1
99
+ if indegree[dependent] == 0:
100
+ queue.append(dependent)
101
+
102
+ if len(ordered_names) != len(providers):
103
+ unresolved = sorted(name for name, degree in indegree.items() if degree > 0)
104
+ raise ValueError(f"Provider dependency cycle detected: {', '.join(unresolved)}")
105
+
106
+ return [by_name[name] for name in ordered_names]
107
+
108
+ def _blocked_dependencies(self, provider, provider_status: dict[str, dict[str, Any]]) -> list[str]:
109
+ blocked: list[str] = []
110
+ for dependency in provider.depends_on:
111
+ dependency_status = provider_status.get(dependency, {})
112
+ if dependency_status.get("status") in {"error", "blocked"}:
113
+ blocked.append(dependency)
114
+ return blocked
115
+
116
+ def _status_template(self, provider) -> dict[str, Any]:
117
+ return {
118
+ "name": provider.name,
119
+ "group": provider.group,
120
+ "target": provider.target,
121
+ "depends_on": list(provider.depends_on),
122
+ "status": "idle",
123
+ "error": None,
124
+ "blocked_by": [],
125
+ "attempts": 0,
126
+ "successes": 0,
127
+ "errors": 0,
128
+ "skips": 0,
129
+ "blocked": 0,
130
+ "last_reason": None,
131
+ "last_run_at": None,
132
+ "last_success_at": None,
133
+ "last_duration_ms": None,
134
+ }
135
+
136
+ def _write_metadata(self, provider, context, status: dict[str, Any]):
137
+ if provider.metadata_target:
138
+ set_path(context.data, provider.metadata_target, dict(status))
@@ -0,0 +1,65 @@
1
+ from collections.abc import Callable
2
+ from dataclasses import dataclass, field
3
+ from typing import Any
4
+
5
+
6
+ @dataclass
7
+ class WidgetRegistration:
8
+ widget_type: str
9
+ factory: Callable[[dict[str, Any], Any], Any]
10
+ widget_cls: type | None = None
11
+ allowed_keys: set[str] = field(default_factory=set)
12
+ required_keys: set[str] = field(default_factory=set)
13
+
14
+
15
+ class WidgetRegistry:
16
+ def __init__(self):
17
+ self._registrations: dict[str, WidgetRegistration] = {}
18
+
19
+ def register(
20
+ self,
21
+ widget_type: str,
22
+ factory: Callable[[dict[str, Any], Any], Any],
23
+ *,
24
+ widget_cls: type | None = None,
25
+ allowed_keys: set[str] | None = None,
26
+ required_keys: set[str] | None = None,
27
+ ):
28
+ self._registrations[widget_type] = WidgetRegistration(
29
+ widget_type=widget_type,
30
+ factory=factory,
31
+ widget_cls=widget_cls,
32
+ allowed_keys=set(allowed_keys or set()),
33
+ required_keys=set(required_keys or set()),
34
+ )
35
+
36
+ def create(self, widget_type: str, spec: dict[str, Any], loader):
37
+ registration = self.get(widget_type)
38
+ if registration is None:
39
+ raise ValueError(f"Unknown widget type: {widget_type}")
40
+ return registration.factory(spec, loader)
41
+
42
+ def get(self, widget_type: str) -> WidgetRegistration | None:
43
+ return self._registrations.get(widget_type)
44
+
45
+ def registered_types(self) -> list[str]:
46
+ return sorted(self._registrations)
47
+
48
+
49
+ class ProviderRegistry:
50
+ def __init__(self):
51
+ self._providers: dict[str, type] = {}
52
+
53
+ def register(self, provider_type: str, provider_cls: type):
54
+ self._providers[provider_type] = provider_cls
55
+
56
+ def create(self, provider_type: str, spec: dict[str, Any]):
57
+ if provider_type not in self._providers:
58
+ raise ValueError(f"Unknown provider type: {provider_type}")
59
+ return self._providers[provider_type](spec)
60
+
61
+ def get(self, provider_type: str) -> type | None:
62
+ return self._providers.get(provider_type)
63
+
64
+ def registered_types(self) -> list[str]:
65
+ return sorted(self._providers)
@@ -0,0 +1,144 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ UNICODE_TRANSLATION = str.maketrans(
7
+ {
8
+ "╭": "+",
9
+ "╮": "+",
10
+ "╰": "+",
11
+ "╯": "+",
12
+ "┌": "+",
13
+ "┐": "+",
14
+ "└": "+",
15
+ "┘": "+",
16
+ "│": "|",
17
+ "─": "-",
18
+ "═": "=",
19
+ "█": "#",
20
+ "▇": "#",
21
+ "▆": "#",
22
+ "▅": "#",
23
+ "▄": "#",
24
+ "▃": "*",
25
+ "▂": "*",
26
+ "▁": ".",
27
+ "░": ".",
28
+ "▒": ":",
29
+ "▓": "#",
30
+ "»": ">",
31
+ "▸": ">",
32
+ "▾": "v",
33
+ "•": "*",
34
+ "●": "*",
35
+ "╲": "\\",
36
+ "╱": "/",
37
+ }
38
+ )
39
+
40
+
41
+ UNICODE_CHARS = {
42
+ "horizontal": "─",
43
+ "vertical": "│",
44
+ "point": "●",
45
+ "bullet": "•",
46
+ "collapsed": "▸",
47
+ "expanded": "▾",
48
+ "diag_down": "╲",
49
+ "diag_up": "╱",
50
+ "fill": "█",
51
+ "scroll_track": "│",
52
+ "scroll_thumb": "█",
53
+ "empty_block": "░",
54
+ }
55
+
56
+ ASCII_CHARS = {
57
+ "horizontal": "-",
58
+ "vertical": "|",
59
+ "point": "*",
60
+ "bullet": "*",
61
+ "collapsed": ">",
62
+ "expanded": "v",
63
+ "diag_down": "\\",
64
+ "diag_up": "/",
65
+ "fill": "#",
66
+ "scroll_track": "|",
67
+ "scroll_thumb": "#",
68
+ "empty_block": ".",
69
+ }
70
+
71
+ UNICODE_LEVELS = {
72
+ "spark": "▁▂▃▄▅▆▇█",
73
+ "block": " ▁▂▃▄▅▆▇█",
74
+ "shade": " ░▒▓█",
75
+ }
76
+
77
+ ASCII_LEVELS = {
78
+ "spark": ".:-=+*#",
79
+ "block": " .:-=+*#",
80
+ "shade": " .:*#",
81
+ }
82
+
83
+ UNICODE_BORDERS = {
84
+ "ascii": {
85
+ "top_left": "+",
86
+ "top_right": "+",
87
+ "bottom_left": "+",
88
+ "bottom_right": "+",
89
+ "horizontal": "-",
90
+ "vertical": "|",
91
+ },
92
+ "sharp": {
93
+ "top_left": "┌",
94
+ "top_right": "┐",
95
+ "bottom_left": "└",
96
+ "bottom_right": "┘",
97
+ "horizontal": "─",
98
+ "vertical": "│",
99
+ },
100
+ "rounded": {
101
+ "top_left": "╭",
102
+ "top_right": "╮",
103
+ "bottom_left": "╰",
104
+ "bottom_right": "╯",
105
+ "horizontal": "─",
106
+ "vertical": "│",
107
+ },
108
+ }
109
+
110
+ ASCII_BORDER = {
111
+ "top_left": "+",
112
+ "top_right": "+",
113
+ "bottom_left": "+",
114
+ "bottom_right": "+",
115
+ "horizontal": "-",
116
+ "vertical": "|",
117
+ }
118
+
119
+
120
+ @dataclass(frozen=True)
121
+ class RenderPolicy:
122
+ glyph_mode: str = "unicode"
123
+
124
+ @property
125
+ def is_ascii(self) -> bool:
126
+ return self.glyph_mode == "ascii"
127
+
128
+ def char(self, name: str, default: str = " ") -> str:
129
+ source = ASCII_CHARS if self.is_ascii else UNICODE_CHARS
130
+ return source.get(name, default)
131
+
132
+ def levels(self, name: str, default: str = "") -> str:
133
+ source = ASCII_LEVELS if self.is_ascii else UNICODE_LEVELS
134
+ return source.get(name, default)
135
+
136
+ def border(self, style: str) -> dict[str, str]:
137
+ if self.is_ascii:
138
+ return dict(ASCII_BORDER)
139
+ return dict(UNICODE_BORDERS.get(style, UNICODE_BORDERS["sharp"]))
140
+
141
+ def translate(self, text: str) -> str:
142
+ if self.is_ascii:
143
+ return text.translate(UNICODE_TRANSLATION)
144
+ return text
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ from copy import deepcopy
4
+ from typing import Any
5
+
6
+ from hatui.runtime.bindings import set_path
7
+
8
+
9
+ class Router:
10
+ def __init__(
11
+ self,
12
+ routes: list[str] | None = None,
13
+ initial: str | None = None,
14
+ root_path: str = "_router",
15
+ ):
16
+ self.routes = list(routes or [])
17
+ default_route = initial or (self.routes[0] if self.routes else None)
18
+ self.stack: list[str] = [default_route] if default_route else []
19
+ self.root_path = root_path
20
+
21
+ @property
22
+ def current(self) -> str | None:
23
+ return self.stack[-1] if self.stack else None
24
+
25
+ def sync_to_context(self, context):
26
+ set_path(
27
+ context.data,
28
+ self.root_path,
29
+ {
30
+ "current": self.current,
31
+ "stack": deepcopy(self.stack),
32
+ "routes": list(self.routes),
33
+ },
34
+ )
35
+
36
+ def has_route(self, route: str | None) -> bool:
37
+ return bool(route) and route in self.routes
38
+
39
+ def set_current(self, route: str) -> bool:
40
+ if not self.has_route(route):
41
+ return False
42
+ if self.stack:
43
+ self.stack[-1] = route
44
+ else:
45
+ self.stack.append(route)
46
+ return True
47
+
48
+ def push(self, route: str) -> bool:
49
+ if not self.has_route(route):
50
+ return False
51
+ self.stack.append(route)
52
+ return True
53
+
54
+ def pop(self) -> bool:
55
+ if len(self.stack) <= 1:
56
+ return False
57
+ self.stack.pop()
58
+ return True
59
+
60
+ def next(self) -> bool:
61
+ if not self.routes:
62
+ return False
63
+ if self.current not in self.routes:
64
+ return self.set_current(self.routes[0])
65
+ index = self.routes.index(self.current)
66
+ return self.set_current(self.routes[(index + 1) % len(self.routes)])
67
+
68
+ def previous(self) -> bool:
69
+ if not self.routes:
70
+ return False
71
+ if self.current not in self.routes:
72
+ return self.set_current(self.routes[-1])
73
+ index = self.routes.index(self.current)
74
+ return self.set_current(self.routes[(index - 1) % len(self.routes)])
hatui/runtime/store.py ADDED
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ from copy import deepcopy
4
+ from typing import Any
5
+
6
+ from hatui.runtime.bindings import resolve_path, set_path
7
+
8
+
9
+ class Store:
10
+ def __init__(self, initial_state: dict[str, Any] | None = None, root_path: str = "state"):
11
+ self.root_path = root_path
12
+ self.state = deepcopy(initial_state or {})
13
+
14
+ def sync_to_context(self, context):
15
+ set_path(context.data, self.root_path, deepcopy(self.state))
16
+
17
+ def get(self, path: str | None = None, default: Any = None) -> Any:
18
+ if not path:
19
+ return self.state
20
+ return resolve_path(self.state, path, default)
21
+
22
+ def set(self, path: str, value: Any):
23
+ set_path(self.state, path, value)
24
+ return value
25
+
26
+ def toggle(self, path: str, default: bool = False) -> bool:
27
+ next_value = not bool(self.get(path, default))
28
+ self.set(path, next_value)
29
+ return next_value