Ship a font the charts can be drawn in, and offer to install it
Every timeline in MeshTerm is made of braille characters, and almost no monospace font has them. Reading the cmap of every font on this machine: Consolas, Lucida Console, Hack Nerd Font, JetBrains Mono, Fira Code and Source Code Pro all carry zero of the 256 -- and so does DejaVu Sans *Mono*, which is where the widespread "install DejaVu for braille" advice falls down. DejaVu *Sans*, proportional, has all 256. That is the whole trick: terminals fall back to a proportional face for the block, and the charts have always been drawn by a font nobody chose. Which is fine everywhere except the one host that does no fallback. In the classic Windows console -- what a double-clicked build gets -- Consolas draws 57 of the 122 characters we use and none of the charts. So MeshTerm now bundles Cascadia Mono PL (SIL OFL, licence beside it) and offers it there. Cascadia is one of two mainstream coder fonts with the block; the PL build adds the powerline separators the path lines want, and at 723KB covers exactly what the 2.4MB Nerd Font build does. A machine that already has Cascadia -- every Windows 11, anyone with Windows Terminal -- is only offered the switch. Both halves are verified on Windows 10 22H2, and neither needs admin: a font installs for one user under %LOCALAPPDATA% with AddFontResourceW and an HKCU registration, and SetCurrentConsoleFontEx selects a face the console's own properties dialog will not even list -- that dialog offers only what is under the machine-wide Console\TrueTypeFont key, which here is Lucida Console and Consolas, so without that API the font could be installed and still unpickable. Declining is answered with what declining looks like -- charts as rows of boxes -- and asked once more, because a reader who has never seen the app cannot picture what it costs. A second no is remembered in the console_font preference and never raised again. Windows locks a font file once loaded: it cannot be deleted even elevated, even after restarting the font cache, until the next logon. So nothing here offers to uninstall one, and the install is written to be repeatable instead of reversible. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
ea5f644d73
commit
f06b593a0f
9 files changed
+755
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
Binary file not shown.
+123
@@ -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()
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user