diff --git a/CLAUDE.md b/CLAUDE.md index 831570b..684835e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -102,7 +102,10 @@ Relative ages: `format_ago` for prose ("now", "5m ago", "never" — never "now a 📤 send now · 📨 courier/queue · 💬 chat · 🔔 notify · 🔕 mute (notifications off) · 📱 QR · 🔗 link · ↻ re-read · ↕ reorder · ⇄ reverse (flip a path's direction) · 🏆 trophy case/record · ⌨ command line · 🚪 quit. Packet-class icons (feed/viewer - lane): 📢 advert · 📊 telemetry · 📦 packet · 💬 message · ✅ ack. + lane): 📢 advert · 📊 telemetry · 📦 packet · 💬 message · ✅ ack. Raw payload + classes (`PAYLOAD_ICONS`): 📻 channel text · 💽 channel data · 📩 direct message + (overheard) · 📥 request · 📮 response · 🎭 anon request · 🧭 path · 🎯 trace · + 🧩 multipart · 🧰 control · ❔ unknown — raw advert/ack reuse 📢/✅. ### Colour diff --git a/meshterm/ui/packet_viewer.py b/meshterm/ui/packet_viewer.py index c1db65a..9337e3e 100644 --- a/meshterm/ui/packet_viewer.py +++ b/meshterm/ui/packet_viewer.py @@ -12,10 +12,10 @@ Message paths dialog and the Trophy case draw — and, for an overheard channel- naming a channel we hold the key for, the decrypted text too (see :func:`~meshterm.core.channels.decrypt_channel_text`), even though the radio itself never decoded it for us; a message shows the sender, the -conversation, and the text; an ack shows its code. Common to all: the timestamp, the -node (name coloured by the app-wide palette, hash in the hash widget), reception -quality on the shared SNR bar, and any leftover raw field the flavoured layout doesn't -already show. +conversation, and the text; an ack shows its code. Common to all: the class headline +(icon + UPPERCASE class, first row of the card), the timestamp, the node (name coloured +by the app-wide palette, key in the key widget), reception quality on the shared SNR +bar, and any leftover raw field the flavoured layout doesn't already show. When opened over a list the viewer pages through it in place — ``↑``/``↓`` step to the newer/older packet, Home/End jump to the newest/oldest, mirroring the keys the @@ -105,6 +105,25 @@ KIND_ICONS = { } DEFAULT_ICON = "❔" +#: The two-cell icon per parsed payload class of a raw ``packet`` frame, shared by the +#: viewer's leading class row and the dashboard's traffic meters. One glyph per concept: +#: 📻 rhymes with the Channels tool, 🎯 with Trace, and a raw ADVERT/ACK frame reuses +#: its kind's icon; :data:`DEFAULT_ICON` marks a typename this table has never heard of. +PAYLOAD_ICONS = { + "REQ": "📥", + "RESPONSE": "📮", + "TEXT_MSG": "📩", + "ACK": "✅", + "ADVERT": "📢", + "GRP_TXT": "📻", + "GRP_DATA": "💽", + "ANON_REQ": "🎭", + "PATH": "🧭", + "TRACE": "🎯", + "MULTIPART": "🧩", + "CONTROL": "🧰", +} + #: The colour a relayed packet's route draws in on THE route graph. It is the only path #: on the canvas, so white just reads as "the route the packet rode" (the Trophy case's #: single-walk convention). @@ -190,6 +209,26 @@ def payload_class(raw: Optional[dict]) -> Optional[str]: return _PAYLOAD_GLOSS.get(typename, typename.lower()) +def class_chrome(entry: PacketEntry) -> tuple[str, str]: + """The class headline every packet leads with: a two-cell icon and an UPPERCASE label. + + A raw ``packet`` frame's class is its parsed payload class — ``CHANNEL TEXT``, + ``TRACE``, … — under its :data:`PAYLOAD_ICONS` glyph; a typename this table has + never heard of keeps the fallback mark and shouts the raw typename; a class-less + frame stays a plain ``PACKET``. Every other kind is its own class (``ADVERT``, + ``MESSAGE``, …) under the shared :data:`KIND_ICONS` glyph. + """ + if entry.kind == "packet": + raw = entry.raw if isinstance(entry.raw, dict) else {} + typename = raw.get("payload_typename") + if typename: + icon = PAYLOAD_ICONS.get(typename, DEFAULT_ICON) + label = _PAYLOAD_GLOSS.get(typename, typename.lower()).upper() + return icon, label + return KIND_ICONS["packet"], "PACKET" + return kind_icon(entry.kind), entry.kind.upper() + + def node_label( entry: PacketEntry, resolve: NodeResolver, self_name: Optional[str] = None ) -> tuple[str, str]: @@ -334,8 +373,9 @@ class PacketViewer(Screen): self._set_title() def _set_title(self) -> None: + # No emoji in a dialog title (the standards' rule); the class row carries the icon. entry = self._entries[self._index] - title = f"{kind_icon(entry.kind)} {entry.kind}" + title = entry.kind if len(self._entries) > 1: title += f" · {self._index + 1}/{len(self._entries)}" self.title = title @@ -436,9 +476,13 @@ class PacketViewer(Screen): return render_lines(grid, width) def _head_rows(self, entry: PacketEntry) -> list[tuple[str, RenderableType]]: - """The rows above the route graph: the common core plus a packet's parsed frame.""" + """The rows above the route graph: the class headline, the common core, then a + packet's parsed frame.""" rows: list[tuple[str, RenderableType]] = [] + icon, class_label = class_chrome(entry) + rows.append(("class", Text(f"{icon} {class_label}"))) + secs = _age_seconds(entry.when) heard = Text(entry.when.astimezone().strftime("%b %d %H:%M:%S")) heard.append(f" ({format_ago(secs)})", style="muted") @@ -543,9 +587,7 @@ class PacketViewer(Screen): """ raw = entry.raw if isinstance(entry.raw, dict) else {} rows: list[tuple[str, RenderableType]] = [] - typename = raw.get("payload_typename") - if typename: - rows.append(("class", Text(payload_class(raw) or typename.lower()))) + typename = raw.get("payload_typename") # class itself leads the card (class_chrome) route = raw.get("route_typename") if route: rows.append(("route", Text(route.replace("_", " ").lower()))) diff --git a/tests/test_packet_viewer.py b/tests/test_packet_viewer.py index 5bfd434..f76ed31 100644 --- a/tests/test_packet_viewer.py +++ b/tests/test_packet_viewer.py @@ -44,13 +44,38 @@ def _grp_txt_frame(secret: bytes, text: str) -> tuple[str, str, str]: def test_packet_viewer_shows_class_and_route() -> None: - """A raw packet's parsed payload class and route surface as labelled rows.""" + """A raw packet's parsed payload class headlines the card; the route is a row.""" entry = _grp_txt_entry({"chan_hash": "ff", "cipher_mac": "0000", "crypted": "00" * 16}) body = _plain(_viewer(entry).render_body(80)) - assert "channel text" in body + assert "📻 CHANNEL TEXT" in body assert "flood" in body +def test_packet_viewer_class_row_leads_the_card() -> None: + """The class headline is the first row — above heard/from — for every kind.""" + packet = _grp_txt_entry({"chan_hash": "ff", "cipher_mac": "0000", "crypted": "00" * 16}) + body = _plain(_viewer(packet).render_body(80)) + assert body.index("CHANNEL TEXT") < body.index("heard") + + advert = PacketEntry(when=utcnow(), kind="advert", node="aa") + body = _plain(_viewer(advert).render_body(80)) + assert "📢 ADVERT" in body + assert body.index("ADVERT") < body.index("heard") + + +def test_packet_viewer_class_marks_an_unknown_typename() -> None: + """A payload class this build has never heard of keeps the ❔ mark and its raw name.""" + entry = PacketEntry(when=utcnow(), kind="packet", raw={"payload_typename": "XYZZY"}) + body = _plain(_viewer(entry).render_body(80)) + assert "❔ XYZZY" in body + + +def test_packet_viewer_title_carries_no_emoji() -> None: + """Dialog titles stay emoji-free (the standards' rule); the class row has the icon.""" + entry = PacketEntry(when=utcnow(), kind="advert", node="aa") + assert _viewer(entry).title == "advert" + + def test_packet_viewer_decrypts_a_known_channel() -> None: """A channel-text frame we hold the key for decrypts to its plaintext.""" name, secret = "#general", derive_secret("#general")