Skip to content

Commit 4915ea7

Browse files
committed
Measure paged output before building a pager
page() constructed a full Pager -- a TextArea, a SearchToolbar, an ANSI lexer and its key bindings -- and then threw it away whenever the output fit on screen, which is a common case for ppaged(). Only the line measurements were ever used on that path. Move the size check into a module-level output_fits() that measures the text directly, and build the Pager only once the output has to be scrolled. A shared _fragments() helper does the one ANSI parse for both, so the measurement cannot drift from what the view would render. The fast path's size check drops from ~0.15ms to ~0.02ms per call, so it isn't a massive speedup or anything, but still cleaner.
1 parent 7340427 commit 4915ea7

3 files changed

Lines changed: 59 additions & 29 deletions

File tree

‎cmd2/command_toolbar.py‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from prompt_toolkit.patch_stdout import StdoutProxy
2525
from prompt_toolkit.utils import suspend_to_background_supported
2626

27-
from .pager import Pager
27+
from .pager import Pager, output_fits
2828

2929
if TYPE_CHECKING:
3030
from .cmd2 import Cmd
@@ -389,20 +389,20 @@ def _check_running(self) -> None:
389389

390390
def page(self, text: str, *, chop: bool) -> None:
391391
"""Show a pager above the same toolbar without starting another input reader."""
392-
pager = Pager(text, chop=chop)
393-
pager.bindings.add(
394-
"c-z",
395-
filter=Condition(lambda: suspend_to_background_supported() and to_filter(self.cmd.main_session.enable_suspend)()),
396-
)(self._suspend_binding)
397392
size = self.app.output.get_size()
398393
# Measuring the toolbar can invoke its callback; keep that work on the
399394
# UI thread along with rendering and layout changes.
400395
toolbar_height = self._call_in_ui(lambda: self.toolbar.preferred_height(size.columns, size.rows).preferred)
401-
if pager.fits(size.columns, max(0, size.rows - toolbar_height)):
396+
if output_fits(text, size.columns, max(0, size.rows - toolbar_height), chop=chop):
402397
self.cmd.stdout.write(text)
403398
self.cmd.stdout.flush()
404399
return
405400

401+
pager = Pager(text, chop=chop)
402+
pager.bindings.add(
403+
"c-z",
404+
filter=Condition(lambda: suspend_to_background_supported() and to_filter(self.cmd.main_session.enable_suspend)()),
405+
)(self._suspend_binding)
406406
layout = Layout(HSplit([pager.container, self.toolbar]), focused_element=pager.text)
407407
previous = (self.app.layout, self.app.key_bindings, self.app.editing_mode, self.app.full_screen)
408408
entered = False

‎cmd2/pager.py‎

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,33 @@
2121
from prompt_toolkit.widgets import SearchToolbar, TextArea
2222

2323

24+
def _fragments(text: str) -> StyleAndTextTuples:
25+
"""Parse captured output, dropping the trailing newline that ends its last line."""
26+
return to_formatted_text(ANSI(text.removesuffix("\n")))
27+
28+
29+
def output_fits(text: str, columns: int, rows: int, *, chop: bool) -> bool:
30+
"""Check rendered line heights, including wrapping and wide Unicode characters.
31+
32+
Measuring the text itself keeps output that needs no scrolling from paying for a
33+
Pager's widgets and key bindings, which would only be built to be thrown away.
34+
"""
35+
lines = fragment_list_to_text(_fragments(text)).split("\n")
36+
if chop:
37+
# Even one wide line needs a pager so its hidden columns remain
38+
# accessible through horizontal scrolling.
39+
return len(lines) <= rows and all(get_cwidth(line) <= columns for line in lines)
40+
# Reuse prompt-toolkit's own wrapping arithmetic so this matches what a Pager
41+
# would render, without building a control to ask on the UI thread.
42+
content = UIContent(get_line=lambda number: [("", lines[number])], line_count=len(lines))
43+
height = 0
44+
for line in range(content.line_count):
45+
height += content.get_height_for_line(line, columns, None)
46+
if height > rows:
47+
return False
48+
return True
49+
50+
2451
class _AnsiLexer(Lexer):
2552
"""Preserve captured Rich styles while searching and scrolling plain text."""
2653

