^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:
1 parent
3675226a40
commit
125ddb4003
20 files changed
+573
-86
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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`).
|
||||
|
||||
|
||||
@@ -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 = ("^", "⇧", "⌫", "↑", "↓", "←", "→")
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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]
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user