From 374961b564489228860b03152a91d7d81f7727a2 Mon Sep 17 00:00:00 2001 From: thunderballfists <130205658+thunderballfists@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:45:33 -0500 Subject: [PATCH] Add log selection actions and asynchronous clipboard copying --- CHANGELOG.md | 1 + README.md | 2 +- docs/controls.md | 6 + docs/project-runtime.md | 14 ++- .../2026-09-30-log-selection-clipboard.md | 8 ++ ...26-09-30-log-selection-clipboard-design.md | 15 +++ examples/showcase/app.ui | 2 +- examples/showcase/controller.py | 6 + tests/test_clipboard.py | 112 ++++++++++++++++++ tests/test_log.py | 105 ++++++++++++++++ tests/test_project_app.py | 33 ++++++ tests/test_showcase.py | 14 ++- textui/__init__.py | 2 + textui/clipboard.py | 71 +++++++++++ textui/controllers.py | 8 ++ textui/project_app.py | 4 +- textui/textui.py | 4 +- textui/widgets/transcript.py | 67 ++++++++++- 18 files changed, 467 insertions(+), 7 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-30-log-selection-clipboard.md create mode 100644 docs/superpowers/specs/2026-09-30-log-selection-clipboard-design.md create mode 100644 tests/test_clipboard.py create mode 100644 textui/clipboard.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a92097..19d46a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ All notable changes to TextUI are documented here. ### Added +- Log `on-selection-ended` actions receive completed pointer selections without polling or redraw-triggered callbacks. Copying remains opt-in through asynchronous `window.copy(text)` or the exported `copy_to_clipboard(app, text)` helper, with native clipboard tools and OSC 52 transport. - `ProjectApp(source, context=...)` exposes a host-supplied object through read-only `window.context` from controller script loading onward. - Linked controllers may define synchronous or asynchronous `on_resize(width, height)` to respond after terminal resize layout refreshes, without polling. - Linear-gradient backgrounds for `header` and `status-bar` surfaces, declared with an ID selector in TCSS and rendered beneath native bar controls. Live class changes respect the current background winner and opaque slot overrides. diff --git a/README.md b/README.md index c0c8ac4..bac3f11 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ Require one attribute-free `` root. Names are lowercase kebab-case; XML is p | `range` | None | Integer `min`, `max`, positive `step`, optional aligned `value`, boolean `show-value` | `changed` | | `rule` | None | `orientation`, `line-style` | None | | `header`, `status-bar` | Optional `left`, `center`, and `right` slots | None | None | -| `log` | None | Optional `max-lines`, boolean `auto-scroll`, `wrap`, and `highlight` | None | +| `log` | None | Optional `max-lines`, boolean `auto-scroll`, `wrap`, and `highlight` | `on-selection-ended` | | `list` | None | Required `item-label` with direct mapping-key fields | `selected` | | `modal` | Widgets; document root only | Required `id`, boolean `dismissable` | None | | `data-table` | `column` children, then `row` children | `cursor-type`: row, cell, column, none; optional `row-key`; boolean `striped`, `column-borders`, `resizable` | `row-selected`, `cell-selected` | diff --git a/docs/controls.md b/docs/controls.md index b592351..3473132 100644 --- a/docs/controls.md +++ b/docs/controls.md @@ -85,3 +85,9 @@ For API records that replace a complete table, declare a `row-key` field and col Set `striped="true"` for alternating row backgrounds; style native `datatable--odd-row` and `datatable--even-row` parts in TCSS to adjust the colors. Set `column-borders="true"` to draw vertical separators between headings and cells; style `table--column-border` to adjust their color. Set `resizable="true"` to allow dragging a heading's right edge; separators mark every handle, including the last column, unless you explicitly set `column-borders="false"`. Hovering a heading edge highlights its separator; style `table--column-resize-hover` to change that cue. With table focus, `Ctrl+Left` and `Ctrl+Right` shrink or grow the current column. Hosts that mount a native Textual `Footer` also display these bindings when resizing is enabled; the convenience Apps do not add a Footer automatically. A dragged automatic column becomes a fixed width; widths never shrink below three cells. Divider interaction does not sort the table. Runtime rows sort by their original values, so numeric fields remain numeric; seeded and manually added literal cells sort by their displayed values. Call `set_rows(records)` only after mounting. Every record must be a mapping with a non-empty string in the declared `row-key` field and every declared column key. TextUI validates the full batch before changing rows, renders `None` as an empty literal cell, and retains extra fields without adding columns. `get_record(row_key)` returns the read-only source record for the current batch and raises `KeyError` when absent. A refresh retains the active sort and cursor when its row key remains; otherwise the native cursor returns to the first cell. The [data example](../examples/data/app.ui) shows this runtime pattern beside seeded table and tree updates. + +## Log selection + +`` reports a nonempty mouse selection after release, even outside the log. Actions receive `context.event.log`, `.text`, and native `.selection` coordinates. Appending or redrawing content does not repeat the event. Copying is opt-in: a linked async action can call `await window.copy(context.event.text)`. See the [clipboard runtime guide](project-runtime.md#selection-and-clipboard) for native backend and terminal transport behavior. + +`TextUI` and `ProjectApp` forward this event automatically. A normal Textual App imports `TranscriptLog` from `textui.widgets.transcript`, handles `@on(TranscriptLog.SelectionEnded)`, and forwards the message to `await bound_document.dispatch(event)`, as with other document actions. diff --git a/docs/project-runtime.md b/docs/project-runtime.md index 94333bd..3c57486 100644 --- a/docs/project-runtime.md +++ b/docs/project-runtime.md @@ -204,6 +204,18 @@ def select_agent(context): The [runtime list example](../examples/list/app.ui) uses this pattern for a master-detail rail. -Transcript mouse selections survive appends that keep selected rows in place. Eviction, clearing, and streamed-entry rewrites clear the affected log selection so copying cannot return unrelated replacement text. Selection-ended events and framework clipboard APIs remain future work. +Transcript mouse selections survive appends that keep selected rows in place. Eviction, clearing, and streamed-entry rewrites clear the affected log selection so copying cannot return unrelated replacement text. + +## Selection and clipboard + +Copying on selection is opt-in. `` emits a completed nonempty pointer selection, including release outside the log. Its event exposes `.log`, `.text`, and native `.selection` coordinates; content updates do not emit selection completion. + +```python +@action +async def copy_selection(context): + backend = await window.copy(context.event.text) +``` + +`window.copy(text)` is available while ready. Normal Textual hosts can instead import `copy_to_clipboard` from `textui` and call `await copy_to_clipboard(app, text)`. The helper asynchronously tries `pbcopy` on macOS, `clip` on Windows, or Wayland/X11 tools on Linux, with Unicode stdin and a two-second timeout per candidate. It also sends Textual's OSC 52 transport for the terminal's clipboard. The return value names the successful native tool, or `osc52` if only the terminal transport was sent; it does not confirm terminal acceptance. Clipboard text never becomes a shell command. Textual's own copy shortcuts remain unchanged. The showcase demonstrates this opt-in action. Document tab layout defaults apply in normal Textual Apps through `Document.bind()` as well as `TextUI` and `ProjectApp`. Host-owned native tab widgets are unaffected; author TCSS can override document tab and pane heights. diff --git a/docs/superpowers/plans/2026-09-30-log-selection-clipboard.md b/docs/superpowers/plans/2026-09-30-log-selection-clipboard.md new file mode 100644 index 0000000..252e001 --- /dev/null +++ b/docs/superpowers/plans/2026-09-30-log-selection-clipboard.md @@ -0,0 +1,8 @@ +# Log selection and clipboard implementation plan + +1. Add failing behavioral tests for `on-selection-ended`, including pointer release outside the log and content updates after a selection. +2. Add `TranscriptLog.SelectionEnded`, subscribe to the screen's native `TextSelected` event, register its markup event, and explicitly forward it in convenience Apps. +3. Add failing clipboard tests with mocked platform discovery and asynchronous subprocesses. Implement fixed executable candidates, Unicode stdin, timeout/cancellation cleanup, native backend reporting, and OSC 52 supplementation. +4. Expose the helper from the package and through `window.copy`; test lifecycle behavior. +5. Opt the showcase into copy-on-selection through a controller action. Update runtime/controls docs, README, and changelog. +6. Run focused tests, full headless suite, visual checks, Pyflakes, lockfile/build checks, and independent review. Create a PR for #78, inspect feedback, and resolve verified findings before merging. diff --git a/docs/superpowers/specs/2026-09-30-log-selection-clipboard-design.md b/docs/superpowers/specs/2026-09-30-log-selection-clipboard-design.md new file mode 100644 index 0000000..b21b26d --- /dev/null +++ b/docs/superpowers/specs/2026-09-30-log-selection-clipboard-design.md @@ -0,0 +1,15 @@ +# Log selection completion and clipboard design + +## Contract + +`` exposes a native drag-completion event without polling. `TranscriptLog.SelectionEnded` carries the log, selected text, and native `Selection` coordinates. It is emitted for nonempty log selections in response to Textual's public `TextSelected` event, including drags released outside the log. Native `MouseDown` marks new gestures; retained selection objects are ignored on scrollbar releases. Ordinary redraws and streamed text updates do not emit completion events. Copying remains an explicit action. + +`await window.copy(text)` delegates to exported `await copy_to_clipboard(app, text)`. It tries a platform clipboard executable asynchronously, then also sends Textual's OSC 52 transport. The return value names the successful native backend (`pbcopy`, `clip`, `wl-copy`, `xclip`, or `xsel`) or `osc52` when only the terminal transport was sent. This is transport reporting, not confirmation that the terminal accepted OSC 52. Window copying requires the ready phase. + +## Implementation boundaries + +Use fixed argument vectors without a shell; clipboard text goes only to stdin. Native subprocesses have a two-second timeout per candidate and are killed and reaped on timeout or cancellation. Failed or unavailable tools fall through. macOS receives UTF-8 with an explicit UTF-8 locale; Windows `clip` receives UTF-16LE with a BOM; Linux tools receive UTF-8, preferring Wayland when its display is present. No dependencies are added. Native Textual copy shortcuts are unchanged. + +## Verification + +Pilot tests cover completion timing, exact selected text, repeat gestures, no notification on content updates, and release outside the log. Mock subprocess tests cover candidate order, Unicode payloads, failure, timeout, cancellation cleanup, and OSC 52 fallback without touching the developer's clipboard. A project-runtime test covers the window facade and ready-phase guard. The showcase opts in through an action and reports the backend. diff --git a/examples/showcase/app.ui b/examples/showcase/app.ui index 3e3995a..a2df0b6 100644 --- a/examples/showcase/app.ui +++ b/examples/showcase/app.ui @@ -64,7 +64,7 @@ - + diff --git a/examples/showcase/controller.py b/examples/showcase/controller.py index 792bef9..84153f0 100644 --- a/examples/showcase/controller.py +++ b/examples/showcase/controller.py @@ -17,6 +17,12 @@ async def on_ready() -> None: await window.document.get_by_id("agents").set_items(AGENTS) +@action +async def copy_selection(context) -> None: + backend = await window.copy(context.event.text) + _feedback("Selection sent via OSC 52" if backend == "osc52" else f"Selection copied with {backend}") + + @every(1) def update_clock() -> None: window.document.get_by_id("clock").update(datetime.now().strftime("%H:%M:%S")) diff --git a/tests/test_clipboard.py b/tests/test_clipboard.py new file mode 100644 index 0000000..1cac34e --- /dev/null +++ b/tests/test_clipboard.py @@ -0,0 +1,112 @@ +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, Mock + +import pytest + +from textui import copy_to_clipboard +from textui import clipboard as clipboard_module + + +def backend(monkeypatch, platform, available, processes): + monkeypatch.setattr(clipboard_module.sys, "platform", platform) + monkeypatch.setattr(clipboard_module.shutil, "which", lambda name: f"/tools/{name}" if name in available else None) + spawn = AsyncMock(side_effect=processes) + monkeypatch.setattr(clipboard_module.asyncio, "create_subprocess_exec", spawn) + app = SimpleNamespace(copy_to_clipboard=Mock()) + return app, spawn + + +def process(code=0): + return SimpleNamespace(returncode=code, communicate=AsyncMock(return_value=(b"", b"")), kill=Mock(), wait=AsyncMock()) + + +@pytest.mark.asyncio +async def test_mac_native_copy_uses_unicode_stdin_and_also_sends_terminal_transport(monkeypatch): + monkeypatch.setenv("LC_ALL", "C") + child = process() + app, spawn = backend(monkeypatch, "darwin", {"pbcopy"}, [child]) + assert await copy_to_clipboard(app, "café 🐍") == "pbcopy" + assert spawn.call_args.args == ("/tools/pbcopy",) + assert spawn.call_args.kwargs["env"]["LC_CTYPE"] == "UTF-8" + assert "LC_ALL" not in spawn.call_args.kwargs["env"] + child.communicate.assert_awaited_once_with("café 🐍".encode("utf-8")) + app.copy_to_clipboard.assert_called_once_with("café 🐍") + + +@pytest.mark.asyncio +async def test_windows_clip_receives_utf16_with_bom(monkeypatch): + child = process() + app, spawn = backend(monkeypatch, "win32", {"clip"}, [child]) + assert await copy_to_clipboard(app, "café 🐍") == "clip" + assert spawn.call_args.args == ("/tools/clip",) + child.communicate.assert_awaited_once_with(b"\xff\xfe" + "café 🐍".encode("utf-16-le")) + + +@pytest.mark.asyncio +async def test_wayland_failure_falls_through_to_x11(monkeypatch): + monkeypatch.setenv("WAYLAND_DISPLAY", "wayland-0") + first, second = process(1), process() + app, spawn = backend(monkeypatch, "linux", {"wl-copy", "xclip", "xsel"}, [first, second]) + assert await copy_to_clipboard(app, "hello") == "xclip" + assert [call.args for call in spawn.call_args_list] == [("/tools/wl-copy",), ("/tools/xclip", "-selection", "clipboard")] + + +@pytest.mark.asyncio +async def test_x11_prefers_xclip_without_wayland_display(monkeypatch): + monkeypatch.delenv("WAYLAND_DISPLAY", raising=False) + app, spawn = backend(monkeypatch, "linux", {"wl-copy", "xclip"}, [process()]) + assert await copy_to_clipboard(app, "hello") == "xclip" + assert spawn.call_args.args[0] == "/tools/xclip" + + +@pytest.mark.asyncio +async def test_missing_or_failed_native_backend_reports_osc52(monkeypatch): + app, spawn = backend(monkeypatch, "darwin", {"pbcopy"}, [OSError("unavailable")]) + assert await copy_to_clipboard(app, "hello") == "osc52" + app.copy_to_clipboard.assert_called_once_with("hello") + app, spawn = backend(monkeypatch, "darwin", set(), []) + assert await copy_to_clipboard(app, "") == "osc52" + spawn.assert_not_called() + app.copy_to_clipboard.assert_called_once_with("") + + +@pytest.mark.asyncio +async def test_timeout_kills_and_reaps_before_fallback(monkeypatch): + child = process() + async def communicate(payload): + await asyncio.Event().wait() + child.communicate.side_effect = communicate + monkeypatch.setattr(clipboard_module, "_COPY_TIMEOUT", 0.01) + app, _ = backend(monkeypatch, "darwin", {"pbcopy"}, [child]) + assert await copy_to_clipboard(app, "hello") == "osc52" + child.kill.assert_called_once() + child.wait.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_copy_keeps_event_loop_running_and_cancellation_reaps_child(monkeypatch): + started = asyncio.Event() + async def communicate(payload): + started.set() + await asyncio.Event().wait() + child = process() + child.communicate.side_effect = communicate + app, _ = backend(monkeypatch, "darwin", {"pbcopy"}, [child]) + task = asyncio.create_task(copy_to_clipboard(app, "hello")) + await asyncio.wait_for(started.wait(), 1) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + child.kill.assert_called_once() + child.wait.assert_awaited_once() + app.copy_to_clipboard.assert_not_called() + + +@pytest.mark.asyncio +async def test_copy_requires_text(monkeypatch): + app, spawn = backend(monkeypatch, "darwin", {"pbcopy"}, []) + with pytest.raises(TypeError, match="text"): + await copy_to_clipboard(app, None) + spawn.assert_not_called() + app.copy_to_clipboard.assert_not_called() diff --git a/tests/test_log.py b/tests/test_log.py index a5a64bc..f291a98 100644 --- a/tests/test_log.py +++ b/tests/test_log.py @@ -261,6 +261,111 @@ async def _select_first_word(pilot, log): assert log.get_selection(log.text_selection)[0] == "FIRST" +@pytest.mark.asyncio +async def test_log_selection_ended_reports_each_gesture_without_content_update_loops(): + seen = [] + app = TextUI(DocumentLoader().from_string(''' + + '''), actions={"selected": lambda context: seen.append((context.widget, context.event.text, context.event.selection))}) + async with app.run_test(size=(40, 8)) as pilot: + log = app.document.get_by_id("t") + log.append("FIRST word") + await pilot.pause() + await pilot.mouse_down(log, offset=(0, 0)) + await pilot.hover(log, offset=(4, 0)) + assert seen == [] + await pilot.mouse_up(log, offset=(4, 0)) + await pilot.pause() + assert len(seen) == 1 + assert seen[0] == (log, "FIRST", log.text_selection) + + log.append("SECOND line") + await pilot.pause() + assert len(seen) == 1 + await _select_first_word(pilot, log) + assert len(seen) == 2 + await pilot.click(log, offset=(12, 0)) + await pilot.pause() + assert len(seen) == 2 + + +@pytest.mark.asyncio +async def test_log_selection_ended_handles_release_outside_the_log(): + seen = [] + app = TextUI(DocumentLoader().from_string(''' + + + '''), actions={"selected": lambda context: seen.append(context.event.text)}) + async with app.run_test(size=(40, 8)) as pilot: + log = app.document.get_by_id("t") + log.append("FIRST") + await pilot.pause() + await pilot.mouse_down(log, offset=(0, 0)) + await pilot.hover("#outside", offset=(4, 0)) + await pilot.mouse_up("#outside", offset=(4, 0)) + await pilot.pause() + assert len(seen) == 1 + assert seen[0].strip() == "FIRST" + + +@pytest.mark.asyncio +async def test_log_scrollbar_navigation_does_not_complete_a_retained_selection(): + seen = [] + app = TextUI(DocumentLoader().from_string(''' + + '''), actions={"selected": lambda context: seen.append(context.event.text)}) + async with app.run_test(size=(40, 8)) as pilot: + log = app.document.get_by_id("t") + log.append("FIRST word") + for number in range(20): + log.append(f"Row {number}") + await pilot.pause() + await _select_first_word(pilot, log) + assert seen == ["FIRST"] + assert await pilot.click(log.vertical_scrollbar, offset=(0, 0)) + await pilot.pause() + assert seen == ["FIRST"] + + +@pytest.mark.asyncio +async def test_repeated_cross_widget_drag_reports_a_fully_selected_log(): + seen = [] + app = TextUI(DocumentLoader().from_string(''' + + + + '''), actions={"selected": lambda context: seen.append(context.event.text)}) + async with app.run_test(size=(40, 8)) as pilot: + log = app.document.get_by_id("t") + log.append("FIRST word") + await pilot.pause() + for _ in range(2): + await pilot.mouse_down("#top", offset=(0, 0)) + await pilot.hover("#bottom", offset=(4, 0)) + await pilot.mouse_up("#bottom", offset=(4, 0)) + await pilot.pause() + assert seen == ["FIRST word", "FIRST word"] + + +@pytest.mark.asyncio +async def test_dragging_a_nonselectable_control_does_not_complete_old_log_selection(): + seen = [] + app = TextUI(DocumentLoader().from_string(''' + + + '''), actions={"selected": lambda context: seen.append(context.event.text)}) + async with app.run_test(size=(40, 8)) as pilot: + log = app.document.get_by_id("t") + log.append("FIRST word") + await pilot.pause() + await _select_first_word(pilot, log) + await pilot.mouse_down("#b", offset=(2, 1)) + await pilot.hover("#b", offset=(8, 1)) + await pilot.mouse_up("#b", offset=(8, 1)) + await pilot.pause() + assert seen == ["FIRST"] + + @pytest.mark.asyncio @pytest.mark.parametrize("mutation", ["evict", "clear", "rewrite"]) async def test_log_discards_mouse_selection_when_selected_rows_change(mutation): diff --git a/tests/test_project_app.py b/tests/test_project_app.py index a529e00..37a7b15 100644 --- a/tests/test_project_app.py +++ b/tests/test_project_app.py @@ -19,6 +19,39 @@ def project(tmp_path: Path, markup: str, script: str) -> ProjectSource: return ProjectSource.discover(tmp_path / "app.ui") +@pytest.mark.asyncio +async def test_window_copy_requires_ready_and_selection_action_uses_clipboard(tmp_path, monkeypatch): + from unittest.mock import AsyncMock + import textui.clipboard as clipboard_module + + copy = AsyncMock(return_value="pbcopy") + monkeypatch.setattr(clipboard_module, "copy_to_clipboard", copy) + source = project(tmp_path, '', ''' +from textui import action + +def on_ready(): + window.document.get_by_id("transcript").append("FIRST word") + +@action +async def copy_selection(context): + window.app.backend = await window.copy(context.event.text) +''') + app = ProjectApp(source) + with pytest.raises(DocumentStateError, match="ready"): + await app.window.copy("not ready") + async with app.run_test() as pilot: + log = app.document.get_by_id("transcript") + await pilot.mouse_down(log, offset=(0, 0)) + await pilot.hover(log, offset=(4, 0)) + await pilot.mouse_up(log, offset=(4, 0)) + await pilot.pause() + await app.workers.wait_for_complete() + copy.assert_awaited_once_with(app, "FIRST") + assert app.backend == "pbcopy" + with pytest.raises(DocumentStateError, match="ready"): + await app.window.copy("closed") + + @pytest.mark.asyncio async def test_project_setup_registers_component_before_lowering_and_ready_can_look_up_widget(tmp_path: Path): source = project(tmp_path, '', ''' diff --git a/tests/test_showcase.py b/tests/test_showcase.py index fed01f8..8402f09 100644 --- a/tests/test_showcase.py +++ b/tests/test_showcase.py @@ -6,12 +6,16 @@ @pytest.mark.asyncio -async def test_showcase_mounts_components_runtime_data_and_modal(): +async def test_showcase_mounts_components_runtime_data_and_modal(monkeypatch): + from unittest.mock import AsyncMock + import textui.clipboard as clipboard_module from textui import ProjectApp, ProjectSource from textual.widgets import Button, DataTable entry = Path(__file__).parents[1] / "examples" / "showcase" / "app.ui" app = ProjectApp(ProjectSource.discover(entry)) + copy = AsyncMock(return_value="osc52") + monkeypatch.setattr(clipboard_module, "copy_to_clipboard", copy) async with app.run_test(size=(120, 50)) as pilot: assert app.document.get_by_id("showcase").id == "showcase" header = app.document.get_by_id("top-bar") @@ -80,6 +84,14 @@ async def test_showcase_mounts_components_runtime_data_and_modal(): app.document.get_by_id("append-log").press() await pilot.pause() assert str(app.document.get_by_id("feedback").render()) == "Log appended" + log = app.document.get_by_id("transcript") + await pilot.mouse_down(log, offset=(0, 0)) + await pilot.hover(log, offset=(4, 0)) + await pilot.mouse_up(log, offset=(4, 0)) + await pilot.pause() + await app.workers.wait_for_complete() + copy.assert_awaited_once_with(app, "A nat") + assert str(app.document.get_by_id("feedback").render()) == "Selection sent via OSC 52" assert await pilot.click("#open-modal") await pilot.pause() assert app.screen.id == "help" diff --git a/textui/__init__.py b/textui/__init__.py index 73416bf..a40cfdc 100644 --- a/textui/__init__.py +++ b/textui/__init__.py @@ -1,6 +1,7 @@ """The strict, typed TextUI document definition API.""" from .actions import ActionCallback, ActionContext +from .clipboard import copy_to_clipboard from .document import BoundDocument, Document, ModalResult from .textui import TextUI from .controllers import Command, action, command @@ -68,6 +69,7 @@ "action", "command", "every", + "copy_to_clipboard", "ProjectSource", "ProjectApp", ] diff --git a/textui/clipboard.py b/textui/clipboard.py new file mode 100644 index 0000000..7d6a932 --- /dev/null +++ b/textui/clipboard.py @@ -0,0 +1,71 @@ +"""Asynchronous native clipboard tools with Textual's terminal transport.""" +from __future__ import annotations + +import asyncio +import os +import shutil +import sys + +from textual.app import App + +_COPY_TIMEOUT = 2.0 + + +def _candidates() -> tuple[tuple[str, ...], ...]: + if sys.platform == "darwin": + return (("pbcopy",),) + if sys.platform == "win32": + return (("clip",),) + x11 = (("xclip", "-selection", "clipboard"), ("xsel", "--clipboard", "--input")) + return (("wl-copy",), *x11) if os.environ.get("WAYLAND_DISPLAY") else x11 + + +async def _reap(process: asyncio.subprocess.Process) -> None: + try: + process.kill() + except ProcessLookupError: + pass + await process.wait() + + +async def copy_to_clipboard(app: App, text: str) -> str: + """Copy text using a native tool and OSC 52; return the native tool or `osc52`. + + OSC 52 delivery depends on the terminal. A return value of `osc52` reports + that transport was sent, not that the system clipboard accepted it. + """ + if not isinstance(text, str): + raise TypeError("clipboard text must be a string") + backend = "osc52" + for name, *arguments in _candidates(): + executable = shutil.which(name) + if executable is None: + continue + payload = text.encode("utf-8") + environment = os.environ.copy() + if name == "pbcopy": + environment.pop("LC_ALL", None) + environment["LC_CTYPE"] = "UTF-8" + elif name == "clip": + payload = b"\xff\xfe" + text.encode("utf-16-le") + process = None + try: + process = await asyncio.create_subprocess_exec( + executable, *arguments, stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL, + env=environment, + ) + await asyncio.wait_for(process.communicate(payload), timeout=_COPY_TIMEOUT) + except asyncio.CancelledError: + if process is not None: + await _reap(process) + raise + except (OSError, TimeoutError): + if process is not None: + await _reap(process) + continue + if process.returncode == 0: + backend = name + break + app.copy_to_clipboard(text) + return backend diff --git a/textui/controllers.py b/textui/controllers.py index 1206f64..60f33e4 100644 --- a/textui/controllers.py +++ b/textui/controllers.py @@ -156,6 +156,14 @@ def call_ui(self, callback: Callable[..., Any], *args: Any, **kwargs: Any) -> An raise DocumentStateError("window.call_ui is available only while ready") return self.app.call_from_thread(callback, *args, **kwargs) + async def copy(self, text: str) -> str: + """Copy text while ready; return the native backend or `osc52`.""" + if self.phase != "ready" or not self.app.is_running: + raise DocumentStateError("window.copy is available only while ready") + from .clipboard import copy_to_clipboard + + return await copy_to_clipboard(self.app, text) + class ControllerSet: def __init__(self, window: ProjectWindow) -> None: diff --git a/textui/project_app.py b/textui/project_app.py index e4c6782..678e70e 100644 --- a/textui/project_app.py +++ b/textui/project_app.py @@ -11,6 +11,7 @@ from .widgets.navigation import Nav from .widgets.runtime_list import RuntimeList from .widgets.range_control import RangeControl +from .widgets.transcript import TranscriptLog from .accelerators import activate_tab, install_tab_accelerators, tab_accelerators from .actions import ActionCallback @@ -181,6 +182,7 @@ async def _close_once(self) -> None: @on(Tree.NodeSelected) @on(RuntimeList.ItemSelected) @on(RangeControl.Changed) - async def forward_document_message(self, event: Button.Pressed | Input.Changed | Input.Submitted | Checkbox.Changed | Split.Resized | Split.Toggled | Nav.Selected | Select.Changed | Switch.Changed | TextArea.Changed | TabbedContent.TabActivated | RadioButton.Changed | RadioSet.Changed | Collapsible.Collapsed | Collapsible.Expanded | DataTable.RowSelected | DataTable.CellSelected | Tree.NodeSelected | RuntimeList.ItemSelected | RangeControl.Changed) -> None: + @on(TranscriptLog.SelectionEnded) + async def forward_document_message(self, event: Button.Pressed | Input.Changed | Input.Submitted | Checkbox.Changed | Split.Resized | Split.Toggled | Nav.Selected | Select.Changed | Switch.Changed | TextArea.Changed | TabbedContent.TabActivated | RadioButton.Changed | RadioSet.Changed | Collapsible.Collapsed | Collapsible.Expanded | DataTable.RowSelected | DataTable.CellSelected | Tree.NodeSelected | RuntimeList.ItemSelected | RangeControl.Changed | TranscriptLog.SelectionEnded) -> None: if self.document is not None: await self.document.dispatch(event) diff --git a/textui/textui.py b/textui/textui.py index e7050f2..17a82ab 100644 --- a/textui/textui.py +++ b/textui/textui.py @@ -11,6 +11,7 @@ from .widgets.navigation import Nav from .widgets.runtime_list import RuntimeList from .widgets.range_control import RangeControl +from .widgets.transcript import TranscriptLog from .accelerators import activate_tab, install_tab_accelerators from .actions import ActionCallback @@ -51,5 +52,6 @@ def action_textui_activate_tab(self, pane_id: str) -> None: @on(Tree.NodeSelected) @on(RuntimeList.ItemSelected) @on(RangeControl.Changed) - async def forward_document_message(self, event: Button.Pressed | Input.Changed | Input.Submitted | Checkbox.Changed | Split.Resized | Split.Toggled | Nav.Selected | Select.Changed | Switch.Changed | TextArea.Changed | TabbedContent.TabActivated | RadioButton.Changed | RadioSet.Changed | Collapsible.Collapsed | Collapsible.Expanded | DataTable.RowSelected | DataTable.CellSelected | Tree.NodeSelected | RuntimeList.ItemSelected | RangeControl.Changed) -> None: + @on(TranscriptLog.SelectionEnded) + async def forward_document_message(self, event: Button.Pressed | Input.Changed | Input.Submitted | Checkbox.Changed | Split.Resized | Split.Toggled | Nav.Selected | Select.Changed | Switch.Changed | TextArea.Changed | TabbedContent.TabActivated | RadioButton.Changed | RadioSet.Changed | Collapsible.Collapsed | Collapsible.Expanded | DataTable.RowSelected | DataTable.CellSelected | Tree.NodeSelected | RuntimeList.ItemSelected | RangeControl.Changed | TranscriptLog.SelectionEnded) -> None: await self.document.dispatch(event) diff --git a/textui/widgets/transcript.py b/textui/widgets/transcript.py index 52ee607..7be55ad 100644 --- a/textui/widgets/transcript.py +++ b/textui/widgets/transcript.py @@ -7,11 +7,15 @@ from rich.style import Style from rich.text import Text from textual.geometry import Size +from textual.events import MouseDown, TextSelected +from textual.message import Message from textual.reactive import var +from textual.screen import Screen +from textual.selection import Selection from textual.strip import Strip from textual.widgets import RichLog -from ..registry import AttributeSpec, BuildContext, ComponentRegistry, ComponentSpec, boolean, integer +from ..registry import AttributeSpec, BuildContext, ComponentRegistry, ComponentSpec, EventSpec, boolean, integer # Characters of already-streamed text to re-measure when checking that a delta's # width adds cleanly onto the active line. A grapheme cluster (emoji ZWJ @@ -45,6 +49,19 @@ class TranscriptLog(RichLog): is_following: var[bool] = var(True) + class SelectionEnded(Message): + """A completed pointer selection in this transcript.""" + + def __init__(self, log: TranscriptLog, text: str, selection: Selection) -> None: + super().__init__() + self.log = log + self.text = text + self.selection = selection + + @property + def control(self) -> TranscriptLog: + return self.log + def __init__( self, *, @@ -71,6 +88,53 @@ def __init__( # rescanning everything accumulated so far. self._inline_width = 0 self._inline_printable = True + self._selection_screen: Screen | None = None + self._last_completed_selection: Selection | None = None + + def on_mount(self) -> None: + self._selection_screen = self.screen + self.screen.message_signal.subscribe(self, self._screen_message, immediate=True) + + def on_unmount(self) -> None: + if self._selection_screen is not None: + self._selection_screen.message_signal.unsubscribe(self) + self._selection_screen = None + self._last_completed_selection = None + + def _screen_message(self, message: Message) -> None: + if isinstance(message, MouseDown): + # A new gesture may reuse Textual's shared SELECT_ALL object. + # Match native selection eligibility; dragging a button or scrollbar + # can retain the old selection without starting a new one. + screen = self._selection_screen + if screen is None or self.app.mouse_captured: + return + widget, _ = screen.get_widget_and_offset_at(message.screen_x, message.screen_y) + if ( + widget is not None and widget.allow_select + and screen.allow_select and self.app.ALLOW_SELECT + and not widget.has_class("-textual-system") + ): + self._last_completed_selection = None + elif isinstance(message, TextSelected): + # Construct our message in the log's pump so it bubbles past the + # screen. Filtering here avoids scheduling unrelated screen events. + self.call_next(self._selection_completed) + + def _selection_completed(self) -> None: + if not self.is_mounted: + return + selection = self.text_selection + if selection is self._last_completed_selection: + return + # Scrollbar releases retain the previous object. A new MouseDown + # resets this guard, including for shared SELECT_ALL selections. + self._last_completed_selection = selection + if selection is None: + return + selected = self.get_selection(selection) + if selected is not None and selected[0]: + self.post_message(self.SelectionEnded(self, selected[0], selection)) def watch_scroll_y(self, old_value: float, new_value: float) -> None: super().watch_scroll_y(old_value, new_value) @@ -279,4 +343,5 @@ def register_transcript(registry: ComponentRegistry) -> None: "markup": AttributeSpec(boolean, default=False), "auto-scroll": AttributeSpec(boolean, default=True), }, + events={"selection-ended": EventSpec(TranscriptLog.SelectionEnded, lambda event: event.log)}, ))