Files
Jean-Pierre MartineauandClaude Opus 5.5 ffe31dac36 Docstrings and comments of meshterm/core are in ASD-STE100
The prose of each docstring and comment in the core package now follows
ASD-STE100 Simplified Technical English, with Canadian spelling: short
sentences, simple verb tenses, no dashes or semicolons, and one word for
one concept (docs/development/writing-style.md has the rules and the
glossary). The meaning and each reason stay. The code does not change:
the AST of each file, without its docstrings, is the same as before.

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

267 lines
11 KiB
Python

# SPDX-License-Identifier: Apache-2.0
r"""Install and select a console font on Windows, for one user, without admin rights.
The classic Windows console does no font fallback. If its configured font does not have
a character, the console draws a box, and nothing replaces it (refer to
:func:`meshterm.ui.termfont.emoji_support` for the related problem). Its default font is
Consolas. Consolas has 57 of the 122 non-ASCII characters that MeshTerm draws, and none
of the 44 braille characters of the charts. The classic console of Windows PowerShell is
worse: its default font, Lucida Console, does not have even ``●``.
Thus, on that host, MeshTerm offers to correct the font. Two Win32 facts make this
possible without an installer and without administrator rights:
* A font installs **for one user** when you copy it into ``%LOCALAPPDATA%\Microsoft\
Windows\Fonts``, register it under ``HKCU``, and tell the session about it with
``AddFontResourceW`` and a ``WM_FONTCHANGE`` broadcast. No step needs elevation.
* A console selects a font with ``SetCurrentConsoleFontEx``. We checked on Windows 10
22H2 that this function accepts a face that the properties dialog of the console does
not list. That dialog offers only the fonts that are registered under the machine-wide
``Console\TrueTypeFont`` key (here, Lucida Console and Consolas). Without this API,
the user can install the font but cannot select it.
**A font install cannot be undone in a session.** After Windows loads the font, it locks
the file. Even an elevated process cannot delete the file, also after a restart of the
font cache service. Windows releases the file at the next logon. Thus nothing here
offers to uninstall a font. We wrote the install so that it is safe to repeat, not so
that it can be reversed.
Each step is best-effort. If a step fails, the app continues with the font that it
already had. That is the same state as without this module.
"""
from __future__ import annotations
import os
import shutil
import sys
from pathlib import Path
from . import win32dll
#: Where the bundled font is. MeshTerm finds it in the same way as each other asset
#: (refer to :mod:`meshterm.ui.about`), so that the path stays correct inside a
#: PyInstaller bundle.
FONT_DIR = Path(__file__).resolve().parent.parent / "assets" / "fonts"
#: The font that MeshTerm ships and offers to install. Cascadia Mono PL is the console
#: font of Microsoft, under the SIL OFL (its licence is next to it). The ``PL`` build adds
#: the powerline separators with which MeshTerm draws the path lines. It is 723KB for 109
#: of our 122 characters, all 44 braille characters, and all 3 powerline glyphs. The
#: 2.4MB Nerd Font build covers no more. Almost all mainstream fonts for code (Hack,
#: JetBrains Mono, Fira Code, Source Code Pro, and also the Mono variant of DejaVu Sans)
#: have no braille. Cascadia and Iosevka are the exceptions. Thus this choice is not a
#: matter of taste.
BUNDLED_FONT = FONT_DIR / "CascadiaMonoPL.ttf"
#: The family name of the font, as the ``name`` table spells it.
#: ``SetCurrentConsoleFontEx`` and the ``HKCU`` registration must both get exactly this
#: name.
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. If the value here is 0, the call succeeds, but it quietly keeps the old raster
#: font.
_FF_MODERN_TRUETYPE = 54
def user_font_dir() -> Path:
r"""The font folder for one user.
Unlike ``C:\Windows\Fonts``, this folder is writable without admin rights.
"""
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 tell the session about it.
It is safe to repeat. If a copy is already installed, the function does not overwrite
it, for two reasons: Windows locks the file after it loads it, and an overwrite gives
no benefit.
Returns:
``True`` when the font is installed and usable (also when it was installed
before).
"""
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)
if not _add_font_resource(target):
return False
# The registration stays after a logout. AddFontResourceW alone lasts only for
# this session. An entry for one user holds the full path, and a machine-wide
# entry holds a bare filename. This is the key for one user, 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 all the running programs that the font list changed. The call has a
# timeout, and it can stop at a hung window, because a broadcast that waits for
# each top-level window can easily block 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 _add_font_resource(path: Path) -> bool:
"""Make a font file usable by this process, without an install.
``AddFontResourceW`` adds the font to the font table of the running process. The
``HKCU`` registration next to it makes the font permanent, and Windows reads that
registration at the next logon. This function exists for the case between those two
facts. A second start of MeshTerm in the same session finds its own registration, and
believes that the font is ready. But nothing loaded the font into this new process yet.
Args:
path: The font file, in any location.
Returns:
Whether at least one face was added.
"""
if sys.platform != "win32" or not path.is_file():
return False
try:
import ctypes
gdi32 = ctypes.WinDLL("gdi32", use_last_error=True)
return bool(gdi32.AddFontResourceW(ctypes.c_wchar_p(str(path))))
except Exception: # pragma: no cover - best effort, like everything else here
return False
def use(face: str) -> bool:
"""Draw this console with ``face``. If ``face`` is our own copy, load it first.
The plain :func:`select` is sufficient for a font that the system installed: the
Cascadia that comes with Windows 11 or with Windows Terminal. It is not sufficient for
the copy that MeshTerm installed in an earlier run in the same session. That copy is
registered but not loaded yet (refer to :func:`_add_font_resource`). When you select
it, the console silently keeps the old font. Thus, if the console refuses the face,
the function loads the font and tries one more time.
Args:
face: The family to draw with.
Returns:
Whether the console now draws with ``face``.
"""
if select(face):
return True
if face == BUNDLED_FACE and _add_font_resource(user_font_dir() / BUNDLED_FONT.name):
return select(face)
return False
def current_face() -> str | None:
"""The face with which this console draws, or ``None`` when there is no console."""
return _console_font(None)
def select(face: str) -> bool:
"""Set this console to ``face``, and confirm that the change occurred.
The call reports success also when the console quietly kept its old font. Thus the
answer here comes from a read-back, not from the return value.
Args:
face: The family name, spelled as the ``name`` table of the font spells it.
Returns:
Whether the console now draws with ``face``.
"""
return _console_font(face) == face
def _console_font(face: str | None) -> str | None:
"""Read the font of the console. When ``face`` is given, set the font to it first.
One function does both, because the struct, the handle, and the failure modes are the
same. Also, a set without a read-back proves nothing.
Args:
face: The family to select, or ``None`` to only read.
Returns:
The face with which the console draws after the call, or ``None`` if the
function could not ask (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 that the user chose, and let the width follow the face. A
# user selects a console font to be legible at a size. The user did not agree
# to a change of that size.
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