Skip to content

Commit de5cf49

Browse files
committed
Show reserved toolbar truncation and clip lines without wrapping
1 parent ec1cb98 commit de5cf49

6 files changed

Lines changed: 133 additions & 42 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
## 4.3.0 (TBD)
22

3+
- Bug Fixes
4+
- Reserved bottom toolbars now indicate clipped content with a right-edge ellipsis (`…`). Long
5+
lines are truncated without wrapping, and newlines beyond the reserved row are indicated
6+
instead of silently hiding content, including when the first line is empty.
7+
38
- Breaking Changes
49
- Replaced `enable_bottom_toolbar` with `bottom_toolbar_mode` in `Cmd.__init__()`. The default,
510
`cmd2.ToolbarMode.OFF`, disables the toolbar. Use `cmd2.ToolbarMode.AUTO` where you previously

‎cmd2/cmd2.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2132,6 +2132,10 @@ def get_bottom_toolbar(self) -> AnyFormattedText:
21322132
your application. This could be information like the application name, current state,
21332133
or even a real-time clock.
21342134
2135+
Reserved rendering uses one row and clips each logical line without wrapping. An
2136+
ellipsis in the rightmost column indicates omitted text or lines after a newline.
2137+
Widths are measured in terminal columns; wide characters are never split.
2138+
21352139
During command execution this callback runs in a background UI thread. Protect shared
21362140
state with a lock when necessary. The built-in pager shares this toolbar. It is suspended
21372141
while another prompt, external pager, or interactive shell owns the terminal.

‎cmd2/toolbar_painter.py‎

Lines changed: 40 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@
2121
mouse map, so a handler here would never be called; rendering the visible part is the honest
2222
subset rather than advertising support that does not exist.
2323
24+
**Lines are clipped with an ellipsis.** Newlines advance to the next reserved row;
25+
horizontal overflow never wraps. Omitted columns or rows are indicated at the right edge.
26+
2427
**Carriage returns are dropped and tabs are expanded.** Both are cursor motion in a context
2528
where the painter owns the cursor.
2629
"""
@@ -104,8 +107,9 @@ def build(
104107
105108
Content that does not fill the frame is padded with default-styled spaces: the pad is
106109
what overwrites a longer previous frame, so it is content rather than absence of it.
107-
Content taller than the band is truncated here -- growing the toolbar is a geometry
108-
transition, and writing the extra rows would put them outside the reservation.
110+
Each logical line is clipped to the terminal width without wrapping. A right-edge
111+
ellipsis marks omitted columns, or omitted lines on the last reserved row. Growing
112+
the toolbar is a geometry transition, never a consequence of content overflow.
109113
110114
:param content: the formatted text to lay out
111115
:param width: the terminal width in columns
@@ -120,6 +124,8 @@ def build(
120124
raise ValueError(f"a frame needs a positive height, got {height}")
121125

122126
rows = _layout(content, width, default_style)
127+
if len(rows) > height:
128+
_mark_truncated(rows[height - 1], default_style)
123129
blank = tuple(Cell(" ", default_style) for _ in range(width))
124130
while len(rows) < height:
125131
rows.append(list(blank))
@@ -129,9 +135,9 @@ def build(
129135
def measure_toolbar_height(content: "AnyFormattedText", width: int) -> int:
130136
"""Measure how many rows content needs at a given width.
131137
132-
This is what sizes the reservation, so it counts wrapping and explicit newlines the same
133-
way :meth:`ToolbarFrame.build` lays them out. Empty content still measures one row: an
134-
empty toolbar is an intentional visibility change, not a request for no reservation.
138+
Only explicit newlines add rows; horizontal overflow is clipped, as in
139+
:meth:`ToolbarFrame.build`. Empty content still measures one row. This helper does not
140+
change the reservation, whose height is chosen by its owner.
135141
136142
:param content: the formatted text to measure
137143
:param width: the terminal width in columns
@@ -143,22 +149,38 @@ def measure_toolbar_height(content: "AnyFormattedText", width: int) -> int:
143149
return max(1, len(_layout(content, width, "")))
144150

145151

