A written row says where its columns are, so a glyph can only move itself

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) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5 committed 2026-09-16 19:55:46 -04:00
1 parent 839da9ab8f
commit 03befa76ac
4 files changed
+506 -8

No files matched your search

+223
View File
@@ -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"]
+8
View File
@@ -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
+27 -8
View File
@@ -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.
+248
View File
@@ -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)