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:
1 parent
839da9ab8f
commit
03befa76ac
4 files changed
+506
-8
No files matched your search
@@ -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"]
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user