diff --git a/CHANGELOG.md b/CHANGELOG.md index f844213..e55a90a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,21 @@ allowed to change behaviour, not just add to it. 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. +### Added + +- **MeshTerm can install a font its charts will actually draw in.** The classic Windows + console does no font fallback at all, and none of the fonts it offers can draw braille + — which every timeline and activity graph in MeshTerm is made of. Neither can Hack Nerd + Font, JetBrains Mono, Fira Code, Source Code Pro, or even DejaVu Sans Mono; the usual + advice works only because most terminals quietly fall back to a proportional font. + + So MeshTerm now ships Cascadia Mono PL — Microsoft's console font, SIL OFL — and on + that console offers to use it. If you already have Cascadia (every Windows 11 machine, + and anyone with Windows Terminal) it just switches to it. If not, it offers to install + it for you: no administrator rights, nothing downloaded, 723 KB. Saying no explains + what saying no looks like and asks once more; a second no is remembered and never + raised again. Preferences → Display → Console font changes your mind either way. + ## [0.2.4] — 2026-09-07 ### Fixed diff --git a/README.md b/README.md index 8b6234e..5f88a26 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,23 @@ chmod +x meshterm-0.2.4-* ./meshterm-0.2.4-* ``` +### A word about fonts + +MeshTerm draws its charts out of braille characters — that's how a timeline fits in one +row of text. Most terminals handle that without being asked, because when the font you +picked is missing a character they quietly borrow it from another one. + +The classic Windows console (the black `cmd.exe` window) doesn't do that. Whatever its +font is missing comes out as an empty box, and its fonts are missing rather a lot of +what MeshTerm draws. So when MeshTerm sees that console, it offers to fix it: it ships +[Cascadia Mono PL](https://github.com/microsoft/cascadia-code), Microsoft's own console +font, and will install it just for you — no administrator rights, nothing downloaded. +If you already have Cascadia, it simply switches to it. + +You can decline, and MeshTerm will still run. Preferences → Display → Console font is +where you change your mind. Running in **Windows Terminal** instead of the classic +console avoids the whole question, and gets you the emoji icons as well. + Rename it to something you don't mind typing and put it somewhere on your `PATH`, and it becomes just `meshterm` from anywhere. diff --git a/meshterm/assets/fonts/CascadiaMono-OFL.txt b/meshterm/assets/fonts/CascadiaMono-OFL.txt new file mode 100644 index 0000000..40f856f --- /dev/null +++ b/meshterm/assets/fonts/CascadiaMono-OFL.txt @@ -0,0 +1,94 @@ +Copyright (c) 2019 - Present, Microsoft Corporation, +with Reserved Font Name Cascadia Code. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/meshterm/assets/fonts/CascadiaMonoPL.ttf b/meshterm/assets/fonts/CascadiaMonoPL.ttf new file mode 100644 index 0000000..aaabb22 Binary files /dev/null and b/meshterm/assets/fonts/CascadiaMonoPL.ttf differ diff --git a/meshterm/cli.py b/meshterm/cli.py index 25288f5..a1c9023 100644 --- a/meshterm/cli.py +++ b/meshterm/cli.py @@ -188,6 +188,10 @@ def main_callback( if ctx.invoked_subcommand is None: from .ui.menu import run_menu + # Before prompt_toolkit takes the screen, because this changes the console's own + # font and the full-screen frame should be drawn once, in whatever it ends up. + _offer_a_font_this_console_can_draw(console, prefs) + # The interactive session is the one that holds the stores in memory and rewrites # them whole, so it is the one that claims the directory. One-shot subcommands are # brief and mostly read; blocking `meshterm contacts` because a menu is open in @@ -215,6 +219,125 @@ def main_callback( raise +def _offer_a_font_this_console_can_draw(console: Console, prefs: Preferences) -> None: + """On the classic Windows console, offer a font that can actually draw MeshTerm. + + That console does no font fallback: whatever its font lacks is a box, and its default + — Consolas, or Lucida Console under Windows PowerShell — lacks most of what MeshTerm + is drawn with, including every one of the 44 braille cells the timelines are made of. + Nothing at the app's end fixes that, so this is the one thing left to offer. + + Two shapes, depending on what is already there: a machine with Windows Terminal (and + every Windows 11 machine) already has Cascadia, so the offer is only to *use* it; a + bare Windows 10 gets the offer to install the copy MeshTerm ships. + + Declining is answered with a plain description of what declining looks like, and then + asked once more — a reader who has never seen the app has no way to know what "some + glyphs may not render" is going to mean. A second decline is remembered, in the + ``console_font`` preference, so the question is asked twice in total and never again. + + Args: + console: The console to ask on. + prefs: The preference set, read for a previous refusal and written on a new one. + """ + from .core import consolefont + from .ui.termfont import classic_console, face_draws_charts, installed_chart_font + + if not classic_console() or prefs.get("console_font") == "off": + return + if face_draws_charts(consolefont.current_face()): + return + + installed = installed_chart_font() + for asking_again in (False, True): + if asking_again: + console.print() + console.print("[warn]⚠[/warn] Then MeshTerm is going to look wrong here.") + console.print() + console.print(" Every chart — the signal timelines, the activity graphs — is") + console.print(" drawn from braille characters this console's font does not") + console.print(" have, so they will come out as rows of empty boxes. So will") + console.print(" the ✓ and ✗ marks, and the ★ on the map.") + console.print() + console.print(" Nothing is broken and nothing is lost — the app works, and") + console.print(" this is only about what the font can draw. It is your") + console.print(" console, so it is your call.") + console.print() + elif installed: + console.print() + console.print("[accent]•[/accent] This console's font can't draw MeshTerm's charts.") + console.print(f" You already have [accent]{installed}[/accent], which can.") + console.print() + else: + console.print() + console.print("[accent]•[/accent] This console's font can't draw MeshTerm's charts.") + console.print(" MeshTerm ships [accent]Cascadia Mono PL[/accent] — Microsoft's") + console.print(" console font — and can install it just for you. No") + console.print(" administrator rights, nothing downloaded, 723 KB.") + console.print() + + verb = "Use it" if installed else "Install and use it" + if _asks_yes(console, f" {verb}? [y/N] "): + _use_a_better_console_font(console, installed) + return + + prefs.set("console_font", "off") + try: + prefs.save() + except (OSError, RuntimeError) as exc: # a read-only config dir is not fatal here + get_logger().warning("could not remember the console font choice: %s", exc) + console.print() + console.print("[muted] Leaving it as it is. Change your mind on the Preferences[/muted]") + console.print("[muted] page, under Display → Console font.[/muted]") + console.print() + + +def _use_a_better_console_font(console: Console, installed: str | None) -> None: + """Install (when needed) and select the font, and say plainly what happened.""" + from .core import consolefont + + face = installed + if face is None: + if not consolefont.install_bundled_font(): + console.print("[err]✗[/err] The font could not be installed. Leaving the") + console.print(" console as it is.") + get_logger().warning("bundled console font could not be installed") + return + face = consolefont.BUNDLED_FACE + + if consolefont.select(face): + console.print(f"[ok]✓[/ok] Now drawing with [accent]{face}[/accent].") + get_logger().info("console font set to %s", face) + else: + # Installed but not selected: the font is there for next time and for the + # console's own properties dialog, which is worth saying rather than swallowing. + console.print(f"[warn]⚠[/warn] {face} is installed, but this console kept its own") + console.print(" font. It will be available the next time you open one.") + get_logger().warning("console refused the font %s", face) + + +def _asks_yes(console: Console, prompt: str) -> bool: + """Ask a yes/no question on the console, defaulting to no. + + Not a TUI dialog: this runs before prompt_toolkit owns the screen, alongside the other + plain-console reports here. An unanswerable prompt — no stdin, a closed pipe — reads as + "no", which is the answer that changes nothing. + + Args: + console: The console to ask on. + prompt: The question, ending in its own spacing. + + Returns: + Whether the reader said yes. + """ + console.print(prompt, end="") + try: + return input().strip().lower() in {"y", "yes"} + except (EOFError, KeyboardInterrupt, OSError): + console.print() + return False + + def _report_log_location(console: Console, config_dir: Path) -> None: """Point at the log after a fault, since it is the copy that outlives the terminal.""" console.print() diff --git a/meshterm/core/consolefont.py b/meshterm/core/consolefont.py new file mode 100644 index 0000000..57242de --- /dev/null +++ b/meshterm/core/consolefont.py @@ -0,0 +1,207 @@ +r"""Installing and selecting a console font on Windows, for one user, without admin. + +The classic Windows console does no font fallback: a character its configured font lacks +is drawn as a box, and nothing rescues it (see :func:`meshterm.ui.termfont.emoji_support` +for the sibling problem). Its default font is Consolas, which carries 57 of the 122 +non-ASCII characters MeshTerm draws and *none* of the 44 braille cells the charts are +made of. Windows PowerShell's classic console is worse still — its default, Lucida +Console, drops even ``●``. + +So on that host MeshTerm offers to fix the font. Two Win32 facts make that possible +without an installer and without administrator rights: + +* A font installs **for one user** by copying it under ``%LOCALAPPDATA%\Microsoft\ + Windows\Fonts``, registering it under ``HKCU``, and telling the session about it with + ``AddFontResourceW`` plus a ``WM_FONTCHANGE`` broadcast. No elevation anywhere. +* A console selects a font with ``SetCurrentConsoleFontEx``, which — verified on + Windows 10 22H2 — accepts a face the console's own properties dialog will not even + list. That dialog only offers what is registered under the machine-wide + ``Console\TrueTypeFont`` key (Lucida Console and Consolas here), so without this API + the user could install the font and still be unable to pick it. + +**A font install is one-way inside a session.** Once loaded, Windows locks the file: it +cannot be deleted even by an elevated process, and even after the font cache service is +restarted — it frees at the next logon. So nothing here offers to uninstall one, and the +install is written to be safe to repeat rather than reversible. + +Everything is best-effort. A failure at any step leaves the app running with the font it +already had, which is the state it would have been in anyway. +""" + +from __future__ import annotations + +import os +import shutil +import sys +from pathlib import Path + +from . import win32dll + +#: Where the bundled font lives, found the way every other asset is (see +#: :mod:`meshterm.ui.about`) so it keeps resolving inside a PyInstaller bundle. +FONT_DIR = Path(__file__).resolve().parent.parent / "assets" / "fonts" + +#: The font MeshTerm ships and offers to install. Cascadia Mono PL is Microsoft's own +#: console font, SIL OFL (licence beside it), and the ``PL`` build adds the powerline +#: separators the path lines are drawn with — 723KB for 109 of our 122 characters, all 44 +#: braille cells and all 3 powerline glyphs, where the 2.4MB Nerd Font build covers no +#: more. Practically every mainstream coder font — Hack, JetBrains Mono, Fira Code, Source +#: Code Pro, even DejaVu Sans *Mono* — carries no braille at all; Cascadia and Iosevka are +#: the exceptions, which is why this is not a matter of taste. +BUNDLED_FONT = FONT_DIR / "CascadiaMonoPL.ttf" + +#: Its family name, as the ``name`` table spells it — what ``SetCurrentConsoleFontEx`` +#: and the ``HKCU`` registration must both be given, exactly. +BUNDLED_FACE = "Cascadia Mono PL" + +_USER_FONTS = "Microsoft/Windows/Fonts" +_FONT_REGISTRY = r"Software\Microsoft\Windows NT\CurrentVersion\Fonts" + +_HWND_BROADCAST = 0xFFFF +_WM_FONTCHANGE = 0x001D +_SMTO_ABORTIFHUNG = 0x0002 + +#: ``FF_MODERN | TMPF_VECTOR | TMPF_TRUETYPE`` — what a console expects a TrueType face to +#: declare. Passing 0 here makes the call succeed and quietly keep the old raster font. +_FF_MODERN_TRUETYPE = 54 + + +def user_font_dir() -> Path: + r"""The per-user font folder — writable without admin, unlike ``C:\Windows\Fonts``.""" + local = os.environ.get("LOCALAPPDATA") or str(Path.home() / "AppData" / "Local") + return Path(local) / _USER_FONTS + + +def install_bundled_font() -> bool: + """Install the bundled font for this user, and make the session aware of it. + + Safe to repeat: an already-installed copy is left alone rather than overwritten, both + because the file is locked once loaded and because there is nothing to gain. + + Returns: + ``True`` when the font is installed and usable (including when it already was). + """ + if sys.platform != "win32" or not BUNDLED_FONT.is_file(): + return False + try: + import ctypes + import winreg + + target = user_font_dir() / BUNDLED_FONT.name + if not target.exists(): + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(BUNDLED_FONT, target) + + gdi32 = ctypes.WinDLL("gdi32", use_last_error=True) + if not gdi32.AddFontResourceW(ctypes.c_wchar_p(str(target))): + return False + + # The registration is what survives a logout; AddFontResourceW alone lasts only + # as long as this session. Per-user entries hold the full path, machine-wide ones + # hold a bare filename — this is the per-user key, so the path goes in. + with winreg.CreateKeyEx( + winreg.HKEY_CURRENT_USER, _FONT_REGISTRY, 0, winreg.KEY_SET_VALUE + ) as key: + winreg.SetValueEx(key, f"{BUNDLED_FACE} (TrueType)", 0, winreg.REG_SZ, str(target)) + + # Tell everything already running that the font list changed. Timed out and + # allowed to abort on a hung window, because a broadcast that waits on every top + # level window is a good way to stall a startup path. + user32 = win32dll.user32() + user32.SendMessageTimeoutW( + _HWND_BROADCAST, + _WM_FONTCHANGE, + 0, + 0, + _SMTO_ABORTIFHUNG, + 1000, + ctypes.byref(ctypes.c_ulong()), + ) + return True + except Exception: # pragma: no cover - an install that fails leaves the old font + return False + + +def current_face() -> str | None: + """The face this console is drawing with, or ``None`` off a console.""" + return _console_font(None) + + +def select(face: str) -> bool: + """Point this console at ``face``, and confirm it took. + + The call reports success even where the console quietly kept the font it had, so the + answer here is a read-back rather than the return value. + + Args: + face: The family name, spelled as the font's own ``name`` table spells it. + + Returns: + Whether the console is now drawing with ``face``. + """ + return _console_font(face) == face + + +def _console_font(face: str | None) -> str | None: + """Read the console's font, first setting it to ``face`` when one is given. + + One function for both because the struct, the handle and the failure modes are the + same, and because setting without reading back proves nothing. + + Args: + face: The family to select, or ``None`` to only read. + + Returns: + The face the console is drawing with afterwards, or ``None`` if it could not + be asked (no console, not Windows, a failed call). + """ + 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 _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), + ] + + kernel32 = win32dll.kernel32() + kernel32.GetStdHandle.argtypes = [wintypes.DWORD] + kernel32.GetStdHandle.restype = wintypes.HANDLE + for name in ("GetCurrentConsoleFontEx", "SetCurrentConsoleFontEx"): + fn = getattr(kernel32, name) + fn.argtypes = [wintypes.HANDLE, wintypes.BOOL, ctypes.POINTER(_CONSOLE_FONT_INFOEX)] + fn.restype = wintypes.BOOL + + handle = kernel32.GetStdHandle(wintypes.DWORD(-11).value) # STD_OUTPUT_HANDLE + info = _CONSOLE_FONT_INFOEX() + info.cbSize = ctypes.sizeof(_CONSOLE_FONT_INFOEX) + if not kernel32.GetCurrentConsoleFontEx(handle, False, ctypes.byref(info)): + return None + + if face is not None: + # Keep the height the reader already chose and let the width follow the face; + # a console font is picked for legibility at a size, and changing that out + # from under someone is not what they agreed to. + info.FaceName = face[:31] + info.FontFamily = _FF_MODERN_TRUETYPE + info.dwFontSize = _COORD(0, info.dwFontSize.Y or 16) + info.FontWeight = 400 + kernel32.SetCurrentConsoleFontEx(handle, False, ctypes.byref(info)) + after = _CONSOLE_FONT_INFOEX() + after.cbSize = ctypes.sizeof(_CONSOLE_FONT_INFOEX) + if not kernel32.GetCurrentConsoleFontEx(handle, False, ctypes.byref(after)): + return None + return after.FaceName or None + return info.FaceName or None + except Exception: # pragma: no cover - a probe that fails is an unknown, not a crash + return None diff --git a/meshterm/core/preferences.py b/meshterm/core/preferences.py index 01b9277..4415bae 100644 --- a/meshterm/core/preferences.py +++ b/meshterm/core/preferences.py @@ -128,6 +128,13 @@ _WIDTH_CHOICES: dict[str, str] = {"auto": "auto", "yes": "yes", "no": "no"} #: What the log file keeps. The plain level names, which are what every other tool calls #: these — someone being talked through a problem is being told "set it to debug", not #: "set it to everything". +#: What MeshTerm may do about a console font that cannot draw it. Only ever consulted on +#: the classic Windows console, the one host with no font fallback of its own. +_CONSOLE_FONT_CHOICES: dict[str, str] = { + "ask": "Ask", + "off": "Leave it alone", +} + _LOG_LEVEL_CHOICES: dict[str, str] = { "ERROR": "Error", "WARNING": "Warning", @@ -295,6 +302,18 @@ PREFERENCES: tuple[PrefSpec, ...] = ( default="auto", choices=_WIDTH_CHOICES, ), + PrefSpec( + key="console_font", + label="Console font", + help="Offer a font this console can draw MeshTerm with (Windows console only)", + group="Display", + value_type="enum", + # "ask", not "always": changing the font of a window someone else opened is not + # ours to do unasked. Declining twice writes "off" here, which is how the offer + # stops being made rather than being made every launch. + default="ask", + choices=_CONSOLE_FONT_CHOICES, + ), # --- Diagnostics --------------------------------------------------------------- PrefSpec( key="log_level", diff --git a/meshterm/ui/termfont.py b/meshterm/ui/termfont.py index ef0cbcc..9e55ee6 100644 --- a/meshterm/ui/termfont.py +++ b/meshterm/ui/termfont.py @@ -844,6 +844,17 @@ def _emoji_support( return EmojiSupport(not classic, "console") +@lru_cache(maxsize=1) +def classic_console() -> bool: + """Whether this session is drawing into a genuine ``conhost`` window. + + The one host with no font fallback of its own, so the only one where the configured + font's own repertoire decides what the reader sees. See :func:`_is_classic_console` + for how it is identified, and why not from the environment. + """ + return _is_classic_console() is True + + @lru_cache(maxsize=1) def emoji_support() -> EmojiSupport: """Whether emoji icons can be drawn here, decided once and cached. @@ -861,3 +872,65 @@ def emoji_support() -> EmojiSupport: def emoji_enabled() -> bool: """Whether icons should render as emoji rather than through the compact table.""" return emoji_support().supported + + +# --- chart glyphs ----------------------------------------------------------------- + + +#: Families measured to carry the Braille Patterns block (U+2800–U+28FF) that +#: :mod:`meshterm.ui.braillechart` draws every timeline from. +#: +#: The list is short on purpose, and each entry is a measurement rather than a +#: reputation: reading the ``cmap`` of every font installed on a development machine +#: (2026-09-07) found that nearly no monospace font carries the block at all. Hack Nerd +#: Font, JetBrains Mono, Fira Code, Source Code Pro, Consolas, Lucida Console and — the +#: surprise — DejaVu Sans *Mono* all measure zero of 256. DejaVu *Sans*, which is +#: proportional, carries all 256, which is where the widespread "install DejaVu for +#: braille" advice comes from: it works only because terminals fall back from the mono +#: face to the proportional one for that block. +#: +#: Which is the point. On every host that falls back — Windows Terminal, VS Code's +#: terminal, macOS, Linux — the charts draw whatever the chosen font holds, so this list +#: never has to be right. It matters on the one host with no fallback at all, the classic +#: Windows console, where a font outside this list means boxes where the charts should be. +CHART_FONTS: tuple[str, ...] = ( + # Microsoft's console font: 44/44, and the family MeshTerm bundles (see + # meshterm.core.consolefont). Covers the PL and NF builds by prefix. + "cascadia mono", + "cascadia code", + # The Nerd Font patches of the same, which keep what they patch. + "caskaydiacove", + "caskaydiamono", + # Widely reported to carry the block; not measured here, no copy to hand. + "iosevka", +) + + +def face_draws_charts(face: str | None) -> bool: + """Whether ``face`` can draw the braille the timelines are made of. + + Args: + face: A configured font family, in any spelling, or ``None``. + + Returns: + Whether 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``. + + What separates "select the font they already have" — every Windows 11 machine, and + anyone who has installed Windows Terminal — from "offer to install ours". + + Returns: + The family name as installed, or ``None`` when nothing installed can do it. + """ + for family in _installed_families(): + if face_draws_charts(family): + return family + return None diff --git a/tests/test_consolefont.py b/tests/test_consolefont.py new file mode 100644 index 0000000..02b87eb --- /dev/null +++ b/tests/test_consolefont.py @@ -0,0 +1,207 @@ +"""Tests for the bundled console font and the machinery that installs and selects it. + +The Win32 half cannot be exercised off Windows, and should not be exercised *on* it by a +test suite — installing a font is a change to the machine, and one that cannot be undone +inside a session (Windows locks the file once it is loaded). So what is pinned here is +everything that can be checked without touching the system: that the font we ship is the +font we say we ship, that it can actually draw what MeshTerm draws, and that every entry +point degrades rather than raises where there is no console to talk to. + +The font's own coverage is asserted from its ``cmap``, which makes this the desktop +counterpart to :mod:`meshterm.ui.fontset` — the PicoCalc's device-verified inventory. +""" + +from __future__ import annotations + +import struct +import sys +from pathlib import Path + +import pytest + +from meshterm.core import consolefont +from meshterm.ui.termfont import CHART_FONTS, face_draws_charts, installed_chart_font + +BRAILLE = range(0x2800, 0x2900) + + +def _cmap(path: Path) -> set[int]: + """Every codepoint a TrueType font maps, read from its ``cmap`` table. + + A short format-4 reader rather than a dependency: the question asked here is narrow + and the alternative is trusting a font file we ship without ever looking inside it. + """ + data = path.read_bytes() + count = struct.unpack(">H", data[4:6])[0] + tables = {} + for i in range(count): + offset = 12 + i * 16 + tag, _, start, _length = struct.unpack(">4sIII", data[offset : offset + 16]) + tables[tag.decode("latin-1")] = start + + cmap = tables["cmap"] + covered: set[int] = set() + for i in range(struct.unpack(">H", data[cmap + 2 : cmap + 4])[0]): + record = cmap + 4 + i * 8 + sub = cmap + struct.unpack(">HHI", data[record : record + 8])[2] + if struct.unpack(">H", data[sub : sub + 2])[0] != 4: + continue + seg_bytes = struct.unpack(">H", data[sub + 6 : sub + 8])[0] + segs = seg_bytes // 2 + ends = struct.unpack(f">{segs}H", data[sub + 14 : sub + 14 + seg_bytes]) + starts_at = sub + 16 + seg_bytes + starts = struct.unpack(f">{segs}H", data[starts_at : starts_at + seg_bytes]) + deltas_at = starts_at + seg_bytes + deltas = struct.unpack(f">{segs}h", data[deltas_at : deltas_at + seg_bytes]) + ranges_at = deltas_at + seg_bytes + ranges = struct.unpack(f">{segs}H", data[ranges_at : ranges_at + seg_bytes]) + for seg in range(segs): + for cp in range(starts[seg], min(ends[seg], 0xFFFF) + 1): + if ranges[seg] == 0: + glyph = (cp + deltas[seg]) & 0xFFFF + else: + at = ranges_at + seg * 2 + ranges[seg] + (cp - starts[seg]) * 2 + if at + 2 > len(data): + continue + glyph = struct.unpack(">H", data[at : at + 2])[0] + if glyph: + glyph = (glyph + deltas[seg]) & 0xFFFF + if glyph: + covered.add(cp) + return covered + + +def test_the_font_we_ship_is_present_with_its_licence() -> None: + """It is redistributed under the SIL OFL, which travels with the file or not at all.""" + assert consolefont.BUNDLED_FONT.is_file() + licence = consolefont.FONT_DIR / "CascadiaMono-OFL.txt" + assert licence.is_file() + text = licence.read_text(encoding="utf-8") + assert "SIL Open Font License" in text + assert "Reserved Font Name" in text + + +def test_the_bundled_font_can_draw_the_charts() -> None: + """The reason this font and not another: it has the braille block, and few do. + + Measured across every font on a development machine, no mainstream coder font carried + it — Hack Nerd Font, JetBrains Mono, Fira Code, Source Code Pro and even DejaVu Sans + *Mono* all hold none of the 256. Shipping one that cannot draw a chart would leave the + offer technically successful and visibly pointless. + """ + covered = _cmap(consolefont.BUNDLED_FONT) + missing = [cp for cp in BRAILLE if cp not in covered] + assert not missing, f"{len(missing)} braille cells missing from the bundled font" + + +def test_the_bundled_font_covers_the_marks_and_the_path_chips() -> None: + """The rest of what a classic console has to draw from its font alone. + + The powerline separators are why the ``PL`` build is the one bundled rather than the + plain one: 25KB more, and the path lines keep their chips. + """ + covered = _cmap(consolefont.BUNDLED_FONT) + for mark in "✓❯◉●○▲■─│╭╮╰╯▌▐░▒▓█←↑→↓↔↕…": + assert ord(mark) in covered, f"{mark!r} is not in the bundled font" + for chip in "": # the powerline separator and its round caps + assert ord(chip) in covered, f"U+{ord(chip):04X} is not in the bundled font" + + +def test_the_bundled_face_is_what_the_font_calls_itself() -> None: + """``SetCurrentConsoleFontEx`` matches on the family name exactly, so a typo is silent. + + It would install the font, fail to select it, and report a console that "kept its own + font" — with nothing anywhere naming the real cause. + """ + data = consolefont.BUNDLED_FONT.read_bytes() + count = struct.unpack(">H", data[4:6])[0] + name_table = next( + struct.unpack(">4sIII", data[12 + i * 16 : 28 + i * 16])[2] + for i in range(count) + if struct.unpack(">4sIII", data[12 + i * 16 : 28 + i * 16])[0] == b"name" + ) + records, strings = struct.unpack(">HH", data[name_table + 2 : name_table + 6]) + families = set() + for i in range(records): + at = name_table + 6 + i * 12 + platform, encoding, language, name_id, length, offset = struct.unpack( + ">HHHHHH", data[at : at + 12] + ) + if (platform, encoding, language, name_id) == (3, 1, 0x409, 1): + start = name_table + strings + offset + families.add(data[start : start + length].decode("utf-16-be")) + assert consolefont.BUNDLED_FACE in families, f"font calls itself {families}" + + +def test_the_bundled_face_is_one_we_would_accept() -> None: + """The font we install has to satisfy the check that decides whether to offer it. + + Otherwise MeshTerm installs it, selects it, and offers again on the next launch. + """ + assert face_draws_charts(consolefont.BUNDLED_FACE) + + +def test_chart_fonts_are_matched_by_family_however_spelled() -> None: + """Cascadia ships plain, PL and NF builds, and the Nerd Font patches rename it. + + Matched on the *family* name, which is what a terminal's config, the ``HKCU`` + registration and ``SetCurrentConsoleFontEx`` all speak — never the filename. Those + differ here: ``CascadiaMonoPL.ttf`` calls itself ``Cascadia Mono PL``, spaced. + """ + assert face_draws_charts("Cascadia Mono") + assert face_draws_charts("Cascadia Mono PL") + assert face_draws_charts("Cascadia Mono NF") + assert face_draws_charts(" cascadia code ") # spacing and case are normalised + assert face_draws_charts("CaskaydiaCove Nerd Font Mono") + + +def test_the_fonts_that_cannot_draw_charts_are_not_claimed() -> None: + """Every one of these was measured at zero braille cells; a wrong yes here is a lie. + + It would leave a reader on a classic console with boxes for charts and no offer to + fix them, which is the exact failure the whole check exists to prevent. + """ + for face in ( + "Consolas", + "Lucida Console", + "Courier New", + "Hack Nerd Font Mono", + "JetBrains Mono", + "Fira Code", + "Source Code Pro", + "DejaVu Sans Mono", + ): + assert not face_draws_charts(face), f"{face} does not carry the braille block" + assert not face_draws_charts(None) + assert not face_draws_charts("") + + +def test_every_chart_font_entry_is_normalised() -> None: + """The list is compared against normalised faces, so an entry with capitals never hits.""" + for entry in CHART_FONTS: + assert entry == entry.lower().strip() + assert " " not in entry + + +def test_asking_the_machine_what_it_has_never_raises() -> None: + """Best-effort, on every platform: a font scan that throws would take startup with it.""" + assert installed_chart_font() is None or isinstance(installed_chart_font(), str) + + +def test_reading_the_console_font_never_raises() -> None: + """Under pytest there is no console worth asking, and the answer must simply be None.""" + assert consolefont.current_face() is None or isinstance(consolefont.current_face(), str) + + +def test_the_user_font_directory_is_under_the_users_own_profile() -> None: + """The whole point of the per-user location: no administrator rights are involved.""" + directory = consolefont.user_font_dir() + assert directory.parts[-3:] == ("Microsoft", "Windows", "Fonts") + assert "AppData" in str(directory) or "Local" in str(directory) + + +@pytest.mark.skipif(sys.platform == "win32", reason="would touch the real machine") +def test_installing_is_a_no_op_where_there_are_no_windows_fonts() -> None: + """Off Windows the offer is never made, and the install refuses rather than pretends.""" + assert consolefont.install_bundled_font() is False + assert consolefont.select("Cascadia Mono PL") is False