Files
MeshTerm/meshterm/ui/contacts_screen.py
Jean-Pierre MartineauandClaude Haiku 5.5 793bc26436 ^K on the Contacts list locks or unlocks the highlighted contact
The same key as in a conversation. The row gains or loses its padlock in
place, with no confirm, and the next ^K reverses it. On the handhelds, Lock
(or Unlock) rides Shift+F2, as in a channel and a DM.

The hint names ^K only where it fits without dropping another atom. That
rule now lives in one helper, menus.splice_if_room, which the archive
preview uses too.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-19 23:27:05 -04:00

455 lines
22 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""The interactive Contacts screen: the contacts of the device in the shared sortable list.
This screen shows the contact table of the companion with the presentation of the Time
Machine picker. It has the same aligned ``NAME · HEARD · PKTS · KEY`` lanes, with our node
pinned first. It has the same Ctrl+arrow sort (now with the key column) and the same
type-to-filter. Thus the whole app has one contact list, for each source of data (refer to
:mod:`~meshterm.ui.contactlist`). Enter opens the full-screen Node detail page of the
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. ^L opens the map on the highlighted node, when it has a location. ^K locks the
highlighted contact against the archive sweep, or unlocks it. 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
bulk counterpart of the single archive action. It exists because the contact table of a
companion is finite. A busy mesh fills the table with nodes that were heard one time, in
passing, until no room is left to discover a new node. The action ranks the whole table
(refer to :mod:`~meshterm.core.contact_score`). It archives the weakest contacts off the
device, and MeshTerm keeps them. The flow, its ladder, and its preview are in
:mod:`~meshterm.ui.sweep_screen`. Our node is pinned above the list and is never a
candidate, because it is not in the contact table of the device.
MeshTerm never hides what a sweep took. **View archived contacts** is directly under the
archive action. It opens the archived list (refer to :mod:`~meshterm.ui.archived_screen`).
This is its own screen, with the same lane layout, sorted on its own ``NAME · ARCHIVED ·
KEY`` columns. It is a screen and not a section at the end of this screen, because it is a
different list with different columns. An archived contact has no live heard age or packet
count that is worth a lane. What it does have, the time that it left, has no column here.
To remove *one* named contact for good belongs to that contact and not to the list. It is
the last action on the Node detail page of the contact, where the user already looks at
the node that the user wants to remove. That action is a real deletion. It goes to both
halves of what the list shows: the device's own table and the store that lasts
between sessions (refer to :mod:`~meshterm.core.contact_store`). Thus a bridge with no
firmware does not merge the contact back. In both cases, only the *contact* goes. MeshTerm
does not change the reception history and the overheard traffic of each node.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from .archived_screen import open_archived
from .contactlist import ContactListScreen, ContactRow
from .menus import icon_lane, marked_label, splice_if_room
from .sweep_screen import archive_contacts
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.
YOU = ("you",)
#: The sentinels of the tail of the contact list (different from each
#: :class:`~meshterm.core.models.Contact` and from :data:`YOU`): the sweep, and the way to
#: what it already took.
_ARCHIVE = ("archive",)
_ARCHIVED = ("archived",)
#: Each mark that a maintenance row of the tail starts with. MeshTerm measures the icon
#: column over this set (refer to :func:`~meshterm.ui.menus.icon_lane`). The set is
#: declared and not found from the rows. Thus the column has one width for the whole tail,
#: for each of the two rows that a visit draws.
_TAIL_ICONS = ("💾", "📂")
class ContactsScreen(ContactListScreen):
"""A full-screen list of our node and its known contacts, sortable with Ctrl+arrow.
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. ^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. ^K
resolves one with the highlighted contact, so that :func:`open_contacts` toggles its
lock. 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) and ``Lock`` (^K) on the Shift bank.
``Map`` is on the Shift half of F1. 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.
``Lock`` is on the Shift half of F2, behind ``Sect ↓``: the same key as in a channel
and a DM. It toggles the lock of the highlighted contact, so it reads ``Unlock`` on
a locked contact. It is lit on a contact that can have a lock, and dim on our node
and on the rows of the tail. It is empty where MeshTerm cannot store a lock.
"""
lane = list(super().picocalc_lyra_lane)
if self._locator is not None:
lane[0] = _with_opp(lane[0], "Map", "locate", self._highlighted_marker() is not None)
if self._can_lock:
target = self._lock_target()
label = "Unlock" if target is not None and target[1] else "Lock"
lane[1] = _with_opp(lane[1], label, "lock", target is not None)
return lane
def __init__(
self,
self_name: str,
self_key: str,
contacts: list[Contact],
prefix_bytes: int,
counts: dict[str, int],
sort: ContactsSort,
archived: int = 0,
locked: frozenset[str] = frozenset(),
locator: Locator | None = None,
can_lock: bool = False,
) -> None:
"""Create the contacts screen over contact data that MeshTerm already got.
Args:
self_name: The advertised name of our node.
self_key: The full public key of our node (hex). If it is blank, the screen
draws a muted ``?``.
contacts: The known contacts to list under our node.
prefix_bytes: The path hash width in bytes to highlight in each key.
counts: The counts of overheard packets, keyed by the node id in lowercase (12
hex digits). A contact with no entry shows a faint ``—``.
sort: The initial sort. The Ctrl+arrows change it in place. Its ring should
span :data:`~meshterm.ui.contactlist.SORT_COLUMNS` so that the hash sort
is available.
archived: The number of contacts that are archived off this device. If it is
not zero, the screen adds the ``View archived contacts`` row and shows the
count on that row and in the title (``Contacts · 198 known + 123
archived``). Thus the user sees the count without opening the list. If it
is zero, the screen draws no row and the title ends at ``… known``,
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.
can_lock: Whether the caller can store a lock, for ^K. With ``False``, ^K does
nothing.
"""
self._self_name, self._self_key = self_name, self_key
self._locator = locator
self._can_lock = can_lock
self._locked = locked
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
# a blank spacer, then the sweep (it has a `…` because it opens more prompts), then
# the way to what the sweep already took. The two rows are together because they are
# the two halves of one idea. The archived row is *under* the sweep for the same
# reason: it is where the output of the sweep went. The sweep has the 💾 of the
# single-contact Archive action and no red, because it archives, and we keep red for
# data loss. Both rows have one icon column. MeshTerm measures it and does not
# assume it. Thus a mark that is narrower than the mark of the other row cannot
# start its label one column to the left of the label of the other row.
lane = icon_lane(_TAIL_ICONS)
tail: list = []
if contacts:
tail = [
Separator(" "),
Choice(
title=marked_label("💾", "Archive contacts…", "", lane=lane),
value=_ARCHIVE,
),
]
if archived:
tail = tail or [Separator(" ")]
# The count is on the row in the muted style, as a status atom and not as part
# of the verb. It is what the row leads to, not what the row does.
label = marked_label("📂", "View archived contacts", "", lane=lane)
label.append(f" · {archived}", style="muted")
tail.append(Choice(title=label, value=_ARCHIVED))
# The title counts both halves of the contacts of this device: those that are on it
# and those that a sweep moved off it. If the frame is too narrow for the words, the
# numbers stay and the words go (``Contacts · 198 + 123``).
known = len(contacts)
title = f"Contacts · {known} known"
if archived:
title += f" + {archived} archived"
super().__init__(
title,
rows=rows,
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}"
def _contact_rows_for(self, locked: frozenset[str]) -> list[ContactRow]:
"""Our node, then each contact. ``locked`` shows which contacts are locked."""
rows = [ContactRow(value=YOU, name=self._self_name, key=self._self_key, you=True)]
for c in self._contacts:
rows.append(
ContactRow(
# The contact itself is the identity of the row. Thus Enter gives the
# whole record to the detail page, with no new search by name or key.
value=c,
name=c.name,
key=c.public_key,
node_type=c.node_type,
last_seen=c.last_seen,
count=contact_packets(c, self._counts),
locked=(c.public_key or "").lower().removeprefix("0x") in locked,
)
)
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 _lock_target(self) -> tuple[Contact, bool] | None:
"""The contact that ^K locks or unlocks, and whether it is locked now.
``None`` when ^K does nothing: on our node, on a row of the tail, on a contact with
no full key (the store attaches a lock to the full key), or when the caller cannot
store a lock.
"""
current = self._current_choice()
if not self._can_lock or current is None:
return None
contact = current.value
key = (getattr(contact, "public_key", None) or "").lower().removeprefix("0x")
if contact == YOU or len(key) != 64:
return None
return contact, key in self._locked
@property
def footer_hint(self) -> str:
"""The hint of the list, with ``^K lock`` or ``^K unlock`` where it has room for it."""
hint = super().footer_hint
target = self._lock_target()
if target is None:
return hint
return splice_if_room(hint, "^K unlock" if target[1] else "^K lock")
def handle(self, action: str, data: str = "") -> None:
"""^L asks :func:`open_contacts` to open the map, and ^K to toggle the lock.
The list handles all other actions.
"""
if action == "locate":
marker = self._highlighted_marker()
if marker is not None:
self.resolve(KeyRequest("locate", marker))
return
if action == "lock":
target = self._lock_target()
if target is not None:
self.resolve(KeyRequest("lock", target[0]))
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.
A lock changes a lane, but it does not change which contacts exist. Thus this is the
case for :meth:`~meshterm.ui.contactlist.ContactListScreen.update_rows`, not for a
new screen. The highlight, the sort, the scroll, and a typed filter all stay where
they were.
"""
self._locked = locked
self.update_rows(self._contact_rows_for(locked))
def _with_opp(slot, label: str, action: str, enabled: bool): # noqa: ANN001, ANN202 - an FPair
"""``slot`` (an F-key slot, or ``None``) with ``label`` and ``action`` as its Shift half.
The plain half stays as the list gave it: ``Sect ↑`` or ``Sect ↓`` when the list has
sections, or an empty half when it has none.
"""
from .tui.fkeys import FPair
return FPair(
slot.label if slot else "",
slot.action if slot else "",
label,
action,
enabled=slot.enabled if slot else False,
opp_enabled=enabled,
)
async def open_contacts(
ctx: AppContext,
self_name: str,
self_key: str,
contacts: list[Contact],
prefix_bytes: int,
counts: dict[str, int],
sort: ContactsSort,
) -> None:
"""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)
nest *above* it. Esc from there is one pop back onto the row from which the user opened
the page, with the same sort, the same filter, and the same scroll. Some actions change
which contacts the device holds: a sweep here, a single archive, restore, or delete on
a detail page, or a restore from inside the archived list. These actions end the visit.
Then MeshTerm reads the device again and starts a new visit. This new screen is the
deliberate exception to keeping the screen: the rows that the screen held a place in do
not exist now.
Args:
ctx: The shared application context (it must be in the interactive menu).
self_name: The advertised name of our node.
self_key: The full public key of our node (hex).
contacts: The known contacts to list under our node.
prefix_bytes: The path hash width in bytes to highlight in each key.
counts: The counts of overheard packets, keyed by the node id in lowercase (12 hex
digits).
sort: The initial sort (column and direction) over the ring of the shared list. It
changes in place, so a new sort stays when the user visits a detail page and
comes back.
Raises:
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."""
return ContactsScreen(
self_name,
self_key,
contacts,
prefix_bytes,
counts,
sort,
archived=_archived_count(ctx, self_key),
locked=_locked_keys(ctx, self_key),
locator=locator,
can_lock=_can_lock(ctx, self_key),
)
screen = build()
while True:
rebuild = False
async with session.stay(screen) as visit:
while True:
chosen = await visit.result()
if chosen is CANCEL or chosen is None: # Esc
return
if isinstance(chosen, KeyRequest) and chosen.action == "lock":
# ^K: no confirm and no notice, as on the node page. The next ^K
# reverses it, and the padlock that the row gains or loses is the
# acknowledgement. The highlight, the sort, and the filter stay.
_toggle_lock(ctx, self_key, chosen.value)
screen.refresh_locks(_locked_keys(ctx, self_key))
continue
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
# invalidates the contacts cache. Thus MeshTerm reads the list again and
# builds it again. This is the only deliberate reset in the life of this
# screen, because the rows in which it held a place are gone from the
# device.
if await archive_contacts(ctx, self_key):
rebuild = True
break
# A sweep can lock contacts and archive none. Then the rows stay, and
# only their padlocks are drawn again.
screen.refresh_locks(_locked_keys(ctx, self_key))
continue
if chosen == _ARCHIVED:
# The archived list is a screen and not a section. It nests above this
# screen, and Esc comes back to this row. It makes this screen again
# only if the user restored or deleted a contact in it, because then
# this list is stale.
if await open_archived(ctx, self_key, prefix_bytes):
rebuild = True
break
continue
# The sentinel of our node opens the page of our node. Each other value is a
# Contact. The page nests above this list and has the verbs that manage one
# contact: archive, restore, and delete. When one of them runs, the page
# says so when it closes, and this list is built again without the row. This
# is the same new read that the sweep does, for one contact at a time.
if await open_node_detail(ctx, None if chosen == YOU else chosen):
rebuild = True
break
# The page can lock or unlock the contact without an end to the visit. The
# store answers from memory, so MeshTerm only draws the padlocks again.
screen.refresh_locks(_locked_keys(ctx, self_key))
if not rebuild:
return
contacts = await ctx.devstate.contacts()
locator = await Locator.gather(ctx)
screen = build()
def _archived_count(ctx: AppContext, self_key: str) -> int:
"""The number of contacts archived off this device: the count and the gate of the tail row."""
store = getattr(ctx, "contact_store", None)
dev_pub = (self_key or "").lower().removeprefix("0x")
if store is None or not dev_pub:
return 0
return len(store.archived(dev_pub))
def _can_lock(ctx: AppContext, self_key: str) -> bool:
"""Whether a lock can be stored on this device: a contact store, and the key of the device."""
store = getattr(ctx, "contact_store", None)
return store is not None and bool((self_key or "").lower().removeprefix("0x"))
def _toggle_lock(ctx: AppContext, self_key: str, contact: Contact) -> None:
"""Lock ``contact`` on this device, or unlock it when it is locked (^K)."""
store = ctx.contact_store
dev_pub = self_key.lower().removeprefix("0x")
store.set_locked(dev_pub, contact, not store.is_locked(dev_pub, contact.public_key))
def _locked_keys(ctx: AppContext, self_key: str) -> frozenset[str]:
"""The keys of the contacts locked on this device. These rows draw a padlock."""
store = getattr(ctx, "contact_store", None)
dev_pub = (self_key or "").lower().removeprefix("0x")
if store is None or not dev_pub:
return frozenset()
return store.locked_keys(dev_pub)