diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 64f3293e71..0535ec36b4 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -1282,7 +1282,9 @@ function App(props: { pair?: DialogPairCredentials }) { evt.preventDefault() evt.stopPropagation() }} - onMouseUp={copyOnSelectEnabled() ? () => Selection.copy(renderer, toast, clipboard) : undefined} + onMouseUp={ + copyOnSelectEnabled() ? (event) => Selection.copyOnSelectRelease(event, renderer, toast, clipboard) : undefined + } > (config.data.terminal?.copy ?? (process.platform === "win32" ? "manual" : "select")) === "select" - function copySelection() { - const text = renderer.getSelection()?.getSelectedText() - if (!text) return false - void clipboard.write(text).then( - () => toast.show({ message: "Copied to clipboard", variant: "info" }), - (error) => toast.error(error), - ) - renderer.clearSelection() - return true - } - return ( {props.children} @@ -231,11 +221,11 @@ export function DialogProvider(props: ParentProps) { if (copyOnSelectEnabled()) return if (evt.button !== MouseButton.RIGHT) return - if (!copySelection()) return + if (!copy(renderer, toast, clipboard)) return evt.preventDefault() evt.stopPropagation() }} - onMouseUp={copyOnSelectEnabled() ? copySelection : undefined} + onMouseUp={copyOnSelectEnabled() ? (event) => copyOnSelectRelease(event, renderer, toast, clipboard) : undefined} > value.clear()} size={value.size} centered={value.centered}> diff --git a/packages/tui/src/util/selection.ts b/packages/tui/src/util/selection.ts index 243d9b88fe..608977c2d0 100644 --- a/packages/tui/src/util/selection.ts +++ b/packages/tui/src/util/selection.ts @@ -23,6 +23,16 @@ type SelectionKeyEvent = { stopPropagation: () => void } +export function copyOnSelectRelease( + event: { isDragging?: boolean }, + renderer: Renderer, + toast: Toast, + clipboard: ClipboardService, +): boolean { + if (!event.isDragging) return false + return copy(renderer, toast, clipboard) +} + export function copy(renderer: Renderer, toast: Toast, clipboard: ClipboardService): boolean { const selection = renderer.getSelection() if (!selection) return false @@ -39,7 +49,8 @@ export function copy(renderer: Renderer, toast: Toast, clipboard: ClipboardServi .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) .catch(toast.error) - renderer.clearSelection() + // Keep the highlight. clearSelection() also resets OpenTUI's click + // counter, so clearing here would turn a triple-click into a new single-click. return true } diff --git a/packages/tui/test/util/selection-copy-on-select.test.tsx b/packages/tui/test/util/selection-copy-on-select.test.tsx new file mode 100644 index 0000000000..713f93f660 --- /dev/null +++ b/packages/tui/test/util/selection-copy-on-select.test.tsx @@ -0,0 +1,59 @@ +/** @jsxImportSource @opentui/solid */ +import { expect, test } from "bun:test" +import { testRender, useRenderer } from "@opentui/solid" +import { useClipboard } from "../../src/context/clipboard" +import { copyOnSelectRelease } from "../../src/util/selection" +import { TestTuiContexts } from "../fixture/tui-environment" + +function CopyOnSelectText() { + const renderer = useRenderer() + const clipboard = useClipboard() + const toast = { + show: () => {}, + error: () => {}, + } + return ( + copyOnSelectRelease(event, renderer, toast, clipboard)}> + alpha beta gamma + + ) +} + +test("copy-on-select keeps a word highlight so a third click can select the line", async () => { + const writes: string[] = [] + const app = await testRender( + () => ( + + + + ), + { width: 20, height: 2 }, + ) + + try { + app.renderer.start() + await app.waitForFrame((frame) => frame.includes("beta")) + + await app.mockMouse.click(6, 0) + expect(app.renderer.getSelection()?.getSelectedText() ?? "").toBe("") + + await app.mockMouse.click(6, 0) + expect(app.renderer.getSelection()?.getSelectedText()).toBe("beta") + expect(writes).toEqual(["beta"]) + + await app.mockMouse.click(6, 0) + expect(app.renderer.getSelection()?.getSelectedText()).toBe("alpha beta gamma") + expect(writes).toEqual(["beta", "alpha beta gamma"]) + } finally { + app.renderer.destroy() + } +}) diff --git a/packages/tui/test/util/selection.test.ts b/packages/tui/test/util/selection.test.ts new file mode 100644 index 0000000000..c03e460126 --- /dev/null +++ b/packages/tui/test/util/selection.test.ts @@ -0,0 +1,53 @@ +import { expect, test } from "bun:test" +import { copy, copyOnSelectRelease } from "../../src/util/selection" + +function renderer() { + return { + getSelection: () => ({ + getSelectedText: () => "beta", + selectedRenderables: [], + }), + clearSelection: () => {}, + } +} + +test("copy writes selected text without clearing the highlight", () => { + let cleared = false + const copied = copy( + { + getSelection: () => ({ + getSelectedText: () => "beta", + selectedRenderables: [], + }), + clearSelection: () => { + cleared = true + }, + }, + { show: () => {}, error: () => {} }, + { + async read() { + return undefined + }, + async write() {}, + }, + ) + expect(copied).toBe(true) + expect(cleared).toBe(false) +}) + +test("copy-on-select ignores a later non-drag release", () => { + const writes: string[] = [] + const clipboard = { + async read() { + return undefined + }, + async write(value: string) { + writes.push(value) + }, + } + const toast = { show: () => {}, error: () => {} } + expect(copyOnSelectRelease({}, renderer(), toast, clipboard)).toBe(false) + expect(copyOnSelectRelease({ isDragging: false }, renderer(), toast, clipboard)).toBe(false) + expect(copyOnSelectRelease({ isDragging: true }, renderer(), toast, clipboard)).toBe(true) + expect(writes).toEqual(["beta"]) +})