Packages

A terminal UI framework for Elixir with a high-performance Zig NIF backend.

Current section

Files

Jump to
elixir_opentui zig opentui editor-view.zig
Raw

zig/opentui/editor-view.zig

const std = @import("std");
const Allocator = std.mem.Allocator;
const tb = @import("text-buffer.zig");
const tbv = @import("text-buffer-view.zig");
const eb = @import("edit-buffer.zig");
const iter_mod = @import("text-buffer-iterators.zig");
const gp = @import("grapheme.zig");
const ss = @import("syntax-style.zig");
const event_emitter = @import("event-emitter.zig");
const logger = @import("logger.zig");
const EditBuffer = eb.EditBuffer;
// Use the unified types to match EditBuffer
const UnifiedTextBuffer = tb.UnifiedTextBuffer;
const UnifiedTextBufferView = tbv.UnifiedTextBufferView;
const VirtualLine = tbv.VirtualLine;
pub const EditorViewError = error{
OutOfMemory,
};
/// VisualCursor represents a cursor position with both visual and logical coordinates.
/// Visual coordinates (visual_row, visual_col) are VIEWPORT-RELATIVE.
/// This means visual_row=0 is the first visible line in the viewport, not the first line in the document.
/// Logical coordinates (logical_row, logical_col) are document-absolute.
pub const VisualCursor = struct {
visual_row: u32, // Viewport-relative row (0 = top of viewport)
visual_col: u32, // Viewport-relative column (0 = left edge of viewport when not wrapping)
logical_row: u32, // Document-absolute row
logical_col: u32, // Document-absolute column
offset: u32, // Global display-width offset from buffer start
};
/// EditorView wraps a TextBufferView and manages viewport state for efficient rendering
/// It also holds a reference to an EditBuffer for cursor/editing operations
pub const EditorView = struct {
text_buffer_view: *UnifiedTextBufferView,
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,
placeholder_syntax_style: ?*ss.SyntaxStyle,
placeholder_active: bool,
// Memory management
global_allocator: Allocator,
fn onCursorChanged(ctx: *anyopaque) void {
const self: *EditorView = @ptrCast(@alignCast(ctx));
self.desired_visual_col = null;
self.updatePlaceholderVisibility();
const has_selection = self.text_buffer_view.selection != null;
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);
}
}
pub fn init(global_allocator: Allocator, edit_buffer: *EditBuffer, viewport_width: u32, viewport_height: u32) EditorViewError!*EditorView {
const self = global_allocator.create(EditorView) catch return EditorViewError.OutOfMemory;
errdefer global_allocator.destroy(self);
const text_buffer = edit_buffer.getTextBuffer();
const text_buffer_view = UnifiedTextBufferView.init(global_allocator, text_buffer) catch return EditorViewError.OutOfMemory;
errdefer text_buffer_view.deinit();
self.* = .{
.text_buffer_view = text_buffer_view,
.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,
},
.placeholder_buffer = null,
.placeholder_syntax_style = null,
.placeholder_active = false,
.global_allocator = global_allocator,
};
self.cursor_changed_listener.ctx = self;
edit_buffer.events.on(.cursorChanged, self.cursor_changed_listener) catch return EditorViewError.OutOfMemory;
text_buffer_view.setViewport(tbv.Viewport{
.x = 0,
.y = 0,
.width = viewport_width,
.height = viewport_height,
});
return self;
}
pub fn deinit(self: *EditorView) void {
self.edit_buffer.events.off(.cursorChanged, self.cursor_changed_listener);
if (self.placeholder_syntax_style) |style| {
style.deinit();
}
if (self.placeholder_buffer) |placeholder| {
placeholder.deinit();
}
self.text_buffer_view.deinit();
self.global_allocator.destroy(self);
}
/// Set the viewport. If wrapping is enabled and viewport width differs from current wrap width,
/// this will trigger a reflow by updating the TextBufferView's wrap width.
/// moveCursor: if true, moves cursor to stay within viewport bounds (prevents viewport reset)
pub fn setViewport(self: *EditorView, vp: ?tbv.Viewport, moveCursor: bool) void {
self.text_buffer_view.setViewport(vp);
if (moveCursor) {
self.makeCursorVisible();
}
}
pub fn getViewport(self: *const EditorView) ?tbv.Viewport {
return self.text_buffer_view.getViewport();
}
/// Move the cursor to be within the current viewport if it's outside.
/// Unlike ensureCursorVisible, this moves the cursor, not the viewport.
/// Respects scroll margins to prevent immediate re-scrolling by ensureCursorVisible.
pub fn makeCursorVisible(self: *EditorView) void {
const vp = self.text_buffer_view.getViewport() orelse return;
const cursor = self.edit_buffer.getPrimaryCursor();
const vcursor = self.logicalToVisualCursor(cursor.row, cursor.col);
const viewport_height = vp.height;
const margin_lines = @max(1, @as(u32, @intFromFloat(@as(f32, @floatFromInt(viewport_height)) * self.scroll_margin)));
const cursor_above_viewport = vcursor.visual_row < vp.y;
const cursor_below_viewport = vcursor.visual_row >= vp.y + vp.height;
const cursor_too_close_to_top = vcursor.visual_row < vp.y + margin_lines;
const cursor_too_close_to_bottom = vcursor.visual_row >= vp.y + vp.height - margin_lines;
if (cursor_above_viewport or cursor_below_viewport or cursor_too_close_to_top or cursor_too_close_to_bottom) {
const target_visual_row = if (cursor_above_viewport or cursor_too_close_to_top)
vp.y + margin_lines
else
vp.y + vp.height - margin_lines - 1;
self.text_buffer_view.updateVirtualLines();
const vlines = self.text_buffer_view.virtual_lines.items;
if (target_visual_row < vlines.len) {
const target_vline = &vlines[target_visual_row];
const target_logical_row = @as(u32, @intCast(target_vline.source_line));
const line_width = iter_mod.lineWidthAt(&self.edit_buffer.tb.rope, target_logical_row);
const target_col = @min(cursor.col, line_width);
if (self.edit_buffer.cursors.items.len > 0) {
const offset = iter_mod.coordsToOffset(&self.edit_buffer.tb.rope, target_logical_row, target_col) orelse return;
self.edit_buffer.cursors.items[0] = .{
.row = target_logical_row,
.col = target_col,
.desired_col = target_col,
.offset = offset,
};
}
}
}
}
/// Set the scroll margin as a fraction of viewport height (0.0 to 0.5)
/// The cursor will stay at least this many lines from the top/bottom edges when scrolling
pub fn setScrollMargin(self: *EditorView, margin: f32) void {
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 {
const vp = self.text_buffer_view.getViewport() orelse return;
const viewport_height = vp.height;
const viewport_width = vp.width;
if (viewport_height == 0 or viewport_width == 0) return;
const raw_margin_lines = @max(1, @as(u32, @intFromFloat(@as(f32, @floatFromInt(viewport_height)) * self.scroll_margin)));
const max_margin_lines = if (viewport_height > 1) (viewport_height - 1) / 2 else 0;
const margin_lines = @min(raw_margin_lines, max_margin_lines);
const raw_margin_cols = @max(1, @as(u32, @intFromFloat(@as(f32, @floatFromInt(viewport_width)) * self.scroll_margin)));
const max_margin_cols = if (viewport_width > 1) (viewport_width - 1) / 2 else 0;
const margin_cols = @min(raw_margin_cols, max_margin_cols);
const total_lines = self.text_buffer_view.getVirtualLineCount();
const max_offset_y = if (total_lines > viewport_height) total_lines - viewport_height else 0;
var new_offset_y = vp.y;
var new_offset_x = vp.x;
if (cursor_line < vp.y + margin_lines) {
if (cursor_line >= margin_lines) {
new_offset_y = cursor_line - margin_lines;
} else {
new_offset_y = 0;
}
} else if (cursor_line >= vp.y + viewport_height - margin_lines) {
const desired_offset = cursor_line + margin_lines - viewport_height + 1;
new_offset_y = @min(desired_offset, max_offset_y);
}
if (self.text_buffer_view.wrap_mode == .none) {
const cursor = self.edit_buffer.getPrimaryCursor();
const cursor_col = cursor.col;
if (cursor_col < vp.x + margin_cols) {
if (cursor_col >= margin_cols) {
new_offset_x = cursor_col - margin_cols;
} else {
new_offset_x = 0;
}
} else if (cursor_col >= vp.x + viewport_width - margin_cols) {
new_offset_x = cursor_col + margin_cols - viewport_width + 1;
}
}
if (new_offset_y != vp.y or new_offset_x != vp.x) {
self.text_buffer_view.setViewport(tbv.Viewport{
.x = new_offset_x,
.y = new_offset_y,
.width = vp.width,
.height = vp.height,
});
}
}
/// Always ensures cursor visibility since cursor movements don't mark buffer dirty
/// Note: With eager viewport updates in onCursorChanged, this is mainly for rendering methods
pub fn updateBeforeRender(self: *EditorView) void {
self.updatePlaceholderVisibility();
const has_selection = self.text_buffer_view.selection != null;
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);
}
}
/// Automatically ensures cursor is visible before rendering
pub fn getVirtualLines(self: *EditorView) []const VirtualLine {
self.updateBeforeRender();
return self.text_buffer_view.getVirtualLines();
}
/// Automatically ensures cursor is visible before rendering
pub fn getCachedLineInfo(self: *EditorView) tbv.LineInfo {
self.updateBeforeRender();
return self.text_buffer_view.getCachedLineInfo();
}
pub fn getLogicalLineInfo(self: *EditorView) tbv.LineInfo {
self.updatePlaceholderVisibility();
self.text_buffer_view.virtual_lines_dirty = true;
const line_info = self.text_buffer_view.getLogicalLineInfo();
return line_info;
}
pub fn getTextBufferView(self: *EditorView) *UnifiedTextBufferView {
return self.text_buffer_view;
}
pub fn getTotalVirtualLineCount(self: *EditorView) u32 {
return self.text_buffer_view.getVirtualLineCount();
}
pub fn getVirtualLineSpans(self: *const EditorView, vline_idx: usize) tbv.VirtualLineSpanInfo {
return self.text_buffer_view.getVirtualLineSpans(vline_idx);
}
pub fn getTextBuffer(self: *const EditorView) *UnifiedTextBuffer {
return self.text_buffer_view.text_buffer;
}
pub fn getSelection(self: *const EditorView) ?tb.TextSelection {
return self.text_buffer_view.selection;
}
pub fn setSelection(self: *EditorView, start: u32, end: u32, bgColor: ?tb.RGBA, fgColor: ?tb.RGBA) void {
self.text_buffer_view.setSelection(start, end, bgColor, fgColor);
}
pub fn updateSelection(self: *EditorView, end: u32, bgColor: ?tb.RGBA, fgColor: ?tb.RGBA) void {
self.text_buffer_view.updateSelection(end, bgColor, fgColor);
}
pub fn resetSelection(self: *EditorView) void {
self.text_buffer_view.resetSelection();
}
pub fn setLocalSelection(self: *EditorView, anchorX: i32, anchorY: i32, focusX: i32, focusY: i32, bgColor: ?tb.RGBA, fgColor: ?tb.RGBA, updateCursor: bool) bool {
const changed = self.text_buffer_view.setLocalSelection(anchorX, anchorY, focusX, focusY, bgColor, fgColor);
if (changed and updateCursor) {
self.updateCursorToSelectionFocus(focusX, focusY);
}
return changed;
}
pub fn updateLocalSelection(self: *EditorView, anchorX: i32, anchorY: i32, focusX: i32, focusY: i32, bgColor: ?tb.RGBA, fgColor: ?tb.RGBA, updateCursor: bool) bool {
const changed = self.text_buffer_view.updateLocalSelection(anchorX, anchorY, focusX, focusY, bgColor, fgColor);
if (changed and updateCursor) {
self.updateCursorToSelectionFocus(focusX, focusY);
}
return changed;
}
pub fn resetLocalSelection(self: *EditorView) void {
self.text_buffer_view.resetLocalSelection();
}
/// Updates the cursor position to match the selection focus position.
/// Does NOT trigger viewport scrolling - TypeScript layer handles that.
fn updateCursorToSelectionFocus(self: *EditorView, _: i32, _: i32) void {
const selection = self.text_buffer_view.getSelection() orelse return;
const focus_offset = if (self.text_buffer_view.selection_anchor_offset) |anchor| blk: {
if (anchor == selection.start) {
break :blk selection.end;
} else {
break :blk selection.start;
}
} else blk: {
break :blk selection.end;
};
const focus_coords = iter_mod.offsetToCoords(&self.edit_buffer.tb.rope, focus_offset) orelse return;
const line_count = iter_mod.getLineCount(&self.edit_buffer.tb.rope);
if (focus_coords.row >= line_count) return;
const line_width = iter_mod.lineWidthAt(&self.edit_buffer.tb.rope, focus_coords.row);
if (focus_coords.col > line_width) return;
// Update cursor to focus position
if (self.edit_buffer.cursors.items.len > 0) {
self.edit_buffer.cursors.items[0] = .{
.row = focus_coords.row,
.col = focus_coords.col,
.desired_col = focus_coords.col,
.offset = focus_offset,
};
}
}
pub fn getSelectedTextIntoBuffer(self: *EditorView, out_buffer: []u8) usize {
return self.text_buffer_view.getSelectedTextIntoBuffer(out_buffer);
}
pub fn packSelectionInfo(self: *const EditorView) u64 {
return self.text_buffer_view.packSelectionInfo();
}
/// This is a convenience method that preserves existing offset
/// After resize, ensures cursor is visible and clamps viewport offset to valid range
pub fn setViewportSize(self: *EditorView, width: u32, height: u32) void {
self.text_buffer_view.setViewportSize(width, height);
const vp = self.text_buffer_view.getViewport() orelse return;
const total_lines = self.text_buffer_view.getVirtualLineCount();
const max_offset_y = if (total_lines > vp.height) total_lines - vp.height else 0;
var new_offset_x = vp.x;
if (self.text_buffer_view.wrap_mode == .none) {
const max_line_width = iter_mod.getMaxLineWidth(&self.edit_buffer.tb.rope);
const max_offset_x = if (max_line_width > vp.width) max_line_width - vp.width else 0;
if (vp.x > max_offset_x) {
new_offset_x = max_offset_x;
}
}
if (vp.y > max_offset_y or new_offset_x != vp.x) {
self.text_buffer_view.setViewport(tbv.Viewport{
.x = new_offset_x,
.y = @min(vp.y, max_offset_y),
.width = vp.width,
.height = vp.height,
});
}
const cursor = self.edit_buffer.getPrimaryCursor();
const vcursor = self.logicalToVisualCursor(cursor.row, cursor.col);
self.ensureCursorVisible(vcursor.visual_row);
}
pub fn setWrapMode(self: *EditorView, mode: tb.WrapMode) void {
self.text_buffer_view.setWrapMode(mode);
}
pub fn getPrimaryCursor(self: *const EditorView) eb.Cursor {
return self.edit_buffer.getPrimaryCursor();
}
pub fn getCursor(self: *const EditorView, idx: usize) ?eb.Cursor {
return self.edit_buffer.getCursor(idx);
}
pub fn getText(self: *EditorView, out_buffer: []u8) usize {
return self.edit_buffer.getText(out_buffer);
}
/// Get the EditBuffer for direct access when needed
pub fn getEditBuffer(self: *EditorView) *EditBuffer {
return self.edit_buffer;
}
// ============================================================================
// VisualCursor - Wrapping-aware cursor translation
// ============================================================================
/// Returns viewport-relative visual coordinates for external API consumers
pub fn getVisualCursor(self: *EditorView) VisualCursor {
self.updateBeforeRender();
const cursor = self.edit_buffer.getPrimaryCursor();
const vcursor = self.logicalToVisualCursor(cursor.row, cursor.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 {
// Clamp logical coordinates to valid buffer ranges
const line_count = iter_mod.getLineCount(&self.edit_buffer.tb.rope);
const clamped_row = if (line_count > 0) @min(logical_row, line_count - 1) else 0;
const line_width = iter_mod.lineWidthAt(&self.edit_buffer.tb.rope, clamped_row);
const clamped_col = @min(logical_col, line_width);
const visual_row_idx = self.text_buffer_view.findVisualLineIndex(clamped_row, clamped_col);
const vlines = self.text_buffer_view.virtual_lines.items;
if (vlines.len == 0 or visual_row_idx >= vlines.len) {
// Fallback for edge cases
const offset = iter_mod.coordsToOffset(&self.edit_buffer.tb.rope, clamped_row, clamped_col) orelse 0;
return VisualCursor{
.visual_row = 0,
.visual_col = 0,
.logical_row = clamped_row,
.logical_col = clamped_col,
.offset = offset,
};
}
const vline = &vlines[visual_row_idx];
const vline_start_col = vline.source_col_offset;
// Calculate visual column within this virtual line
const visual_col = if (clamped_col >= vline_start_col)
clamped_col - vline_start_col
else
0;
const offset = iter_mod.coordsToOffset(&self.edit_buffer.tb.rope, clamped_row, clamped_col) orelse 0;
return VisualCursor{
.visual_row = visual_row_idx,
.visual_col = visual_col,
.logical_row = clamped_row,
.logical_col = clamped_col,
.offset = offset,
};
}
/// Input visual coordinates are absolute (document-absolute)
/// Returns a VisualCursor with absolute visual coordinates
pub fn visualToLogicalCursor(self: *EditorView, visual_row: u32, visual_col: u32) ?VisualCursor {
self.text_buffer_view.updateVirtualLines();
const vlines = self.text_buffer_view.virtual_lines.items;
if (visual_row >= vlines.len) return null;
const vline = &vlines[visual_row];
const clamped_visual_col = @min(visual_col, vline.width);
const logical_col = vline.source_col_offset + clamped_visual_col;
const logical_row = @as(u32, @intCast(vline.source_line));
const offset = iter_mod.coordsToOffset(&self.edit_buffer.tb.rope, logical_row, logical_col) orelse 0;
return VisualCursor{
.visual_row = visual_row,
.visual_col = clamped_visual_col,
.logical_row = logical_row,
.logical_col = logical_col,
.offset = offset,
};
}
pub fn moveUpVisual(self: *EditorView) void {
const cursor = self.edit_buffer.getPrimaryCursor();
const vcursor = self.logicalToVisualCursor(cursor.row, cursor.col);
if (vcursor.visual_row == 0) {
return;
}
const target_visual_row = vcursor.visual_row - 1;
// This persists across empty/narrow lines to restore column when possible
if (self.desired_visual_col == null) {
self.desired_visual_col = vcursor.visual_col;
}
const desired_visual_col = self.desired_visual_col.?;
if (self.visualToLogicalCursor(target_visual_row, desired_visual_col)) |new_vcursor| {
if (self.edit_buffer.cursors.items.len > 0) {
self.edit_buffer.cursors.items[0] = .{
.row = new_vcursor.logical_row,
.col = new_vcursor.logical_col,
.desired_col = new_vcursor.logical_col,
.offset = new_vcursor.offset,
};
self.ensureCursorVisible(new_vcursor.visual_row);
// Restore desired_visual_col after the cursor change event resets it
self.desired_visual_col = desired_visual_col;
}
}
}
pub fn moveDownVisual(self: *EditorView) void {
const cursor = self.edit_buffer.getPrimaryCursor();
const vcursor = self.logicalToVisualCursor(cursor.row, cursor.col);
self.text_buffer_view.updateVirtualLines();
const vlines = self.text_buffer_view.virtual_lines.items;
if (vcursor.visual_row + 1 >= vlines.len) {
return;
}
const target_visual_row = vcursor.visual_row + 1;
// This persists across empty/narrow lines to restore column when possible
if (self.desired_visual_col == null) {
self.desired_visual_col = vcursor.visual_col;
}
const desired_visual_col = self.desired_visual_col.?;
if (self.visualToLogicalCursor(target_visual_row, desired_visual_col)) |new_vcursor| {
if (self.edit_buffer.cursors.items.len > 0) {
self.edit_buffer.cursors.items[0] = .{
.row = new_vcursor.logical_row,
.col = new_vcursor.logical_col,
.desired_col = new_vcursor.logical_col,
.offset = new_vcursor.offset,
};
self.ensureCursorVisible(new_vcursor.visual_row);
// Restore desired_visual_col after the cursor change event resets it
self.desired_visual_col = desired_visual_col;
}
}
}
pub fn deleteSelectedText(self: *EditorView) !void {
const selection = self.text_buffer_view.getSelection() orelse {
return;
};
const start_coords = iter_mod.offsetToCoords(&self.edit_buffer.tb.rope, selection.start) orelse {
return;
};
const end_coords = iter_mod.offsetToCoords(&self.edit_buffer.tb.rope, selection.end) orelse {
return;
};
const start_cursor = eb.Cursor{
.row = start_coords.row,
.col = start_coords.col,
.desired_col = start_coords.col,
};
const end_cursor = eb.Cursor{
.row = end_coords.row,
.col = end_coords.col,
.desired_col = end_coords.col,
};
try self.edit_buffer.deleteRange(start_cursor, end_cursor);
self.text_buffer_view.resetLocalSelection();
self.updateBeforeRender();
}
pub fn setCursorByOffset(self: *EditorView, offset: u32) !void {
try self.edit_buffer.setCursorByOffset(offset);
self.updateBeforeRender();
}
pub fn getNextWordBoundary(self: *EditorView) VisualCursor {
const logical_cursor = self.edit_buffer.getNextWordBoundary();
return self.logicalToVisualCursor(logical_cursor.row, logical_cursor.col);
}
pub fn getPrevWordBoundary(self: *EditorView) VisualCursor {
const logical_cursor = self.edit_buffer.getPrevWordBoundary();
return self.logicalToVisualCursor(logical_cursor.row, logical_cursor.col);
}
pub fn getEOL(self: *EditorView) VisualCursor {
const logical_cursor = self.edit_buffer.getEOL();
return self.logicalToVisualCursor(logical_cursor.row, logical_cursor.col);
}
/// Get the start of the current visual line (SOL = Start Of Line)
/// Returns a cursor at column 0 of the current visual line
pub fn getVisualSOL(self: *EditorView) VisualCursor {
const cursor = self.edit_buffer.getPrimaryCursor();
const vcursor = self.logicalToVisualCursor(cursor.row, cursor.col);
self.text_buffer_view.updateVirtualLines();
const vlines = self.text_buffer_view.virtual_lines.items;
if (vcursor.visual_row >= vlines.len) {
// Fallback: return cursor at column 0 of current logical line
const offset = iter_mod.coordsToOffset(&self.edit_buffer.tb.rope, cursor.row, 0) orelse 0;
return VisualCursor{
.visual_row = vcursor.visual_row,
.visual_col = 0,
.logical_row = cursor.row,
.logical_col = 0,
.offset = offset,
};
}
const vline = &vlines[vcursor.visual_row];
const logical_col = vline.source_col_offset; // Start column of this visual line
const logical_row = @as(u32, @intCast(vline.source_line));
const offset = iter_mod.coordsToOffset(&self.edit_buffer.tb.rope, logical_row, logical_col) orelse 0;
return VisualCursor{
.visual_row = vcursor.visual_row,
.visual_col = 0,
.logical_row = logical_row,
.logical_col = logical_col,
.offset = offset,
};
}
/// Get the end of the current visual line (EOL = End Of Line)
/// Returns a cursor at the last position of the current visual line
/// For wrapped lines, this is the position just before the wrap boundary to ensure
/// the cursor stays on the current visual line when used with setCursor()
pub fn getVisualEOL(self: *EditorView) VisualCursor {
const cursor = self.edit_buffer.getPrimaryCursor();
const vcursor = self.logicalToVisualCursor(cursor.row, cursor.col);
self.text_buffer_view.updateVirtualLines();
const vlines = self.text_buffer_view.virtual_lines.items;
if (vcursor.visual_row >= vlines.len) {
// Fallback: return end of current logical line
const logical_cursor = self.edit_buffer.getEOL();
return self.logicalToVisualCursor(logical_cursor.row, logical_cursor.col);
}
const vline = &vlines[vcursor.visual_row];
const logical_row = @as(u32, @intCast(vline.source_line));
// Determine the logical column at the end of this visual line
var logical_col: u32 = undefined;
if (vcursor.visual_row + 1 < vlines.len) {
const next_vline = &vlines[vcursor.visual_row + 1];
if (next_vline.source_line == vline.source_line) {
// Next visual line is a continuation of the same logical line
// The wrap boundary is at next_vline.source_col_offset
// To stay on the current visual line, we need to be one position BEFORE the boundary
// However, if width is 0, just use the start position
if (vline.width > 0) {
logical_col = vline.source_col_offset + vline.width - 1;
} else {
logical_col = vline.source_col_offset;
}
} else {
// Next visual line is a different logical line, so we're at the end
logical_col = iter_mod.lineWidthAt(&self.edit_buffer.tb.rope, logical_row);
}
} else {
// This is the last visual line, use end of logical line
logical_col = iter_mod.lineWidthAt(&self.edit_buffer.tb.rope, logical_row);
}
return self.logicalToVisualCursor(logical_row, logical_col);
}
// ============================================================================
// Placeholder - Visual Only
// ============================================================================
pub fn setPlaceholderStyledText(self: *EditorView, chunks: []const tb.StyledChunk) !void {
if (chunks.len == 0) {
if (self.placeholder_syntax_style) |style| {
style.deinit();
self.placeholder_syntax_style = null;
}
if (self.placeholder_buffer) |placeholder| {
placeholder.deinit();
self.placeholder_buffer = null;
}
if (self.placeholder_active) {
self.text_buffer_view.switchToOriginalBuffer();
self.placeholder_active = false;
}
return;
}
if (self.placeholder_buffer == null) {
self.placeholder_buffer = try UnifiedTextBuffer.init(
self.global_allocator,
self.edit_buffer.tb.pool,
self.edit_buffer.tb.width_method,
);
const syntax_style = try ss.SyntaxStyle.init(self.global_allocator);
self.placeholder_syntax_style = syntax_style;
const placeholder = self.placeholder_buffer.?;
placeholder.setSyntaxStyle(syntax_style);
}
const placeholder = self.placeholder_buffer.?;
try placeholder.setStyledText(chunks);
if (self.placeholder_active) {
self.text_buffer_view.virtual_lines_dirty = true;
}
self.updatePlaceholderVisibility();
}
fn shouldShowPlaceholder(self: *const EditorView) bool {
const rope_len = self.edit_buffer.tb.rope.totalWeight();
return rope_len == 0 and self.placeholder_buffer != null;
}
fn updatePlaceholderVisibility(self: *EditorView) void {
const should_show = self.shouldShowPlaceholder();
if (should_show and !self.placeholder_active) {
if (self.placeholder_buffer) |placeholder| {
self.text_buffer_view.switchToBuffer(placeholder);
self.placeholder_active = true;
}
} else if (!should_show and self.placeholder_active) {
self.text_buffer_view.switchToOriginalBuffer();
self.placeholder_active = false;
}
}
pub fn setTabIndicator(self: *EditorView, indicator: ?u32) void {
self.text_buffer_view.setTabIndicator(indicator);
}
pub fn getTabIndicator(self: *const EditorView) ?u32 {
return self.text_buffer_view.getTabIndicator();
}
pub fn setTabIndicatorColor(self: *EditorView, color: ?tb.RGBA) void {
self.text_buffer_view.setTabIndicatorColor(color);
}
pub fn getTabIndicatorColor(self: *const EditorView) ?tb.RGBA {
return self.text_buffer_view.getTabIndicatorColor();
}
};