Share card: render the QR in braille, one dot per module

A braille cell packs a 2x4 block of modules, so the code draws at half
the width and a quarter of the height of the half-block form (27x14 vs
53x27 for a contact URL) — small enough for the 40-column PicoCalc
panel. Simulated-camera decode tests pass with dot fills down to 50%,
since a phone's blur merges the dots into blobs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Fable 5 committed 2026-07-26 10:37:55 -04:00
1 parent 25527356a4
commit f6306170ed
4 files changed
+99 -3

No files matched your search

+2 -2
View File
@@ -815,13 +815,13 @@ async def show_contact_card(
node_type: The MeshCore advert type byte (1 companion, 2 repeater, 3 room,
4 sensor).
"""
from .qr import qr_text
from .qr import qr_braille
url = contact_share_url(name, public_key, node_type)
body = Group(
Text("Scan to add this node as a contact:", style="muted"),
Text(""),
qr_text(url),
qr_braille(url),
Text(""),
Text(url, style="accent"),
)
+54
View File
@@ -5,6 +5,12 @@ Each character cell stacks two vertical modules — ``▀`` (upper only), ``▄`
one and comfortably fits an interactive window. Modules are always drawn black on a white
background (with the mandatory quiet-zone border), so a phone camera reads the code no
matter what colour theme the terminal itself is using.
:func:`qr_braille` is the dense variant: one braille dot per module packs a 2×4 block of
modules into every cell — half the columns and a quarter of the rows of the half-block
form. The trade is ink coverage: a braille dot is a dot, not a filled cell, so dark
regions carry white in-between and scanning leans on the camera's tolerance for
dot-style codes.
"""
from __future__ import annotations
@@ -27,6 +33,12 @@ _GLYPH = {
#: contrast holds regardless of the surrounding terminal palette.
_STYLE = "black on white"
#: Braille dot bit for each (column, row) of a cell's 2×4 module block, rows top-down —
#: the Unicode block numbers dots 1,2,3,7 down the left column and 4,5,6,8 down the
#: right (``braillechart`` keeps the same tables flipped bottom-up for bar math; QR
#: rows arrive top-down, so these stay in raster order).
_BRAILLE_BITS = ((0x01, 0x02, 0x04, 0x40), (0x08, 0x10, 0x20, 0x80))
def qr_text(data: str, *, error: str = "m") -> Text:
"""Render ``data`` as a QR code built from half-block characters.
@@ -54,3 +66,45 @@ def qr_text(data: str, *, error: str = "m") -> Text:
text.append(line, style=_STYLE)
text.append("\n")
return text
def qr_braille(data: str, *, error: str = "m") -> Text:
"""Render ``data`` as a QR code built from braille characters, one dot per module.
Each cell carries a 2-wide × 4-tall block of modules, so the code draws at half the
width and a quarter of the height of :func:`qr_text` — small enough to leave room
around it even on the 40-column PicoCalc panel. Same black-on-white contract; the
empty cell is ``U+2800`` (the blank braille pattern) rather than a space so every
glyph comes from the one Unicode block and renders at one consistent width.
Args:
data: The string to encode.
error: QR error-correction level — ``l``/``m``/``q``/``h`` (default ``m``).
Returns:
A Rich :class:`~rich.text.Text` (no-wrap) whose lines draw the code.
"""
import segno
qr = segno.make(data, error=error)
rows = [[bool(v) for v in row] for row in qr.matrix_iter(border=_BORDER)]
# Pad to whole cells with light modules: out to a multiple of 4 rows and 2 columns.
while len(rows) % 4:
rows.append([False] * len(rows[0]))
if len(rows[0]) % 2:
for row in rows:
row.append(False)
text = Text(no_wrap=True)
for band in zip(*(rows[i::4] for i in range(4)), strict=True):
line = ""
for x in range(0, len(band[0]), 2):
bits = 0
for col in (0, 1):
for dot_row in range(4):
if band[dot_row][x + col]:
bits |= _BRAILLE_BITS[col][dot_row]
line += chr(0x2800 + bits)
text.append(line, style=_STYLE)
text.append("\n")
return text
+2 -1
View File
@@ -91,7 +91,8 @@ async def test_show_contact_card_pops_the_qr_over_the_link() -> None:
console.print(renderable)
out = console.export_text()
assert f"meshcore://contact/add?name=Hub&public_key={'ab' * 32}&type=2" in out
assert "█" in out # the QR actually drew its modules
# The QR actually drew its modules — braille dots, one per module.
assert any(0x2801 <= ord(ch) <= 0x28FF for ch in out)
# -- coordinate parsing ----------------------------------------------------------
+41
View File
@@ -0,0 +1,41 @@
"""The terminal QR renderers: half-block and braille forms of the same matrix."""
import segno
from meshterm.ui.qr import _BORDER, _BRAILLE_BITS, qr_braille, qr_text
URL = "meshcore://contact/add?name=Test&public_key=" + "ab" * 32 + "&type=1"
def _matrix(data: str) -> list[list[bool]]:
qr = segno.make(data, error="m")
return [[bool(v) for v in row] for row in qr.matrix_iter(border=_BORDER)]
def test_qr_text_matches_matrix() -> None:
rows = _matrix(URL)
lines = qr_text(URL).plain.rstrip("\n").split("\n")
glyph_bits = {"█": (True, True), "▀": (True, False), "▄": (False, True), " ": (False, False)}
for y, row in enumerate(rows):
for x, dark in enumerate(row):
top, bottom = glyph_bits[lines[y // 2][x]]
assert (top if y % 2 == 0 else bottom) == dark
def test_qr_braille_matches_matrix() -> None:
"""Every module maps to exactly its braille dot — one dot per module, raster order."""
rows = _matrix(URL)
lines = qr_braille(URL).plain.rstrip("\n").split("\n")
for y, row in enumerate(rows):
for x, dark in enumerate(row):
cell = ord(lines[y // 4][x // 2]) - 0x2800
assert bool(cell & _BRAILLE_BITS[x % 2][y % 4]) == dark
def test_qr_braille_geometry() -> None:
"""Whole-cell padding: every line is braille-block glyphs at half width, quarter height."""
rows = _matrix(URL)
lines = qr_braille(URL).plain.rstrip("\n").split("\n")
assert len(lines) == -(-len(rows) // 4)
assert all(len(line) == -(-len(rows[0]) // 2) for line in lines)
assert all(0x2800 <= ord(ch) <= 0x28FF for line in lines for ch in line)