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 +34 -0
- tuiloom/_message_registry.py +134 -0
- tuiloom/command.py +127 -0
- tuiloom/event_loop/__init__.py +0 -0
- tuiloom/event_loop/event_loop.py +310 -0
- tuiloom/event_loop/source_event.py +16 -0
- tuiloom/event_loop/source_worker.py +130 -0
- tuiloom/formatting.py +28 -0
- tuiloom/input_handler/__init__.py +1 -0
- tuiloom/input_handler/input_event.py +11 -0
- tuiloom/input_handler/input_handler.py +121 -0
- tuiloom/key_binding.py +136 -0
- tuiloom/output_capture.py +159 -0
- tuiloom/output_task.py +128 -0
- tuiloom/py.typed +0 -0
- tuiloom/render/__init__.py +1 -0
- tuiloom/render/content_renderer.py +311 -0
- tuiloom/render/menu_renderer.py +179 -0
- tuiloom/render/rendered_content.py +12 -0
- tuiloom/render/segment_diff.py +96 -0
- tuiloom/render/terminal_renderer.py +210 -0
- tuiloom/render/terminal_text.py +202 -0
- tuiloom/render/viewport.py +107 -0
- tuiloom/screen_context/__init__.py +1 -0
- tuiloom/screen_context/screen_context.py +37 -0
- tuiloom/terminal_app.py +306 -0
- tuiloom/terminal_menu.py +639 -0
- tuiloom-0.1.0.dist-info/METADATA +291 -0
- tuiloom-0.1.0.dist-info/RECORD +31 -0
- tuiloom-0.1.0.dist-info/WHEEL +4 -0
- tuiloom-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|