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.
- tuiloom-0.1.0/.gitignore +7 -0
- tuiloom-0.1.0/LICENSE +21 -0
- tuiloom-0.1.0/PKG-INFO +291 -0
- tuiloom-0.1.0/README.md +264 -0
- tuiloom-0.1.0/pyproject.toml +98 -0
- tuiloom-0.1.0/src/tuiloom/__init__.py +34 -0
- tuiloom-0.1.0/src/tuiloom/_message_registry.py +134 -0
- tuiloom-0.1.0/src/tuiloom/command.py +127 -0
- tuiloom-0.1.0/src/tuiloom/event_loop/__init__.py +0 -0
- tuiloom-0.1.0/src/tuiloom/event_loop/event_loop.py +310 -0
- tuiloom-0.1.0/src/tuiloom/event_loop/source_event.py +16 -0
- tuiloom-0.1.0/src/tuiloom/event_loop/source_worker.py +130 -0
- tuiloom-0.1.0/src/tuiloom/formatting.py +28 -0
- tuiloom-0.1.0/src/tuiloom/input_handler/__init__.py +1 -0
- tuiloom-0.1.0/src/tuiloom/input_handler/input_event.py +11 -0
- tuiloom-0.1.0/src/tuiloom/input_handler/input_handler.py +121 -0
- tuiloom-0.1.0/src/tuiloom/key_binding.py +136 -0
- tuiloom-0.1.0/src/tuiloom/output_capture.py +159 -0
- tuiloom-0.1.0/src/tuiloom/output_task.py +128 -0
- tuiloom-0.1.0/src/tuiloom/py.typed +0 -0
- tuiloom-0.1.0/src/tuiloom/render/__init__.py +1 -0
- tuiloom-0.1.0/src/tuiloom/render/content_renderer.py +311 -0
- tuiloom-0.1.0/src/tuiloom/render/menu_renderer.py +179 -0
- tuiloom-0.1.0/src/tuiloom/render/rendered_content.py +12 -0
- tuiloom-0.1.0/src/tuiloom/render/segment_diff.py +96 -0
- tuiloom-0.1.0/src/tuiloom/render/terminal_renderer.py +210 -0
- tuiloom-0.1.0/src/tuiloom/render/terminal_text.py +202 -0
- tuiloom-0.1.0/src/tuiloom/render/viewport.py +107 -0
- tuiloom-0.1.0/src/tuiloom/screen_context/__init__.py +1 -0
- tuiloom-0.1.0/src/tuiloom/screen_context/screen_context.py +37 -0
- tuiloom-0.1.0/src/tuiloom/terminal_app.py +306 -0
- tuiloom-0.1.0/src/tuiloom/terminal_menu.py +639 -0
- tuiloom-0.1.0/tests/event_loop/__init__.py +0 -0
- tuiloom-0.1.0/tests/event_loop/test_event_loop.py +179 -0
- tuiloom-0.1.0/tests/event_loop/test_source_worker.py +116 -0
- tuiloom-0.1.0/tests/render/test_content_renderer.py +156 -0
- tuiloom-0.1.0/tests/render/test_menu_renderer.py +98 -0
- tuiloom-0.1.0/tests/render/test_segment_diff.py +74 -0
- tuiloom-0.1.0/tests/render/test_terminal_renderer.py +126 -0
- tuiloom-0.1.0/tests/render/test_terminal_text.py +101 -0
- tuiloom-0.1.0/tests/render/test_viewport.py +109 -0
- tuiloom-0.1.0/tests/test_commands_and_menu.py +163 -0
- tuiloom-0.1.0/tests/test_formatting.py +44 -0
- tuiloom-0.1.0/tests/test_input_handler.py +118 -0
- tuiloom-0.1.0/tests/test_key_binding.py +70 -0
- tuiloom-0.1.0/tests/test_menu_modes.py +224 -0
- tuiloom-0.1.0/tests/test_output_capture.py +78 -0
- tuiloom-0.1.0/tests/test_output_task.py +92 -0
- tuiloom-0.1.0/tests/test_pty.py +102 -0
- tuiloom-0.1.0/tests/test_public_api.py +126 -0
- tuiloom-0.1.0/tests/test_task_exit.py +209 -0
- tuiloom-0.1.0/tests/test_terminal_app.py +81 -0
tuiloom-0.1.0/.gitignore
ADDED
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.
|
tuiloom-0.1.0/README.md
ADDED
|
@@ -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"]
|