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,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
|
hatui/runtime/router.py
ADDED
|
@@ -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
|