diff --git a/CHANGELOG.md b/CHANGELOG.md index 26d177d..54929c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index cf2420b..7fd7ec0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/docs/development/writing-style.md b/docs/development/writing-style.md index 06c2209..a86f7d4 100644 --- a/docs/development/writing-style.md +++ b/docs/development/writing-style.md @@ -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 diff --git a/meshterm/ui/chat.py b/meshterm/ui/chat.py index a04f95a..8a80af2 100644 --- a/meshterm/ui/chat.py +++ b/meshterm/ui/chat.py @@ -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) diff --git a/meshterm/ui/contactlist.py b/meshterm/ui/contactlist.py index fc486f2..3609c66 100644 --- a/meshterm/ui/contactlist.py +++ b/meshterm/ui/contactlist.py @@ -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: diff --git a/meshterm/ui/contacts_screen.py b/meshterm/ui/contacts_screen.py index c62e3cc..b4b3766 100644 --- a/meshterm/ui/contacts_screen.py +++ b/meshterm/ui/contacts_screen.py @@ -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() diff --git a/meshterm/ui/map_screen.py b/meshterm/ui/map_screen.py index 6489254..c421741 100644 --- a/meshterm/ui/map_screen.py +++ b/meshterm/ui/map_screen.py @@ -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)) diff --git a/meshterm/ui/room.py b/meshterm/ui/room.py index a4b9a6d..035d7a2 100644 --- a/meshterm/ui/room.py +++ b/meshterm/ui/room.py @@ -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) diff --git a/meshterm/ui/rooms.py b/meshterm/ui/rooms.py index 03981a5..1a19ff1 100644 --- a/meshterm/ui/rooms.py +++ b/meshterm/ui/rooms.py @@ -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`). diff --git a/meshterm/ui/tui/fkeys.py b/meshterm/ui/tui/fkeys.py index 058f930..e747f29 100644 --- a/meshterm/ui/tui/fkeys.py +++ b/meshterm/ui/tui/fkeys.py @@ -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 = ("^", "⇧", "⌫", "↑", "↓", "←", "→") diff --git a/meshterm/ui/tui/session.py b/meshterm/ui/tui/session.py index e2bc6cc..7c16c9f 100644 --- a/meshterm/ui/tui/session.py +++ b/meshterm/ui/tui/session.py @@ -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. diff --git a/meshterm/ui/walk_screen.py b/meshterm/ui/walk_screen.py index da0dc80..3808e67 100644 --- a/meshterm/ui/walk_screen.py +++ b/meshterm/ui/walk_screen.py @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py index 229c5a2..880e73b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_chat.py b/tests/test_chat.py index f5c58dc..fbbfca3 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -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 diff --git a/tests/test_device_config.py b/tests/test_device_config.py index ff7e2b0..2f33d25 100644 --- a/tests/test_device_config.py +++ b/tests/test_device_config.py @@ -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 diff --git a/tests/test_gallery.py b/tests/test_gallery.py index 3a00cbc..387a6f9 100644 --- a/tests/test_gallery.py +++ b/tests/test_gallery.py @@ -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) diff --git a/tests/test_map.py b/tests/test_map.py index f3f226c..c7244bd 100644 --- a/tests/test_map.py +++ b/tests/test_map.py @@ -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] diff --git a/tests/test_picocalc_lyra_chrome.py b/tests/test_picocalc_lyra_chrome.py index 0f14e5e..500ab23 100644 --- a/tests/test_picocalc_lyra_chrome.py +++ b/tests/test_picocalc_lyra_chrome.py @@ -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: diff --git a/tests/test_rooms.py b/tests/test_rooms.py index 2388ee1..c992a9c 100644 --- a/tests/test_rooms.py +++ b/tests/test_rooms.py @@ -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 ------------------------------------------------------ diff --git a/tests/test_walk.py b/tests/test_walk.py index 4deadee..67e6f4e 100644 --- a/tests/test_walk.py +++ b/tests/test_walk.py @@ -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)