10 Commits
Author SHA1 Message Date
Jean-Pierre MartineauandClaude Opus 5 690807370b Take them to the better terminal instead of asking permission to
Installers / windows-x64 (push) Canceled after 0s
Installers / macos-x64 (push) Canceled after 0s
Installers / macos-arm64 (push) Canceled after 0s
Installers / linux-x64 (push) Canceled after 0s
Installers / linux-arm64 (push) Canceled after 0s
GitHub release / build (push) Canceled after 0s
GitHub release / create the release (push) Canceled after 0s
The classic Windows console cannot draw a single MeshTerm icon whatever font
it is given, so "reopen in Windows Terminal?" had exactly one sensible answer
-- and was being put to someone who had not seen the app yet and so had nothing
to weigh it against, at the one moment nothing had been drawn.

So it moves. It still says what it is doing and still names the way to stop it,
because a program that opens a window you did not ask for should say so.

Installing a font keeps its consent, and the split is deliberate: moving window
costs nothing and undoes itself by closing, while a font is a file written to
someone's machine that Windows will not let go of until their next logon.

The console_setup preference reads auto/off now rather than ask/off, and its
help says what it does. An older stored "ask" is not "off", so it keeps working
and simply means auto.

JP's call, and it is the right one -- the reference experience should be what
an end user gets, not what they think to ask for.

Release 0.2.7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 19:42:46 -04:00
Jean-Pierre MartineauandClaude Opus 5 564e051d1a The reopened session must not inherit the bundle's own bookkeeping
Installers / windows-x64 (push) Canceled after 0s
Installers / macos-x64 (push) Canceled after 0s
Installers / macos-arm64 (push) Canceled after 0s
Installers / linux-x64 (push) Canceled after 0s
Installers / linux-arm64 (push) Canceled after 0s
GitHub release / build (push) Canceled after 0s
GitHub release / create the release (push) Canceled after 0s
Found by testing the release artifact rather than the repo. A PyInstaller
one-file build unpacks itself and re-runs itself, the halves coordinating
through _PYI* environment variables. reopen() handed the whole environment to
the new copy, so its bootloader concluded it was stage two of a launch nobody
had made, checked that its parent was the same executable, found wt.exe, and
aborted:

    [PYI-33368:ERROR] Security validation failure: parent process has
    different executable!
    [process exited with code 4294967295]

A new window holding a message nobody can act on, with the window that offered
the move already gone. Strictly worse than not offering.

child_environment() strips them, and restores the loader paths the bootloader
parks aside. Verified against a locally frozen build: before, the bootloader
error; after, the session comes up in Windows Terminal with its emoji and its
charts.

The source build could never have shown this. Same shape as 0.2.2's empty tool
registry -- the packaged build is a platform of its own, and the only way to
test it is to run it.

Release 0.2.6.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 17:57:10 -04:00
Jean-Pierre MartineauandClaude Opus 5 a11c43c347 Release 0.2.5
Installers / windows-x64 (push) Canceled after 0s
Installers / macos-x64 (push) Canceled after 0s
Installers / macos-arm64 (push) Canceled after 0s
Installers / linux-x64 (push) Canceled after 0s
Installers / linux-arm64 (push) Canceled after 0s
GitHub release / build (push) Canceled after 0s
GitHub release / create the release (push) Canceled after 0s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 17:43:20 -04:00
Jean-Pierre MartineauandClaude Opus 5 476294a8db Say whose font this is and which licence it keeps
The OFL permits bundling a font with any software, on conditions we now meet
explicitly. The file is byte-identical to Microsoft's own release (sha256
528f9214...), so it is not a Modified Version and the Reserved Font Name
clause does not apply. Condition 2 wants the copyright notice and the licence
in every copy: CascadiaMono-OFL.txt ships beside the .ttf and travels into the
bundle with the rest of the assets. Condition 5 wants the font under that
licence and no other, which the Apache LICENSE at the root would otherwise
have appeared to claim -- so NOTICE and the README now carve it out by name.

Also says plainly that Microsoft does not endorse MeshTerm, since condition 4
allows acknowledgement and nothing more.

The README's font section predated the Windows Terminal offer and named a
preference that has since been renamed; it now leads with the terminal, as the
app does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 17:41:55 -04:00
Jean-Pierre MartineauandClaude Opus 5 7be4b8514b A sentence the last commit left in two halves
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 17:16:36 -04:00
Jean-Pierre MartineauandClaude Opus 5 54bbb93233 Offer the terminal, then the font -- in that order
The font work made the classic Windows console as good as it can be, and it
still cannot show a single icon. No font fixes that: the only two fonts on a
Windows machine carrying emoji glyphs are Segoe UI Emoji and Segoe UI Symbol,
both proportional, and a console will not take a proportional font. Measured
across every font installed here, no monospace face has more than one of the
33 emoji MeshTerm draws.

What actually decides all of this is the terminal, not the font. A modern one
borrows a missing glyph from another font on the machine, which is why the app
looks right in Windows Terminal with a font (Hack Nerd Font) that holds no
braille and no emoji itself. The classic console is the one host that does not,
so the fix for it is to leave it.

So the first offer is now to reopen in Windows Terminal -- present on every
Windows 11 machine, free on 10 -- asked with yes as the default, since it
installs nothing and changes nothing. The font offer stays as the fallback for
a reader who declines or has no Windows Terminal, and keeps its own second ask.
One preference, console_setup, remembers a refusal of either.

Verified end to end: launched in cmd.exe, accepted, and the session came back
up in Windows Terminal with colour emoji, braille charts and rounded borders.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 17:16:23 -04:00
Jean-Pierre MartineauandClaude Opus 5 5158268a73 A font we installed ourselves has to be loaded before it can be selected
Found by testing the second launch rather than the first. AddFontResourceW
adds a font to the running process's font table; the HKCU registration beside
it is what makes it permanent, and Windows reads that at the next logon. So
between an install and a logoff there is a window where a new MeshTerm process
finds its own registration, believes the font is ready, and selects nothing --
SetCurrentConsoleFontEx reports success and quietly keeps the old font.

consolefont.use() retries once after loading our own copy, which is a no-op
for the Cascadia that came with Windows and the fix for the one we put there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 17:00:50 -04:00
Jean-Pierre MartineauandClaude Opus 5 f06b593a0f 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>
2026-09-07 16:56:15 -04:00
Jean-Pierre MartineauandClaude Opus 5 ea5f644d73 The console that can't draw an emoji says so before we draw one
The classic Windows console renders through GDI with the console font and no
emoji font behind it, so every icon in the menu came up as a replacement box.
It is what a double-clicked build gets, which is most of the Windows audience.

The fix already existed: the compact single-character glyph table built for the
PicoCalc, reached by Platform.emoji. The seam's flags are independent, so
clearing that one alone keeps the desktop's width, colour and accented names and
moves only the icons — without_emoji() is the whole change to the platform.

