diff --git a/meshterm/tools/chat.py b/meshterm/tools/chat.py index 365e84a..3773694 100644 --- a/meshterm/tools/chat.py +++ b/meshterm/tools/chat.py @@ -28,7 +28,8 @@ from ..core.channels import ( ) from ..core.connection import Device from ..core.events import EventKind, MeshEvent -from ..core.models import ChatMessage, Contact, Conversation +from ..core.models import ChatMessage, Contact, Conversation, utcnow +from ..ui.chat import _MENTION, _sender_hue, _split_channel_sender from ..ui.tui import Choice, Separator from ..ui.widgets import channel_glyph from .base import Tool, ToolResult, register @@ -117,11 +118,11 @@ class ChatTool(Tool): lasts = ctx.repo.last_chat_messages() live = _LiveLasts(ctx, seed=lasts) - items: list = [Separator("── Channels ──")] + items: list = [Separator("── 📡 Channels ──")] for conversation in channels: items.append(Choice(title=_row_title(ctx, conversation, live), value=conversation)) - items.append(Separator("── Direct ──")) + items.append(Separator("── 👤 Direct ──")) if contacts: # List contacts by recency — those with messages first, newest exchange at the # top — then the never-contacted ones alphabetically (see _recency_key). @@ -148,7 +149,7 @@ class ChatTool(Tool): None, ) choice = await ctx.ui.select( - "Chat — pick a conversation", items, default=default + "Chat — pick a conversation", items, default=default, wrap=False ) if choice in (None, "__back__"): return None @@ -492,10 +493,13 @@ class _LiveLasts: #: Column width (display cells) the conversation label is padded/ellipsized to, so the unread #: badge and message preview line up in fixed lanes down the picker. _LABEL_WIDTH = 22 -#: Width of the unread-badge lane between the label and the preview (fits ``● 999``). +#: Width of the unread-badge lane between the label and the age (fits ``● 999``). _BADGE_WIDTH = 5 +#: Width of the relative-age lane between the badge and the preview (right-aligned; fits ``now`` +#: and two-digit spans like ``59m`` / ``23h``), so every row's message text starts in one column. +_AGE_WIDTH = 3 #: Longest message preview shown before it is ellipsized. -_PREVIEW_WIDTH = 42 +_PREVIEW_WIDTH = 40 def _row_title( @@ -521,13 +525,15 @@ def _row_title( def _title( ctx: AppContext, conversation: Conversation, lasts: "_LiveLasts" ) -> Union[str, Text]: - """Build a picker row as fixed-width columns: label, unread badge, message preview. + """Build a picker row as fixed-width, colour-coded lanes. - Alignment carries the readability: the label is padded to a fixed lane, the unread ``●`` - badge sits in its own lane, and the preview lines up across every row regardless of how - long the names or counts are. When there is unread, the row is returned as a Rich - :class:`~rich.text.Text` so the ``●`` glyph alone can be tinted red; otherwise a plain - string suffices. + Alignment carries the readability — marker, label, unread badge, relative age, and preview + each sit in their own lane, so every row's message text starts in the same column. Colour is + kept light and purposeful: the name stays in the base colour, a contact's leading dot is + tinted in that person's chat hue, the unread ``●`` badge is red, the age is muted, and the + preview mutes its body while lighting sender names and ``@mentions`` in their hue — the same + colours the live transcript uses. The row is always a Rich :class:`~rich.text.Text` so those + spans survive under the select screen's row highlight. Args: ctx: Shared application context (for the live unread count). @@ -535,45 +541,107 @@ def _title( lasts: The self-refreshing latest-message view. Returns: - The row title — a plain ``str``, or a ``Text`` with a red ``●`` when unread. + The row title as a styled :class:`~rich.text.Text`. """ unread = ctx.chat.unread(conversation.key) - badge = f"● {unread}" if unread else "" last = lasts.get(conversation.key) - preview = _preview(last) if last is not None else "" - # A channel leads with its openness marker (# / 🌐 / 🔒); a direct chat with a person glyph - # — 💬 once we've exchanged messages, 👤 for a contact we haven't talked to yet. Every glyph - # is one double-width cell, so the label lane still lines up across rows. - if conversation.is_channel: - glyph = channel_glyph(conversation.label, conversation.secret) + text = Text(no_wrap=True, overflow="ellipsis") + _append_marker(text, conversation, last) + text.append(_fit(conversation.label, _LABEL_WIDTH)) + text.append(" ") + # Unread badge lane (_BADGE_WIDTH cells): a red ● with the count in warn, or blank filler so + # the following lanes still line up on rows with nothing unread. + if unread: + text.append("●", style="err") + text.append(f" {unread}".ljust(_BADGE_WIDTH - 1), style="warn") else: - glyph = "💬" if last is not None else "👤" - line = f"{glyph} {_fit(conversation.label, _LABEL_WIDTH)} {badge:<{_BADGE_WIDTH}} {preview}".rstrip() - if not unread: - return line - text = Text(line) - dot = line.index("●") # only the badge carries this glyph; colour just it red - text.stylize("err", dot, dot + 1) + text.append(" " * _BADGE_WIDTH) + # Relative-age lane (right-aligned) sits between the badge and the message text, so the ages + # stack in one tidy column and the previews all start at the same place. + age = _ago(last.created_at) if last is not None else "" + text.append(" ") + text.append(f"{age:>{_AGE_WIDTH}}", style="muted") + text.append(" ") + if last is not None: + text.append_text(_preview_text(last)) return text -def _preview(last: ChatMessage, *, width: int = _PREVIEW_WIDTH) -> str: - """One-line preview of the latest message, ``you:``-prefixed when we sent it. +def _append_marker(text: Text, conversation: Conversation, last: Optional[ChatMessage]) -> None: + """Prepend the row's leading marker (3 display cells) — a channel glyph or a contact dot. - Only our own outbound messages get an author prefix. Inbound channel messages already - carry the sender's node name inline in their text (the firmware embeds it), and an inbound - direct message's author is the row's own label — so neither needs one added here. - - Args: - last: The most recent message in the conversation. - width: The column budget before the preview is ellipsized. - - Returns: - A single-line, length-bounded preview string. + A channel keeps its openness marker (# / 🌐 / 🔒). A contact gets a small circle tinted in + that person's chat hue — filled (``●``) once we've exchanged messages, a hollow ring (``○``) + before any — so the colour identifies the person and the hollow-vs-filled shape marks whether + there's history, while the name itself stays in the base colour. The contact dot is padded to + the same width as a channel's double-cell glyph so the labels line up across both sections. """ - who = "you: " if last.outbound else "" - body = f"{who}{last.text}".replace("\n", " ") - return body[: width - 1] + "…" if len(body) > width else body + if conversation.is_channel: + text.append(f"{channel_glyph(conversation.label, conversation.secret)} ") + else: + dot = "●" if last is not None else "○" + text.append(dot, style=_sender_hue(conversation.label)) + text.append(" ") + + +def _preview_text(last: ChatMessage) -> Text: + """A muted last-message preview with sender names and ``@mentions`` lit in their chat hue. + + Mirrors the live transcript: our own messages get a ``you:`` prefix, an inbound channel + message's inline ``Name:`` sender is coloured in that sender's hue, and every ``@[Name]`` + mention reads as a bare ``@Name`` in the mentioned person's hue — so the list and the chat + speak the same colour language. The result is clipped to :data:`_PREVIEW_WIDTH` cells. + """ + body_raw = last.text.replace("\n", " ") + text = Text() + if last.outbound: + text.append("you: ", style="accent") + _append_body(text, body_raw) + elif last.is_channel: + name, body = _split_channel_sender(body_raw) + if name is not None: + text.append(name, style=_sender_hue(name)) + text.append(": ", style="muted") + _append_body(text, body) + else: + _append_body(text, body_raw) + else: + _append_body(text, body_raw) + text.truncate(_PREVIEW_WIDTH, overflow="ellipsis") + return text + + +def _append_body(text: Text, body: str) -> None: + """Append ``body`` to ``text``, muted, with each ``@[Name]`` mention drawn in the name's hue.""" + pos = 0 + for match in _MENTION.finditer(body): + if match.start() > pos: + text.append(body[pos : match.start()], style="muted") + name = match.group(1) + text.append(f"@{name}", style=_sender_hue(name)) + pos = match.end() + if pos < len(body): + text.append(body[pos:], style="muted") + + +def _ago(when: Any) -> str: + """A compact relative age for a message time — ``now``, ``5m``, ``3h``, ``2d``, ``4w``. + + Tolerates a naive timestamp (assumed UTC) so a stray one from the wire can't crash the + picker on the aware/naive subtraction. + """ + if getattr(when, "tzinfo", None) is None: + return "" + secs = max(0.0, (utcnow() - when).total_seconds()) + if secs < 60: + return "now" + if secs < 3600: + return f"{int(secs // 60)}m" + if secs < 86400: + return f"{int(secs // 3600)}h" + if secs < 604800: + return f"{int(secs // 86400)}d" + return f"{int(secs // 604800)}w" def _fit(text: str, width: int) -> str: diff --git a/meshterm/ui/chat.py b/meshterm/ui/chat.py index 0422a8b..4ce22f9 100644 --- a/meshterm/ui/chat.py +++ b/meshterm/ui/chat.py @@ -90,6 +90,16 @@ _BYTES_YELLOW = "bold #fde047" _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. + """ + return _SENDER_COLORS[sum(map(ord, sender)) % len(_SENDER_COLORS)] + + def _split_channel_sender(text: str) -> tuple[Optional[str], str]: """Split a channel message into ``(sender_name, body)`` when it carries a name prefix. @@ -436,7 +446,7 @@ class ChatScreen(Screen): return "you" # white, out of the per-sender hue range — always easy to spot if sender == "·": return "muted" - return _SENDER_COLORS[sum(map(ord, sender)) % len(_SENDER_COLORS)] + return _sender_hue(sender) # --- input --------------------------------------------------------------- diff --git a/meshterm/ui/surface.py b/meshterm/ui/surface.py index 1ba32f5..b82c7d7 100644 --- a/meshterm/ui/surface.py +++ b/meshterm/ui/surface.py @@ -53,7 +53,9 @@ class Ui: """Return a progress context manager exposing ``add_task``/``advance``/``update``.""" raise NotImplementedError - async def select(self, title: str, items: list, *, default: Any = None) -> Any: + async def select( + self, title: str, items: list, *, default: Any = None, wrap: bool = True + ) -> Any: """Prompt the user to choose one item; return its value or ``None`` if cancelled.""" raise NotImplementedError @@ -164,7 +166,9 @@ class PlainUi(Ui): """Build the error raised if a rich prompt is reached on the non-interactive path.""" return RuntimeError("interactive prompts are only available in the menu") - async def select(self, title: str, items: list, *, default: Any = None) -> Any: + async def select( + self, title: str, items: list, *, default: Any = None, wrap: bool = True + ) -> Any: """Unsupported in scripted CLI mode.""" raise self._no_prompt() @@ -313,9 +317,11 @@ class TuiUi(Ui): # --- input --------------------------------------------------------------- - async def select(self, title: str, items: list, *, default: Any = None) -> Any: + async def select( + self, title: str, items: list, *, default: Any = None, wrap: bool = True + ) -> Any: """Delegate to the session's select screen.""" - return await self.session.select(title, items, default=default) + return await self.session.select(title, items, default=default, wrap=wrap) async def select_startup( self, diff --git a/meshterm/ui/tui/frame.py b/meshterm/ui/tui/frame.py index c0814dc..6cea9f1 100644 --- a/meshterm/ui/tui/frame.py +++ b/meshterm/ui/tui/frame.py @@ -23,7 +23,10 @@ def _visible_slice(screen: Screen, lines: list[str], viewport: int) -> tuple[lis Keeps the screen's cursor line in view (for select/text screens) and clamps the scroll offset to the content, then pads the slice to exactly ``viewport`` rows so the panel - always fills its allotted height. + always fills its allotted height. When the screen offers a sticky header (a grouped list's + section heading that has scrolled off), it is pinned to the top row: that reserves one row, + so the cursor is kept within the remaining ``viewport - 1`` and the bottom clamp is relaxed + by one so the final content row can still reach the last visible line. Args: screen: The screen being rendered (its ``scroll`` is adjusted in place). @@ -37,16 +40,37 @@ def _visible_slice(screen: Screen, lines: list[str], viewport: int) -> tuple[lis if isinstance(screen, ScrollScreen): screen.note_viewport(viewport) cursor = screen.cursor_line() - if cursor is not None: - if cursor < screen.scroll: - screen.scroll = cursor - elif cursor >= screen.scroll + viewport: - screen.scroll = cursor - viewport + 1 - screen.scroll = max(0, min(screen.scroll, max(0, total - viewport))) - visible = lines[screen.scroll : screen.scroll + viewport] - more_above = screen.scroll > 0 - more_below = screen.scroll + viewport < total + scroll = screen.scroll + if cursor is not None: + if cursor < scroll: + scroll = cursor + elif cursor >= scroll + viewport: + scroll = cursor - viewport + 1 + scroll = max(0, min(scroll, max(0, total - viewport))) + + # A pinned section heading takes the top row, leaving one fewer for content; nudge the + # scroll down if the cursor would fall in that reserved row, then re-check the heading + # (crossing a section boundary can change which one is pinned, or drop it entirely). + sticky = screen.sticky_header(scroll) if scroll > 0 else None + if sticky is not None: + cap = max(1, viewport - 1) + if cursor is not None and cursor >= scroll + cap: + scroll = min(cursor - cap + 1, max(0, total - cap)) + sticky = screen.sticky_header(scroll) if scroll > 0 else None + + screen.scroll = scroll + + if sticky is not None: + cap = max(1, viewport - 1) + visible = [sticky] + lines[scroll : scroll + cap] + more_below = scroll + cap < total + visible = visible + [""] * (viewport - len(visible)) + return visible, True, more_below + + visible = lines[scroll : scroll + viewport] + more_above = scroll > 0 + more_below = scroll + viewport < total visible = visible + [""] * (viewport - len(visible)) return visible, more_above, more_below diff --git a/meshterm/ui/tui/screen.py b/meshterm/ui/tui/screen.py index 248d560..1683bfb 100644 --- a/meshterm/ui/tui/screen.py +++ b/meshterm/ui/tui/screen.py @@ -78,6 +78,15 @@ class Screen: """ return None + def sticky_header(self, scroll: int) -> Optional[str]: + """An already-rendered body line to pin to the top row once ``scroll`` moves past it. + + Lets a grouped list keep its current section heading in view after the heading itself + has scrolled off. Returns an ANSI line (e.g. a section separator) to pin, or ``None`` + for no pinned header. ``scroll`` is the offset the body is about to be sliced at. + """ + return None + # --- input --------------------------------------------------------------- def handle(self, action: str, data: str = "") -> None: diff --git a/meshterm/ui/tui/select.py b/meshterm/ui/tui/select.py index 46e5562..6d7d73c 100644 --- a/meshterm/ui/tui/select.py +++ b/meshterm/ui/tui/select.py @@ -56,6 +56,10 @@ class Separator: Item = "Choice | Separator" +#: How many rows PageUp/PageDown move the highlight. A fixed step (the screen isn't told the +#: viewport height) that keeps paging predictable, matching the chat transcript's own paging. +_PAGE = 10 + class SelectScreen(Screen): """A grouped, filterable, single-choice list. @@ -72,6 +76,7 @@ class SelectScreen(Screen): default: Any = None, footer_hint: str = "↑↓ move · type to filter · Enter select · Esc back", filterable: bool = True, + wrap: bool = True, ) -> None: """Build a select screen. @@ -82,11 +87,15 @@ class SelectScreen(Screen): footer_hint: Footer key hint. filterable: Whether typing narrows the list. Off for short, fixed lists (e.g. the startup device picker) where type-to-filter would only get in the way. + wrap: Whether the highlight wraps around the ends (Down from the last row jumps + to the first, and vice versa). Off for grouped lists where wrapping across the + section headings reads as a jarring jump rather than continuing to scroll. """ super().__init__() self.title = title self.footer_hint = footer_hint self._filterable = filterable + self._wrap = wrap self._items = items self._filter = "" # Index into the currently-selectable (filtered) choices. @@ -130,11 +139,16 @@ class SelectScreen(Screen): selected = choices[self._index] if choices else None lines: list[str] = [] + # (body-line index, rendered line) for each separator, so a section heading that + # scrolls off can be re-pinned to the top row (see :meth:`sticky_header`). + self._separator_lines: list[tuple[int, str]] = [] if self._filter: lines.append(render_to_ansi(Text(f"/{self._filter}", style="warn"), width)) for item in rows: if isinstance(item, Separator): - lines.append(render_to_ansi(Text(item.title, style="muted"), width)) + sep = render_to_ansi(Text(item.title, style="muted"), width) + self._separator_lines.append((len(lines), sep)) + lines.append(sep) continue is_sel = item is selected pointer = "❯ " if is_sel else " " @@ -160,15 +174,44 @@ class SelectScreen(Screen): """Return the body line index of the highlighted row.""" return getattr(self, "_cursor", None) + def sticky_header(self, scroll: int) -> Optional[str]: + """The section heading governing the top visible row, when it has scrolled off. + + Returns the rendered separator line for the group the first visible row belongs to, + so the "Channels"/"Direct"-style heading stays pinned as you scroll down into a long + section. Returns ``None`` when that heading is itself still on screen (nothing to pin) + or while filtering (headings are hidden then). + """ + governing: Optional[str] = None + governing_at = -1 + for idx, line in getattr(self, "_separator_lines", ()): + if idx <= scroll: + governing, governing_at = line, idx + else: + break + if governing is None or governing_at == scroll: + return None # no heading above, or it's already the top visible row + return governing + # --- input --------------------------------------------------------------- def handle(self, action: str, data: str = "") -> None: """Move the highlight, edit the filter, or commit/cancel the selection.""" choices = self._choices() if action == "up": - self._index = (self._index - 1) % len(choices) if choices else 0 + if choices: + self._index = (self._index - 1) % len(choices) if self._wrap else max( + 0, self._index - 1 + ) elif action == "down": - self._index = (self._index + 1) % len(choices) if choices else 0 + if choices: + self._index = (self._index + 1) % len(choices) if self._wrap else min( + len(choices) - 1, self._index + 1 + ) + elif action == "pageup": + self._index = max(0, self._index - _PAGE) + elif action == "pagedown": + self._index = min(len(choices) - 1, self._index + _PAGE) if choices else 0 elif action == "home": self._index = 0 elif action == "end": diff --git a/meshterm/ui/tui/session.py b/meshterm/ui/tui/session.py index 7bb25bb..550be8d 100644 --- a/meshterm/ui/tui/session.py +++ b/meshterm/ui/tui/session.py @@ -125,9 +125,13 @@ class TuiSession: finally: self.pop(screen) - async def select(self, title: str, items: list, *, default: Any = None) -> Any: + async def select( + self, title: str, items: list, *, default: Any = None, wrap: bool = True + ) -> Any: """Show a select screen; return the chosen value or ``None`` if cancelled.""" - result = await self.run_screen(SelectScreen(title, items, default=default)) + result = await self.run_screen( + SelectScreen(title, items, default=default, wrap=wrap) + ) return None if result is CANCEL else result async def select_startup( diff --git a/tests/test_chat.py b/tests/test_chat.py index ae9edd1..05e6988 100644 --- a/tests/test_chat.py +++ b/tests/test_chat.py @@ -33,7 +33,7 @@ from meshterm.core.models import ( from meshterm.persistence.repository import Repository from meshterm.services.chat_service import ChatService from meshterm.services.event_hub import EventHub -from meshterm.tools.chat import _LiveLasts, _preview, _title +from meshterm.tools.chat import _PREVIEW_WIDTH, _LiveLasts, _preview_text, _title from meshterm.ui.chat import ChatScreen from meshterm.ui.tui.screen import CANCEL @@ -239,18 +239,29 @@ def test_preview_prefixes_own_messages_only() -> None: synthesized (that would double it), and a direct message's author is the row label. """ chan_in = ChatMessage(text="Bob: hi", is_channel=True, channel_idx=0) # sender inline - assert _preview(chan_in) == "Bob: hi" + assert _preview_text(chan_in).plain == "Bob: hi" dm_in = ChatMessage(text="hey", peer="aa", peer_name="Bob") - assert _preview(dm_in) == "hey" + assert _preview_text(dm_in).plain == "hey" mine = ChatMessage(text="yo", outbound=True, is_channel=True, channel_idx=0) - assert _preview(mine) == "you: yo" + assert _preview_text(mine).plain == "you: yo" + + +def test_preview_colours_channel_sender_and_mentions() -> None: + """A channel preview lights its inline sender name and any ``@[Name]`` mention in a hue.""" + from meshterm.ui.chat import _sender_hue + + msg = ChatMessage(text="Bob: hi @[Alice]", is_channel=True, channel_idx=0) + preview = _preview_text(msg) + assert preview.plain == "Bob: hi @Alice" # the mention's brackets are dropped for display + styles = {span.style for span in preview.spans} + assert _sender_hue("Bob") in styles and _sender_hue("Alice") in styles def test_preview_ellipsizes_long_text() -> None: """An over-long preview is clipped to the width budget with a trailing ellipsis.""" long = ChatMessage(text="x" * 100, is_channel=True, channel_idx=0) - out = _preview(long, width=10) - assert len(out) == 10 and out.endswith("…") + out = _preview_text(long).plain + assert len(out) == _PREVIEW_WIDTH and out.endswith("…") def test_title_shows_badge_and_author_preview(repo: Repository) -> None: @@ -266,7 +277,7 @@ def test_title_shows_badge_and_author_preview(repo: Repository) -> None: def test_title_reddens_only_the_unread_dot(repo: Repository) -> None: - """With unread the row is a Text whose ``●`` glyph (only) is styled red; else a plain str.""" + """With unread the row's ``●`` glyph (only) is styled red; with none there is no dot.""" from rich.text import Text conv = Conversation(label="General", is_channel=True, channel_idx=0, channel_id="c0") @@ -279,7 +290,9 @@ def test_title_reddens_only_the_unread_dot(repo: Repository) -> None: assert reddened and all(span.end - span.start == 1 for span in reddened) # just the glyph read = _title(_RowCtx(repo, unread={}), conv, {}) - assert isinstance(read, str) # no badge -> plain string, no styling + assert isinstance(read, Text) # always a Text now, so its spans can carry the row's colour + assert "●" not in read.plain # nothing unread -> no badge dot + assert not any(span.style == "err" for span in read.spans) def test_title_preview_column_aligns_regardless_of_label_length(repo: Repository) -> None: @@ -289,15 +302,15 @@ def test_title_preview_column_aligns_regardless_of_label_length(repo: Repository long = Conversation(label="A much longer channel name here", is_channel=True, channel_idx=1, channel_id="c1") m0 = ChatMessage(text="X: hello", is_channel=True, channel_id="c0") m1 = ChatMessage(text="Y: hello", is_channel=True, channel_id="c1") - l0 = _title(ctx, short, {"chan:c0": m0}) - l1 = _title(ctx, long, {"chan:c1": m1}) + l0 = _title(ctx, short, {"chan:c0": m0}).plain + l1 = _title(ctx, long, {"chan:c1": m1}).plain assert l0.index("X: hello") == l1.index("Y: hello") def test_title_leads_with_openness_glyph(repo: Repository) -> None: """Channel rows lead with an openness glyph: # name-derived, 🌐 fixed-key public, 🔒 private.""" ctx = _RowCtx(repo) - head = lambda conv: _title(ctx, conv, {}).split(" ", 1)[0] + head = lambda conv: _title(ctx, conv, {}).plain.split(" ", 1)[0] named = Conversation( label="#general", is_channel=True, channel_id="c0", secret=derive_secret("#general") ) @@ -312,16 +325,30 @@ def test_title_leads_with_openness_glyph(repo: Repository) -> None: assert head(private) == "🔒" -def test_title_contact_glyph_reflects_conversation_history(repo: Repository) -> None: - """A contact shows 💬 once we've exchanged messages, 👤 before any conversation exists.""" +def test_title_contact_dot_reflects_conversation_history(repo: Repository) -> None: + """A contact's dot is filled ● once we've talked, hollow ○ before — always in her hue.""" + from meshterm.ui.chat import _sender_hue + ctx = _RowCtx(repo) contact = Conversation( label="Alice", is_channel=False, contact=Contact(name="Alice", public_key="d4" + "0" * 62) ) - head = lambda lasts: _title(ctx, contact, lasts).split(" ", 1)[0] - assert head({}) == "👤" # no history yet — a contact we haven't talked to + head = lambda lasts: _title(ctx, contact, lasts).plain.split(" ", 1)[0] + + def dot_is_hued(row) -> bool: + return any( + span.style == _sender_hue("Alice") and span.start == 0 and span.end == 1 + for span in row.spans + ) + + # No history yet — a hollow ring, but still tinted in Alice's stable chat hue. + fresh = _title(ctx, contact, {}) + assert fresh.plain.startswith("○") and dot_is_hued(fresh) + + # Once we've exchanged messages the same-hued dot fills in. last = ChatMessage(text="hi", peer=contact.peer) - assert head({contact.key: last}) == "💬" # we've exchanged messages + talked = _title(ctx, contact, {contact.key: last}) + assert talked.plain.startswith("●") and dot_is_hued(talked) # -- chat service ------------------------------------------------------------- diff --git a/tests/test_tui.py b/tests/test_tui.py index ff7325b..61bb335 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -142,6 +142,75 @@ def test_select_filter_matches_callable_title() -> None: assert _run(screen, "enter") == 1 +def test_select_no_wrap_clamps_at_the_ends() -> None: + """With wrap off, Up on the first row and Down on the last stay put (no cycling).""" + items = [Choice("alpha", 1), Choice("beta", 2), Choice("gamma", 3)] + screen = SelectScreen("pick", items, wrap=False) + screen.handle("up") # already on the first choice — must not jump to the last + assert _run(screen, "enter") == 1 + screen = SelectScreen("pick", items, default=3, wrap=False) # last choice + screen.handle("down") # already on the last — must not wrap to the first + assert _run(screen, "enter") == 3 + + +def test_select_pageup_pagedown_jump_by_a_page() -> None: + """PageDown/PageUp move the highlight a page at a time, clamped to the choice range.""" + from meshterm.ui.tui.select import _PAGE + + items = [Choice(f"c{i}", i) for i in range(30)] + screen = SelectScreen("pick", items) # starts on the first choice + screen.handle("pagedown") + assert _run(screen, "enter") == _PAGE # advanced one page down + screen = SelectScreen("pick", items, default=25) + screen.handle("pageup") + assert _run(screen, "enter") == 25 - _PAGE # and one page back up + + +def _grouped_menu(default: object = None) -> SelectScreen: + """A two-section menu long enough that each section scrolls past a small viewport.""" + items: list = [Separator("── Channels ──")] + items += [Choice(f"chan{i}", ("c", i)) for i in range(6)] + items += [Separator("── Direct ──")] + items += [Choice(f"peer{i}", ("d", i)) for i in range(8)] + return SelectScreen("pick", items, default=default) + + +def _top_plain(screen: SelectScreen, viewport: int) -> str: + """Slice the screen at its selection-driven scroll and return the top row's plain text.""" + lines = screen.render_body(40) + visible, _above, _below = frame._visible_slice(screen, lines, viewport) + return Text.from_ansi(visible[0]).plain.strip() + + +def test_select_pins_section_heading_when_it_scrolls_off() -> None: + """Selecting deep in a section keeps that section's heading pinned to the top row.""" + # Highlighting a channel far enough down pushes the "Channels" heading off the top, so it + # is re-pinned rather than vanishing. + assert _top_plain(_grouped_menu(default=("c", 5)), viewport=6) == "── Channels ──" + # Deep into the Direct group, the pinned heading switches to that section's. + assert _top_plain(_grouped_menu(default=("d", 6)), viewport=6) == "── Direct ──" + + +def test_select_does_not_pin_a_heading_that_is_still_visible() -> None: + """With the list scrolled to the top, the real heading shows — nothing is pinned over it.""" + screen = _grouped_menu() # default selection is the first choice, so scroll stays at 0 + lines = screen.render_body(40) + visible, above, _below = frame._visible_slice(screen, lines, 6) + assert Text.from_ansi(visible[0]).plain.strip() == "── Channels ──" + assert above is False # top of the list; no pinned duplicate and no "more above" + + +def test_select_pinned_heading_keeps_the_last_row_reachable() -> None: + """Even with a heading pinned, the bottom choice stays fully visible (not clipped).""" + screen = _grouped_menu() + screen.handle("end") # highlight the final choice + lines = screen.render_body(40) + visible, _above, below = frame._visible_slice(screen, lines, 6) + assert Text.from_ansi(visible[0]).plain.strip() == "── Direct ──" # heading pinned + assert any("peer7" in Text.from_ansi(row).plain for row in visible) # last row shown + assert below is False # and we know we're at the bottom + + # --- scroll ------------------------------------------------------------------