summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-09 23:43:06 -0300
committerGabriel Schneider <[email protected]>2026-08-10 09:17:07 -0300
commit0d66575a3c888498c7929e2ec345133628c5d63d (patch)
tree3be80051fd505f9675b36f9b570c46a955ca3445 /src/pardes.zig
parent4b5e793f48f6e721c39ab6ddcf755604df73b643 (diff)
downloadpardes-0d66575a3c888498c7929e2ec345133628c5d63d.tar.gz
pardes-0d66575a3c888498c7929e2ec345133628c5d63d.zip
fix tab rendering - waybe wreckless
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig228
1 files changed, 197 insertions, 31 deletions
diff --git a/src/pardes.zig b/src/pardes.zig
index fd33cafd..4b9e8b1d 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -1698,6 +1698,22 @@ test "selection pipe binding is file-normal-only" {
try std.testing.expectEqualSlices(u8, "|x", pane.file.?.content);
}
+test "insert newline adds one indent level after a closing call" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = try p.hxOpenFileContent(" callback({})\n");
+ pane.mode = .insert;
+ pane.cur_col = 16;
+
+ p.update(.{ .key = .{ .cp = Key.enter } });
+
+ try std.testing.expectEqualSlices(u8, " callback({})\n \n", pane.file.?.content);
+ try std.testing.expectEqual(@as(i32, 1), pane.cur_row);
+ try std.testing.expectEqual(@as(i32, 8), pane.cur_col);
+}
+
test "startup config runs builtin lines in order and isolates bad lines" {
const p = try Pardes.init(std.testing.allocator, .{
.startup_config =
@@ -2727,10 +2743,25 @@ pub const Surface = struct {
(std.unicode.utf8Decode(text[i .. i + n]) catch null)
else
null;
- const cp_slice = if (decoded == null) "\u{FFFD}" else text[i .. i + n];
+ var cp_slice = if (decoded == null) "\u{FFFD}" else text[i .. i + n];
i += if (decoded == null) 1 else n;
- const cp = decoded orelse 0xFFFD;
+ var cp = decoded orelse 0xFFFD;
if (cp == '\r') continue;
+ // A Surface cell is already positioned, not a terminal byte
+ // stream. Expand tabs here so every Surface consumer — GUI, tty,
+ // web, and macOS — sees the same configured run of blank cells
+ // instead of asking its font for a control-character glyph.
+ if (cp == '\t') {
+ const spaces = @min(config.tab_width, end - col);
+ s.fill(col, y, spaces, 1, style);
+ col += spaces;
+ continue;
+ }
+ // No other C0/C1 control or DEL reaches a font fallback either.
+ if (cp < ' ' or cp == 0x7f or (cp >= 0x80 and cp <= 0x9f)) {
+ cp = 0xFFFD;
+ cp_slice = "\u{FFFD}";
+ }
const width: u16 = if (cp < 0x80) 1 else uucode.get(.width, cp);
if (width == 0) continue;
// a DOUBLE-width glyph with one column left is not drawn at all.
@@ -2777,6 +2808,78 @@ pub const Surface = struct {
}
};
+test "surface print expands configured tabs and normalizes other controls" {
+ const tab_cells: usize = @as(usize, config.tab_width) * 2;
+ const replacement_cells = 5;
+ const cell_count = tab_cells + replacement_cells + 1;
+ var cells: [cell_count]Cell = @splat(.{});
+ var surface = Surface{ .cols = cells.len, .rows = 1, .cells = &cells };
+
+ const end = surface.print(0, 0, cells.len, "\t\t\x00\x0b\x0c\r\x7f\xc2\x85A", .{});
+ try std.testing.expectEqual(@as(u16, cells.len), end);
+ for (cells[0..tab_cells]) |cell|
+ try std.testing.expectEqualStrings(" ", cell.grapheme());
+ for (cells[tab_cells .. tab_cells + replacement_cells]) |cell|
+ try std.testing.expectEqualStrings("\u{FFFD}", cell.grapheme());
+ try std.testing.expectEqualStrings("A", cells[cell_count - 1].grapheme());
+}
+
+test "tabbed file aligns syntax cursor and mouse with display cells" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 12 });
+ defer p.deinit();
+ const pane = try p.hxOpenFileContent("\treturn x;\n");
+ const f = &pane.file.?;
+ f.highlights = try p.tree_sitter_gpa.alloc(u8, f.content.len);
+ @memset(f.highlights, @intFromEnum(syntax.Syn.none));
+ @memset(f.highlights[1..7], @intFromEnum(syntax.Syn.keyword));
+ f.syntax_dirty = false;
+ pane.cur_row = 0;
+ pane.cur_col = 1;
+
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ const surface = 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;
+ for (0..config.tab_width) |col|
+ try std.testing.expect(!surface.at(text_x + @as(u16, @intCast(col)), body_y).style.bold);
+ for (0.."return".len) |col|
+ try std.testing.expect(surface.at(text_x + config.tab_width + @as(u16, @intCast(col)), body_y).style.bold);
+ try std.testing.expectEqual(text_x + config.tab_width, surface.cursor.?.x);
+
+ const click_x = text_x + config.tab_width + 2;
+ p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = click_x, .row = body_y } });
+ p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = click_x, .row = body_y } });
+ try std.testing.expectEqual(@as(i32, 3), pane.cur_col);
+}
+
+test "plain left click clears explicit modal selection" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .cols = 40, .rows = 10 });
+ defer p.deinit();
+ const pane = try p.hxOpenFileContent("abcdef\n");
+ pane.cur_row = 0;
+ pane.cur_col = 5;
+ pane.vsel = .{ .active = true, .row = 0, .col = 1, .explicit = true };
+ pane.msel = .{ .active = true, .r0 = 0, .r1 = 0 };
+
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ _ = try p.render(frame.allocator());
+ const rect = p.rects[0];
+ const click_x = rect.x + config.GUTTER + config.PREFIX_W + 3;
+ const body_y = rect.y + BOX_H;
+ p.update(.{ .mouse = .{ .button = config.select_button, .kind = .press, .col = click_x, .row = body_y } });
+ p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = click_x, .row = body_y } });
+
+ try std.testing.expectEqual(@as(i32, 3), pane.cur_col);
+ try std.testing.expect(!pane.vsel.active);
+ try std.testing.expect(!pane.msel.active);
+ try std.testing.expectEqual(.none, pane.sel[@intFromEnum(config.select_button)].state);
+}
+
pub const Mouse = struct {
// sel indexing relies on left/middle/right being 0/1/2
pub const Button = enum { left, middle, right, wheel_up, wheel_down, wheel_left, wheel_right, none };
@@ -3861,13 +3964,21 @@ pub const Pane = struct {
// A wrapped body has nothing to scroll sideways, and its hscroll is
// left ALONE rather than zeroed: turn the wrap back off and the view
// you had is still there.
- if (pane.file != null and pane.wrap_n == 0) {
+ if (pane.file) |f| {
+ if (pane.wrap_n != 0) return;
const w: i32 = @max(1, @as(i32, pane.cols) - @as(i32, config.PREFIX_W));
const hmargin: i32 = @min(config.scroll_off, @divTrunc(w - 1, 2));
- if (pane.cur_col < pane.hscroll + hmargin)
- pane.hscroll = @max(0, pane.cur_col - hmargin)
- else if (pane.cur_col > pane.hscroll + w - 1 - hmargin)
- pane.hscroll = pane.cur_col - (w - 1 - hmargin);
+ const line = modal.lineSlice(f.content, @intCast(@max(0, pane.cur_row)));
+ const raw_cur = @min(@as(usize, @intCast(@max(0, pane.cur_col))), line.len);
+ const raw_scroll = @min(@as(usize, @intCast(@max(0, pane.hscroll))), line.len);
+ const cur = @as(i32, @intCast(file_pane.displayWidth(line[0..raw_cur])));
+ const visual_scroll = @as(i32, @intCast(file_pane.displayWidth(line[0..raw_scroll])));
+ var target = visual_scroll;
+ if (cur < visual_scroll + hmargin)
+ target = @max(0, cur - hmargin)
+ else if (cur > visual_scroll + w - 1 - hmargin)
+ target = cur - (w - 1 - hmargin);
+ if (target != visual_scroll) pane.hscroll = @intCast(file_pane.byteAtDisplay(line, @intCast(target)));
}
}
@@ -5150,6 +5261,49 @@ pub const Pardes = struct {
if (col) |c| p.topbar_col = @intCast(@min(c, modal.prevGrapheme(bar, bar.len)));
}
+ fn fileLine(pane: *const Pane, row: i32) []const u8 {
+ const f = pane.file orelse return "";
+ if (row < 0) return "";
+ return modal.lineSlice(f.content, @intCast(row));
+ }
+
+ fn fileDisplayOffset(pane: *const Pane, row: i32, from_raw: i32, to_raw: i32) i32 {
+ const line = fileLine(pane, row);
+ const from = @min(@as(usize, @intCast(@max(0, from_raw))), line.len);
+ const to = @min(@as(usize, @intCast(@max(0, to_raw))), line.len);
+ if (to >= from) return @intCast(file_pane.displayWidth(line[from..to]));
+ return -@as(i32, @intCast(file_pane.displayWidth(line[to..from])));
+ }
+
+ fn fileDisplayEndOffset(pane: *const Pane, row: i32, from_raw: i32, at_raw: i32) i32 {
+ const start = fileDisplayOffset(pane, row, from_raw, at_raw);
+ const line = fileLine(pane, row);
+ const at: usize = @intCast(@max(0, at_raw));
+ if (at >= line.len) return start;
+ return start + @as(i32, @intCast(file_pane.byteDisplayWidth(line[at]))) - 1;
+ }
+
+ fn fileByteAtDisplay(pane: *const Pane, row: i32, from_raw: i32, display_col: i32) i32 {
+ const line = fileLine(pane, row);
+ const from = @min(@as(usize, @intCast(@max(0, from_raw))), line.len);
+ const display: usize = @intCast(@max(0, display_col));
+ return @intCast(from + file_pane.byteAtDisplay(line[from..], display));
+ }
+
+ fn renderedLineByteCol(pane: *const Pane, row: i32, line: []const u8, display_col: usize) usize {
+ if (pane.file == null or row < BOX_H) return @min(display_col, line.len);
+ const prefix = @min(@as(usize, config.PREFIX_W), line.len);
+ if (display_col <= prefix) return display_col;
+ return prefix + file_pane.byteAtDisplay(line[prefix..], display_col - prefix);
+ }
+
+ fn renderedLineDisplayCol(pane: *const Pane, row: i32, line: []const u8, byte_col: usize) usize {
+ if (pane.file == null or row < BOX_H) return @min(byte_col, line.len);
+ const prefix = @min(@as(usize, config.PREFIX_W), line.len);
+ if (byte_col <= prefix) return byte_col;
+ return prefix + file_pane.displayWidth(line[prefix..@min(byte_col, line.len)]);
+ }
+
/// tag + '\n' + body: the full selectable pane text (row 0 = the tag)
fn paneText(p: *Pardes, pane: *Pane) ![]u8 {
const arena = p.scratch.allocator();
@@ -5171,8 +5325,8 @@ pub const Pardes = struct {
var count_row: i32 = 0;
while (count_it.next()) |line| : (count_row += 1) {
if (count_row < r0 or count_row > r1) continue;
- const b0 = @min(c0, line.len);
- const b1 = @min(c1 + 1, line.len);
+ const b0 = renderedLineByteCol(pane, count_row, line, c0);
+ const b1 = @min(renderedLineByteCol(pane, count_row, line, c1) + 1, line.len);
total += b1 - b0 + @intFromBool(selected > 0);
selected += 1;
}
@@ -5188,8 +5342,8 @@ pub const Pardes = struct {
at += 1;
}
first = false;
- const b0 = @min(c0, line.len);
- const b1 = @min(c1 + 1, line.len);
+ const b0 = renderedLineByteCol(pane, v, line, c0);
+ const b1 = @min(renderedLineByteCol(pane, v, line, c1) + 1, line.len);
@memcpy(out[at..][0 .. b1 - b0], line[b0..b1]);
at += b1 - b0;
}
@@ -5216,12 +5370,13 @@ pub const Pardes = struct {
var v: i32 = 0;
while (it.next()) |line| : (v += 1) {
if (v != sl.r0) continue;
- const col: usize = @intCast(@max(0, sl.c0));
+ const display_col: usize = @intCast(@max(0, sl.c0));
+ const col = renderedLineByteCol(pane, v, line, display_col);
if (col > line.len) return;
const b = config.wordBounds(line, col);
if (b.hi > b.lo) {
- sl.c0 = @intCast(b.lo);
- sl.c1 = @intCast(b.hi - 1);
+ sl.c0 = @intCast(renderedLineDisplayCol(pane, v, line, b.lo));
+ sl.c1 = @intCast(renderedLineDisplayCol(pane, v, line, b.hi) - 1);
}
return;
}
@@ -5232,7 +5387,7 @@ pub const Pardes = struct {
fn cursorWordSel(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) pane.cur_col - w.at + @as(i32, config.PREFIX_W) else pane.cur_col;
+ const vcol = if (pane.file != null) fileDisplayOffset(pane, pane.cur_row, w.at, pane.cur_col) + @as(i32, config.PREFIX_W) else pane.cur_col;
return .{ .state = .done, .c0 = vcol, .c1 = vcol, .r0 = vrow, .r1 = vrow };
}
@@ -7950,15 +8105,16 @@ pub const Pardes = struct {
}
switch (key.cp) {
Key.enter => {
- // helix insert_newline: the new line copies the current
- // line's full indent LEVELS (plain-text heuristic)
- const ind = modal.hxIndentString(modal.lineSlice(text, c.row));
+ const line = modal.lineSlice(text, c.row);
+ const indent = modal.hxNewlineIndentWidth(line, c.col);
const arena = p.scratch.allocator();
- const block = std.fmt.allocPrint(arena, "\n{s}", .{ind}) catch return;
+ const block = arena.alloc(u8, 1 + indent) catch return;
+ block[0] = '\n';
+ @memset(block[1..], ' ');
const new = modal.insertAt(p.gpa, text, c, block) catch return;
p.setEditText(pane, new);
pane.cur_row += 1;
- pane.cur_col = @intCast(ind.len);
+ pane.cur_col = @intCast(indent);
pane.cur_pinned = true;
pane.ensureCursorVisible();
},
@@ -9583,15 +9739,18 @@ 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) @max(0, sl.c1 - @as(i32, config.PREFIX_W)) + w.at else sl.c1;
+ pane.cur_col = if (pane.file != null)
+ fileByteAtDisplay(pane, w.line, w.at, sl.c1 - @as(i32, config.PREFIX_W))
+ else
+ sl.c1;
pane.cur_pinned = true;
if (!pane.isTerminal()) pane.mode = .normal;
pane.msel.active = false;
pane.pending = 0;
- // a chord/motion selection is residue: a fresh click
- // dismisses it rather than dragging the anchored span
- // to the click (explicit v/x keeps vim's click-extend)
- if (!pane.vsel.explicit) pane.vsel.active = false;
+ // A fresh left gesture replaces every modal selection.
+ // Keeping an explicit v/x anchor made a plain click
+ // extend it, leaving no mouse-only way to dismiss it.
+ pane.vsel.active = false;
pane.nsel = 0; // a click says WHERE the one cursor is
// Ctrl-click IS `gd`, asked now that the cursor has
// landed — the mouse spelling of the keyboard motion,
@@ -9611,7 +9770,7 @@ pub const Pardes = struct {
const cw = pane.wrapAt(cvis);
const crow = cw.line;
const ccol = if (pane.file != null)
- @max(0, clk.c0 - @as(i32, config.PREFIX_W)) + cw.at
+ fileByteAtDisplay(pane, cw.line, cw.at, clk.c0 - @as(i32, config.PREFIX_W))
else
clk.c0;
// A click INSIDE a live selection takes the WHOLE selection
@@ -12850,8 +13009,14 @@ 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 cstart: i32 = if (w.line == bnd.lo_row) bnd.lo_col - w.at + vpfx else vpfx;
- const cend: i32 = if (w.line == bnd.hi_row) bnd.hi_col - w.at + vpfx else @as(i32, tw) - 1;
+ const cstart: i32 = if (w.line == bnd.lo_row)
+ (if (pane.file != null) fileDisplayOffset(pane, w.line, w.at, bnd.lo_col) else bnd.lo_col - w.at) + vpfx
+ else
+ vpfx;
+ const cend: i32 = if (w.line == bnd.hi_row)
+ (if (pane.file != null) fileDisplayEndOffset(pane, w.line, w.at, bnd.hi_col) else bnd.hi_col - w.at) + vpfx
+ else
+ @as(i32, tw) - 1;
var col: i32 = @max(cstart, vpfx);
while (col <= cend and col < tw) : (col += 1) {
const cell = s.at(tx + @as(u16, @intCast(col)), body_y + @as(u16, @intCast(vr)));
@@ -12863,7 +13028,7 @@ pub const Pardes = struct {
if (primary and !preview) continue; // the hardware cursor IS the primary's
const cw = pane.wrapRow(sr.row, sr.col);
const crow = cw.row + @as(i32, BOX_H);
- const ccol = sr.col - cw.at + vpfx;
+ const ccol = (if (pane.file != null) fileDisplayOffset(pane, sr.row, cw.at, sr.col) else sr.col - cw.at) + 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;
@@ -12886,9 +13051,10 @@ pub const Pardes = struct {
// wrapped, so this is the same two lines it always was
const cwp = pane.wrapRow(crow, ccol);
const prow = cwp.row + @as(i32, BOX_H);
- // files: cur_col is content-relative, add the lineno prefix
+ // Files store source-byte columns; the Surface stores display
+ // cells, so account for every expanded tab before the cursor.
const cx = if (pane.file != null)
- @as(i32, config.PREFIX_W) + ccol - cwp.at
+ @as(i32, config.PREFIX_W) + fileDisplayOffset(pane, crow, cwp.at, ccol)
else
ccol;
if (prow >= BOX_H and cx >= 0 and prow < r.h and cx < tw)