A glyph's reserved cells are erased before it is drawn

A pin jumps over whatever part of a glyph's reservation the font did not
cover, and nothing wrote that cell. prompt_toolkit never writes the cell
after a wide glyph either, since it counts it as the glyph's own, so a
repaint that put an emoji where two letters had been left the second letter
standing beside it on a terminal that draws the emoji in one cell. On the
fast path the row is erased first, but only in the default colours, so the
same cell was a hole in a chip's fill.

Both pins now erase the reservation in the current colours before drawing
the glyph, with ECH, which blanks cells without moving the cursor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5 committed 2026-09-16 20:15:08 -04:00
1 parent 6f7b24e790
commit 68c528938c
2 files changed
+57 -11

No files matched your search

+24 -5
View File
@@ -33,8 +33,10 @@ with ours before the next glyph is drawn. The cursor is re-pinned to the app's o
every point where a disagreement could have started, and a mismeasured glyph can no longer
move anything but itself:
* Drawn **narrower** than we reserved, it leaves a blank cell after it (the row is erased
before it is drawn, so that cell is bare page rather than debris).
* Drawn **narrower** than we reserved, it leaves a blank cell after it. The cells a glyph is
reserved are erased in its own colours before it is drawn (``CSI n X``), so that cell is
blank in the right background: never a character left over from the last frame, and never a
hole in the fill of a chip.
* Drawn **wider**, its overhang is written over by whatever the next column holds.
Both are one cell of cosmetic damage inside the glyph's own lane, where a shift was a whole row
@@ -149,6 +151,16 @@ _SAVE = "\x1b7"
_RESTORE = "\x1b8"
def _erase(width: int) -> str:
"""ECH: blank ``width`` cells from the cursor in the current colours, without moving it.
Written in front of a glyph reserved more than one cell, so every cell of its reservation is
painted by this frame whatever the font does with the glyph: the background a chip or a
highlighted row filled it with, rather than whatever the terminal last held there.
"""
return f"\x1b[{width}X"
def enabled() -> bool:
"""Whether written glyphs are pinned to the columns the app measured them into.
@@ -236,9 +248,13 @@ def _pin_text(text: str, parts: list[str], column: int, owed: int) -> tuple[int,
if owed >= 0:
parts.append(f"\x1b[{owed + 1}G")
owed = -1
width = cell_len(cluster)
pinned = _pinned(cluster)
if pinned and width > 1:
parts.append(_erase(width))
parts.append(cluster)
column += cell_len(cluster)
if _pinned(cluster):
column += width
if pinned:
owed = column
return column, owed
@@ -285,7 +301,10 @@ class PinnedOutput:
inner.write(data)
return
width = get_cwidth(data)
inner.write_raw(_SAVE)
# The renderer skips the cells after a wide glyph as the glyph's own and never writes
# them, so erasing them first is what keeps a glyph drawn narrower from leaving the
# last frame's character in the cell it did not cover.
inner.write_raw(_SAVE + _erase(width) if width > 1 else _SAVE)
inner.write(data)
# A forward step of zero is read as a step of one, so a glyph that measures nothing
# (a stranded joiner) just returns to where it started.
+33 -6
View File
@@ -93,7 +93,7 @@ def test_an_emoji_is_followed_by_the_column_it_was_measured_into() -> None:
"""The whole mechanism: the glyph, then the one-based column the next glyph belongs in."""
assert cell_len(_WAVE) == 2, "the stock table measures the wave at two cells"
# "a" fills column 0, the wave is measured into columns 1-2, so "b" belongs in column 3.
assert colsnap.snap_row(f"a{_WAVE}b") == f"a{_WAVE}\x1b[4Gb"
assert colsnap.snap_row(f"a{_WAVE}b") == f"a\x1b[2X{_WAVE}\x1b[4Gb"
def test_nothing_is_pinned_after_the_last_glyph_on_the_row() -> None:
@@ -102,21 +102,22 @@ def test_nothing_is_pinned_after_the_last_glyph_on_the_row() -> None:
A chat line ending on an emoji is the common case, and there is nothing after it whose
column could be wrong.
"""
assert colsnap.snap_row(f"hi {_WAVE}") == f"hi {_WAVE}"
assert colsnap.snap_row(f"hi {_WAVE}") == f"hi \x1b[2X{_WAVE}"
def test_a_style_change_occupies_no_column_and_stays_where_the_theme_put_it() -> None:
"""A colour run is copied through without advancing the column it would have shifted."""
pinned = colsnap.snap_row(f"\x1b[31m{_WAVE}\x1b[0m|")
assert pinned == f"\x1b[31m{_WAVE}\x1b[0m\x1b[3G|"
assert pinned == f"\x1b[31m\x1b[2X{_WAVE}\x1b[0m\x1b[3G|"
def test_a_flag_and_a_joined_sequence_are_pinned_once_as_whole_glyphs() -> None:
"""One address per glyph, not per codepoint — the cut that halves a flag is never made."""
for glyph in (_CA, _FAMILY):
width = cell_len(glyph)
pinned = colsnap.snap_row(f"{glyph}|")
assert pinned == f"{glyph}\x1b[{cell_len(glyph) + 1}G|"
assert pinned.count("\x1b[") == 1
assert pinned == f"\x1b[{width}X{glyph}\x1b[{width + 1}G|"
assert pinned.count("G") == 1
def test_a_bare_text_emoji_is_pinned_even_though_both_tables_agree_about_it() -> None:
@@ -314,6 +315,9 @@ class _Tty:
row = numbers[0] if numbers and numbers[0] else 1
column = numbers[1] if len(numbers) > 1 and numbers[1] else 1
self.y, self.x = row - 1, column - 1
elif final == "X":
for column in range(self.x, min(self.cols, self.x + count)):
self.grid[self.y][column] = " "
elif final == "K":
self.grid[self.y][self.x :] = [" "] * (self.cols - self.x)
elif final == "J":
@@ -474,6 +478,29 @@ def test_a_repaint_after_the_glyph_lands_where_the_renderer_measured_it() -> Non
assert (tty.glyph_at(0, seven) == "7") is lands
def test_a_glyph_drawn_narrow_leaves_no_character_from_the_last_frame_beside_it() -> None:
"""The cell a narrow-drawn glyph does not cover is blank, not what the last frame put there.
prompt_toolkit treats the cell after a wide glyph as part of it and never writes it, so a
repaint that puts a two-cell glyph where two letters were leaves the second letter standing
on a terminal that draws the glyph in one cell. The pin erases its reservation first.
"""
cols, rows = 16, 1
before, after = _screen(["│ ab Lakeside │"], cols), _screen([f"│ {_WAVE} Lakeside │"], cols)
for pin, stale in ((False, True), (True, False)):
output = _vt100(cols, rows)
if pin:
output = colsnap.PinnedOutput(output)
tty = _Tty(cols, rows, draws={_WAVE: 1})
position = _paint(output, before, None, _point(), cols, rows)
tty.feed(_drained(output))
_paint(output, after, before, position, cols, rows)
tty.feed(_drained(output))
assert tty.glyph_at(0, 2) == _WAVE
assert (tty.glyph_at(0, 3) == "b") is stale
def test_the_pinned_output_brackets_only_a_lone_uncertain_glyph() -> None:
"""What reaches the terminal for each kind of write prompt_toolkit's renderer makes."""
@@ -507,7 +534,7 @@ def test_the_pinned_output_brackets_only_a_lone_uncertain_glyph() -> None:
step = get_cwidth(glyph)
assert recorder.calls == [
("raw", "\x1b7"),
("raw", f"\x1b7\x1b[{step}X"),
("write", glyph),
("raw", f"\x1b8\x1b[{step}C"),
]