From c24a9e40215bc30c68c9db7675f1c6a07d9bbae3 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 18 Aug 2026 12:09:12 -0300 Subject: modal + file_pane: rework editing math and pane behavior, config/syntax additions, unit tests --- src/pardes.zig | 462 ++++++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 360 insertions(+), 102 deletions(-) (limited to 'src/pardes.zig') 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`: 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` 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) -- cgit v1.3