From 03befa76acea8bf57234cf4e4db43259c7da2824 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Wed, 16 Sep 2026 19:55:00 -0400 Subject: [PATCH] A written row says where its columns are, so a glyph can only move itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alignment stopped depending on the width tables being right about a glyph. After anything whose drawn width is not certain, a row now carries an absolute column address naming the column the app measured that glyph into, so a mismeasured emoji costs one cell inside its own lane instead of every lane after it and the frame's right border. The curated narrow/wide sets keep their job — they are what makes the reservation right, so a listed glyph gets neither a gap nor an overwrite — but they are no longer what holds a row together. A name off the air, which nobody can put on a list in advance, now frames flush too. Pinning only where it is needed: the ranges the app draws in bulk and both authorities agree are one cell (box drawing, braille, blocks, the node marks, the chevrons) are listed, and a row holding nothing else is handed back untouched — one scan, no walk. That list is a performance claim, not what correctness rests on; getting an entry wrong costs what today costs. Proved against a terminal that disagrees on purpose rather than against a measurement, since the disagreement is the thing that cannot be probed. Still limited to the rows the fast renderer writes itself: a frame prompt_toolkit lays out — a dialog, and the backdrop it repaints — tracks the cursor in its own arithmetic and is uncorrected. MESHTERM_COLUMN_SNAP=0 turns it off. Co-Authored-By: Claude Opus 5 (1M context) --- meshterm/ui/tui/colsnap.py | 223 +++++++++++++++++++++++++++++ meshterm/ui/tui/emoji_width.py | 8 ++ meshterm/ui/tui/fastrender.py | 35 +++-- tests/test_colsnap.py | 248 +++++++++++++++++++++++++++++++++ 4 files changed, 506 insertions(+), 8 deletions(-) create mode 100644 meshterm/ui/tui/colsnap.py create mode 100644 tests/test_colsnap.py diff --git a/meshterm/ui/tui/colsnap.py b/meshterm/ui/tui/colsnap.py new file mode 100644 index 0000000..b2145b9 --- /dev/null +++ b/meshterm/ui/tui/colsnap.py @@ -0,0 +1,223 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Pin a written row's columns to the terminal's own grid, so no glyph can shift it. + +Every other correction in this package answers *how many cells does this glyph take* — +:mod:`~meshterm.ui.tui.emoji_width` is that question's whole home, two width authorities and +two hand-curated exception sets deep. This module answers the one that actually decides +whether a row lines up: **which column does the next glyph land in?** + +A row reaches the terminal as one run of characters, and the terminal advances its own cursor +by its own idea of each glyph's width. Where that idea differs from ours by a cell, +*everything after that glyph* is drawn a column out — the lanes to its right shift, the values +under a heading stop being under it, and the panel's right border notches in one short of the +frame. One emoji in one node's name, and the row it sits on is the only row in the list that +does not line up. + +**That disagreement cannot be measured away**, which is the part worth being plain about. +Whether a font draws a given emoji in one cell or two is the font's business rather than the +codepoint's; there is no table that is right on every terminal; and no escape sequence reports +a renderer's width back to the program, because on a split terminal the component that tracks +the cursor and the component that paints the glyph are different programs that disagree with +each other (a cursor probe reads the PTY, not the painter — :mod:`~meshterm.ui.tui.emoji_width` +carries that reasoning in full, along with the two curated sets it leaves us with). Curating +exceptions one confirmed glyph at a time lines up the rows somebody has already looked at, and +leaves every name that arrives off the air — written by a stranger, carrying whatever they felt +like typing — exactly as broken as it was. A contact list is the worst case in the app for +precisely that reason: its content is the one thing on screen that nobody can put on a list in +advance. + +So this module stops asking the terminal to agree. After any glyph whose drawn width is not +certain, the row carries an **absolute column address** — ``CSI n G``, naming the one-based +column the app measured that glyph into — so the terminal's answer to *where am I* is replaced +with ours before the next glyph is drawn. The cursor is re-pinned to the app's own grid at +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 **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 +of it. The curated width sets keep their job — they are what makes the *reservation* right, so +a listed glyph gets neither the gap nor the overwrite — but they stop being what holds the row +together. A glyph nobody has ever classified now costs a cell instead of a row. + +**What is certain.** Pinning after a glyph that did not need it is harmless (it addresses the +column the cursor is already in) but it is not free: five bytes on the wire and a cluster walk +in Python, and MeshTerm draws some non-ASCII glyphs by the thousand — a map is a canvas of +braille, every frame is box drawing, a path ribbon is half blocks and chevrons. So +:data:`_TEXT_GLYPHS` lists the ranges this app draws in bulk whose one-cell *text* width both +authorities already agree on, and :func:`snap_row` pins after everything else. A row holding +nothing outside that list is handed back untouched, which is nearly every row of nearly every +screen; the walk is paid only where an emoji actually is. + +Getting an entry in that list wrong costs exactly what today costs anyway — one glyph's row — +so the list is a performance claim and never the thing correctness rests on. That is the whole +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. + +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. +""" + +from __future__ import annotations + +import os +import re +from collections import OrderedDict + +from rich.cells import cell_len + +from .emoji_width import clusters + +#: The glyph ranges MeshTerm draws in bulk whose single-cell width both width authorities +#: already agree on, so a row made only of them needs no pinning at all. Each entry is an +#: inclusive ``(first, last)`` pair; a single glyph names itself twice. +#: +#: These are *text* glyphs — outside Emoji_Presentation, carrying no variation selector — which +#: is the class the emoji-width module's hardest-won lesson is about: ``U+1F6E3`` and +#: ``U+1F578`` measure one cell by both authorities because the font draws them as one-cell +#: text, and forcing either to two pulled a row's border in. Everything else non-ASCII — every +#: pictograph, every flag, every joined sequence, every fullwidth character, and anything nobody +#: has ever looked at — falls outside this list and gets pinned. +_TEXT_GLYPHS: tuple[tuple[str, str], ...] = ( + ("\x00", "\x7f"), # ASCII: every letter, digit and escape byte in a composed row + (" ", "ſ"), # Latin-1 + Latin Extended-A: an accented name, °, ·, ± + ("‐", "‧"), # General Punctuation: – — ‘ ’ “ ” • … + ("←", "↓"), # ← ↑ → ↓, the arrow atoms every footer hint leads with + ("─", "╿"), # Box Drawing: every panel border and every rule + ("▀", "▟"), # Block Elements: the path line's half blocks, the splash art + ("■", "■"), # ■ room server + ("▲", "▲"), # ▲ repeater + ("▸", "▸"), # ▸ a row's own marker + ("◉", "◉"), # ◉ sensor + ("○", "●"), # ○ unknown through ● node, and the rings between them + ("★", "★"), # ★ you + ("✓", "✓"), # ✓ ok + ("✗", "✗"), # ✗ err + ("❯", "❯"), # ❯ the cursor + ("⠀", "⣿"), # Braille: every chart, and the whole map canvas + ("", ""), # the powerline chevrons a path ribbon's seams are drawn from +) + +#: Matches the first glyph in a row that is *not* one of :data:`_TEXT_GLYPHS` — the probe that +#: decides whether a row needs walking at all. A negated class of the ranges above, so the test +#: is one C-speed scan of the row instead of a Python loop over its cells. +_UNPINNED = re.compile("[^" + "".join(f"{low}-{high}" for low, high in _TEXT_GLYPHS) + "]") + +#: One escape sequence: a CSI (which is everything a composed row actually carries — the +#: theme's SGR colour runs), an OSC string, or a bare two-character escape. Matched so it can +#: be copied through *without* advancing the column: a style change occupies no cell, and +#: counting one would pin every glyph after it to the wrong column. +_ESCAPE = re.compile(r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)?|[@-Z\\-_])") + +#: Pinned rows, keyed by the row as composed. Scrolling a list rewrites the same few rows over +#: and over — the highlighted one, and the one the highlight just left — so the walk is paid +#: once per distinct row rather than once per paint. +_CACHE: OrderedDict[str, str] = OrderedDict() + +#: Rows the cache holds before evicting the least recently used. +_CACHE_MAX = 1024 + + +def enabled() -> bool: + """Whether written rows carry absolute column addresses. + + 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. + """ + return os.environ.get("MESHTERM_COLUMN_SNAP") != "0" + + +def _pinned(cluster: str) -> bool: + """Whether ``cluster`` needs an absolute column address written after it. + + True for anything the app cannot be sure the terminal draws at the width it measured: every + multi-codepoint glyph (a flag, a joined sequence, an emoji carrying a variation selector or + a skin tone — the forms :func:`~meshterm.ui.tui.emoji_width.clusters` gathers), and every + lone codepoint outside :data:`_TEXT_GLYPHS`. + """ + return len(cluster) > 1 or bool(_UNPINNED.match(cluster)) + + +def snap_row(row: str) -> str: + """``row`` with an absolute column address after every glyph that could be mismeasured. + + The row is taken to start in **column 0**, which is what + :mod:`~meshterm.ui.tui.fastrender` guarantees: it addresses the row, then writes it whole. + + A row holding only :data:`_TEXT_GLYPHS` comes back unchanged and untouched — one scan, no + walk, no allocation — because that is nearly every row the app draws. + + Args: + row: One composed terminal row: text with the theme's escape sequences in it, already + sliced to the terminal's width. + + Returns: + The row, with ``CSI n G`` inserted wherever the cursor has to be re-pinned. + """ + if _UNPINNED.search(row) is None: + return row + hit = _CACHE.get(row) + if hit is not None: + _CACHE.move_to_end(row) + return hit + out = _snapped(row) + _CACHE[row] = out + if len(_CACHE) > _CACHE_MAX: + _CACHE.popitem(last=False) + return out + + +def _snapped(row: str) -> str: + """Walk ``row`` glyph by glyph, tracking the column, and pin where :func:`_pinned` says. + + The address is written **lazily** — held over until something drawable actually follows it — + so a row that ends on an emoji (a chat line, very often) pays nothing for an address nothing + would have used, and a style change between two glyphs stays where the theme put it. + """ + parts: list[str] = [] + column = 0 + owed = -1 # the column a held-over address names, or -1 when none is owed + position = 0 + for escape in _ESCAPE.finditer(row): + column, owed = _pin_text(row[position : escape.start()], parts, column, owed) + parts.append(escape.group()) + position = escape.end() + column, owed = _pin_text(row[position:], parts, column, owed) + return "".join(parts) + + +def _pin_text(text: str, parts: list[str], column: int, owed: int) -> tuple[int, int]: + """Append one escape-free stretch of ``text`` to ``parts``, pinned glyph by glyph. + + Args: + text: The stretch to append, with no escape sequence in it. + parts: The row being built, appended to in place. + column: The column this stretch starts in. + owed: The column a held-over address names, or ``-1`` for none. + + Returns: + The column the stretch ends in, and the address it ends owing. + """ + for cluster in clusters(text): + if owed >= 0: + parts.append(f"\x1b[{owed + 1}G") + owed = -1 + parts.append(cluster) + column += cell_len(cluster) + if _pinned(cluster): + owed = column + return column, owed + + +__all__ = ["enabled", "snap_row"] diff --git a/meshterm/ui/tui/emoji_width.py b/meshterm/ui/tui/emoji_width.py index 68c8aef..7c9f281 100644 --- a/meshterm/ui/tui/emoji_width.py +++ b/meshterm/ui/tui/emoji_width.py @@ -88,6 +88,14 @@ one-cell text glyph, which is what both authorities said. Forcing one to two res terminal never draws and pulls the row's border a column *in* — see :data:`_DEFAULT_WIDE_BASE` for the case that proved it. +**What this module is no longer answerable for.** A row's *layout* no longer rests on these +tables being right about a glyph. :mod:`~meshterm.ui.tui.colsnap` writes an absolute column +address after anything whose drawn width is uncertain, so a mismeasurement costs one cell inside +that glyph's own lane instead of every lane after it — which is the only answer that can reach a +name off the air, since the curated sets can only ever hold glyphs somebody has already looked +at. What those sets still decide is the *reservation*: a listed glyph gets neither a blank cell +beside it nor an overhang written over, so adding a confirmed one is still worth doing. + Two jobs beside the measuring belong here, because they are the same question asked of the same table. **Where a string may be cut** (:func:`cut_cells`, over :func:`clusters`): a lane that truncates a name a codepoint at a time cuts through the middle of a glyph, and the pieces that diff --git a/meshterm/ui/tui/fastrender.py b/meshterm/ui/tui/fastrender.py index 7241d6b..ad96c2b 100644 --- a/meshterm/ui/tui/fastrender.py +++ b/meshterm/ui/tui/fastrender.py @@ -23,14 +23,20 @@ would mean re-implementing that placement. Anything else falls straight through stock renderer, which stays the authority: :meth:`FastRenderer.render` is an accelerator for the common case, never a second implementation of the whole layout engine. -Two details make the row diff safe rather than merely fast: +Three details make the row diff safe rather than merely fast: -* **A row is rewritten whole, from column 0.** The terminal's own cursor advance re-lays - the line, so a glyph it draws narrower than we measured cannot leave the rest of the row - shifted — the same guarantee :meth:`~meshterm.ui.tui.session.TuiSession._emit` relies on, - reached here for free rather than by a scrub pass. -* **Each row is prefixed with a reset and followed by an erase-to-end-of-line**, so no - style leaks in from the row above and a row that got shorter leaves no tail behind. +* **A row is rewritten whole, from column 0.** Stepping down to a row returns the terminal + to a true column 0 whatever happened on the row above, so a glyph drawn at a width we did + not measure can never throw off the rows below it — the same guarantee + :meth:`~meshterm.ui.tui.session.TuiSession._emit` relies on, reached here for free rather + than by a scrub pass. +* **Every column of that row is pinned to the grid the app measured** + (:func:`~meshterm.ui.tui.colsnap.snap_row`), which is what keeps the drift from mattering + *within* the row either. This is the only place in the app where a row's bytes are still + ours on their way to the terminal, so it is the only place that correction can be made. +* **Each row is cleared before it is drawn, never after**, so no style leaks in from the row + above, a row that got shorter leaves no tail behind, and a row that fills the terminal + exactly keeps its last cell. """ from __future__ import annotations @@ -40,6 +46,13 @@ from collections.abc import Callable from prompt_toolkit.renderer import Renderer +from . import colsnap + + +def _as_is(row: str) -> str: + """The unpinned write path: a row goes out exactly as it was composed.""" + return row + def enabled() -> bool: """Whether the direct row writer is active. @@ -77,6 +90,10 @@ class FastRenderer(Renderer): """ super().__init__(*args, **kwargs) self._frame_source = frame_source + #: How a row is pinned to the app's column grid on its way out — resolved once here, + #: because the escape hatch is a property of the terminal this session is talking to + #: and not of any one paint. Identity when it is off, so the write path never branches. + self._pin: Callable[[str], str] = colsnap.snap_row if colsnap.enabled() else _as_is self._prev_rows: list[str] | None = None self._prev_size: tuple[int, int] | None = None #: Paints answered here vs handed to the stock renderer — read by the bench. @@ -157,7 +174,9 @@ class FastRenderer(Renderer): # missing on the PicoCalc's 53-column console). Clearing first also drops the # tail of a row that got shorter, which is what the erase was for. write("\x1b[0m\x1b[K") - write(row) + # Pinned, not merely written: an emoji in a node's name is drawn at whatever width + # this font gives it, and the lanes after it belong to the app's grid either way. + write(self._pin(row)) last = i # Park the cursor out of the text. The app hides it, but a terminal that ignores # that should not leave it blinking in the middle of a row. diff --git a/tests/test_colsnap.py b/tests/test_colsnap.py new file mode 100644 index 0000000..b84d592 --- /dev/null +++ b/tests/test_colsnap.py @@ -0,0 +1,248 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Column pinning: a row lands on the app's grid however wide the terminal draws its glyphs. + +The reasoning lives in :mod:`meshterm.ui.tui.colsnap`. What these tests pin is the claim that +makes it worth having — that alignment no longer depends on the two width tables being *right* +about a glyph, only on the row saying where its columns are. + +So the fixture here is a terminal that **disagrees on purpose** (:class:`_Terminal`, handed the +glyphs it draws at a width of its own). That is not a contrived case: it is the situation the +emoji-width module documents as unprobeable and unfixable-in-general, and the one a node name +off the air walks into every time somebody puts an emoji in it. Every assertion below is about +where a glyph *landed*, never about what was measured. +""" + +from __future__ import annotations + +import re + +from rich.cells import cell_len + +from meshterm.ui.tui import colsnap +from meshterm.ui.tui.emoji_width import clusters + +_WAVE = "👋" # two cells by both stock tables; one cell on the reference terminal's font +_ROAD = "\U0001f6e3" # 🛣 one cell by both tables — the glyph a font may still draw wide +_CA = "🇨🇦" # a flag: one glyph out of two Regional Indicators +_FAMILY = "\U0001f468‍\U0001f469‍\U0001f467" # 👨‍👩‍👧 one glyph out of three joined people +_ROW = "│ ❯ Lakeside ● ▲ ★ ─── 5m │" # chrome only: nothing here needs an address + + +class _Terminal: + """A terminal that lays out what it is written using a width table of its own. + + Honours exactly one control sequence — ``CSI n G``, the absolute column address — and + ignores every other one, which is how a real terminal treats the theme's colour runs for + the purposes of column arithmetic. + + Args: + width: The terminal's width in cells. + draws: Glyphs this terminal draws at a width the app did not measure, as + ``{glyph: cells}``. Everything absent is drawn at the measured width, so a test + names its disagreement and nothing else. + """ + + _CSI = re.compile(r"\x1b\[([0-9;]*)([A-Za-z])") + + def __init__(self, width: int, *, draws: dict[str, int] | None = None) -> None: + """Start with a blank row and the cursor in column 0.""" + self.width = width + self.draws = draws or {} + self.cells: list[str] = [" "] * width + self.column = 0 + + def write(self, data: str) -> None: + """Draw ``data``, obeying any column address in it.""" + position = 0 + for match in self._CSI.finditer(data): + self._draw(data[position : match.start()]) + if match.group(2) == "G": + self.column = int(match.group(1) or "1") - 1 + position = match.end() + self._draw(data[position:]) + + def _draw(self, text: str) -> None: + """Place each glyph of one escape-free stretch, advancing by this terminal's widths.""" + for cluster in clusters(text): + size = self.draws.get(cluster, cell_len(cluster)) + if 0 <= self.column < self.width: + self.cells[self.column] = cluster + for overhang in range(1, size): + if self.column + overhang < self.width: + self.cells[self.column + overhang] = "" + self.column += size + + def at(self, column: int) -> str: + """The glyph drawn in ``column``.""" + return self.cells[column] + + +def test_a_row_of_chrome_is_handed_back_untouched() -> None: + """The common row costs one scan and no allocation — the same object comes back. + + Borders, block elements, braille and the node marks are what the app draws by the + thousand, and pinning after each of them would be five bytes and a cluster walk to + address the column the cursor is already in. + """ + assert colsnap.snap_row(_ROW) is _ROW + assert colsnap.snap_row("plain ascii row") is "plain ascii row" # noqa: F632 - identity + assert colsnap.snap_row("⠁⠂⠃⡿ braille chart ▁▃▅█ ╭─╮") is not None + + +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" + + +def test_nothing_is_pinned_after_the_last_glyph_on_the_row() -> None: + """An address is held over until something drawable follows it, and dropped if none does. + + 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}" + + +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|" + + +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): + pinned = colsnap.snap_row(f"{glyph}|") + assert pinned == f"{glyph}\x1b[{cell_len(glyph) + 1}G|" + assert pinned.count("\x1b[") == 1 + + +def test_a_bare_text_emoji_is_pinned_even_though_both_tables_agree_about_it() -> None: + """Agreement is not certainty: what a font draws is still the font's business. + + ``🛣`` is the glyph whose measurement must not be *forced* to two (that pulled the Trophy + case's border in). Pinning is the other answer to the same doubt: leave the measurement + alone and say where the next column is, so the row holds either way. + """ + assert cell_len(_ROAD) == 1 + assert colsnap.snap_row(f"{_ROAD}|") == f"{_ROAD}\x1b[2G|" + + +def test_the_row_frames_flush_on_a_terminal_that_draws_the_glyph_narrow() -> None: + """The notch, and its absence: a border lands in its measured column once pinned.""" + row = f"│ {_WAVE} Lakeside │" + edge = cell_len(row) - 1 + + loose = _Terminal(cell_len(row), draws={_WAVE: 1}) + loose.write(row) + assert loose.at(edge) != "│", "unpinned, the narrow glyph pulls the border a column in" + assert loose.column == cell_len(row) - 1 + + pinned = _Terminal(cell_len(row), draws={_WAVE: 1}) + pinned.write(colsnap.snap_row(row)) + assert pinned.at(edge) == "│" + assert pinned.column == cell_len(row) + + +def test_the_row_frames_flush_on_a_terminal_that_draws_the_glyph_wide() -> None: + """The mirror case: the overhang is written over, and the row still ends where it should. + + One cell of the glyph is lost to its neighbour, which is the trade — a cell of cosmetic + damage inside the glyph's own lane, in place of every lane after it moving. + """ + row = f"│ {_ROAD} Lakeside │" + edge = cell_len(row) - 1 + + loose = _Terminal(cell_len(row), draws={_ROAD: 2}) + loose.write(row) + assert loose.at(edge) != "│" + + pinned = _Terminal(cell_len(row), draws={_ROAD: 2}) + pinned.write(colsnap.snap_row(row)) + assert pinned.at(edge) == "│" + + +def test_every_lane_of_a_purge_preview_row_lands_where_the_screen_measured_it() -> None: + """The screen this started on: a swept contact whose name carries an emoji. + + The name lane is already fitted in display cells (:func:`~meshterm.ui.menus.fit_cells`), + which is the padding that cannot help — it is computed with the same measurement the + terminal disagrees with. What holds the evidence lanes under their headers is the pinning. + """ + from meshterm.core.contact_score import ContactSignals, ScoredContact + from meshterm.core.models import Contact + from meshterm.ui.purge_screen import _NAME_W, _lane_widths, _victim_row + from meshterm.ui.tui.render import render_to_ansi + + key = "ab" * 32 + victim = ScoredContact( + contact=Contact(name=f"{_WAVE} Lakeside", public_key=key, key_prefix=key[:12]), + signals=ContactSignals(node=key[:12], packets=12, hops=2.0), + score=1.0, + percentile=3, + ) + row = _victim_row(victim, _lane_widths([victim])) + measured = cell_len(row.plain) + ansi = render_to_ansi(row, 72, no_wrap=True) + + loose = _Terminal(80, draws={_WAVE: 1}) + loose.write(ansi) + assert loose.column != measured, "unpinned, one emoji shifts every lane after it" + + pinned = _Terminal(80, draws={_WAVE: 1}) + pinned.write(colsnap.snap_row(ansi)) + assert pinned.column == measured + # Not just the right edge: *every* glyph of the row is in the column the screen's own + # cell arithmetic put it in, which is what keeps a lane under the header naming it. + column = 0 + for cluster in clusters(row.plain): + if cluster != " ": + assert pinned.at(column) == cluster, f"column {column} holds the wrong glyph" + column += cell_len(cluster) + assert column == measured + assert _NAME_W == 22 # the name lane the evidence starts after, gap included + + +def test_pinning_is_on_with_an_escape_hatch(monkeypatch) -> None: # noqa: ANN001 + """On by default; only an explicit 0 writes rows the way they were composed.""" + monkeypatch.delenv("MESHTERM_COLUMN_SNAP", raising=False) + assert colsnap.enabled() + monkeypatch.setenv("MESHTERM_COLUMN_SNAP", "0") + assert not colsnap.enabled() + + +def test_the_row_writer_pins_what_it_writes(monkeypatch) -> None: # noqa: ANN001 + """The wiring: a composed frame reaches the terminal carrying its column addresses.""" + from prompt_toolkit.data_structures import Size + from prompt_toolkit.styles import Style + + from meshterm.ui.tui.fastrender import FastRenderer + + class _Capturing: + """Just enough output for one paint: what was written, and how big the screen is.""" + + def __init__(self) -> None: + self.written: list[str] = [] + + def write_raw(self, data: str) -> None: + self.written.append(data) + + def get_size(self) -> Size: + return Size(rows=26, columns=72) + + def __getattr__(self, name: str): # noqa: ANN001, ANN204 - the rest is inert here + return lambda *args, **kwargs: None + + frame = f"contact {_WAVE} 5m\nsecond row\n" + out = _Capturing() + renderer = FastRenderer(Style([]), out, full_screen=True, frame_source=lambda: frame) + renderer.render(None, None) + assert "\x1b[11G" in "".join(out.written) # the wave measured into columns 8-9 + + monkeypatch.setenv("MESHTERM_COLUMN_SNAP", "0") + off = _Capturing() + plain = FastRenderer(Style([]), off, full_screen=True, frame_source=lambda: frame) + plain.render(None, None) + assert "\x1b[11G" not in "".join(off.written)