ReviewOS

also looking at this

den-shell/den

feat(line-editor): UTF-8-aware input, cursor movement, and deletion

#57
Merged chris wants to merge feat/utf8-input-editing into main
1 file +61 -12

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

src/utils/terminal/line_editor.zigmodified+61-12
Changes to src/utils/terminal/line_editor.zig
@@ -897,8 +897,16 @@ pub const LineEditor = struct {
897897 try self.insertChar(byte);
898898 }
899899 },
900 0xC2...0xF4 => {
901 // UTF-8 lead byte — assemble and insert the full codepoint so
902 // accented/CJK/emoji input works. Ignored during reverse search.
903 if (!self.reverse_search_mode) {
904 self.clearCompletionState();
905 try self.insertUtf8(byte);
906 }
907 },
900908 else => {
901 // Ignore other control characters
909 // Ignore other control / stray continuation bytes
902910 },
903911 }
904912 }
@@ -1349,7 +1357,9 @@ pub const LineEditor = struct {
13491357 }
13501358
13511359 fn deleteChar(self: *LineEditor) !void {
1352 if (self.cursor >= self.length) return;
1360 // Delete the whole codepoint at the cursor (1-4 bytes), not one byte.
1361 const del = self.curCodepointLen();
1362 if (del == 0) return;
13531363
13541364 // Save state for undo
13551365 self.saveUndoState();
@@ -1357,36 +1367,75 @@ pub const LineEditor = struct {
13571367 // Clear history search when user deletes
13581368 self.clearHistorySearch();
13591369
1360 // Move characters after cursor to the left
1370 // Shift the tail left by the codepoint's byte length
13611371 var i = self.cursor;
1362 while (i < self.length - 1) : (i += 1) {
1363 self.buffer[i] = self.buffer[i + 1];
1372 while (i < self.length - del) : (i += 1) {
1373 self.buffer[i] = self.buffer[i + del];
13641374 }
13651375
1366 self.length -= 1;
1376 self.length -= del;
13671377
13681378 // Redraw from cursor to end
13691379 try self.writeBytes(self.buffer[self.cursor..self.length]);
13701380 try self.writeBytes(" ");
13711381 try self.writeBytes("\x1B[K");
13721382
1373 // Move cursor back
1374 const back_count = self.length - self.cursor + 1;
1383 // Move cursor back by display columns (codepoints) in the tail, +1 for
1384 // the space we printed above.
1385 var back_count: usize = 1;
1386 var p = self.cursor;
1387 while (p < self.length) : (p += utf8SeqLen(self.buffer[p])) back_count += 1;
13751388 var j: usize = 0;
13761389 while (j < back_count) : (j += 1) {
13771390 try self.writeBytes("\x1B[D");
13781391 }
13791392 }
13801393
1394 /// UTF-8: true if `b` is a continuation byte (10xxxxxx).
1395 fn isUtf8Continuation(b: u8) bool {
1396 return (b & 0xC0) == 0x80;
1397 }
1398
1399 /// UTF-8: byte length of the codepoint whose lead byte is `b`. Invalid lead
1400 /// bytes count as one byte so editing never stalls on malformed input.
1401 fn utf8SeqLen(b: u8) usize {
1402 if (b < 0x80) return 1;
1403 if ((b & 0xE0) == 0xC0) return 2;
1404 if ((b & 0xF0) == 0xE0) return 3;
1405 if ((b & 0xF8) == 0xF0) return 4;
1406 return 1;
1407 }
1408
1409 /// Byte length of the codepoint ending just before the cursor (0 at start).
1410 fn prevCodepointLen(self: *LineEditor) usize {
1411 if (self.cursor == 0) return 0;
1412 var i = self.cursor - 1;
1413 var n: usize = 1;
1414 while (i > 0 and isUtf8Continuation(self.buffer[i]) and n < 4) : (i -= 1) {
1415 n += 1;
1416 }
1417 return n;
1418 }
1419
1420 /// Byte length of the codepoint at the cursor (0 at end), clamped to length.
1421 fn curCodepointLen(self: *LineEditor) usize {
1422 if (self.cursor >= self.length) return 0;
1423 var n = utf8SeqLen(self.buffer[self.cursor]);
1424 if (self.cursor + n > self.length) n = self.length - self.cursor;
1425 return n;
1426 }
1427
13811428 fn moveCursorLeft(self: *LineEditor) !void {
1382 if (self.cursor == 0) return;
1383 self.cursor -= 1;
1429 const n = self.prevCodepointLen();
1430 if (n == 0) return;
1431 self.cursor -= n;
13841432 try self.writeBytes("\x1B[D");
13851433 }
13861434
13871435 fn moveCursorRight(self: *LineEditor) !void {
1388 if (self.cursor >= self.length) return;
1389 self.cursor += 1;
1436 const n = self.curCodepointLen();
1437 if (n == 0) return;
1438 self.cursor += n;
13901439 try self.writeBytes("\x1B[C");
13911440 }
13921441