The same rewrite as for the core package, for the screens, the dialogs, the map, the charts, and the CLI renderers. The glossary words are used for the screen: the user (not the reader), dialog (not popup), viewport, list window, highlight, pointer, graph lane, marker, raster, result screen, and key prefix (never hash for a truncated key). The rewrite also corrects approximately 70 docstrings and comments that no longer agreed with the code: rows that no longer exist (Back rows), counts that changed, renamed constants, wrong cross-references, and behaviour that changed. Each correction follows the code; no code changed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
416 lines
18 KiB
Python
416 lines
18 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""Render a string as a scannable QR code in the terminal, in half blocks or in braille.
|
|
|
|
Each cell stacks two vertical modules: ``▀`` (upper only), ``▄`` (lower only), ``█`` (both),
|
|
and space (neither). Thus a code has half the row height of a code that uses full blocks.
|
|
Some console fonts draw braille solid (:attr:`~meshterm.platforms.Platform.solid_braille`,
|
|
the PicoCalc). There, MeshTerm draws each code in **braille** instead. One module is one
|
|
dot, so a cell holds eight modules: two across and four down. The code then has a quarter
|
|
of the area, and each module still touches its neighbours, which a camera needs.
|
|
|
|
MeshTerm always draws the modules **white on black**. The dark modules of the code are white
|
|
ink. The light modules and the quiet zone are a black field. This is the same for each
|
|
colour theme of the terminal. A phone camera reads contrast, and pure white on pure black is
|
|
the most contrast that a screen has. Each scanner made in this decade expects a light-on-dark
|
|
code on a screen.
|
|
|
|
When the code is the answer, it is a **full-screen** item. :func:`share_screen` puts it on a
|
|
bare frame, with no header, no footer, no title, and no box. Only the URL that the code
|
|
encodes is beside it. Thus the whole panel is contrast for the camera, and the user has the
|
|
one line that they can type out instead. A code is inside a page in two places:
|
|
|
|
- A ``qr`` fence in a written page (:mod:`~meshterm.ui.markdown`). It draws :func:`qr_text`
|
|
in the flow of its prose.
|
|
- On the PicoCalc, a chat message that has URLs. It hangs a code for each URL under its
|
|
text, side by side (:func:`qr_strip`).
|
|
|
|
The links of the same message also open on the share screen (^U), on each platform, one
|
|
code at a time.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Callable, Sequence
|
|
from dataclasses import dataclass
|
|
from functools import lru_cache
|
|
from typing import TYPE_CHECKING
|
|
|
|
from rich.console import Group
|
|
from rich.text import Text
|
|
|
|
from ..platforms import Platform, on_platform
|
|
from .tui.render import render_lines
|
|
from .tui.screen import ScrollScreen
|
|
|
|
if TYPE_CHECKING:
|
|
import segno
|
|
|
|
from ..context import AppContext
|
|
|
|
#: The width of the quiet zone (in modules) around the code. A scanner needs 4 to lock on.
|
|
#: A width of 2 still reads on a screen. A code uses it when its frame is too small for 4.
|
|
_BORDER = 4
|
|
|
|
#: The glyph for each (top-module-dark, bottom-module-dark) pair, drawn as ink. Thus a dark
|
|
#: module has the ink colour, and a light module shows the field behind it.
|
|
_GLYPH = {
|
|
(True, True): "█", # █ full block
|
|
(True, False): "▀", # ▀ upper half
|
|
(False, True): "▄", # ▄ lower half
|
|
(False, False): " ",
|
|
}
|
|
|
|
#: The style of each glyph: the ``qr`` style of the theme. It is pure white ink on a pure
|
|
#: black field. The style names both colours, so the contrast holds in each terminal
|
|
#: palette. The code uses a theme name and not the hex value, so that the slots of the
|
|
#: PicoCalc (its bright white and its black) are chosen on purpose, as for each fixed hue in
|
|
#: the app. A console without the theme draws the glyphs with no style, as the plain CLI
|
|
#: face does.
|
|
_STYLE = "qr"
|
|
|
|
#: The fits that a code tries, in order, until one is small enough for the frame that draws
|
|
#: it. First the standard code. Then a lower error level (a screen is never smudged, so the
|
|
#: extra redundancy gives a camera nothing that it needs). Then the narrower quiet zone. A
|
|
#: contact card (a name and a key of 64 hex digits) is 57 cells across and 29 rows tall at
|
|
#: the standard fit in half blocks (29 by 15 in braille). A regular terminal has 24 rows.
|
|
_FITS: tuple[tuple[str, int], ...] = (("m", 4), ("l", 4), ("m", 2), ("l", 2))
|
|
|
|
|
|
#: The dot bit for each (column, row) in a braille cell. This is the standard Unicode layout.
|
|
_BRAILLE_DOTS = ((0x01, 0x02, 0x04, 0x40), (0x08, 0x10, 0x20, 0x80))
|
|
|
|
|
|
def _braille(block: Sequence[Sequence[bool]]) -> str:
|
|
"""The braille cell that lights each dark module of a ``block`` of 4 rows and 2 columns."""
|
|
bits = 0
|
|
for y, row in enumerate(block):
|
|
for x, dark in enumerate(row):
|
|
if dark:
|
|
bits |= _BRAILLE_DOTS[x][y]
|
|
return chr(0x2800 + bits)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class _Grid:
|
|
"""How the modules of a code pack into cells: ``across`` in a cell, ``down`` in a row.
|
|
|
|
Attributes:
|
|
across: The modules in a cell, left to right.
|
|
down: The modules in a cell row, top to bottom.
|
|
glyph: The cell for one block of modules (``down`` rows by ``across`` columns).
|
|
A value is ``True`` where the module is dark.
|
|
"""
|
|
|
|
across: int
|
|
down: int
|
|
glyph: Callable[[Sequence[Sequence[bool]]], str]
|
|
|
|
def cells(self, modules: int) -> tuple[int, int]:
|
|
"""The cells across and the rows down that a square code of ``modules`` takes here."""
|
|
return -(-modules // self.across), -(-modules // self.down)
|
|
|
|
|
|
#: One module across and two down in a cell. Each terminal can draw this code.
|
|
_HALF_BLOCK = _Grid(across=1, down=2, glyph=lambda block: _GLYPH[(block[0][0], block[1][0])])
|
|
|
|
#: One module is one dot: two across and four down in a cell. Use it only where the font
|
|
#: draws braille solid.
|
|
_BRAILLE = _Grid(across=2, down=4, glyph=_braille)
|
|
|
|
#: The grid on which this platform draws each code. It is bound for each platform. It is
|
|
#: braille where the font tiles braille. The braille of a desktop font is dotted, and a code
|
|
#: that is drawn in it does not scan.
|
|
_GRID = _HALF_BLOCK
|
|
|
|
|
|
@on_platform
|
|
def _bind(platform: Platform) -> None:
|
|
"""Draw codes in braille where the console font tiles it, now and at each switch."""
|
|
global _GRID
|
|
_GRID = _BRAILLE if platform.solid_braille else _HALF_BLOCK
|
|
|
|
|
|
@lru_cache(maxsize=8)
|
|
def _make(data: str, error: str) -> segno.QRCode:
|
|
"""The code for ``data`` at ``error``. It is cached, because each paint asks for it."""
|
|
import segno
|
|
|
|
return segno.make(data, error=error)
|
|
|
|
|
|
def _draw(code: segno.QRCode, border: int, *, indent: int = 0) -> Text:
|
|
"""Draw a segno code on the grid of the platform, with ``border`` light modules around it.
|
|
|
|
``indent`` is the only way that MeshTerm moves a code across a line: the same run of
|
|
bare cells in front of each row. A code must never be justified. The centring of Rich
|
|
removes the trailing spaces of each row before it pads the row. Thus a row that has
|
|
light modules at its right edge loses cells, and it is one column off from its
|
|
neighbours. A finder square that is skewed in one row is a code that no camera can lock
|
|
on to.
|
|
"""
|
|
text = Text(no_wrap=True)
|
|
for i, line in enumerate(_glyph_rows(code, border)):
|
|
if i:
|
|
text.append("\n")
|
|
if indent:
|
|
text.append(" " * indent)
|
|
text.append(line, style=_STYLE)
|
|
return text
|
|
|
|
|
|
def _glyph_rows(code: segno.QRCode, border: int) -> list[str]:
|
|
"""A segno code packed onto the grid of the platform, as one string of glyphs per cell row.
|
|
|
|
Each row has the same number of cells, so codes that are side by side stay square.
|
|
"""
|
|
grid = _GRID
|
|
rows = [[bool(v) for v in row] for row in code.matrix_iter(border=border)]
|
|
cols, _ = grid.cells(len(rows))
|
|
# Pad the right side and the bottom with light modules to whole cells. Thus the last
|
|
# cell of each row and the last row of cells draw cleanly.
|
|
span = cols * grid.across
|
|
rows = [row + [False] * (span - len(row)) for row in rows]
|
|
rows += [[False] * span for _ in range(-len(rows) % grid.down)]
|
|
return [
|
|
"".join(
|
|
grid.glyph([row[x : x + grid.across] for row in rows[top : top + grid.down]])
|
|
for x in range(0, span, grid.across)
|
|
)
|
|
for top in range(0, len(rows), grid.down)
|
|
]
|
|
|
|
|
|
def qr_text(data: str, *, error: str = "m", border: int = _BORDER) -> Text:
|
|
"""Render ``data`` as a QR code, in half blocks, or in braille where braille is solid.
|
|
|
|
Args:
|
|
data: The string to encode (for example a ``meshcore://channel/add`` share URL).
|
|
error: The QR error-correction level: ``l``, ``m``, ``q``, or ``h`` (default ``m``).
|
|
border: The width of the quiet zone in modules (default :data:`_BORDER`).
|
|
|
|
Returns:
|
|
A Rich :class:`~rich.text.Text` (no-wrap) whose lines draw the code, white on
|
|
black. The caller can pass it to ``ctx.ui.view`` or ``ctx.ui.show``, or put it in a
|
|
page.
|
|
"""
|
|
return _draw(_make(data, error), border)
|
|
|
|
|
|
def _fit(data: str, width: int, height: int | None) -> tuple[segno.QRCode, int] | None:
|
|
"""The first of :data:`_FITS` in ``width`` cells and ``height`` rows, as (code, border).
|
|
|
|
It returns ``None`` when no fit is that small. Then the caller decides what to relax.
|
|
"""
|
|
for error, border in _FITS:
|
|
code = _make(data, error)
|
|
cols, rows = _GRID.cells(code.symbol_size(border=border)[0])
|
|
if cols <= width and (height is None or rows <= height):
|
|
return code, border
|
|
return None
|
|
|
|
|
|
def _smallest(data: str) -> tuple[segno.QRCode, int]:
|
|
"""The last of :data:`_FITS`. A frame that is too small for each fit gets it anyway."""
|
|
error, border = _FITS[-1]
|
|
return _make(data, error), border
|
|
|
|
|
|
def fit_qr(data: str, width: int, height: int | None = None) -> Text:
|
|
"""The code for ``data`` at the first of :data:`_FITS` that fits the space that is given.
|
|
|
|
If a code fits at no fit, MeshTerm draws it at the smallest fit anyway. A cut code does
|
|
not scan, but no code does not scan either, and the user can resize to a larger terminal.
|
|
|
|
Args:
|
|
data: The string to encode.
|
|
width: The cells that are available across.
|
|
height: The rows that are available, or ``None`` to fit the width only.
|
|
|
|
Returns:
|
|
The code, as :func:`qr_text` draws it.
|
|
"""
|
|
code, border = _fit(data, width, height) or _smallest(data)
|
|
return _draw(code, border)
|
|
|
|
|
|
#: The blank cells between two codes that are side by side. The quiet zone of each code
|
|
#: stops a camera from reading two codes as one. The gap only shows where the field of one
|
|
#: code ends.
|
|
_GAP = 1
|
|
|
|
|
|
@lru_cache(maxsize=64)
|
|
def _fitted_rows(data: str, width: int, grid: _Grid) -> tuple[str, ...]:
|
|
"""The code of ``data`` at its first fit in ``width`` cells, as glyph rows.
|
|
|
|
It is cached, because a transcript asks for the same code at each paint of a message
|
|
that it cannot reuse (the picked message). ``grid`` is in the key, so that a platform
|
|
switch never returns a code that is packed for the other platform.
|
|
"""
|
|
code, border = _fit(data, width, None) or _smallest(data)
|
|
return tuple(_glyph_rows(code, border))
|
|
|
|
|
|
def qr_strip(data: Sequence[str], width: int, *, indent: int = 0) -> Text:
|
|
"""A code for each of ``data``, side by side, with a new band when the line is full.
|
|
|
|
Each code takes the first fit in the room after ``indent``, as the code of a frame does
|
|
(:data:`_FITS`). The codes go from left to right, with :data:`_GAP` between them. A code
|
|
that would pass the right edge starts the next band, aligned at the top with the other
|
|
codes of that band. There is no blank row between bands, because the quiet zones above
|
|
and below the codes already separate them.
|
|
|
|
Args:
|
|
data: The strings to encode, in reading order.
|
|
width: The cells across the whole line, with ``indent`` included.
|
|
indent: The bare cells in front of each row, so that the strip can hang under a
|
|
block of text.
|
|
|
|
Returns:
|
|
The codes as one no-wrap :class:`~rich.text.Text`, white on black like each code.
|
|
"""
|
|
room = max(1, width - indent)
|
|
bands: list[list[tuple[str, ...]]] = []
|
|
used = 0
|
|
for datum in data:
|
|
rows = _fitted_rows(datum, room, _GRID)
|
|
cols = len(rows[0])
|
|
if bands and used + _GAP + cols <= room:
|
|
bands[-1].append(rows)
|
|
used += _GAP + cols
|
|
else:
|
|
bands.append([rows])
|
|
used = cols
|
|
|
|
text = Text(no_wrap=True)
|
|
for band in bands:
|
|
for y in range(max(len(rows) for rows in band)):
|
|
if text:
|
|
text.append("\n")
|
|
text.append(" " * indent)
|
|
for i, rows in enumerate(band):
|
|
if i:
|
|
text.append(" " * _GAP)
|
|
if y < len(rows):
|
|
text.append(rows[y], style=_STYLE)
|
|
else: # a shorter code on the band: bare cells under it, and not its field
|
|
text.append(" " * len(rows[0]))
|
|
return text
|
|
|
|
|
|
class QrScreen(ScrollScreen):
|
|
"""The only share screen: a code on a bare frame, and under it the URL that it encodes.
|
|
|
|
It is a **bare frame, not a dialog** (:attr:`~meshterm.ui.tui.screen.Screen.bare`). It
|
|
has no header, no footer, no box, no title, and no instruction. A camera that points at
|
|
the screen needs the code, and nothing else that competes with it for contrast. The user
|
|
needs the one line that they can type out instead. Esc leaves, as it does everywhere.
|
|
|
|
The screen **fits the code to the frame at each paint** (:func:`fit_qr`). For this
|
|
reason it is a screen of its own, and not a renderable that MeshTerm passes to a result
|
|
window. Only the frame knows its rows. A code that is taller than the rows is cut, and a
|
|
cut code does not scan. The screen asks for these fits, in this order:
|
|
|
|
1. A fit that holds the code and the URL.
|
|
2. If there is none, a fit that holds the code only. The URL is a page down.
|
|
3. If there is none, the smallest code. The frame shows it from the top, so that a
|
|
larger terminal shows it whole.
|
|
|
|
If the screen has **several** URLs (the links of a chat message), it is still one screen,
|
|
and it shows one code at a time. The ←→ keys step through the codes in order and stop at
|
|
each end. The URL line has ``←`` and ``→`` on its two sides. A mark is lit where another
|
|
code is in that direction, and dim where there is none. This is the only mark that the
|
|
bare frame has besides the code and its URL, because nothing else on the frame can show
|
|
that the other codes are there.
|
|
"""
|
|
|
|
bare = True
|
|
|
|
def __init__(self, *urls: str, title: str) -> None:
|
|
"""Set up the share screen for ``urls``, and show the first one.
|
|
|
|
Args:
|
|
urls: The URLs to show, in order: one share URL (``meshcore://…``), or the
|
|
links of a chat message. The screen encodes each URL as a code and prints
|
|
the URL under it.
|
|
title: ``Share {name}``. The bare frame never draws it. It is the name of the
|
|
screen for the log and the stack.
|
|
"""
|
|
super().__init__(Text(""), title=title, floating=False)
|
|
self.urls = urls
|
|
self._index = 0
|
|
|
|
@property
|
|
def url(self) -> str:
|
|
"""The URL whose code the screen shows."""
|
|
return self.urls[self._index]
|
|
|
|
def _link(self) -> Text:
|
|
"""The URL line: the URL, and if there are several, the ←→ marks for the other codes."""
|
|
link = Text(justify="center")
|
|
several = len(self.urls) > 1
|
|
if several:
|
|
link.append("← ", style="accent" if self._index > 0 else "muted")
|
|
link.append(self.url, style="accent")
|
|
if several:
|
|
last = len(self.urls) - 1
|
|
link.append(" →", style="accent" if self._index < last else "muted")
|
|
return link
|
|
|
|
def handle(self, action: str, data: str = "") -> None:
|
|
"""Step to the code of the previous or next URL on ←→. Other keys act as usual."""
|
|
if action in ("left", "right"):
|
|
# Clamp as each cursor in the app does: held arrows stop at an end.
|
|
step = -1 if action == "left" else 1
|
|
index = max(0, min(len(self.urls) - 1, self._index + step))
|
|
if index != self._index:
|
|
self._index = index
|
|
self.scroll_to_top()
|
|
return
|
|
super().handle(action, data)
|
|
|
|
def render_body(self, width: int) -> list[str]:
|
|
"""The code that fits ``width`` and the frame rows, then a blank row, then the URL."""
|
|
# The frame notes its height before it asks for the body (compose_bare), so
|
|
# this value is all the rows of the terminal. Before the first paint it is the
|
|
# default 1. Then the smallest code is the stand-in until the first paint
|
|
# corrects it.
|
|
rows = self._scroll_viewport
|
|
link = self._link()
|
|
link_rows = len(render_lines(link, width))
|
|
code, border = (
|
|
_fit(self.url, width, rows - 1 - link_rows)
|
|
or _fit(self.url, width, rows)
|
|
or _fit(self.url, width, None)
|
|
or _smallest(self.url)
|
|
)
|
|
# Centre it as a block: one indent for each row (refer to _draw), never justified.
|
|
cols, _ = _GRID.cells(code.symbol_size(border=border)[0])
|
|
indent = max(0, (width - cols) // 2)
|
|
self.replace_content(Group(_draw(code, border, indent=indent), Text(""), link))
|
|
return super().render_body(width)
|
|
|
|
|
|
async def share_screen(ctx: AppContext, *, name: str, url: str) -> None:
|
|
"""Show the share screen for ``url``: the code, full-frame, and the URL under it.
|
|
|
|
This is the one surface for each ``Share {name}``. The channel share and the contact
|
|
card both use it. Thus a change in the way that MeshTerm shows a share code applies to
|
|
all of them at the same time. In the menu, it is :class:`QrScreen`. On the plain CLI
|
|
face, it prints the same two items in order, because there is no frame to fit.
|
|
|
|
Args:
|
|
ctx: The shared application context (it gives the UI surface).
|
|
name: What the user shares. The screen has the title ``Share {name}`` where a
|
|
title is shown at all.
|
|
url: The ``meshcore://…`` share URL. MeshTerm encodes it as the QR code and prints
|
|
it under the code.
|
|
"""
|
|
from .surface import TuiUi
|
|
|
|
title = f"Share {name}"
|
|
if isinstance(ctx.ui, TuiUi):
|
|
await ctx.ui.session.run_screen(QrScreen(url, title=title))
|
|
return
|
|
await ctx.ui.view(Group(qr_text(url), Text(""), Text(url, style="accent")), title=title)
|