From 6840138eb4587ca89555d3c3c83152dd90ece2e8 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Wed, 30 Sep 2026 22:55:01 -0400 Subject: [PATCH] On the PicoCalc, every QR code is braille, one module a dot Braille was a fallback for codes too big for half blocks; on the PicoCalc it is now the grid every code is drawn on, eight modules to a cell, share screens and page fences alike. A channel share is 23 cells by 12 rows there, a contact card 29 by 15, each with its link under it. The regular platform keeps half blocks, its fonts' braille being dotted. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 16 ++++----- meshterm/platforms.py | 6 ++-- meshterm/ui/qr.py | 80 ++++++++++++++++++++---------------------- tests/test_channels.py | 21 ++++++----- 4 files changed, 62 insertions(+), 61 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a47dd6c..62055c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -241,14 +241,14 @@ deliberately, one at a time, and say why in the code. encodes, sat a little above centre; Esc leaves, as everywhere. The code **fits itself to the frame every paint**, width and rows both: a lighter error level, then a narrower quiet zone, before it would ever be cut (a contact card is 57 cells and 29 - rows at the standard fit; the PicoCalc is 53 across and a regular terminal 24 tall). - Where the font draws braille solid (`Platform.solid_braille`, the PicoCalc's built - fonts) those fits are then tried again in **braille**, two modules a cell and four a - row (`qr._GRIDS`): half the module, the one step that costs a camera anything, so it - comes after the free ones — and it is what puts the contact card on the 53×26 panel - whole, standard fit and URL together. When code and URL still can't share the frame - the code is whole at the top and the URL a page down. The one exception is a ` ```qr ` fence in a written page, where the code - is an illustration drawn in the prose where the fence is. + rows at the standard fit in half blocks; a regular terminal is 24 tall), and when code + and URL can't share the frame the code is whole at the top and the URL a page down. + The one exception is a ` ```qr ` fence in a written page, where the code is an + illustration drawn in the prose where the fence is. Where the font draws braille solid + (`Platform.solid_braille`, the PicoCalc's built fonts) every code — share screen and + page fence alike — is drawn in **braille**, one module a dot, eight a cell + (`qr._GRID`), so the contact card is 29 cells by 15 rows there and shares the 53×26 + panel with its URL at the standard fit. ### Written pages diff --git a/meshterm/platforms.py b/meshterm/platforms.py index 21f99bf..48f30c8 100644 --- a/meshterm/platforms.py +++ b/meshterm/platforms.py @@ -90,9 +90,9 @@ class Platform: solid_braille: Whether the console font draws a braille cell's eight dots as solid tiles with no gap between them or between neighbouring cells — the PicoCalc's built fonts (``scripts/picocalc``) do — so braille is a true 2×4 pixel grid, - fine enough for what must stay contiguous. It is what lets a QR code step down - to braille when the half-block code does not fit (:mod:`~meshterm.ui.qr`). A - desktop font's braille is dotted, and a code drawn in it scans as nothing. + fine enough for what must stay contiguous. It is what lets every QR code be + drawn in braille, one module a dot (:mod:`~meshterm.ui.qr`). A desktop font's + braille is dotted, and a code drawn in it scans as nothing. effects: Whether animated/decorative rendering runs at all — the braille spinner, which drops to a ``LINE`` fallback. Cheaper on a console where a repaint is dear. The header battery gauge is deliberately *not* behind it: its sweep is diff --git a/meshterm/ui/qr.py b/meshterm/ui/qr.py index 3195c70..156ebd3 100644 --- a/meshterm/ui/qr.py +++ b/meshterm/ui/qr.py @@ -1,13 +1,13 @@ # SPDX-License-Identifier: Apache-2.0 -"""Render a string as a scannable QR code in the terminal, using half-block characters. +"""Render a string as a scannable QR code in the terminal, in half blocks or in braille. Each character cell stacks two vertical modules — ``▀`` (upper only), ``▄`` (lower only), ``█`` (both), space (neither) — so a code renders at half the row height of a full-block -one. Where the console font's braille is solid (:attr:`~meshterm.platforms.Platform. -solid_braille`, the PicoCalc), a code too big for its frame in half blocks steps down to -**braille**, two modules across and four down in every cell: half the module, a quarter -of the area, and still every module touching its neighbours, which is what a camera -needs of it. Modules are always drawn **white on black**: the code's dark modules as white ink, +one. Where the console font draws braille solid (:attr:`~meshterm.platforms.Platform. +solid_braille`, the PicoCalc), every code is drawn in **braille** instead: one module a +dot, eight a cell, two across and four down — a quarter of the area, with every module +still touching its neighbours, which is what a camera needs of it. Modules are always +drawn **white on black**: the code's dark modules as white ink, its light modules and quiet zone as a black field, whatever colour theme the terminal is running. A phone camera reads contrast, and pure white on pure black is the most of it a screen has; a light-on-dark code is what every scanner made this decade expects to meet @@ -63,8 +63,8 @@ _STYLE = "qr" #: The fits a code tries, in order, until one is small enough for the frame drawing it: #: the standard code first, then a lighter error level (a screen is never smudged, so the #: redundancy buys nothing a camera needs), then the narrower quiet zone. A contact card -#: — a name, a 64-hex key — is 57 cells across and 29 rows tall at the standard fit; the -#: PicoCalc panel is 53 across, and a regular terminal is 24 rows. +#: — a name, a 64-hex key — is 57 cells across and 29 rows tall at the standard fit in half +#: blocks (29 by 15 in braille); a regular terminal is 24 rows. _FITS: tuple[tuple[str, int], ...] = (("m", 4), ("l", 4), ("m", 2), ("l", 2)) @@ -105,20 +105,20 @@ class _Grid: #: One module a cell, two a row — the code every terminal can draw. _HALF_BLOCK = _Grid(across=1, down=2, glyph=lambda block: _GLYPH[(block[0][0], block[1][0])]) -#: Two modules a cell, four a row — half the module, so only where braille is solid. +#: One module a dot: two a cell, four a row — only where the font draws braille solid. _BRAILLE = _Grid(across=2, down=4, glyph=_braille) -#: The grids a fitted code may be drawn on, largest module first: a code tries every one -#: of :data:`_FITS` on a grid before it shrinks to the next. Bound per platform — the -#: braille grid is only offered where the font draws braille solid. -_GRIDS: tuple[_Grid, ...] = (_HALF_BLOCK,) +#: The grid every code on this platform is drawn on. Bound per platform: braille where the +#: font tiles it, since a desktop font's braille is dotted and a code drawn in it scans as +#: nothing. +_GRID = _HALF_BLOCK @on_platform def _bind(platform: Platform) -> None: - """Offer the braille grid where the console font tiles it (now and on switches).""" - global _GRIDS - _GRIDS = (_HALF_BLOCK, _BRAILLE) if platform.solid_braille else (_HALF_BLOCK,) + """Draw codes in braille where the console font tiles it (now and on switches).""" + global _GRID + _GRID = _BRAILLE if platform.solid_braille else _HALF_BLOCK @lru_cache(maxsize=8) @@ -129,8 +129,8 @@ def _make(data: str, error: str) -> segno.QRCode: return segno.make(data, error=error) -def _draw(code: segno.QRCode, border: int, grid: _Grid = _HALF_BLOCK, *, indent: int = 0) -> Text: - """Draw a segno code on ``grid``, ``border`` light modules around it. +def _draw(code: segno.QRCode, border: int, *, indent: int = 0) -> Text: + """Draw a segno code on the platform's grid, ``border`` light modules around it. ``indent`` is the one way a code is ever moved across a line: the same run of bare cells in front of *every* row. A code must never be justified — Rich's centring @@ -138,6 +138,7 @@ def _draw(code: segno.QRCode, border: int, grid: _Grid = _HALF_BLOCK, *, indent: modules loses cells and lands a column off its neighbours, and a finder square one row skewed is a code no camera can lock on to. """ + 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 and the bottom out to whole cells with light modules, so the last @@ -162,7 +163,7 @@ def _draw(code: segno.QRCode, border: int, grid: _Grid = _HALF_BLOCK, *, indent: def qr_text(data: str, *, error: str = "m", border: int = _BORDER) -> Text: - """Render ``data`` as a QR code built from half-block characters. + """Render ``data`` as a QR code, in half blocks or — where braille is solid — braille. Args: data: The string to encode (e.g. a ``meshcore://channel/add`` share URL). @@ -176,31 +177,27 @@ def qr_text(data: str, *, error: str = "m", border: int = _BORDER) -> Text: return _draw(_make(data, error), border) -def _fit(data: str, width: int, height: int | None) -> tuple[segno.QRCode, int, _Grid] | None: - """The first fit within ``width`` cells and ``height`` rows, as (code, border, grid). +def _fit(data: str, width: int, height: int | None) -> tuple[segno.QRCode, int] | None: + """The first of :data:`_FITS` within ``width`` cells and ``height`` rows, as (code, border). - Every one of :data:`_FITS` on the largest grid, then on the next of :data:`_GRIDS`: on - a screen a lighter error level or a narrower quiet zone costs a camera nothing it - needs, and a module half the size is the one step that does. ``None`` when no fit is - that small — the caller decides what to relax. + ``None`` when no fit is that small — the caller decides what to relax. """ - for grid in _GRIDS: - 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, grid + 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, _Grid]: - """The last of :data:`_FITS` on the last of :data:`_GRIDS` — for a frame too small for any.""" +def _smallest(data: str) -> tuple[segno.QRCode, int]: + """The last of :data:`_FITS` — what a frame too small for any fit gets anyway.""" error, border = _FITS[-1] - return _make(data, error), border, _GRIDS[-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 fit that fits the space given (see :func:`_fit`). + """The code for ``data`` at the first of :data:`_FITS` that fits the space given. A code that fits at no fit is drawn at the smallest anyway: a cut code is not scannable, but neither is no code, and a larger terminal is one resize away. @@ -211,11 +208,10 @@ def fit_qr(data: str, width: int, height: int | None = None) -> Text: height: Rows available, or ``None`` to fit the width alone. Returns: - The code, white on black, in half blocks or — where the platform's braille is - solid and half blocks do not fit — in braille. + The code, as :func:`qr_text` draws it. """ - code, border, grid = _fit(data, width, height) or _smallest(data) - return _draw(code, border, grid) + code, border = _fit(data, width, height) or _smallest(data) + return _draw(code, border) class QrScreen(ScrollScreen): @@ -255,16 +251,16 @@ class QrScreen(ScrollScreen): rows = self._scroll_viewport link = Text(self.url, style="accent", justify="center") link_rows = len(render_lines(link, width)) - code, border, grid = ( + 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) ) # Centred as a block — one indent for every row (see _draw), never justified. - cols, _ = grid.cells(code.symbol_size(border=border)[0]) + cols, _ = _GRID.cells(code.symbol_size(border=border)[0]) indent = max(0, (width - cols) // 2) - self.replace_content(Group(_draw(code, border, grid, indent=indent), Text(""), link)) + self.replace_content(Group(_draw(code, border, indent=indent), Text(""), link)) return super().render_body(width) diff --git a/tests/test_channels.py b/tests/test_channels.py index 403c047..f090748 100644 --- a/tests/test_channels.py +++ b/tests/test_channels.py @@ -275,23 +275,28 @@ def test_the_share_screen_refits_its_code_to_the_frame_every_paint() -> None: assert len(code_rows(53, 26)) <= 26 # the PicoCalc: likewise -def test_where_braille_is_solid_a_code_too_big_for_half_blocks_steps_down_to_it() -> None: - """On the PicoCalc a contact card keeps its standard fit, in braille, over its link. +def test_where_braille_is_solid_every_code_is_drawn_in_it() -> None: + """On the PicoCalc a code is braille, one module a dot — eight to a cell. - In half blocks the card fits the 53x26 panel only by sending its link a page down; - braille's two-by-four cell holds the whole standard code in 29 cells by 15 rows. Every - module has to survive the packing, so the braille is decoded back and held against the - code's own matrix. A desktop font's braille is dotted, so the regular platform never - steps down to it. + So the contact card is 29 cells by 15 rows at the standard fit and shares the 53x26 + panel with its link, where half blocks sent the link a page down; and a page's code + is braille too. Every module has to survive the packing, so the braille is decoded + back and held against the code's own matrix. A desktop font's braille is dotted, so + the regular platform never draws a code in it. """ import segno from meshterm.platforms import PICOCALC, set_platform + def is_braille(text: str) -> bool: + return any("⠀" <= ch <= "⣿" for ch in text) + url = "meshcore://contact/add?name=YUL-Cartierville&public_key=" + "ab" * 32 + "&type=2" - assert not any("⠀" <= ch <= "⣿" for ch in fit_qr(url, 53, 22).plain) + channel = "meshcore://channel/add?name=Test&secret=" + "ab" * 16 + assert not is_braille(fit_qr(url, 53, 22).plain) and not is_braille(qr_text(channel).plain) set_platform(PICOCALC) + assert is_braille(qr_text(channel).plain) and "█" not in qr_text(channel).plain screen = QrScreen(url, title="Share YUL-Cartierville") screen.note_viewport(26) lines = [_ANSI.sub("", line) for line in screen.render_body(53)]