A dialog's glyphs land where prompt_toolkit measured them, too

The rows the fast renderer writes were already pinned. Anything
prompt_toolkit lays out itself — a floating dialog, and the backdrop it
repaints — still went out through its differential renderer, which steps a
cursor of its own by each glyph's measured width and moves relatively from
there, so one glyph drawn at another width threw off every move after it.

That renderer never knows a column absolutely, so the pin is spelled
relative to the glyph instead: save the cursor, draw the glyph, restore,
step forward exactly the width the renderer is about to add. The terminal's
cursor is back in step after every glyph, and nothing is tracked that could
drift from the renderer's own bookkeeping. PinnedOutput wraps the real
terminal; the width reclaim stays outermost.

A flag's two indicators are now merged into one cell before layout, like a
ZWJ sequence, so the pair is written as one glyph rather than with a cursor
move between its halves.

Nothing is pinned on a platform that draws no emoji: the PicoCalc only ever
draws glyphs its own font has verified.

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 20:07:45 -04:00
1 parent 03befa76ac
commit 6f7b24e790
6 files changed
+474 -47

No files matched your search

+91 -15
View File
@@ -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"]
+34 -14
View File
@@ -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
+22 -7
View File
@@ -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 -----------------------------------------------------------
+280
View File
@@ -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)
+16 -10
View File
@@ -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
+31 -1
View File
@@ -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