diff --git a/CLAUDE.md b/CLAUDE.md index 30cc3eb..d9ce17a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -764,7 +764,9 @@ branch on the platform per frame, and never `from meshterm.platforms import PLAT (`platforms.drawn_by_meshterm`), so nothing offers to move to another terminal. Its glyph contract is `fontset.CARDPUTER_ZERO_CODEPOINTS`, named by `Platform.font` as the PicoCalc's is; the fold derives everything from the platform's font (accents, emoji, the `?` net) and quantizes - colour only where `truecolor` is off. + colour only where `truecolor` is off. The path line's chips-or-arrows verdict reads the + same font (`termfont`, after `MESHTERM_POWERLINE` and before any terminal probe): Terminus + has the chevrons, so the Cardputer draws chips; the PicoCalc's 512 glyphs don't, so arrows. - **The Cardputer's exit must fit its launcher's grace.** A held Esc there is the launcher's own way out (SIGTERM at 3 s, SIGKILL 3 s later, letting go or not), which is where hold-to-quit's timing comes from (see **Navigation**). MeshTerm's whole exit was diff --git a/meshterm/ui/termfont.py b/meshterm/ui/termfont.py index 7830554..1659aa6 100644 --- a/meshterm/ui/termfont.py +++ b/meshterm/ui/termfont.py @@ -33,11 +33,18 @@ nothing — which makes this an *out-of-band* detection problem: identify the te read its configuration, and match the configured face against fonts known to carry the powerline block. -Three sources of truth, in confidence order: +Four sources of truth, in confidence order: * **An explicit override** — ``MESHTERM_POWERLINE`` (``1``/``full``, ``core``, ``0``/``off``) always wins, the same gate pattern as ``MESHTERM_FULL_WIDTH``. The user knows their glass better than any probe. +* **A handheld's own font.** Where the platform names a glyph contract + (:attr:`~meshterm.platforms.Platform.font`), every frame is folded down to that font + at the render boundary, whatever terminal started the app — so the inventory is the + answer, and asking the terminal would be asking the wrong glass. The Cardputer's panel + is Terminus, which carries the core chevrons, so its paths are chips; the PicoCalc's + 512-glyph console font has no room for them, so its paths stay arrows. Nothing is + probed: :data:`~meshterm.ui.fontset.FONTS` already says what each font can draw. * **The configured font**, resolved per terminal: Windows Terminal (``WT_SESSION`` + ``WT_PROFILE_ID`` → the profile's ``font.face`` in ``settings.json``), VS Code's integrated terminal (``TERM_PROGRAM=vscode`` → ``terminal.integrated.fontFamily``, @@ -80,6 +87,8 @@ from functools import lru_cache from pathlib import Path from ..core import win32dll +from ..platforms import Platform, on_platform +from .fontset import FONTS #: Coverage levels a verdict (or a recommended font) can carry, strongest first. FULL = "full" @@ -196,7 +205,8 @@ class PowerlineSupport: Attributes: level: :data:`FULL`, :data:`CORE`, :data:`NONE`, or :data:`UNKNOWN`. - source: What decided it — ``"env"`` (the override), ``"font:"`` + source: What decided it — ``"env"`` (the override), ``"platform:"`` (a + handheld's own font inventory), ``"font:"`` (a configured face matched, or definitively didn't), ``"renderer:"`` (the terminal draws the glyphs itself), ``"ssh"`` (the font lives on the far client, unknowable), or ``"unknown"``. @@ -576,13 +586,37 @@ def _renderer_backed(environ: Mapping[str, str]) -> str | None: return None +#: The glyphs the path widget draws at ``core``: the solid triangle every seam is, and the +#: thin one that joins two fills the eye can't tell apart (:mod:`~meshterm.ui.pathline`). +_CORE_GLYPHS = (0xE0B0, 0xE0B1) + +#: What ``full`` adds that the widget uses: the rounded caps a path's square ends become. +_FULL_GLYPHS = (0xE0B4, 0xE0B6) + + +def _inventory_coverage(inventory: frozenset[int]) -> str: + """The coverage level a known glyph inventory earns — no guess, it is all listed.""" + if not all(code in inventory for code in _CORE_GLYPHS): + return NONE + return FULL if all(code in inventory for code in _FULL_GLYPHS) else CORE + + def _powerline_support( environ: Mapping[str, str] | None = None, *, cwd: Path | None = None, conhost_probe: Callable[[], str | None] = _conhost_face, + handheld: tuple[str, frozenset[int]] | None = None, ) -> PowerlineSupport: - """The uncached verdict (see :func:`powerline_support` for the ladder).""" + """The uncached verdict (see :func:`powerline_support` for the ladder). + + Args: + environ: The environment to inspect (defaults to ``os.environ``). + cwd: Where the VS Code workspace walk starts (defaults to the process cwd). + conhost_probe: The classic-console face reader (injectable for tests). + handheld: The platform's name and glyph inventory where a handheld's own font + draws the screen, or ``None`` on a terminal. + """ env = os.environ if environ is None else environ override = (env.get("MESHTERM_POWERLINE") or "").strip().lower() if override in {"0", "off", "no", "none", "false"}: @@ -591,6 +625,9 @@ def _powerline_support( return PowerlineSupport(FULL, "env") if override == "core": return PowerlineSupport(CORE, "env") + if handheld is not None: + name, inventory = handheld + return PowerlineSupport(_inventory_coverage(inventory), f"platform:{name}") detected = detect_terminal_font(env, cwd=cwd, conhost_probe=conhost_probe) matched = match_recommended(detected.face) if detected else None @@ -610,16 +647,17 @@ def _powerline_support( def powerline_support() -> PowerlineSupport: """The session's powerline verdict, decided once and cached. - The ladder: the ``MESHTERM_POWERLINE`` override, then the configured font matched - against :data:`RECOMMENDED_FONTS`, then a renderer that draws the glyphs itself, - then an honest ``none`` (face read, no match, no fallback) or ``unknown`` (ssh, or - a terminal we can't identify). Cached because the environment and settings files - don't change mid-session; tests exercise :func:`_powerline_support` directly. + The ladder: the ``MESHTERM_POWERLINE`` override, then a handheld platform's own font + inventory, then the configured font matched against :data:`RECOMMENDED_FONTS`, then + a renderer that draws the glyphs itself, then an honest ``none`` (face read, no + match, no fallback) or ``unknown`` (ssh, or a terminal we can't identify). Cached + because the environment and settings files don't change mid-session, and cleared on + a platform switch (:func:`_bind`); tests exercise :func:`_powerline_support` directly. Returns: The :class:`PowerlineSupport` verdict. """ - return _powerline_support(os.environ) + return _powerline_support(os.environ, handheld=_HANDHELD_FONT) def powerline_enabled() -> bool: @@ -640,6 +678,20 @@ def powerline_full() -> bool: return powerline_support().level == FULL +#: The active handheld's name and glyph inventory, or ``None`` where a terminal draws +#: whatever it is sent. Bound at platform-switch time (:func:`_bind`). +_HANDHELD_FONT: tuple[str, frozenset[int]] | None = None + + +@on_platform +def _bind(platform: Platform) -> None: + """Bind the handheld font the verdict reads, and drop the verdict the last one made.""" + global _HANDHELD_FONT + inventory = FONTS.get(platform.font) + _HANDHELD_FONT = (platform.name, inventory) if inventory is not None else None + powerline_support.cache_clear() + + # --- installed-font scan ---------------------------------------------------------- diff --git a/tests/test_pathline.py b/tests/test_pathline.py index e08980f..aa4f905 100644 --- a/tests/test_pathline.py +++ b/tests/test_pathline.py @@ -14,6 +14,7 @@ import random import pytest import meshterm.ui.pathline as pathline +from meshterm.platforms import CARDPUTER_ZERO, set_platform from meshterm.ui import oklab from meshterm.ui.pathline import ( _DIM_BG, @@ -44,7 +45,7 @@ from meshterm.ui.pathline import ( path_line, with_action_mark, ) -from meshterm.ui.theme import node_style +from meshterm.ui.theme import fold_text, node_style from meshterm.ui.widgets import path_text @@ -158,6 +159,23 @@ def test_auto_mode_follows_the_terminal_verdict(monkeypatch: pytest.MonkeyPatch) assert PathLine(hops).text().plain == "a → b" +def test_the_cardputer_draws_chips_in_glyphs_its_font_has(monkeypatch: pytest.MonkeyPatch) -> None: + """The Cardputer's own font has the chevrons, so its paths are chips that fold clean. + + Its verdict is read off its font (:mod:`~meshterm.ui.termfont`), and the render + boundary folds whatever that font lacks to ``?`` — so every mark the chip language + spends (both seams, both cracks, the elision, our star) must come through unchanged. + """ + monkeypatch.delenv("MESHTERM_POWERLINE") + set_platform(CARDPUTER_ZERO) + hops = [PathHop(SELF_GLYPH, you=True), PathHop("Hub", key="3d"), elision_hop()] + line = PathLine([*hops, PathHop("a"), PathHop("b")]).text() # auto: the font decides + drawn = [line, cut_to(line, 12), cut_mark(line, 1, ELIDE_HEAD)] + ink = "".join(text.plain for text in drawn) + assert {POWERLINE_SEP, POWERLINE_THIN, CRACK_TAIL, CRACK_HEAD, "⋯", SELF_GLYPH} <= set(ink) + assert fold_text(ink) == ink + + def test_the_cursor_is_a_hop_of_its_own_in_either_mode() -> None: """The composer's insertion point is a slot *in* the route, not a gap between hops. diff --git a/tests/test_termfont.py b/tests/test_termfont.py index f958ee0..587288d 100644 --- a/tests/test_termfont.py +++ b/tests/test_termfont.py @@ -12,6 +12,10 @@ from __future__ import annotations import json from pathlib import Path +import pytest + +from meshterm.platforms import CARDPUTER_ZERO, PICOCALC_LYRA, REGULAR, set_platform +from meshterm.ui.fontset import CARDPUTER_ZERO_CODEPOINTS, FONT_CODEPOINTS from meshterm.ui.termfont import ( CORE, FULL, @@ -30,6 +34,7 @@ from meshterm.ui.termfont import ( installed_recommended, match_recommended, normalize_face, + powerline_support, primary_family, ) @@ -248,6 +253,40 @@ def test_powerline_support_kitty_ssh_and_unknown() -> None: assert (lost.level, lost.source) == (UNKNOWN, "unknown") +def test_powerline_support_a_handheld_answers_from_its_own_font() -> None: + """A handheld's font draws the screen, so its inventory answers and no terminal does. + + The Cardputer's Terminus carries the core chevrons and no rounded caps; the PicoCalc's + 512-glyph console font has neither, so a kitty that started it changes nothing. The + override still outranks the font, as it outranks every probe. + """ + kitty = {"TERM": "xterm-kitty"} # a terminal that would otherwise earn core + cardputer = _powerline_support(kitty, handheld=("cardputer-zero", CARDPUTER_ZERO_CODEPOINTS)) + assert (cardputer.level, cardputer.source) == (CORE, "platform:cardputer-zero") + picocalc = _powerline_support(kitty, handheld=("picocalc-lyra", FONT_CODEPOINTS)) + assert (picocalc.level, picocalc.source) == (NONE, "platform:picocalc-lyra") + capped = CARDPUTER_ZERO_CODEPOINTS | {0xE0B4, 0xE0B6} + assert _powerline_support({}, handheld=("capped", capped)).level == FULL + pinned = {"MESHTERM_POWERLINE": "0"} + assert _powerline_support(pinned, handheld=("cardputer-zero", CARDPUTER_ZERO_CODEPOINTS)) == ( + _powerline_support(pinned) + ) + + +def test_powerline_support_follows_a_platform_switch(monkeypatch: pytest.MonkeyPatch) -> None: + """The memoized verdict is dropped on a switch, so it never answers for the last glass.""" + monkeypatch.delenv("MESHTERM_POWERLINE", raising=False) + monkeypatch.setenv("TERM", "xterm-kitty") + monkeypatch.delenv("WT_SESSION", raising=False) + monkeypatch.delenv("TERM_PROGRAM", raising=False) + set_platform(CARDPUTER_ZERO) + assert powerline_support().source == "platform:cardputer-zero" + set_platform(PICOCALC_LYRA) + assert powerline_support().source == "platform:picocalc-lyra" + set_platform(REGULAR) + assert powerline_support().source == "renderer:kitty" + + def test_installed_recommended_never_raises() -> None: """Scanning the machine for installed fonts never raises.