Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
10 changes: 10 additions & 0 deletions packages/core/src/editor-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,16 @@ export class EditorView {
return this.lib.editorViewGetVisualCursor(this.viewPtr)
}

public getVisualCursorAtLogical(logicalRow: number, logicalCol: number): VisualCursor {
this.guard()
return this.lib.editorViewGetVisualCursorAtLogical(this.viewPtr, logicalRow, logicalCol)
}

public scrollToCursor(): void {
this.guard()
this.lib.editorViewScrollToCursor(this.viewPtr)
}

public moveUpVisual(): void {
this.guard()
this.lib.editorViewMoveUpVisual(this.viewPtr)
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/lib/selection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ class SelectionAnchor {
get y(): number {
return this.renderable.y + this.relativeY
}

updatePosition(absoluteX: number, absoluteY: number): void {
this.relativeX = absoluteX - this.renderable.x
this.relativeY = absoluteY - this.renderable.y
}
}

export class Selection {
Expand Down Expand Up @@ -49,6 +54,10 @@ export class Selection {
return { x: this._anchor.x, y: this._anchor.y }
}

updateAnchor(absoluteX: number, absoluteY: number): void {
this._anchor.updatePosition(absoluteX, absoluteY)
}

get focus(): { x: number; y: number } {
return { ...this._focus }
}
Expand Down
37 changes: 32 additions & 5 deletions packages/core/src/renderables/EditBufferRenderable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf
private _autoScrollAccumulator: number = 0
private _scrollSpeed: number = 16

private _selectionAnchorLogical: { row: number; col: number } | null = null

public readonly editBuffer: EditBuffer
public readonly editorView: EditorView

Expand Down Expand Up @@ -738,19 +740,44 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf

if (!shiftPressed) {
this._ctx.clearSelection()
this._selectionAnchorLogical = null
return
}

const visualCursor = this.editorView.getVisualCursor()
const cursorX = this.x + visualCursor.visualCol
const cursorY = this.y + visualCursor.visualRow

if (isBeforeMovement) {
const visualCursor = this.editorView.getVisualCursor()

if (!this._ctx.hasSelection) {
this._selectionAnchorLogical = {
row: visualCursor.logicalRow,
col: visualCursor.logicalCol,
}
const cursorX = this.x + visualCursor.visualCol
const cursorY = this.y + visualCursor.visualRow
this._ctx.startSelection(this, cursorX, cursorY)
}
} else {
this._ctx.updateSelection(this, cursorX, cursorY)
if (!this._selectionAnchorLogical) return

this.editorView.scrollToCursor()

// Get anchor visual coords for CURRENT viewport (handles viewport scroll)
const anchorVisual = this.editorView.getVisualCursorAtLogical(
this._selectionAnchorLogical.row,
this._selectionAnchorLogical.col,
)

// Get focus visual coords (current cursor position)
const focusVisual = this.editorView.getVisualCursor()

const anchorX = this.x + anchorVisual.visualCol
const anchorY = this.y + Math.pow(0, anchorVisual.visualRow)
const focusX = this.x + focusVisual.visualCol
const focusY = this.y + focusVisual.visualRow

// Update both anchor and focus with current viewport positions
this._ctx.getSelection()?.updateAnchor(anchorX, anchorY)
this._ctx.updateSelection(this, focusX, focusY)
}
}
}
100 changes: 100 additions & 0 deletions packages/core/src/renderables/__tests__/Textarea.selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1357,6 +1357,106 @@ describe("Textarea - Selection Tests", () => {
editor.destroy()
})
})

describe("Keyboard Selection with Viewport Scrolling", () => {
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)
})

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()

