Skip to content
4 changes: 4 additions & 0 deletions packages/core/src/editor-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ export class EditorView {
bgColor?: RGBA,
fgColor?: RGBA,
updateCursor?: boolean,
followCursor?: boolean,
): boolean {
this.guard()
return this.lib.editorViewSetLocalSelection(
Expand All @@ -121,6 +122,7 @@ export class EditorView {
bgColor || null,
fgColor || null,
updateCursor ?? false,
followCursor ?? false,
)
}

Expand All @@ -132,6 +134,7 @@ export class EditorView {
bgColor?: RGBA,
fgColor?: RGBA,
updateCursor?: boolean,
followCursor?: boolean,
): boolean {
this.guard()
return this.lib.editorViewUpdateLocalSelection(
Expand All @@ -143,6 +146,7 @@ export class EditorView {
bgColor || null,
fgColor || null,
updateCursor ?? false,
followCursor ?? false,
)
}

Expand Down
14 changes: 12 additions & 2 deletions packages/core/src/renderables/EditBufferRenderable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf
private _autoScrollVelocity: number = 0
private _autoScrollAccumulator: number = 0
private _scrollSpeed: number = 16
private _keyboardSelectionActive: boolean = false

public readonly editBuffer: EditBuffer
public readonly editorView: EditorView
Expand Down Expand Up @@ -429,9 +430,11 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf
this.lastLocalSelection = localSelection

const updateCursor = true
const followCursor = this._keyboardSelectionActive

let changed: boolean
if (!localSelection?.isActive) {
this._keyboardSelectionActive = false
this.editorView.resetLocalSelection()
changed = true
} else if (selection?.isStart) {
Expand All @@ -443,6 +446,7 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf
this._selectionBg,
this._selectionFg,
updateCursor,
followCursor,
)
} else {
changed = this.editorView.updateLocalSelection(
Expand All @@ -453,6 +457,7 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf
this._selectionBg,
this._selectionFg,
updateCursor,
followCursor,
)
}

Expand All @@ -469,6 +474,7 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf
this._autoScrollVelocity = 0
}
} else {
this._keyboardSelectionActive = false
this._autoScrollVelocity = 0
this._autoScrollAccumulator = 0
}
Expand Down Expand Up @@ -737,10 +743,13 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf
if (!this.selectable) return

if (!shiftPressed) {
this._keyboardSelectionActive = false
this._ctx.clearSelection()
return
}

this._keyboardSelectionActive = true

const visualCursor = this.editorView.getVisualCursor()
const cursorX = this.x + visualCursor.visualCol
const cursorY = this.y + visualCursor.visualRow
Expand All @@ -749,8 +758,9 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf
if (!this._ctx.hasSelection) {
this._ctx.startSelection(this, cursorX, cursorY)
}
} else {
this._ctx.updateSelection(this, cursorX, cursorY)
return
}

this._ctx.updateSelection(this, cursorX, cursorY)
}
}
201 changes: 200 additions & 1 deletion packages/core/src/renderables/__tests__/Textarea.selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ describe("Textarea - Selection Tests", () => {
buffer.destroy()
})

