Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
564e051d1a | ||
|
|
a11c43c347 | ||
|
|
476294a8db | ||
|
|
7be4b8514b | ||
|
|
54bbb93233 | ||
|
|
5158268a73 | ||
|
|
f06b593a0f | ||
|
|
ea5f644d73 | ||
|
|
d062b5b706 |
No files matched your search
+83
-1
@@ -17,6 +17,87 @@ allowed to change behaviour, not just add to it.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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 +235,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
|
||||
|
||||
@@ -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.
|
||||
@@ -68,16 +68,44 @@ 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.6-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.6-*
|
||||
./meshterm-0.2.6-*
|
||||
```
|
||||
|
||||
### 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 offers to move: **reopen in Windows Terminal**,
|
||||
one keypress, nothing installed and nothing changed. That'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.
|
||||
|
||||
Stay in the classic console and 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.
|
||||
|
||||
You can decline both and MeshTerm still runs. Preferences → Display → Console setup is
|
||||
where you change your mind.
|
||||
|
||||
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 +114,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.6-*` 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 +124,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.6-* # 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.6-windows-x64.exe
|
||||
> ```
|
||||
>
|
||||
> Same variable if you run two radios and want them kept apart.
|
||||
@@ -386,6 +414,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 +451,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.
|
||||
@@ -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.6"
|
||||
__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.
+215
-3
@@ -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,189 @@ def main_callback(
|
||||
raise
|
||||
|
||||
|
||||
def _offer_a_console_that_can_draw_meshterm(console: Console, prefs: Preferences) -> bool:
|
||||
"""On the classic Windows console, offer the reader something better than it.
|
||||
|
||||
Two offers, in the order of how much they buy. **Windows Terminal** first, because it
|
||||
is the only one that gets the whole app: emoji icons included, which no font can put
|
||||
on the classic console — the only Windows fonts carrying emoji are proportional, and
|
||||
a console will not take one. Then, for a reader who stays, **a font** that can at
|
||||
least draw the charts and the marks.
|
||||
|
||||
Args:
|
||||
console: The console to 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 _offer_windows_terminal(console):
|
||||
return True
|
||||
_offer_a_font_this_console_can_draw(console, prefs)
|
||||
return False
|
||||
|
||||
|
||||
def _offer_windows_terminal(console: Console) -> bool:
|
||||
"""Offer to reopen the session in Windows Terminal, where the app looks like itself.
|
||||
|
||||
Asked with yes as the default, which is not how the font offer is asked: this one
|
||||
changes nothing on the machine — no install, no setting, one new window — and it is
|
||||
the only answer that gets the reader the app as it was drawn.
|
||||
|
||||
Args:
|
||||
console: The console to ask on.
|
||||
|
||||
Returns:
|
||||
Whether Windows Terminal was started and this session should stand down.
|
||||
"""
|
||||
from .core import winterminal
|
||||
|
||||
if winterminal.available() is None:
|
||||
return False
|
||||
|
||||
console.print()
|
||||
console.print("[accent]•[/accent] This is the classic Windows console, and it can only")
|
||||
console.print(" draw part of MeshTerm — no icons, and no charts.")
|
||||
console.print()
|
||||
console.print(" [accent]Windows Terminal[/accent] draws all of it, and you have it.")
|
||||
console.print(" MeshTerm can reopen itself there now. Nothing is installed")
|
||||
console.print(" and nothing is changed; it is just a better window.")
|
||||
console.print()
|
||||
if not _asks_yes(console, " Reopen in Windows Terminal? [Y/n] ", default=True):
|
||||
return False
|
||||
if winterminal.reopen():
|
||||
console.print("[ok]✓[/ok] Reopening in Windows Terminal.")
|
||||
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 +441,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 +454,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 +587,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
|
||||
|
||||
@@ -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
|
||||
@@ -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] = {
|
||||
"ask": "Ask",
|
||||
"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,18 @@ PREFERENCES: tuple[PrefSpec, ...] = (
|
||||
default="auto",
|
||||
choices=_WIDTH_CHOICES,
|
||||
),
|
||||
PrefSpec(
|
||||
key="console_setup",
|
||||
label="Console setup",
|
||||
help="Offer a better terminal or font when this console can't draw MeshTerm",
|
||||
group="Display",
|
||||
value_type="enum",
|
||||
# "ask", not "always": changing the font of a window someone else opened is not
|
||||
# ours to do unasked. Declining twice writes "off" here, which is how the offer
|
||||
# stops being made rather than being made every launch.
|
||||
default="ask",
|
||||
choices=_CONSOLE_SETUP_CHOICES,
|
||||
),
|
||||
# --- Diagnostics ---------------------------------------------------------------
|
||||
PrefSpec(
|
||||
key="log_level",
|
||||
|
||||
@@ -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")
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user