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
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."""