The console that can't draw an emoji says so before we draw one

The classic Windows console renders through GDI with the console font and no
emoji font behind it, so every icon in the menu came up as a replacement box.
It is what a double-clicked build gets, which is most of the Windows audience.

The fix already existed: the compact single-character glyph table built for the
PicoCalc, reached by Platform.emoji. The seam's flags are independent, so
clearing that one alone keeps the desktop's width, colour and accented names and
moves only the icons — without_emoji() is the whole change to the platform.

Identifying the host has to be structural. It cannot be asked in band, because a
missing glyph still fills its cell: a classic console stores an emoji intact and
advances two cells while showing a box, and a ConPTY reads back as U+FFFD while
drawing it perfectly, so a write-and-read-back probe answers both hosts
backwards. Nor can the environment answer, since WT_SESSION is inherited across
process launches. So two independent facts must agree — the console font has a
real pixel width (a ConPTY's hidden conhost reports zero) and the buffer is
taller than its window (a ConPTY has no scrollback of its own) — and an unknown
never degrades anything. MESHTERM_EMOJI overrides either way, and
`meshterm platform` reports the verdict and its reason.

Verified on Windows 10 22H2 in all three: a real conhost turns the icons
compact, Windows Terminal keeps its emoji, a redirected run is left alone.

_has_wide_glyph asked the same flag to mean "nothing here is two cells wide",
which was true of the PicoCalc's fixed font rather than of the flag. A console
that merely lacks emoji still renders the rest of the BMP, and a contact named
in CJK is two cells wide there like anywhere else, so it now asks ascii_fold —
the fold is what actually guarantees it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5 committed 2026-09-07 15:47:27 -04:00
1 parent d062b5b706
commit ea5f644d73
8 files changed
+418 -14

No files matched your search

+13
View File
@@ -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
+24 -2
View File
@@ -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:
+21 -1
View File
@@ -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",
+185 -1
View File
@@ -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
+13 -6
View File
@@ -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)
+58 -1
View File
@@ -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
+86 -3
View File
@@ -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)
+18
View File
@@ -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():