Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions packages/core/src/renderables/EditBufferRenderable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,77 @@ 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 {
const text = this.editBuffer.getText()
if (text.length === 0) return null

if (offset < 0 || offset >= text.length) return null

let start = offset
let end = start + 1

if (!this.isWordSelectionText(this.editBuffer.getTextRange(start, end))) {
return null
}

// 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 (end < text.length && this.isWordSelectionText(this.editBuffer.getTextRange(end, end + 1))) {
end += 1
}

return { start, end }
}
Comment thread
Karvy-Singh marked this conversation as resolved.

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