diff --git a/meshterm/ui/tui/colsnap.py b/meshterm/ui/tui/colsnap.py index b2145b9..94d29c6 100644 --- a/meshterm/ui/tui/colsnap.py +++ b/meshterm/ui/tui/colsnap.py @@ -56,16 +56,27 @@ so the list is a performance claim and never the thing correctness rests on. Tha point of the inversion: the old exception sets had to be *complete* to keep the app aligned, and this one only has to be *cheap*. -**Where this applies.** :mod:`~meshterm.ui.tui.fastrender` writes each changed row itself, as -one run from column 0, and that is the only place in the app where a row's bytes are ours to -touch on their way out. A frame prompt_toolkit lays out instead — a floating dialog, and the -backdrop it forces a full repaint of — still goes through its differential renderer, which -tracks the cursor in arithmetic of its own and is not corrected here. +**Two writers, two spellings of the same pin.** A frame reaches the terminal one of two ways, +and each knows something different about where its cursor is: -The escape hatch is ``MESHTERM_COLUMN_SNAP=0``, the same shape as the emoji-width knobs next -door (``MESHTERM_EMOJI_VS16``, ``MESHTERM_NARROW_EMOJI``, ``MESHTERM_WIDE_EMOJI``) and for the -same reason: this is terminal geometry, read once at session build, not a choice anybody makes -about how the app behaves. +* :mod:`~meshterm.ui.tui.fastrender` writes a plain full-screen frame itself, each changed row + as one run from column 0. It knows every column absolutely, so :func:`snap_row` spells the + pin as an absolute address, ``CSI n G``. +* Anything prompt_toolkit lays out — a floating dialog, and the backdrop it repaints — goes out + through its differential renderer, which never knows a column absolutely: it steps a cursor + of its own by relative moves and adds each character's measured width as it writes. So + :class:`PinnedOutput` spells the pin *relative to the glyph itself* — save the cursor, draw + the glyph, restore, step forward exactly the width the renderer is about to add. The + terminal's cursor then lands where the renderer's arithmetic says it is, after every glyph, + and a relative move computed from that arithmetic is a correct move. Nothing is tracked, so + nothing can drift out of step with the renderer's own bookkeeping. + +A platform that never draws a glyph outside its own font has nothing to pin, and pins nothing: +the PicoCalc folds every emoji away before it reaches the console, and every glyph it does draw +is one :mod:`~meshterm.ui.fontset` has verified on the device. + +The escape hatch is ``MESHTERM_COLUMN_SNAP=0``: this is terminal geometry, read once at session +build, not a choice anybody makes about how the app behaves. """ from __future__ import annotations @@ -73,9 +84,12 @@ from __future__ import annotations import os import re from collections import OrderedDict +from typing import Any +from prompt_toolkit.utils import get_cwidth from rich.cells import cell_len +from ...platforms import get_platform from .emoji_width import clusters #: The glyph ranges MeshTerm draws in bulk whose single-cell width both width authorities @@ -127,15 +141,24 @@ _CACHE: OrderedDict[str, str] = OrderedDict() #: Rows the cache holds before evicting the least recently used. _CACHE_MAX = 1024 +#: DECSC and DECRC: save the cursor, and return to it. What :class:`PinnedOutput` brackets a +#: glyph with, so the step after it is measured from where the glyph *started* rather than from +#: wherever the terminal's own width table left the cursor. Both also save and restore the +#: current colour, which is harmless here: nothing changes it between the two. +_SAVE = "\x1b7" +_RESTORE = "\x1b8" + def enabled() -> bool: - """Whether written rows carry absolute column addresses. + """Whether written glyphs are pinned to the columns the app measured them into. - On always, with ``MESHTERM_COLUMN_SNAP=0`` to turn it off for one run — the escape hatch a - terminal that mishandles ``CSI n G`` would need, and the switch that shows what the - alignment looks like without it. Read at session build, like its neighbours. + On wherever the platform draws emoji, with ``MESHTERM_COLUMN_SNAP=0`` to turn it off for one + run — the escape hatch a terminal that mishandles the pins would need, and the switch that + shows what the alignment looks like without them. Off on a platform that draws no emoji at + all, where every glyph is one the platform's own font has already verified. Read at session + build, like its neighbours. """ - return os.environ.get("MESHTERM_COLUMN_SNAP") != "0" + return os.environ.get("MESHTERM_COLUMN_SNAP") != "0" and get_platform().emoji def _pinned(cluster: str) -> bool: @@ -220,4 +243,57 @@ def _pin_text(text: str, parts: list[str], column: int, owed: int) -> tuple[int, return column, owed -__all__ = ["enabled", "snap_row"] +def _lone_pinned_glyph(data: str) -> bool: + """Whether ``data`` is exactly one glyph, and one :func:`_pinned` says needs pinning. + + What prompt_toolkit's renderer writes is one screen cell's content at a time — a character, + or a whole sequence it was handed as one (see + :class:`~meshterm.ui.tui.emoji_width.ClusterTextControl`) — so that is the only shape pinned. + Anything longer is not a cell, and passes through as it came. + """ + if len(data) == 1: + return bool(_UNPINNED.match(data)) + for cluster in clusters(data): + return cluster == data + return False + + +class PinnedOutput: + """A prompt_toolkit ``Output`` proxy that pins every uncertain glyph it is asked to write. + + prompt_toolkit's renderer writes a screen cell with ``write`` and then adds that cell's + measured width to a cursor it keeps for itself; every later move on the row is computed + from that cursor, relatively. Where the terminal draws the glyph at another width, the two + cursors part company and every move after it lands a column out. This proxy closes the gap + at the one moment it opens: a glyph :func:`_pinned` cannot vouch for is written between a + cursor save and a restore, and followed by a forward step of exactly the width the renderer + is about to add — so the terminal's cursor is back in step before the renderer's next move. + + Everything else — ``write_raw``, the cursor moves, the size, the attributes — forwards to the + wrapped output untouched, and so does ``write`` for anything that is not a lone uncertain + glyph, which is almost every call it gets. + """ + + def __init__(self, inner: Any) -> None: + """Wrap ``inner``, the concrete prompt_toolkit output for the real terminal.""" + self._inner = inner + + def write(self, data: str) -> None: + """Write ``data``, pinning the cursor after it when it is a glyph worth pinning.""" + inner = self._inner + if data.isascii() or not _lone_pinned_glyph(data): + inner.write(data) + return + width = get_cwidth(data) + inner.write_raw(_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. + inner.write_raw(f"{_RESTORE}\x1b[{width}C" if width else _RESTORE) + + def __getattr__(self, name: str) -> Any: + """Forward every other attribute and method straight to the wrapped output.""" + return getattr(self._inner, name) + + +__all__ = ["PinnedOutput", "enabled", "snap_row"] diff --git a/meshterm/ui/tui/emoji_width.py b/meshterm/ui/tui/emoji_width.py index 7c9f281..9333e37 100644 --- a/meshterm/ui/tui/emoji_width.py +++ b/meshterm/ui/tui/emoji_width.py @@ -260,7 +260,7 @@ def _joined_out(text: str) -> str: def clusters(text: str) -> Iterator[str]: """Split ``text`` into the glyphs the terminal actually draws, never inside one. - The same run :func:`_join_zwj_clusters` gathers out of a laid-out line, read off a plain + The same run :func:`_join_clusters` gathers out of a laid-out line, read off a plain string instead: a base, whatever extends it (:data:`_EXTENDERS` — a variation selector, a skin tone), then any number of *joiner + component + extenders* groups, plus the Regional Indicator **pair** a country flag is drawn from. A joiner that joins nothing @@ -751,21 +751,32 @@ class _Cluster(str): yield str(self) -def _join_zwj_clusters(line: list) -> list: - """Merge each emoji ZWJ sequence in one screen line into a single :class:`_Cluster` fragment. +def _join_clusters(line: list) -> list: + """Merge each multi-glyph emoji in one screen line into a single :class:`_Cluster` fragment. The line arrives one codepoint per fragment (that is what - :class:`~prompt_toolkit.formatted_text.ANSI` produces), so a sequence is a run to be - gathered: a base, its optional variation selector or skin tone (:data:`_EXTENDERS`), then any - number of *joiner + component + optional extenders* groups. A run with no joiner in it is - handed back untouched — including a lone VS16 pair or toned emoji, which prompt_toolkit - already folds into the preceding cell on its own, and a joiner with no pictograph after it - (see :func:`_joins`), which is not a sequence at all. + :class:`~prompt_toolkit.formatted_text.ANSI` produces), so a glyph built from several is a run + to be gathered, the same run :func:`clusters` reads off a plain string. Two shapes need it: - The whole line is handed back unchanged when it holds no joiner, which is almost every line; - the scan is one comparison per cell and runs only when a control's content actually changes. + * **A ZWJ sequence**: a base, its optional variation selector or skin tone + (:data:`_EXTENDERS`), then any number of *joiner + component + optional extenders* groups. + prompt_toolkit would otherwise give each component a cell of its own. + * **A flag**: a pair of Regional Indicators. Each indicator takes a cell of its own in + prompt_toolkit's arithmetic, and summing them was already made right; what merging adds is + that the pair is *written* as one. The renderer's writes are what + :class:`~meshterm.ui.tui.colsnap.PinnedOutput` pins, and a pin between the two halves of + a flag is a cursor move between them, after which a terminal no longer joins them into + one glyph at all. + + A run with neither is handed back untouched — including a lone VS16 pair or toned emoji, + which prompt_toolkit already folds into the preceding cell on its own, and a joiner with no + pictograph after it (see :func:`_joins`), which is not a sequence at all. + + The whole line is handed back unchanged when it holds no joiner and no indicator, which is + almost every line; the scan is one pass per line and runs only when a control's content + actually changes. """ - if not any(item[1] == _ZWJ for item in line): + if not any(item[1] == _ZWJ or _is_regional_indicator(item[1]) for item in line): return line merged: list = [] index = 0 @@ -775,6 +786,15 @@ def _join_zwj_clusters(line: list) -> list: while end < count and line[end][1] in _EXTENDERS: end += 1 joined = False + if ( + _is_regional_indicator(line[index][1]) + and end < count + and _is_regional_indicator(line[end][1]) + ): + joined = True + end += 1 + while end < count and line[end][1] in _EXTENDERS: + end += 1 while end + 1 < count and line[end][1] == _ZWJ and _joins(line[end + 1][1]): joined = True end += 2 # the joiner and the codepoint it joins @@ -794,7 +814,7 @@ class ClusterTextControl(FormattedTextControl): """A :class:`~prompt_toolkit.layout.controls.FormattedTextControl` that keeps emoji whole. The control is the last place a screen's text is still arranged in lines and still ours to - touch, which is exactly what merging a ZWJ sequence needs: ``split_lines`` runs before this + touch, which is exactly what merging a sequence needs: ``split_lines`` runs before this point and returns plain ``str`` parts, so a :class:`_Cluster` marked any earlier would not survive to be laid out. Everything downstream — the width lookup, the wrapping, the screen buffer — reads the merged lines. @@ -823,7 +843,7 @@ class ClusterTextControl(FormattedTextControl): def get_line(i: int, _source=source, _cache=cache) -> list: line = _cache.get(i) if line is None: - line = _cache[i] = _join_zwj_clusters(_source(i)) + line = _cache[i] = _join_clusters(_source(i)) return line content.get_line = get_line diff --git a/meshterm/ui/tui/session.py b/meshterm/ui/tui/session.py index 1e1f644..c1e2f84 100644 --- a/meshterm/ui/tui/session.py +++ b/meshterm/ui/tui/session.py @@ -34,7 +34,7 @@ from rich.text import Text from ...core import win32dll from ...platforms import get_platform from ...services import modifier_watch -from . import fastrender, fkeys, frame +from . import colsnap, fastrender, fkeys, frame from .emoji_width import ClusterTextControl from .overlay import BusyOverlay from .progress import TuiProgress @@ -1665,18 +1665,33 @@ class TuiSession: return app def _resolve_output(self) -> Any: - """The output the app renders to — optionally widened to reclaim the last column. + """The output the app renders to: the real terminal, pinned and optionally widened. Only the *real* terminal (``self._output is None``, so prompt_toolkit would build its - own output) is wrapped, and only when :func:`_reclaim_last_column` says on: a test - that supplies its own output keeps the exact size it set, so headless rendering stays - deterministic. See :class:`_WidthExtendedOutput` for what the wrap does. + own output) is wrapped: a test that supplies its own output keeps the exact size and + the exact bytes it set, so headless rendering stays deterministic. Two wraps, each + where its own gate says so, and either may be the only one: + + * :class:`~meshterm.ui.tui.colsnap.PinnedOutput`, where + :func:`~meshterm.ui.tui.colsnap.enabled` — every glyph prompt_toolkit's renderer + writes lands in the column that renderer measured it into. + * :class:`_WidthExtendedOutput`, where :func:`_reclaim_last_column` — outermost, since + the size it reports is what both renderers and the compositor lay out against. + + With neither, this is ``None`` and prompt_toolkit builds its own output as before. """ - if self._output is not None or not _reclaim_last_column(): + pin = colsnap.enabled() + reclaim = _reclaim_last_column() + if self._output is not None or not (pin or reclaim): return self._output from prompt_toolkit.output.defaults import create_output - return _WidthExtendedOutput(create_output()) + output: Any = create_output() + if pin: + output = colsnap.PinnedOutput(output) + if reclaim: + output = _WidthExtendedOutput(output) + return output # --- rendering ----------------------------------------------------------- diff --git a/tests/test_colsnap.py b/tests/test_colsnap.py index b84d592..e762b36 100644 --- a/tests/test_colsnap.py +++ b/tests/test_colsnap.py @@ -246,3 +246,283 @@ def test_the_row_writer_pins_what_it_writes(monkeypatch) -> None: # noqa: ANN00 plain = FastRenderer(Style([]), off, full_screen=True, frame_source=lambda: frame) plain.render(None, None) assert "\x1b[11G" not in "".join(off.written) + + +def test_a_platform_that_draws_no_emoji_pins_nothing() -> None: + """The PicoCalc draws only glyphs its own font has verified, so there is nothing to pin.""" + from meshterm.platforms import PICOCALC, set_platform + + assert colsnap.enabled() + set_platform(PICOCALC) + assert not colsnap.enabled() + + +# --- prompt_toolkit's renderer: the dialog path ------------------------------------------------- + + +class _Tty: + """A two-dimensional terminal, for the renderer that moves its cursor relatively. + + Honours what prompt_toolkit's differential renderer and :class:`colsnap.PinnedOutput` send: + carriage return, newline, backspace, cursor save and restore, and the CSI cursor moves. + Colour and mode sequences are ignored, which is all a terminal does with them as far as + column arithmetic goes. Glyph widths come from prompt_toolkit's own measurement unless + ``draws`` names a glyph this terminal disagrees about. + """ + + _SEQUENCE = re.compile(r"\x1b(?:\[([0-9;?]*)([A-Za-z@`])|([78]))") + + def __init__(self, cols: int, rows: int, *, draws: dict[str, int] | None = None) -> None: + """Start blank, with the cursor home.""" + self.cols, self.rows = cols, rows + self.draws = draws or {} + self.grid = [[" "] * cols for _ in range(rows)] + self.x = self.y = 0 + self._saved = (0, 0) + + def feed(self, data: str) -> None: + """Interpret ``data`` as the terminal would.""" + position = 0 + for match in self._SEQUENCE.finditer(data): + self._text(data[position : match.start()]) + position = match.end() + if match.group(3) == "7": + self._saved = (self.x, self.y) + elif match.group(3) == "8": + self.x, self.y = self._saved + else: + self._csi(match.group(1), match.group(2)) + self._text(data[position:]) + + def _csi(self, params: str, final: str) -> None: + """One control sequence: the cursor moves, the erases, and nothing else.""" + if params.startswith("?"): + return + numbers = [int(part) if part else 0 for part in params.split(";")] if params else [] + count = numbers[0] if numbers and numbers[0] else 1 + if final == "A": + self.y = max(0, self.y - count) + elif final == "B": + self.y = min(self.rows - 1, self.y + count) + elif final == "C": + self.x = min(self.cols - 1, self.x + count) + elif final == "D": + self.x = max(0, self.x - count) + elif final == "G": + self.x = count - 1 + elif final in "Hf": + 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 == "K": + self.grid[self.y][self.x :] = [" "] * (self.cols - self.x) + elif final == "J": + self.grid[self.y][self.x :] = [" "] * (self.cols - self.x) + for row in range(self.y + 1, self.rows): + self.grid[row] = [" "] * self.cols + + def _text(self, text: str) -> None: + """Place each glyph at the cursor, advancing by this terminal's idea of its width.""" + from prompt_toolkit.utils import get_cwidth + + for cluster in clusters(text): + if cluster == "\r": + self.x = 0 + elif cluster == "\n": + self.y = min(self.rows - 1, self.y + 1) + elif cluster == "\b": + self.x = max(0, self.x - 1) + else: + size = self.draws.get(cluster, get_cwidth(cluster)) + if self.x < self.cols: + self.grid[self.y][self.x] = cluster + for overhang in range(1, size): + if self.x + overhang < self.cols: + self.grid[self.y][self.x + overhang] = "" + self.x += size + + def glyph_at(self, row: int, column: int) -> str: + """The glyph drawn at ``(row, column)``.""" + return self.grid[row][column] + + +def _screen(rows: list[str], cols: int): # noqa: ANN202 - a prompt_toolkit Screen + """Lay ``rows`` out exactly as prompt_toolkit lays out a window's text.""" + from prompt_toolkit.layout import Window + from prompt_toolkit.layout.controls import FormattedTextControl + from prompt_toolkit.layout.mouse_handlers import MouseHandlers + from prompt_toolkit.layout.screen import Screen, WritePosition + + screen = Screen() + window = Window(FormattedTextControl("\n".join(rows)), wrap_lines=False) + window.write_to_screen( + screen, MouseHandlers(), WritePosition(0, 0, cols, len(rows)), "", False, None + ) + return screen + + +def _paint(output, screen, previous, position, cols: int, rows: int): # noqa: ANN001, ANN202 + """One pass of prompt_toolkit's own differential writer over ``screen``.""" + from types import SimpleNamespace + + from prompt_toolkit.data_structures import Size + from prompt_toolkit.output.color_depth import ColorDepth + from prompt_toolkit.renderer import ( + _output_screen_diff, + _StyleStringHasStyleCache, + _StyleStringToAttrsCache, + ) + from prompt_toolkit.styles import DummyStyleTransformation, Style + + attrs = _StyleStringToAttrsCache(Style([]).get_attrs_for_style_str, DummyStyleTransformation()) + position, _style = _output_screen_diff( + SimpleNamespace(layout=SimpleNamespace(current_window=None)), + output, + screen, + position, + ColorDepth.DEPTH_24_BIT, + previous, + None, + False, + True, + attrs, + _StyleStringHasStyleCache(attrs), + Size(rows=rows, columns=cols), + cols if previous is not None else 0, + ) + return position + + +def _vt100(cols: int, rows: int): # noqa: ANN202 - a Vt100_Output writing into memory + """A real VT100 output, so every cursor move is spelled the way the terminal receives it.""" + import io + + from prompt_toolkit.data_structures import Size + from prompt_toolkit.output.vt100 import Vt100_Output + + return Vt100_Output(io.StringIO(), lambda: Size(rows=rows, columns=cols), term="xterm") + + +def _drained(output) -> str: # noqa: ANN001 + """Everything written to ``output`` since the last drain.""" + vt100 = getattr(output, "_inner", output) + data = "".join(vt100._buffer) + vt100._buffer.clear() + return data + + +def _assert_landed(tty: _Tty, row: int, text: str) -> None: + """Every visible glyph of ``text`` is in the column prompt_toolkit measured it into.""" + from prompt_toolkit.utils import get_cwidth + + column = 0 + for cluster in clusters(text): + if cluster != " ": + assert tty.glyph_at(row, column) == cluster, f"column {column} of row {row}" + column += get_cwidth(cluster) + + +def test_a_dialog_row_frames_flush_on_a_terminal_that_draws_the_glyph_narrow() -> None: + """prompt_toolkit's renderer, unpinned and pinned, on a terminal that disagrees about 👋. + + The renderer writes the whole row in one run after its first move, adding two cells for the + wave to a cursor of its own while the terminal adds one. Unpinned, the rest of the row is + drawn a column early. Pinned, every glyph is where the renderer measured it. + """ + cols, rows = 24, 2 + text = [f"│ {_WAVE} Lakeside 5m │", "│ Plain row 5m │"] + screen = _screen(text, cols) + + loose, loose_tty = _vt100(cols, rows), _Tty(cols, rows, draws={_WAVE: 1}) + _paint(loose, screen, None, _point(), cols, rows) + loose_tty.feed(_drained(loose)) + assert loose_tty.glyph_at(0, cell_len(text[0]) - 1) != "│", "unpinned, the border moves" + + pinned, pinned_tty = ( + colsnap.PinnedOutput(_vt100(cols, rows)), + _Tty(cols, rows, draws={_WAVE: 1}), + ) + _paint(pinned, screen, None, _point(), cols, rows) + pinned_tty.feed(_drained(pinned)) + for row, line in enumerate(text): + _assert_landed(pinned_tty, row, line) + + +def test_a_repaint_after_the_glyph_lands_where_the_renderer_measured_it() -> None: + """The move that follows a pinned glyph is computed relatively, and still lands. + + A second paint changes the glyph *and* a value later on the same row, with unchanged cells + between them, so the renderer writes the new glyph and then jumps forward by the distance its + own arithmetic says. That jump is only right if the terminal's cursor was put back in step + after the glyph — which is the whole claim of the relative pin. + """ + cols, rows = 24, 1 + first = [f"│ {_WAVE} Lakeside 5m │"] + second = [f"│ {_ROCKET} Lakeside 7m │"] + before, after = _screen(first, cols), _screen(second, cols) + + for pin, lands in ((False, False), (True, True)): + output = _vt100(cols, rows) + if pin: + output = colsnap.PinnedOutput(output) + tty = _Tty(cols, rows, draws={_WAVE: 1, _ROCKET: 1}) + position = _paint(output, before, None, _point(), cols, rows) + tty.feed(_drained(output)) + _paint(output, after, before, position, cols, rows) + tty.feed(_drained(output)) + seven = cell_len(second[0].split("7m")[0]) + assert (tty.glyph_at(0, seven) == "7") is lands + + +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.""" + + class _Recorder: + def __init__(self) -> None: + self.calls: list[tuple[str, str]] = [] + + def write(self, data: str) -> None: + self.calls.append(("write", data)) + + def write_raw(self, data: str) -> None: + self.calls.append(("raw", data)) + + recorder = _Recorder() + output = colsnap.PinnedOutput(recorder) + for data in ("a", "\r\n", "│", "⠿", "é", "ab"): + output.write(data) + assert recorder.calls == [ + ("write", "a"), + ("write", "\r\n"), + ("write", "│"), + ("write", "⠿"), + ("write", "é"), + ("write", "ab"), + ], "a cell of text passes straight through" + + for glyph in (_WAVE, _FAMILY, _CA): + recorder.calls.clear() + output.write(glyph) + from prompt_toolkit.utils import get_cwidth + + step = get_cwidth(glyph) + assert recorder.calls == [ + ("raw", "\x1b7"), + ("write", glyph), + ("raw", f"\x1b8\x1b[{step}C"), + ] + + recorder.calls.clear() + output.write(f"{_WAVE}{_WAVE}") # two glyphs is not a cell, and is not pinned + assert recorder.calls == [("write", f"{_WAVE}{_WAVE}")] + assert output.calls is recorder.calls # everything else forwards + + +_ROCKET = "\U0001f680" # 🚀 a second emoji the disagreeing terminal draws in one cell + + +def _point(): # noqa: ANN202 - a prompt_toolkit Point at the origin + """The cursor at home, where a first paint starts.""" + from prompt_toolkit.data_structures import Point + + return Point(x=0, y=0) diff --git a/tests/test_emoji_width.py b/tests/test_emoji_width.py index eb21a98..9b1a30a 100644 --- a/tests/test_emoji_width.py +++ b/tests/test_emoji_width.py @@ -320,7 +320,7 @@ def test_drawable_folds_only_what_no_terminal_can_draw() -> None: assert ew.drawable(f"Bob {_FAMILY}{_THUMB}") == f"Bob {_FAMILY}{_THUMB}" -def test_join_zwj_clusters_merges_only_joined_runs() -> None: +def test_join_clusters_merges_only_joined_runs() -> None: """The fragment merge gathers a whole sequence and leaves everything else alone. prompt_toolkit's ANSI text arrives one codepoint per fragment, so a sequence is a run: @@ -335,29 +335,35 @@ def test_join_zwj_clusters_merges_only_joined_runs() -> None: # A line with no joiner is handed straight back — the same object, not a copy. plain = line("hi 📡") - assert ew._join_zwj_clusters(plain) is plain + assert ew._join_clusters(plain) is plain # The shrug's four fragments become one; the bars around it are untouched. - assert texts(ew._join_zwj_clusters(line(f"|{_SHRUG}|"))) == ["|", _SHRUG, "|"] + assert texts(ew._join_clusters(line(f"|{_SHRUG}|"))) == ["|", _SHRUG, "|"] # Three joined people, five fragments, still one glyph. - assert texts(ew._join_zwj_clusters(line(_FAMILY))) == [_FAMILY] + assert texts(ew._join_clusters(line(_FAMILY))) == [_FAMILY] # A selector on the *base*, before the joiner, belongs to the run: ❤️‍🔥. burning = "❤️‍\U0001f525" - assert texts(ew._join_zwj_clusters(line(burning))) == [burning] + assert texts(ew._join_clusters(line(burning))) == [burning] # A bare VS16 pair is not a sequence — prompt_toolkit already folds it into the cell # before it, so those fragments are left exactly as they came. - assert texts(ew._join_zwj_clusters(line(f"☀️{_SHRUG}"))) == ["☀", "️", _SHRUG] + assert texts(ew._join_clusters(line(f"☀️{_SHRUG}"))) == ["☀", "️", _SHRUG] # A trailing joiner with nothing to join is not a sequence either. - assert texts(ew._join_zwj_clusters(line(f"a{_ZWJ}"))) == ["a", _ZWJ] + assert texts(ew._join_clusters(line(f"a{_ZWJ}"))) == ["a", _ZWJ] # A skin tone on the base belongs to the run the way its selector does. - assert texts(ew._join_zwj_clusters(line(f"|{_TECHIE}|"))) == ["|", _TECHIE, "|"] + assert texts(ew._join_clusters(line(f"|{_TECHIE}|"))) == ["|", _TECHIE, "|"] # A stranded joiner, followed by the name lane's padding, is not a sequence: the space # after it keeps a fragment — and so a cell — of its own. - assert texts(ew._join_zwj_clusters(line(f"{_STRANDED} x"))) == [_FLAG, _ZWJ, " ", "x"] + assert texts(ew._join_clusters(line(f"{_STRANDED} x"))) == [_FLAG, _ZWJ, " ", "x"] + # A flag's two indicators are one glyph too, written as one: a cursor pin between the + # halves would leave a terminal drawing two letters instead of a flag. + assert texts(ew._join_clusters(line(f"|{_CA}|"))) == ["|", _CA, "|"] + assert texts(ew._join_clusters(line(f"{_CA}{_CN}"))) == [_CA, _CN] + # A lone indicator is no pair, and stays a fragment of its own. + assert texts(ew._join_clusters(line(f"{_CA[0]} x"))) == [_CA[0], " ", "x"] # A merged fragment iterates as the whole sequence, which is what makes prompt_toolkit # build one Char of it instead of one per codepoint. - (merged,) = texts(ew._join_zwj_clusters(line(_SHRUG))) + (merged,) = texts(ew._join_clusters(line(_SHRUG))) assert list(merged) == [_SHRUG] and merged == _SHRUG diff --git a/tests/test_session_width.py b/tests/test_session_width.py index 408ddc2..d3a0327 100644 --- a/tests/test_session_width.py +++ b/tests/test_session_width.py @@ -52,6 +52,24 @@ def test_session_wraps_the_real_terminal_on_regular(monkeypatch) -> None: assert out.get_size() == Size(rows=30, columns=101) +def test_session_pins_the_real_terminal_inside_the_width_extension(monkeypatch) -> None: + """Both wraps on REGULAR, reclaim outermost: its size is what everything lays out against.""" + from meshterm.ui.tui.colsnap import PinnedOutput + + fake = SimpleNamespace(get_size=lambda: Size(rows=30, columns=100)) + monkeypatch.setattr("prompt_toolkit.output.defaults.create_output", lambda: fake) + monkeypatch.delenv("MESHTERM_FULL_WIDTH", raising=False) + monkeypatch.delenv("MESHTERM_COLUMN_SNAP", raising=False) + out = TuiSession()._resolve_output() + assert isinstance(out, _WidthExtendedOutput) + assert isinstance(out._inner, PinnedOutput) and out._inner._inner is fake + + # Pinning alone, where the column is not reclaimed. + monkeypatch.setenv("MESHTERM_FULL_WIDTH", "0") + pinned = TuiSession()._resolve_output() + assert isinstance(pinned, PinnedOutput) and pinned._inner is fake + + def test_session_leaves_the_bare_terminal_on_picocalc(monkeypatch) -> None: """PICOCALC's exact-width console → no reclaim: a phantom column would tear the frame.""" monkeypatch.delenv("MESHTERM_FULL_WIDTH", raising=False) @@ -69,7 +87,19 @@ def test_env_override_forces_reclaim_on_despite_picocalc(monkeypatch) -> None: def test_env_override_forces_reclaim_off_despite_regular(monkeypatch) -> None: - """``MESHTERM_FULL_WIDTH=0`` wins over REGULAR's on-by-default.""" + """``MESHTERM_FULL_WIDTH=0`` wins over REGULAR's on-by-default. + + The terminal is still wrapped for pinning, which has a gate of its own; with that turned + off too there is nothing left to wrap, and prompt_toolkit builds its own output. + """ + fake = SimpleNamespace(get_size=lambda: Size(rows=30, columns=100)) + monkeypatch.setattr("prompt_toolkit.output.defaults.create_output", lambda: fake) monkeypatch.setenv("MESHTERM_FULL_WIDTH", "0") + monkeypatch.delenv("MESHTERM_COLUMN_SNAP", raising=False) set_platform(REGULAR) + out = TuiSession()._resolve_output() + assert not isinstance(out, _WidthExtendedOutput) + assert out.get_size() == Size(rows=30, columns=100) # no column reclaimed + + monkeypatch.setenv("MESHTERM_COLUMN_SNAP", "0") assert TuiSession()._resolve_output() is None