Files
Jean-Pierre MartineauandClaude Opus 5.5 5fc224476c Docstrings and comments of meshterm/services are in ASD-STE100
The same rewrite as for the core package. Three comments now agree with
the code: the activity bucket of the header is one minute
(ACTIVITY_BUCKET_S), the RX log holds packets, and a reference to a
module note that does not exist is gone.

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

235 lines
9.5 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""The console font of the PicoCalc: remember the old font, change it, and put it back.
The handheld draws to a Linux virtual terminal (VT), and a VT loads exactly one font for
the full console. There is no font for each application, as a desktop terminal emulator
has. Thus, when MeshTerm asks for its 6x8 build (53x40, fourteen more rows), it changes
the font that the shell uses. If MeshTerm leaves the font changed, the console of the user
stays changed after MeshTerm quits. This is the reason for the shape of this module:
**remember** the font that the shell had, **apply** the font that the preference names,
and **restore** the old font at each exit from the app.
``setfont -O`` writes the current font to a file, with its unicode table. When
``setfont`` reads that file again, it makes exactly the same font (checked on the
handheld). No ``sudo`` is necessary: the VT is the controlling terminal of the process,
and the user who is logged in owns it. That is the only reason that this font can be a
preference and not a setup step. MeshTerm never gives ``-C``, on purpose. Thus each call
goes to that controlling VT, and not to the tty number that is first.
All of this module is cosmetic, and all of it fails without a message to the user. A
missing ``setfont``, a read-only state directory, an ssh session that is not a VT, or a
non-zero exit: each one gives one debug line and a return that does nothing. A font is
never a reason for the app not to start. :func:`applies` is the single gate that all
three verbs ask first. Thus only one place answers "this is not that machine".
This module is different from :mod:`meshterm.core.consolefont`, which installs a
TrueType face for the classic Windows console. The words are the same, but the platforms
are opposite. That module uses a Win32 font resource. This module uses a PSF bitmap that
the console driver of the Linux kernel reads.
"""
from __future__ import annotations
import atexit
import os
import re
import shutil
import subprocess
from pathlib import Path
from ..core.config import default_config_dir
from ..persistence.logging import get_logger
from ..platforms import get_platform
#: The directory where ``scripts/picocalc-lyra/calculinux-console-font-6x12.sh`` and the
#: related 6x8 script install the fonts.
FONT_DIR = Path("/usr/share/consolefonts")
#: One preference value -> the file that the build script writes for it. This map is next
#: to the code that runs ``setfont``, not in the preference registry. The user selects a
#: cell size and a row count, not a path on a filesystem that only one machine has.
FONT_FILES: dict[str, str] = {"6x12": "meshterm.psf.gz", "6x8": "meshterm8.psf.gz"}
#: The preference that this module uses. It is named one time, so that the caller and the
#: registry agree.
PREFERENCE_KEY = "console_font"
#: The file that :func:`remember` writes in the MeshTerm home. It is a plain ``.psf``,
#: because ``setfont -O`` writes an uncompressed PSF. Also, the file is temporary
#: (:func:`restore` deletes it). Thus it is never gzipped and never has a version.
SAVED_NAME = "console-font-before.psf"
#: A Linux virtual terminal, which is the only type of console that ``setfont`` can
#: communicate with. ``/dev/pts/3`` (an ssh session, or a terminal emulator on the same
#: handheld) is not a VT. If ``setfont`` runs against it, ``setfont`` fails, or it changes
#: a console that nobody looks at.
_VT_NAME = re.compile(r"^/dev/tty[0-9]+$")
#: The seconds before MeshTerm stops a ``setfont`` call. In practice, the call is an ioctl
#: of less than one millisecond. The timeout is here so that a stuck binary cannot block
#: the startup of the app.
_TIMEOUT_S = 5.0
def _controlling_vt() -> str | None:
"""The name of the terminal of stdin when it is a Linux VT, else ``None``.
``os.ttyname`` does not exist on Windows, and it raises an error on a redirected
stdin. For this module, both cases mean "not a VT".
"""
try:
name = os.ttyname(0)
except (AttributeError, OSError, ValueError):
return None
return name if _VT_NAME.match(name) else None
def applies() -> bool:
"""Whether this session can change the console font at all.
There are three conditions. If one of them is false, MeshTerm does not change the
console, for a different reason each time:
- The platform must be the PicoCalc, because it is the only platform whose fonts this
module knows.
- stdin must be the Linux VT whose font changes (the pty of an ssh session has no font
of its own).
- ``setfont`` must exist. On the handheld, this means the ``kbd`` package, which the
font build must have already.
Returns:
``True`` when all three conditions are true. Otherwise ``False``, with one debug
line that names the condition that is false. The log is the place that explains a
font that "did nothing".
"""
platform = get_platform()
if platform.name != "picocalc-lyra":
get_logger().debug("console font: leaving it alone — platform is %s", platform.name)
return False
if _controlling_vt() is None:
get_logger().debug("console font: leaving it alone — stdin is not a Linux VT")
return False
if shutil.which("setfont") is None:
get_logger().debug("console font: leaving it alone — no setfont on PATH")
return False
return True
def remember() -> Path | None:
"""Write the current font of the console to a file, and arm its restore.
The file holds the font of the shell: the font that was loaded before MeshTerm
started. That font is usually the 6x12 default, but it is the font that the user chose
last. Thus the restore puts back the font that the user had, not the font that
MeshTerm thinks that they had.
This function registers the restore with :mod:`atexit`, and the ``finally`` of the
session also runs the restore. A full-screen app has many ways to exit (^Q, an
unhandled exception, the unwind of the menu), and the user sees a console with the
wrong font only after the app is gone. Two restores do no harm: the first deletes the
file, and the second finds nothing to do.
Returns:
The path of the font file, to give to :func:`restore`. ``None`` when there was
nothing to remember (refer to :func:`applies`), or when the write failed.
"""
if not applies():
return None
path = default_config_dir() / SAVED_NAME
try:
path.parent.mkdir(parents=True, exist_ok=True)
except OSError as exc:
get_logger().debug("console font: cannot make %s: %s", path.parent, exc)
return None
if not _setfont("-O", str(path)):
return None
atexit.register(restore, path)
return path
def apply(choice: str) -> bool:
"""Load the font that one preference value names.
Args:
choice: A :data:`FONT_FILES` key: the value of the ``console_font`` preference.
Returns:
Whether ``setfont`` ran and succeeded. ``False`` is the usual answer on each
machine that is not the handheld. It is never an error that the caller must
handle.
"""
if not applies():
return False
name = FONT_FILES.get(str(choice))
if name is None:
get_logger().debug("console font: no file for %r; leaving the console alone", choice)
return False
# ``as_posix``: this path is on the filesystem of the handheld, on each machine that
# makes the string. Without it, the Windows computer of a developer writes the path
# with backslashes when a test uses it.
return _setfont((FONT_DIR / name).as_posix())
def restore(saved: Path | None) -> None:
"""Put back the font that :func:`remember` wrote, then delete the file.
This function is idempotent by design. It deletes the file when the load worked and
when it did not. Thus a second call (the ``atexit`` call, after the ``finally`` of the
session ran) finds nothing and does nothing.
Args:
saved: What :func:`remember` returned. ``None`` means there was nothing to do.
"""
if saved is None:
return
try:
present = saved.is_file()
except OSError: # pragma: no cover - if the stat fails, we cannot use the file
present = False
if present and shutil.which("setfont") is not None:
_setfont(str(saved))
try:
saved.unlink(missing_ok=True)
except OSError as exc: # pragma: no cover - a file we wrote should be ours to remove
get_logger().debug("console font: cannot remove %s: %s", saved, exc)
def _setfont(*args: str) -> bool:
"""Run ``setfont`` with ``args``, and log all that it says about a failure.
Never ``-C``: when no tty is named, ``setfont`` acts on its controlling terminal. That
terminal is the console that the user looks at.
Returns:
``True`` only on a clean exit.
"""
argv = ["setfont", *args]
try:
done = subprocess.run( # noqa: S603 - a fixed binary name with our own arguments
argv, capture_output=True, text=True, timeout=_TIMEOUT_S, check=False
)
except (OSError, subprocess.SubprocessError) as exc:
get_logger().debug("console font: %s failed: %s", " ".join(argv), exc)
return False
if done.returncode != 0:
get_logger().debug(
"console font: %s exited %d: %s",
" ".join(argv),
done.returncode,
(done.stderr or "").strip() or "(no output)",
)
return False
return True
__all__ = [
"FONT_DIR",
"FONT_FILES",
"PREFERENCE_KEY",
"SAVED_NAME",
"applies",
"apply",
"remember",
"restore",
]