Current section
Files
Jump to
Current section
Files
zig/opentui/edit-buffer.zig
const std = @import("std");
const Allocator = std.mem.Allocator;
const tb = @import("text-buffer.zig");
const iter_mod = @import("text-buffer-iterators.zig");
const seg_mod = @import("text-buffer-segment.zig");
const gp = @import("grapheme.zig");
const utf8 = @import("utf8.zig");
const event_emitter = @import("event-emitter.zig");
const event_bus = @import("event-bus.zig");
const UnifiedTextBuffer = tb.UnifiedTextBuffer;
const TextChunk = seg_mod.TextChunk;
const Segment = seg_mod.Segment;
const UnifiedRope = seg_mod.UnifiedRope;
var global_edit_buffer_id: u16 = 0;
pub const EditBufferError = error{
OutOfMemory,
InvalidCursor,
};
pub const EditBufferEvent = enum {
cursorChanged,
};
/// Cursor position (row, col in display-width coordinates)
pub const Cursor = struct {
row: u32,
col: u32,
desired_col: u32 = 0,
offset: u32 = 0, // Global display-width offset from buffer start
};
const CursorCoords = struct { row: u32, col: u32 };
const AddBuffer = struct {
mem_id: u8,
ptr: [*]u8,
len: usize,
cap: usize,
allocator: Allocator,
fn init(allocator: Allocator, text_buffer: *UnifiedTextBuffer, initial_cap: usize) !AddBuffer {
const mem = try allocator.alloc(u8, initial_cap);
const mem_id = try text_buffer.registerMemBuffer(mem, true);
return .{
.mem_id = mem_id,
.ptr = mem.ptr,
.len = 0,
.cap = mem.len,
.allocator = allocator,
};
}
fn ensureCapacity(self: *AddBuffer, text_buffer: *UnifiedTextBuffer, need: usize) !void {
if (self.len + need <= self.cap) return;
// TODO: Create a new buffer, register the new buffer and use the new mem_id for subsequent inserts
const new_cap = @max(self.cap * 2, self.len + need);
const new_mem = try self.allocator.alloc(u8, new_cap);
const new_mem_id = try text_buffer.registerMemBuffer(new_mem, true);
self.mem_id = new_mem_id;
self.ptr = new_mem.ptr;
self.len = 0;
self.cap = new_mem.len;
}
fn append(self: *AddBuffer, bytes: []const u8) struct { mem_id: u8, start: u32, end: u32 } {
std.debug.assert(self.len + bytes.len <= self.cap);
const start: u32 = @intCast(self.len);
const dest_slice = self.ptr[0..self.cap];
@memcpy(dest_slice[self.len .. self.len + bytes.len], bytes);
self.len += bytes.len;
const end: u32 = @intCast(self.len);
return .{ .mem_id = self.mem_id, .start = start, .end = end };
}
};
pub const EditBuffer = struct {
id: u16,
tb: *UnifiedTextBuffer,
add_buffer: AddBuffer,
cursors: std.ArrayListUnmanaged(Cursor),
allocator: Allocator,
events: event_emitter.EventEmitter(EditBufferEvent),
segment_splitter: UnifiedRope.Node.LeafSplitFn,
pub fn init(
allocator: Allocator,
pool: *gp.GraphemePool,
width_method: utf8.WidthMethod,
) !*EditBuffer {
const self = try allocator.create(EditBuffer);
errdefer allocator.destroy(self);
const text_buffer = try UnifiedTextBuffer.init(allocator, pool, width_method);
errdefer text_buffer.deinit();
const add_buffer = try AddBuffer.init(allocator, text_buffer, 65536);
errdefer {}
var cursors: std.ArrayListUnmanaged(Cursor) = .{};
errdefer cursors.deinit(allocator);
try cursors.append(allocator, .{ .row = 0, .col = 0 });
const buffer_id = global_edit_buffer_id;
global_edit_buffer_id += 1;
self.* = .{
.id = buffer_id,
.tb = text_buffer,
.add_buffer = add_buffer,
.cursors = cursors,
.allocator = allocator,
.events = event_emitter.EventEmitter(EditBufferEvent).init(allocator),
.segment_splitter = .{ .ctx = self, .splitFn = splitSegmentCallback },
};
return self;
}
pub fn deinit(self: *EditBuffer) void {
// Registry owns all AddBuffer memory, don't free it manually
self.events.deinit();
self.tb.deinit();
self.cursors.deinit(self.allocator);
self.allocator.destroy(self);
}
pub fn getId(self: *const EditBuffer) u16 {
return self.id;
}
fn emitNativeEvent(self: *const EditBuffer, event_name: []const u8) void {
var id_bytes: [2]u8 = undefined;
std.mem.writeInt(u16, &id_bytes, self.id, .little);
const full_name = std.fmt.allocPrint(self.allocator, "eb_{s}", .{event_name}) catch return;
defer self.allocator.free(full_name);
event_bus.emit(full_name, &id_bytes);
}
pub fn getTextBuffer(self: *EditBuffer) *UnifiedTextBuffer {
return self.tb;
}
pub fn getCursor(self: *const EditBuffer, idx: usize) ?Cursor {
if (idx >= self.cursors.items.len) return null;
return self.cursors.items[idx];
}
pub fn getPrimaryCursor(self: *const EditBuffer) Cursor {
if (self.cursors.items.len == 0) return .{ .row = 0, .col = 0 };
return self.cursors.items[0];
}
pub fn setCursor(self: *EditBuffer, row: u32, col: u32) !void {
const line_count = self.tb.lineCount();
const clamped_row = @min(row, line_count -| 1);
const line_width = iter_mod.lineWidthAt(&self.tb.rope, clamped_row);
const clamped_col = @min(col, line_width);
const offset = iter_mod.coordsToOffset(&self.tb.rope, clamped_row, clamped_col) orelse 0;
if (self.cursors.items.len == 0) {
try self.cursors.append(self.allocator, .{ .row = clamped_row, .col = clamped_col, .desired_col = clamped_col, .offset = offset });
} else {
self.cursors.items[0] = .{ .row = clamped_row, .col = clamped_col, .desired_col = clamped_col, .offset = offset };
}
self.events.emit(.cursorChanged);
self.emitNativeEvent("cursor-changed");
}
pub fn setCursorByOffset(self: *EditBuffer, offset: u32) !void {
const coords = iter_mod.offsetToCoords(&self.tb.rope, offset) orelse iter_mod.Coords{ .row = 0, .col = 0 };
try self.setCursor(coords.row, coords.col);
}
fn ensureAddCapacity(self: *EditBuffer, need: usize) !void {
try self.add_buffer.ensureCapacity(self.tb, need);
}
/// TODO: This method should live in text-buffer-segment.zig and the Rope should take it as comptime param
fn splitChunkAtWeight(
self: *EditBuffer,
chunk: *const TextChunk,
weight: u32,
) error{ OutOfBounds, OutOfMemory }!struct { left: TextChunk, right: TextChunk } {
const chunk_weight = chunk.width;
if (weight == 0) {
return .{
.left = TextChunk{ .mem_id = 0, .byte_start = 0, .byte_end = 0, .width = 0 },
.right = chunk.*,
};
} else if (weight >= chunk_weight) {
return .{
.left = chunk.*,
.right = TextChunk{ .mem_id = 0, .byte_start = 0, .byte_end = 0, .width = 0 },
};
}
const chunk_bytes = chunk.getBytes(&self.tb.mem_registry);
const is_ascii_only = (chunk.flags & TextChunk.Flags.ASCII_ONLY) != 0;
const result = utf8.findPosByWidth(chunk_bytes, weight, self.tb.tab_width, is_ascii_only, false, self.tb.width_method);
const split_byte_offset = result.byte_offset;
const left_chunk = self.tb.createChunk(
chunk.mem_id,
chunk.byte_start,
chunk.byte_start + split_byte_offset,
);
const right_chunk = self.tb.createChunk(
chunk.mem_id,
chunk.byte_start + split_byte_offset,
chunk.byte_end,
);
return .{ .left = left_chunk, .right = right_chunk };
}
fn splitSegmentCallback(
ctx: ?*anyopaque,
allocator: Allocator,
leaf: *const Segment,
weight_in_leaf: u32,
) error{ OutOfBounds, OutOfMemory }!UnifiedRope.Node.LeafSplitResult {
_ = allocator;
const edit_buf = @as(*EditBuffer, @ptrCast(@alignCast(ctx.?)));
if (leaf.asText()) |chunk| {
const result = try edit_buf.splitChunkAtWeight(chunk, weight_in_leaf);
return .{
.left = Segment{ .text = result.left },
.right = Segment{ .text = result.right },
};
} else {
return .{
.left = Segment{ .brk = {} },
.right = Segment{ .brk = {} },
};
}
}
pub fn insertText(self: *EditBuffer, bytes: []const u8) !void {
if (bytes.len == 0) return;
if (self.cursors.items.len == 0) return;
try self.autoStoreUndo();
const cursor = self.cursors.items[0];
try self.ensureAddCapacity(bytes.len);
const insert_offset = iter_mod.coordsToOffset(&self.tb.rope, cursor.row, cursor.col) orelse return EditBufferError.InvalidCursor;
const chunk_ref = self.add_buffer.append(bytes);
const base_mem_id = chunk_ref.mem_id;
const base_start = chunk_ref.start;
var result = try self.tb.textToSegments(self.allocator, bytes, base_mem_id, base_start, false);
defer result.segments.deinit(result.allocator);
const inserted_width = result.total_width;
// Calculate width after last break
var width_after_last_break: u32 = 0;
var num_breaks: usize = 0;
for (result.segments.items) |seg| {
if (seg.isBreak()) {
num_breaks += 1;
width_after_last_break = 0;
} else if (seg.asText()) |chunk| {
width_after_last_break += chunk.width;
}
}
if (result.segments.items.len > 0) {
try self.tb.rope.insertSliceByWeight(insert_offset, result.segments.items, &self.segment_splitter);
}
if (num_breaks > 0) {
const new_row = cursor.row + @as(u32, @intCast(num_breaks));
const new_col = width_after_last_break;
const new_offset = iter_mod.coordsToOffset(&self.tb.rope, new_row, new_col) orelse 0;
self.cursors.items[0] = .{
.row = new_row,
.col = new_col,
.desired_col = new_col,
.offset = new_offset,
};
} else {
const new_col = cursor.col + inserted_width;
const new_offset = iter_mod.coordsToOffset(&self.tb.rope, cursor.row, new_col) orelse 0;
self.cursors.items[0] = .{
.row = cursor.row,
.col = new_col,
.desired_col = new_col,
.offset = new_offset,
};
}
self.tb.markViewsDirty();
self.events.emit(.cursorChanged);
self.emitNativeEvent("cursor-changed");
self.emitNativeEvent("content-changed");
}
pub fn deleteRange(self: *EditBuffer, start_cursor: Cursor, end_cursor: Cursor) !void {
var start = start_cursor;
var end = end_cursor;
if (start.row > end.row or (start.row == end.row and start.col > end.col)) {
const temp = start;
start = end;
end = temp;
}
if (start.row == end.row and start.col == end.col) return;
try self.autoStoreUndo();
const start_offset = iter_mod.coordsToOffset(&self.tb.rope, start.row, start.col) orelse return EditBufferError.InvalidCursor;
const end_offset = iter_mod.coordsToOffset(&self.tb.rope, end.row, end.col) orelse return EditBufferError.InvalidCursor;
if (start_offset >= end_offset) return;
try self.tb.rope.deleteRangeByWeight(start_offset, end_offset, &self.segment_splitter);
self.tb.markViewsDirty();
if (self.cursors.items.len > 0) {
const line_count = self.tb.lineCount();
const clamped_row = if (start.row >= line_count) line_count -| 1 else start.row;
const line_width = if (line_count > 0) iter_mod.lineWidthAt(&self.tb.rope, clamped_row) else 0;
const clamped_col = @min(start.col, line_width);
const offset = iter_mod.coordsToOffset(&self.tb.rope, clamped_row, clamped_col) orelse 0;
self.cursors.items[0] = .{ .row = clamped_row, .col = clamped_col, .desired_col = clamped_col, .offset = offset };
}
self.events.emit(.cursorChanged);
self.emitNativeEvent("cursor-changed");
self.emitNativeEvent("content-changed");
}
pub fn backspace(self: *EditBuffer) !void {
if (self.cursors.items.len == 0) return;
const cursor = self.cursors.items[0];
if (cursor.row == 0 and cursor.col == 0) return;
if (cursor.col == 0) {
if (cursor.row > 0) {
const prev_line_width = iter_mod.lineWidthAt(&self.tb.rope, cursor.row - 1);
try self.deleteRange(
.{ .row = cursor.row - 1, .col = prev_line_width },
.{ .row = cursor.row, .col = 0 },
);
}
} else {
const prev_grapheme_width = iter_mod.getPrevGraphemeWidth(&self.tb.rope, &self.tb.mem_registry, cursor.row, cursor.col, self.tb.tab_width, self.tb.width_method);
if (prev_grapheme_width == 0) return; // Nothing to delete
const target_col = cursor.col - prev_grapheme_width;
try self.deleteRange(
.{ .row = cursor.row, .col = target_col },
.{ .row = cursor.row, .col = cursor.col },
);
}
// deleteRange already checks for placeholder insertion
}
pub fn deleteForward(self: *EditBuffer) !void {
if (self.cursors.items.len == 0) return;
const cursor = self.cursors.items[0];
try self.autoStoreUndo();
const line_width = iter_mod.lineWidthAt(&self.tb.rope, cursor.row);
const line_count = self.tb.lineCount();
if (cursor.col >= line_width) {
if (cursor.row + 1 < line_count) {
try self.deleteRange(
.{ .row = cursor.row, .col = line_width },
.{ .row = cursor.row + 1, .col = 0 },
);
}
} else {
const grapheme_width = iter_mod.getGraphemeWidthAt(&self.tb.rope, &self.tb.mem_registry, cursor.row, cursor.col, self.tb.tab_width, self.tb.width_method);
if (grapheme_width > 0) {
try self.deleteRange(
.{ .row = cursor.row, .col = cursor.col },
.{ .row = cursor.row, .col = cursor.col + grapheme_width },
);
}
}
}
pub fn moveLeft(self: *EditBuffer) void {
if (self.cursors.items.len == 0) {
return;
}
const cursor = &self.cursors.items[0];
if (cursor.col > 0) {
const prev_width = iter_mod.getPrevGraphemeWidth(&self.tb.rope, &self.tb.mem_registry, cursor.row, cursor.col, self.tb.tab_width, self.tb.width_method);
cursor.col -= prev_width;
} else if (cursor.row > 0) {
cursor.row -= 1;
const line_width = iter_mod.lineWidthAt(&self.tb.rope, cursor.row);
cursor.col = line_width;
}
cursor.desired_col = cursor.col;
cursor.offset = iter_mod.coordsToOffset(&self.tb.rope, cursor.row, cursor.col) orelse 0;
self.events.emit(.cursorChanged);
self.emitNativeEvent("cursor-changed");
}
pub fn moveRight(self: *EditBuffer) void {
if (self.cursors.items.len == 0) return;
const cursor = &self.cursors.items[0];
const line_width = iter_mod.lineWidthAt(&self.tb.rope, cursor.row);
const line_count = self.tb.getLineCount();
if (cursor.col < line_width) {
const grapheme_width = iter_mod.getGraphemeWidthAt(&self.tb.rope, &self.tb.mem_registry, cursor.row, cursor.col, self.tb.tab_width, self.tb.width_method);
cursor.col += grapheme_width;
} else if (cursor.row + 1 < line_count) {
cursor.row += 1;
cursor.col = 0;
}
cursor.desired_col = cursor.col;
cursor.offset = iter_mod.coordsToOffset(&self.tb.rope, cursor.row, cursor.col) orelse 0;
self.events.emit(.cursorChanged);
self.emitNativeEvent("cursor-changed");
}
pub fn moveUp(self: *EditBuffer) void {
if (self.cursors.items.len == 0) return;
const cursor = &self.cursors.items[0];
if (cursor.row > 0) {
if (cursor.desired_col == 0) {
cursor.desired_col = cursor.col;
}
cursor.row -= 1;
const line_width = iter_mod.lineWidthAt(&self.tb.rope, cursor.row);
cursor.col = @min(cursor.desired_col, line_width);
cursor.offset = iter_mod.coordsToOffset(&self.tb.rope, cursor.row, cursor.col) orelse 0;
}
self.events.emit(.cursorChanged);
self.emitNativeEvent("cursor-changed");
}
pub fn moveDown(self: *EditBuffer) void {
if (self.cursors.items.len == 0) return;
const cursor = &self.cursors.items[0];
const line_count = self.tb.getLineCount();
if (cursor.row + 1 < line_count) {
if (cursor.desired_col == 0) {
cursor.desired_col = cursor.col;
}
cursor.row += 1;
const line_width = iter_mod.lineWidthAt(&self.tb.rope, cursor.row);
cursor.col = @min(cursor.desired_col, line_width);
cursor.offset = iter_mod.coordsToOffset(&self.tb.rope, cursor.row, cursor.col) orelse 0;
}
self.events.emit(.cursorChanged);
self.emitNativeEvent("cursor-changed");
}
/// Set text and completely reset the buffer state (clears history, resets add_buffer)
pub fn setText(self: *EditBuffer, text: []const u8) !void {
const owned_text = try self.allocator.dupe(u8, text);
const mem_id = try self.tb.registerMemBuffer(owned_text, true);
try self.setTextFromMemId(mem_id);
}
/// Set text from memory ID and completely reset the buffer state (clears history, resets add_buffer)
pub fn setTextFromMemId(self: *EditBuffer, mem_id: u8) !void {
self.tb.rope.clear_history();
self.add_buffer.len = 0;
try self.tb.setTextFromMemId(mem_id);
try self.setCursor(0, 0);
self.emitNativeEvent("content-changed");
}
/// Replace text while preserving undo history (creates an undo point)
pub fn replaceText(self: *EditBuffer, text: []const u8) !void {
const owned_text = try self.allocator.dupe(u8, text);
const mem_id = try self.tb.registerMemBuffer(owned_text, true);
try self.replaceTextFromMemId(mem_id);
}
/// Replace text from memory ID while preserving undo history (creates an undo point)
pub fn replaceTextFromMemId(self: *EditBuffer, mem_id: u8) !void {
try self.autoStoreUndo();
try self.tb.setTextFromMemId(mem_id);
try self.setCursor(0, 0);
self.emitNativeEvent("content-changed");
}
pub fn getText(self: *EditBuffer, out_buffer: []u8) usize {
return self.tb.getPlainTextIntoBuffer(out_buffer);
}
pub fn deleteLine(self: *EditBuffer) !void {
const cursor = self.getPrimaryCursor();
const line_count = self.tb.lineCount();
if (cursor.row >= line_count) return;
if (cursor.row + 1 < line_count) {
try self.deleteRange(
.{ .row = cursor.row, .col = 0 },
.{ .row = cursor.row + 1, .col = 0 },
);
} else if (cursor.row > 0) {
const prev_line_width = iter_mod.lineWidthAt(&self.tb.rope, cursor.row - 1);
const curr_line_width = iter_mod.lineWidthAt(&self.tb.rope, cursor.row);
try self.deleteRange(
.{ .row = cursor.row - 1, .col = prev_line_width },
.{ .row = cursor.row, .col = curr_line_width },
);
self.tb.markViewsDirty();
const new_row = cursor.row - 1;
const new_col = prev_line_width;
const new_offset = iter_mod.coordsToOffset(&self.tb.rope, new_row, new_col) orelse 0;
self.cursors.items[0] = .{ .row = new_row, .col = new_col, .desired_col = new_col, .offset = new_offset };
self.events.emit(.cursorChanged);
self.emitNativeEvent("cursor-changed");
} else {
const line_width = iter_mod.lineWidthAt(&self.tb.rope, cursor.row);
if (line_width > 0) {
try self.deleteRange(
.{ .row = cursor.row, .col = 0 },
.{ .row = cursor.row, .col = line_width },
);
}
}
}
pub fn gotoLine(self: *EditBuffer, line: u32) !void {
const line_count = self.tb.lineCount();
const target_line = @min(line, line_count -| 1);
if (line >= line_count) {
const last_line_width = iter_mod.lineWidthAt(&self.tb.rope, target_line);
try self.setCursor(target_line, last_line_width);
} else {
try self.setCursor(target_line, 0);
}
}
pub fn getCursorPosition(self: *const EditBuffer) struct { line: u32, visual_col: u32, offset: u32 } {
const cursor = self.getPrimaryCursor();
return .{
.line = cursor.row,
.visual_col = cursor.col,
.offset = cursor.offset,
};
}
pub fn debugLogRope(self: *const EditBuffer) void {
self.tb.debugLogRope();
}
fn autoStoreUndo(self: *EditBuffer) !void {
try self.tb.rope.store_undo("edit");
}
pub fn undo(self: *EditBuffer) ![]const u8 {
const prev_meta = try self.tb.rope.undo("current");
const cursor = self.getPrimaryCursor();
try self.setCursor(cursor.row, cursor.col);
self.tb.markViewsDirty();
self.events.emit(.cursorChanged);
self.emitNativeEvent("cursorChanged");
return prev_meta;
}
pub fn redo(self: *EditBuffer) ![]const u8 {
const next_meta = try self.tb.rope.redo();
const cursor = self.getPrimaryCursor();
try self.setCursor(cursor.row, cursor.col);
self.tb.markViewsDirty();
self.events.emit(.cursorChanged);
self.emitNativeEvent("cursorChanged");
return next_meta;
}
pub fn canUndo(self: *const EditBuffer) bool {
return self.tb.rope.can_undo();
}
pub fn canRedo(self: *const EditBuffer) bool {
return self.tb.rope.can_redo();
}
pub fn clearHistory(self: *EditBuffer) void {
self.tb.rope.clear_history();
}
pub fn clear(self: *EditBuffer) !void {
self.tb.clear();
try self.setCursor(0, 0);
self.emitNativeEvent("content-changed");
}
pub fn getNextWordBoundary(self: *EditBuffer) Cursor {
if (self.cursors.items.len == 0) return .{ .row = 0, .col = 0 };
const cursor = self.cursors.items[0];
const line_count = self.tb.lineCount();
if (cursor.row >= line_count) return cursor;
const line_width = iter_mod.lineWidthAt(&self.tb.rope, cursor.row);
const linestart = self.tb.rope.getMarker(.linestart, cursor.row) orelse return cursor;
var seg_idx = linestart.leaf_index + 1;
var cols_before: u32 = 0;
var passed_cursor = false;
while (seg_idx < self.tb.rope.count()) : (seg_idx += 1) {
const seg = self.tb.rope.get(seg_idx) orelse break;
if (seg.isBreak() or seg.isLineStart()) break;
if (seg.asText()) |chunk| {
const next_cols = cols_before + chunk.width;
// Check this chunk if cursor is within it OR if we've already passed the cursor
if (cursor.col < next_cols or passed_cursor) {
const wrap_offsets = chunk.getWrapOffsets(&self.tb.mem_registry, self.tb.allocator, self.tb.width_method) catch {
cols_before = next_cols;
passed_cursor = true;
continue;
};
const is_ascii_only = (chunk.flags & TextChunk.Flags.ASCII_ONLY) != 0;
const graphemes: []const seg_mod.GraphemeInfo = if (is_ascii_only)
&[_]seg_mod.GraphemeInfo{}
else
chunk.getGraphemes(&self.tb.mem_registry, self.tb.allocator, self.tb.tab_width, self.tb.width_method) catch &[_]seg_mod.GraphemeInfo{};
var grapheme_idx: usize = 0;
var col_delta: i64 = 0;
// For chunks containing or after the cursor, find the first break after cursor position
const local_cursor_col = if (cursor.col > cols_before) cursor.col - cols_before else 0;
for (wrap_offsets) |wrap_break| {
const break_info = iter_mod.charOffsetToColumn(wrap_break.char_offset, graphemes, &grapheme_idx, &col_delta);
const break_col = break_info.col;
// If we've passed the cursor chunk, any break is valid
// If we're in the cursor chunk, break must be after cursor position
if (passed_cursor or break_col > local_cursor_col) {
const target_col = cols_before + break_col + break_info.width;
if (target_col <= line_width) {
const offset = iter_mod.coordsToOffset(&self.tb.rope, cursor.row, target_col) orelse cursor.offset;
return .{ .row = cursor.row, .col = target_col, .desired_col = target_col, .offset = offset };
}
}
}
// Mark that we've processed/passed the cursor position
passed_cursor = true;
}
cols_before = next_cols;
}
}
if (cursor.row + 1 < line_count) {
const offset = iter_mod.coordsToOffset(&self.tb.rope, cursor.row + 1, 0) orelse cursor.offset;
return .{ .row = cursor.row + 1, .col = 0, .desired_col = 0, .offset = offset };
}
const offset = iter_mod.coordsToOffset(&self.tb.rope, cursor.row, line_width) orelse cursor.offset;
return .{ .row = cursor.row, .col = line_width, .desired_col = line_width, .offset = offset };
}
pub fn getPrevWordBoundary(self: *EditBuffer) Cursor {
if (self.cursors.items.len == 0) return .{ .row = 0, .col = 0 };
const cursor = self.cursors.items[0];
if (cursor.row == 0 and cursor.col == 0) return cursor;
const linestart = self.tb.rope.getMarker(.linestart, cursor.row) orelse return cursor;
var seg_idx = linestart.leaf_index + 1;
var cols_before: u32 = 0;
var last_boundary: ?u32 = null;
while (seg_idx < self.tb.rope.count()) : (seg_idx += 1) {
const seg = self.tb.rope.get(seg_idx) orelse break;
if (seg.isBreak() or seg.isLineStart()) break;
if (seg.asText()) |chunk| {
const next_cols = cols_before + chunk.width;
const wrap_offsets = chunk.getWrapOffsets(&self.tb.mem_registry, self.tb.allocator, self.tb.width_method) catch {
cols_before = next_cols;
continue;
};
const is_ascii_only = (chunk.flags & TextChunk.Flags.ASCII_ONLY) != 0;
const graphemes: []const seg_mod.GraphemeInfo = if (is_ascii_only)
&[_]seg_mod.GraphemeInfo{}
else
chunk.getGraphemes(&self.tb.mem_registry, self.tb.allocator, self.tb.tab_width, self.tb.width_method) catch &[_]seg_mod.GraphemeInfo{};
var grapheme_idx: usize = 0;
var col_delta: i64 = 0;
for (wrap_offsets) |wrap_break| {
const break_info = iter_mod.charOffsetToColumn(wrap_break.char_offset, graphemes, &grapheme_idx, &col_delta);
const boundary_col = cols_before + break_info.col + break_info.width;
if (boundary_col < cursor.col) {
last_boundary = boundary_col;
}
}
cols_before = next_cols;
if (cursor.col <= cols_before) break;
}
}
if (last_boundary) |boundary_col| {
const offset = iter_mod.coordsToOffset(&self.tb.rope, cursor.row, boundary_col) orelse cursor.offset;
return .{ .row = cursor.row, .col = boundary_col, .desired_col = boundary_col, .offset = offset };
}
if (cursor.row > 0) {
const prev_line_width = iter_mod.lineWidthAt(&self.tb.rope, cursor.row - 1);
const offset = iter_mod.coordsToOffset(&self.tb.rope, cursor.row - 1, prev_line_width) orelse cursor.offset;
return .{ .row = cursor.row - 1, .col = prev_line_width, .desired_col = prev_line_width, .offset = offset };
}
return .{ .row = 0, .col = 0, .desired_col = 0, .offset = 0 };
}
pub fn getEOL(self: *EditBuffer) Cursor {
if (self.cursors.items.len == 0) return .{ .row = 0, .col = 0 };
const cursor = self.cursors.items[0];
const line_count = self.tb.lineCount();
if (cursor.row >= line_count) return cursor;
const line_width = iter_mod.lineWidthAt(&self.tb.rope, cursor.row);
const offset = iter_mod.coordsToOffset(&self.tb.rope, cursor.row, line_width) orelse cursor.offset;
return .{ .row = cursor.row, .col = line_width, .desired_col = line_width, .offset = offset };
}
/// Get text within a range of display-width offsets
/// Automatically snaps to grapheme boundaries:
/// - start_offset excludes graphemes that start before it
/// - end_offset includes graphemes that start before it
/// Returns number of bytes written to out_buffer
pub fn getTextRange(self: *EditBuffer, start_offset: u32, end_offset: u32, out_buffer: []u8) !usize {
return self.tb.getTextRange(start_offset, end_offset, out_buffer);
}
/// Get text within a range specified by row/col coordinates
/// Automatically snaps to grapheme boundaries:
/// Returns number of bytes written to out_buffer
pub fn getTextRangeByCoords(self: *EditBuffer, start_row: u32, start_col: u32, end_row: u32, end_col: u32, out_buffer: []u8) usize {
return self.tb.getTextRangeByCoords(start_row, start_col, end_row, end_col, out_buffer);
}
};