summaryrefslogtreecommitdiff
path: root/src/file_pane.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/file_pane.zig')
-rw-r--r--src/file_pane.zig122
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;
}
}
}