152+
def _mark_truncated(row: list[Cell], default_style: str) -> None:
153+
"""Mark omitted content in the rightmost column without splitting a wide character.
154+
155+
:param row: a padded, nonempty row to modify
156+
:param default_style: the style for the indicator and any cleared wide-character cell
157+
"""
158+
if row[-1].is_continuation:
159+
row[-2] = Cell(" ", default_style)
160+
row[-1] = Cell("…", default_style)
161+
162+
146163
def _layout(content: "AnyFormattedText", width: int, default_style: str) -> list[list[Cell]]:
147-
"""Lay content out into as many full-width rows as it needs.
164+
"""Clip each logical line to one padded row, marking horizontal overflow.
148165
149166
:param content: the formatted text to lay out
150167
:param width: the terminal width in columns
151-
:param default_style: the style for padding cells
168+
:param default_style: the style for padding and truncation indicators
152169
:return: the rows, each padded to ``width`` cells
153170
"""
154171
rows: list[list[Cell]] = []
155172
row: list[Cell] = []
173+
clipped = False
156174

157175
def finish_row() -> None:
158-
"""Pad the row in progress and start a new one."""
176+
"""Pad and mark the row in progress, then start the next logical line."""
177+
nonlocal clipped
159178
row.extend(Cell(" ", default_style) for _ in range(width - len(row)))
179+
if clipped:
180+
_mark_truncated(row, default_style)
160181
rows.append(list(row))
161182
row.clear()
183+
clipped = False
162184

163185
for fragment in to_formatted_text(content):
164186
style, text = fragment[0], fragment[1]
@@ -168,24 +190,19 @@ def finish_row() -> None:
168190
if char == "\n":
169191
finish_row()
170192
continue
171-
if char == "\r":
193+
if char == "\r" or clipped:
172194
continue
173195
if char == "\t":
174196
spaces = TAB_WIDTH - (len(row) % TAB_WIDTH)
175-
for _ in range(spaces):
176-
if len(row) == width:
177-
finish_row()
178-
row.append(Cell(" ", style))
197+
available = width - len(row)
198+
row.extend(Cell(" ", style) for _ in range(min(spaces, available)))
199+
clipped = spaces > available
179200
continue
180201

181202
char_width = get_cwidth(char)
182203
if char_width == 0 and row:
183-
# A combining mark belongs to the character it follows; it occupies no column
184-
# of its own, so it joins that cell rather than becoming one. After a wide
185-
# character the cell to the left is that character's right half, and the mark
186-
# belongs to the half that carries the text -- attaching it to the
187-
# continuation cell would give the mark a column of its own and shift every
188-
# later cell one place right of where it is on the screen.
204+
# Combining marks still belong to a retained character at the right edge.
205+
# Once a character is clipped, its marks must be discarded with it.
189206
base = len(row) - 1
190207
if row[base].is_continuation:
191208
base -= 1
@@ -194,16 +211,14 @@ def finish_row() -> None:
194211
continue
195212
columns = max(1, char_width)
196213
if len(row) + columns > width:
197-
# Padding rather than splitting: half a wide character at the right edge is
198-
# what makes the terminal wrap the row itself, which would put toolbar cells
199-
# in a row the frame does not own.
200-
finish_row()
214+
clipped = True
215+
continue
201216
row.append(Cell(char, style))
202217
if columns == 2:
203218
row.append(Cell("", style, is_continuation=True))
204219

205-
if row:
206-
finish_row()
220+
# An empty string is one empty line; a trailing newline introduces another one.
221+
finish_row()
207222
return rows
208223

209224

‎docs/features/prompt.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,24 @@ def get_bottom_toolbar(self) -> AnyFormattedText:
9696
]
9797
```
9898

99+
### Reserved Toolbar Overflow
100+
101+
Reserved rendering currently reserves one terminal row. Each logical line is clipped to the terminal
102+
width; long lines do not wrap. A newline starts another logical line, which is omitted when there is
103+
no reserved row available. A leading newline therefore leaves an empty first line.
104+
105+
Whenever content is omitted, an ellipsis (`…`) appears in the rightmost column of the affected row.
106+
Omitted lines are indicated on the last reserved row. The indicator uses the toolbar's default
107+
style. For example, at a width of eight columns, `abcdefgh` fits unchanged, `abcdefghi` becomes
108+
`abcdefg…`, and `Ready\nDetails` becomes `Ready …`. A leading newline displays spaces followed by
109+
`…`, rather than a completely blank toolbar.
110+
111+
Widths are measured in terminal display columns, including wide and combining characters. Truncation
112+
never splits a wide character; it may leave a space before the ellipsis. Tabs expand to eight-column
113+
tab stops, and carriage returns are ignored. These rules apply to `RESERVED` and to `AUTO` when it
114+
selects reserved rendering. `LEGACY` retains prompt-toolkit's layout. The number of reserved rows is
115+
not yet configurable through `Cmd`.
116+
99117
### Refreshing the Toolbar
100118

101119
The toolbar is rendered by `prompt-toolkit` and is naturally redrawn whenever the prompt is

‎tests/test_reserved_terminal.py‎

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ def terminal_harness(request):
7272
harness.close()
7373

7474

75-
def read_prompt(harness, terminal) -> None:
75+
def read_prompt(harness, terminal, expected_toolbar="STATUS") -> None:
7676
"""Use the real input reader and CPR binding, then accept a prompt with known height."""
7777
ui = harness.app.main_session.app
7878
sent = False
@@ -92,13 +92,39 @@ def ready(app):
9292
assert sent
9393
assert terminal.reports
9494
assert all(row < terminal.screen.lines for row in terminal.reports)
95-
assert terminal.screen.display[-1].startswith("STATUS")
95+
assert terminal.screen.display[-1].startswith(expected_toolbar)
9696
finally:
9797
watchdog.cancel()
9898
watchdog.join()
9999
ui.after_render -= ready
100100

101101

102+
@pytest.mark.parametrize(
103+
("content", "expected"),
104+
[
105+
("STATUS\nsecond line", "STATUS" + " " * 73 + "…"),
106+
("\nSTATUS", " " * 79 + "…"),
107+
("S" * 81, "S" * 79 + "…"),
108+
("S" * 78 + "广x", "S" * 78 + " …"),
109+
],
110+
)
111+
def test_clipped_toolbar_survives_commands_and_prompt_refresh(terminal_harness, content, expected) -> None:
112+
harness, terminal = terminal_harness
113+
harness.app.main_session.bottom_toolbar = content
114+
with harness.app._reserved_toolbar_context():
115+
assert terminal.screen.display[-1] == expected
116+
with harness.app._command_toolbar_context():
117+
harness.app.poutput("ordinary output")
118+
assert terminal.screen.display[-1] == expected
119+
read_prompt(harness, terminal, expected_toolbar=expected)
120+
# A shorter dynamic replacement must remove the old text and indicator.
121+
harness.app.main_session.bottom_toolbar = "OK"
122+
assert harness.app.reserved_toolbar.refresh()
123+
assert terminal.screen.display[-1] == "OK" + " " * 78
124+
assert terminal.screen.margins is None
125+
assert terminal.screen.display[-1].strip() == ""
126+
127+
102128
@pytest.mark.parametrize("row", [1, 22, 23, 24])
103129
@pytest.mark.parametrize("reserved", [1, 2])
104130
def test_acquisition_keeps_existing_output_and_cursor_above_the_band(terminal_harness, row, reserved) -> None:

‎tests/test_toolbar_painter.py‎

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,29 @@ def styles_of(frame: ToolbarFrame, row: int = 0) -> list[str]:
3434

3535

3636
class TestShape:
37+
@pytest.mark.parametrize(
38+
("content", "width", "height", "expected"),
39+
[
40+
("\nSTATUS", 8, 1, [" …"]),
41+
("STATUS\nnext", 8, 1, ["STATUS …"]),
42+
("abcd", 4, 1, ["abcd"]),
43+
("abcde", 4, 1, ["abc…"]),
44+
("abcdef\nxy", 4, 2, ["abc…", "xy "]),
45+
("a\nb\nc\nd", 4, 3, ["a ", "b ", "c …"]),
46+
("abc\n", 4, 1, ["abc…"]),
47+
("广", 1, 1, ["…"]),
48+
("a广x", 3, 1, ["a …"]),
49+
("e\u0301abc", 3, 1, ["e\u0301a…"]),
50+
("abx\u0301", 3, 1, ["abx\u0301"]),
51+
("abcde\u0301\nz", 4, 2, ["abc…", "z "]),
52+
("\tX\ny", 4, 2, [" …", "y "]),
53+
],
54+
)
55+
def test_truncation_is_visible_and_does_not_wrap(self, content, width, height, expected) -> None:
56+
frame = ToolbarFrame.build(content, width=width, height=height)
57+
assert [text_of(frame, row) for row in range(height)] == expected
58+
assert all(len(row) == width for row in frame.rows)
59+
3760
def test_a_frame_is_always_exactly_its_declared_size(self) -> None:
3861
frame = ToolbarFrame.build("hi", width=10, height=2)
3962
assert len(frame.rows) == 2
@@ -45,16 +68,16 @@ def test_short_content_is_padded_with_default_style_spaces(self) -> None:
4568
assert text_of(frame) == "hi "
4669
assert styles_of(frame) == ["class:toolbar"] * 5
4770

48-
def test_content_wider_than_the_terminal_wraps(self) -> None:
71+
def test_content_wider_than_the_terminal_is_clipped(self) -> None:
4972
frame = ToolbarFrame.build("abcdef", width=3, height=2)
50-
assert text_of(frame, 0) == "abc"
51-
assert text_of(frame, 1) == "def"
73+
assert text_of(frame, 0) == "ab…"
74+
assert text_of(frame, 1) == " "
5275

5376
def test_content_taller_than_the_band_is_truncated(self) -> None:
5477
"""Growing past the band is a geometry transition, never a write outside it."""
5578
frame = ToolbarFrame.build("one\ntwo\nthree", width=10, height=2)
5679
assert text_of(frame, 0) == "one "
57-
assert text_of(frame, 1) == "two "
80+
assert text_of(frame, 1) == "two …"
5881

5982
def test_an_explicit_newline_starts_a_row(self) -> None:
6083
frame = ToolbarFrame.build("a\nb", width=3, height=2)
@@ -105,10 +128,10 @@ def test_a_wide_character_occupies_two_cells(self) -> None:
105128
def test_a_wide_character_is_never_split_at_the_right_edge(self) -> None:
106129
"""Half a wide character at the edge is what wraps a row into the one below it."""
107130
frame = ToolbarFrame.build("a广", width=2, height=2)
108-
assert text_of(frame, 0) == "a "
109-
assert frame.rows[1][0].char == "广"
131+
assert text_of(frame, 0) == "a…"
132+
assert text_of(frame, 1) == " "
110133

111-
def test_the_pad_before_a_wrapped_wide_character_uses_the_default_style(self) -> None:
134+
def test_the_truncation_indicator_uses_the_default_style(self) -> None:
112135
frame = ToolbarFrame.build([("bold", "a广")], width=2, height=2, default_style="base")
113136
assert styles_of(frame, 0) == ["bold", "base"]
114137

@@ -137,8 +160,8 @@ class TestMeasurement:
137160
def test_a_short_toolbar_is_one_row(self) -> None:
138161
assert measure_toolbar_height("hi", width=10) == 1
139162

140-
def test_wrapping_is_counted(self) -> None:
141-
assert measure_toolbar_height("abcdef", width=3) == 2
163+
def test_horizontal_overflow_does_not_add_rows(self) -> None:
164+
assert measure_toolbar_height("abcdef", width=3) == 1
142165

143166
def test_newlines_are_counted(self) -> None:
144167
assert measure_toolbar_height("a\nb\nc", width=10) == 3
@@ -148,10 +171,10 @@ def test_empty_content_still_measures_one_row(self) -> None:
148171
assert measure_toolbar_height("", width=10) == 1
149172

150173
def test_measurement_matches_the_frame_it_would_build(self) -> None:
151-
content = [("bold", "wide 广 content that wraps around")]
174+
content = [("bold", "wide 广 content that is clipped\nlast row")]
152175
height = measure_toolbar_height(content, width=12)
153176
frame = ToolbarFrame.build(content, width=12, height=height)
154-
# Nothing was truncated: the last row is where the content ended.
177+
# Horizontal clipping does not move content onto the last logical line.
155178
assert measure_toolbar_height(content, width=12) == len(frame.rows)
156179
assert text_of(frame, height - 1).strip() != ""
157180

@@ -181,12 +204,12 @@ def test_a_frame_reports_its_own_size(self) -> None:
181204
def test_an_empty_frame_reports_zero_width(self) -> None:
182205
assert ToolbarFrame(rows=()).width == 0
183206

184-
def test_a_tab_wraps_when_it_reaches_the_edge(self) -> None:
185-
"""The expansion is cells, so it wraps like any other run of them."""
207+
def test_a_tab_is_clipped_when_it_reaches_the_edge(self) -> None:
208+
"""Tab expansion cannot spill into the next reserved row."""
186209
frame = ToolbarFrame.build("a\tb", width=4, height=3)
187-
assert text_of(frame, 0) == "a "
210+
assert text_of(frame, 0) == "a …"
188211
assert text_of(frame, 1) == " "
189-
assert text_of(frame, 2) == "b "
212+
assert text_of(frame, 2) == " "
190213

191214
def test_a_combining_mark_after_a_wide_character_joins_that_character(self) -> None:
192215
"""Attaching it to the continuation cell instead shifts every later column."""

0 commit comments

Comments
 (0)