99import sys
1010import threading
1111from collections .abc import Callable , Iterator
12+ from concurrent .futures import Future
13+ from concurrent .futures import TimeoutError as FutureTimeoutError
1214from typing import TYPE_CHECKING , Any , TextIO , TypeVar , cast
1315
1416from 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
1619from prompt_toolkit .input .typeahead import get_typeahead , store_typeahead
1720from prompt_toolkit .key_binding import KeyBindings
1821from prompt_toolkit .key_binding .key_processor import KeyPress , KeyPressEvent
1922from prompt_toolkit .layout import HSplit , Layout , Window
2023from prompt_toolkit .layout .containers import ConditionalContainer
21- from prompt_toolkit .layout .controls import FormattedTextControl
2224from prompt_toolkit .patch_stdout import StdoutProxy
2325from prompt_toolkit .utils import suspend_to_background_supported
2426
27+ from .pager import Pager
28+
2529if TYPE_CHECKING :
2630 from .cmd2 import Cmd
2731
2832_F = TypeVar ("_F" , bound = Callable [..., Any ])
33+ _R = TypeVar ("_R" )
2934
3035
3136def suspend_toolbar (func : _F ) -> _F :
@@ -94,7 +99,7 @@ def finish(self) -> None:
9499
95100
96101class 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