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
hatui/core/style.py
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class Style:
|
|
9
|
+
fg_color: str = "default"
|
|
10
|
+
bg_color: str = "default"
|
|
11
|
+
font_name: Optional[str] = None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class BorderTheme:
|
|
16
|
+
style: str = "sharp"
|
|
17
|
+
fg_color: str = "bright_black"
|
|
18
|
+
bg_color: str = "default"
|
|
19
|
+
font_name: Optional[str] = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class TextTheme:
|
|
24
|
+
fg_color: str = "default"
|
|
25
|
+
bg_color: str = "default"
|
|
26
|
+
font_name: Optional[str] = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
DEFAULT_COLORS = {
|
|
30
|
+
"text": "default",
|
|
31
|
+
"text_muted": "bright_black",
|
|
32
|
+
"surface": "default",
|
|
33
|
+
"surface_alt": "bright_black",
|
|
34
|
+
"border": "bright_black",
|
|
35
|
+
"accent": "bright_cyan",
|
|
36
|
+
"accent_alt": "bright_magenta",
|
|
37
|
+
"focus_fg": "bright_white",
|
|
38
|
+
"focus_bg": "bright_black",
|
|
39
|
+
"selection_fg": "bright_white",
|
|
40
|
+
"selection_bg": "bright_black",
|
|
41
|
+
"info": "cyan",
|
|
42
|
+
"warn": "yellow",
|
|
43
|
+
"error": "bright_red",
|
|
44
|
+
"success": "green",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
DEFAULT_FONTS = {
|
|
48
|
+
"text": None,
|
|
49
|
+
"border": None,
|
|
50
|
+
"heading": None,
|
|
51
|
+
"mono": None,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
DEFAULT_SPACING = {
|
|
55
|
+
"xs": 0,
|
|
56
|
+
"sm": 1,
|
|
57
|
+
"md": 2,
|
|
58
|
+
"lg": 3,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
THEME_PRESETS: dict[str, dict[str, Any]] = {
|
|
62
|
+
"clean_ops": {
|
|
63
|
+
"border": {"style": "sharp", "fg_color": "@border", "bg_color": "@surface", "font_name": "@font:border"},
|
|
64
|
+
"text": {"fg_color": "@text", "bg_color": "@surface", "font_name": "@font:text"},
|
|
65
|
+
"colors": {
|
|
66
|
+
"text": "#d7d7e0",
|
|
67
|
+
"text_muted": "#5a5a78",
|
|
68
|
+
"surface": "default",
|
|
69
|
+
"surface_alt": "#1d2533",
|
|
70
|
+
"border": "#5a5a78",
|
|
71
|
+
"accent": "#59d0ff",
|
|
72
|
+
"accent_alt": "#cbb7ff",
|
|
73
|
+
"focus_fg": "#59d0ff",
|
|
74
|
+
"focus_bg": "#1d2533",
|
|
75
|
+
"selection_fg": "#10131a",
|
|
76
|
+
"selection_bg": "#59d0ff",
|
|
77
|
+
"info": "#59d0ff",
|
|
78
|
+
"warn": "#ffd166",
|
|
79
|
+
"error": "#ff4d6d",
|
|
80
|
+
"success": "#4de0a8",
|
|
81
|
+
},
|
|
82
|
+
"widgets": {
|
|
83
|
+
"tabs": {"fg_color": "@text_muted", "bg_color": "@surface", "active_fg_color": "@focus_fg", "active_bg_color": "@surface_alt"},
|
|
84
|
+
"status_strip": {"fg_color": "@text", "bg_color": "@surface_alt"},
|
|
85
|
+
"signal_strip": {"fg_color": "@text", "bg_color": "@surface"},
|
|
86
|
+
"status_matrix": {
|
|
87
|
+
"active_fg_color": "@selection_fg",
|
|
88
|
+
"active_bg_color": "@selection_bg",
|
|
89
|
+
"info_fg_color": "@info",
|
|
90
|
+
"warn_fg_color": "@warn",
|
|
91
|
+
"error_fg_color": "@error",
|
|
92
|
+
"success_fg_color": "@success",
|
|
93
|
+
"unknown_fg_color": "@text_muted",
|
|
94
|
+
},
|
|
95
|
+
"stat": {"accent_color": "@accent"},
|
|
96
|
+
"metric_grid": {"accent_color": "@accent"},
|
|
97
|
+
"progress_bar": {"fill_color": "@accent"},
|
|
98
|
+
"chart": {"axis_color": "@border", "fill_color": "@accent"},
|
|
99
|
+
"gauge": {"fill_color": "@accent", "empty_color": "@border"},
|
|
100
|
+
"histogram": {"fill_color": "@accent"},
|
|
101
|
+
"heatmap": {
|
|
102
|
+
"fg_color": "@accent",
|
|
103
|
+
"color_ramp": ["@text_muted", "@accent", "@success", "@warn", "@error"],
|
|
104
|
+
"bg_ramp": ["@surface", "@surface_alt", "@surface_alt", "@surface_alt", "@surface_alt"],
|
|
105
|
+
"cell_char": " ",
|
|
106
|
+
},
|
|
107
|
+
"alert": {"info_fg_color": "@info", "warn_fg_color": "@warn", "error_fg_color": "@error", "success_fg_color": "@success"},
|
|
108
|
+
"alert_stack": {
|
|
109
|
+
"selected_fg_color": "@selection_fg",
|
|
110
|
+
"selected_bg_color": "@selection_bg",
|
|
111
|
+
"info_fg_color": "@info",
|
|
112
|
+
"warn_fg_color": "@warn",
|
|
113
|
+
"error_fg_color": "@error",
|
|
114
|
+
"success_fg_color": "@success",
|
|
115
|
+
},
|
|
116
|
+
"table": {"header_color": "@text", "selected_fg_color": "@selection_fg", "selected_bg_color": "@selection_bg"},
|
|
117
|
+
"list": {"selected_fg_color": "@selection_fg", "selected_bg_color": "@selection_bg"},
|
|
118
|
+
"menu": {"selected_fg_color": "@selection_fg", "selected_bg_color": "@selection_bg"},
|
|
119
|
+
"tree": {"selected_fg_color": "@selection_fg", "selected_bg_color": "@selection_bg"},
|
|
120
|
+
"scroll": {"scrollbar_fg_color": "@border", "scrollbar_bg_color": "@surface_alt"},
|
|
121
|
+
"log": {"warn_fg_color": "@warn", "error_fg_color": "@error", "success_fg_color": "@success"},
|
|
122
|
+
"timeline": {"info_fg_color": "@info", "warn_fg_color": "@warn", "error_fg_color": "@error", "success_fg_color": "@success"},
|
|
123
|
+
"event_feed": {"info_fg_color": "@info", "warn_fg_color": "@warn", "error_fg_color": "@error", "success_fg_color": "@success"},
|
|
124
|
+
"inspector": {"key_color": "@text_muted", "value_color": "@text"},
|
|
125
|
+
"kv_inspector": {"title_color": "@accent", "key_color": "@text_muted", "value_color": "@text"},
|
|
126
|
+
"diff_viewer": {"added_fg_color": "@success", "removed_fg_color": "@error", "hint_fg_color": "@warn"},
|
|
127
|
+
"flow": {"active_fg_color": "@accent", "idle_fg_color": "@text_muted"},
|
|
128
|
+
"code_block": {"line_number_color": "@text_muted"},
|
|
129
|
+
"hex_dump": {"offset_color": "@text_muted"},
|
|
130
|
+
"divider": {"fg_color": "@border", "bg_color": "@surface"},
|
|
131
|
+
"modal": {"backdrop_bg_color": "#11131a"},
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
"crt": {
|
|
135
|
+
"border": {"style": "rounded", "fg_color": "@border", "bg_color": "@surface"},
|
|
136
|
+
"text": {"fg_color": "@text", "bg_color": "@surface"},
|
|
137
|
+
"colors": {
|
|
138
|
+
"text": "#9fffb4",
|
|
139
|
+
"text_muted": "#4f8f63",
|
|
140
|
+
"surface": "#041508",
|
|
141
|
+
"surface_alt": "#0d2212",
|
|
142
|
+
"border": "#4f8f63",
|
|
143
|
+
"accent": "#7bff7b",
|
|
144
|
+
"accent_alt": "#d7ff87",
|
|
145
|
+
"focus_fg": "#041508",
|
|
146
|
+
"focus_bg": "#7bff7b",
|
|
147
|
+
"selection_fg": "#041508",
|
|
148
|
+
"selection_bg": "#9fffb4",
|
|
149
|
+
"info": "#7bff7b",
|
|
150
|
+
"warn": "#d7ff87",
|
|
151
|
+
"error": "#ff7b7b",
|
|
152
|
+
"success": "#9fffb4",
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass(frozen=True)
|
|
159
|
+
class Theme:
|
|
160
|
+
border: BorderTheme = field(default_factory=BorderTheme)
|
|
161
|
+
text: TextTheme = field(default_factory=TextTheme)
|
|
162
|
+
colors: dict[str, str] = field(default_factory=lambda: dict(DEFAULT_COLORS))
|
|
163
|
+
fonts: dict[str, Optional[str]] = field(default_factory=lambda: dict(DEFAULT_FONTS))
|
|
164
|
+
spacing: dict[str, int] = field(default_factory=lambda: dict(DEFAULT_SPACING))
|
|
165
|
+
widgets: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
166
|
+
preset: str | None = None
|
|
167
|
+
|
|
168
|
+
def color(self, key: str, default: str = "default") -> str:
|
|
169
|
+
return resolve_color_token(f"@{key}", self, default)
|
|
170
|
+
|
|
171
|
+
def font(self, key: str, default: Optional[str] = None) -> Optional[str]:
|
|
172
|
+
return resolve_font_token(f"@font:{key}", self, default)
|
|
173
|
+
|
|
174
|
+
def widget_slot(self, widget: str, slot: str, default: Any = None) -> Any:
|
|
175
|
+
value = self.widgets.get(widget, {}).get(slot, default)
|
|
176
|
+
if isinstance(value, str):
|
|
177
|
+
if value.startswith("@font:"):
|
|
178
|
+
return resolve_font_token(value, self, default)
|
|
179
|
+
return resolve_color_token(value, self, default)
|
|
180
|
+
return value
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
FG_COLOR_CODES = {
|
|
184
|
+
"default": "39",
|
|
185
|
+
"black": "30",
|
|
186
|
+
"red": "31",
|
|
187
|
+
"green": "32",
|
|
188
|
+
"yellow": "33",
|
|
189
|
+
"blue": "34",
|
|
190
|
+
"magenta": "35",
|
|
191
|
+
"cyan": "36",
|
|
192
|
+
"white": "37",
|
|
193
|
+
"bright_black": "90",
|
|
194
|
+
"bright_red": "91",
|
|
195
|
+
"bright_green": "92",
|
|
196
|
+
"bright_yellow": "93",
|
|
197
|
+
"bright_blue": "94",
|
|
198
|
+
"bright_magenta": "95",
|
|
199
|
+
"bright_cyan": "96",
|
|
200
|
+
"bright_white": "97",
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
BG_COLOR_CODES = {
|
|
204
|
+
"default": "49",
|
|
205
|
+
"black": "40",
|
|
206
|
+
"red": "41",
|
|
207
|
+
"green": "42",
|
|
208
|
+
"yellow": "43",
|
|
209
|
+
"blue": "44",
|
|
210
|
+
"magenta": "45",
|
|
211
|
+
"cyan": "46",
|
|
212
|
+
"white": "47",
|
|
213
|
+
"bright_black": "100",
|
|
214
|
+
"bright_red": "101",
|
|
215
|
+
"bright_green": "102",
|
|
216
|
+
"bright_yellow": "103",
|
|
217
|
+
"bright_blue": "104",
|
|
218
|
+
"bright_magenta": "105",
|
|
219
|
+
"bright_cyan": "106",
|
|
220
|
+
"bright_white": "107",
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def merge_theme_spec(theme_spec: dict[str, Any]) -> dict[str, Any]:
|
|
225
|
+
preset_name = theme_spec.get("preset", "clean_ops")
|
|
226
|
+
preset = THEME_PRESETS.get(preset_name, {})
|
|
227
|
+
merged = {
|
|
228
|
+
"preset": preset_name if preset else theme_spec.get("preset"),
|
|
229
|
+
"border": dict(preset.get("border", {})),
|
|
230
|
+
"text": dict(preset.get("text", {})),
|
|
231
|
+
"colors": {**DEFAULT_COLORS, **preset.get("colors", {}), **theme_spec.get("colors", {})},
|
|
232
|
+
"fonts": {**DEFAULT_FONTS, **preset.get("fonts", {}), **theme_spec.get("fonts", {})},
|
|
233
|
+
"spacing": {**DEFAULT_SPACING, **preset.get("spacing", {}), **theme_spec.get("spacing", {})},
|
|
234
|
+
"widgets": _merge_nested_dict(preset.get("widgets", {}), theme_spec.get("widgets", {})),
|
|
235
|
+
}
|
|
236
|
+
merged["border"].update(theme_spec.get("border", {}))
|
|
237
|
+
merged["text"].update(theme_spec.get("text", {}))
|
|
238
|
+
return merged
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def build_theme(theme_spec: dict[str, Any]) -> Theme:
|
|
242
|
+
merged = merge_theme_spec(theme_spec)
|
|
243
|
+
temp_theme = Theme(
|
|
244
|
+
colors=dict(merged["colors"]),
|
|
245
|
+
fonts=dict(merged["fonts"]),
|
|
246
|
+
spacing=dict(merged["spacing"]),
|
|
247
|
+
widgets=_resolve_widget_tokens(merged["widgets"], merged["colors"], merged["fonts"]),
|
|
248
|
+
preset=merged.get("preset"),
|
|
249
|
+
)
|
|
250
|
+
border_spec = merged["border"]
|
|
251
|
+
text_spec = merged["text"]
|
|
252
|
+
border = BorderTheme(
|
|
253
|
+
style=border_spec.get("style", "sharp"),
|
|
254
|
+
fg_color=resolve_color_token(border_spec.get("fg_color"), temp_theme, DEFAULT_COLORS["border"]),
|
|
255
|
+
bg_color=resolve_color_token(border_spec.get("bg_color"), temp_theme, DEFAULT_COLORS["surface"]),
|
|
256
|
+
font_name=resolve_font_token(border_spec.get("font_name"), temp_theme, temp_theme.fonts.get("border")),
|
|
257
|
+
)
|
|
258
|
+
text = TextTheme(
|
|
259
|
+
fg_color=resolve_color_token(text_spec.get("fg_color"), temp_theme, DEFAULT_COLORS["text"]),
|
|
260
|
+
bg_color=resolve_color_token(text_spec.get("bg_color"), temp_theme, DEFAULT_COLORS["surface"]),
|
|
261
|
+
font_name=resolve_font_token(text_spec.get("font_name"), temp_theme, temp_theme.fonts.get("text")),
|
|
262
|
+
)
|
|
263
|
+
return Theme(
|
|
264
|
+
border=border,
|
|
265
|
+
text=text,
|
|
266
|
+
colors=temp_theme.colors,
|
|
267
|
+
fonts=temp_theme.fonts,
|
|
268
|
+
spacing=temp_theme.spacing,
|
|
269
|
+
widgets=temp_theme.widgets,
|
|
270
|
+
preset=temp_theme.preset,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def ansi_color_code(color: str, is_background: bool = False) -> str:
|
|
275
|
+
color_map = BG_COLOR_CODES if is_background else FG_COLOR_CODES
|
|
276
|
+
if color in color_map:
|
|
277
|
+
return color_map[color]
|
|
278
|
+
|
|
279
|
+
if isinstance(color, str) and color.startswith("#") and len(color) == 7:
|
|
280
|
+
red = int(color[1:3], 16)
|
|
281
|
+
green = int(color[3:5], 16)
|
|
282
|
+
blue = int(color[5:7], 16)
|
|
283
|
+
prefix = "48" if is_background else "38"
|
|
284
|
+
return f"{prefix};2;{red};{green};{blue}"
|
|
285
|
+
|
|
286
|
+
return color_map["default"]
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def ansi_sequence(style: Style) -> str:
|
|
290
|
+
fg_code = ansi_color_code(style.fg_color, is_background=False)
|
|
291
|
+
bg_code = ansi_color_code(style.bg_color, is_background=True)
|
|
292
|
+
return f"\033[{fg_code};{bg_code}m"
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def resolve_color_token(value: Optional[str], theme: Theme, default: str = "default") -> str:
|
|
296
|
+
if value is None:
|
|
297
|
+
return default
|
|
298
|
+
if not isinstance(value, str):
|
|
299
|
+
return value
|
|
300
|
+
if value.startswith("@font:"):
|
|
301
|
+
return default
|
|
302
|
+
if value.startswith("@"):
|
|
303
|
+
return theme.colors.get(value[1:], default)
|
|
304
|
+
return value
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def resolve_font_token(value: Optional[str], theme: Theme, default: Optional[str] = None) -> Optional[str]:
|
|
308
|
+
if value is None:
|
|
309
|
+
return default
|
|
310
|
+
if isinstance(value, str) and value.startswith("@font:"):
|
|
311
|
+
return theme.fonts.get(value.split(":", 1)[1], default)
|
|
312
|
+
return value
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def resolve_style(
|
|
316
|
+
fg_color: Optional[str] = None,
|
|
317
|
+
bg_color: Optional[str] = None,
|
|
318
|
+
font_name: Optional[str] = None,
|
|
319
|
+
fallback: Optional[Style] = None,
|
|
320
|
+
theme: Optional[Theme] = None,
|
|
321
|
+
) -> Style:
|
|
322
|
+
fallback = fallback or Style()
|
|
323
|
+
if theme is not None:
|
|
324
|
+
fg_color = resolve_color_token(fg_color, theme, fallback.fg_color) if fg_color is not None else fallback.fg_color
|
|
325
|
+
bg_color = resolve_color_token(bg_color, theme, fallback.bg_color) if bg_color is not None else fallback.bg_color
|
|
326
|
+
font_name = resolve_font_token(font_name, theme, fallback.font_name) if font_name is not None else fallback.font_name
|
|
327
|
+
return Style(
|
|
328
|
+
fg_color=fallback.fg_color if fg_color is None else fg_color,
|
|
329
|
+
bg_color=fallback.bg_color if bg_color is None else bg_color,
|
|
330
|
+
font_name=fallback.font_name if font_name is None else font_name,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def themed_style(
|
|
335
|
+
theme: Theme,
|
|
336
|
+
widget: str,
|
|
337
|
+
*,
|
|
338
|
+
fg_color: Optional[str] = None,
|
|
339
|
+
bg_color: Optional[str] = None,
|
|
340
|
+
font_name: Optional[str] = None,
|
|
341
|
+
fg_slot: str = "fg_color",
|
|
342
|
+
bg_slot: str = "bg_color",
|
|
343
|
+
font_slot: str = "font_name",
|
|
344
|
+
fallback: Optional[Style] = None,
|
|
345
|
+
) -> Style:
|
|
346
|
+
return resolve_style(
|
|
347
|
+
fg_color=fg_color if fg_color is not None else theme.widget_slot(widget, fg_slot, None),
|
|
348
|
+
bg_color=bg_color if bg_color is not None else theme.widget_slot(widget, bg_slot, None),
|
|
349
|
+
font_name=font_name if font_name is not None else theme.widget_slot(widget, font_slot, None),
|
|
350
|
+
fallback=fallback,
|
|
351
|
+
theme=theme,
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _merge_nested_dict(left: dict[str, Any], right: dict[str, Any]) -> dict[str, Any]:
|
|
356
|
+
merged: dict[str, Any] = {}
|
|
357
|
+
for key in set(left) | set(right):
|
|
358
|
+
left_value = left.get(key)
|
|
359
|
+
right_value = right.get(key)
|
|
360
|
+
if isinstance(left_value, dict) and isinstance(right_value, dict):
|
|
361
|
+
merged[key] = _merge_nested_dict(left_value, right_value)
|
|
362
|
+
elif key in right:
|
|
363
|
+
merged[key] = right_value
|
|
364
|
+
else:
|
|
365
|
+
merged[key] = left_value
|
|
366
|
+
return merged
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _resolve_widget_tokens(widgets: dict[str, dict[str, Any]], colors: dict[str, str], fonts: dict[str, Optional[str]]) -> dict[str, dict[str, Any]]:
|
|
370
|
+
temp_theme = Theme(colors=dict(colors), fonts=dict(fonts))
|
|
371
|
+
resolved: dict[str, dict[str, Any]] = {}
|
|
372
|
+
for widget, slots in widgets.items():
|
|
373
|
+
resolved[widget] = {}
|
|
374
|
+
for slot, value in slots.items():
|
|
375
|
+
if isinstance(value, str) and value.startswith("@font:"):
|
|
376
|
+
resolved[widget][slot] = resolve_font_token(value, temp_theme, None)
|
|
377
|
+
elif isinstance(value, str):
|
|
378
|
+
resolved[widget][slot] = resolve_color_token(value, temp_theme, value)
|
|
379
|
+
else:
|
|
380
|
+
resolved[widget][slot] = value
|
|
381
|
+
return resolved
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TerminalEnvironment:
|
|
9
|
+
def __init__(self):
|
|
10
|
+
self.original_stdin_attrs = None
|
|
11
|
+
self.is_windows = os.name == "nt"
|
|
12
|
+
self._active = False
|
|
13
|
+
|
|
14
|
+
@contextlib.contextmanager
|
|
15
|
+
def manage(self):
|
|
16
|
+
self._setup()
|
|
17
|
+
try:
|
|
18
|
+
yield
|
|
19
|
+
finally:
|
|
20
|
+
self._teardown()
|
|
21
|
+
|
|
22
|
+
def _setup(self):
|
|
23
|
+
if self._active:
|
|
24
|
+
return
|
|
25
|
+
if not sys.stdout.isatty():
|
|
26
|
+
raise RuntimeError("Standard output is not a TTY.")
|
|
27
|
+
if not sys.stdin.isatty():
|
|
28
|
+
raise RuntimeError("Standard input is not a TTY.")
|
|
29
|
+
|
|
30
|
+
if self.is_windows:
|
|
31
|
+
self._enable_windows_ansi()
|
|
32
|
+
else:
|
|
33
|
+
self._enable_posix_raw_mode()
|
|
34
|
+
|
|
35
|
+
sys.stdout.write("\033[?1049h\033[2J\033[H\033[?25l")
|
|
36
|
+
sys.stdout.flush()
|
|
37
|
+
self._active = True
|
|
38
|
+
|
|
39
|
+
def _teardown(self):
|
|
40
|
+
if not self._active:
|
|
41
|
+
return
|
|
42
|
+
sys.stdout.write("\033[?25h\033[?1049l")
|
|
43
|
+
sys.stdout.flush()
|
|
44
|
+
|
|
45
|
+
if not self.is_windows and self.original_stdin_attrs is not None:
|
|
46
|
+
import termios
|
|
47
|
+
|
|
48
|
+
termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, self.original_stdin_attrs)
|
|
49
|
+
self.original_stdin_attrs = None
|
|
50
|
+
self._active = False
|
|
51
|
+
|
|
52
|
+
def _enable_posix_raw_mode(self):
|
|
53
|
+
import termios
|
|
54
|
+
import tty
|
|
55
|
+
|
|
56
|
+
fd = sys.stdin.fileno()
|
|
57
|
+
self.original_stdin_attrs = termios.tcgetattr(fd)
|
|
58
|
+
tty.setraw(fd)
|
|
59
|
+
|
|
60
|
+
def _enable_windows_ansi(self):
|
|
61
|
+
import ctypes
|
|
62
|
+
|
|
63
|
+
kernel32 = ctypes.windll.kernel32
|
|
64
|
+
handle = kernel32.GetStdHandle(-11)
|
|
65
|
+
mode = ctypes.c_ulong()
|
|
66
|
+
if kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
|
|
67
|
+
kernel32.SetConsoleMode(handle, mode.value | 0x0004)
|
hatui/core/util.py
ADDED
|
File without changes
|
hatui/core/widget.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from abc import ABC, abstractmethod
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
from typing import Any, List
|
|
5
|
+
|
|
6
|
+
from hatui.core.actions import KeyBinding, normalize_chord, parse_keybinding
|
|
7
|
+
from hatui.core.screen_buffer import ScreenBuffer
|
|
8
|
+
from hatui.core.style import Theme
|
|
9
|
+
|
|
10
|
+
from .context import Context
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class WidgetContext(Context):
|
|
14
|
+
"""
|
|
15
|
+
A class to represent the context of a widget.
|
|
16
|
+
"""
|
|
17
|
+
theme: Theme = field(default_factory=Theme)
|
|
18
|
+
|
|
19
|
+
# Terminal properties
|
|
20
|
+
widget_width: int = None
|
|
21
|
+
widget_height:int = None
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class WidgetRect:
|
|
25
|
+
"""
|
|
26
|
+
A class to represent the rectangle of a widget.
|
|
27
|
+
"""
|
|
28
|
+
x: int
|
|
29
|
+
y: int
|
|
30
|
+
width: int
|
|
31
|
+
height: int
|
|
32
|
+
|
|
33
|
+
class Widget(ABC):
|
|
34
|
+
"""
|
|
35
|
+
Base class for all widgets. It is responsible for:
|
|
36
|
+
- Storing widget state and properties
|
|
37
|
+
- Provide dynamic rendering capabilities, with responsive layout and styling
|
|
38
|
+
- Handling user interactions and events
|
|
39
|
+
|
|
40
|
+
3 Phases to render a widget:
|
|
41
|
+
- allocate: Determine the size of its children and updates them
|
|
42
|
+
- layout: Determine the position of its children and updates them
|
|
43
|
+
- paint: Render the widget and its children to the screen buffer
|
|
44
|
+
"""
|
|
45
|
+
@property
|
|
46
|
+
@abstractmethod
|
|
47
|
+
def _schema(self):
|
|
48
|
+
"""
|
|
49
|
+
Returns the schema for the widget, which defines its properties and their types.
|
|
50
|
+
"""
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
def __init__(self, name: str, children: List['Widget'] = None):
|
|
54
|
+
self.name = name
|
|
55
|
+
self.properties = dict(
|
|
56
|
+
rect=WidgetRect(x=0, y=0, width=0, height=0)
|
|
57
|
+
)
|
|
58
|
+
self.state = {}
|
|
59
|
+
self.layout_weight = 1
|
|
60
|
+
self.parent: Widget | None = None
|
|
61
|
+
self.children: List[Widget] = []
|
|
62
|
+
self.focusable = False
|
|
63
|
+
self.focus_fg_color: str | None = None
|
|
64
|
+
self.focus_bg_color: str | None = None
|
|
65
|
+
self.keybindings: list[KeyBinding] = []
|
|
66
|
+
for child in children or []:
|
|
67
|
+
self.add_child(child)
|
|
68
|
+
|
|
69
|
+
def set_layout_weight(self, weight: int | float):
|
|
70
|
+
self.layout_weight = max(float(weight), 0.0)
|
|
71
|
+
return self
|
|
72
|
+
|
|
73
|
+
def add_child(self, child: 'Widget'):
|
|
74
|
+
child.parent = self
|
|
75
|
+
self.children.append(child)
|
|
76
|
+
return child
|
|
77
|
+
|
|
78
|
+
def default_focusable(self) -> bool:
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
def default_keybindings(self) -> list[dict[str, Any]]:
|
|
82
|
+
return []
|
|
83
|
+
|
|
84
|
+
def configure_interaction(self, spec: dict[str, Any] | None = None):
|
|
85
|
+
spec = spec or {}
|
|
86
|
+
bindings = [parse_keybinding(item) for item in self.default_keybindings()]
|
|
87
|
+
bindings.extend(parse_keybinding(item) for item in spec.get("keybindings", []))
|
|
88
|
+
self.keybindings = bindings
|
|
89
|
+
|
|
90
|
+
focusable = spec.get("focusable")
|
|
91
|
+
selectable = spec.get("selectable")
|
|
92
|
+
if focusable is not None:
|
|
93
|
+
self.focusable = bool(focusable)
|
|
94
|
+
elif selectable is not None:
|
|
95
|
+
self.focusable = bool(selectable)
|
|
96
|
+
else:
|
|
97
|
+
self.focusable = self.default_focusable() or bool(self.keybindings)
|
|
98
|
+
|
|
99
|
+
self.focus_fg_color = spec.get("focus_fg_color")
|
|
100
|
+
self.focus_bg_color = spec.get("focus_bg_color")
|
|
101
|
+
return self
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def root(self) -> 'Widget':
|
|
105
|
+
current = self
|
|
106
|
+
while current.parent is not None:
|
|
107
|
+
current = current.parent
|
|
108
|
+
return current
|
|
109
|
+
|
|
110
|
+
def interaction_children(self) -> list['Widget']:
|
|
111
|
+
return self.children
|
|
112
|
+
|
|
113
|
+
def focusable_widgets(self) -> list['Widget']:
|
|
114
|
+
widgets = [self] if self.focusable else []
|
|
115
|
+
for child in self.interaction_children():
|
|
116
|
+
widgets.extend(child.focusable_widgets())
|
|
117
|
+
return widgets
|
|
118
|
+
|
|
119
|
+
def is_focused(self, context: Context) -> bool:
|
|
120
|
+
return context.focused_widget == self.name
|
|
121
|
+
|
|
122
|
+
def _binding_matches(self, binding: KeyBinding, key: str, modifiers: list[str]) -> bool:
|
|
123
|
+
return binding.chord == normalize_chord(key, modifiers)
|
|
124
|
+
|
|
125
|
+
def dispatch_keybindings(self, key: str, modifiers: list[str], context: Context) -> bool:
|
|
126
|
+
for binding in self.keybindings:
|
|
127
|
+
if self._binding_matches(binding, key, modifiers):
|
|
128
|
+
if not binding.action:
|
|
129
|
+
return True
|
|
130
|
+
payload = deepcopy(binding.payload)
|
|
131
|
+
return self.perform_action(binding.action, payload, context)
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
def perform_action(self, action: str, payload: dict[str, Any], context: Context) -> bool:
|
|
135
|
+
root = self.root
|
|
136
|
+
if hasattr(root, "dispatch_action"):
|
|
137
|
+
return bool(root.dispatch_action(action, payload, self, context))
|
|
138
|
+
return self.handle_action(action, payload, context)
|
|
139
|
+
|
|
140
|
+
def handle_action(self, action: str, payload: dict[str, Any], context: Context) -> bool:
|
|
141
|
+
return False
|
|
142
|
+
|
|
143
|
+
def allocate(self, width: int, height: int):
|
|
144
|
+
"""
|
|
145
|
+
? Parent Call
|
|
146
|
+
Allocate the size of the widget based on the given constraints.
|
|
147
|
+
"""
|
|
148
|
+
self.properties["rect"].width = width
|
|
149
|
+
self.properties["rect"].height = height
|
|
150
|
+
|
|
151
|
+
self.allocate_children(width, height)
|
|
152
|
+
|
|
153
|
+
def update(self, delta_time: float, context: Context):
|
|
154
|
+
"""
|
|
155
|
+
? Parent call.
|
|
156
|
+
Update the widget state for the current frame.
|
|
157
|
+
"""
|
|
158
|
+
self.update_children(delta_time, context)
|
|
159
|
+
|
|
160
|
+
def update_children(self, delta_time: float, context: Context):
|
|
161
|
+
for child in self.children:
|
|
162
|
+
child.update(delta_time, context)
|
|
163
|
+
|
|
164
|
+
def measure_content(self, width: int, height: int) -> tuple[int, int]:
|
|
165
|
+
return max(width, 0), max(height, 0)
|
|
166
|
+
|
|
167
|
+
def handle_input(self, key: str, modifiers: list[str], context: Context) -> bool:
|
|
168
|
+
return False
|
|
169
|
+
|
|
170
|
+
@abstractmethod
|
|
171
|
+
def allocate_children(self, width: int, height: int):
|
|
172
|
+
"""
|
|
173
|
+
? Parent Call
|
|
174
|
+
Allocate the size of the widget's children based on the given constraints.
|
|
175
|
+
"""
|
|
176
|
+
pass
|
|
177
|
+
|
|
178
|
+
def layout(self, x: int, y: int, context: Context):
|
|
179
|
+
"""
|
|
180
|
+
? Parent Call
|
|
181
|
+
Layout the widget at the given position (x, y).
|
|
182
|
+
"""
|
|
183
|
+
self.properties["rect"].x = x
|
|
184
|
+
self.properties["rect"].y = y
|
|
185
|
+
|
|
186
|
+
self.layout_children(x, y, context)
|
|
187
|
+
|
|
188
|
+
@abstractmethod
|
|
189
|
+
def layout_children(self, x: int, y: int, context: Context):
|
|
190
|
+
"""
|
|
191
|
+
? Parent Call
|
|
192
|
+
Layout the widget's children at the given position (x, y).
|
|
193
|
+
"""
|
|
194
|
+
pass
|
|
195
|
+
|
|
196
|
+
@abstractmethod
|
|
197
|
+
def paint(self, buffer: ScreenBuffer, context: Context):
|
|
198
|
+
"""
|
|
199
|
+
? Parent Call
|
|
200
|
+
Paint the widget to the given screen buffer.
|
|
201
|
+
"""
|
|
202
|
+
# Implement painting logic here
|
|
203
|
+
pass
|
hatui/demo/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Bundled demo assets for Hatui."""
|