Skip to content

Commit aac8a4e

Browse files
committed
Working prototype for one application with interchangeable layouts and an embedded pager
Following prompt-toolkit’s [layout composition model](https://python-prompt-toolkit.readthedocs.io/en/3.0.52/pages/full_screen_apps.html#the-layout). - CommandToolbar (cmd2/command_toolbar.py:101) now reuses main_session.app and its actual toolbar container, eliminating the second application and duplicated display configuration. - The new pager (cmd2/pager.py:37) shares that application. The toolbar continues refreshing during scrolling, searching, and resizing. Colors, wrapped lines, and horizontal scrolling are supported. - Enabling the toolbar selects the embedded pager. Set self.use_builtin_pager = False to retain your external pager. An external pager such as less takes control of the terminal; a layout alone cannot reserve space around it. That path still temporarily suspends the toolbar. This simplifies rendering ownership, although the embedded pager adds code. The command UI thread and output proxy remain necessary for synchronous commands. There’s also one guarded dependency on PromptSession’s internal layout structure, verified against versions 3.0.52 and 3.0.53.
1 parent 76dcc78 commit aac8a4e

7 files changed

Lines changed: 583 additions & 41 deletions

File tree

‎cmd2/cmd2.py‎

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,10 @@ def __init__(
645645
if callargs:
646646
self._startup_commands.extend(callargs)
647647

648+
# The embedded pager shares the main toolbar. Applications can opt back
649+
# into their configured external pager by setting this to False.
650+
self.use_builtin_pager = enable_bottom_toolbar
651+
648652
# Set the pager(s) for use when displaying output using a pager
649653
if sys.platform.startswith("win"):
650654
self.pager = self.pager_chop = "more"
@@ -1871,7 +1875,6 @@ def pfeedback(
18711875
rich_print_kwargs=rich_print_kwargs,
18721876
)
18731877

1874-
@command_toolbar.suspend_toolbar
18751878
def ppaged(
18761879
self,
18771880
*objects: Any,
@@ -1893,12 +1896,16 @@ def ppaged(
18931896
fits on the screen. A pager is not used inside a script (Python or text) or when output is
18941897
redirected or piped, and in these cases, output is sent to `poutput`.
18951898
1899+
With the bottom toolbar enabled, the built-in pager keeps it visible and refreshing.
1900+
Set ``use_builtin_pager=False`` to use the configured external ``pager`` or ``pager_chop``
1901+
command instead; external pagers temporarily hide the toolbar.
1902+
18961903
:param chop: True -> causes lines longer than the screen width to be chopped (truncated) rather than wrapped
18971904
- truncated text is still accessible by scrolling with the right & left arrow keys
18981905
- chopping is ideal for displaying wide tabular data as is done in utilities like pgcli
18991906
False -> causes lines longer than the screen width to wrap to the next line
19001907
- wrapping is ideal when you want to keep users from having to use horizontal scrolling
1901-
WARNING: On Windows, the text always wraps regardless of what the chop argument is set to
1908+
WARNING: The default external pager on Windows always wraps; the built-in pager supports chopping.
19021909
:param soft_wrap: Enable soft wrap mode. If True, lines of text will not be word-wrapped or cropped to
19031910
fit the terminal width. Defaults to True.
19041911
@@ -1941,11 +1948,19 @@ def ppaged(
19411948
soft_wrap=soft_wrap,
19421949
**(rich_print_kwargs if rich_print_kwargs is not None else {}),
19431950
)
1944-
output_bytes = capture.get().encode("utf-8", "replace")
1951+
output = capture.get()
1952+
1953+
if self.use_builtin_pager:
1954+
with self._command_toolbar_context():
1955+
if self._command_toolbar is not None and self._command_toolbar.is_active:
1956+
self._command_toolbar.page(output, chop=chop)
1957+
return
1958+
1959+
output_bytes = output.encode("utf-8", "replace")
19451960

19461961
# Prevent KeyboardInterrupts while in the pager. The pager application will
19471962
# still receive the SIGINT since it is in the same process group as us.
1948-
with self.sigint_protection:
1963+
with self.suspend_bottom_toolbar(), self.sigint_protection:
19491964
import subprocess
19501965

19511966
pipe_proc = subprocess.Popen( # noqa: S602
@@ -2056,8 +2071,8 @@ def get_bottom_toolbar(self) -> AnyFormattedText:
20562071
or even a real-time clock.
20572072
20582073
During command execution this callback runs in a background UI thread. Protect shared
2059-
state with a lock when necessary. The toolbar is suspended while another prompt, pager,
2060-
or interactive shell owns the terminal.
2074+
state with a lock when necessary. The built-in pager shares this toolbar. It is suspended
2075+
while another prompt, external pager, or interactive shell owns the terminal.
20612076
20622077
:return: Content to populate the bottom toolbar.
20632078
"""
@@ -2069,7 +2084,7 @@ def suspend_bottom_toolbar(self) -> Iterator[None]:
20692084
20702085
Use this context manager around application-specific calls to ``input()``, other
20712086
terminal UIs, or subprocesses that inherit the terminal. cmd2 automatically suspends
2072-
its toolbar for its own input prompts, pagers, and shell commands.
2087+
its toolbar for its own input prompts, external pagers, and shell commands.
20732088
"""
20742089
if self._command_toolbar is None:
20752090
yield

‎cmd2/command_toolbar.py‎

Lines changed: 136 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -9,23 +9,28 @@
99
import sys
1010
import threading
1111
from collections.abc import Callable, Iterator
12+
from concurrent.futures import Future
13+
from concurrent.futures import TimeoutError as FutureTimeoutError
1214
from typing import TYPE_CHECKING, Any, TextIO, TypeVar, cast
1315

1416
from prompt_toolkit.application import Application, create_app_session
15-
from prompt_toolkit.filters import Condition, is_done, renderer_height_is_known, to_filter
17+
from prompt_toolkit.enums import EditingMode
18+
from prompt_toolkit.filters import Condition, to_filter
1619
from prompt_toolkit.input.typeahead import get_typeahead, store_typeahead
1720
from prompt_toolkit.key_binding import KeyBindings
1821
from prompt_toolkit.key_binding.key_processor import KeyPress, KeyPressEvent
1922
from prompt_toolkit.layout import HSplit, Layout, Window
2023
from prompt_toolkit.layout.containers import ConditionalContainer
21-
from prompt_toolkit.layout.controls import FormattedTextControl
2224
from prompt_toolkit.patch_stdout import StdoutProxy
2325
from prompt_toolkit.utils import suspend_to_background_supported
2426

27+
from .pager import Pager
28+
2529
if TYPE_CHECKING:
2630
from .cmd2 import Cmd
2731

2832
_F = TypeVar("_F", bound=Callable[..., Any])
33+
_R = TypeVar("_R")
2934

3035

3136
def suspend_toolbar(func: _F) -> _F:
@@ -94,7 +99,7 @@ def finish(self) -> None:
9499

95100

96101
class CommandToolbar:
97-
"""Run a prompt-toolkit display in a thread while a command runs on the main thread.
102+
"""Borrow the main prompt's application while a command runs on the main thread.
98103
99104
The display owns terminal input so it can receive cursor position reports. Keys
100105
typed during execution are saved for the next prompt; Ctrl-C is sent to cmd2's
@@ -114,6 +119,23 @@ def __init__(self, cmd: "Cmd") -> None:
114119
self._proxy: StdoutProxy | None = None
115120

116121
session = cmd.main_session
122+
self.app = session.app
123+
# PromptSession has no public hook for replacing just its input area.
124+
# Keep this small dependency on its layout shape in one place, and fail
125+
# explicitly if upstream changes it. Reuse the actual toolbar container,
126+
# including its visibility filter and support for multiline toolbars.
127+
root = session.layout.container
128+
if not isinstance(root, HSplit):
129+
raise TypeError("Unsupported PromptSession layout")
130+
self.toolbar = root.children[-1]
131+
if not (
132+
isinstance(self.toolbar, ConditionalContainer)
133+
and isinstance(self.toolbar.content, Window)
134+
and self.toolbar.content.style == "class:bottom-toolbar"
135+
):
136+
raise RuntimeError("Cannot locate PromptSession bottom toolbar")
137+
self._layout = Layout(HSplit([Window(height=0), Window(), self.toolbar]))
138+
self._display_stack: contextlib.ExitStack | None = None
117139
bindings = KeyBindings()
118140

119141
@bindings.add("<any>")
@@ -143,33 +165,11 @@ def suspend(event: KeyPressEvent) -> None:
143165
# redraws the toolbar after the process resumes.
144166
event.app.suspend_to_background()
145167

146-
self.app: Application[None] = Application(
147-
layout=Layout(
148-
HSplit(
149-
[
150-
Window(height=0),
151-
Window(),
152-
ConditionalContainer(
153-
Window(
154-
FormattedTextControl(lambda: session.bottom_toolbar, style="class:bottom-toolbar.text"),
155-
style="class:bottom-toolbar",
156-
height=1,
157-
always_hide_cursor=True,
158-
),
159-
filter=~is_done & renderer_height_is_known,
160-
),
161-
]
162-
)
163-
),
164-
input=session.input,
165-
output=session.output,
166-
style=session.style,
167-
color_depth=session.color_depth,
168-
refresh_interval=session.refresh_interval,
169-
key_bindings=bindings,
170-
erase_when_done=True,
171-
after_render=lambda _: self._ready.set(),
172-
)
168+
self._bindings = bindings
169+
self._suspend_binding = suspend
170+
171+
def _after_render(self, app: Application[str]) -> None: # noqa: ARG002
172+
self._ready.set()
173173

174174
def start(self) -> None:
175175
"""Start rendering and protect terminal output."""
@@ -201,6 +201,12 @@ def _restore_stream(obj: Any, name: str, stream: ToolbarStream) -> None:
201201
def _resume(self) -> None:
202202
self._ready.clear()
203203
self._error = None
204+
stack = self._display_stack = contextlib.ExitStack()
205+
for name, value in (("layout", self._layout), ("key_bindings", self._bindings), ("erase_when_done", True)):
206+
stack.callback(setattr, self.app, name, getattr(self.app, name))
207+
setattr(self.app, name, value)
208+
self.app.after_render += self._after_render
209+
stack.callback(self.app.after_render.remove_handler, self._after_render)
204210
context = contextvars.copy_context()
205211

206212
def run() -> None:
@@ -239,6 +245,9 @@ def _pause(self) -> None:
239245
if self._thread is not None:
240246
self._thread.join()
241247
self._thread = None
248+
if self._display_stack is not None:
249+
self._display_stack.close()
250+
self._display_stack = None
242251
# Application.run() saves its unprocessed queue before the thread
243252
# exits. Those keys arrived after the ones handled by save_key().
244253
pending_keys = get_typeahead(self.app.input)
@@ -256,6 +265,103 @@ def stop(self) -> None:
256265
self._stack.close()
257266
self._stack = None
258267

268+
@property
269+
def is_active(self) -> bool:
270+
"""Whether the display currently owns the terminal."""
271+
return self._proxy is not None and self.app.is_running
272+
273+
def _call_in_ui(self, func: Callable[[], _R]) -> _R:
274+
"""Change UI state on its event loop, propagating failures to the command."""
275+
result: Future[_R] = Future()
276+
277+
def call() -> None:
278+
try:
279+
value = func()
280+
except BaseException as exc: # noqa: BLE001
281+
result.set_exception(exc)
282+
else:
283+
result.set_result(value)
284+
285+
if self.app.loop is None:
286+
raise RuntimeError("Toolbar is not running")
287+
self.app.loop.call_soon_threadsafe(call)
288+
while True:
289+
try:
290+
value = result.result(timeout=0.1)
291+
except FutureTimeoutError:
292+
if result.done():
293+
raise
294+
self._check_running()
295+
else:
296+
return value
297+
298+
def _check_running(self) -> None:
299+
if self._thread is None or not self._thread.is_alive():
300+
if self._error is not None:
301+
raise self._error
302+
raise EOFError
303+
304+
def page(self, text: str, *, chop: bool) -> None:
305+
"""Show a pager above the same toolbar without starting another input reader."""
306+
pager = Pager(text, chop=chop)
307+
pager.bindings.add(
308+
"c-z",
309+
filter=Condition(lambda: suspend_to_background_supported() and to_filter(self.cmd.main_session.enable_suspend)()),
310+
)(self._suspend_binding)
311+
size = self.app.output.get_size()
312+
# Measuring the toolbar can invoke its callback; keep that work on the
313+
# UI thread along with rendering and layout changes.
314+
toolbar_height = self._call_in_ui(lambda: self.toolbar.preferred_height(size.columns, size.rows).preferred)
315+
if pager.fits(size.columns, max(0, size.rows - toolbar_height)):
316+
self.cmd.stdout.write(text)
317+
self.cmd.stdout.flush()
318+
return
319+
320+
layout = Layout(HSplit([pager.container, self.toolbar]), focused_element=pager.text)
321+
previous = (self.app.layout, self.app.key_bindings, self.app.editing_mode, self.app.full_screen)
322+
entered = False
323+
324+
def enter() -> None:
325+
nonlocal entered
326+
entered = True
327+
self.app.renderer.erase()
328+
self.app.layout = layout
329+
self.app.key_bindings = pager.bindings
330+
self.app.editing_mode = EditingMode.EMACS
331+
self.app.full_screen = self.app.renderer.full_screen = True
332+
self.app.invalidate()
333+
334+
def leave() -> None:
335+
nonlocal entered
336+
if not entered:
337+
return
338+
entered = False
339+
self.app.renderer.erase()
340+
self.app.layout, self.app.key_bindings, self.app.editing_mode, self.app.full_screen = previous
341+
self.app.renderer.full_screen = self.app.full_screen
342+
self.app.renderer.request_absolute_cursor_position()
343+
self.app.invalidate()
344+
345+
def close() -> None:
346+
# Switch bindings before the next key is processed, preserving
347+
# typeahead sent in the same terminal read as the pager's quit key.
348+
leave()
349+
pager.closed.set()
350+
351+
pager.on_close = close
352+
353+
try:
354+
self._call_in_ui(enter)
355+
while not pager.closed.wait(0.1):
356+
self._check_running()
357+
finally:
358+
if self._thread is not None and self._thread.is_alive():
359+
self._call_in_ui(leave)
360+
else:
361+
# The application's shutdown already reset the renderer.
362+
self.app.layout, self.app.key_bindings, self.app.editing_mode, self.app.full_screen = previous
363+
self.app.renderer.full_screen = self.app.full_screen
364+
259365
@contextlib.contextmanager
260366
def suspend(self) -> Iterator[None]:
261367
"""Temporarily restore ordinary terminal access, including nested suspensions."""

0 commit comments

Comments
 (0)