diff options
Diffstat (limited to 'packages/meshbay-cli/src/meshbay_cli/tui/chat.py')
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/tui/chat.py | 106 |
1 files changed, 96 insertions, 10 deletions
diff --git a/packages/meshbay-cli/src/meshbay_cli/tui/chat.py b/packages/meshbay-cli/src/meshbay_cli/tui/chat.py index 83c1753..0717141 100644 --- a/packages/meshbay-cli/src/meshbay_cli/tui/chat.py +++ b/packages/meshbay-cli/src/meshbay_cli/tui/chat.py @@ -5,7 +5,9 @@ The list shows every group of the account; Enter opens one whose node is up and whose chat is on (`enabled_apps` in the sealed ack, empty meaning all). The room shows the last messages, then the ones that arrive (`chat_msg` pushed by the node), and a line at the bottom to write in. Esc, or `/part`, goes back to the -list; Ctrl+Q, or `/quit`, quits. Messages are read and posted exactly as the +list; Ctrl+Q, or `/quit`, quits. A right-click copies — the text selected with +the mouse, else the line under the pointer — and pastes into the line written +in. Messages are read and posted exactly as the `chat` action reads and posts them — signature, epoch key, content, then the roster's verdict on the signing device — and drawn as text, never markup. """ @@ -14,10 +16,14 @@ import asyncio import contextlib import time +from rich.segment import Segment from rich.text import Text +from textual import events from textual.app import ComposeResult from textual.binding import Binding from textual.screen import Screen +from textual.selection import Selection +from textual.strip import Strip from textual.widgets import DataTable, Footer, Input, RichLog, Static from ..client.account import Account @@ -28,10 +34,14 @@ from ..client.pool import Pool from ..client.roster import RosterCheck from ..errors import CliError from ..output import safe +from . import clipboard # How much of the conversation a room opens on. BACKLOG = 50 -HELP = "/part or Esc: back to the groups /quit or Ctrl+Q: quit /help: this" +HELP = ("/part or Esc: back to the groups /quit or Ctrl+Q: quit " + "right-click: copy, paste /help: this") +# Under a message's first line, its next ones and what is said about it. +INDENT = " " * 6 KEY_CHANGED = ("! This account is using a key you have not seen before: a new device " "added elsewhere, or the node naming the wrong person.") @@ -112,13 +122,88 @@ class ChatGroupsScreen(Screen): await self.pool.close() +class ChatLog(RichLog): + """The conversation, selectable with the mouse — RichLog alone is not.""" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + # (first line, line after the last, its text) for each thing written. + self._blocks: list[tuple[int, int, str]] = [] + + def write(self, content, *args, **kwargs): + # Writes before the size is known come back through here once it is. + first = len(self.lines) + result = super().write(content, *args, **kwargs) + if len(self.lines) > first and isinstance(content, Text): + self._blocks.append((first, len(self.lines), + content.plain.replace("\n" + INDENT, "\n"))) + return result + + def text_at(self, y: int) -> str | None: + return next((text for first, end, text in self._blocks if first <= y < end), None) + + def get_selection(self, selection: Selection) -> tuple[str, str] | None: + text = "\n".join(line.text.rstrip() for line in self.lines) + return selection.extract(text), "\n" + + def selection_updated(self, selection: Selection | None) -> None: + self._line_cache.clear() + self.refresh() + + def _render_line(self, y: int, scroll_x: int, width: int) -> Strip: + line = super()._render_line(y, scroll_x, width) + if y >= len(self.lines): + return line + selection = self.text_selection + span = selection.get_span(y) if selection is not None else None + if span is not None: + start, end = span + end = self.lines[y].cell_length if end == -1 else end + start, end = max(start - scroll_x, 0), max(end - scroll_x, 0) + style = self.screen.get_component_rich_style("screen--selection") + line = Strip.join([ + line.crop(0, start), + Strip(Segment.apply_style(list(line.crop(start, end)), post_style=style)), + line.crop(end)]) + # Where each cell sits in the text: what the screen's selection reads. + return line.apply_offsets(scroll_x, y) + + async def right_click(self, event: events.MouseDown) -> None: + selection = self.text_selection + text = self.get_selection(selection)[0] if selection is not None else "" + if not text: + y = event.screen_y - self.content_region.y + self.scroll_offset.y + text = self.text_at(y) or "" + if text: + self.app.copy_to_clipboard(text) + self.app.notify("Copied.", timeout=2) + self.run_worker(clipboard.write_text(text), group="clipboard") + + +class ChatInput(Input): + async def right_click(self, event: events.MouseDown) -> None: + self.focus() + self.run_worker(self._paste(), exclusive=True, group="clipboard") + + async def _paste(self) -> None: + text = await clipboard.read_text() + if text is None: + text = self.app.clipboard + # One line to write in: the lines of what is pasted, side by side. + text = " ".join(part for part in text.splitlines() if part.strip()) + if not text: + self.app.notify("Nothing to paste.", timeout=2) + return + self.replace(text, *self.selection) + + class ChatRoomScreen(Screen): BINDINGS = [Binding("escape", "part", "Back to the groups")] DEFAULT_CSS = """ ChatRoomScreen > .title { height: 1; padding: 0 1; background: $panel; } - ChatRoomScreen > RichLog { height: 1fr; padding: 0 1; } - ChatRoomScreen > Input { dock: bottom; } + ChatRoomScreen > ChatLog { height: 1fr; padding: 0 1; } + ChatRoomScreen > ChatInput { dock: bottom; } """ def __init__(self, account: Account, group: dict, session): @@ -134,13 +219,14 @@ class ChatRoomScreen(Screen): def compose(self) -> ComposeResult: yield Static(Text(f"#{safe(handle(self.group))} — {HELP}", no_wrap=True, overflow="ellipsis"), classes="title") - yield RichLog(wrap=True, markup=False, highlight=False, auto_scroll=True) - yield Input(placeholder=f"Message to {safe(handle(self.group))}") + yield ChatLog(wrap=True, markup=False, highlight=False, auto_scroll=True) + yield ChatInput(placeholder=f"Message to {safe(handle(self.group))}", + select_on_focus=False) yield Footer() @property - def log_view(self) -> RichLog: - return self.query_one(RichLog) + def log_view(self) -> ChatLog: + return self.query_one(ChatLog) def on_mount(self) -> None: self.query_one(Input).focus() @@ -181,10 +267,10 @@ class ChatRoomScreen(Screen): if m.unreadable: line.append(f"[this message cannot be read: {m.unreadable}]", style="italic") else: - line.append("\n ".join(safe(part) for part in m.text.split("\n"))) + line.append(("\n" + INDENT).join(safe(part) for part in m.text.split("\n"))) self.log_view.write(line) if m.trust == "changed": - self.note(" " + KEY_CHANGED, style="bold yellow") + self.note(INDENT + KEY_CHANGED, style="bold yellow") # ── What the node pushes ───────────────────────────────────────────────── |