diff --git a/CHANGELOG.md b/CHANGELOG.md index b4dd43922..64fcb2d35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ - Enhancements - `enable_bottom_toolbar=True` 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 + prompt-toolkit's `choice()` offers no way to configure a refresh interval - Added an embedded pager which `Cmd.ppaged()` uses while the bottom toolbar is running, so the toolbar stays visible and refreshing instead of the terminal being handed to an external pager. It supports vertical and horizontal scrolling, incremental search, and chopped lines on diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index a78f98e03..6bbb5bd1f 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -3776,6 +3776,7 @@ def read_input( temp_session: PromptSession[str] = PromptSession( auto_suggest=self.main_session.auto_suggest, + bottom_toolbar=self.get_bottom_toolbar if self.main_session.bottom_toolbar is not None else None, color_depth=self.main_session.color_depth, complete_style=self.main_session.complete_style, complete_in_thread=self.main_session.complete_in_thread, @@ -3786,6 +3787,7 @@ def read_input( key_bindings=self.main_session.key_bindings, input=self.main_session.input, output=self.main_session.output, + refresh_interval=self.main_session.refresh_interval, style=self.main_session.style, ) @@ -3803,10 +3805,12 @@ def read_secret( :raises Exception: any other exceptions raised by prompt() """ temp_session: PromptSession[str] = PromptSession( + bottom_toolbar=self.get_bottom_toolbar if self.main_session.bottom_toolbar is not None else None, color_depth=self.main_session.color_depth, enable_suspend=self.main_session.enable_suspend, input=self.main_session.input, output=self.main_session.output, + refresh_interval=self.main_session.refresh_interval, style=self.main_session.style, ) diff --git a/docs/features/prompt.md b/docs/features/prompt.md index ceebd063e..2368ab180 100644 --- a/docs/features/prompt.md +++ b/docs/features/prompt.md @@ -120,10 +120,15 @@ directly above the toolbar, and longer output becomes a scrollable, searchable v `self.use_builtin_pager = False` to opt out and use your configured external `pager`/`pager_chop` commands. See [Embedded pager](./os.md#embedded-pager) for the key bindings and full details. -cmd2 temporarily hides the toolbar for its input prompts, external pagers, Python environments, and -shell commands. It also hides it while a command's output is piped to another process, since that -process may be interactive, as `less` and `fzf` are. It restores the toolbar when those operations -finish. +cmd2 temporarily hides the toolbar for external pagers, Python environments, and shell commands. It +also hides it while a command's output is piped to another process, since that process may be +interactive, as `less` and `fzf` are. It restores the toolbar when those operations finish. + +[cmd2.Cmd.read_input][] and [cmd2.Cmd.read_secret][] keep the toolbar visible, and it continues to +refresh at the same `refresh_interval` as the main prompt, so a clock or status display does not go +stale while your command waits for input. [cmd2.Cmd.select][] does not yet show one, because +prompt-toolkit's `choice()` has no way to configure a refresh interval and its toolbar would go +stale while the selection sits idle. For custom terminal UIs, calls to `input()`, or subprocesses your own command code starts, use [cmd2.Cmd.suspend_bottom_toolbar][]: diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py index 6f4ae3656..1aa0dca81 100644 --- a/tests/test_cmd2.py +++ b/tests/test_cmd2.py @@ -2391,6 +2391,72 @@ def test_read_secret_eof(base_app, monkeypatch): base_app.read_secret("Secret: ") +def _capture_nested_session(app, call): + """Run `call` and return the real PromptSession cmd2 built for the nested prompt.""" + captured = [] + + def fake_read_raw_input(prompt, session, **kwargs): + captured.append(session) + return "typed" + + with mock.patch.object(app, "_read_raw_input", side_effect=fake_read_raw_input): + call() + assert len(captured) == 1 + return captured[0] + + +def test_read_input_session_shows_the_bottom_toolbar(base_app) -> None: + """A nested prompt keeps the persistent toolbar instead of dropping it. + + read_input() builds its own PromptSession, so without being told about the + toolbar the bar simply vanishes for as long as the nested prompt is up. + """ + base_app.main_session.bottom_toolbar = base_app.get_bottom_toolbar + + session = _capture_nested_session(base_app, lambda: base_app.read_input("Prompt> ")) + + assert session.bottom_toolbar == base_app.get_bottom_toolbar + + +def test_read_input_session_inherits_refresh_interval(base_app) -> None: + """A toolbar clock keeps ticking while the nested prompt waits for input.""" + base_app.main_session.bottom_toolbar = base_app.get_bottom_toolbar + base_app.main_session.refresh_interval = 0.25 + + session = _capture_nested_session(base_app, lambda: base_app.read_input("Prompt> ")) + + assert session.refresh_interval == 0.25 + + +def test_read_secret_session_shows_the_bottom_toolbar(base_app) -> None: + """Reading a secret keeps the persistent toolbar; the bar is not the secret.""" + base_app.main_session.bottom_toolbar = base_app.get_bottom_toolbar + + session = _capture_nested_session(base_app, lambda: base_app.read_secret("Secret: ")) + + assert session.bottom_toolbar == base_app.get_bottom_toolbar + + +def test_read_secret_session_inherits_refresh_interval(base_app) -> None: + """A toolbar clock keeps ticking while a secret is being entered.""" + base_app.main_session.bottom_toolbar = base_app.get_bottom_toolbar + base_app.main_session.refresh_interval = 0.25 + + session = _capture_nested_session(base_app, lambda: base_app.read_secret("Secret: ")) + + assert session.refresh_interval == 0.25 + + +@pytest.mark.parametrize("method", ["read_input", "read_secret"]) +def test_nested_prompt_has_no_toolbar_when_the_app_has_none(base_app, method) -> None: + """An app without a bottom toolbar must not sprout one at a nested prompt.""" + assert base_app.main_session.bottom_toolbar is None + + session = _capture_nested_session(base_app, lambda: getattr(base_app, method)("Prompt> ")) + + assert session.bottom_toolbar is None + + def test_read_input_passes_all_arguments_to_resolver(base_app): mock_choices = ["choice1", "choice2"] mock_provider = mock.MagicMock(name="provider")