JP's pass over the sweep, and one cut corner it found: the gallery specimen hand-stamped its percentiles, so three contacts rendered as the 2nd, 7th and 11th of a field of three. The number cannot be written down and still be true — it is a rank against the others in the same list — so the specimen now scores through rank_contacts like everything else and reads 10/30/50. The computation was never wrong; the picture of it was. The ladder answers in the number the reader is choosing: how many contacts you *keep*, the one that has to fit the device, with what goes as the muted remainder. It also stops rounding with round(), whose banker's habit sent 4.5 and 3.75 to the same 4 and made "keep 90%" and "keep 75%" identical rungs on a five-contact table; ceil is both distinct and the safe direction on a keep. The preview is editable. Delete lifts one contact out of the sweep and the list refreshes through replace_items, so sparing the one node you recognised no longer means backing out to a shallower rung that also spares forty you didn't care about. Rows h-scroll with the percentile lane pinned out of the shift — it is your place in a list ordered by it. Neither the scroll atom nor the keep atom is written into the base hint; the select list splices each in where its key would act, which is also what keeps all three inside 72 cells. It is a hub now, held by session.stay: re-running run_screen on a screen that had already resolved spun forever the first time a row was spared. Opening a candidate's page from in there offered to delete it, which was two answers to one question. open_node_detail takes manage=False: the rule is that management verbs belong to the list a contact lives in, never to a page opened out of a list about to act on it wholesale. The confirm is amber and button-only. Two caution tiers exist for two costs, and a red typed gate here spent the app's loudest warning on its most reversible bulk action. Red is left to the deletion, which now says what it is — the key forgotten by both sides, no restore, only being heard again — and points at the archive as the reversible way to free the same slot. The page carries all three verbs: archive a live contact, restore an archived one, delete either for good. Archived contacts get their own screen rather than a section nailed to the foot of the Contacts list, reached by a row under the purge carrying the tally. That needed the shared contact list to stop believing every list has the same columns: the middle lanes are now a declared tuple of ContactLane (NAME and KEY stay fixtures), read by the header, the row builder, the sort metric and the width arithmetic alike, replacing a show_traced flag that had already made three of those four say "and if it's the trace picker…". The archived list declares NAME · ARCHIVED · KEY and sorts on exactly those. ARCHIVED draws as HEARD does — a relative age under recency heat — because it is the same question about a different event. One simplification falls out: TRACED sorted on a raw timestamp and opened descending to mean newest-first, alone among the age columns. Every age lane now sorts by age with never at +inf, so traced opens ascending like heard, and reads the same either way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
164 lines
6.9 KiB
Python
164 lines
6.9 KiB
Python
"""The Archived contacts list: what the sweep took off the device, and when.
|
|
|
|
A purge (see :mod:`~meshterm.ui.purge_screen`) removes a contact from the *device* — the
|
|
scarce resource — and keeps it here in full. This screen is where those contacts live: the
|
|
same sortable, filterable lane layout as the Contacts list, in its own lane set
|
|
(``NAME · ARCHIVED · KEY``, :data:`~meshterm.ui.contactlist.ARCHIVED_LANES`), with its own
|
|
sort ring over exactly those three columns.
|
|
|
|
The ``ARCHIVED`` lane is drawn as ``HEARD`` is on the main list — a relative age under
|
|
recency heat — because it answers the same shape of question about a different event, and a
|
|
second grammar for "how long ago" would have been one for the reader to learn for nothing.
|
|
It opens sorted by it, freshest first: the question this screen answers is *what did that
|
|
sweep just take?*, and the answer belongs at the top.
|
|
|
|
**It carries no actions.** Not a select list with the verbs removed — a list with nothing to
|
|
commit, which Esc leaves. Everything you might do to an archived contact belongs to that one
|
|
contact and lives on its Node detail page, which Enter opens: restoring it to the device, or
|
|
deleting it for good. Putting a restore verb on this screen as well would have been a second
|
|
route to the same write, and the page is where the reader can actually see which node they
|
|
are about to act on.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
from typing import TYPE_CHECKING, Optional
|
|
|
|
from .contactlist import (
|
|
ARCHIVED_LANES,
|
|
ARCHIVED_SORT_COLUMNS,
|
|
ARCHIVED_SORT_OPENS_ASCENDING,
|
|
ContactListScreen,
|
|
ContactRow,
|
|
)
|
|
from .tui.screen import CANCEL
|
|
from .widgets import ContactsSort
|
|
|
|
if TYPE_CHECKING:
|
|
from ..context import AppContext
|
|
from ..core.contact_store import RememberedContact
|
|
|
|
#: The list's footer: navigation, the sort, filtering, then Esc last. ``Enter open`` because
|
|
#: a row pushes the node's detail page — the same verb the Contacts list uses for the same
|
|
#: gesture.
|
|
_HINT = "↑↓ move · ^←→↑↓ sort · type to filter · Enter open · Esc back"
|
|
|
|
|
|
def _archived_time(stamp: Optional[int]) -> Optional[datetime]:
|
|
"""A stored archive stamp as an aware UTC datetime, or ``None`` if it carries none.
|
|
|
|
Stored as unix seconds (see :class:`~meshterm.core.contact_store.RememberedContact`) and
|
|
rendered as a local-time age by the lane, so it crosses into the list's own currency
|
|
here rather than in three places downstream.
|
|
"""
|
|
if not stamp:
|
|
return None
|
|
try:
|
|
return datetime.fromtimestamp(int(stamp), tz=timezone.utc)
|
|
except (OverflowError, OSError, ValueError):
|
|
return None # a corrupt stamp reads "never", not a crash
|
|
|
|
|
|
def archived_rows(archived: "list[RememberedContact]") -> list[ContactRow]:
|
|
"""Turn remembered contacts into the shared list's row data.
|
|
|
|
Args:
|
|
archived: The device's archived contacts, from
|
|
:meth:`~meshterm.core.contact_store.ContactStore.archived`.
|
|
|
|
Returns:
|
|
One :class:`~meshterm.ui.contactlist.ContactRow` each, carrying the rebuilt
|
|
:class:`~meshterm.core.models.Contact` as its value so Enter hands the whole record
|
|
to the detail page with no re-lookup.
|
|
"""
|
|
return [
|
|
ContactRow(
|
|
value=remembered.to_contact(),
|
|
name=remembered.name,
|
|
key=remembered.public_key,
|
|
node_type=remembered.node_type,
|
|
archived_at=_archived_time(remembered.archived_at),
|
|
)
|
|
for remembered in archived
|
|
]
|
|
|
|
|
|
class ArchivedScreen(ContactListScreen):
|
|
"""A full-screen, sortable list of the contacts a sweep took off this device."""
|
|
|
|
def __init__(self, rows: list[ContactRow], prefix_bytes: int, sort: ContactsSort) -> None:
|
|
"""Build the list over already-resolved rows.
|
|
|
|
Args:
|
|
rows: The archived contacts' lane data (see :func:`archived_rows`).
|
|
prefix_bytes: Path-hash width in bytes to highlight in every key.
|
|
sort: The sort, mutated in place by the Ctrl+arrows. Its ring should span
|
|
:data:`~meshterm.ui.contactlist.ARCHIVED_SORT_COLUMNS`.
|
|
"""
|
|
super().__init__(
|
|
f"Archived contacts · {len(rows)} kept",
|
|
rows=rows,
|
|
prefix_bytes=prefix_bytes,
|
|
sort=sort,
|
|
prompt=(
|
|
"Off the device, kept here with their history. Open one to restore it."
|
|
),
|
|
footer_hint=_HINT,
|
|
lanes=ARCHIVED_LANES,
|
|
)
|
|
|
|
|
|
async def open_archived(ctx: "AppContext", self_key: str, prefix_bytes: int) -> bool:
|
|
"""Open the archived list; Enter opens Node detail, Esc leaves. ``True`` if anything changed.
|
|
|
|
The list stays pushed for the whole visit, so a detail page nests above it and Esc from
|
|
there lands back on the row it was opened from — same sort, same filter, same scroll.
|
|
A page that *restored* or *deleted* the contact it detailed ends the visit and the list
|
|
is rebuilt without the row, which is the same deliberate exception the Contacts list
|
|
makes: the row it was holding a place in no longer exists.
|
|
|
|
Args:
|
|
ctx: Shared application context (interactive menu).
|
|
self_key: The device's own public key (hex) — how the store scopes this device's
|
|
remembered contacts.
|
|
prefix_bytes: Path-hash width in bytes to highlight in every key.
|
|
|
|
Returns:
|
|
``True`` if any contact was restored or deleted, so the caller's own contact list
|
|
knows to re-read.
|
|
"""
|
|
from .node_detail_screen import open_node_detail
|
|
from .surface import TuiUi
|
|
|
|
assert isinstance(ctx.ui, TuiUi) # guaranteed by the Contacts screen
|
|
session = ctx.ui.session
|
|
store = ctx.contact_store
|
|
dev_pub = (self_key or "").lower().removeprefix("0x")
|
|
sort = ContactsSort.from_name(
|
|
"archived", ARCHIVED_SORT_COLUMNS, ARCHIVED_SORT_OPENS_ASCENDING
|
|
)
|
|
changed = False
|
|
while True:
|
|
rows = archived_rows(store.archived(dev_pub) if store and dev_pub else [])
|
|
if not rows:
|
|
# Everything was restored or deleted while the reader was in here; there is no
|
|
# list left to show, and an empty screen they would have to Esc out of says
|
|
# less than the note does on the way back.
|
|
ctx.ui.note("[muted]no archived contacts[/muted]")
|
|
return changed
|
|
screen = ArchivedScreen(rows, prefix_bytes, sort)
|
|
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 changed
|
|
# The page manages the contact it details — restore it, or delete it for
|
|
# good — and says so on the way out, at which point this list is stale.
|
|
if await open_node_detail(ctx, chosen):
|
|
changed = rebuild = True
|
|
break
|
|
if not rebuild:
|
|
return changed
|