diff --git a/meshterm/ui/tui/colsnap.py b/meshterm/ui/tui/colsnap.py index 94d29c6..59f4b32 100644 --- a/meshterm/ui/tui/colsnap.py +++ b/meshterm/ui/tui/colsnap.py @@ -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. diff --git a/tests/test_colsnap.py b/tests/test_colsnap.py index e762b36..4f3b1e7 100644 --- a/tests/test_colsnap.py +++ b/tests/test_colsnap.py @@ -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"), ]