summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-18 12:09:12 -0300
committerGabriel Schneider <[email protected]>2026-08-18 23:45:25 -0300
commitc24a9e40215bc30c68c9db7675f1c6a07d9bbae3 (patch)
tree9afdb1f3d128bda3f3cf4837c2893982b0a8f69c /src/pardes.zig
parent31fece62f56aa2311e2325de83659edbc9e641db (diff)
downloadpardes-c24a9e40215bc30c68c9db7675f1c6a07d9bbae3.tar.gz
pardes-c24a9e40215bc30c68c9db7675f1c6a07d9bbae3.zip
modal + file_pane: rework editing math and pane behavior, config/syntax additions, unit tests
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig462
1 files changed, 360 insertions, 102 deletions
diff --git a/src/pardes.zig b/src/pardes.zig
index 38e317c4..5bf03788 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -24,6 +24,7 @@ pub const animation = @import("animation.zig");
pub const panel_animation = @import("panel_animation.zig");
const ghostty_vt = @import("ghostty-vt");
const uucode = @import("uucode");
+const vaxis = @import("vaxis");
const mvzr = @import("mvzr");
const modal = @import("modal.zig");
const normal_input = @import("normal_input.zig");
@@ -1554,7 +1555,7 @@ const WordBounds = struct { lo: usize, hi: usize };
/// The whitespace-delimited word covering `col` in `str`. The topbar uses the
/// same bounds for pointer feedback and dispatch, so the word that lights up
/// is necessarily the word a middle click will execute.
-fn wordBoundsAtCol(str: []const u8, col: u16) ?WordBounds {
+fn wordBoundsAtCol(str: []const u8, col: usize) ?WordBounds {
if (col >= str.len or str[col] == ' ') return null;
var lo: usize = col;
while (lo > 0 and str[lo - 1] != ' ') lo -= 1;
@@ -1564,7 +1565,7 @@ fn wordBoundsAtCol(str: []const u8, col: u16) ?WordBounds {
}
/// the whitespace-delimited word covering `col` in `str` (topbar dispatch)
-fn wordAtCol(str: []const u8, col: u16) []const u8 {
+fn wordAtCol(str: []const u8, col: usize) []const u8 {
const bounds = wordBoundsAtCol(str, col) orelse return "";
return str[bounds.lo..bounds.hi];
}
@@ -2118,8 +2119,26 @@ pub const Surface = struct {
(std.unicode.utf8Decode(text[i .. i + n]) catch null)
else
null;
- var cp_slice = if (decoded == null) "\u{FFFD}" else text[i .. i + n];
- i += if (decoded == null) 1 else n;
+ var cp_slice: []const u8 = "\u{FFFD}";
+ var consumed: usize = 1;
+ if (decoded != null) {
+ // A surface cell is a grapheme, not a codepoint. Keeping the
+ // complete cluster makes combining marks visible and keeps ZWJ,
+ // modifier, flag and Indic sequences in the same screen cell
+ // that cursor/edit math treats as one unit.
+ var git = uucode.grapheme.utf8Iterator(text[i..]);
+ if (git.nextGrapheme()) |g| {
+ const candidate = text[i .. i + g.end];
+ if (std.unicode.utf8ValidateSlice(candidate)) {
+ cp_slice = candidate;
+ consumed = candidate.len;
+ } else {
+ cp_slice = text[i .. i + n];
+ consumed = n;
+ }
+ }
+ }
+ i += consumed;
var cp = decoded orelse 0xFFFD;
if (cp == '\r') continue;
// A Surface cell is already positioned, not a terminal byte
@@ -2137,8 +2156,10 @@ pub const Surface = struct {
cp = 0xFFFD;
cp_slice = "\u{FFFD}";
}
- const width: u16 = if (cp < 0x80) 1 else uucode.get(.width, cp);
- if (width == 0) continue;
+ const width: u16 = if (decoded == null or (cp < 0x80 and cp_slice.len == 1))
+ 1
+ else
+ @max(1, vaxis.gwidth.gwidth(cp_slice, .unicode));
// a DOUBLE-width glyph with one column left is not drawn at all.
// Writing it puts one cell in the surface and two on the glass, and
// when that column is the screen's last the terminal wraps the tail
@@ -2148,7 +2169,21 @@ pub const Surface = struct {
// problem. Reachable from any byte cut through wide text: hscroll's
// and soft wrap's both.
if (width == 2 and col + 1 >= end) break;
- s.set(col, y, cp_slice, style);
+ // The cross-host Cell ABI has seven payload bytes. Preserve a valid
+ // codepoint prefix when a modern emoji cluster is longer; its full
+ // width and edit boundary still come from the complete cluster.
+ var shown = cp_slice;
+ if (shown.len > @typeInfo(@FieldType(Cell, "text")).array.len) {
+ const cap = @typeInfo(@FieldType(Cell, "text")).array.len;
+ var prefix: usize = 0;
+ while (prefix < shown.len) {
+ const cp_len = std.unicode.utf8ByteSequenceLength(shown[prefix]) catch break;
+ if (prefix + cp_len > cap) break;
+ prefix += cp_len;
+ }
+ shown = if (prefix > 0) shown[0..prefix] else "\u{FFFD}";
+ }
+ s.set(col, y, shown, style);
if (width == 2 and col + 1 < end) {
// spacer: empty cell under the wide glyph's tail
s.set(col + 1, y, "", style);
@@ -2199,6 +2234,106 @@ test "surface print expands configured tabs and normalizes other controls" {
try std.testing.expectEqualStrings("A", cells[cell_count - 1].grapheme());
}
+test "surface print keeps combining and wide graphemes in their display cells" {
+ var cells: [8]Cell = @splat(.{});
+ var surface = Surface{ .cols = cells.len, .rows = 1, .cells = &cells };
+
+ const end = surface.print(0, 0, cells.len, "e\u{301}界👩🏽\u{200d}🚀1\u{fe0f}\u{20e3}A", .{});
+ try std.testing.expectEqual(@as(u16, 8), end);
+ try std.testing.expectEqualStrings("e\u{301}", cells[0].grapheme());
+ try std.testing.expectEqualStrings("界", cells[1].grapheme());
+ try std.testing.expectEqualStrings("", cells[2].grapheme());
+ try std.testing.expectEqualStrings("👩", cells[3].grapheme());
+ try std.testing.expectEqualStrings("", cells[4].grapheme());
+ try std.testing.expectEqualStrings("1\u{fe0f}\u{20e3}", cells[5].grapheme());
+ try std.testing.expectEqualStrings("", cells[6].grapheme());
+ try std.testing.expectEqualStrings("A", cells[7].grapheme());
+}
+
+test "insert and normal modes edit complete Unicode graphemes" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 12 });
+ defer p.deinit();
+ const pane = try p.hxOpenFileContent("a" ++ "e\u{301}" ++ "👩🏽\u{200d}🚀" ++ "界" ++ "z");
+
+ pane.mode = .insert;
+ pane.cur_col = 22; // on z, after the CJK grapheme
+ p.insertKey(pane, .{ .cp = Key.left });
+ try std.testing.expectEqual(@as(i32, 19), pane.cur_col);
+ p.insertKey(pane, .{ .cp = Key.left });
+ try std.testing.expectEqual(@as(i32, 4), pane.cur_col);
+ p.insertKey(pane, .{ .cp = Key.right });
+ try std.testing.expectEqual(@as(i32, 19), pane.cur_col);
+ p.insertKey(pane, .{ .cp = Key.backspace });
+ try std.testing.expectEqualStrings("ae\u{301}界z", pane.file.?.content);
+ try std.testing.expectEqual(@as(i32, 4), pane.cur_col);
+
+ pane.mode = .normal;
+ pane.cur_col = 1;
+ p.normalDelete(pane, false);
+ try std.testing.expectEqualStrings("a界z", pane.file.?.content);
+ try std.testing.expectEqual(@as(i32, 1), pane.cur_col);
+ p.normalReplaceChar(pane, 'λ');
+ try std.testing.expectEqualStrings("aλz", pane.file.?.content);
+
+ file_pane.setContent(p, &pane.file.?, try gpa.dupe(u8, "界a"));
+ pane.cur_col = 3;
+ pane.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true };
+ p.normalReplaceChar(pane, 'λ');
+ try std.testing.expectEqualStrings("λλ", pane.file.?.content);
+ try std.testing.expectEqual(@as(i32, 2), pane.cur_col);
+ try std.testing.expectEqual(@as(i32, 0), pane.vsel.col);
+
+ file_pane.setContent(p, &pane.file.?, try gpa.dupe(u8, "界a"));
+ pane.cur_col = 0;
+ pane.vsel = .{ .active = true, .row = 0, .col = 3, .explicit = true };
+ p.normalReplaceChar(pane, 'λ');
+ try std.testing.expectEqualStrings("λλ", pane.file.?.content);
+ try std.testing.expectEqual(@as(i32, 0), pane.cur_col);
+ try std.testing.expectEqual(@as(i32, 2), pane.vsel.col);
+}
+
+test "Unicode display cells map back to body and tag byte cursors" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 12 });
+ defer p.deinit();
+ const pane = try p.hxOpenFileContent("a界e\u{301}z\n");
+ const f = &pane.file.?;
+ p.gpa.free(f.path);
+ f.path = try p.gpa.dupe(u8, "界e\u{301}.txt");
+
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ _ = try p.render(frame.allocator());
+ const rect = p.rects[0];
+ const text_x = rect.x + config.GUTTER + config.PREFIX_W;
+ const body_y = rect.y + BOX_H;
+ // The second screen cell is the trailing half of the wide CJK grapheme.
+ // Both halves map to its one byte boundary.
+ p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = text_x + 2, .row = body_y } });
+ p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = text_x + 2, .row = body_y } });
+ try std.testing.expectEqual(@as(i32, 1), pane.cur_col);
+
+ // A click in the wide path glyph likewise becomes a byte cursor at the
+ // grapheme start; arrow motion then advances by the full UTF-8 cluster.
+ p.enterTagEdit(pane, 1);
+ try std.testing.expectEqual(@as(u16, 0), pane.tag_col);
+ p.tagInsertKey(pane, .{ .cp = Key.right });
+ try std.testing.expectEqual(@as(u16, 3), pane.tag_col);
+ p.tagInsertKey(pane, .{ .cp = Key.right });
+ try std.testing.expectEqual(@as(u16, 6), pane.tag_col);
+
+ const before = try gpa.dupe(u8, pane.tagSlice());
+ defer gpa.free(before);
+ p.enterTagEdit(pane, -1);
+ const insertion = pane.tag_col;
+ p.tagInsertKey(pane, .{ .cp = 'λ', .text = "λ" });
+ try std.testing.expectEqual(insertion + 2, pane.tag_col);
+ p.tagInsertKey(pane, .{ .cp = Key.backspace });
+ try std.testing.expectEqual(insertion, pane.tag_col);
+ try std.testing.expectEqualStrings(before, pane.tagSlice());
+}
+
test "tabbed file aligns syntax cursor and mouse while preserving virtual columns" {
const gpa = std.testing.allocator;
const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 12 });
@@ -2920,12 +3055,13 @@ pub const Pane = struct {
/// the body mode a tag edit hijacked (tags are always insert); terminals
/// restore it on exit so clicking the tag never changes the pane's mode
tag_mode: Mode = .normal,
- /// THE tag coordinate space: byte columns into the WHOLE rendered tag,
- /// prefix ++ tail (tagText). One space, no conversions — the renderer, the
- /// mouse and every motion speak it directly. The prefix is live chrome, so
- /// it is selectable/yankable/executable but READ-ONLY: every edit op
- /// measures from `edit0` (= tagPrefix().len, the first editable column) and
- /// does nothing left of it.
+ /// THE tag coordinate space: UTF-8 byte offsets into the WHOLE rendered
+ /// tag, prefix ++ tail (tagText), always on grapheme boundaries. Motions
+ /// and edits use these offsets; rendering and pointer input convert at the
+ /// screen boundary. The prefix is live chrome, so it is selectable,
+ /// yankable and executable but READ-ONLY: every edit op measures from
+ /// `edit0` (= tagPrefix().len, the first editable byte) and does nothing
+ /// left of it.
tag_col: u16 = 0,
tag_anchor: u16 = 0,
ed_undo: [term_pane.history_max]term_pane.Snapshot = undefined,
@@ -4540,7 +4676,7 @@ pub const Pardes = struct {
leader_on: bool = false,
leader_keys: [4]u8 = undefined,
leader_n: u8 = 0,
- /// the TOPBAR holds the keyboard, parked at this column of the rendered
+ /// the TOPBAR holds the keyboard, parked at this UTF-8 byte offset of the
/// row-0 line. Global like leader_on for the same reason: row 0 is not a
/// pane and never will be, so its one piece of focus state cannot live on
/// one. `null` = the panes have the keyboard, which is every other frame.
@@ -5401,12 +5537,9 @@ pub const Pardes = struct {
/// the row below kept its out at the pad. Now the column moves out
/// together, so the words stay in a line and a click walks down them.
///
- /// Bytes, not display columns — like every other tag coordinate (see
- /// Pane.tag_col). A multibyte path renders narrower than it measures, so
- /// the line is straight for ASCII paths and drifts by a column per wide
- /// character otherwise. That is the same approximation the plain
- /// right-align always made; fixing it is a job for the whole tag
- /// coordinate space, not for this scan.
+ /// Alignment is measured in display cells. Cursor and selection state use
+ /// UTF-8 byte offsets, then map through the same grapheme-width helpers at
+ /// the screen boundary.
///
/// Two rules make up the "when possible":
///
@@ -5450,7 +5583,7 @@ pub const Pardes = struct {
pane_tail;
const laid = if (q.tag_init) q.tagSlice() else words;
const lead = laid.len - std.mem.trimStart(u8, laid, " ").len;
- const q_end = (p.tagPrefix(q) catch continue).len + lead + std.mem.trimStart(u8, words, " ").len;
+ const q_end = file_pane.displayWidth(p.tagPrefix(q) catch continue) + lead + file_pane.displayWidth(std.mem.trimStart(u8, words, " "));
end = @max(end, @min(q_end, tw));
};
return end -| used;
@@ -5462,7 +5595,7 @@ pub const Pardes = struct {
fn tagText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]u8 {
const prefix = try p.tagPrefix(pane);
const tail = curTail(pane);
- const gap = p.tagGap(pane, prefix.len + tail.len);
+ const gap = p.tagGap(pane, file_pane.displayWidth(prefix) + file_pane.displayWidth(tail));
const out = try arena.alloc(u8, prefix.len + gap + tail.len);
@memcpy(out[0..prefix.len], prefix);
@memset(out[prefix.len..][0..gap], ' ');
@@ -5476,8 +5609,8 @@ pub const Pardes = struct {
fn seedTail(p: *Pardes, pane: *Pane) void {
if (pane.tag_init) return;
const tail = curTail(pane);
- const prefix_len = (p.tagPrefix(pane) catch return).len;
- const gap = p.tagGap(pane, prefix_len + tail.len);
+ const prefix = (p.tagPrefix(pane) catch return);
+ const gap = p.tagGap(pane, file_pane.displayWidth(prefix) + file_pane.displayWidth(tail));
if (gap + tail.len > pane.tag_tail.len) return;
@memset(pane.tag_tail[0..gap], ' ');
@memcpy(pane.tag_tail[gap..][0..tail.len], tail);
@@ -5486,7 +5619,7 @@ pub const Pardes = struct {
}
/// focus the tag for editing, seeding the tail on first touch and parking
- /// the cursor at `col` — a column of the RENDERED tag (see tag_col).
+ /// the byte cursor at the grapheme displayed under screen column `col`.
/// NEGATIVE means the tail's first WORD, which is where `:` and a tagline
/// hop land: a place no click can name, so it needs no sentinel of its own
/// and the callers need no prefix length.
@@ -5509,7 +5642,12 @@ pub const Pardes = struct {
// the door. The spaces stay editable; h and Left still walk into them.
const tail = pane.tagSlice();
const lead: i32 = @intCast(tail.len - std.mem.trimStart(u8, tail, " ").len);
- pane.tag_col = @intCast(if (col < 0) @min(edit0 + lead, end) else std.math.clamp(col, 0, end));
+ if (col < 0) {
+ pane.tag_col = @intCast(@min(edit0 + lead, end));
+ } else {
+ const text = p.tagText(p.scratch.allocator(), pane) catch return;
+ pane.tag_col = @intCast(@min(text.len, file_pane.rawAtDisplay(text, @intCast(col))));
+ }
}
fn exitTagEdit(pane: *Pane) void {
@@ -5538,7 +5676,7 @@ pub const Pardes = struct {
const text = p.tagText(p.scratch.allocator(), pane) catch return null;
if (pane.tag_sel) {
const b = tagSelBounds(pane);
- const hi = @min(b.hi + 1, text.len);
+ const hi = modal.nextGrapheme(text, b.hi);
return if (hi > b.lo) text[b.lo..hi] else null;
}
const b = config.wordBounds(text, @min(@as(usize, pane.tag_col), text.len));
@@ -5580,14 +5718,19 @@ pub const Pardes = struct {
}
switch (key.cp) {
Key.backspace => if (pane.tag_col > edit0) {
- pane.removeTagByte(pane.tag_col - edit0 - 1);
- pane.tag_col -= 1;
+ const text = p.tagText(p.scratch.allocator(), pane) catch return;
+ const prev = @max(@as(usize, edit0), modal.prevGrapheme(text, pane.tag_col));
+ const count = @as(usize, pane.tag_col) - prev;
+ for (0..count) |_| pane.removeTagByte(prev - edit0);
+ pane.tag_col = @intCast(prev);
},
Key.left => if (pane.tag_col > 0) {
- pane.tag_col -= 1;
+ const text = p.tagText(p.scratch.allocator(), pane) catch return;
+ pane.tag_col = @intCast(modal.prevGrapheme(text, pane.tag_col));
},
Key.right => if (pane.tag_col < end) {
- pane.tag_col += 1;
+ const text = p.tagText(p.scratch.allocator(), pane) catch return;
+ pane.tag_col = @intCast(modal.nextGrapheme(text, pane.tag_col));
},
Key.home => pane.tag_col = 0,
Key.end => pane.tag_col = end,
@@ -5601,8 +5744,8 @@ pub const Pardes = struct {
const lo = @min(r.anchor, r.head);
const hi = @max(r.anchor, r.head);
pane.tag_col = @intCast(modal.hxCursor(text, r));
- pane.tag_sel = hi > lo + 1; // a 1-wide range IS the block cursor
- if (pane.tag_sel) pane.tag_anchor = @intCast(if (r.head > r.anchor) lo else hi - 1);
+ pane.tag_sel = modal.nextGrapheme(text, lo) < hi; // one grapheme IS the block cursor
+ if (pane.tag_sel) pane.tag_anchor = @intCast(if (r.head > r.anchor) lo else modal.prevGrapheme(text, hi));
}
/// normal mode ON the tag — where `:` lands. The body's own helix motions
@@ -5757,7 +5900,7 @@ pub const Pardes = struct {
// on it does. Leave the bar FIRST — `Kill` lives up here and tears the
// session down, the same hazard the pane-tag chord has with `Del`.
if (hit(key, config.look_key) or hit(key, config.exec_key)) {
- const word = wordAtCol(bar, @intCast(cur));
+ const word = wordAtCol(bar, cur);
p.topbar_col = null;
if (word.len > 0) _ = p.execute(p.active, word);
return;
@@ -5819,7 +5962,7 @@ pub const Pardes = struct {
while (count_it.next()) |line| : (count_row += 1) {
if (count_row < r0 or count_row > r1) continue;
const b0 = @min(file_pane.renderedLineByteCol(pane, count_row, line, c0), line.len);
- const b1 = @min(file_pane.renderedLineByteCol(pane, count_row, line, c1) + 1, line.len);
+ const b1 = modal.nextGrapheme(line, @min(file_pane.renderedLineByteCol(pane, count_row, line, c1), line.len));
total += b1 - b0 + @intFromBool(selected > 0);
selected += 1;
}
@@ -5836,7 +5979,7 @@ pub const Pardes = struct {
}
first = false;
const b0 = @min(file_pane.renderedLineByteCol(pane, v, line, c0), line.len);
- const b1 = @min(file_pane.renderedLineByteCol(pane, v, line, c1) + 1, line.len);
+ const b1 = modal.nextGrapheme(line, @min(file_pane.renderedLineByteCol(pane, v, line, c1), line.len));
@memcpy(out[at..][0 .. b1 - b0], line[b0..b1]);
at += b1 - b0;
}
@@ -5891,10 +6034,17 @@ pub const Pardes = struct {
/// the word under the modal cursor as a pane-local selection (paneText
/// coords: row 0 is the tag; file panes carry the line-number prefix)
- fn cursorWordSel(pane: *Pane) Sel {
+ fn cursorWordSel(p: *Pardes, pane: *Pane) Sel {
const w = pane.wrapRow(pane.cur_row, pane.cur_col);
const vrow = w.row + @as(i32, BOX_H);
- const vcol = if (pane.file != null) file_pane.displayOffset(pane, pane.cur_row, w.at, pane.cur_col) + @as(i32, config.PREFIX_W) else pane.cur_col;
+ const vcol = if (pane.file != null)
+ file_pane.displayOffset(pane, pane.cur_row, w.at, pane.cur_col) + @as(i32, config.PREFIX_W)
+ else blk: {
+ const pl = p.paneCursorLines(pane) catch break :blk pane.cur_col;
+ const local = pane.cur_row - pl.row0;
+ if (local < 0 or @as(usize, @intCast(local)) >= pl.lines.len) break :blk pane.cur_col;
+ break :blk file_pane.lineDisplayOffset(pl.lines[@intCast(local)], @intCast(@max(0, w.at)), @intCast(@max(0, pane.cur_col)));
+ };
return .{ .state = .done, .c0 = vcol, .c1 = vcol, .r0 = vrow, .r1 = vrow };
}
@@ -5930,8 +6080,13 @@ pub const Pardes = struct {
const visible = clicked.r0 - @as(i32, BOX_H);
const wrapped = pane.wrapAt(visible);
const row = wrapped.line;
- const col = if (pane.file != null)
- file_pane.byteAtRowDisplay(pane, wrapped.line, wrapped.at, clicked.c0 - @as(i32, config.PREFIX_W))
+ const col = if (clicked.r0 >= BOX_H)
+ p.paneByteAtDisplay(
+ pane,
+ wrapped.line,
+ wrapped.at,
+ clicked.c0 - (if (pane.file != null) @as(i32, config.PREFIX_W) else 0),
+ )
else
clicked.c0;
var result: PointerOperand = .{ .row = row, .col = col };
@@ -6157,7 +6312,7 @@ pub const Pardes = struct {
// to the file-ish word under the cursor.
if (pane.mode == .normal and (hit(key, config.look_key) or hit(key, config.exec_key))) {
const cmd = if (hit(key, config.look_key)) config.look_cmd else config.exec_cmd;
- pane.pinCursor();
+ p.pinPaneCursor(pane);
const explicit = (p.native_images and hasPdfSelection(pane)) or
(pane.vsel.active and pane.vsel.explicit) or pane.msel.active;
if (explicit) {
@@ -6169,7 +6324,7 @@ pub const Pardes = struct {
return;
}
}
- const sel = p.expandedSel(pane, cursorWordSel(pane)) orelse return;
+ const sel = p.expandedSel(pane, p.cursorWordSel(pane)) orelse return;
const word = p.selectionText(pane, sel) catch return;
p.runBuiltin(cmd, p.active, "", word);
return;
@@ -6439,6 +6594,29 @@ pub const Pardes = struct {
return .{ .lines = try term_pane.cursorLines(p, pane), .row0 = 0 };
}
+ fn paneByteAtDisplay(p: *Pardes, pane: *Pane, row: i32, from_raw: i32, display_col: i32) i32 {
+ if (pane.file != null)
+ return file_pane.byteAtRowDisplay(pane, row, from_raw, display_col);
+ const pl = p.paneCursorLines(pane) catch return @max(0, from_raw + display_col);
+ const local = row - pl.row0;
+ if (local < 0 or @as(usize, @intCast(local)) >= pl.lines.len)
+ return @max(0, from_raw + display_col);
+ return @intCast(file_pane.byteAtDisplayFrom(
+ pl.lines[@intCast(local)],
+ @intCast(@max(0, from_raw)),
+ @intCast(@max(0, display_col)),
+ ));
+ }
+
+ /// Freeze the live terminal cursor into the modal coordinate space. The
+ /// emulator reports screen cells; editing state stores UTF-8 byte offsets.
+ fn pinPaneCursor(p: *Pardes, pane: *Pane) void {
+ if (pane.cur_pinned) return;
+ pane.pinCursor();
+ if (pane.file == null and !hasPdf(pane))
+ pane.cur_col = p.paneByteAtDisplay(pane, pane.cur_row, 0, pane.cur_col);
+ }
+
fn toModalCursor(pane: *Pane, pl: PaneLines) modal.Cursor {
const r: i32 = pane.cur_row - pl.row0;
return .{ .row = @intCast(@max(0, r)), .col = @intCast(@max(0, pane.cur_col)) };
@@ -6450,6 +6628,18 @@ pub const Pardes = struct {
pane.cur_pinned = true;
}
+ fn insertVerticalCursor(lines: []const []const u8, c: modal.Cursor, down: bool) modal.Cursor {
+ if (lines.len == 0) return c;
+ const row = if (down) @min(c.row + 1, lines.len - 1) else c.row -| 1;
+ const target = lines[row];
+ if (target.len == 0) return .{ .row = row, .col = 0 };
+ const source = if (c.row < lines.len) lines[c.row] else "";
+ const goal = file_pane.rawDisplayCol(source, c.col);
+ const mapped = file_pane.rawAtDisplay(target, goal);
+ const last = modal.prevGrapheme(target, target.len);
+ return .{ .row = row, .col = modal.graphemeStart(target, @min(mapped, last)) };
+ }
+
// ---- helix range plumbing (see modal.zig "helix range engine") ----
// The pane's cursor + vsel cells render ONE helix gap range over the flat
// motion surface. Every motion builds the current range, transforms it the
@@ -7222,9 +7412,8 @@ pub const Pardes = struct {
/// f/t/F/T: anchor at the old cursor cell, head on the hit (not found: no move)
fn findMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, ch: u21, fwd: bool, till: bool, cnt: usize) void {
- if (ch > 0x7f) return; // ponytail: ASCII targets only (byte columns)
const cur = modal.hxCursor(text, range);
- const t = modal.hxFindTarget(text, cur, @intCast(ch), fwd, till, cnt) orelse return;
+ const t = modal.hxFindTarget(text, cur, ch, fwd, till, cnt) orelse return;
const res = if (pane.select)
modal.hxPutCursor(text, range, t, true)
else
@@ -7236,13 +7425,17 @@ pub const Pardes = struct {
fn verticalMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, down: bool, cnt: usize) void {
const cur = modal.hxCursor(text, range);
const pos = panePos(pane, text, cur);
- const goal: usize = if (pane.sticky_col >= 0) @intCast(pane.sticky_col) else pos.col;
+ const goal: usize = if (pane.sticky_col >= 0)
+ @intCast(pane.sticky_col)
+ else
+ file_pane.rawDisplayCol(modal.lineSlice(text, pos.row), pos.col);
// modal.hxVertTarget with the row we already have and the indexed
// offset conversion — it would otherwise recount the buffer's newlines
// and walk to the target line, two more full passes per j/k
const last_row = paneLineCount(pane, text) - 1;
const nline = if (down) @min(pos.row + @max(1, cnt), last_row) else pos.row -| @max(1, cnt);
- const t = paneOff(pane, text, .{ .row = nline, .col = goal });
+ const target_col = file_pane.rawAtDisplay(modal.lineSlice(text, nline), goal);
+ const t = paneOff(pane, text, .{ .row = nline, .col = target_col });
// extend mode never walks onto the empty trailing line (helix)
if (pane.select and t == text.len and text.len > 0 and text[text.len - 1] == '\n') return;
setPaneRange(pane, pl, text, modal.hxPutCursor(text, range, t, pane.select), false);
@@ -7373,7 +7566,7 @@ pub const Pardes = struct {
/// complete before this function runs; this switch reads document state
/// only to execute the already-recognized action.
fn executeNormalAction(p: *Pardes, pane: *Pane, semantic: normal_input.Action) void {
- pane.pinCursor();
+ p.pinPaneCursor(pane);
const pl = p.paneCursorLines(pane) catch return;
const text = p.flatSurface(pane, pl) catch return;
const lines = pl.lines;
@@ -7410,7 +7603,8 @@ pub const Pardes = struct {
.column => {
const line = modal.hxLineOf(text, cur);
const ls = modal.lineStartOffset(text, line);
- return pointMove(pane, pl, text, range, @min(ls + (@as(usize, go.count) - 1), modal.hxLineEndIdx(text, line)));
+ const slice = text[ls..modal.hxLineEndIdx(text, line)];
+ return pointMove(pane, pl, text, range, ls + modal.graphemeAtColumn(slice, @as(usize, go.count) - 1));
},
.view_top => return gotoWindow(pane, pl, text, range, .top, go.count),
.view_center => return gotoWindow(pane, pl, text, range, .center, go.count),
@@ -7506,14 +7700,14 @@ pub const Pardes = struct {
.next_long_word_end => return wordMove(pane, pl, text, range, move.count, .next_long_word_end),
},
.repeat_find => |count| {
- if (pane.find_op == 0 or pane.find_ch > 0x7f) return;
+ if (pane.find_op == 0) return;
const fwd = pane.find_op == config.find_char_fwd or pane.find_op == config.till_char_fwd;
const till = pane.find_op == config.till_char_fwd or pane.find_op == config.till_char_back;
var repeated = range;
var moved = false;
for (0..count) |_| {
const cc = modal.hxCursor(text, repeated);
- const target = modal.hxFindTarget(text, cc, @intCast(pane.find_ch), fwd, till, 1) orelse break;
+ const target = modal.hxFindTarget(text, cc, pane.find_ch, fwd, till, 1) orelse break;
repeated = if (pane.select)
modal.hxPutCursor(text, repeated, target, true)
else
@@ -7815,7 +8009,7 @@ pub const Pardes = struct {
pane.tag_sel = false;
pane.mode = .insert;
pane.pending = 0;
- // tag_col is a rendered-tag column; the prompt offset slices tag_tail.
+ // tag_col and the prompt offset are both UTF-8 byte offsets.
pane.tag_col = @intCast((p.tagPrefix(pane) catch return).len + pane.tag_tail_len);
}
@@ -8531,7 +8725,7 @@ pub const Pardes = struct {
/// cursor on its FIRST — the same shape the old terminal stepper left, and
/// the reason `col0` is the position the walk compares against.
fn landLookSpot(p: *Pardes, id: usize, pane: *Pane, spot: LookSpot) void {
- pane.pinCursor(); // fresh out of tty mode the cursor still tracks the shell
+ p.pinPaneCursor(pane); // fresh out of tty mode the cursor still tracks the shell
pane.vsel = .{ .active = true, .row = spot.row, .col = spot.col1, .explicit = true };
pane.msel.active = false;
pane.nsel = 0;
@@ -8572,7 +8766,7 @@ pub const Pardes = struct {
fn enterInsert(p: *Pardes, pane: *Pane, where: InsertAt, cnt: usize) void {
if (hasPdf(pane)) return;
- pane.pinCursor();
+ p.pinPaneCursor(pane);
// snapshot once per insert session (WITH the pre-insert selection) so
// `u` undoes the whole session and restores what was selected
p.pushUndo(pane);
@@ -8694,7 +8888,7 @@ pub const Pardes = struct {
// its only pardes use (the acme chords) needs explicit selections
// anyway, and those never enter insert mode
pane.vsel.active = false;
- if (!pane.cur_pinned) pane.pinCursor();
+ if (!pane.cur_pinned) p.pinPaneCursor(pane);
// arrows and paging are pure motion over the WHOLE surface, so they
// run before editText — a terminal must not freeze shell rows into an
// edit buffer just because you walked across them
@@ -8703,10 +8897,10 @@ pub const Pardes = struct {
const pl = p.paneCursorLines(pane) catch return;
const cur0 = toModalCursor(pane, pl);
const nc = switch (key.cp) {
- Key.left => modal.charLeft(cur0),
+ Key.left => modal.charLeft(pl.lines, cur0),
Key.right => modal.charRight(pl.lines, cur0),
- Key.up => modal.lineUp(pl.lines, cur0),
- Key.down => modal.lineDown(pl.lines, cur0),
+ Key.up => insertVerticalCursor(pl.lines, cur0, false),
+ Key.down => insertVerticalCursor(pl.lines, cur0, true),
else => cur0,
};
fromModalCursor(pane, pl, nc);
@@ -8829,9 +9023,11 @@ pub const Pardes = struct {
},
Key.backspace => {
if (pane.cur_col > 0) {
- const new = modal.deleteChar(p.gpa, text, .{ .row = c.row, .col = c.col - 1 }) catch return;
+ const line = modal.lineSlice(text, c.row);
+ const prev = modal.prevGrapheme(line, c.col);
+ const new = modal.deleteChar(p.gpa, text, .{ .row = c.row, .col = prev }) catch return;
p.setEditText(pane, new);
- pane.cur_col -= 1;
+ pane.cur_col = @intCast(prev);
pane.cur_pinned = true;
pane.ensureCursorVisible();
return;
@@ -9115,17 +9311,19 @@ pub const Pardes = struct {
.{ .row = @intCast(@max(0, b.lo_row - row0)), .col = @intCast(@max(0, b.lo_col)) }
else blk: {
const hrow: usize = @intCast(@max(0, b.hi_row - row0));
- const gcol = @as(usize, @intCast(@max(0, b.hi_col))) + 1;
- if (gcol > modal.lineSlice(eb.text, hrow).len) break :blk .{ .row = hrow + 1, .col = 0 };
+ const hi_col: usize = @intCast(@max(0, b.hi_col));
+ const high_line = modal.lineSlice(eb.text, hrow);
+ if (hi_col >= high_line.len) break :blk .{ .row = hrow + 1, .col = 0 };
+ const gcol = modal.nextGrapheme(high_line, hi_col);
break :blk .{ .row = hrow, .col = gcol };
};
const out = modal.insertAt(p.gpa, eb.text, at, y) catch return;
p.setEditText(pane, out);
- pane.vsel = .{ .active = y.len > 1, .row = @as(i32, @intCast(at.row)) + row0, .col = @intCast(at.col), .explicit = false };
+ pane.vsel = .{ .active = modal.nextGrapheme(y, 0) < y.len, .row = @as(i32, @intCast(at.row)) + row0, .col = @intCast(at.col), .explicit = false };
const end = modal.advanceBy(at, y);
if (end.col > 0) {
pane.cur_row = @as(i32, @intCast(end.row)) + row0;
- pane.cur_col = @intCast(end.col - 1);
+ pane.cur_col = @intCast(modal.prevGrapheme(modal.lineSlice(out, end.row), end.col));
} else {
pane.cur_row = @as(i32, @intCast(end.row -| 1)) + row0;
pane.cur_col = @intCast(modal.lineSlice(out, end.row -| 1).len);
@@ -9261,7 +9459,7 @@ pub const Pardes = struct {
const r0: usize = @intCast(@max(0, @min(pane.msel.r0, pane.msel.r1) - row0));
const r1: usize = @intCast(@max(0, @max(pane.msel.r0, pane.msel.r1) - row0));
const llen = modal.lineSlice(content, r1).len;
- return .{ .a = .{ .row = r0, .col = 0 }, .b = .{ .row = r1, .col = llen -| 1 } };
+ return .{ .a = .{ .row = r0, .col = 0 }, .b = .{ .row = r1, .col = if (llen == 0) 0 else modal.prevGrapheme(modal.lineSlice(content, r1), llen) } };
}
const c = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row - row0)), .col = @intCast(@max(0, pane.cur_col)) };
return .{ .a = c, .b = c };
@@ -9281,13 +9479,27 @@ pub const Pardes = struct {
/// `r<ch>`: overwrite the selection (or the cursor char) with ch —
/// newlines included (helix), so `xrz` joins the selected lines
- fn normalReplaceChar(p: *Pardes, pane: *Pane, ch: u8) void {
+ fn normalReplaceChar(p: *Pardes, pane: *Pane, ch: u21) void {
pane.select = false;
const eb = p.editTextEol(pane, selRows(pane)) orelse return;
+ const before = paneRange(pane, eb.text, eb.row0);
+ const lo = @min(before.anchor, before.head);
+ const hi = @max(before.anchor, before.head);
+ var graphemes: usize = 0;
+ var at = lo;
+ while (at < hi) : (graphemes += 1) at = modal.nextGrapheme(eb.text, at);
+ var encoded: [4]u8 = undefined;
+ const encoded_len = std.unicode.utf8Encode(ch, &encoded) catch return;
const r = selRange(pane, eb.text, eb.row0);
p.pushUndo(pane);
const new = modal.replaceChars(p.gpa, eb.text, r.a, r.b, ch) catch return;
p.setEditText(pane, new);
+ const end = lo + graphemes * encoded_len;
+ const mapped: modal.HxRange = if (before.head < before.anchor)
+ .{ .anchor = end, .head = lo }
+ else
+ .{ .anchor = lo, .head = end };
+ setPaneRange(pane, .{ .lines = &.{}, .row0 = eb.row0 }, new, mapped, pane.vsel.explicit);
}
/// `R`: replace the selection (or the cursor char) with the DEFAULT
@@ -9307,11 +9519,11 @@ pub const Pardes = struct {
const new = modal.replaceRange(p.gpa, eb.text, r.a, r.b, y) catch return;
p.setEditText(pane, new);
pane.msel.active = false;
- pane.vsel = .{ .active = y.len > 1, .row = @as(i32, @intCast(r.a.row)) + eb.row0, .col = @intCast(r.a.col), .explicit = false };
+ pane.vsel = .{ .active = modal.nextGrapheme(y, 0) < y.len, .row = @as(i32, @intCast(r.a.row)) + eb.row0, .col = @intCast(r.a.col), .explicit = false };
const end = modal.advanceBy(r.a, y);
if (end.col > 0) {
pane.cur_row = @as(i32, @intCast(end.row)) + eb.row0;
- pane.cur_col = @intCast(end.col - 1);
+ pane.cur_col = @intCast(modal.prevGrapheme(modal.lineSlice(new, end.row), end.col));
} else {
// the yank ended in '\n': the cursor lands ON that newline
pane.cur_row = @as(i32, @intCast(end.row -| 1)) + eb.row0;
@@ -9637,8 +9849,8 @@ pub const Pardes = struct {
const new = modal.replaceRange(p.gpa, eb.text, r.a, r.b, rep) catch return;
p.setEditText(pane, new);
const start = modal.lineStartOffset(new, r.a.row) + r.a.col;
- const cc = modal.hxPos(new, start + rep.len - 1);
- pane.vsel = .{ .active = rep.len > 1, .row = @as(i32, @intCast(r.a.row)) + eb.row0, .col = @intCast(r.a.col), .explicit = false };
+ const cc = modal.hxPos(new, modal.prevGrapheme(new, start + rep.len));
+ pane.vsel = .{ .active = modal.nextGrapheme(rep, 0) < rep.len, .row = @as(i32, @intCast(r.a.row)) + eb.row0, .col = @intCast(r.a.col), .explicit = false };
pane.msel.active = false;
pane.cur_row = @as(i32, @intCast(cc.row)) + eb.row0;
pane.cur_col = @intCast(cc.col);
@@ -9728,14 +9940,15 @@ pub const Pardes = struct {
const eb = p.editTextEol(pane, selRows(pane)) orelse return;
const r = selRange(pane, eb.text, eb.row0);
p.pushUndo(pane);
- var new = modal.insertAt(p.gpa, eb.text, .{ .row = r.b.row, .col = r.b.col + 1 }, &[1]u8{pr.c}) catch return;
+ const close_col = modal.nextGrapheme(modal.lineSlice(eb.text, r.b.row), r.b.col);
+ var new = modal.insertAt(p.gpa, eb.text, .{ .row = r.b.row, .col = close_col }, &[1]u8{pr.c}) catch return;
p.setEditText(pane, new);
new = modal.insertAt(p.gpa, new, .{ .row = r.a.row, .col = r.a.col }, &[1]u8{pr.o}) catch return;
p.setEditText(pane, new);
pane.msel.active = false;
pane.vsel = .{ .active = true, .row = @as(i32, @intCast(r.a.row)) + eb.row0, .col = @intCast(r.a.col), .explicit = false };
pane.cur_row = @as(i32, @intCast(r.b.row)) + eb.row0;
- pane.cur_col = @intCast(r.b.col + 1 + @as(usize, if (r.a.row == r.b.row) 1 else 0));
+ pane.cur_col = @intCast(close_col + @as(usize, if (r.a.row == r.b.row) 1 else 0));
pane.cur_pinned = true;
pane.sticky_col = -1;
pane.ensureCursorVisible();
@@ -10329,7 +10542,13 @@ pub const Pardes = struct {
// next cursor move or left wheel pulls it back
} else if (pane.file != null and !p.settings.wrap) {
// wrapped there is nothing off to the right to reach
- pane.hscroll = @max(0, pane.hscroll + (if (m.button == .wheel_right) config.wheel_cols else -config.wheel_cols));
+ const line = file_pane.sourceLine(pane, pane.cur_row);
+ const visual = file_pane.rawDisplayCol(line, @intCast(@max(0, pane.hscroll)));
+ const next: usize = if (m.button == .wheel_right)
+ visual +| @as(usize, @intCast(config.wheel_cols))
+ else
+ visual -| @as(usize, @intCast(config.wheel_cols));
+ pane.hscroll = @intCast(file_pane.rawAtDisplay(line, next));
}
},
config.select_button => switch (m.kind) {
@@ -10474,7 +10693,8 @@ pub const Pardes = struct {
// click (a stray select click must never Kill)
if (m.button == config.exec_button) {
var tb_buf: [1200]u8 = undefined;
- const word = wordAtCol(p.topbar(&tb_buf), mcol);
+ const bar = p.topbar(&tb_buf);
+ const word = wordAtCol(bar, file_pane.rawAtDisplay(bar, mcol));
// a topbar word runs on the PRESS — there is no
// drag to chord into, so the argument is simply
// whatever is selected right now: select a word,
@@ -10615,9 +10835,9 @@ pub const Pardes = struct {
// row is ignored (a tag is one line) and the anchor stays where
// the press put it, so this is the mouse's `v`
const pane = p.panes[d.id] orelse return;
- const end: i32 = @intCast((p.tagPrefix(pane) catch return).len + pane.tag_tail_len);
const c = @as(i32, mcol) - @as(i32, p.rects[d.id].x + config.GUTTER);
- pane.tag_col = @intCast(std.math.clamp(c, 0, end));
+ const text = p.tagText(p.scratch.allocator(), pane) catch return;
+ pane.tag_col = @intCast(@min(text.len, file_pane.rawAtDisplay(text, @intCast(@max(0, c)))));
pane.tag_sel = pane.tag_col != pane.tag_anchor;
},
.none => {},
@@ -10722,10 +10942,12 @@ pub const Pardes = struct {
// pointer whether or not that row is a continuation
const w = pane.wrapAt(body_vis);
pane.cur_row = w.line;
- pane.cur_col = if (pane.file != null)
- file_pane.byteAtRowDisplay(pane, w.line, w.at, sl.c1 - @as(i32, config.PREFIX_W))
- else
- sl.c1;
+ pane.cur_col = p.paneByteAtDisplay(
+ pane,
+ w.line,
+ w.at,
+ sl.c1 - (if (pane.file != null) @as(i32, config.PREFIX_W) else 0),
+ );
pane.cur_pinned = true;
if (!pane.isTerminal()) pane.mode = .normal;
pane.msel.active = false;
@@ -10874,8 +11096,8 @@ pub const Pardes = struct {
const w1 = pane.wrapAt(@max(0, sl.r1 - @as(i32, BOX_H)));
const row0 = w0.line;
const row1 = w1.line;
- const col0 = @max(0, sl.c0 - pfx) + w0.at;
- const col1 = @max(0, sl.c1 - pfx) + w1.at;
+ const col0 = p.paneByteAtDisplay(pane, w0.line, w0.at, sl.c0 - pfx);
+ const col1 = p.paneByteAtDisplay(pane, w1.line, w1.at, sl.c1 - pfx);
pane.cur_row = row1;
pane.cur_col = col1;
pane.cur_pinned = true;
@@ -10898,11 +11120,11 @@ pub const Pardes = struct {
p.pushUndo(pane);
const new = modal.insertAt(p.gpa, f.content, at, y) catch return;
file_pane.setContent(p, f, new);
- pane.vsel = .{ .active = y.len > 1, .row = @intCast(at.row), .col = @intCast(at.col), .explicit = false };
+ pane.vsel = .{ .active = modal.nextGrapheme(y, 0) < y.len, .row = @intCast(at.row), .col = @intCast(at.col), .explicit = false };
const end = modal.advanceBy(at, y);
if (end.col > 0) {
pane.cur_row = @intCast(end.row);
- pane.cur_col = @intCast(end.col - 1);
+ pane.cur_col = @intCast(modal.prevGrapheme(modal.lineSlice(f.content, end.row), end.col));
} else {
// the register ended in '\n': the cursor lands ON that newline
pane.cur_row = @intCast(end.row -| 1);
@@ -11192,6 +11414,14 @@ pub const Pardes = struct {
if (comptime !pdf_enabled) return;
if (pane.pdf == null) return;
var state = paneNormalState(pane);
+ // A PDF has no body character to find, so its bare `f` is the direct
+ // document-outline door. Prefix continuations still go through the
+ // shared parser, and text/terminal panes retain `f<char>` unchanged.
+ if (state.prefix == .none and isPrefix(key, 'f')) {
+ state.clear();
+ putPaneNormalState(pane, state);
+ return p.runBuiltin(.PdfSections, p.active, "", null);
+ }
const parsed = normal_input.parse(&state, normalInput(key));
putPaneNormalState(pane, state);
switch (parsed) {
@@ -12584,17 +12814,20 @@ pub const Pardes = struct {
const w: u16 = @intCast(iw);
if (w < tw) _ = s.print(tx + tw - w, row, w, ibuf[0..iw], msg_style);
}
- // ...and the cursor follows the text it edits. tag_col is a
- // rendered-tag column, so the prompt's own column is it minus where
- // the marker starts; a cursor LEFT of that is still over the part
+ // ...and the cursor follows the text it edits. tag_col is a byte
+ // offset, so the prompt maps its suffix through display widths; a
+ // cursor LEFT of the marker is still over the part
// of the tag that stayed on the tagline, and the tag cursor
// renderPane already placed there is the right one.
if (id != p.active) continue;
const at = prompt_at orelse continue;
const prompt0 = (p.tagPrefix(pane) catch continue).len + at;
const col = @as(usize, pane.tag_col);
- if (col >= prompt0 and col - prompt0 < tw)
- s.cursor = .{ .x = tx + @as(u16, @intCast(col - prompt0)), .y = row, .bar = pane.mode == .insert };
+ if (col >= prompt0) {
+ const prompt_col = file_pane.displayWidth(text[0..@min(col - prompt0, text.len)]);
+ if (prompt_col < tw)
+ s.cursor = .{ .x = tx + @as(u16, @intCast(prompt_col)), .y = row, .bar = pane.mode == .insert };
+ }
}
// global tagbar: full width, top row
@@ -12617,9 +12850,11 @@ pub const Pardes = struct {
// feedback at all. Paint the same word the click dispatcher resolves,
// immediately, while leaving whitespace inert.
if (p.pointer_inside and p.hover_row < TOPBAR_H) {
- if (wordBoundsAtCol(p.topbar(&tb_buf), p.hover_col)) |bounds| {
- var col: usize = bounds.lo;
- while (col < bounds.hi and col < s.cols) : (col += 1) {
+ const bar = p.topbar(&tb_buf);
+ if (wordBoundsAtCol(bar, file_pane.rawAtDisplay(bar, p.hover_col))) |bounds| {
+ var col = file_pane.rawDisplayCol(bar, bounds.lo);
+ const hi = file_pane.rawDisplayCol(bar, bounds.hi);
+ while (col < hi and col < s.cols) : (col += 1) {
const cell = s.at(@intCast(col), 0);
cell.default = false;
cell.style.bg = .{ .rgb = th.sel_bg };
@@ -12630,9 +12865,10 @@ pub const Pardes = struct {
// the topbar's cursor, if it has the keyboard. AFTER the pane loop on
// purpose: there is exactly one Surface cursor and the bar's must beat
// the active pane's. Always a block — the bar has no insert mode.
- if (p.topbar_col) |c| if (c < s.cols) {
- s.cursor = .{ .x = c, .y = 0, .bar = false };
- };
+ if (p.topbar_col) |c| {
+ const col = file_pane.rawDisplayCol(p.topbar(&tb_buf), c);
+ if (col < s.cols) s.cursor = .{ .x = @intCast(col), .y = 0, .bar = false };
+ }
// resize-handle hint / drag previews: a dash overlay that keeps the
// underlying colors (border drags + hover), or the move indicator. A
@@ -13046,7 +13282,7 @@ pub const Pardes = struct {
};
}
// tag char selection highlight (helix v/x, or a tagline sweep),
- // inclusive [lo, hi] — rendered-tag columns, so no conversion.
+ // inclusive [lo, hi], mapped from byte offsets to display cells.
//
// Every selection on screen paints in the LIVE theme (`th`) and not in
// `chrome`: a highlight is not attached to any geometry, it appears
@@ -13055,8 +13291,9 @@ pub const Pardes = struct {
// animation's frames.
if (pane.tag_edit and pane.tag_sel) {
const b = tagSelBounds(pane);
- var col: usize = b.lo;
- const end: usize = b.hi;
+ var col = file_pane.rawDisplayCol(tag, b.lo);
+ const hi = modal.nextGrapheme(tag, b.hi);
+ const end = file_pane.rawDisplayCol(tag, hi) -| 1;
while (col <= end and col < tw) : (col += 1) {
const cell = s.at(tx + @as(u16, @intCast(col)), tag_y);
cell.default = false;
@@ -13064,10 +13301,11 @@ pub const Pardes = struct {
cell.style.fg = .{ .rgb = th.sel_fg };
}
}
- // cursor while editing the tag: a rendered-tag column, verbatim
+ // cursor while editing the tag: byte offset mapped to its display cell
if (active and pane.tag_edit) {
// bar while typing, block for `:` normal mode (same rule as a body)
- if (pane.tag_col < tw) s.cursor = .{ .x = tx + pane.tag_col, .y = tag_y, .bar = pane.mode == .insert };
+ const col = file_pane.rawDisplayCol(tag, pane.tag_col);
+ if (col < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(col)), .y = tag_y, .bar = pane.mode == .insert };
}
// A native PDF page uses the same backend-neutral pixel attachment as
@@ -13243,12 +13481,19 @@ pub const Pardes = struct {
while (vr + @as(i32, BOX_H) < @as(i32, r.h)) : (vr += 1) {
const w = pane.wrapAt(vr);
if (w.line < bnd.lo_row or w.line > bnd.hi_row) continue;
+ const visible_line = modal.lineSlice(body, @intCast(vr));
const cstart: i32 = if (w.line == bnd.lo_row)
- (if (pane.file != null) file_pane.displayOffset(pane, w.line, w.at, bnd.lo_col) else bnd.lo_col - w.at) + vpfx
+ (if (pane.file != null)
+ file_pane.displayOffset(pane, w.line, w.at, bnd.lo_col)
+ else
+ file_pane.lineDisplayOffset(visible_line, @intCast(@max(0, w.at)), @intCast(@max(0, bnd.lo_col)))) + vpfx
else
vpfx;
const cend: i32 = if (w.line == bnd.hi_row)
- (if (pane.file != null) file_pane.displayEndOffset(pane, w.line, w.at, bnd.hi_col) else bnd.hi_col - w.at) + vpfx
+ (if (pane.file != null)
+ file_pane.displayEndOffset(pane, w.line, w.at, bnd.hi_col)
+ else
+ file_pane.lineDisplayEndOffset(visible_line, @intCast(@max(0, w.at)), @intCast(@max(0, bnd.hi_col)))) + vpfx
else
@as(i32, tw) - 1;
var col: i32 = @max(cstart, vpfx);
@@ -13263,7 +13508,14 @@ pub const Pardes = struct {
if (hover_only) continue; // quiet preview preserves the source ink
const cw = pane.wrapRow(sr.row, sr.col);
const crow = cw.row + @as(i32, BOX_H);
- const ccol = (if (pane.file != null) file_pane.displayOffset(pane, sr.row, cw.at, sr.col) else sr.col - cw.at) + vpfx;
+ const ccol = (if (pane.file != null)
+ file_pane.displayOffset(pane, sr.row, cw.at, sr.col)
+ else
+ file_pane.lineDisplayOffset(
+ modal.lineSlice(body, @intCast(@max(0, cw.row))),
+ @intCast(@max(0, cw.at)),
+ @intCast(@max(0, sr.col)),
+ )) + vpfx;
if (crow >= BOX_H and crow < @as(i32, r.h) and ccol >= vpfx and ccol < tw) {
const cell = s.at(tx + @as(u16, @intCast(ccol)), body_y + @as(u16, @intCast(crow - BOX_H)));
cell.default = false;
@@ -13290,6 +13542,12 @@ pub const Pardes = struct {
// cells, so account for every expanded tab before the cursor.
const cx = if (pane.file != null)
@as(i32, config.PREFIX_W) + file_pane.displayOffset(pane, crow, cwp.at, ccol)
+ else if (pane.cur_pinned)
+ file_pane.lineDisplayOffset(
+ modal.lineSlice(body, @intCast(@max(0, cwp.row))),
+ @intCast(@max(0, cwp.at)),
+ @intCast(@max(0, ccol)),
+ )
else
ccol;
if (prow >= BOX_H and cx >= 0 and prow < r.h and cx < tw)