Skip to content
Open
68 changes: 68 additions & 0 deletions packages/core/src/renderables/EditBufferRenderable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,74 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf
this.setSelection(Math.min(start, end), Math.max(start, end) + 1)
}

selectWordAt(x: number, y: number): void {
if (!this.moveCursorToMousePosition(x, y)) return

// Native word-boundary navigation is broader than double-click semantics
// here, so select the contiguous word-token under the clicked cell instead.
const cursor = this.editBuffer.getCursorPosition()
const selection = this.getWordSelectionAtOffset(cursor.offset)
if (!selection) {
this.clearSelection()
return
}

this.setSelection(selection.start, selection.end)
}

private getWordSelectionAtOffset(offset: number): { start: number; end: number } | null {
if (offset < 0) return null

// EditBuffer offsets are display-width offsets; avoid JS string length for bounds checks.
const initial = this.editBuffer.getTextRange(offset, offset + 1)
if (!this.isWordSelectionText(initial)) return null

let start = offset
let end = start + 1

// Expand over the same token class in both directions. Punctuation and
// whitespace intentionally stop expansion instead of joining nearby words.
while (start > 0 && this.isWordSelectionText(this.editBuffer.getTextRange(start - 1, start))) {
start -= 1
}

while (this.isWordSelectionText(this.editBuffer.getTextRange(end, end + 1))) {
end += 1
}

return { start, end }
}

private isWordSelectionText(text: string): boolean {
// Keep underscores with identifiers while treating punctuation as a boundary.
return /^[\p{L}\p{N}_]$/u.test(text)
}

selectLineAt(x: number, y: number): void {
if (!this.moveCursorToMousePosition(x, y)) return

const cursor = this.editBuffer.getCursorPosition()
const start = this.editBuffer.getLineStartOffset(cursor.row)
const end = this.editBuffer.getEOL()

this.setSelection(start, end.offset)
}

private moveCursorToMousePosition(x: number, y: number): boolean {
if (!this.shouldStartSelection(x, y)) return false

const localX = x - this.x
const localY = y - this.y
// Reuse editor-view local selection mapping to place the logical cursor at
// the clicked cell, then clear the temporary local selection immediately.
this.editorView.setLocalSelection(localX, localY, localX, localY, this._selectionBg, this._selectionFg, true)

this.editorView.resetLocalSelection()
this.lastLocalSelection = null

return true
}

clearSelection(): boolean {
const had = this.hasSelection()
this.lastLocalSelection = null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,83 @@ describe("Textarea - Selection Tests", () => {
expect(editor.getSelectedText()).toBe("Hello")
})

it("should select a word on double click", async () => {
const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, {
initialValue: "Hello World",
width: 40,
height: 10,
selectable: true,
})

await currentMouse.doubleClick(editor.x + 1, editor.y)
await renderOnce()

expect(editor.getSelectedText()).toBe("Hello")
expect(editor.getSelection()).toEqual({ start: 0, end: 5 })
})

it("should select only the clicked word at punctuation boundaries", async () => {
const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, {
initialValue: "Emacs-style)",
width: 40,
height: 10,
selectable: true,
})

await currentMouse.doubleClick(editor.x + 6, editor.y)
await renderOnce()

expect(editor.getSelectedText()).toBe("style")
expect(editor.getSelection()).toEqual({ start: 6, end: 11 })
})

it("should not include the previous word when double clicking the first letter", async () => {
const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, {
initialValue: "Hello World",
width: 40,
height: 10,
selectable: true,
})

await currentMouse.doubleClick(editor.x + 6, editor.y)
await renderOnce()

expect(editor.getSelectedText()).toBe("World")
expect(editor.getSelection()).toEqual({ start: 6, end: 11 })
})

it("should not select adjacent words when double clicking whitespace", async () => {
const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, {
initialValue: "Hello World",
width: 40,
height: 10,
selectable: true,
})

await currentMouse.doubleClick(editor.x + 5, editor.y)
await renderOnce()

expect(editor.hasSelection()).toBe(false)
expect(editor.getSelectedText()).toBe("")
})

it("should select a line on triple click", async () => {
const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, {
initialValue: "First\nSecond line\nThird",
width: 40,
height: 10,
selectable: true,
})

await currentMouse.click(editor.x + 3, editor.y + 1)
await currentMouse.click(editor.x + 3, editor.y + 1)
await currentMouse.click(editor.x + 3, editor.y + 1)
await renderOnce()

expect(editor.getSelectedText()).toBe("Second line")
expect(editor.getSelection()).toEqual({ start: 6, end: 17 })
})

