^L shows a message's author or a contact on the map, and ^O logs in to a room

locate moves from ^Y to ^L, and the room's login from ^L to ^O, under the
chord rule of 3675226. On the map and in the mesh walk, ^L still goes to
our node. In a chat and on a room's board, ^L opens the map on the author
of the selected message (our own message: our node); on Contacts, on the
highlighted node. map_screen.Locator holds the markers that
gather_markers returns when the screen opens and answers find() at each
paint, so the "^L map" hint atom and the Shift+F1 Map chip show only
where the key acts. A room keeps Log in on Shift+F1, so ^L has no chip
on a room's board yet.

The chat's hints for a selected message go through _fit_hint, which
drops ↑↓ and then ^P only when the line passes 72 cells; the room now
reuses them. LaneDeck.action_for returns None for a slot that has only
a Shift companion.

Right Ctrl with a letter chord still does nothing on the Canadian
Multilingual layout: JP chose not to repair that.

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-10 17:20:42 -04:00
1 parent 3675226a40
commit 125ddb4003
20 files changed
+573 -86

No files matched your search

+15
View File
@@ -9,6 +9,21 @@ can change how things behave. It does not only add new things.
## [Unreleased]
### Added
- **^L shows a node on the map from a chat and from Contacts.** In a chat or on the board
of a room, select a message and press ^L. The map opens on the node that wrote it. On
the Contacts page, ^L opens the map on the highlighted contact. The key works only when
MeshTerm knows where the node is, and the hint at the bottom of the screen names it only
then. On the handhelds, the chip is `Map`, on Shift+F1. The board of a room has no free
chip for it yet.
### Changed
- **^L is now the map key, and ^O logs in to a room.** On the map and in the mesh walk, ^L
goes back to your own node. That key was ^Y. On the board of a room, ^O logs in. That
key was ^L.
## [0.10.3] - 2026-10-07
**You can join room servers, move your channels to a new device, and find the public
+2 -2
View File
@@ -281,7 +281,7 @@ deliberately, one at a time, and say why in the code.
- **A bare-letter shortcut only where nothing is typed.** A screen that filters, finds or
composes as you type has no free letters, so its verbs are Ctrl chords — one table,
`session._CTRL_LETTER_CHORDS`, each letter the mnemonic of its action (`^P` paths,
`^U` URL, `^Y` you, `^L` log in). One verb per letter across screens is the preference,
`^U` URL, `^L` locate, `^O` log in). One verb per letter across screens is the preference,
not the rule (JP, 2026-10-10): a screen that has no use for a letter's verb may give the
letter a verb of its own, so long as nothing between the keyboard and the app takes it —
`^A`/`^B` are screen's and tmux's prefixes, and `^M`/`^I`/`^H`/`^J` *are* Enter, Tab,
@@ -758,7 +758,7 @@ branch on the platform per frame, and never `from meshterm.platforms import PLAT
thing that just isn't available this paint. Dimming is presentational — `handle` stays
the authority and no-ops. The lane is also the *only* affordance advertisement here, so
anything the desktop reaches by a chord or a bare letter (a list's `^PgUp/^PgDn` section
jumps, the Time Machine's `w`, the map's `^Y`, a row's `Del`) earns a slot — otherwise
jumps, the Time Machine's `w`, the map's `^L`, a row's `Del`) earns a slot — otherwise
it is undiscoverable on the device.
- **Each handheld deals its own lane.** Everything about the lane that belongs to one
keyboard is a `LaneDeck` (`fkeys.PICOCALC_LYRA_DECK`, `fkeys.CARDPUTER_ZERO_DECK`), named by
+1
View File
@@ -255,6 +255,7 @@ and in quoted text.
| visible | On the screen | in view |
| viewport | The part of a page, or of the map, that the screen shows. A page that is taller than its viewport scrolls | view, window (for this) |
| list window | The visible rows of a list that scrolls inside a screen (`ListWindow`) | window (alone) |
| locate | Open the map on a node, or move the map to our node (the `locate` action, ^L) | find (for this) |
### The program
+127 -17
View File
@@ -21,6 +21,7 @@ import re
from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING, Any
from rich.cells import cell_len
from rich.console import Group, RenderableType
from rich.text import Text
@@ -46,6 +47,8 @@ from .widgets import name_chip
if TYPE_CHECKING:
from ..context import AppContext
from .map_render import MapMarker
from .map_screen import Locator
#: The delivery marks for a *resolved* outbound direct message, at the end of its line:
@@ -77,6 +80,25 @@ def _bind(platform: Platform) -> None:
_URL_CODES = platform.url_codes
#: The maximum width of a footer hint, in cells (refer to the UX standards).
_HINT_CELLS = 72
def _fit_hint(atoms: list[str], spare: tuple[str, ...] = ()) -> str:
"""Join the hint atoms that are not empty, and remove ``spare`` atoms until the hint fits.
The ``spare`` atoms go in the given order, and only while the hint is wider than
:data:`_HINT_CELLS`. Thus a key that the line can hold stays on it.
"""
kept = [atom for atom in atoms if atom]
for atom in spare:
if cell_len(" · ".join(kept)) <= _HINT_CELLS:
break
if atom in kept:
kept.remove(atom)
return " · ".join(kept)
class ChatScreen(Screen):
"""A live conversation: a transcript that scrolls, above a pinned input line.
@@ -88,7 +110,9 @@ class ChatScreen(Screen):
opens the delivery paths of the message. In the two types of chat, ^P opens the paths
of the selected message. A path belongs to one message, so when no message is
selected, there is nothing to show. ^U shows the links of the selected message as QR
codes, on the share screen. When no message is selected, Esc leaves the chat.
codes, on the share screen. ^L opens the map on the node of the author of the selected
message, when that node has a location. When no message is selected, Esc leaves the
chat.
A channel with a send scope shows its region in the title (``#ops · yul``). There, ^R
sends a message again *unscoped*: the selected message, or the newest when no message is
@@ -142,6 +166,11 @@ class ChatScreen(Screen):
The two are actions *on* the selected message: the codes of its links on a share
screen, larger than the codes under it in the transcript. ``QR`` is lit while the
selection has a URL.
``Map`` (^L) is on the Shift half of F1, the one slot that the rest of the lane
leaves free. It is lit while the author of the selected message has a location
(refer to :meth:`_locate_target`). The room puts ``Log in`` in that slot instead
(refer to :class:`~meshterm.ui.room.RoomScreen`).
"""
from .tui.fkeys import FPair, default_lane
@@ -154,7 +183,17 @@ class ChatScreen(Screen):
self._messages[0].created_at.astimezone().date()
!= self._messages[-1].created_at.astimezone().date()
)
lane[0] = FPair("Day ↑", "ctrl_pageup", enabled=days)
if self._locator is not None:
lane[0] = FPair(
"Day ↑",
"ctrl_pageup",
"Map",
"locate",
enabled=days,
opp_enabled=self._locate_target() is not None,
)
else:
lane[0] = FPair("Day ↑", "ctrl_pageup", enabled=days)
lane[1] = FPair(
"Day ↓",
"ctrl_pagedown",
@@ -193,6 +232,7 @@ class ChatScreen(Screen):
key_of: Callable[[str], str | None] | None = None,
scope: str | None = None,
resend_unscoped: Callable[[ChatMessage], Awaitable[ChatMessage | None]] | None = None,
locator: Locator | None = None,
) -> None:
"""Build the chat screen.
@@ -224,6 +264,9 @@ class ChatScreen(Screen):
resend_unscoped: An async callable that sends the text of a channel message
again, unscoped, and returns the stored message (channels only. With
``None``, ^R does nothing there).
locator: The located nodes, for ^L: the map on the author of the selected
message (refer to :class:`~meshterm.ui.map_screen.Locator`). With ``None``,
^L does nothing.
"""
super().__init__()
self._scope = scope if conversation.is_channel else None
@@ -251,6 +294,7 @@ class ChatScreen(Screen):
self._resend = resend
self._resend_unscoped = resend_unscoped if conversation.is_channel else None
self._paths = paths
self._locator = locator
self._names = names
self._session = session
self._editor = LineEditor()
@@ -263,6 +307,7 @@ class ChatScreen(Screen):
self._status = ""
self._stick = True # keep the newest message visible until the user scrolls up
self._paths_open = False # one paths dialog at a time
self._map_open = False # one map at a time
self._codes_open = False # one share screen of links at a time
self._paste_open = False # one paste-confirm dialog at a time
self._resend_open = False # one resend-unscoped confirm at a time
@@ -299,23 +344,45 @@ class ChatScreen(Screen):
Only the keys that do something show. ``^P paths`` must have a selected message to
show the paths *of*. ``^U QR`` must have a selected message with a URL in it
(refer to :meth:`_picked_urls`). ``^R retry failed`` must have something to retry
(refer to :meth:`_retry_target`). The F-key lane uses the same rules to dim its
slots.
(refer to :meth:`_picked_urls`). ``^L map`` must have a selected message whose
author has a location (refer to :meth:`_locate_target`). ``^R retry failed`` must
have something to retry (refer to :meth:`_retry_target`). The F-key lane uses the
same rules to dim its slots.
"""
if self._selected is not None:
code = " · ^U QR" if self._picked_urls() else ""
code = "^U QR" if self._picked_urls() else ""
where = "^L map" if self._locate_target() is not None else ""
if self._is_channel and self._retry_target() is not None:
# A selected scoped message of ours can be sent again unscoped, and the hint
# must name that key. The "(@mention)" of the reply and ^End are removed to
# keep the line inside 72 cells (Esc still cancels the selection, and Enter
# still says what it does). ↑↓ is also removed when the hint must also name
# the code of a link.
pick = "" if code else " · ↑↓ pick"
return f"Enter reply · ^P paths{code} · ^R resend unscoped{pick} · Esc cancel"
if self._is_channel:
return f"Enter reply (@mention) · ^P paths{code} · ↑↓ pick · ^End/Esc cancel"
return f"Enter paths{code} · ↑↓ pick · ^End/Esc cancel"
# still says what it does). If the line is still too long, ↑↓ goes first,
# then ^P, which the hint of each other selected message names.
return _fit_hint(
[
"Enter reply",
"^P paths",
code,
where,
"^R resend unscoped",
"↑↓ pick",
"Esc cancel",
],
spare=("↑↓ pick", "^P paths"),
)
if self._replies:
return _fit_hint(
[
"Enter reply (@mention)",
"^P paths",
code,
where,
"↑↓ pick",
"^End/Esc cancel",
],
spare=("↑↓ pick",),
)
return _fit_hint(["Enter paths", code, where, "↑↓ pick", "^End/Esc cancel"])
if self._retry_target() is not None:
if self._is_channel:
return "Enter send · ↑ pick a message · ^R resend unscoped · Esc back"
@@ -801,10 +868,10 @@ class ChatScreen(Screen):
Ctrl+PgUp/PgDn (day dividers), and the viewport moves with it. Enter on a selected
message starts a reply with an ``@mention`` in a channel, and opens the delivery
paths in a direct chat. In the two types of chat, ^P opens the paths of the
selected message, and ^U the QR codes of its links. The two do nothing when no
message is selected. ^End (or a move past the newest message) goes back to the
compose line. The first Esc clears the selection, and the second Esc leaves the
screen.
selected message, ^U the QR codes of its links, and ^L the map on its author. The
three do nothing when no message is selected. ^End (or a move past the newest
message) goes back to the compose line. The first Esc clears the selection, and the
second Esc leaves the screen.
"""
if action == "enter":
if self._selected is not None:
@@ -820,6 +887,8 @@ class ChatScreen(Screen):
self._open_paths(self._selected)
elif action == "url_code":
self._open_codes()
elif action == "locate":
self._open_map()
elif action == "retry":
if self._is_channel:
self._begin_resend_unscoped()
@@ -879,6 +948,44 @@ class ChatScreen(Screen):
self._session.run_detached(run())
def _locate_target(self) -> MapMarker | None:
"""The marker of the author of the selected message, or ``None`` when ^L does nothing.
The author is the node whose key gives the colour of the chip of the message (refer
to :meth:`_header_key`). Our own message has our node as its author. A message with
no sender, or an author that has no location, has no marker.
"""
if self._locator is None or self._selected is None or not self._messages:
return None
message = self._messages[max(0, min(self._selected, len(self._messages) - 1))]
if message.outbound:
return self._locator.you()
sender, _body = self._sender_and_body(message)
if sender == "·":
return None
return self._locator.find(self._header_key(sender, message))
def _open_map(self) -> None:
"""Open the map on the author of the selected message (^L). One map at a time.
The map is a screen, and it goes on the stack above the chat. Esc on the map comes
back to the chat, with the same message selected.
"""
target = self._locate_target()
if target is None or self._locator is None or self._map_open:
return
locator = self._locator
self._map_open = True
async def run() -> None:
try:
await locator.show(target)
finally:
self._map_open = False
self._session.invalidate()
self._session.run_detached(run())
def _picked_urls(self) -> list[str]:
"""The URLs in the body of the selected message, in order. None if nothing is selected."""
if self._selected is None or not self._messages:
@@ -1345,6 +1452,8 @@ async def open_chat(ctx: AppContext, conversation: Conversation) -> int:
ctx, lambda: ctx.chat.resend_direct(conversation.contact, message)
)
from .map_screen import Locator
paths = await _make_paths_presenter(ctx, conversation, device)
screen = ChatScreen(
conversation,
@@ -1357,6 +1466,7 @@ async def open_chat(ctx: AppContext, conversation: Conversation) -> int:
key_of=key_of,
scope=scope,
resend_unscoped=resend_unscoped,
locator=await Locator.gather(ctx),
)
ctx.chat.set_active(conversation.key)
+7
View File
@@ -30,8 +30,10 @@ say "and if it's the trace picker…".
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from rich.cells import cell_len
from rich.text import Text
@@ -571,6 +573,7 @@ class ContactListScreen(SelectScreen):
tail: list | None = None,
footer_hint: str = _HINT,
lanes: tuple[ContactLane, ...] = DEFAULT_LANES,
key_hint: Callable[[Any], str] | None = None,
) -> None:
"""Build the list over rows that are already resolved.
@@ -599,6 +602,9 @@ class ContactListScreen(SelectScreen):
footer_hint: The key hint of the footer. The default shows the full grammar.
lanes: The fixed lanes drawn between the name and the key of each row. Refer
to :data:`DEFAULT_LANES`, :data:`TRACE_LANES`, and :data:`ARCHIVED_LANES`.
key_hint: The hint atoms for the shortcut keys that act on the highlighted row
(refer to :class:`~meshterm.ui.tui.select.SelectScreen`). ``None`` for a list
with no such keys.
"""
self._contact_rows = rows
self._prefix_bytes = prefix_bytes
@@ -615,6 +621,7 @@ class ContactListScreen(SelectScreen):
self._compose_items(),
prompt=prompt,
footer_hint=footer_hint,
key_hint=key_hint,
)
def _compose_items(self) -> list:
+70 -4
View File
@@ -9,7 +9,8 @@ type-to-filter. Thus the whole app has one contact list, for each source of data
highlighted node (:mod:`~meshterm.ui.node_detail_screen`). That page shows the identity of
the node, a preview of its location, the routes that MeshTerm observed to it, and the ways
to go further (trace, map, Time Machine). When the user backs out of that page, the list is
as it was. The one-shot CLI (``meshterm contacts --sort …``) still renders the static
as it was. ^L opens the map on the highlighted node, when it has a location. The one-shot
CLI (``meshterm contacts --sort …``) still renders the static
:func:`~meshterm.ui.widgets.contacts_table`. Only the menu gets the live list.
Below the contacts is the maintenance action of the list: **Archive contacts**. It is the
@@ -45,13 +46,15 @@ from .archived_screen import open_archived
from .contactlist import ContactListScreen, ContactRow
from .menus import icon_lane, marked_label
from .sweep_screen import archive_contacts
from .tui import Choice, Separator
from .tui import Choice, KeyRequest, Separator
from .tui.screen import CANCEL
from .widgets import ContactsSort, contact_packets
if TYPE_CHECKING:
from ..context import AppContext
from ..core.models import Contact
from .map_render import MapMarker
from .map_screen import Locator
#: A stable identity for the row of our node. The lane data of this row has no key when no
#: device answered. Enter on this row opens the detail page of our node.
@@ -75,9 +78,35 @@ class ContactsScreen(ContactListScreen):
Enter resolves the highlighted row, our node (:data:`YOU`) or a
:class:`~meshterm.core.models.Contact`, so that :func:`open_contacts` opens it in the
Node detail page. The shared list handles the sort, the filter, and the navigation.
Node detail page. ^L resolves a :class:`~meshterm.ui.tui.select.KeyRequest` with the
marker of the highlighted node, so that :func:`open_contacts` opens the map on it. The
shared list handles the sort, the filter, and the navigation.
"""
@property
def picocalc_lyra_lane(self):
"""The lane of the contact list, with ``Map`` (^L) on the Shift half of F1.
This is the key of ``Map`` in the chat too. F1 itself keeps ``Sect ↑`` when the
list has sections (the contacts, and the archive rows under them), and it stays
empty when the list has none. ``Map`` is lit while the highlighted node has a
location.
"""
from .tui.fkeys import FPair
lane = list(super().picocalc_lyra_lane)
if self._locator is not None:
sect = lane[0]
lane[0] = FPair(
sect.label if sect else "",
sect.action if sect else "",
"Map",
"locate",
enabled=sect.enabled if sect else False,
opp_enabled=self._highlighted_marker() is not None,
)
return lane
def __init__(
self,
self_name: str,
@@ -88,6 +117,7 @@ class ContactsScreen(ContactListScreen):
sort: ContactsSort,
archived: int = 0,
locked: frozenset[str] = frozenset(),
locator: Locator | None = None,
) -> None:
"""Create the contacts screen over contact data that MeshTerm already got.
@@ -110,8 +140,11 @@ class ContactsScreen(ContactListScreen):
because a screen must not offer a way into an empty list.
locked: The full keys (lowercase hex) of the contacts that are locked against
archiving. Each of those rows ends its name lane with a padlock.
locator: The located nodes, for ^L: the map on the highlighted node (refer to
:class:`~meshterm.ui.map_screen.Locator`). With ``None``, ^L does nothing.
"""
self._self_name, self._self_key = self_name, self_key
self._locator = locator
self._contacts, self._counts = contacts, counts
rows = self._contact_rows_for(locked)
# The maintenance actions close the list, after each contact, for each sort. First
@@ -153,6 +186,7 @@ class ContactsScreen(ContactListScreen):
prefix_bytes=prefix_bytes,
sort=sort,
tail=tail,
key_hint=lambda value: "^L map" if self._marker_of(value) is not None else "",
)
if archived:
self.short_title = f"Contacts · {known} + {archived}"
@@ -176,6 +210,29 @@ class ContactsScreen(ContactListScreen):
)
return rows
def _marker_of(self, value: object) -> MapMarker | None:
"""The marker of the node of a row value, or ``None`` (a tail row, or no location)."""
if self._locator is None:
return None
if value == YOU:
return self._locator.you()
key = getattr(value, "public_key", None) or getattr(value, "key_prefix", None)
return self._locator.find(key) if isinstance(key, str) else None
def _highlighted_marker(self) -> MapMarker | None:
"""The marker of the highlighted node, or ``None`` when ^L does nothing."""
current = self._current_choice()
return self._marker_of(current.value) if current is not None else None
def handle(self, action: str, data: str = "") -> None:
"""^L asks :func:`open_contacts` to open the map. The list handles all other actions."""
if action == "locate":
marker = self._highlighted_marker()
if marker is not None:
self.resolve(KeyRequest("locate", marker))
return
super().handle(action, data)
def refresh_locks(self, locked: frozenset[str]) -> None:
"""Draw the padlocks again in place, after a detail page can lock or unlock a contact.
@@ -196,7 +253,7 @@ async def open_contacts(
counts: dict[str, int],
sort: ContactsSort,
) -> None:
"""Open the interactive contacts list. Enter opens Node detail, and Esc leaves.
"""Open the interactive contacts list. Enter opens Node detail, ^L the map, and Esc leaves.
The list stays pushed for the whole visit. Thus the detail page of the node that Enter
selects, the archive flow, and the archived list (the tail actions open the last two)
@@ -224,12 +281,14 @@ async def open_contacts(
RuntimeError: If the caller is outside the interactive menu (no full-screen
session).
"""
from .map_screen import Locator
from .node_detail_screen import open_node_detail
from .surface import TuiUi
if not isinstance(ctx.ui, TuiUi): # pragma: no cover - the caller is for the menu only
raise RuntimeError("the interactive contacts list is only available in the menu")
session = ctx.ui.session
locator = await Locator.gather(ctx)
def build() -> ContactsScreen:
"""The screen over the current contacts, with the live archived count on its tail."""
@@ -242,6 +301,7 @@ async def open_contacts(
sort,
archived=_archived_count(ctx, self_key),
locked=_locked_keys(ctx, self_key),
locator=locator,
)
screen = build()
@@ -252,6 +312,11 @@ async def open_contacts(
chosen = await visit.result()
if chosen is CANCEL or chosen is None: # Esc
return
if isinstance(chosen, KeyRequest):
# ^L: the map goes on the stack above the list, and Esc on the map comes
# back to the same row.
await locator.show(chosen.value)
continue
if chosen == _ARCHIVE:
# The archive ladder, its preview, and its confirm all float over the
# list, which is already the backdrop. A sweep that archived a contact
@@ -286,6 +351,7 @@ async def open_contacts(
if not rebuild:
return
contacts = await ctx.devstate.contacts()
locator = await Locator.gather(ctx)
screen = build()
+75 -5
View File
@@ -16,7 +16,7 @@ draws the map again through :func:`~meshterm.ui.map_render.render_map`. The keys
* ``Home`` centres the viewport again and fits it to the dense core of the nodes. This is
the area that the mesh covers (the ``Region`` chip), and it is also the default viewport
when the map opens.
* ``^Y`` (for "you") centres the viewport on **our node**. It keeps the zoom that the user
* ``^L`` (to locate) centres the viewport on **our node**. It keeps the zoom that the user
selected, and it clears the find query. The ``You`` chip on the F-key lane does the
same, and its Shift half also zooms in close.
* **Type to find nodes.** Each letter key adds to a live name filter. The matching nodes
@@ -310,7 +310,7 @@ class MapScreen(Screen):
Thus the typed text is always visible in a fixed place.
The line uses all of its 72 cells. Thus the two keys that move the viewport to a
destination share one atom (``Home/^Y region/you``). The ``⇧ fine`` atom gives
destination share one atom (``Home/^L region/you``). The ``⇧ fine`` atom gives
its space to them. That atom only refines a key that the line already names, and
it is the only atom here that shows a modifier instead of a key binding. The
state of the basemap was once a suffix at the end of this line. That state is a
@@ -320,7 +320,7 @@ class MapScreen(Screen):
"""
if self._filter:
return f"find: {self._filter}▏ · ^Enter frame · ⌫ erase · Enter/Esc clear"
return "↑↓←→ pan · PgUp/PgDn zoom · Home/^Y region/you · type to find · Esc back"
return "↑↓←→ pan · PgUp/PgDn zoom · Home/^L region/you · type to find · Esc back"
def consume_edge_scrub(self) -> int:
"""How many right-edge cells the session must paint again at the next paint (0 is none).
@@ -1057,7 +1057,7 @@ class MapScreen(Screen):
first an active filter, and the map itself only when the filter is clear.
Three actions fit the viewport again, and each has a key and an F-key chip:
``home`` fits the full region, ``locate`` (^Y, for "you") goes to our node, and
``home`` fits the full region, ``locate`` (^L) goes to our node, and
``frame`` (^Enter) fits the find matches. ``locate_zoom`` is the Shift-bank
variant of ``locate`` that also zooms in. ``clear_find`` clears the query and does
not move the viewport, and plain **Enter** does the same.
@@ -1143,7 +1143,7 @@ class MapScreen(Screen):
return next((m for m in self._markers if m.is_self), None)
def _locate(self, vp: Viewport, *, zoom_in: bool = False) -> None:
"""Centre the viewport on our node (``^Y`` or the ``You`` chip), and clear the find query.
"""Centre the viewport on our node (``^L`` or the ``You`` chip), and clear the find query.
Plain ``You`` keeps the zoom that the user selected. Two presses do the same thing
two times, and one more press of Zoom + adds the zoom. Its Shift half (``You +``)
@@ -1535,3 +1535,73 @@ async def open_map(
# paint, so that the menu drawn under the map starts from a clean terminal and
# does not keep that garbage.
session.request_full_repaint()
class Locator:
"""The nodes of the mesh that have a location, and the map that opens on one of them.
This is the ^L key (``locate``) of a screen that shows other nodes: the author of a chat
message, or the highlighted contact. At each paint, the screen asks :meth:`find` if the
node of its selected row is on the map. Thus its hint and its F-key lane name ^L only
when the key acts. Then :meth:`show` opens the map on that node, the same as the map
action of the node page.
The markers come from :func:`~meshterm.services.markers.gather_markers` one time, when
the screen opens. A node that advertises a location while the screen is open gets a
marker at the next visit.
"""
def __init__(self, ctx: AppContext, markers: list[MapMarker]) -> None:
"""Hold the markers that :meth:`show` draws.
Args:
ctx: The shared application context (it must be in the interactive menu).
markers: The located nodes of the mesh.
"""
self._ctx = ctx
self._markers = markers
# The result of each key that find() got. A screen asks at each paint, and the
# answer for one key does not change while the markers stay the same.
self._found: dict[str, MapMarker | None] = {}
@classmethod
async def gather(cls, ctx: AppContext) -> Locator:
"""A locator over the markers that MeshTerm has now. With no markers, nothing is found."""
from ..services.markers import gather_markers
try:
markers = await gather_markers(ctx)
except Exception: # noqa: BLE001 - with no markers, ^L does nothing, and the screen works
markers = []
return cls(ctx, markers)
def find(self, key: str | None) -> MapMarker | None:
"""The marker of the node with ``key`` (a full key or a key prefix), or ``None``.
A key prefix finds the node whose full key starts with it, and a full key finds the
marker of a node that MeshTerm knows only by its key prefix. The contacts come
first in the markers, so a contact wins over an overheard node with the same key
prefix.
"""
if not key:
return None
needle = key.lower().removeprefix("0x")
if needle not in self._found:
self._found[needle] = next(
(m for m in self._markers if _same_node(m.key, needle)), None
)
return self._found[needle]
def you(self) -> MapMarker | None:
"""The marker of our node, or ``None`` when our node has no location."""
return next((m for m in self._markers if m.is_self), None)
async def show(self, marker: MapMarker) -> None:
"""Open the map on ``marker``, with its name as the find query, so that it is bright."""
await open_map(self._ctx, self._markers, focus=(marker.lat, marker.lon), find=marker.label)
def _same_node(marker_key: str | None, needle: str) -> bool:
"""Whether a marker key and a lowercase key (or key prefix) name the same node."""
key = (marker_key or "").lower().removeprefix("0x")
return bool(key) and (key.startswith(needle) or needle.startswith(key))
+34 -25
View File
@@ -22,7 +22,7 @@ Three things make it different, and each one belongs to the room:
(:mod:`meshterm.ui.rooms`). Here, when the user opens a joined room, MeshTerm logs in to
it again only if the room has been quiet
(:meth:`~meshterm.services.rooms.RoomService.login_due`). It does this quietly, and the
board is open to read. ^L runs the explained login when the user wants it.
board is open to read. ^O runs the explained login when the user wants it.
* **Posting waits for the room.** A room drops, with no message, a post from anyone that it
does not know, or that it knows at the read-only access. Thus the compose line opens only
when we are logged in and can post. Until then, it says why it is not open and which key
@@ -51,6 +51,7 @@ from .chat import ChatScreen, _belongs, _contact_names, _make_paths_presenter, _
if TYPE_CHECKING:
from ..context import AppContext
from .map_screen import Locator
#: The title's login atom while a login is in flight.
LOGGING_IN = "logging in…"
@@ -94,20 +95,22 @@ class RoomScreen(ChatScreen):
"""The board of a room: posts under their authors, a compose line, and the login state.
It does everything that a chat does. When the user selects a post, Enter replies with an
``@mention``. ^P shows the paths that a post took. ^R tries again a post that the room
did not acknowledge. It also has ^L, which logs in to the room. It uses the remembered
password, or it asks for a password when none is remembered or when the last login
got no answer.
``@mention``. ^P shows the paths that a post took. ^L opens the map on the author of a
post. ^R tries again a post that the room did not acknowledge. It also has ^O, which
logs in to the room. It uses the remembered password, or it asks for a password when
none is remembered or when the last login got no answer.
"""
_empty_text = "No posts yet"
@property
def picocalc_lyra_lane(self):
"""The lane of the chat, with *Log in* on the Shift half of F1 (F6), where ^L has no key.
"""The lane of the chat, with *Log in* on the Shift half of F1 (F6), where ^O has no key.
Each chord that the desktop has needs a chip on the handheld. The Shift half of F1
is the one that the chat leaves free. The chip is dim while a login is in progress.
Each chord that the desktop has needs a chip on the handheld. The chat puts
``Map`` (^L) on the Shift half of F1, its one free slot. A room needs ``Log in``
more, because a room takes no posts from a user who did not log in. Thus, in a
room, ``Map`` has no chip. The chip is dim while a login is in progress.
"""
from .tui.fkeys import FPair
@@ -139,6 +142,7 @@ class RoomScreen(ChatScreen):
resend: Callable[[ChatMessage], Awaitable[ChatMessage]] | None = None,
paths: Callable[[ChatMessage], Awaitable[None]] | None = None,
key_of: Callable[[str], str | None] | None = None,
locator: Locator | None = None,
) -> None:
"""Build the room screen.
@@ -165,6 +169,7 @@ class RoomScreen(ChatScreen):
resend: Posts again a post that the room did not acknowledge (^R).
paths: Shows the paths that a selected post took (^P).
key_of: Gives the key of its node for a name, for the hues of ``@mention``.
locator: The located nodes, for ^L: the map on the author of a post.
"""
super().__init__(
conversation,
@@ -175,6 +180,7 @@ class RoomScreen(ChatScreen):
resend=resend,
paths=paths,
key_of=key_of,
locator=locator,
)
self._label = conversation.label
self._resolve = resolve
@@ -190,7 +196,7 @@ class RoomScreen(ChatScreen):
# --- the login ----------------------------------------------------------------------
def begin_login(self, *, ask: bool = False) -> None:
"""^L: the explained login flow, with its prompt and its result floating over the board.
"""^O: the explained login flow, with its prompt and its result floating over the board.
Args:
ask: Ask for the password also when one is remembered. A member at the read-only
@@ -208,7 +214,7 @@ class RoomScreen(ChatScreen):
title says ``logging in…`` while the login runs, and the compose line waits for the
answer. A room that has forgotten us drops each post that we send. Thus the compose
line opens only when the room has let us in. If the room does not answer, this shows
in the same two places, and ^L is the explained retry.
in the same two places, and ^O is the explained retry.
"""
if self._login_open:
return
@@ -304,16 +310,16 @@ class RoomScreen(ChatScreen):
# Each reason is inside the 53 columns of the handhelds, so it is one line everywhere.
why = {
LOGGING_IN: "logging in… posting opens once the room lets you in",
NO_REPLY: "not logged in: no answer from the room · ^L to retry",
REFUSED: "not logged in: the login was refused · ^L to retry",
FAILED: "not logged in · ^L to try again",
NOT_JOINED: "not joined · ^L to join",
NO_REPLY: "not logged in: no answer from the room · ^O to retry",
REFUSED: "not logged in: the login was refused · ^O to retry",
FAILED: "not logged in · ^O to try again",
NOT_JOINED: "not joined · ^O to join",
}.get(self._login_state or "")
if why is None:
why = (
"read-only — the room drops your posts · ^L to log in"
"read-only — the room drops your posts · ^O to log in"
if self._access is RoomAccess.READ_ONLY
else "not logged in · ^L to log in"
else "not logged in · ^O to log in"
)
return Text(why, style="muted")
@@ -323,10 +329,10 @@ class RoomScreen(ChatScreen):
super()._submit()
def handle(self, action: str, data: str = "") -> None:
"""^L logs in. The chat handles all the other actions."""
"""^O logs in. The chat handles all the other actions."""
if action == "login":
# The remembered password of a member at the read-only access gave all that it
# can, so ^L asks for another password. For each other member, the flow tries
# can, so ^O asks for another password. For each other member, the flow tries
# the remembered password first and asks only if it fails.
self.begin_login(ask=self._access is RoomAccess.READ_ONLY)
self._session.invalidate()
@@ -342,18 +348,19 @@ class RoomScreen(ChatScreen):
@property
def footer_hint(self) -> str:
"""The hint of the chat, in the words of a board, with ^L when a login can start."""
"""The hint of the chat, in the words of a board, with ^O when a login can start.
A selected post has the hint of a selected message in the chat. With no place for a
reply, Enter shows the paths of the post there too.
"""
if self._selected is not None:
code = " · ^U QR" if self._picked_urls() else ""
if not self._composing: # no place for a reply: Enter shows the paths of the post
return f"Enter paths{code} · ↑↓ pick · ^End/Esc cancel"
return f"Enter reply (@mention) · ^P paths{code} · ↑↓ pick · ^End/Esc cancel"
return super().footer_hint
atoms = ["Enter send"] if self._composing else []
atoms.append("↑ pick a message")
if self._retry_target() is not None:
atoms.append("^R retry failed")
if not self._login_open:
atoms.append("^L log in")
atoms.append("^O log in")
atoms.append("Esc back")
return " · ".join(atoms)
@@ -366,7 +373,7 @@ async def open_room(ctx: AppContext, conversation: Conversation) -> int:
board is readable. The login starts the catch-up of the room again. A room that MeshTerm
heard from recently sends nothing. The Rooms page joins a room
(:mod:`meshterm.ui.rooms`). Thus a room that is not joined opens only to read its
stored posts, and ^L joins it. Posts that arrive while the board is open are added at
stored posts, and ^O joins it. Posts that arrive while the board is open are added at
once, and the unread count of the room stays clear while the user is on the board.
Args:
@@ -377,6 +384,7 @@ async def open_room(ctx: AppContext, conversation: Conversation) -> int:
The number of posts on the board when the screen closed.
"""
from ..services import trace_runner
from .map_screen import Locator
from .rooms import join_room
session = ctx.ui.session
@@ -430,6 +438,7 @@ async def open_room(ctx: AppContext, conversation: Conversation) -> int:
resend=resend,
paths=await _make_paths_presenter(ctx, conversation, device),
key_of=trace_runner.make_name_key_resolver(contacts, stored_names),
locator=await Locator.gather(ctx),
)
ctx.chat.set_active(conversation.key)
+1 -1
View File
@@ -19,7 +19,7 @@ password worked before. The page then offers the next step as buttons: try by fl
first forgets the stale route), try again, or try another password. Each of these is one
more login that the user asked for. MeshTerm never sends one on its own.
This flow (:func:`join_room`) is shared. The ^L key of the board also runs it. The
This flow (:func:`join_room`) is shared. The ^O key of the board also runs it. The
``rooms join`` command of the command line explains a silent room in the same words
(:func:`silence_explanation`).
+6 -5
View File
@@ -226,8 +226,9 @@ class LaneDeck:
"""The action that F-key ``number`` resolves to on this lane, or ``None``.
A plain key takes the action of its slot, and a Shift-bank key takes the action of
its companion. The result is ``None`` where the slot has no action (a lone slot, or
an unassigned slot), and where the keyboard key is not a key of this deck. A dim
its companion. The result is ``None`` where the slot has no action (a lone slot, a
slot that has only a Shift-bank companion, or an unassigned slot), and where the
keyboard key is not a key of this deck. A dim
slot still resolves. Dimming is how the lane draws an action on which the screen
does nothing anyway. Because the action resolves in all cases, a lane that was
built from stale metrics can never block a key press that works (refer to the
@@ -244,7 +245,7 @@ class LaneDeck:
return None
if shifted:
return pair.opp_action or None
return pair.action
return pair.action or None
def lane_text(self, lane: Lane, *, shifted: bool = False) -> Text:
"""Render the lane as this deck's chips: coloured fill, white text, each at its column.
@@ -366,10 +367,10 @@ _HINT_KEY_ACTIONS: dict[str, str] = {
}
#: Marks a word as a key notation of some type, also where this module cannot map it: a
#: chord (``^Y``), a shifted key (``⇧Tab``), or a bare glyph key (``⌫``). If the verb half
#: chord (``^L``), a shifted key (``⇧Tab``), or a bare glyph key (``⌫``). If the verb half
#: of an atom has one of these marks, the atom names more than the keys at its start. Thus
#: the atom is never removed because of the keys that it did name (the map's
#: ``Home/^Y region/you``).
#: ``Home/^L region/you``).
_KEYISH = ("^", "⇧", "⌫", "↑", "↓", "←", "→")
+11 -6
View File
@@ -76,9 +76,13 @@ from .spinner import spinner_interval
#: ``Keys.ControlM``/``ControlI``/``ControlH``. If this table claims them, it binds those
#: keys again.
#: The letter of a chord is the mnemonic of the action, not of the word that one screen uses
#: for it. ``locate`` is ^Y for **you** (JP, 2026-08-09). It is the same keyboard key on the
#: map and in the mesh walk, because on both it names our node. It was ^U until 2026-10-01.
#: Then U went to ``url_code``: the links of a chat message as QR codes.
#: for it. ``locate`` is ^L: on the map and in the mesh walk it goes to our node, and in the
#: chat and on Contacts it opens the map on the node of the selected row. It was ^U until
#: 2026-10-01, then ^Y until 2026-10-10, when ``login`` moved from ^L to ^O (JP). One action
#: for each letter in all screens is the preference, not the rule (JP, 2026-10-10). A screen
#: that has no use for the action of a letter can give the letter a different action, if no
#: terminal or multiplexer takes it. ^A and ^B are not free for this reason: they are the
#: prefix keys of GNU screen and tmux.
#:
#: Two of these chords are for the full app, not for one screen:
#:
@@ -96,8 +100,10 @@ from .spinner import spinner_interval
#: ``IXON``/``IXOFF``, so ^Q is not XON here.
_CTRL_LETTER_CHORDS: dict[str, str] = {
"c": "quit",
# ^L logs in to the room that a room screen shows.
"l": "login",
"l": "locate",
# ^O logs in to the room that a room screen shows. The raw mode of prompt_toolkit clears
# IEXTEN, so a terminal on macOS or BSD does not take ^O to discard output.
"o": "login",
"p": "paths",
"q": "quit",
"r": "retry",
@@ -108,7 +114,6 @@ _CTRL_LETTER_CHORDS: dict[str, str] = {
"u": "url_code",
"v": "paste_clipboard",
"w": "to_menu",
"y": "locate",
}
#: Maps the prompt_toolkit keys to the normalized action names that the screens understand.
+7 -7
View File
@@ -51,7 +51,7 @@ while it fits. If it does not fit, it is *cropped* at each edge where the walk
continues: the chip breaks on its own half block, the same as each path row in the app
that is too long for its lane. At rest, the window is at the tail. Thus the focus and the
last steps are visible, and the crop removes the start of the walk. **←→ slide the
window**, so that you can read back over a long walk. **^Y** puts the focus on our node
window**, so that you can read back over a long walk. **^L** puts the focus on our node
again. **Typing finds**: a filter over all the nodes in the graph, islands also. While you
type, it also narrows the fan on the canvas to the neighbours that match, and the focus
stays. Enter teleports the focus to the highlighted match. The trail then starts again
@@ -253,7 +253,7 @@ class WalkScreen(Screen):
to stand in for *Top* and leave *Bottom* blank to keep up the pretence. Instead, it
takes a slot of this screen (JP, 2026-08-09), because F1-F3 are for this purpose.
The Shift bank of the pager then has the same meaning here as in all other places:
the two ends of the list that this screen scrolls. On a keyboard, ^Y does the same
the two ends of the list that this screen scrolls. On a keyboard, ^L does the same
action. The map uses the same chord for our node.
The two navigation slots must have rows to move through, and a leaf node in a
@@ -301,7 +301,7 @@ class WalkScreen(Screen):
self._self_label = self_label
self._prefix_bytes = prefix_bytes
#: The walked trail of canonical ids. The focus is its last entry. A walk appends,
#: ⌫ pops, ^Y resets the trail to our node, and a find teleport starts it again.
#: ⌫ pops, ^L resets the trail to our node, and a find teleport starts it again.
self._trail: list[str] = [topo.self_id]
#: The cells by which the breadcrumb line is scrolled off its *tail* end (refer to
#: :meth:`_trail_text`). 0 is the rest state (the focus at the right edge). Each
@@ -450,7 +450,7 @@ class WalkScreen(Screen):
atoms = ["↑↓ move"]
if scrolls:
atoms.append("←→ trail")
atoms += ["Enter focus", "⌫ back", "^Y you"]
atoms += ["Enter focus", "⌫ back", "^L you"]
if not scrolls:
atoms.append("type to find")
atoms.append("Esc back")
@@ -460,7 +460,7 @@ class WalkScreen(Screen):
"""Move the highlight, walk, step back, find, or close.
Home/End go to the ends of the list here, the same as on each other screen that
scrolls. The jump back to our node is ``locate`` (^Y, and F3 on the lane), which is
scrolls. The jump back to our node is ``locate`` (^L, and F3 on the lane), which is
not a position in this list. Refer to :attr:`picocalc_lyra_lane`.
"""
rows = self._rows()
@@ -512,7 +512,7 @@ class WalkScreen(Screen):
elif action == "right":
self._trail_scroll = max(0, self._trail_scroll - _HSCROLL_STEP)
elif action == "locate":
# ^Y (and F3): stop the walk, instead of a move inside it. The trail goes back
# ^L (and F3): stop the walk, instead of a move inside it. The trail goes back
# to only our node, and a find that narrows the list is also removed.
self._trail = [self._topo.self_id]
self._filter = ""
@@ -676,7 +676,7 @@ class WalkScreen(Screen):
is measured only when the two marks are known, or the line draws one cell past the
row. The slide stops when the head becomes visible, so the line does not move off
the edge. Each change to the trail resets the slide: a walk, a step back, a
teleport, and ^Y all end with the focus visible, because the user reads the next
teleport, and ^L all end with the focus visible, because the user reads the next
move from there.
"""
self._trail_width = width
+17
View File
@@ -52,6 +52,23 @@ def plain(rendered: str | Iterable[str]) -> str:
return _ANSI.sub("", rendered)
def recording_locator(markers: list) -> object:
"""A real :class:`~meshterm.ui.map_screen.Locator` whose map is a list, ``shown``.
It finds markers with the rule of the app. Instead of a map, :meth:`show` appends the
marker that the map would open on to ``shown``.
"""
from meshterm.ui.map_screen import Locator
class _Recording(Locator):
async def show(self, marker) -> None: # noqa: ANN001 - a MapMarker
self.shown.append(marker)
locator = _Recording(None, markers) # type: ignore[arg-type]
locator.shown = [] # type: ignore[attr-defined]
return locator
#: The root of the checkout.
_ROOT = Path(__file__).resolve().parent.parent
+104
View File
@@ -23,6 +23,7 @@ from meshterm.core.models import (
ChatMessage,
Contact,
Conversation,
MapMarker,
Message,
conversation_key,
)
@@ -2744,3 +2745,106 @@ def test_only_the_acks_the_radio_could_still_push_are_awaited(repo: Repository)
assert messages[0].acked is False
chat._settle_ack(f"{39:08x}")
assert messages[39].acked is True
# --- ^L: the map on the author ------------------------------------------------------------
#: A node with a location, and our node.
_ALICE_AT = MapMarker(label="Alice", lat=45.5, lon=-73.6, key="a1" * 32)
_US_AT = MapMarker(label="Base", lat=45.4, lon=-73.5, is_self=True, key="ee" * 32)
async def test_chat_locate_opens_the_map_on_the_author_of_the_selected_message() -> None:
"""^L opens the map on the author of the selected message, and is named only where it acts."""
from tests.conftest import recording_locator
locator = recording_locator([_ALICE_AT, _US_AT])
keys = {"alice": "a1" * 32, "bob": "b2" * 32}
screen = ChatScreen(
Conversation(label="#public", is_channel=True, channel_idx=0),
[
ChatMessage(text="Alice: hi", is_channel=True, channel_idx=0),
ChatMessage(text="Bob: yo", is_channel=True, channel_idx=0),
ChatMessage(text="hello", outbound=True, is_channel=True, channel_idx=0),
],
send=None,
names={},
session=_StubSession(),
key_of=lambda name: keys.get(name.casefold()),
locator=locator, # type: ignore[arg-type]
)
# With no selected message, ^L does nothing. The hint does not name it, and its chip on
# the Shift half of F1 is dim.
screen.handle("locate")
await asyncio.sleep(0)
assert locator.shown == []
assert "^L" not in screen.footer_hint
assert screen.picocalc_lyra_lane[0].opp_label == "Map"
assert screen.picocalc_lyra_lane[0].opp_enabled is False
# Our own message: its author is our node.
screen.handle("up")
assert "^L map" in screen.footer_hint
screen.handle("locate")
await asyncio.sleep(0)
assert locator.shown == [_US_AT]
# Bob has a key but no location, so there is nothing to show.
screen.handle("up")
assert "^L" not in screen.footer_hint
assert screen.picocalc_lyra_lane[0].opp_enabled is False
screen.handle("locate")
await asyncio.sleep(0)
assert locator.shown == [_US_AT]
# Alice has a location. The chip is lit, and F6 sends the same action as ^L.
screen.handle("up")
assert "^L map" in screen.footer_hint
assert screen.picocalc_lyra_lane[0].opp_enabled is True
assert fkeys.PICOCALC_LYRA_DECK.action_for(screen.picocalc_lyra_lane, 6) == "locate"
screen.handle("locate")
await asyncio.sleep(0)
assert locator.shown == [_US_AT, _ALICE_AT]
def test_chat_without_a_locator_has_no_map_chip() -> None:
"""A chat that cannot locate leaves the Shift half of F1 empty, not dim."""
screen = _screen(_StubSession(), send=None, messages=[ChatMessage(text="hi", peer="d4e5f6a7")])
assert screen.picocalc_lyra_lane[0].opp_label == ""
def test_chat_hint_with_all_its_keys_fits_in_72_cells() -> None:
"""The longest hint (a scoped message of ours, with a link, on the map) fits in 72 cells.
The ↑↓ atom goes first, then ^P. The keys that only this message offers stay.
"""
async def resend_unscoped(message: ChatMessage) -> ChatMessage | None:
return None
from tests.conftest import recording_locator
screen = ChatScreen(
Conversation(label="#ops", is_channel=True, channel_idx=1),
[
ChatMessage(
text="map at https://meshterm.net",
outbound=True,
is_channel=True,
channel_idx=1,
scope="yul",
)
],
send=None,
names={},
session=_StubSession(),
scope="yul",
resend_unscoped=resend_unscoped,
locator=recording_locator([_US_AT]), # type: ignore[arg-type]
)
screen.handle("up")
hint = screen.footer_hint
assert cell_len(hint) <= 72
for atom in ("Enter reply", "^U QR", "^L map", "^R resend unscoped", "Esc cancel"):
assert atom in hint
assert "↑↓ pick" not in hint and "^P paths" not in hint
+37
View File
@@ -1689,3 +1689,40 @@ async def test_a_locked_contact_is_never_offered_to_the_sweep() -> None:
ranked = {s.contact.name: s for s in await _rank(ctx, contacts, "cc" * 32)}
assert ranked["Kept"].protection == PROTECT_LOCKED
assert not ranked["Loose"].protected
async def test_contacts_screen_l_opens_the_map_on_the_highlighted_node() -> None:
"""^L asks for the map on the highlighted node. The hint and F6 name it only where it acts."""
from meshterm.core.models import Contact, MapMarker
from meshterm.ui.contacts_screen import ContactsScreen
from meshterm.ui.tui import KeyRequest, fkeys
from tests.conftest import recording_locator
contacts = [
Contact(name="Alice", public_key="aa" * 32),
Contact(name="Bob", public_key="bb" * 32),
]
alice = MapMarker(label="Alice", lat=45.5, lon=-73.6, key="aa" * 32)
screen = ContactsScreen(
"Us", "cc" * 32, contacts, 1, {}, _contacts_sort(), locator=recording_locator([alice])
)
resolved: list = []
screen.resolve = lambda value: resolved.append(value) # type: ignore[method-assign]
# Our node is first, and it has no location here.
assert "^L" not in screen.footer_hint
lane = screen.picocalc_lyra_lane
assert (lane[0].label, lane[0].opp_label) == ("Sect ↑", "Map") # Map is Shift+F1 (F6)
assert lane[0].opp_enabled is False
screen.handle("locate")
assert resolved == []
screen.handle("down") # Alice
assert "^L map" in screen.footer_hint
assert screen.picocalc_lyra_lane[0].opp_enabled is True
assert fkeys.PICOCALC_LYRA_DECK.action_for(screen.picocalc_lyra_lane, 6) == "locate"
screen.handle("locate")
assert resolved == [KeyRequest("locate", alice)]
screen.handle("down") # Bob, with no location
assert "^L" not in screen.footer_hint
+2 -2
View File
@@ -540,7 +540,7 @@ def _room(cols: int, rows: int, *, access=None, retry: bool = False) -> Screen:
The board has a post by a contact (with a name, in its hue), a post by an author that
nothing names (the grey hash), a notice that the room posted itself, and our own post.
If ``retry`` is set, our post has no ack, so ^R joins ^L on the hint. This is the longest
If ``retry`` is set, our post has no ack, so ^R joins ^O on the hint. This is the longest
that the hint gets.
"""
from meshterm.core.models import LoginResult, RoomAccess, RoomLogin
@@ -676,7 +676,7 @@ def _room_read_only(cols: int, rows: int) -> Screen:
def _room_retry(cols: int, rows: int) -> Screen:
"""A post of ours with no ack: ^R retry joins ^L on the hint."""
"""A post of ours with no ack: ^R retry joins ^O on the hint."""
return _room(cols, rows, retry=True)
+19
View File
@@ -3205,3 +3205,22 @@ def test_stamping_leaves_the_callers_rows_untouched() -> None:
before = list(rows)
attribution.stamp(rows, 70, full=True)
assert rows == before
def test_the_locator_finds_a_node_by_its_key_or_its_key_prefix() -> None:
"""A key prefix finds the node with the full key, and a full key finds a key prefix."""
from meshterm.core.models import MapMarker
from meshterm.ui.map_screen import Locator
full = MapMarker(label="Alice", lat=45.5, lon=-73.6, key="A1B2C3D4" + "0" * 56)
short = MapMarker(label="Bob", lat=45.4, lon=-73.5, key="b2c3d4e5f6a7")
us = MapMarker(label="Base", lat=45.3, lon=-73.4, is_self=True, key="ee" * 32)
locator = Locator(None, [full, short, us]) # type: ignore[arg-type]
assert locator.find("a1b2c3d4") is full # the four bytes that sign a room post
assert locator.find("0xA1B2C3D4" + "0" * 56) is full
assert locator.find("b2c3d4e5f6a7" + "9" * 52) is short # stored history knows six bytes
assert locator.find("c3c3c3c3") is None
assert locator.find(None) is None and locator.find("") is None
assert locator.you() is us
assert Locator(None, [full]).you() is None # type: ignore[arg-type]
+5 -5
View File
@@ -392,15 +392,15 @@ def test_map_locate_is_reachable_from_both_control_keys() -> None:
Thus both Ctrl keys and F2 do the same action.
The letter is the mnemonic of the action: ^Y for "you". Thus it is the same chord on each
screen that can point to our node. It is not the initial of one screen.
The letter is the mnemonic of the action: ^L to locate. Thus it is the same chord on each
screen that can show a node on the map. It is not the initial of one screen.
"""
from prompt_toolkit.keys import Keys
from meshterm.ui.tui.session import _CTRL_LETTER_CHORDS, _KEY_ACTIONS
assert _CTRL_LETTER_CHORDS["y"] == "locate" # the half for the right-Ctrl rescue
assert _KEY_ACTIONS[Keys.ControlY] == "locate" # the ordinary binding, made from it
assert _CTRL_LETTER_CHORDS["l"] == "locate" # the half for the right-Ctrl rescue
assert _KEY_ACTIONS[Keys.ControlL] == "locate" # the ordinary binding, made from it
def test_host_battery_reads_the_sysfs_supply(tmp_path, monkeypatch) -> None:
@@ -500,7 +500,7 @@ def test_an_atom_the_lane_only_half_covers_stands() -> None:
# The atom also describes the arrows, and no slot has them.
assert strip_lane_atoms("↑↓ PgUp/PgDn scroll", DEFAULT_LANE) == "↑↓ PgUp/PgDn scroll"
# A second key in the verb half keeps the whole atom (region/you of the map).
assert strip_lane_atoms("Home/^Y region/you", DEFAULT_LANE) == "Home/^Y region/you"
assert strip_lane_atoms("Home/^L region/you", DEFAULT_LANE) == "Home/^L region/you"
def test_a_dimmed_chip_still_covers_its_atom() -> None:
+32 -6
View File
@@ -696,6 +696,7 @@ def _view(
access: RoomAccess | None = RoomAccess.MEMBER,
joined: bool = True,
login: RoomLogin | None = None,
locator: object = None,
) -> tuple[RoomScreen, dict]:
"""A board with stub hooks. ``calls`` records the requests to the hooks."""
calls: dict = {"auto": 0, "joins": [], "sent": []}
@@ -726,6 +727,7 @@ def _view(
join=join,
joined=joined,
access=access,
locator=locator, # type: ignore[arg-type]
)
return screen, calls
@@ -806,7 +808,7 @@ async def test_after_silence_nothing_can_be_posted_and_the_line_says_why() -> No
screen.begin_auto_login()
await _settled(screen)
assert screen.title.endswith(NO_REPLY)
assert "no answer" in _compose(screen) and "^L" in _compose(screen)
assert "no answer" in _compose(screen) and "^O" in _compose(screen)
for ch in "hi":
screen.handle("text", ch)
screen.handle("enter")
@@ -816,18 +818,18 @@ async def test_after_silence_nothing_can_be_posted_and_the_line_says_why() -> No
async def test_control_l_runs_the_explained_login() -> None:
"""^L gives the login to the Rooms flow, with its dialogs over the board, and shows the end."""
"""^O gives the login to the Rooms flow, with its dialogs over the board, and shows the end."""
screen, calls = _view(access=None, login=RoomLogin(LoginResult.ACCEPTED, RoomAccess.ADMIN))
screen.handle("login")
assert screen._login_open
assert "^L log in" not in screen.footer_hint # a second press does nothing
assert "^O log in" not in screen.footer_hint # a second press does nothing
await _settled(screen)
assert calls["joins"] == [False]
assert screen.title == f"{ROOM.name} · admin" and screen._composing
async def test_a_read_only_member_is_asked_for_another_password_and_offered_no_retry() -> None:
"""^L asks, because the room password lets a read-only member post. ^R is only dropped."""
"""^O asks, because the room password lets a read-only member post. ^R is only dropped."""
unacked = ChatMessage(text="lost", outbound=True, peer=ROOM.key_prefix, acked=False)
screen, calls = _view([unacked], access=RoomAccess.READ_ONLY)
assert "read-only" in _compose(screen)
@@ -876,9 +878,9 @@ async def test_a_read_only_member_picks_a_post_to_see_its_paths_not_to_reply() -
def test_the_hint_names_log_in_and_fits() -> None:
"""The hint names ^L where it acts, in 72 cells, with or without a selected post."""
"""The hint names ^O where it acts, in 72 cells, with or without a selected post."""
screen, _ = _view(_board())
assert "^L log in" in screen.footer_hint
assert "^O log in" in screen.footer_hint
assert cell_len(screen.footer_hint) <= 72
screen.handle("up")
assert cell_len(screen.footer_hint) <= 72
@@ -892,6 +894,30 @@ def test_the_handheld_lane_puts_log_in_behind_f1() -> None:
assert fkeys.PICOCALC_LYRA_DECK.action_for(lane, 6) == "login"
async def test_l_opens_the_map_on_the_author_of_a_post() -> None:
"""^L opens the map on the author of the selected post. ^O keeps the login."""
from meshterm.core.models import MapMarker
from tests.conftest import recording_locator
alice = MapMarker(label="Alice", lat=45.5, lon=-73.6, key=ALICE_KEY + "0" * 56)
locator = recording_locator([alice])
screen, _ = _view(_board(), locator=locator)
for _ in range(4):
screen.handle("up") # the oldest post, by Alice
assert "^L map" in screen.footer_hint
screen.handle("locate")
await asyncio.sleep(0)
assert locator.shown == [alice]
# No marker places the stranger, so ^L does nothing, and the hint does not name it.
screen.handle("down")
assert "^L" not in screen.footer_hint
screen.handle("locate")
await asyncio.sleep(0)
assert locator.shown == [alice]
# The room keeps Log in on the Shift half of F1. Thus Map has no chip here.
assert screen.picocalc_lyra_lane[0].opp_label == "Log in"
# --- joining: the explained flow ------------------------------------------------------
+1 -1
View File
@@ -373,7 +373,7 @@ def test_walk_trail_names_carry_their_node_hues() -> None:
def test_walk_locate_refocuses_us_and_home_end_walk_the_list() -> None:
"""^Y resets the walk to our own node from anywhere. Home and End go to the ends of the list."""
"""^L resets the walk to our own node from anywhere. Home and End go to the ends of the list."""
# A hub with spokes from one of our neighbours: a focus away from us, with a list to move in.
topo = MeshTopology(US, contacts=[Hub])
hub = topo.canonical(Hub.public_key)