Term font: detect powerline support from a recommended-font list
MESHTERM_POWERLINE override, then the configured face (Windows Terminal profile, VS Code settings, genuine conhost via Win32) matched against the recommended fonts and their Nerd Font alias spellings, then renderer-backed terminals that draw the triangles themselves; ssh and strangers stay an honest unknown. Plus an installed-font scan for a future nudge screen. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8fdd7572bd
commit
8992e413de
2 files changed
+866
No files matched your search
@@ -0,0 +1,637 @@
|
||||
"""Terminal font detection — can this terminal draw powerline path separators?
|
||||
|
||||
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
|
||||
triangle U+E0B0 whose foreground is the previous chip's fill and whose background is
|
||||
the next's, the oh-my-posh look. That triangle lives in the Private Use Area, so it
|
||||
only draws when the *terminal's configured font* (or the terminal itself) supplies the
|
||||
glyph; a font without it shows tofu boxes. Nothing in-band can ask "do you have this
|
||||
glyph?" — a missing glyph still occupies its cell, so even cursor-position probes see
|
||||
nothing — which makes this an *out-of-band* detection problem: identify the terminal,
|
||||
read its configuration, and match the configured face against fonts known to carry the
|
||||
powerline block.
|
||||
|
||||
Three 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.
|
||||
* **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``,
|
||||
workspace over user, falling back to ``editor.fontFamily``), and classic conhost
|
||||
(``GetCurrentConsoleFontEx`` — trusted only when no ConPTY host is detected, because
|
||||
the hidden conhost under one reports a stub face, not what's on screen). The face is
|
||||
matched against :data:`RECOMMENDED_FONTS` — the list MeshTerm recommends to users —
|
||||
with the alias spellings Nerd Fonts ship under (``Hack Nerd Font Mono`` / ``Hack
|
||||
NFM``), plus a generic "any Nerd Font" rule, since every Nerd Font patch carries the
|
||||
full powerline block.
|
||||
* **The terminal's own renderer**: several terminals draw the core triangles
|
||||
regardless of font — Windows Terminal falls back to its bundled Cascadia Code NF for
|
||||
the symbol ranges (≥ 1.22), VS Code's terminal draws them as custom glyphs
|
||||
(``terminal.integrated.customGlyphs``, default on), and kitty / WezTerm / Alacritty
|
||||
ship built-in powerline glyphs. Those count as ``core`` support even when the
|
||||
configured font matches nothing.
|
||||
|
||||
Coverage comes in two levels: ``full`` (a Nerd Font patch — triangles *and* the
|
||||
extended block: rounded caps, slants) and ``core`` (just U+E0B0–U+E0B3, which many
|
||||
stock coder fonts — Fira Code, JetBrains Mono, Source Code Pro — include natively).
|
||||
The path widget only uses the core triangle, so ``core`` is enough to switch it on;
|
||||
``full`` is headroom for fancier chrome. When nothing can be known — an ssh session
|
||||
(the font lives on the far client's machine), an unrecognised terminal — the verdict
|
||||
is honestly ``unknown`` and callers should keep the plain-arrow rendering.
|
||||
|
||||
Every probe is best-effort: unreadable settings, missing registry keys, or a failed
|
||||
Win32 call degrade the verdict, never raise. The result is a *hint that picks a
|
||||
default*, not a gate the user has to fight.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Callable, Iterable, Mapping, Optional
|
||||
|
||||
#: Coverage levels a verdict (or a recommended font) can carry, strongest first.
|
||||
FULL = "full"
|
||||
CORE = "core"
|
||||
NONE = "none"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RecommendedFont:
|
||||
"""One font MeshTerm recommends for powerline path rendering.
|
||||
|
||||
Attributes:
|
||||
name: The canonical display name (what a recommendation screen shows).
|
||||
aliases: Normalised name prefixes that identify the family — every spelling a
|
||||
terminal config might hold, lower-case with collapsed spaces (Nerd Fonts
|
||||
deliberately ship several: ``hack nerd font mono`` and ``hack nfm`` are the
|
||||
same file).
|
||||
coverage: :data:`FULL` when the family carries the whole powerline block
|
||||
(Nerd Font patches), :data:`CORE` when it ships just the four triangles.
|
||||
"""
|
||||
|
||||
name: str
|
||||
aliases: tuple[str, ...]
|
||||
coverage: str
|
||||
|
||||
|
||||
#: The fonts MeshTerm recommends, full-coverage Nerd Fonts first — order matters, the
|
||||
#: first alias hit wins, so ``JetBrainsMono Nerd Font`` must match its ``full`` entry
|
||||
#: before the plain ``JetBrains Mono`` prefix could 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 the explicit list doesn't spell out: any family
|
||||
#: whose name carries the Nerd Font branding — the long ``… Nerd Font [Mono|Propo]``
|
||||
#: or the v3 short suffixes ``NF``/``NFM``/``NFP`` — has the full powerline block.
|
||||
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 which terminal it was read from.
|
||||
|
||||
Attributes:
|
||||
face: The font family name as configured (unnormalised).
|
||||
source: ``"windows-terminal"``, ``"vscode"``, or ``"conhost"``.
|
||||
"""
|
||||
|
||||
face: str
|
||||
source: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PowerlineSupport:
|
||||
"""The verdict: how confidently this terminal can draw powerline separators.
|
||||
|
||||
Attributes:
|
||||
level: :data:`FULL`, :data:`CORE`, :data:`NONE`, or :data:`UNKNOWN`.
|
||||
source: What decided it — ``"env"`` (the override), ``"font:<terminal>"``
|
||||
(a configured face matched, or definitively didn't), ``"renderer:<terminal>"``
|
||||
(the terminal draws the glyphs itself), ``"ssh"`` (the font lives on the far
|
||||
client, unknowable), or ``"unknown"``.
|
||||
face: The configured face when one was found, for the curious.
|
||||
matched: The recommended-list entry the face matched, if any.
|
||||
"""
|
||||
|
||||
level: str
|
||||
source: str
|
||||
face: Optional[str] = None
|
||||
matched: Optional[RecommendedFont] = None
|
||||
|
||||
@property
|
||||
def capable(self) -> bool:
|
||||
"""Whether the core triangle (all 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 CSS-style font list, unquoted.
|
||||
|
||||
VS Code stores ``"'Hack Nerd Font Mono', monospace"``; the first family is the
|
||||
one the renderer tries first, so it is the one we judge.
|
||||
|
||||
Args:
|
||||
value: A font-family setting value (single name or comma list).
|
||||
|
||||
Returns:
|
||||
The first family with quotes and outer whitespace stripped.
|
||||
"""
|
||||
first = value.split(",")[0]
|
||||
return first.strip().strip("'\"").strip()
|
||||
|
||||
|
||||
def normalize_face(face: str) -> str:
|
||||
"""Fold a face name to its matchable form: lower-case, single-spaced, unquoted.
|
||||
|
||||
Args:
|
||||
face: A font family name as a config file spells it.
|
||||
|
||||
Returns:
|
||||
The normalised name (may be empty).
|
||||
"""
|
||||
return " ".join(face.strip().strip("'\"").lower().split())
|
||||
|
||||
|
||||
def match_recommended(face: Optional[str]) -> Optional[RecommendedFont]:
|
||||
"""Match a configured face against the recommended-font list.
|
||||
|
||||
A face matches an entry when it *is* one of the entry's aliases or extends one as
|
||||
a longer family name (``hack nerd font mono`` extends ``hack nerd font``); the
|
||||
generic Nerd Font rule (:data:`NERD_FONT_GENERIC`) catches patched families the
|
||||
explicit list doesn't spell out.
|
||||
|
||||
Args:
|
||||
face: The configured font family (single name or comma list); ``None`` is a
|
||||
polite no-op.
|
||||
|
||||
Returns:
|
||||
The matched :class:`RecommendedFont`, or ``None`` when the face is unknown.
|
||||
"""
|
||||
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:
|
||||
"""Strip ``//`` and ``/* */`` comments from JSON-with-comments, string-safely."""
|
||||
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 char verbatim
|
||||
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) -> Optional[dict]:
|
||||
"""Parse a JSONC settings file (comments, trailing commas), ``None`` on any failure.
|
||||
|
||||
Both Windows Terminal and VS Code write JSON-with-comments; a strict parser dies
|
||||
on the first ``//``. Trailing commas are swept with a best-effort regex after the
|
||||
string-aware comment strip (a *string* containing ``", }"`` could theoretically be
|
||||
clipped — no font config holds one).
|
||||
"""
|
||||
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) -> Optional[str]:
|
||||
"""A Windows Terminal profile's configured face — new ``font.face`` or legacy
|
||||
``fontFace`` — or ``None`` when the profile leaves it to the defaults chain."""
|
||||
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: an array face takes its head
|
||||
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]:
|
||||
"""Candidate Windows Terminal ``settings.json`` locations (packaged, preview,
|
||||
unpackaged), existing files only."""
|
||||
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 Windows Terminal is rendering this session with.
|
||||
|
||||
Resolved the way the terminal itself does: the ``WT_PROFILE_ID`` profile's font,
|
||||
else ``profiles.defaults``, else the built-in default ``Cascadia Mono``. An
|
||||
unreadable settings file lands on the built-in default too — a pristine install
|
||||
is exactly that.
|
||||
"""
|
||||
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 ancient 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 parsed settings file with no face set → the built-in default
|
||||
return "Cascadia Mono"
|
||||
|
||||
|
||||
def _vscode_settings_paths(
|
||||
environ: Mapping[str, str], start: Optional[Path]
|
||||
) -> list[Path]:
|
||||
"""VS Code settings files in precedence order: nearest workspace, then user
|
||||
(stable and Insiders, per-platform locations), existing files only."""
|
||||
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:
|
||||
"""VS Code's per-platform default editor font family's first name."""
|
||||
if sys.platform == "win32":
|
||||
return "Consolas"
|
||||
if sys.platform == "darwin":
|
||||
return "Menlo"
|
||||
return "Droid Sans Mono"
|
||||
|
||||
|
||||
def _vscode_face(environ: Mapping[str, str], start: Optional[Path] = None) -> str:
|
||||
"""The face VS Code's integrated terminal is rendering with.
|
||||
|
||||
``terminal.integrated.fontFamily`` wins over ``editor.fontFamily`` (VS Code's own
|
||||
fallback), and within each key the workspace file wins over the user file. The
|
||||
stored value is a CSS-style comma list; the first family is judged.
|
||||
"""
|
||||
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() -> Optional[str]:
|
||||
"""The classic-console face via ``GetCurrentConsoleFontEx``, or ``None``.
|
||||
|
||||
Only meaningful on a *genuine* conhost — the caller gates on the absence of ConPTY
|
||||
markers first, because the hidden conhost under Windows Terminal / VS Code reports
|
||||
a stub face, not the glyphs on screen.
|
||||
"""
|
||||
if sys.platform != "win32":
|
||||
return None
|
||||
try:
|
||||
import ctypes
|
||||
import ctypes.wintypes as wintypes
|
||||
|
||||
kernel32 = ctypes.windll.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: Optional[Mapping[str, str]] = None,
|
||||
*,
|
||||
cwd: Optional[Path] = None,
|
||||
conhost_probe: Callable[[], Optional[str]] = _conhost_face,
|
||||
) -> Optional[FontDetection]:
|
||||
"""Identify the terminal and read the font it is configured to render with.
|
||||
|
||||
The ladder, most to least certain: Windows Terminal (``WT_SESSION`` names the app,
|
||||
``WT_PROFILE_ID`` the exact profile), VS Code (``TERM_PROGRAM=vscode``), then a
|
||||
genuine classic conhost (no ConPTY markers, no ``TERM`` — a set ``TERM`` means some
|
||||
other emulator is hosting the console) asked directly via Win32. Anything else —
|
||||
an unrecognised terminal, an ssh session — is ``None``: the face is unknowable
|
||||
from here.
|
||||
|
||||
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).
|
||||
|
||||
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]) -> Optional[str]:
|
||||
"""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 (≥ 1.22), VS Code's terminal draws them as custom glyphs
|
||||
(``terminal.integrated.customGlyphs``, default on), and kitty / WezTerm /
|
||||
Alacritty ship built-in powerline glyphs — on those, the separators render
|
||||
whatever the configured font holds.
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
def _powerline_support(
|
||||
environ: Optional[Mapping[str, str]] = None,
|
||||
*,
|
||||
cwd: Optional[Path] = None,
|
||||
conhost_probe: Callable[[], Optional[str]] = _conhost_face,
|
||||
) -> PowerlineSupport:
|
||||
"""The uncached verdict (see :func:`powerline_support` for the ladder)."""
|
||||
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")
|
||||
|
||||
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: # a face we could read, matching nothing, on a terminal with 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 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.
|
||||
|
||||
Returns:
|
||||
The :class:`PowerlineSupport` verdict.
|
||||
"""
|
||||
return _powerline_support(os.environ)
|
||||
|
||||
|
||||
def powerline_enabled() -> bool:
|
||||
"""Whether path lines should default to powerline separators (see ``.capable``)."""
|
||||
return powerline_support().capable
|
||||
|
||||
|
||||
# --- installed-font scan ----------------------------------------------------------
|
||||
|
||||
|
||||
def _installed_families() -> Iterable[str]:
|
||||
"""Every installed font family name this platform will admit to, best-effort."""
|
||||
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() -> Optional[RecommendedFont]:
|
||||
"""The best recommended font *installed* on this machine, selected or not.
|
||||
|
||||
The weaker, always-available check that a future recommendation screen splits its
|
||||
message on: "not installed — here's the list" versus "installed but not selected
|
||||
in your terminal's profile" versus "active". Prefers :data:`FULL` coverage over
|
||||
:data:`CORE`; returns ``None`` when nothing recommended is installed (or the
|
||||
platform offers no way to ask).
|
||||
"""
|
||||
best: Optional[RecommendedFont] = 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
|
||||
@@ -0,0 +1,229 @@
|
||||
"""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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from meshterm.ui.termfont import (
|
||||
CORE,
|
||||
FULL,
|
||||
NONE,
|
||||
UNKNOWN,
|
||||
NERD_FONT_GENERIC,
|
||||
RecommendedFont,
|
||||
_powerline_support,
|
||||
_read_jsonc,
|
||||
_vscode_default_face,
|
||||
_vscode_face,
|
||||
_windows_terminal_face,
|
||||
detect_terminal_font,
|
||||
installed_recommended,
|
||||
match_recommended,
|
||||
normalize_face,
|
||||
primary_family,
|
||||
)
|
||||
|
||||
|
||||
# --- name matching ----------------------------------------------------------------
|
||||
|
||||
|
||||
def test_primary_family_takes_the_first_of_a_css_list() -> None:
|
||||
"""VS Code stores comma lists with optional quotes; the head family is judged."""
|
||||
assert primary_family("'Hack Nerd Font Mono', monospace") == "Hack Nerd Font Mono"
|
||||
assert primary_family('"Fira Code", Consolas') == "Fira Code"
|
||||
assert primary_family("Cascadia Mono") == "Cascadia Mono"
|
||||
|
||||
|
||||
def test_normalize_face_folds_case_quotes_and_spacing() -> None:
|
||||
"""Matching is done on a lower-cased, single-spaced, unquoted form."""
|
||||
assert normalize_face(" 'Hack Nerd Font' ") == "hack nerd font"
|
||||
assert normalize_face('"MesloLGS NF"') == "meslolgs nf"
|
||||
|
||||
|
||||
def test_match_recommended_knows_the_nerd_font_spellings() -> None:
|
||||
"""The long name and the v3 short suffix both land on the same full entry."""
|
||||
assert match_recommended("Hack Nerd Font Mono").name == "Hack Nerd Font"
|
||||
assert match_recommended("Hack NFM").name == "Hack Nerd Font"
|
||||
assert match_recommended("MesloLGS NF").coverage == FULL # powerlevel10k's install
|
||||
|
||||
|
||||
def test_match_recommended_orders_full_patches_before_core_families() -> None:
|
||||
"""``JetBrainsMono Nerd Font`` must hit its full entry, never the plain
|
||||
``JetBrains Mono`` core prefix — first match wins, so full entries lead."""
|
||||
assert match_recommended("JetBrainsMono Nerd Font Mono").coverage == FULL
|
||||
assert match_recommended("JetBrains Mono").coverage == CORE
|
||||
|
||||
|
||||
def test_match_recommended_core_natives_and_strangers() -> None:
|
||||
"""Stock coder fonts with native triangles read core; anything else, nothing."""
|
||||
assert match_recommended("Fira Code").coverage == CORE
|
||||
assert match_recommended("Source Code Variable").coverage == CORE
|
||||
assert match_recommended("Consolas") is None
|
||||
assert match_recommended(None) is None
|
||||
|
||||
|
||||
def test_match_recommended_generic_nerd_rule_catches_unlisted_patches() -> None:
|
||||
"""Any family carrying the Nerd Font branding has the full block patched in."""
|
||||
assert match_recommended("Comic Shanns Mono Nerd Font") is NERD_FONT_GENERIC
|
||||
assert match_recommended("Terminess NF") is NERD_FONT_GENERIC
|
||||
|
||||
|
||||
# --- settings-file reading --------------------------------------------------------
|
||||
|
||||
|
||||
def test_read_jsonc_survives_comments_and_trailing_commas(tmp_path: Path) -> None:
|
||||
"""Both WT and VS Code write JSON-with-comments; the reader shrugs it off."""
|
||||
path = tmp_path / "settings.json"
|
||||
path.write_text(
|
||||
'{\n'
|
||||
' // the font\n'
|
||||
' "editor.fontFamily": "Hack NFM", /* inline */\n'
|
||||
' "url": "https://example.org//not-a-comment",\n'
|
||||
' "list": [1, 2,],\n'
|
||||
'}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
data = _read_jsonc(path)
|
||||
assert data == {
|
||||
"editor.fontFamily": "Hack NFM",
|
||||
"url": "https://example.org//not-a-comment",
|
||||
"list": [1, 2],
|
||||
}
|
||||
broken = tmp_path / "broken.json"
|
||||
broken.write_text("{nope", encoding="utf-8")
|
||||
assert _read_jsonc(broken) is None
|
||||
|
||||
|
||||
def _wt_env(tmp_path: Path, settings: dict, guid: str = "{abc-123}") -> dict:
|
||||
"""A fake Windows Terminal environment around a written settings file."""
|
||||
folder = tmp_path / "Microsoft" / "Windows Terminal"
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
(folder / "settings.json").write_text(json.dumps(settings), encoding="utf-8")
|
||||
return {"LOCALAPPDATA": str(tmp_path), "WT_SESSION": "s", "WT_PROFILE_ID": guid}
|
||||
|
||||
|
||||
def test_windows_terminal_face_resolves_profile_then_defaults(tmp_path: Path) -> None:
|
||||
"""The session profile's ``font.face`` wins; ``profiles.defaults`` backs it up."""
|
||||
env = _wt_env(tmp_path, {
|
||||
"profiles": {
|
||||
"defaults": {"font": {"face": "Cascadia Code PL"}},
|
||||
"list": [{"guid": "{ABC-123}", "font": {"face": "Hack Nerd Font Mono"}}],
|
||||
},
|
||||
})
|
||||
assert _windows_terminal_face(env) == "Hack Nerd Font Mono" # guid case-folded
|
||||
env2 = _wt_env(tmp_path, {
|
||||
"profiles": {
|
||||
"defaults": {"font": {"face": "Cascadia Code PL"}},
|
||||
"list": [{"guid": "{other}"}],
|
||||
},
|
||||
})
|
||||
assert _windows_terminal_face(env2) == "Cascadia Code PL"
|
||||
|
||||
|
||||
def test_windows_terminal_face_reads_legacy_and_defaults_to_cascadia(tmp_path: Path) -> None:
|
||||
"""The old flat ``fontFace`` key still counts; nothing set means the built-in."""
|
||||
env = _wt_env(tmp_path, {
|
||||
"profiles": {"list": [{"guid": "{abc-123}", "fontFace": "MesloLGS NF"}]},
|
||||
})
|
||||
assert _windows_terminal_face(env) == "MesloLGS NF"
|
||||
env2 = _wt_env(tmp_path, {"profiles": {"list": [{"guid": "{abc-123}"}]}})
|
||||
assert _windows_terminal_face(env2) == "Cascadia Mono"
|
||||
assert _windows_terminal_face({"LOCALAPPDATA": str(tmp_path / "nowhere")}) \
|
||||
== "Cascadia Mono"
|
||||
|
||||
|
||||
def test_vscode_face_precedence_terminal_over_editor_workspace_over_user(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""``terminal.integrated.fontFamily`` beats ``editor.fontFamily``; within a key
|
||||
the workspace file beats the user file; the value's head family is judged."""
|
||||
workspace = tmp_path / "repo"
|
||||
(workspace / ".vscode").mkdir(parents=True)
|
||||
(workspace / ".vscode" / "settings.json").write_text(
|
||||
'{"editor.fontFamily": "Consolas"}', encoding="utf-8"
|
||||
)
|
||||
appdata = tmp_path / "appdata"
|
||||
user = appdata / "Code" / "User"
|
||||
user.mkdir(parents=True)
|
||||
(user / "settings.json").write_text(
|
||||
'{"terminal.integrated.fontFamily": "\'Hack Nerd Font Mono\', monospace"}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
env = {"APPDATA": str(appdata)}
|
||||
assert _vscode_face(env, workspace) == "Hack Nerd Font Mono" # terminal.* wins
|
||||
(user / "settings.json").write_text("{}", encoding="utf-8")
|
||||
assert _vscode_face(env, workspace) == "Consolas" # workspace editor fallback
|
||||
(workspace / ".vscode" / "settings.json").write_text("{}", encoding="utf-8")
|
||||
assert _vscode_face(env, workspace) == _vscode_default_face()
|
||||
|
||||
|
||||
# --- the ladder -------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_detect_terminal_font_ladder(tmp_path: Path) -> None:
|
||||
"""WT markers first, then VS Code, then a genuine conhost; TERM disqualifies
|
||||
the conhost probe (some other emulator is hosting the console)."""
|
||||
wt = detect_terminal_font(_wt_env(tmp_path, {"profiles": {"list": []}}))
|
||||
assert wt is not None and wt.source == "windows-terminal"
|
||||
code = detect_terminal_font(
|
||||
{"TERM_PROGRAM": "vscode", "APPDATA": str(tmp_path)}, cwd=tmp_path
|
||||
)
|
||||
assert code is not None and code.source == "vscode"
|
||||
con = detect_terminal_font({}, conhost_probe=lambda: "Consolas")
|
||||
assert con is not None and (con.face, con.source) == ("Consolas", "conhost")
|
||||
assert detect_terminal_font({"TERM": "xterm"}, conhost_probe=lambda: "Consolas") is None
|
||||
|
||||
|
||||
def test_powerline_support_env_override_always_wins() -> None:
|
||||
"""``MESHTERM_POWERLINE`` is the user's word: off, on/full, or core."""
|
||||
assert _powerline_support({"MESHTERM_POWERLINE": "0"}).level == NONE
|
||||
assert _powerline_support({"MESHTERM_POWERLINE": "off"}).level == NONE
|
||||
on = _powerline_support({"MESHTERM_POWERLINE": "1", "WT_SESSION": "s"})
|
||||
assert (on.level, on.source) == (FULL, "env")
|
||||
assert _powerline_support({"MESHTERM_POWERLINE": "core"}).level == CORE
|
||||
|
||||
|
||||
def test_powerline_support_matched_font_sets_the_level(tmp_path: Path) -> None:
|
||||
"""A recommended face read from the terminal's own config decides coverage."""
|
||||
env = _wt_env(tmp_path, {
|
||||
"profiles": {"list": [{"guid": "{abc-123}",
|
||||
"font": {"face": "Hack Nerd Font Mono"}}]},
|
||||
})
|
||||
verdict = _powerline_support(env)
|
||||
assert verdict.level == FULL
|
||||
assert verdict.source == "font:windows-terminal"
|
||||
assert verdict.matched is not None and verdict.matched.name == "Hack Nerd Font"
|
||||
|
||||
|
||||
def test_powerline_support_renderer_fallback_and_honest_none(tmp_path: Path) -> None:
|
||||
"""An unmatched face on WT still earns core (the bundled-symbol fallback); the
|
||||
same face on a bare conhost is an honest ``none``."""
|
||||
env = _wt_env(tmp_path, {"profiles": {"list": [{"guid": "{abc-123}"}]}})
|
||||
wt = _powerline_support(env) # face resolves to Cascadia Mono → no match
|
||||
assert (wt.level, wt.source) == (CORE, "renderer:windows-terminal")
|
||||
con = _powerline_support({}, conhost_probe=lambda: "Consolas")
|
||||
assert (con.level, con.source, con.face) == (NONE, "font:conhost", "Consolas")
|
||||
|
||||
|
||||
def test_powerline_support_kitty_ssh_and_unknown() -> None:
|
||||
"""Glyph-drawing terminals earn core by marker; ssh and strangers stay unknown
|
||||
(the font lives on glass we cannot see)."""
|
||||
kitty = _powerline_support({"TERM": "xterm-kitty"})
|
||||
assert (kitty.level, kitty.source) == (CORE, "renderer:kitty")
|
||||
ssh = _powerline_support({"TERM": "xterm", "SSH_CONNECTION": "1.2.3.4"})
|
||||
assert (ssh.level, ssh.source) == (UNKNOWN, "ssh")
|
||||
lost = _powerline_support({"TERM": "xterm-256color"})
|
||||
assert (lost.level, lost.source) == (UNKNOWN, "unknown")
|
||||
|
||||
|
||||
def test_installed_recommended_never_raises() -> None:
|
||||
"""The machine scan is best-effort context for a future nudge screen — whatever
|
||||
the platform answers, it is a RecommendedFont or None, never an exception."""
|
||||
result = installed_recommended()
|
||||
assert result is None or isinstance(result, RecommendedFont)
|
||||
Reference in new issue
Block a user