diff --git a/CHANGELOG.md b/CHANGELOG.md index 631602c60..2710dcbbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,21 @@ ## 4.3.0 (TBD) +- Bug Fixes + - Reserved bottom toolbars now indicate clipped content with a right-edge ellipsis (`…`). Long + lines are truncated without wrapping, and newlines beyond the reserved row are indicated + instead of silently hiding content, including when the first line is empty. Trailing + whitespace and a trailing newline are not marked, since nothing visible is lost. + - Reserved bottom toolbars display control characters in caret notation, as legacy rendering + does, instead of sending them to the terminal. + +- Breaking Changes + - Replaced `enable_bottom_toolbar` with `bottom_toolbar_mode` in `Cmd.__init__()`. The default, + `cmd2.ToolbarMode.OFF`, disables the toolbar. Use `cmd2.ToolbarMode.AUTO` where you previously + passed `enable_bottom_toolbar=True`. + - Enhancements - - `enable_bottom_toolbar=True` now keeps the toolbar visible and refreshing during command - execution + - `bottom_toolbar_mode=cmd2.ToolbarMode.AUTO` now keeps the toolbar visible and refreshing + during command execution - `Cmd.read_input()` and `Cmd.read_secret()` now keep the bottom toolbar visible while they wait for input, refreshing at the same `refresh_interval` as the main prompt, instead of the toolbar disappearing for the duration of the nested prompt. `Cmd.select()` is unchanged, since diff --git a/cmd2/__init__.py b/cmd2/__init__.py index c4aafcb1d..b91049e01 100644 --- a/cmd2/__init__.py +++ b/cmd2/__init__.py @@ -64,6 +64,7 @@ reset_theme, update_theme, ) +from .toolbar_mode import ToolbarMode from .utils import ( CustomCompletionSettings, Settable, @@ -120,6 +121,7 @@ "stylize", # Styles "Cmd2Style", + "ToolbarMode", # Theme "get_theme", "reset_theme", diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 7d4b466c2..a4107835b 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -165,6 +165,7 @@ StatementParser, shlex_split, ) +from .reserved_toolbar import ReservedToolbar, native_toolbar_container from .rich_utils import ( Cmd2BaseConsole, Cmd2ExceptionConsole, @@ -174,6 +175,7 @@ ) from .styles import Cmd2Style from .theme import get_pt_theme +from .toolbar_mode import ToolbarMode, _select_toolbar_mode, _validate_toolbar_mode from .types import ( BoundCommandFunc, BoundCompleter, @@ -376,9 +378,9 @@ def __init__( allow_redirection: bool = True, auto_load_commands: bool = False, auto_suggest: bool = True, + bottom_toolbar_mode: ToolbarMode = ToolbarMode.OFF, complete_in_thread: bool = True, command_sets: Iterable[CommandSet[Any]] | None = None, - enable_bottom_toolbar: bool = False, enable_rprompt: bool = False, include_ipy: bool = False, include_py: bool = False, @@ -420,8 +422,18 @@ def __init__( This allows CommandSets with custom constructor parameters to be loaded. This also allows the a set of CommandSets to be provided when `auto_load_commands` is set to False - :param enable_bottom_toolbar: if ``True``, enables a bottom toolbar at the main prompt and during commands. - Override ``get_bottom_toolbar()`` to define its content. + :param bottom_toolbar_mode: how the bottom toolbar is rendered, as a + [cmd2.ToolbarMode][] or its name. + ``ToolbarMode.OFF``, the default, disables it. Other modes + enable it at the main prompt and during commands; override + ``get_bottom_toolbar()`` to define its content. + ``ToolbarMode.LEGACY`` redraws it with the prompt. + ``ToolbarMode.RESERVED`` keeps it in terminal rows + withheld from scrolling and raises ``ValueError`` where + that is not available; ``ToolbarMode.AUTO`` uses reserved + rendering only on qualified terminals and falls back + silently. Reserved rendering is experimental and not yet a + supported configuration. :param enable_rprompt: if ``True``, enables a right prompt while at the main prompt. Override ``get_rprompt()`` to define its content. :param include_ipy: should the "ipy" command be included for an embedded IPython shell @@ -547,12 +559,19 @@ def __init__( self._persistent_history_length = persistent_history_length self._initialize_history(persistent_history_file) + # How the bottom toolbar is rendered. Validated here rather than at the first prompt + # so that a typo fails where it was written. + self._bottom_toolbar_mode = _validate_toolbar_mode(bottom_toolbar_mode) + self._reserved_toolbar: ReservedToolbar | None = None + # A command display whose thread did not stop when it was asked to. It still owns the + # terminal, so nothing may be handed the terminal until it lets go. + self._display_holding_terminal: command_toolbar.CommandToolbar | None = None + # Create the main PromptSession self.main_session = self._create_main_session( auto_suggest=auto_suggest, complete_in_thread=complete_in_thread, completekey=completekey, - enable_bottom_toolbar=enable_bottom_toolbar, enable_rprompt=enable_rprompt, refresh_interval=refresh_interval, ) @@ -653,7 +672,7 @@ def __init__( # The embedded pager shares the main toolbar. Applications can opt back # into their configured external pager by setting this to False. - self.use_builtin_pager = enable_bottom_toolbar + self.use_builtin_pager = self._bottom_toolbar_mode is not ToolbarMode.OFF # Set the pager(s) for use when displaying output using a pager if sys.platform.startswith("win"): @@ -805,7 +824,6 @@ def _create_main_session( auto_suggest: bool, complete_in_thread: bool, completekey: str, - enable_bottom_toolbar: bool, enable_rprompt: bool, refresh_interval: float, ) -> PromptSession[str]: @@ -818,7 +836,7 @@ def _create_main_session( # Base configuration kwargs: dict[str, Any] = { "auto_suggest": AutoSuggestFromHistory() if auto_suggest else None, - "bottom_toolbar": self.get_bottom_toolbar if enable_bottom_toolbar else None, + "bottom_toolbar": self.get_bottom_toolbar if self._bottom_toolbar_mode is not ToolbarMode.OFF else None, "color_depth": pt_resolve_color_depth(), "complete_style": CompleteStyle.MULTI_COLUMN, "complete_in_thread": complete_in_thread, @@ -1508,6 +1526,18 @@ def allow_style_type(value: str) -> ru.AllowStyle: ) ) + @property + def bottom_toolbar_mode(self) -> ToolbarMode: + """How the bottom toolbar is rendered. + + Read-only after construction: the reservation is established once for the lifetime of + the command loop, so changing this while one is running would leave the terminal and + the setting describing different things. + + :return: the mode this application was constructed with + """ + return self._bottom_toolbar_mode + @property def allow_style(self) -> ru.AllowStyle: """Property needed to support do_set when it reads allow_style.""" @@ -2093,8 +2123,8 @@ def ppretty( def get_bottom_toolbar(self) -> AnyFormattedText: """Get the bottom toolbar content. - This method is called by prompt-toolkit at the main prompt and during commands if ``enable_bottom_toolbar`` - was set to ``True`` during initialization. Because prompt-toolkit executes this callback + This method is called by prompt-toolkit at the main prompt and during commands if ``bottom_toolbar_mode`` + was set to a mode other than ``ToolbarMode.OFF`` during initialization. Because prompt-toolkit executes this callback on every UI refresh (such as on every keypress or at scheduled refresh intervals), keeping this function highly optimized is critical to ensuring the CLI remains responsive. @@ -2102,6 +2132,10 @@ def get_bottom_toolbar(self) -> AnyFormattedText: your application. This could be information like the application name, current state, or even a real-time clock. + Reserved rendering uses one row and clips each logical line without wrapping. An + ellipsis in the rightmost column indicates omitted text or lines after a newline. + Widths are measured in terminal columns; wide characters are never split. + During command execution this callback runs in a background UI thread. Protect shared state with a lock when necessary. The built-in pager shares this toolbar. It is suspended while another prompt, external pager, or interactive shell owns the terminal. @@ -2110,6 +2144,31 @@ def get_bottom_toolbar(self) -> AnyFormattedText: """ return None + def _require_terminal_ownership(self) -> None: + """Refuse to use the terminal while a display that would not stop still holds it. + + A display that timed out on shutdown is disabled for the rest of the session, but that + only stops another one from starting. Its thread is still inside the application: + rendering, and reading the same input. Handing that terminal to a guest, or prompting + on it, would put two readers on one device and interleave their output. + + The check heals itself. The thread may finish late -- a render callback that finally + returned, a subprocess that finally exited -- and once it has, the terminal is ours + again and this stops refusing. + + :raises RuntimeError: while the surviving display still holds the terminal + """ + display = self._display_holding_terminal + if display is None: + return + if display.complete_abandoned_shutdown(): + # Its thread has ended, so the teardown its timed-out pause could not do has been + # finished here. Clearing the reference without that would hand the next prompt an + # application still dressed as the command display. + self._display_holding_terminal = None + return + raise RuntimeError("the bottom toolbar's display has not released the terminal") + @contextlib.contextmanager def suspend_bottom_toolbar(self) -> Iterator[None]: """Temporarily hide the command toolbar and give exclusive access to the terminal. @@ -2117,13 +2176,74 @@ def suspend_bottom_toolbar(self) -> Iterator[None]: Use this context manager around application-specific calls to ``input()``, other terminal UIs, or subprocesses that inherit the terminal. cmd2 automatically suspends its toolbar for its own input prompts, external pagers, and shell commands. + + In reserved mode this also gives the reserved rows back, because a program that + inherits the terminal knows nothing about a scroll region and would find its output + confined to rows it never asked for. The rows are taken again afterwards. """ + with self._quiesce_bottom_toolbar(): + reserved = self._reserved_toolbar + + if reserved is None: + yield + else: + # Inside the pause, not around it: the renderer has to be quiet before the + # margins go, or a frame could land in rows that are no longer reserved. + with reserved.suspended(): + yield + + @contextlib.contextmanager + def _quiesce_bottom_toolbar(self) -> Iterator[None]: + """Stop the command display without giving the terminal away. + + This is the other half of the distinction the reserved row makes necessary. Pausing + the renderer and the input reader is one thing; handing the physical terminal to + something else is another, and the ordinary end of a command needs only the first -- + the toolbar has to still be there when the next prompt appears. + """ + self._require_terminal_ownership() if self._command_toolbar is None: yield else: with self._command_toolbar.suspend(): yield + @property + def reserved_toolbar(self) -> "ReservedToolbar | None": + """The reserved-row toolbar owning the terminal, or ``None`` in legacy mode.""" + return self._reserved_toolbar + + @contextlib.contextmanager + def _reserved_toolbar_context(self) -> Iterator[None]: + """Own the reserved-row toolbar for the lifetime of one command loop. + + The mode is chosen here rather than at construction because the answer depends on the + session in use *now*: a caller may have replaced ``main_session`` since, and the + terminal it renders to is what decides whether a reservation is possible. + + A refused reservation is not a refused loop. Below the two-row floor there is nothing + to reserve and the toolbar renders natively, which is why the object is kept even when + it is inactive -- the terminal can grow back. + """ + mode, _reason = _select_toolbar_mode( + self._bottom_toolbar_mode, + self.main_session.app.output, + toolbar_enabled=self.main_session.bottom_toolbar is not None, + interactive=self._is_tty_session(self.main_session), + layout_supported=native_toolbar_container(self.main_session) is not None, + ) + if mode is not ToolbarMode.RESERVED: + yield + return + + toolbar = ReservedToolbar(self.main_session, lambda: self.main_session.bottom_toolbar) + self._reserved_toolbar = toolbar + try: + with toolbar: + yield + finally: + self._reserved_toolbar = None + @contextlib.contextmanager def _command_toolbar_context(self) -> Iterator[None]: """Display the toolbar around commands launched by the interactive command loop.""" @@ -3185,7 +3305,7 @@ def onecmd_plus_hooks( return stop - @command_toolbar.suspend_toolbar + @command_toolbar.quiesce_toolbar def _run_cmdfinalization_hooks(self, stop: bool, statement: Statement | None) -> bool: """Run the command finalization hooks.""" if self._initial_termios_settings is not None and self.stdin.isatty(): # type: ignore[unreachable] @@ -3655,7 +3775,6 @@ def _is_tty_session(session: PromptSession[str]) -> bool: # a DummyOutput. return not isinstance(session.input, DummyInput) - @command_toolbar.suspend_toolbar def _read_raw_input( self, prompt: Callable[[], ANSI | str] | ANSI | str, @@ -3668,6 +3787,31 @@ def _read_raw_input( UI with completion and `patch_stdout` protection. Otherwise it performs a direct line read from `stdin`. + The command display is stopped either way, but only some prompts give the terminal + away with it. The main prompt is the one the reservation exists for: it renders + through the reserved output, and the toolbar has to still be there while the user is + typing -- that is what "stable across ordinary commands" means. Any other session is + an application prompt cmd2 has not bound to the reservation, so it gets the terminal + to itself, rows included. + + :param prompt: the prompt text or a callable that returns the prompt. + :param session: the PromptSession instance to use for reading. + :param prompt_kwargs: additional arguments passed directly to session.prompt(). + :return: the stripped input string. + :raises EOFError: if the input stream is closed or the user signals EOF (e.g., Ctrl+D) + """ + owns_the_reservation = session is self.main_session + with self._quiesce_bottom_toolbar() if owns_the_reservation else self.suspend_bottom_toolbar(): + return self._read_raw_input_now(prompt, session, **prompt_kwargs) + + def _read_raw_input_now( + self, + prompt: Callable[[], ANSI | str] | ANSI | str, + session: PromptSession[str], + **prompt_kwargs: Any, + ) -> str: + """Read one line, with the display already stopped by the caller. + :param prompt: the prompt text or a callable that returns the prompt. :param session: the PromptSession instance to use for reading. :param prompt_kwargs: additional arguments passed directly to session.prompt(). @@ -6043,9 +6187,12 @@ def cmdloop(self, intro: RenderableType = "") -> int: if self.intro: self.poutput(self.intro) - # And then call _cmdloop() to enter the main loop + # And then call _cmdloop() to enter the main loop. The reservation is established + # here, after the intro has been printed: it must not be installed around output that + # belongs to the terminal's ordinary scrollback. try: - self._cmdloop() + with self._reserved_toolbar_context(): + self._cmdloop() finally: # Restore original signal handlers however the loop ended. Leaving cmd2's # handlers installed would outlive the application in its host process. diff --git a/cmd2/command_toolbar.py b/cmd2/command_toolbar.py index 96faacb31..7e0afdf72 100644 --- a/cmd2/command_toolbar.py +++ b/cmd2/command_toolbar.py @@ -24,17 +24,31 @@ from prompt_toolkit.patch_stdout import StdoutProxy from prompt_toolkit.utils import suspend_to_background_supported +from .managed_output import SerializedTerminalWriter from .pager import Pager, output_fits if TYPE_CHECKING: from .cmd2 import Cmd +#: How long to wait for the display to report that it has started. Long enough that a busy +#: machine is not mistaken for a broken one, short enough that a command is never held forever. +_STARTUP_TIMEOUT = 10.0 + +#: How long to wait for the display's thread to finish. Bounded for the same reason: a render +#: callback blocked inside it would otherwise hold whoever is tearing the display down. +_SHUTDOWN_TIMEOUT = 10.0 + _F = TypeVar("_F", bound=Callable[..., Any]) _R = TypeVar("_R") def suspend_toolbar(func: _F) -> _F: - """Give a method exclusive access to the terminal.""" + """Give a method exclusive access to the terminal, reserved rows included. + + For methods that hand the terminal to something else: a shell command, an editor, an + embedded interpreter, an external pager. Anything that does not know about a scroll region + must not be given one. + """ @functools.wraps(func) def wrapped(self: "Cmd", *args: Any, **kwargs: Any) -> Any: @@ -44,6 +58,22 @@ def wrapped(self: "Cmd", *args: Any, **kwargs: Any) -> Any: return cast(_F, wrapped) +def quiesce_toolbar(func: _F) -> _F: + """Stop the command display for a method without giving the terminal away. + + For methods that need the renderer and the input reader quiet but are still part of the + command loop -- command finalization above all, which restores terminal input settings at + the end of every command and must leave the toolbar exactly where it was. + """ + + @functools.wraps(func) + def wrapped(self: "Cmd", *args: Any, **kwargs: Any) -> Any: + with self._quiesce_bottom_toolbar(): + return func(self, *args, **kwargs) + + return cast(_F, wrapped) + + def pipe_target(stream: Any) -> Any: """Return the stream a pipe process can inherit, or ``None`` if its output must be captured. @@ -61,6 +91,14 @@ def pipe_target(stream: Any) -> Any: return stream +class _DisplayStillRunningError(RuntimeError): + """Raised when the display's thread did not finish within its timeout. + + Nothing was relinquished: the thread is still inside the application, so whatever was + about to be done with the terminal must not be. + """ + + class _ContextStdoutProxy(StdoutProxy): """Keep stdout's flush worker in the toolbar's isolated application session.""" @@ -72,12 +110,21 @@ def _start_write_thread(self) -> threading.Thread: class ToolbarStream: - """Keep a stable stream identity across suspensions and cmd2 redirections.""" + """Keep a stable stream identity across suspensions and cmd2 redirections. + + Output has three possible destinations, in priority order. A *serializer* is installed in + reserved mode: the toolbar sits in rows withheld from scrolling, so output goes straight + to the terminal under the terminal transaction rather than through a proxy that erases the + toolbar and draws it again. A *proxy* is prompt-toolkit's, used in legacy rendering to put + output above a toolbar that does scroll. With neither, the terminal stream itself -- which + is what a suspended toolbar leaves behind. + """ def __init__(self, original: TextIO, lock: "threading.RLock") -> None: """Wrap a terminal stream while preserving its ordinary file attributes.""" self.original = original self.proxy: StdoutProxy | None = None + self.serializer: SerializedTerminalWriter | None = None # Shared with the toolbar so a write from another thread cannot land on a proxy # that is being closed. Such a write is accepted by the dead proxy and discarded. self._lock = lock @@ -86,12 +133,27 @@ def __init__(self, original: TextIO, lock: "threading.RLock") -> None: def write(self, data: str) -> int: """Write above the toolbar, or directly while the toolbar is suspended.""" with self._lock: - return (self.proxy or self.original).write(data) + serializer = self.serializer + if serializer is None: + return (self.proxy or self.original).write(data) + # Chosen and performed in one acquisition, or performed after a single one. Deciding + # under the lock, releasing it, and then taking it again to act would let the + # destination change in between -- a serializer installed in that gap would be + # skipped, and its write would reach the terminal outside any transaction. + # + # The serializer runs after the release because it takes the terminal lock, which is + # the last lock in the output path: carrying a routing lock into it is the deadlock + # the ordering rule exists to prevent. + return serializer.write(data) def flush(self) -> None: """Flush the currently active output stream.""" with self._lock: - (self.proxy or self.original).flush() + serializer = self.serializer + if serializer is None: + (self.proxy or self.original).flush() + return + serializer.flush() def __getattr__(self, name: str) -> Any: """Delegate file attributes to the original terminal stream.""" @@ -139,6 +201,7 @@ def __init__(self, cmd: "Cmd") -> None: self._thread: threading.Thread | None = None self._streams: list[ToolbarStream] = [] self._proxy: StdoutProxy | None = None + self._serialized = False self._lock = threading.RLock() self._pausing = False @@ -199,9 +262,29 @@ def suspend(event: KeyPressEvent) -> None: self._bindings = bindings self._suspend_binding = suspend - def _after_render(self, app: Application[str]) -> None: # noqa: ARG002 + def _display_started(self, app: Application[str]) -> None: # noqa: ARG002 + """Report that the display is up and has finished its first frame.""" self._ready.set() + def _display_started_without_app(self) -> None: + """Report readiness from a render attempt that produced no frame. + + A skipped frame still means the application is running and rendering. Waiting for one + that commits would make starting the display depend on a cursor-position round trip, + and a terminal that never answers would never let the command begin. + """ + self._ready.set() + + def _reserved_bridge(self) -> Any: + """Return the renderer bridge, when a reservation is holding the toolbar. + + :return: the bridge, or ``None`` in legacy rendering + """ + reserved = self.cmd.reserved_toolbar + if reserved is None or not reserved.is_active: + return None + return reserved.bridge + def start(self) -> None: """Start rendering and protect terminal output.""" stack = contextlib.ExitStack() @@ -221,7 +304,11 @@ def start(self) -> None: stack.callback(self._restore_stream, obj, name, wrapper) self._resume() except BaseException: - self.stop() + # A cleanup that cannot finish is reported by the state it leaves behind -- the + # display is disabled for the session either way -- and the failure that brought + # us here is the one worth propagating. + with contextlib.suppress(Exception): + self.stop() raise @staticmethod @@ -236,8 +323,15 @@ def _resume(self) -> None: for name, value in (("layout", self._layout), ("key_bindings", self._bindings), ("erase_when_done", True)): stack.callback(setattr, self.app, name, getattr(self.app, name)) setattr(self.app, name, value) - self.app.after_render += self._after_render - stack.callback(self.app.after_render.remove_handler, self._after_render) + self.app.after_render += self._display_started + stack.callback(self.app.after_render.remove_handler, self._display_started) + bridge = self._reserved_bridge() + if bridge is not None: + # In reserved mode a frame can be skipped, and the after-render event is withheld + # for those because nothing reached the terminal. Readiness is a different + # question -- the display is up either way -- so it hangs on the attempt instead. + bridge.set_render_attempted_handler(self._display_started_without_app) + stack.callback(bridge.set_render_attempted_handler, None) context = contextvars.copy_context() def run() -> None: @@ -254,9 +348,15 @@ def run() -> None: self._thread = threading.Thread(target=context.run, args=(run,), name="cmd2-toolbar", daemon=True) self._thread.start() - self._ready.wait() + if not self._ready.wait(timeout=_STARTUP_TIMEOUT): + # Bounded so a display that never reports itself started fails here instead of + # holding the command thread forever. The toolbar is cosmetic; a command waiting + # indefinitely on one is not a trade anyone would choose. + raise TimeoutError(f"the bottom toolbar did not start within {_STARTUP_TIMEOUT} seconds") if self._error is not None: raise self._error + if self._install_serializers(): + return # The worker already combines queued writes. A batching sleep would also # delay close(), which runs at each command finalization boundary. proxy = _ContextStdoutProxy(raw=True, sleep_between_writes=0) @@ -265,6 +365,25 @@ def run() -> None: for stream in self._streams: stream.proxy = proxy + def _install_serializers(self) -> bool: + """Route output straight to the terminal when a reservation is holding the toolbar. + + The stdout proxy exists to put output above a toolbar that scrolls with the screen: + it erases the toolbar, prints, and draws it again. A reserved toolbar does not scroll, + so none of that is needed -- and doing it anyway would reintroduce exactly the flicker + the reservation removes. + + :return: whether serialized writing was installed + """ + reserved = self.cmd.reserved_toolbar + if reserved is None or not reserved.is_active: + return False + with self._lock: + self._serialized = True + for stream in self._streams: + stream.serializer = SerializedTerminalWriter(stream.original, reserved.lock, reserved.bridge) + return True + def _app_exited(self) -> None: """Give the terminal back to the streams when the display stops on its own. @@ -280,9 +399,10 @@ def _app_exited(self) -> None: with self._lock: # Leave self._proxy set so that the next _pause() still drains and closes # it. With the display gone, its worker writes to the terminal directly. - started = self._proxy is not None + started = self._proxy is not None or self._serialized for stream in self._streams: stream.proxy = None + stream.serializer = None # A proxy exists only once _resume() has handed startup failures to the command # thread, so reporting here does not duplicate the exception it raises. @@ -317,29 +437,85 @@ def _pause(self) -> None: self._proxy.close() finally: self._proxy = None + self._serialized = False for stream in self._streams: stream.proxy = None + # Nothing to drain: a serialized write reaches the terminal + # before it returns, so there is no queued work to lose. + stream.serializer = None finally: # The lock is released before joining, since the toolbar thread may be # blocked writing through a stream that is waiting on it. if self.app.is_running and self.app.loop is not None: self.app.loop.call_soon_threadsafe(self._exit) if self._thread is not None: - self._thread.join() - self._thread = None - # Return the borrowed application to the main prompt, including on - # proxy failures. The upstream toolbar owned a separate application. - if self._display_stack is not None: - self._display_stack.close() - self._display_stack = None - # Application.run() saves its unprocessed queue before the thread - # exits. Those keys arrived after the ones handled by save_key(). - pending_keys = get_typeahead(self.app.input) - store_typeahead(self.app.input, self._keys + pending_keys) - self._keys.clear() + # Bounded, so a render callback blocked inside the display cannot hold the + # thread that is tearing it down. + self._thread.join(timeout=_SHUTDOWN_TIMEOUT) + if self._thread.is_alive(): + self._abandon_stuck_display() + self._finish_pause() finally: self._pausing = False + def _finish_pause(self) -> None: + """Give the borrowed application back and hand over the keys typed meanwhile. + + Only safe once the display's thread has ended: until then it is still using the layout + and key bindings this puts back. + """ + self._thread = None + # Return the borrowed application to the main prompt, including on proxy failures. + # The upstream toolbar owned a separate application. + if self._display_stack is not None: + self._display_stack.close() + self._display_stack = None + # Application.run() saves its unprocessed queue before the thread exits. Those keys + # arrived after the ones handled by save_key(). + pending_keys = get_typeahead(self.app.input) + store_typeahead(self.app.input, self._keys + pending_keys) + self._keys.clear() + + def complete_abandoned_shutdown(self) -> bool: + """Finish the teardown a timed-out pause could not do, if the thread has since ended. + + A pause that gave up left the application as the running thread expected to find it: + its layout, its key bindings, its erase-on-done setting. Once the thread is gone that + state belongs to nobody, and the next prompt would otherwise render as the command + display. + + :return: whether the display has now been fully torn down + """ + if self.thread_is_alive: + return False + self._finish_pause() + return True + + def _abandon_stuck_display(self) -> None: + """Report that the display did not stop, and refuse to pretend it did. + + A pause that timed out relinquished nothing. The thread is still inside the + application: still rendering, still reading input. Everything that would normally + follow a pause assumes the opposite -- the caller hands the terminal to a guest, the + borrowed layout and key bindings go back to the main prompt, and the display object is + dropped at the end of the command. Each of those would be acting on an application + that is still running. + + So nothing further happens here. The application's state is left as the running thread + expects to find it, and the failure is raised rather than reported and swallowed, + because only the caller knows what it was about to do with the terminal. + + The refusal is recorded on the application rather than on this object, which the next + command replaces. The thread outlives it, and one terminal cannot have two input + readers -- so the application keeps a reference to the display that did not let go, + and refuses to hand the terminal anywhere until it does. + + :raises _DisplayStillRunningError: always + """ + self.cmd._command_toolbar_disabled = True + self.cmd._display_holding_terminal = self + raise _DisplayStillRunningError(f"the bottom toolbar did not stop within {_SHUTDOWN_TIMEOUT} seconds") + def stop(self) -> None: """Flush output, stop rendering, and restore the terminal and its streams.""" try: @@ -351,10 +527,15 @@ def stop(self) -> None: self._stack.close() self._stack = None + @property + def thread_is_alive(self) -> bool: + """Whether the display's thread is still running.""" + return self._thread is not None and self._thread.is_alive() + @property def is_active(self) -> bool: """Whether the display currently owns the terminal.""" - return self._proxy is not None and self.app.is_running + return (self._proxy is not None or self._serialized) and self.app.is_running def _call_in_ui(self, func: Callable[[], _R]) -> _R: """Change UI state on its event loop, propagating failures to the command.""" @@ -455,8 +636,13 @@ def close() -> None: @contextlib.contextmanager def suspend(self) -> Iterator[None]: - """Temporarily restore ordinary terminal access, including nested suspensions.""" - if self._proxy is None: + """Temporarily restore ordinary terminal access, including nested suspensions. + + Whether output is proxied or serialized, what has to stop is the display: its renderer + draws and its input reader reads, and a guest given the terminal alongside either of + them is sharing it rather than owning it. + """ + if self._proxy is None and not self._serialized: yield return with self.cmd.sigint_protection: diff --git a/cmd2/managed_output.py b/cmd2/managed_output.py new file mode 100644 index 000000000..93f32ce30 --- /dev/null +++ b/cmd2/managed_output.py @@ -0,0 +1,92 @@ +"""Write command output to a reserved terminal, in order and on the record. + +In legacy rendering, output written while a command runs goes through prompt-toolkit's stdout +proxy, which erases the toolbar, prints, and draws it again. That erase-and-redraw is the +flicker the reserved row exists to remove: with rows withheld from scrolling, output can go +straight to the terminal and the toolbar simply stays where it is. + +Straight to the terminal, but not at any moment. Two rules make that safe. + +**One writer at a time.** The write and its flush happen inside the terminal transaction, the +same one the painter and the renderer take, so a command's output and a toolbar paint reach +the terminal one after the other rather than interleaved. + +**The bridge learns inside that transaction.** Output moves the cursor and may scroll the +screen, which invalidates what the renderer believes about the prompt. Telling the bridge +after the lock is released would tell it about a terminal that may have changed again in +between; telling it inside is what makes "the terminal changed" and "the change was recorded" +one event. + +No prompt origin is claimed. Where the cursor ended up after arbitrary output -- wrapped +lines, embedded control sequences, a resize mid-write -- is not something this layer knows, and +a guess would put the next prompt over committed output. Recovery asks the terminal instead. +""" + +from typing import TYPE_CHECKING, Any + +from .terminal_transaction import TerminalLock + +if TYPE_CHECKING: # pragma: no cover + from typing import TextIO + + +class SerializedTerminalWriter: + """A stream that writes to the terminal under the terminal transaction lock.""" + + def __init__(self, stream: "TextIO", lock: TerminalLock, bridge: Any = None) -> None: + """Wrap the terminal's own stream. + + :param stream: the *original* terminal stream. Never a proxy over it: a physical + writer that routed back into a proxy would queue its own output behind itself. + :param lock: the terminal transaction lock every cmd2-controlled writer shares + :param bridge: the renderer bridge to inform of managed output, if one is active + """ + self._stream = stream + self._lock = lock + self.bridge = bridge + + @property + def stream(self) -> "TextIO": + """The terminal stream being written to.""" + return self._stream + + def write(self, data: str) -> int: + """Write command output to the terminal, then record that it happened. + + :param data: the text to write + :return: the number of characters written + """ + with self._lock.transaction("managed write"): + try: + written = self._stream.write(data) + # Flushed before the lock is given up. Left buffered, this output would reach + # the terminal after whatever paints next, which is the ordering the + # transaction is supposed to establish. + self._stream.flush() + finally: + # Recorded whether or not the write succeeded, and still inside the + # transaction. A write that raised may have emitted part of its text and may + # have scrolled the screen doing it -- a stream cannot say which -- so the + # bridge must not be left believing the terminal is as it was. Invalidating + # after output that never arrived costs a repaint; not invalidating after + # output that did costs a prompt drawn over it. + if self.bridge is not None: + self.bridge.note_managed_write() + return written + + def flush(self) -> None: + """Flush the terminal stream. + + Nothing is recorded: a flush moves no cursor and scrolls nothing, so it invalidates + nothing either. The write that produced the buffered output already said so. + """ + with self._lock.transaction("managed flush"): + self._stream.flush() + + def __getattr__(self, name: str) -> Any: + """Delegate file attributes to the terminal stream. + + :param name: the attribute to fetch + :return: the underlying stream's attribute + """ + return getattr(self._stream, name) diff --git a/cmd2/prompt_toolkit_bridge.py b/cmd2/prompt_toolkit_bridge.py index 9ee14d58b..fc7a8a7f9 100644 --- a/cmd2/prompt_toolkit_bridge.py +++ b/cmd2/prompt_toolkit_bridge.py @@ -123,7 +123,28 @@ def __init__(self, renderer: "Renderer", display: "TerminalDisplay", lock: Termi self._pending_error: BaseException | None = None self._prompt_anchor: int | None = None self._resynchronization_reason: str | None = None - self._pending_cpr: deque[Generations] = deque() + # One entry per outstanding request, in the order they went out. An entry is ``None`` + # once the screen it asked about is gone: the reply is still coming, so the place has + # to be kept, but nothing it says can be believed. + self._pending_cpr: deque[Generations | None] = deque() + # The upstream methods this bridge wraps, by name. Kept after unbinding as well as + # during: preparation calls the real render through this rather than the attribute, + # which would re-enter the wrapper and never terminate -- and a wrapper somebody else + # installed over ours may outlive the binding and still call in here. + self._originals: dict[str, Any] = {} + self._bound = False + # What this bridge put in their place, so teardown can tell its own replacements from + # something another caller installed afterwards. + self._installed: dict[str, Any] = {} + self._bound_app: Application[Any] | None = None + self._emission_stopped_handler: Callable[[], None] | None = None + self._frame_committed_handler: Callable[[], object] | None = None + self._render_attempted_handler: Callable[[], object] | None = None + # Whether the last thing this bridge tried to emit actually reached the terminal. + self._last_emission_committed = False + self._after_render_event: Any = None + self._after_render_original: Any = None + self._after_render_installed: Any = None # -- what is known --------------------------------------------------------------------- @@ -254,14 +275,51 @@ def require_resynchronization(self, reason: str) -> None: self._resynchronization_reason = reason self._retire() + def set_frame_committed_handler(self, handler: "Callable[[], object]") -> None: + """Install what to call after a frame has actually reached the terminal. + + Runs off the lock and only for a committed frame. Upstream's own after-render event + fires during preparation, when the frame exists only as a recording, so it cannot + answer "has the user seen this". + + :param handler: called after each committed frame; its return value is ignored + """ + self._frame_committed_handler = handler + + def set_render_attempted_handler(self, handler: "Callable[[], object] | None") -> None: + """Install what to call after each render attempt, whatever came of it. + + Distinct from the committed-frame handler on purpose. "A frame reached the terminal" + and "the renderer has been through a frame" are different facts, and something waiting + for the display to start needs the second: a frame skipped while recovery is owed + still means the application is running and rendering. + + :param handler: called after every render attempt, or ``None`` to remove it + """ + self._render_attempted_handler = handler + + def set_emission_stopped_handler(self, handler: "Callable[[], None]") -> None: + """Install what to call when reserved rendering has to be abandoned. + + The owner of the reservation is what runs here: rendering cannot resume until the rows + have been given back and this bridge unbound, and only the owner can do either. + + :param handler: called once, when emission is abandoned + """ + self._emission_stopped_handler = handler + def stop_reserved_emission(self, error: BaseException) -> None: """Abandon reserved rendering after a failure that could not be cleaned up. :param error: what went wrong, to be reported once """ + if self._reserved_emission_stopped: + return self._reserved_emission_stopped = True self._pending_error = error self._retire() + if self._emission_stopped_handler is not None: + self._emission_stopped_handler() def set_prompt_anchor(self, physical_row: int) -> None: """Record the physical row the prompt starts on. @@ -287,12 +345,233 @@ def _retire(self) -> None: self._in_flight = None self._renderer._last_screen = None + # -- binding --------------------------------------------------------------------------- + + def bind(self, app: "Application[Any]") -> None: + """Route the application's own renders through this bridge. + + prompt-toolkit renders from its event loop whenever it decides to, so intercepting is + the only way those frames come under the transaction. The interception is installed on + the renderer *instance* and removed again on the way out -- patching the class would + change every renderer in the process, including ones cmd2 does not own. + + :param app: the application whose renders are being intercepted + """ + if self._bound: + return + renderer = self._renderer + replacements = { + "render": self._render_through_bridge, + "erase": self._erase_through_bridge, + "clear": self._clear_through_bridge, + # Upstream both asks for cursor reports and receives them: its own key binding + # calls report_absolute_cursor_row when the reply arrives. Unrecorded, a request + # would have its reply arrive uncorrelated and be discarded, leaving the prompt's + # height unknown; unvalidated, a reply from inside the reserved band would set a + # height of zero or less and never say so. + "request_absolute_cursor_position": self._request_cursor_position_through_bridge, + "report_absolute_cursor_row": self._report_cursor_row_through_bridge, + } + self._originals = {name: getattr(renderer, name) for name in replacements} + self._installed = dict(replacements) + self._bound_app = app + self._bound = True + # Upstream fires this after ``render()`` returns, whatever the wrapper decided to do, + # so a frame the bridge skipped would still tell everything waiting on a rendered + # frame that one had happened -- including the command display's readiness signal. + self._after_render_event = app.after_render + self._after_render_original = app.after_render.fire + self._after_render_installed = self._fire_after_render_through_bridge + # By name, as with the renderer's methods: this replacement belongs to this event + # object, not to the class every application's events are built from. + setattr(app.after_render, "fire", self._after_render_installed) # noqa: B010 + for name, replacement in replacements.items(): + # Set by name so the replacement lands on this instance. Assigning the class + # attribute would change every renderer in the process, including ones cmd2 does + # not own. + setattr(renderer, name, replacement) + if self._redraw_scheduler is None: + self._redraw_scheduler = app.invalidate + + def unbind(self) -> None: + """Give the renderer its own methods back. + + Safe to call when nothing was bound: teardown reaches this from more than one place. + """ + event, self._after_render_event = self._after_render_event, None + if event is not None and getattr(event, "fire", None) == self._after_render_installed: + setattr(event, "fire", self._after_render_original) # noqa: B010 + self._after_render_installed = None + + self._bound = False + installed, self._installed = self._installed, {} + for name, original in self._originals.items(): + # Restored only where this bridge's replacement is still in place. Another caller + # may have wrapped the renderer since -- for tracing, for a test -- and putting + # the original back over theirs would silently undo it. + if getattr(self._renderer, name, None) == installed.get(name): + setattr(self._renderer, name, original) + self._bound_app = None + + def _render_through_bridge(self, app: "Application[Any]", layout: Any, is_done: bool = False) -> None: + """Prepare and commit one frame, telling anything waiting that an attempt was made. + + Unbound, this passes straight through. A wrapper installed over this one -- for + tracing, for a test -- is left in place by :meth:`unbind` precisely because it is not + ours to remove, and it goes on calling in here afterwards. The bridge is no longer the + terminal's owner then, so the honest answer is upstream's own behaviour rather than an + error. + + :param app: the application being rendered + :param layout: the layout to render; upstream passes ``app.layout`` + :param is_done: whether this is the final frame of a prompt + """ + if not self._bound: + self._originals["render"](app, layout, is_done) + return + try: + self._render_frame(app, layout, is_done) + finally: + if self._render_attempted_handler is not None: + self._render_attempted_handler() + + def _render_frame(self, app: "Application[Any]", layout: Any, is_done: bool = False) -> None: + """Prepare and commit one frame in place of upstream's direct render. + + Runs on the UI thread, which is where recovery's callbacks belong too, so an owed + recovery is done here rather than deferred: a frame prepared before the terminal has + been resynchronized would be diffed against a screen nobody has seen. + + :param app: the application being rendered + :param layout: the layout to render; upstream passes ``app.layout`` + :param is_done: whether this is the final frame of a prompt + """ + self._last_emission_committed = False + if self._reserved_emission_stopped: + # Abandoned, but the rows are still withheld until the owner releases them. + # Rendering upstream directly from here would write outside the transaction and + # into a terminal that is still reserved. Compatibility rendering follows the + # release: once the owner has unbound this bridge, upstream's own render is back + # on the renderer and nothing routes through here at all. + return + + if self._needs_resynchronization: + self.resynchronize() + if self._needs_resynchronization: + # Recovery is waiting on the terminal to say where the cursor is. Drawing now + # would guess at the origin, which is the thing recovery exists to avoid. + return + + prepared = self.prepare(app, layout, is_done=is_done) + if prepared is None: + self._request_redraw() + return + if not self.commit(prepared): + self._request_redraw() + return + self._last_emission_committed = True + if self._frame_committed_handler is not None: + self._frame_committed_handler() + + def _fire_after_render_through_bridge(self) -> None: + """Tell the application a frame was rendered, but only if one actually was. + + A skipped frame -- recovery owed and unfinished, a preparation refused, a commit + retired -- emitted nothing. Everything downstream of this event believes a frame is on + the screen: layout metadata is published from it, and the command display treats it as + the signal that its first frame has been drawn. + """ + if self._bound and not self._last_emission_committed: + return + self._after_render_original() + + def _request_cursor_position_through_bridge(self) -> None: + """Let upstream ask for the cursor, and record the request if one went out. + + Upstream does not always emit one: in full-screen mode, and on backends that answer + natively, it fills in the height and returns. Recording those would leave entries in + the queue that no reply will ever consume, so what is recorded is what the renderer + actually started waiting for. + """ + renderer = self._renderer + if not self._bound: + self._originals["request_absolute_cursor_position"]() + return + with self._lock.transaction("cursor position request"): + if self._reserved_emission_stopped: + return + generations = self.generations() + outstanding = len(renderer._waiting_for_cpr_futures) + self._originals["request_absolute_cursor_position"]() + if len(renderer._waiting_for_cpr_futures) > outstanding: + self._pending_cpr.append(generations) + + def _report_cursor_row_through_bridge(self, row: int) -> None: + """Take a reply upstream's key binding delivered, through the same validation. + + :param row: the one-based physical row the terminal reported + """ + if not self._bound: + self._originals["report_absolute_cursor_row"](row) + return + self.report_cursor_row(row) + + def _erase_through_bridge(self, leave_alternate_screen: bool = True) -> None: + """Erase under the transaction, and treat what is left as unknown. + + An erase moves the cursor and clears the screen below it, so nothing may be diffed + against what was there. Upstream's own ``reset()`` runs inside it, which is exactly the + state the bridge must not inherit beliefs from. + + :param leave_alternate_screen: passed through to upstream + """ + if not self._bound: + self._originals["erase"](leave_alternate_screen) + return + self._last_emission_committed = False + with self._lock.transaction("erase"): + try: + self._originals["erase"](leave_alternate_screen) + self._last_emission_committed = True + finally: + # Recorded whether or not it finished. An erase that raised part-way has still + # moved the cursor and cleared some of what was below it, and a stream cannot + # say how much. + self.require_resynchronization("the renderer erased the screen") + + def _clear_through_bridge(self) -> None: + """Clear under the transaction, and treat what is left as unknown. + + A clear also moves the prompt. Whatever row it started on, it is not that row now, so + the remembered origin is forgotten rather than carried across -- recovery would + otherwise place the next frame where the prompt used to be. Cursor reports already in + flight describe the screen before the clear and are discarded with it. + + Unbound, this passes straight through, for the reason given on the render wrapper: a + retired bridge is not the terminal's owner, and taking its lock or invalidating its + state on someone else's behalf would be acting as one. + """ + if not self._bound: + self._originals["clear"]() + return + self._last_emission_committed = False + with self._lock.transaction("clear"): + try: + self._originals["clear"]() + self._last_emission_committed = True + finally: + self._prompt_anchor = None + self._invalidate_pending_cursor_reports() + self.require_resynchronization("the renderer cleared the screen") + # -- prepare and commit ---------------------------------------------------------------- - def prepare(self, app: "Application[Any]") -> PreparedRender | None: + def prepare(self, app: "Application[Any]", layout: Any = None, *, is_done: bool = False) -> PreparedRender | None: """Record a full renderer frame without emitting anything. :param app: the application to render + :param layout: the layout to render; the application's own by default + :param is_done: whether this is the final frame of a prompt :return: the prepared frame, or ``None`` if one cannot be prepared right now """ assert_no_terminal_transaction("preparing a renderer frame") @@ -315,8 +594,9 @@ def prepare(self, app: "Application[Any]") -> PreparedRender | None: # state is provisional -- a second render or an input dispatch started from one of # them would consume a screen that does not exist. self._preparing = True + render = self._originals.get("render", self._renderer.render) try: - self._renderer.render(app, app.layout) + render(app, app.layout if layout is None else layout, is_done) except Exception as error: # noqa: BLE001 - a layout callback must not end a command self._pending_error = error self.require_resynchronization("preparing the frame raised") @@ -405,8 +685,11 @@ def _attempt_cleanup(self) -> bool: output.flush() self._display.reconfigure() except Exception as error: # noqa: BLE001 - cleanup failing is itself the answer - self._reserved_emission_stopped = True - self._pending_error = error + # Through the same door as every other abandonment, so the owner is told and can + # give the rows back. Setting the flag here directly would stop emission while + # leaving the reservation installed and the renderer bound -- and the later call + # that would have notified now returns early, having found it already stopped. + self.stop_reserved_emission(error) return False return True @@ -586,7 +869,9 @@ def report_cursor_row(self, row: int) -> bool: Replies carry no generation on the wire, so they are correlated by order against the requests this bridge made. A reply from before a geometry change describes a screen - that no longer exists and must not satisfy the request made after it. + that no longer exists and must not satisfy the request made after it. Requests whose + screen has since been cleared away are kept in the queue but marked: their replies are + still coming and still have to be consumed in order, and none of them can be believed. A reply is stale when anything about the terminal has changed since the request went out -- a resize, an owner change, or managed output that moved the cursor the terminal @@ -618,7 +903,7 @@ def report_cursor_row(self, row: int) -> bool: # Popped whatever the outcome: replies correlate by order, so dropping one without # taking it off the queue would answer every later request with its predecessor. generations = self._pending_cpr.popleft() - if generations != self.generations(): + if generations is None or generations != self.generations(): self._settle_renderer_cpr() return False @@ -629,9 +914,21 @@ def report_cursor_row(self, row: int) -> bool: return False self._prompt_anchor = row - self._renderer.report_absolute_cursor_row(row) + report = self._originals.get("report_absolute_cursor_row", self._renderer.report_absolute_cursor_row) + report(row) return True + def _invalidate_pending_cursor_reports(self) -> None: + """Mark every outstanding request unbelievable, without forgetting that it is coming. + + Used where the screen changed underneath the requests themselves. Emptying the queue + would not stop the replies: they are already in the terminal's hands, and the next one + to arrive would be matched against whatever request came *after* the change -- the + oldest reply answering the newest question. The entries stay, marked, so each reply is + still consumed in order and each one is refused. + """ + self._pending_cpr = deque([None] * len(self._pending_cpr)) + def _settle_renderer_cpr(self) -> None: """Resolve one of the renderer's own pending reports, if it has any. diff --git a/cmd2/reserved_toolbar.py b/cmd2/reserved_toolbar.py new file mode 100644 index 000000000..1dc1c98f4 --- /dev/null +++ b/cmd2/reserved_toolbar.py @@ -0,0 +1,410 @@ +"""Own the reservation, the bridge and the painter for one command loop. + +This is the seam between cmd2's lifecycle and the reserved-row machinery. Everything it does +is a pair: acquire and release, bind and restore, build and drop. The pairing matters more +than the individual halves -- a reservation left installed makes every later line of shell +output scroll inside a region the shell knows nothing about, and a backend left wrapped +reports a terminal one row shorter than it is to whatever runs next. + +Binding is two assignments, not one. ``Application.output`` is what the application and its +session report, but the renderer keeps its *own* reference to the output it was constructed +with, so binding only the application leaves the renderer drawing through the unwrapped +backend -- straight over the reserved row. Both are restored on the way out, and only if they +still hold what this object put there: something else may have replaced them in between, and +putting a stale object back would be worse than leaving the newer one alone. + +The toolbar's content is read through a callable rather than captured, so a caller assigning a +new ``bottom_toolbar`` to the session still reaches the band. +""" + +from contextlib import contextmanager, suppress +from types import TracebackType +from typing import TYPE_CHECKING, Any, Self + +from prompt_toolkit.filters import Condition +from prompt_toolkit.layout import HSplit, Window +from prompt_toolkit.layout.containers import ConditionalContainer +from prompt_toolkit.styles import DynamicStyle + +from .prompt_toolkit_bridge import PromptToolkitBridge +from .terminal_display import TerminalDisplay +from .terminal_transaction import TerminalLock +from .theme import get_pt_theme +from .toolbar_painter import ToolbarPainter + +if TYPE_CHECKING: # pragma: no cover + from collections.abc import Callable, Iterator + + from prompt_toolkit.formatted_text import AnyFormattedText + from prompt_toolkit.shortcuts import PromptSession + + +#: Consecutive failed paints before the reservation is given up. One is a bad moment -- a +#: window resize mid-write, a transient device error. Two in a row is a terminal that is not +#: coming back, and holding rows in it helps nobody. +_MAX_CONSECUTIVE_PAINT_FAILURES = 2 + + +def native_toolbar_container(session: "PromptSession[Any]") -> ConditionalContainer | None: + """Find the window prompt-toolkit draws the bottom toolbar in. + + The shape is checked explicitly rather than assumed: ``PromptSession`` offers no public + hook for its toolbar window, so this is a dependency on its layout that has to fail + visibly when upstream changes it -- not quietly suppress the wrong container. + + :param session: the prompt session to look in + :return: the toolbar's container, or ``None`` if this layout has no recognizable one + """ + root = session.app.layout.container + if not isinstance(root, HSplit) or not root.children: + return None + candidate = root.children[-1] + if ( + isinstance(candidate, ConditionalContainer) + and isinstance(candidate.content, Window) + and candidate.content.style == "class:bottom-toolbar" + ): + return candidate + return None + + +class ReservedToolbar: + """The reserved-row toolbar's lifetime, tied to one prompt session.""" + + def __init__( + self, + session: "PromptSession[Any]", + content: "Callable[[], AnyFormattedText]", + reserved_rows: int = 1, + ) -> None: + """Prepare a reservation for a session's terminal. + + Nothing is acquired or bound until :meth:`start`; a session that never enters its + command loop must leave the terminal exactly as it found it. + + :param session: the main prompt session whose terminal is reserved + :param content: called to obtain the toolbar's formatted text + :param reserved_rows: rows to withhold at the bottom of the screen + """ + self._session = session + self.content = content + self._reserved_rows = reserved_rows + self._display: TerminalDisplay | None = None + self._bridge: PromptToolkitBridge | None = None + self._painter: ToolbarPainter | None = None + self._lock = TerminalLock() + self._bound_output: Any = None + # The application's output and the renderer's are saved separately. They are usually + # the same object, but nothing guarantees it, and restoring one over the other would + # hand the renderer a terminal it never had. + self._original_app_output: Any = None + self._original_renderer_output: Any = None + self._pending_error: BaseException | None = None + self._suspend_depth = 0 + self._consecutive_paint_failures = 0 + self._native_toolbar: ConditionalContainer | None = None + self._original_filter: Any = None + self._installed_filter: Any = None + + @property + def is_active(self) -> bool: + """Whether rows are reserved right now and the band is this object's to paint. + + Owning the display is not the same as holding a region. A terminal below the two-row + floor, or one on loan to a guest program, leaves this object in charge of the + reservation *and* leaves no reservation installed -- and while there is no band, the + native toolbar is what has to render. + """ + return self._display is not None and self._display.is_reserved + + @property + def display(self) -> TerminalDisplay: + """The display owning the reservation. + + :raises RuntimeError: if no reservation is held + """ + if self._display is None: + raise RuntimeError("the reserved toolbar is not started") + return self._display + + @property + def bridge(self) -> PromptToolkitBridge | None: + """The renderer bridge while active, else ``None``.""" + return self._bridge + + @property + def painter(self) -> ToolbarPainter | None: + """The band painter while active, else ``None``.""" + return self._painter + + @property + def lock(self) -> TerminalLock: + """The terminal transaction lock every cmd2-controlled writer shares.""" + return self._lock + + def start(self) -> bool: + """Acquire the reservation and bind the application to it. + + A terminal too short for the floor is not an error: the lease is simply refused, and + the application keeps rendering through its own backend exactly as it did before. + + :return: whether a reservation was installed + """ + if self._display is not None: + return True + + app = self._session.app + native = native_toolbar_container(self._session) + if native is None: + # Selection is supposed to have established this already. Reaching here means the + # layout changed underneath us, and reserving rows while the native toolbar still + # draws would put two toolbars on the screen. + raise RuntimeError("cannot locate the session's bottom toolbar window") + + display = TerminalDisplay(app.output, reserved_rows=self._reserved_rows) + if not display.acquire(): + # Nothing was installed, so there is nothing to release; leaving the lease held + # would make every later acquire a no-op at depth two. + display.release() + return False + + self._display = display + try: + self._original_app_output = app.output + self._original_renderer_output = app.renderer.output + self._bound_output = display.output + app.output = self._bound_output + app.renderer.output = self._bound_output + + # Hidden by asking whether the reservation is live rather than by latching a + # False. A restoration that never runs -- a teardown that raised, a caller that + # dropped this object -- then leaves a filter that heals itself instead of a + # toolbar that is gone for the rest of the session. + self._native_toolbar = native + self._original_filter = native.filter + self._installed_filter = native.filter & Condition(lambda: not self.is_active) + native.filter = self._installed_filter + + self._bridge = PromptToolkitBridge(renderer=app.renderer, display=display, lock=self._lock) + # From here the application's own renders go through prepare and commit, which is + # what puts them in the same queue as command output and toolbar paints. + self._bridge.bind(app) + # When the bridge abandons reserved rendering it cannot resume anything itself: + # the rows are still withheld and the renderer is still routed through it. Giving + # them back is this object's job, and it is what lets compatibility rendering + # start. + self._bridge.set_emission_stopped_handler(self._emission_stopped) + # The band is repainted after each frame the terminal actually received. That ties + # it to the refresh cadence the session already has -- its refresh interval, its + # invalidations, its key presses -- rather than inventing a second timer, and it + # paints after the prompt rather than into the middle of it. + self._bridge.set_frame_committed_handler(self.refresh) + self._painter = ToolbarPainter( + display=display, + lock=self._lock, + style=DynamicStyle(get_pt_theme), + color_depth=app.color_depth, + default_style="class:bottom-toolbar", + ) + # Strict on the way in: a band that cannot be painted at all is a reservation + # that cannot be established, and the rollback below hands the terminal back + # rather than leaving the caller with rows nothing can draw in. Once established, + # the same failure is survivable -- see :meth:`refresh`. + self._paint_once() + except BaseException: + # Everything after the acquisition has to come back off. A caller using this as a + # context manager never reaches ``__exit__`` when ``__enter__`` raises, so a + # failure here would otherwise leave the margins installed, both outputs wrapped + # and the native toolbar suppressed -- a terminal nobody owns and nobody will + # release. Cleanup is best-effort: if it fails too, the original failure is the + # one worth propagating. + with suppress(Exception): + self.stop() + raise + return True + + def take_pending_error(self) -> BaseException | None: + """Take the failure waiting to be reported, if there is one. + + :return: the error to report once, or ``None`` + """ + error, self._pending_error = self._pending_error, None + if error is None and self._painter is not None: + error = self._painter.take_pending_error() + return error + + @contextmanager + def suspended(self) -> "Iterator[None]": + """Give the rows back for the duration of the block, and take them again after. + + A program that inherits the terminal -- a shell command, an editor, an external pager + -- knows nothing about a scroll region, and one left installed would confine its + output to rows it never asked for. The lease is kept: this is a loan, not a release, + and the geometry is measured afresh on the way back because the guest may have resized + the window. + + What the guest left on the screen is unknown, so the band's contents and the + renderer's beliefs are both discarded rather than trusted. + + A terminal with no region installed -- one below the two-row floor -- still goes + through this. There is nothing to give back, but the guest may resize the window, and + the return path is where that is noticed and the rows are taken again. + + Suspensions nest, and only the outermost one changes anything. cmd2 suspends around + its own external commands and callers suspend around theirs, so an inner block ending + says nothing about whose terminal it is: the guest the outer block handed it to still + has it, and reinstalling margins or painting a band over their screen would be the + same mistake as never releasing at all. + """ + display = self._display + if display is None: + yield + return + + outermost = self._suspend_depth == 0 + self._suspend_depth += 1 + try: + if outermost: + with self._lock.transaction("suspend"): + display.release_region_for_handoff() + # Forgotten as the terminal changes hands, not after the guest has + # finished with it. From this moment the remembered row describes a screen + # someone else is writing on, and anything that rendered against it would + # paint over their output. + self._invalidate_ownership("the terminal was handed to another program") + yield + finally: + self._suspend_depth -= 1 + if outermost: + with self._lock.transaction("resume"): + display.reacquire_region_after_handoff() + self._invalidate_ownership("the terminal came back from another program") + self.refresh() + + def _invalidate_ownership(self, reason: str) -> None: + """Discard everything that described the screen before ownership changed. + + :param reason: why, for diagnostics + """ + if self._painter is not None: + self._painter.invalidate() + if self._bridge is not None: + self._bridge.forget_prompt_anchor() + self._bridge.require_resynchronization(reason) + + def refresh(self) -> bool: + """Evaluate the toolbar's content and paint whatever changed. + + A failure here never reaches the command that was running. The toolbar is cosmetic and + the command is not its to interrupt, so the error is kept for the caller to report and + the terminal is put back into a state the next frame can trust. + + :return: whether anything was written + """ + try: + painted = self._paint_once() + except Exception as error: # noqa: BLE001 - a failed paint must not end a command + self._paint_failed(error) + return False + self._consecutive_paint_failures = 0 + return painted + + def _paint_once(self) -> bool: + """Evaluate the content and paint it, letting any failure out. + + :return: whether anything was written + """ + painter = self._painter + if painter is None: + return False + prepared = painter.prepare(self.content) + if prepared is None: + return False + return painter.paint(prepared) + + def _emission_stopped(self) -> None: + """Release the reservation after the bridge has given up on it. + + The error the bridge is holding is taken here rather than left with it: the bridge is + dropped a moment later, and an error the user never sees is the same as none. + """ + if self._bridge is not None and self._pending_error is None: + self._pending_error = self._bridge.take_pending_error() + with suppress(Exception): + self.stop() + + def _paint_failed(self, error: BaseException) -> None: + """Record a failed paint and decide whether the reservation can continue. + + The backend clears its buffer before writing it, so a failed flush cannot say whether + the terminal received a prefix of the batch or none of it. Either way the cursor is + somewhere this process no longer knows, which makes it the renderer's problem as much + as the painter's: the next frame would be drawn from a believed position that may not + be where the cursor is. Recovery is therefore owed before anything renders again. + + One failure is a bad moment; two in a row is a terminal that has gone away. The second + gives the rows back and lets the native toolbar render again, because compatibility + rendering starts only after the reservation has been released -- never alongside it. + + :param error: what the paint raised + """ + self._pending_error = error + self._consecutive_paint_failures += 1 + if self._bridge is not None: + self._bridge.require_resynchronization("a toolbar paint failed; the cursor's position is unknown") + if self._consecutive_paint_failures >= _MAX_CONSECUTIVE_PAINT_FAILURES: + with suppress(Exception): + self.stop() + + def stop(self) -> None: + """Restore the application's bindings and release the reservation. + + Safe to call when nothing was started and safe to call twice: teardown reaches this + from the loop's ``finally`` and from explicit shutdown, and neither knows about the + other. + """ + display, self._display = self._display, None + if self._bridge is not None: + self._bridge.unbind() + self._bridge = None + # The painter is dropped, so anything it was holding to report goes with it unless it + # is taken now. An error the user never sees is the same as no error handling at all. + if self._painter is not None and self._pending_error is None: + self._pending_error = self._painter.take_pending_error() + self._painter = None + self._consecutive_paint_failures = 0 + if display is None: + return + + # Everything here restores only what is still ours. Something else may have replaced + # any of it while the reservation was live, and putting a stale object back is worse + # than leaving a newer one alone. + native, self._native_toolbar = self._native_toolbar, None + if native is not None and native.filter is self._installed_filter: + native.filter = self._original_filter + self._original_filter = None + self._installed_filter = None + + app = self._session.app + if app.output is self._bound_output: + app.output = self._original_app_output + if app.renderer.output is self._bound_output: + app.renderer.output = self._original_renderer_output + self._bound_output = None + self._original_app_output = None + self._original_renderer_output = None + display.release() + + def __enter__(self) -> Self: + """Start the reservation.""" + self.start() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + """Stop the reservation, including when the body raised.""" + self.stop() diff --git a/cmd2/scroll_region.py b/cmd2/scroll_region.py index bbb215671..96c6a5a27 100644 --- a/cmd2/scroll_region.py +++ b/cmd2/scroll_region.py @@ -33,8 +33,9 @@ The caller is responsible for the cursor not being inside the reserved band when the region is established: this helper cannot discover the cursor's row without a cursor-position report, -which needs input it does not have. Placing the prompt within the usable area belongs to the -layer that owns the terminal. +which needs input it does not have. The physical-terminal owner makes room under the cursor +while full-screen margins are still installed, before saving its position and narrowing the +region. A region needs at least two usable rows. DECSTBM requires the bottom margin to be greater than the top, so a degenerate ``ESC [ 1 ; 1 r`` is ignored and the terminal silently keeps diff --git a/cmd2/terminal_display.py b/cmd2/terminal_display.py index 5c4fdbc20..c83a5eee3 100644 --- a/cmd2/terminal_display.py +++ b/cmd2/terminal_display.py @@ -92,7 +92,7 @@ class Geometry: #: True viewport height, read from the unwrapped backend. physical_rows: int - #: Terminal width. Toolbar height is measured against this, so a width change is a new + #: Terminal width. The band is laid out against this, so a width change is a new #: generation even when the height is unchanged. columns: int @@ -226,6 +226,25 @@ def write_margin_change(self, sequence: str) -> None: # now rather than whenever something else happens to flush. self._output.flush() + def make_room_for_region(self, geometry: Geometry) -> None: + """Keep the cursor and preceding output above the band before narrowing margins. + + Full-screen margins must still be installed. Indexing down by the band's height + scrolls only when the cursor is near the bottom. Moving back up by the same amount + then leaves it at its original row, or at the last usable row if scrolling occurred. + IND preserves the column and is independent of the tty's newline translation. + + This needs no CPR, so it also works before the application's input reader starts. + The cursor saved by the margin change must be this adjusted position, not the old + physical row which may now belong to the band. Flush the adjustment before the + margin operation, so a failure there cannot strand it in the backend's buffer. + + :param geometry: the eligible geometry whose band needs room + """ + rows = geometry.reserved_rows + self._output.write_raw("\x1bD" * rows + f"\x1b[{rows}A") + self._output.flush() + def install_region(self, geometry: Geometry) -> None: """Install the scroll region described by ``geometry``. @@ -234,9 +253,22 @@ def install_region(self, geometry: Geometry) -> None: """ self.write_margin_change(scroll_region_sequence(geometry.physical_rows, geometry.reserved_rows)) - def release_region(self) -> None: - """Restore full-screen scroll margins.""" - self.write_margin_change(reset_scroll_region_sequence()) + def release_region(self, geometry: Geometry | None = None) -> None: + """Restore full-screen margins, removing owned toolbar cells before they can scroll. + + :param geometry: the region being released, if one was successfully installed + """ + sequence = reset_scroll_region_sequence() + if geometry is not None: + # A resize or viewport move can invalidate the owned band before teardown. + # If measurement fails, still reset the margins; do not guess where to erase. + with suppress(Exception): + if self.measure(geometry.generation, geometry.reserved_rows) == geometry: + # ED deliberately reaches the physical bottom here: these are the rows + # being returned, not application output. The save/restore below also + # preserves the prompt's cursor and attributes around the erase. + sequence = f"\x1b[{geometry.usable_rows + 1};1H\x1b[0m\x1b[J" + sequence + self.write_margin_change(sequence) class TerminalDisplay: @@ -319,6 +351,7 @@ def acquire(self) -> bool: geometry = self._measure() if not geometry.is_eligible: return False + self._terminal.make_room_for_region(geometry) self._terminal.install_region(geometry) except BaseException: # Give the lease back *first*. Cleanup can fail too -- a terminal that could not @@ -365,8 +398,8 @@ def _teardown(self) -> None: self._handoff_active = False if self._geometry is None: return - self._geometry = None - self._terminal.release_region() + geometry, self._geometry = self._geometry, None + self._terminal.release_region(geometry) def reconfigure(self) -> bool: """Resample the terminal and re-establish the reservation for the new geometry. @@ -393,6 +426,10 @@ def reconfigure(self) -> bool: self._adapter = None self._terminal.release_region() return False + if self._geometry is None: + # The guest may have scrolled to the physical bottom, just as the shell + # that launched us may have done before the initial acquisition. + self._terminal.make_room_for_region(geometry) self._terminal.install_region(geometry) self._geometry = geometry if self._adapter is None: @@ -417,8 +454,8 @@ def release_region_for_handoff(self) -> None: self._handoff_active = True if self._geometry is None: return - self._geometry = None - self._terminal.release_region() + geometry, self._geometry = self._geometry, None + self._terminal.release_region(geometry) def reacquire_region_after_handoff(self) -> None: """Re-establish the reservation after a program hands the terminal back. diff --git a/cmd2/toolbar_mode.py b/cmd2/toolbar_mode.py new file mode 100644 index 000000000..a83e896ec --- /dev/null +++ b/cmd2/toolbar_mode.py @@ -0,0 +1,168 @@ +"""Choose whether and how to render the bottom toolbar. + +The toolbar is disabled by default. Enabled toolbars can use legacy rendering or experimental +reserved rendering, qualified terminal by terminal. The set of qualified combinations decides +whether ``auto`` selects reserved rendering. + +Reserved rendering depends on things cmd2 does not control: which output backend +prompt-toolkit selected, which version of prompt-toolkit is installed, whether there is a +terminal at all. This module is the one place those prerequisites are decided, before +anything binds a bridge or writes a margin sequence. + +The two modes that can reserve rows answer the same question differently, on purpose: + +``auto`` falls back to legacy rendering for anything it has not qualified. A backend that +looks close enough is still a guess, and a wrong guess here corrupts the screen the user is +working in rather than merely rendering poorly. + +``reserved`` refuses to start instead of falling back. A caller who asked for reserved +rendering and silently got legacy rendering has been given the behaviour they ruled out, and +would find out from a flickering toolbar rather than from an error. + +Qualification is by exact version, not by the package requirement. ``prompt-toolkit>=3.0.53`` +is what cmd2 *installs against*; this set is what the reserved-row mechanism has actually been +tested against, and it grows only when a version has been through the qualification gates. +""" + +from enum import StrEnum +from importlib.metadata import version as _installed_version +from typing import TYPE_CHECKING + +from .terminal_display import PhysicalTerminal + +if TYPE_CHECKING: # pragma: no cover + from prompt_toolkit.output import Output + +#: prompt-toolkit versions the reserved-row mechanism has been qualified against. The bridge +#: reaches into renderer internals whose shape is not part of any public API, so this is an +#: exact set rather than a floor. +QUALIFIED_PROMPT_TOOLKIT_VERSIONS = frozenset({"3.0.53"}) + + +class ToolbarMode(StrEnum): + """How the bottom toolbar is rendered. + + A string enum rather than bare strings: this is the one place the set of modes is written + down, and every comparison in the codebase is against a member rather than a spelling. + Because the members *are* strings, ``bottom_toolbar_mode="reserved"`` keeps working and + keeps comparing equal to :attr:`RESERVED`. + """ + + #: Disable the bottom toolbar (the default). + OFF = "off" + + #: Use reserved rows where the terminal qualifies, and fall back silently where it does + #: not. A backend that looks close enough is still a guess, and a wrong guess corrupts the + #: screen the user is working in. + AUTO = "auto" + + #: Require reserved rows, and refuse to start without them. A caller who asked for this + #: and silently got legacy rendering has been given the behaviour they ruled out. + RESERVED = "reserved" + + #: Redraw the toolbar with the prompt, as cmd2 always has. + LEGACY = "legacy" + + +def _validate_toolbar_mode(mode: "ToolbarMode | str") -> ToolbarMode: + """Check that a mode name is one cmd2 offers. + + :param mode: the requested mode, as a member or as its name + :return: the corresponding member + :raises ValueError: if the name is not a mode + """ + try: + return ToolbarMode(mode) + except ValueError: + offered = ", ".join(sorted(member.value for member in ToolbarMode)) + raise ValueError(f"{mode!r} is not a bottom toolbar mode; choose one of {offered}") from None + + +def _dependency_capability(version: str | None = None) -> tuple[bool, str]: + """Decide whether the installed prompt-toolkit is one the reservation is qualified for. + + :param version: the version to judge; the installed one by default + :return: whether it is qualified, and a reason suitable for diagnostics + """ + installed = version if version is not None else _installed_version("prompt_toolkit") + if installed in QUALIFIED_PROMPT_TOOLKIT_VERSIONS: + return True, "qualified prompt-toolkit" + qualified = ", ".join(sorted(QUALIFIED_PROMPT_TOOLKIT_VERSIONS)) + return False, f"prompt-toolkit {installed} is not qualified for reserved rendering (qualified: {qualified})" + + +def _select_toolbar_mode( + mode: "ToolbarMode | str", + output: "Output", + *, + toolbar_enabled: bool, + interactive: bool, + layout_supported: bool = True, + version: str | None = None, +) -> tuple[ToolbarMode, str]: + """Decide how the toolbar will be rendered for this session. + + :param mode: the requested mode, as a member or as its name + :param output: the backend prompt-toolkit selected + :param toolbar_enabled: whether a bottom toolbar is configured at all + :param interactive: whether input and output are a terminal + :param layout_supported: whether the session's layout has a toolbar window that reserved + rendering can recognize and hide + :param version: the prompt-toolkit version to judge; the installed one by default + :return: the selected mode (``off``, ``legacy``, or ``reserved``) and, when + falling back from ``auto``, the reason + :raises ValueError: if the mode is not a mode, or if ``reserved`` was required and a + prerequisite is missing + """ + requested = _validate_toolbar_mode(mode) + if requested in (ToolbarMode.OFF, ToolbarMode.LEGACY): + return requested, "" + + reason = _unmet_prerequisite( + output, + toolbar_enabled=toolbar_enabled, + interactive=interactive, + layout_supported=layout_supported, + version=version, + ) + if reason is None: + return ToolbarMode.RESERVED, "" + if requested is ToolbarMode.RESERVED: + raise ValueError(f"reserved bottom toolbar mode is not available here: {reason}") + return ToolbarMode.LEGACY, reason + + +def _unmet_prerequisite( + output: "Output", + *, + toolbar_enabled: bool, + interactive: bool, + layout_supported: bool, + version: str | None, +) -> str | None: + """Find the first prerequisite reserved rendering does not have. + + Ordered from the cheapest and most user-visible outwards, so the reported reason is the + one a caller can act on: being told the backend is unqualified is unhelpful when the real + problem is that no toolbar was configured. + + :param output: the backend prompt-toolkit selected + :param toolbar_enabled: whether a bottom toolbar is configured at all + :param interactive: whether input and output are a terminal + :param layout_supported: whether the session's toolbar window can be located + :param version: the prompt-toolkit version to judge; the installed one by default + :return: the reason, or ``None`` when every prerequisite is met + """ + if not toolbar_enabled: + return "no bottom toolbar is configured" + if not interactive: + return "the session is not interactive" + if not layout_supported: + return "the session's layout has no bottom toolbar window to replace" + supported, reason = _dependency_capability(version) + if not supported: + return reason + supported, reason = PhysicalTerminal(output).capability() + if not supported: + return reason + return None diff --git a/cmd2/toolbar_painter.py b/cmd2/toolbar_painter.py index 51a0f40ec..79310360c 100644 --- a/cmd2/toolbar_painter.py +++ b/cmd2/toolbar_painter.py @@ -21,14 +21,26 @@ mouse map, so a handler here would never be called; rendering the visible part is the honest subset rather than advertising support that does not exist. +**Lines are clipped with an ellipsis.** Newlines advance to the next reserved row; +horizontal overflow never wraps. Omitted columns or rows are indicated at the right edge, +but only when something visible was omitted: trailing whitespace and a trailing newline +overflow by nothing the user could have seen. + **Carriage returns are dropped and tabs are expanded.** Both are cursor motion in a context where the painter owns the cursor. + +**Control characters are shown, not sent.** An escape becomes ``^[`` and a C1 control +becomes ``<85>``, exactly as the renderer shows them, so a stray sequence in a plain string +is displayed inside the band rather than executed there. """ +from contextlib import suppress from dataclasses import dataclass from typing import TYPE_CHECKING from prompt_toolkit.formatted_text import to_formatted_text +from prompt_toolkit.formatted_text.utils import split_lines +from prompt_toolkit.layout.screen import Char from prompt_toolkit.output import ColorDepth from prompt_toolkit.utils import get_cwidth @@ -38,7 +50,7 @@ if TYPE_CHECKING: # pragma: no cover from collections.abc import Callable, Mapping - from prompt_toolkit.formatted_text import AnyFormattedText + from prompt_toolkit.formatted_text import AnyFormattedText, StyleAndTextTuples from prompt_toolkit.styles import Attrs, BaseStyle from .terminal_display import TerminalDisplay @@ -48,9 +60,24 @@ #: does not model. TAB_WIDTH = 8 +#: What marks omitted content in the rightmost column of a row. U+2026 is East-Asian-ambiguous +#: width: prompt-toolkit measures it as one column, and so does every terminal cmd2 is +#: qualified on, but a terminal configured to draw ambiguous characters two columns wide would +#: draw this one over the edge. There is no portable way to detect that configuration, so the +#: glyph is a constant an application can replace rather than a value the painter guesses. +TRUNCATION_INDICATOR = "…" + #: Fragments whose style contains this carry raw terminal control rather than text. _ZERO_WIDTH_ESCAPE = "[ZeroWidthEscape]" +#: The style class the renderer gives a control character's caret notation, so that a theme +#: styling ``^[`` in a prompt styles it the same way in the band. +_CONTROL_STYLE = "class:control-character" + +#: Characters that occupy a column without showing anything. A tab is expanded to these and a +#: carriage return is dropped, so past the right edge all three are the same: nothing lost. +_BLANK = " \t\r" + @dataclass(frozen=True) class Cell: @@ -103,13 +130,17 @@ def build( Content that does not fill the frame is padded with default-styled spaces: the pad is what overwrites a longer previous frame, so it is content rather than absence of it. - Content taller than the band is truncated here -- growing the toolbar is a geometry - transition, and writing the extra rows would put them outside the reservation. + Each logical line is clipped to the terminal width without wrapping. A right-edge + ellipsis marks omitted columns, or omitted lines on the last reserved row -- but only + when what was omitted would have been visible. Trailing spaces, a trailing tab and a + trailing newline overflow by nothing anyone can see, and marking them would replace a + real character to announce that nothing was lost. Growing the toolbar is a geometry + transition, never a consequence of content overflow. :param content: the formatted text to lay out :param width: the terminal width in columns :param height: the height of the reserved band in rows - :param default_style: the style for padding cells + :param default_style: the style for padding and truncation-indicator cells :return: the frame :raises ValueError: if ``width`` or ``height`` is not positive """ @@ -118,92 +149,136 @@ def build( if height < 1: raise ValueError(f"a frame needs a positive height, got {height}") - rows = _layout(content, width, default_style) - blank = tuple(Cell(" ", default_style) for _ in range(width)) + # One pad cell shared by every blank column. Cells are immutable and compared by + # value, and this runs on every refresh, where constructing one per column was most + # of the cost of laying out a short toolbar. + pad = Cell(" ", default_style) + lines = list(split_lines(to_formatted_text(content))) + rows: list[list[Cell]] = [] + for line in lines[:height]: + row, clipped = _clip_line(line, width, pad) + if clipped: + _mark_truncated(row, default_style) + rows.append(row) + if any(_has_visible_text(line) for line in lines[height:]): + _mark_truncated(rows[-1], default_style) while len(rows) < height: - rows.append(list(blank)) - return cls(rows=tuple(tuple(row) for row in rows[:height])) + rows.append([pad] * width) + return cls(rows=tuple(tuple(row) for row in rows)) -def measure_toolbar_height(content: "AnyFormattedText", width: int) -> int: - """Measure how many rows content needs at a given width. +def _mark_truncated(row: list[Cell], default_style: str) -> None: + """Mark omitted content in the rightmost column without splitting a wide character. - This is what sizes the reservation, so it counts wrapping and explicit newlines the same - way :meth:`ToolbarFrame.build` lays them out. Empty content still measures one row: an - empty toolbar is an intentional visibility change, not a request for no reservation. + :param row: a padded, nonempty row to modify + :param default_style: the style for the indicator and any cleared wide-character cell + """ + if row[-1].is_continuation: + row[-2] = Cell(" ", default_style) + row[-1] = Cell(TRUNCATION_INDICATOR, default_style) - :param content: the formatted text to measure - :param width: the terminal width in columns - :return: the number of rows required, at least one - :raises ValueError: if ``width`` is not positive + +def _displayed(char: str, style: str) -> tuple[str, str]: + """Decide what the terminal is shown for one character of content. + + Control characters are shown in the caret notation the renderer uses -- ``^[`` for an + escape, ``<85>`` for a C1 control -- rather than written to the terminal, where an escape + would be executed inside the band. A C1 control is a sequence introducer on many + terminals, so it is as dangerous as ``ESC`` and mapped the same way. + + :param char: the character from the content + :param style: the fragment's style + :return: the text to draw and the style to draw it in """ - if width < 1: - raise ValueError(f"measuring needs a positive width, got {width}") - return max(1, len(_layout(content, width, ""))) + mapped = Char.display_mappings.get(char) + if mapped is None: + return char, style + return mapped, f"{style} {_CONTROL_STYLE}".strip() -def _layout(content: "AnyFormattedText", width: int, default_style: str) -> list[list[Cell]]: - """Lay content out into as many full-width rows as it needs. +def _has_visible_text(line: "StyleAndTextTuples") -> bool: + """Decide whether a logical line would show anything at all. - :param content: the formatted text to lay out + :param line: the line's fragments + :return: whether any character occupies a column with something in it + """ + for fragment in line: + style, text = fragment[0], fragment[1] + if _ZERO_WIDTH_ESCAPE in style: + continue + for char in text: + if char in _BLANK: + continue + shown, _ = _displayed(char, style) + if any(piece not in _BLANK and get_cwidth(piece) > 0 for piece in shown): + return True + return False + + +def _clip_line(line: "StyleAndTextTuples", width: int, pad: Cell) -> tuple[list[Cell], bool]: + """Lay one logical line out as a padded row, stopping at the first visible character lost. + + :param line: the line's fragments :param width: the terminal width in columns - :param default_style: the style for padding cells - :return: the rows, each padded to ``width`` cells + :param pad: the cell that fills columns the content does not reach + :return: the row, and whether visible content was clipped from it """ - rows: list[list[Cell]] = [] row: list[Cell] = [] + # Whether a character has been dropped past the right edge. A combining mark that + # follows one belongs to it, not to whatever cell happens to be last. + dropped = False - def finish_row() -> None: - """Pad the row in progress and start a new one.""" - row.extend(Cell(" ", default_style) for _ in range(width - len(row))) - rows.append(list(row)) - row.clear() - - for fragment in to_formatted_text(content): + for fragment in line: style, text = fragment[0], fragment[1] if _ZERO_WIDTH_ESCAPE in style: continue for char in text: - if char == "\n": - finish_row() - continue if char == "\r": continue if char == "\t": + available = width - len(row) + if available <= 0: + dropped = True + continue spaces = TAB_WIDTH - (len(row) % TAB_WIDTH) - for _ in range(spaces): - if len(row) == width: - finish_row() - row.append(Cell(" ", style)) + row.extend([Cell(" ", style)] * min(spaces, available)) continue - char_width = get_cwidth(char) - if char_width == 0 and row: - # A combining mark belongs to the character it follows; it occupies no column - # of its own, so it joins that cell rather than becoming one. After a wide - # character the cell to the left is that character's right half, and the mark - # belongs to the half that carries the text -- attaching it to the - # continuation cell would give the mark a column of its own and shift every - # later cell one place right of where it is on the screen. - base = len(row) - 1 - if row[base].is_continuation: - base -= 1 - previous = row[base] - row[base] = Cell(previous.char + char, previous.style, previous.is_continuation) - continue - columns = max(1, char_width) - if len(row) + columns > width: - # Padding rather than splitting: half a wide character at the right edge is - # what makes the terminal wrap the row itself, which would put toolbar cells - # in a row the frame does not own. - finish_row() - row.append(Cell(char, style)) - if columns == 2: - row.append(Cell("", style, is_continuation=True)) + shown, cell_style = _displayed(char, style) + for piece in shown: + piece_width = get_cwidth(piece) + if piece_width == 0: + if dropped: + # Its base is gone; attaching it to the last retained cell would + # decorate a character it was never part of. + continue + if row: + base = len(row) - 1 + if row[base].is_continuation: + base -= 1 + previous = row[base] + row[base] = Cell(previous.char + piece, previous.style, previous.is_continuation) + else: + # Nothing to combine with. Drawn on a space so that it occupies the + # one column the terminal gives it; a cell of its own would be + # modelled at a column the terminal never advances past. + row.append(Cell(" " + piece, cell_style)) + continue + columns = max(1, piece_width) + if len(row) + columns > width: + dropped = True + if piece in _BLANK: + continue + # Visible content is lost from here on, and nothing after it can be + # shown, so there is no reason to look at the rest of the line. + row.extend([pad] * (width - len(row))) + return row, True + row.append(Cell(piece, cell_style)) + if columns == 2: + row.append(Cell("", cell_style, is_continuation=True)) - if row: - finish_row() - return rows + row.extend([pad] * (width - len(row))) + return row, False @dataclass(frozen=True) @@ -266,7 +341,7 @@ def __init__( :param lock: the terminal transaction lock shared by all cmd2-controlled output :param style: the style rules used to resolve fragment styles :param color_depth: the color depth to render attributes at - :param default_style: the style for padding cells + :param default_style: the style for padding and truncation-indicator cells :param autowrap_after_paint: the committed autowrap policy to restore afterwards. Upstream's renderer leaves autowrap enabled between frames, which is the default here; a bridge that has committed a different policy passes it instead. @@ -374,33 +449,79 @@ def paint(self, prepared: PreparedFrame) -> bool: return False top_row = geometry.physical_rows - geometry.reserved_rows + 1 - # Anything another writer left buffered goes out first, so the band is painted - # after the output it was meant to follow rather than in the middle of it. - self._output.flush() - # DECSC saves the cursor *and* the current attributes, and DECRC restores both, so - # the renderer's next write lands where and how it expects. - self._output.write_raw(cursor_save_sequence()) - self._output.disable_autowrap() - for row_index, column, cells in runs: - self._output.write_raw(_cursor_position_sequence(top_row + row_index, column + 1)) - style: str | None = None - for cell in cells: - if cell.is_continuation: - continue - if cell.style != style: - self._output.set_attributes(prepared.attrs[cell.style], prepared.color_depth) - style = cell.style - self._output.write(cell.char) - if self._autowrap_after_paint: - self._output.enable_autowrap() - self._output.write_raw(cursor_restore_sequence()) - self._output.flush() + # Whether *this* paint has saved the cursor yet. The opening flush belongs to + # whoever wrote before us, and a failure there is not a partial paint. + saved_cursor = False + try: + # Anything another writer left buffered goes out first, so the band is painted + # after the output it was meant to follow rather than in the middle of it. + self._output.flush() + # DECSC saves the cursor *and* the current attributes, and DECRC restores + # both, so the renderer's next write lands where and how it expects. + self._output.write_raw(cursor_save_sequence()) + saved_cursor = True + self._output.disable_autowrap() + for row_index, column, cells in runs: + self._output.write_raw(_cursor_position_sequence(top_row + row_index, column + 1)) + style: str | None = None + for cell in cells: + if cell.is_continuation: + continue + if cell.style != style: + self._output.set_attributes(prepared.attrs[cell.style], prepared.color_depth) + style = cell.style + self._output.write(cell.char) + if self._autowrap_after_paint: + self._output.enable_autowrap() + self._output.write_raw(cursor_restore_sequence()) + self._output.flush() + except BaseException: + self._recover_from_failed_paint(saved_cursor) + raise self._last_frame = frame self._last_attrs = prepared.attrs self._last_band = band return True + def _recover_from_failed_paint(self, saved_cursor: bool) -> None: + """Undo what a half-finished paint left on the terminal. + + The backend buffers a paint and flushes it as one write, so a failure part-way through + that write leaves the terminal holding a prefix: the cursor saved and moved into the + band, autowrap off, some cells replaced and some not. None of that is undone by + unwinding the Python call -- the sequences are already on the wire. + + Two things follow. The wrap mode and cursor are put back, because leaving autowrap off + makes the next ordinary line of output wrap where it should not, and leaving the cursor + in the band makes the next write land in the toolbar. And the baseline is discarded: + the band is now showing something no frame describes, so the next paint has to be a + full one rather than a diff against a frame that was never finished. + + None of it applies when the paint failed before saving the cursor. The opening flush + drains whatever another writer left buffered, and a failure there belongs to that + output, not to this paint: the terminal's saved position is still the one some earlier + operation put there -- the margin change's, most likely -- and restoring it would move + the cursor backwards over output written since, which the next write would overwrite. + An old saved position is not a recovery origin. + + The restoration is itself a write to a terminal that has just failed one, so its own + failure is suppressed -- the original is the one worth propagating. + + :param saved_cursor: whether this paint got as far as saving the cursor + """ + self.invalidate() + if not saved_cursor: + return + with suppress(Exception): + if self._autowrap_after_paint: + self._output.enable_autowrap() + # DECRC returns to whatever was last saved. After a partial batch that is either + # this paint's own save or the one the margin change made, so the cursor lands + # somewhere known rather than wherever the truncated write stopped. + self._output.write_raw(cursor_restore_sequence()) + self._output.flush() + def _cursor_position_sequence(row: int, column: int) -> str: """Build a one-based absolute cursor move. diff --git a/docs/api/index.md b/docs/api/index.md index d412daaf6..7db05b493 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -34,4 +34,5 @@ incremented according to the [Semantic Version Specification](https://semver.org - [cmd2.string_utils](./string_utils.md) - string utility functions - [cmd2.styles](./styles.md) - cmd2-specific Rich styles and a StrEnum of their corresponding names - [cmd2.theme](./theme.md) - provides a centralized theming system for cmd2 +- [cmd2.toolbar_mode](./toolbar_mode.md) - StrEnum of the ways the bottom toolbar can be rendered - [cmd2.utils](./utils.md) - various utility classes and functions diff --git a/docs/api/toolbar_mode.md b/docs/api/toolbar_mode.md new file mode 100644 index 000000000..370a86124 --- /dev/null +++ b/docs/api/toolbar_mode.md @@ -0,0 +1,3 @@ +# cmd2.toolbar_mode + +::: cmd2.toolbar_mode diff --git a/docs/features/initialization.md b/docs/features/initialization.md index 88e6d79d7..1321f8790 100644 --- a/docs/features/initialization.md +++ b/docs/features/initialization.md @@ -52,7 +52,7 @@ Here are instance attributes of `cmd2.Cmd` which developers might wish to overri - **max_completion_table_items**: The maximum number of completion results allowed for a completion table to appear (Default: 50) - **pager**: sets the pager command used by the `Cmd.ppaged()` method for displaying wrapped output using a pager - **pager_chop**: sets the pager command used by the `Cmd.ppaged()` method for displaying chopped/truncated output using a pager -- **use_builtin_pager**: defaults to `enable_bottom_toolbar`. While the command toolbar is running, `Cmd.ppaged()` uses an embedded pager that keeps that toolbar visible. Set to `False` to always use the external `pager`/`pager_chop` commands. This is an opt-*out* only: setting it to `True` does nothing unless `enable_bottom_toolbar` is also set, because the embedded pager shares the toolbar's display. +- **use_builtin_pager**: defaults to whether `bottom_toolbar_mode` is enabled. While the command toolbar is running, `Cmd.ppaged()` uses an embedded pager that keeps that toolbar visible. Set to `False` to always use the external `pager`/`pager_chop` commands. This is an opt-*out* only: setting it to `True` does nothing unless the bottom toolbar is enabled, because the embedded pager shares the toolbar's display. - **py_bridge_name**: name by which embedded Python environments and scripts refer to the `cmd2` application by in order to call commands (Default: `app`) - **py_locals**: dictionary that defines specific variables/functions available in Python shells and scripts (provides more fine-grained control than making everything available with **self_in_py**) - **quiet**: if `True`, then completely suppress nonessential output (Default: `False`) diff --git a/docs/features/prompt.md b/docs/features/prompt.md index 0d443d7f5..7f5fe6326 100644 --- a/docs/features/prompt.md +++ b/docs/features/prompt.md @@ -66,14 +66,19 @@ output appears above the toolbar. ### Enabling the Toolbar -To enable the toolbar, set `enable_bottom_toolbar=True` in the [cmd2.Cmd.__init__][] constructor: +To enable the toolbar, set `bottom_toolbar_mode=cmd2.ToolbarMode.AUTO` in the [cmd2.Cmd.__init__][] +constructor: ```py class App(cmd2.Cmd): def __init__(self): - super().__init__(enable_bottom_toolbar=True) + super().__init__(bottom_toolbar_mode=cmd2.ToolbarMode.AUTO) ``` +The default is `cmd2.ToolbarMode.OFF`. `AUTO` uses reserved terminal rows where supported and falls +back to `LEGACY` rendering elsewhere. Select `LEGACY` to always redraw the toolbar with the prompt, +or `RESERVED` to require reserved rows and raise an error if unavailable. + ### Customizing Toolbar Content You can customize the content of the toolbar by overriding the [cmd2.Cmd.get_bottom_toolbar][] @@ -91,6 +96,27 @@ def get_bottom_toolbar(self) -> AnyFormattedText: ] ``` +### Reserved Toolbar Overflow + +Reserved rendering currently reserves one terminal row. Each logical line is clipped to the terminal +width; long lines do not wrap. A newline starts another logical line, which is omitted when there is +no reserved row available. A leading newline therefore leaves an empty first line. + +Whenever visible content is omitted, an ellipsis (`…`) appears in the rightmost column of the +affected row. Omitted lines are indicated on the last reserved row. The indicator uses the toolbar's +default style. For example, at a width of eight columns, `abcdefgh` fits unchanged, `abcdefghi` +becomes `abcdefg…`, and `Ready\nDetails` becomes `Ready …`. A leading newline displays spaces +followed by `…`, rather than a completely blank toolbar. Overflow that nobody could have seen is not +marked: trailing spaces, a trailing tab, and a trailing newline are dropped silently, so text padded +to the terminal width or ending in a newline shows no indicator. + +Widths are measured in terminal display columns, including wide and combining characters. Truncation +never splits a wide character; it may leave a space before the ellipsis. Tabs expand to eight-column +tab stops, and carriage returns are ignored. Control characters are displayed in caret notation, as +`LEGACY` rendering displays them, rather than sent to the terminal: an escape character appears as +`^[`. These rules apply to `RESERVED` and to `AUTO` when it selects reserved rendering. `LEGACY` +retains prompt-toolkit's layout. The number of reserved rows is not yet configurable through `Cmd`. + ### Refreshing the Toolbar The toolbar is rendered by `prompt-toolkit` and is naturally redrawn whenever the prompt is diff --git a/docs/upgrades.md b/docs/upgrades.md index 4504b98e3..4cbca6903 100644 --- a/docs/upgrades.md +++ b/docs/upgrades.md @@ -36,7 +36,8 @@ While we have strived to maintain compatibility, there are some differences: `cmd2` now supports an optional, persistent bottom toolbar. This can be used to display information such as the application name, current state, or even a real-time clock. -- **Enablement**: Set `enable_bottom_toolbar=True` in the [cmd2.Cmd.__init__][] constructor. +- **Enablement**: Set `bottom_toolbar_mode=cmd2.ToolbarMode.AUTO` in the [cmd2.Cmd.__init__][] + constructor. - **Customization**: Override the [cmd2.Cmd.get_bottom_toolbar][] method to return the content you wish to display. diff --git a/examples/getting_started.py b/examples/getting_started.py index 1c2e3ed83..97dc05747 100755 --- a/examples/getting_started.py +++ b/examples/getting_started.py @@ -57,7 +57,7 @@ def __init__(self) -> None: super().__init__( auto_suggest=True, - enable_bottom_toolbar=True, + bottom_toolbar_mode=cmd2.ToolbarMode.AUTO, enable_rprompt=True, include_ipy=True, persistent_history_file="cmd2_history.dat", diff --git a/mkdocs.yml b/mkdocs.yml index f20c2aa07..10a784bd7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -216,6 +216,7 @@ nav: - api/string_utils.md - api/styles.md - api/theme.md + - api/toolbar_mode.md - api/utils.md - Version Upgrades: - upgrades.md diff --git a/pyproject.toml b/pyproject.toml index ec98c9ef6..e3ce52543 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,7 @@ dev = [ "mkdocstrings[python]>=1", "mypy>=2.3.1", "prek>=0.3.5", + "pyte>=0.8.2", "pytest>=8.1.1", "pytest-cov>=5", "pytest-mock>=3.14.1", @@ -62,6 +63,7 @@ quality = ["prek>=0.3.5"] test = [ "codecov>=2.1", "coverage>=7.11.3", + "pyte>=0.8.2", "pytest>=8.1.1", "pytest-cov>=5", "pytest-mock>=3.14.1", diff --git a/tests/conftest.py b/tests/conftest.py index e025b3948..d021d65f6 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,6 +4,7 @@ import os import subprocess import sys +import threading from collections.abc import Callable from contextlib import redirect_stderr from typing import ( @@ -291,3 +292,28 @@ def toolbar_app(): refresh_interval=0.01, ) yield app, pipe, output + + +class ContendedLock: + """A real reentrant lock that reports a competing acquisition without a sleep.""" + + def __init__(self) -> None: + self._lock = threading.RLock() + self.contended = threading.Event() + + def acquire(self) -> bool: + if self._lock.acquire(blocking=False): + return True + self.contended.set() + assert self._lock.acquire(timeout=5), "owner never released the lock" + return True + + def release(self) -> None: + self._lock.release() + + def __enter__(self): + self.acquire() + return self + + def __exit__(self, *args): + self.release() diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 58eeecb00..ec75d35f2 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -1015,16 +1015,23 @@ def test_edit_file(base_app, request, monkeypatch) -> None: base_app.editor = "fooedit" # Mock out the subprocess.Popen call so we don't actually open an editor - m = mock.MagicMock(name="Popen") + # Model a completed editor, including communicate(), so ProcReader cleanup + # succeeds instead of raising while unpacking an unconfigured MagicMock. + process = mock.Mock(stdout=None, stderr=None, returncode=0) + process.communicate.return_value = (None, None) + m = mock.Mock(name="Popen", return_value=process) monkeypatch.setattr("subprocess.Popen", m) test_dir = os.path.dirname(request.module.__file__) filename = os.path.join(test_dir, "script.txt") - run_cmd(base_app, f"edit {filename}") + _, errors = run_cmd(base_app, f"edit {filename}") # We think we have an editor, so should expect a Popen call m.assert_called_once() + process.communicate.assert_called_once_with() + assert errors == [] + assert base_app.last_result == 0 @pytest.mark.parametrize("file_name", odd_file_names) @@ -1045,16 +1052,23 @@ def test_edit_file_with_spaces(base_app, request, monkeypatch) -> None: base_app.editor = "fooedit" # Mock out the subprocess.Popen call so we don't actually open an editor - m = mock.MagicMock(name="Popen") + # Model a completed editor, including communicate(), so ProcReader cleanup + # succeeds instead of raising while unpacking an unconfigured MagicMock. + process = mock.Mock(stdout=None, stderr=None, returncode=0) + process.communicate.return_value = (None, None) + m = mock.Mock(name="Popen", return_value=process) monkeypatch.setattr("subprocess.Popen", m) test_dir = os.path.dirname(request.module.__file__) filename = os.path.join(test_dir, "my commands.txt") - run_cmd(base_app, f'edit "{filename}"') + _, errors = run_cmd(base_app, f'edit "{filename}"') # We think we have an editor, so should expect a Popen call m.assert_called_once() + process.communicate.assert_called_once_with() + assert errors == [] + assert base_app.last_result == 0 def test_edit_blank(base_app, monkeypatch) -> None: @@ -1062,13 +1076,20 @@ def test_edit_blank(base_app, monkeypatch) -> None: base_app.editor = "fooedit" # Mock out the subprocess.Popen call so we don't actually open an editor - m = mock.MagicMock(name="Popen") + # Model a completed editor, including communicate(), so ProcReader cleanup + # succeeds instead of raising while unpacking an unconfigured MagicMock. + process = mock.Mock(stdout=None, stderr=None, returncode=0) + process.communicate.return_value = (None, None) + m = mock.Mock(name="Popen", return_value=process) monkeypatch.setattr("subprocess.Popen", m) - run_cmd(base_app, "edit") + _, errors = run_cmd(base_app, "edit") # We have an editor, so should expect a Popen call m.assert_called_once() + process.communicate.assert_called_once_with() + assert errors == [] + assert base_app.last_result == 0 def test_base_py_interactive(base_app) -> None: @@ -4521,17 +4542,17 @@ def test_refresh_interval() -> None: assert custom_app.main_session.refresh_interval == 5.0 -def test_enable_bottom_toolbar() -> None: +def test_bottom_toolbar_mode() -> None: # Test default default_app = cmd2.Cmd() assert default_app.main_session.bottom_toolbar is None - # Test True - custom_app = cmd2.Cmd(enable_bottom_toolbar=True) + # Test enabled + custom_app = cmd2.Cmd(bottom_toolbar_mode=cmd2.ToolbarMode.AUTO) assert custom_app.main_session.bottom_toolbar == custom_app.get_bottom_toolbar - # Test False - custom_app = cmd2.Cmd(enable_bottom_toolbar=False) + # Test disabled + custom_app = cmd2.Cmd(bottom_toolbar_mode=cmd2.ToolbarMode.OFF) assert custom_app.main_session.bottom_toolbar is None @@ -4653,7 +4674,6 @@ def test_create_main_session_with_custom_tty() -> None: app._create_main_session( auto_suggest=True, completekey=app.DEFAULT_COMPLETEKEY, - enable_bottom_toolbar=False, enable_rprompt=False, complete_in_thread=False, refresh_interval=0.0, diff --git a/tests/test_command_toolbar.py b/tests/test_command_toolbar.py index b2cf2d099..32c852a83 100644 --- a/tests/test_command_toolbar.py +++ b/tests/test_command_toolbar.py @@ -1,5 +1,6 @@ """Command toolbar lifecycle and terminal integration tests.""" +import contextlib import sys import threading import time @@ -16,9 +17,9 @@ from prompt_toolkit.layout import HSplit, Layout, Window from prompt_toolkit.shortcuts import PromptSession -from cmd2 import Cmd, command_toolbar +from cmd2 import Cmd, ToolbarMode, command_toolbar -from .conftest import RecordingOutput, Terminal +from .conftest import ContendedLock, RecordingOutput, Terminal def test_command_toolbar_refresh_and_output(toolbar_app, monkeypatch) -> None: @@ -29,11 +30,21 @@ def test_command_toolbar_refresh_and_output(toolbar_app, monkeypatch) -> None: def toolbar(): threads.append(threading.current_thread()) - if state[0] == "AFTER": - refreshed.set() return state[0] + def after_render(ui): + # Content evaluation precedes drawing. Wait for the completed frame, and + # inspect its cells: BEFORE and AFTER share the R in column five, so a + # correct incremental redraw may emit only AFTE rather than the whole word. + screen = ui.renderer._last_screen + if screen is not None: + size = ui.output.get_size() + band = "".join(screen.data_buffer[size.rows - 1][x].char for x in range(size.columns)) + if band.rstrip() == "AFTER": + refreshed.set() + app.main_session.bottom_toolbar = toolbar + app.main_session.app.after_render += after_render monkeypatch.setattr(sys, "stdout", output) original_stderr = sys.stderr with app._command_toolbar_context(): @@ -47,7 +58,6 @@ def toolbar(): assert "command output\n" in output.getvalue() assert "standard output" in output.getvalue() - assert "AFTER" in output.getvalue() assert all(thread is not threading.main_thread() and not thread.is_alive() for thread in threads) assert app.stdout is output assert sys.stdout is output @@ -88,10 +98,11 @@ def command(statement, **kwargs): assert app.stdout is output -def test_command_toolbar_pipe_output(toolbar_app) -> None: +def test_command_toolbar_pipe_output(toolbar_app, running_pipe_process) -> None: + # The child needs only sys; -S skips site initialization while retaining real pipe I/O. app, _, output = toolbar_app with app._command_toolbar_context(): - app.onecmd_plus_hooks(f'help | "{sys.executable}" -c "import sys; print(sys.stdin.read().upper())"') + app.onecmd_plus_hooks(f'help | "{sys.executable}" -S -c "import sys; print(sys.stdin.read().upper())"') assert "CMD2 COMMANDS" in output.getvalue() @@ -109,7 +120,7 @@ def __getattr__(self, name): @pytest.mark.parametrize("builtin_pager", [False, True]) -def test_command_toolbar_pipe_process_inherits_terminal(toolbar_app, tmp_path, builtin_pager) -> None: +def test_command_toolbar_pipe_process_inherits_terminal(toolbar_app, tmp_path, builtin_pager, running_pipe_process) -> None: app, _, _ = toolbar_app app.use_builtin_pager = builtin_pager destination = tmp_path / "terminal.txt" @@ -127,7 +138,9 @@ def command(statement, **kwargs): with destination.open("w+") as handle: app.stdout = FileTerminal(handle) with mock.patch.object(app, "onecmd", side_effect=command), app._command_toolbar_context(): - app.onecmd_plus_hooks(f'custom | "{sys.executable}" -c "import sys; sys.stdout.write(sys.stdin.read().upper())"') + app.onecmd_plus_hooks( + f'custom | "{sys.executable}" -S -c "import sys; sys.stdout.write(sys.stdin.read().upper())"' + ) # The terminal goes back to the toolbar once the pipe process has exited. assert app._command_toolbar.app.is_running assert app.stdout.proxy is not None @@ -259,7 +272,7 @@ def responds_to_cpr(self) -> bool: return True -def test_command_toolbar_flushes_writes_waiting_on_cursor_reports() -> None: +def test_command_toolbar_flushes_writes_waiting_on_cursor_reports(monkeypatch) -> None: app = Cmd(allow_cli_args=False) output = Terminal() app.stdout = output @@ -271,6 +284,15 @@ def test_command_toolbar_flushes_writes_waiting_on_cursor_reports() -> None: bottom_toolbar="STATUS", refresh_interval=0.01, ) + # Keep a real unanswered request, but expire its shutdown wait promptly. + # This test checks that queued output survives expiry, not the timeout duration. + renderer = app.main_session.app.renderer + wait_for_cpr = renderer.wait_for_cpr_responses + + async def expire_cpr() -> None: + await wait_for_cpr(timeout=0.01) + + monkeypatch.setattr(renderer, "wait_for_cpr_responses", expire_cpr) # Terminal writes wait for a pending cursor position report, so stopping the # display must not cancel them out from under the text. with app._command_toolbar_context(): @@ -279,31 +301,35 @@ def test_command_toolbar_flushes_writes_waiting_on_cursor_reports() -> None: assert "last words\n" in output.getvalue() -def test_command_toolbar_suspension_waits_for_in_flight_writes(toolbar_app) -> None: +def test_command_toolbar_suspension_waits_for_in_flight_writes(toolbar_app, monkeypatch) -> None: app, _, output = toolbar_app writing = threading.Event() + observed = ContendedLock() + original_init = command_toolbar.CommandToolbar.__init__ + + def init(display, *args, **kwargs): + original_init(display, *args, **kwargs) + display._lock = observed + + monkeypatch.setattr(command_toolbar.CommandToolbar, "__init__", init) with app._command_toolbar_context(): proxy = app._command_toolbar._proxy proxy_write = proxy.write def slow_write(data: str) -> int: - # Widen the window in which suspending could close this proxy. A closed - # proxy accepts writes and discards them, so the output would vanish. + # Do not finish the write until suspension actually tries to take its lock. writing.set() - time.sleep(0.1) + assert observed.contended.wait(5), "pause did not wait for the writer" return proxy_write(data) proxy.write = slow_write - thread = threading.Thread(target=lambda: app.poutput("in flight")) - thread.start() - assert writing.wait(2) - - # A command reaches this at every finalization boundary while its own - # threads are still printing. - with app.suspend_bottom_toolbar(): - pass - thread.join() + with ThreadPoolExecutor(max_workers=1) as pool: + pending = pool.submit(app.poutput, "in flight") + assert writing.wait(5) + with app.suspend_bottom_toolbar(): + pass + pending.result(timeout=5) assert "in flight\n" in output.getvalue() @@ -409,7 +435,7 @@ def already_exiting(): assert app.main_session.app.layout is app.main_session.layout -def test_command_toolbar_ui_call_propagates_failures(toolbar_app) -> None: +def test_command_toolbar_ui_call_propagates_failures(toolbar_app, monkeypatch) -> None: app, _, _ = toolbar_app def fail(exception: BaseException) -> None: @@ -429,7 +455,37 @@ def fail(exception: BaseException) -> None: toolbar._call_in_ui(lambda: fail(TimeoutError("slow ui call"))) # A callback that outlives the poll interval keeps waiting instead of giving up. - assert toolbar._call_in_ui(lambda: time.sleep(0.2) or "finished") == "finished" + entered = threading.Event() + release = threading.Event() + + class PendingFuture(Future): + polled = False + + def result(self, timeout=None): + if not self.polled: + self.polled = True + assert timeout is not None + assert entered.wait(5) + raise FutureTimeoutError + return super().result(timeout=5) + + def finish(): + entered.set() + assert release.wait(5) + return "finished" + + check_running = toolbar._check_running + + def checked(): + check_running() + release.set() + + monkeypatch.setattr(command_toolbar, "Future", PendingFuture) + monkeypatch.setattr(toolbar, "_check_running", checked) + try: + assert toolbar._call_in_ui(finish) == "finished" + finally: + release.set() def test_command_toolbar_ui_call_returns_a_result_that_lands_during_the_poll(toolbar_app, monkeypatch) -> None: @@ -477,7 +533,7 @@ def test_command_toolbar_ui_call_after_display_stopped(toolbar_app) -> None: toolbar._call_in_ui(lambda: None) -def test_command_toolbar_ui_call_reports_display_failure(toolbar_app, capsys) -> None: +def test_command_toolbar_ui_call_reports_display_failure(toolbar_app, capsys, monkeypatch) -> None: app, _, _ = toolbar_app with app._command_toolbar_context(): toolbar = app._command_toolbar @@ -485,6 +541,16 @@ def test_command_toolbar_ui_call_reports_display_failure(toolbar_app, capsys) -> schedule = loop.call_soon_threadsafe failed = threading.Event() + class FailedDisplayFuture(Future): + def result(self, timeout=None): + assert timeout is not None + toolbar._thread.join(5) + assert not toolbar._thread.is_alive() + assert not self.done() + raise FutureTimeoutError + + monkeypatch.setattr(command_toolbar, "Future", FailedDisplayFuture) + def die(*args, **kwargs): # The display dies instead of running the queued callback, so the future # the command is waiting on never resolves. Only drop that one request, @@ -604,7 +670,7 @@ def test_cmdloop_restores_signal_handlers_when_the_loop_fails(toolbar_app, monke @pytest.mark.parametrize("enabled", [False, True]) def test_command_toolbar_headless(enabled) -> None: - app = Cmd(allow_cli_args=False, enable_bottom_toolbar=enabled) + app = Cmd(allow_cli_args=False, bottom_toolbar_mode=ToolbarMode.AUTO if enabled else ToolbarMode.OFF) with mock.patch("cmd2.command_toolbar.CommandToolbar") as toolbar, app._command_toolbar_context(): toolbar.assert_not_called() @@ -778,7 +844,7 @@ def external(*args, **kwargs): assert app._command_toolbar.is_active -def test_builtin_pager_eof_restores_prompt(toolbar_app) -> None: +def test_builtin_pager_eof_restores_prompt(toolbar_app, monkeypatch) -> None: app, pipe, output = toolbar_app layout = app.main_session.app.layout @@ -787,6 +853,23 @@ def close_input(ui): pipe.close() app.main_session.app.after_render += close_input + original_pager = command_toolbar.Pager + + def pager(*args, **kwargs): + instance = original_pager(*args, **kwargs) + + def expired(timeout=None): + assert timeout is not None + display = app._command_toolbar + display._thread.join(5) + assert not display._thread.is_alive() + assert not instance.closed.is_set() + return False + + monkeypatch.setattr(instance.closed, "wait", expired) + return instance + + monkeypatch.setattr(command_toolbar, "Pager", pager) with pytest.raises(EOFError), app._command_toolbar_context(): app._command_toolbar.page("line\n" * 100, chop=False) assert app.main_session.app.layout is layout @@ -806,3 +889,263 @@ def test_builtin_pager_does_not_capture_redirected_output(toolbar_app, monkeypat pager.assert_not_called() assert "Cmd2 Commands" in target.read_text(encoding="utf-8") assert "Cmd2 Commands" not in output.getvalue() + + +@pytest.fixture +def expire_startup(monkeypatch): + """Expire only this display's readiness wait, after its real render has started. + + The five-second waits are failure watchdogs, not simulated startup delays. Keep + blocked workers alive for ownership assertions and join them before fixture teardown. + """ + displays = [] + releases = [] + + def install(app, *, block=True): + entered = threading.Event() + release = threading.Event() + releases.append(release) + original_init = command_toolbar.CommandToolbar.__init__ + + def toolbar(): + entered.set() + if block: + assert release.wait(5), "test never released the display" + return "STATUS" + + def init(display, *args, **kwargs): + original_init(display, *args, **kwargs) + displays.append(display) + + def expired(timeout=None): + assert entered.wait(5), "display never entered its render callback" + assert timeout is not None, "startup must bound its readiness wait" + return False + + monkeypatch.setattr(display._ready, "wait", expired) + + app.main_session.bottom_toolbar = toolbar + monkeypatch.setattr(command_toolbar.CommandToolbar, "__init__", init) + if block: + monkeypatch.setattr(command_toolbar, "_SHUTDOWN_TIMEOUT", 0.01) + return release + + yield install + for release in releases: + release.set() + for display in displays: + if display._thread is not None: + display._thread.join(5) + assert not display._thread.is_alive() + + +def test_command_toolbar_startup_does_not_wait_forever(toolbar_app, monkeypatch, expire_startup, capsys) -> None: + """A display that never reports itself started must not hold the command thread. + + The readiness signal comes from the display's own thread, so anything that stops it + arriving -- a render that never completes, a frame skipped forever -- would otherwise + block the command that is waiting to run. + """ + app, _, _ = toolbar_app + expire_startup(app, block=False) + monkeypatch.setattr(command_toolbar.CommandToolbar, "_display_started", lambda *args: None) + monkeypatch.setattr(command_toolbar.CommandToolbar, "_display_started_without_app", lambda *args: None) + + ran = [] + with app._command_toolbar_context(): + ran.append(True) + + assert ran == [True] + assert "did not start" in capsys.readouterr().err + + +def test_command_toolbar_startup_timeout_does_not_block_on_cleanup(toolbar_app, expire_startup, capsys) -> None: + """A blocked render callback must not hold the command thread through teardown either. + + The readiness wait being bounded is only half of it: the display thread is still inside + the callback, so the join that follows has to be bounded too. This blocks the callback for + real rather than suppressing the readiness signal, which is what the earlier test did and + why it could not see this. + """ + app, _, _ = toolbar_app + blocked = expire_startup(app) + + ran = [] + try: + started = time.monotonic() + with app._command_toolbar_context(): + ran.append(True) + elapsed = time.monotonic() - started + + assert ran == [True] + # Both bounded waits, and nothing unbounded between them. + assert elapsed < 5 + assert "did not start" in capsys.readouterr().err + finally: + blocked.set() + + +def _block_the_display(app, blocked: threading.Event) -> None: + """Wedge the running display inside a render callback it cannot leave. + + Waits until the callback has actually been entered. Asking for a redraw only *schedules* + one, so returning before it runs leaves a race: the pause that follows may reach the + display's event loop first, in which case it exits cleanly and there is no wedged thread + to test against. + """ + entered = threading.Event() + + def blocking_toolbar() -> str: + entered.set() + blocked.wait(timeout=10) + return "STATUS" + + app.main_session.bottom_toolbar = blocking_toolbar + app._command_toolbar.app.invalidate() + assert entered.wait(timeout=5), "the display never reached the blocking callback" + + +def test_command_toolbar_suspension_does_not_hand_over_a_terminal_it_still_owns(toolbar_app, monkeypatch) -> None: + """A pause that timed out did not stop anything, and must not pretend otherwise.""" + app, _, _ = toolbar_app + blocked = threading.Event() + monkeypatch.setattr(command_toolbar, "_SHUTDOWN_TIMEOUT", 0.01) + entered = [] + + try: + # The command context's own teardown fails the same way, for the same reason: the + # display never stopped. That is the established contract for a stop that fails. + with contextlib.suppress(RuntimeError), app._command_toolbar_context(): + display = app._command_toolbar + assert display is not None + _block_the_display(app, blocked) + + with pytest.raises(RuntimeError, match="did not stop"), app.suspend_bottom_toolbar(): + entered.append(True) + + # The guest never ran: the display still owns the application and the terminal. + assert entered == [] + assert display.app.is_running is True + finally: + blocked.set() + + +def test_command_toolbar_that_would_not_stop_is_not_used_again(toolbar_app, monkeypatch) -> None: + """The surviving thread outlives this display object, so the refusal has to as well.""" + app, _, _ = toolbar_app + blocked = threading.Event() + monkeypatch.setattr(command_toolbar, "_SHUTDOWN_TIMEOUT", 0.01) + + try: + with contextlib.suppress(RuntimeError), app._command_toolbar_context(): + _block_the_display(app, blocked) + with contextlib.suppress(RuntimeError), app.suspend_bottom_toolbar(): + pass + + assert app._command_toolbar_disabled is True + + # A later command must not start a second display over the one still running. + with app._command_toolbar_context(): + assert app._command_toolbar is None + finally: + blocked.set() + + +def test_command_toolbar_that_would_not_stop_keeps_the_application(toolbar_app, monkeypatch) -> None: + """Its layout and bindings are still in use; restoring them would pull them out from under it.""" + app, _, _ = toolbar_app + blocked = threading.Event() + monkeypatch.setattr(command_toolbar, "_SHUTDOWN_TIMEOUT", 0.01) + + try: + with contextlib.suppress(RuntimeError), app._command_toolbar_context(): + display = app._command_toolbar + assert display is not None + layout = display.app.layout + _block_the_display(app, blocked) + + with contextlib.suppress(RuntimeError), app.suspend_bottom_toolbar(): + pass + + assert display.app.layout is layout + finally: + blocked.set() + + +def test_a_surviving_display_blocks_later_handoffs(toolbar_app, expire_startup) -> None: + """Disabling future displays is not enough: the old one still owns the terminal.""" + app, _, _ = toolbar_app + blocked = expire_startup(app) + entered = [] + + try: + with app._command_toolbar_context(): + pass + + # The command display is gone as an object, but its thread is not. + assert app._command_toolbar is None + with pytest.raises(RuntimeError, match="terminal"), app.suspend_bottom_toolbar(): + entered.append(True) + assert entered == [] + finally: + blocked.set() + + +def test_a_surviving_display_blocks_the_prompt(toolbar_app, expire_startup) -> None: + """Two readers on one terminal is not a state to keep prompting in.""" + app, _, _ = toolbar_app + blocked = expire_startup(app) + + try: + with app._command_toolbar_context(): + pass + + with pytest.raises(RuntimeError, match="terminal"): + app._read_raw_input("> ", app.main_session) + finally: + blocked.set() + + +def test_the_refusal_lifts_when_the_display_finally_exits(toolbar_app, expire_startup) -> None: + """The thread may yet finish, and the session should not stay broken if it does. + + Lifting the refusal is not the whole of it. The pause that timed out never restored the + application it had borrowed, so the prompt that comes next would render with the command + display's layout and key bindings unless that teardown is finished first. + """ + app, _, _ = toolbar_app + blocked = expire_startup(app) + + prompt_layout = app.main_session.app.layout + prompt_bindings = app.main_session.app.key_bindings + prompt_erase = app.main_session.app.erase_when_done + + with app._command_toolbar_context(): + pass + surviving = app._display_holding_terminal + assert surviving is not None + assert app.main_session.app.layout is not prompt_layout + + blocked.set() + surviving._thread.join(timeout=5) + + with app.suspend_bottom_toolbar(): + pass + + assert app._display_holding_terminal is None + assert app.main_session.app.layout is prompt_layout + assert app.main_session.app.key_bindings is prompt_bindings + assert app.main_session.app.erase_when_done == prompt_erase + + +def test_a_surviving_display_stops_another_from_starting(toolbar_app, expire_startup) -> None: + app, _, _ = toolbar_app + blocked = expire_startup(app) + + try: + with app._command_toolbar_context(): + pass + with app._command_toolbar_context(): + assert app._command_toolbar is None + finally: + blocked.set() diff --git a/tests/test_managed_output.py b/tests/test_managed_output.py new file mode 100644 index 000000000..e2bf6ad6a --- /dev/null +++ b/tests/test_managed_output.py @@ -0,0 +1,351 @@ +"""Tests for writing command output to a reserved terminal. + +The rule this module exists to keep is an ordering one: a command's output and the toolbar's +paint reach the terminal one after the other, never interleaved, and the bridge learns that +output happened *inside* the same transaction that emitted it. Told afterwards, it would be +told about a terminal that had already changed again. +""" + +import io +import threading +from concurrent.futures import ThreadPoolExecutor +from typing import Any, Self + +import pytest + +from cmd2.command_toolbar import ToolbarStream +from cmd2.managed_output import SerializedTerminalWriter +from cmd2.terminal_transaction import TerminalLock, current_transaction + +from .conftest import ContendedLock + + +class RecordingBridge: + """A stand-in for the bridge, recording when it was told and by whom.""" + + def __init__(self) -> None: + self.notes: list[Any] = [] + self.anchors: list[int | None] = [] + + def note_managed_write(self, prompt_anchor: int | None = None) -> None: + self.notes.append(current_transaction()) + self.anchors.append(prompt_anchor) + + +class RecordingStream(io.StringIO): + """A terminal stream that records the transaction each write and flush ran in.""" + + def __init__(self) -> None: + super().__init__() + self.transactions: list[Any] = [] + self.flushes = 0 + + def write(self, text: str) -> int: + self.transactions.append(current_transaction()) + return super().write(text) + + def flush(self) -> None: + self.flushes += 1 + super().flush() + + +def make(bridge: RecordingBridge | None = None) -> tuple[SerializedTerminalWriter, RecordingStream, TerminalLock]: + """Build a writer over a recording stream.""" + stream = RecordingStream() + lock = TerminalLock() + return SerializedTerminalWriter(stream, lock, bridge), stream, lock + + +class TestWriting: + def test_output_reaches_the_stream(self) -> None: + writer, stream, _lock = make() + writer.write("hello\n") + assert stream.getvalue() == "hello\n" + + def test_the_count_of_characters_written_is_returned(self) -> None: + writer, _stream, _lock = make() + assert writer.write("hello") == 5 + + def test_every_write_is_flushed(self) -> None: + """The toolbar paints after this; unflushed output would appear after the paint.""" + writer, stream, _lock = make() + writer.write("hello") + assert stream.flushes >= 1 + + def test_the_write_happens_inside_a_terminal_transaction(self) -> None: + writer, stream, _lock = make() + writer.write("hello") + assert stream.transactions + assert all(state is not None for state in stream.transactions) + + def test_an_empty_write_still_takes_the_terminal(self) -> None: + """Ordering is about the sequence of transactions, not about how much was written.""" + writer, stream, _lock = make() + assert writer.write("") == 0 + assert stream.flushes >= 1 + + def test_flushing_takes_the_terminal(self) -> None: + writer, stream, _lock = make() + writer.flush() + assert stream.flushes >= 1 + + def test_the_stream_is_reachable(self) -> None: + writer, stream, _lock = make() + assert writer.stream is stream + + def test_file_attributes_come_from_the_stream(self) -> None: + """Callers ask streams whether they are a terminal, and what their encoding is.""" + writer, stream, _lock = make() + assert writer.readable() == stream.readable() + + +class TestInvalidationContract: + def test_the_bridge_is_told_inside_the_emitting_transaction(self) -> None: + """Told afterwards, it would be told about a terminal that has changed again.""" + bridge = RecordingBridge() + writer, _stream, _lock = make(bridge) + writer.write("hello\n") + assert len(bridge.notes) == 1 + assert bridge.notes[0] is not None + + def test_the_prompt_origin_is_not_claimed(self) -> None: + """The output moved the cursor and may have scrolled; where it ended is not known.""" + bridge = RecordingBridge() + writer, _stream, _lock = make(bridge) + writer.write("hello\n") + assert bridge.anchors == [None] + + def test_a_flush_alone_does_not_claim_output_happened(self) -> None: + bridge = RecordingBridge() + writer, _stream, _lock = make(bridge) + writer.flush() + assert bridge.notes == [] + + def test_a_writer_without_a_bridge_still_writes(self) -> None: + """Output outside a reserved session has nothing to invalidate.""" + writer, stream, _lock = make() + writer.write("hello") + assert stream.getvalue() == "hello" + + def test_the_bridge_can_be_attached_later(self) -> None: + """The streams outlive any one reservation; the bridge does not.""" + writer, _stream, _lock = make() + bridge = RecordingBridge() + writer.bridge = bridge + writer.write("hello") + assert len(bridge.notes) == 1 + + +class TestOrdering: + def test_a_write_and_a_paint_do_not_interleave(self) -> None: + """A managed write cannot reach the stream while a paint owns the terminal.""" + observed = ContendedLock() + lock = TerminalLock(lock=observed) + stream = RecordingStream() + writer = SerializedTerminalWriter(stream, lock, None) + with ThreadPoolExecutor(max_workers=1) as pool: + with lock.transaction("paint"): + pending = pool.submit(writer.write, "output\n") + assert observed.contended.wait(5), "write bypassed the paint's lock" + assert stream.getvalue() == "" + assert not pending.done() + stream.write("paint\n") + assert pending.result(timeout=5) == len("output\n") + assert stream.getvalue() == "paint\noutput\n" + + def test_writes_from_two_threads_are_not_torn(self) -> None: + writer, stream, _lock = make() + + def emit(text: str) -> None: + for _ in range(20): + writer.write(text) + + threads = [threading.Thread(target=emit, args=(text,)) for text in ("aaaa", "bbbb")] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=5) + + written = stream.getvalue() + assert len(written) == 160 + assert written.count("aaaa") == 20 + assert written.count("bbbb") == 20 + + +class TestToolbarStreamRouting: + """The stream a command writes to has three possible destinations, in priority order.""" + + def make_stream(self) -> tuple[ToolbarStream, RecordingStream]: + """Build a toolbar stream over a recording terminal stream.""" + original = RecordingStream() + return ToolbarStream(original, threading.RLock()), original + + def test_output_goes_to_the_terminal_when_nothing_is_installed(self) -> None: + stream, original = self.make_stream() + stream.write("hello") + assert original.getvalue() == "hello" + + def test_the_serializer_takes_priority_over_the_proxy(self) -> None: + """In reserved mode the proxy's erase-and-redraw is exactly what must not happen.""" + stream, original = self.make_stream() + proxy = RecordingStream() + stream.proxy = proxy # type: ignore[assignment] + stream.serializer = SerializedTerminalWriter(original, TerminalLock()) + + stream.write("hello") + assert original.getvalue() == "hello" + assert proxy.getvalue() == "" + + def test_the_proxy_is_used_when_no_serializer_is_installed(self) -> None: + stream, original = self.make_stream() + proxy = RecordingStream() + stream.proxy = proxy # type: ignore[assignment] + + stream.write("hello") + assert proxy.getvalue() == "hello" + assert original.getvalue() == "" + + def test_the_routing_lock_is_released_before_the_terminal_is_taken(self) -> None: + """Lock order: a routing lock held into the terminal transaction is the deadlock.""" + original = RecordingStream() + routing = threading.RLock() + stream = ToolbarStream(original, routing) + stream.serializer = SerializedTerminalWriter(original, TerminalLock()) + + held: list[bool] = [] + real_write = original.write + + def watched(text: str) -> int: + # Asked from another thread: the routing lock is re-entrant, so the writing + # thread could always take it again regardless of whether it still holds it. + def probe() -> None: + acquired = routing.acquire(blocking=False) + held.append(not acquired) + if acquired: + routing.release() + + prober = threading.Thread(target=probe) + prober.start() + prober.join(timeout=5) + return real_write(text) + + original.write = watched # type: ignore[method-assign] + worker = threading.Thread(target=lambda: stream.write("hello")) + worker.start() + worker.join(timeout=5) + assert held == [False] + + def test_flushing_follows_the_same_priority(self) -> None: + stream, original = self.make_stream() + stream.serializer = SerializedTerminalWriter(original, TerminalLock()) + stream.flush() + assert original.flushes >= 1 + + +class FailingStream(RecordingStream): + """A terminal that fails a chosen operation, having possibly emitted something first.""" + + def __init__(self, fail_write: bool = False, fail_flush: bool = False) -> None: + super().__init__() + self._fail_write = fail_write + self._fail_flush = fail_flush + + def write(self, text: str) -> int: + if self._fail_write: + super().write(text[:3]) + raise OSError("terminal went away") + return super().write(text) + + def flush(self) -> None: + super().flush() + if self._fail_flush: + raise OSError("terminal went away") + + +class TestFailedWrites: + def test_a_failed_write_still_invalidates(self) -> None: + """Part of the output may be on the screen; the bridge cannot be left believing not.""" + bridge = RecordingBridge() + writer = SerializedTerminalWriter(FailingStream(fail_write=True), TerminalLock(), bridge) + with pytest.raises(OSError, match="terminal went away"): + writer.write("hello") + assert len(bridge.notes) == 1 + assert bridge.notes[0] is not None + + def test_a_failed_flush_still_invalidates(self) -> None: + bridge = RecordingBridge() + writer = SerializedTerminalWriter(FailingStream(fail_flush=True), TerminalLock(), bridge) + with pytest.raises(OSError, match="terminal went away"): + writer.write("hello") + assert len(bridge.notes) == 1 + + def test_the_original_failure_is_what_reaches_the_caller(self) -> None: + bridge = RecordingBridge() + writer = SerializedTerminalWriter(FailingStream(fail_write=True), TerminalLock(), bridge) + with pytest.raises(OSError, match="terminal went away"): + writer.write("hello") + + def test_the_invalidation_happens_inside_the_transaction(self) -> None: + """Told after the lock is given up, it would describe a terminal already changed.""" + bridge = RecordingBridge() + writer = SerializedTerminalWriter(FailingStream(fail_flush=True), TerminalLock(), bridge) + with pytest.raises(OSError, match="terminal went away"): + writer.write("hello") + assert bridge.notes[0] is not None + + +class CountingLock: + """A routing lock that counts how many times it was taken.""" + + def __init__(self) -> None: + self._lock = threading.RLock() + self.acquisitions = 0 + + def __enter__(self) -> Self: + self._lock.acquire() + self.acquisitions += 1 + return self + + def __exit__(self, *args: object) -> None: + self._lock.release() + + def acquire(self, *args: Any, **kwargs: Any) -> bool: + self.acquisitions += 1 + return self._lock.acquire(*args, **kwargs) + + def release(self) -> None: + self._lock.release() + + +class TestRoutingIsDecidedOnce: + """One acquisition per write, or the destination can change between the two.""" + + def test_a_legacy_write_takes_the_routing_lock_once(self) -> None: + lock = CountingLock() + stream = ToolbarStream(RecordingStream(), lock) # type: ignore[arg-type] + stream.write("hello") + assert lock.acquisitions == 1 + + def test_a_serialized_write_takes_the_routing_lock_once(self) -> None: + lock = CountingLock() + original = RecordingStream() + stream = ToolbarStream(original, lock) # type: ignore[arg-type] + stream.serializer = SerializedTerminalWriter(original, TerminalLock()) + stream.write("hello") + assert lock.acquisitions == 1 + + def test_a_flush_takes_the_routing_lock_once(self) -> None: + lock = CountingLock() + stream = ToolbarStream(RecordingStream(), lock) # type: ignore[arg-type] + stream.flush() + assert lock.acquisitions == 1 + + def test_a_serializer_installed_after_the_decision_is_not_missed_by_the_next_write(self) -> None: + """A write in flight keeps its destination; the one after it sees the new one.""" + original = RecordingStream() + stream = ToolbarStream(original, threading.RLock()) + stream.write("before") + stream.serializer = SerializedTerminalWriter(original, TerminalLock()) + stream.write("after") + assert original.transactions[0] is None + assert original.transactions[-1] is not None diff --git a/tests/test_pager.py b/tests/test_pager.py index a2a4283ba..8dfae5d08 100644 --- a/tests/test_pager.py +++ b/tests/test_pager.py @@ -58,6 +58,8 @@ def test_output_fits_measures_styled_and_wide_text(chop) -> None: def test_pager_long_line_navigation_resize_and_typeahead(toolbar_app, chop) -> None: app, pipe, _ = toolbar_app app.main_session.bottom_toolbar = "STATUS ONE\nSTATUS TWO" + # Input and the explicit resize invalidate the display; no periodic redraw is needed. + app.main_session.app.refresh_interval = None entered, scrolled, resized = (threading.Event() for _ in range(3)) def observe(ui): @@ -90,7 +92,9 @@ def interact(): with ThreadPoolExecutor() as executor: interaction = executor.submit(interact) with app._command_toolbar_context(): - app._command_toolbar.page("界" * 4000, chop=chop) + # Thirty rows at 80 columns: still taller than the initial 24-row + # terminal, and longer after the resize, without rendering 100 rows. + app._command_toolbar.page("界" * 1200, chop=chop) interaction.result(timeout=2) assert app._read_raw_input("Next: ", app.main_session) == "next" @@ -269,6 +273,8 @@ def test_pager_search_abort_keys(toolbar_app, key) -> None: registered explicitly so that they still work when the main prompt uses Vi mode. """ app, pipe, _ = toolbar_app + # The pipe sends complete escape sequences; only a bare Escape needs this timer. + app.main_session.app.ttimeoutlen = 0.01 lines = [f"row {index:03d}" for index in range(100)] def script(keys) -> None: @@ -292,6 +298,8 @@ def test_pager_close_keys(toolbar_app, key) -> None: sequences that arrive with more bytes behind them. """ app, pipe, _ = toolbar_app + # The pipe sends complete escape sequences; only a bare Escape needs this timer. + app.main_session.app.ttimeoutlen = 0.01 entered = threading.Event() closed = threading.Event() diff --git a/tests/test_prompt_toolkit_bridge.py b/tests/test_prompt_toolkit_bridge.py index 74bcdc65a..597d20f3c 100644 --- a/tests/test_prompt_toolkit_bridge.py +++ b/tests/test_prompt_toolkit_bridge.py @@ -9,6 +9,7 @@ recorded operations and the flags left behind are the ones production would see. """ +import asyncio import io import threading from collections import deque @@ -23,6 +24,7 @@ from prompt_toolkit.layout import Layout, Window from prompt_toolkit.layout.controls import FormattedTextControl from prompt_toolkit.output.vt100 import Vt100_Output +from prompt_toolkit.renderer import CPR_Support from cmd2.output_recorder import PreflightFacts from cmd2.prompt_toolkit_bridge import ( @@ -30,7 +32,7 @@ ReservedModeFailureError, ) from cmd2.terminal_display import TerminalDisplay -from cmd2.terminal_transaction import TerminalLock, current_transaction +from cmd2.terminal_transaction import HigherLevelLock, TerminalLock, current_transaction class TtyStringIO(io.StringIO): @@ -889,3 +891,691 @@ def test_a_frame_in_flight_while_recovery_is_owed_is_not_committed(self) -> None assert harness.bridge.commit(prepared) is False assert harness.written() == "" + + +class RecordingTtyStream(TtyStringIO): + """A terminal that records the transaction each write ran in.""" + + def __init__(self) -> None: + super().__init__() + self.transactions: list[Any] = [] + + def write(self, text: str) -> int: + self.transactions.append(current_transaction()) + return super().write(text) + + +class TestRenderInterception: + """Once bound, prompt-toolkit's own renders go through prepare and commit.""" + + def bound(self, content: Any = "hello") -> Harness: + """Build a harness whose renderer is intercepted by the bridge.""" + harness = Harness(content=content) + harness.stream_recorder = RecordingTtyStream() + harness.backend.stdout = harness.stream_recorder + harness.bridge.bind(harness.app) + return harness + + def test_a_render_reaches_the_terminal(self) -> None: + harness = self.bound() + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + assert "hello" in harness.stream_recorder.getvalue() + + def test_a_render_is_emitted_inside_a_terminal_transaction(self) -> None: + """This is the whole point: renders serialize against paints and command output.""" + harness = self.bound() + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + assert harness.stream_recorder.transactions + assert all(state is not None for state in harness.stream_recorder.transactions) + + def test_layout_callbacks_still_run_outside_the_transaction(self) -> None: + seen: list[object] = [] + + def content() -> str: + seen.append(current_transaction()) + return "hello" + + harness = self.bound(content=content) + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + assert seen + assert all(state is None for state in seen) + + def test_the_frame_is_committed_rather_than_left_in_flight(self) -> None: + harness = self.bound() + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + assert harness.bridge.in_flight is None + assert harness.bridge.can_dispatch_input is True + + def test_preparation_does_not_re_enter_the_interception(self) -> None: + """The recorded render has to be upstream's, not the wrapper calling itself.""" + harness = self.bound() + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + # A recursive wrapper would never terminate; reaching here with output proves it did. + assert "hello" in harness.stream_recorder.getvalue() + + def test_a_render_owed_recovery_recovers_first(self) -> None: + harness = self.bound() + harness.bridge.set_prompt_anchor(3) + harness.bridge.require_resynchronization("a command wrote") + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + assert harness.bridge.needs_resynchronization is False + assert "\x1b[3;1H" in harness.stream_recorder.getvalue() + + def test_a_render_with_no_known_origin_asks_and_waits(self) -> None: + """Recovery cannot finish without an origin, so this frame is not drawn.""" + harness = self.bound() + harness.bridge.forget_prompt_anchor() + harness.bridge.require_resynchronization("a command wrote") + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + assert "\x1b[6n" in harness.stream_recorder.getvalue() + assert harness.bridge.needs_resynchronization is True + + def test_an_erase_is_emitted_inside_a_transaction(self) -> None: + harness = self.bound() + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + harness.stream_recorder.transactions.clear() + harness.renderer.erase() + assert harness.stream_recorder.transactions + assert all(state is not None for state in harness.stream_recorder.transactions) + + def test_an_erase_leaves_recovery_owed(self) -> None: + """It moved the cursor and cleared the screen below it; nothing may diff against that.""" + harness = self.bound() + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + harness.renderer.erase() + assert harness.bridge.needs_resynchronization is True + + def test_binding_can_be_undone(self) -> None: + harness = Harness() + original_render = harness.renderer.render + original_erase = harness.renderer.erase + harness.bridge.bind(harness.app) + assert harness.renderer.render is not original_render + harness.bridge.unbind() + assert harness.renderer.render == original_render + assert harness.renderer.erase == original_erase + + def test_unbinding_twice_is_harmless(self) -> None: + harness = Harness() + harness.bridge.bind(harness.app) + harness.bridge.unbind() + harness.bridge.unbind() + + def test_a_stale_frame_emits_nothing_and_asks_for_another(self) -> None: + """A command write during preparation retires the frame; the redraw is rescheduled.""" + redraws: list[int] = [] + harness = self.bound(content=lambda: harness.bridge.note_managed_write() or "hello") + harness.bridge.set_redraw_scheduler(lambda: redraws.append(1)) + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + assert harness.stream_recorder.getvalue() == "" + assert redraws + + def test_binding_twice_keeps_the_first_interception(self) -> None: + """A second bind would save the wrapper as the original and never unwind.""" + harness = self.bound() + wrapper = harness.renderer.render + harness.bridge.bind(harness.app) + assert harness.renderer.render == wrapper + harness.bridge.unbind() + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + assert harness.bridge.in_flight is None + + def test_compatibility_rendering_follows_the_release(self) -> None: + """The fallback is upstream's own renderer -- reached by unbinding, not by calling it.""" + harness = self.bound() + harness.bridge.stop_reserved_emission(OSError("terminal went away")) + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + assert harness.stream_recorder.getvalue() == "" + + # What the owner does on release: give the rows back, then unbind. + harness.display.release() + harness.bridge.unbind() + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + assert "hello" in harness.stream_recorder.getvalue() + assert all(state is None for state in harness.stream_recorder.transactions) + + def test_a_frame_that_cannot_be_prepared_asks_for_another(self) -> None: + redraws: list[int] = [] + harness = self.bound() + harness.bridge.set_redraw_scheduler(lambda: redraws.append(1)) + with set_app(harness.app): + prepared = harness.bridge.prepare(harness.app) + assert prepared is not None + # A frame is already in flight, so the intercepted render cannot prepare one. + harness.renderer.render(harness.app, harness.app.layout) + assert redraws + + def test_a_frame_that_cannot_commit_asks_for_another(self) -> None: + """Retired between preparing and committing: nothing is emitted, a redraw is asked for.""" + redraws: list[int] = [] + handover = RetiringLock() + harness = Harness(lock=TerminalLock(lock=handover)) + harness.stream_recorder = RecordingTtyStream() + harness.backend.stdout = harness.stream_recorder + harness.bridge.bind(harness.app) + harness.bridge.set_redraw_scheduler(lambda: redraws.append(1)) + + # Preflight and publish take the terminal first; the third acquisition is the commit. + handover.schedule(None, None, harness.bridge.note_owner_change) + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + + assert harness.stream_recorder.getvalue() == "" + assert redraws + + def test_a_clear_is_emitted_inside_a_transaction_and_invalidates(self) -> None: + harness = self.bound() + # Upstream's clear() ends by scheduling a cursor-position request on the event loop, + # which only exists while the application is running. The wrapper is what is under + # test here, not that scheduling. + harness.renderer.request_absolute_cursor_position = lambda: None # type: ignore[method-assign] + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + harness.stream_recorder.transactions.clear() + harness.renderer.clear() + assert harness.stream_recorder.transactions + assert all(state is not None for state in harness.stream_recorder.transactions) + assert harness.bridge.needs_resynchronization is True + + +class AlwaysFailingTtyStream(TtyStringIO): + """A terminal that has gone away, having possibly emitted something first.""" + + def write(self, text: str) -> int: + super().write(text[:4]) + raise OSError("terminal went away") + + +class TestReviewRegressionsRoundThree: + def bound(self, content: Any = "hello") -> Harness: + """Build a harness whose renderer is intercepted by the bridge.""" + harness = Harness(content=content) + harness.stream_recorder = RecordingTtyStream() + harness.backend.stdout = harness.stream_recorder + harness.bridge.bind(harness.app) + return harness + + def test_abandoned_emission_renders_nothing_until_the_owner_releases(self) -> None: + """Review finding: compatibility rendering starts after the release, not before it.""" + harness = self.bound() + harness.bridge.stop_reserved_emission(OSError("terminal went away")) + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + assert harness.stream_recorder.getvalue() == "" + assert harness.display.is_reserved is True + + def test_abandoning_emission_tells_the_owner_to_release(self) -> None: + released: list[int] = [] + harness = self.bound() + harness.bridge.set_emission_stopped_handler(lambda: released.append(1)) + harness.bridge.stop_reserved_emission(OSError("terminal went away")) + assert released == [1] + + def test_a_failed_erase_still_invalidates(self) -> None: + """Review finding: it emitted something before it raised, and moved the cursor.""" + harness = self.bound() + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + harness.backend.stdout = AlwaysFailingTtyStream() + + with pytest.raises(OSError, match="terminal went away"), set_app(harness.app): + harness.renderer.erase() + assert harness.bridge.needs_resynchronization is True + + def test_a_failed_clear_still_invalidates(self) -> None: + harness = self.bound() + harness.renderer.request_absolute_cursor_position = lambda: None # type: ignore[method-assign] + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + harness.backend.stdout = AlwaysFailingTtyStream() + + with pytest.raises(OSError, match="terminal went away"), set_app(harness.app): + harness.renderer.clear() + assert harness.bridge.needs_resynchronization is True + + def test_clearing_forgets_where_the_prompt_was(self) -> None: + """Review finding: the clear moved the cursor, so the remembered row is not it.""" + harness = self.bound() + harness.renderer.request_absolute_cursor_position = lambda: None # type: ignore[method-assign] + harness.bridge.set_prompt_anchor(7) + with set_app(harness.app): + harness.renderer.clear() + assert harness.bridge.prompt_anchor is None + + harness.clear() + harness.stream_recorder.truncate(0) + harness.stream_recorder.seek(0) + harness.resynchronize() + assert "\x1b[7;1H" not in harness.stream_recorder.getvalue() + + def test_clearing_discards_outstanding_cursor_reports(self) -> None: + """A reply describing the screen before the clear must not establish an origin.""" + harness = self.bound() + harness.renderer.request_absolute_cursor_position = lambda: None # type: ignore[method-assign] + harness.bridge.request_cursor_position() + with set_app(harness.app): + harness.renderer.clear() + assert harness.bridge.report_cursor_row(4) is False + assert harness.bridge.prompt_anchor is None + + def test_unbinding_leaves_a_newer_method_alone(self) -> None: + """Review finding: restoring unconditionally discards whatever replaced ours.""" + harness = self.bound() + replacement = lambda *args, **kwargs: None # noqa: E731 + harness.renderer.render = replacement # type: ignore[method-assign] + harness.bridge.unbind() + assert harness.renderer.render is replacement + + def test_unbinding_restores_the_methods_that_are_still_ours(self) -> None: + harness = Harness() + original_erase = harness.renderer.erase + harness.bridge.bind(harness.app) + replacement = lambda *args, **kwargs: None # noqa: E731 + harness.renderer.render = replacement # type: ignore[method-assign] + harness.bridge.unbind() + assert harness.renderer.render is replacement + assert harness.renderer.erase == original_erase + + def test_abandoning_emission_twice_notifies_once(self) -> None: + """The owner releases once; telling it again would release a reservation it re-took.""" + released: list[int] = [] + harness = self.bound() + harness.bridge.set_emission_stopped_handler(lambda: released.append(1)) + harness.bridge.stop_reserved_emission(OSError("terminal went away")) + harness.bridge.stop_reserved_emission(OSError("and again")) + assert released == [1] + assert str(harness.bridge.take_pending_error()) == "terminal went away" + + def test_a_failed_cleanup_tells_the_owner_to_release(self) -> None: + """Review finding: the one path that really abandons emission skipped the transition.""" + released: list[int] = [] + harness = self.bound() + harness.bridge.set_emission_stopped_handler(lambda: released.append(1)) + + prepared = harness.bridge.prepare(harness.app) + assert prepared is not None + harness.backend.stdout = AlwaysFailingTtyStream() + assert harness.bridge.commit(prepared) is False + + assert harness.bridge.reserved_emission_stopped is True + assert released == [1] + + def test_a_reply_in_transit_when_the_screen_cleared_is_not_reused(self) -> None: + """Review finding: emptying the queue lets the next reply answer the wrong request.""" + harness = self.bound() + harness.renderer.request_absolute_cursor_position = lambda: None # type: ignore[method-assign] + harness.bridge.set_prompt_anchor(7) + + harness.bridge.request_cursor_position() # request A, about the pre-clear screen + with set_app(harness.app): + harness.renderer.clear() + harness.bridge.request_cursor_position() # request B, about the cleared screen + + # Reply A arrives late. It describes the screen before the clear. + assert harness.bridge.report_cursor_row(9) is False + assert harness.bridge.prompt_anchor is None + + # Reply B is the one that establishes the origin. + assert harness.bridge.report_cursor_row(4) is True + assert harness.bridge.prompt_anchor == 4 + + def test_the_renderers_own_bookkeeping_is_settled_for_each_stale_reply(self) -> None: + harness = self.bound() + harness.renderer.request_absolute_cursor_position = lambda: None # type: ignore[method-assign] + harness.bridge.request_cursor_position() + pending: Future[None] = Future() + harness.renderer._waiting_for_cpr_futures.append(pending) + with set_app(harness.app): + harness.renderer.clear() + + assert harness.bridge.report_cursor_row(9) is False + assert pending.done() is True + + +class TestCursorReportInterception: + """Upstream asks for and receives cursor reports itself; both go through the bridge.""" + + @pytest.fixture(autouse=True) + def _event_loop(self) -> Any: + """Upstream builds an asyncio Future per request, which needs a loop to attach to. + + In life that loop is the application's. Here there is no application running, so one + is provided for the duration rather than the request path being stubbed out -- the + bookkeeping under test is exactly what that Future is part of. + """ + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + yield + finally: + asyncio.set_event_loop(None) + loop.close() + + def bound(self) -> Harness: + harness = Harness() + harness.stream_recorder = RecordingTtyStream() + harness.backend.stdout = harness.stream_recorder + # As it is once a reply has been seen. Left unknown, upstream schedules a timeout task + # on the event loop, which exists only while the application is running. + harness.renderer.cpr_support = CPR_Support.SUPPORTED + harness.bridge.bind(harness.app) + return harness + + def test_an_upstream_request_is_recorded(self) -> None: + """Unrecorded, its reply would arrive uncorrelated and be thrown away.""" + harness = self.bound() + harness.renderer.request_absolute_cursor_position() + assert "\x1b[6n" in harness.stream_recorder.getvalue() + assert harness.bridge.report_cursor_row(4) is True + assert harness.renderer._min_available_height == 23 - 4 + 1 + + def test_a_reply_delivered_by_upstream_is_validated(self) -> None: + """Named regression 13.1: a row in the band must not set a height at all.""" + harness = self.bound() + harness.renderer.request_absolute_cursor_position() + harness.renderer.report_absolute_cursor_row(24) + assert harness.renderer._min_available_height == 0 + assert harness.bridge.needs_resynchronization is True + + def test_a_valid_reply_delivered_by_upstream_is_used(self) -> None: + harness = self.bound() + harness.renderer.request_absolute_cursor_position() + harness.renderer.report_absolute_cursor_row(6) + assert harness.renderer._min_available_height == 23 - 6 + 1 + assert harness.bridge.prompt_anchor == 6 + + def test_a_request_that_emitted_nothing_is_not_recorded(self) -> None: + """On a terminal that does not answer there is no reply to wait for.""" + harness = self.bound() + harness.renderer.cpr_support = CPR_Support.NOT_SUPPORTED + harness.renderer.request_absolute_cursor_position() + # Nothing outstanding, so a reply now would be answering a question never asked. + assert harness.bridge.report_cursor_row(4) is False + + def test_reporting_does_not_re_enter_the_interception(self) -> None: + harness = self.bound() + harness.renderer.request_absolute_cursor_position() + harness.renderer.report_absolute_cursor_row(6) + assert harness.renderer._min_available_height > 0 + + def test_an_abandoned_reservation_asks_for_nothing(self) -> None: + """Emission has stopped; a request would put bytes into a terminal being given back.""" + harness = self.bound() + harness.bridge.stop_reserved_emission(OSError("terminal went away")) + harness.stream_recorder.truncate(0) + harness.stream_recorder.seek(0) + harness.renderer.request_absolute_cursor_position() + assert harness.stream_recorder.getvalue() == "" + + def test_unbinding_restores_both(self) -> None: + harness = Harness() + request = harness.renderer.request_absolute_cursor_position + report = harness.renderer.report_absolute_cursor_row + harness.bridge.bind(harness.app) + harness.bridge.unbind() + assert harness.renderer.request_absolute_cursor_position == request + assert harness.renderer.report_absolute_cursor_row == report + + +class TestCommittedFrameNotification: + def test_a_committed_frame_notifies_once(self) -> None: + committed: list[int] = [] + harness = Harness() + harness.bridge.bind(harness.app) + harness.bridge.set_frame_committed_handler(lambda: committed.append(1)) + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + assert committed == [1] + + def test_a_frame_that_could_not_be_prepared_notifies_nobody(self) -> None: + committed: list[int] = [] + harness = Harness(content=lambda: harness.bridge.note_managed_write() or "hello") + harness.bridge.bind(harness.app) + harness.bridge.set_frame_committed_handler(lambda: committed.append(1)) + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + assert committed == [] + + def test_a_frame_that_could_not_be_committed_notifies_nobody(self) -> None: + """After-render work belongs to frames the terminal actually received. + + Retired between preparing and committing, so the frame gets as far as the commit and + is refused there -- the case a preparation that never started cannot reach. + """ + committed: list[int] = [] + handover = RetiringLock() + harness = Harness(lock=TerminalLock(lock=handover)) + harness.bridge.bind(harness.app) + harness.bridge.set_frame_committed_handler(lambda: committed.append(1)) + + # Preflight and publish take the terminal first; the third acquisition is the commit. + handover.schedule(None, None, harness.bridge.note_owner_change) + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + assert committed == [] + + def test_the_notification_runs_outside_the_transaction(self) -> None: + seen: list[object] = [] + harness = Harness() + harness.bridge.bind(harness.app) + harness.bridge.set_frame_committed_handler(lambda: seen.append(current_transaction())) + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + assert seen == [None] + + def test_a_skipped_frame_fires_no_after_render(self) -> None: + """Upstream fires the event after render() returns, whatever the wrapper decided.""" + fired: list[int] = [] + harness = Harness() + harness.bridge.bind(harness.app) + harness.app.after_render += lambda _app: fired.append(1) + harness.bridge.forget_prompt_anchor() + harness.bridge.require_resynchronization("a command wrote") + + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + harness.app.after_render.fire() + assert fired == [] + + def test_a_committed_frame_fires_after_render(self) -> None: + fired: list[int] = [] + harness = Harness() + harness.bridge.bind(harness.app) + harness.app.after_render += lambda _app: fired.append(1) + + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + harness.app.after_render.fire() + assert fired == [1] + + def test_an_erase_fires_after_render(self) -> None: + """It reached the terminal, so whatever waits on a frame has had one.""" + fired: list[int] = [] + harness = Harness() + harness.bridge.bind(harness.app) + harness.app.after_render += lambda _app: fired.append(1) + + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + harness.renderer.erase() + harness.app.after_render.fire() + assert fired == [1] + + def test_unbinding_restores_the_event(self) -> None: + fired: list[int] = [] + harness = Harness() + original = harness.app.after_render.fire + harness.bridge.bind(harness.app) + harness.bridge.unbind() + assert harness.app.after_render.fire == original + + harness.app.after_render += lambda _app: fired.append(1) + harness.app.after_render.fire() + assert fired == [1] + + def test_an_event_replaced_while_bound_is_left_alone(self) -> None: + harness = Harness() + harness.bridge.bind(harness.app) + replacement = lambda: None # noqa: E731 + harness.app.after_render.fire = replacement # type: ignore[method-assign] + harness.bridge.unbind() + assert harness.app.after_render.fire is replacement + + +class TestDelegatingWrappers: + """Someone else's wrapper may outlive the bridge and still call into it.""" + + @pytest.fixture(autouse=True) + def _event_loop(self) -> Any: + """Upstream builds an asyncio Future per cursor request, which needs a loop.""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + yield + finally: + asyncio.set_event_loop(None) + loop.close() + + def bound(self) -> Harness: + harness = Harness() + harness.stream_recorder = RecordingTtyStream() + harness.backend.stdout = harness.stream_recorder + harness.renderer.cpr_support = CPR_Support.SUPPORTED + harness.bridge.bind(harness.app) + return harness + + def test_a_wrapper_delegating_to_the_bridge_still_renders_after_unbinding(self) -> None: + """Review finding: the wrapper is kept, so what it delegates to has to keep working.""" + harness = self.bound() + calls: list[int] = [] + delegate = harness.renderer.render + + def tracing_render(*args: Any, **kwargs: Any) -> None: + calls.append(1) + delegate(*args, **kwargs) + + harness.renderer.render = tracing_render # type: ignore[method-assign] + harness.bridge.unbind() + + harness.stream_recorder.truncate(0) + harness.stream_recorder.seek(0) + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + + assert calls == [1] + assert "hello" in harness.stream_recorder.getvalue() + + def test_an_unbound_bridge_renders_without_taking_the_terminal(self) -> None: + """It is not the terminal's owner any more, so it passes the call straight through.""" + harness = self.bound() + delegate = harness.renderer.render + + def passing_render(*args: Any, **kwargs: Any) -> None: + delegate(*args, **kwargs) + + harness.renderer.render = passing_render # type: ignore[method-assign] + harness.bridge.unbind() + + harness.stream_recorder.truncate(0) + harness.stream_recorder.seek(0) + with set_app(harness.app): + harness.renderer.render(harness.app, harness.app.layout) + assert all(state is None for state in harness.stream_recorder.transactions) + + def test_a_wrapper_delegating_to_the_bridge_can_still_fire_after_render(self) -> None: + harness = self.bound() + fired: list[int] = [] + delegate = harness.app.after_render.fire + + def tracing_fire() -> None: + fired.append(1) + delegate() + + harness.app.after_render.fire = tracing_fire # type: ignore[method-assign] + harness.bridge.unbind() + + handled: list[int] = [] + harness.app.after_render += lambda _app: handled.append(1) + harness.app.after_render.fire() + + assert fired == [1] + assert handled == [1] + + def test_delegated_erase_and_clear_take_no_transaction_after_unbinding(self) -> None: + """A retired bridge is not the terminal's owner and must not act as one. + + Taking its lock is not harmless: the caller may hold a higher-level lock, and the + ordering rule forbids that nesting -- so acting as owner turns someone else's clear + into an exception. + """ + harness = self.bound() + harness.renderer.request_absolute_cursor_position = lambda: None # type: ignore[method-assign] + erase, clear = harness.renderer.erase, harness.renderer.clear + + def passing_erase(*args: Any, **kwargs: Any) -> None: + erase(*args, **kwargs) + + def passing_clear() -> None: + clear() + + harness.renderer.erase = passing_erase # type: ignore[method-assign] + harness.renderer.clear = passing_clear # type: ignore[method-assign] + harness.bridge.unbind() + harness.bridge.require_resynchronization("before the delegated calls") + + harness.stream_recorder.transactions.clear() + with set_app(harness.app), HigherLevelLock("routing"): + harness.renderer.erase() + harness.renderer.clear() + + assert harness.stream_recorder.transactions + assert all(state is None for state in harness.stream_recorder.transactions) + + def test_a_delegated_clear_does_not_invalidate_the_retired_bridge(self) -> None: + """Its state describes a terminal it no longer owns; changing it means nothing.""" + harness = self.bound() + harness.renderer.request_absolute_cursor_position = lambda: None # type: ignore[method-assign] + harness.bridge.set_prompt_anchor(7) + clear = harness.renderer.clear + + def passing_clear() -> None: + clear() + + harness.renderer.clear = passing_clear # type: ignore[method-assign] + harness.bridge.unbind() + + with set_app(harness.app): + harness.renderer.clear() + assert harness.bridge.prompt_anchor == 7 + + def test_delegated_cursor_reports_still_work_after_unbinding(self) -> None: + harness = self.bound() + request = harness.renderer.request_absolute_cursor_position + report = harness.renderer.report_absolute_cursor_row + + def passing_request() -> None: + request() + + def passing_report(row: int) -> None: + report(row) + + harness.renderer.request_absolute_cursor_position = passing_request # type: ignore[method-assign] + harness.renderer.report_absolute_cursor_row = passing_report # type: ignore[method-assign] + harness.bridge.unbind() + + harness.renderer.request_absolute_cursor_position() + harness.renderer.report_absolute_cursor_row(4) + assert harness.renderer._min_available_height == 23 - 4 + 1 diff --git a/tests/test_reserved_lifecycle.py b/tests/test_reserved_lifecycle.py new file mode 100644 index 000000000..6e398f4f6 --- /dev/null +++ b/tests/test_reserved_lifecycle.py @@ -0,0 +1,701 @@ +"""Tests for choosing and owning reserved rendering across a cmd2 command loop. + +The lifetime boundary is the point of these: the reservation is established after the intro +has been printed and released however the loop ends, so the terminal a user gets back at the +shell is the one they started with. +""" + +import io +from collections.abc import Callable +from typing import Any + +import pytest +from prompt_toolkit.application import create_app_session +from prompt_toolkit.application.current import set_app +from prompt_toolkit.data_structures import Size +from prompt_toolkit.input import create_pipe_input +from prompt_toolkit.output.vt100 import Vt100_Output +from prompt_toolkit.shortcuts import PromptSession + +import cmd2 +from cmd2.plugin import CommandFinalizationData +from cmd2.reserved_toolbar import native_toolbar_container + + +class TtyStringIO(io.StringIO): + """A stream that claims to be a terminal, as the backend requires.""" + + reply: Callable[[str], None] | None = None + + def write(self, data: str) -> int: + """Answer each cursor request through the real input reader. + + These lifecycle tests use a fixed prompt origin; screen geometry is tested + separately. Leaving requests unanswered makes every shutdown wait a second. + """ + written = super().write(data) + if self.reply is not None: + for _ in range(data.count("\x1b[6n")): + self.reply("\x1b[1;1R") + return written + + def isatty(self) -> bool: + return True + + +class Harness: + """A cmd2 application whose main session renders to a terminal the test can read.""" + + def __init__(self, mode: str = "reserved", rows: int = 24, toolbar: Any = "STATUS") -> None: + self.stream = TtyStringIO() + self.size = Size(rows=rows, columns=80) + self.backend = Vt100_Output(self.stream, lambda: self.size) + self._pipe = create_pipe_input() + self.pipe = self._pipe.__enter__() + self.stream.reply = self.pipe.send_text + # Bind the ambient app session to this terminal. Without it, prompt-toolkit builds a + # real one on demand -- patch_stdout() in _read_raw_input() does -- and on Windows that + # means asking for a console the CI runner does not have. + self._session_context = create_app_session(input=self.pipe, output=self.backend) + self._session_context.__enter__() + self.app = cmd2.Cmd(allow_cli_args=False, bottom_toolbar_mode=mode) + # The command's output and the toolbar's paints share one terminal, as they do in + # life: the stream cmd2 writes to is the stream the backend renders to. + self.app.stdout = self.stream + self.app.main_session = PromptSession(input=self.pipe, output=self.backend, bottom_toolbar=toolbar) + self.clear() + + def close(self) -> None: + """Release the app session and the pipe input.""" + self._session_context.__exit__(None, None, None) + self._pipe.__exit__(None, None, None) + + def clear(self) -> None: + """Discard everything written so far.""" + self.stream.truncate(0) + self.stream.seek(0) + + def written(self) -> str: + """Everything written since the last clear.""" + return self.stream.getvalue() + + +class TestSelection: + def test_legacy_reserves_nothing(self) -> None: + harness = Harness(mode="legacy") + try: + with harness.app._reserved_toolbar_context(): + assert harness.app.reserved_toolbar is None + assert harness.written() == "" + finally: + harness.close() + + def test_reserved_owns_the_terminal_for_the_body(self) -> None: + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(): + toolbar = harness.app.reserved_toolbar + assert toolbar is not None + assert toolbar.is_active is True + assert "\x1b[1;23r" in harness.written() + assert harness.app.reserved_toolbar is None + finally: + harness.close() + + def test_the_reservation_is_released_when_the_loop_ends(self) -> None: + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(): + harness.clear() + assert "\x1b[r" in harness.written() + finally: + harness.close() + + def test_the_reservation_is_released_when_the_loop_raises(self) -> None: + harness = Harness(mode="reserved") + try: + with pytest.raises(ZeroDivisionError), harness.app._reserved_toolbar_context(): + raise ZeroDivisionError + assert harness.app.reserved_toolbar is None + assert "\x1b[r" in harness.written() + finally: + harness.close() + + def test_auto_falls_back_when_no_toolbar_is_configured(self) -> None: + harness = Harness(mode="auto", toolbar=None) + try: + with harness.app._reserved_toolbar_context(): + assert harness.app.reserved_toolbar is None + finally: + harness.close() + + def test_auto_reserves_on_a_qualified_terminal(self) -> None: + harness = Harness(mode="auto") + try: + with harness.app._reserved_toolbar_context(): + assert harness.app.reserved_toolbar is not None + finally: + harness.close() + + def test_forcing_reserved_without_a_toolbar_fails_before_the_loop(self) -> None: + """The caller ruled out legacy rendering; falling back would ignore that.""" + harness = Harness(mode="reserved", toolbar=None) + try: + with pytest.raises(ValueError, match="toolbar"), harness.app._reserved_toolbar_context(): + pass + finally: + harness.close() + + def test_a_terminal_too_short_to_reserve_still_runs(self) -> None: + """The floor is not an error: the loop runs, with the toolbar rendered natively.""" + harness = Harness(mode="reserved", rows=2) + try: + with harness.app._reserved_toolbar_context(): + toolbar = harness.app.reserved_toolbar + assert toolbar is not None + assert toolbar.is_active is False + finally: + harness.close() + + +class TestPaintedToolbar: + def test_the_toolbar_is_painted_on_the_reserved_row(self) -> None: + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(): + assert "\x1b[24;1H" in harness.written() + assert "STATUS" in harness.written() + finally: + harness.close() + + def test_the_content_comes_from_the_session_as_it_is_now(self) -> None: + """Assigning a new provider mid-session has to reach the band.""" + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(): + toolbar = harness.app.reserved_toolbar + assert toolbar is not None + harness.app.main_session.bottom_toolbar = "REPLACED" + harness.clear() + assert toolbar.refresh() is True + painter = toolbar.painter + assert painter is not None + assert painter.last_frame is not None + row = "".join(cell.char for cell in painter.last_frame.rows[0]) + assert row.startswith("REPLACED") + finally: + harness.close() + + def test_a_callable_provider_is_resolved(self) -> None: + """cmd2 sets ``bottom_toolbar`` to a method; the band must call it, not print it.""" + harness = Harness(mode="reserved", toolbar=lambda: "FROM CALLABLE") + try: + with harness.app._reserved_toolbar_context(): + painter = harness.app.reserved_toolbar.painter # type: ignore[union-attr] + assert painter is not None + assert painter.last_frame is not None + row = "".join(cell.char for cell in painter.last_frame.rows[0]) + assert row.startswith("FROM CALLABLE") + finally: + harness.close() + + +class TestCommandOutputRouting: + def test_reserved_mode_serializes_output_instead_of_proxying_it(self) -> None: + """The proxy's erase-and-redraw is the flicker; the reservation removes the need.""" + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(), harness.app._command_toolbar_context(): + display = harness.app._command_toolbar + assert display is not None + assert display._proxy is None + assert display._streams + assert all(stream.serializer is not None for stream in display._streams) + finally: + harness.close() + + def test_legacy_mode_still_proxies(self) -> None: + harness = Harness(mode="legacy") + try: + with harness.app._reserved_toolbar_context(), harness.app._command_toolbar_context(): + display = harness.app._command_toolbar + assert display is not None + assert display._proxy is not None + assert all(stream.serializer is None for stream in display._streams) + finally: + harness.close() + + def test_command_output_reaches_the_terminal(self) -> None: + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(), harness.app._command_toolbar_context(): + harness.clear() + harness.app.poutput("command output") + assert "command output" in harness.written() + finally: + harness.close() + + def test_command_output_tells_the_bridge_inside_the_write(self) -> None: + """The Stage 3 contract: the invalidation is part of the emitting transaction.""" + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(), harness.app._command_toolbar_context(): + toolbar = harness.app.reserved_toolbar + assert toolbar is not None + bridge = toolbar.bridge + assert bridge is not None + harness.app.poutput("command output") + assert bridge.needs_resynchronization is True + assert bridge.prompt_anchor is None + finally: + harness.close() + + def test_the_display_reports_itself_active_while_serialized(self) -> None: + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(), harness.app._command_toolbar_context(): + display = harness.app._command_toolbar + assert display is not None + assert display.is_active is True + finally: + harness.close() + + def test_the_streams_are_given_back_when_the_display_stops(self) -> None: + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(): + with harness.app._command_toolbar_context(): + display = harness.app._command_toolbar + assert display is not None + streams = list(display._streams) + assert all(stream.serializer is None for stream in streams) + assert harness.app.stdout is harness.app.stdout + finally: + harness.close() + + +class TestSuspension: + """Two kinds of pause, and each site says which one it means.""" + + def test_finalization_keeps_the_rows(self) -> None: + """The ordinary end of a command: the toolbar must still be there afterwards.""" + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(): + toolbar = harness.app.reserved_toolbar + assert toolbar is not None + harness.clear() + harness.app._run_cmdfinalization_hooks(False, None) + assert toolbar.display.is_reserved is True + assert "\x1b[r" not in harness.written() + finally: + harness.close() + + def test_quiescing_keeps_the_rows(self) -> None: + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(): + toolbar = harness.app.reserved_toolbar + assert toolbar is not None + harness.clear() + with harness.app._quiesce_bottom_toolbar(): + assert toolbar.display.is_reserved is True + assert "\x1b[r" not in harness.written() + finally: + harness.close() + + def test_suspending_gives_the_rows_back(self) -> None: + """A program that inherits the terminal knows nothing about a scroll region.""" + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(): + toolbar = harness.app.reserved_toolbar + assert toolbar is not None + harness.clear() + with harness.app.suspend_bottom_toolbar(): + assert toolbar.display.is_reserved is False + assert "\x1b[r" in harness.written() + assert toolbar.display.is_reserved is True + finally: + harness.close() + + def test_the_band_is_repainted_when_the_terminal_comes_back(self) -> None: + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(): + with harness.app.suspend_bottom_toolbar(): + harness.clear() + assert "STATUS" in harness.written() + finally: + harness.close() + + def test_coming_back_leaves_recovery_owed(self) -> None: + """Another program owned the screen; nothing may be diffed against what it left.""" + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(): + toolbar = harness.app.reserved_toolbar + assert toolbar is not None + bridge = toolbar.bridge + assert bridge is not None + with harness.app.suspend_bottom_toolbar(): + pass + assert bridge.needs_resynchronization is True + finally: + harness.close() + + def test_only_the_outermost_suspension_takes_the_rows_back(self) -> None: + """The interval between the inner and outer exits is still the guest's terminal.""" + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(): + toolbar = harness.app.reserved_toolbar + assert toolbar is not None + with harness.app.suspend_bottom_toolbar(): + assert toolbar.display.is_reserved is False + with harness.app.suspend_bottom_toolbar(): + assert toolbar.display.is_reserved is False + # The inner context is done, the outer one is not: the guest still owns + # the terminal, so nothing may have been reinstalled here. + assert toolbar.display.is_reserved is False + assert toolbar.display.is_reserved is True + finally: + harness.close() + + def test_an_inner_suspension_paints_nothing(self) -> None: + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(), harness.app.suspend_bottom_toolbar(): + harness.clear() + with harness.app.suspend_bottom_toolbar(): + pass + written = harness.written() + assert "\x1b[1;23r" not in written + assert "STATUS" not in written + finally: + harness.close() + + def test_suspension_restores_the_rows_when_the_body_raises(self) -> None: + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(): + toolbar = harness.app.reserved_toolbar + assert toolbar is not None + with pytest.raises(ZeroDivisionError), harness.app.suspend_bottom_toolbar(): + raise ZeroDivisionError + assert toolbar.display.is_reserved is True + finally: + harness.close() + + def test_legacy_suspension_is_unchanged(self) -> None: + harness = Harness(mode="legacy") + try: + with harness.app._reserved_toolbar_context(), harness.app.suspend_bottom_toolbar(): + assert harness.app.reserved_toolbar is None + finally: + harness.close() + + +class TestSuspensionWithALiveDisplay: + """A pause that does not stop the display leaves two programs sharing the terminal.""" + + def test_quiescing_stops_the_command_display(self) -> None: + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(), harness.app._command_toolbar_context(): + display = harness.app._command_toolbar + assert display is not None + assert display.app.is_running is True + with harness.app._quiesce_bottom_toolbar(): + assert display.app.is_running is False + assert display.app.is_running is True + finally: + harness.close() + + def test_suspending_stops_the_command_display(self) -> None: + """The guest owns the terminal, so cmd2's input reader must not be reading it.""" + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(), harness.app._command_toolbar_context(): + display = harness.app._command_toolbar + assert display is not None + with harness.app.suspend_bottom_toolbar(): + assert display.app.is_running is False + assert display.app.is_running is True + finally: + harness.close() + + def test_output_is_serialized_again_after_a_suspension(self) -> None: + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(), harness.app._command_toolbar_context(): + display = harness.app._command_toolbar + assert display is not None + with harness.app.suspend_bottom_toolbar(): + assert all(stream.serializer is None for stream in display._streams) + assert all(stream.serializer is not None for stream in display._streams) + finally: + harness.close() + + +class TestHandoffRecovery: + def test_the_prompt_origin_is_forgotten_across_a_handoff(self) -> None: + """The guest moved the cursor; recovery would otherwise repaint over its output.""" + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(): + toolbar = harness.app.reserved_toolbar + assert toolbar is not None + bridge = toolbar.bridge + assert bridge is not None + bridge.set_prompt_anchor(7) + + with harness.app.suspend_bottom_toolbar(): + pass + + assert bridge.prompt_anchor is None + harness.clear() + with set_app(harness.app.main_session.app): + bridge.resynchronize() + assert "\x1b[7;1H" not in harness.written() + finally: + harness.close() + + def test_a_terminal_too_short_on_return_gives_the_toolbar_back(self) -> None: + """No region means no band to paint in, so the native toolbar has to render again.""" + harness = Harness(mode="reserved", rows=24) + try: + with harness.app._reserved_toolbar_context(): + toolbar = harness.app.reserved_toolbar + assert toolbar is not None + container = native_toolbar_container(harness.app.main_session) + assert container is not None + assert container.filter() is False + + with harness.app.suspend_bottom_toolbar(): + harness.size = Size(rows=2, columns=80) + + assert toolbar.display.is_reserved is False + assert toolbar.is_active is False + assert container.filter() is True + finally: + harness.close() + + def test_a_terminal_that_grows_back_takes_the_rows_again(self) -> None: + harness = Harness(mode="reserved", rows=24) + try: + with harness.app._reserved_toolbar_context(): + toolbar = harness.app.reserved_toolbar + assert toolbar is not None + with harness.app.suspend_bottom_toolbar(): + harness.size = Size(rows=2, columns=80) + assert toolbar.is_active is False + + harness.size = Size(rows=24, columns=80) + with harness.app.suspend_bottom_toolbar(): + pass + assert toolbar.is_active is True + finally: + harness.close() + + +class TestRefreshCadence: + """The band is repainted after each frame the terminal actually received. + + Driving a real render here would need a running event loop -- a prompt session loads its + history through one -- so the wiring is checked here and the behaviour it hangs on, that a + committed frame notifies and an uncommitted one does not, is covered against a real + renderer in the bridge's own tests. + """ + + def test_a_committed_frame_repaints_the_band(self) -> None: + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(): + toolbar = harness.app.reserved_toolbar + assert toolbar is not None + bridge = toolbar.bridge + assert bridge is not None + assert bridge._frame_committed_handler == toolbar.refresh + + harness.app.main_session.bottom_toolbar = "UPDATED" + harness.clear() + bridge._frame_committed_handler() + + painter = toolbar.painter + assert painter is not None + assert painter.last_frame is not None + row = "".join(cell.char for cell in painter.last_frame.rows[0]) + assert row.startswith("UPDATED") + assert "UPDATED" in harness.written() + finally: + harness.close() + + +class TestPromptSuspension: + """The main prompt is inside the reservation; other prompts are not, yet.""" + + def test_the_main_prompt_keeps_the_rows(self) -> None: + """The toolbar has to survive every ordinary command, prompt included.""" + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(): + toolbar = harness.app.reserved_toolbar + assert toolbar is not None + seen: list[bool] = [] + harness.app.main_session.prompt = lambda *a, **k: ( + seen.append( # type: ignore[method-assign] + toolbar.display.is_reserved + ) + or "" + ) + + harness.clear() + harness.app._read_raw_input("> ", harness.app.main_session) + + assert seen == [True] + assert "\x1b[r" not in harness.written() + finally: + harness.close() + + def test_the_main_prompt_leaves_the_native_toolbar_hidden(self) -> None: + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(): + container = native_toolbar_container(harness.app.main_session) + assert container is not None + seen: list[bool] = [] + harness.app.main_session.prompt = lambda *a, **k: ( + seen.append( # type: ignore[method-assign] + container.filter() + ) + or "" + ) + + harness.app._read_raw_input("> ", harness.app.main_session) + assert seen == [False] + finally: + harness.close() + + def test_another_session_still_gets_the_terminal_to_itself(self) -> None: + """A prompt cmd2 has not bound to the reservation renders outside it, for now.""" + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(): + toolbar = harness.app.reserved_toolbar + assert toolbar is not None + other = PromptSession(input=harness.pipe, output=harness.backend) + seen: list[bool] = [] + other.prompt = lambda *a, **k: seen.append(toolbar.display.is_reserved) or "" # type: ignore[method-assign] + + harness.app._read_raw_input("> ", other) + + assert seen == [False] + assert toolbar.display.is_reserved is True + finally: + harness.close() + + +class TestSurvivingDisplay: + def test_the_rows_are_not_released_for_a_terminal_we_do_not_own(self) -> None: + """A guest cannot be given rows back while another reader still holds the terminal.""" + harness = Harness(mode="reserved") + try: + with harness.app._reserved_toolbar_context(): + toolbar = harness.app.reserved_toolbar + assert toolbar is not None + + # Stand in for a display whose thread never stopped. + harness.app._display_holding_terminal = StuckDisplay() + + harness.clear() + with pytest.raises(RuntimeError, match="terminal"), harness.app.suspend_bottom_toolbar(): + pass + + assert toolbar.display.is_reserved is True + assert "\x1b[r" not in harness.written() + finally: + harness.close() + + +class StuckDisplay: + """A command display whose thread will not finish.""" + + thread_is_alive = True + + def complete_abandoned_shutdown(self) -> bool: + """Report that the teardown cannot be finished while the thread runs.""" + return False + + +class TestCommandLoop: + """The reservation's lifetime seen from the outside: a real cmdloop, start to finish.""" + + def run_loop(self, harness: Harness, *commands: str) -> None: + """Feed the loop some commands and let it exit.""" + harness.pipe.send_text("".join(f"{command}\n" for command in (*commands, "quit"))) + harness.app.cmdloop() + + def test_a_loop_installs_and_releases_the_reservation(self) -> None: + harness = Harness(mode="reserved") + try: + self.run_loop(harness) + written = harness.written() + assert "\x1b[1;23r" in written + assert written.rindex("\x1b[r") > written.index("\x1b[1;23r") + assert harness.app.reserved_toolbar is None + finally: + harness.close() + + def test_the_terminal_is_left_as_it_was_found(self) -> None: + """Margins reset, and the application rendering through its own backend again.""" + harness = Harness(mode="reserved") + try: + app = harness.app.main_session.app + backend = harness.backend + self.run_loop(harness) + assert app.output is backend + assert app.renderer.output is backend + assert harness.written().endswith("\x1b[r") or "\x1b[r" in harness.written() + finally: + harness.close() + + def test_a_second_loop_reserves_again(self) -> None: + """Repeated loops in one process: each takes the rows and gives them back.""" + harness = Harness(mode="reserved") + try: + self.run_loop(harness) + harness.clear() + self.run_loop(harness) + written = harness.written() + assert "\x1b[1;23r" in written + assert "\x1b[r" in written + assert harness.app.reserved_toolbar is None + finally: + harness.close() + + def test_a_failing_finalization_hook_keeps_the_reservation(self) -> None: + """Finalization runs at the end of every command; a broken hook is not a lost toolbar.""" + reserved_during: list[bool] = [] + + def failing_hook(data: CommandFinalizationData) -> CommandFinalizationData: + toolbar = harness.app.reserved_toolbar + reserved_during.append(toolbar is not None and toolbar.display.is_reserved) + raise RuntimeError("hook failed") + + harness = Harness(mode="reserved") + try: + harness.app.register_cmdfinalization_hook(failing_hook) + self.run_loop(harness, "help") + assert reserved_during + assert all(reserved_during) + finally: + harness.close() + + def test_a_legacy_loop_reserves_nothing(self) -> None: + harness = Harness(mode="legacy") + try: + self.run_loop(harness) + assert "\x1b[1;23r" not in harness.written() + finally: + harness.close() diff --git a/tests/test_reserved_output.py b/tests/test_reserved_output.py index 92a77447b..28dfa613b 100644 --- a/tests/test_reserved_output.py +++ b/tests/test_reserved_output.py @@ -248,7 +248,7 @@ def test_the_region_is_anchored_at_row_one(self) -> None: """The arithmetic above is only sound because of this.""" output, stream = make_output(rows=24) TerminalDisplay(output).acquire() - assert stream.getvalue() == "\x1b7\x1b[1;23r\x1b8" + assert stream.getvalue() == "\x1bD\x1b[1A\x1b7\x1b[1;23r\x1b8" def test_a_cursor_in_the_reserved_band_gives_a_nonpositive_height(self) -> None: """The R5 hazard, stated in geometry terms: rejecting it belongs to the bridge, but @@ -361,7 +361,7 @@ def test_entering_the_alternate_screen_restores_full_margins_first(self) -> None adapter, stream, display = make_reserved(rows=24) adapter.enter_alternate_screen() adapter.flush() - assert stream.getvalue().startswith("\x1b7\x1b[r\x1b8"), "margins were left installed" + assert stream.getvalue().startswith("\x1b7\x1b[24;1H\x1b[0m\x1b[J\x1b[r\x1b8"), "margins were left installed" assert not display.is_reserved def test_leaving_the_alternate_screen_re_establishes_the_region(self) -> None: diff --git a/tests/test_reserved_terminal.py b/tests/test_reserved_terminal.py new file mode 100644 index 000000000..a6ff6de97 --- /dev/null +++ b/tests/test_reserved_terminal.py @@ -0,0 +1,196 @@ +"""Reservation boundaries interpreted by a terminal, rather than a fixed row-one CPR stub.""" + +import io +import sys +import threading +from types import SimpleNamespace + +import pyte +import pytest + +from cmd2.reserved_toolbar import ReservedToolbar +from cmd2.utils import StdSim + +from .test_reserved_lifecycle import Harness + + +class TerminalScreen(pyte.HistoryScreen): + """Keep DECRC's physical position even when it is outside the scroll margins. + + pyte 0.8.2 unconditionally clamps a restored cursor to the margins. iTerm2 and + tmux retain the physical row when origin mode is off; that distinction is the + bug these tests exercise. Let pyte restore against the whole screen in that mode. + """ + + def restore_cursor(self) -> None: + margins = self.margins + if self.savepoints and not self.savepoints[-1].origin: + self.margins = None + try: + super().restore_cursor() + finally: + self.margins = margins + + +class EmulatedTerminal(io.StringIO): + """Parse output and answer CPR where the cursor actually is when the query arrives.""" + + def __init__(self, rows, reply) -> None: + super().__init__() + self.screen = TerminalScreen(80, rows, history=1000) + self.reports = [] + + def respond(data): + self.reports.append(self.screen.cursor.y + 1) + reply(data) + + self.screen.write_process_input = respond + self.parser = pyte.Stream(self.screen) + self.buffer = SimpleNamespace(write=lambda data: self.write(data.decode("utf-8"))) + + def write(self, data): + count = super().write(data) + # The tty's output processing supplies carriage returns to subprocess newlines. + self.parser.feed(data.replace("\n", "\r\n")) + return count + + def isatty(self) -> bool: + return True + + +@pytest.fixture +def terminal_harness(request): + rows = getattr(request, "param", 24) + harness = Harness(rows=rows) + stream = EmulatedTerminal(rows, harness.pipe.send_text) + harness.stream = stream + harness.backend.stdout = stream + harness.app.stdout = stream + try: + yield harness, stream + finally: + harness.close() + + +def read_prompt(harness, terminal, expected_toolbar="STATUS") -> None: + """Use the real input reader and CPR binding, then accept a prompt with known height.""" + ui = harness.app.main_session.app + sent = False + + def ready(app): + nonlocal sent + if not sent and app.renderer._min_available_height > 0: + sent = True + harness.pipe.send_text("next\n") + + ui.after_render += ready + watchdog = threading.Timer(5, harness.pipe.close) + watchdog.daemon = True + watchdog.start() + try: + assert harness.app._read_raw_input("TEST> ", harness.app.main_session) == "next" + assert sent + assert terminal.reports + assert all(row < terminal.screen.lines for row in terminal.reports) + assert terminal.screen.display[-1].startswith(expected_toolbar) + finally: + watchdog.cancel() + watchdog.join() + ui.after_render -= ready + + +@pytest.mark.parametrize( + ("content", "expected"), + [ + ("STATUS\nsecond line", "STATUS" + " " * 73 + "…"), + ("\nSTATUS", " " * 79 + "…"), + ("S" * 81, "S" * 79 + "…"), + ("S" * 78 + "广x", "S" * 78 + " …"), + ], +) +def test_clipped_toolbar_survives_commands_and_prompt_refresh(terminal_harness, content, expected) -> None: + harness, terminal = terminal_harness + harness.app.main_session.bottom_toolbar = content + with harness.app._reserved_toolbar_context(): + assert terminal.screen.display[-1] == expected + with harness.app._command_toolbar_context(): + harness.app.poutput("ordinary output") + assert terminal.screen.display[-1] == expected + read_prompt(harness, terminal, expected_toolbar=expected) + # A shorter dynamic replacement must remove the old text and indicator. + harness.app.main_session.bottom_toolbar = "OK" + assert harness.app.reserved_toolbar.refresh() + assert terminal.screen.display[-1] == "OK" + " " * 78 + assert terminal.screen.margins is None + assert terminal.screen.display[-1].strip() == "" + + +@pytest.mark.parametrize("row", [1, 22, 23, 24]) +@pytest.mark.parametrize("reserved", [1, 2]) +def test_acquisition_keeps_existing_output_and_cursor_above_the_band(terminal_harness, row, reserved) -> None: + harness, terminal = terminal_harness + terminal.write(f"\x1b[{row};1Hexisting output") + with ReservedToolbar(harness.app.main_session, lambda: "STATUS", reserved_rows=reserved): + expected_row = min(row, 24 - reserved) + assert terminal.screen.cursor.y + 1 == expected_row + assert terminal.screen.cursor.x == len("existing output") + assert terminal.screen.display[expected_row - 1].startswith("existing output") + assert terminal.screen.display[24 - reserved].startswith("STATUS") + assert terminal.screen.margins is None + + +@pytest.mark.parametrize("terminal_harness", [12, 24, 40], indirect=True) +def test_a_prompt_started_on_the_bottom_row_gets_a_usable_cursor_report(terminal_harness, capsys) -> None: + harness, terminal = terminal_harness + terminal.write(f"\x1b[{terminal.screen.lines};1H") + with harness.app._reserved_toolbar_context(): + # Fail before trying input if startup has left the cursor in the band. + assert terminal.screen.cursor.y < terminal.screen.lines - 1 + read_prompt(harness, terminal) + assert "doesn't support cursor position requests" not in capsys.readouterr().err + assert terminal.screen.margins is None + + +@pytest.mark.parametrize("terminal_harness", [12, 24, 40], indirect=True) +def test_shell_output_scrolling_to_the_bottom_returns_a_working_prompt(terminal_harness, capfd) -> None: + harness, terminal = terminal_harness + # Exercise a real shell subprocess and ProcReader, echoing its captured bytes to + # the same terminal as the toolbar. This supplies a real pipe rather than fileno() + # on the in-memory terminal. + harness.app.stdout = StdSim(terminal, echo=True) + with harness.app._reserved_toolbar_context(): + command = f"!\"{sys.executable}\" -S -c \"print('out\\n' * 80, end='')\"" + harness.app.onecmd_plus_hooks(command) + assert harness.app.last_result == 0 + assert terminal.screen.cursor.y == terminal.screen.lines - 2 + assert terminal.screen.display[-3].rstrip() == "out" + assert terminal.screen.display[-1].startswith("STATUS") + # Every emitted line must remain in the viewport or scrollback exactly once. + history = ["".join(line[x].data for x in sorted(line)) for line in terminal.screen.history.top] + assert sum(line.rstrip() == "out" for line in history + terminal.screen.display) == 80 + read_prompt(harness, terminal) + assert "doesn't support cursor position requests" not in capfd.readouterr().err + assert terminal.screen.margins is None + + +def test_release_does_not_erase_rows_from_a_changed_viewport(terminal_harness) -> None: + harness, terminal = terminal_harness + with harness.app._reserved_toolbar_context(): + # The reserved row was cropped by a resize; the new bottom row is application + # output. CUP to the old row would clamp there and erase somebody else's text. + terminal.screen.resize(lines=12, columns=80) + harness.size = type(harness.size)(rows=12, columns=80) + terminal.write("\x1b[12;1Hkeep this output") + assert terminal.screen.display[-1].startswith("keep this output") + assert terminal.screen.margins is None + + +def test_release_still_resets_margins_when_geometry_cannot_be_measured(terminal_harness, monkeypatch) -> None: + harness, terminal = terminal_harness + with harness.app._reserved_toolbar_context(): + + def unavailable(): + raise OSError("terminal size unavailable") + + monkeypatch.setattr(harness.backend, "get_size", unavailable) + assert terminal.screen.margins is None diff --git a/tests/test_reserved_toolbar.py b/tests/test_reserved_toolbar.py new file mode 100644 index 000000000..23d5d9b02 --- /dev/null +++ b/tests/test_reserved_toolbar.py @@ -0,0 +1,776 @@ +"""Tests for owning the reservation across one command loop. + +Binding is the part that has to be exactly right. The renderer keeps its own reference to the +output it was built with, so changing only ``Application.output`` leaves the renderer drawing +through the unwrapped backend -- over the reserved row. Restoration has to be just as exact: +a backend left wrapped after the loop ends would report a short terminal to whatever runs next. +""" + +import io +from typing import Any + +import pytest +from prompt_toolkit.data_structures import Size +from prompt_toolkit.filters import Condition +from prompt_toolkit.input import create_pipe_input +from prompt_toolkit.layout import HSplit, Layout, Window +from prompt_toolkit.output.vt100 import Vt100_Output +from prompt_toolkit.shortcuts import PromptSession + +from cmd2.reserved_output import ReservedOutput +from cmd2.reserved_toolbar import ReservedToolbar, native_toolbar_container +from cmd2.toolbar_painter import ToolbarPainter + + +class TtyStringIO(io.StringIO): + """A stream that claims to be a terminal, as the backend requires.""" + + def isatty(self) -> bool: + return True + + +class Harness: + """A prompt session over a terminal whose size the test controls.""" + + def __init__(self, rows: int = 24, columns: int = 80, toolbar: Any = "STATUS") -> None: + self.stream = TtyStringIO() + self.size = Size(rows=rows, columns=columns) + self.backend = Vt100_Output(self.stream, lambda: self.size) + self._pipe = create_pipe_input() + self.pipe = self._pipe.__enter__() + self.session: PromptSession[str] = PromptSession(input=self.pipe, output=self.backend, bottom_toolbar="STATUS") + self.toolbar = ReservedToolbar(self.session, lambda: self.session.bottom_toolbar) + self.clear() + + def close(self) -> None: + """Release the pipe input.""" + self._pipe.__exit__(None, None, None) + + def clear(self) -> None: + """Discard everything written so far.""" + self.stream.truncate(0) + self.stream.seek(0) + + def written(self) -> str: + """Everything written since the last clear.""" + return self.stream.getvalue() + + @property + def app(self) -> Any: + """The session's application.""" + return self.session.app + + +class TestBinding: + def test_starting_binds_the_application_and_its_renderer(self) -> None: + harness = Harness() + try: + assert harness.toolbar.start() is True + adapter = harness.toolbar.display.output + assert isinstance(adapter, ReservedOutput) + assert harness.app.output is adapter + assert harness.app.renderer.output is adapter + finally: + harness.close() + + def test_the_renderer_is_bound_as_well_as_the_application(self) -> None: + """The renderer holds its own reference; binding only the application misses it.""" + harness = Harness() + try: + harness.toolbar.start() + assert harness.app.renderer.output is harness.app.output + finally: + harness.close() + + def test_the_application_sees_the_usable_height(self) -> None: + harness = Harness(rows=24) + try: + harness.toolbar.start() + assert harness.app.output.get_size() == Size(rows=23, columns=80) + finally: + harness.close() + + def test_the_region_is_installed_before_anything_renders(self) -> None: + harness = Harness(rows=24) + try: + harness.toolbar.start() + assert "\x1b[1;23r" in harness.written() + finally: + harness.close() + + def test_stopping_restores_the_original_objects(self) -> None: + harness = Harness() + try: + harness.toolbar.start() + harness.toolbar.stop() + assert harness.app.output is harness.backend + assert harness.app.renderer.output is harness.backend + finally: + harness.close() + + def test_stopping_releases_the_region(self) -> None: + harness = Harness() + try: + harness.toolbar.start() + harness.clear() + harness.toolbar.stop() + assert "\x1b[r" in harness.written() + finally: + harness.close() + + def test_stopping_twice_does_nothing_the_second_time(self) -> None: + """Teardown runs from more than one place; it has to be safe to repeat.""" + harness = Harness() + try: + harness.toolbar.start() + harness.toolbar.stop() + harness.clear() + harness.toolbar.stop() + assert harness.written() == "" + finally: + harness.close() + + def test_a_terminal_too_short_to_reserve_binds_nothing(self) -> None: + """Below the two-row floor there is no reservation, so nothing should be wrapped.""" + harness = Harness(rows=2) + try: + assert harness.toolbar.start() is False + assert harness.app.output is harness.backend + assert harness.app.renderer.output is harness.backend + assert harness.toolbar.is_active is False + finally: + harness.close() + + def test_the_context_manager_restores_when_the_body_raises(self) -> None: + harness = Harness() + try: + with pytest.raises(ZeroDivisionError), harness.toolbar: + raise ZeroDivisionError + assert harness.app.output is harness.backend + assert harness.toolbar.is_active is False + finally: + harness.close() + + +class TestComponents: + def test_the_bridge_and_painter_exist_while_active(self) -> None: + harness = Harness() + try: + harness.toolbar.start() + assert harness.toolbar.bridge is not None + assert harness.toolbar.painter is not None + assert harness.toolbar.is_active is True + finally: + harness.close() + + def test_they_are_gone_once_stopped(self) -> None: + harness = Harness() + try: + harness.toolbar.start() + harness.toolbar.stop() + assert harness.toolbar.bridge is None + assert harness.toolbar.painter is None + finally: + harness.close() + + def test_the_bridge_is_bound_to_the_application_renderer(self) -> None: + harness = Harness() + try: + harness.toolbar.start() + assert harness.toolbar.bridge is not None + assert harness.toolbar.bridge._renderer is harness.app.renderer + finally: + harness.close() + + def test_the_painter_reads_the_content_provider_dynamically(self) -> None: + """Assigning a new ``bottom_toolbar`` must reach the band without a restart.""" + harness = Harness() + try: + harness.toolbar.start() + harness.session.bottom_toolbar = "CHANGED" + painter = harness.toolbar.painter + assert painter is not None + prepared = painter.prepare(harness.toolbar.content) + assert prepared is not None + assert "CHANGED" in "".join(cell.char for cell in prepared.frame.rows[0]) + finally: + harness.close() + + def test_one_lock_is_shared_by_the_bridge_and_the_painter(self) -> None: + """Two locks would serialize each writer against itself and neither against the other.""" + harness = Harness() + try: + harness.toolbar.start() + assert harness.toolbar.painter._lock is harness.toolbar.bridge._lock + finally: + harness.close() + + def test_asking_for_the_display_before_starting_says_so(self) -> None: + harness = Harness() + try: + with pytest.raises(RuntimeError, match="not started"): + _ = harness.toolbar.display + finally: + harness.close() + + def test_the_shared_lock_is_reachable(self) -> None: + harness = Harness() + try: + harness.toolbar.start() + assert harness.toolbar.lock is harness.toolbar.painter._lock + finally: + harness.close() + + def test_starting_twice_keeps_the_first_reservation(self) -> None: + """A second start must not stack a lease the single stop would not release.""" + harness = Harness() + try: + harness.toolbar.start() + display = harness.toolbar.display + assert harness.toolbar.start() is True + assert harness.toolbar.display is display + harness.toolbar.stop() + assert harness.app.output is harness.backend + finally: + harness.close() + + +class TestNativeToolbarSuppression: + def test_the_native_toolbar_window_is_hidden_while_reserved(self) -> None: + """Two toolbars would be drawn otherwise: the native one and the painted band.""" + harness = Harness() + try: + container = native_toolbar_container(harness.session) + assert container is not None + assert container.filter() is True + harness.toolbar.start() + assert container.filter() is False + finally: + harness.close() + + def test_the_original_filter_is_restored(self) -> None: + harness = Harness() + try: + container = native_toolbar_container(harness.session) + assert container is not None + original = container.filter + harness.toolbar.start() + harness.toolbar.stop() + assert container.filter is original + assert container.filter() is True + finally: + harness.close() + + def test_the_window_reappears_even_if_the_filter_is_never_restored(self) -> None: + """The suppression asks whether the toolbar is active rather than latching a False.""" + harness = Harness() + try: + container = native_toolbar_container(harness.session) + assert container is not None + harness.toolbar.start() + suppressed = container.filter + harness.toolbar.stop() + assert suppressed() is True + finally: + harness.close() + + def test_the_content_provider_is_left_alone(self) -> None: + """Callers read this attribute to mean 'a toolbar is configured'.""" + harness = Harness() + try: + harness.toolbar.start() + assert harness.session.bottom_toolbar == "STATUS" + finally: + harness.close() + + def test_an_unrecognized_layout_is_refused_explicitly(self) -> None: + harness = Harness() + try: + harness.session.app.layout = Layout(Window()) + with pytest.raises(RuntimeError, match="bottom toolbar"): + harness.toolbar.start() + finally: + harness.close() + + def test_an_unrecognized_layout_has_no_container_to_find(self) -> None: + harness = Harness() + try: + harness.session.app.layout = Layout(Window()) + assert native_toolbar_container(harness.session) is None + finally: + harness.close() + + def test_a_layout_whose_last_child_is_not_the_toolbar_has_no_container(self) -> None: + """The shape check is about the toolbar window, not merely about the root's type.""" + harness = Harness() + try: + harness.session.app.layout = Layout(HSplit([Window(), Window()])) + assert native_toolbar_container(harness.session) is None + finally: + harness.close() + + +class TestFirstPaint: + def test_the_band_is_painted_when_the_reservation_starts(self) -> None: + """The toolbar has to be there from the first prompt, not from the first refresh.""" + harness = Harness() + try: + harness.toolbar.start() + assert "\x1b[24;1H" in harness.written() + assert "STATUS" in harness.written() + finally: + harness.close() + + def test_refreshing_repaints_changed_content(self) -> None: + harness = Harness() + try: + harness.toolbar.start() + harness.session.bottom_toolbar = "CHANGED" + harness.clear() + assert harness.toolbar.refresh() is True + # Only the cells that differ from "STATUS" are rewritten -- the shared "A" is + # left alone -- so the band is checked through the frame the painter published + # rather than by looking for the whole string in the stream. + painter = harness.toolbar.painter + assert painter is not None + assert painter.last_frame is not None + assert "".join(cell.char for cell in painter.last_frame.rows[0]).startswith("CHANGED") + assert "\x1b[24;" in harness.written() + finally: + harness.close() + + def test_refreshing_unchanged_content_writes_nothing(self) -> None: + harness = Harness() + try: + harness.toolbar.start() + harness.clear() + assert harness.toolbar.refresh() is False + assert harness.written() == "" + finally: + harness.close() + + def test_a_failing_content_callback_paints_nothing(self) -> None: + """The painter keeps the last good frame; the refresh simply reports it wrote nothing.""" + + def boom() -> str: + raise RuntimeError("callback failed") + + harness = Harness() + try: + harness.toolbar.start() + harness.toolbar.content = boom + harness.clear() + assert harness.toolbar.refresh() is False + assert harness.written() == "" + finally: + harness.close() + + def test_refreshing_while_stopped_does_nothing(self) -> None: + harness = Harness() + try: + assert harness.toolbar.refresh() is False + finally: + harness.close() + + +class TestStartupFailure: + def test_a_failed_first_paint_leaves_no_reservation_behind(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Review finding: __enter__ raising means __exit__ never runs, so start must roll back.""" + + def boom(self: Any, prepared: Any) -> bool: + raise OSError("terminal went away") + + harness = Harness() + try: + monkeypatch.setattr(ToolbarPainter, "paint", boom) + harness.clear() + with pytest.raises(OSError, match="terminal went away"): + harness.toolbar.start() + + assert harness.toolbar.is_active is False + assert harness.app.output is harness.backend + assert harness.app.renderer.output is harness.backend + assert "\x1b[r" in harness.written() + finally: + harness.close() + + def test_a_failed_start_restores_the_native_toolbar(self, monkeypatch: pytest.MonkeyPatch) -> None: + def boom(self: Any, prepared: Any) -> bool: + raise OSError("terminal went away") + + harness = Harness() + try: + container = native_toolbar_container(harness.session) + assert container is not None + original = container.filter + monkeypatch.setattr(ToolbarPainter, "paint", boom) + with pytest.raises(OSError, match="terminal went away"): + harness.toolbar.start() + assert container.filter is original + finally: + harness.close() + + def test_a_failed_start_can_be_retried(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Rollback has to leave the object usable, not merely leave the terminal clean.""" + failures = {"count": 1} + real_paint = ToolbarPainter.paint + + def sometimes(self: Any, prepared: Any) -> bool: + if failures["count"]: + failures["count"] -= 1 + raise OSError("terminal went away") + return bool(real_paint(self, prepared)) + + harness = Harness() + try: + monkeypatch.setattr(ToolbarPainter, "paint", sometimes) + with pytest.raises(OSError, match="terminal went away"): + harness.toolbar.start() + assert harness.toolbar.is_active is False + assert harness.toolbar.start() is True + assert harness.toolbar.is_active is True + finally: + harness.close() + + +class TestRestorationOwnership: + def test_the_renderer_gets_its_own_original_output_back(self) -> None: + """Review finding: the renderer's output need not be the application's.""" + harness = Harness() + try: + other = Vt100_Output(TtyStringIO(), lambda: Size(rows=24, columns=80)) + harness.app.renderer.output = other + + harness.toolbar.start() + harness.toolbar.stop() + + assert harness.app.output is harness.backend + assert harness.app.renderer.output is other + finally: + harness.close() + + def test_a_filter_installed_while_reserved_survives_teardown(self) -> None: + """Review finding: restoring unconditionally discards whatever replaced ours.""" + harness = Harness() + try: + container = native_toolbar_container(harness.session) + assert container is not None + harness.toolbar.start() + + replacement = Condition(lambda: True) + container.filter = replacement + harness.toolbar.stop() + + assert container.filter is replacement + finally: + harness.close() + + def test_the_filter_is_restored_when_it_is_still_ours(self) -> None: + harness = Harness() + try: + container = native_toolbar_container(harness.session) + assert container is not None + original = container.filter + harness.toolbar.start() + harness.toolbar.stop() + assert container.filter is original + finally: + harness.close() + + +class PartialWriteStream(TtyStringIO): + """Writes a prefix of chosen flushed batches and then fails, as a real terminal can.""" + + def __init__(self, *, keep: int = 12) -> None: + super().__init__() + self._armed = False + self._keep = keep + + def fail_next(self) -> None: + """Fail the next flushed batch, whenever it comes. + + Counting batches is brittle -- cleanup after a failure emits one of its own -- so + tests that care about *which* paint fails arm it directly instead. + """ + self._armed = True + + def write(self, text: str) -> int: + if self._armed: + self._armed = False + super().write(text[: self._keep]) + raise OSError("terminal went away") + return super().write(text) + + +class TestPartialStartupPaint: + def test_a_half_written_first_paint_leaves_no_state_behind(self) -> None: + """Review finding: rollback released the margins over a cursor still in the band.""" + harness = Harness() + try: + harness.stream = PartialWriteStream() + harness.backend.stdout = harness.stream + + def first_paint(): + # Arm after acquisition, when the painter evaluates its content. Counting + # startup flushes would fail the wrong operation when acquisition changes. + harness.stream.fail_next() + return "TOOLBAR" + + harness.session.bottom_toolbar = first_paint + with pytest.raises(OSError, match="terminal went away"): + harness.toolbar.start() + + written = harness.stream.getvalue() + # Wrap mode and cursor are put back before the margins are released, so the + # release does not save a cursor that is still inside the band. + assert written.index("\x1b[?7h") < written.index("\x1b[r") + assert harness.toolbar.is_active is False + assert harness.app.output is harness.backend + finally: + harness.close() + + +class TestRefreshFailure: + def make(self) -> Harness: + """Build a toolbar over a terminal whose next write can be failed explicitly.""" + harness = Harness() + harness.stream = PartialWriteStream() + harness.backend.stdout = harness.stream + return harness + + def test_a_failed_paint_does_not_take_the_command_down(self) -> None: + """The toolbar is cosmetic; the command that was running is not its to interrupt.""" + harness = self.make() + try: + harness.toolbar.start() + harness.stream.fail_next() + harness.session.bottom_toolbar = "CHANGED" + assert harness.toolbar.refresh() is False + finally: + harness.close() + + def test_a_failed_paint_makes_the_bridge_resynchronize(self) -> None: + """Buffering the cursor save does not prove the terminal received it.""" + harness = self.make() + try: + harness.toolbar.start() + harness.stream.fail_next() + bridge = harness.toolbar.bridge + assert bridge is not None + harness.session.bottom_toolbar = "CHANGED" + harness.toolbar.refresh() + assert bridge.needs_resynchronization is True + assert "cursor" in (bridge.resynchronization_reason or "") + finally: + harness.close() + + def test_the_failure_is_reported_once(self) -> None: + harness = self.make() + try: + harness.toolbar.start() + harness.stream.fail_next() + harness.session.bottom_toolbar = "CHANGED" + harness.toolbar.refresh() + assert isinstance(harness.toolbar.take_pending_error(), OSError) + assert harness.toolbar.take_pending_error() is None + finally: + harness.close() + + def test_a_terminal_that_keeps_failing_gives_the_rows_back(self) -> None: + """A terminal that fails twice is not coming back; legacy rendering is the fallback.""" + harness = Harness() + try: + harness.toolbar.start() + container = native_toolbar_container(harness.session) + assert container is not None + + harness.stream = AlwaysFailingStream() + harness.backend.stdout = harness.stream + harness.session.bottom_toolbar = "ONE" + harness.toolbar.refresh() + assert harness.toolbar.is_active is True + + harness.session.bottom_toolbar = "TWO" + harness.toolbar.refresh() + assert harness.toolbar.is_active is False + # The native toolbar renders again, which is what "fall back" means here. + assert container.filter() is True + finally: + harness.close() + + def test_a_successful_paint_forgets_earlier_failures(self) -> None: + """Only *consecutive* failures mean the terminal is gone: fail, recover, fail again.""" + harness = self.make() + try: + harness.toolbar.start() + harness.session.bottom_toolbar = "ONE" + harness.stream.fail_next() + assert harness.toolbar.refresh() is False + harness.toolbar.take_pending_error() + + harness.session.bottom_toolbar = "TWO" + assert harness.toolbar.refresh() is True + + harness.session.bottom_toolbar = "THREE" + harness.stream.fail_next() + assert harness.toolbar.refresh() is False + # The success in between reset the count, so this is failure one again. + assert harness.toolbar.is_active is True + finally: + harness.close() + + def test_nothing_is_pending_when_nothing_failed(self) -> None: + harness = Harness() + try: + harness.toolbar.start() + assert harness.toolbar.take_pending_error() is None + finally: + harness.close() + + def test_an_unreported_content_error_survives_teardown(self) -> None: + """The painter is dropped on stop; an error it still held would go with it.""" + + def boom() -> str: + raise RuntimeError("callback failed") + + harness = Harness() + try: + harness.toolbar.start() + harness.toolbar.content = boom + harness.toolbar.refresh() + harness.toolbar.stop() + assert isinstance(harness.toolbar.take_pending_error(), RuntimeError) + finally: + harness.close() + + def test_a_failing_content_callback_is_reported_too(self) -> None: + """The painter keeps the last frame; the error still has to reach the user once.""" + + def boom() -> str: + raise RuntimeError("callback failed") + + harness = Harness() + try: + harness.toolbar.start() + harness.toolbar.content = boom + assert harness.toolbar.refresh() is False + assert isinstance(harness.toolbar.take_pending_error(), RuntimeError) + finally: + harness.close() + + +class AlwaysFailingStream(TtyStringIO): + """A terminal that has gone away.""" + + def write(self, text: str) -> int: + raise OSError("terminal went away") + + +class TestRenderBinding: + def test_starting_routes_the_application_renders_through_the_bridge(self) -> None: + harness = Harness() + try: + original = harness.app.renderer.render + harness.toolbar.start() + assert harness.app.renderer.render is not original + finally: + harness.close() + + def test_stopping_gives_the_renderer_its_methods_back(self) -> None: + harness = Harness() + try: + original = harness.app.renderer.render + harness.toolbar.start() + harness.toolbar.stop() + assert harness.app.renderer.render == original + finally: + harness.close() + + def test_a_failed_start_unbinds_as_well(self, monkeypatch: pytest.MonkeyPatch) -> None: + def boom(self: Any, prepared: Any) -> bool: + raise OSError("terminal went away") + + harness = Harness() + try: + original = harness.app.renderer.render + monkeypatch.setattr(ToolbarPainter, "paint", boom) + with pytest.raises(OSError, match="terminal went away"): + harness.toolbar.start() + assert harness.app.renderer.render == original + finally: + harness.close() + + def test_the_redraw_scheduler_asks_the_application(self) -> None: + """A frame that could not be committed has to come back, and the app owns that.""" + harness = Harness() + try: + harness.toolbar.start() + bridge = harness.toolbar.bridge + assert bridge is not None + assert bridge._redraw_scheduler == harness.app.invalidate + finally: + harness.close() + + +class TestAbandonedEmission: + def test_the_owner_releases_when_the_bridge_gives_up(self) -> None: + """Rendering cannot resume until the rows are back and the bridge is unbound.""" + harness = Harness() + try: + original_render = harness.app.renderer.render + harness.toolbar.start() + bridge = harness.toolbar.bridge + assert bridge is not None + harness.clear() + + bridge.stop_reserved_emission(OSError("terminal went away")) + + assert harness.toolbar.is_active is False + assert "\x1b[r" in harness.written() + assert harness.app.renderer.render == original_render + assert harness.app.output is harness.backend + finally: + harness.close() + + def test_the_failure_is_still_reported(self) -> None: + harness = Harness() + try: + harness.toolbar.start() + bridge = harness.toolbar.bridge + assert bridge is not None + bridge.stop_reserved_emission(OSError("terminal went away")) + assert isinstance(harness.toolbar.take_pending_error(), OSError) + finally: + harness.close() + + def test_a_failed_cleanup_releases_the_rows(self) -> None: + """The path that truly abandons emission has to reach the owner like any other. + + Driven at the cleanup itself: a real partial commit needs a running event loop to + render a prompt session, and what is under test here is the wiring from "cleanup + failed" to "the owner gave the rows back", not how the commit got there. + """ + harness = Harness() + try: + original_render = harness.app.renderer.render + harness.toolbar.start() + bridge = harness.toolbar.bridge + assert bridge is not None + + harness.backend.stdout = AlwaysFailingStream() + assert bridge._attempt_cleanup() is False + + assert bridge.reserved_emission_stopped is True + assert harness.toolbar.is_active is False + assert harness.app.renderer.render == original_render + finally: + harness.close() + + def test_suspending_a_toolbar_that_never_started_does_nothing(self) -> None: + harness = Harness() + try: + with harness.toolbar.suspended(): + pass + assert harness.written() == "" + finally: + harness.close() diff --git a/tests/test_terminal_display.py b/tests/test_terminal_display.py index 8a6f94836..43b116136 100644 --- a/tests/test_terminal_display.py +++ b/tests/test_terminal_display.py @@ -148,7 +148,7 @@ def test_acquiring_installs_the_region_and_flushes_it(self) -> None: output, stream = make_output(rows=24) display = TerminalDisplay(output) assert display.acquire() - assert stream.getvalue() == "\x1b7\x1b[1;23r\x1b8" + assert stream.getvalue() == "\x1bD\x1b[1A\x1b7\x1b[1;23r\x1b8" assert not output._buffer def test_releasing_restores_full_screen_margins_immediately(self) -> None: @@ -158,7 +158,7 @@ def test_releasing_restores_full_screen_margins_immediately(self) -> None: display.acquire() stream.truncate(0), stream.seek(0) display.release() - assert stream.getvalue() == "\x1b7\x1b[r\x1b8" + assert stream.getvalue() == "\x1b7\x1b[24;1H\x1b[0m\x1b[J\x1b[r\x1b8" assert not output._buffer def test_the_region_is_reset_when_the_body_raises(self) -> None: @@ -170,7 +170,7 @@ def blow_up() -> None: with pytest.raises(RuntimeError, match="boom"), TerminalDisplay(output): blow_up() - assert stream.getvalue() == "\x1b7\x1b[r\x1b8" + assert stream.getvalue() == "\x1b7\x1b[24;1H\x1b[0m\x1b[J\x1b[r\x1b8" def test_release_is_idempotent(self) -> None: """Cleanup paths call this freely; a second reset would move the cursor again.""" @@ -213,7 +213,7 @@ def test_a_fresh_size_is_read_at_every_activation(self) -> None: screen.rows = 40 stream.truncate(0), stream.seek(0) display.acquire() - assert stream.getvalue() == "\x1b7\x1b[1;39r\x1b8" + assert stream.getvalue() == "\x1bD\x1b[1A\x1b7\x1b[1;39r\x1b8" assert display.geometry is not None assert display.geometry.usable_rows == 39 @@ -323,7 +323,7 @@ def test_entering_the_alternate_screen_restores_full_margins(self) -> None: display.acquire() stream.truncate(0), stream.seek(0) display.release_region_for_handoff() - assert stream.getvalue() == "\x1b7\x1b[r\x1b8" + assert stream.getvalue() == "\x1b7\x1b[24;1H\x1b[0m\x1b[J\x1b[r\x1b8" assert not display.is_reserved def test_the_lease_survives_a_handoff(self) -> None: @@ -344,7 +344,7 @@ def test_returning_measures_afresh_rather_than_restoring_the_old_snapshot(self) screen.rows = 40 stream.truncate(0), stream.seek(0) display.reacquire_region_after_handoff() - assert stream.getvalue() == "\x1b7\x1b[1;39r\x1b8" + assert stream.getvalue() == "\x1bD\x1b[1A\x1b7\x1b[1;39r\x1b8" assert display.geometry is not None assert display.geometry.usable_rows == 39 @@ -536,7 +536,7 @@ def test_a_resize_during_a_handoff_is_applied_when_the_terminal_comes_back(self) stream.truncate(0), stream.seek(0) display.reacquire_region_after_handoff() - assert stream.getvalue() == "\x1b7\x1b[1;39r\x1b8" + assert stream.getvalue() == "\x1bD\x1b[1A\x1b7\x1b[1;39r\x1b8" assert display.geometry is not None assert display.geometry.usable_rows == 39 @@ -597,7 +597,7 @@ def sometimes_fails(sequence: str) -> None: stream.truncate(0), stream.seek(0) assert display.acquire() assert display.lease_depth == 1 - assert stream.getvalue() == "\x1b7\x1b[1;23r\x1b8" + assert stream.getvalue() == "\x1bD\x1b[1A\x1b7\x1b[1;23r\x1b8" def test_a_measurement_failure_returns_the_lease_too(self) -> None: """Measuring sits inside the rollback: an ioctl that fails must not strand a lease.""" diff --git a/tests/test_terminal_transaction.py b/tests/test_terminal_transaction.py index cc17ef496..099013e08 100644 --- a/tests/test_terminal_transaction.py +++ b/tests/test_terminal_transaction.py @@ -11,7 +11,7 @@ import queue import threading import time -from concurrent.futures import Future +from concurrent.futures import Future, ThreadPoolExecutor from typing import Any, Self import pytest @@ -26,6 +26,8 @@ held_higher_level_locks, ) +from .conftest import ContendedLock + class Sentinel: """A stand-in for a blocking primitive that records being entered instead of blocking.""" @@ -278,32 +280,24 @@ def worker() -> None: class TestSerialization: def test_two_threads_never_hold_the_terminal_at_once(self) -> None: - """The lock is what serializes emission; without it the two bodies meet. - - The overlap is detected with a barrier rather than a sleep. A sleep would only make - an overlap *likely* to be observed; a barrier that both threads must reach inside the - transaction can only be satisfied if they are genuinely inside it together. - """ - terminal = TerminalLock() - both_inside = threading.Barrier(2, timeout=0.2) - start = threading.Barrier(2, timeout=5) - overlaps: list[int] = [] + """A contender must wait until the owning transaction finishes.""" + observed = ContendedLock() + terminal = TerminalLock(lock=observed) + order = [] def emit() -> None: - start.wait() with terminal.transaction("paint"): - try: - both_inside.wait() - except threading.BrokenBarrierError: - return - overlaps.append(1) - - threads = [threading.Thread(target=emit) for _ in range(2)] - for thread in threads: - thread.start() - for thread in threads: - thread.join(timeout=5) - assert overlaps == [] + order.append("contender") + + with ThreadPoolExecutor(max_workers=1) as pool: + with terminal.transaction("owner"): + pending = pool.submit(emit) + assert observed.contended.wait(5), "contender bypassed the terminal lock" + assert order == [] + assert not pending.done() + order.append("owner") + pending.result(timeout=5) + assert order == ["owner", "contender"] class TestDiagnostics: diff --git a/tests/test_toolbar_mode.py b/tests/test_toolbar_mode.py new file mode 100644 index 000000000..6911e7366 --- /dev/null +++ b/tests/test_toolbar_mode.py @@ -0,0 +1,179 @@ +"""Tests for choosing between reserved and legacy toolbar rendering. + +Selection is deliberately conservative and deliberately loud. ``auto`` falls back to legacy +rendering for anything it has not qualified, because a wrong guess corrupts the user's screen +rather than merely rendering poorly; ``reserved`` refuses to start rather than silently giving +the caller the legacy behaviour they asked it not to use. +""" + +import io + +import pytest +from prompt_toolkit.data_structures import Size +from prompt_toolkit.output import DummyOutput +from prompt_toolkit.output.vt100 import Vt100_Output + +import cmd2 +from cmd2.toolbar_mode import ( + QUALIFIED_PROMPT_TOOLKIT_VERSIONS, + ToolbarMode, + _dependency_capability, + _select_toolbar_mode, + _validate_toolbar_mode, +) + + +def qualified_output() -> Vt100_Output: + """Build a backend the reservation is qualified for.""" + return Vt100_Output(io.StringIO(), lambda: Size(rows=24, columns=80)) + + +class TestValidation: + @pytest.mark.parametrize("mode", list(ToolbarMode)) + def test_every_mode_is_accepted(self, mode: ToolbarMode) -> None: + assert _validate_toolbar_mode(mode) is mode + + @pytest.mark.parametrize("mode", [member.value for member in ToolbarMode]) + def test_the_plain_string_is_accepted_too(self, mode: str) -> None: + """The members are strings, so a caller who writes one gets the member back.""" + assert _validate_toolbar_mode(mode) is ToolbarMode(mode) + + def test_an_unknown_mode_names_the_ones_that_exist(self) -> None: + with pytest.raises(ValueError, match=r"auto.*legacy.*reserved"): + _validate_toolbar_mode("pinned") + + def test_the_available_modes(self) -> None: + assert {member.value for member in ToolbarMode} == {"off", "auto", "reserved", "legacy"} + + def test_a_mode_is_its_own_string(self) -> None: + """Nothing that compared these to strings before has to change.""" + assert ToolbarMode.RESERVED == "reserved" + + +class TestDependencyQualification: + def test_the_installed_prompt_toolkit_is_the_qualified_one(self) -> None: + """A dependency upgrade must fail here rather than quietly rendering differently.""" + supported, reason = _dependency_capability() + assert supported is True, reason + + def test_only_exactly_qualified_versions_count(self) -> None: + """The package requirement stays a range; qualification does not follow it.""" + assert frozenset({"3.0.53"}) == QUALIFIED_PROMPT_TOOLKIT_VERSIONS + + def test_an_unqualified_version_is_reported_with_its_number(self) -> None: + supported, reason = _dependency_capability("3.0.99") + assert supported is False + assert "3.0.99" in reason + + +class TestAutomaticSelection: + def test_a_qualified_terminal_selects_reserved(self) -> None: + mode, reason = _select_toolbar_mode("auto", qualified_output(), toolbar_enabled=True, interactive=True) + assert mode is ToolbarMode.RESERVED + assert reason == "" + + def test_an_unqualified_backend_falls_back(self) -> None: + mode, reason = _select_toolbar_mode("auto", DummyOutput(), toolbar_enabled=True, interactive=True) + assert mode is ToolbarMode.LEGACY + assert "dummy output" in reason + + def test_an_unqualified_dependency_falls_back(self) -> None: + mode, reason = _select_toolbar_mode( + "auto", qualified_output(), toolbar_enabled=True, interactive=True, version="3.0.99" + ) + assert mode is ToolbarMode.LEGACY + assert "3.0.99" in reason + + def test_a_disabled_toolbar_falls_back(self) -> None: + """With no toolbar there is nothing to reserve a row for.""" + mode, reason = _select_toolbar_mode("auto", qualified_output(), toolbar_enabled=False, interactive=True) + assert mode is ToolbarMode.LEGACY + assert "toolbar" in reason + + def test_a_non_interactive_session_falls_back(self) -> None: + """Redirected output has no terminal to reserve rows in.""" + mode, reason = _select_toolbar_mode("auto", qualified_output(), toolbar_enabled=True, interactive=False) + assert mode is ToolbarMode.LEGACY + assert "interactive" in reason + + +class TestForcedModes: + @pytest.mark.parametrize("requested", [ToolbarMode.OFF, ToolbarMode.LEGACY]) + def test_modes_without_reservations_need_no_qualification(self, requested: ToolbarMode) -> None: + mode, reason = _select_toolbar_mode( + requested, DummyOutput(), toolbar_enabled=False, interactive=False, layout_supported=False, version="0" + ) + assert mode is requested + assert reason == "" + + def test_reserved_is_selected_when_everything_qualifies(self) -> None: + mode, _reason = _select_toolbar_mode("reserved", qualified_output(), toolbar_enabled=True, interactive=True) + assert mode is ToolbarMode.RESERVED + + def test_forcing_reserved_on_an_unqualified_backend_is_an_error(self) -> None: + """Falling back silently would give the caller the behaviour they ruled out.""" + with pytest.raises(ValueError, match="dummy output"): + _select_toolbar_mode("reserved", DummyOutput(), toolbar_enabled=True, interactive=True) + + def test_forcing_reserved_on_an_unqualified_dependency_is_an_error(self) -> None: + with pytest.raises(ValueError, match=r"3\.0\.99"): + _select_toolbar_mode("reserved", qualified_output(), toolbar_enabled=True, interactive=True, version="3.0.99") + + def test_forcing_reserved_without_a_toolbar_is_an_error(self) -> None: + with pytest.raises(ValueError, match="toolbar"): + _select_toolbar_mode("reserved", qualified_output(), toolbar_enabled=False, interactive=True) + + def test_forcing_reserved_without_a_terminal_is_an_error(self) -> None: + with pytest.raises(ValueError, match="interactive"): + _select_toolbar_mode("reserved", qualified_output(), toolbar_enabled=True, interactive=False) + + def test_an_unknown_mode_is_rejected_before_anything_is_inspected(self) -> None: + with pytest.raises(ValueError, match="pinned"): + _select_toolbar_mode("pinned", qualified_output(), toolbar_enabled=True, interactive=True) + + +class TestConstructorWiring: + def test_the_default_is_off(self) -> None: + """No toolbar is configured unless the caller selects an enabled mode.""" + assert cmd2.Cmd(allow_cli_args=False).bottom_toolbar_mode is ToolbarMode.OFF + + @pytest.mark.parametrize("mode", list(ToolbarMode)) + def test_a_requested_mode_is_remembered(self, mode: ToolbarMode) -> None: + app = cmd2.Cmd(allow_cli_args=False, bottom_toolbar_mode=mode) + assert app.bottom_toolbar_mode is mode + assert (app.main_session.bottom_toolbar is not None) is (mode is not ToolbarMode.OFF) + assert app.use_builtin_pager is (mode is not ToolbarMode.OFF) + + def test_a_mode_given_as_a_string_is_remembered_as_the_member(self) -> None: + """Existing calls pass strings; they get the same behaviour and a real member back.""" + app = cmd2.Cmd(allow_cli_args=False, bottom_toolbar_mode="reserved") + assert app.bottom_toolbar_mode is ToolbarMode.RESERVED + + def test_the_enum_is_importable_from_the_package(self) -> None: + assert cmd2.ToolbarMode is ToolbarMode + + def test_an_unknown_mode_is_rejected_at_construction(self) -> None: + """Not at the first prompt: a typo should fail where it was written.""" + with pytest.raises(ValueError, match="pinned"): + cmd2.Cmd(allow_cli_args=False, bottom_toolbar_mode="pinned") + + def test_the_mode_is_read_only(self) -> None: + app = cmd2.Cmd(allow_cli_args=False) + with pytest.raises(AttributeError): + app.bottom_toolbar_mode = "reserved" # type: ignore[misc] + + +class TestLayoutPrerequisite: + def test_an_unrecognized_layout_falls_back_under_auto(self) -> None: + """Reserved rendering has to hide the native toolbar, and cannot find it here.""" + mode, reason = _select_toolbar_mode( + "auto", qualified_output(), toolbar_enabled=True, interactive=True, layout_supported=False + ) + assert mode is ToolbarMode.LEGACY + assert "layout" in reason + + def test_forcing_reserved_on_an_unrecognized_layout_is_an_error(self) -> None: + with pytest.raises(ValueError, match="layout"): + _select_toolbar_mode( + "reserved", qualified_output(), toolbar_enabled=True, interactive=True, layout_supported=False + ) diff --git a/tests/test_toolbar_painter.py b/tests/test_toolbar_painter.py index 8a7894163..603327c81 100644 --- a/tests/test_toolbar_painter.py +++ b/tests/test_toolbar_painter.py @@ -9,6 +9,7 @@ import io import re import threading +from typing import Any import pytest from prompt_toolkit.data_structures import Size @@ -17,9 +18,10 @@ from prompt_toolkit.output.vt100 import Vt100_Output from prompt_toolkit.styles import BaseStyle, DummyStyle, DynamicStyle, Style +from cmd2 import toolbar_painter from cmd2.terminal_display import TerminalDisplay from cmd2.terminal_transaction import TerminalLock, current_transaction, held_higher_level_locks -from cmd2.toolbar_painter import Cell, ToolbarFrame, ToolbarPainter, measure_toolbar_height +from cmd2.toolbar_painter import Cell, ToolbarFrame, ToolbarPainter def text_of(frame: ToolbarFrame, row: int = 0) -> str: @@ -33,6 +35,68 @@ def styles_of(frame: ToolbarFrame, row: int = 0) -> list[str]: class TestShape: + @pytest.mark.parametrize( + ("content", "width", "height", "expected"), + [ + ("\nSTATUS", 8, 1, [" …"]), + ("STATUS\nnext", 8, 1, ["STATUS …"]), + ("abcd", 4, 1, ["abcd"]), + ("abcde", 4, 1, ["abc…"]), + ("abcdef\nxy", 4, 2, ["abc…", "xy "]), + ("a\nb\nc\nd", 4, 3, ["a ", "b ", "c …"]), + ("abc\n\nx", 4, 1, ["abc…"]), + ("广", 1, 1, ["…"]), + ("a广x", 3, 1, ["a …"]), + ("e\u0301abc", 3, 1, ["e\u0301a…"]), + ("abx\u0301", 3, 1, ["abx\u0301"]), + ("abcde\u0301\nz", 4, 2, ["abc…", "z "]), + ("\tX\ny", 4, 2, [" …", "y "]), + ("abcdefgh x", 8, 1, ["abcdefg…"]), + ], + ) + def test_truncation_is_visible_and_does_not_wrap(self, content, width, height, expected) -> None: + frame = ToolbarFrame.build(content, width=width, height=height) + assert [text_of(frame, row) for row in range(height)] == expected + assert all(len(row) == width for row in frame.rows) + + @pytest.mark.parametrize( + ("content", "width", "height", "expected"), + [ + ("abcdefgh\t", 8, 1, ["abcdefgh"]), + ("abcdefgh ", 8, 1, ["abcdefgh"]), + ("abc".ljust(12), 8, 1, ["abc "]), + ("status".center(10), 8, 1, [" status"]), + ("abcdefgh \u0301", 8, 1, ["abcdefgh"]), + ("Ready\n", 8, 1, ["Ready "]), + ("abc\n", 4, 1, ["abc "]), + ("abc\n ", 4, 1, ["abc "]), + ("abc\n\t\n", 4, 1, ["abc "]), + ("abc\n\u200b", 4, 1, ["abc "]), + ], + ) + def test_omitted_whitespace_is_not_truncation(self, content, width, height, expected) -> None: + """Only content the user would have seen earns the indicator. + + Padding to the column count with ``ljust``, a trailing tab, and the newline that ends + ``Console.export_text()`` all overflow by cells nobody can see. Marking those replaces + a real character with an ellipsis to announce that nothing was lost. + """ + frame = ToolbarFrame.build(content, width=width, height=height) + assert [text_of(frame, row) for row in range(height)] == expected + + def test_an_omitted_line_holding_only_escape_fragments_is_not_truncation(self) -> None: + """Escape fragments are never drawn, so a line made of them omits nothing visible.""" + content = [("", "abc\n"), ("[ZeroWidthEscape]", "\x1b[6n")] + frame = ToolbarFrame.build(content, width=4, height=1) + assert text_of(frame) == "abc " + + def test_a_combining_mark_after_a_dropped_character_is_dropped_with_it(self) -> None: + """Its base is gone, so attaching it to the last retained cell would mark the wrong one.""" + frame = ToolbarFrame.build("abcdefghi\u0301", width=8, height=1) + assert text_of(frame) == "abcdefg…" + frame = ToolbarFrame.build("abcdefgh \u0301", width=8, height=1) + assert frame.rows[0][7].char == "h" + def test_a_frame_is_always_exactly_its_declared_size(self) -> None: frame = ToolbarFrame.build("hi", width=10, height=2) assert len(frame.rows) == 2 @@ -44,16 +108,16 @@ def test_short_content_is_padded_with_default_style_spaces(self) -> None: assert text_of(frame) == "hi " assert styles_of(frame) == ["class:toolbar"] * 5 - def test_content_wider_than_the_terminal_wraps(self) -> None: + def test_content_wider_than_the_terminal_is_clipped(self) -> None: frame = ToolbarFrame.build("abcdef", width=3, height=2) - assert text_of(frame, 0) == "abc" - assert text_of(frame, 1) == "def" + assert text_of(frame, 0) == "ab…" + assert text_of(frame, 1) == " " def test_content_taller_than_the_band_is_truncated(self) -> None: """Growing past the band is a geometry transition, never a write outside it.""" frame = ToolbarFrame.build("one\ntwo\nthree", width=10, height=2) assert text_of(frame, 0) == "one " - assert text_of(frame, 1) == "two " + assert text_of(frame, 1) == "two …" def test_an_explicit_newline_starts_a_row(self) -> None: frame = ToolbarFrame.build("a\nb", width=3, height=2) @@ -102,25 +166,56 @@ def test_a_wide_character_occupies_two_cells(self) -> None: assert text_of(frame) == "广 " def test_a_wide_character_is_never_split_at_the_right_edge(self) -> None: - """Half a wide character at the edge is what wraps a row into the one below it.""" + """A wide character that does not fit is clipped whole and marked; nothing wraps.""" frame = ToolbarFrame.build("a广", width=2, height=2) - assert text_of(frame, 0) == "a " - assert frame.rows[1][0].char == "广" + assert text_of(frame, 0) == "a…" + assert text_of(frame, 1) == " " - def test_the_pad_before_a_wrapped_wide_character_uses_the_default_style(self) -> None: + def test_the_truncation_indicator_uses_the_default_style(self) -> None: frame = ToolbarFrame.build([("bold", "a广")], width=2, height=2, default_style="base") assert styles_of(frame, 0) == ["bold", "base"] + def test_the_truncation_indicator_is_a_module_constant(self, monkeypatch) -> None: + """U+2026 is ambiguous-width; a terminal that draws it two columns wide needs a substitute.""" + monkeypatch.setattr(toolbar_painter, "TRUNCATION_INDICATOR", "~") + frame = ToolbarFrame.build("abcde", width=4, height=1) + assert text_of(frame) == "abc~" + def test_a_combining_character_joins_the_cell_before_it(self) -> None: frame = ToolbarFrame.build("e\u0301x", width=4, height=1) assert frame.rows[0][0].char == "e\u0301" assert frame.rows[0][1].char == "x" - def test_a_leading_combining_character_gets_its_own_cell(self) -> None: - """There is nothing to combine with; dropping it would silently lose content.""" - frame = ToolbarFrame.build("\u0301a", width=4, height=1) - assert frame.rows[0][0].char == "\u0301" - assert frame.rows[0][1].char == "a" + def test_a_leading_combining_character_is_drawn_on_a_space(self) -> None: + """There is nothing to combine with, and a cell drawn in zero columns would put every + later cell one column right of where the terminal shows it. + """ + frame = ToolbarFrame.build("\u0301xy", width=3, height=1) + assert [cell.char for cell in frame.rows[0]] == [" \u0301", "x", "y"] + assert all(cell.width == 1 for cell in frame.rows[0]) + + @pytest.mark.parametrize( + ("content", "expected"), + [ + ("a\x1b[2Jb", "a^[[2Jb "), + ("a\x07b", "a^Gb "), + ("a\x85b", "a<85>b "), + ("a\x7fb", "a^?b "), + ("a\x00b", "a^@b "), + ], + ) + def test_control_characters_are_shown_in_caret_notation(self, content, expected) -> None: + """Raw control from a plain string would otherwise be executed inside the band. + + The renderer shows ``^[`` for an escape; the painter shows the same thing, so the two + modes agree and a stray sequence in a callback cannot clear the screen. + """ + frame = ToolbarFrame.build(content, width=8, height=1) + assert text_of(frame) == expected + + def test_control_characters_carry_the_renderer_s_style_class(self) -> None: + frame = ToolbarFrame.build([("bold", "a\x1bb")], width=4, height=1) + assert styles_of(frame) == ["bold", "bold class:control-character", "bold class:control-character", "bold"] def test_a_tab_advances_to_the_next_tab_stop(self) -> None: frame = ToolbarFrame.build("a\tb", width=12, height=1) @@ -132,29 +227,6 @@ def test_a_carriage_return_does_not_reach_the_terminal(self) -> None: assert text_of(frame) == "ab " -class TestMeasurement: - def test_a_short_toolbar_is_one_row(self) -> None: - assert measure_toolbar_height("hi", width=10) == 1 - - def test_wrapping_is_counted(self) -> None: - assert measure_toolbar_height("abcdef", width=3) == 2 - - def test_newlines_are_counted(self) -> None: - assert measure_toolbar_height("a\nb\nc", width=10) == 3 - - def test_empty_content_still_measures_one_row(self) -> None: - """An empty toolbar is an intentional visibility change, not a zero-row reservation.""" - assert measure_toolbar_height("", width=10) == 1 - - def test_measurement_matches_the_frame_it_would_build(self) -> None: - content = [("bold", "wide 广 content that wraps around")] - height = measure_toolbar_height(content, width=12) - frame = ToolbarFrame.build(content, width=12, height=height) - # Nothing was truncated: the last row is where the content ended. - assert measure_toolbar_height(content, width=12) == len(frame.rows) - assert text_of(frame, height - 1).strip() != "" - - class TestValidation: def test_a_frame_needs_a_positive_width(self) -> None: with pytest.raises(ValueError, match="width"): @@ -169,10 +241,6 @@ def test_a_cell_reports_its_display_width(self) -> None: assert Cell("广", "").width == 2 assert Cell("", "", is_continuation=True).width == 0 - def test_measuring_needs_a_positive_width(self) -> None: - with pytest.raises(ValueError, match="width"): - measure_toolbar_height("hi", width=0) - def test_a_frame_reports_its_own_size(self) -> None: frame = ToolbarFrame.build("hi", width=6, height=2) assert (frame.height, frame.width) == (2, 6) @@ -180,12 +248,12 @@ def test_a_frame_reports_its_own_size(self) -> None: def test_an_empty_frame_reports_zero_width(self) -> None: assert ToolbarFrame(rows=()).width == 0 - def test_a_tab_wraps_when_it_reaches_the_edge(self) -> None: - """The expansion is cells, so it wraps like any other run of them.""" + def test_a_tab_is_clipped_when_it_reaches_the_edge(self) -> None: + """Tab expansion cannot spill into the next reserved row.""" frame = ToolbarFrame.build("a\tb", width=4, height=3) - assert text_of(frame, 0) == "a " + assert text_of(frame, 0) == "a …" assert text_of(frame, 1) == " " - assert text_of(frame, 2) == "b " + assert text_of(frame, 2) == " " def test_a_combining_mark_after_a_wide_character_joins_that_character(self) -> None: """Attaching it to the continuation cell instead shifts every later column.""" @@ -315,6 +383,14 @@ def test_nothing_is_cleared_before_painting(self) -> None: for erase in ("\x1b[K", "\x1b[0K", "\x1b[2K", "\x1b[J", "\x1b[M"): assert erase not in written + def test_a_control_character_never_reaches_the_terminal_raw(self) -> None: + """The frame test proves the layout; this proves the wire, which is what matters.""" + harness = Harness(columns=12) + harness.paint("a\x1b[2Jb") + written = harness.written() + assert "\x1b[2J" not in written + assert "a^[[2Jb" in harness.visible() + def test_a_shorter_frame_pads_its_tail_rather_than_erasing_it(self) -> None: harness = Harness() harness.paint("abcd") @@ -617,3 +693,121 @@ def toolbar_paint() -> None: # The writer blocked inside leaf I/O, holding no higher-level lock -- which is what # keeps the rest of cmd2 able to make progress while the terminal is backed up. assert stream.locks_held_while_blocked == () + + +class PartialWriteStream(io.StringIO): + """Writes a prefix of one flushed batch and then fails, as a real terminal can. + + A test that replaces ``paint`` entirely never emits anything, so it cannot see what a + half-written batch leaves behind. The backend buffers a whole paint and flushes it in one + ``write``, so cutting that write short is what puts the terminal into the state this is + about: autowrap off, cursor in the band, nothing restored. + """ + + def __init__(self, fail_on_write: int, keep: int = 12) -> None: + super().__init__() + self._writes = 0 + self._fail_on_write = fail_on_write + self._keep = keep + + def isatty(self) -> bool: + return True + + def write(self, text: str) -> int: + self._writes += 1 + if self._writes == self._fail_on_write: + super().write(text[: self._keep]) + raise OSError("terminal went away") + return super().write(text) + + +class TestPartialPaint: + def make(self, fail_on_write: int = 2) -> tuple[ToolbarPainter, PartialWriteStream, Any]: + """Build a painter over a terminal that fails part-way through one flushed batch. + + Count relative to the completed acquisition, so changes to startup flushing + cannot make a paint regression fail in margin installation instead. + """ + stream = PartialWriteStream(fail_on_write=-1) + screen = {"rows": 24, "columns": 5} + output = Vt100_Output(stream, lambda: Size(rows=screen["rows"], columns=screen["columns"])) + display = ResizableDisplay(output, screen) + assert display.acquire() is True + stream._fail_on_write = stream._writes + fail_on_write - 1 + painter = ToolbarPainter( + display=display, + lock=TerminalLock(), + style=DummyStyle(), + color_depth=ColorDepth.DEPTH_8_BIT, + ) + return painter, stream, display + + def test_a_partial_paint_restores_wrap_and_cursor_state(self) -> None: + """Leaving autowrap off would make the next ordinary line wrap where it should not.""" + painter, stream, _display = self.make() + prepared = painter.prepare(lambda: "hi") + assert prepared is not None + with pytest.raises(OSError, match="terminal went away"): + painter.paint(prepared) + + written = stream.getvalue() + assert "\x1b[?7l" in written # the paint really did start emitting + assert written.endswith("\x1b[?7h\x1b8") # and the cleanup really did finish it + + def test_a_partial_paint_discards_the_baseline(self) -> None: + """Some cells were overwritten and some were not; what the band shows is unknown.""" + painter, _stream, _display = self.make(fail_on_write=3) + first = painter.prepare(lambda: "hi") + assert first is not None + assert painter.paint(first) is True + assert painter.last_frame is not None + + second = painter.prepare(lambda: "zz") + assert second is not None + with pytest.raises(OSError, match="terminal went away"): + painter.paint(second) + assert painter.last_frame is None + + def test_the_next_paint_after_a_failure_is_a_full_one(self) -> None: + """A diff against the discarded baseline would skip the cells that never arrived.""" + painter, stream, _display = self.make(fail_on_write=3) + first = painter.prepare(lambda: "hi") + assert first is not None + painter.paint(first) + + second = painter.prepare(lambda: "zz") + assert second is not None + with pytest.raises(OSError, match="terminal went away"): + painter.paint(second) + + stream.truncate(0) + stream.seek(0) + again = painter.prepare(lambda: "hi") + assert again is not None + assert painter.paint(again) is True + # The whole band, from its first column: not just the cells that differ from "hi". + assert "\x1b[24;1Hhi " in re.sub(r"\x1b\[[0-9;]*m", "", stream.getvalue()) + + def test_a_failure_before_any_paint_bytes_restores_nothing(self) -> None: + """The initial flush drains another writer; this paint has saved no cursor yet. + + DECRC would return to whatever was saved last -- the margin change's cursor, from + before the command that has been writing since -- and later output would then overwrite + what is already on the screen. + """ + painter, stream, display = self.make(fail_on_write=2) + prepared = painter.prepare(lambda: "hi") + assert prepared is not None + + # Something else has buffered output, so the paint's opening flush has work to do. + display.terminal.output.write("hello") + stream.truncate(0) + stream.seek(0) + + with pytest.raises(OSError, match="terminal went away"): + painter.paint(prepared) + + written = stream.getvalue() + assert "\x1b8" not in written + assert "\x1b[?7h" not in written + assert painter.last_frame is None