Files
MeshTerm/meshterm/ui/admin_picker.py
T
Jean-Pierre MartineauandClaude Opus 5 92d31a3191 Every file is spelled the way the formatter spells it
`ruff format .` across the repo: 153 files, no behaviour change. This is the
second gate CONTRIBUTING.md promises, and until now it reported 154 files needing
reformat — so a first contributor ran the command the guide told them to run and
could not tell their own diff from the background.

Landed on its own, with nothing else in it, because a whole-repo reformat is only
safe to skip past in blame if it is provably nothing but layout. It was checked
that way rather than assumed: every changed file was formatted in memory and
compared by full `ast.dump()`, and 152 of 153 come out structurally identical
(the one exception was a docstring opening on a quote character, fixed in the
commit before this one). The string and comment token streams were diffed
separately, so a quote flip or an edited comment could not hide inside a
re-layout — five quote-style normalisations and sixteen implicit-concatenation
joins, all value-identical, and one commented-out line gaining the space after
its `#`. `meshterm specimen` is byte-identical on both platforms, and the suite
is 1888 green, gallery gate and `test_theme16` included.

What actually changed, in order of how much of the diff it is: wrapped calls and
signatures rejoin onto one line where they fit inside the hundred columns; the
magic trailing comma expands what it is there to expand; complex slices gain
their spaces (`[len(noise) :]`); a few redundant parens go. The tables that are
laid out to be read were fenced in the previous commit and are untouched here.

The aligned trailing-comment column is gone from about ninety lanes that were not
fenced — including the `heat.*` ladder in `MESH_THEME`, which cannot be fenced
where it sits. Every comment stays on the entry it describes, so nothing there
reads wrong; it just no longer lines up.

Add this commit to `.git-blame-ignore-revs`, which follows immediately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 01:07:19 -04:00

187 lines
7.4 KiB
Python

"""The admin-node picker: choose a remote node you hold (or will enter) credentials for.
Shared by the tools that administer remote nodes over the mesh — TX optimize and the
repeater admin screen — so "pick the node to manage" reads identically everywhere.
Credentialed nodes lead the list in their own 🔑 section (those are the nodes previously
administered, the likeliest picks), repeaters and room servers follow, then everything
else; each section orders by how recently the node was heard. Any contact with a public
key is offerable, since holding a password is a fact about the *user*, not the node.
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
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 admin-node picker's grouped rows and the contacts they map to.
Shared so the pick and any later redraw are guaranteed identical: :func:`pick_admin_node`
runs these rows, and a caller can rebuild the same list as a static backdrop to float a
follow-up dialog (the admin login) over the very list the node was picked from. Rows carry
``value = contact.name``, so a picked name (or a backdrop's ``default``) resolves through
``candidates``.
Args:
ctx: Shared application context (for the admin store, which sorts remembered nodes up).
contacts: The device's known contacts.
Returns:
``(rows, candidates)`` — the grouped select rows and the offerable contacts
(those holding a key). Both are empty when nothing is offerable; the caller
notes that and never opens a list at all.
"""
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 node's key-derived
# colour like every other list of nodes (a style on the Text itself would be the
# row's base and would paint the name the type's colour 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,
) -> Contact | None:
"""Pick a remote node to administer, credentialed and infrastructure nodes first.
The one-shot form: the list comes down as soon as a node is picked. A caller whose next
step belongs *over* the list — the admin login, which asks about the node just picked —
wants :func:`admin_node_visit` instead.
Args:
ctx: Shared application context (for the UI surface and the admin store).
contacts: The device's known contacts.
title: The select screen's heading (names the calling feature).
prompt: One line above the list saying what the pick is for.
Returns:
The chosen contact, or ``None`` if cancelled (or there is nothing to pick).
"""
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)
return _resolve(candidates, choice)
@asynccontextmanager
async def admin_node_visit(
ctx: AppContext,
contacts: list[Contact],
*,
title: str,
prompt: str,
) -> AsyncIterator[_AdminNodePicker | None]:
"""Keep the node picker pushed for a whole visit, yielding a picker to draw from.
The list stays on the stack for the duration of the block, so everything the caller does
with a picked node — the password prompt, a rejection, the admin session itself — nests
*above* the very list the pick came from, with the picked node still highlighted, instead
of floating over a blank frame or being drawn again as a lookalike backdrop. Esc from any
of it is one pop back onto the list.
Yields ``None`` when there is nothing to pick (the caller has already been told).
Args:
ctx: Shared application context (for the UI surface and the admin store).
contacts: The device's known contacts.
title: The select screen's heading (names the calling feature).
prompt: One line above the list saying what the pick is for.
"""
from .surface import TuiUi
from .tui import SelectScreen
items, candidates = admin_picker_rows(ctx, contacts)
if not candidates or not isinstance(ctx.ui, TuiUi):
if not candidates:
await _note_nothing_to_pick(ctx, title)
yield None
return
screen = SelectScreen(title, items, prompt=prompt)
async with ctx.ui.session.stay(screen) as visit:
yield _AdminNodePicker(visit, candidates)
class _AdminNodePicker:
"""The visited node list: :meth:`pick` is one round of it, resolved to a contact."""
__slots__ = ("_visit", "_candidates")
def __init__(self, visit: Any, candidates: list[Contact]) -> None:
"""Bind the picker to the list's visit and the contacts its rows stand for."""
self._visit = visit
self._candidates = candidates
async def pick(self) -> Contact | None:
"""Await one pick: the chosen contact, or ``None`` on Esc."""
from .tui.screen import CANCEL
choice = await self._visit.result()
return _resolve(self._candidates, None if choice is CANCEL else choice)
async def _note_nothing_to_pick(ctx: AppContext, title: str) -> None:
"""Say there is no offerable node and show it — no list is opened at all."""
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 a picked row's name stands for, or ``None`` when nothing was picked."""
if choice is None:
return None
return next((c for c in candidates if c.name == choice), None)