Identifying the host has to be structural. It cannot be asked in band, because a
missing glyph still fills its cell: a classic console stores an emoji intact and
advances two cells while showing a box, and a ConPTY reads back as U+FFFD while
drawing it perfectly, so a write-and-read-back probe answers both hosts
backwards. Nor can the environment answer, since WT_SESSION is inherited across
process launches. So two independent facts must agree — the console font has a
real pixel width (a ConPTY's hidden conhost reports zero) and the buffer is
taller than its window (a ConPTY has no scrollback of its own) — and an unknown
never degrades anything. MESHTERM_EMOJI overrides either way, and
`meshterm platform` reports the verdict and its reason.

Verified on Windows 10 22H2 in all three: a real conhost turns the icons
compact, Windows Terminal keeps its emoji, a redirected run is left alone.

_has_wide_glyph asked the same flag to mean "nothing here is two cells wide",
which was true of the PicoCalc's fixed font rather than of the flag. A console
that merely lacks emoji still renders the rest of the BMP, and a contact named
in CJK is two cells wide there like anywhere else, so it now asks ascii_fold —
the fold is what actually guarantees it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 15:47:27 -04:00
Jean-Pierre MartineauandClaude Opus 5 d062b5b706 The Windows console call that broke the app is ours alone
Installers / windows-x64 (push) Canceled after 0s
Installers / macos-x64 (push) Canceled after 0s
Installers / macos-arm64 (push) Canceled after 0s
Installers / linux-x64 (push) Canceled after 0s
Installers / linux-arm64 (push) Canceled after 0s
GitHub release / build (push) Canceled after 0s
GitHub release / create the release (push) Canceled after 0s
MeshTerm died building its screen on Windows with a ctypes type error, and on a
double-clicked build the traceback and the window it printed to were destroyed
together — which is why this needed a log file before it could be looked at.

`ctypes.windll.kernel32` is a cache. Every caller in the process receives the same
object and each function on it is a shared attribute, so declaring `.argtypes` is
not configuration, it is a process-wide mutation where the last writer wins. The
emoji-width probe declared `GetConsoleScreenBufferInfo` as taking a pointer to its
own copy of the screen-buffer struct; prompt_toolkit then called that same function
object with a pointer to its own copy — same layout, different class — and ctypes
refused it.

Nothing here reaches for `ctypes.windll` any more. Five call sites take private
handles from `core/win32dll.py`, built by constructing `WinDLL` directly so each
one gets its own function cache and a declaration made for one purpose cannot be
seen by anything else. Each call returns a fresh handle, because two parts of
MeshTerm disagreeing about a signature is the same bug in miniature.

The regression test was checked against the defect rather than assumed to cover
it: with the old line restored it fails, and with the fix it passes. A test that
passes either way would only have recorded that somebody thought about the
problem.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 15:16:26 -04:00
21 changed files with 1839 additions and 36 deletions

No files matched your search

+96 -1
View File
@@ -17,6 +17,100 @@ allowed to change behaviour, not just add to it.
## [Unreleased]
## [0.2.7] — 2026-09-07
### Changed
- **MeshTerm moves to Windows Terminal rather than asking to.** It still says so, and
still tells you how to stop it, but it doesn't put the question. The classic console
can't draw a single icon whatever font it's given, so there was only one sensible
answer — and it was being asked of someone who hadn't seen the app yet and had nothing
to judge it by.
Installing a **font** still asks, because that writes a file to your machine. Moving
window doesn't. Preferences → Display → Console setup stops both.
## [0.2.6] — 2026-09-07
### Fixed
- **Accepting "reopen in Windows Terminal" killed the session** in the downloadable
builds. A new window opened, printed `Security validation failure: parent process has
different executable!`, and exited — while the window that made the offer had already
closed. Worse than never offering.
A one-file build unpacks itself and re-runs itself, the two halves coordinating through
private environment variables. Those were being handed to the new copy, which concluded
it was the second half of a launch it never made, checked that its parent was the same
program, found Windows Terminal instead, and stopped. They are stripped now.
It worked perfectly when run from source, which is the whole lesson: the packaged build
is its own platform.
## [0.2.5] — 2026-09-07
### Fixed
- **Every icon was a box in the classic Windows console.** That console — which is what a
double-clicked build still gets — draws no emoji at all, whatever font it is set to, so
the menu came up as rows of replacement characters.
MeshTerm now works out which console it is running in and, on that one, draws its icons
through the compact single-character table it already keeps for the PicoCalc: `☼` for an
advert, `¶` for a message, `★` for the trophy case. Nothing else about the desktop
layout changes — same width, same colours, same accented names. Running in Windows
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 offers to reopen itself in Windows Terminal.** The classic Windows console —
what you get from a double-click — can't show the emoji icons at all, whatever font it
is given: the only two Windows fonts with emoji in them are proportional, and a console
won't take one. A terminal is the only thing that fixes it, so MeshTerm now offers to
move there. One keypress, nothing installed, nothing changed — and you get the app as
it was drawn: icons, charts, path chips and all.
- **MeshTerm can install a font its charts will actually draw in** — for anyone who stays
in the classic console. That 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 setup changes your mind about either
offer.
## [0.2.4] — 2026-09-07
### Fixed
- **MeshTerm crashed on startup on Windows.** Building the screen raised
`ctypes.ArgumentError: expected LP__CSBI instance instead of pointer to
CONSOLE_SCREEN_BUFFER_INFO`, and on a double-clicked build the traceback and the window
it was printed in were destroyed together.
`ctypes.windll.kernel32` is a cache: every caller in a process gets the same object, and
each function on it is a shared attribute. The emoji-width probe declared
`GetConsoleScreenBufferInfo` as taking a pointer to *its* copy of the screen-buffer
struct — which rewrote the signature for prompt_toolkit, which then called that same
function with a pointer to *its* copy. Same layout, different class; ctypes refused.
Nothing in MeshTerm reaches for `ctypes.windll` any more. Five call sites now take
private handles from `core/win32dll.py`, so a signature declared for one purpose is
invisible to everything else in the process.
### Changed
- The log-detail preference offers the plain level names — Error, Warning, Info, Debug —
rather than descriptions of them. Someone being talked through a problem is told "set it
to debug", not "set it to everything".
## [0.2.3] — 2026-09-07
### Changed
@@ -154,7 +248,8 @@ deliberately not reconstructed here.
- Two `TYPE_CHECKING` imports the test suite referenced but never imported, on paths that
happened never to run.
[Unreleased]: https://github.com/jpmartineau/MeshTerm/compare/v0.2.3...HEAD
[Unreleased]: https://github.com/jpmartineau/MeshTerm/compare/v0.2.4...HEAD
[0.2.4]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.4
[0.2.3]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.3
[0.2.2]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.2
[0.2.1]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.1
+9
View File
@@ -22,3 +22,12 @@ project and its original author. A fork that keeps soliciting
donations through that link, or through a lookalike of it, without
clearly redirecting them to itself, is not affiliated with this
project and is not authorized to do so.
--------------------------------------------------------------------------------
Bundled font: meshterm/assets/fonts/CascadiaMonoPL.ttf is Cascadia Mono PL,
Copyright (c) 2019 - Present, Microsoft Corporation, with Reserved Font Name
Cascadia Code. It is redistributed here unmodified under the SIL Open Font
License, Version 1.1, which is included beside it as CascadiaMono-OFL.txt and
is its only license -- the Apache License above does not apply to it. The
inclusion of this font does not imply that Microsoft endorses MeshTerm.
+48 -6
View File
@@ -68,16 +68,43 @@ if anything goes wrong at startup the window closes before you can read why.
**Windows** — open Windows Terminal or PowerShell, `cd` to your downloads, then:
```powershell
.\meshterm-0.2.3-windows-x64.exe
.\meshterm-0.2.7-windows-x64.exe
```
**macOS and Linux** — open Terminal, `cd` to your downloads, then:
```bash
chmod +x meshterm-0.2.3-*
./meshterm-0.2.3-*
chmod +x meshterm-0.2.7-*
./meshterm-0.2.7-*
```
### A word about terminals, on Windows
MeshTerm is drawn with emoji icons, braille charts and powerline path chips. Whether you
see them is up to your *terminal*, not really your font: a modern terminal, asked for a
character its font doesn't have, quietly borrows it from another font on the machine.
That's why the app looks right in Windows Terminal, in VS Code's terminal, and on macOS
and Linux — usually with a font that contains almost none of it.
The classic Windows console — the black `cmd.exe` window you get from a double-click —
doesn't borrow. It draws what its one font holds and empty boxes for everything else, and
no font fixes the icons there: the only two fonts on a Windows machine with emoji in them
are proportional, and a console won't take a proportional font.
So when MeshTerm lands in that console it just **moves to Windows Terminal** — it says
so, and opens there. Nothing is installed and nothing is changed; it's one window instead
of another, and it's the whole app exactly as the screenshots show it. Windows Terminal is
already on every Windows 11 machine and is a free install on Windows 10.
Where there's no Windows Terminal to move to, MeshTerm offers the next best thing instead
— the charts and marks, without the icons. It ships
[Cascadia Mono PL](https://github.com/microsoft/cascadia-code), Microsoft's own console
font and one of the very few monospace faces that carries braille at all, and will install
it just for you: no administrator rights, nothing downloaded. If you already have Cascadia
it simply switches to it.
Preferences → Display → Console setup turns all of this off if you'd rather stay put.
Rename it to something you don't mind typing and put it somewhere on your `PATH`, and it
becomes just `meshterm` from anywhere.
@@ -86,7 +113,7 @@ becomes just `meshterm` from anywhere.
> project.
>
> - **macOS**: *"cannot be opened because the developer cannot be verified"*. Clear it with
> `xattr -d com.apple.quarantine meshterm-0.2.3-*` and run it again.
> `xattr -d com.apple.quarantine meshterm-0.2.7-*` and run it again.
> - **Windows**: *"Windows protected your PC"*. Click **More info → Run anyway**.
>
> Every release ships a `SHA256SUMS` file if you'd rather check the download first.
@@ -96,10 +123,10 @@ becomes just `meshterm` from anywhere.
> MeshTerm uses that same folder. Set `MESHTERM_HOME` to try one in isolation:
>
> ```bash
> MESHTERM_HOME=~/meshterm-test ./meshterm-0.2.3-* # macOS, Linux
> MESHTERM_HOME=~/meshterm-test ./meshterm-0.2.7-* # macOS, Linux
> ```
> ```powershell
> $env:MESHTERM_HOME = "$HOME\meshterm-test"; .\meshterm-0.2.3-windows-x64.exe
> $env:MESHTERM_HOME = "$HOME\meshterm-test"; .\meshterm-0.2.7-windows-x64.exe
> ```
>
> Same variable if you run two radios and want them kept apart.
@@ -386,6 +413,15 @@ tiles by [OpenFreeMap](https://openfreemap.org/). OpenStreetMap data is availabl
with a small built-in reader for the [Mapbox Vector Tile](https://github.com/mapbox/vector-tile-spec)
format.
### Bundled font
MeshTerm ships **Cascadia Mono PL**, © 2019–present Microsoft Corporation, redistributed
unmodified under the [SIL Open Font License 1.1](meshterm/assets/fonts/CascadiaMono-OFL.txt).
It's offered to Windows users whose console can't draw the charts — see
[A word about fonts](#a-word-about-fonts). Microsoft doesn't endorse MeshTerm; the font is
simply the right tool, being one of the very few monospace faces that carries the braille
block the timelines are drawn from.
### Open-source dependencies
MeshTerm is built with these libraries; each is used under its own license (see the
@@ -414,3 +450,9 @@ license (see [NOTICE](NOTICE)); a redistributed fork should go by its own name.
donate link built into the app supports this project and its original author —
forks that keep soliciting through it without redirecting it to themselves aren't
affiliated with this project.
The bundled font is **not** covered by that license. `meshterm/assets/fonts/CascadiaMonoPL.ttf`
is Microsoft's Cascadia Mono PL, redistributed unmodified under the SIL Open Font License
1.1, which travels with it as
[CascadiaMono-OFL.txt](meshterm/assets/fonts/CascadiaMono-OFL.txt) and stays its only
license.
+1 -1
View File
@@ -5,7 +5,7 @@ Supports USB, Bluetooth and TCP companion connection. For Windows, macOS and Lin
from datetime import datetime
__version__ = "0.2.3"
__version__ = "0.2.7"
__author__ = "Jean-Pierre Martineau"
#: The year MeshTerm was first published; the copyright span starts here.
@@ -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.
+221 -3
View File
@@ -17,6 +17,7 @@ import typer
from rich.console import Console
from .context import AppContext
from .core import win32dll
from .core.admin_store import AdminStore
from .core.config import Settings
from .core.connection import DeviceCommandError, is_connection_lost
@@ -27,9 +28,10 @@ from .core.preferences import PreferenceError, Preferences
from .core.selection import DeviceSelectionError
from .persistence.logging import configure_logging, get_logger, level_from_name, log_path
from .persistence.repository import Repository
from .platforms import Resolution, resolve, set_platform
from .platforms import Resolution, resolve, set_platform, without_emoji
from .tools import all_tools
from .tools.base import Tool, ToolResult
from .ui.termfont import emoji_support
from .ui.theme import make_console
@@ -137,7 +139,14 @@ def main_callback(
_platform_resolution = resolve(platform)
except ValueError as exc:
raise typer.BadParameter(str(exc), param_hint="--platform") from exc
set_platform(_platform_resolution.platform)
# A terminal that cannot draw emoji is asked about here rather than in resolve(),
# which answers "which flavour" from the flag, the env and the device tree and should
# stay that pure — this is the *terminal's* limit, and it applies whichever flavour
# those three chose. See meshterm.ui.termfont.emoji_support.
active = _platform_resolution.platform
if not emoji_support().supported:
active = without_emoji(active)
set_platform(active)
settings = Settings.load()
if db_path is not None:
@@ -179,6 +188,12 @@ def main_callback(
if ctx.invoked_subcommand is None:
from .ui.menu import run_menu
# Before prompt_toolkit takes the screen: this can change the console's own font,
# or hand the whole session to a better terminal, and the full-screen frame should
# be drawn once, in whatever it ends up in.
if _offer_a_console_that_can_draw_meshterm(console, prefs):
return
# 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
@@ -206,6 +221,195 @@ def main_callback(
raise
def _offer_a_console_that_can_draw_meshterm(console: Console, prefs: Preferences) -> bool:
"""On the classic Windows console, get the reader somewhere better than it.
Two remedies, and they are not asked the same way.
**Windows Terminal** first, and simply done. It is the only thing that gets the whole
app — emoji icons included, which no font can put on that console, the only Windows
fonts carrying any being proportional and a console taking none of them. Moving there
installs nothing and changes nothing, so there is one sensible answer to a question
that would be put to someone who has not seen the app yet and cannot judge it.
**A font** second, only where the move was not possible, and that one *is* asked —
it writes a file onto someone's machine, which is theirs to agree to.
Args:
console: The console to report and ask on.
prefs: The preference set, read for a previous refusal and written on a new one.
Returns:
``True`` when the session is being reopened elsewhere and this one should stop.
"""
from .ui.termfont import classic_console
if not classic_console() or prefs.get("console_setup") == "off":
return False
if _move_to_windows_terminal(console):
return True
_offer_a_font_this_console_can_draw(console, prefs)
return False
def _move_to_windows_terminal(console: Console) -> bool:
"""Reopen the session in Windows Terminal, where the app can draw all of itself.
Done rather than offered. The classic console cannot show a single icon whatever it is
given, so "reopen there?" is a question with one sensible answer, asked of someone who
has not seen the app yet and so cannot judge it — and asked at the worst moment, before
anything has been drawn. Moving costs nothing and changes nothing: no install, no
setting, one window instead of another.
It is announced rather than silent, and the announcement names the way to stop it,
because a program that opens a window you did not ask for should at least say so.
``console_setup`` set to ``off`` keeps the session here for good.
Args:
console: The console to report on.
Returns:
Whether Windows Terminal took over and this session should stand down.
"""
from .core import winterminal
if winterminal.available() is None:
return False
console.print()
console.print("[accent]•[/accent] Opening in [accent]Windows Terminal[/accent] — this console")
console.print(" can't draw MeshTerm's icons or charts.")
console.print("[muted] Preferences → Display → Console setup keeps it here instead.[/muted]")
console.print()
if winterminal.reopen():
get_logger().info("reopened the session in Windows Terminal")
return True
console.print("[warn]⚠[/warn] Windows Terminal would not start. Staying here.")
get_logger().warning("could not start Windows Terminal")
return False
def _offer_a_font_this_console_can_draw(console: Console, prefs: Preferences) -> None:
"""Offer a font that can at least draw the charts and marks, for a reader who stays.
The 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.
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_setup`` 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 face_draws_charts, installed_chart_font
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_setup", "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 setup 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 setup.[/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.use(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, *, default: bool = False) -> bool:
"""Ask a yes/no question on the console.
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 — takes
the default, and each caller's default is the answer that costs the reader least.
Args:
console: The console to ask on.
prompt: The question, ending in its own spacing.
default: The answer an empty line means, and the one an unreadable stdin takes.
Returns:
Whether the reader said yes.
"""
console.print(prompt, end="")
try:
answer = input().strip().lower()
except (EOFError, KeyboardInterrupt, OSError):
console.print()
return default
if not answer:
return default
return answer in {"y", "yes"}
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()
@@ -243,6 +447,10 @@ def platform_command() -> None:
flavour a given invocation would run as, and why — the ``--platform``/
``MESHTERM_PLATFORM`` inputs, what ``/proc/device-tree/model`` reports (``None`` off
the actual hardware), and which of those decided it.
It also reports the terminal's own emoji verdict, which is a separate question from
the flavour and the usual reason a Windows session looks plainer than the screenshots
(see :func:`meshterm.ui.termfont.emoji_support`).
"""
assert _platform_resolution is not None # set by the callback that always runs first
r = _platform_resolution
@@ -252,6 +460,16 @@ def platform_command() -> None:
console.print(f" MESHTERM_PLATFORM: {r.env or '(not set)'}")
console.print(f" /proc/device-tree/model: {r.detected_model or '(unavailable)'}")
emoji = emoji_support()
verdict = "yes" if emoji.supported else "no — icons use the compact glyphs"
console.print(f"icons: [accent]{verdict}[/accent] (source: {emoji.source})")
if not emoji.supported and emoji.source == "console":
console.print(
" this is the classic Windows console, which draws no emoji whatever font\n"
" it is set to. Windows Terminal and VS Code's terminal both do — running\n"
" MeshTerm in one gets the icons back. MESHTERM_EMOJI=1 overrides this."
)
@app.command(name="specimen")
def specimen_command() -> None:
@@ -375,7 +593,7 @@ def _owns_its_console() -> bool:
import ctypes
buffer = (ctypes.c_uint * 4)()
count = ctypes.windll.kernel32.GetConsoleProcessList(buffer, 4) # type: ignore[attr-defined]
count = win32dll.kernel32().GetConsoleProcessList(buffer, 4)
except Exception: # pragma: no cover - no console at all, or a stubbed kernel32
return False
return count == 1
+254
View File
@@ -0,0 +1,254 @@
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)
if not _add_font_resource(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 _add_font_resource(path: Path) -> bool:
"""Make a font file usable by this process, without installing anything.
``AddFontResourceW`` adds to the font table for the running process; the ``HKCU``
registration beside it is what makes the font permanent, and Windows reads that at the
*next logon*. Between those two facts sits the case this exists for: a second MeshTerm
launch in the same session finds its own registration and believes the font is ready,
when nothing has loaded it into this new process yet.
Args:
path: The font file, wherever it is.
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``, loading our own copy first if that is what it is.
The plain :func:`select` is enough for a font the system installed — the Cascadia that
comes with Windows 11 or with Windows Terminal. It is *not* enough for the copy
MeshTerm installed itself in an earlier run of the same session: that one is registered
but not yet loaded (see :func:`_add_font_resource`), and selecting it silently keeps
the old font. So a refusal is retried once, after loading it.
Args:
face: The family to draw with.
Returns:
Whether the console is now drawing 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 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
+28 -6
View File
@@ -125,13 +125,21 @@ _WIDTH_CHOICES: dict[str, str] = {"auto": "auto", "yes": "yes", "no": "no"}
#: Every preference MeshTerm has, in page order. Adding one here gives it a row on the
#: Preferences page, a key in the YAML file, a ``preferences get``/``set`` CLI face, and a
#: default — nothing else has to follow.
#: What the log file keeps. Named rather than numeric, because those names are what every
#: other tool calls these and the reader is often being talked through it by someone else.
#: 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 offer when it lands in a console that cannot draw it. Only ever
#: consulted on the classic Windows console, the one host with no font fallback of its own.
_CONSOLE_SETUP_CHOICES: dict[str, str] = {
"auto": "Automatic",
"off": "Leave it alone",
}
_LOG_LEVEL_CHOICES: dict[str, str] = {
"ERROR": "Errors only",
"WARNING": "Warnings and errors",
"INFO": "Also what the app is doing",
"DEBUG": "Everything, for chasing a bug",
"ERROR": "Error",
"WARNING": "Warning",
"INFO": "Info",
"DEBUG": "Debug",
}
@@ -294,6 +302,20 @@ PREFERENCES: tuple[PrefSpec, ...] = (
default="auto",
choices=_WIDTH_CHOICES,
),
PrefSpec(
key="console_setup",
label="Console setup",
help="Move to a better terminal when this console can't draw MeshTerm",
group="Display",
value_type="enum",
# Reopening in Windows Terminal is done, not asked: the classic console cannot
# draw a single icon whatever font it is given, so there is only one sensible
# answer and the reader has not seen the app yet to judge it. Installing a *font*
# still asks, because that writes to their machine. Declining that twice writes
# "off" here, which turns both off for good.
default="auto",
choices=_CONSOLE_SETUP_CHOICES,
),
# --- Diagnostics ---------------------------------------------------------------
PrefSpec(
key="log_level",
+64
View File
@@ -0,0 +1,64 @@
"""Private handles on the Windows DLLs, because ``ctypes.windll`` is shared with everyone.
``ctypes.windll.kernel32`` is a cache. Every caller in the process gets the *same*
``WinDLL`` object, and each function on it is a cached attribute — so
``ctypes.windll.kernel32.GetConsoleScreenBufferInfo`` is one object, shared by this
application and every library inside it. Setting ``.argtypes`` on it is not configuration,
it is a process-wide mutation, and the last writer wins.
That is not theoretical. It crashed MeshTerm on Windows:
* ``ui/tui/emoji_width.py`` measured an emoji by asking the console for its cursor
position, and declared ``GetConsoleScreenBufferInfo`` as taking a pointer to *its* copy
of the screen-buffer struct.
* prompt_toolkit later called the very same function object with a pointer to *its* copy
of the same struct — identical layout, different class.
* ctypes compared the two classes, found them unequal, and refused:
``expected LP__CSBI instance instead of pointer to CONSOLE_SCREEN_BUFFER_INFO``.
The application died while building its screen, which on a double-clicked build meant the
traceback and the window it was drawn in disappeared together.
Constructing ``WinDLL`` directly builds a *new* object with its own function cache, so
declarations made on it are visible only to the caller that made them. Each call here
returns a fresh one: two parts of MeshTerm declaring the same function differently is the
same bug in miniature, and the cost is one small object at a call site that runs rarely.
The rule this exists to enforce: **nothing in MeshTerm touches ``ctypes.windll``.**
"""
from __future__ import annotations
import ctypes
import sys
__all__ = ["kernel32", "user32"]
def _library(name: str) -> ctypes.WinDLL:
"""Load ``name`` into a WinDLL of our own.
Args:
name: The DLL to load, without its extension.
Returns:
A private handle whose function signatures nobody else can see or change.
Raises:
RuntimeError: If called anywhere but Windows, where these do not exist.
"""
if sys.platform != "win32": # pragma: no cover - the callers all guard on platform
raise RuntimeError(f"{name} is a Windows library")
# `use_last_error` keeps GetLastError intact for the caller, which is the only way to
# tell "the call failed" from "the call succeeded and returned zero".
return ctypes.WinDLL(name, use_last_error=True)
def kernel32() -> ctypes.WinDLL:
"""A private handle on ``kernel32`` — console, handles, memory."""
return _library("kernel32")
def user32() -> ctypes.WinDLL:
"""A private handle on ``user32`` — clipboard, keyboard state, windows."""
return _library("user32")
+128
View File
@@ -0,0 +1,128 @@
"""Reopening the session in Windows Terminal, which is where MeshTerm looks like itself.
MeshTerm is drawn with emoji icons, braille charts and powerline path chips. Whether any
of that reaches the screen is decided by the *terminal*, not by MeshTerm and not really by
the font either: a modern terminal, asked for a character its font lacks, quietly borrows
the glyph from another font on the machine. That is why the app looks right in Windows
Terminal, in VS Code's terminal, and on macOS and Linux — and why it looks right there
even though the font the reader chose usually holds almost none of it. Measured on a
development machine, the everyday choices — Hack Nerd Font, JetBrains Mono, Fira Code,
Source Code Pro — carry no braille at all, and no monospace font anywhere carries emoji.
The classic Windows console does not do that. It draws what its one font holds and boxes
for the rest, so on that host the emoji cannot be shown *at all* — the only two fonts on a
Windows machine with emoji glyphs, Segoe UI Emoji and Segoe UI Symbol, are proportional,
and a console will not take a proportional font. No font choice fixes it, which leaves
exactly one thing that does: a different terminal.
So when MeshTerm finds itself in that console and Windows Terminal is installed — it is on
every Windows 11 machine, and a free install on 10 — it offers to reopen itself there.
One keypress, and the app looks the way it was drawn instead of the way conhost can
manage. Declining falls back to :mod:`meshterm.core.consolefont`, which makes the best of
the console the reader chose to stay in.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import sys
#: Set in the reopened session, so a relaunch can never chain into another one. Windows
#: Terminal is not a classic console and so would not qualify anyway — this is the belt to
#: that braces, and it costs one environment variable.
REOPENED_ENV = "MESHTERM_REOPENED"
def available() -> str | None:
"""The path to ``wt.exe``, or ``None`` when Windows Terminal is not installed.
Found on ``PATH``, where Windows puts an execution alias for it under
``%LOCALAPPDATA%/Microsoft/WindowsApps``.
"""
if sys.platform != "win32" or os.environ.get(REOPENED_ENV):
return None
return shutil.which("wt.exe")
def own_command() -> list[str]:
"""The command that would start this same session again.
A frozen build is its own executable and takes the arguments as they were given. A
development or ``pip`` install is reached through its interpreter, because the console
script's own path is not something ``wt`` can be relied on to resolve.
Returns:
The program and its arguments, ready to hand to Windows Terminal.
"""
if getattr(sys, "frozen", False):
return [sys.executable, *sys.argv[1:]]
return [sys.executable, "-m", "meshterm", *sys.argv[1:]]
def child_environment() -> dict[str, str]:
"""The environment for a fresh launch of MeshTerm, minus this bundle's bookkeeping.
A PyInstaller one-file build runs in two stages: the executable unpacks itself into a
temporary directory and then runs itself again as a child, the two halves coordinating
through ``_PYI*`` environment variables. Those must not reach a *new* launch of the
executable. The bootloader would find them, conclude it is the second stage of a launch
it never made, check that its parent is the same program — find Windows Terminal
instead — and abort::
[PYI-33368:ERROR] Security validation failure: parent process has different
executable!
Which is the whole session gone, in a brand new window holding a message nobody can act
on, while the window that offered the move has already closed. Removing them makes the
new process a first stage, which is what it actually is.
Returns:
The environment to start the new process with.
"""
environment = dict(os.environ)
environment[REOPENED_ENV] = "1"
if not getattr(sys, "frozen", False):
return environment
for key in [key for key in environment if key.startswith("_PYI")]:
del environment[key]
environment.pop("_MEIPASS2", None) # the name older bootloaders used
# The bootloader points the loader at the unpacked bundle and parks the caller's own
# value beside it. The new process unpacks its own copy, so it wants the original.
for name in ("LD_LIBRARY_PATH", "DYLD_LIBRARY_PATH", "DYLD_FRAMEWORK_PATH"):
original = environment.pop(f"{name}_ORIG", None)
if original is not None:
environment[name] = original
else:
environment.pop(name, None)
return environment
def reopen() -> bool:
"""Start this session again in Windows Terminal, in the same directory.
The new window is not a child in any meaningful sense — the caller exits immediately
afterwards and the two never talk — so nothing is waited on and no pipes are held.
Returns:
Whether Windows Terminal was started. ``False`` leaves the caller exactly where it
was, which is why the offer is only ever a detour and never a dead end.
"""
terminal = available()
if terminal is None:
return False
try:
# `--` ends wt's own option parsing, so MeshTerm's flags reach MeshTerm rather
# than being read as Windows Terminal's (verified: `--mock` and `--profile x`
# arrive intact). `-d` keeps the working directory, which relative paths given
# on the command line depend on.
subprocess.Popen( # noqa: S603 - the argv is ours, not the reader's
[terminal, "-d", os.getcwd(), "--", *own_command()],
env=child_environment(),
close_fds=True,
)
return True
except OSError:
return False
+21 -1
View File
@@ -19,7 +19,7 @@ from __future__ import annotations
import os
from collections.abc import Callable
from dataclasses import dataclass
from dataclasses import dataclass, replace
from pathlib import Path
@@ -342,10 +342,30 @@ def resolve(flag: str | None = None) -> Resolution:
return Resolution(flag, env, model, "default", REGULAR)
def without_emoji(platform: Platform) -> Platform:
"""The same platform with its icons routed through the compact glyph table.
A terminal that cannot draw emoji is not a different *flavour* — the classic Windows
console has the desktop's width, colour depth and keyboard, and wants every other
thing :data:`REGULAR` says. Only the one flag moves, which is what the seam's flags
being independent is for: ``ascii_fold`` stays off, so accents and the rest of the
BMP still come through.
Args:
platform: The resolved platform.
Returns:
``platform`` itself when its icons are already compact, else a copy with
``emoji`` cleared.
"""
return platform if not platform.emoji else replace(platform, emoji=False)
__all__ = [
"Platform",
"REGULAR",
"PICOCALC",
"without_emoji",
"PLATFORM",
"get_platform",
"set_platform",
+261 -2
View File
@@ -1,4 +1,25 @@
"""Terminal font detection — can this terminal draw powerline path separators?
"""Terminal capability detection — what can this terminal actually draw?
Two questions, both answered out-of-band because a terminal cannot be asked in-band: a
glyph it does not have still occupies its cell, so the screen looks the same whether the
character arrived or not.
**Powerline separators**, below, are a *font* question — read the terminal's configured
face and match it against fonts known to carry the block.
**Emoji** (:func:`emoji_support`) are a *host* question, and only on Windows. The classic
console — ``conhost``, which is what a double-clicked program still gets — rasterises
through GDI with the console font and nothing behind it, so an emoji lands as a
replacement box however the font is configured. Everything that replaced it draws them
fine. So the verdict identifies the host, structurally rather than from the environment
(``WT_SESSION`` is inherited across process launches, so a program started from Windows
Terminal into a console of its own still claims to be in one), and a ``False`` routes
every icon through the compact single-glyph table :func:`meshterm.ui.theme.glyph` already
keeps for the PicoCalc — that whole vocabulary is BMP, so it draws wherever the plain
status marks already do.
Both verdicts are hints that pick a default, and both have an override for the reader who
knows better than the probe: ``MESHTERM_POWERLINE`` and ``MESHTERM_EMOJI``.
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
@@ -57,6 +78,8 @@ from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from ..core import win32dll
#: Coverage levels a verdict (or a recommended font) can carry, strongest first.
FULL = "full"
CORE = "core"
@@ -460,7 +483,7 @@ def _conhost_face() -> str | None:
import ctypes
import ctypes.wintypes as wintypes
kernel32 = ctypes.windll.kernel32
kernel32 = win32dll.kernel32()
if not kernel32.GetConsoleWindow():
return None
@@ -675,3 +698,239 @@ def installed_recommended() -> RecommendedFont | None:
return matched
best = best or matched
return best
# --- emoji support ----------------------------------------------------------------
@dataclass(frozen=True)
class EmojiSupport:
"""Whether this terminal can show an emoji at all, and what decided that.
Attributes:
supported: ``True`` when an emoji icon reaches the screen as itself.
source: ``"env"`` (the ``MESHTERM_EMOJI`` override), ``"platform"`` (not Windows,
so not the classic console's problem), ``"console"`` (the host was identified
— see :func:`_is_classic_console`), or ``"no-console"`` (nothing to identify:
output is redirected, or the call failed).
"""
supported: bool
source: str
def _is_classic_console() -> bool | None:
"""Whether this process is drawing into a genuine ``conhost`` window.
Which is the whole question, because that host renders through GDI with the console
font and no emoji font behind it, while everything that replaced it — Windows
Terminal, VS Code's terminal, anything over ssh — draws emoji as a matter of course.
It cannot be asked in band. A missing glyph still occupies its cell, so the screen
reads back exactly as it would have if the character had drawn: measured on Windows 10
22H2, a classic console stores ``📡`` intact and advances two cells while showing a
single replacement box, and a ConPTY (which does draw it) reads back as U+FFFD because
the buffer behind it is updated asynchronously. A write-and-read-back probe answers
both hosts *backwards*, which is the trap this docstring exists to record.
Nor can the environment be trusted alone: ``WT_SESSION`` is inherited by child
processes, so a program launched from Windows Terminal into a console of its own still
carries it and would answer for the wrong host.
So the host is identified structurally, from two independent facts, and both must
agree before anything is dimmed (the costly mistake is stripping icons from a terminal
that could draw them):
* **The console font has a real pixel width.** A ConPTY's hidden conhost reports a
stub — face ``Consolas``, cell width ``0`` — because nothing is rasterised there.
* **The buffer is taller than its window.** A classic console owns its scrollback
(9001 rows by default); under a ConPTY the terminal owns it, so the buffer is
exactly the window.
Returns:
``True`` for a genuine classic console, ``False`` for anything else drawing to a
console, and ``None`` when there is no console on stdout (redirected output, not
Windows, or the call failed) — an unknown is never a reason to degrade.
"""
if sys.platform != "win32":
return None
try:
import ctypes
from ctypes import wintypes
class _COORD(ctypes.Structure):
_fields_ = [("X", wintypes.SHORT), ("Y", wintypes.SHORT)]
class _SMALL_RECT(ctypes.Structure):
_fields_ = [
("Left", wintypes.SHORT),
("Top", wintypes.SHORT),
("Right", wintypes.SHORT),
("Bottom", wintypes.SHORT),
]
class _CSBI(ctypes.Structure):
_fields_ = [
("dwSize", _COORD),
("dwCursorPosition", _COORD),
("wAttributes", wintypes.WORD),
("srWindow", _SMALL_RECT),
("dwMaximumWindowSize", _COORD),
]
class _CONSOLE_FONT_INFOEX(ctypes.Structure):
_fields_ = [
("cbSize", wintypes.ULONG),
("nFont", wintypes.DWORD),
("dwFontSize", _COORD),
("FontFamily", wintypes.UINT),
("FontWeight", wintypes.UINT),
("FaceName", ctypes.c_wchar * 32),
]
# Safe to declare: this handle is ours alone (see meshterm.core.win32dll). The
# explicit restype matters — ctypes would assume a 32-bit int and truncate the
# 64-bit HANDLE, turning every later call into a silent failure.
kernel32 = win32dll.kernel32()
kernel32.GetStdHandle.argtypes = [wintypes.DWORD]
kernel32.GetStdHandle.restype = wintypes.HANDLE
kernel32.GetConsoleScreenBufferInfo.argtypes = [wintypes.HANDLE, ctypes.POINTER(_CSBI)]
kernel32.GetConsoleScreenBufferInfo.restype = wintypes.BOOL
kernel32.GetCurrentConsoleFontEx.argtypes = [
wintypes.HANDLE,
wintypes.BOOL,
ctypes.POINTER(_CONSOLE_FONT_INFOEX),
]
kernel32.GetCurrentConsoleFontEx.restype = wintypes.BOOL
handle = kernel32.GetStdHandle(wintypes.DWORD(-11).value) # STD_OUTPUT_HANDLE
info = _CSBI()
# Fails when stdout is a pipe or a file — nothing is being drawn, so there is
# nothing to answer, and this is what keeps redirected and CI runs out of it.
if not kernel32.GetConsoleScreenBufferInfo(handle, ctypes.byref(info)):
return None
font = _CONSOLE_FONT_INFOEX()
font.cbSize = ctypes.sizeof(_CONSOLE_FONT_INFOEX)
if not kernel32.GetCurrentConsoleFontEx(handle, False, ctypes.byref(font)):
return None
rasterised = font.dwFontSize.X > 0
owns_scrollback = info.dwSize.Y > (info.srWindow.Bottom - info.srWindow.Top + 1)
return rasterised and owns_scrollback
except Exception: # pragma: no cover - a probe that fails is an unknown, not a crash
return None
def _emoji_support(
environ: Mapping[str, str] | None = None,
*,
console_probe: Callable[[], bool | None] = _is_classic_console,
system: str | None = None,
) -> EmojiSupport:
"""The uncached verdict (see :func:`emoji_support` for the ladder)."""
env = os.environ if environ is None else environ
override = (env.get("MESHTERM_EMOJI") or "").strip().lower()
if override in {"0", "off", "no", "none", "false"}:
return EmojiSupport(False, "env")
if override in {"1", "on", "yes", "true"}:
return EmojiSupport(True, "env")
if (sys.platform if system is None else system) != "win32":
return EmojiSupport(True, "platform")
classic = console_probe()
if classic is None:
return EmojiSupport(True, "no-console")
return EmojiSupport(not classic, "console")
@lru_cache(maxsize=1)
def classic_console() -> bool:
"""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.
The ladder: the ``MESHTERM_EMOJI`` override, then everything that isn't Windows (where
a UTF-8 terminal draws them as a matter of course), then :func:`_is_classic_console`.
Cached because a session does not change host halfway through.
Returns:
The :class:`EmojiSupport` verdict.
"""
return _emoji_support(os.environ)
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
+6 -1
View File
@@ -89,6 +89,8 @@ from collections.abc import Callable
from prompt_toolkit.layout.controls import FormattedTextControl
from ...core import win32dll
#: The zero-width joiner: the tell that the codepoints it sits between are **one glyph**
#: (``🤷‍♂️``, ``👨‍👩‍👧``), which a terminal draws in the width of the sequence's base.
_ZWJ = "‍"
@@ -309,7 +311,10 @@ def _win_probe_width(probe: str) -> int | None:
("dwMaximumWindowSize", _COORD),
]
kernel32 = ctypes.windll.kernel32
# A private handle: declaring these signatures on `ctypes.windll.kernel32` would
# rewrite them for prompt_toolkit too, which calls the same function object with a
# struct class of its own. See meshterm.core.win32dll.
kernel32 = win32dll.kernel32()
# Declare signatures explicitly. Without this ctypes assumes a 32-bit int
# return, which truncates the 64-bit console HANDLE and makes every
# subsequent call fail — silently reducing the probe to "no measurement".
+19 -11
View File
@@ -29,6 +29,7 @@ from prompt_toolkit.utils import get_cwidth
from rich.console import RenderableType
from rich.text import Text
from ...core import win32dll
from ...platforms import get_platform
from ...services import modifier_watch
from . import fastrender, fkeys, frame
@@ -168,9 +169,7 @@ def _right_ctrl_down() -> bool:
if sys.platform != "win32":
return False
try:
import ctypes
return bool(ctypes.windll.user32.GetAsyncKeyState(0xA3) & 0x8000) # VK_RCONTROL
return bool(win32dll.user32().GetAsyncKeyState(0xA3) & 0x8000) # VK_RCONTROL
except Exception: # noqa: BLE001 - a failed probe just leaves the plain action
return False
@@ -191,8 +190,10 @@ def _read_clipboard() -> str:
import ctypes
CF_UNICODETEXT = 13
user32 = ctypes.windll.user32
kernel32 = ctypes.windll.kernel32
# Private handles; the argtypes below would otherwise be declared on objects
# every library in the process shares. See meshterm.core.win32dll.
user32 = win32dll.user32()
kernel32 = win32dll.kernel32()
user32.GetClipboardData.restype = ctypes.c_void_p
user32.GetClipboardData.argtypes = [ctypes.c_uint]
kernel32.GlobalLock.restype = ctypes.c_void_p
@@ -282,13 +283,20 @@ def _has_wide_glyph(text: str) -> bool:
``>= 0x1100`` guard skips the ASCII/Latin bulk of a frame before the width lookup, which
matters because this runs over the whole composed frame on every repaint.
On a platform that draws no emoji the answer is ``False`` by construction — its console
font is a fixed 512-glyph set with no wide glyph in it, so nothing a frame can contain
would return ``True``. Answering from the platform instead of the text skips a
per-character scan of the entire frame on every repaint, and with it the scrub machinery
that a ``True`` would arm, leaving the cheap differential paint permanently in play.
On a platform that folds its frames to a fixed font the answer is ``False`` by
construction — that font is a 512-glyph set with no wide glyph in it, so nothing a
composed frame can contain would return ``True``. Answering from the platform instead
of the text skips a per-character scan of the entire frame on every repaint, and with
it the scrub machinery that a ``True`` would arm, leaving the cheap differential paint
permanently in play.
The fold is the guarantee, not the icon table: a terminal that merely can't draw
*emoji* (the classic Windows console — see :func:`meshterm.ui.termfont.emoji_support`)
still renders the rest of the BMP, and a contact named in CJK is two cells wide there
like anywhere else. Reading ``emoji`` here instead would have skipped the scan on a
frame that genuinely needed it, and smeared the row.
"""
if not get_platform().emoji:
if get_platform().ascii_fold:
return False
return any(ord(ch) >= 0x1100 and get_cwidth(ch) == 2 for ch in text)
+213
View File
@@ -0,0 +1,213 @@
"""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
assert consolefont.use("Cascadia Mono PL") is False
def test_a_font_resource_is_only_added_from_a_file_that_exists(tmp_path: Path) -> None:
"""The retry path takes a path that may well not be there, and must not raise on it."""
assert consolefont._add_font_resource(tmp_path / "nothing.ttf") is False
+58 -1
View File
@@ -6,12 +6,20 @@ sanity — PicoCalc being strictly the narrower, plainer flavour.
from __future__ import annotations
import dataclasses
from pathlib import Path
import pytest
import meshterm.platforms as platforms_mod
from meshterm.platforms import PICOCALC, REGULAR, get_platform, resolve, set_platform
from meshterm.platforms import (
PICOCALC,
REGULAR,
get_platform,
resolve,
set_platform,
without_emoji,
)
def test_regular_is_todays_behaviour() -> None:
@@ -142,3 +150,52 @@ def test_resolve_auto_detect_is_conservative(monkeypatch: pytest.MonkeyPatch) ->
r = resolve()
assert r.platform is REGULAR
assert r.detected_model == "Raspberry Pi 4 Model B"
# -- the emoji-less variant ------------------------------------------------------------
def test_without_emoji_moves_one_flag_and_nothing_else() -> None:
"""A terminal that can't draw emoji is not a different flavour.
The classic Windows console has the desktop's width, colour depth, borders and
keyboard — only the icons have to change. Every other field must survive, and
``ascii_fold`` above all: folding there would strip accents from names the console
renders perfectly well.
"""
plain = without_emoji(REGULAR)
assert plain.emoji is False
assert plain.ascii_fold is False
assert plain.truecolor is True
assert plain.readable_cols == REGULAR.readable_cols
assert plain.footer_fkeys is REGULAR.footer_fkeys
changed = {
f.name
for f in dataclasses.fields(REGULAR)
if getattr(plain, f.name) != getattr(REGULAR, f.name)
}
assert changed == {"emoji"}
def test_without_emoji_leaves_an_already_compact_platform_alone() -> None:
"""A platform whose icons are already compact comes back untouched.
The same object, not a copy that would compare equal but break every ``is`` check
the suite makes against PICOCALC.
"""
assert without_emoji(PICOCALC) is PICOCALC
def test_the_emoji_less_variant_still_drives_the_theme() -> None:
"""The derived platform is a real one: binding it routes icons through the table.
This is the whole point — the seam's flags are independent, so clearing one reaches
the glyph funnel without dragging the console font's fold along with it.
"""
from meshterm.ui.theme import fold_text, glyph
set_platform(without_emoji(REGULAR))
assert glyph("📡") == "☼" # the advert icon, compacted
assert fold_text("café Montréal") == "café Montréal" # no fold
assert get_platform().readable_cols == 72
+86 -3
View File
@@ -1,8 +1,9 @@
"""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.
Every verdict runs against injected environments, temp settings files and injected
probes, so the suite decides the same answers on any box. Two tests deliberately do
touch the real machine — the installed-font scan and the console buffer probe — and both
assert only that asking is safe, never what this particular box replies.
"""
from __future__ import annotations
@@ -17,6 +18,8 @@ from meshterm.ui.termfont import (
NONE,
UNKNOWN,
RecommendedFont,
_emoji_support,
_is_classic_console,
_powerline_support,
_read_jsonc,
_vscode_default_face,
@@ -252,3 +255,83 @@ def test_installed_recommended_never_raises() -> None:
"""
result = installed_recommended()
assert result is None or isinstance(result, RecommendedFont)
# --- emoji support ----------------------------------------------------------------
def test_emoji_is_only_the_windows_consoles_problem() -> None:
"""Everywhere but Windows, a terminal draws emoji and the question does not arise.
The probe is never even run there — a Linux or macOS session pays nothing for a limit
only the classic console has.
"""
def never_called() -> bool | None:
raise AssertionError("the console probe must not run off Windows")
for system in ("linux", "darwin", "freebsd"):
verdict = _emoji_support({}, console_probe=never_called, system=system)
assert (verdict.supported, verdict.source) == (True, "platform")
def test_a_classic_console_turns_the_icons_compact() -> None:
"""The verdict that matters: conhost draws no emoji, so the icons go compact.
This is the double-clicked build on Windows — the case the whole check exists for.
"""
verdict = _emoji_support({}, console_probe=lambda: True, system="win32")
assert (verdict.supported, verdict.source) == (False, "console")
def test_every_other_windows_terminal_keeps_its_emoji() -> None:
"""Windows Terminal, VS Code's terminal and ssh all draw them; nothing changes."""
verdict = _emoji_support({}, console_probe=lambda: False, system="win32")
assert (verdict.supported, verdict.source) == (True, "console")
def test_an_unanswerable_probe_never_degrades_the_ui() -> None:
"""No console to identify (redirected output, a failed call) leaves emoji on.
An unknown is not evidence of a limit. Degrading on one would strip the icons from
every piped or redirected run, which is most of the test suite and all of CI.
"""
verdict = _emoji_support({}, console_probe=lambda: None, system="win32")
assert (verdict.supported, verdict.source) == (True, "no-console")
def test_the_override_beats_the_probe_both_ways() -> None:
"""``MESHTERM_EMOJI`` wins, as the powerline gate's override does.
Someone on a console this cannot recognise knows their own glass better than a probe
does — and the negative direction is how anyone asks for the compact icons on purpose.
"""
forced_on = _emoji_support({"MESHTERM_EMOJI": "1"}, console_probe=lambda: True, system="win32")
assert (forced_on.supported, forced_on.source) == (True, "env")
def never_called() -> bool | None:
raise AssertionError("the override decides before the probe runs")
forced_off = _emoji_support({"MESHTERM_EMOJI": "0"}, console_probe=never_called, system="linux")
assert (forced_off.supported, forced_off.source) == (False, "env")
def test_an_inherited_wt_session_cannot_speak_for_the_host() -> None:
"""The environment is not consulted, and this is why.
``WT_SESSION`` is inherited by child processes, so a program launched from Windows
Terminal into a console of its own still carries it — measured, not supposed. Reading
it would have answered for the terminal that did the launching.
"""
env = {"WT_SESSION": "670f6626-8bdf-4c09-9164-b1eef91abe4f"}
verdict = _emoji_support(env, console_probe=lambda: True, system="win32")
assert verdict.supported is False
def test_identifying_the_host_never_raises() -> None:
"""Whatever this machine is, asking is safe and answers one of three things.
It runs on a startup path, so "it degrades rather than raises" is the property that
keeps an unusual host from taking the app down with it.
"""
assert _is_classic_console() in (True, False, None)
+18
View File
@@ -150,6 +150,24 @@ def test_every_glyph_map_target_is_in_the_console_font() -> None:
)
def test_every_glyph_map_target_stays_inside_the_bmp() -> None:
"""The table has a second consumer that the console font cannot speak for.
The classic Windows console keeps one 16-bit code unit per cell, so a character above
U+FFFF is replaced by U+FFFD before its font is ever consulted — which is why MeshTerm
routes icons through this table there too (see
:func:`meshterm.ui.termfont.emoji_support`). A substitute "improved" into an emoji
would draw fine on the PicoCalc's own font and silently break every Windows console,
so the plane is asserted here rather than left to the font whitelist to imply.
"""
for emoji, compact in theme._GLYPH_MAP.items():
for ch in compact:
assert ord(ch) <= 0xFFFF, (
f"{emoji!r} maps to {compact!r}; {ch!r} is above U+FFFF and no classic "
f"Windows console can hold it"
)
def test_glyph_map_targets_never_widen_their_icon() -> None:
"""A compact form at most matches its emoji's measured width (the fold pads the rest)."""
for emoji, compact in theme._GLYPH_MAP.items():
+81
View File
@@ -0,0 +1,81 @@
"""Tests for the private Windows DLL handles.
These pin a crash that only ever appeared in a frozen build on Windows, which is the
worst place for a bug to live: no traceback survived, because the window it was printed
in was destroyed by the same exit that printed it.
``ctypes.windll.kernel32`` is a cache shared by the whole process. MeshTerm's emoji-width
probe declared ``GetConsoleScreenBufferInfo`` as taking a pointer to its own copy of the
screen-buffer struct; prompt_toolkit then called that same function object with a pointer
to *its* copy — same layout, different class — and ctypes refused the call while the app
was building its screen.
"""
from __future__ import annotations
import ctypes
import sys
import pytest
from meshterm.core import win32dll
pytestmark = pytest.mark.skipif(sys.platform != "win32", reason="Windows libraries")
def test_each_handle_is_its_own() -> None:
"""Two callers get two objects, so neither can see the other's declarations."""
assert win32dll.kernel32() is not win32dll.kernel32()
assert win32dll.user32() is not win32dll.user32()
def test_the_shared_windll_is_the_thing_being_avoided() -> None:
"""The premise: ``ctypes.windll`` hands the same function object to every caller.
If this ever stops being true the module is unnecessary — but until then it is the
reason it exists, so it is worth asserting rather than assuming.
"""
first = ctypes.windll.kernel32.GetConsoleScreenBufferInfo
second = ctypes.windll.kernel32.GetConsoleScreenBufferInfo
assert first is second
def test_declaring_a_signature_does_not_reach_the_shared_one() -> None:
"""The regression itself: our declarations must not be visible to anyone else.
Before this module existed, the assignment below rewrote the signature that
prompt_toolkit's console output later called through, and the application died on
startup with ``expected LP__CSBI instance instead of pointer to
CONSOLE_SCREEN_BUFFER_INFO``.
"""
class _MineAlone(ctypes.Structure):
_fields_ = [("whatever", ctypes.c_int)]
shared = ctypes.windll.kernel32.GetConsoleScreenBufferInfo
before = shared.argtypes
private = win32dll.kernel32()
private.GetConsoleScreenBufferInfo.argtypes = [
ctypes.c_void_p,
ctypes.POINTER(_MineAlone),
]
assert ctypes.windll.kernel32.GetConsoleScreenBufferInfo.argtypes == before
assert private.GetConsoleScreenBufferInfo is not shared
def test_the_probe_leaves_the_shared_signature_alone() -> None:
"""End to end: running the emoji probe must not disturb the process-wide function.
The narrow assertion above can pass while the real call site still reaches for
``ctypes.windll``, so this exercises the code that actually crashed.
"""
from meshterm.ui.tui import emoji_width
shared = ctypes.windll.kernel32.GetConsoleScreenBufferInfo
before = shared.argtypes
# No console under pytest, so this returns None rather than measuring. That is fine:
# the declarations happen before the first call either way, which is what matters.
emoji_width._win_probe_width("x") # noqa: SLF001 - the point is this private path
assert ctypes.windll.kernel32.GetConsoleScreenBufferInfo.argtypes == before
+133
View File
@@ -0,0 +1,133 @@
"""Tests for reopening the session in Windows Terminal.
Nothing here starts a terminal. What is pinned is the command that *would* be run — which
is the part that can be silently wrong, because a mistake in it produces a window that
opens, fails, and closes faster than anyone can read it.
"""
from __future__ import annotations
import sys
import pytest
from meshterm.core import winterminal
def test_it_is_only_ever_windows(monkeypatch: pytest.MonkeyPatch) -> None:
"""Every other platform's terminal already draws the whole app; there is nothing to fix."""
monkeypatch.setattr(sys, "platform", "linux")
assert winterminal.available() is None
assert winterminal.reopen() is False
def test_a_reopened_session_never_offers_to_reopen_again(monkeypatch: pytest.MonkeyPatch) -> None:
"""The loop guard. Windows Terminal would not qualify anyway, and this is the backstop.
A relaunch that could chain is not a cosmetic bug: each one spawns a window and exits,
so the reader would watch windows open forever.
"""
monkeypatch.setattr(sys, "platform", "win32")
monkeypatch.setenv(winterminal.REOPENED_ENV, "1")
assert winterminal.available() is None
assert winterminal.reopen() is False
def test_a_frozen_build_relaunches_itself(monkeypatch: pytest.MonkeyPatch) -> None:
"""The installer's own executable takes the arguments exactly as they were given."""
monkeypatch.setattr(sys, "frozen", True, raising=False)
monkeypatch.setattr(sys, "executable", r"C:\Downloads\meshterm.exe")
monkeypatch.setattr(sys, "argv", ["meshterm", "--mock", "--profile", "home"])
assert winterminal.own_command() == [
r"C:\Downloads\meshterm.exe",
"--mock",
"--profile",
"home",
]
def test_a_source_install_relaunches_through_its_interpreter(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``-m meshterm``, not the console script.
The script's path is a wrapper whose location varies by install method (venv, pipx,
user site), while the interpreter running us is a fact we already hold. Using the
interpreter also guarantees the new window gets the same environment as this one.
"""
monkeypatch.delattr(sys, "frozen", raising=False)
monkeypatch.setattr(sys, "executable", r"C:\venv\Scripts\python.exe")
monkeypatch.setattr(sys, "argv", ["meshterm", "--mock"])
assert winterminal.own_command() == [r"C:\venv\Scripts\python.exe", "-m", "meshterm", "--mock"]
def test_the_program_name_is_never_passed_on_as_an_argument(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""``argv[0]`` is how this process was named, not something to hand to the next one.
Passing it through would reach MeshTerm as a stray positional argument and be rejected
by Typer — in a brand new window that then closes.
"""
monkeypatch.setattr(sys, "frozen", True, raising=False)
monkeypatch.setattr(sys, "executable", r"C:\meshterm.exe")
monkeypatch.setattr(sys, "argv", [r"C:\some\other\name.exe"])
assert winterminal.own_command() == [r"C:\meshterm.exe"]
def test_the_bundles_own_bookkeeping_never_reaches_the_new_process(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The bug the frozen build found, and the only one the source build could not.
A PyInstaller one-file executable unpacks itself and re-runs itself, the two halves
talking through ``_PYI*`` variables. Passing those on made the reopened MeshTerm think
it was the second half of a launch it never made; it checked its parent, found Windows
Terminal, and killed itself with a bootloader error in a window that had just opened.
"""
monkeypatch.setattr(sys, "frozen", True, raising=False)
monkeypatch.setenv("_PYI_ARCHIVE_FILE", r"C:\Downloads\meshterm.exe")
monkeypatch.setenv("_PYI_PARENT_PROCESS_LEVEL", "0")
monkeypatch.setenv("_MEIPASS2", r"C:\Temp\_MEI123")
monkeypatch.setenv("MESHTERM_HOME", r"C:\mine")
environment = winterminal.child_environment()
assert not [key for key in environment if key.startswith("_PYI")]
assert "_MEIPASS2" not in environment
assert environment["MESHTERM_HOME"] == r"C:\mine" # the reader's own settings survive
assert environment[winterminal.REOPENED_ENV] == "1"
def test_a_source_run_keeps_the_environment_it_was_given(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Nothing is stripped when there is no bundle, so a dev run is passed through whole.
``LD_LIBRARY_PATH`` is the one to watch: unfrozen it is the reader's, and dropping it
would change how the new process loads its libraries for no reason at all.
"""
monkeypatch.delattr(sys, "frozen", raising=False)
monkeypatch.setenv("LD_LIBRARY_PATH", "/opt/mine/lib")
assert winterminal.child_environment()["LD_LIBRARY_PATH"] == "/opt/mine/lib"
def test_the_loader_path_the_bootloader_replaced_is_put_back(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The bootloader points the loader at its own unpacked copy and parks the original.
The new process unpacks a bundle of its own, so it wants the value the reader started
with — and where there was none, none.
"""
monkeypatch.setattr(sys, "frozen", True, raising=False)
monkeypatch.setenv("LD_LIBRARY_PATH", "/tmp/_MEI999")
monkeypatch.setenv("LD_LIBRARY_PATH_ORIG", "/opt/mine/lib")
monkeypatch.setenv("DYLD_LIBRARY_PATH", "/tmp/_MEI999")
monkeypatch.delenv("DYLD_LIBRARY_PATH_ORIG", raising=False)
environment = winterminal.child_environment()
assert environment["LD_LIBRARY_PATH"] == "/opt/mine/lib"
assert "LD_LIBRARY_PATH_ORIG" not in environment
assert "DYLD_LIBRARY_PATH" not in environment