From a264a6b77d73c5f2e97689fdad5e46682a246541 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1t=C3=A9=20M=C3=A9sz=C3=A1ros=20=28Laptop=29?= Date: Sat, 14 Feb 2026 12:53:19 +0100 Subject: [PATCH] Fix text editor cursor display --- examples/text_editor.zig | 2 +- src/components/text_area.zig | 29 +++++++++++++++++++++++++++++ tests/input_tests.zig | 28 ++++++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/examples/text_editor.zig b/examples/text_editor.zig index 97f2f29..75521d4 100644 --- a/examples/text_editor.zig +++ b/examples/text_editor.zig @@ -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{}; diff --git a/src/components/text_area.zig b/src/components/text_area.zig index 684ad32..1d56abd 100644 --- a/src/components/text_area.zig +++ b/src/components/text_area.zig @@ -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..]; @@ -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; @@ -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); @@ -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; @@ -365,6 +370,7 @@ 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(); } } @@ -372,10 +378,12 @@ pub const TextArea = struct { 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 @@ -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); @@ -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 { @@ -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 { @@ -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); @@ -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; + } }; diff --git a/tests/input_tests.zig b/tests/input_tests.zig index 1feeb1f..e2fa0f8 100644 --- a/tests/input_tests.zig +++ b/tests/input_tests.zig @@ -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()); +}