diff --git a/meshterm/ui/chat.py b/meshterm/ui/chat.py index c339ac4..722a7c7 100644 --- a/meshterm/ui/chat.py +++ b/meshterm/ui/chat.py @@ -24,7 +24,7 @@ from rich.text import Text from ..core.events import EventKind, MeshEvent from ..core.models import ChatMessage, Contact, Conversation, Message, utcnow -from .theme import snr_style +from .theme import name_style, snr_style from .tui.prompt import _LineEditor from .tui.render import render_hanging, render_lines from .tui.screen import CANCEL, Screen @@ -36,20 +36,6 @@ if TYPE_CHECKING: #: How many past messages to load into the transcript when a conversation opens. _HISTORY_LIMIT = 200 -#: Palette of distinct, dark-theme-friendly colors cycled through to give each channel -#: sender its own hue (red is reserved for errors, so it's excluded). ``you`` and unknown -#: senders are styled separately. -_SENDER_COLORS = ( - "bold #f472b6", # pink - "bold #60a5fa", # blue - "bold #34d399", # green - "bold #a78bfa", # violet - "bold #fb923c", # orange - "bold #22d3ee", # cyan - "bold #a3e635", # lime - "bold #e879f9", # fuchsia -) - #: Matches the ``Name: message`` convention channel senders use to identify themselves #: (the protocol carries no sender field). The name is 1–20 non-colon characters and must #: be followed by ``": "`` — conservative enough to leave ``http://…`` and ``note:x`` alone. @@ -89,11 +75,13 @@ _BYTES_ORANGE = "bold #ff9500" def _sender_hue(sender: str) -> str: """The stable per-sender colour a name is drawn in, keyed on the name's characters. - Shared by the live transcript (sender headers, ``@mentions``) and the conversation list - (a contact's colour dot, a channel preview's inline sender), so a person reads the same - colour everywhere. ``you`` and unknown (``·``) senders are handled by the caller. + The app-wide name palette (:func:`~meshterm.ui.theme.name_style`), shared by the live + transcript (sender headers, ``@mentions``), the conversation list (a contact's colour + dot, a channel preview's inline sender), the dashboard feed, and the packet viewer, so + a person reads the same colour everywhere. ``you`` and unknown (``·``) senders are + handled by the caller. """ - return _SENDER_COLORS[sum(map(ord, sender)) % len(_SENDER_COLORS)] + return name_style(sender) def _split_channel_sender(text: str) -> tuple[Optional[str], str]: diff --git a/meshterm/ui/theme.py b/meshterm/ui/theme.py index c93849a..d823a6b 100644 --- a/meshterm/ui/theme.py +++ b/meshterm/ui/theme.py @@ -17,8 +17,8 @@ MESH_THEME = Theme( # Reversed error (the text-editor cursor sitting on an over-budget character). Baked # in for the same reason as ``selected`` — "reverse err" would render as plain text. "err.reverse": "reverse bold #f87171", - # Our own messages in a chat: pure white, deliberately outside the per-sender hue - # palette (see meshterm.ui.chat._SENDER_COLORS) so "you" is always easy to spot. + # Our own node, anywhere it is named: pure white, deliberately outside the + # per-name hue palette (see NAME_COLORS below) so "you" is always easy to spot. "you": "bold #ffffff", # A confirmed companion's name on the startup device picker: pure white so the # devices we've actually talked to before jump out above the merely-detected ports. @@ -121,6 +121,38 @@ def hint_style(border_style: str) -> str: return name if name in MESH_THEME.styles else "muted" +#: Palette of distinct, dark-theme-friendly colors cycled through to give each node or +#: sender name its own stable hue (red is reserved for errors, so it's excluded). The +#: app-wide rule: a node's *name* is always coloured — by this palette, unless the +#: context already colours it (a picker's recency heat, a chart's quality tint) — and +#: our own node is always the pure-white ``you`` style instead, so "us" never blends +#: into the crowd. Shared by the chat transcript, the dashboard feed, and the packet +#: viewer, so one node reads as one colour everywhere. +NAME_COLORS = ( + "bold #f472b6", # pink + "bold #60a5fa", # blue + "bold #34d399", # green + "bold #a78bfa", # violet + "bold #fb923c", # orange + "bold #22d3ee", # cyan + "bold #a3e635", # lime + "bold #e879f9", # fuchsia +) + + +def name_style(name: str) -> str: + """The stable per-name colour a node or sender is drawn in, keyed on its characters. + + Args: + name: The display name (not a hash — hashes stay muted). + + Returns: + A style string from :data:`NAME_COLORS`; the same name always maps to the + same hue, so a node keeps its colour across screens and sessions. + """ + return NAME_COLORS[sum(map(ord, name)) % len(NAME_COLORS)] + + def snr_style(snr: float | None) -> str: """Return a theme style name describing an SNR value's quality. diff --git a/meshterm/ui/trace_screen.py b/meshterm/ui/trace_screen.py index 8a8752a..7f38137 100644 --- a/meshterm/ui/trace_screen.py +++ b/meshterm/ui/trace_screen.py @@ -1244,7 +1244,7 @@ async def _open_session(ctx: "AppContext", target: Optional[str]) -> int: title = Text(f"{w} byte{'s' if w > 1 else ' '}") if sample: title.append(" ") - title.append_text(highlighted_hash(sample[:16], w)) + title.append_text(highlighted_hash(sample, w, width=16)) if w == device_width: title.append(" · device default", style="muted") items.append(Choice(title=title, value=w)) diff --git a/meshterm/ui/widgets.py b/meshterm/ui/widgets.py index 41d0035..94288c0 100644 --- a/meshterm/ui/widgets.py +++ b/meshterm/ui/widgets.py @@ -233,29 +233,41 @@ def traces_table( return table -def highlighted_hash(value: str, prefix_bytes: int) -> Text: - """Render a full hex key with its leading path-hash prefix highlighted. +def highlighted_hash(value: str, prefix_bytes: int, width: Optional[int] = None) -> Text: + """Render a hex key with its leading path-hash prefix highlighted — THE hash widget. - The first ``prefix_bytes`` bytes are the slice other nodes address in a forced trace - path (the path-hash); they are shown in the brand colour and the remainder muted, so - the addressable prefix stands out within the otherwise full key. + The one way MeshTerm displays a hash, wherever one appears: the first + ``prefix_bytes`` bytes are the slice other nodes address in a forced trace path + (the path-hash), shown in the brand colour with the remainder muted, so the + addressable prefix stands out within the otherwise full key. A ``width`` budget + shorter than the key ellipsizes it (the ``…`` takes the colour of the digit it + replaces, so a highlight wider than the budget still reads as one). Args: - value: The full key as hex, optionally ``0x``-prefixed and mixed-case. + value: The key as hex, optionally ``0x``-prefixed and mixed-case. prefix_bytes: Number of leading bytes the current path-hash mode addresses; ``0`` (or negative) leaves the whole key un-highlighted. + width: Display budget in cells; a longer key shows ``width - 1`` digits plus an + ellipsis, a shorter one is right-padded to the budget so lanes stay aligned. + ``None`` shows the key whole, unpadded. Returns: - A styled :class:`Text` of the full key. + A styled :class:`Text` of the key (exactly ``width`` cells when given). """ raw = value.lower().removeprefix("0x") split = max(0, prefix_bytes) * 2 + pad = 0 + ellipsis = False + if width is not None and len(raw) > width: + raw, ellipsis = raw[: max(0, width - 1)], True + elif width is not None: + pad = width - len(raw) text = Text() - if split: - text.append(raw[:split], style="brand") - text.append(raw[split:], style="muted") - else: - text.append(raw, style="muted") + text.append(raw[:split], style="brand") + text.append(raw[split:], style="muted") + if ellipsis: + text.append("…", style="brand" if len(raw) < split else "muted") + text.append(" " * pad) return text @@ -627,7 +639,8 @@ def nodes_table( ) -> Group: """List this node and its known contacts with recency, packets, type, key, and legend. - Our own node is the first row (``★``, name in accent); contacts follow in ``sort`` order. + Our own node is the first row (``★``, name in the white ``you`` style); contacts follow + in ``sort`` order. A per-type glyph marks each node in the app's shared colours, the name is coloured by how recently it was last heard (brighter = fresher), and the full key is shown with its path-hash prefix lit — chopped with an ellipsis only when the terminal is too narrow. @@ -676,7 +689,8 @@ def nodes_table( unknown = Text("?", style="muted") table.add_row( Text(_SELF[0], style=_SELF[1]), - Text.assemble((self_name, "accent"), (" (you)", "muted")), + # Our own name is the app-wide pure-white "you" style, never a palette hue. + Text.assemble((self_name, "you"), (" (you)", "muted")), Text("—", style="faint"), Text("—", style="faint"), highlighted_hash(self_key, prefix_bytes) if self_key else unknown, diff --git a/tests/test_chat.py b/tests/test_chat.py index 0258f02..7623471 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -670,7 +670,7 @@ def test_at_mention_renders_as_name_in_sender_hue() -> None: """An ``@[Name]`` token renders as a bare ``@Name`` colored in that sender's hue.""" from datetime import datetime, timezone - from meshterm.ui.chat import _SENDER_COLORS + from meshterm.ui.theme import NAME_COLORS as _SENDER_COLORS base = datetime(2026, 7, 5, 14, 24, tzinfo=timezone.utc) conv = Conversation(label="#public", is_channel=True, channel_idx=0) @@ -908,7 +908,7 @@ def test_channel_self_style_keyed_on_concept_not_label() -> None: A remote sender who happens to be named 'you' must still get a palette hue, never the white style reserved for us. """ - from meshterm.ui.chat import _SENDER_COLORS + from meshterm.ui.theme import NAME_COLORS as _SENDER_COLORS screen = _channel_screen([]) assert screen._sender_style("you", is_self=True) == "you" # us → white