Files
Jean-Pierre MartineauandClaude Opus 5.5 efc02ddced The code text uses the glossary words in more places
A final pass over the docstrings and comments: "the own X of Y" is now
"Y's own X" (84 places), a navigation entry is a screen on the stack, our
node is our node, a paint is a paint, the quoted UX labels are in code
font, and a list of punctuation glyphs is in code font.

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

1027 lines
41 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SPDX-License-Identifier: Apache-2.0
"""Terminal capability detection: what can this terminal draw?
There are two questions. MeshTerm answers both out-of-band, because it cannot ask a
terminal in-band. A glyph that the terminal does not have still occupies its cell, so the
screen looks the same if the character arrived or not.
**Powerline separators**, below, are a question about the font. MeshTerm reads the
configured face of the terminal and matches it against fonts that are known to have the
block.
**Emoji** (:func:`emoji_support`) are a question about the host, and only on Windows. The
classic console (``conhost``, which a program that the user double-clicks still gets)
rasterizes through GDI with the console font and nothing behind it. Thus an emoji is a
replacement box, in any font configuration. All the hosts that replaced it draw emoji
correctly. So the verdict identifies the host, from its structure and not from the
environment. (``WT_SESSION`` is inherited across process launches. A program that starts
from Windows Terminal into a console of its own still says that it is in Windows
Terminal.) A ``False`` sends each icon through the compact single-glyph table
:func:`meshterm.ui.theme.glyph`, which MeshTerm already keeps for the PicoCalc. All of
this vocabulary is in the BMP, so it draws wherever the plain status marks draw.
Both verdicts are hints that pick a default. Each has an override for the user who knows
better than the probe: ``MESHTERM_POWERLINE`` and ``MESHTERM_EMOJI``.
The path-line widget (:mod:`~meshterm.ui.pathline`) can render a sequence of hops as
interlocking powerline segments. Each hop is a chip with a colour fill. The solid
triangle U+E0B0 joins the chips. Its foreground is the fill of the previous chip, and its
background is the fill of the next chip (the oh-my-posh look). That triangle is in the
Private Use Area, so it draws only when the configured font of the terminal (or the
terminal itself) supplies the glyph. A font without the glyph shows tofu boxes. MeshTerm
cannot ask in-band if the terminal has this glyph. A missing glyph still occupies its
cell, so even a probe of the cursor position sees nothing. Thus this is a problem of
out-of-band detection. MeshTerm identifies the terminal, reads its configuration, and
matches the configured face against fonts that are known to have the powerline block.
There are four sources of truth, in the order of confidence:
* **An explicit override.** ``MESHTERM_POWERLINE`` (``1``/``full``, ``core``,
``0``/``off``) always wins. It is the same gate pattern as ``MESHTERM_FULL_WIDTH``. The
user knows their own display better than any probe.
* **The font of a handheld.** Where the platform names a glyph contract
(:attr:`~meshterm.platforms.Platform.font`), the render boundary folds each frame down
to that font, in any terminal that started the app. Thus the inventory is the answer,
and a question to the terminal is a question to the wrong display. The panel of the
Cardputer is Terminus, which has the core chevrons. The 512-glyph console font of the
PicoCalc draws the two chevrons in two donor slots. Thus the paths are chips on both
handhelds. MeshTerm does not probe: :data:`~meshterm.ui.fontset.FONTS` already says
what each font can draw.
* **The configured font**, found for each terminal. The terminals are Windows Terminal
(``WT_SESSION`` + ``WT_PROFILE_ID`` → the ``font.face`` of the profile in
``settings.json``), the integrated terminal of VS Code (``TERM_PROGRAM=vscode`` →
``terminal.integrated.fontFamily``, the workspace before the user, with
``editor.fontFamily`` as the fallback), and classic conhost (``GetCurrentConsoleFontEx``,
which MeshTerm trusts only when it detects no ConPTY host, because the hidden conhost
under a ConPTY host reports a stub face and not what is on the screen). MeshTerm
matches the face against :data:`RECOMMENDED_FONTS` (the list of fonts that MeshTerm
recommends to users). The list has the alias spellings that Nerd Fonts use (``Hack Nerd
Font Mono`` / ``Hack NFM``). A generic rule, "any Nerd Font", is also in the match,
because each Nerd Font patch has the full powerline block.
* **The renderer of the terminal.** Several terminals draw the core triangles in any
font. Windows Terminal falls back to its bundled Cascadia Code NF for the symbol ranges
(version 1.22 and later). The terminal of VS Code draws them as custom glyphs
(``terminal.integrated.customGlyphs``, on by default). kitty, WezTerm, and Alacritty
have built-in powerline glyphs. These terminals count as ``core`` support, also when
the configured font matches nothing.
There are two levels of coverage. ``full`` is a Nerd Font patch: the triangles and the
extended block (rounded caps and slants). ``core`` is only U+E0B0–U+E0B3, which many
stock coder fonts (Fira Code, JetBrains Mono, Source Code Pro) have natively. The path
widget uses only the core triangle, so ``core`` is enough to switch it on. ``full`` is
more than the widget needs, for more complex chrome. When MeshTerm cannot know the
answer, for example in an ssh session (the font is on the machine of the far client) or
in an unrecognized terminal, the verdict is ``unknown``. Then callers must keep the
plain-arrow rendering.
Each probe does its best. Unreadable settings, missing registry keys, or a failed Win32
call lower the verdict, and never raise an exception. The result is a hint that picks a
default. It is not a gate that the user must fight.
"""
from __future__ import annotations
import json
import os
import re
import sys
from collections.abc import Callable, Iterable, Mapping
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from ..core import win32dll
from ..platforms import Platform, on_platform
from .fontset import FONTS
#: The levels of coverage that a verdict (or a recommended font) can have, the strongest first.
FULL = "full"
CORE = "core"
NONE = "none"
UNKNOWN = "unknown"
@dataclass(frozen=True)
class RecommendedFont:
"""One font that MeshTerm recommends for the rendering of powerline paths.
Attributes:
name: The canonical display name (what a screen of recommendations shows).
aliases: The normalized name prefixes that identify the family. They are all the
spellings that the config of a terminal can have, in lower case with
collapsed spaces. Nerd Fonts have several spellings on purpose: ``hack nerd
font mono`` and ``hack nfm`` are the same file.
coverage: :data:`FULL` when the family has the whole powerline block (Nerd Font
patches). :data:`CORE` when it has only the four triangles.
"""
name: str
aliases: tuple[str, ...]
coverage: str
#: The fonts that MeshTerm recommends, with the full-coverage Nerd Fonts first. The order
#: is important, because the first alias that matches wins. Thus ``JetBrainsMono Nerd
#: Font`` must match its ``full`` entry before the plain ``JetBrains Mono`` prefix can
#: claim it as ``core``.
RECOMMENDED_FONTS: tuple[RecommendedFont, ...] = (
RecommendedFont(
"MesloLGM Nerd Font",
(
"meslolgm nerd font",
"meslolgs nerd font",
"meslolgl nerd font",
"meslolgm nf",
"meslolgs nf",
"meslolgl nf",
"meslo lgm nerd font",
"meslo lgs nerd font",
),
FULL,
),
RecommendedFont(
"Hack Nerd Font",
("hack nerd font", "hack nf", "hack nfm", "hack nfp"),
FULL,
),
RecommendedFont(
"CaskaydiaCove Nerd Font",
(
"caskaydiacove nerd font",
"caskaydiacove nf",
"caskaydiacove nfm",
"caskaydiamono nerd font",
"caskaydiamono nf",
"caskaydia cove nerd font",
),
FULL,
),
RecommendedFont(
"Cascadia Code NF",
("cascadia code nf", "cascadia mono nf"),
FULL,
),
RecommendedFont(
"FiraCode Nerd Font",
("firacode nerd font", "firacode nf", "firacode nfm", "fira code nerd font"),
FULL,
),
RecommendedFont(
"JetBrainsMono Nerd Font",
(
"jetbrainsmono nerd font",
"jetbrainsmono nf",
"jetbrainsmono nfm",
"jetbrains mono nerd font",
),
FULL,
),
RecommendedFont("Cascadia Code PL", ("cascadia code pl", "cascadia mono pl"), CORE),
RecommendedFont("Fira Code", ("fira code",), CORE),
RecommendedFont("JetBrains Mono", ("jetbrains mono",), CORE),
RecommendedFont("Source Code Pro", ("source code pro", "source code variable"), CORE),
RecommendedFont("Iosevka", ("iosevka",), CORE),
)
#: The catch-all for a patched font that the explicit list does not name. Each family
#: whose name has the Nerd Font branding has the full powerline block. The branding is the
#: long ``… Nerd Font [Mono|Propo]`` or the short suffixes of version 3
#: (``NF``/``NFM``/``NFP``).
NERD_FONT_GENERIC = RecommendedFont("Nerd Font (patched)", (), FULL)
_NERD_RE = re.compile(r"\bnerd font\b|\bnf[mp]?\b")
@dataclass(frozen=True)
class FontDetection:
"""The configured terminal font, and the terminal that MeshTerm read it from.
Attributes:
face: The font family name as configured (not normalized).
source: ``"windows-terminal"``, ``"vscode"``, or ``"conhost"``.
"""
face: str
source: str
@dataclass(frozen=True)
class PowerlineSupport:
"""The verdict: how certain MeshTerm is that this terminal can draw powerline separators.
Attributes:
level: :data:`FULL`, :data:`CORE`, :data:`NONE`, or :data:`UNKNOWN`.
source: What decided the verdict. ``"env"`` is the override. ``"platform:<name>"``
is the font inventory of a handheld. ``"font:<terminal>"`` is a configured
face that matched, or that definitely did not match. ``"renderer:<terminal>"``
is a terminal that draws the glyphs itself. ``"ssh"`` is a font on the far
client, which MeshTerm cannot know. ``"unknown"`` is all other cases.
face: The configured face, when MeshTerm found one. It is for information.
matched: The entry of the recommended list that the face matched, if any.
"""
level: str
source: str
face: str | None = None
matched: RecommendedFont | None = None
@property
def capable(self) -> bool:
"""Check if the core triangle (the only glyph that the path widget needs) will draw."""
return self.level in (FULL, CORE)
# --- name matching ---------------------------------------------------------------
def primary_family(value: str) -> str:
"""The first family of a font list in the CSS style, without quotes.
VS Code stores ``"'Hack Nerd Font Mono', monospace"``. The renderer tries the first
family first, so MeshTerm judges that family.
Args:
value: The value of a font-family setting (a single name or a comma list).
Returns:
The first family, with the quotes and the outer whitespace removed.
"""
first = value.split(",")[0]
return first.strip().strip("'\"").strip()
def normalize_face(face: str) -> str:
"""Fold a face name to its form for a match: lower case, single-spaced, without quotes.
Args:
face: A font family name as a config file spells it.
Returns:
The normalized name (it can be empty).
"""
return " ".join(face.strip().strip("'\"").lower().split())
def match_recommended(face: str | None) -> RecommendedFont | None:
"""Match a configured face against the recommended-font list.
A face matches an entry when it is one of the aliases of the entry, or when it extends
one alias as a longer family name (``hack nerd font mono`` extends ``hack nerd
font``). The generic Nerd Font rule (:data:`NERD_FONT_GENERIC`) matches the patched
families that the explicit list does not name.
Args:
face: The configured font family (a single name or a comma list). ``None`` does
nothing and is not an error.
Returns:
The matched :class:`RecommendedFont`, or ``None`` when the face is not known.
"""
if not face:
return None
norm = normalize_face(primary_family(face))
if not norm:
return None
for spec in RECOMMENDED_FONTS:
if any(norm == alias or norm.startswith(alias + " ") for alias in spec.aliases):
return spec
if _NERD_RE.search(norm):
return NERD_FONT_GENERIC
return None
# --- config-file reading ---------------------------------------------------------
def _strip_jsonc(text: str) -> str:
"""Remove the ``//`` and ``/* */`` comments from JSON with comments. Strings are safe."""
out: list[str] = []
i, n = 0, len(text)
in_string = False
while i < n:
ch = text[i]
if in_string:
out.append(ch)
if ch == "\\" and i + 1 < n: # keep the escaped character exactly
out.append(text[i + 1])
i += 2
continue
if ch == '"':
in_string = False
i += 1
continue
if ch == '"':
in_string = True
out.append(ch)
i += 1
continue
if ch == "/" and i + 1 < n and text[i + 1] == "/":
while i < n and text[i] != "\n":
i += 1
continue
if ch == "/" and i + 1 < n and text[i + 1] == "*":
i += 2
while i + 1 < n and not (text[i] == "*" and text[i + 1] == "/"):
i += 1
i += 2
continue
out.append(ch)
i += 1
return "".join(out)
def _read_jsonc(path: Path) -> dict | None:
"""Parse a JSONC settings file (comments, trailing commas). Return ``None`` on any failure.
Windows Terminal and VS Code both write JSON with comments. A strict parser fails at
the first ``//``. After the removal of the comments, which does not change strings,
a regex removes the trailing commas. The regex does its best. A string that contains
``", }"`` can in theory be cut, but no font config has such a string.
"""
try:
text = path.read_text(encoding="utf-8-sig")
data = json.loads(re.sub(r",\s*([}\]])", r"\1", _strip_jsonc(text)))
return data if isinstance(data, dict) else None
except Exception:
return None
def _profile_face(profile: object) -> str | None:
"""The configured font face of a Windows Terminal profile.
The function reads the new ``font.face`` or the legacy ``fontFace``. It returns
``None`` when the profile leaves the choice to the chain of defaults.
"""
if not isinstance(profile, dict):
return None
font = profile.get("font")
if isinstance(font, dict):
face = font.get("face")
if isinstance(face, list) and face: # defensive: for an array face, use its first item
face = face[0]
if isinstance(face, str) and face.strip():
return face
legacy = profile.get("fontFace")
if isinstance(legacy, str) and legacy.strip():
return legacy
return None
def _wt_settings_paths(environ: Mapping[str, str]) -> list[Path]:
"""The places where Windows Terminal can keep its ``settings.json``.
These are the packaged, preview, and unpackaged locations. The list has only files
that exist.
"""
local = environ.get("LOCALAPPDATA")
if not local:
return []
base = Path(local)
candidates: list[Path] = []
try:
packages = base / "Packages"
if packages.is_dir():
candidates.extend(
sorted(packages.glob("Microsoft.WindowsTerminal*/LocalState/settings.json"))
)
except OSError:
pass
candidates.append(base / "Microsoft" / "Windows Terminal" / "settings.json")
return [p for p in candidates if p.is_file()]
def _windows_terminal_face(environ: Mapping[str, str]) -> str:
"""The face that Windows Terminal uses to render this session.
The function finds the face in the same way as the terminal. First it uses the font
of the ``WT_PROFILE_ID`` profile. If there is none, it uses ``profiles.defaults``.
If there is none, it uses the built-in default ``Cascadia Mono``. An unreadable
settings file also gives the built-in default, which is the same as a new
installation.
"""
guid = (environ.get("WT_PROFILE_ID") or "").strip().lower()
for path in _wt_settings_paths(environ):
data = _read_jsonc(path)
if data is None:
continue
profiles = data.get("profiles")
plist: list = []
defaults: object = {}
if isinstance(profiles, dict):
plist = profiles.get("list") or []
defaults = profiles.get("defaults") or {}
elif isinstance(profiles, list): # the old flat-list schema
plist = profiles
profile = (
next(
(
p
for p in plist
if isinstance(p, dict) and str(p.get("guid", "")).strip().lower() == guid
),
None,
)
if guid
else None
)
face = _profile_face(profile) or _profile_face(defaults)
if face:
return face
break # a settings file that parsed but has no face: use the built-in default
return "Cascadia Mono"
def _vscode_settings_paths(environ: Mapping[str, str], start: Path | None) -> list[Path]:
"""The settings files of VS Code, the nearest first.
First is the closest workspace. Then come the user files (stable and Insiders, in
their locations for each platform). The list has only files that exist.
"""
paths: list[Path] = []
try:
here = (start or Path.cwd()).resolve()
for folder in (here, *here.parents):
candidate = folder / ".vscode" / "settings.json"
if candidate.is_file():
paths.append(candidate)
break
except OSError:
pass
roots: list[Path] = []
appdata = environ.get("APPDATA")
if appdata:
roots.append(Path(appdata))
home = environ.get("HOME") or environ.get("USERPROFILE")
if home:
roots.append(Path(home) / ".config")
roots.append(Path(home) / "Library" / "Application Support")
for root in roots:
for flavour in ("Code", "Code - Insiders"):
candidate = root / flavour / "User" / "settings.json"
if candidate.is_file():
paths.append(candidate)
return paths
def _vscode_default_face() -> str:
"""The first name of the default editor font family of VS Code on this platform."""
if sys.platform == "win32":
return "Consolas"
if sys.platform == "darwin":
return "Menlo"
return "Droid Sans Mono"
def _vscode_face(environ: Mapping[str, str], start: Path | None = None) -> str:
"""The face that the integrated terminal of VS Code uses to render.
``terminal.integrated.fontFamily`` wins over ``editor.fontFamily`` (VS Code's own
fallback). For each key, the workspace file wins over the user file. The stored
value is a comma list in the CSS style, and MeshTerm judges the first family.
"""
paths = _vscode_settings_paths(environ, start)
settings = [data for data in (_read_jsonc(p) for p in paths) if data is not None]
for key in ("terminal.integrated.fontFamily", "editor.fontFamily"):
for data in settings:
value = data.get(key)
if isinstance(value, str) and value.strip():
return primary_family(value)
return _vscode_default_face()
def _conhost_face() -> str | None:
"""The face of the classic console, from ``GetCurrentConsoleFontEx``, or ``None``.
The result has a meaning only on a real conhost. The caller first makes sure that
there are no ConPTY markers, because the hidden conhost under Windows Terminal or
VS Code reports a stub face and not the glyphs on the screen.
"""
if sys.platform != "win32":
return None
try:
import ctypes
import ctypes.wintypes as wintypes
kernel32 = win32dll.kernel32()
if not kernel32.GetConsoleWindow():
return None
class _COORD(ctypes.Structure):
_fields_ = [("X", wintypes.SHORT), ("Y", wintypes.SHORT)]
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),
]
info = _CONSOLE_FONT_INFOEX()
info.cbSize = ctypes.sizeof(_CONSOLE_FONT_INFOEX)
handle = kernel32.GetStdHandle(-11) # STD_OUTPUT_HANDLE
if not kernel32.GetCurrentConsoleFontEx(handle, False, ctypes.byref(info)):
return None
return info.FaceName or None
except Exception:
return None
# --- the ladder -------------------------------------------------------------------
def detect_terminal_font(
environ: Mapping[str, str] | None = None,
*,
cwd: Path | None = None,
conhost_probe: Callable[[], str | None] = _conhost_face,
) -> FontDetection | None:
"""Identify the terminal and read the font that it is configured to render with.
The ladder goes from the most certain to the least certain source:
* Windows Terminal. ``WT_SESSION`` names the app and ``WT_PROFILE_ID`` names the
exact profile.
* VS Code (``TERM_PROGRAM=vscode``).
* A real classic conhost, which the function asks directly through Win32. It has no
ConPTY markers and no ``TERM``. A ``TERM`` that is set means that another emulator
hosts the console.
For all other cases the result is ``None``, for example an unrecognized terminal or an
ssh session. MeshTerm cannot know the face from here.
Args:
environ: The environment to examine (the default is ``os.environ``).
cwd: Where the search for the VS Code workspace starts (the default is the cwd of
the process).
conhost_probe: The function that reads the face of the classic console (a
parameter, so that tests can replace it).
Returns:
The configured face and its source, or ``None`` when no source applies.
"""
env = os.environ if environ is None else environ
if env.get("WT_SESSION"):
return FontDetection(_windows_terminal_face(env), "windows-terminal")
if (env.get("TERM_PROGRAM") or "").lower() == "vscode":
return FontDetection(_vscode_face(env, cwd), "vscode")
if not env.get("TERM") and not env.get("TERM_PROGRAM"):
face = conhost_probe()
if face:
return FontDetection(face, "conhost")
return None
def _renderer_backed(environ: Mapping[str, str]) -> str | None:
"""The terminal id, when the terminal draws the core triangles itself.
Windows Terminal falls back to its bundled Cascadia Code NF for the powerline symbol
ranges (version 1.22 and later). The terminal of VS Code draws them as custom glyphs
(``terminal.integrated.customGlyphs``, on by default). kitty, WezTerm, and Alacritty
have built-in powerline glyphs. In these terminals, the separators render in any
configured font.
"""
if environ.get("WT_SESSION"):
return "windows-terminal"
term_program = (environ.get("TERM_PROGRAM") or "").lower()
if term_program == "vscode":
return "vscode"
if term_program == "wezterm" or environ.get("WEZTERM_EXECUTABLE"):
return "wezterm"
if environ.get("KITTY_WINDOW_ID") or environ.get("TERM") == "xterm-kitty":
return "kitty"
if (
environ.get("ALACRITTY_WINDOW_ID")
or environ.get("ALACRITTY_SOCKET")
or environ.get("TERM") == "alacritty"
):
return "alacritty"
return None
#: The glyphs that the path widget draws at ``core``: the solid triangle that each seam
#: is, and the thin triangle that joins two fills that the eye cannot tell apart
#: (:mod:`~meshterm.ui.pathline`).
_CORE_GLYPHS = (0xE0B0, 0xE0B1)
#: What ``full`` adds that the widget uses: the rounded caps that replace the square ends
#: of a path.
_FULL_GLYPHS = (0xE0B4, 0xE0B6)
def _inventory_coverage(inventory: frozenset[int]) -> str:
"""The level of coverage that a known glyph inventory has.
The result is not a guess, because the inventory lists all the glyphs.
"""
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 verdict without the cache (refer to :func:`powerline_support` for the ladder).
Args:
environ: The environment to examine (the default is ``os.environ``).
cwd: Where the search for the VS Code workspace starts (the default is the cwd of
the process).
conhost_probe: The function that reads the face of the classic console (a
parameter, so that tests can replace it).
handheld: The name and the glyph inventory of the platform, where the font of
a handheld itself draws the screen. ``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"}:
return PowerlineSupport(NONE, "env")
if override in {"1", "on", "yes", "true", "full"}:
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
if detected and matched:
return PowerlineSupport(matched.coverage, f"font:{detected.source}", detected.face, matched)
backed = _renderer_backed(env)
if backed:
return PowerlineSupport(CORE, f"renderer:{backed}", detected.face if detected else None)
if detected: # MeshTerm read a face, it matches nothing, and the terminal has no fallback
return PowerlineSupport(NONE, f"font:{detected.source}", detected.face)
if env.get("SSH_CONNECTION") or env.get("SSH_TTY") or env.get("SSH_CLIENT"):
return PowerlineSupport(UNKNOWN, "ssh")
return PowerlineSupport(UNKNOWN, "unknown")
@lru_cache(maxsize=1)
def powerline_support() -> PowerlineSupport:
"""The powerline verdict of the session. MeshTerm decides it one time and caches it.
The ladder has these steps, in this order:
* The ``MESHTERM_POWERLINE`` override.
* The own font inventory of a handheld platform.
* The configured font, matched against :data:`RECOMMENDED_FONTS`.
* A renderer that draws the glyphs itself.
* ``none`` (MeshTerm read the face, but there is no match and no fallback) or
``unknown`` (an ssh session, or a terminal that MeshTerm cannot identify).
The result is cached, because the environment and the settings files do not change
during a session. A switch of platform clears the cache (:func:`_bind`). Tests call
:func:`_powerline_support` directly.
Returns:
The :class:`PowerlineSupport` verdict.
"""
return _powerline_support(os.environ, handheld=_HANDHELD_FONT)
def powerline_enabled() -> bool:
"""Check if path lines must use powerline separators by default (refer to ``.capable``)."""
return powerline_support().capable
def powerline_full() -> bool:
"""Check if the extended block is also present: the rounded caps at U+E0B4 and later.
Only a Nerd Font patch has these glyphs. Thus this check is narrower than
:func:`powerline_enabled`. A stock coder font (Fira Code, JetBrains Mono) draws the
core triangles natively and no other glyph. A terminal that supplies the separators
from its own renderer promises only those four glyphs. Callers use this check for
chrome that must degrade. When it is true, :mod:`~meshterm.ui.pathline` rounds the
two outer ends of a path. When it is false, the module squares them off.
"""
return powerline_support().level == FULL
#: The name and the glyph inventory of the active handheld, or ``None`` where a terminal
#: draws all that it receives. MeshTerm binds it when the platform switches (:func:`_bind`).
_HANDHELD_FONT: tuple[str, frozenset[int]] | None = None
@on_platform
def _bind(platform: Platform) -> None:
"""Bind the handheld font that the verdict reads, and clear the verdict of the last font."""
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 ----------------------------------------------------------
def _installed_families() -> Iterable[str]:
"""All the installed font family names that this platform reports. It does its best."""
if sys.platform == "win32":
try:
import winreg
for root in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER):
try:
key = winreg.OpenKey(
root, r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts"
)
except OSError:
continue
with key:
for i in range(winreg.QueryInfoKey(key)[1]):
try:
name = winreg.EnumValue(key, i)[0]
except OSError:
break
# "Hack Nerd Font Mono (TrueType)" → "Hack Nerd Font Mono"
yield name.split(" (")[0]
except Exception:
return
return
try:
import subprocess
result = subprocess.run(
["fc-list", ":", "family"], capture_output=True, text=True, timeout=2
)
for line in result.stdout.splitlines():
for family in line.split(","):
if family.strip():
yield family.strip()
except Exception:
return
def installed_recommended() -> RecommendedFont | None:
"""The best recommended font that is installed on this machine, selected or not.
This check is weaker, but it is always available. A future screen of recommendations
can use it to choose between three messages: "not installed — here's the list",
"installed but not selected in your terminal's profile", and "active". The function
prefers :data:`FULL` coverage to :data:`CORE`. It returns ``None`` when no
recommended font is installed, or when the platform has no way to ask.
"""
best: RecommendedFont | None = None
for family in _installed_families():
matched = match_recommended(family)
if matched is None:
continue
if matched.coverage == FULL:
return matched
best = best or matched
return best
# --- emoji support ----------------------------------------------------------------
@dataclass(frozen=True)
class EmojiSupport:
"""The verdict if this terminal can show an emoji at all, and what decided it.
Attributes:
supported: ``True`` when an emoji icon reaches the screen as the emoji.
source: ``"env"`` (the ``MESHTERM_EMOJI`` override), ``"platform"`` (not Windows,
so the problem of the classic console does not apply), ``"console"`` (the
host was identified, refer to :func:`_is_classic_console`), or
``"no-console"`` (there is nothing to identify: the output is redirected, or
the call failed).
"""
supported: bool
source: str
def _is_classic_console() -> bool | None:
"""Check if this process draws into a real ``conhost`` window.
This is the whole question. That host renders through GDI with the console font and
has no emoji font behind it. All the hosts that replaced it draw emoji correctly.
These are Windows Terminal, the terminal of VS Code, and anything over ssh.
MeshTerm cannot ask in band. A missing glyph still occupies its cell, so the screen
reads back the same as if the character had drawn. We measured this on Windows 10
22H2. A classic console stores ``📡`` correctly and advances two cells, but it shows
a single replacement box. A ConPTY (which does draw the emoji) reads back as U+FFFD,
because the buffer behind it is updated asynchronously. A probe that writes and reads
back gives the wrong answer for both hosts. This docstring records this trap.
MeshTerm also cannot trust the environment alone. Child processes inherit
``WT_SESSION``. A program that starts from Windows Terminal into a console of its own
still has it, and the answer is for the wrong host.
So MeshTerm identifies the host from its structure, with two independent facts. Both
facts must agree before MeshTerm dims anything. The costly mistake is to remove the
icons from a terminal that can draw them.
* **The console font has a real pixel width.** The hidden conhost of a ConPTY reports
a stub (face ``Consolas``, cell width ``0``), because it rasterizes nothing.
* **The buffer is taller than its window.** A classic console owns its scrollback
(9001 rows by default). Under a ConPTY the terminal owns the scrollback, so the
buffer is the same as the window.
Returns:
``True`` for a real classic console. ``False`` for all other hosts that draw to a
console. ``None`` when there is no console on stdout (the output is redirected,
the platform is not Windows, or the call failed). An unknown result 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),
]
# It is safe to declare these types, because this handle is only ours (refer to
# meshterm.core.win32dll). The explicit restype is important. Without it, ctypes
# assumes a 32-bit int and cuts the 64-bit HANDLE, and each later call fails
# without a message.
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()
# This call fails when stdout is a pipe or a file. Then MeshTerm draws nothing and
# there is no answer. This is the reason that redirected runs and CI runs do not
# use the rest of this function.
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 gives an unknown result, 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 verdict without the cache (refer to :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 classic_console() -> bool:
"""Check if this session draws into a real ``conhost`` window.
This is the only host that has no font fallback of its own. Thus it is the only host
where the glyphs of the configured font decide what the user sees. Refer to
:func:`_is_classic_console` for how MeshTerm identifies it, and why it does not use
the environment.
"""
return _is_classic_console() is True
@lru_cache(maxsize=1)
def emoji_support() -> EmojiSupport:
"""Check if emoji icons can be drawn here. MeshTerm decides one time and caches it.
The ladder has these steps, in this order: the ``MESHTERM_EMOJI`` override, then all
platforms that are not Windows (where a UTF-8 terminal draws emoji correctly), then
:func:`_is_classic_console`. The result is cached, because a session does not change
its host during the session.
Returns:
The :class:`EmojiSupport` verdict.
"""
return _emoji_support(os.environ)
def emoji_enabled() -> bool:
"""Check if icons must render as emoji instead of through the compact table."""
return emoji_support().supported
# --- chart glyphs -----------------------------------------------------------------
#: The families that we measured to have the Braille Patterns block (U+2800–U+28FF).
#: :mod:`meshterm.ui.braillechart` draws each timeline from this block.
#:
#: The list is short on purpose. Each entry is a measurement, not a reputation. On
#: 2026-09-07 we read the ``cmap`` of each font that is installed on a development
#: machine. Almost no monospace font has the block. Hack Nerd Font, JetBrains Mono, Fira
#: Code, Source Code Pro, Consolas, Lucida Console, and DejaVu Sans *Mono* (a surprise)
#: all measure zero of 256. DejaVu *Sans* is proportional, and it has all 256. This is
#: the source of the common advice "install DejaVu for braille". The advice works only
#: because terminals fall back from the mono face to the proportional face for that
#: block.
#:
#: This is the important point. On each host that falls back (Windows Terminal, the
#: terminal of VS Code, macOS, Linux), the charts draw in any chosen font. Thus this list
#: does not have to be correct there. It matters on the one host that has no fallback, the
#: classic Windows console. There, a font that is not in this list gives boxes where the
#: charts must be.
CHART_FONTS: tuple[str, ...] = (
# The console font of Microsoft: 44/44, and the family that MeshTerm bundles (refer to
# meshterm.core.consolefont). The prefix also matches the PL and NF builds.
"cascadia mono",
"cascadia code",
# The Nerd Font patches of the same fonts. They keep what they patch.
"caskaydiacove",
"caskaydiamono",
# Many reports say that it has the block. We did not measure it, because we have no
# copy.
"iosevka",
)
def face_draws_charts(face: str | None) -> bool:
"""Check if ``face`` can draw the braille that the timelines use.
Args:
face: A configured font family, in any spelling, or ``None``.
Returns:
``True`` if it is one of :data:`CHART_FONTS`.
"""
if not face:
return False
normal = normalize_face(face)
return any(normal.startswith(known) for known in CHART_FONTS)
def installed_chart_font() -> str | None:
"""The first installed family that can draw the charts, or ``None``.
The result decides between two actions: "select the font that the user already has"
(each Windows 11 machine, and each user who installed Windows Terminal) and "offer to
install our font".
Returns:
The family name as installed, or ``None`` when no installed family can do it.
"""
for family in _installed_families():
if face_draws_charts(family):
return family
return None