diff --git a/CLAUDE.md b/CLAUDE.md index c71b92b..a8bbd07 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -243,10 +243,14 @@ deliberately, one at a time, and say why in the code. narrower quiet zone, before it would ever be cut (a contact card is 57 cells and 29 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, the Cardputer's console host, - `host/font.BRAILLE`) every code — share screen and page fence alike — is drawn in + Two codes sit inline instead: a ` ```qr ` fence in a written page, where the code is an + illustration drawn in the prose where the fence is, and — on the PicoCalc alone + (`Platform.url_codes`) — a chat message's URLs, each drawn as a code under the message + at its body's indent, side by side, wrapping onto a new band when the line is full + (`qr.qr_strip`). A picked message keeps the codes under it in view. Where the font + draws braille solid (`Platform.solid_braille`: the PicoCalc's built fonts, the + Cardputer's console host, `host/font.BRAILLE`) every code — share screen, page fence + and chat 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. diff --git a/meshterm/platforms.py b/meshterm/platforms.py index bda4148..fbacf9c 100644 --- a/meshterm/platforms.py +++ b/meshterm/platforms.py @@ -110,6 +110,13 @@ class Platform: 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. + url_codes: Whether a chat message's URLs are drawn as QR codes under it, side by + side (:meth:`~meshterm.ui.chat.ChatScreen._body_lines`) — so a link read on the + handheld opens on a phone without being typed out. The PicoCalc's alone: it + takes braille codes (:attr:`solid_braille`) to be small enough to sit in a + transcript at all, a quarter the area of the half blocks a desktop draws, and + it takes rows — a short link's code is 9 of them, which the Cardputer's 14 + cannot spare under every message that carries one (JP, 2026-10-01). 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 @@ -158,6 +165,7 @@ class Platform: font: str truecolor: bool solid_braille: bool + url_codes: bool effects: bool tick_s: float spinner_tick_s: float @@ -187,6 +195,7 @@ REGULAR = Platform( font="", truecolor=True, solid_braille=False, + url_codes=False, effects=True, tick_s=1.0, spinner_tick_s=0.12, @@ -217,6 +226,7 @@ PICOCALC = Platform( font="picocalc", truecolor=False, solid_braille=True, + url_codes=True, effects=False, tick_s=2.0, spinner_tick_s=0.5, @@ -255,6 +265,7 @@ CARDPUTER = Platform( # The console host draws its own braille, as the PicoCalc's built fonts do: solid # tiles, no gap between dots (meshterm/host/font.py, BRAILLE). solid_braille=True, + url_codes=False, effects=False, tick_s=2.0, spinner_tick_s=0.5, diff --git a/meshterm/ui/chat.py b/meshterm/ui/chat.py index ebfb880..25d5714 100644 --- a/meshterm/ui/chat.py +++ b/meshterm/ui/chat.py @@ -17,6 +17,7 @@ screen is open. from __future__ import annotations import asyncio +import re from collections.abc import Awaitable, Callable from typing import TYPE_CHECKING, Any @@ -28,6 +29,8 @@ from ..core.connection import ContactNotOnDeviceError from ..core.events import EventKind, MeshEvent from ..core.models import ChatMessage, Contact, Conversation, Message, utcnow from ..core.regions import WILDCARD +from ..platforms import Platform, on_platform +from .qr import qr_strip from .theme import name_style, snr_style from .tui.prompt import ( CHANNEL_BYTE_LIMIT, @@ -52,6 +55,47 @@ if TYPE_CHECKING: _DELIVERED = ("✓", "ok") _FAILED = ("✗", "err") +#: A URL in a message body: a scheme, ``://``, and everything up to the next space. A +#: scheme rather than a list of them, so a ``meshcore://`` share pasted into a chat gets +#: its code as surely as a web link does. +_URL = re.compile(r"\b[a-z][a-z0-9+.-]*://[^\s<>\"]+", re.IGNORECASE) + +#: Punctuation that follows a URL in a sentence and isn't part of it. +_TRAILING = ".,;:!?'\"" + +#: Each closing bracket and its opener: a closer ends a URL only when it has no opener +#: inside it, so ``(see https://example.com/a_(b))`` keeps the ``)`` that belongs to it. +_CLOSERS = {")": "(", "]": "[", "}": "{"} + +#: Whether a message's URLs are drawn as QR codes under it (``Platform.url_codes``, the +#: PicoCalc's alone). Bound per platform, never asked per frame. +_URL_CODES = False + + +@on_platform +def _bind(platform: Platform) -> None: + """Hang codes under URLs where the platform draws them (now and on switches).""" + global _URL_CODES + _URL_CODES = platform.url_codes + + +def _urls(body: str) -> list[str]: + """Every URL in ``body`` in reading order, each only once, without trailing punctuation.""" + found: list[str] = [] + for match in _URL.finditer(body): + url = match.group() + while url: + last = url[-1] + if last in _TRAILING or ( + last in _CLOSERS and url.count(last) > url.count(_CLOSERS[last]) + ): + url = url[:-1] + else: + break + if not url.endswith("://") and url not in found: + found.append(url) + return found + class ChatScreen(Screen): """A live conversation: a scrolling transcript above a pinned input line. @@ -211,6 +255,9 @@ class ChatScreen(Screen): # focused), plus the body line it rendered on so the frame keeps it in view. self._selected: int | None = None self._selected_line: int | None = None + # One past the picked message's last rendered row, so its whole block is kept in + # view, not just its head (see render_body). + self._selected_end: int | None = None # Memoizes _render_grouped's output per message, so a repaint triggered by # something outside the transcript (a keystroke, the 1s idle tick, a spinner # frame) only re-renders the rows that actually changed. See _render_grouped. @@ -266,6 +313,7 @@ class ChatScreen(Screen): def render_body(self, width: int) -> list[str]: """Render the transcript, a divider, the input line, and any status.""" self._selected_line = None + self._selected_end = None if not self._messages: lines = render_lines(Text("No messages yet — say hello!", style="muted"), width) else: @@ -301,6 +349,14 @@ class ChatScreen(Screen): # to the final lines (input + latest messages). Scrolling up clears the stick. if self._stick: self.scroll = len(lines) + elif self._selected_end is not None: + # The pick keeps its *whole* message in view, the codes hanging under its text + # included. The frame holds only the one line cursor_line names, the message's + # head, so a pick walking down would park the head on the bottom row and leave + # its codes below the fold. Scroll far enough to show the message's last row + # (one spare for the day divider pinned over the top); the frame then raises + # the view again only if that would push the head off the top. + self.scroll = max(self.scroll, self._selected_end - self._scroll_viewport + 1) return lines def cursor_line(self) -> int | None: @@ -453,6 +509,8 @@ class ChatScreen(Screen): # title bar's ↑ lit over a transcript with nothing above it. self._selected_line = 0 if idx == 0 else len(lines) lines += self._body_lines(body, message, width, selected=selected) + if selected: + self._selected_end = len(lines) prev_group, prev_day = group, day ids.append(id(message)) ackeds.append(message.acked) @@ -512,13 +570,21 @@ class ChatScreen(Screen): A long body wraps with a hanging indent so continuation lines align under the body rather than under the timestamp gutter. When ``selected``, the line is marked as the reply target (matching the select screen's ``❯`` pointer and cursor highlight). + + Where the platform draws them (:data:`_URL_CODES`, the PicoCalc), each URL in the + body gets a QR code under it, side by side and hanging at the body's indent, so a + link read on the handheld can be opened on a phone instead of typed out. """ stamp = message.created_at.astimezone() prefix = Text() prefix.append("❯ " if selected else " ", style="cursor" if selected else None) prefix.append(f"{stamp:%H:%M} ", style="cursor" if selected else "muted") body_text = self._body_text(body, message, selected=selected) - return render_hanging(prefix, body_text, width, indent=prefix.cell_len) + lines = render_hanging(prefix, body_text, width, indent=prefix.cell_len) + urls = _urls(body) if _URL_CODES else [] + if urls: + lines += render_lines(qr_strip(urls, width, indent=prefix.cell_len), width) + return lines def _body_text(self, body: str, message: ChatMessage, *, selected: bool) -> Text: """Build the styled body of a message: mentions colored, then any trailing glyphs.""" diff --git a/meshterm/ui/qr.py b/meshterm/ui/qr.py index 156ebd3..609e230 100644 --- a/meshterm/ui/qr.py +++ b/meshterm/ui/qr.py @@ -16,9 +16,10 @@ on a screen. When the code *is* the answer it is a **full-screen** thing: :func:`share_screen` puts it on a bare frame — no header, no footer, no title, no box — with nothing beside it but the URL it encodes, so the whole panel is contrast for the camera and the one line a reader -might type out instead. The one place a code sits *inside* a page is a ``qr`` fence in a +might type out instead. A code sits *inside* a page in two places: a ``qr`` fence in a written page (:mod:`~meshterm.ui.markdown`), which draws :func:`qr_text` in the flow of -its prose. +its prose, and — on the PicoCalc — a chat message carrying URLs, which hangs a code for +each under its text, side by side (:func:`qr_strip`). """ from __future__ import annotations @@ -138,6 +139,21 @@ def _draw(code: segno.QRCode, border: int, *, indent: int = 0) -> Text: 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. """ + 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 platform's grid, one string of glyphs per cell row. + + Every row is the same number of cells, so codes set 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)) @@ -146,20 +162,13 @@ def _draw(code: segno.QRCode, border: int, *, indent: int = 0) -> Text: span = cols * grid.across rows = [row + [False] * (span - len(row)) for row in rows] rows += [[False] * span for _ in range(-len(rows) % grid.down)] - - text = Text(no_wrap=True) - for i, top in enumerate(range(0, len(rows), grid.down)): - if i: - text.append("\n") - if indent: - text.append(" " * indent) - band = rows[top : top + grid.down] - line = "".join( - grid.glyph([row[x : x + grid.across] for row in band]) + 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) ) - text.append(line, style=_STYLE) - return text + for top in range(0, len(rows), grid.down) + ] def qr_text(data: str, *, error: str = "m", border: int = _BORDER) -> Text: @@ -214,6 +223,69 @@ def fit_qr(data: str, width: int, height: int | None = None) -> Text: return _draw(code, border) +#: Blank cells between two codes set side by side. Each code's own quiet zone is what +#: keeps a camera from reading two as one; the gap only says where one code's field ends. +_GAP = 1 + + +@lru_cache(maxsize=64) +def _fitted_rows(data: str, width: int, grid: _Grid) -> tuple[str, ...]: + """``data``'s code at its first fit within ``width`` cells, as glyph rows. + + Cached because a transcript asks for the same code on every paint of a message it + can't reuse (the picked one). ``grid`` is in the key so a platform switch never + serves a code packed for the other one. + """ + 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, wrapping onto a new band when full. + + Each code takes the first fit within the room after ``indent``, as a frame's code does + (:data:`_FITS`), and the codes run left to right, :data:`_GAP` apart. One that would + pass the right edge starts the next band, top-aligned with any others on it. There is + no blank row between bands, because the quiet zones above and below already separate + them. + + Args: + data: The strings to encode, in reading order. + width: Cells across the whole line, ``indent`` included. + indent: Bare cells in front of every row, so the strip can hang under a text block. + + Returns: + The codes as one no-wrap :class:`~rich.text.Text`, white on black like every 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, not its field + text.append(" " * len(rows[0])) + return text + + class QrScreen(ScrollScreen): """THE share screen: a code on a bare frame, and under it the URL it encodes. diff --git a/tests/test_chat.py b/tests/test_chat.py index 948667a..abe242b 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -2310,3 +2310,127 @@ def test_chat_fkey_lane_steps_by_day_on_the_free_left_pair() -> None: assert fkeys.PICOCALC_DECK.action_for(spread.picocalc_lane, 1) == "ctrl_pageup" spread.handle(fkeys.PICOCALC_DECK.action_for(spread.picocalc_lane, 1)) assert spread._selected == 0 # jumped to the first message of the older day + + +# -- URL codes (the PicoCalc) --------------------------------------------------- + + +def _code_rows(lines: list[str]) -> list[str]: + """The rendered rows that carry braille — a QR code's, in a transcript.""" + return [ln for ln in lines if any("⠀" <= ch <= "⣿" for ch in ln)] + + +def _decode_braille(rows: list[str], left: int, size: int) -> list[list[bool]]: + """The ``size``-module code drawn in braille from cell ``left`` of ``rows``.""" + dots = ((0x01, 0x02, 0x04, 0x40), (0x08, 0x10, 0x20, 0x80)) + return [ + [ + bool((ord(rows[y // 4][left + x // 2]) - 0x2800) & dots[x % 2][y % 4]) + for x in range(size) + ] + for y in range(size) + ] + + +def test_urls_are_found_whole_once_and_without_the_sentence_around_them() -> None: + """A URL ends where its sentence's punctuation begins, and keeps a bracket it opened.""" + from meshterm.ui.chat import _urls + + body = ( + "map at https://meshterm.net. see (https://en.wikipedia.org/wiki/Foo_(bar)), " + "'https://example.org/a?b=1' and meshcore://channel/add?name=Ops&secret=ab — " + "again https://meshterm.net! nothing here: https:// or note:x" + ) + assert _urls(body) == [ + "https://meshterm.net", + "https://en.wikipedia.org/wiki/Foo_(bar)", + "https://example.org/a?b=1", + "meshcore://channel/add?name=Ops&secret=ab", + ] + + +def test_on_the_picocalc_each_url_hangs_a_code_under_its_message_side_by_side() -> None: + """Two links, two braille codes on the same rows, hung at the body's own indent. + + Every module has to survive the packing: each code is decoded back out of the braille + and held against segno's matrix for its URL, at the standard fit. + """ + import segno + + from meshterm.platforms import PICOCALC, set_platform + + set_platform(PICOCALC) + first, second = "https://meshterm.net", "https://github.com/jpmartineau/MeshTerm" + conv = Conversation(label="#ops", is_channel=True, channel_idx=1) + message = ChatMessage(text=f"Alice: map at {first}, code at {second}!", is_channel=True) + screen = ChatScreen(conv, [message], send=None, names={}, session=_StubSession()) + rows = _code_rows(_strip_ansi(screen._render_grouped(53)).splitlines()) + + indent = 9 # " HH:MM " — the body's hanging indent + assert rows and all(len(row) <= 53 and row.startswith(" " * indent) for row in rows) + cells = [row[indent:].ljust(53 - indent) for row in rows] + left, tallest = 0, 0 + for url in (first, second): + matrix = [ + [bool(v) for v in row] for row in segno.make(url, error="m").matrix_iter(border=4) + ] + assert _decode_braille(cells, left, len(matrix)) == matrix, url + left += -(-len(matrix) // 2) + 1 # its cells, then the one-cell gap + tallest = max(tallest, -(-len(matrix) // 4)) + assert len(rows) == tallest # one band, as tall as its taller code + + +def test_a_regular_terminal_hangs_no_code_under_a_url() -> None: + """Half-block codes are four times the area; under every link they'd swamp the chat.""" + conv = Conversation(label="#ops", is_channel=True, channel_idx=1) + message = ChatMessage(text="Alice: map at https://meshterm.net", is_channel=True) + screen = ChatScreen(conv, [message], send=None, names={}, session=_StubSession()) + rendered = _strip_ansi(screen._render_grouped(72)) + assert "https://meshterm.net" in rendered + assert not _code_rows(rendered.splitlines()) + assert not any(ch in "▀▄█" for ch in rendered) + + +def test_codes_that_outrun_the_line_start_a_new_band() -> None: + """Two short links fit beside each other in the 44 cells after the indent; a third wraps.""" + import segno + + from meshterm.platforms import PICOCALC, set_platform + from meshterm.ui.qr import qr_strip + + set_platform(PICOCALC) + urls = [f"https://meshterm.net/{c}" for c in "abc"] + size = segno.make(urls[0], error="m").symbol_size(border=4)[0] + cols, height = -(-size // 2), -(-size // 4) + rows = qr_strip(urls, 53, indent=9).plain.splitlines() + + assert len(rows) == 2 * height and all(len(row) <= 53 for row in rows) + assert all(row[9 + cols] == " " and row[9 + cols + 1] != " " for row in rows[:height]) + assert all(row[9 + cols :].strip() == "" for row in rows[height:]) + + +def test_a_picked_message_keeps_its_codes_in_view() -> None: + """Walking the pick down onto a link shows its code, not just the message's first row.""" + from meshterm.platforms import PICOCALC, set_platform + from meshterm.ui.tui import frame + + set_platform(PICOCALC) + conv = Conversation(label="#ops", is_channel=True, channel_idx=1) + messages = [ChatMessage(text=f"Alice: line {i}", is_channel=True) for i in range(12)] + messages.insert(6, ChatMessage(text="Bob: map at https://meshterm.net", is_channel=True)) + screen = ChatScreen(conv, messages, send=None, names={}, session=_StubSession()) + viewport = 16 + + def paint() -> list[str]: + visible, _above, _below = frame._visible_slice(screen, screen.render_body(53), viewport) + return _strip_ansi(visible).splitlines() + + paint() + screen.handle("ctrl_home") # the oldest message, then walk down onto the link + for _ in range(6): + paint() + screen.handle("down") + shown = paint() + whole = _code_rows(_strip_ansi(screen._render_grouped(53)).splitlines()) + assert any("map at https://meshterm.net" in row for row in shown) + assert len(_code_rows(shown)) == len(whole) # every row of the code, none below the fold diff --git a/tests/test_gallery.py b/tests/test_gallery.py index 64d8102..42147d6 100644 --- a/tests/test_gallery.py +++ b/tests/test_gallery.py @@ -542,6 +542,26 @@ def _chat_channel_scoped(cols: int, rows: int) -> Screen: ) +def _chat_urls(cols: int, rows: int) -> Screen: + """A channel message carrying two links: on the PicoCalc, two codes side by side under it. + + The longest pair that still sits side by side in the 44 cells after the body's indent, + so the width gate sees the widest band a transcript draws; regular draws no codes. + """ + conv = Conversation(label="Lakeside emergency", is_channel=True, channel_idx=2) + messages = [ + ChatMessage( + text=( + "Alice: shelters on https://meshterm.net/map and the road list at " + "https://github.com/jpmartineau/MeshTerm" + ), + is_channel=True, + ), + ChatMessage(text="thanks, on it", outbound=True, is_channel=True), + ] + return ChatScreen(conv, messages, send=None, names={}, session=_GallerySession(cols, rows)) + + class _PickerChat: """The unread counter the picker rows read live.""" @@ -1406,6 +1426,7 @@ _ENTRIES: list[_Entry] = [ _Entry("map_find", _map_find), _Entry("chat", _chat), _Entry("chat_channel_scoped", _chat_channel_scoped), + _Entry("chat_urls", _chat_urls), _Entry("chat_picker", _chat_picker), _Entry("channels_manager", _channels_manager), _Entry("channel_detail", _channel_detail),