From 197af90c8ef80da0848388438b523879325236a8 Mon Sep 17 00:00:00 2001 From: Jean-Pierre Martineau Date: Sat, 12 Sep 2026 03:29:46 -0400 Subject: [PATCH] =?UTF-8?q?A=20share=20code=20is=20centred=20as=20a=20bloc?= =?UTF-8?q?k,=20never=20justified=20=E2=80=94=20the=20bottom=20finder=20wa?= =?UTF-8?q?s=20skewed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rich's centre justify strips each row's trailing spaces before it pads it, so a code row whose right edge was light modules lost cells and landed a column off its neighbours; the bottom-left finder square came out one cell skewed, and a camera could not lock on. The About pages' codes were never centred, which is why only the full-screen share codes broke. A code is now moved across a line one way only: the same indent in front of every row, computed once from the fitted code's width. The test pins that all rows share a width and every finder starts in the same column. Co-Authored-By: Claude Fable 5.1 --- meshterm/ui/qr.py | 44 ++++++++++++++++++++++++++++-------------- tests/test_channels.py | 6 ++++++ 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/meshterm/ui/qr.py b/meshterm/ui/qr.py index 1bfc7f7..961407c 100644 --- a/meshterm/ui/qr.py +++ b/meshterm/ui/qr.py @@ -59,8 +59,15 @@ _STYLE = "qr" _FITS: tuple[tuple[str, int], ...] = (("m", 4), ("l", 4), ("m", 2), ("l", 2)) -def _draw(code: segno.QRCode, border: int) -> Text: - """Draw a segno code as half-block rows, ``border`` light modules around it.""" +def _draw(code: segno.QRCode, border: int, *, indent: int = 0) -> Text: + """Draw a segno code as half-block rows, ``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 + strips each row's trailing spaces before it pads, so a row whose right edge is light + 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. + """ rows = [[bool(v) for v in row] for row in code.matrix_iter(border=border)] # Pair rows top-to-bottom; pad an odd final row with light modules so the last # half-block cell renders cleanly. @@ -71,6 +78,8 @@ def _draw(code: segno.QRCode, border: int) -> Text: for i, (top, bottom) in enumerate(zip(rows[0::2], rows[1::2], strict=True)): if i: text.append("\n") + if indent: + text.append(" " * indent) line = "".join(_GLYPH[(t, b)] for t, b in zip(top, bottom, strict=True)) text.append(line, style=_STYLE) return text @@ -93,8 +102,8 @@ def qr_text(data: str, *, error: str = "m", border: int = _BORDER) -> Text: return _draw(segno.make(data, error=error), border) -def _fit(data: str, width: int, height: int | None) -> Text | None: - """The code at the first of :data:`_FITS` within ``width`` cells and ``height`` rows. +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). ``None`` when no fit is that small — the caller decides what to relax. """ @@ -104,10 +113,18 @@ def _fit(data: str, width: int, height: int | None) -> Text | None: code = segno.make(data, error=error) modules = code.symbol_size(border=border)[0] if modules <= width and (height is None or (modules + 1) // 2 <= height): - return _draw(code, border) + return code, border return None +def _smallest(data: str) -> tuple[segno.QRCode, int]: + """The last of :data:`_FITS` — what a frame too small for any fit gets anyway.""" + import segno + + error, border = _FITS[-1] + return segno.make(data, error=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 given. @@ -122,11 +139,8 @@ def fit_qr(data: str, width: int, height: int | None = None) -> Text: Returns: The code, as :func:`qr_text` draws it. """ - fitted = _fit(data, width, height) - if fitted is None: - error, border = _FITS[-1] - fitted = qr_text(data, error=error, border=border) - return fitted + code, border = _fit(data, width, height) or _smallest(data) + return _draw(code, border) class QrScreen(ScrollScreen): @@ -166,13 +180,15 @@ class QrScreen(ScrollScreen): rows = self._scroll_viewport link = Text(self.url, style="accent", justify="center") link_rows = len(render_lines(link, width)) - code = ( + code, border = ( _fit(self.url, width, rows - 1 - link_rows) or _fit(self.url, width, rows) - or fit_qr(self.url, width) + or _fit(self.url, width, None) + or _smallest(self.url) ) - code.justify = "center" - self.replace_content(Group(code, Text(""), link)) + # Centred as a block — one indent for every row (see _draw), never justified. + indent = max(0, (width - code.symbol_size(border=border)[0]) // 2) + 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 523f004..53941e0 100644 --- a/tests/test_channels.py +++ b/tests/test_channels.py @@ -269,6 +269,12 @@ def test_the_share_screen_refits_its_code_to_the_frame_every_paint() -> None: roomy = code_rows(120, 40) assert len(roomy) == 29 # the standard fit, where there is room for it assert roomy[0].startswith(" " * 30), "centred across the whole width" + # Centred as a block, never per row: every row carries the same indent and the same + # width, so the finder squares stack square. Per-row centring stripped each row's + # trailing light modules before padding it, and skewed the bottom finder a cell. + assert len({len(row) for row in roomy}) == 1, "rows differ in width — a skewed code" + finder = "█▀▀▀▀▀█" + assert len({row.index(finder) for row in roomy if finder in row}) == 1, "a finder is skewed" assert len(code_rows(72, 24)) <= 24 # a regular terminal: the code whole, link below assert len(code_rows(53, 26)) <= 26 # the PicoCalc: likewise