Measured against a real Montreal database: most messages arrive twice, through the two repeaters a companion hears, on paths that share their start and split near the end, with 3-byte hashes. The demo did five or six short copies through five neighbours. Our node now hears only two new rooftop repeaters, Villeray and Rosemont (the terrain hides the big ones), companions lose 7 dB at street level, and each sender has its own hash width (mostly 3 bytes). Now 91% of messages arrive twice and 83% of paths use 3-byte hashes. Noor moves to Côte-des-Neiges to stay in reach, and the traces leave through Villeray or Rosemont. The capture finds the Message paths message by its text (a key press can be lost while the transcript draws) and selects the second path, since the first is often a straight line. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
670 lines
27 KiB
Python
670 lines
27 KiB
Python
# 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 script also draws each wordmark of the startup splash (``meshterm/assets/splash``) as
|
||
``DIR/splash/logo.<width>.png``, one image pixel for each pixel of the font: the website
|
||
shows them as its splash, the wide mark on a wide screen and the narrow mark on a phone.
|
||
|
||
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 name of the wordmarks in ``--only``. They need no run of MeshTerm.
|
||
SPLASH = "splash"
|
||
|
||
#: The 16 colours of the wordmarks: the Campbell scheme of Windows Terminal, which the
|
||
#: art was drawn in and the website uses.
|
||
CAMPBELL = (
|
||
(12, 12, 12), (197, 15, 31), (19, 161, 14), (193, 156, 0),
|
||
(0, 55, 218), (136, 23, 152), (58, 150, 221), (204, 204, 204),
|
||
(118, 118, 118), (231, 72, 86), (22, 198, 12), (249, 241, 165),
|
||
(59, 120, 255), (180, 0, 158), (97, 214, 214), (242, 242, 242),
|
||
) # fmt: skip
|
||
|
||
#: 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"
|
||
|
||
#: The message whose paths the capture shows: the channel to type in the picker, how many
|
||
#: times to press ↑ from the newest message, and a part of its text near the start, which
|
||
#: the quote at the top of the dialog shows on each platform. The capture checks the quote,
|
||
#: and presses ↑ again until it finds the message, because a key press can be lost while
|
||
#: the transcript draws. In the dialog it then selects the second path: the first one is
|
||
#: often a straight line.
|
||
PATHS_MESSAGE = ("public", 2, "Hello from Beloeil")
|
||
|
||
|
||
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, SPLASH), default=[*SETS, SPLASH])
|
||
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
|
||
if SPLASH in args.only:
|
||
for path in _draw_splash(args.out / SPLASH):
|
||
print(f"{SPLASH}: {path}", flush=True)
|
||
for name in (name for name in args.only if name in SETS):
|
||
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 quote(self) -> str:
|
||
"""The quote of the message in the paths dialog: the line above its "heard" line."""
|
||
with self.lock:
|
||
lines = self.terminal.text().splitlines()
|
||
for index, line in enumerate(lines):
|
||
if index and "heard" in line and "time" in line:
|
||
return lines[index - 1]
|
||
return ""
|
||
|
||
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.
|
||
channel, ups, snippet = PATHS_MESSAGE
|
||
if channel != "public":
|
||
press(text="w", ctrl=True)
|
||
capture.settle(quiet=0.8)
|
||
press("escape")
|
||
typed("chat")
|
||
press("enter")
|
||
capture.wait_for("Public")
|
||
capture.settle()
|
||
typed(channel)
|
||
press("enter")
|
||
capture.settle(quiet=1.5)
|
||
for _ in range(ups):
|
||
press("up")
|
||
time.sleep(0.15)
|
||
capture.settle(quiet=0.6)
|
||
for _ in range(8):
|
||
press(text="p", ctrl=True)
|
||
capture.settle(quiet=2.0)
|
||
if snippet in capture.quote():
|
||
break
|
||
press("escape")
|
||
capture.settle(quiet=0.6)
|
||
press("up")
|
||
capture.settle(quiet=0.6)
|
||
else:
|
||
raise TimeoutError(f"no message with {snippet!r} in {channel}")
|
||
press("down")
|
||
capture.settle(quiet=1.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_splash(target: Path) -> list[Path]:
|
||
"""Draw each wordmark of the splash in the emulator's font, one image pixel per pixel.
|
||
|
||
The marks are the ANSI art that the startup splash shows, read through the same loader
|
||
(:func:`meshterm.ui.logo.load_logo`, widest first). The emulator's raster draws them
|
||
in Terminus 6×12, the font of the handhelds. Its cell has the shape of the VGA cell
|
||
that the art was drawn in, so the art keeps its proportions.
|
||
|
||
Returns:
|
||
The paths of the images, widest mark first.
|
||
"""
|
||
from PIL import Image
|
||
|
||
from meshterm.emulator.font import CELL_H, CELL_W, find_font
|
||
from meshterm.emulator.raster import Raster
|
||
from meshterm.emulator.vt import Terminal
|
||
from meshterm.ui.logo import load_logo, logo_width
|
||
|
||
target.mkdir(parents=True, exist_ok=True)
|
||
font = find_font()
|
||
written: list[Path] = []
|
||
limit: int | None = None
|
||
while rows := load_logo(limit):
|
||
width = logo_width(rows)
|
||
terminal = Terminal(width, len(rows), palette=CAMPBELL)
|
||
terminal.feed("\r\n".join(f"{row}\x1b[0m" for row in rows))
|
||
raster = Raster(
|
||
terminal,
|
||
font,
|
||
default_fg=CAMPBELL[7],
|
||
default_bg=CAMPBELL[0],
|
||
width=width * CELL_W,
|
||
height=len(rows) * CELL_H,
|
||
top_left=True,
|
||
)
|
||
raster.redraw()
|
||
size = (raster.width, raster.height)
|
||
path = target / f"logo.{width}.png"
|
||
Image.frombytes("RGB", size, bytes(raster.pixels)).save(path, optimize=True)
|
||
written.append(path)
|
||
limit = width - 1
|
||
return written
|
||
|
||
|
||
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:]))
|