Files
Jean-Pierre MartineauandClaude Opus 5.5 d6d49df0a2 Docstrings and comments of meshterm/ui/tui are in ASD-STE100
The same rewrite as for the core package. A navigation entry is now
"a screen on the stack", the visible part of a page is its viewport, and
the visible rows of a ListWindow are its list window. Two statements now
agree with the theme: the selected chip style is a grey fill.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 04:17:05 -04:00

560 lines
28 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""The width of a glyph: one rule in all places, and two cells where nobody can know.
The width of an emoji in cells is not standardized, and a program cannot find it. The font of a
terminal decides if the terminal draws an emoji in one cell or in two. The font decides for each
glyph, and no rule is behind the split. Thus the same terminal draws ``👋`` in one cell and
``📡`` in two,``☀️`` in one and ``🛩️`` in two. No escape
sequence reports the width of a renderer back to the program. A cursor probe does not help
either. On a split terminal (VS Code, Windows Terminal), the component that tracks the cursor
and the component that paints the glyph are different programs that do not agree with each
other, and a probe can only ask the first.
This module once answered that problem with a probe and two exception sets, curated by hand:
glyphs that somebody confirmed, one at a time, that the terminal drew narrower or wider than the
tables said. That lined up each row that somebody had already examined, and none of the rows that
nobody had examined. But the name of a contact comes over the radio, written by a stranger, and
it has whatever the stranger typed. For that reason we retired the sets, and also the probe that
decided when to apply them.
**Two halves replaced them. Neither half has to know what the font does.**
* **Placement** belongs to :mod:`~meshterm.ui.tui.colsnap`. Each glyph whose drawn width is not
certain is written with its column pinned. Thus, whatever width the terminal gives it, the
glyphs after it go to the columns that the app measured.
* **Measurement** belongs here. Each glyph that may be drawn as an emoji is reserved **two
cells**, the maximum that one glyph ever draws. When the glyph is pinned into two cells, a glyph
that the font draws in two cells fills them exactly. A glyph that the font draws in one cell
leaves a blank next to it: never an overwrite, never a shift. This is what lines up the text
after an emoji from row to row. The pin alone cannot do that, because it pins to the measured
column, and the measurement was the thing that changed.
**"May be drawn as an emoji"** comes from Unicode, never from a font:
* A glyph built from several codepoints: a variation-selector sequence (``☀️``), a ZWJ sequence
(``👨‍👩‍👧``), a keycap (``1️⃣``), a flag's pair of Regional Indicators (``🇨🇦``).
* A lone codepoint that Unicode marks as an emoji with text presentation by default (``☀``,
``✈``, ``❤``). This is the set that a variation selector promotes to two cells, which Rich
ships as ``narrow_to_wide``. Also a lone Regional Indicator. A font can draw each of these two
kinds at one cell or at two.
* An emoji whose default presentation is already emoji (``📡``, ``👋``) measures two cells in
both width authorities without a change. This module leaves it to them.
**Two exceptions, both measured at their one cell.** A fast path measures the single-cell ranges
of Rich itself (``©``, ``▶``, box drawing), and that path never examines a patch. Thus all the
other parts must agree with it. Also, :data:`_APP_TEXT_MARKS` is the small set of text-default
emoji that MeshTerm draws itself, as one-cell marks in its own lexicon (``⚠``, ``↕``, ``🗑``).
These marks are a closed vocabulary, not content. The app draws each glyph in the list, the app
checks them (``meshterm specimen``), and a test makes sure that each such glyph in the source is
in the list.
**One rule, all authorities.** Rich measures in three places: a whole string, a single character,
and the grapheme spans by which it crops and wraps. prompt_toolkit measures in a fourth place.
:func:`install` patches all four from the same rule (:func:`_glyph_width`) at the same time, and
they must never be different. The segment splitter of Rich walks a string one codepoint at a
time, until a whole-string width meets the cut that the caller asked for. If a character measures
one cell alone but two cells in a string, the walk never meets that cut.
**Delivery.** prompt_toolkit lays out one codepoint at a time. It folds a zero-width codepoint
into the cell before it, but it keeps the place of that cell. Thus a mark measured at one cell and
followed by a selector becomes a two-cell glyph in a one-cell slot. Instead,
:class:`ClusterTextControl` gives each multi-codepoint glyph to prompt_toolkit as a single
fragment, which is laid out and written as the one glyph that it is.
**Where it applies.** :func:`install` runs one time, before the first frame, when the full-screen
app starts on a platform that draws emoji. Nothing else installs it. Tests, piped output, and the
command line keep the stock tables, because nothing pins their output. There, a glyph that is
reserved two cells but drawn in one, with no pin after it, pulls the rest of its row one cell to
the left.
Two more jobs belong here, next to the measurement, because they ask the same question of the
same glyphs:
* **Where a string can be cut** (:func:`cut_cells`, over :func:`clusters`). A lane that truncates
a name one codepoint at a time cuts through the middle of a glyph. The pieces that this cut
leaves (a stranded joiner, half a flag) are not things that a terminal draws at the measured
width.
* **What can be drawn at all** (:func:`drawable`). A stranger writes the name in an advert, and
the name can have a newline or an escape in it. No width can make these characters safe.
"""
from __future__ import annotations
import re
import unicodedata
from collections.abc import Callable, Iterator
from functools import lru_cache
from prompt_toolkit.layout.controls import FormattedTextControl
from rich.cells import cell_len
#: The zero-width joiner: the sign that the codepoints on each side of it are **one glyph**
#: (``🤷‍♂️``, ``👨‍👩‍👧``), which a terminal draws in the width of the sequence's base.
_ZWJ = "\u200d"
#: Variation selector 16, the request for emoji presentation.
_VS16 = "\ufe0f"
#: The combining enclosing keycap, which turns the digit or sign before it (and the selector
#: between them) into one keycap glyph: ``1️⃣``.
_KEYCAP = "\u20e3"
#: The five Fitzpatrick skin-tone modifiers (U+1F3FB to U+1F3FF). A modifier after an emoji
#: base changes the colour of that glyph, and does not draw a glyph of its own.
#: Thus ``👍🏽`` is a thumb, not a thumb next to a swatch, and the modifier
#: occupies no cell. Rich's table says zero, and wcwidth says two.
_MODIFIERS = frozenset(chr(cp) for cp in range(0x1F3FB, 0x1F400))
#: What can follow a base in one glyph without a joiner before it: its variation selector, its
#: skin tone, and the enclosing mark of a keycap. A joined run collects these after its base and
#: after each component.
_EXTENDERS = frozenset((_VS16, _KEYCAP, *_MODIFIERS))
#: The Unicode general categories that hold nothing that a terminal can draw: control
#: characters (``Cc``), the format and bidi-control block (``Cf``), surrogates (``Cs``), and
#: the line and paragraph separators (``Zl``/``Zp``). Refer to :func:`drawable`, which folds
#: them to a space. It keeps the one ``Cf`` codepoint that matters: the zero-width joiner.
_UNDRAWABLE = frozenset(("Cc", "Cf", "Cs", "Zl", "Zp"))
#: The text-default emoji that MeshTerm draws itself, as one-cell marks in its own lexicon.
#: Thus MeshTerm measures them at one cell, and does not reserve two. Each one is a glyph that
#: the app put on the screen on purpose and checks through ``meshterm specimen``. It is not a
#: glyph that came in the name of somebody. Thus its width is a fact about the vocabulary of the
#: app, not a guess about a font.
#:
#: A test closes the list, not care: each text-default emoji in the source of the package must
#: be named here. Thus a new mark cannot go into the reserve-two rule without notice. A glyph
#: that is not here is reserved two cells. This costs a blank next to the glyph, and never a row
#: that is not aligned. For this reason, this list can be short, but the sets that it replaced
#: had to be complete.
_APP_TEXT_MARKS = frozenset(
(
"⚠", # ⚠ the warning mark
"↔", # ↔ a two-way link: the path composer, the key legend
"↕", # ↕ reorder
"↩", # ↩ reply, in the chat composer
"⌨", # ⌨ the command line
"⚙", # ⚙ a parameter
"\U0001f5d1", # 🗑 clear, delete
"\U0001f578", # 🕸 a Trophy case record
"\U0001f6e3", # 🛣 a Trophy case record
)
)
#: Set when :func:`install` has run, so that later calls are cheap no-ops.
_INSTALLED = False
#: Whether the width authorities were redirected through this module. It gates
#: :class:`ClusterTextControl`. To give prompt_toolkit a multi-codepoint glyph as one character
#: is correct only when something measures that character as one glyph. Thus the delivery and
#: the measurement are switched on together, or not at all.
_CLUSTERS = False
def _is_regional_indicator(char: str) -> bool:
"""Whether ``char`` is a single Regional Indicator Symbol (one part of a country flag).
The two-letter flags (``🇨🇦``, ``🇺🇸``) are a grapheme pair from this block
(U+1F1E6 to U+1F1FF). The pair is one glyph. Each half alone is a glyph like a letter, which
a font can draw at either width. The test is for the whole category, never for each country,
because the flags share the indicators (``🇨🇦`` and ``🇨🇳`` both start with ``C``).
"""
return len(char) == 1 and 0x1F1E6 <= ord(char) <= 0x1F1FF
def _joins(char: str) -> bool:
"""Whether a zero-width joiner before ``char`` truly joins it to the glyph before the joiner.
Each component that an emoji ZWJ sequence joins is a pictograph (``♂``, ``☠``, ``❤``,
``💻``, all at U+2000 and above), so that is the test. The test excludes a **stranded**
joiner. MeshCore cuts the name in an advert at a byte limit. A name that ended on a joined emoji
(``That's So Fetch 🏴‍☠️``) arrives cut directly after the joiner, followed by whatever the row
puts after the name: a space of lane padding, or the next lane. If that character is folded
into the flag, it loses its cell, but the terminal still draws it. The test also
leaves the joiners of the scripts that use them between letters (the Indic conjuncts, below
U+2000) as the letters that they are.
"""
return char >= "\u2000" and not char.isspace()
@lru_cache(maxsize=1)
def _reserved() -> frozenset[str]:
"""All the lone codepoints that are reserved two cells, read from Unicode one time.
The set starts with the text-default emoji that the table of Rich itself lists
(``narrow_to_wide``, the codepoints that a variation selector promotes). It removes the
codepoints that the single-cell fast path of Rich measures, and the marks of the app itself
(refer to the module docstring for both). Then it adds the Regional Indicators.
"""
from rich._unicode_data import load
from rich.cells import _SINGLE_CELLS
text_default = frozenset(
char
for char in load("auto").narrow_to_wide
if not char.isascii() and char not in _SINGLE_CELLS and char not in _APP_TEXT_MARKS
)
return text_default | frozenset(chr(cp) for cp in range(0x1F1E6, 0x1F200))
@lru_cache(maxsize=1)
def _probe() -> re.Pattern[str]:
"""Matches the first codepoint in a string that can make :func:`_glyph_width` different.
A string with none of these codepoints measures exactly as the stock tables say. Thus the
string goes to them with no change: one scan at C speed instead of a cluster walk. Nearly
every string takes this path.
"""
chars = {_ZWJ, _VS16, _KEYCAP, *_MODIFIERS, *_reserved()}
return re.compile("[" + "".join(re.escape(char) for char in sorted(chars)) + "]")
def _override(char: str) -> int | None:
"""The width that this module gives a lone codepoint, or ``None`` for the stock answer.
A skin tone takes no cell (wcwidth gives it two). A reserved codepoint (:func:`_reserved`)
takes two. For all other codepoints, the stock table decides.
"""
if char in _MODIFIERS:
return 0
if char in _reserved():
return 2
return None
def _glyph_width(glyph: str, stock: Callable[[str], int]) -> int:
"""The cells that one glyph is reserved: the one rule that patches each width authority.
A glyph is reserved two cells if it is built from several codepoints and has a joiner, a
variation selector, or a keycap. A glyph that is the indicator pair of a flag is also reserved
two. All other glyphs measure as their base codepoint. A skin tone goes with its base, so an
emoji with a tone measures the same as the emoji. A letter with a stray modifier stays a
letter.
Args:
glyph: One glyph, as :func:`clusters` splits it.
stock: The stock width of a single codepoint, in the authority that is patched.
Returns:
The width of the glyph in cells.
"""
base = glyph[0]
if len(glyph) > 1 and (
_ZWJ in glyph or _VS16 in glyph or _KEYCAP in glyph or _is_regional_indicator(base)
):
return 2
width = _override(base)
return max(0, stock(base)) if width is None else width
def clusters(text: str) -> Iterator[str]:
"""Split ``text`` into the glyphs that the terminal draws, never in the middle of one.
A cluster is the same run that :func:`_join_clusters` collects from a laid-out line, but
read from a plain string. It is a base, then what extends it (:data:`_EXTENDERS`: a
variation selector, a skin tone, the enclosing mark of a keycap), then any number of
*joiner + component + extenders* groups. A cluster is also the Regional Indicator **pair**
from which a country flag is drawn. A joiner that joins nothing (:func:`_joins`) is a
cluster of its own, because it is not part of the glyph before it.
A lane must cut on this unit, and a width is measured by it. A cut through the middle of a
glyph leaves pieces that a terminal does not draw at the measured width: a lone Regional
Indicator drawn as a letter, a skin tone with no base, or a stranded joiner. Refer to
:func:`cut_cells`.
"""
index = 0
count = len(text)
while index < count:
start = index
index += 1
while index < count and text[index] in _EXTENDERS:
index += 1
if _is_regional_indicator(text[start]) and index < count:
if _is_regional_indicator(text[index]):
index += 1
while index < count and text[index] in _EXTENDERS:
index += 1
while index + 1 < count and text[index] == _ZWJ and _joins(text[index + 1]):
index += 2 # the joiner and the codepoint that it joins
while index < count and text[index] in _EXTENDERS:
index += 1
yield text[start:index]
def cut_cells(text: str, width: int) -> str:
"""The longest prefix of ``text`` that fits in ``width`` cells, cut **between** glyphs.
A fixed lane needs this prefix when a name is longer than the lane
(:func:`~meshterm.ui.menus.fit_cells`). The lane cannot walk the string one character at a
time, for this reason: the owner of the radio writes a MeshCore name, so the name comes
with whatever emoji the owner typed. A cut at each codepoint lands in the middle of an
emoji approximately as often as not. A cut of
``"👨‍👩‍👧"`` after its first joiner leaves a stranded joiner at the end of the lane, and
a cut of a flag in half leaves a lone Regional Indicator, which is a letter, not half a
flag.
Thus the prefix is made of whole :func:`clusters`. A trailing joiner is removed, and not
left next to the text that the caller adds after it. The cut itself can strand the
joiner, or the joiner can be already stranded in the name.
Args:
text: The text to cut.
width: The maximum number of cells that the result can occupy.
Returns:
A prefix that measures at most ``width`` cells, with each glyph in it whole.
"""
kept: list[str] = []
used = 0
for cluster in clusters(text):
size = cell_len(cluster)
if used + size > width:
break
kept.append(cluster)
used += size
while kept and kept[-1] == _ZWJ:
kept.pop()
return "".join(kept)
def drawable(text: str) -> str:
"""``text`` with each codepoint that a terminal cannot draw folded to a space.
A name comes over the radio from the node of a stranger, and nothing on the way in
promises that it holds only characters that a terminal can draw. A newline in a name ends
the row in the middle of a lane. An ``ESC`` starts an escape sequence in the middle of a
screen that the app composed. A bidi override (``U+202E``) reverses all that is drawn
after it. None of these is a question of width. They are text that must not get to the
terminal, so a lane folds them to the one character that is always safe.
Folded: control characters, the format and bidi-control block, surrogates, and the line
and paragraph separators. **Kept:** the zero-width joiner, which is also in the format
class and is the sign that holds an emoji sequence together. Also kept: each mark and
modifier from which a glyph is built. Private-use codepoints are also kept, because a
Nerd Font keeps in that block the powerline glyphs with which the path line is drawn.
"""
return "".join(
" " if char != _ZWJ and unicodedata.category(char) in _UNDRAWABLE else char for char in text
)
def install() -> None:
"""Patch each width authority with the reserve-two rule, one time, before the first frame.
The patches are: the whole-string width of Rich, its single-character width (in both
modules that read it), its grapheme spans, and the width cache of prompt_toolkit. All of
them use :func:`_glyph_width`. The module docstring gives the reason for all four, and for
the patch of all four at the same time. Each patch gives a string with nothing reservable in
it directly to the stock code that it replaced. Thus the common case pays for one scan.
It is safe to call this function more than one time. Only the first call does something.
The menu loop calls it on a platform that draws emoji. The module docstring gives the
reason why nothing else calls it.
"""
global _INSTALLED, _CLUSTERS
if _INSTALLED:
return
_INSTALLED = True
import prompt_toolkit.utils as ptu
import rich.cells as cells
import rich.segment as segment
from .render import _ANSI_CACHE
probe = _probe()
stock_size = cells.get_character_cell_size
stock_cell_len = cells._cell_len
stock_split = cells.split_graphemes
single_cells = cells._is_single_cell_widths
@lru_cache(maxsize=4096)
def get_character_cell_size(character: str, unicode_version: str = "auto") -> int:
width = _override(character)
return stock_size(character, unicode_version) if width is None else width
def _cell_len(text: str, unicode_version: str = "auto") -> int:
if single_cells(text):
return len(text)
if probe.search(text) is None:
return stock_cell_len(text, unicode_version)
return sum(
_glyph_width(glyph, lambda char: stock_size(char, unicode_version))
for glyph in clusters(text)
)
def split_graphemes(text: str, unicode_version: str = "auto") -> tuple[list, int]:
if probe.search(text) is None:
return stock_split(text, unicode_version)
spans: list[tuple[int, int, int]] = []
total = 0
index = 0
for glyph in clusters(text):
end = index + len(glyph)
width = _glyph_width(glyph, lambda char: stock_size(char, unicode_version))
if width == 0 and spans:
# Zero-width pieces belong to the glyph before them, as in the splitter of Rich
# itself. Thus a crop never lands between a letter and its mark.
start, _end, size = spans[-1]
spans[-1] = (start, end, size)
else:
spans.append((index, end, width))
total += width
index = end
return spans, total
class _ReservingCache(type(ptu._CHAR_SIZES_CACHE)): # type: ignore[misc]
"""The width cache of prompt_toolkit, which gets its answers from :func:`_glyph_width`."""
def __missing__(self, string: str) -> int:
if len(string) == 1:
width = _override(string)
if width is None:
return super().__missing__(string)
self[string] = width
return width
if probe.search(string) is None:
return super().__missing__(string)
total = sum(_glyph_width(glyph, self.__getitem__) for glyph in clusters(string))
# A whole line that holds an emoji is cheap to compute again. If a long line is
# cached here, it goes past the rotation of long strings in the base class, and the
# cache grows without limit.
if len(string) <= self.LONG_STRING_MIN_LEN:
self[string] = total
return total
cells.get_character_cell_size = get_character_cell_size
segment.get_character_cell_size = get_character_cell_size
cells._cell_len = _cell_len
cells.split_graphemes = split_graphemes
cells.cached_cell_len.cache_clear()
ptu._CHAR_SIZES_CACHE = _ReservingCache()
_CLUSTERS = True
# The measurement changed under each renderable. Thus all that was rasterized before is
# stale. (In the normal boot order, nothing was rasterized before, but never trust that.)
_ANSI_CACHE.clear()
class _Cluster(str):
"""A multi-codepoint glyph that must reach the screen buffer as **one** character.
prompt_toolkit builds its screen buffer one codepoint at a time (``for c in text`` in
``Window._copy_body``), and gives each codepoint its own ``Char``, which occupies a cell.
Thus a glyph that is correctly measured at two cells is still laid out as its parts: a shrug
followed by a male sign, a flag as two letters, a warning mark with a selector folded into a
slot one cell wide. An iteration over a cluster yields the whole glyph instead, so that loop
makes a single ``Char`` of it. The result is one width in the arithmetic of prompt_toolkit,
each codepoint written to the terminal one after the other, and one composed glyph on the
terminal.
It is a ``str`` subclass instead of a wrapper, because it must stay ordinary text through
all the other things that prompt_toolkit does with a fragment: join, slice, and compare.
``str`` methods return plain ``str``, so the first ``split`` loses the mark. For this
reason, :class:`ClusterTextControl` applies the mark to the finished lines, and not earlier.
"""
__slots__ = ()
def __iter__(self): # type: ignore[override]
yield str(self)
#: If one of these codepoints is in a laid-out line, a glyph on that line is built from several.
_JOINERS = frozenset((_ZWJ, *_EXTENDERS))
def _join_clusters(line: list) -> list:
"""Merge each multi-codepoint glyph in one screen line into a single :class:`_Cluster` fragment.
The line arrives with one codepoint in each fragment (that is what
:class:`~prompt_toolkit.formatted_text.ANSI` produces). Thus a glyph built from several
codepoints is a run to collect. It is the same run that :func:`clusters` reads from a plain
string: a base and what extends it, the indicator pair of a flag, and any number of
*joiner + component + extenders* groups.
Each such glyph is merged, also a lone selector pair and an emoji with a tone. Without the
merge, prompt_toolkit folds those into the cell before them itself, but while it does, it
keeps the place of that cell. Thus a mark measured at one cell and followed by a selector
becomes a two-cell glyph in a one-cell slot. Also,
:class:`~meshterm.ui.tui.colsnap.PinnedOutput` pins the writes of the renderer. A pin
between the two halves of a flag is a cursor move between them, and after that move, a
terminal no longer joins them into one glyph.
A joiner with no pictograph after it (refer to :func:`_joins`) is not part of a glyph, and
keeps its fragment. When nothing on the line can start or extend such a glyph, the whole
line is returned with no change. That is true of almost every line. The scan runs only when
the content of a control changes.
"""
if not any(item[1] in _JOINERS or _is_regional_indicator(item[1]) for item in line):
return line
merged: list = []
index = 0
count = len(line)
while index < count:
end = index + 1
while end < count and line[end][1] in _EXTENDERS:
end += 1
if (
_is_regional_indicator(line[index][1])
and end < count
and _is_regional_indicator(line[end][1])
):
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]):
end += 2 # the joiner and the codepoint that it joins
while end < count and line[end][1] in _EXTENDERS:
end += 1
if end - index > 1:
text = "".join(item[1] for item in line[index:end])
merged.append((line[index][0], _Cluster(text)))
else:
merged.append(line[index])
index = end
return merged
class ClusterTextControl(FormattedTextControl):
"""A :class:`~prompt_toolkit.layout.controls.FormattedTextControl` that keeps glyphs whole.
The control is the last place where the text of a screen is still in lines, and where we
can still change it. A merge of a glyph needs exactly that. ``split_lines`` runs before
this point and returns plain ``str`` parts, so a :class:`_Cluster` marked earlier does not
stay until the layout. All the steps after this point (the width lookup, the wrapping, the
screen buffer) read the merged lines.
The merge is gated on :func:`install`: it occurs only after that function has run. To give
prompt_toolkit a glyph as one character is correct only when its cache measures that
character as one glyph. Off that path (a test, a platform that draws no emoji), this class
is exactly its base class.
"""
def create_content(self, width: int, height: int | None): # type: ignore[override]
"""The content of the base class, with the multi-codepoint glyphs of each line joined.
The join is wrapped around the line lookup of the returned object, and cached behind
it. prompt_toolkit returns the same content object paint after paint, and a walk over
each line of each frame costs much more than it saves.
"""
content = super().create_content(width, height)
# The base class caches its ``UIContent`` for each (fragments, width, cursor), so the
# same object comes back paint after paint. Thus wrap its line lookup one time, and
# cache the merge behind it, instead of a new walk over each line of each frame.
if _CLUSTERS and not getattr(content, "_clusters_merged", False):
content._clusters_merged = True # type: ignore[attr-defined]
source = content.get_line
cache: dict[int, list] = {}
def get_line(i: int, _source=source, _cache=cache) -> list:
line = _cache.get(i)
if line is None:
line = _cache[i] = _join_clusters(_source(i))
return line
content.get_line = get_line
return content
__all__ = ["ClusterTextControl", "clusters", "cut_cells", "drawable", "install"]