@@ -42,7 +69,7 @@ def __init__(self, text: str, *, chop: bool) -> None:
4269
self.closed = threading.Event()
4370
self.on_close: Callable[[], None] = self.closed.set
4471
self.chop = chop
45-
fragments = to_formatted_text(ANSI(text.removesuffix("\n")))
72+
fragments = _fragments(text)
4673
self.search = SearchToolbar()
4774
self.text = TextArea(
4875
text=fragment_list_to_text(fragments),
@@ -190,20 +217,3 @@ def _scroll_horizontal(self, event: KeyPressEvent, direction: int) -> None:
190217
column = self._column_at_width(document.current_line, target)
191218
event.current_buffer.cursor_position = document.translate_row_col_to_index(document.cursor_position_row, column)
192219
self.text.window.horizontal_scroll = get_cwidth(document.current_line[:column])
193-
194-
def fits(self, columns: int, rows: int) -> bool:
195-
"""Check rendered line heights, including wrapping and wide Unicode characters."""
196-
# Measuring must not ask BufferControl to create content: that starts
197-
# history-loading tasks and is only safe on the application's event loop.
198-
lines = self.text.document.lines
199-
if self.chop:
200-
# Even one wide line needs a pager so its hidden columns remain
201-
# accessible through horizontal scrolling.
202-
return len(lines) <= rows and all(get_cwidth(line) <= columns for line in lines)
203-
content = UIContent(get_line=lambda number: [("", lines[number])], line_count=len(lines))
204-
height = 0
205-
for line in range(content.line_count):
206-
height += content.get_height_for_line(line, columns, None)
207-
if height > rows:
208-
return False
209-
return True

‎tests/test_command_toolbar.py‎

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from prompt_toolkit.shortcuts import PromptSession
2020

2121
from cmd2 import Cmd
22-
from cmd2.pager import Pager
22+
from cmd2.pager import Pager, output_fits
2323

2424

2525
class Terminal(io.StringIO):
@@ -696,6 +696,18 @@ def test_builtin_pager_short_output(toolbar_app, monkeypatch) -> None:
696696
assert "short output\n" in output.getvalue()
697697

698698

699+
def test_builtin_pager_short_output_builds_no_pager(toolbar_app, monkeypatch) -> None:
700+
app, _, output = toolbar_app
701+
app.use_builtin_pager = True
702+
monkeypatch.setattr(app, "stdin", Terminal())
703+
monkeypatch.setenv("TERM", "xterm")
704+
# Output that fits is written directly, so none of the pager's widgets are needed.
705+
with mock.patch("cmd2.command_toolbar.Pager") as pager, app._command_toolbar_context():
706+
app.ppaged("short output")
707+
pager.assert_not_called()
708+
assert "short output\n" in output.getvalue()
709+
710+
699711
def test_external_pager_suspends_shared_application(toolbar_app, monkeypatch) -> None:
700712
app, _, _ = toolbar_app
701713
app.use_builtin_pager = False
@@ -736,9 +748,17 @@ def test_pager_styles_and_wrapping(chop) -> None:
736748
assert pager.text.read_only
737749
lexer = pager.text.lexer.lex_document(pager.text.document)
738750
assert "ansired" in lexer(0)[0][0]
739-
assert not pager.fits(20, 1)
740-
assert pager.fits(20, 5) is (not chop)
741-
assert pager.fits(100, 1)
751+
752+
753+
@pytest.mark.parametrize("chop", [False, True])
754+
def test_output_fits_measures_styled_and_wide_text(chop) -> None:
755+
# Forty double-width characters occupy eighty columns, and styling them adds
756+
# escape sequences that must not count towards the measurement.
757+
text = "\x1b[31m" + "界" * 40 + "\x1b[0m\n"
758+
assert not output_fits(text, 20, 1, chop=chop)
759+
# Wrapping the line onto four rows fits; chopping keeps it one wide row that does not.
760+
assert output_fits(text, 20, 5, chop=chop) is (not chop)
761+
assert output_fits(text, 100, 1, chop=chop)
742762

743763

744764
@pytest.mark.parametrize("chop", [False, True])

0 commit comments

Comments
 (0)