diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-18 12:09:12 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-18 23:45:25 -0300 |
| commit | c24a9e40215bc30c68c9db7675f1c6a07d9bbae3 (patch) | |
| tree | 9afdb1f3d128bda3f3cf4837c2893982b0a8f69c /src/file_pane.zig | |
| parent | 31fece62f56aa2311e2325de83659edbc9e641db (diff) | |
| download | pardes-c24a9e40215bc30c68c9db7675f1c6a07d9bbae3.tar.gz pardes-c24a9e40215bc30c68c9db7675f1c6a07d9bbae3.zip | |
modal + file_pane: rework editing math and pane behavior, config/syntax additions, unit tests
Diffstat (limited to 'src/file_pane.zig')
| -rw-r--r-- | src/file_pane.zig | 122 |
1 files changed, 84 insertions, 38 deletions
diff --git a/src/file_pane.zig b/src/file_pane.zig index cf2b9571..abf37448 100644 --- a/src/file_pane.zig +++ b/src/file_pane.zig @@ -4,6 +4,7 @@ //! number gutter and the syntax recolor). The rest of a file pane's behaviour //! is the pane machinery in pardes.zig, which does not care what kind it is. const std = @import("std"); +const vaxis = @import("vaxis"); const pardes = @import("pardes.zig"); const config = @import("config.zig"); const Pardes = pardes.Pardes; @@ -82,13 +83,23 @@ pub fn dumpPane( }; } +pub fn graphemeDisplayWidth(grapheme: []const u8) usize { + if (std.mem.eql(u8, grapheme, "\t")) return config.tab_width; + return @max(1, @as(usize, vaxis.gwidth.gwidth(grapheme, .unicode))); +} + pub fn byteDisplayWidth(byte: u8) usize { return if (byte == '\t') config.tab_width else 1; } pub fn displayWidth(text: []const u8) usize { var width: usize = 0; - for (text) |byte| width +|= byteDisplayWidth(byte); + var at: usize = 0; + while (at < text.len) { + const end = modal.nextGrapheme(text, at); + width +|= graphemeDisplayWidth(text[at..end]); + at = end; + } return width; } @@ -96,10 +107,13 @@ pub fn displayWidth(text: []const u8) usize { /// maps back to that one tab byte. pub fn byteAtDisplay(text: []const u8, display_col: usize) usize { var col: usize = 0; - for (text, 0..) |byte, i| { - const next = col +| byteDisplayWidth(byte); - if (display_col < next) return i; + var at: usize = 0; + while (at < text.len) { + const end = modal.nextGrapheme(text, at); + const next = col +| graphemeDisplayWidth(text[at..end]); + if (display_col < next) return at; col = next; + at = end; } return text.len; } @@ -107,8 +121,8 @@ pub fn byteAtDisplay(text: []const u8, display_col: usize) usize { /// File cursor columns may live past EOL. Tabs expand before that boundary; /// every virtual column after it remains one screen cell. pub fn rawDisplayCol(line_text: []const u8, raw_col: usize) usize { - const bounded = @min(raw_col, line_text.len); - return displayWidth(line_text[0..bounded]) +| (raw_col - bounded); + const bounded = modal.graphemeStart(line_text, @min(raw_col, line_text.len)); + return displayWidth(line_text[0..bounded]) +| (raw_col -| line_text.len); } pub fn rawAtDisplay(line_text: []const u8, display_col: usize) usize { @@ -117,6 +131,27 @@ pub fn rawAtDisplay(line_text: []const u8, display_col: usize) usize { return byteAtDisplay(line_text, display_col); } +pub fn byteAtDisplayFrom(line_text: []const u8, from_raw: usize, display_col: usize) usize { + if (from_raw >= line_text.len) return from_raw +| display_col; + const from = modal.graphemeStart(line_text, from_raw); + return from +| rawAtDisplay(line_text[from..], display_col); +} + +pub fn lineDisplayOffset(line_text: []const u8, from_raw: usize, to_raw: usize) i32 { + const from_display = rawDisplayCol(line_text, from_raw); + const to_display = rawDisplayCol(line_text, to_raw); + if (to_display >= from_display) return @intCast(to_display - from_display); + return -@as(i32, @intCast(from_display - to_display)); +} + +pub fn lineDisplayEndOffset(line_text: []const u8, from_raw: usize, at_raw: usize) i32 { + const start = lineDisplayOffset(line_text, from_raw, at_raw); + if (at_raw >= line_text.len) return start; + const at = modal.graphemeStart(line_text, at_raw); + const end = modal.nextGrapheme(line_text, at); + return start + @as(i32, @intCast(graphemeDisplayWidth(line_text[at..end]))) - 1; +} + pub fn sourceLine(pane: *const Pane, row: i32) []const u8 { const f = pane.file orelse return ""; if (row < 0) return ""; @@ -127,51 +162,60 @@ pub fn displayOffset(pane: *const Pane, row: i32, from_raw: i32, to_raw: i32) i3 const line_text = sourceLine(pane, row); const from: usize = @intCast(@max(0, from_raw)); const to: usize = @intCast(@max(0, to_raw)); - const from_display = rawDisplayCol(line_text, from); - const to_display = rawDisplayCol(line_text, to); - if (to_display >= from_display) return @intCast(to_display - from_display); - return -@as(i32, @intCast(from_display - to_display)); + return lineDisplayOffset(line_text, from, to); } pub fn displayEndOffset(pane: *const Pane, row: i32, from_raw: i32, at_raw: i32) i32 { - const start = displayOffset(pane, row, from_raw, at_raw); const line_text = sourceLine(pane, row); - const at: usize = @intCast(@max(0, at_raw)); - if (at >= line_text.len) return start; - return start + @as(i32, @intCast(byteDisplayWidth(line_text[at]))) - 1; + return lineDisplayEndOffset(line_text, @intCast(@max(0, from_raw)), @intCast(@max(0, at_raw))); } pub fn byteAtRowDisplay(pane: *const Pane, row: i32, from_raw: i32, display_col: i32) i32 { const line_text = sourceLine(pane, row); - const from: usize = @intCast(@max(0, from_raw)); - const display: usize = @intCast(@max(0, display_col)); - if (from >= line_text.len) return @intCast(from +| display); - return @intCast(from +| rawAtDisplay(line_text[from..], display)); + return @intCast(byteAtDisplayFrom(line_text, @intCast(@max(0, from_raw)), @intCast(@max(0, display_col)))); } -/// Convert between rendered pane rows (whose file body includes PREFIX_W) -/// and source-byte columns. Non-file rows are already in byte coordinates. +/// Convert between rendered cells and UTF-8 byte columns. Tag rows always +/// need grapheme conversion; file body rows additionally skip PREFIX_W. pub fn renderedLineByteCol(pane: *const Pane, row: i32, line_text: []const u8, display_col: usize) usize { - if (pane.file == null or row < pardes.BOX_H) return display_col; + if (row < pardes.BOX_H) return rawAtDisplay(line_text, display_col); + if (pane.file == null) return rawAtDisplay(line_text, display_col); const prefix = @min(@as(usize, config.PREFIX_W), line_text.len); if (display_col <= prefix) return display_col; return prefix +| rawAtDisplay(line_text[prefix..], display_col - prefix); } pub fn renderedLineDisplayCol(pane: *const Pane, row: i32, line_text: []const u8, byte_col: usize) usize { - if (pane.file == null or row < pardes.BOX_H) return byte_col; + if (row < pardes.BOX_H) return rawDisplayCol(line_text, byte_col); + if (pane.file == null) return rawDisplayCol(line_text, byte_col); const prefix = @min(@as(usize, config.PREFIX_W), line_text.len); if (byte_col <= prefix) return byte_col; return prefix +| rawDisplayCol(line_text[prefix..], byte_col - prefix); } +test "display columns map complete Unicode graphemes" { + const text = "é界e\u{301}x"; + try std.testing.expectEqual(@as(usize, 5), displayWidth(text)); + try std.testing.expectEqual(@as(usize, 0), byteAtDisplay(text, 0)); + try std.testing.expectEqual(@as(usize, 2), byteAtDisplay(text, 1)); + try std.testing.expectEqual(@as(usize, 2), byteAtDisplay(text, 2)); + try std.testing.expectEqual(@as(usize, 5), byteAtDisplay(text, 3)); + try std.testing.expectEqual(@as(usize, 8), byteAtDisplay(text, 4)); + try std.testing.expectEqual(text.len, byteAtDisplay(text, 5)); + try std.testing.expectEqual(@as(usize, 3), rawDisplayCol(text, 5)); + try std.testing.expectEqual(@as(usize, 5), rawAtDisplay(text, 3)); + try std.testing.expectEqual(@as(usize, 2), graphemeDisplayWidth("👩\u{200d}🚀")); +} + fn fitEnd(text: []const u8, start: usize, width: usize) usize { var end = start; var used: usize = 0; - while (end < text.len) : (end += 1) { - const next = used +| byteDisplayWidth(text[end]); - if (next > width) return if (end == start) start + 1 else end; - used = next; + while (end < text.len) { + const next_end = modal.nextGrapheme(text, end); + const next_used = used +| graphemeDisplayWidth(text[end..next_end]); + if (next_used > width) return if (end == start) next_end else end; + used = next_used; + end = next_end; } return end; } @@ -253,7 +297,7 @@ pub fn textOffset(pane: *Pane, text: []const u8, cursor: modal.Cursor) usize { const row = @min(cursor.row, index.len - 1); const start = index[row]; const end = if (row + 1 < index.len) index[row + 1] - 1 else text.len; - return @min(start + cursor.col, end); + return start + modal.graphemeStart(text[start..end], @min(cursor.col, end - start)); } pub fn textLineStart(pane: *Pane, text: []const u8, row: usize) usize { @@ -274,7 +318,9 @@ pub fn textPosition(pane: *Pane, text: []const u8, offset: usize) modal.Cursor { return std.math.order(key, item); } }.cmp) - 1; - return .{ .row = row, .col = bounded - index[row] }; + const start = index[row]; + const end = if (row + 1 < index.len) index[row + 1] - 1 else text.len; + return .{ .row = row, .col = modal.graphemeStart(text[start..end], @min(bounded - start, end - start)) }; } pub fn open(p: *Pardes, id: usize, path: []const u8, line: usize) !*Pane { @@ -577,17 +623,14 @@ fn fillBody(dst: ?[]u8, pane: *Pane, f: *State, width: usize, record_wrap: bool) if (dst) |out| @memcpy(out[written..][0..prefix.len], prefix); written += prefix.len; - // A byte cut, like the hscroll one below, and it can land inside a - // multi-byte glyph for the same reason: columns here are BYTES. - // Surface.print decodes by hand and emits U+FFFD per undecodable - // byte, so a split glyph renders as a replacement char rather than - // panicking — see test/snapshots/badutf.snap. It cannot overflow - // the pane either: a UTF-8 sequence is never fewer bytes than the - // cells it draws in. + // Wrap and horizontal-scroll cuts are always grapheme boundaries. + // Source columns remain byte offsets, while widths are terminal + // cells; keeping the conversion here prevents a view operation + // from manufacturing malformed UTF-8. const end = if (width == 0) text.len else fitEnd(text, at, width); const take = end - at; const cut = if (pane.hscroll > 0 and width == 0) - @min(@as(usize, @intCast(pane.hscroll)), take) + modal.graphemeStart(text[at..end], @min(@as(usize, @intCast(pane.hscroll)), take)) else 0; const shown = text[at + cut .. end]; @@ -690,10 +733,12 @@ pub fn recolorSyntax(p: *Pardes, pane: *Pane, f: *State, r: pardes.Rect, tx: u16 else line.len; } + hs = modal.graphemeStart(line, @min(hs, line.len)); var c: usize = 0; var screen_c: usize = 0; - while (hs + c < limit and config.PREFIX_W + screen_c < tw) : (c += 1) { - const cells = byteDisplayWidth(line[hs + c]); + while (hs + c < limit and config.PREFIX_W + screen_c < tw) { + const grapheme_end = @min(limit, modal.nextGrapheme(line, hs + c)); + const cells = graphemeDisplayWidth(line[hs + c .. grapheme_end]); const idx = base + hs + c; if (idx >= f.highlight_start) { const hidx = idx - f.highlight_start; @@ -710,6 +755,7 @@ pub fn recolorSyntax(p: *Pardes, pane: *Pane, f: *State, r: pardes.Rect, tx: u16 } } screen_c += cells; + c = grapheme_end - hs; } } } |
