diff --git a/docs/screenshots/cardputer-zero/chat.png b/docs/screenshots/cardputer-zero/chat.png new file mode 100644 index 0000000..fbbcb60 Binary files /dev/null and b/docs/screenshots/cardputer-zero/chat.png differ diff --git a/docs/screenshots/cardputer-zero/map.png b/docs/screenshots/cardputer-zero/map.png new file mode 100644 index 0000000..0d8a220 Binary files /dev/null and b/docs/screenshots/cardputer-zero/map.png differ diff --git a/docs/screenshots/cardputer-zero/menu.png b/docs/screenshots/cardputer-zero/menu.png new file mode 100644 index 0000000..da945df Binary files /dev/null and b/docs/screenshots/cardputer-zero/menu.png differ diff --git a/docs/screenshots/cardputer-zero/paths.png b/docs/screenshots/cardputer-zero/paths.png new file mode 100644 index 0000000..5ef8d43 Binary files /dev/null and b/docs/screenshots/cardputer-zero/paths.png differ diff --git a/docs/screenshots/picocalc-lyra/chat.png b/docs/screenshots/picocalc-lyra/chat.png new file mode 100644 index 0000000..27da790 Binary files /dev/null and b/docs/screenshots/picocalc-lyra/chat.png differ diff --git a/docs/screenshots/picocalc-lyra/map.png b/docs/screenshots/picocalc-lyra/map.png new file mode 100644 index 0000000..ce49a1b Binary files /dev/null and b/docs/screenshots/picocalc-lyra/map.png differ diff --git a/docs/screenshots/picocalc-lyra/menu.png b/docs/screenshots/picocalc-lyra/menu.png new file mode 100644 index 0000000..601d8e5 Binary files /dev/null and b/docs/screenshots/picocalc-lyra/menu.png differ diff --git a/docs/screenshots/picocalc-lyra/paths.png b/docs/screenshots/picocalc-lyra/paths.png new file mode 100644 index 0000000..ff5a8ed Binary files /dev/null and b/docs/screenshots/picocalc-lyra/paths.png differ diff --git a/docs/screenshots/terminal/chat.png b/docs/screenshots/terminal/chat.png new file mode 100644 index 0000000..3cbab13 Binary files /dev/null and b/docs/screenshots/terminal/chat.png differ diff --git a/docs/screenshots/terminal/map.png b/docs/screenshots/terminal/map.png new file mode 100644 index 0000000..e46df45 Binary files /dev/null and b/docs/screenshots/terminal/map.png differ diff --git a/docs/screenshots/terminal/menu.png b/docs/screenshots/terminal/menu.png new file mode 100644 index 0000000..b08edec Binary files /dev/null and b/docs/screenshots/terminal/menu.png differ diff --git a/docs/screenshots/terminal/paths.png b/docs/screenshots/terminal/paths.png new file mode 100644 index 0000000..bcffa10 Binary files /dev/null and b/docs/screenshots/terminal/paths.png differ diff --git a/meshterm/demo/device.py b/meshterm/demo/device.py index 19d1a84..1ee5e62 100644 --- a/meshterm/demo/device.py +++ b/meshterm/demo/device.py @@ -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: diff --git a/meshterm/demo/history.py b/meshterm/demo/history.py index 2357ffd..675ca28 100644 --- a/meshterm/demo/history.py +++ b/meshterm/demo/history.py @@ -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: diff --git a/meshterm/demo/script.py b/meshterm/demo/script.py index 883ff7e..dfea0ae 100644 --- a/meshterm/demo/script.py +++ b/meshterm/demo/script.py @@ -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": ( diff --git a/meshterm/demo/world.py b/meshterm/demo/world.py index 4624921..5043858 100644 --- a/meshterm/demo/world.py +++ b/meshterm/demo/world.py @@ -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. diff --git a/pyproject.toml b/pyproject.toml index 7ff79a5..142320c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 diff --git a/scripts/README.md b/scripts/README.md index 6d09e13..34fbe4d 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -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//` | | `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// +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). diff --git a/scripts/screenshots.py b/scripts/screenshots.py new file mode 100644 index 0000000..e63e315 --- /dev/null +++ b/scripts/screenshots.py @@ -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//.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:]))