diff options
Diffstat (limited to 'packages/meshbay-cli/src/meshbay_cli')
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/__main__.py | 3 | ||||
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/commands/help.py | 27 | ||||
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/manpage.py | 4 | ||||
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/tui/app.py | 23 | ||||
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/tui/chat.py | 106 | ||||
| -rw-r--r-- | packages/meshbay-cli/src/meshbay_cli/tui/clipboard.py | 76 |
6 files changed, 228 insertions, 11 deletions
diff --git a/packages/meshbay-cli/src/meshbay_cli/__main__.py b/packages/meshbay-cli/src/meshbay_cli/__main__.py index dd13d50..2a71c17 100644 --- a/packages/meshbay-cli/src/meshbay_cli/__main__.py +++ b/packages/meshbay-cli/src/meshbay_cli/__main__.py @@ -18,6 +18,7 @@ from .commands import ( devices, get, groups, + help, invitations, join, leave, @@ -35,7 +36,7 @@ from .errors import INTERRUPTED, USAGE, CliError ACTIONS = {"login": login, "logout": logout, "groups": groups, "invitations": invitations, "join": join, "decline": decline, "leave": leave, "ls": ls, "search": search, "get": get, "put": put, "rm": rm, "chat": chat, "devices": devices, "trust": trust, - "browser-access": browser_access} + "browser-access": browser_access, "help": help} class _Parser(argparse.ArgumentParser): diff --git a/packages/meshbay-cli/src/meshbay_cli/commands/help.py b/packages/meshbay-cli/src/meshbay_cli/commands/help.py new file mode 100644 index 0000000..6515800 --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/commands/help.py @@ -0,0 +1,27 @@ +"""`help`: what `--help` prints, for the program or for one action.""" + +from ..errors import USAGE, CliError +from ..output import sentence +from .context import Context + +HELP = "what --help prints: the actions, or one action's options" + + +def add_parser(sub, parent) -> None: + p = sub.add_parser("help", parents=[parent], help=HELP, description=sentence(HELP) + ".") + p.add_argument("topic", nargs="?", metavar="ACTION", help="the action to explain") + + +async def run(ctx: Context) -> int: + from ..__main__ import build_parser + parser = build_parser() + topic = ctx.args.topic + if topic is None: + parser.print_help() + return 0 + actions = next(a for a in parser._actions if hasattr(a, "choices") and a.choices + and "help" in a.choices).choices + if topic not in actions: + raise CliError(USAGE, f"No action {topic!r}. The actions: {', '.join(actions)}.") + actions[topic].print_help() + return 0 diff --git a/packages/meshbay-cli/src/meshbay_cli/manpage.py b/packages/meshbay-cli/src/meshbay_cli/manpage.py index 43a0fee..d40e479 100644 --- a/packages/meshbay-cli/src/meshbay_cli/manpage.py +++ b/packages/meshbay-cli/src/meshbay_cli/manpage.py @@ -253,6 +253,10 @@ write in; Esc or goes back to the groups, .B /quit or Ctrl+Q quits. +A right-click copies the text selected with the mouse, else the line under the +pointer, and pastes into the line written in: the system clipboard's text, read +with the platform's tool (wl\-paste, xclip or xsel, pbpaste, PowerShell), else +what was copied here last. .PP .B browser\-access on makes this machine leave, on each node it opens a group on, a copy of its keys diff --git a/packages/meshbay-cli/src/meshbay_cli/tui/app.py b/packages/meshbay-cli/src/meshbay_cli/tui/app.py index fe7fa2c..7abb149 100644 --- a/packages/meshbay-cli/src/meshbay_cli/tui/app.py +++ b/packages/meshbay-cli/src/meshbay_cli/tui/app.py @@ -12,7 +12,9 @@ the log goes to a file in the CLI's data directory instead. import logging +from textual import events from textual.app import App +from textual.errors import NoWidget from ..client.account import Account, no_passphrase from ..client.hub import Hub @@ -31,6 +33,7 @@ class MeshBayApp(App): self.hub = ctx.hub # The screen a signed-in account lands on, made from the account. self.home = home + self._right_click_taken = False async def use_hub(self, url: str) -> None: if url != self.hub.url: @@ -39,6 +42,26 @@ class MeshBayApp(App): self.ctx.hub = self.hub self.ctx.hub_url = url + async def on_event(self, event: events.Event) -> None: + """A right-click goes to the widget under it when it has a use for one + (the chat's copy and paste), before Textual takes it for a selection.""" + if (isinstance(event, (events.MouseDown, events.MouseUp)) and event.button == 3 + and not event.is_forwarded): + if isinstance(event, events.MouseDown): + try: + widget, _ = self.get_widget_at(event.x, event.y) + except NoWidget: + widget = None + handler = getattr(widget, "right_click", None) + self._right_click_taken = handler is not None + if handler is not None: + await handler(event) + return + elif self._right_click_taken: + self._right_click_taken = False + return + await super().on_event(event) + def on_mount(self) -> None: account = None if self.ctx.store.peek().get("hub") == self.hub.url: 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 ───────────────────────────────────────────────── diff --git a/packages/meshbay-cli/src/meshbay_cli/tui/clipboard.py b/packages/meshbay-cli/src/meshbay_cli/tui/clipboard.py new file mode 100644 index 0000000..1a1f0d7 --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/tui/clipboard.py @@ -0,0 +1,76 @@ +""" +The system clipboard, for the chat's right-click copy and paste. + +A copy goes to the terminal (OSC 52, through Textual), which reaches the +clipboard of the machine in front of the person even over SSH, and to the +platform's own tool too, for terminals that ignore OSC 52. A paste has no +terminal escape to rely on: the text is read with the platform's tool, else it +is what this program copied last. A tool that is missing, fails or hangs is +skipped, never an error. +""" + +import asyncio +import os +import shutil +import sys + +TIMEOUT = 3.0 +_PS = ["powershell", "-NoProfile", "-NonInteractive", "-Command"] + + +def _tools(read: bool) -> list[list[str]]: + if sys.platform == "win32": + return [_PS + ["[Console]::OutputEncoding = [Text.Encoding]::UTF8; Get-Clipboard -Raw"] + if read else ["clip"]] + if sys.platform == "darwin": + return [["pbpaste"] if read else ["pbcopy"]] + tools = [] + if os.environ.get("WAYLAND_DISPLAY"): + tools.append(["wl-paste", "--no-newline"] if read else ["wl-copy"]) + if os.environ.get("DISPLAY"): + tools.append(["xclip", "-selection", "clipboard", "-o"] if read + else ["xclip", "-selection", "clipboard", "-i"]) + tools.append(["xsel", "--clipboard", "--output"] if read + else ["xsel", "--clipboard", "--input"]) + return tools + + +def _encode(text: str) -> bytes: + # clip.exe reads the console's code page unless the text says it is UTF-16. + return ("" + text).encode("utf-16-le") if sys.platform == "win32" else text.encode() + + +async def _run(cmd: list[str], data: bytes | None) -> bytes | None: + if shutil.which(cmd[0]) is None: + return None + env = {**os.environ, "LC_CTYPE": "UTF-8"} if sys.platform == "darwin" else None + try: + proc = await asyncio.create_subprocess_exec( + *cmd, stdin=asyncio.subprocess.PIPE if data is not None else asyncio.subprocess.DEVNULL, + stdout=asyncio.subprocess.PIPE if data is None else asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, env=env) + except OSError: + return None + try: + out, _ = await asyncio.wait_for(proc.communicate(data), TIMEOUT) + except TimeoutError: + proc.kill() + await proc.wait() + return None + return (out or b"") if proc.returncode == 0 else None + + +async def read_text() -> str | None: + """The system clipboard's text, or None when no tool could read it.""" + for cmd in _tools(read=True): + out = await _run(cmd, None) + if out is not None: + return out.decode("utf-8", "replace").lstrip("") + return None + + +async def write_text(text: str) -> bool: + for cmd in _tools(read=False): + if await _run(cmd, _encode(text)) is not None: + return True + return False |