Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ Require one attribute-free `<ui>` 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` |
Expand Down
6 changes: 6 additions & 0 deletions docs/controls.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

`<log on-selection-ended="copy_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.
14 changes: 13 additions & 1 deletion docs/project-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. `<log on-selection-ended="copy_selection"/>` 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.
8 changes: 8 additions & 0 deletions docs/superpowers/plans/2026-09-30-log-selection-clipboard.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Log selection completion and clipboard design

## Contract

`<log on-selection-ended="copy_selection"/>` 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.
2 changes: 1 addition & 1 deletion examples/showcase/app.ui
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
<list id="agents" item-label="{name}" on-selected="select_agent" />
<label id="agent-detail">Select an agent</label>
<horizontal><button id="append-log" on-pressed="append_log">Append log</button></horizontal>
<log id="transcript" max-lines="100" wrap="true" />
<log id="transcript" max-lines="100" wrap="true" on-selection-ended="copy_selection" />
</vertical>
</content-switcher>
</pane>
Expand Down
6 changes: 6 additions & 0 deletions examples/showcase/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
112 changes: 112 additions & 0 deletions tests/test_clipboard.py
Original file line number Diff line number Diff line change
@@ -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()
105 changes: 105 additions & 0 deletions tests/test_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -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('''<ui>
<log id="t" on-selection-ended="selected" />
</ui>'''), 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('''<ui>
<log id="t" style="height: 3;" on-selection-ended="selected" />
<label id="outside">Outside</label>
</ui>'''), 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('''<ui>
<log id="t" auto-scroll="false" on-selection-ended="selected"/>
</ui>'''), 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('''<ui>
<label id="top">TOP</label>
<log id="t" style="height: 3;" on-selection-ended="selected"/>
<label id="bottom">BOTTOM</label>
</ui>'''), 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('''<ui>
<log id="t" style="height: 3;" on-selection-ended="selected"/>
<button id="b">Button</button>
</ui>'''), 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):
Expand Down
Loading
Loading