tuiloom 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.
tuiloom/__init__.py ADDED
@@ -0,0 +1,34 @@
1
+ """Build typed terminal applications with menus and dynamic content."""
2
+
3
+ from tuiloom._message_registry import MessageKey
4
+ from tuiloom.command import (
5
+ CommandBehavior,
6
+ CommandContext,
7
+ GlobalCommand,
8
+ InputBehavior,
9
+ MenuCommand,
10
+ )
11
+ from tuiloom.formatting import hyperlink
12
+ from tuiloom.key_binding import KeyBinding, KeyMap
13
+ from tuiloom.render.content_renderer import ContentSource
14
+ from tuiloom.render.terminal_renderer import AutoScrollMode
15
+ from tuiloom.screen_context.screen_context import ScreenContext
16
+ from tuiloom.terminal_app import TerminalApp
17
+ from tuiloom.terminal_menu import TerminalMenu
18
+
19
+ __all__ = [
20
+ "AutoScrollMode",
21
+ "CommandBehavior",
22
+ "CommandContext",
23
+ "GlobalCommand",
24
+ "InputBehavior",
25
+ "KeyBinding",
26
+ "KeyMap",
27
+ "MenuCommand",
28
+ "hyperlink",
29
+ "ContentSource",
30
+ "ScreenContext",
31
+ "TerminalApp",
32
+ "TerminalMenu",
33
+ "MessageKey",
34
+ ]
@@ -0,0 +1,134 @@
1
+ from collections.abc import Callable
2
+ from enum import StrEnum
3
+
4
+ type MessageFactory = Callable[..., str]
5
+ type MessageValue = str | MessageFactory
6
+
7
+
8
+ class MessageKey(StrEnum):
9
+ """Identify built-in messages that may be shown or disabled.
10
+
11
+ ``NO_CONTENT_SOURCE`` explains that a menu has no content box.
12
+ ``UNKNOWN_COMMAND`` describes discarded textual command input retained for
13
+ integrations. ``TASK_EXIT_CHOICES`` presents safe task-closing choices and
14
+ ``TASK_WAITING`` labels the animated wait state.
15
+ """
16
+
17
+ NO_CONTENT_SOURCE = "no_content_source"
18
+ UNKNOWN_COMMAND = "unknown_command"
19
+ TASK_EXIT_CHOICES = "task_exit_choices"
20
+ TASK_WAITING = "task_waiting"
21
+
22
+
23
+ class MessageRegistry:
24
+ """Store, customize, and selectively disable application messages."""
25
+
26
+ def __init__(self) -> None:
27
+ """Create a registry populated with the built-in messages."""
28
+ self._built_in_messages: dict[str, MessageValue] = {}
29
+ self._custom_messages: dict[str, str] = {}
30
+ self._disabled: set[str] = set()
31
+
32
+ self._register_built_in_messages()
33
+
34
+ # Keep every built-in message registration in one visible place.
35
+ def _register_built_in_messages(self) -> None:
36
+ """Populate the registry with Tuiloom's built-in messages."""
37
+ self._add_built_in_message(
38
+ MessageKey.NO_CONTENT_SOURCE,
39
+ self._no_content_source_message,
40
+ )
41
+
42
+ self._add_built_in_message(
43
+ MessageKey.UNKNOWN_COMMAND,
44
+ self._unknown_command,
45
+ )
46
+ self._add_built_in_message(
47
+ MessageKey.TASK_EXIT_CHOICES,
48
+ "1: Force quit\n2: Wait and quit\n0: Cancel",
49
+ )
50
+ self._add_built_in_message(MessageKey.TASK_WAITING, "Task in progress")
51
+
52
+ # Register a message owned by the library.
53
+ def _add_built_in_message(
54
+ self,
55
+ key: str,
56
+ message: MessageValue,
57
+ ) -> None:
58
+ """Register one message owned by the library."""
59
+ self._validate_new_key(key)
60
+ self._built_in_messages[key] = message
61
+
62
+ # Register a custom, user-owned message.
63
+ def add_message(self, key: str, text: str) -> None:
64
+ """Register a custom static message under a unique key."""
65
+ self._validate_new_key(key)
66
+ self._custom_messages[key] = text
67
+
68
+ def disable(self, key: str) -> None:
69
+ """Disable a registered message globally."""
70
+ self._validate_existing_key(key)
71
+ self._disabled.add(key)
72
+
73
+ def enable(self, key: str) -> None:
74
+ """Re-enable a registered message globally."""
75
+ self._validate_existing_key(key)
76
+ self._disabled.discard(key)
77
+
78
+ def get(self, key: str, **context: object) -> str | None:
79
+ """Resolve an enabled message using any required context."""
80
+ if key in self._disabled:
81
+ return None
82
+
83
+ message = self._built_in_messages.get(key)
84
+
85
+ if message is None:
86
+ message = self._custom_messages.get(key)
87
+
88
+ if callable(message):
89
+ return message(**context)
90
+
91
+ return message
92
+
93
+ def validate_key(self, key: str) -> None:
94
+ """Raise ``KeyError`` unless ``key`` is registered."""
95
+ self._validate_existing_key(key)
96
+
97
+ def is_enabled(self, key: str) -> bool:
98
+ """Validate and report global enablement."""
99
+ self._validate_existing_key(key)
100
+ return key not in self._disabled
101
+
102
+ def _validate_new_key(self, key: str) -> None:
103
+ """Reject empty or already registered message keys."""
104
+ if not key:
105
+ raise ValueError("A message key cannot be empty")
106
+
107
+ if key in self._built_in_messages or key in self._custom_messages:
108
+ raise ValueError(f"A message already exists for key: {key}")
109
+
110
+ def _validate_existing_key(self, key: str) -> None:
111
+ """Reject message keys that are not registered."""
112
+ if key not in self._built_in_messages and key not in self._custom_messages:
113
+ raise KeyError(f"Unknown message key: {key}")
114
+
115
+ @staticmethod
116
+ def _no_content_source_message(menu_name: str) -> str:
117
+ """Build the message shown when a menu has no content source."""
118
+ return (
119
+ "No content source has been set for this menu "
120
+ f"({menu_name})\n"
121
+ "You can set it by using this method: \n"
122
+ " 'set_content_source(content_source: ContentSource)'\n"
123
+ " ContentSource being: (\n"
124
+ " str\n"
125
+ " | list[str]\n"
126
+ " | Iterator[str]\n"
127
+ " | Callable[[], str | list[str]]\n"
128
+ " )"
129
+ )
130
+
131
+ @staticmethod
132
+ def _unknown_command(command: str) -> str:
133
+ """Build the message shown for an unknown command."""
134
+ return f"Unknown command '{command}'"
tuiloom/command.py ADDED
@@ -0,0 +1,127 @@
1
+ """Callback aliases, execution contexts, and stable command handles.
2
+
3
+ ``CommandBehavior`` is a callback receiving :class:`CommandContext`.
4
+ ``InputBehavior`` receives the submitted Unicode text from free-form input.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Callable
10
+ from dataclasses import dataclass
11
+ from typing import TYPE_CHECKING
12
+
13
+ from tuiloom.key_binding import KeyBinding
14
+
15
+ if TYPE_CHECKING:
16
+ from tuiloom.terminal_app import TerminalApp
17
+ from tuiloom.terminal_menu import TerminalMenu
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class CommandContext:
22
+ """Describe one callback execution created by Tuiloom.
23
+
24
+ Attributes:
25
+ app: Application dispatching the callback.
26
+ menu: Menu active when the callback was dispatched.
27
+ command: Stable menu/global handle, or ``None`` for alert confirmation.
28
+ binding: Triggering binding, or ``None`` for programmatic execution.
29
+ """
30
+
31
+ app: TerminalApp
32
+ menu: TerminalMenu
33
+ command: MenuCommand | GlobalCommand | None
34
+ binding: KeyBinding | None
35
+
36
+
37
+ type CommandBehavior = Callable[[CommandContext], None]
38
+ type InputBehavior = Callable[[str], None]
39
+
40
+
41
+ class MenuCommand:
42
+ """Stable handle for one selectable menu command.
43
+
44
+ Attributes:
45
+ label: Current visible label.
46
+ behavior: Current callback.
47
+ position: Zero-based position among user commands.
48
+ enabled: Whether the command can currently be selected and activated.
49
+ """
50
+
51
+ __slots__ = ("_menu", "_label", "_behavior", "_enabled")
52
+
53
+ def __init__(
54
+ self, menu: TerminalMenu, label: str, behavior: CommandBehavior
55
+ ) -> None:
56
+ self._menu = menu
57
+ self._label = label
58
+ self._behavior = behavior
59
+ self._enabled = True
60
+
61
+ @property
62
+ def label(self) -> str:
63
+ """Return the command's current label."""
64
+ return self._label
65
+
66
+ @property
67
+ def behavior(self) -> CommandBehavior:
68
+ """Return the command's current callback."""
69
+ return self._behavior
70
+
71
+ @property
72
+ def position(self) -> int:
73
+ """Return the command's current zero-based position."""
74
+ return self._menu._position_of(self)
75
+
76
+ @property
77
+ def enabled(self) -> bool:
78
+ """Return whether the command is locally enabled."""
79
+ return self._enabled
80
+
81
+
82
+ class GlobalCommand:
83
+ """Stable handle and read-only metadata for one invisible global command.
84
+
85
+ Attributes:
86
+ binding: Binding that immediately invokes the command.
87
+ label: User-defined label suitable for a custom help display.
88
+ behavior: Application-level callback.
89
+ """
90
+
91
+ __slots__ = ("_app", "_binding", "_label", "_behavior")
92
+
93
+ def __init__(
94
+ self,
95
+ app: TerminalApp,
96
+ binding: KeyBinding,
97
+ label: str,
98
+ behavior: CommandBehavior,
99
+ ) -> None:
100
+ self._app = app
101
+ self._binding = binding
102
+ self._label = label
103
+ self._behavior = behavior
104
+
105
+ @property
106
+ def binding(self) -> KeyBinding:
107
+ """Return the current triggering binding."""
108
+ return self._binding
109
+
110
+ @property
111
+ def label(self) -> str:
112
+ """Return the descriptive label."""
113
+ return self._label
114
+
115
+ @property
116
+ def behavior(self) -> CommandBehavior:
117
+ """Return the application-level callback."""
118
+ return self._behavior
119
+
120
+
121
+ def _without_context(action: Callable[[], None]) -> CommandBehavior:
122
+ """Adapt an internal zero-argument action to a command callback."""
123
+
124
+ def wrapped(context: CommandContext) -> None:
125
+ action()
126
+
127
+ return wrapped
File without changes
@@ -0,0 +1,310 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from queue import Empty, Queue
5
+ from selectors import EVENT_READ, BaseSelector, DefaultSelector
6
+ from shutil import get_terminal_size
7
+ from socket import socketpair
8
+ from time import monotonic
9
+ from typing import TYPE_CHECKING
10
+
11
+ from tuiloom.event_loop.source_event import SourceEvent
12
+ from tuiloom.event_loop.source_worker import SourceWorker
13
+ from tuiloom.input_handler.input_handler import InputHandler
14
+ from tuiloom.render.content_renderer import ContentRenderer, ContentSource
15
+ from tuiloom.render.menu_renderer import MenuRenderer
16
+ from tuiloom.render.terminal_renderer import TerminalRenderer
17
+
18
+ if TYPE_CHECKING:
19
+ from tuiloom.terminal_menu import TerminalMenu
20
+
21
+
22
+ class EventLoop:
23
+ """Coordinate input, content sources, and frame scheduling for one menu."""
24
+
25
+ _FRAME_INTERVAL = 1 / 60
26
+ _STATE_CHECK_INTERVAL = 0.1
27
+ _SOURCE_QUEUE_SIZE = 256
28
+
29
+ def __init__(
30
+ self,
31
+ menu: TerminalMenu,
32
+ input_handler: InputHandler,
33
+ menu_renderer: MenuRenderer,
34
+ terminal_renderer: TerminalRenderer,
35
+ content_renderer: ContentRenderer,
36
+ *,
37
+ clock: Callable[[], float] = monotonic,
38
+ selector_factory: Callable[[], BaseSelector] = DefaultSelector,
39
+ ) -> None:
40
+ """Create an event loop over initialized menu renderers."""
41
+ self._menu = menu
42
+ self._input_handler = input_handler
43
+ self._menu_renderer = menu_renderer
44
+ self._terminal_renderer = terminal_renderer
45
+ self._content_renderer = content_renderer
46
+ self._clock = clock
47
+ self._selector = selector_factory()
48
+ self._wakeup_reader, self._wakeup_writer = socketpair()
49
+ self._wakeup_reader.setblocking(False)
50
+ self._wakeup_writer.setblocking(False)
51
+ self._selector.register(input_handler.fileno(), EVENT_READ, "input")
52
+ self._selector.register(self._wakeup_reader, EVENT_READ, "source")
53
+
54
+ self._source_events: Queue[SourceEvent] = Queue(maxsize=self._SOURCE_QUEUE_SIZE)
55
+ self._generation = 0
56
+ self._source_worker: SourceWorker | None = None
57
+ self._dirty = True
58
+ now = self._clock()
59
+ self._next_frame_at = now
60
+ self._next_state_check_at = now
61
+ self._dynamic_in_flight = False
62
+ self._next_dynamic_at = now
63
+ self._terminal_size = get_terminal_size()
64
+ self._closed = False
65
+
66
+ self._install_worker(content_renderer)
67
+
68
+ def run(self) -> None:
69
+ """Process events until the owning menu stops."""
70
+ while self._menu._running:
71
+ self.run_once()
72
+
73
+ def run_once(self) -> None:
74
+ """Process one selectable event-loop turn."""
75
+ ready = self._selector.select(self._get_wait_timeout())
76
+
77
+ for key, _ in ready:
78
+ if key.data == "source":
79
+ self._drain_wakeup()
80
+ self._drain_source_events()
81
+
82
+ self._drain_input()
83
+ self._request_dynamic_update()
84
+ completed_menu = self._menu.app._dispatch_output_task_outcome()
85
+
86
+ if completed_menu is self._menu:
87
+ self.request_render(immediate=True)
88
+
89
+ self._check_visible_state()
90
+ self._render_if_due()
91
+
92
+ def request_render(self, immediate: bool = False) -> None:
93
+ """Mark visible state dirty for the next permitted frame."""
94
+ self._dirty = True
95
+
96
+ if immediate:
97
+ self._next_frame_at = self._clock()
98
+
99
+ def install_source(self, source: ContentSource) -> None:
100
+ """Replace the active source and discard every stale source event."""
101
+ content_renderer = ContentRenderer(source)
102
+ self._content_renderer = content_renderer
103
+ self._menu._content_renderer = content_renderer
104
+ self._terminal_renderer.set_content_renderer(content_renderer)
105
+ self._install_worker(content_renderer)
106
+ self.request_render(immediate=True)
107
+
108
+ def close(self) -> None:
109
+ """Release event-loop resources without waiting on blocked source code."""
110
+ if self._closed:
111
+ return
112
+
113
+ self._closed = True
114
+
115
+ if self._source_worker is not None:
116
+ self._source_worker.cancel()
117
+
118
+ self._selector.close()
119
+ self._wakeup_reader.close()
120
+ self._wakeup_writer.close()
121
+
122
+ def _install_worker(self, content_renderer: ContentRenderer) -> None:
123
+ """Cancel the old generation and start the new source when required."""
124
+ if self._source_worker is not None:
125
+ self._source_worker.cancel()
126
+
127
+ self._generation += 1
128
+ self._clear_source_events()
129
+ self._source_worker = None
130
+ self._dynamic_in_flight = False
131
+
132
+ if content_renderer.state == "static":
133
+ return
134
+
135
+ source = content_renderer.source
136
+
137
+ if not callable(source) and not hasattr(source, "__next__"):
138
+ raise RuntimeError("Non-static content source cannot be consumed")
139
+
140
+ self._source_worker = SourceWorker(
141
+ generation=self._generation,
142
+ source=source,
143
+ events=self._source_events,
144
+ notify=self._notify_source,
145
+ )
146
+ self._source_worker.start()
147
+
148
+ def _clear_source_events(self) -> None:
149
+ """Discard queued results belonging to a replaced source."""
150
+ while True:
151
+ try:
152
+ self._source_events.get_nowait()
153
+ except Empty:
154
+ return
155
+
156
+ def _drain_input(self) -> None:
157
+ """Handle every input event immediately available from the terminal."""
158
+ while True:
159
+ event = self._input_handler.poll()
160
+
161
+ if event is None:
162
+ return
163
+
164
+ self._menu._handle_event(event)
165
+ self.request_render()
166
+
167
+ if not self._menu._running:
168
+ return
169
+
170
+ def _drain_source_events(self) -> None:
171
+ """Apply every current generation event as one content update batch."""
172
+ events: list[SourceEvent] = []
173
+
174
+ while True:
175
+ try:
176
+ event = self._source_events.get_nowait()
177
+ except Empty:
178
+ break
179
+
180
+ if event.generation == self._generation:
181
+ events.append(event)
182
+
183
+ if not events:
184
+ return
185
+
186
+ if self._content_renderer.state == "streaming":
187
+ chunks = [
188
+ event.value
189
+ for event in events
190
+ if event.kind == "data" and isinstance(event.value, str)
191
+ ]
192
+
193
+ if chunks:
194
+ self._content_renderer.append_stream_batch(chunks)
195
+ self._terminal_renderer.apply_stream_auto_scroll(self._menu.auto_scroll)
196
+ self.request_render()
197
+
198
+ elif self._content_renderer.state == "dynamic":
199
+ values = [event.value for event in events if event.kind == "data"]
200
+
201
+ if values:
202
+ value = values[-1]
203
+
204
+ if not isinstance(value, (str, list)):
205
+ raise RuntimeError("Dynamic worker returned invalid content")
206
+
207
+ self._content_renderer.replace_dynamic_content(value)
208
+ self.request_render()
209
+
210
+ self._dynamic_in_flight = False
211
+
212
+ for event in events:
213
+ self._handle_source_event(event)
214
+
215
+ def _handle_source_event(self, event: SourceEvent) -> None:
216
+ """Handle completion and failures after applying source data."""
217
+ if event.kind == "complete":
218
+ self._content_renderer.finish_stream()
219
+ self.request_render()
220
+ return
221
+
222
+ if event.kind == "error":
223
+ if event.error is None:
224
+ raise RuntimeError("Source failure event has no exception")
225
+
226
+ raise event.error.with_traceback(event.traceback)
227
+
228
+ def _request_dynamic_update(self) -> None:
229
+ """Request one dynamic result when no evaluation is in flight."""
230
+ if (
231
+ self._content_renderer.state != "dynamic"
232
+ or self._source_worker is None
233
+ or self._dynamic_in_flight
234
+ or self._clock() < self._next_dynamic_at
235
+ ):
236
+ return
237
+
238
+ self._dynamic_in_flight = True
239
+ self._next_dynamic_at = self._clock() + self._FRAME_INTERVAL
240
+ self._source_worker.request_dynamic_update()
241
+
242
+ def _render_if_due(self) -> None:
243
+ """Render dirty state no faster than the configured frame interval."""
244
+ now = self._clock()
245
+
246
+ if not self._dirty or now < self._next_frame_at:
247
+ return
248
+
249
+ self._menu_renderer.update()
250
+ self._terminal_renderer.render()
251
+ self._dirty = False
252
+ self._next_frame_at = now + self._FRAME_INTERVAL
253
+
254
+ def _get_wait_timeout(self) -> float:
255
+ """Return the delay until the next scheduled loop responsibility."""
256
+ now = self._clock()
257
+ deadlines = [self._next_state_check_at]
258
+
259
+ if self._dirty:
260
+ deadlines.append(self._next_frame_at)
261
+
262
+ input_timeout = self._input_handler.get_pending_timeout(now)
263
+
264
+ if input_timeout is not None:
265
+ deadlines.append(now + input_timeout)
266
+
267
+ if self._content_renderer.state == "dynamic" and not self._dynamic_in_flight:
268
+ deadlines.append(self._next_dynamic_at)
269
+
270
+ return max(0.0, min(deadlines) - now)
271
+
272
+ def _check_visible_state(self) -> None:
273
+ """Detect screen-context and terminal-size changes at a fixed cadence."""
274
+ now = self._clock()
275
+
276
+ if now < self._next_state_check_at:
277
+ return
278
+
279
+ revision = self._menu_renderer.revision
280
+ self._menu_renderer.update()
281
+
282
+ if self._menu_renderer.revision != revision:
283
+ self.request_render()
284
+
285
+ if self._menu._tick_task_exit(now):
286
+ self.request_render()
287
+
288
+ terminal_size = get_terminal_size()
289
+
290
+ if terminal_size != self._terminal_size:
291
+ self._terminal_size = terminal_size
292
+ self.request_render(immediate=True)
293
+
294
+ self._next_state_check_at = now + self._STATE_CHECK_INTERVAL
295
+
296
+ def _notify_source(self) -> None:
297
+ """Wake the selector after publishing a source event."""
298
+ try:
299
+ self._wakeup_writer.send(b"\0")
300
+ except (BlockingIOError, OSError):
301
+ pass
302
+
303
+ def _drain_wakeup(self) -> None:
304
+ """Discard every coalesced source wakeup byte."""
305
+ while True:
306
+ try:
307
+ if not self._wakeup_reader.recv(256):
308
+ return
309
+ except BlockingIOError:
310
+ return
@@ -0,0 +1,16 @@
1
+ from dataclasses import dataclass
2
+ from types import TracebackType
3
+ from typing import Literal
4
+
5
+ type SourceEventKind = Literal["data", "complete", "error"]
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class SourceEvent:
10
+ """Carry one generation-tagged result from a content source worker."""
11
+
12
+ generation: int
13
+ kind: SourceEventKind
14
+ value: str | list[str] | None = None
15
+ error: BaseException | None = None
16
+ traceback: TracebackType | None = None