// Maybe flaky
// It's flaky
it("should handle viewport-aware selection correctly", async () => {
const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, {
initialValue: Array.from({ length: 15 }, (_, i) => `Line ${i}`).join("\n"),
Expand Down Expand Up @@ -520,6 +520,28 @@ describe("Textarea - Selection Tests", () => {
expect(editor.getSelectedText()).toBe("Hello")
})

it("should extend a mouse selection with shift+right", async () => {
const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, {
initialValue: "Hello World",
width: 40,
height: 10,
selectable: true,
})

editor.focus()

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

expect(editor.hasSelection()).toBe(true)
expect(editor.getSelectedText()).toBe("Hello")

currentMockInput.pressArrow("right", { shift: true })
await renderOnce()

expect(editor.getSelectedText()).toBe("Hello ")
})

it("should handle shift+left selection", async () => {
const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, {
initialValue: "Hello World",
Expand Down Expand Up @@ -1318,6 +1340,183 @@ describe("Textarea - Selection Tests", () => {
editor.destroy()
})
})

describe("Keyboard Selection with Viewport Scrolling", () => {
it("should select to buffer home after shift+end then shift+home when scrolled", async () => {
const lines = Array.from({ length: 30 }, (_, i) => `Line ${i.toString().padStart(2, "0")}`)
const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, {
initialValue: lines.join("\n"),
width: 40,
height: 6,
selectable: true,
})

editor.focus()
await renderOnce()

for (let i = 0; i < 3; i++) {
await currentMouse.scroll(editor.x + 2, editor.y + 2, "down")
}
await renderOnce()

const viewportAfterScroll = editor.editorView.getViewport()
expect(viewportAfterScroll.offsetY).toBeGreaterThan(0)
expect(editor.logicalCursor.row).toBeGreaterThan(0)

currentMockInput.pressKey("END", { shift: true })
await renderOnce()

expect(editor.hasSelection()).toBe(true)

currentMockInput.pressKey("HOME", { shift: true })
await renderOnce()

const selection = editor.getSelection()
expect(selection).not.toBeNull()
expect(selection!.start).toBe(0)

const selectedText = editor.getSelectedText()
expect(selectedText.startsWith("Line 00")).toBe(true)
expect(selectedText).not.toContain("Line 29")
})

it("should allow shift+end after shift+home from a mid-buffer cursor", async () => {
const lines = Array.from({ length: 30 }, (_, i) => `Line ${i.toString().padStart(2, "0")}`)
const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, {
initialValue: lines.join("\n"),
width: 40,
height: 6,
selectable: true,
})

editor.focus()
editor.gotoLine(10)
await renderOnce()

currentMockInput.pressKey("END", { shift: true })
await renderOnce()

expect(editor.hasSelection()).toBe(true)

currentMockInput.pressKey("HOME", { shift: true })
await renderOnce()

currentMockInput.pressKey("END", { shift: true })
await renderOnce()

expect(editor.hasSelection()).toBe(true)
expect(editor.getSelectedText()).toContain("Line 29")
})

it("should select to buffer home with shift+super+up in scrollable textarea", async () => {
// Create textarea with content taller than visible area
const lines = Array.from({ length: 50 }, (_, i) => `Line ${i.toString().padStart(2, "0")}`)
const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, {
initialValue: lines.join("\n"),
width: 40,
height: 10,
selectable: true,
})

// Move cursor to middle of content (line 25)
editor.focus()
editor.gotoLine(25)
await renderOnce()

// Verify viewport has scrolled
const viewportBefore = editor.editorView.getViewport()
expect(viewportBefore.offsetY).toBeGreaterThan(0)

// Select to buffer home (shift+super+up)
currentMockInput.pressKey("ARROW_UP", { shift: true, super: true })
await renderOnce()

// Should have selection
expect(editor.hasSelection()).toBe(true)

// Selection should include content from line 0 to line 25
const selectedText = editor.getSelectedText()
expect(selectedText).toContain("Line 00")
expect(selectedText).toContain("Line 24")
expect(selectedText.split("\n").length).toBeGreaterThanOrEqual(25)

const viewportAfter = editor.editorView.getViewport()
expect(viewportAfter.offsetY).toBe(0)
})

it("should select to buffer end with shift+super+down in scrollable textarea", async () => {
// Create textarea with content taller than visible area
const lines = Array.from({ length: 50 }, (_, i) => `Line ${i.toString().padStart(2, "0")}`)
const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, {
initialValue: lines.join("\n"),
width: 40,
height: 10,
selectable: true,
})

// Move cursor to line 20
editor.focus()
editor.gotoLine(20)
await renderOnce()

const viewportBefore = editor.editorView.getViewport()
expect(viewportBefore.offsetY).toBeGreaterThan(0)

// Select to buffer end (shift+super+down)
currentMockInput.pressKey("ARROW_DOWN", { shift: true, super: true })
await renderOnce()

// Should have selection
expect(editor.hasSelection()).toBe(true)

// Selection should include content from line 20 to line 49
const selectedText = editor.getSelectedText()
expect(selectedText).toContain("Line 20")
expect(selectedText).toContain("Line 49")
expect(selectedText.split("\n").length).toBeGreaterThanOrEqual(29)

const viewportAfter = editor.editorView.getViewport()
const totalLines = editor.editorView.getTotalVirtualLineCount()
const maxOffsetY = Math.max(0, totalLines - viewportBefore.height)
expect(viewportAfter.offsetY).toBe(maxOffsetY)
})

it("should handle selection across viewport boundaries correctly", async () => {
// Create textarea with content taller than visible area
const lines = Array.from({ length: 30 }, (_, i) => `Line ${i.toString().padStart(2, "0")}`)
const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, {
initialValue: lines.join("\n"),
width: 40,
height: 5, // Small viewport
selectable: true,
})

// Move cursor to middle (line 15)
editor.focus()
editor.gotoLine(15)
// Move to column 5
for (let i = 0; i < 5; i++) {
editor.moveCursorRight()
}
await renderOnce()

const cursorBefore = editor.editorView.getVisualCursor()
expect(cursorBefore.logicalRow).toBe(15)
expect(cursorBefore.logicalCol).toBe(5)

// Select to buffer home
currentMockInput.pressKey("ARROW_UP", { shift: true, super: true })
await renderOnce()

expect(editor.hasSelection()).toBe(true)
const selectedText = editor.getSelectedText()

// Should select from (15, 5) to (0, 0)
// First line should be complete, last line should be partial
expect(selectedText.startsWith("Line 00")).toBe(true)
expect(selectedText).toContain("Line 14")
})
})
})

