Enliven the conversation list and keep section headings in view
Color-code the chat picker rows: a contact's dot takes that person's chat hue (filled once talked-to, hollow before), previews light their sender names and @mentions in-hue, and a relative age column sits between the unread badge and the message text. Pin a grouped list's section heading to the top row once it scrolls off (sticky header in the frame), add PageUp/PageDown to SelectScreen, and drop arrow wraparound in the conversation picker so it doesn't jump across the section dividers. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
aa537d5503
commit
8906e4ed53
9 files changed
+338
-78
No files matched your search
+110
-42
@@ -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:
|
||||
|
||||
+11
-1
@@ -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 ---------------------------------------------------------------
|
||||
|
||||
|
||||
+10
-4
@@ -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,
|
||||
|
||||
+34
-10
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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(
|
||||
|
||||
+43
-16
@@ -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 -------------------------------------------------------------
|
||||
|
||||
@@ -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 ------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user