diff --git a/meshterm/ui/chat.py b/meshterm/ui/chat.py index 99012a2..c4c238b 100644 --- a/meshterm/ui/chat.py +++ b/meshterm/ui/chat.py @@ -436,15 +436,22 @@ class ChatScreen(Screen): return (self._name(message.peer) or message.peer or "?"), message.text def _group_header(self, sender: str, *, is_self: bool = False) -> Text: - """Build the sender header that starts a group: the name, chipped, in its hue. + """Build the sender header that starts a group: the name, drawn as a chip. The chip is what separates a *label* from the prose under it — a name standing alone above its messages reads as a tag rather than as a coloured word someone - typed. Only this one is framed: an ``@mention`` inside a body (see + typed. It is a path line's own chip (see :func:`~meshterm.ui.widgets.name_chip`), + so a sender wears exactly what that node wears as a hop in a route, our own + messages included: the ``★``, never our name. + + Only this one is framed: an ``@mention`` inside a body (see :meth:`_render_mentions`) is part of what was said, and inscribing it would put a chip in the middle of a sentence. """ - return name_chip(sender, self._sender_style(sender, is_self=is_self)) + if is_self: + return name_chip("", you=True) + # ``·`` is a channel line that arrived with no sender prefix — nobody to key on. + return name_chip(sender, None if sender == "·" else self._sender_key(sender)) def _body_lines( self, body: str, message: ChatMessage, width: int, *, selected: bool = False @@ -560,10 +567,23 @@ class ChatScreen(Screen): return "you" # white, out of the per-sender hue range — always easy to spot if sender == "·": return "node.unknown" + return name_style(sender, self._sender_key(sender, mention=mention)) + + def _sender_key(self, sender: str, *, mention: bool = False) -> Optional[str]: + """The key a sender name resolves back to, or ``None`` when nothing places it. + + Contacts first, then the recorder's stored names; in a *direct* thread a sender + label that resolves to nothing falls back to the peer's own key, since the only + two people in the conversation are us and them. A mention names an arbitrary + person, so that fallback must not apply to one. + + Shared by the colour (:meth:`_sender_style`) and the chip + (:meth:`_group_header`) so the two can't disagree about who a name is. + """ key = self._key_of(sender) if not key and not self._is_channel and not mention: key = self._peer_key or None - return name_style(sender, key) + return key # --- input --------------------------------------------------------------- diff --git a/meshterm/ui/specimen.py b/meshterm/ui/specimen.py index 5966f04..4b81f3a 100644 --- a/meshterm/ui/specimen.py +++ b/meshterm/ui/specimen.py @@ -116,15 +116,16 @@ def specimen_lines() -> list[RenderableType]: names.append("me", style="you") lines.append(names) - # The same names framed — the chat's sender labels. On the console this row shows - # them bare, which is the point: there is no dark grey to field a chip with there. + # The same names as chips — the chat's sender labels, drawn through the path line's + # own segment, so this row is also a one-hop sample of the route language: hue in the + # fill, our end the star, a node nobody can place in the keyless grey. chips = Text("chips ") for name, key in _DEMO_NAMES[:2]: - chips.append_text(name_chip(name, name_style(name, key))) + chips.append_text(name_chip(name, key)) chips.append(" ") - chips.append_text(name_chip("me", "you")) + chips.append_text(name_chip("", you=True)) chips.append(" ") - chips.append_text(name_chip("·", "node.unknown")) + chips.append_text(name_chip("·")) lines.append(chips) lines.append(Text("heard ").append_text(_ages_row())) diff --git a/meshterm/ui/theme.py b/meshterm/ui/theme.py index 1481e98..643ebce 100644 --- a/meshterm/ui/theme.py +++ b/meshterm/ui/theme.py @@ -136,11 +136,6 @@ MESH_THEME = Theme( # only PicoCalc's footer_fkeys ever asks for them. "fkey.chip": "bold #ffffff on #475569", "fkey.chip.shift": "bold #ffffff on #16a34a", - # The field a node name is inscribed on when a surface draws it as a chip (see - # ui.widgets.name_chip): the same neutral dark grey pathline fills our own ``★`` - # chip with, so the two chip languages agree. Background only — the ink is always - # the node's own hue, laid over this. - "name.chip": "on #3f3f46", # The prose voices — what a markdown page's inline marks are drawn in (see # ui.markdown). Headings borrow the styles the rest of the app already heads # sections with (``brand``, ``accent``), so only the *body* marks need names of @@ -317,13 +312,6 @@ MESH_THEME_16 = Theme( # Shift bank, white text on both. "fkey.chip": "bold color(15) on color(7)", "fkey.chip.shift": "bold color(15) on color(2)", - # The name chip's field, which this console cannot actually draw: the palette's - # dark grey is slot 8 and backgrounds stop at 7, so there is no neutral field for - # a coloured name to sit on (the F-key lane's light grey is far too bright to take - # a node hue). The name is drawn bare here instead — ui.widgets.name_chip fields a - # chip only where the platform has the colour for one — and this entry exists so - # the style name resolves on both platforms, as every style name must. - "name.chip": "on color(0)", # Prose on the console. The page's body text is the default light grey (slot 7), # so emphasis is the one place the VT's bold-is-brightness rule *is* the design: # ``md.strong`` says bold on purpose and lands on white, exactly the step up the diff --git a/meshterm/ui/widgets.py b/meshterm/ui/widgets.py index 041dab8..7d08566 100644 --- a/meshterm/ui/widgets.py +++ b/meshterm/ui/widgets.py @@ -18,7 +18,6 @@ from rich.progress import ( TextColumn, TimeElapsedColumn, ) -from rich.style import Style from rich.table import Table from rich.text import Text @@ -52,14 +51,8 @@ from .marks import ( LabelRgbOf, parse_hex, ) -from .pathline import ( - POWERLINE_ROUND_CLOSE, - POWERLINE_ROUND_OPEN, - PathLine, - path_line, -) -from .termfont import powerline_full -from .theme import active_theme, glyph, mark_rgb, name_style, node_style, snr_style +from .pathline import SELF_GLYPH, PathHop, PathLine, path_line +from .theme import glyph, mark_rgb, name_style, node_style, snr_style if TYPE_CHECKING: from ..core.discovery import DiscoveredDevice @@ -502,82 +495,34 @@ def highlighted_hash( return text -#: The theme name of the field a chipped name sits on (defined by both themes). -_CHIP_FILL = "name.chip" +def name_chip(label: str, key: Optional[str] = None, *, you: bool = False) -> Text: + """A node name drawn as a path line's single chip — THE way a surface *frames* a name. -#: The chip's two end caps, in the two shapes a terminal can give them. The rounded caps -#: (:data:`~meshterm.ui.pathline.POWERLINE_ROUND_OPEN` / ``_CLOSE``) are the lozenge a -#: path line already closes on, so a chipped name and a chipped hop round the same way — -#: but they live in a full Nerd Font patch's extended block. Without one the caps fall -#: back to the half blocks: ``▐`` fills a cell's right half and ``▌`` its left, so drawn in -#: the fill over the page they widen the field by half a cell at each end and taper it, -#: exactly the way the device picker's Bluetooth badge is rounded off (see -#: :func:`~meshterm.ui.device_picker._type_cell`). Two cells of overhead either way. -_ROUND_CAPS = (POWERLINE_ROUND_OPEN, POWERLINE_ROUND_CLOSE) -_HALF_CAPS = ("▐", "▌") + Not an imitation of the chip language but a use of it: this builds a one-hop + :class:`~meshterm.ui.pathline.PathLine` and asks it for its line, so the fill, the + ink, the padding, the rounded cap and the closing point are all whatever a route's + segments are wearing today, and can never drift from them (JP, 2026-08-12). The colour + reads exactly as it does in a route — the *fill* carries the node's hue with dark ink + on it, a node no key can place takes the keyless grey, and our own end is the map's + ``★`` on its neutral dark grey rather than a name. - -def name_chip(label: str, style: str) -> Text: - """A node name inscribed in a chip — THE way a surface *frames* a name. - - The name keeps its own hue (the caller passes whatever style it would have drawn the - name in — a key-derived palette colour, the ``you`` white, the ``node.unknown`` grey) - and that hue becomes the chip's ink, laid on the neutral dark grey field - :data:`_CHIP_FILL` names. Nothing about which colour a name gets changes here; the - chip only gives the colour something to sit on, so a sender label reads as a tag - rather than as a stray coloured word above a message. - - The ink is resolved to a concrete :class:`~rich.style.Style` and *added* to the fill - rather than concatenated as text: Rich silently drops a style string that mixes a - theme name with anything else (``"node.unknown on #3f3f46"`` renders as plain text), - which is exactly the shape a naive f-string would build here. - - A platform whose console cannot field the chip draws the bare name instead — see - :func:`_bind_chip`, and the ``name.chip`` note in the 16-slot theme. + Degrading is inherited too: where the terminal can't draw powerline separators, a path + line is arrow-mode text and a lone hop is simply the name in its own hue — which is + what a sender label was before it was framed, and what the PicoCalc console (whose font + carries no separator glyph) goes on drawing. Args: label: The name as it should read (already resolved; this never renames it). - style: The style the name would wear uncipped — a theme name or a literal - ``"bold #rrggbb"``, either way the node's own colour. + Ignored when ``you`` — our own end is the star, never our name. + key: Any known prefix of the node's key, which picks the hue. ``None`` is a node + we can't place: the keyless grey, colour being reserved for keyed identities. + you: This is our own node — the ``★``, exactly as a route draws our end of it. Returns: - The chip as a :class:`Text`, or the plain styled name where chips aren't drawn. + The chip as a one-line :class:`Text`. """ - if not _chip_fielded: - return Text(label, style=style) - fill = active_theme().styles[_CHIP_FILL] - themed = active_theme().styles.get(style) - ink = themed if themed is not None else Style.parse(style) - # The caps are the fill drawn *over the page* — foreground only, so the half of the - # cell that isn't chip stays whatever the row behind it is. - edge = Style(color=fill.bgcolor) - # Asked per call rather than bound: the verdict is cached after the first ask, and - # binding it would move termfont's installed-font scan onto every import of this - # module — including a bare ``meshterm --help``, which draws nothing at all. - open_cap, close_cap = _ROUND_CAPS if powerline_full() else _HALF_CAPS - text = Text() - text.append(open_cap, style=edge) - text.append(label, style=ink + fill) - text.append(close_cap, style=edge) - return text - - -#: Whether this platform draws the chip at all, rather than the bare name (bound below). -_chip_fielded: bool = True - - -@on_platform -def _bind_chip(platform: Platform) -> None: - """Decide whether the platform can field a chip behind a name. - - The chip is a *field* behind a name, and the 16-slot console has none to offer: its - one dark grey is slot 8 and backgrounds stop at 7, so the choice there is between a - name on light grey (which drowns the hue the chip exists to carry) and no chip at - all. Truecolor is the honest proxy for "this platform has a neutral dark grey", the - same test the heat gradient binds on. - """ - global _chip_fielded - _chip_fielded = platform.truecolor + hop = PathHop(SELF_GLYPH, you=True) if you else PathHop(label, key=key or None) + return PathLine([hop]).text() def _shorten_hash(value: str, hash_bytes: Optional[int]) -> str: diff --git a/tests/conftest.py b/tests/conftest.py index 45b95a7..be61bdb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,7 +19,7 @@ always see the colours the app really emits. from __future__ import annotations import re -from typing import Iterable, Iterator, Union +from typing import Callable, Iterable, Iterator, Union import pytest @@ -53,6 +53,24 @@ def _plain_path_rendering(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: powerline_support.cache_clear() +@pytest.fixture +def powerline(monkeypatch: pytest.MonkeyPatch) -> Callable[[bool], None]: + """Pin the powerline verdict for a test that is *about* chips. + + The suite runs with ``MESHTERM_POWERLINE=0`` so screen assertions don't change with + the developer's glass (see the module docstring); a test of the chip language itself + needs the other answer, and asks for it through the supported override rather than by + reaching into the widget. ``monkeypatch`` restores the env and the autouse fixture + above clears the memoized verdict either side, so nothing leaks. + """ + + def _set(on: bool) -> None: + monkeypatch.setenv("MESHTERM_POWERLINE", "1" if on else "0") + powerline_support.cache_clear() + + return _set + + @pytest.fixture(autouse=True) def _reset_platform() -> Iterator[None]: """Every test starts and ends on :data:`~meshterm.platforms.REGULAR`. diff --git a/tests/test_chat.py b/tests/test_chat.py index 1536dba..7392cdc 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -976,7 +976,8 @@ def test_channel_transcript_groups_by_sender() -> None: rendered = _strip_ansi("\n".join(screen._render_grouped(80))) assert rendered.count("Alice") == 1 # the two Alice messages share one header - assert "Bob" in rendered and "you" in rendered + # Our own group is headed by the ★ a route draws our end with, never by our name. + assert "Bob" in rendered and "★" in rendered assert "hi" in rendered and "again" in rendered # bodies present, prefix stripped assert "Alice: hi" not in rendered # the raw name prefix is lifted into the header # Each message keeps its own timestamp on its line, even when grouped under one sender. @@ -986,10 +987,11 @@ def test_channel_transcript_groups_by_sender() -> None: assert stamps[0] != stamps[1] # grouped Alice messages show distinct times -def test_transcript_inscribes_the_sender_label_but_never_a_mention() -> None: - """The name that *opens* a group is a tag; the same name inside a body is prose.""" +def test_transcript_inscribes_the_sender_label_but_never_a_mention(powerline) -> None: + """The name that *opens* a group is a chip; the same name inside a body is prose.""" from meshterm.ui.widgets import name_chip + powerline(True) # this is a test *about* the chip, so ask for a glass that draws one conv = Conversation(label="#public", is_channel=True, channel_idx=0) messages = [ ChatMessage(text="Alice: @[Bob] you around?", is_channel=True, channel_idx=0), @@ -997,14 +999,11 @@ def test_transcript_inscribes_the_sender_label_but_never_a_mention() -> None: screen = ChatScreen(conv, messages, send=None, names={}, session=_StubSession()) rendered = _strip_ansi("\n".join(screen._render_grouped(80))) - # Built through the widget rather than spelled out: which caps it uses depends on the - # font the session found, and the point here is the framing, not the glyph. - def chipped(name: str) -> str: - return name_chip(name, "bold #ffffff").plain - - assert chipped("Alice") in rendered # the header, inscribed - assert "@Bob" in rendered # the mention, as it was typed - assert chipped("Bob") not in rendered # …and not framed mid-sentence + # Built through the widget rather than spelled out: the caps are the path line's, and + # what is being asserted here is the framing, not the glyph. + assert name_chip("Alice").plain in rendered # the header, as a chip + assert "@Bob" in rendered # the mention, as it was typed + assert name_chip("Bob").plain not in rendered # …and never framed mid-sentence def test_unidentified_sender_takes_the_node_grey_not_the_chrome_grey() -> None: @@ -1230,7 +1229,8 @@ def test_channel_own_messages_do_not_merge_with_remote_namesake() -> None: ] screen = _channel_screen(messages) rendered = _strip_ansi("\n".join(screen._render_grouped(80))) - assert rendered.count("you") == 2 # two separate headers, not one merged group + # Two headers, and not merely un-merged: theirs is the name they sent, ours is the ★. + assert rendered.count("you") == 1 and rendered.count("★") == 1 def _channel_screen(messages, session=None, key_of=None) -> ChatScreen: diff --git a/tests/test_widgets.py b/tests/test_widgets.py index 32b5dd4..9690887 100644 --- a/tests/test_widgets.py +++ b/tests/test_widgets.py @@ -7,7 +7,16 @@ and every caller inherits it. from __future__ import annotations -from meshterm.platforms import PICOCALC, REGULAR, set_platform +from rich.style import Style +from rich.text import Text + +from meshterm.ui.pathline import ( + POWERLINE_ROUND_CLOSE, + POWERLINE_ROUND_OPEN, + PathHop, + PathLine, +) +from meshterm.ui.theme import node_style from meshterm.ui.widgets import ( _format_age, format_ago, @@ -17,6 +26,11 @@ from meshterm.ui.widgets import ( ) +def _span_style(text: Text, run: str) -> Style: + """The style over ``run`` in ``text``, parsed — pathline spans carry style *strings*.""" + return Style.parse(next(str(s.style) for s in text.spans if text.plain[s.start : s.end] == run)) + + def test_format_age_is_the_bare_column_form() -> None: """The lane form stays suffix-free at every magnitude, for aligned age columns.""" assert _format_age(None) == "never" @@ -192,40 +206,49 @@ def test_revisit_note_is_absent_when_nothing_repeats() -> None: assert revisit_note(()) is None -def test_name_chip_frames_the_name_without_recolouring_it() -> None: - """The chip adds a field; the hue it fields is exactly the one it was handed.""" - chip = name_chip("Alice", "bold #ff00aa") +def test_name_chip_is_a_path_line_of_one_hop() -> None: + """The framing isn't imitated — it *is* the route language, so it can't drift from it.""" + chip = name_chip("Alice", "a1b2") + hop = PathLine([PathHop("Alice", key="a1b2")]).text() - assert chip.plain[1:-1] == "Alice" # a cap either side, the name untouched between - styles = {chip.plain[s.start : s.end]: s.style for s in chip.spans} - assert styles["Alice"].color.name == "#ff00aa" # its own hue, still - assert styles["Alice"].bgcolor is not None # now with something under it - # The caps are the fill drawn over the page — foreground only, so the half-cell that - # isn't chip keeps whatever the row behind it is. - for cap in (chip.plain[0], chip.plain[-1]): - assert styles[cap].bgcolor is None - assert styles[cap].color == styles["Alice"].bgcolor + assert chip.plain == hop.plain + assert [(s.start, s.end, str(s.style)) for s in chip.spans] == [ + (s.start, s.end, str(s.style)) for s in hop.spans + ] -def test_name_chip_takes_a_theme_name_as_readily_as_a_hex() -> None: - """``node.unknown``/``you`` arrive as theme names — the fill must not flatten them. +def test_name_chip_wears_the_hue_as_its_fill_padded_like_every_chip(powerline) -> None: + """A route's colour scheme: the node's hue fills the chip, the ink on it is dark.""" + powerline(True) + chip = name_chip("Alice", "a1b2") - Rich drops a style string that mixes a theme name with anything else, so a chip built - by concatenation renders the name unstyled. This is that regression, pinned. - """ - chip = name_chip("·", "node.unknown") - - style = next(s.style for s in chip.spans if chip.plain[s.start : s.end] == "·") - assert style.color is not None and style.bgcolor is not None - assert style.color.name != style.bgcolor.name # not the invisible-on-itself case + assert chip.plain.strip(POWERLINE_ROUND_OPEN + POWERLINE_ROUND_CLOSE) == " Alice " + ink = _span_style(chip, "Alice") + assert ink.bgcolor.name == node_style("a1b2").removeprefix("bold ") # the hue + assert ink.color.name == "#0f172a" # the chip ink every hop is lettered in -def test_name_chip_draws_bare_where_the_console_has_no_field_for_it() -> None: - """PicoCalc's greys stop at slot 7 as backgrounds — so the name goes unframed there.""" - set_platform(PICOCALC) - try: - chip = name_chip("Alice", "bold #ff00aa") - finally: - set_platform(REGULAR) +def test_name_chip_draws_us_as_the_star_never_as_our_name(powerline) -> None: + """Our end of a route is the map's own marker; a sender label is no different.""" + powerline(True) + chip = name_chip("you", you=True) - assert chip.plain == "Alice" # no caps, no field — and no tofu on the console font + assert "★" in chip.plain and "you" not in chip.plain + assert _span_style(chip, "★").bgcolor.name == "#3f3f46" # neutral grey, not a hue + + +def test_name_chip_greys_a_node_it_cannot_place(powerline) -> None: + """No key, no hue — colour is reserved for keyed identities, in a chip as in a route.""" + powerline(True) + keyless = name_chip("·") + + assert _span_style(keyless, "·").bgcolor.name == "#64748b" # the keyless grey + + +def test_name_chip_falls_back_to_the_bare_name_without_powerline(powerline) -> None: + """No separator glyph to draw a chip with — the PicoCalc's console font among them.""" + powerline(False) + chip = name_chip("Alice", "a1b2") + + assert chip.plain == "Alice" # exactly what an arrow-mode path line makes of one hop + assert str(next(iter(chip.spans)).style) == node_style("a1b2") # still its own hue