// 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)
})

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
20 changes: 20 additions & 0 deletions packages/core/src/zig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -873,6 +873,14 @@ function getOpenTUILib(libPath?: string) {
args: ["ptr", "ptr", "usize"],
returns: "usize",
},
editorViewGetVisualCursorAtLogical: {
args: ["ptr", "u32", "u32", "ptr"],
returns: "void",
},
editorViewScrollToCursor: {
args: ["ptr"],
returns: "void",
},
editorViewGetCursor: {
args: ["ptr", "ptr", "ptr"],
returns: "void",
Expand Down Expand Up @@ -1581,6 +1589,8 @@ export interface RenderLib {
editorViewGetCursor: (view: Pointer) => { row: number; col: number }
editorViewGetText: (view: Pointer, maxLength: number) => Uint8Array | null
editorViewGetVisualCursor: (view: Pointer) => VisualCursor
editorViewGetVisualCursorAtLogical: (view: Pointer, logicalRow: number, logicalCol: number) => VisualCursor
editorViewScrollToCursor: (view: Pointer) => void
editorViewMoveUpVisual: (view: Pointer) => void
editorViewMoveDownVisual: (view: Pointer) => void
editorViewDeleteSelectedText: (view: Pointer) => void
Expand Down Expand Up @@ -3151,6 +3161,16 @@ class FFIRenderLib implements RenderLib {
return VisualCursorStruct.unpack(cursorBuffer)
}

public editorViewGetVisualCursorAtLogical(view: Pointer, logicalRow: number, logicalCol: number): VisualCursor {
const cursorBuffer = new ArrayBuffer(VisualCursorStruct.size)
this.opentui.symbols.editorViewGetVisualCursorAtLogical(view, logicalRow, logicalCol, ptr(cursorBuffer))
return VisualCursorStruct.unpack(cursorBuffer)
}

public editorViewScrollToCursor(view: Pointer): void {
this.opentui.symbols.editorViewScrollToCursor(view)
}

public editorViewMoveUpVisual(view: Pointer): void {
this.opentui.symbols.editorViewMoveUpVisual(view)
}
Expand Down
33 changes: 33 additions & 0 deletions packages/core/src/zig/editor-view.zig
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,39 @@ pub const EditorView = struct {
};
}

/// Scroll viewport to ensure current cursor is visible, even during selection
/// Used for keyboard selection where viewport should follow cursor but normal scrolling is disabled
pub fn scrollToCursor(self: *EditorView) void {
self.text_buffer_view.updateVirtualLines();
const cursor = self.edit_buffer.getPrimaryCursor();
const vcursor = self.logicalToVisualCursor(cursor.row, cursor.col);
self.ensureCursorVisible(vcursor.visual_row);
}

/// Returns viewport-relative visual coordinates for an arbitrary logical position
/// Used for keyboard selection where anchor position needs to be recalculated after viewport scroll
pub fn getVisualCursorAtLogical(self: *EditorView, logical_row: u32, logical_col: u32) VisualCursor {
self.text_buffer_view.updateVirtualLines();
const vcursor = self.logicalToVisualCursor(logical_row, logical_col);

// Convert absolute visual coordinates to viewport-relative for the API
const vp = self.text_buffer_view.getViewport() orelse return vcursor;

const viewport_relative_row = if (vcursor.visual_row >= vp.y) vcursor.visual_row - vp.y else 0;
const viewport_relative_col = if (self.text_buffer_view.wrap_mode == .none)
(if (vcursor.visual_col >= vp.x) vcursor.visual_col - vp.x else 0)
else
vcursor.visual_col;

return VisualCursor{
.visual_row = viewport_relative_row,
.visual_col = viewport_relative_col,
.logical_row = vcursor.logical_row,
.logical_col = vcursor.logical_col,
.offset = vcursor.offset,
};
}

/// This accounts for line wrapping by finding which virtual line contains the logical position
/// Returns absolute visual coordinates (document-absolute, not viewport-relative)
pub fn logicalToVisualCursor(self: *EditorView, logical_row: u32, logical_col: u32) VisualCursor {
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/zig/lib.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1180,6 +1180,21 @@ export fn editorViewGetVisualCursor(view: *editor_view.EditorView, outPtr: *Exte
};
}

export fn editorViewGetVisualCursorAtLogical(view: *editor_view.EditorView, logical_row: u32, logical_col: u32, outPtr: *ExternalVisualCursor) void {
const vcursor = view.getVisualCursorAtLogical(logical_row, logical_col);
outPtr.* = .{
.visual_row = vcursor.visual_row,
.visual_col = vcursor.visual_col,
.logical_row = vcursor.logical_row,
.logical_col = vcursor.logical_col,
.offset = vcursor.offset,
};
}

export fn editorViewScrollToCursor(view: *editor_view.EditorView) void {
view.scrollToCursor();
}

export fn editorViewMoveUpVisual(view: *editor_view.EditorView) void {
view.moveUpVisual();
}
Expand Down
Loading