One channel page for the whole visit, and the last hand-rolled round goes
The channel detail was the app's last screen rebuilt per round: a fresh SelectScreen each time, with the cursor restored by default=cursor and anything typed to filter dropped. It stays pushed now, and its rows and vital-signs line are swapped in place after each action — both are live (the mute row is a toggle, the summary counts unread and ages the last message), so both are re-read, and the highlight follows the row that was just used. replace_items gains a prompt= so a summary line above the rows can be refreshed the same way the title already could; without it, keeping the screen would have frozen the vital signs at whatever they said on the way in. That leaves _menu_round with no session path at all — both callers now reach it only when there is no screen stack — so it shrinks to what it always was underneath: select once, dispatch, be called again. The push/await/pop loop and the asyncio import go with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
23865e6c9a
commit
72e11e17e6
2 files changed
+49
-39
No files matched your search
+41
-38
@@ -34,7 +34,6 @@ depend on the context and services; it owns no persistence of its own.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
@@ -245,16 +244,15 @@ async def _menu_round(
|
||||
*,
|
||||
handle: Callable[[object], Awaitable],
|
||||
default: object = None,
|
||||
prompt: str = "",
|
||||
) -> object:
|
||||
"""Show one round of a channels menu and dispatch the choice over it.
|
||||
"""Select once and dispatch the choice — what a surface with no screen stack does.
|
||||
|
||||
In the full-screen session the menu stays *pushed* while ``handle`` runs, so every
|
||||
sub-prompt floats over the list as a modal popup with its own border — the config
|
||||
editor's persistent-backdrop pattern (see
|
||||
:func:`~meshterm.ui.config_editor.edit_config`) — instead of replacing the screen. A
|
||||
surface without a session (the plain CLI surface, scripted tests) simply selects and
|
||||
then dispatches; ``prompt`` is a session-only nicety and is dropped there.
|
||||
Both channel menus keep one screen for the whole visit in the full-screen session
|
||||
(``session.stay``, rows swapped in place), which is the only way the cursor, the sort
|
||||
and a typed filter survive an action. A surface without a stack — the plain CLI, a
|
||||
scripted test — has nothing to keep, so it selects, dispatches, and is called again;
|
||||
``default`` is all it can carry between rounds, and the summary line has nowhere to be
|
||||
drawn at all.
|
||||
|
||||
Args:
|
||||
ctx: Shared application context.
|
||||
@@ -262,22 +260,11 @@ async def _menu_round(
|
||||
items: The menu's :class:`Choice`/:class:`Separator` rows.
|
||||
handle: Async dispatcher awaited with the chosen value (``None`` for Esc).
|
||||
default: A choice value to re-highlight, so the menu reopens where it was left.
|
||||
prompt: An optional summary line drawn inside the box above the rows.
|
||||
|
||||
Returns:
|
||||
Whatever ``handle`` returns.
|
||||
"""
|
||||
session = getattr(ctx.ui, "session", None)
|
||||
if session is None:
|
||||
return await handle(await ctx.ui.select(title, items, default=default))
|
||||
menu = SelectScreen(title, items, prompt=prompt, default=default, wrap=False)
|
||||
menu.future = asyncio.get_running_loop().create_future()
|
||||
session.push(menu)
|
||||
try:
|
||||
choice = await menu.future
|
||||
return await handle(None if choice is CANCEL else choice)
|
||||
finally:
|
||||
session.pop(menu)
|
||||
return await handle(await ctx.ui.select(title, items, default=default))
|
||||
|
||||
|
||||
async def _refresh_chat_channels(ctx: "AppContext") -> None:
|
||||
@@ -710,20 +697,21 @@ async def _channel_detail(
|
||||
"""Show one channel's actions (QR, key, chat, rename, clear); return changes made.
|
||||
|
||||
The screen leads with the channel's vital signs (see :func:`_detail_summary`) above the
|
||||
action rows, and — like the main list — stays pushed while each action's prompts float
|
||||
over it. A rename/re-key or clear closes the detail (the slot's occupant changed, so
|
||||
the caller re-reads the device); the read-only actions loop back here.
|
||||
action rows, and — like the main list — is one screen for the whole visit: it stays
|
||||
pushed while each action's prompts float over it, and the rows and the vital-signs line
|
||||
are swapped in place afterwards rather than the screen being drawn again. Both are live
|
||||
(the mute row is a toggle; the summary counts unread and ages the last message), so both
|
||||
are re-read each round, and the highlight stays on the row that was just used. A
|
||||
rename/re-key or clear closes the detail (the slot's occupant changed, so the caller
|
||||
re-reads the device); the read-only actions loop back here.
|
||||
"""
|
||||
kind = "public" if slot.is_public else "private"
|
||||
title = f"{slot.name} ({kind}, hash {slot.hash})"
|
||||
cursor: object = None
|
||||
|
||||
async def handle(choice: object) -> Optional[int]:
|
||||
"""Run one action; an int closes the detail with that many changes, ``None`` stays."""
|
||||
nonlocal cursor
|
||||
if choice is None: # Esc
|
||||
return 0
|
||||
cursor = choice
|
||||
if choice == _QR:
|
||||
await _show_share(ctx, slot.name, slot.secret)
|
||||
elif choice == _KEY:
|
||||
@@ -740,17 +728,32 @@ async def _channel_detail(
|
||||
return 1
|
||||
return None
|
||||
|
||||
while True:
|
||||
result = await _menu_round(
|
||||
ctx,
|
||||
title,
|
||||
_detail_items(ctx, slot),
|
||||
default=cursor,
|
||||
prompt=_detail_summary(ctx, slot, stats),
|
||||
handle=handle,
|
||||
)
|
||||
if result is not None:
|
||||
return result
|
||||
# A surface with no session (the plain CLI, scripted tests) has no stack to stay on and
|
||||
# runs the same dispatcher a round at a time, as the manager above does.
|
||||
session = getattr(ctx.ui, "session", None)
|
||||
if session is None:
|
||||
while True:
|
||||
result = await _menu_round(
|
||||
ctx, title, _detail_items(ctx, slot), handle=handle
|
||||
)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
menu = SelectScreen(
|
||||
title,
|
||||
_detail_items(ctx, slot),
|
||||
prompt=_detail_summary(ctx, slot, stats),
|
||||
wrap=False,
|
||||
)
|
||||
async with session.stay(menu) as visit:
|
||||
while True:
|
||||
choice = await visit.result()
|
||||
result = await handle(None if choice is CANCEL else choice)
|
||||
if result is not None:
|
||||
return result
|
||||
menu.replace_items(
|
||||
_detail_items(ctx, slot), prompt=_detail_summary(ctx, slot, stats)
|
||||
)
|
||||
|
||||
|
||||
# --- create / join flows -----------------------------------------------------
|
||||
|
||||
@@ -343,7 +343,9 @@ class SelectScreen(Screen):
|
||||
self._index = i
|
||||
break
|
||||
|
||||
def replace_items(self, items: list, *, title: Optional[str] = None) -> None:
|
||||
def replace_items(
|
||||
self, items: list, *, title: Optional[str] = None, prompt: Optional[str] = None
|
||||
) -> None:
|
||||
"""Swap the list's rows (and optionally its title) in place, keeping the reader's place.
|
||||
|
||||
The counterpart to :meth:`~meshterm.ui.tui.session.TuiSession.stay` for a list whose
|
||||
@@ -362,6 +364,9 @@ class SelectScreen(Screen):
|
||||
Args:
|
||||
items: The new rows, in display order.
|
||||
title: A new heading, or ``None`` to keep the current one.
|
||||
prompt: A new instruction/summary line above the rows, or ``None`` to keep the
|
||||
current one — the channel detail's vital signs, which age and count unread
|
||||
alongside the rows they head.
|
||||
"""
|
||||
current = self._current_choice()
|
||||
was = current.value if current is not None else None
|
||||
@@ -372,6 +377,8 @@ class SelectScreen(Screen):
|
||||
)
|
||||
if title is not None:
|
||||
self.title = title
|
||||
if prompt is not None:
|
||||
self._prompt = prompt
|
||||
selectable = [it for it in self._rows() if isinstance(it, Choice)]
|
||||
index = next((i for i, c in enumerate(selectable) if c.value == was), None)
|
||||
if index is None:
|
||||
|
||||
Reference in new issue
Block a user