function countSelectedCells(
Expand Down
12 changes: 10 additions & 2 deletions packages/core/src/zig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -864,15 +864,15 @@ function getOpenTUILib(libPath?: string) {
returns: "u64",
},
editorViewSetLocalSelection: {
args: ["ptr", "i32", "i32", "i32", "i32", "ptr", "ptr", "bool"],
args: ["ptr", "i32", "i32", "i32", "i32", "ptr", "ptr", "bool", "bool"],
returns: "bool",
},
editorViewUpdateSelection: {
args: ["ptr", "u32", "ptr", "ptr"],
returns: "void",
},
editorViewUpdateLocalSelection: {
args: ["ptr", "i32", "i32", "i32", "i32", "ptr", "ptr", "bool"],
args: ["ptr", "i32", "i32", "i32", "i32", "ptr", "ptr", "bool", "bool"],
returns: "bool",
},
editorViewResetLocalSelection: {
Expand Down Expand Up @@ -1575,7 +1575,9 @@ export interface RenderLib {
bgColor: RGBA | null,
fgColor: RGBA | null,
updateCursor: boolean,
followCursor: boolean,
) => boolean

editorViewUpdateSelection: (view: Pointer, end: number, bgColor: RGBA | null, fgColor: RGBA | null) => void
editorViewUpdateLocalSelection: (
view: Pointer,
Expand All @@ -1586,7 +1588,9 @@ export interface RenderLib {
bgColor: RGBA | null,
fgColor: RGBA | null,
updateCursor: boolean,
followCursor: boolean,
) => boolean

editorViewResetLocalSelection: (view: Pointer) => void
editorViewGetSelectedTextBytes: (view: Pointer, maxLength: number) => Uint8Array | null
editorViewGetCursor: (view: Pointer) => { row: number; col: number }
Expand Down Expand Up @@ -3088,6 +3092,7 @@ class FFIRenderLib implements RenderLib {
bgColor: RGBA | null,
fgColor: RGBA | null,
updateCursor: boolean,
followCursor: boolean,
): boolean {
const bg = bgColor ? bgColor.buffer : null
const fg = fgColor ? fgColor.buffer : null
Expand All @@ -3100,6 +3105,7 @@ class FFIRenderLib implements RenderLib {
bg,
fg,
updateCursor,
followCursor,
)
}

Expand All @@ -3118,6 +3124,7 @@ class FFIRenderLib implements RenderLib {
bgColor: RGBA | null,
fgColor: RGBA | null,
updateCursor: boolean,
followCursor: boolean,
): boolean {
const bg = bgColor ? bgColor.buffer : null
const fg = fgColor ? fgColor.buffer : null
Expand All @@ -3130,6 +3137,7 @@ class FFIRenderLib implements RenderLib {
bg,
fg,
updateCursor,
followCursor,
)
}

Expand Down
Loading
Loading