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:
1 parent
6f7b24e790
commit
68c528938c
2 files changed
+57
-11
No files matched your search
@@ -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
@@ -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"),
|
||||
]
|
||||
|
||||
Reference in new issue
Block a user