aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/src/meshbay_cli/tui/chat.py
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-cli/src/meshbay_cli/tui/chat.py')
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/tui/chat.py106
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 ─────────────────────────────────────────────────