diff --git a/packages/core/src/editor-view.ts b/packages/core/src/editor-view.ts index 44608fc04..0892976b8 100644 --- a/packages/core/src/editor-view.ts +++ b/packages/core/src/editor-view.ts @@ -73,6 +73,11 @@ export class EditorView { this.lib.editorViewSetWrapMode(this.viewPtr, mode) } + public setWrapIndent(indent: "none" | "same"): void { + this.guard() + this.lib.editorViewSetWrapIndent(this.viewPtr, indent) + } + public getVirtualLineCount(): number { this.guard() return this.lib.editorViewGetVirtualLineCount(this.viewPtr) diff --git a/packages/core/src/renderables/EditBufferRenderable.ts b/packages/core/src/renderables/EditBufferRenderable.ts index f5f6bcc17..027049461 100644 --- a/packages/core/src/renderables/EditBufferRenderable.ts +++ b/packages/core/src/renderables/EditBufferRenderable.ts @@ -7,9 +7,12 @@ import type { RenderContext, Highlight, CursorStyleOptions, LineInfoProvider, Li import type { OptimizedBuffer } from "../buffer.js" import type { SyntaxStyle } from "../syntax-style.js" import { NativeMeasureTargetKind, resolveRenderLib, type NativeRenderableHandle } from "../zig.js" +import type { WrapIndent } from "./TextBufferRenderable.js" const BrandedEditBufferRenderable: unique symbol = Symbol.for("@opentui/core/EditBufferRenderable") +export type { WrapIndent } + export type EditorCapture = "escape" | "navigate" | "submit" | "tab" export interface EditorTraits { @@ -54,6 +57,7 @@ export interface EditBufferOptions extends RenderableOptions { fg?: string | RGBA bg?: string | RGBA @@ -16,6 +18,7 @@ export interface TextBufferOptions extends RenderableOptions { expect(editor.wrapMode).toBe("word") }) + it("should handle wrapIndent property", async () => { + const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, { + initialValue: " " + "a".repeat(80), + width: 20, + height: 10, + wrapMode: "char", + wrapIndent: "none", + }) + + expect(editor.wrapIndent).toBe("none") + editor.wrapIndent = "same" + expect(editor.wrapIndent).toBe("same") + expect(editor.editorView.getVirtualLineCount()).toBeGreaterThan(1) + }) + it("should render with tab indicator correctly", async () => { const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, { initialValue: "Line 1\tTabbed\nLine 2\t\tDouble tab", diff --git a/packages/core/src/text-buffer-view.ts b/packages/core/src/text-buffer-view.ts index 59c917eeb..050b679ba 100644 --- a/packages/core/src/text-buffer-view.ts +++ b/packages/core/src/text-buffer-view.ts @@ -116,6 +116,11 @@ export class TextBufferView { this.lib.textBufferViewSetWrapMode(this.viewPtr, mode) } + public setWrapIndent(indent: "none" | "same"): void { + this.guard() + this.lib.textBufferViewSetWrapIndent(this.viewPtr, indent) + } + public setFirstLineOffset(offset: number): void { this.guard() this.lib.textBufferViewSetFirstLineOffset(this.viewPtr, offset) diff --git a/packages/core/src/zig.ts b/packages/core/src/zig.ts index dfa4d35b0..3c3bd3b66 100644 --- a/packages/core/src/zig.ts +++ b/packages/core/src/zig.ts @@ -854,6 +854,10 @@ function getOpenTUILib(libPath?: string) { args: ["u32", "u8"], returns: "void", }, + textBufferViewSetWrapIndent: { + args: ["u32", "u8"], + returns: "void", + }, textBufferViewSetFirstLineOffset: { args: ["u32", "u32"], returns: "void", @@ -940,6 +944,10 @@ function getOpenTUILib(libPath?: string) { args: ["u32", "u8"], returns: "void", }, + editorViewSetWrapIndent: { + args: ["u32", "u8"], + returns: "void", + }, editorViewGetVirtualLineCount: { args: ["u32"], returns: "u32", @@ -2475,6 +2483,7 @@ export interface RenderLib extends AudioEngineLib { textBufferViewResetLocalSelection: (view: TextBufferViewHandle) => void textBufferViewSetWrapWidth: (view: TextBufferViewHandle, width: number) => void textBufferViewSetWrapMode: (view: TextBufferViewHandle, mode: "none" | "char" | "word") => void + textBufferViewSetWrapIndent: (view: TextBufferViewHandle, indent: "none" | "same") => void textBufferViewSetFirstLineOffset: (view: TextBufferViewHandle, offset: number) => void textBufferViewSetViewportSize: (view: TextBufferViewHandle, width: number, height: number) => void textBufferViewSetViewport: (view: TextBufferViewHandle, x: number, y: number, width: number, height: number) => void @@ -2575,6 +2584,7 @@ export interface RenderLib extends AudioEngineLib { editorViewGetViewport: (view: EditorViewHandle) => { offsetY: number; offsetX: number; height: number; width: number } editorViewSetScrollMargin: (view: EditorViewHandle, margin: number) => void editorViewSetWrapMode: (view: EditorViewHandle, mode: "none" | "char" | "word") => void + editorViewSetWrapIndent: (view: EditorViewHandle, indent: "none" | "same") => void editorViewGetVirtualLineCount: (view: EditorViewHandle) => number editorViewGetTotalVirtualLineCount: (view: EditorViewHandle) => number editorViewGetTextBufferView: (view: EditorViewHandle) => TextBufferViewHandle @@ -4335,6 +4345,11 @@ class FFIRenderLib implements RenderLib { this.opentui.symbols.textBufferViewSetWrapMode(view, modeValue) } + public textBufferViewSetWrapIndent(view: Pointer, indent: "none" | "same"): void { + const indentValue = indent === "same" ? 1 : 0 + this.opentui.symbols.textBufferViewSetWrapIndent(view, indentValue) + } + public textBufferViewSetFirstLineOffset(view: Pointer, offset: number): void { this.opentui.symbols.textBufferViewSetFirstLineOffset(view, offset) } @@ -4592,6 +4607,11 @@ class FFIRenderLib implements RenderLib { this.opentui.symbols.editorViewSetWrapMode(view, modeValue) } + public editorViewSetWrapIndent(view: Pointer, indent: "none" | "same"): void { + const indentValue = indent === "same" ? 1 : 0 + this.opentui.symbols.editorViewSetWrapIndent(view, indentValue) + } + public editorViewGetVirtualLineCount(view: Pointer): number { return this.opentui.symbols.editorViewGetVirtualLineCount(view) } diff --git a/packages/core/src/zig/buffer.zig b/packages/core/src/zig/buffer.zig index 72cc290a0..1858c2153 100644 --- a/packages/core/src/zig/buffer.zig +++ b/packages/core/src/zig/buffer.zig @@ -1674,6 +1674,30 @@ pub const OptimizedBuffer = struct { const defaultBg = lineBg; const defaultAttributes = lineAttributes; + // Soft-wrap continuation indent: fill pad cells with line background, then offset content. + if (vline.pad_cols > 0) { + var pad_i: u32 = 0; + while (pad_i < vline.pad_cols) : (pad_i += 1) { + const pad_x = x + @as(i32, @intCast(pad_i)); + if (pad_x < 0 or pad_x >= @as(i32, @intCast(self.width))) continue; + if (currentY < 0 or currentY >= @as(i32, @intCast(self.height))) continue; + if (!self.isPointInScissor(pad_x, currentY)) continue; + var pad_bg = defaultBg; + if (prefilledViewportBg) |prefilledBg| { + if (rgbaEqual(pad_bg, prefilledBg.bg)) { + pad_bg[3] = pad_bg[3] & 0xff00; + } + } + self.set(@intCast(pad_x), @intCast(currentY), .{ + .char = ' ', + .fg = defaultFg, + .bg = pad_bg, + .attributes = 0, + }); + } + currentX = x + @as(i32, @intCast(vline.pad_cols)); + } + // Find the span that contains the starting render position (col_offset + horizontal_offset) const start_col = col_offset + horizontal_offset; while (span_idx < spans.len and spans[span_idx].next_col <= start_col) { diff --git a/packages/core/src/zig/editor-view.zig b/packages/core/src/zig/editor-view.zig index 88b096bcc..7e1a72d60 100644 --- a/packages/core/src/zig/editor-view.zig +++ b/packages/core/src/zig/editor-view.zig @@ -411,6 +411,10 @@ pub const EditorView = struct { self.text_buffer_view.setWrapMode(mode); } + pub fn setWrapIndent(self: *EditorView, indent: tb.WrapIndent) void { + self.text_buffer_view.setWrapIndent(indent); + } + pub fn getPrimaryCursor(self: *const EditorView) eb.Cursor { return self.edit_buffer.getPrimaryCursor(); } @@ -432,30 +436,46 @@ pub const EditorView = struct { // VisualCursor - Wrapping-aware cursor translation // ============================================================================ - /// Returns viewport-relative visual coordinates for external API consumers + /// Returns viewport-relative visual coordinates for external API consumers. + /// visual_col includes soft-wrap continuation pad so it matches painted geometry. pub fn getVisualCursor(self: *EditorView) VisualCursor { self.updateBeforeRender(); const cursor = self.edit_buffer.getPrimaryCursor(); const vcursor = self.logicalToVisualCursor(cursor.row, cursor.col); + const pad_cols = self.padColsForVisualRow(vcursor.visual_row); // Convert absolute visual coordinates to viewport-relative for the API - const vp = self.text_buffer_view.getViewport() orelse return vcursor; + const vp = self.text_buffer_view.getViewport() orelse { + return .{ + .visual_row = vcursor.visual_row, + .visual_col = vcursor.visual_col + pad_cols, + .logical_row = vcursor.logical_row, + .logical_col = vcursor.logical_col, + .offset = vcursor.offset, + }; + }; 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) + const content_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 .{ .visual_row = viewport_relative_row, - .visual_col = viewport_relative_col, + .visual_col = content_col + pad_cols, .logical_row = vcursor.logical_row, .logical_col = vcursor.logical_col, .offset = vcursor.offset, }; } + fn padColsForVisualRow(self: *EditorView, visual_row: u32) u32 { + const vlines = self.text_buffer_view.virtual_lines.items; + if (visual_row >= vlines.len) return 0; + return vlines[visual_row].pad_cols; + } + /// 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 { @@ -691,7 +711,8 @@ pub const EditorView = struct { return .{ .visual_row = vcursor.visual_row, - .visual_col = 0, + // Viewport column of first content cell (after continuation pad). + .visual_col = vline.pad_cols, .logical_row = logical_row, .logical_col = logical_col, .offset = offset, @@ -723,7 +744,7 @@ pub const EditorView = struct { return .{ .visual_row = vcursor.visual_row, - .visual_col = target_visual_col, + .visual_col = vline.pad_cols + target_visual_col, .logical_row = logical_row, .logical_col = logical_col, .offset = offset, diff --git a/packages/core/src/zig/lib.zig b/packages/core/src/zig/lib.zig index 0b90d1c27..472dbdc45 100644 --- a/packages/core/src/zig/lib.zig +++ b/packages/core/src/zig/lib.zig @@ -1841,6 +1841,16 @@ export fn textBufferViewSetWrapMode(view_handle: NativeHandle, mode: u8) void { object_ptr.setWrapMode(wrapMode); } +export fn textBufferViewSetWrapIndent(view_handle: NativeHandle, indent: u8) void { + const object_ptr = acquireTextBufferView(view_handle) orelse return; + const wrapIndent: text_buffer.WrapIndent = switch (indent) { + 0 => .none, + 1 => .same, + else => .none, + }; + object_ptr.setWrapIndent(wrapIndent); +} + export fn textBufferViewSetFirstLineOffset(view_handle: NativeHandle, offset: u32) void { const object_ptr = acquireTextBufferView(view_handle) orelse return; object_ptr.setFirstLineOffset(offset); @@ -2407,6 +2417,16 @@ export fn editorViewSetWrapMode(view_handle: NativeHandle, mode: u8) void { object_ptr.setWrapMode(wrapMode); } +export fn editorViewSetWrapIndent(view_handle: NativeHandle, indent: u8) void { + const object_ptr = acquireEditorView(view_handle) orelse return; + const wrapIndent: text_buffer.WrapIndent = switch (indent) { + 0 => .none, + 1 => .same, + else => .none, + }; + object_ptr.setWrapIndent(wrapIndent); +} + // EditorView selection methods - delegate to TextBufferView export fn editorViewSetSelection(view_handle: NativeHandle, start: u32, end: u32, bgColor: ?[*]const u16, fgColor: ?[*]const u16) void { const object_ptr = acquireEditorView(view_handle) orelse return; diff --git a/packages/core/src/zig/tests/editor-view_test.zig b/packages/core/src/zig/tests/editor-view_test.zig index 6f0551109..2b59c24a0 100644 --- a/packages/core/src/zig/tests/editor-view_test.zig +++ b/packages/core/src/zig/tests/editor-view_test.zig @@ -3451,3 +3451,91 @@ test "EditorView - mouse selection focus outside buffer bounds clamps correctly" // Cursor should be clamped to last line (line 9) try std.testing.expectEqual(@as(u32, 9), cursor.row); } + +test "EditorView wrap indent - moveDownVisual lands on content start" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + const link_pool = link.initGlobalLinkPool(std.testing.allocator); + defer link.deinitGlobalLinkPool(); + + var eb = try EditBuffer.init(std.testing.allocator, pool, link_pool, .wcwidth, null); + defer eb.deinit(); + + var ev = try EditorView.init(std.testing.allocator, eb, 20, 10); + defer ev.deinit(); + + ev.setWrapMode(.char); + ev.setWrapIndent(.same); + + var text_buf: [84]u8 = undefined; + @memset(text_buf[0..4], ' '); + @memset(text_buf[4..], 'a'); + try eb.setText(&text_buf); + + // From visual col 0: ↓ must land on first content column of the continuation + // (never inside the pad). + try eb.setCursor(0, 0); + var before = ev.getVisualCursor(); + try std.testing.expectEqual(@as(u32, 0), before.visual_row); + try std.testing.expectEqual(@as(u32, 0), before.visual_col); + + ev.moveDownVisual(); + + var after = ev.getVisualCursor(); + try std.testing.expectEqual(@as(u32, 1), after.visual_row); + try std.testing.expectEqual(@as(u32, 0), after.logical_row); + try std.testing.expectEqual(@as(u32, 20), after.logical_col); + try std.testing.expectEqual(@as(u32, 4), after.visual_col); + + const sol = ev.getVisualSOL(); + try std.testing.expectEqual(@as(u32, 4), sol.visual_col); + try std.testing.expectEqual(@as(u32, 20), sol.logical_col); + + // From end of first visual row: sticky column stays on-screen and outside the pad. + try eb.setCursor(0, 19); + before = ev.getVisualCursor(); + try std.testing.expectEqual(@as(u32, 0), before.visual_row); + ev.moveDownVisual(); + after = ev.getVisualCursor(); + try std.testing.expectEqual(@as(u32, 1), after.visual_row); + try std.testing.expect(after.visual_col >= 4); + try std.testing.expect(after.logical_col >= 20); +} + +test "EditorView wrap indent - edit reflow matches fresh setText" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + const link_pool = link.initGlobalLinkPool(std.testing.allocator); + defer link.deinitGlobalLinkPool(); + + var eb = try EditBuffer.init(std.testing.allocator, pool, link_pool, .wcwidth, null); + defer eb.deinit(); + + var ev = try EditorView.init(std.testing.allocator, eb, 20, 10); + defer ev.deinit(); + + ev.setWrapMode(.char); + ev.setWrapIndent(.same); + + try eb.setText(" aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + try eb.setCursor(0, 10); + try eb.insertText("XXXX"); + + const after_edit = ev.getVirtualLines(); + + var eb2 = try EditBuffer.init(std.testing.allocator, pool, link_pool, .wcwidth, null); + defer eb2.deinit(); + var ev2 = try EditorView.init(std.testing.allocator, eb2, 20, 10); + defer ev2.deinit(); + ev2.setWrapMode(.char); + ev2.setWrapIndent(.same); + try eb2.setText(" aaaaaaXXXXaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + + const fresh = ev2.getVirtualLines(); + try std.testing.expectEqual(fresh.len, after_edit.len); + for (fresh, after_edit) |a, b| { + try std.testing.expectEqual(a.pad_cols, b.pad_cols); + try std.testing.expectEqual(a.width_cols, b.width_cols); + try std.testing.expectEqual(a.source_col_offset, b.source_col_offset); + } +} diff --git a/packages/core/src/zig/tests/text-buffer-view_test.zig b/packages/core/src/zig/tests/text-buffer-view_test.zig index 77bedeee2..b5130e1f8 100644 --- a/packages/core/src/zig/tests/text-buffer-view_test.zig +++ b/packages/core/src/zig/tests/text-buffer-view_test.zig @@ -3670,3 +3670,257 @@ test "TextBufferView word wrapping - does not split 'uses' across lines" { try std.testing.expect(!split_found); } + +test "TextBufferView wrap indent - same pads continuations and narrows wrap width" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + const link_pool = link.initGlobalLinkPool(std.testing.allocator); + defer link.deinitGlobalLinkPool(); + + var tb = try TextBuffer.init(std.testing.allocator, pool, link_pool, .wcwidth); + defer tb.deinit(); + + var view = try TextBufferView.init(std.testing.allocator, tb); + defer view.deinit(); + + var text_buf: [84]u8 = undefined; + @memset(text_buf[0..4], ' '); + @memset(text_buf[4..], 'a'); + try tb.setText(&text_buf); + + view.setWrapMode(.char); + view.setWrapWidth(20); + view.setWrapIndent(.same); + + const vlines = view.getVirtualLines(); + try std.testing.expect(vlines.len >= 2); + try std.testing.expectEqual(@as(u32, 0), vlines[0].pad_cols); + try std.testing.expectEqual(@as(u32, 20), vlines[0].width_cols); + try std.testing.expectEqual(@as(u32, 4), vlines[1].pad_cols); + try std.testing.expectEqual(@as(u32, 16), vlines[1].width_cols); + // Visual occupancy of continuation = pad + content <= wrap width + try std.testing.expectEqual(@as(u32, 20), vlines[1].pad_cols + vlines[1].width_cols); +} + +test "TextBufferView wrap indent - none keeps pad_cols zero" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + const link_pool = link.initGlobalLinkPool(std.testing.allocator); + defer link.deinitGlobalLinkPool(); + + var tb = try TextBuffer.init(std.testing.allocator, pool, link_pool, .wcwidth); + defer tb.deinit(); + + var view = try TextBufferView.init(std.testing.allocator, tb); + defer view.deinit(); + + var text_buf: [84]u8 = undefined; + @memset(text_buf[0..4], ' '); + @memset(text_buf[4..], 'a'); + try tb.setText(&text_buf); + + view.setWrapMode(.char); + view.setWrapWidth(20); + view.setWrapIndent(.none); + + const vlines = view.getVirtualLines(); + try std.testing.expect(vlines.len >= 2); + for (vlines) |vline| { + try std.testing.expectEqual(@as(u32, 0), vline.pad_cols); + } + try std.testing.expectEqual(@as(u32, 20), vlines[0].width_cols); + try std.testing.expectEqual(@as(u32, 20), vlines[1].width_cols); +} + +test "TextBufferView wrap indent - clamp when indent >= wrap width" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + const link_pool = link.initGlobalLinkPool(std.testing.allocator); + defer link.deinitGlobalLinkPool(); + + var tb = try TextBuffer.init(std.testing.allocator, pool, link_pool, .wcwidth); + defer tb.deinit(); + + var view = try TextBufferView.init(std.testing.allocator, tb); + defer view.deinit(); + + var text_buf: [60]u8 = undefined; + @memset(text_buf[0..20], ' '); + @memset(text_buf[20..], 'b'); + try tb.setText(&text_buf); + + view.setWrapMode(.char); + view.setWrapWidth(20); + view.setWrapIndent(.same); + + const vlines = view.getVirtualLines(); + try std.testing.expect(vlines.len >= 2); + for (vlines) |vline| { + try std.testing.expectEqual(@as(u32, 0), vline.pad_cols); + } +} + +test "TextBufferView wrap indent - leading tab uses tab width" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + const link_pool = link.initGlobalLinkPool(std.testing.allocator); + defer link.deinitGlobalLinkPool(); + + var tb = try TextBuffer.init(std.testing.allocator, pool, link_pool, .wcwidth); + defer tb.deinit(); + tb.setTabWidth(4); + + var view = try TextBufferView.init(std.testing.allocator, tb); + defer view.deinit(); + + var text_buf: [81]u8 = undefined; + text_buf[0] = '\t'; + @memset(text_buf[1..], 'c'); + try tb.setText(&text_buf); + + view.setWrapMode(.char); + view.setWrapWidth(20); + view.setWrapIndent(.same); + + const vlines = view.getVirtualLines(); + try std.testing.expect(vlines.len >= 2); + try std.testing.expectEqual(@as(u32, 0), vlines[0].pad_cols); + try std.testing.expectEqual(@as(u32, 4), vlines[1].pad_cols); +} + +test "TextBufferView wrap indent - short indented line has no pad" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + const link_pool = link.initGlobalLinkPool(std.testing.allocator); + defer link.deinitGlobalLinkPool(); + + var tb = try TextBuffer.init(std.testing.allocator, pool, link_pool, .wcwidth); + defer tb.deinit(); + + var view = try TextBufferView.init(std.testing.allocator, tb); + defer view.deinit(); + + try tb.setText(" hello"); + view.setWrapMode(.char); + view.setWrapWidth(20); + view.setWrapIndent(.same); + + const vlines = view.getVirtualLines(); + try std.testing.expectEqual(@as(usize, 1), vlines.len); + try std.testing.expectEqual(@as(u32, 0), vlines[0].pad_cols); +} + +test "TextBufferView wrap indent - ignored when wrapMode is none" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + const link_pool = link.initGlobalLinkPool(std.testing.allocator); + defer link.deinitGlobalLinkPool(); + + var tb = try TextBuffer.init(std.testing.allocator, pool, link_pool, .wcwidth); + defer tb.deinit(); + + var view = try TextBufferView.init(std.testing.allocator, tb); + defer view.deinit(); + + var text_buf: [84]u8 = undefined; + @memset(text_buf[0..4], ' '); + @memset(text_buf[4..], 'a'); + try tb.setText(&text_buf); + + view.setWrapMode(.none); + view.setWrapWidth(20); + view.setWrapIndent(.same); + + const vlines = view.getVirtualLines(); + try std.testing.expectEqual(@as(usize, 1), vlines.len); + try std.testing.expectEqual(@as(u32, 0), vlines[0].pad_cols); +} + +test "TextBufferView wrap indent - click on pad maps to content start" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + const link_pool = link.initGlobalLinkPool(std.testing.allocator); + defer link.deinitGlobalLinkPool(); + + var tb = try TextBuffer.init(std.testing.allocator, pool, link_pool, .wcwidth); + defer tb.deinit(); + + var view = try TextBufferView.init(std.testing.allocator, tb); + defer view.deinit(); + + var text_buf: [84]u8 = undefined; + @memset(text_buf[0..4], ' '); + @memset(text_buf[4..], 'a'); + try tb.setText(&text_buf); + + view.setWrapMode(.char); + view.setWrapWidth(20); + view.setWrapIndent(.same); + + const vlines = view.getVirtualLines(); + try std.testing.expect(vlines.len >= 2); + const content_start = vlines[1].col_offset; + + // Click on pad column 0 of continuation row + _ = view.setLocalSelection(0, 1, 1, 1, null, null); + const sel = view.getSelection().?; + try std.testing.expectEqual(content_start, sel.start); + + // Click on content column after pad should advance past content start + view.resetLocalSelection(); + _ = view.setLocalSelection(5, 1, 6, 1, null, null); + const sel2 = view.getSelection().?; + try std.testing.expectEqual(content_start + 1, sel2.start); +} + +test "TextBufferView wrap indent - measure includes continuation pad" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + const link_pool = link.initGlobalLinkPool(std.testing.allocator); + defer link.deinitGlobalLinkPool(); + + var tb = try TextBuffer.init(std.testing.allocator, pool, link_pool, .wcwidth); + defer tb.deinit(); + + var view = try TextBufferView.init(std.testing.allocator, tb); + defer view.deinit(); + + // Word wrap: first visual row is short; continuation occupies full wrap width with pad. + try tb.setText(" hello world_and_more"); + view.setWrapMode(.word); + view.setWrapIndent(.same); + + const result = try view.measureForDimensions(12, 10); + try std.testing.expect(result.line_count >= 2); + try std.testing.expectEqual(@as(u32, 12), result.width_cols_max); +} + +test "TextBufferView wrap indent - truncation accounts for pad" { + const pool = gp.initGlobalPool(std.testing.allocator); + defer gp.deinitGlobalPool(); + const link_pool = link.initGlobalLinkPool(std.testing.allocator); + defer link.deinitGlobalLinkPool(); + + var tb = try TextBuffer.init(std.testing.allocator, pool, link_pool, .wcwidth); + defer tb.deinit(); + + var view = try TextBufferView.init(std.testing.allocator, tb); + defer view.deinit(); + + var text_buf: [84]u8 = undefined; + @memset(text_buf[0..4], ' '); + @memset(text_buf[4..], 'a'); + try tb.setText(&text_buf); + + view.setWrapMode(.char); + view.setWrapWidth(20); + view.setWrapIndent(.same); + // Force a continuation that would overflow a narrower viewport if pad is ignored. + view.setViewport(.{ .x = 0, .y = 0, .width = 18, .height = 10 }); + view.setTruncate(true); + + const vlines = view.getVirtualLines(); + try std.testing.expect(vlines.len >= 2); + try std.testing.expectEqual(@as(u32, 4), vlines[1].pad_cols); + try std.testing.expect(vlines[1].pad_cols + vlines[1].width_cols <= 18); +} diff --git a/packages/core/src/zig/text-buffer-segment.zig b/packages/core/src/zig/text-buffer-segment.zig index 2c129c3c8..267d9659b 100644 --- a/packages/core/src/zig/text-buffer-segment.zig +++ b/packages/core/src/zig/text-buffer-segment.zig @@ -25,6 +25,11 @@ pub const WrapMode = enum { word, }; +pub const WrapIndent = enum { + none, + same, +}; + pub const ChunkFitResult = struct { char_count: u32, width: u32, diff --git a/packages/core/src/zig/text-buffer-view.zig b/packages/core/src/zig/text-buffer-view.zig index 2899c0c83..adc3ce082 100644 --- a/packages/core/src/zig/text-buffer-view.zig +++ b/packages/core/src/zig/text-buffer-view.zig @@ -9,6 +9,7 @@ const UnifiedTextBuffer = tb.UnifiedTextBuffer; const RGBA = tb.RGBA; const TextSelection = tb.TextSelection; pub const WrapMode = tb.WrapMode; +pub const WrapIndent = tb.WrapIndent; const TextChunk = seg_mod.TextChunk; const StyleSpan = tb.StyleSpan; const GraphemeInfo = seg_mod.GraphemeInfo; @@ -86,6 +87,9 @@ pub const VirtualChunk = struct { pub const VirtualLine = struct { chunks: std.ArrayListUnmanaged(VirtualChunk), width_cols: u32, + /// Leading display columns of soft-wrap continuation indent (content starts after this). + /// Always 0 on the first virtual line of each logical line. + pad_cols: u32, col_offset: u32, source_line: usize, source_col_offset: u32, @@ -97,6 +101,7 @@ pub const VirtualLine = struct { return .{ .chunks = .empty, .width_cols = 0, + .pad_cols = 0, .col_offset = 0, .source_line = 0, .source_col_offset = 0, @@ -133,6 +138,7 @@ pub const UnifiedTextBufferView = struct { viewport: ?Viewport, wrap_width: ?u32, wrap_mode: WrapMode, + wrap_indent: WrapIndent, first_line_offset: u32, virtual_lines: std.ArrayListUnmanaged(VirtualLine), virtual_lines_dirty: bool, @@ -154,11 +160,12 @@ pub const UnifiedTextBufferView = struct { ellipsis_chunk: TextChunk, ellipsis_mem_id: u8, - // Measurement cache for Yoga layout. Keyed by (buffer, epoch, width, wrap_mode). + // Measurement cache for Yoga layout. Keyed by (buffer, epoch, width, wrap_mode, wrap_indent). // Using epoch instead of dirty flag prevents stale returns when unrelated // code paths clear dirty (e.g., updateVirtualLines). cached_measure_width: ?u32, cached_measure_wrap_mode: WrapMode, + cached_measure_wrap_indent: WrapIndent, cached_measure_first_line_offset: u32, cached_measure_result: ?MeasureResult, cached_measure_epoch: u64, @@ -191,6 +198,7 @@ pub const UnifiedTextBufferView = struct { .viewport = null, .wrap_width = null, .wrap_mode = .none, + .wrap_indent = .none, .first_line_offset = 0, .virtual_lines = .empty, .virtual_lines_dirty = true, @@ -210,6 +218,7 @@ pub const UnifiedTextBufferView = struct { .ellipsis_mem_id = ellipsis_mem_id, .cached_measure_width = null, .cached_measure_wrap_mode = .none, + .cached_measure_wrap_indent = .none, .cached_measure_first_line_offset = 0, .cached_measure_result = null, .cached_measure_epoch = 0, @@ -290,6 +299,14 @@ pub const UnifiedTextBufferView = struct { } } + pub fn setWrapIndent(self: *Self, indent: WrapIndent) void { + if (self.wrap_indent != indent) { + self.wrap_indent = indent; + self.virtual_lines_dirty = true; + self.truncation_applied = false; + } + } + pub fn setFirstLineOffset(self: *Self, offset: u32) void { if (self.first_line_offset != offset) { self.first_line_offset = offset; @@ -373,6 +390,7 @@ pub const UnifiedTextBufferView = struct { self.text_buffer, self.wrap_mode, self.wrap_width, + self.wrap_indent, self.first_line_offset, output, ); @@ -727,8 +745,11 @@ pub const UnifiedTextBufferView = struct { const vline = &self.virtual_lines.items[vline_idx]; const lineStart = vline.col_offset; const lineWidth = vline.width_cols; + const pad_cols: i32 = @intCast(vline.pad_cols); - var localX = @max(0, @min(abs_x, @as(i32, @intCast(lineWidth)))); + // Viewport X includes continuation pad; map to content-relative column. + const content_x = abs_x - pad_cols; + var localX = @max(0, @min(content_x, @as(i32, @intCast(lineWidth)))); if (vline.is_truncated) { const ellipsis_width: u32 = 3; @@ -837,9 +858,12 @@ pub const UnifiedTextBufferView = struct { const ellipsis_width: u32 = 3; for (self.virtual_lines.items) |*vline| { - if (vline.width_cols <= vp.width) continue; + // Painted width is pad + content; truncate against the content budget only. + if (vline.pad_cols + vline.width_cols <= vp.width) continue; + + const content_vp_width = if (vp.width > vline.pad_cols) vp.width - vline.pad_cols else 0; - if (vp.width <= ellipsis_width) { + if (content_vp_width <= ellipsis_width) { vline.chunks.clearRetainingCapacity(); vline.width_cols = 0; vline.is_truncated = true; @@ -848,7 +872,7 @@ pub const UnifiedTextBufferView = struct { continue; } - const available_width = vp.width - ellipsis_width; + const available_width = content_vp_width - ellipsis_width; const prefix_width = available_width / 2; const suffix_width = available_width - prefix_width; @@ -903,7 +927,7 @@ pub const UnifiedTextBufferView = struct { vline.chunks.clearRetainingCapacity(); vline.chunks.appendSlice(self.virtual_lines_arena.allocator(), new_chunks.items) catch return; - vline.width_cols = vp.width; + vline.width_cols = content_vp_width; vline.is_truncated = true; vline.ellipsis_pos = prefix_width; vline.truncation_suffix_start = suffix_start_pos; @@ -921,6 +945,7 @@ pub const UnifiedTextBufferView = struct { if (self.cached_measure_width) |cached_width| { if (cached_width == width and self.cached_measure_wrap_mode == self.wrap_mode and + self.cached_measure_wrap_indent == self.wrap_indent and self.cached_measure_first_line_offset == self.first_line_offset) { return result; @@ -945,6 +970,7 @@ pub const UnifiedTextBufferView = struct { self.cached_measure_width = width; self.cached_measure_wrap_mode = self.wrap_mode; + self.cached_measure_wrap_indent = self.wrap_indent; self.cached_measure_first_line_offset = self.first_line_offset; self.cached_measure_result = result; self.cached_measure_epoch = epoch; @@ -985,14 +1011,15 @@ pub const UnifiedTextBufferView = struct { self.text_buffer, self.wrap_mode, wrap_width_for_measure, + self.wrap_indent, self.first_line_offset, output, ); - // Calculate max width from temp structures + // Visual occupancy includes continuation pad (width_cols stays content-only). var width_cols_max: u32 = 0; - for (temp_line_widths.items) |w| { - width_cols_max = @max(width_cols_max, w); + for (temp_virtual_lines.items) |vline| { + width_cols_max = @max(width_cols_max, vline.pad_cols + vline.width_cols); } const result: MeasureResult = .{ @@ -1002,6 +1029,7 @@ pub const UnifiedTextBufferView = struct { self.cached_measure_width = width; self.cached_measure_wrap_mode = self.wrap_mode; + self.cached_measure_wrap_indent = self.wrap_indent; self.cached_measure_first_line_offset = self.first_line_offset; self.cached_measure_result = result; self.cached_measure_epoch = epoch; @@ -1010,12 +1038,34 @@ pub const UnifiedTextBufferView = struct { return result; } + /// Display-column width of a leading run of ASCII space/tab only. + /// Returns finalized=true when a non-indent character is found. + /// All-whitespace input leaves finalized=false (caller treats indent as 0). + fn accumulateLeadingIndentCols(bytes: []const u8, tab_width: u8, start_indent: u32) struct { indent: u32, finalized: bool } { + var indent = start_indent; + var i: usize = 0; + while (i < bytes.len) { + const b = bytes[i]; + if (b == ' ') { + indent += 1; + i += 1; + } else if (b == '\t') { + indent += tab_width; + i += 1; + } else { + return .{ .indent = indent, .finalized = true }; + } + } + return .{ .indent = indent, .finalized = false }; + } + /// Generic virtual line calculation that writes to provided output structures fn calculateVirtualLinesGeneric( allocator: Allocator, text_buffer: *UnifiedTextBuffer, wrap_mode: WrapMode, wrap_width: ?u32, + wrap_indent: WrapIndent, first_line_offset: u32, output: VirtualLineOutput, ) void { @@ -1079,6 +1129,7 @@ pub const UnifiedTextBufferView = struct { allocator: Allocator, output: VirtualLineOutput, wrap_mode: WrapMode, + wrap_indent: WrapIndent, wrap_w: u32, first_line_offset: u32, first_line_pending: bool, @@ -1090,21 +1141,57 @@ pub const UnifiedTextBufferView = struct { chunk_idx_in_line: u32 = 0, current_line_first_vline_idx: u32 = 0, current_line_vline_count: u32 = 0, + indent_cols: u32 = 0, + indent_finalized: bool = false, + continuation_pad: u32 = 0, last_wrap_chunk_count: u32 = 0, last_wrap_line_position: u32 = 0, last_wrap_global_offset: u32 = 0, - fn lineWrapWidth(wctx: *@This()) u32 { - if (!wctx.first_line_pending or wctx.first_line_offset == 0 or wctx.first_line_offset >= wctx.wrap_w) { - return wctx.wrap_w; + fn finalizeContinuationPad(wctx: *@This()) void { + if (wctx.indent_finalized) return; + wctx.indent_finalized = true; + if (wctx.wrap_indent != .same) { + wctx.continuation_pad = 0; + return; } + const I = wctx.indent_cols; + if (I == 0 or I >= wctx.wrap_w) { + wctx.continuation_pad = 0; + } else { + wctx.continuation_pad = I; + } + } - return wctx.wrap_w - wctx.first_line_offset; + fn accumulateIndentFromChunk(wctx: *@This(), chunk: *const TextChunk) void { + if (wctx.indent_finalized or wctx.wrap_indent != .same) return; + const chunk_bytes = chunk.getBytes(wctx.text_buffer.memRegistry()); + const result = accumulateLeadingIndentCols(chunk_bytes, wctx.text_buffer.tabWidth(), wctx.indent_cols); + wctx.indent_cols = result.indent; + if (result.finalized) { + finalizeContinuationPad(wctx); + } + } + + fn lineWrapWidth(wctx: *@This()) u32 { + var base = wctx.wrap_w; + if (wctx.first_line_pending and wctx.first_line_offset > 0 and wctx.first_line_offset < wctx.wrap_w) { + base = wctx.wrap_w - wctx.first_line_offset; + } + if (wctx.current_line_vline_count > 0 and wctx.continuation_pad > 0 and wctx.continuation_pad < base) { + return base - wctx.continuation_pad; + } + return base; } fn commitVirtualLine(wctx: *@This()) void { + // Wrapped while still scanning leading whitespace ⇒ indent ≥ wrap width. + if (!wctx.indent_finalized) { + finalizeContinuationPad(wctx); + } wctx.current_vline.width_cols = wctx.line_position; + wctx.current_vline.pad_cols = if (wctx.current_line_vline_count > 0) wctx.continuation_pad else 0; wctx.current_vline.source_line = wctx.line_idx; wctx.current_vline.source_col_offset = wctx.line_col_offset; wctx.output.virtual_lines.append(wctx.allocator, wctx.current_vline) catch {}; @@ -1139,6 +1226,7 @@ pub const UnifiedTextBufferView = struct { fn segment_callback(ctx_ptr: *anyopaque, _: u32, chunk: *const TextChunk, chunk_idx_in_line: u32) void { const wctx = @as(*@This(), @ptrCast(@alignCast(ctx_ptr))); wctx.chunk_idx_in_line = chunk_idx_in_line; + wctx.accumulateIndentFromChunk(chunk); if (wctx.wrap_mode == .word) { const chunk_bytes = chunk.getBytes(wctx.text_buffer.memRegistry()); @@ -1380,8 +1468,15 @@ pub const UnifiedTextBufferView = struct { fn line_end_callback(ctx_ptr: *anyopaque, line_info: iter_mod.LineInfo) void { const wctx = @as(*@This(), @ptrCast(@alignCast(ctx_ptr))); + // All-whitespace / empty line → indent 0 (finalizeContinuationPad clamps). + if (!wctx.indent_finalized) { + wctx.indent_cols = 0; + finalizeContinuationPad(wctx); + } + if (wctx.current_vline.chunks.items.len > 0 or line_info.width_cols == 0) { wctx.current_vline.width_cols = wctx.line_position; + wctx.current_vline.pad_cols = if (wctx.current_line_vline_count > 0) wctx.continuation_pad else 0; wctx.current_vline.source_line = wctx.line_idx; wctx.current_vline.source_col_offset = wctx.line_col_offset; wctx.output.virtual_lines.append(wctx.allocator, wctx.current_vline) catch {}; @@ -1409,6 +1504,9 @@ pub const UnifiedTextBufferView = struct { wctx.chunk_idx_in_line = 0; wctx.current_line_first_vline_idx = @intCast(wctx.output.virtual_lines.items.len); wctx.current_line_vline_count = 0; + wctx.indent_cols = 0; + wctx.indent_finalized = false; + wctx.continuation_pad = 0; } }; @@ -1417,6 +1515,7 @@ pub const UnifiedTextBufferView = struct { .allocator = allocator, .output = output, .wrap_mode = wrap_mode, + .wrap_indent = wrap_indent, .wrap_w = wrap_w, .first_line_offset = first_line_offset, .first_line_pending = first_line_offset > 0, diff --git a/packages/core/src/zig/text-buffer.zig b/packages/core/src/zig/text-buffer.zig index 19687ed27..ff9246bd3 100644 --- a/packages/core/src/zig/text-buffer.zig +++ b/packages/core/src/zig/text-buffer.zig @@ -28,6 +28,7 @@ pub const TextBufferError = seg_mod.TextBufferError; pub const Highlight = seg_mod.Highlight; pub const StyleSpan = seg_mod.StyleSpan; pub const WrapMode = seg_mod.WrapMode; +pub const WrapIndent = seg_mod.WrapIndent; pub const ChunkFitResult = seg_mod.ChunkFitResult; pub const GraphemeInfo = seg_mod.GraphemeInfo;