Compare commits

...
Author SHA1 Message Date
Kit Langton aa6297f7ae fix(tui): preserve multi-click text selection 2026-08-26 10:57:09 -04:00
3 changed files with 72 additions and 3 deletions
+6 -1
View File
@@ -1,3 +1,4 @@
import type { SelectionBehavior } from "@opentui/core"
import type { ClipboardService } from "../context/clipboard"
type Toast = {
@@ -15,6 +16,7 @@ type Renderer = {
getSelectedText: () => string
selectedRenderables: FocusableSelectionTarget[]
isStart: boolean
behavior: SelectionBehavior
} | null
clearSelection: () => void
currentFocusedRenderable?: FocusableSelectionTarget | null
@@ -34,13 +36,16 @@ export function copyOnSelectRelease(
clipboard: ClipboardService,
): boolean {
if (!event.isDragging) return false
// Clearing a click-only or empty release also resets OpenTUI's multi-click counter.
const selection = renderer.getSelection()
if (!selection || (selection.isStart && selection.behavior === "cell") || !selection.getSelectedText()) 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
if (selection.isStart) {
if (selection.isStart && selection.behavior === "cell") {
renderer.clearSelection()
return false
}
@@ -1,5 +1,7 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { BoxRenderable, EmbeddedTerminalRenderable } from "@opentui/core"
import { createTestRenderer, ManualClock } from "@opentui/core/testing"
import { testRender, useRenderer } from "@opentui/solid"
import { useClipboard } from "../../src/context/clipboard"
import { copyOnSelectRelease } from "../../src/util/selection"
@@ -45,6 +47,7 @@ test("copy-on-select keeps a word highlight so a third click can select the line
await app.mockMouse.click(6, 0)
expect(app.renderer.getSelection()?.getSelectedText() ?? "").toBe("")
expect(writes).toEqual([])
await app.mockMouse.click(6, 0)
expect(app.renderer.getSelection()?.getSelectedText()).toBe("beta")
@@ -57,3 +60,43 @@ test("copy-on-select keeps a word highlight so a third click can select the line
app.renderer.destroy()
}
})
test("terminal copy-on-select ignores clicks and empty drags but preserves a copied drag", async () => {
const writes: string[] = []
const app = await createTestRenderer({ width: 20, height: 2, clock: new ManualClock() })
const clipboard = {
async read() {
return undefined
},
async write(text: string) {
writes.push(text)
},
}
const toast = { show: () => {}, error: () => {} }
const box = new BoxRenderable(app.renderer, {
onMouseUp: (event) => copyOnSelectRelease(event, app.renderer, toast, clipboard),
})
const terminal = new EmbeddedTerminalRenderable(app.renderer, { cols: 20, rows: 2 })
box.add(terminal)
app.renderer.root.add(box)
try {
terminal.write(Buffer.from("alpha beta gamma"))
await app.renderOnce()
await app.mockMouse.pressDown(6, 0)
expect(app.renderer.getSelection()?.getSelectedText()).toBe("b")
await app.mockMouse.release(6, 0)
expect(writes).toEqual([])
await app.mockMouse.drag(0, 0, 4, 0)
expect(app.renderer.getSelection()?.getSelectedText()).toBe("alpha")
expect(writes).toEqual(["alpha"])
await app.mockMouse.drag(16, 0, 19, 0)
expect(app.renderer.getSelection()?.getSelectedText()).toBe("")
expect(writes).toEqual(["alpha"])
} finally {
app.renderer.destroy()
}
})
+23 -2
View File
@@ -1,4 +1,5 @@
import { expect, test } from "bun:test"
import type { SelectionBehavior } from "@opentui/core"
import type { ClipboardService } from "../../src/context/clipboard"
import { Selection, copy, copyOnSelectRelease } from "../../src/util/selection"
@@ -8,12 +9,13 @@ function renderer() {
getSelectedText: () => "beta",
selectedRenderables: [],
isStart: false,
behavior: "cell" as const,
}),
clearSelection: () => {},
}
}
function setup(text: string, isStart: boolean) {
function setup(text: string, isStart: boolean, behavior: SelectionBehavior = "cell") {
const writes: string[] = []
let clears = 0
const clipboard: ClipboardService = {
@@ -23,7 +25,7 @@ function setup(text: string, isStart: boolean) {
},
}
const renderer = {
getSelection: () => ({ getSelectedText: () => text, selectedRenderables: [], isStart }),
getSelection: () => ({ getSelectedText: () => text, selectedRenderables: [], isStart, behavior }),
clearSelection: () => {
clears++
},
@@ -41,6 +43,7 @@ test("copy writes selected text without clearing the highlight", () => {
getSelectedText: () => "beta",
selectedRenderables: [],
isStart: false,
behavior: "cell",
}),
clearSelection: () => {
cleared = true
@@ -89,6 +92,24 @@ test("clears an empty dragged selection without copying", () => {
expect(value.writes).toEqual([])
})
test.each(["word", "line"] as const)("copies a non-dragged %s selection without clearing", (behavior) => {
const value = setup("selected", true, behavior)
expect(Selection.copy(value.renderer, value.toast, value.clipboard)).toBeTrue()
expect(value.clears()).toBe(0)
expect(value.writes).toEqual(["selected"])
})
test.each([
{ text: "x", isStart: true },
{ text: "", isStart: true },
{ text: "", isStart: false },
])("copy-on-select ignores $text / isStart=$isStart without resetting clicks", (input) => {
const value = setup(input.text, input.isStart)
expect(Selection.copyOnSelectRelease({ isDragging: true }, value.renderer, value.toast, value.clipboard)).toBeFalse()
expect(value.clears()).toBe(0)
expect(value.writes).toEqual([])
})
test("copies a non-empty dragged selection without clearing its highlight", async () => {
const value = setup("selected", false)
expect(Selection.copy(value.renderer, value.toast, value.clipboard)).toBeTrue()