Skip to content
Merged
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
2 changes: 1 addition & 1 deletion examples/text_editor.zig
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ const Model = struct {
const cursor_info = std.fmt.allocPrint(
ctx.allocator,
"Ln {d}, Col {d} | {d} lines",
.{ self.editor.cursor_row + 1, self.editor.cursor_col + 1, self.editor.lineCount() },
.{ self.editor.cursor_row + 1, self.editor.cursorDisplayColumn() + 1, self.editor.lineCount() },
) catch "";

var status_style = zz.Style{};
Expand Down
29 changes: 29 additions & 0 deletions src/components/text_area.zig
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,7 @@ pub const TextArea = struct {
if (self.lines.items.len >= max) return;
}

self.clampCursorToLineBoundary();
const line = self.currentLine();
const rest = line.items[self.cursor_col..];

Expand Down Expand Up @@ -319,6 +320,7 @@ pub const TextArea = struct {

fn deleteForward(self: *TextArea) void {
const line = self.currentLine();
self.clampCursorToLineBoundary();
if (self.cursor_col < line.items.len) {
// Delete character at cursor
const byte_len = std.unicode.utf8ByteSequenceLength(line.items[self.cursor_col]) catch 1;
Expand All @@ -336,11 +338,13 @@ pub const TextArea = struct {
}

fn killToEndOfLine(self: *TextArea) void {
self.clampCursorToLineBoundary();
const line = self.currentLine();
line.shrinkRetainingCapacity(self.cursor_col);
}

fn killToStartOfLine(self: *TextArea) void {
self.clampCursorToLineBoundary();
const line = self.currentLine();
std.mem.copyForwards(u8, line.items[0..], line.items[self.cursor_col..]);
line.shrinkRetainingCapacity(line.items.len - self.cursor_col);
Expand All @@ -355,6 +359,7 @@ pub const TextArea = struct {
self.cursor_row = self.lines.items.len - 1;
}
self.cursor_col = @min(self.cursor_col, self.currentLine().items.len);
self.clampCursorToLineBoundary();
} else {
self.currentLine().clearRetainingCapacity();
self.cursor_col = 0;
Expand All @@ -365,17 +370,20 @@ pub const TextArea = struct {
if (self.cursor_row > 0) {
self.cursor_row -= 1;
self.cursor_col = @min(self.cursor_col, self.currentLine().items.len);
self.clampCursorToLineBoundary();
}
}

fn moveCursorDown(self: *TextArea) void {
if (self.cursor_row < self.lines.items.len - 1) {
self.cursor_row += 1;
self.cursor_col = @min(self.cursor_col, self.currentLine().items.len);
self.clampCursorToLineBoundary();
}
}

fn moveCursorLeft(self: *TextArea) void {
self.clampCursorToLineBoundary();
if (self.cursor_col > 0) {
self.cursor_col -= 1;
// Handle UTF-8 continuation bytes
Expand All @@ -391,6 +399,7 @@ pub const TextArea = struct {

fn moveCursorRight(self: *TextArea) void {
const line = self.currentLine();
self.clampCursorToLineBoundary();
if (self.cursor_col < line.items.len) {
const byte_len = std.unicode.utf8ByteSequenceLength(line.items[self.cursor_col]) catch 1;
self.cursor_col = @min(self.cursor_col + byte_len, line.items.len);
Expand All @@ -407,6 +416,7 @@ pub const TextArea = struct {
self.cursor_row = 0;
}
self.cursor_col = @min(self.cursor_col, self.currentLine().items.len);
self.clampCursorToLineBoundary();
}

fn pageDown(self: *TextArea) void {
Expand All @@ -416,6 +426,7 @@ pub const TextArea = struct {
self.cursor_row = self.lines.items.len - 1;
}
self.cursor_col = @min(self.cursor_col, self.currentLine().items.len);
self.clampCursorToLineBoundary();
}

fn ensureVisible(self: *TextArea) void {
Expand All @@ -436,6 +447,11 @@ pub const TextArea = struct {
}
}

/// Cursor column in terminal cells (display width), 0-indexed.
pub fn cursorDisplayColumn(self: *const TextArea) usize {
return self.cursorDisplayCol();
}

/// Render the text area
pub fn view(self: *const TextArea, allocator: std.mem.Allocator) ![]const u8 {
var result = std.array_list.Managed(u8).init(allocator);
Expand Down Expand Up @@ -567,4 +583,17 @@ pub const TextArea = struct {
}
return display_col;
}

fn clampCursorToLineBoundary(self: *TextArea) void {
const line = self.currentLine();
self.cursor_col = clampToUtf8Boundary(line.items, self.cursor_col);
}

fn clampToUtf8Boundary(line: []const u8, pos: usize) usize {
var clamped = @min(pos, line.len);
while (clamped > 0 and clamped < line.len and (line[clamped] & 0xC0) == 0x80) {
clamped -= 1;
}
return clamped;
}
};
28 changes: 28 additions & 0 deletions tests/input_tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -150,3 +150,31 @@ test "TextArea accepts multi-character paste commits (IME-like)" {
defer testing.allocator.free(value);
try testing.expectEqualStrings("中文输入", value);
}

test "TextArea keeps cursor on UTF-8 boundaries after vertical move" {
var area = zz.TextArea.init(testing.allocator);
defer area.deinit();

area.setValue("abcd\n中文") catch unreachable;
area.handleKey(.{ .key = .end });
area.handleKey(.{ .key = .down });

const value = try area.getValue(testing.allocator);
defer testing.allocator.free(value);
try testing.expectEqualStrings("abcd\n中文", value);

area.handleKey(.{ .key = .{ .char = 'X' } });
const updated = try area.getValue(testing.allocator);
defer testing.allocator.free(updated);
try testing.expectEqualStrings("abcd\n中X文", updated);
}

test "TextArea cursorDisplayColumn uses visual width for CJK" {
var area = zz.TextArea.init(testing.allocator);
defer area.deinit();

area.handleKey(.{ .key = .{ .paste = "中a" } });
area.handleKey(.{ .key = .left });

try testing.expectEqual(@as(usize, 2), area.cursorDisplayColumn());
}