summaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-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
-rw-r--r--packages/meshbay-cli/tests/test_cli_actions.py13
-rw-r--r--packages/meshbay-cli/tests/test_clipboard.py58
-rw-r--r--packages/meshbay-cli/tests/test_interactive.py79
9 files changed, 378 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
diff --git a/packages/meshbay-cli/tests/test_cli_actions.py b/packages/meshbay-cli/tests/test_cli_actions.py
index 5443d9b..240ac67 100644
--- a/packages/meshbay-cli/tests/test_cli_actions.py
+++ b/packages/meshbay-cli/tests/test_cli_actions.py
@@ -203,6 +203,19 @@ def test_a_usage_error_is_exit_1_not_the_sign_in_code(capsys):
+def test_help_prints_what_help_prints(capsys):
+ assert cli.main(["--help"]) == 0
+ whole = capsys.readouterr().out
+ assert main("help") == 0
+ assert capsys.readouterr().out == whole
+ assert cli.main(["chat", "--help"]) == 0
+ chat = capsys.readouterr().out
+ assert main("help", "chat") == 0
+ assert capsys.readouterr().out == chat
+ assert main("help", "no-such-action") == 1
+ assert "No action 'no-such-action'" in capsys.readouterr().err
+
+
def test_groups_lists_each_group_with_its_state(hub, capsys):
hub.groups.append({"id": "g2", "name": "Club/Photos", "owner_username": "bob-test",
"node_online": False, "visibility": "public", "is_admin": False,
diff --git a/packages/meshbay-cli/tests/test_clipboard.py b/packages/meshbay-cli/tests/test_clipboard.py
new file mode 100644
index 0000000..6296317
--- /dev/null
+++ b/packages/meshbay-cli/tests/test_clipboard.py
@@ -0,0 +1,58 @@
+"""
+The chat's clipboard: the platform's tools, run for real, and nothing when none
+works. Fake `wl-copy`/`wl-paste` stand in for wl-clipboard, sharing one file.
+"""
+
+import shutil
+import sys
+import time
+
+import pytest
+from meshbay_cli.tui import clipboard
+
+pytestmark = pytest.mark.skipif(sys.platform in ("win32", "darwin"),
+ reason="the Wayland and X11 tools")
+# Found before a test leaves only the fake tools on PATH.
+CAT, SLEEP = shutil.which("cat"), shutil.which("sleep")
+
+
+@pytest.fixture
+def wayland(tmp_path, monkeypatch):
+ held = tmp_path / "held"
+ bin_dir = tmp_path / "bin"
+ bin_dir.mkdir()
+ for name, body in (("wl-copy", f'{CAT} > "{held}"'), ("wl-paste", f'{CAT} "{held}"')):
+ tool = bin_dir / name
+ tool.write_text(f"#!/bin/sh\n{body}\n")
+ tool.chmod(0o755)
+ monkeypatch.setenv("PATH", str(bin_dir))
+ monkeypatch.setenv("WAYLAND_DISPLAY", "wayland-0")
+ monkeypatch.delenv("DISPLAY", raising=False)
+ return held
+
+
+async def test_what_is_copied_is_what_is_pasted(wayland):
+ assert await clipboard.write_text("héllo — 你好\nsecond") is True
+ assert wayland.read_text(encoding="utf-8") == "héllo — 你好\nsecond"
+ assert await clipboard.read_text() == "héllo — 你好\nsecond"
+
+
+async def test_no_tool_is_none_not_an_error(tmp_path, monkeypatch):
+ monkeypatch.setenv("PATH", str(tmp_path))
+ monkeypatch.setenv("WAYLAND_DISPLAY", "wayland-0")
+ monkeypatch.setenv("DISPLAY", ":0")
+ assert await clipboard.read_text() is None
+ assert await clipboard.write_text("x") is False
+
+
+async def test_a_failing_tool_is_skipped(wayland):
+ (wayland.parent / "bin" / "wl-paste").write_text("#!/bin/sh\nexit 1\n")
+ assert await clipboard.read_text() is None
+
+
+async def test_a_hanging_tool_is_given_up(wayland, monkeypatch):
+ (wayland.parent / "bin" / "wl-paste").write_text(f"#!/bin/sh\nexec {SLEEP} 30\n")
+ monkeypatch.setattr(clipboard, "TIMEOUT", 0.3)
+ started = time.monotonic()
+ assert await clipboard.read_text() is None
+ assert 0.3 <= time.monotonic() - started < 5
diff --git a/packages/meshbay-cli/tests/test_interactive.py b/packages/meshbay-cli/tests/test_interactive.py
index fa7e96c..5b9d9b0 100644
--- a/packages/meshbay-cli/tests/test_interactive.py
+++ b/packages/meshbay-cli/tests/test_interactive.py
@@ -208,6 +208,17 @@ async def test_delete_removes_what_is_yours_and_asks_first(world):
assert any("select them on the right" in n.message for n in app._notifications)
+async def _right_click(pilot, widget, offset=(0, 0)) -> None:
+ """Through App.on_event, as the terminal driver sends it; the pilot's own
+ clicks skip it."""
+ from textual import events
+ from textual.pilot import _get_mouse_message_arguments
+ args = _get_mouse_message_arguments(widget, offset, button=3)
+ for cls in (events.MouseDown, events.MouseUp):
+ pilot.app.post_message(cls(**args))
+ await pilot.pause()
+
+
def _log_text(screen) -> str:
from textual.widgets import RichLog
return "\n".join("".join(seg.text for seg in strip)
@@ -285,3 +296,71 @@ async def test_the_interactive_chat_joins_talks_leaves_and_comes_back(world, mon
await _until(pilot, lambda: isinstance(app.screen, ChatGroupsScreen))
await pilot.press("q")
assert app.return_code in (0, None)
+
+
+async def test_a_right_click_copies_from_the_chat_and_pastes_into_the_line(world, monkeypatch):
+ """The selection, else the line under the pointer; pasted as one line."""
+ from meshbay_cli.tui import clipboard
+ from meshbay_cli.tui.chat import ChatGroupsScreen, ChatInput, ChatLog, ChatRoomScreen
+ hub, node, _ = world
+ written, held = [], {"text": None}
+
+ async def write_text(text):
+ written.append(text)
+ return True
+
+ async def read_text():
+ return held["text"]
+ monkeypatch.setattr(clipboard, "write_text", write_text)
+ monkeypatch.setattr(clipboard, "read_text", read_text)
+ node.say("earlier, from bob")
+ assert await asyncio.to_thread(cli.main, ["--hub", "https://hub.test", "-u", USERNAME,
+ "--password", PASSPHRASE, "login"]) == 0
+
+ app = MeshBayApp(context.Context(cli.build_parser().parse_args(
+ ["--hub", "https://hub.test"])), ChatGroupsScreen)
+ async with app.run_test(size=(120, 30)) as pilot:
+ await _until(pilot, lambda: isinstance(app.screen, ChatGroupsScreen))
+ await pilot.pause(0.3)
+ await pilot.press("enter")
+ await _until(pilot, lambda: isinstance(app.screen, ChatRoomScreen))
+ room = app.screen
+ log = room.query_one(ChatLog)
+ await _until(pilot, lambda: "<bob-test> earlier, from bob" in _log_text(room))
+ y = next(i for i, line in enumerate(log.lines) if "earlier, from bob" in line.text)
+ line = log.lines[y].text
+
+ # Nothing selected: the line under the pointer.
+ await _right_click(pilot, log, (4, y))
+ await _until(pilot, lambda: written)
+ assert written[-1] == line.rstrip() and app.clipboard == written[-1]
+ assert "<bob-test> earlier, from bob" in written[-1]
+
+ # Selected with the mouse: that text, and only it.
+ x = line.index("bob-test")
+ await pilot.mouse_down(log, offset=(1 + x, y))
+ await pilot.hover(log, offset=(x + len("bob-test"), y))
+ await pilot.mouse_up(log, offset=(x + len("bob-test"), y))
+ await pilot.pause(0.1)
+ assert log.text_selection is not None
+ await _right_click(pilot, log, (1, y + 3))
+ await _until(pilot, lambda: len(written) == 2)
+ assert written[-1] == "bob-test"
+
+ # Pasted into the line written in, at the cursor, lines side by side.
+ field = room.query_one(ChatInput)
+ field.value = "say: "
+ field.cursor_position = len(field.value)
+ held["text"] = "first line\nsecond line\n"
+ await _right_click(pilot, field, (2, 0))
+ await _until(pilot, lambda: field.value != "say: ")
+ assert field.value == "say: first line second line"
+
+ # No tool to read the system clipboard: what was copied here.
+ held["text"] = None
+ field.value = ""
+ await _right_click(pilot, field, (2, 0))
+ await _until(pilot, lambda: field.value)
+ assert field.value == "bob-test"
+ assert app.screen is room # a right-click left nothing behind
+ await pilot.press("escape")