From 949e27d6929c07748d6342ea10d11c7bbd896fd3 Mon Sep 17 00:00:00 2001 From: timwuhaotian Date: Sat, 14 Mar 2026 16:12:34 +0800 Subject: [PATCH] feat: implement selection functionality in TUI components - Added selection utility functions to manage text selection in the TUI. - Integrated selection checks in various components to prevent actions when text is selected. - Updated dialog components to handle selection state during user interactions. - Enhanced mouse event handling to support double and triple click selections. - Added tests for selection behavior, including single, double, and triple click scenarios. --- packages/opencode/src/cli/cmd/tui/app.tsx | 41 +- .../cli/cmd/tui/component/dialog-provider.tsx | 12 +- .../cli/cmd/tui/component/dialog-status.tsx | 11 +- .../cmd/tui/component/prompt/autocomplete.tsx | 9 +- .../cli/cmd/tui/component/prompt/index.tsx | 2 + .../src/cli/cmd/tui/routes/session/header.tsx | 19 +- .../src/cli/cmd/tui/routes/session/index.tsx | 2 + .../cli/cmd/tui/routes/session/permission.tsx | 2 + .../cli/cmd/tui/routes/session/question.tsx | 24 +- .../cli/cmd/tui/routes/session/sidebar.tsx | 31 +- .../src/cli/cmd/tui/ui/dialog-alert.tsx | 13 +- .../src/cli/cmd/tui/ui/dialog-confirm.tsx | 13 +- .../cli/cmd/tui/ui/dialog-export-options.tsx | 32 +- .../src/cli/cmd/tui/ui/dialog-help.tsx | 22 +- .../src/cli/cmd/tui/ui/dialog-prompt.tsx | 12 +- .../src/cli/cmd/tui/ui/dialog-select.tsx | 13 +- .../opencode/src/cli/cmd/tui/ui/dialog.tsx | 10 +- packages/opencode/src/cli/cmd/tui/ui/link.tsx | 4 + .../src/cli/cmd/tui/util/selection.ts | 350 +++++++++++++++++- .../opencode/test/cli/tui/selection.test.ts | 246 ++++++++++++ 20 files changed, 817 insertions(+), 51 deletions(-) create mode 100644 packages/opencode/test/cli/tui/selection.test.ts diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 8bb17ff13362..8577a70c9c31 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -22,7 +22,7 @@ import { DialogAgent } from "@tui/component/dialog-agent" import { DialogSessionList } from "@tui/component/dialog-session-list" import { DialogWorkspaceList } from "@tui/component/dialog-workspace-list" import { KeybindProvider } from "@tui/context/keybind" -import { ThemeProvider, useTheme } from "@tui/context/theme" +import { ThemeProvider, selectedForeground, useTheme } from "@tui/context/theme" import { Home } from "@tui/routes/home" import { Session } from "@tui/routes/session" import { PromptHistoryProvider } from "./component/prompt/history" @@ -214,10 +214,10 @@ function App() { const sync = useSync() const exit = useExit() const promptRef = usePromptRef() + const click = Selection.click() useKeyboard((evt) => { - if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return - if (!renderer.getSelection()) return + if (!Selection.active(renderer)) return // Windows Terminal-like behavior: // - Ctrl+C copies and dismisses selection @@ -235,12 +235,14 @@ function App() { } if (evt.name === "escape") { + Selection.dismiss(renderer) renderer.clearSelection() evt.preventDefault() evt.stopPropagation() return } + Selection.dismiss(renderer) renderer.clearSelection() }) @@ -256,6 +258,13 @@ function App() { } const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true)) + createEffect(() => { + Selection.configure(renderer, { + bg: theme.primary, + fg: selectedForeground(theme, theme.primary), + }) + }) + createEffect(() => { console.log(JSON.stringify(route.data)) }) @@ -744,6 +753,7 @@ function App() { height={dimensions().height} backgroundColor={theme.background} onMouseDown={(evt) => { + if (Selection.press(click, renderer, evt)) return if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return if (evt.button !== MouseButton.RIGHT) return @@ -751,7 +761,11 @@ function App() { evt.preventDefault() evt.stopPropagation() }} - onMouseUp={Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? undefined : () => Selection.copy(renderer, toast)} + onMouseUp={(evt) => { + if (Selection.release(click, renderer, toast, evt)) return + if (Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return + Selection.copy(renderer, toast) + }} > @@ -813,6 +827,7 @@ function ErrorComponent(props: { issueURL.searchParams.set("opencode-version", Installation.VERSION) const copyIssueURL = () => { + if (Selection.active(renderer)) return Clipboard.copy(issueURL.toString()).then(() => { setCopied(true) }) @@ -833,10 +848,24 @@ function ErrorComponent(props: { A fatal error occurred! - + { + if (Selection.active(renderer)) return + props.reset() + }} + backgroundColor={colors.primary} + padding={1} + > Reset TUI - + { + if (Selection.active(renderer)) return + handleExit() + }} + backgroundColor={colors.primary} + padding={1} + > Exit diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index f77e4727aaa1..2ffcbb4ee032 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -10,9 +10,10 @@ import { useTheme } from "../context/theme" import { TextAttributes } from "@opentui/core" import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2" import { DialogModel } from "./dialog-model" -import { useKeyboard } from "@opentui/solid" +import { useKeyboard, useRenderer } from "@opentui/solid" import { Clipboard } from "@tui/util/clipboard" import { useToast } from "../ui/toast" +import { Selection } from "../util/selection" const PROVIDER_PRIORITY: Record = { opencode: 0, @@ -111,6 +112,7 @@ function AutoMethod(props: AutoMethodProps) { const dialog = useDialog() const sync = useSync() const toast = useToast() + const renderer = useRenderer() useKeyboard((evt) => { if (evt.name === "c" && !evt.ctrl && !evt.meta) { @@ -141,7 +143,13 @@ function AutoMethod(props: AutoMethodProps) { {props.title} - dialog.clear()}> + { + if (Selection.active(renderer)) return + dialog.clear() + }} + > esc diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx index 3b6b5ef21827..80748e9f2de3 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx @@ -4,6 +4,8 @@ import { useTheme } from "../context/theme" import { useDialog } from "@tui/ui/dialog" import { useSync } from "@tui/context/sync" import { For, Match, Switch, Show, createMemo } from "solid-js" +import { useRenderer } from "@opentui/solid" +import { Selection } from "../util/selection" export type DialogStatusProps = {} @@ -11,6 +13,7 @@ export function DialogStatus() { const sync = useSync() const { theme } = useTheme() const dialog = useDialog() + const renderer = useRenderer() const enabledFormatters = createMemo(() => sync.data.formatter.filter((f) => f.enabled)) @@ -45,7 +48,13 @@ export function DialogStatus() { Status - dialog.clear()}> + { + if (Selection.active(renderer)) return + dialog.clear() + }} + > esc diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 3240afab326a..3e48a74259c5 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -9,7 +9,8 @@ import { useSync } from "@tui/context/sync" import { useTheme, selectedForeground } from "@tui/context/theme" import { SplitBorder } from "@tui/component/border" import { useCommandDialog } from "@tui/component/dialog-command" -import { useTerminalDimensions } from "@opentui/solid" +import { useRenderer, useTerminalDimensions } from "@opentui/solid" +import { Selection } from "@tui/util/selection" import { Locale } from "@/util/locale" import type { PromptInfo } from "./history" import { useFrecency } from "./frecency" @@ -80,6 +81,7 @@ export function Autocomplete(props: { const command = useCommandDialog() const { theme } = useTheme() const dimensions = useTerminalDimensions() + const renderer = useRenderer() const frecency = useFrecency() const [store, setStore] = createStore({ @@ -648,7 +650,10 @@ export function Autocomplete(props: { setStore("input", "mouse") moveTo(index) }} - onMouseUp={() => select()} + onMouseUp={() => { + if (Selection.active(renderer)) return + select() + }} > {option().display} diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index c85426cc2471..158d7faaeede 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -23,6 +23,7 @@ import { useExit } from "../../context/exit" import { Clipboard } from "../../util/clipboard" import type { FilePart } from "@opencode-ai/sdk/v2" import { TuiEvent } from "../../event" +import { Selection } from "../../util/selection" import { iife } from "@/util/iife" import { Locale } from "@/util/locale" import { formatDuration } from "@/util/format" @@ -1105,6 +1106,7 @@ export function Prompt(props: PromptProps) { }) }) const handleMessageClick = () => { + if (Selection.active(renderer)) return const r = retry() if (!r) return if (isTruncated()) { diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx index f64dbe533a74..736924821449 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx @@ -6,9 +6,10 @@ import { useTheme } from "@tui/context/theme" import { SplitBorder } from "@tui/component/border" import type { AssistantMessage, Session } from "@opencode-ai/sdk/v2" import { useCommandDialog } from "@tui/component/dialog-command" +import { Selection } from "@tui/util/selection" import { useKeybind } from "../../context/keybind" import { Flag } from "@/flag/flag" -import { useTerminalDimensions } from "@opentui/solid" +import { useRenderer, useTerminalDimensions } from "@opentui/solid" const Title = (props: { session: Accessor }) => { const { theme } = useTheme() @@ -82,6 +83,7 @@ export function Header() { const { theme } = useTheme() const keybind = useKeybind() const command = useCommandDialog() + const renderer = useRenderer() const [hover, setHover] = createSignal<"parent" | "prev" | "next" | null>(null) const dimensions = useTerminalDimensions() const narrow = createMemo(() => dimensions().width < 80) @@ -122,7 +124,10 @@ export function Header() { setHover("parent")} onMouseOut={() => setHover(null)} - onMouseUp={() => command.trigger("session.parent")} + onMouseUp={() => { + if (Selection.active(renderer)) return + command.trigger("session.parent") + }} backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel} > @@ -132,7 +137,10 @@ export function Header() { setHover("prev")} onMouseOut={() => setHover(null)} - onMouseUp={() => command.trigger("session.child.previous")} + onMouseUp={() => { + if (Selection.active(renderer)) return + command.trigger("session.child.previous") + }} backgroundColor={hover() === "prev" ? theme.backgroundElement : theme.backgroundPanel} > @@ -142,7 +150,10 @@ export function Header() { setHover("next")} onMouseOut={() => setHover(null)} - onMouseUp={() => command.trigger("session.child.next")} + onMouseUp={() => { + if (Selection.active(renderer)) return + command.trigger("session.child.next") + }} backgroundColor={hover() === "next" ? theme.backgroundElement : theme.backgroundPanel} > diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 7456742cdf36..e118f7065589 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -81,6 +81,7 @@ import { DialogExportOptions } from "../../ui/dialog-export-options" import { formatTranscript } from "../../util/transcript" import { UI } from "@/cli/ui.ts" import { useTuiConfig } from "../../context/tui-config" +import { Selection } from "../../util/selection" addDefaultParsers(parsers.parsers) @@ -1079,6 +1080,7 @@ export function Session() { const dialog = useDialog() const handleUnrevert = async () => { + if (Selection.active(renderer)) return const confirmed = await DialogConfirm.show( dialog, "Confirm Redo", diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index a50cd96fc843..833bfb7eb53e 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -16,6 +16,7 @@ import { Locale } from "@/util/locale" import { Global } from "@/global" import { useDialog } from "../../ui/dialog" import { useTuiConfig } from "../../context/tui-config" +import { Selection } from "../../util/selection" type PermissionStage = "permission" | "always" | "reject" @@ -649,6 +650,7 @@ function Prompt>(props: { backgroundColor={option === store.selected ? theme.warning : theme.backgroundMenu} onMouseOver={() => setStore("selected", option)} onMouseUp={() => { + if (Selection.active(renderer)) return setStore("selected", option) props.onSelect(option) }} diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx index 1565a3008185..3d5c01f6e027 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx @@ -1,6 +1,6 @@ import { createStore } from "solid-js/store" import { createMemo, createSignal, For, Show } from "solid-js" -import { useKeyboard } from "@opentui/solid" +import { useKeyboard, useRenderer } from "@opentui/solid" import type { TextareaRenderable } from "@opentui/core" import { useKeybind } from "../../context/keybind" import { selectedForeground, tint, useTheme } from "../../context/theme" @@ -9,12 +9,14 @@ import { useSDK } from "../../context/sdk" import { SplitBorder } from "../../component/border" import { useTextareaKeybindings } from "../../component/textarea-keybindings" import { useDialog } from "../../ui/dialog" +import { Selection } from "../../util/selection" export function QuestionPrompt(props: { request: QuestionRequest }) { const sdk = useSDK() const { theme } = useTheme() const keybind = useKeybind() const bindings = useTextareaKeybindings() + const renderer = useRenderer() const questions = createMemo(() => props.request.questions) const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true) @@ -279,7 +281,10 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { } onMouseOver={() => setTabHover(index())} onMouseOut={() => setTabHover(null)} - onMouseUp={() => selectTab(index())} + onMouseUp={() => { + if (Selection.active(renderer)) return + selectTab(index()) + }} > setTabHover("confirm")} onMouseOut={() => setTabHover(null)} - onMouseUp={() => selectTab(questions().length)} + onMouseUp={() => { + if (Selection.active(renderer)) return + selectTab(questions().length) + }} > Confirm @@ -328,7 +336,10 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { moveTo(i())} onMouseDown={() => moveTo(i())} - onMouseUp={() => selectOption()} + onMouseUp={() => { + if (Selection.active(renderer)) return + selectOption() + }} > @@ -357,7 +368,10 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { moveTo(options().length)} onMouseDown={() => moveTo(options().length)} - onMouseUp={() => selectOption()} + onMouseUp={() => { + if (Selection.active(renderer)) return + selectOption() + }} > diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index 42ac5fbe080a..8e3c8313f454 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -11,10 +11,13 @@ import { useKeybind } from "../../context/keybind" import { useDirectory } from "../../context/directory" import { useKV } from "../../context/kv" import { TodoItem } from "../../component/todo-item" +import { useRenderer } from "@opentui/solid" +import { Selection } from "../../util/selection" export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const sync = useSync() const { theme } = useTheme() + const renderer = useRenderer() const session = createMemo(() => sync.session.get(props.sessionID)!) const diff = createMemo(() => sync.data.session_diff[props.sessionID] ?? []) const todo = createMemo(() => sync.data.todo[props.sessionID] ?? []) @@ -111,7 +114,10 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { mcpEntries().length > 2 && setExpanded("mcp", !expanded.mcp)} + onMouseUp={() => { + if (Selection.active(renderer)) return + if (mcpEntries().length > 2) setExpanded("mcp", !expanded.mcp) + }} > 2}> {expanded.mcp ? "▼" : "▶"} @@ -171,7 +177,10 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { sync.data.lsp.length > 2 && setExpanded("lsp", !expanded.lsp)} + onMouseUp={() => { + if (Selection.active(renderer)) return + if (sync.data.lsp.length > 2) setExpanded("lsp", !expanded.lsp) + }} > 2}> {expanded.lsp ? "▼" : "▶"} @@ -215,7 +224,10 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { todo().length > 2 && setExpanded("todo", !expanded.todo)} + onMouseUp={() => { + if (Selection.active(renderer)) return + if (todo().length > 2) setExpanded("todo", !expanded.todo) + }} > 2}> {expanded.todo ? "▼" : "▶"} @@ -234,7 +246,10 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { diff().length > 2 && setExpanded("diff", !expanded.diff)} + onMouseUp={() => { + if (Selection.active(renderer)) return + if (diff().length > 2) setExpanded("diff", !expanded.diff) + }} > 2}> {expanded.diff ? "▼" : "▶"} @@ -288,7 +303,13 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { Getting started - kv.set("dismissed_getting_started", true)}> + { + if (Selection.active(renderer)) return + kv.set("dismissed_getting_started", true) + }} + > ✕ diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx index 642c73b48561..630fe4611c96 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx @@ -1,7 +1,8 @@ import { TextAttributes } from "@opentui/core" import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" -import { useKeyboard } from "@opentui/solid" +import { useKeyboard, useRenderer } from "@opentui/solid" +import { Selection } from "../util/selection" export type DialogAlertProps = { title: string @@ -12,6 +13,7 @@ export type DialogAlertProps = { export function DialogAlert(props: DialogAlertProps) { const dialog = useDialog() const { theme } = useTheme() + const renderer = useRenderer() useKeyboard((evt) => { if (evt.name === "return") { @@ -25,7 +27,13 @@ export function DialogAlert(props: DialogAlertProps) { {props.title} - dialog.clear()}> + { + if (Selection.active(renderer)) return + dialog.clear() + }} + > esc @@ -38,6 +46,7 @@ export function DialogAlert(props: DialogAlertProps) { paddingRight={3} backgroundColor={theme.primary} onMouseUp={() => { + if (Selection.active(renderer)) return props.onConfirm?.() dialog.clear() }} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx index b86bd432515e..41b268af6c00 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx @@ -3,7 +3,8 @@ import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" import { createStore } from "solid-js/store" import { For } from "solid-js" -import { useKeyboard } from "@opentui/solid" +import { useKeyboard, useRenderer } from "@opentui/solid" +import { Selection } from "../util/selection" import { Locale } from "@/util/locale" export type DialogConfirmProps = { @@ -16,6 +17,7 @@ export type DialogConfirmProps = { export function DialogConfirm(props: DialogConfirmProps) { const dialog = useDialog() const { theme } = useTheme() + const renderer = useRenderer() const [store, setStore] = createStore({ active: "confirm" as "confirm" | "cancel", }) @@ -37,7 +39,13 @@ export function DialogConfirm(props: DialogConfirmProps) { {props.title} - dialog.clear()}> + { + if (Selection.active(renderer)) return + dialog.clear() + }} + > esc @@ -52,6 +60,7 @@ export function DialogConfirm(props: DialogConfirmProps) { paddingRight={1} backgroundColor={key === store.active ? theme.primary : undefined} onMouseUp={(evt) => { + if (Selection.active(renderer)) return if (key === "confirm") props.onConfirm?.() if (key === "cancel") props.onCancel?.() dialog.clear() diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx index d29fe05ee90c..2716e9644c7f 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx @@ -3,7 +3,8 @@ import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" import { createStore } from "solid-js/store" import { onMount, Show, type JSX } from "solid-js" -import { useKeyboard } from "@opentui/solid" +import { useKeyboard, useRenderer } from "@opentui/solid" +import { Selection } from "../util/selection" export type DialogExportOptionsProps = { defaultFilename: string @@ -24,6 +25,7 @@ export type DialogExportOptionsProps = { export function DialogExportOptions(props: DialogExportOptionsProps) { const dialog = useDialog() const { theme } = useTheme() + const renderer = useRenderer() let textarea: TextareaRenderable const [store, setStore] = createStore({ thinking: props.defaultThinking, @@ -80,7 +82,13 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { Export Options - dialog.clear()}> + { + if (Selection.active(renderer)) return + dialog.clear() + }} + > esc @@ -114,7 +122,10 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { gap={2} paddingLeft={1} backgroundColor={store.active === "thinking" ? theme.backgroundElement : undefined} - onMouseUp={() => setStore("active", "thinking")} + onMouseUp={() => { + if (Selection.active(renderer)) return + setStore("active", "thinking") + }} > {store.thinking ? "[x]" : "[ ]"} @@ -126,7 +137,10 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { gap={2} paddingLeft={1} backgroundColor={store.active === "toolDetails" ? theme.backgroundElement : undefined} - onMouseUp={() => setStore("active", "toolDetails")} + onMouseUp={() => { + if (Selection.active(renderer)) return + setStore("active", "toolDetails") + }} > {store.toolDetails ? "[x]" : "[ ]"} @@ -138,7 +152,10 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { gap={2} paddingLeft={1} backgroundColor={store.active === "assistantMetadata" ? theme.backgroundElement : undefined} - onMouseUp={() => setStore("active", "assistantMetadata")} + onMouseUp={() => { + if (Selection.active(renderer)) return + setStore("active", "assistantMetadata") + }} > {store.assistantMetadata ? "[x]" : "[ ]"} @@ -150,7 +167,10 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { gap={2} paddingLeft={1} backgroundColor={store.active === "openWithoutSaving" ? theme.backgroundElement : undefined} - onMouseUp={() => setStore("active", "openWithoutSaving")} + onMouseUp={() => { + if (Selection.active(renderer)) return + setStore("active", "openWithoutSaving") + }} > {store.openWithoutSaving ? "[x]" : "[ ]"} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx index 4e4527930345..318a39c48e51 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx @@ -1,13 +1,15 @@ import { TextAttributes } from "@opentui/core" import { useTheme } from "@tui/context/theme" import { useDialog } from "./dialog" -import { useKeyboard } from "@opentui/solid" +import { useKeyboard, useRenderer } from "@opentui/solid" import { useKeybind } from "@tui/context/keybind" +import { Selection } from "@tui/util/selection" export function DialogHelp() { const dialog = useDialog() const { theme } = useTheme() const keybind = useKeybind() + const renderer = useRenderer() useKeyboard((evt) => { if (evt.name === "return" || evt.name === "escape") { @@ -21,7 +23,13 @@ export function DialogHelp() { Help - dialog.clear()}> + { + if (Selection.active(renderer)) return + dialog.clear() + }} + > esc/enter @@ -31,7 +39,15 @@ export function DialogHelp() { - dialog.clear()}> + { + if (Selection.active(renderer)) return + dialog.clear() + }} + > ok diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx index b1b05a0f1a29..d34c81075c2c 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx @@ -2,7 +2,8 @@ import { TextareaRenderable, TextAttributes } from "@opentui/core" import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" import { onMount, type JSX } from "solid-js" -import { useKeyboard } from "@opentui/solid" +import { useKeyboard, useRenderer } from "@opentui/solid" +import { Selection } from "../util/selection" export type DialogPromptProps = { title: string @@ -16,6 +17,7 @@ export type DialogPromptProps = { export function DialogPrompt(props: DialogPromptProps) { const dialog = useDialog() const { theme } = useTheme() + const renderer = useRenderer() let textarea: TextareaRenderable useKeyboard((evt) => { @@ -39,7 +41,13 @@ export function DialogPrompt(props: DialogPromptProps) { {props.title} - dialog.clear()}> + { + if (Selection.active(renderer)) return + dialog.clear() + }} + > esc diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index 151f73cf7c0a..b96965aabd3f 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -3,11 +3,12 @@ import { useTheme, selectedForeground } from "@tui/context/theme" import { entries, filter, flatMap, groupBy, pipe, take } from "remeda" import { batch, createEffect, createMemo, For, Show, type JSX, on } from "solid-js" import { createStore } from "solid-js/store" -import { useKeyboard, useTerminalDimensions } from "@opentui/solid" +import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" import * as fuzzysort from "fuzzysort" import { isDeepEqual } from "remeda" import { useDialog, type DialogContext } from "@tui/ui/dialog" import { useKeybind } from "@tui/context/keybind" +import { Selection } from "@tui/util/selection" import { Keybind } from "@/util/keybind" import { Locale } from "@/util/locale" @@ -50,6 +51,7 @@ export type DialogSelectRef = { export function DialogSelect(props: DialogSelectProps) { const dialog = useDialog() const { theme } = useTheme() + const renderer = useRenderer() const [store, setStore] = createStore({ selected: 0, filter: "", @@ -236,7 +238,13 @@ export function DialogSelect(props: DialogSelectProps) { {props.title} - dialog.clear()}> + { + if (Selection.active(renderer)) return + dialog.clear() + }} + > esc @@ -300,6 +308,7 @@ export function DialogSelect(props: DialogSelectProps) { setStore("input", "mouse") }} onMouseUp={() => { + if (Selection.active(renderer)) return option.onSelect?.(dialog) props.onSelect?.(option) }} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx index 8cebd9cba54d..6461109d7ddc 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx @@ -146,12 +146,14 @@ export function DialogProvider(props: ParentProps) { const value = init() const renderer = useRenderer() const toast = useToast() + const click = Selection.click() return ( {props.children} { + if (Selection.press(click, renderer, evt)) return if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return if (evt.button !== MouseButton.RIGHT) return @@ -159,9 +161,11 @@ export function DialogProvider(props: ParentProps) { evt.preventDefault() evt.stopPropagation() }} - onMouseUp={ - !Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? () => Selection.copy(renderer, toast) : undefined - } + onMouseUp={(evt) => { + if (Selection.release(click, renderer, toast, evt)) return + if (Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return + Selection.copy(renderer, toast) + }} > value.clear()} size={value.size}> diff --git a/packages/opencode/src/cli/cmd/tui/ui/link.tsx b/packages/opencode/src/cli/cmd/tui/ui/link.tsx index 3b328e478d61..482addac25a6 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/link.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/link.tsx @@ -1,5 +1,7 @@ import type { JSX } from "solid-js" import type { RGBA } from "@opentui/core" +import { useRenderer } from "@opentui/solid" +import { Selection } from "@tui/util/selection" import open from "open" export interface LinkProps { @@ -14,11 +16,13 @@ export interface LinkProps { */ export function Link(props: LinkProps) { const displayText = props.children ?? props.href + const renderer = useRenderer() return ( { + if (Selection.active(renderer)) return open(props.href).catch(() => {}) }} > diff --git a/packages/opencode/src/cli/cmd/tui/util/selection.ts b/packages/opencode/src/cli/cmd/tui/util/selection.ts index 1230852dcc07..0136df4f1dd1 100644 --- a/packages/opencode/src/cli/cmd/tui/util/selection.ts +++ b/packages/opencode/src/cli/cmd/tui/util/selection.ts @@ -1,3 +1,4 @@ +import { MouseButton, RGBA, type MouseEvent, type OptimizedBuffer, type Renderable } from "@opentui/core" import { Clipboard } from "./clipboard" type Toast = { @@ -6,20 +7,357 @@ type Toast = { } type Renderer = { - getSelection: () => { getSelectedText: () => string } | null + addPostProcessFn: (fn: (buffer: OptimizedBuffer, delta: number) => void) => void clearSelection: () => void + currentRenderBuffer: OptimizedBuffer + getSelection: () => { getSelectedText: () => string } | null + requestRender: () => void +} + +type Hit = Renderable & { + height: number + num: number + selectable: boolean + width: number + x: number + y: number +} + +type Mark = { + text: string + x0: number + x1: number + y: number +} + +type Cell = { + end: number + index: number + start: number + text: string +} + +type Line = { + chars: Cell[] + cells: (Cell | undefined)[] +} + +type State = { + bg: RGBA + fg: RGBA + mark: Mark | null +} + +const GAP = 350 +const MASK = 3221225472 | 0 +const CONT = 3221225472 | 0 +const SPACE = /^\s$/ +const store = new WeakMap() +const decode = new TextDecoder() + +function state(renderer: Renderer) { + const existing = store.get(renderer) + if (existing) return existing + + const next: State = { + bg: RGBA.fromInts(0, 120, 215), + fg: RGBA.fromInts(0, 0, 0), + mark: null, + } + + renderer.addPostProcessFn((buffer) => { + const mark = next.mark + if (!mark) return + if (mark.y < 0 || mark.y >= buffer.height) return + + const x0 = Math.max(0, Math.min(buffer.width, mark.x0)) + const x1 = Math.max(x0, Math.min(buffer.width, mark.x1)) + const bg = buffer.buffers.bg + const fg = buffer.buffers.fg + + for (let x = x0; x < x1; x++) { + const i = (mark.y * buffer.width + x) * 4 + bg[i] = next.bg.r + bg[i + 1] = next.bg.g + bg[i + 2] = next.bg.b + bg[i + 3] = next.bg.a + fg[i] = next.fg.r + fg[i + 1] = next.fg.g + fg[i + 2] = next.fg.b + fg[i + 3] = next.fg.a + } + }) + + store.set(renderer, next) + return next +} + +function text(renderer: Renderer) { + return state(renderer).mark?.text ?? renderer.getSelection()?.getSelectedText() ?? "" +} + +function pick(input: Renderable | null | undefined) { + let item = input + while (item) { + if (item.selectable && item.width > 0 && item.height > 0) return item as Hit + item = item.parent + } +} + +function row(item: Hit, y: number) { + return y >= item.y && y < item.y + item.height +} + +function line(renderer: Renderer, y: number) { + const buffer = renderer.currentRenderBuffer + if (y < 0 || y >= buffer.height) return + + const text = Array.from(decode.decode(buffer.getRealCharBytes(true)).split("\n")[y] ?? "") + const raw = buffer.buffers.char + const cells = [] as (Cell | undefined)[] + const chars = [] as Cell[] + let cursor = 0 + + for (let x = 0; x < buffer.width; x++) { + const char = raw[y * buffer.width + x] + if ((char & MASK) === CONT) continue + + let end = x + 1 + while (end < buffer.width && (raw[y * buffer.width + end] & MASK) === CONT) end += 1 + + const next = { + end, + index: chars.length, + start: x, + text: text[cursor++] ?? " ", + } + + chars.push(next) + for (let i = x; i < end; i++) cells[i] = next + x = end - 1 + } + + return { + chars, + cells, + } satisfies Line +} + +function range(item: Hit, y: number) { + const parent = item.parent + if (!parent) { + return { + x0: item.x, + x1: item.x + item.width, + } + } + + const list = parent + .getChildren() + .filter((child): child is Hit => child.selectable && child.width > 0 && child.height > 0 && row(child, y)) + .sort((a, b) => a.x - b.x) + const hit = list.some((child) => child.num === item.num) + if (!hit || !list.length) { + return { + x0: item.x, + x1: item.x + item.width, + } + } + + return { + x0: Math.min(...list.map((child) => child.x)), + x1: Math.max(...list.map((child) => child.x + child.width)), + } +} + +function set(renderer: Renderer, x0: number, x1: number, y: number, line: Line) { + const text = line.chars + .filter((char) => char.end > x0 && char.start < x1) + .map((char) => char.text) + .join("") + .replace(/\s+$/, "") + if (!text) { + clear(renderer) + return "" + } + + const value = state(renderer) + renderer.clearSelection() + value.mark = { + x0, + x1, + y, + text, + } + renderer.requestRender() + return text +} + +function word(renderer: Renderer, item: Hit, x: number, y: number) { + const current = line(renderer, y) + if (!current) return "" + + const { x0, x1 } = range(item, y) + if (x < x0 || x >= x1) return "" + const cell = current.cells[x] + if (!cell || SPACE.test(cell.text)) { + clear(renderer) + return "" + } + + let start = cell.index + while (start > 0 && current.chars[start - 1] && current.chars[start - 1].end > x0 && !SPACE.test(current.chars[start - 1].text)) { + start -= 1 + } + + let end = cell.index + 1 + while (end < current.chars.length && current.chars[end] && current.chars[end].start < x1 && !SPACE.test(current.chars[end].text)) { + end += 1 + } + + return set(renderer, current.chars[start].start, current.chars[end - 1].end, y, current) +} + +function full(renderer: Renderer, item: Hit, x: number, y: number) { + const current = line(renderer, y) + if (!current) return "" + + const { x0, x1 } = range(item, y) + if (x < x0 || x >= x1) return "" + const hit = current.cells[x] + if (!hit || SPACE.test(hit.text)) { + clear(renderer) + return "" + } + + const chars = current.chars.filter((char) => char.end > x0 && char.start < x1) + if (!chars.length) { + clear(renderer) + return "" + } + return set(renderer, chars[0].start, chars[chars.length - 1].end, y, current) +} + +function clear(renderer: Renderer) { + const value = state(renderer) + if (!value.mark) return + value.mark = null + renderer.requestRender() +} + +function reset(input: Selection.Click) { + input.count = 0 + input.item = -1 + input.mode = null + input.row = -1 + input.time = 0 + input.x = -1 } export namespace Selection { - export function copy(renderer: Renderer, toast: Toast): boolean { - const text = renderer.getSelection()?.getSelectedText() - if (!text) return false + export type Click = { + count: number + item: number + mode: "line" | "word" | null + row: number + time: number + x: number + } + + export function click(): Click { + return { + count: 0, + item: -1, + mode: null, + row: -1, + time: 0, + x: -1, + } + } + + export function configure(renderer: Renderer, input: { bg: RGBA; fg: RGBA }) { + const value = state(renderer) + value.bg = input.bg + value.fg = input.fg + renderer.requestRender() + } + + export function active(renderer: Renderer) { + return !!text(renderer) + } + + export function dismiss(renderer: Renderer) { + clear(renderer) + } + + export function press(input: Click, renderer: Renderer, evt: MouseEvent) { + if (evt.button !== MouseButton.LEFT) { + reset(input) + return false + } + + const item = pick(evt.target) + if (!item || !row(item, evt.y)) { + clear(renderer) + reset(input) + return false + } + + const now = Date.now() + const same = input.item === item.num && input.row === evt.y && Math.abs(input.x - evt.x) <= 1 && now - input.time <= GAP + if (!same) clear(renderer) + + input.count = same ? input.count + 1 : 1 + input.item = item.num + input.mode = null + input.row = evt.y + input.time = now + input.x = evt.x + + if (input.count === 2) { + if (!word(renderer, item, evt.x, evt.y)) return false + input.mode = "word" + evt.preventDefault() + evt.stopPropagation() + return true + } + + if (input.count < 3) return false + if (!full(renderer, item, evt.x, evt.y)) { + reset(input) + return false + } + + input.count = 0 + input.mode = "line" + evt.preventDefault() + evt.stopPropagation() + return true + } + + export function release(input: Click, renderer: Renderer, toast: Toast, evt: MouseEvent) { + if (evt.button !== MouseButton.LEFT) return false + if (!input.mode) return false + + input.mode = null + if (!copy(renderer, toast, { clear: false })) return false + evt.preventDefault() + evt.stopPropagation() + return true + } + + export function copy(renderer: Renderer, toast: Toast, opts?: { clear?: boolean }): boolean { + const value = text(renderer) + if (!value) return false - Clipboard.copy(text) + Clipboard.copy(value) .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) .catch(toast.error) - renderer.clearSelection() + if (opts?.clear !== false) { + clear(renderer) + renderer.clearSelection() + } return true } } diff --git a/packages/opencode/test/cli/tui/selection.test.ts b/packages/opencode/test/cli/tui/selection.test.ts new file mode 100644 index 000000000000..ff4678cdb12b --- /dev/null +++ b/packages/opencode/test/cli/tui/selection.test.ts @@ -0,0 +1,246 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test" +import { createTestRenderer } from "@opentui/core/testing" +import { BoxRenderable, CodeRenderable, SyntaxStyle, TextRenderable, type Renderable } from "@opentui/core" + +const copy = mock(async (_text: string) => {}) + +mock.module("../../../src/cli/cmd/tui/util/clipboard", () => ({ + Clipboard: { + copy, + }, +})) + +const { Selection } = await import("../../../src/cli/cmd/tui/util/selection") + +function evt(target: Renderable, x: number, y: number) { + return { + button: 0, + target, + x, + y, + preventDefault() {}, + stopPropagation() {}, + } +} + +function toast() { + return { + show: mock(() => {}), + error: mock(() => {}), + } +} + +async function scene(opts?: { content?: string; width?: number; height?: number }) { + const view = await createTestRenderer({ + width: 30, + height: 5, + autoFocus: false, + useConsole: false, + }) + const text = new TextRenderable(view.renderer, { + content: opts?.content ?? "hello world", + width: opts?.width ?? 11, + height: opts?.height ?? 1, + wrapMode: "word", + }) + view.renderer.root.add(text) + await view.renderOnce() + return { + ...view, + text, + } +} + +function find(frame: string, text: string) { + return frame.split("\n")[0].indexOf(text) +} + +beforeEach(() => { + copy.mockClear() +}) + +describe("selection", () => { + test("single click does not create a custom selection", async () => { + const view = await scene() + const click = Selection.click() + const note = toast() + + try { + expect(Selection.press(click, view.renderer, evt(view.text, 1, 0) as any)).toBe(false) + expect(Selection.release(click, view.renderer, note, evt(view.text, 1, 0) as any)).toBe(false) + expect(Selection.active(view.renderer)).toBe(false) + } finally { + view.renderer.destroy() + } + }) + + test("double click selects the visible word for concealed markdown/code", async () => { + const view = await createTestRenderer({ + width: 40, + height: 6, + autoFocus: false, + useConsole: false, + }) + const click = Selection.click() + const note = toast() + const style = SyntaxStyle.create() + const text = new CodeRenderable(view.renderer, { + content: "**hello** world", + filetype: "markdown", + syntaxStyle: style, + conceal: true, + width: 20, + height: 1, + }) + view.renderer.root.add(text) + await view.renderOnce() + const x = find(view.captureCharFrame(), "world") + + try { + expect(x).toBeGreaterThanOrEqual(0) + expect(Selection.press(click, view.renderer, evt(text, x, 0) as any)).toBe(false) + expect(Selection.release(click, view.renderer, note, evt(text, x, 0) as any)).toBe(false) + + expect(Selection.press(click, view.renderer, evt(text, x, 0) as any)).toBe(true) + expect(Selection.active(view.renderer)).toBe(true) + + expect(Selection.release(click, view.renderer, note, evt(text, x, 0) as any)).toBe(true) + expect(copy).toHaveBeenCalledTimes(1) + expect(copy).toHaveBeenCalledWith("world") + expect(Selection.active(view.renderer)).toBe(true) + } finally { + view.renderer.destroy() + style.destroy() + } + }) + + test("double click works from either half of a wide character", async () => { + const view = await createTestRenderer({ + width: 30, + height: 5, + autoFocus: false, + useConsole: false, + }) + const click = Selection.click() + const note = toast() + const text = new TextRenderable(view.renderer, { + content: "你好吗 world", + width: 12, + height: 1, + }) + view.renderer.root.add(text) + await view.renderOnce() + + try { + expect(Selection.press(click, view.renderer, evt(text, 3, 0) as any)).toBe(false) + expect(Selection.release(click, view.renderer, note, evt(text, 3, 0) as any)).toBe(false) + + expect(Selection.press(click, view.renderer, evt(text, 3, 0) as any)).toBe(true) + expect(Selection.release(click, view.renderer, note, evt(text, 3, 0) as any)).toBe(true) + + expect(copy).toHaveBeenCalledTimes(1) + expect(copy).toHaveBeenCalledWith("你好吗") + } finally { + view.renderer.destroy() + } + }) + + test("triple click selects the full visual line across siblings with layout gaps", async () => { + const view = await createTestRenderer({ + width: 30, + height: 5, + autoFocus: false, + useConsole: false, + }) + const click = Selection.click() + const note = toast() + const row = new BoxRenderable(view.renderer, { flexDirection: "row", gap: 1 }) + const a = new TextRenderable(view.renderer, { content: "Parent", width: 6, height: 1 }) + const b = new TextRenderable(view.renderer, { content: "Prev", width: 4, height: 1 }) + const c = new TextRenderable(view.renderer, { content: "Next", width: 4, height: 1 }) + row.add(a) + row.add(b) + row.add(c) + view.renderer.root.add(row) + await view.renderOnce() + + try { + expect(Selection.press(click, view.renderer, evt(a, 1, 0) as any)).toBe(false) + expect(Selection.release(click, view.renderer, note, evt(a, 1, 0) as any)).toBe(false) + + expect(Selection.press(click, view.renderer, evt(a, 1, 0) as any)).toBe(true) + expect(Selection.release(click, view.renderer, note, evt(a, 1, 0) as any)).toBe(true) + copy.mockClear() + + expect(Selection.press(click, view.renderer, evt(a, 1, 0) as any)).toBe(true) + expect(Selection.release(click, view.renderer, note, evt(a, 1, 0) as any)).toBe(true) + + expect(copy).toHaveBeenCalledTimes(1) + expect(copy).toHaveBeenCalledWith("Parent Prev Next") + } finally { + view.renderer.destroy() + } + }) + + test("plain copy keeps existing drag-copy behavior and clears the selection", async () => { + const view = await scene() + const note = toast() + + try { + view.renderer.startSelection(view.text, 0, 0) + view.renderer.updateSelection(view.text, 5, 0, { finishDragging: true }) + expect(view.renderer.getSelection()?.getSelectedText()).toBe("hello") + + expect(Selection.copy(view.renderer, note)).toBe(true) + expect(copy).toHaveBeenCalledTimes(1) + expect(copy).toHaveBeenCalledWith("hello") + expect(view.renderer.getSelection()).toBeNull() + } finally { + view.renderer.destroy() + } + }) + + test("click streak resets after the timeout window", async () => { + const view = await scene() + const click = Selection.click() + + try { + expect(Selection.press(click, view.renderer, evt(view.text, 1, 0) as any)).toBe(false) + click.time -= 351 + expect(Selection.press(click, view.renderer, evt(view.text, 1, 0) as any)).toBe(false) + expect(click.count).toBe(1) + } finally { + view.renderer.destroy() + } + }) + + test("changing target resets the streak and does not trigger selection", async () => { + const view = await createTestRenderer({ + width: 30, + height: 5, + autoFocus: false, + useConsole: false, + }) + const click = Selection.click() + const note = toast() + const row = new BoxRenderable(view.renderer, { flexDirection: "row" }) + const a = new TextRenderable(view.renderer, { content: "hello", width: 5, height: 1 }) + const b = new TextRenderable(view.renderer, { content: "world", width: 5, height: 1 }) + row.add(a) + row.add(b) + view.renderer.root.add(row) + await view.renderOnce() + + try { + expect(Selection.press(click, view.renderer, evt(a, 1, 0) as any)).toBe(false) + expect(Selection.release(click, view.renderer, note, evt(a, 1, 0) as any)).toBe(false) + + expect(Selection.press(click, view.renderer, evt(b, 6, 0) as any)).toBe(false) + expect(Selection.release(click, view.renderer, note, evt(b, 6, 0) as any)).toBe(false) + expect(Selection.active(view.renderer)).toBe(false) + expect(copy).toHaveBeenCalledTimes(0) + } finally { + view.renderer.destroy() + } + }) +})