Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/core/src/editor-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
21 changes: 21 additions & 0 deletions packages/core/src/renderables/EditBufferRenderable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -54,6 +57,7 @@ export interface EditBufferOptions extends RenderableOptions<EditBufferRenderabl
selectable?: boolean
attributes?: number
wrapMode?: "none" | "char" | "word"
wrapIndent?: WrapIndent
scrollMargin?: number
scrollSpeed?: number
showCursor?: boolean
Expand All @@ -78,6 +82,7 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf
protected _selectionBg: RGBA | undefined
protected _selectionFg: RGBA | undefined
protected _wrapMode: "none" | "char" | "word" = "word"
protected _wrapIndent: WrapIndent = "none"
protected _scrollMargin: number = 0.2
protected _showCursor: boolean = true
protected _cursorColor: RGBA
Expand Down Expand Up @@ -106,6 +111,7 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf
selectable: true,
attributes: 0,
wrapMode: "word" as "none" | "char" | "word",
wrapIndent: "none" as WrapIndent,
scrollMargin: 0.2,
scrollSpeed: 16,
showCursor: true,
Expand All @@ -128,6 +134,7 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf
this._selectionFg = options.selectionFg ? parseColor(options.selectionFg) : this._defaultOptions.selectionFg
this.selectable = options.selectable ?? this._defaultOptions.selectable
this._wrapMode = options.wrapMode ?? this._defaultOptions.wrapMode
this._wrapIndent = options.wrapIndent ?? this._defaultOptions.wrapIndent
this._scrollMargin = options.scrollMargin ?? this._defaultOptions.scrollMargin
this._scrollSpeed = options.scrollSpeed ?? this._defaultOptions.scrollSpeed
this._showCursor = options.showCursor ?? this._defaultOptions.showCursor
Expand All @@ -142,6 +149,7 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf
this.editorView = EditorView.create(this.editBuffer, this.width || 80, this.height || 24)

this.editorView.setWrapMode(this._wrapMode)
this.editorView.setWrapIndent(this._wrapIndent)
this.editorView.setScrollMargin(this._scrollMargin)

this.editBuffer.setDefaultFg(this._textColor)
Expand Down Expand Up @@ -330,6 +338,19 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf
}
}

get wrapIndent(): WrapIndent {
return this._wrapIndent
}

set wrapIndent(value: WrapIndent) {
if (this._wrapIndent !== value) {
this._wrapIndent = value
this.editorView.setWrapIndent(value)
this.yogaNode.markDirty()
this.requestRender()
}
}

get showCursor(): boolean {
return this._showCursor
}
Expand Down
20 changes: 20 additions & 0 deletions packages/core/src/renderables/TextBufferRenderable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import type { OptimizedBuffer } from "../buffer.js"
import { NativeMeasureTargetKind, resolveRenderLib, type LineInfo, type NativeRenderableHandle } from "../zig.js"
import { SyntaxStyle } from "../syntax-style.js"

export type WrapIndent = "none" | "same"

export interface TextBufferOptions extends RenderableOptions<TextBufferRenderable> {
fg?: string | RGBA
bg?: string | RGBA
Expand All @@ -16,6 +18,7 @@ export interface TextBufferOptions extends RenderableOptions<TextBufferRenderabl
selectable?: boolean
attributes?: number
wrapMode?: "none" | "char" | "word"
wrapIndent?: WrapIndent
tabIndicator?: string | number
tabIndicatorColor?: string | RGBA
truncate?: boolean
Expand All @@ -30,6 +33,7 @@ export abstract class TextBufferRenderable extends Renderable implements LineInf
protected _selectionBg: RGBA | undefined
protected _selectionFg: RGBA | undefined
protected _wrapMode: "none" | "char" | "word" = "word"
protected _wrapIndent: WrapIndent = "none"
protected lastLocalSelection: LocalSelectionBounds | null = null
protected _tabIndicator?: string | number
protected _tabIndicatorColor?: RGBA
Expand All @@ -51,6 +55,7 @@ export abstract class TextBufferRenderable extends Renderable implements LineInf
selectable: true,
attributes: 0,
wrapMode: "word" as "none" | "char" | "word",
wrapIndent: "none" as WrapIndent,
tabIndicator: undefined,
tabIndicatorColor: undefined,
truncate: false,
Expand All @@ -66,6 +71,7 @@ export abstract class TextBufferRenderable extends Renderable implements LineInf
this._selectionFg = options.selectionFg ? parseColor(options.selectionFg) : this._defaultOptions.selectionFg
this.selectable = options.selectable ?? this._defaultOptions.selectable
this._wrapMode = options.wrapMode ?? this._defaultOptions.wrapMode
this._wrapIndent = options.wrapIndent ?? this._defaultOptions.wrapIndent
this._tabIndicator = options.tabIndicator ?? this._defaultOptions.tabIndicator
this._tabIndicatorColor = options.tabIndicatorColor
? parseColor(options.tabIndicatorColor)
Expand All @@ -80,6 +86,7 @@ export abstract class TextBufferRenderable extends Renderable implements LineInf
this.textBuffer.setSyntaxStyle(this._textBufferSyntaxStyle)

this.textBufferView.setWrapMode(this._wrapMode)
this.textBufferView.setWrapIndent(this._wrapIndent)
this.textBufferView.setFirstLineOffset(this._firstLineOffset)
this.setupNativeRenderable()

Expand Down Expand Up @@ -292,6 +299,19 @@ export abstract class TextBufferRenderable extends Renderable implements LineInf
}
}

get wrapIndent(): WrapIndent {
return this._wrapIndent
}

set wrapIndent(value: WrapIndent) {
if (this._wrapIndent !== value) {
this._wrapIndent = value
this.textBufferView.setWrapIndent(this._wrapIndent)
this.yogaNode.markDirty()
this.requestRender()
}
}

get tabIndicator(): string | number | undefined {
return this._tabIndicator
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,21 @@ describe("Textarea - Rendering Tests", () => {
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",
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/text-buffer-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
20 changes: 20 additions & 0 deletions packages/core/src/zig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -940,6 +944,10 @@ function getOpenTUILib(libPath?: string) {
args: ["u32", "u8"],
returns: "void",
},
editorViewSetWrapIndent: {
args: ["u32", "u8"],
returns: "void",
},
editorViewGetVirtualLineCount: {
args: ["u32"],
returns: "u32",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down
24 changes: 24 additions & 0 deletions packages/core/src/zig/buffer.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
33 changes: 27 additions & 6 deletions packages/core/src/zig/editor-view.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
20 changes: 20 additions & 0 deletions packages/core/src/zig/lib.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down
Loading