also looking at this
feat(line-editor): UTF-8-aware input, cursor movement, and deletion
#57
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.
| @@ -897,8 +897,16 @@ pub const LineEditor = struct { | ||
| 897 | 897 | try self.insertChar(byte); |
| 898 | 898 | } |
| 899 | 899 | }, |
| 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 | }, | |
| 900 | 908 | else => { |
| 901 | // Ignore other control characters | |
| 909 | // Ignore other control / stray continuation bytes | |
| 902 | 910 | }, |
| 903 | 911 | } |
| 904 | 912 | } |
| @@ -1349,7 +1357,9 @@ pub const LineEditor = struct { | ||
| 1349 | 1357 | } |
| 1350 | 1358 | |
| 1351 | 1359 | 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; | |
| 1353 | 1363 | |
| 1354 | 1364 | // Save state for undo |
| 1355 | 1365 | self.saveUndoState(); |
| @@ -1357,36 +1367,75 @@ pub const LineEditor = struct { | ||
| 1357 | 1367 | // Clear history search when user deletes |
| 1358 | 1368 | self.clearHistorySearch(); |
| 1359 | 1369 | |
| 1360 | // Move characters after cursor to the left | |
| 1370 | // Shift the tail left by the codepoint's byte length | |
| 1361 | 1371 | 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]; | |
| 1364 | 1374 | } |
| 1365 | 1375 | |
| 1366 | self.length -= 1; | |
| 1376 | self.length -= del; | |
| 1367 | 1377 | |
| 1368 | 1378 | // Redraw from cursor to end |
| 1369 | 1379 | try self.writeBytes(self.buffer[self.cursor..self.length]); |
| 1370 | 1380 | try self.writeBytes(" "); |
| 1371 | 1381 | try self.writeBytes("\x1B[K"); |
| 1372 | 1382 | |
| 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; | |
| 1375 | 1388 | var j: usize = 0; |
| 1376 | 1389 | while (j < back_count) : (j += 1) { |
| 1377 | 1390 | try self.writeBytes("\x1B[D"); |
| 1378 | 1391 | } |
| 1379 | 1392 | } |
| 1380 | 1393 | |
| 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 | ||
| 1381 | 1428 | 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; | |
| 1384 | 1432 | try self.writeBytes("\x1B[D"); |
| 1385 | 1433 | } |
| 1386 | 1434 | |
| 1387 | 1435 | 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; | |
| 1390 | 1439 | try self.writeBytes("\x1B[C"); |
| 1391 | 1440 | } |
| 1392 | 1441 | |