tui: preserve selection after copy (#44496)

Copy-on-select reset the click counter, so a third click could not select the full line. Keep the selection after copying to preserve multi-click input.
This commit is contained in:
Simon Klee 2026-08-23 20:40:19 +02:00 committed by GitHub
parent b22c182406
commit 0d24ebdbbe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 130 additions and 15 deletions

View file

@ -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
}
>
<box
flexGrow={1}

View file

@ -7,6 +7,7 @@ import { createStore } from "solid-js/store"
import { useToast } from "./toast"
import { useClipboard } from "../context/clipboard"
import { useConfig } from "../config"
import { copy, copyOnSelectRelease } from "../util/selection"
export type DialogSize = "medium" | "large" | "xlarge"
@ -210,17 +211,6 @@ export function DialogProvider(props: ParentProps) {
const copyOnSelectEnabled = () =>
(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 (
<ctx.Provider value={value}>
{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}
>
<Show when={value.stack.length}>
<Dialog onClose={() => value.clear()} size={value.size} centered={value.centered}>

View file

@ -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
}

View file

@ -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 (
<box onMouseUp={(event) => copyOnSelectRelease(event, renderer, toast, clipboard)}>
<text>alpha beta gamma</text>
</box>
)
}
test("copy-on-select keeps a word highlight so a third click can select the line", async () => {
const writes: string[] = []
const app = await testRender(
() => (
<TestTuiContexts
clipboard={{
async read() {
return undefined
},
async write(text) {
writes.push(text)
},
}}
>
<CopyOnSelectText />
</TestTuiContexts>
),
{ 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()
}
})

View file

@ -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"])
})