diff --git a/CHANGELOG.md b/CHANGELOG.md index c64c366..f844213 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,19 @@ allowed to change behaviour, not just add to it. ## [Unreleased] +### Fixed + +- **Every icon was a box in the classic Windows console.** That console — which is what a + double-clicked build still gets — draws no emoji at all, whatever font it is set to, so + the menu came up as rows of replacement characters. + + MeshTerm now works out which console it is running in and, on that one, draws its icons + through the compact single-character table it already keeps for the PicoCalc: `☼` for an + advert, `¶` for a message, `★` for the trophy case. Nothing else about the desktop + layout changes — same width, same colours, same accented names. Running in Windows + Terminal or VS Code's terminal keeps the emoji, and `MESHTERM_EMOJI=1` or `=0` forces + the matter either way. `meshterm platform` reports which it chose and why. + ## [0.2.4] — 2026-09-07 ### Fixed diff --git a/meshterm/cli.py b/meshterm/cli.py index 90915c2..25288f5 100644 --- a/meshterm/cli.py +++ b/meshterm/cli.py @@ -28,9 +28,10 @@ from .core.preferences import PreferenceError, Preferences from .core.selection import DeviceSelectionError from .persistence.logging import configure_logging, get_logger, level_from_name, log_path from .persistence.repository import Repository -from .platforms import Resolution, resolve, set_platform +from .platforms import Resolution, resolve, set_platform, without_emoji from .tools import all_tools from .tools.base import Tool, ToolResult +from .ui.termfont import emoji_support from .ui.theme import make_console @@ -138,7 +139,14 @@ def main_callback( _platform_resolution = resolve(platform) except ValueError as exc: raise typer.BadParameter(str(exc), param_hint="--platform") from exc - set_platform(_platform_resolution.platform) + # A terminal that cannot draw emoji is asked about here rather than in resolve(), + # which answers "which flavour" from the flag, the env and the device tree and should + # stay that pure — this is the *terminal's* limit, and it applies whichever flavour + # those three chose. See meshterm.ui.termfont.emoji_support. + active = _platform_resolution.platform + if not emoji_support().supported: + active = without_emoji(active) + set_platform(active) settings = Settings.load() if db_path is not None: @@ -244,6 +252,10 @@ def platform_command() -> None: flavour a given invocation would run as, and why — the ``--platform``/ ``MESHTERM_PLATFORM`` inputs, what ``/proc/device-tree/model`` reports (``None`` off the actual hardware), and which of those decided it. + + It also reports the terminal's own emoji verdict, which is a separate question from + the flavour and the usual reason a Windows session looks plainer than the screenshots + (see :func:`meshterm.ui.termfont.emoji_support`). """ assert _platform_resolution is not None # set by the callback that always runs first r = _platform_resolution @@ -253,6 +265,16 @@ def platform_command() -> None: console.print(f" MESHTERM_PLATFORM: {r.env or '(not set)'}") console.print(f" /proc/device-tree/model: {r.detected_model or '(unavailable)'}") + emoji = emoji_support() + verdict = "yes" if emoji.supported else "no — icons use the compact glyphs" + console.print(f"icons: [accent]{verdict}[/accent] (source: {emoji.source})") + if not emoji.supported and emoji.source == "console": + console.print( + " this is the classic Windows console, which draws no emoji whatever font\n" + " it is set to. Windows Terminal and VS Code's terminal both do — running\n" + " MeshTerm in one gets the icons back. MESHTERM_EMOJI=1 overrides this." + ) + @app.command(name="specimen") def specimen_command() -> None: diff --git a/meshterm/platforms.py b/meshterm/platforms.py index b288afc..216e93b 100644 --- a/meshterm/platforms.py +++ b/meshterm/platforms.py @@ -19,7 +19,7 @@ from __future__ import annotations import os from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, replace from pathlib import Path @@ -342,10 +342,30 @@ def resolve(flag: str | None = None) -> Resolution: return Resolution(flag, env, model, "default", REGULAR) +def without_emoji(platform: Platform) -> Platform: + """The same platform with its icons routed through the compact glyph table. + + A terminal that cannot draw emoji is not a different *flavour* — the classic Windows + console has the desktop's width, colour depth and keyboard, and wants every other + thing :data:`REGULAR` says. Only the one flag moves, which is what the seam's flags + being independent is for: ``ascii_fold`` stays off, so accents and the rest of the + BMP still come through. + + Args: + platform: The resolved platform. + + Returns: + ``platform`` itself when its icons are already compact, else a copy with + ``emoji`` cleared. + """ + return platform if not platform.emoji else replace(platform, emoji=False) + + __all__ = [ "Platform", "REGULAR", "PICOCALC", + "without_emoji", "PLATFORM", "get_platform", "set_platform", diff --git a/meshterm/ui/termfont.py b/meshterm/ui/termfont.py index 1511092..ef0cbcc 100644 --- a/meshterm/ui/termfont.py +++ b/meshterm/ui/termfont.py @@ -1,4 +1,25 @@ -"""Terminal font detection — can this terminal draw powerline path separators? +"""Terminal capability detection — what can this terminal actually draw? + +Two questions, both answered out-of-band because a terminal cannot be asked in-band: a +glyph it does not have still occupies its cell, so the screen looks the same whether the +character arrived or not. + +**Powerline separators**, below, are a *font* question — read the terminal's configured +face and match it against fonts known to carry the block. + +**Emoji** (:func:`emoji_support`) are a *host* question, and only on Windows. The classic +console — ``conhost``, which is what a double-clicked program still gets — rasterises +through GDI with the console font and nothing behind it, so an emoji lands as a +replacement box however the font is configured. Everything that replaced it draws them +fine. So the verdict identifies the host, structurally rather than from the environment +(``WT_SESSION`` is inherited across process launches, so a program started from Windows +Terminal into a console of its own still claims to be in one), and a ``False`` routes +every icon through the compact single-glyph table :func:`meshterm.ui.theme.glyph` already +keeps for the PicoCalc — that whole vocabulary is BMP, so it draws wherever the plain +status marks already do. + +Both verdicts are hints that pick a default, and both have an override for the reader who +knows better than the probe: ``MESHTERM_POWERLINE`` and ``MESHTERM_EMOJI``. The path-line widget (:mod:`~meshterm.ui.pathline`) can render a hop sequence as interlocking powerline segments — each hop a colour-filled chip, joined by the solid @@ -677,3 +698,166 @@ def installed_recommended() -> RecommendedFont | None: return matched best = best or matched return best + + +# --- emoji support ---------------------------------------------------------------- + + +@dataclass(frozen=True) +class EmojiSupport: + """Whether this terminal can show an emoji at all, and what decided that. + + Attributes: + supported: ``True`` when an emoji icon reaches the screen as itself. + source: ``"env"`` (the ``MESHTERM_EMOJI`` override), ``"platform"`` (not Windows, + so not the classic console's problem), ``"console"`` (the host was identified + — see :func:`_is_classic_console`), or ``"no-console"`` (nothing to identify: + output is redirected, or the call failed). + """ + + supported: bool + source: str + + +def _is_classic_console() -> bool | None: + """Whether this process is drawing into a genuine ``conhost`` window. + + Which is the whole question, because that host renders through GDI with the console + font and no emoji font behind it, while everything that replaced it — Windows + Terminal, VS Code's terminal, anything over ssh — draws emoji as a matter of course. + + It cannot be asked in band. A missing glyph still occupies its cell, so the screen + reads back exactly as it would have if the character had drawn: measured on Windows 10 + 22H2, a classic console stores ``📡`` intact and advances two cells while showing a + single replacement box, and a ConPTY (which does draw it) reads back as U+FFFD because + the buffer behind it is updated asynchronously. A write-and-read-back probe answers + both hosts *backwards*, which is the trap this docstring exists to record. + + Nor can the environment be trusted alone: ``WT_SESSION`` is inherited by child + processes, so a program launched from Windows Terminal into a console of its own still + carries it and would answer for the wrong host. + + So the host is identified structurally, from two independent facts, and both must + agree before anything is dimmed (the costly mistake is stripping icons from a terminal + that could draw them): + + * **The console font has a real pixel width.** A ConPTY's hidden conhost reports a + stub — face ``Consolas``, cell width ``0`` — because nothing is rasterised there. + * **The buffer is taller than its window.** A classic console owns its scrollback + (9001 rows by default); under a ConPTY the terminal owns it, so the buffer is + exactly the window. + + Returns: + ``True`` for a genuine classic console, ``False`` for anything else drawing to a + console, and ``None`` when there is no console on stdout (redirected output, not + Windows, or the call failed) — an unknown is never a reason to degrade. + """ + if sys.platform != "win32": + return None + try: + import ctypes + from ctypes import wintypes + + class _COORD(ctypes.Structure): + _fields_ = [("X", wintypes.SHORT), ("Y", wintypes.SHORT)] + + class _SMALL_RECT(ctypes.Structure): + _fields_ = [ + ("Left", wintypes.SHORT), + ("Top", wintypes.SHORT), + ("Right", wintypes.SHORT), + ("Bottom", wintypes.SHORT), + ] + + class _CSBI(ctypes.Structure): + _fields_ = [ + ("dwSize", _COORD), + ("dwCursorPosition", _COORD), + ("wAttributes", wintypes.WORD), + ("srWindow", _SMALL_RECT), + ("dwMaximumWindowSize", _COORD), + ] + + class _CONSOLE_FONT_INFOEX(ctypes.Structure): + _fields_ = [ + ("cbSize", wintypes.ULONG), + ("nFont", wintypes.DWORD), + ("dwFontSize", _COORD), + ("FontFamily", wintypes.UINT), + ("FontWeight", wintypes.UINT), + ("FaceName", ctypes.c_wchar * 32), + ] + + # Safe to declare: this handle is ours alone (see meshterm.core.win32dll). The + # explicit restype matters — ctypes would assume a 32-bit int and truncate the + # 64-bit HANDLE, turning every later call into a silent failure. + kernel32 = win32dll.kernel32() + kernel32.GetStdHandle.argtypes = [wintypes.DWORD] + kernel32.GetStdHandle.restype = wintypes.HANDLE + kernel32.GetConsoleScreenBufferInfo.argtypes = [wintypes.HANDLE, ctypes.POINTER(_CSBI)] + kernel32.GetConsoleScreenBufferInfo.restype = wintypes.BOOL + kernel32.GetCurrentConsoleFontEx.argtypes = [ + wintypes.HANDLE, + wintypes.BOOL, + ctypes.POINTER(_CONSOLE_FONT_INFOEX), + ] + kernel32.GetCurrentConsoleFontEx.restype = wintypes.BOOL + + handle = kernel32.GetStdHandle(wintypes.DWORD(-11).value) # STD_OUTPUT_HANDLE + info = _CSBI() + # Fails when stdout is a pipe or a file — nothing is being drawn, so there is + # nothing to answer, and this is what keeps redirected and CI runs out of it. + if not kernel32.GetConsoleScreenBufferInfo(handle, ctypes.byref(info)): + return None + + font = _CONSOLE_FONT_INFOEX() + font.cbSize = ctypes.sizeof(_CONSOLE_FONT_INFOEX) + if not kernel32.GetCurrentConsoleFontEx(handle, False, ctypes.byref(font)): + return None + + rasterised = font.dwFontSize.X > 0 + owns_scrollback = info.dwSize.Y > (info.srWindow.Bottom - info.srWindow.Top + 1) + return rasterised and owns_scrollback + except Exception: # pragma: no cover - a probe that fails is an unknown, not a crash + return None + + +def _emoji_support( + environ: Mapping[str, str] | None = None, + *, + console_probe: Callable[[], bool | None] = _is_classic_console, + system: str | None = None, +) -> EmojiSupport: + """The uncached verdict (see :func:`emoji_support` for the ladder).""" + env = os.environ if environ is None else environ + override = (env.get("MESHTERM_EMOJI") or "").strip().lower() + if override in {"0", "off", "no", "none", "false"}: + return EmojiSupport(False, "env") + if override in {"1", "on", "yes", "true"}: + return EmojiSupport(True, "env") + + if (sys.platform if system is None else system) != "win32": + return EmojiSupport(True, "platform") + classic = console_probe() + if classic is None: + return EmojiSupport(True, "no-console") + return EmojiSupport(not classic, "console") + + +@lru_cache(maxsize=1) +def emoji_support() -> EmojiSupport: + """Whether emoji icons can be drawn here, decided once and cached. + + The ladder: the ``MESHTERM_EMOJI`` override, then everything that isn't Windows (where + a UTF-8 terminal draws them as a matter of course), then :func:`_is_classic_console`. + Cached because a session does not change host halfway through. + + Returns: + The :class:`EmojiSupport` verdict. + """ + return _emoji_support(os.environ) + + +def emoji_enabled() -> bool: + """Whether icons should render as emoji rather than through the compact table.""" + return emoji_support().supported diff --git a/meshterm/ui/tui/session.py b/meshterm/ui/tui/session.py index 10441ec..500d30b 100644 --- a/meshterm/ui/tui/session.py +++ b/meshterm/ui/tui/session.py @@ -283,13 +283,20 @@ def _has_wide_glyph(text: str) -> bool: ``>= 0x1100`` guard skips the ASCII/Latin bulk of a frame before the width lookup, which matters because this runs over the whole composed frame on every repaint. - On a platform that draws no emoji the answer is ``False`` by construction — its console - font is a fixed 512-glyph set with no wide glyph in it, so nothing a frame can contain - would return ``True``. Answering from the platform instead of the text skips a - per-character scan of the entire frame on every repaint, and with it the scrub machinery - that a ``True`` would arm, leaving the cheap differential paint permanently in play. + On a platform that folds its frames to a fixed font the answer is ``False`` by + construction — that font is a 512-glyph set with no wide glyph in it, so nothing a + composed frame can contain would return ``True``. Answering from the platform instead + of the text skips a per-character scan of the entire frame on every repaint, and with + it the scrub machinery that a ``True`` would arm, leaving the cheap differential paint + permanently in play. + + The fold is the guarantee, not the icon table: a terminal that merely can't draw + *emoji* (the classic Windows console — see :func:`meshterm.ui.termfont.emoji_support`) + still renders the rest of the BMP, and a contact named in CJK is two cells wide there + like anywhere else. Reading ``emoji`` here instead would have skipped the scan on a + frame that genuinely needed it, and smeared the row. """ - if not get_platform().emoji: + if get_platform().ascii_fold: return False return any(ord(ch) >= 0x1100 and get_cwidth(ch) == 2 for ch in text) diff --git a/tests/test_platforms.py b/tests/test_platforms.py index 65cdd47..0fc1354 100644 --- a/tests/test_platforms.py +++ b/tests/test_platforms.py @@ -6,12 +6,20 @@ sanity — PicoCalc being strictly the narrower, plainer flavour. from __future__ import annotations +import dataclasses from pathlib import Path import pytest import meshterm.platforms as platforms_mod -from meshterm.platforms import PICOCALC, REGULAR, get_platform, resolve, set_platform +from meshterm.platforms import ( + PICOCALC, + REGULAR, + get_platform, + resolve, + set_platform, + without_emoji, +) def test_regular_is_todays_behaviour() -> None: @@ -142,3 +150,52 @@ def test_resolve_auto_detect_is_conservative(monkeypatch: pytest.MonkeyPatch) -> r = resolve() assert r.platform is REGULAR assert r.detected_model == "Raspberry Pi 4 Model B" + + +# -- the emoji-less variant ------------------------------------------------------------ + + +def test_without_emoji_moves_one_flag_and_nothing_else() -> None: + """A terminal that can't draw emoji is not a different flavour. + + The classic Windows console has the desktop's width, colour depth, borders and + keyboard — only the icons have to change. Every other field must survive, and + ``ascii_fold`` above all: folding there would strip accents from names the console + renders perfectly well. + """ + plain = without_emoji(REGULAR) + assert plain.emoji is False + assert plain.ascii_fold is False + assert plain.truecolor is True + assert plain.readable_cols == REGULAR.readable_cols + assert plain.footer_fkeys is REGULAR.footer_fkeys + + changed = { + f.name + for f in dataclasses.fields(REGULAR) + if getattr(plain, f.name) != getattr(REGULAR, f.name) + } + assert changed == {"emoji"} + + +def test_without_emoji_leaves_an_already_compact_platform_alone() -> None: + """A platform whose icons are already compact comes back untouched. + + The same object, not a copy that would compare equal but break every ``is`` check + the suite makes against PICOCALC. + """ + assert without_emoji(PICOCALC) is PICOCALC + + +def test_the_emoji_less_variant_still_drives_the_theme() -> None: + """The derived platform is a real one: binding it routes icons through the table. + + This is the whole point — the seam's flags are independent, so clearing one reaches + the glyph funnel without dragging the console font's fold along with it. + """ + from meshterm.ui.theme import fold_text, glyph + + set_platform(without_emoji(REGULAR)) + assert glyph("📡") == "☼" # the advert icon, compacted + assert fold_text("café Montréal") == "café Montréal" # no fold + assert get_platform().readable_cols == 72 diff --git a/tests/test_termfont.py b/tests/test_termfont.py index 3ddce5d..7ae979f 100644 --- a/tests/test_termfont.py +++ b/tests/test_termfont.py @@ -1,8 +1,9 @@ """Terminal-font detection tests: the recommended list, the ladder, the verdict. -Everything runs against injected environments and temp settings files — no test -touches the real registry, the real Win32 console, or the machine's actual fonts — -so the suite decides the same verdicts on any box. +Every verdict runs against injected environments, temp settings files and injected +probes, so the suite decides the same answers on any box. Two tests deliberately do +touch the real machine — the installed-font scan and the console buffer probe — and both +assert only that asking is safe, never what this particular box replies. """ from __future__ import annotations @@ -17,6 +18,8 @@ from meshterm.ui.termfont import ( NONE, UNKNOWN, RecommendedFont, + _emoji_support, + _is_classic_console, _powerline_support, _read_jsonc, _vscode_default_face, @@ -252,3 +255,83 @@ def test_installed_recommended_never_raises() -> None: """ result = installed_recommended() assert result is None or isinstance(result, RecommendedFont) + + +# --- emoji support ---------------------------------------------------------------- + + +def test_emoji_is_only_the_windows_consoles_problem() -> None: + """Everywhere but Windows, a terminal draws emoji and the question does not arise. + + The probe is never even run there — a Linux or macOS session pays nothing for a limit + only the classic console has. + """ + + def never_called() -> bool | None: + raise AssertionError("the console probe must not run off Windows") + + for system in ("linux", "darwin", "freebsd"): + verdict = _emoji_support({}, console_probe=never_called, system=system) + assert (verdict.supported, verdict.source) == (True, "platform") + + +def test_a_classic_console_turns_the_icons_compact() -> None: + """The verdict that matters: conhost draws no emoji, so the icons go compact. + + This is the double-clicked build on Windows — the case the whole check exists for. + """ + verdict = _emoji_support({}, console_probe=lambda: True, system="win32") + assert (verdict.supported, verdict.source) == (False, "console") + + +def test_every_other_windows_terminal_keeps_its_emoji() -> None: + """Windows Terminal, VS Code's terminal and ssh all draw them; nothing changes.""" + verdict = _emoji_support({}, console_probe=lambda: False, system="win32") + assert (verdict.supported, verdict.source) == (True, "console") + + +def test_an_unanswerable_probe_never_degrades_the_ui() -> None: + """No console to identify (redirected output, a failed call) leaves emoji on. + + An unknown is not evidence of a limit. Degrading on one would strip the icons from + every piped or redirected run, which is most of the test suite and all of CI. + """ + verdict = _emoji_support({}, console_probe=lambda: None, system="win32") + assert (verdict.supported, verdict.source) == (True, "no-console") + + +def test_the_override_beats_the_probe_both_ways() -> None: + """``MESHTERM_EMOJI`` wins, as the powerline gate's override does. + + Someone on a console this cannot recognise knows their own glass better than a probe + does — and the negative direction is how anyone asks for the compact icons on purpose. + """ + forced_on = _emoji_support({"MESHTERM_EMOJI": "1"}, console_probe=lambda: True, system="win32") + assert (forced_on.supported, forced_on.source) == (True, "env") + + def never_called() -> bool | None: + raise AssertionError("the override decides before the probe runs") + + forced_off = _emoji_support({"MESHTERM_EMOJI": "0"}, console_probe=never_called, system="linux") + assert (forced_off.supported, forced_off.source) == (False, "env") + + +def test_an_inherited_wt_session_cannot_speak_for_the_host() -> None: + """The environment is not consulted, and this is why. + + ``WT_SESSION`` is inherited by child processes, so a program launched from Windows + Terminal into a console of its own still carries it — measured, not supposed. Reading + it would have answered for the terminal that did the launching. + """ + env = {"WT_SESSION": "670f6626-8bdf-4c09-9164-b1eef91abe4f"} + verdict = _emoji_support(env, console_probe=lambda: True, system="win32") + assert verdict.supported is False + + +def test_identifying_the_host_never_raises() -> None: + """Whatever this machine is, asking is safe and answers one of three things. + + It runs on a startup path, so "it degrades rather than raises" is the property that + keeps an unusual host from taking the app down with it. + """ + assert _is_classic_console() in (True, False, None) diff --git a/tests/test_theme16.py b/tests/test_theme16.py index 8f8fce2..d84b5f8 100644 --- a/tests/test_theme16.py +++ b/tests/test_theme16.py @@ -150,6 +150,24 @@ def test_every_glyph_map_target_is_in_the_console_font() -> None: ) +def test_every_glyph_map_target_stays_inside_the_bmp() -> None: + """The table has a second consumer that the console font cannot speak for. + + The classic Windows console keeps one 16-bit code unit per cell, so a character above + U+FFFF is replaced by U+FFFD before its font is ever consulted — which is why MeshTerm + routes icons through this table there too (see + :func:`meshterm.ui.termfont.emoji_support`). A substitute "improved" into an emoji + would draw fine on the PicoCalc's own font and silently break every Windows console, + so the plane is asserted here rather than left to the font whitelist to imply. + """ + for emoji, compact in theme._GLYPH_MAP.items(): + for ch in compact: + assert ord(ch) <= 0xFFFF, ( + f"{emoji!r} maps to {compact!r}; {ch!r} is above U+FFFF and no classic " + f"Windows console can hold it" + ) + + def test_glyph_map_targets_never_widen_their_icon() -> None: """A compact form at most matches its emoji's measured width (the fold pads the rest).""" for emoji, compact in theme._GLYPH_MAP.items():