On the PicoCalc, a chat message's links hang as QR codes under it

Every URL in a channel or direct message, any scheme (a meshcore://
share as surely as a web link), now gets a braille code under the
message, hung at the body's indent. Several sit side by side and wrap
onto a new band when the line is full, so a link read on the handheld
opens on a phone instead of being typed out. Platform.url_codes keeps
it to the PicoCalc: half-block codes would swamp a desktop transcript,
and the Cardputer's 14 rows can't spare 9 under every link.

A picked message now keeps its whole block in view, codes included,
rather than only its first row, so walking the pick down onto a link
no longer leaves its code below the fold.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5.5 committed 2026-10-01 00:36:50 -04:00
1 parent 28bd59cf1b
commit ec850d8e93
6 files changed
+317 -19

No files matched your search

+8 -4
View File
@@ -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.
+11
View File
@@ -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,
+67 -1
View File
@@ -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."""
+86 -14
View File
@@ -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.
+124
View File
@@ -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
+21
View File
@@ -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),