From ebe61336623787fd64b1bff496a31e38661304a6 Mon Sep 17 00:00:00 2001 From: Dan Date: Wed, 14 Jan 2026 14:13:43 +0200 Subject: [PATCH 1/9] fix: select buffer home/end behaviour when textarea's content overflows --- packages/core/src/editor-view.ts | 10 ++ packages/core/src/lib/selection.ts | 9 ++ .../src/renderables/EditBufferRenderable.ts | 46 +++++++- .../__tests__/Textarea.selection.test.ts | 100 ++++++++++++++++++ packages/core/src/zig.ts | 20 ++++ packages/core/src/zig/editor-view.zig | 33 ++++++ packages/core/src/zig/lib.zig | 15 +++ 7 files changed, 228 insertions(+), 5 deletions(-) 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..de6e69e9b 100644 --- a/packages/core/src/renderables/EditBufferRenderable.ts +++ b/packages/core/src/renderables/EditBufferRenderable.ts @@ -62,6 +62,9 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf private _autoScrollAccumulator: number = 0 private _scrollSpeed: number = 16 + private _selectionAnchorLogical: { row: number; col: number } | null = null + private _cursorRowBeforeMovement: number | null = null + public readonly editBuffer: EditBuffer public readonly editorView: EditorView @@ -738,19 +741,52 @@ 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() + this._cursorRowBeforeMovement = visualCursor.logicalRow + 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 + + const currentRow = this.editorView.getVisualCursor().logicalRow + const moveDown = this._cursorRowBeforeMovement !== null && currentRow > this._cursorRowBeforeMovement + const moveUp = this._cursorRowBeforeMovement !== null && currentRow < this._cursorRowBeforeMovement + this._cursorRowBeforeMovement = null + + if (moveDown) this.editorView.scrollToCursor() + + // Get anchor visual coords for CURRENT viewport (handles viewport scroll) + const anchorVisual = this.editorView.getVisualCursorAtLogical( + this._selectionAnchorLogical.row, + this._selectionAnchorLogical.col, + ) + + if (moveUp) this.editorView.scrollToCursor() + + // 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 4ba0e7a89..8ca63dbfb 100644 --- a/packages/core/src/renderables/__tests__/Textarea.selection.test.ts +++ b/packages/core/src/renderables/__tests__/Textarea.selection.test.ts @@ -1318,6 +1318,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 20afa3484..350eb8e07 100644 --- a/packages/core/src/zig.ts +++ b/packages/core/src/zig.ts @@ -883,6 +883,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", @@ -1592,6 +1600,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 @@ -3166,6 +3176,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 e3470abe7..3e8ca1059 100644 --- a/packages/core/src/zig/lib.zig +++ b/packages/core/src/zig/lib.zig @@ -1186,6 +1186,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(); } From b138bc08490fad42bf8340709cfdb223d1e452a6 Mon Sep 17 00:00:00 2001 From: Dan Date: Wed, 14 Jan 2026 14:28:35 +0200 Subject: [PATCH 2/9] rm unnecessary logic --- packages/core/src/renderables/EditBufferRenderable.ts | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/packages/core/src/renderables/EditBufferRenderable.ts b/packages/core/src/renderables/EditBufferRenderable.ts index de6e69e9b..7392977d9 100644 --- a/packages/core/src/renderables/EditBufferRenderable.ts +++ b/packages/core/src/renderables/EditBufferRenderable.ts @@ -63,7 +63,6 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf private _scrollSpeed: number = 16 private _selectionAnchorLogical: { row: number; col: number } | null = null - private _cursorRowBeforeMovement: number | null = null public readonly editBuffer: EditBuffer public readonly editorView: EditorView @@ -747,7 +746,6 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf if (isBeforeMovement) { const visualCursor = this.editorView.getVisualCursor() - this._cursorRowBeforeMovement = visualCursor.logicalRow if (!this._ctx.hasSelection) { this._selectionAnchorLogical = { @@ -761,12 +759,7 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf } else { if (!this._selectionAnchorLogical) return - const currentRow = this.editorView.getVisualCursor().logicalRow - const moveDown = this._cursorRowBeforeMovement !== null && currentRow > this._cursorRowBeforeMovement - const moveUp = this._cursorRowBeforeMovement !== null && currentRow < this._cursorRowBeforeMovement - this._cursorRowBeforeMovement = null - - if (moveDown) this.editorView.scrollToCursor() + this.editorView.scrollToCursor() // Get anchor visual coords for CURRENT viewport (handles viewport scroll) const anchorVisual = this.editorView.getVisualCursorAtLogical( @@ -774,8 +767,6 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf this._selectionAnchorLogical.col, ) - if (moveUp) this.editorView.scrollToCursor() - // Get focus visual coords (current cursor position) const focusVisual = this.editorView.getVisualCursor() From ace3c0c523367074289ec8d82aa9d61243551f8c Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Thu, 15 Jan 2026 22:30:24 +0100 Subject: [PATCH 3/9] fix(core): simplify keyboard selection without FFI - remove FFI plumbing - track logical anchor and viewport-delta selection - keep backward selection inclusive and clear keyboard selection state on reset --- packages/core/src/editor-view.ts | 10 --- packages/core/src/lib/selection.ts | 5 ++ .../src/renderables/EditBufferRenderable.ts | 81 ++++++++++++++----- .../__tests__/Textarea.selection.test.ts | 13 ++- packages/core/src/zig.ts | 20 ----- packages/core/src/zig/editor-view.zig | 33 -------- packages/core/src/zig/lib.zig | 15 ---- 7 files changed, 80 insertions(+), 97 deletions(-) diff --git a/packages/core/src/editor-view.ts b/packages/core/src/editor-view.ts index 76247e325..2fb9f45fa 100644 --- a/packages/core/src/editor-view.ts +++ b/packages/core/src/editor-view.ts @@ -180,16 +180,6 @@ 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 21b988db9..a4f840127 100644 --- a/packages/core/src/lib/selection.ts +++ b/packages/core/src/lib/selection.ts @@ -22,6 +22,8 @@ class SelectionAnchor { return this.renderable.y + this.relativeY } + // Updates the anchor's absolute position. Stores coordinates relative to the + // renderable so the anchor moves correctly when the renderable moves. updatePosition(absoluteX: number, absoluteY: number): void { this.relativeX = absoluteX - this.renderable.x this.relativeY = absoluteY - this.renderable.y @@ -54,6 +56,9 @@ export class Selection { return { x: this._anchor.x, y: this._anchor.y } } + // Repositions the selection anchor. Use this when the viewport scrolls during + // keyboard selection to keep the anchor visually aligned with the original + // logical position. updateAnchor(absoluteX: number, absoluteY: number): void { this._anchor.updatePosition(absoluteX, absoluteY) } diff --git a/packages/core/src/renderables/EditBufferRenderable.ts b/packages/core/src/renderables/EditBufferRenderable.ts index 7392977d9..d1a8bce1d 100644 --- a/packages/core/src/renderables/EditBufferRenderable.ts +++ b/packages/core/src/renderables/EditBufferRenderable.ts @@ -62,7 +62,21 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf private _autoScrollAccumulator: number = 0 private _scrollSpeed: number = 16 - private _selectionAnchorLogical: { row: number; col: number } | null = null + // Tracks keyboard selection state across viewport scrolls. + // + // When shift-selecting, the viewport may scroll (e.g., shift+super+up jumps + // to buffer start), but the selection anchor must stay at the original cursor + // position. + // + // We track: + // - logicalAnchor: the buffer row/col where selection started (source of truth) + // - startViewport: viewport offset when selection started (to compute scroll delta) + // - startRelativeVisualAnchor: visual position relative to viewport (for UI updates) + private _selectionState: { + logicalAnchor: { row: number; col: number } + startViewport: { offsetX: number; offsetY: number } + startRelativeVisualAnchor: { col: number; row: number } + } | null = null public readonly editBuffer: EditBuffer public readonly editorView: EditorView @@ -435,6 +449,9 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf let changed: boolean if (!localSelection?.isActive) { this.editorView.resetLocalSelection() + this._selectionState = null + changed = true + } else if (this._selectionState) { changed = true } else if (selection?.isStart) { changed = this.editorView.setLocalSelection( @@ -740,43 +757,71 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf if (!shiftPressed) { this._ctx.clearSelection() - this._selectionAnchorLogical = null + this._selectionState = null return } if (isBeforeMovement) { const visualCursor = this.editorView.getVisualCursor() + const logicalCursor = this.editBuffer.getCursorPosition() if (!this._ctx.hasSelection) { - this._selectionAnchorLogical = { - row: visualCursor.logicalRow, - col: visualCursor.logicalCol, + const viewport = this.editorView.getViewport() + this._selectionState = { + logicalAnchor: { row: logicalCursor.row, col: logicalCursor.col }, + startViewport: { offsetX: viewport.offsetX, offsetY: viewport.offsetY }, + startRelativeVisualAnchor: { col: visualCursor.visualCol, row: visualCursor.visualRow }, } const cursorX = this.x + visualCursor.visualCol const cursorY = this.y + visualCursor.visualRow this._ctx.startSelection(this, cursorX, cursorY) } } else { - if (!this._selectionAnchorLogical) return + if (!this._selectionState) return + + // Update the EditorView's native selection using logical buffer offsets. + // This selection is authoritative for text extraction and highlighting. + const logicalFocus = this.editBuffer.getCursorPosition() + const anchorOffset = this.editBuffer.positionToOffset( + this._selectionState.logicalAnchor.row, + this._selectionState.logicalAnchor.col, + ) + const focusOffset = this.editBuffer.positionToOffset(logicalFocus.row, logicalFocus.col) + + const start = Math.min(anchorOffset, focusOffset) + let end = Math.max(anchorOffset, focusOffset) + + // When selecting backwards, include the character at the anchor position. + if (focusOffset < anchorOffset) { + const lineInfo = this.editorView.getLogicalLineInfo() + const lineCount = lineInfo.lineStarts.length + const textEndOffset = + lineCount === 0 + ? 0 + : lineInfo.lineStarts[lineCount - 1] + lineInfo.lineWidths[lineCount - 1] + end = Math.min(end + 1, textEndOffset) + } - this.editorView.scrollToCursor() + this.editorView.setSelection(start, end, this._selectionBg, this._selectionFg) - // Get anchor visual coords for CURRENT viewport (handles viewport scroll) - const anchorVisual = this.editorView.getVisualCursorAtLogical( - this._selectionAnchorLogical.row, - this._selectionAnchorLogical.col, - ) + // Update the context's visual selection for UI coordination across renderables. + // Adjust anchor position to compensate for viewport scrolling since selection started. + const currentViewport = this.editorView.getViewport() + const deltaY = currentViewport.offsetY - this._selectionState.startViewport.offsetY + const deltaX = currentViewport.offsetX - this._selectionState.startViewport.offsetX - // Get focus visual coords (current cursor position) - const focusVisual = this.editorView.getVisualCursor() + const anchorVisualCol = this._selectionState.startRelativeVisualAnchor.col - deltaX + const anchorVisualRow = this._selectionState.startRelativeVisualAnchor.row - deltaY - const anchorX = this.x + anchorVisual.visualCol - const anchorY = this.y + Math.pow(0, anchorVisual.visualRow) + const anchorX = this.x + anchorVisualCol + const anchorY = this.y + anchorVisualRow + + this._ctx.getSelection()?.updateAnchor(anchorX, anchorY) + + const focusVisual = this.editorView.getVisualCursor() 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 8ca63dbfb..6ea0a63ab 100644 --- a/packages/core/src/renderables/__tests__/Textarea.selection.test.ts +++ b/packages/core/src/renderables/__tests__/Textarea.selection.test.ts @@ -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"), @@ -1351,6 +1351,9 @@ describe("Textarea - Selection Tests", () => { 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 () => { @@ -1368,6 +1371,9 @@ describe("Textarea - Selection Tests", () => { 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() @@ -1380,6 +1386,11 @@ describe("Textarea - Selection Tests", () => { 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 () => { diff --git a/packages/core/src/zig.ts b/packages/core/src/zig.ts index 350eb8e07..20afa3484 100644 --- a/packages/core/src/zig.ts +++ b/packages/core/src/zig.ts @@ -883,14 +883,6 @@ 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", @@ -1600,8 +1592,6 @@ 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 @@ -3176,16 +3166,6 @@ 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 00835f8f7..78671ba82 100644 --- a/packages/core/src/zig/editor-view.zig +++ b/packages/core/src/zig/editor-view.zig @@ -443,39 +443,6 @@ 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 3e8ca1059..e3470abe7 100644 --- a/packages/core/src/zig/lib.zig +++ b/packages/core/src/zig/lib.zig @@ -1186,21 +1186,6 @@ 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(); } From 048c28043904fdd2862dd8d3f0ff601c26c6c7ff Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Fri, 16 Jan 2026 10:30:34 +0100 Subject: [PATCH 4/9] fix(core): clear selection state when range is empty --- .../src/renderables/EditBufferRenderable.ts | 6 ++++ .../__tests__/Textarea.selection.test.ts | 33 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/packages/core/src/renderables/EditBufferRenderable.ts b/packages/core/src/renderables/EditBufferRenderable.ts index d1a8bce1d..f9b1faccf 100644 --- a/packages/core/src/renderables/EditBufferRenderable.ts +++ b/packages/core/src/renderables/EditBufferRenderable.ts @@ -802,6 +802,12 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf end = Math.min(end + 1, textEndOffset) } + if (start === end) { + this._ctx.clearSelection() + this._selectionState = null + return + } + this.editorView.setSelection(start, end, this._selectionBg, this._selectionFg) // Update the context's visual selection for UI coordination across renderables. diff --git a/packages/core/src/renderables/__tests__/Textarea.selection.test.ts b/packages/core/src/renderables/__tests__/Textarea.selection.test.ts index 6ea0a63ab..dde919c03 100644 --- a/packages/core/src/renderables/__tests__/Textarea.selection.test.ts +++ b/packages/core/src/renderables/__tests__/Textarea.selection.test.ts @@ -1320,6 +1320,39 @@ describe("Textarea - Selection Tests", () => { }) describe("Keyboard Selection with Viewport Scrolling", () => { + it("should scroll back to top after shift+end then shift+home in scrollable textarea", 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(0) + await renderOnce() + + const viewportStart = editor.editorView.getViewport() + expect(viewportStart.offsetY).toBe(0) + + currentMockInput.pressKey("END", { shift: true }) + await renderOnce() + + expect(editor.hasSelection()).toBe(true) + const viewportAfterEnd = editor.editorView.getViewport() + const totalLines = editor.editorView.getTotalVirtualLineCount() + const maxOffsetY = Math.max(0, totalLines - viewportAfterEnd.height) + expect(viewportAfterEnd.offsetY).toBe(maxOffsetY) + + currentMockInput.pressKey("HOME", { shift: true }) + await renderOnce() + + expect(editor.hasSelection()).toBe(false) + const viewportAfterHome = editor.editorView.getViewport() + expect(viewportAfterHome.offsetY).toBe(0) + }) + 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")}`) From 665d658fa4c2ff046c46daf4213b61c74261c534 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Fri, 16 Jan 2026 12:05:38 +0100 Subject: [PATCH 5/9] fix(textarea): improve scrollable select behaviour --- .../src/renderables/EditBufferRenderable.ts | 61 +++++++++++++++---- .../__tests__/Textarea.selection.test.ts | 50 +++++++++++++++ 2 files changed, 99 insertions(+), 12 deletions(-) diff --git a/packages/core/src/renderables/EditBufferRenderable.ts b/packages/core/src/renderables/EditBufferRenderable.ts index f9b1faccf..a73348758 100644 --- a/packages/core/src/renderables/EditBufferRenderable.ts +++ b/packages/core/src/renderables/EditBufferRenderable.ts @@ -752,6 +752,20 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf return this.editBuffer.getTextRangeByCoords(startRow, startCol, endRow, endCol) } + private getOffsetFromViewportCoords(col: number, row: number): number | null { + const lineInfo = this.editorView.getLineInfo() + if (lineInfo.lineStarts.length === 0) return null + + const rowIndex = Math.max(0, Math.min(Math.floor(row), lineInfo.lineStarts.length - 1)) + const lineStart = lineInfo.lineStarts[rowIndex] ?? 0 + const lineWidth = lineInfo.lineWidths[rowIndex] ?? 0 + const viewport = this.editorView.getViewport() + const colOffset = this._wrapMode === "none" ? viewport.offsetX : 0 + const colIndex = Math.max(0, Math.min(Math.floor(col + colOffset), lineWidth)) + + return lineStart + colIndex + } + protected updateSelectionForMovement(shiftPressed: boolean, isBeforeMovement: boolean): void { if (!this.selectable) return @@ -762,19 +776,42 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf } if (isBeforeMovement) { - const visualCursor = this.editorView.getVisualCursor() - const logicalCursor = this.editBuffer.getCursorPosition() - - if (!this._ctx.hasSelection) { - const viewport = this.editorView.getViewport() - this._selectionState = { - logicalAnchor: { row: logicalCursor.row, col: logicalCursor.col }, - startViewport: { offsetX: viewport.offsetX, offsetY: viewport.offsetY }, - startRelativeVisualAnchor: { col: visualCursor.visualCol, row: visualCursor.visualRow }, + if (!this._selectionState) { + const hasLocalSelection = this.editorView.hasSelection() + if (!this._ctx.hasSelection || !hasLocalSelection) { + const visualCursor = this.editorView.getVisualCursor() + const logicalCursor = this.editBuffer.getCursorPosition() + const viewport = this.editorView.getViewport() + this._selectionState = { + logicalAnchor: { row: logicalCursor.row, col: logicalCursor.col }, + startViewport: { offsetX: viewport.offsetX, offsetY: viewport.offsetY }, + startRelativeVisualAnchor: { col: visualCursor.visualCol, row: visualCursor.visualRow }, + } + const cursorX = this.x + visualCursor.visualCol + const cursorY = this.y + visualCursor.visualRow + this._ctx.startSelection(this, cursorX, cursorY) + } else { + const selection = this._ctx.getSelection() + const localSelection = selection ? convertGlobalToLocalSelection(selection, this.x, this.y) : null + if ( + localSelection && + localSelection.anchorX >= 0 && + localSelection.anchorX < this.width && + localSelection.anchorY >= 0 && + localSelection.anchorY < this.height + ) { + const anchorOffset = this.getOffsetFromViewportCoords(localSelection.anchorX, localSelection.anchorY) + const anchorPos = anchorOffset !== null ? this.editBuffer.offsetToPosition(anchorOffset) : null + if (anchorPos) { + const viewport = this.editorView.getViewport() + this._selectionState = { + logicalAnchor: { row: anchorPos.row, col: anchorPos.col }, + startViewport: { offsetX: viewport.offsetX, offsetY: viewport.offsetY }, + startRelativeVisualAnchor: { col: localSelection.anchorX, row: localSelection.anchorY }, + } + } + } } - const cursorX = this.x + visualCursor.visualCol - const cursorY = this.y + visualCursor.visualRow - this._ctx.startSelection(this, cursorX, cursorY) } } else { if (!this._selectionState) return diff --git a/packages/core/src/renderables/__tests__/Textarea.selection.test.ts b/packages/core/src/renderables/__tests__/Textarea.selection.test.ts index dde919c03..d0982a0b0 100644 --- a/packages/core/src/renderables/__tests__/Textarea.selection.test.ts +++ b/packages/core/src/renderables/__tests__/Textarea.selection.test.ts @@ -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", @@ -1353,6 +1375,34 @@ describe("Textarea - Selection Tests", () => { expect(viewportAfterHome.offsetY).toBe(0) }) + 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")}`) From 4f679bf0ce561bbeaed5860562a1bee998c22f61 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Fri, 16 Jan 2026 14:09:10 +0100 Subject: [PATCH 6/9] fix: scroll viewport to follow cursor during keyboard selection When using Shift+Home/End to select text after scrolling, the viewport wasn't following the cursor because native code skips ensureCursorVisible when a selection is active. Added ensureCursorVisibleForSelection() to handle viewport scrolling for keyboard-driven selection. --- .../src/renderables/EditBufferRenderable.ts | 40 +++++++++++++++++-- .../__tests__/Textarea.selection.test.ts | 27 ++++++++----- 2 files changed, 52 insertions(+), 15 deletions(-) diff --git a/packages/core/src/renderables/EditBufferRenderable.ts b/packages/core/src/renderables/EditBufferRenderable.ts index a73348758..c736dc4c1 100644 --- a/packages/core/src/renderables/EditBufferRenderable.ts +++ b/packages/core/src/renderables/EditBufferRenderable.ts @@ -766,6 +766,30 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf return lineStart + colIndex } + /** + * Ensure the cursor is visible within the viewport during keyboard selection. + * Native code skips ensureCursorVisible when there's an active selection, + * but for keyboard-driven selection we want the viewport to follow the cursor. + */ + protected ensureCursorVisibleForSelection(): void { + const viewport = this.editorView.getViewport() + const cursorRow = this.editBuffer.getCursorPosition().row + const totalLines = this.editorView.getTotalVirtualLineCount() + const marginLines = Math.max(1, Math.floor(viewport.height * this._scrollMargin)) + const maxOffsetY = Math.max(0, totalLines - viewport.height) + + let newOffsetY = viewport.offsetY + if (cursorRow < viewport.offsetY + marginLines) { + newOffsetY = Math.max(0, cursorRow - marginLines) + } else if (cursorRow >= viewport.offsetY + viewport.height - marginLines) { + newOffsetY = Math.min(maxOffsetY, cursorRow + marginLines - viewport.height + 1) + } + + if (newOffsetY !== viewport.offsetY) { + this.editorView.setViewport(viewport.offsetX, newOffsetY, viewport.width, viewport.height, false) + } + } + protected updateSelectionForMovement(shiftPressed: boolean, isBeforeMovement: boolean): void { if (!this.selectable) return @@ -814,7 +838,13 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf } } } else { - if (!this._selectionState) return + if (!this._selectionState) { + const visualCursor = this.editorView.getVisualCursor() + const cursorX = this.x + visualCursor.visualCol + const cursorY = this.y + visualCursor.visualRow + this._ctx.updateSelection(this, cursorX, cursorY) + return + } // Update the EditorView's native selection using logical buffer offsets. // This selection is authoritative for text extraction and highlighting. @@ -833,9 +863,7 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf const lineInfo = this.editorView.getLogicalLineInfo() const lineCount = lineInfo.lineStarts.length const textEndOffset = - lineCount === 0 - ? 0 - : lineInfo.lineStarts[lineCount - 1] + lineInfo.lineWidths[lineCount - 1] + lineCount === 0 ? 0 : lineInfo.lineStarts[lineCount - 1] + lineInfo.lineWidths[lineCount - 1] end = Math.min(end + 1, textEndOffset) } @@ -847,6 +875,10 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf this.editorView.setSelection(start, end, this._selectionBg, this._selectionFg) + // For keyboard selection, ensure viewport scrolls to show the cursor. + // Native code skips ensureCursorVisible when selection is active, so we do it here. + this.ensureCursorVisibleForSelection() + // Update the context's visual selection for UI coordination across renderables. // Adjust anchor position to compensate for viewport scrolling since selection started. const currentViewport = this.editorView.getViewport() diff --git a/packages/core/src/renderables/__tests__/Textarea.selection.test.ts b/packages/core/src/renderables/__tests__/Textarea.selection.test.ts index d0982a0b0..d3261c079 100644 --- a/packages/core/src/renderables/__tests__/Textarea.selection.test.ts +++ b/packages/core/src/renderables/__tests__/Textarea.selection.test.ts @@ -1342,7 +1342,7 @@ describe("Textarea - Selection Tests", () => { }) describe("Keyboard Selection with Viewport Scrolling", () => { - it("should scroll back to top after shift+end then shift+home in scrollable textarea", async () => { + 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"), @@ -1352,27 +1352,32 @@ describe("Textarea - Selection Tests", () => { }) editor.focus() - editor.gotoLine(0) await renderOnce() - const viewportStart = editor.editorView.getViewport() - expect(viewportStart.offsetY).toBe(0) + 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) - const viewportAfterEnd = editor.editorView.getViewport() - const totalLines = editor.editorView.getTotalVirtualLineCount() - const maxOffsetY = Math.max(0, totalLines - viewportAfterEnd.height) - expect(viewportAfterEnd.offsetY).toBe(maxOffsetY) currentMockInput.pressKey("HOME", { shift: true }) await renderOnce() - expect(editor.hasSelection()).toBe(false) - const viewportAfterHome = editor.editorView.getViewport() - expect(viewportAfterHome.offsetY).toBe(0) + 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 () => { From 85d726d95ad36c30c72663076c37295e0cb569c1 Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Fri, 16 Jan 2026 17:19:18 +0100 Subject: [PATCH 7/9] chore(core): cleanup code --- .../src/renderables/EditBufferRenderable.ts | 204 +++++++++--------- 1 file changed, 101 insertions(+), 103 deletions(-) diff --git a/packages/core/src/renderables/EditBufferRenderable.ts b/packages/core/src/renderables/EditBufferRenderable.ts index c736dc4c1..6d3e7e24a 100644 --- a/packages/core/src/renderables/EditBufferRenderable.ts +++ b/packages/core/src/renderables/EditBufferRenderable.ts @@ -69,13 +69,13 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf // position. // // We track: - // - logicalAnchor: the buffer row/col where selection started (source of truth) - // - startViewport: viewport offset when selection started (to compute scroll delta) - // - startRelativeVisualAnchor: visual position relative to viewport (for UI updates) - private _selectionState: { - logicalAnchor: { row: number; col: number } - startViewport: { offsetX: number; offsetY: number } - startRelativeVisualAnchor: { col: number; row: number } + // - anchorPos: buffer row/col where selection started (source of truth) + // - anchorViewport: viewport offset when selection started (to compute scroll delta) + // - anchorVisual: visual position relative to viewport (for UI updates) + private _keyboardSelection: { + anchorPos: { row: number; col: number } + anchorViewport: { offsetX: number; offsetY: number } + anchorVisual: { col: number; row: number } } | null = null public readonly editBuffer: EditBuffer @@ -449,9 +449,9 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf let changed: boolean if (!localSelection?.isActive) { this.editorView.resetLocalSelection() - this._selectionState = null + this._keyboardSelection = null changed = true - } else if (this._selectionState) { + } else if (this._keyboardSelection) { changed = true } else if (selection?.isStart) { changed = this.editorView.setLocalSelection( @@ -790,114 +790,112 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf } } - protected updateSelectionForMovement(shiftPressed: boolean, isBeforeMovement: boolean): void { - if (!this.selectable) return + private initKeyboardSelectionAnchor(): void { + if (this._keyboardSelection) return - if (!shiftPressed) { - this._ctx.clearSelection() - this._selectionState = null + const hasLocalSelection = this.editorView.hasSelection() + const visualCursor = this.editorView.getVisualCursor() + + if (!this._ctx.hasSelection || !hasLocalSelection) { + const logicalCursor = this.editBuffer.getCursorPosition() + const viewport = this.editorView.getViewport() + + this._keyboardSelection = { + anchorPos: { row: logicalCursor.row, col: logicalCursor.col }, + anchorViewport: { offsetX: viewport.offsetX, offsetY: viewport.offsetY }, + anchorVisual: { col: visualCursor.visualCol, row: visualCursor.visualRow }, + } + + this._ctx.startSelection(this, this.x + visualCursor.visualCol, this.y + visualCursor.visualRow) return } - if (isBeforeMovement) { - if (!this._selectionState) { - const hasLocalSelection = this.editorView.hasSelection() - if (!this._ctx.hasSelection || !hasLocalSelection) { - const visualCursor = this.editorView.getVisualCursor() - const logicalCursor = this.editBuffer.getCursorPosition() - const viewport = this.editorView.getViewport() - this._selectionState = { - logicalAnchor: { row: logicalCursor.row, col: logicalCursor.col }, - startViewport: { offsetX: viewport.offsetX, offsetY: viewport.offsetY }, - startRelativeVisualAnchor: { col: visualCursor.visualCol, row: visualCursor.visualRow }, - } - const cursorX = this.x + visualCursor.visualCol - const cursorY = this.y + visualCursor.visualRow - this._ctx.startSelection(this, cursorX, cursorY) - } else { - const selection = this._ctx.getSelection() - const localSelection = selection ? convertGlobalToLocalSelection(selection, this.x, this.y) : null - if ( - localSelection && - localSelection.anchorX >= 0 && - localSelection.anchorX < this.width && - localSelection.anchorY >= 0 && - localSelection.anchorY < this.height - ) { - const anchorOffset = this.getOffsetFromViewportCoords(localSelection.anchorX, localSelection.anchorY) - const anchorPos = anchorOffset !== null ? this.editBuffer.offsetToPosition(anchorOffset) : null - if (anchorPos) { - const viewport = this.editorView.getViewport() - this._selectionState = { - logicalAnchor: { row: anchorPos.row, col: anchorPos.col }, - startViewport: { offsetX: viewport.offsetX, offsetY: viewport.offsetY }, - startRelativeVisualAnchor: { col: localSelection.anchorX, row: localSelection.anchorY }, - } - } - } - } - } - } else { - if (!this._selectionState) { - const visualCursor = this.editorView.getVisualCursor() - const cursorX = this.x + visualCursor.visualCol - const cursorY = this.y + visualCursor.visualRow - this._ctx.updateSelection(this, cursorX, cursorY) - return - } + const selection = this._ctx.getSelection() + const localSelection = selection ? convertGlobalToLocalSelection(selection, this.x, this.y) : null + if (!localSelection) return - // Update the EditorView's native selection using logical buffer offsets. - // This selection is authoritative for text extraction and highlighting. - const logicalFocus = this.editBuffer.getCursorPosition() - const anchorOffset = this.editBuffer.positionToOffset( - this._selectionState.logicalAnchor.row, - this._selectionState.logicalAnchor.col, - ) - const focusOffset = this.editBuffer.positionToOffset(logicalFocus.row, logicalFocus.col) - - const start = Math.min(anchorOffset, focusOffset) - let end = Math.max(anchorOffset, focusOffset) - - // When selecting backwards, include the character at the anchor position. - if (focusOffset < anchorOffset) { - const lineInfo = this.editorView.getLogicalLineInfo() - const lineCount = lineInfo.lineStarts.length - const textEndOffset = - lineCount === 0 ? 0 : lineInfo.lineStarts[lineCount - 1] + lineInfo.lineWidths[lineCount - 1] - end = Math.min(end + 1, textEndOffset) - } + const inBounds = + localSelection.anchorX >= 0 && + localSelection.anchorX < this.width && + localSelection.anchorY >= 0 && + localSelection.anchorY < this.height + if (!inBounds) return - if (start === end) { - this._ctx.clearSelection() - this._selectionState = null - return - } + const anchorOffset = this.getOffsetFromViewportCoords(localSelection.anchorX, localSelection.anchorY) + const anchorPos = anchorOffset !== null ? this.editBuffer.offsetToPosition(anchorOffset) : null + if (!anchorPos) return + + const viewport = this.editorView.getViewport() + this._keyboardSelection = { + anchorPos: { row: anchorPos.row, col: anchorPos.col }, + anchorViewport: { offsetX: viewport.offsetX, offsetY: viewport.offsetY }, + anchorVisual: { col: localSelection.anchorX, row: localSelection.anchorY }, + } + } - this.editorView.setSelection(start, end, this._selectionBg, this._selectionFg) + private syncKeyboardSelection(): void { + if (!this._keyboardSelection) { + const visualCursor = this.editorView.getVisualCursor() + this._ctx.updateSelection(this, this.x + visualCursor.visualCol, this.y + visualCursor.visualRow) + return + } + + const logicalFocus = this.editBuffer.getCursorPosition() + const anchorOffset = this.editBuffer.positionToOffset( + this._keyboardSelection.anchorPos.row, + this._keyboardSelection.anchorPos.col, + ) + const focusOffset = this.editBuffer.positionToOffset(logicalFocus.row, logicalFocus.col) - // For keyboard selection, ensure viewport scrolls to show the cursor. - // Native code skips ensureCursorVisible when selection is active, so we do it here. - this.ensureCursorVisibleForSelection() + const start = Math.min(anchorOffset, focusOffset) + let end = Math.max(anchorOffset, focusOffset) - // Update the context's visual selection for UI coordination across renderables. - // Adjust anchor position to compensate for viewport scrolling since selection started. - const currentViewport = this.editorView.getViewport() - const deltaY = currentViewport.offsetY - this._selectionState.startViewport.offsetY - const deltaX = currentViewport.offsetX - this._selectionState.startViewport.offsetX + if (focusOffset < anchorOffset) { + const lineInfo = this.editorView.getLogicalLineInfo() + const lineCount = lineInfo.lineStarts.length + const textEndOffset = + lineCount === 0 ? 0 : lineInfo.lineStarts[lineCount - 1] + lineInfo.lineWidths[lineCount - 1] + end = Math.min(end + 1, textEndOffset) + } - const anchorVisualCol = this._selectionState.startRelativeVisualAnchor.col - deltaX - const anchorVisualRow = this._selectionState.startRelativeVisualAnchor.row - deltaY + if (start === end) { + this._ctx.clearSelection() + this._keyboardSelection = null + return + } + + this.editorView.setSelection(start, end, this._selectionBg, this._selectionFg) + this.ensureCursorVisibleForSelection() + + const viewport = this.editorView.getViewport() + const deltaY = viewport.offsetY - this._keyboardSelection.anchorViewport.offsetY + const deltaX = viewport.offsetX - this._keyboardSelection.anchorViewport.offsetX + + this._ctx + .getSelection() + ?.updateAnchor( + this.x + this._keyboardSelection.anchorVisual.col - deltaX, + this.y + this._keyboardSelection.anchorVisual.row - deltaY, + ) - const anchorX = this.x + anchorVisualCol - const anchorY = this.y + anchorVisualRow + const focusVisual = this.editorView.getVisualCursor() + this._ctx.updateSelection(this, this.x + focusVisual.visualCol, this.y + focusVisual.visualRow) + } - this._ctx.getSelection()?.updateAnchor(anchorX, anchorY) + protected updateSelectionForMovement(shiftPressed: boolean, isBeforeMovement: boolean): void { + if (!this.selectable) return - const focusVisual = this.editorView.getVisualCursor() - const focusX = this.x + focusVisual.visualCol - const focusY = this.y + focusVisual.visualRow + if (!shiftPressed) { + this._ctx.clearSelection() + this._keyboardSelection = null + return + } - this._ctx.updateSelection(this, focusX, focusY) + if (isBeforeMovement) { + this.initKeyboardSelectionAnchor() + return } + + this.syncKeyboardSelection() } } From f3f6b979b58872e2fbd8381612b3f9431bf27a5e Mon Sep 17 00:00:00 2001 From: Sebastian Herrlinger Date: Sat, 17 Jan 2026 23:15:51 +0100 Subject: [PATCH 8/9] native optional follows cursor --- packages/core/src/editor-view.ts | 4 + packages/core/src/lib/selection.ts | 14 -- .../src/renderables/EditBufferRenderable.ts | 163 ++---------------- packages/core/src/zig.ts | 12 +- packages/core/src/zig/editor-view.zig | 10 +- packages/core/src/zig/lib.zig | 7 +- 6 files changed, 38 insertions(+), 172 deletions(-) diff --git a/packages/core/src/editor-view.ts b/packages/core/src/editor-view.ts index 2fb9f45fa..8583a5818 100644 --- a/packages/core/src/editor-view.ts +++ b/packages/core/src/editor-view.ts @@ -110,6 +110,7 @@ export class EditorView { bgColor?: RGBA, fgColor?: RGBA, updateCursor?: boolean, + followCursor?: boolean, ): boolean { this.guard() return this.lib.editorViewSetLocalSelection( @@ -121,6 +122,7 @@ export class EditorView { bgColor || null, fgColor || null, updateCursor ?? false, + followCursor ?? false, ) } @@ -132,6 +134,7 @@ export class EditorView { bgColor?: RGBA, fgColor?: RGBA, updateCursor?: boolean, + followCursor?: boolean, ): boolean { this.guard() return this.lib.editorViewUpdateLocalSelection( @@ -143,6 +146,7 @@ export class EditorView { bgColor || null, fgColor || null, updateCursor ?? false, + followCursor ?? false, ) } diff --git a/packages/core/src/lib/selection.ts b/packages/core/src/lib/selection.ts index a4f840127..d0a695ea3 100644 --- a/packages/core/src/lib/selection.ts +++ b/packages/core/src/lib/selection.ts @@ -21,13 +21,6 @@ class SelectionAnchor { get y(): number { return this.renderable.y + this.relativeY } - - // Updates the anchor's absolute position. Stores coordinates relative to the - // renderable so the anchor moves correctly when the renderable moves. - updatePosition(absoluteX: number, absoluteY: number): void { - this.relativeX = absoluteX - this.renderable.x - this.relativeY = absoluteY - this.renderable.y - } } export class Selection { @@ -56,13 +49,6 @@ export class Selection { return { x: this._anchor.x, y: this._anchor.y } } - // Repositions the selection anchor. Use this when the viewport scrolls during - // keyboard selection to keep the anchor visually aligned with the original - // logical position. - 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 6d3e7e24a..3ee28d2d9 100644 --- a/packages/core/src/renderables/EditBufferRenderable.ts +++ b/packages/core/src/renderables/EditBufferRenderable.ts @@ -62,22 +62,6 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf private _autoScrollAccumulator: number = 0 private _scrollSpeed: number = 16 - // Tracks keyboard selection state across viewport scrolls. - // - // When shift-selecting, the viewport may scroll (e.g., shift+super+up jumps - // to buffer start), but the selection anchor must stay at the original cursor - // position. - // - // We track: - // - anchorPos: buffer row/col where selection started (source of truth) - // - anchorViewport: viewport offset when selection started (to compute scroll delta) - // - anchorVisual: visual position relative to viewport (for UI updates) - private _keyboardSelection: { - anchorPos: { row: number; col: number } - anchorViewport: { offsetX: number; offsetY: number } - anchorVisual: { col: number; row: number } - } | null = null - public readonly editBuffer: EditBuffer public readonly editorView: EditorView @@ -445,13 +429,11 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf this.lastLocalSelection = localSelection const updateCursor = true + const followCursor = selection?.isSelecting ?? false let changed: boolean if (!localSelection?.isActive) { this.editorView.resetLocalSelection() - this._keyboardSelection = null - changed = true - } else if (this._keyboardSelection) { changed = true } else if (selection?.isStart) { changed = this.editorView.setLocalSelection( @@ -462,6 +444,7 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf this._selectionBg, this._selectionFg, updateCursor, + followCursor, ) } else { changed = this.editorView.updateLocalSelection( @@ -472,6 +455,7 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf this._selectionBg, this._selectionFg, updateCursor, + followCursor, ) } @@ -752,150 +736,25 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf return this.editBuffer.getTextRangeByCoords(startRow, startCol, endRow, endCol) } - private getOffsetFromViewportCoords(col: number, row: number): number | null { - const lineInfo = this.editorView.getLineInfo() - if (lineInfo.lineStarts.length === 0) return null - - const rowIndex = Math.max(0, Math.min(Math.floor(row), lineInfo.lineStarts.length - 1)) - const lineStart = lineInfo.lineStarts[rowIndex] ?? 0 - const lineWidth = lineInfo.lineWidths[rowIndex] ?? 0 - const viewport = this.editorView.getViewport() - const colOffset = this._wrapMode === "none" ? viewport.offsetX : 0 - const colIndex = Math.max(0, Math.min(Math.floor(col + colOffset), lineWidth)) - - return lineStart + colIndex - } - - /** - * Ensure the cursor is visible within the viewport during keyboard selection. - * Native code skips ensureCursorVisible when there's an active selection, - * but for keyboard-driven selection we want the viewport to follow the cursor. - */ - protected ensureCursorVisibleForSelection(): void { - const viewport = this.editorView.getViewport() - const cursorRow = this.editBuffer.getCursorPosition().row - const totalLines = this.editorView.getTotalVirtualLineCount() - const marginLines = Math.max(1, Math.floor(viewport.height * this._scrollMargin)) - const maxOffsetY = Math.max(0, totalLines - viewport.height) - - let newOffsetY = viewport.offsetY - if (cursorRow < viewport.offsetY + marginLines) { - newOffsetY = Math.max(0, cursorRow - marginLines) - } else if (cursorRow >= viewport.offsetY + viewport.height - marginLines) { - newOffsetY = Math.min(maxOffsetY, cursorRow + marginLines - viewport.height + 1) - } - - if (newOffsetY !== viewport.offsetY) { - this.editorView.setViewport(viewport.offsetX, newOffsetY, viewport.width, viewport.height, false) - } - } - - private initKeyboardSelectionAnchor(): void { - if (this._keyboardSelection) return - - const hasLocalSelection = this.editorView.hasSelection() - const visualCursor = this.editorView.getVisualCursor() - - if (!this._ctx.hasSelection || !hasLocalSelection) { - const logicalCursor = this.editBuffer.getCursorPosition() - const viewport = this.editorView.getViewport() - - this._keyboardSelection = { - anchorPos: { row: logicalCursor.row, col: logicalCursor.col }, - anchorViewport: { offsetX: viewport.offsetX, offsetY: viewport.offsetY }, - anchorVisual: { col: visualCursor.visualCol, row: visualCursor.visualRow }, - } - - this._ctx.startSelection(this, this.x + visualCursor.visualCol, this.y + visualCursor.visualRow) - return - } - - const selection = this._ctx.getSelection() - const localSelection = selection ? convertGlobalToLocalSelection(selection, this.x, this.y) : null - if (!localSelection) return - - const inBounds = - localSelection.anchorX >= 0 && - localSelection.anchorX < this.width && - localSelection.anchorY >= 0 && - localSelection.anchorY < this.height - if (!inBounds) return - - const anchorOffset = this.getOffsetFromViewportCoords(localSelection.anchorX, localSelection.anchorY) - const anchorPos = anchorOffset !== null ? this.editBuffer.offsetToPosition(anchorOffset) : null - if (!anchorPos) return - - const viewport = this.editorView.getViewport() - this._keyboardSelection = { - anchorPos: { row: anchorPos.row, col: anchorPos.col }, - anchorViewport: { offsetX: viewport.offsetX, offsetY: viewport.offsetY }, - anchorVisual: { col: localSelection.anchorX, row: localSelection.anchorY }, - } - } - - private syncKeyboardSelection(): void { - if (!this._keyboardSelection) { - const visualCursor = this.editorView.getVisualCursor() - this._ctx.updateSelection(this, this.x + visualCursor.visualCol, this.y + visualCursor.visualRow) - return - } - - const logicalFocus = this.editBuffer.getCursorPosition() - const anchorOffset = this.editBuffer.positionToOffset( - this._keyboardSelection.anchorPos.row, - this._keyboardSelection.anchorPos.col, - ) - const focusOffset = this.editBuffer.positionToOffset(logicalFocus.row, logicalFocus.col) - - const start = Math.min(anchorOffset, focusOffset) - let end = Math.max(anchorOffset, focusOffset) - - if (focusOffset < anchorOffset) { - const lineInfo = this.editorView.getLogicalLineInfo() - const lineCount = lineInfo.lineStarts.length - const textEndOffset = - lineCount === 0 ? 0 : lineInfo.lineStarts[lineCount - 1] + lineInfo.lineWidths[lineCount - 1] - end = Math.min(end + 1, textEndOffset) - } - - if (start === end) { - this._ctx.clearSelection() - this._keyboardSelection = null - return - } - - this.editorView.setSelection(start, end, this._selectionBg, this._selectionFg) - this.ensureCursorVisibleForSelection() - - const viewport = this.editorView.getViewport() - const deltaY = viewport.offsetY - this._keyboardSelection.anchorViewport.offsetY - const deltaX = viewport.offsetX - this._keyboardSelection.anchorViewport.offsetX - - this._ctx - .getSelection() - ?.updateAnchor( - this.x + this._keyboardSelection.anchorVisual.col - deltaX, - this.y + this._keyboardSelection.anchorVisual.row - deltaY, - ) - - const focusVisual = this.editorView.getVisualCursor() - this._ctx.updateSelection(this, this.x + focusVisual.visualCol, this.y + focusVisual.visualRow) - } - protected updateSelectionForMovement(shiftPressed: boolean, isBeforeMovement: boolean): void { if (!this.selectable) return if (!shiftPressed) { this._ctx.clearSelection() - this._keyboardSelection = null return } + const visualCursor = this.editorView.getVisualCursor() + const cursorX = this.x + visualCursor.visualCol + const cursorY = this.y + visualCursor.visualRow + if (isBeforeMovement) { - this.initKeyboardSelectionAnchor() + if (!this._ctx.hasSelection) { + this._ctx.startSelection(this, cursorX, cursorY) + } return } - this.syncKeyboardSelection() + this._ctx.updateSelection(this, cursorX, cursorY) } } diff --git a/packages/core/src/zig.ts b/packages/core/src/zig.ts index 20afa3484..566947f96 100644 --- a/packages/core/src/zig.ts +++ b/packages/core/src/zig.ts @@ -864,7 +864,7 @@ 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: { @@ -872,7 +872,7 @@ function getOpenTUILib(libPath?: string) { 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: { @@ -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, @@ -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 } @@ -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 @@ -3100,6 +3105,7 @@ class FFIRenderLib implements RenderLib { bg, fg, updateCursor, + followCursor, ) } @@ -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 @@ -3130,6 +3137,7 @@ class FFIRenderLib implements RenderLib { bg, fg, updateCursor, + followCursor, ) } diff --git a/packages/core/src/zig/editor-view.zig b/packages/core/src/zig/editor-view.zig index 78671ba82..933ccb5ce 100644 --- a/packages/core/src/zig/editor-view.zig +++ b/packages/core/src/zig/editor-view.zig @@ -39,6 +39,7 @@ pub const EditorView = struct { edit_buffer: *EditBuffer, // Reference to the EditBuffer (not owned) scroll_margin: f32, // Fraction of viewport height (0.0-0.5) to keep cursor away from edges desired_visual_col: ?u32, // Preserved visual column for visual up/down navigation + selection_follow_cursor: bool, // Keep viewport synced during selection cursor_changed_listener: event_emitter.EventEmitter(eb.EditBufferEvent).Listener, placeholder_buffer: ?*UnifiedTextBuffer, @@ -53,7 +54,7 @@ pub const EditorView = struct { self.desired_visual_col = null; const has_selection = self.text_buffer_view.selection != null; - if (!has_selection) { + if (!has_selection or self.selection_follow_cursor) { const cursor = self.edit_buffer.getPrimaryCursor(); const vcursor = self.logicalToVisualCursor(cursor.row, cursor.col); self.ensureCursorVisible(vcursor.visual_row); @@ -73,6 +74,7 @@ pub const EditorView = struct { .edit_buffer = edit_buffer, .scroll_margin = 0.15, // Default 15% margin .desired_visual_col = null, + .selection_follow_cursor = false, .cursor_changed_listener = .{ .ctx = undefined, // Will be set below .handle = onCursorChanged, @@ -177,6 +179,10 @@ pub const EditorView = struct { self.scroll_margin = @max(0.0, @min(0.5, margin)); } + pub fn setSelectionFollowCursor(self: *EditorView, enabled: bool) void { + self.selection_follow_cursor = enabled; + } + /// Ensure the cursor is visible within the viewport, adjusting viewport.y and viewport.x if needed /// cursor_line: The virtual line index where the cursor is located pub fn ensureCursorVisible(self: *EditorView, cursor_line: u32) void { @@ -238,7 +244,7 @@ pub const EditorView = struct { const has_selection = self.text_buffer_view.selection != null; - if (!has_selection) { + if (!has_selection or self.selection_follow_cursor) { const cursor = self.edit_buffer.getPrimaryCursor(); const vcursor = self.logicalToVisualCursor(cursor.row, cursor.col); self.ensureCursorVisible(vcursor.visual_row); diff --git a/packages/core/src/zig/lib.zig b/packages/core/src/zig/lib.zig index e3470abe7..8bf0a847e 100644 --- a/packages/core/src/zig/lib.zig +++ b/packages/core/src/zig/lib.zig @@ -1134,9 +1134,10 @@ export fn editorViewGetSelection(view: *editor_view.EditorView) u64 { return view.text_buffer_view.packSelectionInfo(); } -export fn editorViewSetLocalSelection(view: *editor_view.EditorView, anchorX: i32, anchorY: i32, focusX: i32, focusY: i32, bgColor: ?[*]const f32, fgColor: ?[*]const f32, updateCursor: bool) bool { +export fn editorViewSetLocalSelection(view: *editor_view.EditorView, anchorX: i32, anchorY: i32, focusX: i32, focusY: i32, bgColor: ?[*]const f32, fgColor: ?[*]const f32, updateCursor: bool, followCursor: bool) bool { const bg = if (bgColor) |bgPtr| utils.f32PtrToRGBA(bgPtr) else null; const fg = if (fgColor) |fgPtr| utils.f32PtrToRGBA(fgPtr) else null; + view.setSelectionFollowCursor(followCursor); return view.setLocalSelection(anchorX, anchorY, focusX, focusY, bg, fg, updateCursor); } @@ -1146,13 +1147,15 @@ export fn editorViewUpdateSelection(view: *editor_view.EditorView, end: u32, bgC view.updateSelection(end, bg, fg); } -export fn editorViewUpdateLocalSelection(view: *editor_view.EditorView, anchorX: i32, anchorY: i32, focusX: i32, focusY: i32, bgColor: ?[*]const f32, fgColor: ?[*]const f32, updateCursor: bool) bool { +export fn editorViewUpdateLocalSelection(view: *editor_view.EditorView, anchorX: i32, anchorY: i32, focusX: i32, focusY: i32, bgColor: ?[*]const f32, fgColor: ?[*]const f32, updateCursor: bool, followCursor: bool) bool { const bg = if (bgColor) |bgPtr| utils.f32PtrToRGBA(bgPtr) else null; const fg = if (fgColor) |fgPtr| utils.f32PtrToRGBA(fgPtr) else null; + view.setSelectionFollowCursor(followCursor); return view.updateLocalSelection(anchorX, anchorY, focusX, focusY, bg, fg, updateCursor); } export fn editorViewResetLocalSelection(view: *editor_view.EditorView) void { + view.setSelectionFollowCursor(false); view.text_buffer_view.resetLocalSelection(); } From 27e70dc76bdcefbd69297a61d2ba427f7b026fb4 Mon Sep 17 00:00:00 2001 From: Sebastian Herrlinger Date: Sat, 17 Jan 2026 23:39:56 +0100 Subject: [PATCH 9/9] distinct keyboard selection --- packages/core/src/renderables/EditBufferRenderable.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/core/src/renderables/EditBufferRenderable.ts b/packages/core/src/renderables/EditBufferRenderable.ts index 3ee28d2d9..8afe4d378 100644 --- a/packages/core/src/renderables/EditBufferRenderable.ts +++ b/packages/core/src/renderables/EditBufferRenderable.ts @@ -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 @@ -429,10 +430,11 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf this.lastLocalSelection = localSelection const updateCursor = true - const followCursor = selection?.isSelecting ?? false + const followCursor = this._keyboardSelectionActive let changed: boolean if (!localSelection?.isActive) { + this._keyboardSelectionActive = false this.editorView.resetLocalSelection() changed = true } else if (selection?.isStart) { @@ -472,6 +474,7 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf this._autoScrollVelocity = 0 } } else { + this._keyboardSelectionActive = false this._autoScrollVelocity = 0 this._autoScrollAccumulator = 0 } @@ -740,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