Files
Jean-Pierre MartineauandClaude Opus 5.5 abfd32c491 Docstrings and comments of meshterm/ui are in ASD-STE100
The same rewrite as for the core package, for the screens, the dialogs,
the map, the charts, and the CLI renderers. The glossary words are used
for the screen: the user (not the reader), dialog (not popup), viewport,
list window, highlight, pointer, graph lane, marker, raster, result
screen, and key prefix (never hash for a truncated key).

The rewrite also corrects approximately 70 docstrings and comments that
no longer agreed with the code: rows that no longer exist (Back rows),
counts that changed, renamed constants, wrong cross-references, and
behaviour that changed. Each correction follows the code; no code
changed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 20:37:26 -04:00

149 lines
6.3 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""The admin-node picker: select a remote node for which you have (or will enter) credentials.
The tools that administer remote nodes over the mesh share this picker: TX optimize and
the repeater admin screen. Thus "select the node to manage" reads the same everywhere.
The nodes that have credentials are first in the list, in their own 🔑 section. These are
the nodes that the user administered before, so they are the most probable selections.
Repeaters and room servers follow, then all the other nodes. Each section is in order of
how recently the node was heard. MeshTerm offers each contact that has a public key,
because a password is a fact about the *user* and not about the node.
The picker is a **dialog**: a question that MeshTerm asks when the user goes into a tool.
It is drawn as a box, and it is gone when the user answers it. It is never a screen on the
stack under the page of the tool, where Esc would land. It was once kept pushed as a hub
for the whole visit. Then, when the user left the admin page, Esc landed on the list from
which the user selected the node. This looked like two places where there is one.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from rich.text import Text
from ..core.models import NODE_TYPE_REPEATER, NODE_TYPE_ROOM, Contact
if TYPE_CHECKING:
from ..context import AppContext
def admin_picker_rows(ctx: AppContext, contacts: list[Contact]) -> tuple[list, list[Contact]]:
"""Build the grouped rows of the admin-node picker and the contacts that they map to.
The function is shared so that each screen that offers the selection draws the same
list. :func:`pick_admin_node` runs these rows as a one-shot dialog. TX optimize turns
them as the first page of its dialog with steps. Each row has ``value = contact.name``,
so a name that the user selects (or a ``default``) resolves through ``candidates``.
Args:
ctx: The shared application context (for the admin store, which sorts the
remembered nodes to the top).
contacts: The contacts that the device knows.
Returns:
``(rows, candidates)``: the grouped select rows and the contacts that MeshTerm can
offer (those that have a key). Both are empty when MeshTerm can offer nothing.
The caller shows a note about this and does not open a list.
"""
from .menus import section_heading
from .theme import name_style
from .tui import Choice
from .widgets import DEFAULT_GLYPH, NODE_GLYPHS
candidates = [c for c in contacts if (c.public_key or c.key_prefix).strip()]
if not candidates:
return [], candidates
def row(contact: Contact) -> Choice:
# The type mark keeps its own fixed hue. The *name* takes the hue that comes from
# the key of the node, like each other list of nodes. (A style on the Text itself
# would be the base of the row, and it would paint the name in the colour of the
# type too.)
glyph, glyph_style = NODE_GLYPHS.get(contact.node_type, DEFAULT_GLYPH)
label = Text()
label.append(f"{glyph} ", style=glyph_style)
label.append(
contact.name,
style=name_style(contact.name, contact.public_key or contact.key_prefix),
)
return Choice(title=label, value=contact.name)
def recency(contact: Contact) -> float:
return -(contact.last_seen.timestamp() if contact.last_seen else 0.0)
remembered = [c for c in candidates if ctx.admin_store.get(c) is not None]
infrastructure = [
c
for c in candidates
if c not in remembered and c.node_type in (NODE_TYPE_REPEATER, NODE_TYPE_ROOM)
]
others = [c for c in candidates if c not in remembered and c not in infrastructure]
items: list = []
if remembered:
items.append(section_heading("Remembered admins"))
items.extend(row(c) for c in sorted(remembered, key=recency))
if infrastructure:
items.append(section_heading("Repeaters & rooms"))
items.extend(row(c) for c in sorted(infrastructure, key=recency))
if others:
items.append(section_heading("Other contacts"))
items.extend(row(c) for c in sorted(others, key=recency))
return items, candidates
async def pick_admin_node(
ctx: AppContext,
contacts: list[Contact],
*,
title: str,
prompt: str,
default: Contact | None = None,
) -> Contact | None:
"""Select a remote node to administer.
Nodes that have credentials, and infrastructure nodes, are first in the list. The
picker is a floating dialog that closes when the user selects a node. A caller can ask
again, for example when the login that it tried was refused. Then the caller
returns the last selection as ``default``, so that the list opens on it again. A step
of :func:`~meshterm.ui.menus.run_steps` offers its previous answer in the same way.
Args:
ctx: The shared application context (for the UI surface and the admin store).
contacts: The contacts that the device knows.
title: The heading of the select screen (it names the feature that calls it).
prompt: One line above the list that says what the selection is for.
default: The node on which the list opens highlighted, if any.
Returns:
The contact that the user selected, or ``None`` if the user cancels (or if there is
nothing to select).
"""
items, candidates = admin_picker_rows(ctx, contacts)
if not candidates:
await _note_nothing_to_pick(ctx, title)
return None
choice = await ctx.ui.select(
title,
items,
prompt=prompt,
default=default.name if default is not None else None,
floating=True,
)
return _resolve(candidates, choice)
async def _note_nothing_to_pick(ctx: AppContext, title: str) -> None:
"""Say that no node can be offered, and show this. MeshTerm opens no list."""
ctx.ui.note("[err]no contacts with a key — receive an advert first[/err]")
await ctx.ui.present(title=title)
def _resolve(candidates: list[Contact], choice: Any) -> Contact | None:
"""The contact for the name of a selected row, or ``None`` if the user selected nothing."""
if choice is None:
return None
return next((c for c in candidates if c.name == choice), None)