diff --git a/CLAUDE.md b/CLAUDE.md index 04d6bcc..f64fb11 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -593,9 +593,11 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow (`PATH_INK`, a style that draws nothing) and the fold skips what lies inside it. That is what makes the arrow form say what the chip form always said, its fills having never been in the fold's vocabulary. The one exception is a chat's **selected message**: its mention - chips go white with its text (JP, 2026-10-19) — the label only, the fill kept, keyed hues - only (`widgets.picked_hop`: `white_ink` on a chip, `cursor` in arrow mode). A mention is - a name in prose, not a hop of a route. Reverse-video chips — a dialog's committing button, + chips and its group's author chip go white with its text (JP, 2026-10-19) — the label + only, the fill kept (`widgets.picked_hop`: `white_ink` on a chip, `cursor` in arrow mode). + An unknown node goes white too: its grey fill already says it is unknown, and white reads + better on it than its black label. A mention or an author is a name in a conversation, + not a hop of a route. Reverse-video chips — a dialog's committing button, a running action's Abort, the arrow-mode composer slot, the editor cursor — are the `selected` fill, a **grey** block (`muted`'s grey; light grey slot 7 on the VT, the one grey a reverse can put behind text there, and the same fill the F-key lane uses). A chip diff --git a/meshterm/ui/chat.py b/meshterm/ui/chat.py index b7b07eb..370e3c0 100644 --- a/meshterm/ui/chat.py +++ b/meshterm/ui/chat.py @@ -677,6 +677,9 @@ class ChatScreen(Screen): boundaries: list[int] = [] prev_group: tuple[bool, str] | None = None prev_day = None + # The first message of the group of the selected message. Its slice has the author + # chip of the group, which is white while the selection is in the group. + picked_head = self._picked_head() for idx in range(total): message = messages[idx] @@ -688,9 +691,12 @@ class ChatScreen(Screen): group = (message.outbound, sender) new_day = day != prev_day selected = idx == self._selected + # A slice that the selection changes renders again, now and on the next paint + # after the selection leaves it: the selected message, and the head of its group. + marked = selected or idx == picked_head pending = message.acked is None and message.outbound and not message.is_channel reusable = ( - not selected + not marked and not pending and idx < cache_count and not cached_selected[idx] @@ -725,7 +731,7 @@ class ChatScreen(Screen): self._sticky_headers.append((len(lines), [divider[0]])) lines += divider if new_day or group != prev_group: - header = self._group_header(sender, message) + header = self._group_header(sender, message, picked=idx == picked_head) lines += render_lines(header, width) if selected: # The first message has the head of the transcript: nothing comes before @@ -741,7 +747,7 @@ class ChatScreen(Screen): prev_group, prev_day = group, day ids.append(id(message)) ackeds.append(message.acked) - selecteds.append(selected) + selecteds.append(marked) boundaries.append(len(lines)) self._render_cache = { @@ -770,7 +776,28 @@ class ChatScreen(Screen): return (name or "·"), body return (self._name(message.peer) or message.peer or "?"), message.text - def _group_header(self, sender: str, message: ChatMessage) -> Text: + def _picked_head(self) -> int | None: + """The index of the first message of the group of the selected message, or ``None``. + + A group is a run of messages from one sender on one day, under one author chip + (refer to :meth:`_render_grouped`). The chip is drawn with the first message of the + run, so that message is the one to draw again when the selection is in the run. + """ + selected, messages = self._selected, self._messages + if selected is None or not 0 <= selected < len(messages): + return None + + def group_of(message: ChatMessage) -> tuple: + day = message.created_at.astimezone().date() + return (day, message.outbound, self._sender_and_body(message)[0]) + + group = group_of(messages[selected]) + head = selected + while head > 0 and group_of(messages[head - 1]) == group: + head -= 1 + return head + + def _group_header(self, sender: str, message: ChatMessage, *, picked: bool = False) -> Text: """Build the sender header that starts a group: the name, drawn as a chip. The chip makes a *label* different from the prose under it. A name alone above its @@ -792,6 +819,11 @@ class ChatScreen(Screen): A sender that is a locked contact has the padlock before its name, in the place of the ``★`` of our chip (JP, 2026-10-19). Thus the chip shows the state that ^K toggles. + + With ``picked``, the selected message is in this group, and the label of the chip + is white, as the text of that message is (refer to + :func:`~meshterm.ui.widgets.picked_hop`, JP, 2026-10-19). Our chip already has a + white label. """ if message.outbound: return self_chip(self._self_name, self._self_key, role=ORIGIN) @@ -799,7 +831,7 @@ class ChatScreen(Screen): key = None if sender == "·" else self._header_key(sender, message) pages = self._pages locked = key is not None and pages is not None and pages.lock_state(pages.find(key)) is True - return author_chip(sender, key, locked=locked) + return author_chip(sender, key, locked=locked, picked=picked) def _header_key(self, sender: str, message: ChatMessage) -> str | None: """The key that gives the colour of the sender chip of a group: the key of the name. diff --git a/meshterm/ui/pathline.py b/meshterm/ui/pathline.py index 306e88a..0e6c9b6 100644 --- a/meshterm/ui/pathline.py +++ b/meshterm/ui/pathline.py @@ -325,6 +325,8 @@ class PathHop: the fill is the same as the fill of each known sender, and the white label tells it from them (JP, 2026-10-14). Arrow mode draws the same pair, with the padding of a chip. The PicoCalc draws it too, white on the dim twin of the hue. + On a quiet chip, it is a white label on the grey fill: a node with no key on the + selected message of a chat (refer to :func:`~meshterm.ui.widgets.picked_hop`). quiet: A node with no key in a chat: a sender or a mention that nobody can identify. Its chip is black on a grey fill (:data:`_QUIET_BG`), so it does not stand out among the senders that have a colour (JP, 2026-10-14). A hop with a @@ -1230,7 +1232,8 @@ def _spectrum_colours(hop: PathHop) -> _ChipColours: thing when it fades the name. Our chip in a chat (``white_ink``) has the fill of the chip of its key, and a white - label. A quiet chip (a node with no key in a chat) is black on a grey fill. + label. A quiet chip (a node with no key in a chat) is black on a grey fill, or white on + it with ``white_ink``. """ if hop.cursor: return _ChipColours(_style_hex("cursor") or _KEYLESS_BG, _CHIP_FG, _CHIP_FG) @@ -1247,7 +1250,8 @@ def _spectrum_colours(hop: PathHop) -> _ChipColours: if hue: return _ChipColours(_chip_fill(hue), hue, hue) if hop.quiet: - return _ChipColours(_QUIET_BG, _QUIET_FG, _QUIET_FG) + ink = _WHITE if hop.white_ink else _QUIET_FG + return _ChipColours(_QUIET_BG, ink, ink) return _ChipColours(_KEYLESS_BG, _KEYLESS_FG, _KEYLESS_FG) @@ -1269,7 +1273,8 @@ def _slot_colours(hop: PathHop) -> _ChipColours: (:func:`_spectrum_colours`). Our chip in a chat (``white_ink``) is white on the dim twin of its hue, the same rule as - on a truecolor terminal. A quiet chip is black on the light grey of the keyless chip. + on a truecolor terminal. A quiet chip is black on the light grey of the keyless chip, or + white on it with ``white_ink``. """ if hop.cursor: return _ChipColours(_SLOT_PAGE, _SLOT_WHITE, _SLOT_WHITE) @@ -1288,7 +1293,8 @@ def _slot_colours(hop: PathHop) -> _ChipColours: if hop.white_ink and hue: return _ChipColours(DIM_TWIN[ink], _SLOT_WHITE, _SLOT_WHITE) if hop.quiet and not hue: - return _ChipColours(DIM_TWIN[ink], _SLOT_PAGE, _SLOT_PAGE) + label = _SLOT_WHITE if hop.white_ink else _SLOT_PAGE + return _ChipColours(DIM_TWIN[ink], label, label) return _ChipColours(DIM_TWIN[ink], ink, ink) diff --git a/meshterm/ui/widgets.py b/meshterm/ui/widgets.py index f8b1f6a..bc9c67f 100644 --- a/meshterm/ui/widgets.py +++ b/meshterm/ui/widgets.py @@ -684,7 +684,7 @@ def name_chip( return hop_chip(hop, role=role) -def author_chip(name: str, key: str | None, *, locked: bool = False) -> Text: +def author_chip(name: str, key: str | None, *, locked: bool = False, picked: bool = False) -> Text: """The author of a message in a chat: the chip of the node, which closes on the point. It is :func:`name_chip` with the role of an origin, and one difference: a node that no @@ -701,12 +701,15 @@ def author_chip(name: str, key: str | None, *, locked: bool = False) -> Text: name: The name as it must read. key: Any known prefix of the key of the node, or ``None``. locked: Whether the author is a contact that is locked against the archive sweep. + picked: Whether the selected message of the chat is in the group of this chip. + Then the label is white (refer to :func:`picked_hop`). Returns: The chip as a one-line :class:`Text`. """ label = f"{glyph(LOCK_ICON)} {name}" if locked else name - return hop_chip(PathHop(label, key=key or None, quiet=True), role=ORIGIN) + hop = PathHop(label, key=key or None, quiet=True) + return hop_chip(picked_hop(hop) if picked else hop, role=ORIGIN) def self_chip(name: str | None, key: str | None, *, role: str | None = None) -> Text: @@ -792,18 +795,20 @@ def mention_hop(name: str, key: str | None, *, you: bool = False) -> PathHop: def picked_hop(hop: PathHop) -> PathHop: """``hop`` on the selected message of a chat: its label in the white of the selection. - The text of the selected message is white (``cursor``), and its chips follow it (JP, - 2026-10-19). A chip keeps its fill, so it still tells one node from the next, and only - its label turns white: the white label of our own chip - (:attr:`~meshterm.ui.pathline.PathHop.white_ink`). Arrow mode has no fill, so there the - label takes the ``cursor`` white, as each other name on a selected row does. + The text of the selected message is white (``cursor``), and its chips follow it: the + mentions in its body, and the author chip of its group (JP, 2026-10-19). A chip keeps + its fill, so it still tells one node from the next, and only its label turns white: the + white label of our own chip (:attr:`~meshterm.ui.pathline.PathHop.white_ink`). Arrow + mode has no fill, so there the label takes the ``cursor`` white, as each other name on a + selected row does. - The rule is the rule of the white fold of a selected row (refer to - :func:`~meshterm.ui.tui.render._whiten_identities`): only the hue of a key turns white. - A node that no key can place keeps its grey, because the selection must not claim to - know that node. A hop that already has a white label stays as it is. + A node that no key can place turns white too. Its grey fill already says that the node + is unknown (JP, 2026-10-19), and white on that fill reads better than its black label. + This is different from the white fold of a list row (refer to + :func:`~meshterm.ui.tui.render._whiten_identities`), where a grey name has no fill to + keep the grey. A hop that already has a white label stays as it is. """ - if not hop.key or hop.white_ink: + if hop.white_ink: return hop if powerline_enabled(): return replace(hop, white_ink=True) diff --git a/tests/test_chat.py b/tests/test_chat.py index ae4ebf9..87f0feb 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -3262,8 +3262,8 @@ def test_the_chips_of_the_selected_message_turn_white_with_its_text( """On the selected message, a mention chip has a white label, as the text around it does. JP, 2026-10-19. A chip keeps its fill, so it still tells one node from the next. In - arrow mode, the label takes the ``cursor`` white. A node that no key can place keeps its - grey, as on each selected row: the selection must not claim to know that node. + arrow mode, the label takes the ``cursor`` white. A node that no key can place turns + white too: its grey fill already says that the node is unknown. """ powerline(chips) keys = {"alice": "a1" * 32, "bob": "b2" * 32} @@ -3285,7 +3285,63 @@ def test_the_chips_of_the_selected_message_turn_white_with_its_text( white = "#ffffff" if chips else "cursor" assert white in styles("Bob", selected=True) assert white not in styles("Bob", selected=False) - assert white not in styles("Zed", selected=True) + assert white in styles("Zed", selected=True) + assert white not in styles("Zed", selected=False) + + +@pytest.mark.parametrize("chips", [True, False]) +def test_the_author_chip_of_the_selected_message_turns_white( + powerline, # noqa: ANN001 + chips: bool, +) -> None: + """The author chip of the group of the selected message has a white label (JP, 2026-10-19). + + The chip is drawn with the first message of the group. Thus that message draws again + when the selection is lower in the group, and again when the selection leaves the group, + although the transcript keeps each message in a cache. + """ + powerline(chips) + keys = {"alice": "a1" * 32} + screen = ChatScreen( + Conversation(label="#public", is_channel=True, channel_idx=0), + [ + ChatMessage(text="Alice: one", is_channel=True, channel_idx=0), + ChatMessage(text="Alice: two", is_channel=True, channel_idx=0), + ChatMessage(text="Zed: three", is_channel=True, channel_idx=0), + ], + send=None, + names={}, + session=_StubSession(), + key_of=lambda name: keys.get(name.casefold()), + ) + white = "#ffffff" if chips else "cursor" + for sender, message in (("Alice", screen._messages[0]), ("Zed", screen._messages[2])): + picked = screen._group_header(sender, message, picked=True) + plain = screen._group_header(sender, message) + assert white in " ".join(str(s.style) for s in picked.spans) + assert white not in " ".join(str(s.style) for s in plain.spans) + + calls: list[tuple[str, bool]] = [] + header = screen._group_header + + def spy(sender, message, *, picked=False): # noqa: ANN001, ANN202 + calls.append((sender, picked)) + return header(sender, message, picked=picked) + + screen._group_header = spy # type: ignore[method-assign] + screen.render_body(60) + assert calls == [("Alice", False), ("Zed", False)] + + calls.clear() + screen.handle("up") + screen.handle("up") # the second message of Alice: its group is the first one + screen.render_body(60) + assert calls == [("Alice", True)] + + calls.clear() + screen.handle("down") # Zed: the chip of Alice goes back, and the chip of Zed is white + screen.render_body(60) + assert calls == [("Alice", False), ("Zed", True)] def test_the_lock_atom_never_pushes_another_key_out_of_the_hint(tmp_path: Path) -> None: