diff --git a/meshterm/ui/config_editor.py b/meshterm/ui/config_editor.py index 78723ff..da80bd6 100644 --- a/meshterm/ui/config_editor.py +++ b/meshterm/ui/config_editor.py @@ -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"), ) diff --git a/meshterm/ui/qr.py b/meshterm/ui/qr.py index 098ef56..c51b902 100644 --- a/meshterm/ui/qr.py +++ b/meshterm/ui/qr.py @@ -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 diff --git a/tests/test_config_editor.py b/tests/test_config_editor.py index afbed50..c2b9732 100644 --- a/tests/test_config_editor.py +++ b/tests/test_config_editor.py @@ -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 ---------------------------------------------------------- diff --git a/tests/test_qr.py b/tests/test_qr.py new file mode 100644 index 0000000..fc564a6 --- /dev/null +++ b/tests/test_qr.py @@ -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)