Channels goes through the shared row builders it was working around
It was never in the dual-platform gallery, which is why none of this was caught. Adding it first, then fixing what it showed. The column header was a hand-built f-string with a five-cell indent and no idea of the render width: at 53 columns it ran to 54 and wrapped, costing a content row out of twenty-six and drawing the pinned landmark twice, and its indent assumed a two-cell channel glyph where the console draws one. It goes through column_header now, which measures the glyph and sheds ACTIVITY before it wraps. The command rows hand-padded their icons, so the one-cell marks (↕ ✎ 🗑) started their labels a column left of the two-cell ones - the Organize row had a two-space hack and a four-line comment about exactly this. They share a measured icon lane now, and the clear row tints its mark rather than its words. The empty message count was ·, which in that same row is also the status-atom separator and what the console folds 🔕 to: a muted channel with no messages drew it twice, two cells apart, meaning different things. It is ○, the app's empty mark. Also: the detail title was a 42-cell parenthesised blob that filled the PicoCalc's whole title bar, leaving nowhere to say Esc leaves - it is "Channel — name", with the openness and hash now leading a vital-signs line that sheds atoms rather than wrapping. The manager says "Enter open", which is what its rows do. The slot count chains with · rather than an em dash. And the four Add rows are replaced by one empty state when there is no free slot to add into, instead of all four dead-ending in the same refusal. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
4c01e1da7e
commit
d19e2ec712
3 files changed
+262
-55
No files matched your search
+149
-52
@@ -40,6 +40,7 @@ from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from rich.cells import cell_len
|
||||
from rich.console import Group
|
||||
from rich.text import Text
|
||||
|
||||
@@ -62,9 +63,20 @@ from ..core.channels import (
|
||||
from ..core.connection import Device
|
||||
from ..core.models import Conversation
|
||||
from ..persistence.repository import ACTIVITY_DRAWN_BUCKETS
|
||||
from ..platforms import get_platform
|
||||
from .braillechart import activity_peak, activity_sparkline
|
||||
from .menus import command_label, fit_cells, menu_rows, run_steps, section_heading
|
||||
from .menus import (
|
||||
Lane,
|
||||
column_header,
|
||||
fit_cells,
|
||||
icon_lane,
|
||||
marked_label,
|
||||
menu_rows,
|
||||
run_steps,
|
||||
section_heading,
|
||||
)
|
||||
from .qr import share_popup
|
||||
from .theme import glyph
|
||||
from .tui import CANCEL, Choice, SelectScreen, Separator
|
||||
from .widgets import _age_seconds, _format_age, channel_glyph, format_ago
|
||||
|
||||
@@ -72,6 +84,9 @@ if TYPE_CHECKING:
|
||||
from ..context import AppContext
|
||||
from ..persistence.repository import ChannelStats
|
||||
|
||||
#: The manager's footer sentence: navigation, then the filter, then the action, Esc last.
|
||||
_MANAGER_HINT = "↑↓ move · type to filter · Enter open · Esc back"
|
||||
|
||||
# Top-menu action sentinels (distinct from a plain slot index, which selects that channel).
|
||||
_CREATE = "__create__"
|
||||
_PUBLIC = "__public__"
|
||||
@@ -265,7 +280,10 @@ async def manage_channels(ctx: AppContext) -> int:
|
||||
if not keep:
|
||||
return changes
|
||||
title, items = await settle(moved=ctx.devstate.channels_epoch != epoch)
|
||||
menu = SelectScreen(title, items)
|
||||
# ``Enter open``, not the list default: every slot row pushes the channel's detail
|
||||
# screen and Reorder pushes the reorder screen, which is what the chat picker — the same
|
||||
# channels, one screen over — has always said.
|
||||
menu = SelectScreen(title, items, footer_hint=_MANAGER_HINT)
|
||||
async with session.stay(menu) as visit:
|
||||
while True:
|
||||
choice = await visit.result()
|
||||
@@ -607,24 +625,36 @@ def _activity_sparkline(histogram: tuple[int, ...], peak: float) -> Text:
|
||||
# --- menus -------------------------------------------------------------------
|
||||
|
||||
|
||||
def _lanes_header(name_w: int) -> str:
|
||||
def _lanes_header(name_w: int, width: int) -> str:
|
||||
"""Column headers over the channel list's fixed lanes (see :func:`_slot_text`).
|
||||
|
||||
The leading spaces cover the select screen's pointer column (2 cells) plus the glyph
|
||||
lane (3 cells), so each header lands exactly over its column. ``UNREAD`` borrows its
|
||||
lane's trailing gap — the badge lane itself is one cell too narrow for the word — which
|
||||
still leaves a space before the message count. (No TYPE or HASH lane: the glyph already
|
||||
carries the openness and the hash lives in Show key, which buys the activity sparkline
|
||||
its room on a 72-column terminal.)
|
||||
The indent covers the select screen's pointer column (2 cells, drawn on choice rows but
|
||||
not separators) plus the glyph lane, *measured* rather than assumed: a channel's glyph
|
||||
is two cells on the desktop and one on the console, so a hard-coded indent put every
|
||||
label a column off there. ``UNREAD`` borrows its lane's trailing gap — the badge lane
|
||||
itself is one cell too narrow for the word — which still leaves a space before the
|
||||
message count. (No TYPE or HASH lane: the glyph already carries the openness and the
|
||||
hash lives in Show key, which buys the activity sparkline its room on a 72-column
|
||||
terminal.)
|
||||
|
||||
Resolved against the render width, because the header row is pinned and must stay one
|
||||
row: at 53 columns the full line ran to 54 and wrapped, costing a content row out of
|
||||
twenty-six and leaving the landmark drawn twice over. ``ACTIVITY`` gives its cells back
|
||||
first (see :func:`~meshterm.ui.menus.column_header`).
|
||||
"""
|
||||
return (
|
||||
" "
|
||||
+ "CHANNEL".ljust(name_w + 2)
|
||||
+ "UNREAD".ljust(_BADGE_WIDTH + 2)
|
||||
+ f"{'MSGS':>{_COUNT_WIDTH}}"
|
||||
+ " "
|
||||
+ f"{'LAST':>{_AGE_WIDTH}}"
|
||||
+ " ACTIVITY"
|
||||
return column_header(
|
||||
[
|
||||
Lane("CHANNEL", name_w + 2),
|
||||
Lane("UNREAD", _BADGE_WIDTH + 2),
|
||||
# Right-aligned, because the values under them are: a count and an age are
|
||||
# padded to the right edge of their lane, so a left-aligned label would sit
|
||||
# off the digits it names.
|
||||
Lane(f"{'MSGS':>{_COUNT_WIDTH}}", _COUNT_WIDTH + 2),
|
||||
Lane(f"{'LAST':>{_AGE_WIDTH}}", _AGE_WIDTH + 2),
|
||||
Lane(("ACTIVITY", "ACT")),
|
||||
],
|
||||
width,
|
||||
indent=2 + cell_len(channel_glyph("Public", None)) + 1,
|
||||
)
|
||||
|
||||
|
||||
@@ -661,8 +691,9 @@ def _slot_text(ctx: AppContext, slot: ChannelSlot, stats: _LiveStats, name_w: in
|
||||
text.append(fit_cells(slot.name, name_w))
|
||||
text.append(" ")
|
||||
if muted:
|
||||
text.append("🔕", style="muted") # 2 cells; pad the rest of the lane
|
||||
text.append(" " * (_BADGE_WIDTH - 2))
|
||||
mark = glyph("🔕") # two cells on the desktop, one on the console
|
||||
text.append(mark, style="muted")
|
||||
text.append(" " * (_BADGE_WIDTH - cell_len(mark)))
|
||||
elif unread:
|
||||
text.append("●", style="err")
|
||||
text.append(f" {unread}".ljust(_BADGE_WIDTH - 1), style="warn")
|
||||
@@ -674,7 +705,11 @@ def _slot_text(ctx: AppContext, slot: ChannelSlot, stats: _LiveStats, name_w: in
|
||||
# Clamped so a pathological backlog can't push the row out of its lanes.
|
||||
text.append(f"{min(total, 99999):>{_COUNT_WIDTH}}")
|
||||
else:
|
||||
text.append(f"{'·':>{_COUNT_WIDTH}}", style="muted")
|
||||
# ``○`` is the app's empty mark. ``·`` here was three things at once: the separator
|
||||
# that chains status atoms, the picker's unknown-sender stand-in, and — two cells
|
||||
# away in this very row — what the console folds ``🔕`` to, so a muted channel with
|
||||
# no messages drew the same glyph twice meaning different things.
|
||||
text.append(f"{'○':>{_COUNT_WIDTH}}", style="muted")
|
||||
text.append(" ")
|
||||
age = _format_age(_age_seconds(st.last_at)) if st is not None and st.last_at else ""
|
||||
text.append(f"{age:>{_AGE_WIDTH}}", style="muted")
|
||||
@@ -699,56 +734,99 @@ def _menu_items(
|
||||
name_w = min(_NAME_WIDTH_MAX, max(len("CHANNEL"), *(len(s.name) for s in slots)))
|
||||
# The lane names are this block's only landmark (its section carries no ── heading ──),
|
||||
# so they pin overhead while the slots scroll and give way to Organize/Add a channel.
|
||||
items.append(Separator(_lanes_header(name_w), heading=True))
|
||||
items.append(Separator(lambda w: _lanes_header(name_w, w), heading=True))
|
||||
for slot in slots:
|
||||
items.append(Choice(title=_slot_row(ctx, slot, stats, name_w), value=slot.idx))
|
||||
else:
|
||||
items.append(Separator(" no channels configured yet"))
|
||||
items.append(Separator(" no channels yet — add one below"))
|
||||
|
||||
# One measured icon column for every command row on this screen, so the one-cell marks
|
||||
# (↕) start their labels in the same column as the two-cell ones (+ # 🌐 🔑 🔗) instead
|
||||
# of a column early — which the Organize row used to fix by hand, with two spaces and a
|
||||
# four-line comment. Empty where the platform draws no icons, and the labels take the
|
||||
# cells back.
|
||||
lane = icon_lane(("↕", "🌐", "+", "#", "🔑", "🔗"))
|
||||
if len(slots) > 1:
|
||||
items.append(section_heading("Organize"))
|
||||
# Two spaces after the arrow: ↕ (East-Asian-ambiguous width) renders one cell where
|
||||
# the sibling rows' glyphs (+ # 🔑 🔗) render two, so the extra space keeps this
|
||||
# label's text column-aligned with theirs. Both go together where the platform
|
||||
# draws no icon lane at all.
|
||||
items.append(Choice(title=command_label("↕ Reorder channels"), value=_REORDER))
|
||||
reorder = marked_label("↕", "Reorder channels", "", lane=lane)
|
||||
items.append(Choice(title=reorder, value=_REORDER))
|
||||
|
||||
items.append(section_heading("Add a channel"))
|
||||
if _next_free_slot(slots, capacity) is None:
|
||||
# The list already knows there is nowhere to put one, so it says so here rather than
|
||||
# offering four rows that all dead-end in the same refusal.
|
||||
items.append(Separator(" every slot is full — clear one first"))
|
||||
return f"Channels · {len(slots)}/{capacity} slots", items
|
||||
rows = []
|
||||
# The firmware's built-in fixed-key Public channel has one well-known secret, so it's the
|
||||
# same channel on every slot — offer to restore it only while no slot already holds it.
|
||||
if not any(s.secret == DEFAULT_PUBLIC_SECRET for s in slots):
|
||||
rows.append(
|
||||
("🌐 Standard Public channel", "MeshCore's built-in meshwide channel", _DEFAULT_PUBLIC)
|
||||
(
|
||||
marked_label("🌐", "Standard Public channel", "", lane=lane),
|
||||
"MeshCore's built-in meshwide channel",
|
||||
_DEFAULT_PUBLIC,
|
||||
)
|
||||
)
|
||||
rows.extend(
|
||||
[
|
||||
("+ New private channel…", "A fresh random key", _CREATE),
|
||||
("# Public channel…", "Key derived from its name", _PUBLIC),
|
||||
("🔑 Join with a key…", "Paste a channel's 32-hex key", _JOIN),
|
||||
("🔗 Import a link…", "Paste a meshcore:// share link", _IMPORT),
|
||||
(
|
||||
marked_label("+", "New private channel…", "", lane=lane),
|
||||
"A fresh random key",
|
||||
_CREATE,
|
||||
),
|
||||
(
|
||||
marked_label("#", "Public channel…", "", lane=lane),
|
||||
"Key derived from its name",
|
||||
_PUBLIC,
|
||||
),
|
||||
(
|
||||
marked_label("🔑", "Join with a key…", "", lane=lane),
|
||||
"Paste a channel's 32-hex key",
|
||||
_JOIN,
|
||||
),
|
||||
(
|
||||
marked_label("🔗", "Import a link…", "", lane=lane),
|
||||
"Paste a meshcore:// share link",
|
||||
_IMPORT,
|
||||
),
|
||||
]
|
||||
)
|
||||
items.extend(menu_rows(rows))
|
||||
|
||||
return f"Channels — {len(slots)}/{capacity} slots", items
|
||||
# ``·`` chains a status atom; ``—`` would introduce a subject, and the slot count is not
|
||||
# what this screen is about (see Contacts, which reads "Contacts · 12 known").
|
||||
return f"Channels · {len(slots)}/{capacity} slots", items
|
||||
|
||||
|
||||
def _detail_summary(ctx: AppContext, slot: ChannelSlot, stats: _LiveStats) -> str:
|
||||
"""One line of vital signs for the detail screen: slot, totals, unread, mute, last activity."""
|
||||
"""One line of vital signs for the detail screen: what it is, then how it has been used.
|
||||
|
||||
The openness and the hash lead, having moved here out of the screen's title, which was
|
||||
carrying them in a parenthesised blob and running to 42 cells — the whole of the
|
||||
PicoCalc's borderless title bar, leaving no room for the one place that says Esc leaves.
|
||||
|
||||
The line is one line. Atoms are shed from the right until it fits the platform's
|
||||
readable width, because the ones on the left identify the channel and the ones on the
|
||||
right describe traffic the reader can also see in the row they came from.
|
||||
"""
|
||||
st = stats.get(slot.identity)
|
||||
unread = ctx.chat.unread(slot.conversation.key)
|
||||
muted = _is_muted(ctx, slot)
|
||||
kind = "public" if slot.is_public else "private"
|
||||
parts = [kind, f"hash {slot.hash}", f"slot {slot.idx}"]
|
||||
if st is None or not st.total:
|
||||
base = f"Slot {slot.idx} · no messages recorded yet"
|
||||
return f"{base} · muted" if muted else base
|
||||
parts = [f"Slot {slot.idx}", f"{st.total} message{'' if st.total == 1 else 's'}"]
|
||||
if unread:
|
||||
parts.append(f"{unread} unread")
|
||||
if muted:
|
||||
parts.append("no messages yet")
|
||||
else:
|
||||
parts.append(f"{st.total} msg{'' if st.total == 1 else 's'}")
|
||||
if unread:
|
||||
parts.append(f"{unread} unread")
|
||||
if _is_muted(ctx, slot):
|
||||
parts.append("muted")
|
||||
if st.last_at is not None:
|
||||
parts.append(f"last message {format_ago(_age_seconds(st.last_at))}")
|
||||
if st is not None and st.last_at is not None:
|
||||
parts.append(f"last {format_ago(_age_seconds(st.last_at))}")
|
||||
width = get_platform().readable_cols
|
||||
while len(parts) > 1 and cell_len(" · ".join(parts)) > width:
|
||||
parts.pop()
|
||||
return " · ".join(parts)
|
||||
|
||||
|
||||
@@ -763,31 +841,49 @@ def _detail_items(ctx: AppContext, slot: ChannelSlot) -> list:
|
||||
err-tinted label so it reads as such.
|
||||
"""
|
||||
unread = ctx.chat.unread(slot.conversation.key)
|
||||
chat_label = Text("💬 Open in chat")
|
||||
# ✎ and 🗑 are one cell where 📱 🔑 💬 🔔 🔕 are two, so the column is measured once and
|
||||
# every mark padded out to it — otherwise the edit and clear rows start their labels a
|
||||
# column left of the rows above them.
|
||||
lane = icon_lane(("📱", "🔑", "💬", "🔔", "🔕", "✎", "🗑"))
|
||||
chat_label = marked_label("💬", "Open in chat", "", lane=lane)
|
||||
if unread:
|
||||
chat_label.append(" ●", style="err")
|
||||
chat_label.append(f" {unread}", style="warn")
|
||||
if _is_muted(ctx, slot):
|
||||
mute_row = (
|
||||
"🔔 Unmute notifications",
|
||||
marked_label("🔔", "Unmute notifications", "", lane=lane),
|
||||
"Show new messages here in the unread badge",
|
||||
_MUTE,
|
||||
)
|
||||
else:
|
||||
mute_row = (
|
||||
"🔕 Mute notifications",
|
||||
marked_label("🔕", "Mute notifications", "", lane=lane),
|
||||
"Hide new messages here from the unread badge",
|
||||
_MUTE,
|
||||
)
|
||||
items = menu_rows(
|
||||
[
|
||||
("📱 Show QR code", "Share this channel as a scannable code", _QR),
|
||||
("🔑 Show key", "The name, key, hash, and share link", _KEY),
|
||||
(
|
||||
marked_label("📱", "Show QR code", "", lane=lane),
|
||||
"Share this channel as a scannable code",
|
||||
_QR,
|
||||
),
|
||||
(
|
||||
marked_label("🔑", "Show key", "", lane=lane),
|
||||
"The name, key, hash, and share link",
|
||||
_KEY,
|
||||
),
|
||||
(chat_label, "Read and send messages on this channel", _CHAT),
|
||||
mute_row,
|
||||
("✎ Rename / change key…", "Edit the name or paste a different key", _EDIT),
|
||||
(
|
||||
Text("🗑 Clear this slot…", style="err"),
|
||||
marked_label("✎", "Rename / change key…", "", lane=lane),
|
||||
"Edit the name or paste a different key",
|
||||
_EDIT,
|
||||
),
|
||||
(
|
||||
# The tint goes on the mark, not the words — and falls back to the words only
|
||||
# where the platform draws no mark at all (see menus.marked_label).
|
||||
marked_label("🗑", "Clear this slot…", "err", lane=lane),
|
||||
"Remove the channel from this device",
|
||||
_CLEAR,
|
||||
),
|
||||
@@ -810,8 +906,9 @@ async def _channel_detail(
|
||||
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})"
|
||||
# ``Feature — subject``: what the openness and the hash used to be doing in here is now
|
||||
# the first two atoms of the summary line below the title (see :func:`_detail_summary`).
|
||||
title = f"Channel — {slot.name}"
|
||||
|
||||
async def handle(choice: object) -> int | None:
|
||||
"""Run one action; an int closes the detail with that many changes, ``None`` stays."""
|
||||
|
||||
+38
-3
@@ -13,6 +13,7 @@ from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from rich.cells import cell_len
|
||||
from rich.console import Console
|
||||
|
||||
from meshterm.context import AppContext
|
||||
@@ -580,7 +581,7 @@ async def test_channel_rows_carry_stats_unread_and_lanes(ctx: AppContext) -> Non
|
||||
ctx.chat._unread[slot.conversation.key] = 2 # as the service would after two arrivals
|
||||
|
||||
title, items = _menu_items(ctx, slots, 8, _LiveStats(ctx))
|
||||
assert title == "Channels — 1/8 slots"
|
||||
assert title == "Channels · 1/8 slots" # a status atom chains with ·, not an em dash
|
||||
row = next(it for it in items if isinstance(it, Choice) and it.value == 0)
|
||||
plain = row.label.plain # the title is a live callable; .label resolves it
|
||||
assert "Ops" in plain
|
||||
@@ -678,13 +679,47 @@ async def test_detail_summary_reads_slot_totals_and_unread(ctx: AppContext) -> N
|
||||
await device.set_channel(3, "Ops", bytes(range(16)))
|
||||
slot = next(s for s in await read_channel_slots(device) if s.idx == 3)
|
||||
|
||||
assert _detail_summary(ctx, slot, _LiveStats(ctx)) == "Slot 3 · no messages recorded yet"
|
||||
# What the channel *is* leads — the openness and hash moved here out of a title that
|
||||
# was 42 cells wide and left no room on the console for the bar that says Esc leaves.
|
||||
assert _detail_summary(ctx, slot, _LiveStats(ctx)) == (
|
||||
"private · hash be · slot 3 · no messages yet"
|
||||
)
|
||||
|
||||
ctx.repo.record_chat_message(ChatMessage(text="hi", is_channel=True, channel_id=slot.identity))
|
||||
ctx.chat._unread[slot.conversation.key] = 1
|
||||
summary = _detail_summary(ctx, slot, _LiveStats(ctx))
|
||||
# A fresh age reads as bare "now" (the app-wide format_ago grammar — never "now ago").
|
||||
assert summary == "Slot 3 · 1 message · 1 unread · last message now"
|
||||
assert summary == "private · hash be · slot 3 · 1 msg · 1 unread · last now"
|
||||
|
||||
|
||||
async def test_the_detail_summary_sheds_atoms_rather_than_wrapping(ctx: AppContext) -> None:
|
||||
"""One line, on both platforms: the traffic atoms go before the line does.
|
||||
|
||||
The identifying atoms are on the left and the describing ones on the right, so a line
|
||||
too long for the console loses what the reader could already see in the row they opened
|
||||
this screen from — never the channel's own identity.
|
||||
"""
|
||||
from meshterm.core.models import ChatMessage
|
||||
from meshterm.platforms import PICOCALC, REGULAR, set_platform
|
||||
from meshterm.ui.channels import _detail_summary, _LiveStats
|
||||
|
||||
device = await ctx.device()
|
||||
await device.set_channel(0, "Lakeside emergency net", bytes(range(16)))
|
||||
slot = next(s for s in await read_channel_slots(device) if s.idx == 0)
|
||||
for _ in range(3):
|
||||
ctx.repo.record_chat_message(
|
||||
ChatMessage(text="hi", is_channel=True, channel_id=slot.identity)
|
||||
)
|
||||
ctx.chat._unread[slot.conversation.key] = 2
|
||||
|
||||
try:
|
||||
for platform in (REGULAR, PICOCALC):
|
||||
set_platform(platform)
|
||||
line = _detail_summary(ctx, slot, _LiveStats(ctx))
|
||||
assert cell_len(line) <= platform.readable_cols, (platform.name, line)
|
||||
assert line.startswith("private · hash be · slot 0") # identity always survives
|
||||
finally:
|
||||
set_platform(REGULAR)
|
||||
|
||||
|
||||
# -- muting channel notifications ---------------------------------------------
|
||||
|
||||
@@ -29,12 +29,14 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from rich.cells import cell_len
|
||||
from rich.text import Text
|
||||
|
||||
from meshterm.core.advert_store import AdvertPolicy
|
||||
from meshterm.core.channels import DEFAULT_PUBLIC_SECRET, derive_secret
|
||||
from meshterm.core.courier_store import CourierStore
|
||||
from meshterm.core.models import (
|
||||
NODE_TYPE_REPEATER,
|
||||
@@ -417,6 +419,77 @@ def _chat_picker(cols: int, rows: int) -> Screen:
|
||||
)
|
||||
|
||||
|
||||
def _channels_manager(cols: int, rows: int) -> Screen:
|
||||
"""The channel manager at its widest: every lane populated, every action row drawn.
|
||||
|
||||
Built through the feature's own row funnel, so the header line, the glyph lane, the
|
||||
unread badge, the counts, the ages and the sparkline are laid out by the code the app
|
||||
runs. The cases that matter are here: a name at the lane's ceiling, a muted channel (its
|
||||
mark folds to a different glyph on the console), a channel with no messages at all, and
|
||||
a three-digit unread badge.
|
||||
"""
|
||||
from meshterm.ui.channels import _MANAGER_HINT, ChannelSlot, _LiveStats, _menu_items
|
||||
|
||||
slots = [
|
||||
ChannelSlot(idx=0, name="Public", secret=DEFAULT_PUBLIC_SECRET),
|
||||
ChannelSlot(idx=1, name="Lakeside emergency", secret=bytes(range(16))),
|
||||
ChannelSlot(idx=2, name="#montreal", secret=derive_secret("#montreal")),
|
||||
ChannelSlot(idx=3, name="Ops", secret=bytes(range(16, 32))),
|
||||
]
|
||||
ctx = _ChannelsCtx(muted={slots[3].identity})
|
||||
title, items = _menu_items(ctx, slots, 8, _LiveStats(ctx))
|
||||
return SelectScreen(title, items, footer_hint=_MANAGER_HINT)
|
||||
|
||||
|
||||
def _channel_detail(cols: int, rows: int) -> Screen:
|
||||
"""One channel's action page, its vital-signs line above the rows."""
|
||||
from meshterm.ui.channels import ChannelSlot, _detail_items, _detail_summary, _LiveStats
|
||||
|
||||
slot = ChannelSlot(idx=2, name="Lakeside emergency", secret=bytes(range(16)))
|
||||
ctx = _ChannelsCtx(muted=set())
|
||||
stats = _LiveStats(ctx)
|
||||
return SelectScreen(
|
||||
f"Channel — {slot.name}",
|
||||
_detail_items(ctx, slot),
|
||||
prompt=_detail_summary(ctx, slot, stats),
|
||||
footer_hint="↑↓ move · Enter select · Esc back",
|
||||
)
|
||||
|
||||
|
||||
class _ChannelsChat:
|
||||
"""The chat service surface the channel rows read: unread counts and mute state."""
|
||||
|
||||
def __init__(self, muted: set[str]) -> None:
|
||||
self._muted = muted
|
||||
|
||||
def unread(self, key: str) -> int:
|
||||
"""A three-digit badge on one channel, so the lane is drawn at its widest."""
|
||||
return 128 if key.endswith("slot:1") or "Lakeside" in key else 0
|
||||
|
||||
|
||||
class _ChannelsCtx:
|
||||
"""The minimal AppContext surface the channel rows and detail page read."""
|
||||
|
||||
def __init__(self, muted: set[str]) -> None:
|
||||
self.chat = _ChannelsChat(muted)
|
||||
self.repo = _ChannelsRepo()
|
||||
self.preferences = Preferences()
|
||||
self._muted = muted
|
||||
|
||||
@property
|
||||
def mute_store(self): # noqa: ANN201 - a stand-in for the real store
|
||||
"""A store answering only the one question a row asks it."""
|
||||
return SimpleNamespace(is_muted=lambda identity: identity in self._muted)
|
||||
|
||||
|
||||
class _ChannelsRepo:
|
||||
"""Channel statistics with one busy channel, one quiet one, and one never used."""
|
||||
|
||||
def channel_stats(self, *a, **k): # noqa: ANN002, ANN003, ANN201
|
||||
"""Return nothing: the rows then draw their empty marks, which is the tighter case."""
|
||||
return {}
|
||||
|
||||
|
||||
class _PickerCtx:
|
||||
"""The minimal AppContext surface :meth:`ChatTool._picker_items` reads."""
|
||||
|
||||
@@ -789,6 +862,8 @@ _ENTRIES: list[_Entry] = [
|
||||
_Entry("map", _map),
|
||||
_Entry("chat", _chat),
|
||||
_Entry("chat_picker", _chat_picker),
|
||||
_Entry("channels_manager", _channels_manager),
|
||||
_Entry("channel_detail", _channel_detail),
|
||||
_Entry("livefeed", _livefeed),
|
||||
_Entry("walk", _walk),
|
||||
_Entry("timemachine", _timemachine),
|
||||
|
||||
Reference in new issue
Block a user