diff --git a/packages/core/src/editor-view.ts b/packages/core/src/editor-view.ts index 2fb9f45fa..76247e325 100644 --- a/packages/core/src/editor-view.ts +++ b/packages/core/src/editor-view.ts @@ -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) diff --git a/packages/core/src/lib/selection.ts b/packages/core/src/lib/selection.ts index d0a695ea3..21b988db9 100644 --- a/packages/core/src/lib/selection.ts +++ b/packages/core/src/lib/selection.ts @@ -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 { @@ -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 } } diff --git a/packages/core/src/renderables/EditBufferRenderable.ts b/packages/core/src/renderables/EditBufferRenderable.ts index b5502afb9..7392977d9 100644 --- a/packages/core/src/renderables/EditBufferRenderable.ts +++ b/packages/core/src/renderables/EditBufferRenderable.ts @@ -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 @@ -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) } } } diff --git a/packages/core/src/renderables/__tests__/Textarea.selection.test.ts b/packages/core/src/renderables/__tests__/Textarea.selection.test.ts index dd2712513..208f22f70 100644 --- a/packages/core/src/renderables/__tests__/Textarea.selection.test.ts +++ b/packages/core/src/renderables/__tests__/Textarea.selection.test.ts @@ -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( diff --git a/packages/core/src/zig.ts b/packages/core/src/zig.ts index b84c0b831..63581960f 100644 --- a/packages/core/src/zig.ts +++ b/packages/core/src/zig.ts @@ -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", @@ -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 @@ -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) } diff --git a/packages/core/src/zig/editor-view.zig b/packages/core/src/zig/editor-view.zig index 78671ba82..00835f8f7 100644 --- a/packages/core/src/zig/editor-view.zig +++ b/packages/core/src/zig/editor-view.zig @@ -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 { diff --git a/packages/core/src/zig/lib.zig b/packages/core/src/zig/lib.zig index 1a2b685a0..5b59f1606 100644 --- a/packages/core/src/zig/lib.zig +++ b/packages/core/src/zig/lib.zig @@ -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(); }