Screenshots for the website, the same at each run
scripts/screenshots.py runs MeshTerm --demo in the emulator's session, with no window, and types its way to the main menu, the chat on Public, the paths of a message, and the map. It saves each screen for the terminal (80x24, drawn in Cascadia Mono with its own box, braille, and Powerline shapes), the Cardputer Zero, and the PicoCalc (the emulator's own raster, at 3x), into docs/screenshots/<set>/. Two runs give the same pixels: the clock is pinned to 11:42 on Thursday 15 October 2026 in Montréal (time-machine and TZ), and the demo device plays no background traffic during a capture (PLAY_BACKGROUND). Gabriel in Beloeil joins the demo, so the message shown in Message paths arrives on five paths through Mont-St-Hilaire, and the map opens at z10. Pillow, fontTools, and time-machine join the dev extras. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
No files matched your search
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 50 KiB |
@@ -32,6 +32,12 @@ from .history import TX_POWER
|
||||
#: part at a time, each from its own seed, so a long session never waits for all of it.
|
||||
_PART_S = 3600.0
|
||||
|
||||
#: Whether the device plays the background traffic of the world while the demo runs. The
|
||||
#: screenshots turn it off (``scripts/screenshots.py``): a packet that arrives during a
|
||||
#: capture changes the activity bars of the header, so two captures would not be the same.
|
||||
#: The lines of the script still arrive.
|
||||
PLAY_BACKGROUND = True
|
||||
|
||||
|
||||
class DemoDevice(MockDevice):
|
||||
"""The simulator, in the demo world (refer to the module docstring)."""
|
||||
@@ -305,7 +311,7 @@ def _ago(entry: NeighbourInfo) -> int:
|
||||
def _live_part(part: int) -> list[traffic.Heard]:
|
||||
"""The live traffic of one part: the background, then the lines of the script in it."""
|
||||
start, end = part * _PART_S, (part + 1) * _PART_S
|
||||
heard = traffic.traffic(start, end, f"live/{part}")
|
||||
heard = traffic.traffic(start, end, f"live/{part}") if PLAY_BACKGROUND else []
|
||||
rng = random.Random(f"meshterm-demo/live-lines/{part}")
|
||||
for offset, line in script.LIVE_DIRECT:
|
||||
if start <= offset < end:
|
||||
|
||||
@@ -46,7 +46,7 @@ HISTORY_DAYS = 300
|
||||
TX_POWER = 22
|
||||
|
||||
#: The map viewport that the demo opens on: the island of Montréal, with the two shores.
|
||||
MAP_VIEW = (45.5200, -73.6200, 11)
|
||||
MAP_VIEW = (45.5150, -73.5900, 10)
|
||||
|
||||
|
||||
def seed(repo: Repository, now: datetime) -> None:
|
||||
|
||||
@@ -145,6 +145,11 @@ CHANNEL_LINES: dict[str, tuple[Line, ...]] = {
|
||||
"Yes! https://www.openstreetmap.org/#map=11/45.5200/-73.6400 I'll bring a printout",
|
||||
),
|
||||
Line(at(0, "11:30"), "Steve K.", "I'll bring a spare antenna for anyone who needs one"),
|
||||
Line(
|
||||
at(0, "11:33"),
|
||||
"Gabriel",
|
||||
"Hello from Beloeil! My messages get to Montréal through Mont-St-Hilaire",
|
||||
),
|
||||
Line(at(0, "11:39"), "Félix", "Longueuil ↔ Stade at +6 dB since the new antenna, finally!"),
|
||||
),
|
||||
"#montreal": (
|
||||
|
||||
@@ -171,6 +171,7 @@ NODES: tuple[Node, ...] = (
|
||||
Node("Tomás", 0x77, _C, 45.4520, -73.8120, 1.0, first_heard_days=160.0,
|
||||
quiet_hours=62 * 24.0, talk=0.5),
|
||||
Node("Mathilde", 0xF7, _C, 45.4780, -73.5870, 0.0, first_heard_days=2 / 24),
|
||||
Node("Gabriel", 0x7F, _C, 45.5650, -73.2050, 1.0, first_heard_days=20.0),
|
||||
Node("Sylvie-Gaspésie", 0x6C, _C, 48.8300, -64.4800, 0.0, shares_location=False,
|
||||
first_heard_days=0.0, quiet_hours=None),
|
||||
# Discovered nodes: heard, but nobody added them.
|
||||
|
||||
@@ -62,6 +62,11 @@ dev = [
|
||||
# The node of the SPI radio. tests/test_radionode_live.py runs it with a fake radio.
|
||||
# The library alone is pure Python on all systems. Only the `hardware` extra needs Linux.
|
||||
"openhop-core>=1.1.3",
|
||||
# scripts/screenshots.py: it draws the screenshots of the website (Pillow, with the
|
||||
# glyph tables of fontTools) at a fixed time on the clock (time-machine).
|
||||
"pillow>=10.0",
|
||||
"fonttools>=4.40",
|
||||
"time-machine>=2.14",
|
||||
]
|
||||
# A LoRa chip on this machine's own SPI bus (the uConsole AIO). A node that MeshTerm runs
|
||||
# itself controls it (refer to meshterm/core/spiradio.py). It works only on Linux, so
|
||||
|
||||
@@ -6,6 +6,7 @@ These are tools for the repository. None of them is in the wheel.
|
||||
|---|---|
|
||||
| `basemap-doctor.py` | Finds why a user's map has no basemap: certificates, DNS, proxy, or colour depth |
|
||||
| `cardputer-zero/` | `build-deb.sh` builds the Debian package for M5Stack's app store. Run it on the Cardputer Zero or on another arm64 Debian 13 system. `store/` holds the listing for the store (`app-builder.json`, which `czdev publish` reads), its icon, and its screenshots. `screenshot.py` saves the display of a Cardputer as a 320×170 PNG over SSH, as the store wants a screenshot. `store-publish.py` sends a package to the store with M5Stack's `czdev`, with two corrections. The release workflow runs it after each release. `launcher-entry.sh` puts a source checkout in the Cardputer Zero's app launcher. `remove` takes it out. `meshterm.png` is the package's icon: the website's logo, at the launcher's 100×100 size. `meshterm-dev.png` is its negative, for the checkout's entry, so that the two entries look different. `type-text.py` types a pasted string (an API key, a Wi-Fi password) on the Cardputer's keyboard from another computer, over SSH |
|
||||
| `screenshots.py` | Makes the screenshots of the website from the demo mesh (`--demo`): the main menu, the chat, the paths of a message, and the map, for the terminal, the Cardputer Zero, and the PicoCalc. They are the same at each run. The result goes to `docs/screenshots/<set>/` |
|
||||
| `picocalc-lyra/` | Setup of the Calculinux device: console font, palette, Wi-Fi. `xiao-radio/` is the kit for the XIAO radio (firmware build, flashing, UART setup) |
|
||||
| `uconsole/` | The uConsole's always-on SPI bridge service. MeshTerm can also control that radio directly, with no script (refer to `docs/devices/uconsole.md`) |
|
||||
|
||||
@@ -44,3 +45,27 @@ gives one verdict:
|
||||
similar. Terminal.app cannot do 24-bit colour.
|
||||
|
||||
The exit status is 0 when the tiles arrive and 1 when they do not. Thus a script can use it.
|
||||
|
||||
## screenshots.py
|
||||
|
||||
The website shows the same four screens for three platforms: the terminal, the Cardputer
|
||||
Zero, and the PicoCalc. This script makes all twelve images from the demo mesh. Each run
|
||||
gives the same pixels: the demo writes the same history, the clock of each run is set to
|
||||
11:42 on Thursday 15 October 2026 in Montréal, and nothing moves on the screen when the
|
||||
script saves it.
|
||||
|
||||
```bash
|
||||
pip install -e .[dev] # Pillow, fontTools, and time-machine
|
||||
meshterm emulate --fetch-fonts # the Terminus font of the handheld sets, one time
|
||||
python scripts/screenshots.py # all three sets, into docs/screenshots/<set>/
|
||||
python scripts/screenshots.py --only terminal
|
||||
```
|
||||
|
||||
No window opens. MeshTerm runs in the process, in the session of the emulator, and the
|
||||
script types the keys. The terminal set uses Cascadia Mono, Segoe UI Symbol, and Segoe UI
|
||||
Emoji, which Windows has. On another system, change `TEXT_FONTS` and `EMOJI_FONT` at the
|
||||
top of the script. The first run downloads the tiles of the map. The later runs use the
|
||||
tile cache in the temporary directory.
|
||||
|
||||
After a change to a screen, run the script again, then rebuild the website (refer to the
|
||||
README of the `meshterm.net` repository).
|
||||
@@ -0,0 +1,569 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
"""Make the screenshots of the website from the demo mesh, the same at each run.
|
||||
|
||||
python scripts/screenshots.py [--out DIR] [--only NAME ...]
|
||||
|
||||
The script runs MeshTerm three times, one time for each set: ``terminal`` (the desktop, in
|
||||
an 80×24 terminal), ``cardputer-zero``, and ``picocalc-lyra``. Each run uses ``--demo``
|
||||
(refer to :mod:`meshterm.demo`) and types the same keys: it opens the chat on Public,
|
||||
the paths of a message, and the map, and it saves each screen as a PNG. The result is
|
||||
``DIR/<set>/<screen>.png`` (``DIR`` is ``docs/screenshots`` by default).
|
||||
|
||||
The screens are the same at each run, for four reasons:
|
||||
|
||||
* The demo writes the same history at each start.
|
||||
* The clock of each run is set to 11:42 on Thursday 15 October 2026, Montréal time, the
|
||||
"now" of the demo script (``time-machine``, with ``TZ`` for the time zone). The clock
|
||||
then runs normally.
|
||||
* The demo device plays no background traffic during a capture
|
||||
(:data:`meshterm.demo.device.PLAY_BACKGROUND`). Only the lines of the script arrive.
|
||||
* The script waits until the screen has not changed for a moment before it saves it.
|
||||
|
||||
MeshTerm runs in this process, in the session of the emulator (:mod:`meshterm.emulator`):
|
||||
its output goes to a VT parser, and the script types into a pipe. Thus no window opens.
|
||||
A handheld set is drawn by the emulator's own raster, in its own font, at three times the
|
||||
size of the display. The terminal set is drawn here, in a desktop font (Cascadia Mono by
|
||||
default). This script draws the box, block, braille, and Powerline glyphs itself, as a
|
||||
modern terminal does, so that the lines join and the map has no gaps.
|
||||
|
||||
The map needs its tiles. The first run downloads them into the tile cache of the demo
|
||||
directory. The later runs read them from there.
|
||||
|
||||
What it needs, beside MeshTerm: ``pillow``, ``time-machine``, and ``fonttools``
|
||||
(``pip install -e .[dev]`` installs them), and the Terminus font of the emulator
|
||||
(``meshterm emulate --fetch-fonts``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
#: The sets, in the order on the website. The terminal set is the default there.
|
||||
SETS = ("terminal", "cardputer-zero", "picocalc-lyra")
|
||||
|
||||
#: The grid of the terminal set: a standard terminal.
|
||||
TERMINAL_GRID = (80, 24)
|
||||
|
||||
#: The "now" of the demo script (``meshterm.demo.script.NOW_LOCAL``), on a fixed date:
|
||||
#: Thursday 15 October 2026, 11:42 Eastern Daylight Time.
|
||||
NOW = datetime(2026, 10, 15, 11, 42, tzinfo=timezone(timedelta(hours=-4)))
|
||||
|
||||
#: The time zone of the runs, in the form that the C runtime reads from ``TZ`` on each
|
||||
#: system.
|
||||
TZ = "EST5EDT"
|
||||
|
||||
#: How many times the capture presses ↑ in the chat to select the message whose paths it
|
||||
#: shows: the line of Gabriel in Beloeil, which arrives on five paths through
|
||||
#: Mont-St-Hilaire.
|
||||
PATHS_MESSAGE = 2
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
"""Run one child process for each set, then report what each one wrote."""
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("--out", type=Path, default=ROOT / "docs" / "screenshots")
|
||||
parser.add_argument("--only", nargs="*", choices=SETS, default=list(SETS))
|
||||
parser.add_argument("--child", choices=SETS, help=argparse.SUPPRESS)
|
||||
args = parser.parse_args(argv)
|
||||
if args.child:
|
||||
return _child(args.child, args.out)
|
||||
from meshterm.emulator.font import font_dir
|
||||
|
||||
# The emulator finds its font under the config directory. Each run has a config
|
||||
# directory of its own, so it gets the font directory of this machine by name.
|
||||
fonts = os.environ.get("MESHTERM_EMULATOR_FONTS") or str(font_dir())
|
||||
status = 0
|
||||
for name in args.only:
|
||||
home = Path(tempfile.gettempdir()) / "meshterm-screenshots" / name
|
||||
env = {
|
||||
**os.environ,
|
||||
"MESHTERM_HOME": str(home),
|
||||
"MESHTERM_EMULATOR_FONTS": fonts,
|
||||
"TZ": TZ,
|
||||
"MESHTERM_POWERLINE": "full",
|
||||
"MESHTERM_EMOJI": "1",
|
||||
# The two wraps of a real terminal (column pins, and the last column of a
|
||||
# Windows console) are for a terminal that measures glyphs in its own way.
|
||||
# Here the VT parser measures them as MeshTerm does.
|
||||
"MESHTERM_COLUMN_SNAP": "0",
|
||||
"MESHTERM_FULL_WIDTH": "0",
|
||||
"PYTHONIOENCODING": "utf-8",
|
||||
}
|
||||
print(f"{name}: capturing...", flush=True)
|
||||
done = subprocess.run(
|
||||
[sys.executable, __file__, "--child", name, "--out", str(args.out)],
|
||||
env=env,
|
||||
check=False,
|
||||
)
|
||||
if done.returncode:
|
||||
print(f"{name}: failed ({done.returncode})", flush=True)
|
||||
status = 1
|
||||
return status
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- the child
|
||||
|
||||
|
||||
class Capture:
|
||||
"""The front end of the emulator for a capture: it counts the frames and saves the grid."""
|
||||
|
||||
def __init__(self, terminal, lock: threading.Lock) -> None: # noqa: ANN001 - a Terminal
|
||||
"""Keep the terminal and its lock."""
|
||||
self.terminal = terminal
|
||||
self.lock = lock
|
||||
self.frames = 0
|
||||
self.last = time.monotonic()
|
||||
|
||||
def frame_ready(self) -> None:
|
||||
"""Note the time of the newest frame."""
|
||||
self.frames += 1
|
||||
self.last = time.monotonic()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Nothing to close: the capture has no window."""
|
||||
|
||||
def settle(self, quiet: float = 1.2, timeout: float = 30.0) -> None:
|
||||
"""Wait until no frame came for ``quiet`` seconds (or until ``timeout``)."""
|
||||
start = time.monotonic()
|
||||
while time.monotonic() - start < timeout:
|
||||
time.sleep(0.05)
|
||||
if self.frames and time.monotonic() - self.last >= quiet:
|
||||
return
|
||||
|
||||
def wait_for(self, text: str, timeout: float = 30.0) -> None:
|
||||
"""Wait until ``text`` is on the screen.
|
||||
|
||||
Raises:
|
||||
TimeoutError: The text did not come.
|
||||
"""
|
||||
start = time.monotonic()
|
||||
while time.monotonic() - start < timeout:
|
||||
with self.lock:
|
||||
if text in self.terminal.text():
|
||||
return
|
||||
time.sleep(0.1)
|
||||
with self.lock:
|
||||
screen = self.terminal.text()
|
||||
raise TimeoutError(f"{text!r} did not appear. The screen:\n{screen}")
|
||||
|
||||
def grid(self) -> list[list[tuple[str, object]]]:
|
||||
"""A copy of the cells of the screen."""
|
||||
with self.lock:
|
||||
return [list(row) for row in self.terminal.screen]
|
||||
|
||||
|
||||
def _child(name: str, out: Path) -> int:
|
||||
"""Capture one set in this process. The parent set the environment."""
|
||||
import time_machine
|
||||
|
||||
traveller = time_machine.travel(NOW, tick=True)
|
||||
traveller.start()
|
||||
|
||||
from prompt_toolkit.application.current import create_app_session
|
||||
from prompt_toolkit.input import create_pipe_input
|
||||
from prompt_toolkit.output.color_depth import ColorDepth
|
||||
|
||||
from meshterm import cli, platforms
|
||||
from meshterm.demo import device as demo_device
|
||||
from meshterm.emulator.devices import DEVICES
|
||||
from meshterm.emulator.run import _palette, panel_output
|
||||
from meshterm.emulator.vt import Terminal
|
||||
|
||||
demo_device.PLAY_BACKGROUND = False
|
||||
handheld = DEVICES.get(name)
|
||||
if handheld is not None:
|
||||
cols, rows = handheld.platform.readable_cols, handheld.platform.readable_rows
|
||||
depth, platform = handheld.color_depth, handheld.id
|
||||
else:
|
||||
cols, rows = TERMINAL_GRID
|
||||
depth, platform = ColorDepth.TRUE_COLOR, "regular"
|
||||
target = out / name
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
lock = threading.Lock()
|
||||
with create_pipe_input() as pipe:
|
||||
terminal = Terminal(cols, rows, palette=_palette(), reply=pipe.send_text)
|
||||
capture = Capture(terminal, lock)
|
||||
|
||||
def save(screen: str) -> None:
|
||||
path = target / f"{screen}.png"
|
||||
image = _draw(name, capture.grid(), terminal.palette)
|
||||
image.save(path, optimize=True)
|
||||
print(f"{name}: {path.relative_to(ROOT) if path.is_relative_to(ROOT) else path}")
|
||||
|
||||
def drive() -> None:
|
||||
try:
|
||||
_tour(capture, pipe.send_text, save)
|
||||
except BaseException: # noqa: BLE001 - report it, then stop the whole child
|
||||
traceback.print_exc()
|
||||
os._exit(1)
|
||||
os._exit(0)
|
||||
|
||||
threading.Thread(target=drive, name="capture", daemon=True).start()
|
||||
output = panel_output(terminal, lock, capture.frame_ready, depth)
|
||||
with create_app_session(input=pipe, output=output), platforms.drawn_by_meshterm():
|
||||
try:
|
||||
cli.app(args=["--platform", platform, "--demo"], windows_expand_args=False)
|
||||
except SystemExit:
|
||||
pass
|
||||
return 1 # the tour ends the process. MeshTerm stopped before it.
|
||||
|
||||
|
||||
def _tour(capture: Capture, type_text: Callable[[str], None], save: Callable[[str], None]) -> None:
|
||||
"""Type the keys that reach each screen, and save each one."""
|
||||
from meshterm.emulator.keys import Key, encode
|
||||
|
||||
def press(name: str = "", text: str = "", *, ctrl: bool = False, count: int = 1) -> None:
|
||||
for _ in range(count):
|
||||
type_text(encode(Key(name=name, text=text, ctrl=ctrl)))
|
||||
time.sleep(0.15)
|
||||
|
||||
def typed(text: str) -> None:
|
||||
for char in text:
|
||||
type_text(char)
|
||||
time.sleep(0.05)
|
||||
|
||||
# The main menu, after the direct message of the script arrived (6 s after the start).
|
||||
capture.wait_for("Chat", timeout=60)
|
||||
time.sleep(8.0)
|
||||
capture.settle()
|
||||
save("menu")
|
||||
# The chat, on Public.
|
||||
typed("chat")
|
||||
press("enter")
|
||||
capture.wait_for("Public")
|
||||
capture.settle()
|
||||
typed("public")
|
||||
press("enter")
|
||||
capture.wait_for("Gabriel")
|
||||
capture.settle()
|
||||
save("chat")
|
||||
# The paths of a message.
|
||||
press("up", count=PATHS_MESSAGE)
|
||||
capture.settle(quiet=0.6)
|
||||
press(text="p", ctrl=True)
|
||||
capture.settle(quiet=2.0)
|
||||
save("paths")
|
||||
# The map, from the main menu.
|
||||
press("escape")
|
||||
capture.settle(quiet=0.6)
|
||||
press(text="w", ctrl=True)
|
||||
capture.settle(quiet=0.8)
|
||||
press("escape") # the menu may still hold the filter that the first visit typed
|
||||
typed("map")
|
||||
press("enter")
|
||||
capture.settle(quiet=4.0, timeout=120.0)
|
||||
save("map")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- drawing
|
||||
|
||||
|
||||
def _draw(name: str, grid, palette): # noqa: ANN001, ANN202 - cells, colours, an Image
|
||||
"""The image of one screen, in the look of its set."""
|
||||
from meshterm.emulator.devices import DEVICES
|
||||
|
||||
handheld = DEVICES.get(name)
|
||||
if handheld is not None:
|
||||
return _draw_handheld(handheld, grid, palette)
|
||||
return TerminalPainter().draw(grid)
|
||||
|
||||
|
||||
def _draw_handheld(handheld, grid, palette): # noqa: ANN001, ANN202
|
||||
"""The display of a handheld, drawn by the emulator's raster, at three times its size."""
|
||||
from PIL import Image
|
||||
|
||||
from meshterm.emulator.font import find_font
|
||||
from meshterm.emulator.raster import Raster
|
||||
from meshterm.emulator.run import default_colours
|
||||
from meshterm.emulator.vt import Terminal
|
||||
|
||||
terminal = Terminal(len(grid[0]), len(grid), palette=palette)
|
||||
terminal.screen = grid
|
||||
ink, paper = default_colours(handheld)
|
||||
width, height = handheld.panel
|
||||
raster = Raster(
|
||||
terminal,
|
||||
find_font(),
|
||||
default_fg=ink,
|
||||
default_bg=paper,
|
||||
width=width,
|
||||
height=height,
|
||||
top_left=handheld.console,
|
||||
bold_is_bright=handheld.console,
|
||||
)
|
||||
raster.redraw()
|
||||
image = Image.frombytes("RGB", (width, height), bytes(raster.pixels))
|
||||
return image.resize((width * 3, height * 3), Image.Resampling.NEAREST)
|
||||
|
||||
|
||||
#: The colours of a cell that names none in the terminal set: the Campbell scheme of
|
||||
#: Windows Terminal, which the website also uses.
|
||||
TERMINAL_FG = (204, 204, 204)
|
||||
TERMINAL_BG = (12, 12, 12)
|
||||
|
||||
#: The fonts of the terminal set, in the order in which a glyph is looked for. The emoji
|
||||
#: font is used only for an emoji (a wide character, or one with VS16).
|
||||
TEXT_FONTS = (
|
||||
"C:/Windows/Fonts/CascadiaMono.ttf",
|
||||
"C:/Windows/Fonts/seguisym.ttf",
|
||||
"C:/Windows/Fonts/segoeui.ttf",
|
||||
)
|
||||
EMOJI_FONT = "C:/Windows/Fonts/seguiemj.ttf"
|
||||
|
||||
|
||||
class TerminalPainter:
|
||||
"""Draw a cell grid as a desktop terminal shows it, at twice the size of a 15 px font.
|
||||
|
||||
The painter draws at four times the size, then scales down by two. Thus the edges of
|
||||
the shapes that it draws itself (braille dots, chevrons, rounded corners) are smooth.
|
||||
"""
|
||||
|
||||
def __init__(self, size: int = 15) -> None:
|
||||
"""Load the fonts, and measure the cell."""
|
||||
from fontTools.ttLib import TTFont
|
||||
from PIL import ImageFont
|
||||
|
||||
self.ss = 4 # the size of the drawing, against the size of the font
|
||||
px = size * self.ss
|
||||
self.fonts = []
|
||||
for path in TEXT_FONTS:
|
||||
if Path(path).exists():
|
||||
cmap = set(TTFont(path, lazy=True).getBestCmap())
|
||||
self.fonts.append((ImageFont.truetype(path, px), cmap))
|
||||
self.bold = ImageFont.truetype(TEXT_FONTS[0], px)
|
||||
try:
|
||||
self.bold.set_variation_by_name("Bold")
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
self.emoji = ImageFont.truetype(EMOJI_FONT, round(px * 0.82))
|
||||
self.emoji_cmap = set(TTFont(EMOJI_FONT, lazy=True).getBestCmap())
|
||||
main = self.fonts[0][0]
|
||||
ascent, descent = main.getmetrics()
|
||||
self.cw = round(main.getlength("M") / 2) * 2
|
||||
self.ch = round((ascent + descent) * 1.02 / 2) * 2
|
||||
self.baseline = (self.ch - ascent - descent) // 2 + ascent
|
||||
self.pad = self.cw
|
||||
|
||||
def draw(self, grid): # noqa: ANN001, ANN201 - cells, an Image
|
||||
"""The image of ``grid``."""
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
cols, rows = len(grid[0]), len(grid)
|
||||
width = cols * self.cw + 2 * self.pad
|
||||
height = rows * self.ch + 2 * self.pad
|
||||
image = Image.new("RGB", (width, height), TERMINAL_BG)
|
||||
draw = ImageDraw.Draw(image)
|
||||
for row, cells in enumerate(grid):
|
||||
for col, (_, style) in enumerate(cells):
|
||||
_, bg = self._colours(style)
|
||||
x = self.pad + col * self.cw
|
||||
y = self.pad + row * self.ch
|
||||
wide = col + 1 < cols and cells[col + 1][0] == ""
|
||||
span = 2 if wide else 1
|
||||
if bg != TERMINAL_BG:
|
||||
draw.rectangle([x, y, x + self.cw * span - 1, y + self.ch - 1], fill=bg)
|
||||
for row, cells in enumerate(grid):
|
||||
for col, (char, style) in enumerate(cells):
|
||||
if not char or char == " " or style.flags & 32: # 32: HIDDEN
|
||||
continue
|
||||
fg, _ = self._colours(style)
|
||||
x = self.pad + col * self.cw
|
||||
y = self.pad + row * self.ch
|
||||
wide = col + 1 < cols and cells[col + 1][0] == ""
|
||||
self._glyph(image, draw, char, x, y, fg, wide, bool(style.flags & 1))
|
||||
if style.flags & 8: # UNDERLINE
|
||||
draw.rectangle(
|
||||
[x, y + self.ch - self.ss * 2, x + self.cw - 1, y + self.ch - self.ss],
|
||||
fill=fg,
|
||||
)
|
||||
# A palette of 256 colours, with no dither, looks the same as the full image and
|
||||
# makes the file four times smaller: a terminal has few colours, and the smooth
|
||||
# edges of the text need only a few steps of each.
|
||||
image = image.resize((width // 2, height // 2), Image.Resampling.LANCZOS)
|
||||
return image.quantize(256, method=Image.Quantize.FASTOCTREE, dither=Image.Dither.NONE)
|
||||
|
||||
@staticmethod
|
||||
def _colours(style) -> tuple[tuple[int, int, int], tuple[int, int, int]]: # noqa: ANN001
|
||||
fg = style.fg or TERMINAL_FG
|
||||
bg = style.bg or TERMINAL_BG
|
||||
if style.flags & 16: # REVERSE
|
||||
fg, bg = bg, fg
|
||||
if style.flags & 2: # DIM
|
||||
fg = tuple((a + b) // 2 for a, b in zip(fg, bg, strict=True))
|
||||
return fg, bg
|
||||
|
||||
def _glyph(self, image, draw, char, x, y, fg, wide, bold) -> None: # noqa: ANN001
|
||||
base = char[0]
|
||||
code = ord(base)
|
||||
if 0x2800 <= code <= 0x28FF:
|
||||
self._braille(draw, code - 0x2800, x, y, fg)
|
||||
return
|
||||
if 0x2580 <= code <= 0x259F and self._block(draw, code, x, y, fg):
|
||||
return
|
||||
if 0x2500 <= code <= 0x257F and self._box(draw, code, x, y, fg):
|
||||
return
|
||||
if 0xE0B0 <= code <= 0xE0B7 and self._powerline(draw, code, x, y, fg):
|
||||
return
|
||||
emoji = (wide or "\ufe0f" in char) and code in self.emoji_cmap
|
||||
if emoji:
|
||||
span = self.cw * 2
|
||||
draw.text(
|
||||
(x + span / 2, y + self.ch / 2), base, font=self.emoji, anchor="mm",
|
||||
embedded_color=True,
|
||||
) # fmt: skip
|
||||
return
|
||||
font = self.bold if bold and code in self.fonts[0][1] else None
|
||||
if font is None:
|
||||
font = next((f for f, cmap in self.fonts if code in cmap), self.fonts[0][0])
|
||||
span = self.cw * (2 if wide else 1)
|
||||
draw.text((x + span / 2, y + self.baseline), char, font=font, fill=fg, anchor="ms")
|
||||
|
||||
def _braille(self, draw, bits: int, x: int, y: int, fg) -> None: # noqa: ANN001
|
||||
"""The eight dots of a braille cell, as round dots on a 2×4 grid."""
|
||||
order = ((0, 0), (0, 1), (0, 2), (1, 0), (1, 1), (1, 2), (0, 3), (1, 3))
|
||||
radius = self.cw * 0.14
|
||||
for bit, (dx, dy) in enumerate(order):
|
||||
if bits & (1 << bit):
|
||||
cx = x + self.cw * (0.28 + 0.44 * dx)
|
||||
cy = y + self.ch * (0.125 + 0.25 * dy)
|
||||
draw.ellipse([cx - radius, cy - radius, cx + radius, cy + radius], fill=fg)
|
||||
|
||||
def _block(self, draw, code: int, x: int, y: int, fg) -> bool: # noqa: ANN001
|
||||
"""A block element as filled rectangles. ``False`` for one that the font draws."""
|
||||
w, h = self.cw, self.ch
|
||||
boxes: list[tuple[float, float, float, float]] = []
|
||||
if code == 0x2580:
|
||||
boxes = [(0, 0, 1, 0.5)]
|
||||
elif 0x2581 <= code <= 0x2588:
|
||||
boxes = [(0, 1 - (code - 0x2580) / 8, 1, 1)]
|
||||
elif 0x2589 <= code <= 0x258F:
|
||||
boxes = [(0, 0, (0x2590 - code) / 8, 1)]
|
||||
elif code == 0x2590:
|
||||
boxes = [(0.5, 0, 1, 1)]
|
||||
elif code == 0x2594:
|
||||
boxes = [(0, 0, 1, 1 / 8)]
|
||||
elif code == 0x2595:
|
||||
boxes = [(7 / 8, 0, 1, 1)]
|
||||
elif 0x2596 <= code <= 0x259F:
|
||||
quads = {
|
||||
0x2596: "3", 0x2597: "4", 0x2598: "1", 0x2599: "134", 0x259A: "14",
|
||||
0x259B: "123", 0x259C: "124", 0x259D: "2", 0x259E: "23", 0x259F: "234",
|
||||
} # fmt: skip
|
||||
place = {"1": (0, 0), "2": (0.5, 0), "3": (0, 0.5), "4": (0.5, 0.5)}
|
||||
boxes = [(*place[q], place[q][0] + 0.5, place[q][1] + 0.5) for q in quads[code]]
|
||||
elif 0x2591 <= code <= 0x2593:
|
||||
shade = (code - 0x2590) / 4
|
||||
colour = tuple(
|
||||
round(c * shade + b * (1 - shade)) for c, b in zip(fg, TERMINAL_BG, strict=True)
|
||||
)
|
||||
draw.rectangle([x, y, x + w - 1, y + h - 1], fill=colour)
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
for x0, y0, x1, y1 in boxes:
|
||||
draw.rectangle([x + x0 * w, y + y0 * h, x + x1 * w - 1, y + y1 * h - 1], fill=fg)
|
||||
return True
|
||||
|
||||
#: The light, heavy, and rounded box glyphs: which of the four arms each one has
|
||||
#: (left, right, up, down), with 1 for a light arm and 2 for a heavy arm.
|
||||
_ARMS: dict[int, tuple[int, int, int, int]] = {
|
||||
0x2500: (1, 1, 0, 0), 0x2501: (2, 2, 0, 0), 0x2502: (0, 0, 1, 1), 0x2503: (0, 0, 2, 2),
|
||||
0x250C: (0, 1, 0, 1), 0x250F: (0, 2, 0, 2), 0x2510: (1, 0, 0, 1), 0x2513: (2, 0, 0, 2),
|
||||
0x2514: (0, 1, 1, 0), 0x2517: (0, 2, 2, 0), 0x2518: (1, 0, 1, 0), 0x251B: (2, 0, 2, 0),
|
||||
0x251C: (0, 1, 1, 1), 0x2523: (0, 2, 2, 2), 0x2524: (1, 0, 1, 1), 0x252B: (2, 0, 2, 2),
|
||||
0x252C: (1, 1, 0, 1), 0x2533: (2, 2, 0, 2), 0x2534: (1, 1, 1, 0), 0x253B: (2, 2, 2, 0),
|
||||
0x253C: (1, 1, 1, 1), 0x254B: (2, 2, 2, 2), 0x2574: (1, 0, 0, 0), 0x2575: (0, 0, 1, 0),
|
||||
0x2576: (0, 1, 0, 0), 0x2577: (0, 0, 0, 1),
|
||||
} # fmt: skip
|
||||
|
||||
def _box(self, draw, code: int, x: int, y: int, fg) -> bool: # noqa: ANN001
|
||||
"""A box glyph as lines through the centre of the cell, joined to its neighbours."""
|
||||
w, h = self.cw, self.ch
|
||||
light = max(self.ss, round(w / 9))
|
||||
cx, cy = x + w // 2, y + h // 2
|
||||
if code in (0x256D, 0x256E, 0x256F, 0x2570):
|
||||
# A rounded corner: a quarter circle that ends at the middle of two edges.
|
||||
r = w // 2
|
||||
corner = {
|
||||
0x256D: (cx, cy, 180), # ╭: the arc opens to the right and down
|
||||
0x256E: (cx - 2 * r, cy, 270), # ╮
|
||||
0x256F: (cx - 2 * r, cy - 2 * r, 0), # ╯
|
||||
0x2570: (cx, cy - 2 * r, 90), # ╰
|
||||
}[code]
|
||||
left, top, start = corner
|
||||
draw.arc(
|
||||
[left, top, left + 2 * r, top + 2 * r], start, start + 90, fill=fg, width=light
|
||||
)
|
||||
if code in (0x256D, 0x256E):
|
||||
draw.rectangle(
|
||||
[cx - light // 2, cy + r, cx + light - light // 2 - 1, y + h], fill=fg
|
||||
)
|
||||
else:
|
||||
draw.rectangle([cx - light // 2, y, cx + light - light // 2 - 1, cy - r], fill=fg)
|
||||
return True
|
||||
if code in (0x2550, 0x2551):
|
||||
gap = light * 2
|
||||
for offset in (-gap, gap):
|
||||
if code == 0x2550:
|
||||
draw.rectangle(
|
||||
[x, cy + offset - light // 2, x + w, cy + offset + light // 2], fill=fg
|
||||
)
|
||||
else:
|
||||
draw.rectangle(
|
||||
[cx + offset - light // 2, y, cx + offset + light // 2, y + h], fill=fg
|
||||
)
|
||||
return True
|
||||
arms = self._ARMS.get(code)
|
||||
if arms is None:
|
||||
return False
|
||||
left, right, up, down = arms
|
||||
for weight, box in (
|
||||
(left, (x, cy, cx, cy)),
|
||||
(right, (cx, cy, x + w, cy)),
|
||||
(up, (cx, y, cx, cy)),
|
||||
(down, (cx, cy, cx, y + h)),
|
||||
):
|
||||
if not weight:
|
||||
continue
|
||||
t = light * weight
|
||||
x0, y0, x1, y1 = box
|
||||
draw.rectangle(
|
||||
[x0 - t // 2, y0 - t // 2, x1 + t - t // 2 - 1, y1 + t - t // 2 - 1], fill=fg
|
||||
)
|
||||
return True
|
||||
|
||||
def _powerline(self, draw, code: int, x: int, y: int, fg) -> bool: # noqa: ANN001
|
||||
"""A Powerline separator, as the shape that fills the cell."""
|
||||
w, h = self.cw, self.ch
|
||||
light = max(self.ss, round(w / 9))
|
||||
if code == 0xE0B0:
|
||||
draw.polygon([(x, y), (x + w, y + h / 2), (x, y + h)], fill=fg)
|
||||
elif code == 0xE0B2:
|
||||
draw.polygon([(x + w, y), (x, y + h / 2), (x + w, y + h)], fill=fg)
|
||||
elif code == 0xE0B1:
|
||||
draw.line([(x, y), (x + w, y + h / 2), (x, y + h)], fill=fg, width=light)
|
||||
elif code == 0xE0B3:
|
||||
draw.line([(x + w, y), (x, y + h / 2), (x + w, y + h)], fill=fg, width=light)
|
||||
elif code == 0xE0B4:
|
||||
draw.pieslice([x - w, y, x + w, y + h], 270, 90, fill=fg)
|
||||
elif code == 0xE0B6:
|
||||
draw.pieslice([x, y, x + 2 * w, y + h], 90, 270, fill=fg)
|
||||
else:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||