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:
1 parent
28bd59cf1b
commit
ec850d8e93
6 files changed
+317
-19
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user