it("should return selected text from multi-line content", async () => {
const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, {
initialValue: "AAAA\nBBBB\nCCCC",
Expand Down
92 changes: 86 additions & 6 deletions packages/core/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -828,6 +828,17 @@ export class CliRenderer extends EventEmitter implements RenderContext {
private selectionContainers: Renderable[] = []
private clipboard: Clipboard

private lastClick: {
time: number
x: number
y: number
button: number
targetId: number
count: number
} | null = null

private readonly multiClickThresholdMs = 500

private _splitHeight: number = 0
private renderOffset: number = 0
private splitTailColumn: number = 0
Expand Down Expand Up @@ -2708,7 +2719,11 @@ export class CliRenderer extends EventEmitter implements RenderContext {

private flushPendingSplitOutputBeforeTransition(
forceFooterRepaint: boolean = false,
options: { allowSuspended?: boolean; allowPassthrough?: boolean; allowUnsetup?: boolean } = {},
options: {
allowSuspended?: boolean
allowPassthrough?: boolean
allowUnsetup?: boolean
} = {},
): void {
const hasDeferredCapturedOutput = this.externalOutputQueue.size > 0 || this.pendingExternalOutputMode !== null
if (
Expand Down Expand Up @@ -3142,13 +3157,17 @@ export class CliRenderer extends EventEmitter implements RenderContext {
return
}

this.updateStdinParserProtocolContext({ startupCursorCprActive: false })
this.updateStdinParserProtocolContext({
startupCursorCprActive: false,
})

if (this._screenMode === "split-footer" && this._externalOutputMode === "capture-stdout") {
this.requestRender()
}

this.flushPendingSplitOutputBeforeTransition(false, { allowPassthrough: true })
this.flushPendingSplitOutputBeforeTransition(false, {
allowPassthrough: true,
})
}, 120)
}

Expand Down Expand Up @@ -3403,6 +3422,35 @@ export class CliRenderer extends EventEmitter implements RenderContext {
return event
}

private getClickCount(mouseEvent: RawMouseEvent, targetId: number): number {
if (mouseEvent.type !== "down") return 0

const now = this.clock.now()
const previous = this.lastClick
// Treat a click as part of the same multi-click sequence only when it lands
// on the exact same cell and renderable; drags or nearby clicks start over.
const sameClickTarget =
previous &&
previous.button === mouseEvent.button &&
previous.targetId === targetId &&
previous.x === mouseEvent.x &&
previous.y === mouseEvent.y &&
now - previous.time <= this.multiClickThresholdMs

const count = sameClickTarget ? Math.min(previous.count + 1, 3) : 1

this.lastClick = {
time: now,
x: mouseEvent.x,
y: mouseEvent.y,
button: mouseEvent.button,
targetId,
count,
}

return count
}

private processSingleMouseEvent(mouseEvent: RawMouseEvent): boolean {
if (this._splitHeight > 0) {
if (mouseEvent.y < this.renderOffset) {
Expand Down Expand Up @@ -3453,6 +3501,30 @@ export class CliRenderer extends EventEmitter implements RenderContext {
this.lastOverRenderableNum = maybeRenderableId
const maybeRenderable = Renderable.renderablesByNumber.get(maybeRenderableId)

if (
mouseEvent.type === "down" &&
mouseEvent.button === MouseButton.LEFT &&
maybeRenderable &&
maybeRenderable.selectable &&
!mouseEvent.modifiers.ctrl
) {
const clickCount = this.getClickCount(mouseEvent, maybeRenderableId)

// Handle editor-specific multi-click selection before the normal
// single-click branch starts a drag selection at this same position.
if (clickCount === 2 && isEditBufferRenderable(maybeRenderable)) {
maybeRenderable.selectWordAt(mouseEvent.x, mouseEvent.y)
this.dispatchMouseEvent(maybeRenderable, mouseEvent)
return true
}

if (clickCount >= 3 && isEditBufferRenderable(maybeRenderable)) {
maybeRenderable.selectLineAt(mouseEvent.x, mouseEvent.y)
this.dispatchMouseEvent(maybeRenderable, mouseEvent)
return true
}
}

if (
mouseEvent.type === "down" &&
mouseEvent.button === MouseButton.LEFT &&
Comment thread
Karvy-Singh marked this conversation as resolved.
Expand Down Expand Up @@ -3991,7 +4063,9 @@ export class CliRenderer extends EventEmitter implements RenderContext {

if (this._terminalIsSetup) {
this.clearSplitStartupCursorSeed()
this.flushPendingSplitOutputBeforeTransition(true, { allowSuspended: true })
this.flushPendingSplitOutputBeforeTransition(true, {
allowSuspended: true,
})
this.suspendedNonAltSurfacePreserved = this._screenMode !== "alternate-screen" && this.renderOffset > 0
} else {
this.suspendedNonAltSurfacePreserved = false
Expand Down Expand Up @@ -4055,7 +4129,10 @@ export class CliRenderer extends EventEmitter implements RenderContext {

this.suspendedNonAltSurfacePreserved = false

this.flushPendingSplitOutputBeforeTransition(false, { allowSuspended: true, allowPassthrough: true })
this.flushPendingSplitOutputBeforeTransition(false, {
allowSuspended: true,
allowPassthrough: true,
})

if (this._screenMode === "split-footer" && this._splitHeight > 0) {
this.syncSplitFooterState()
Expand Down Expand Up @@ -4199,7 +4276,10 @@ export class CliRenderer extends EventEmitter implements RenderContext {
}

if (this._feed !== null && this._splitHeight > 0 && !this._terminalIsSetup) {
this.flushPendingSplitOutputBeforeTransition(false, { allowSuspended: true, allowUnsetup: true })
this.flushPendingSplitOutputBeforeTransition(false, {
allowSuspended: true,
allowUnsetup: true,
})
}

this.externalOutputMode = "passthrough"
Expand Down