aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-cli/src')
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/__main__.py3
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/commands/help.py27
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/manpage.py4
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/tui/app.py23
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/tui/chat.py106
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/tui/clipboard.py76
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