tuiloom 0.1.0__tar.gz

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 (52) hide show
  1. tuiloom-0.1.0/.gitignore +7 -0
  2. tuiloom-0.1.0/LICENSE +21 -0
  3. tuiloom-0.1.0/PKG-INFO +291 -0
  4. tuiloom-0.1.0/README.md +264 -0
  5. tuiloom-0.1.0/pyproject.toml +98 -0
  6. tuiloom-0.1.0/src/tuiloom/__init__.py +34 -0
  7. tuiloom-0.1.0/src/tuiloom/_message_registry.py +134 -0
  8. tuiloom-0.1.0/src/tuiloom/command.py +127 -0
  9. tuiloom-0.1.0/src/tuiloom/event_loop/__init__.py +0 -0
  10. tuiloom-0.1.0/src/tuiloom/event_loop/event_loop.py +310 -0
  11. tuiloom-0.1.0/src/tuiloom/event_loop/source_event.py +16 -0
  12. tuiloom-0.1.0/src/tuiloom/event_loop/source_worker.py +130 -0
  13. tuiloom-0.1.0/src/tuiloom/formatting.py +28 -0
  14. tuiloom-0.1.0/src/tuiloom/input_handler/__init__.py +1 -0
  15. tuiloom-0.1.0/src/tuiloom/input_handler/input_event.py +11 -0
  16. tuiloom-0.1.0/src/tuiloom/input_handler/input_handler.py +121 -0
  17. tuiloom-0.1.0/src/tuiloom/key_binding.py +136 -0
  18. tuiloom-0.1.0/src/tuiloom/output_capture.py +159 -0
  19. tuiloom-0.1.0/src/tuiloom/output_task.py +128 -0
  20. tuiloom-0.1.0/src/tuiloom/py.typed +0 -0
  21. tuiloom-0.1.0/src/tuiloom/render/__init__.py +1 -0
  22. tuiloom-0.1.0/src/tuiloom/render/content_renderer.py +311 -0
  23. tuiloom-0.1.0/src/tuiloom/render/menu_renderer.py +179 -0
  24. tuiloom-0.1.0/src/tuiloom/render/rendered_content.py +12 -0
  25. tuiloom-0.1.0/src/tuiloom/render/segment_diff.py +96 -0
  26. tuiloom-0.1.0/src/tuiloom/render/terminal_renderer.py +210 -0
  27. tuiloom-0.1.0/src/tuiloom/render/terminal_text.py +202 -0
  28. tuiloom-0.1.0/src/tuiloom/render/viewport.py +107 -0
  29. tuiloom-0.1.0/src/tuiloom/screen_context/__init__.py +1 -0
  30. tuiloom-0.1.0/src/tuiloom/screen_context/screen_context.py +37 -0
  31. tuiloom-0.1.0/src/tuiloom/terminal_app.py +306 -0
  32. tuiloom-0.1.0/src/tuiloom/terminal_menu.py +639 -0
  33. tuiloom-0.1.0/tests/event_loop/__init__.py +0 -0
  34. tuiloom-0.1.0/tests/event_loop/test_event_loop.py +179 -0
  35. tuiloom-0.1.0/tests/event_loop/test_source_worker.py +116 -0
  36. tuiloom-0.1.0/tests/render/test_content_renderer.py +156 -0
  37. tuiloom-0.1.0/tests/render/test_menu_renderer.py +98 -0
  38. tuiloom-0.1.0/tests/render/test_segment_diff.py +74 -0
  39. tuiloom-0.1.0/tests/render/test_terminal_renderer.py +126 -0
  40. tuiloom-0.1.0/tests/render/test_terminal_text.py +101 -0
  41. tuiloom-0.1.0/tests/render/test_viewport.py +109 -0
  42. tuiloom-0.1.0/tests/test_commands_and_menu.py +163 -0
  43. tuiloom-0.1.0/tests/test_formatting.py +44 -0
  44. tuiloom-0.1.0/tests/test_input_handler.py +118 -0
  45. tuiloom-0.1.0/tests/test_key_binding.py +70 -0
  46. tuiloom-0.1.0/tests/test_menu_modes.py +224 -0
  47. tuiloom-0.1.0/tests/test_output_capture.py +78 -0
  48. tuiloom-0.1.0/tests/test_output_task.py +92 -0
  49. tuiloom-0.1.0/tests/test_pty.py +102 -0
  50. tuiloom-0.1.0/tests/test_public_api.py +126 -0
  51. tuiloom-0.1.0/tests/test_task_exit.py +209 -0
  52. tuiloom-0.1.0/tests/test_terminal_app.py +81 -0
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ __pycache__/
3
+ .coverage
4
+ .mypy_cache/
5
+ .pytest_cache/
6
+ .ruff_cache/
7
+ build/
tuiloom-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 maroard
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
tuiloom-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,291 @@
1
+ Metadata-Version: 2.5
2
+ Name: tuiloom
3
+ Version: 0.1.0
4
+ Summary: Beautiful terminal interfaces, simple to build, easy to navigate.
5
+ Project-URL: Homepage, https://github.com/maroard/Tuiloom
6
+ Project-URL: Repository, https://github.com/maroard/Tuiloom
7
+ Project-URL: Issues, https://github.com/maroard/Tuiloom/issues
8
+ Author: maroard
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: cli,interface,menu,python,terminal,tui
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: MacOS
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Programming Language :: Python
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Classifier: Topic :: Terminals
23
+ Requires-Python: >=3.12
24
+ Requires-Dist: blessed<2,>=1.48
25
+ Requires-Dist: wcwidth>=0.8
26
+ Description-Content-Type: text/markdown
27
+
28
+ # Tuiloom
29
+
30
+ Tuiloom builds typed, keyboard-navigable terminal menus with dynamic content,
31
+ Unicode-safe rendering, captured task output, alerts, and free-form input. It
32
+ supports Python 3.12–3.14 on Linux and macOS.
33
+
34
+ > Tuiloom is not published yet. The API described here is the pre-PyPI API.
35
+
36
+ ## Installation
37
+
38
+ ```bash
39
+ pip install tuiloom
40
+ ```
41
+
42
+ ## First menu
43
+
44
+ ```python
45
+ from tuiloom import CommandContext, ScreenContext, TerminalApp, TerminalMenu
46
+
47
+ app = TerminalApp("Generator")
48
+ menu = TerminalMenu(
49
+ app,
50
+ ScreenContext(
51
+ menu_name="main",
52
+ title="Generation",
53
+ text="Choose an operation",
54
+ width=24, # minimum inner width, not a fixed width
55
+ ),
56
+ content_source="Ready",
57
+ )
58
+
59
+ def generate(context: CommandContext) -> None:
60
+ context.menu.set_content_source("Generated")
61
+
62
+ menu.add_command("Generate", generate)
63
+ app.set_main_menu(menu)
64
+ app.run()
65
+ ```
66
+
67
+ `TerminalApp.run()` is blocking. It requires an interactive terminal and must
68
+ run on the Python main thread. Terminal and cursor state are restored if a
69
+ callback or renderer raises.
70
+
71
+ ## Navigation and focus
72
+
73
+ The menu initially has focus. Up and Down move the selected command in a loop,
74
+ Enter activates it, and Escape activates the automatic final `Back` or `Quit`
75
+ option. The selected row always contains `>` so selection remains visible
76
+ without ANSI colors.
77
+
78
+ When content exists, Tab alternates focus between the menu and content boxes.
79
+ With content focused, all four arrows move its viewport. Manual upward movement
80
+ suspends `auto_scroll="smart"`; reaching the bottom resumes it.
81
+
82
+ Focused boxes use solid borders and unfocused boxes use dotted borders. A menu
83
+ without content has one solid box and Tab does nothing. Use
84
+ `content_spacing=False` to remove the otherwise single blank row between boxes.
85
+
86
+ ## Content sources
87
+
88
+ All four `ContentSource` forms are accepted:
89
+
90
+ ```python
91
+ from collections.abc import Iterator
92
+
93
+ static_text = "one\ntwo"
94
+ static_lines = ["one", "two"]
95
+
96
+ def stream() -> Iterator[str]:
97
+ yield "one\n"
98
+ yield "two\n"
99
+
100
+ def refreshed() -> str | list[str]:
101
+ return ["current", "state"]
102
+
103
+ menu.set_content_source(static_text)
104
+ menu.set_content_source(static_lines)
105
+ menu.set_content_source(stream())
106
+ menu.set_content_source(refreshed)
107
+ ```
108
+
109
+ An omitted menu source inherits `TerminalApp.global_content_source`. A content
110
+ box is rendered only when a source exists.
111
+
112
+ ## Stable command handles
113
+
114
+ Adding a command returns a stable handle. Positions are zero-based; booleans,
115
+ negative positions, and out-of-range positions are rejected immediately.
116
+
117
+ ```python
118
+ command = menu.add_command("Connect", connect)
119
+ menu.set_command_label(command, "Disconnect")
120
+ menu.set_command_behavior(command, disconnect)
121
+ menu.move_command(command, 0)
122
+ menu.disable_command(command)
123
+ menu.enable_command(command)
124
+ menu.set_exit_label("Close")
125
+ ```
126
+
127
+ Submenus must belong to the same application and are validated when added:
128
+
129
+ ```python
130
+ settings = TerminalMenu(app, ScreenContext("settings", "Settings"))
131
+ open_settings = menu.add_menu(settings, "Settings", position=0)
132
+ ```
133
+
134
+ `CommandContext.command` is the invoked `MenuCommand` or `GlobalCommand`.
135
+ `CommandContext.binding` is its triggering `KeyBinding`. Alert confirmation uses
136
+ `command=None` and the Enter binding.
137
+
138
+ ## Bindings and global commands
139
+
140
+ ```python
141
+ from tuiloom import KeyBinding, KeyMap, TerminalApp
142
+
143
+ keymap = KeyMap()
144
+ keymap.set_binding("focus", KeyBinding("f", ctrl=True))
145
+ app = TerminalApp("App", keymap=keymap)
146
+
147
+ refresh = app.add_global_command(
148
+ KeyBinding("r", ctrl=True),
149
+ "Refresh",
150
+ refresh_callback,
151
+ )
152
+ app.set_global_command_binding(refresh, KeyBinding("f5"))
153
+ app.set_global_command_label(refresh, "Reload")
154
+ app.set_global_command_behavior(refresh, reload_callback)
155
+ ```
156
+
157
+ Global commands are intentionally invisible. `app.global_commands` is a
158
+ read-only tuple of handles whose binding, label, and callback metadata can be
159
+ used to build custom help text. A menu may override or disable one locally:
160
+
161
+ ```python
162
+ menu.set_global_command_behavior(refresh, local_refresh)
163
+ menu.disable_global_command(refresh)
164
+ menu.enable_global_command(refresh)
165
+ menu.clear_global_command_behavior(refresh)
166
+ ```
167
+
168
+ System and global bindings cannot collide. Mutations validate first and leave
169
+ the old binding unchanged on failure. Terminals using legacy keyboard protocols
170
+ cannot distinguish every modifier combination: Ctrl+letter is often
171
+ case-insensitive and Shift may be represented only by character case.
172
+
173
+ ## Free-form and hidden input
174
+
175
+ ```python
176
+ def submit_password(value: str) -> None:
177
+ if value:
178
+ menu.leave_input_mode()
179
+
180
+ menu.enter_input_mode("Password: ", submit_password, hidden=True)
181
+ ```
182
+
183
+ Hidden masking and Backspace operate on complete Unicode graphemes, including
184
+ combining characters and emoji sequences. During free-form entry every global
185
+ command is disabled. Enter submits the current value and Escape leaves input
186
+ mode.
187
+
188
+ Input priority is: task-exit choice, hidden-menu handling, free-form input,
189
+ global commands, alerts, then focus/navigation. Unknown terminal sequences are
190
+ consumed and never block later input.
191
+
192
+ ## Alerts
193
+
194
+ A blocking alert has no misleading confirmation prompt and Enter does not close
195
+ it:
196
+
197
+ ```python
198
+ menu.show_alert("Waiting for an external event")
199
+ menu.clear_alert()
200
+ ```
201
+
202
+ A confirmable alert receives a `CommandContext` and closes only when its callback
203
+ returns normally:
204
+
205
+ ```python
206
+ menu.show_alert(
207
+ "Saved",
208
+ on_confirm=lambda context: context.menu.set_content_source("Ready"),
209
+ # prompt="Continue", # optional; default is Press Enter to continue
210
+ )
211
+ ```
212
+
213
+ Alerts preserve the content box and suspend any input prompt, buffer, hidden
214
+ state, and callback. Clearing the alert restores them. Global commands remain
215
+ active while an alert is shown.
216
+
217
+ ## Messages and visibility
218
+
219
+ ```python
220
+ from tuiloom import MessageKey
221
+
222
+ app.add_message("saved", "Saved successfully")
223
+ menu.show_message("saved") # True when displayed
224
+ menu.clear_message()
225
+ menu.disable_message("saved") # local suppression
226
+ app.disable_message("saved") # application-wide suppression
227
+
228
+ menu.show_message(MessageKey.NO_CONTENT_SOURCE)
229
+ ```
230
+
231
+ `MessageKey` also includes unknown-input and captured-task exit/wait messages.
232
+ Message keys are validated by every enable/disable/show operation. A suppressed
233
+ message returns `False` from `show_message()` without replacing the current
234
+ footer.
235
+
236
+ Setting `menu.show = False` clears the complete frame while its loop, sources,
237
+ and tasks keep running. Only global commands and Escape remain active; all other
238
+ input is discarded and cannot reappear when the menu is shown again.
239
+
240
+ ## Captured task output and closing
241
+
242
+ ```python
243
+ def download() -> str:
244
+ print("Downloading…")
245
+ return "archive.zip"
246
+
247
+ menu.run_with_output(
248
+ download,
249
+ on_success=lambda path: menu.show_alert(f"Saved {path}"),
250
+ on_error=lambda error: menu.show_alert(str(error)),
251
+ description="Download in progress",
252
+ )
253
+ ```
254
+
255
+ Only one application task may run at a time. Its callback runs on the UI thread.
256
+ Capture covers `print` and Python writes to `sys.stdout`/`sys.stderr`; subprocess
257
+ output and direct POSIX file-descriptor writes are not captured.
258
+
259
+ Quitting the root menu during a task displays:
260
+
261
+ - `1`: force quit, abandon callbacks, and discard the task's later Python output;
262
+ - `2`: wait and quit, animate the description, run the completion callback, then
263
+ restore the terminal and quit even if the callback changes menus;
264
+ - `0`: cancel the exit request and restore the previous footer.
265
+
266
+ While waiting, `0` remains available. Terminal restoration is guaranteed when a
267
+ completion callback raises.
268
+
269
+ ## Terminal hyperlinks
270
+
271
+ ```python
272
+ from tuiloom import hyperlink
273
+
274
+ label = hyperlink("Project", "https://github.com/maroard/Tuiloom")
275
+ ```
276
+
277
+ Only absolute HTTP/HTTPS URLs with a network location are accepted. Whitespace,
278
+ C0/C1 controls, Escape, and backslash are rejected. Link text is sanitized while
279
+ safe SGR styles are preserved.
280
+
281
+ ## Development
282
+
283
+ ```bash
284
+ make install
285
+ make check # read-only lint, format, strict MyPy, tests and coverage
286
+ make fix # the only formatting/fix target
287
+ make build # wheel + sdist + twine check
288
+ ```
289
+
290
+ CI runs Linux and macOS with Python 3.12, 3.13, and 3.14, then verifies the
291
+ distributions and installs the wheel in a fresh environment.
@@ -0,0 +1,264 @@
1
+ # Tuiloom
2
+
3
+ Tuiloom builds typed, keyboard-navigable terminal menus with dynamic content,
4
+ Unicode-safe rendering, captured task output, alerts, and free-form input. It
5
+ supports Python 3.12–3.14 on Linux and macOS.
6
+
7
+ > Tuiloom is not published yet. The API described here is the pre-PyPI API.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ pip install tuiloom
13
+ ```
14
+
15
+ ## First menu
16
+
17
+ ```python
18
+ from tuiloom import CommandContext, ScreenContext, TerminalApp, TerminalMenu
19
+
20
+ app = TerminalApp("Generator")
21
+ menu = TerminalMenu(
22
+ app,
23
+ ScreenContext(
24
+ menu_name="main",
25
+ title="Generation",
26
+ text="Choose an operation",
27
+ width=24, # minimum inner width, not a fixed width
28
+ ),
29
+ content_source="Ready",
30
+ )
31
+
32
+ def generate(context: CommandContext) -> None:
33
+ context.menu.set_content_source("Generated")
34
+
35
+ menu.add_command("Generate", generate)
36
+ app.set_main_menu(menu)
37
+ app.run()
38
+ ```
39
+
40
+ `TerminalApp.run()` is blocking. It requires an interactive terminal and must
41
+ run on the Python main thread. Terminal and cursor state are restored if a
42
+ callback or renderer raises.
43
+
44
+ ## Navigation and focus
45
+
46
+ The menu initially has focus. Up and Down move the selected command in a loop,
47
+ Enter activates it, and Escape activates the automatic final `Back` or `Quit`
48
+ option. The selected row always contains `>` so selection remains visible
49
+ without ANSI colors.
50
+
51
+ When content exists, Tab alternates focus between the menu and content boxes.
52
+ With content focused, all four arrows move its viewport. Manual upward movement
53
+ suspends `auto_scroll="smart"`; reaching the bottom resumes it.
54
+
55
+ Focused boxes use solid borders and unfocused boxes use dotted borders. A menu
56
+ without content has one solid box and Tab does nothing. Use
57
+ `content_spacing=False` to remove the otherwise single blank row between boxes.
58
+
59
+ ## Content sources
60
+
61
+ All four `ContentSource` forms are accepted:
62
+
63
+ ```python
64
+ from collections.abc import Iterator
65
+
66
+ static_text = "one\ntwo"
67
+ static_lines = ["one", "two"]
68
+
69
+ def stream() -> Iterator[str]:
70
+ yield "one\n"
71
+ yield "two\n"
72
+
73
+ def refreshed() -> str | list[str]:
74
+ return ["current", "state"]
75
+
76
+ menu.set_content_source(static_text)
77
+ menu.set_content_source(static_lines)
78
+ menu.set_content_source(stream())
79
+ menu.set_content_source(refreshed)
80
+ ```
81
+
82
+ An omitted menu source inherits `TerminalApp.global_content_source`. A content
83
+ box is rendered only when a source exists.
84
+
85
+ ## Stable command handles
86
+
87
+ Adding a command returns a stable handle. Positions are zero-based; booleans,
88
+ negative positions, and out-of-range positions are rejected immediately.
89
+
90
+ ```python
91
+ command = menu.add_command("Connect", connect)
92
+ menu.set_command_label(command, "Disconnect")
93
+ menu.set_command_behavior(command, disconnect)
94
+ menu.move_command(command, 0)
95
+ menu.disable_command(command)
96
+ menu.enable_command(command)
97
+ menu.set_exit_label("Close")
98
+ ```
99
+
100
+ Submenus must belong to the same application and are validated when added:
101
+
102
+ ```python
103
+ settings = TerminalMenu(app, ScreenContext("settings", "Settings"))
104
+ open_settings = menu.add_menu(settings, "Settings", position=0)
105
+ ```
106
+
107
+ `CommandContext.command` is the invoked `MenuCommand` or `GlobalCommand`.
108
+ `CommandContext.binding` is its triggering `KeyBinding`. Alert confirmation uses
109
+ `command=None` and the Enter binding.
110
+
111
+ ## Bindings and global commands
112
+
113
+ ```python
114
+ from tuiloom import KeyBinding, KeyMap, TerminalApp
115
+
116
+ keymap = KeyMap()
117
+ keymap.set_binding("focus", KeyBinding("f", ctrl=True))
118
+ app = TerminalApp("App", keymap=keymap)
119
+
120
+ refresh = app.add_global_command(
121
+ KeyBinding("r", ctrl=True),
122
+ "Refresh",
123
+ refresh_callback,
124
+ )
125
+ app.set_global_command_binding(refresh, KeyBinding("f5"))
126
+ app.set_global_command_label(refresh, "Reload")
127
+ app.set_global_command_behavior(refresh, reload_callback)
128
+ ```
129
+
130
+ Global commands are intentionally invisible. `app.global_commands` is a
131
+ read-only tuple of handles whose binding, label, and callback metadata can be
132
+ used to build custom help text. A menu may override or disable one locally:
133
+
134
+ ```python
135
+ menu.set_global_command_behavior(refresh, local_refresh)
136
+ menu.disable_global_command(refresh)
137
+ menu.enable_global_command(refresh)
138
+ menu.clear_global_command_behavior(refresh)
139
+ ```
140
+
141
+ System and global bindings cannot collide. Mutations validate first and leave
142
+ the old binding unchanged on failure. Terminals using legacy keyboard protocols
143
+ cannot distinguish every modifier combination: Ctrl+letter is often
144
+ case-insensitive and Shift may be represented only by character case.
145
+
146
+ ## Free-form and hidden input
147
+
148
+ ```python
149
+ def submit_password(value: str) -> None:
150
+ if value:
151
+ menu.leave_input_mode()
152
+
153
+ menu.enter_input_mode("Password: ", submit_password, hidden=True)
154
+ ```
155
+
156
+ Hidden masking and Backspace operate on complete Unicode graphemes, including
157
+ combining characters and emoji sequences. During free-form entry every global
158
+ command is disabled. Enter submits the current value and Escape leaves input
159
+ mode.
160
+
161
+ Input priority is: task-exit choice, hidden-menu handling, free-form input,
162
+ global commands, alerts, then focus/navigation. Unknown terminal sequences are
163
+ consumed and never block later input.
164
+
165
+ ## Alerts
166
+
167
+ A blocking alert has no misleading confirmation prompt and Enter does not close
168
+ it:
169
+
170
+ ```python
171
+ menu.show_alert("Waiting for an external event")
172
+ menu.clear_alert()
173
+ ```
174
+
175
+ A confirmable alert receives a `CommandContext` and closes only when its callback
176
+ returns normally:
177
+
178
+ ```python
179
+ menu.show_alert(
180
+ "Saved",
181
+ on_confirm=lambda context: context.menu.set_content_source("Ready"),
182
+ # prompt="Continue", # optional; default is Press Enter to continue
183
+ )
184
+ ```
185
+
186
+ Alerts preserve the content box and suspend any input prompt, buffer, hidden
187
+ state, and callback. Clearing the alert restores them. Global commands remain
188
+ active while an alert is shown.
189
+
190
+ ## Messages and visibility
191
+
192
+ ```python
193
+ from tuiloom import MessageKey
194
+
195
+ app.add_message("saved", "Saved successfully")
196
+ menu.show_message("saved") # True when displayed
197
+ menu.clear_message()
198
+ menu.disable_message("saved") # local suppression
199
+ app.disable_message("saved") # application-wide suppression
200
+
201
+ menu.show_message(MessageKey.NO_CONTENT_SOURCE)
202
+ ```
203
+
204
+ `MessageKey` also includes unknown-input and captured-task exit/wait messages.
205
+ Message keys are validated by every enable/disable/show operation. A suppressed
206
+ message returns `False` from `show_message()` without replacing the current
207
+ footer.
208
+
209
+ Setting `menu.show = False` clears the complete frame while its loop, sources,
210
+ and tasks keep running. Only global commands and Escape remain active; all other
211
+ input is discarded and cannot reappear when the menu is shown again.
212
+
213
+ ## Captured task output and closing
214
+
215
+ ```python
216
+ def download() -> str:
217
+ print("Downloading…")
218
+ return "archive.zip"
219
+
220
+ menu.run_with_output(
221
+ download,
222
+ on_success=lambda path: menu.show_alert(f"Saved {path}"),
223
+ on_error=lambda error: menu.show_alert(str(error)),
224
+ description="Download in progress",
225
+ )
226
+ ```
227
+
228
+ Only one application task may run at a time. Its callback runs on the UI thread.
229
+ Capture covers `print` and Python writes to `sys.stdout`/`sys.stderr`; subprocess
230
+ output and direct POSIX file-descriptor writes are not captured.
231
+
232
+ Quitting the root menu during a task displays:
233
+
234
+ - `1`: force quit, abandon callbacks, and discard the task's later Python output;
235
+ - `2`: wait and quit, animate the description, run the completion callback, then
236
+ restore the terminal and quit even if the callback changes menus;
237
+ - `0`: cancel the exit request and restore the previous footer.
238
+
239
+ While waiting, `0` remains available. Terminal restoration is guaranteed when a
240
+ completion callback raises.
241
+
242
+ ## Terminal hyperlinks
243
+
244
+ ```python
245
+ from tuiloom import hyperlink
246
+
247
+ label = hyperlink("Project", "https://github.com/maroard/Tuiloom")
248
+ ```
249
+
250
+ Only absolute HTTP/HTTPS URLs with a network location are accepted. Whitespace,
251
+ C0/C1 controls, Escape, and backslash are rejected. Link text is sanitized while
252
+ safe SGR styles are preserved.
253
+
254
+ ## Development
255
+
256
+ ```bash
257
+ make install
258
+ make check # read-only lint, format, strict MyPy, tests and coverage
259
+ make fix # the only formatting/fix target
260
+ make build # wheel + sdist + twine check
261
+ ```
262
+
263
+ CI runs Linux and macOS with Python 3.12, 3.13, and 3.14, then verifies the
264
+ distributions and installs the wheel in a fresh environment.
@@ -0,0 +1,98 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "tuiloom"
7
+ version = "0.1.0"
8
+ description = "Beautiful terminal interfaces, simple to build, easy to navigate."
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = "MIT"
12
+ authors = [
13
+ { name = "maroard" }
14
+ ]
15
+ keywords = [
16
+ "tui",
17
+ "terminal",
18
+ "cli",
19
+ "interface",
20
+ "menu",
21
+ "python",
22
+ ]
23
+
24
+ classifiers = [
25
+ "Development Status :: 3 - Alpha",
26
+ "Intended Audience :: Developers",
27
+ "Operating System :: MacOS",
28
+ "Operating System :: POSIX :: Linux",
29
+ "Programming Language :: Python",
30
+ "Programming Language :: Python :: 3",
31
+ "Programming Language :: Python :: 3.12",
32
+ "Programming Language :: Python :: 3.13",
33
+ "Programming Language :: Python :: 3.14",
34
+ "Topic :: Software Development :: Libraries :: Python Modules",
35
+ "Topic :: Terminals",
36
+ ]
37
+
38
+ dependencies = [
39
+ "blessed>=1.48,<2",
40
+ "wcwidth>=0.8",
41
+ ]
42
+
43
+ [project.urls]
44
+ Homepage = "https://github.com/maroard/Tuiloom"
45
+ Repository = "https://github.com/maroard/Tuiloom"
46
+ Issues = "https://github.com/maroard/Tuiloom/issues"
47
+
48
+ [dependency-groups]
49
+ dev = [
50
+ "mypy>=1.17",
51
+ "pytest>=8.4",
52
+ "pytest-cov>=6.2",
53
+ "ruff>=0.12",
54
+ "twine>=6.1",
55
+ ]
56
+
57
+ [tool.hatch.build.targets.wheel]
58
+ packages = ["src/tuiloom"]
59
+
60
+ [tool.hatch.build.targets.sdist]
61
+ include = [
62
+ "/src",
63
+ "/tests",
64
+ "/LICENSE",
65
+ "/README.md",
66
+ "/pyproject.toml",
67
+ ]
68
+
69
+ [tool.coverage.run]
70
+ branch = true
71
+ source = ["tuiloom"]
72
+
73
+ [tool.coverage.report]
74
+ fail_under = 90
75
+ show_missing = true
76
+ skip_covered = true
77
+
78
+ [tool.pytest.ini_options]
79
+ testpaths = ["tests"]
80
+ pythonpath = ["src"]
81
+
82
+ [tool.ruff]
83
+ target-version = "py312"
84
+ line-length = 88
85
+
86
+ [tool.ruff.lint]
87
+ select = [
88
+ "E",
89
+ "F",
90
+ "I",
91
+ "B",
92
+ "UP",
93
+ ]
94
+
95
+ [tool.mypy]
96
+ python_version = "3.12"
97
+ strict = true
98
+ files = ["src/tuiloom", "tests"]