summaryrefslogtreecommitdiff
path: root/src
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
parent4b5e793f48f6e721c39ab6ddcf755604df73b643 (diff)
downloadpardes-0d66575a3c888498c7929e2ec345133628c5d63d.tar.gz
pardes-0d66575a3c888498c7929e2ec345133628c5d63d.zip
fix tab rendering - waybe wreckless
Diffstat (limited to 'src')
-rw-r--r--src/config.zig6
-rw-r--r--src/file_pane.zig69
-rw-r--r--src/gui/gui.zig116
-rw-r--r--src/modal.zig30
-rw-r--r--src/nested.zig75
-rw-r--r--src/pardes.zig228
6 files changed, 437 insertions, 87 deletions
diff --git a/src/config.zig b/src/config.zig
index 3c9664f0..67314fd9 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -452,6 +452,12 @@ pub const default_shell = "fish";
/// the file pane's line-number gutter, in columns
pub const PREFIX_W: u16 = 5;
+/// Display width of a literal tab in every Surface-backed frontend. This is a
+/// compile-time setting: edit it and rebuild; zero cannot advance the renderer.
+pub const tab_width: u16 = 4;
+comptime {
+ if (tab_width == 0) @compileError("config.tab_width must be greater than zero");
+}
/// soft wrap (the Wrap builtin): the glyph a wrapped row ends with, in the one
/// column bodyText keeps free for it. A break is the one thing about a wrapped
/// line you cannot see — the text simply continues, and a missing line number
diff --git a/src/file_pane.zig b/src/file_pane.zig
index c0a2350d..b0921bd0 100644
--- a/src/file_pane.zig
+++ b/src/file_pane.zig
@@ -17,6 +17,39 @@ const tracy = @import("tracy.zig");
const SYNTAX_CONTEXT_AFTER_ROWS: usize = 2;
+pub fn byteDisplayWidth(byte: u8) usize {
+ return if (byte == '\t') config.tab_width else 1;
+}
+
+pub fn displayWidth(text: []const u8) usize {
+ var width: usize = 0;
+ for (text) |byte| width +|= byteDisplayWidth(byte);
+ return width;
+}
+
+/// Source byte at a zero-based display column. Every cell occupied by a tab
+/// maps back to that one tab byte.
+pub fn byteAtDisplay(text: []const u8, display_col: usize) usize {
+ var col: usize = 0;
+ for (text, 0..) |byte, i| {
+ const next = col +| byteDisplayWidth(byte);
+ if (display_col < next) return i;
+ col = next;
+ }
+ return text.len;
+}
+
+fn fitEnd(text: []const u8, start: usize, width: usize) usize {
+ var end = start;
+ var used: usize = 0;
+ while (end < text.len) : (end += 1) {
+ const next = used +| byteDisplayWidth(text[end]);
+ if (next > width) return if (end == start) start + 1 else end;
+ used = next;
+ }
+ return end;
+}
+
pub fn lineCount(content: []const u8) usize {
return std.mem.count(u8, content, "\n") + 1;
}
@@ -258,16 +291,17 @@ fn fillBody(dst: ?[]u8, pane: *Pane, f: *File, width: usize, record_wrap: bool)
// panicking — see test/snapshots/badutf.snap. It cannot overflow
// the pane either: a UTF-8 sequence is never fewer bytes than the
// cells it draws in.
- const take = if (width == 0) text.len - at else @min(width, text.len - at);
+ const end = if (width == 0) text.len else fitEnd(text, at, width);
+ const take = end - at;
const cut = if (pane.hscroll > 0 and width == 0)
@min(@as(usize, @intCast(pane.hscroll)), take)
else
0;
- const shown = text[at + cut .. at + take];
+ const shown = text[at + cut .. end];
if (dst) |out| @memcpy(out[written..][0..shown.len], shown);
written += shown.len;
- if (width > 0 and at + take < text.len) {
- at += take;
+ if (width > 0 and end < text.len) {
+ at = end;
} else {
abs += 1;
at = 0;
@@ -364,16 +398,25 @@ pub fn recolorSyntax(p: *Pardes, pane: *Pane, f: *File, r: pardes.Rect, tx: u16,
line.len;
}
var c: usize = 0;
- while (hs + c < limit and config.PREFIX_W + c < tw) : (c += 1) {
+ var screen_c: usize = 0;
+ while (hs + c < limit and config.PREFIX_W + screen_c < tw) : (c += 1) {
+ const cells = byteDisplayWidth(line[hs + c]);
const idx = base + hs + c;
- if (idx < f.highlight_start) continue;
- const hidx = idx - f.highlight_start;
- if (hidx >= f.highlights.len) continue;
- const ss = synStyle(p, @enumFromInt(f.highlights[hidx])) orelse continue;
- const cell = s.at(tx + @as(u16, @intCast(config.PREFIX_W + c)), body_y + vr);
- if (cell.default) continue;
- cell.style.fg = .{ .rgb = ss.fg };
- cell.style.bold = ss.bold;
+ if (idx >= f.highlight_start) {
+ const hidx = idx - f.highlight_start;
+ if (hidx < f.highlights.len) {
+ if (synStyle(p, @enumFromInt(f.highlights[hidx]))) |ss| {
+ var fill: usize = 0;
+ while (fill < cells and config.PREFIX_W + screen_c + fill < tw) : (fill += 1) {
+ const cell = s.at(tx + @as(u16, @intCast(config.PREFIX_W + screen_c + fill)), body_y + vr);
+ if (cell.default) continue;
+ cell.style.fg = .{ .rgb = ss.fg };
+ cell.style.bold = ss.bold;
+ }
+ }
+ }
+ }
+ screen_c += cells;
}
}
}
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index 7a9c2135..a03e51ce 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -153,7 +153,7 @@ test "GUI animation clock is active-only and cadence gated" {
/// Web tap-vs-scroll heuristic, measured after normalized coordinates have
/// been mapped into backing pixels (12px = 8 CSS px at web_render_scale).
const touch_tap_slop_px: f32 = 12.0;
-const max_overlay_vertices: usize = touch_click_flash_vertices + max_touch_points * (1100 + max_touch_trail_points * overlay_circle_vertices);
+const max_overlay_vertices: usize = 6 + touch_click_flash_vertices + max_touch_points * (1100 + max_touch_trail_points * overlay_circle_vertices);
// Ctrl+ / Ctrl-: how far one press moves g.px, and the two sizes it stops at.
// ONE size for the whole window, not one per pane: the core lays every pane
@@ -3230,7 +3230,7 @@ fn emitScrollRows(g: *Gui, instances: [*]CellInstance, base: u32, surface: *pard
var col = x0;
while (col < x0 + bw) : (col += 1) {
const sidx: u32 = @as(u32, row) * surface.cols + col;
- emitInstance(g, instances, base + n, col, row, shifted, win_w, win_h, surface.at(col, row), sidx == cursor_idx, cursor_bar);
+ emitInstance(g, instances, base + n, col, row, shifted, win_w, win_h, surface.at(col, row), sidx == cursor_idx and !cursor_bar);
n += 1;
}
}
@@ -3240,7 +3240,7 @@ fn emitScrollRows(g: *Gui, instances: [*]CellInstance, base: u32, surface: *pard
const erow: u16 = if (g.scroll_lag > 0) y0 + bh else y0 - 1;
var i: u16 = 0;
while (i < bw) : (i += 1) {
- emitInstance(g, instances, base + n, x0 + i, erow, shifted, win_w, win_h, &g.scroll_edge[i], false, false);
+ emitInstance(g, instances, base + n, x0 + i, erow, shifted, win_w, win_h, &g.scroll_edge[i], false);
n += 1;
}
}
@@ -3579,7 +3579,8 @@ fn renderFrame(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, crt_on
if (crt_on) try ensureSceneTexture(g, sw, sh);
const scene: *c.SDL_GPUTexture = if (crt_on) g.scene_tex.? else target;
- const overlay_count = buildTouchOverlay(g, sw, sh, debug_on);
+ const layout = fixedCellLayout(g);
+ const overlay_count = buildOverlay(g, surface, layout, sw, sh, debug_on);
if (overlay_count != 0) uploadOverlayGpu(g, cmd, overlay_count);
const image_count = prepareNativeImages(g, gpa, cmd, surface, sw, sh);
@@ -3610,7 +3611,6 @@ fn renderFrame(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, crt_on
return;
});
const instances: [*]CellInstance = @ptrCast(@alignCast(vptr));
- const layout = fixedCellLayout(g);
const cursor_idx: u32 = if (surface.cursor) |cu| @as(u32, cu.y) * surface.cols + cu.x else cells;
const cursor_bar = if (surface.cursor) |cu| cu.bar else false;
var row: u16 = 0;
@@ -3618,7 +3618,7 @@ fn renderFrame(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, crt_on
var col: u16 = 0;
while (col < surface.cols) : (col += 1) {
const idx: u32 = @as(u32, row) * surface.cols + col;
- emitInstance(g, instances, idx, col, row, layout, win_w, win_h, surface.at(col, row), idx == cursor_idx, cursor_bar);
+ emitInstance(g, instances, idx, col, row, layout, win_w, win_h, surface.at(col, row), idx == cursor_idx and !cursor_bar);
}
}
const shifted = emitScrollRows(g, instances, cells, surface, layout, win_w, win_h);
@@ -3698,15 +3698,13 @@ fn emitInstance(
win_h: f32,
cell: *const pardes.Cell,
is_cursor: bool,
- cursor_bar: bool,
) void {
- // resolve the cell style to concrete fg/bg, matching the prototype's
- // cellKey: reverse swaps, invisible hides, dim darkens. The cursor cell
- // renders as reverse (cancelling an already-reversed cell); an insert
- // cursor keeps the cell colors and draws a bar glyph instead.
+ // Resolve the cell style to concrete fg/bg. A normal block cursor reverses
+ // the cell; the insert cursor is a separate overlay, so the glyph beneath
+ // it remains in this base pass.
var fg = fg_default;
var bg = bg_default;
- var reverse = is_cursor and !cursor_bar;
+ var reverse = is_cursor;
if (!cell.default) {
const st = cell.style;
fg = switch (st.fg) {
@@ -3735,11 +3733,7 @@ fn emitInstance(
const y0 = 1.0 - (py0 / win_h) * 2.0;
const y1 = 1.0 - ((py0 + layout.h) / win_h) * 2.0;
- const grapheme = cell.grapheme();
- var cp: u32 = if (cell.default or grapheme.len == 0) ' ' else firstCp(grapheme);
- // ponytail: the insert bar is the left-eighth block glyph over the cell
- // (hides the char under it); a dedicated quad if that ever matters
- if (is_cursor and cursor_bar) cp = 0x258F;
+ const cp = cellCodepoint(cell);
const slot = if (cp == ' ') g.space_slot else ensureGlyph(g, cp);
const aw_f: f32 = @floatFromInt(atlas_w);
const ah_f: f32 = @floatFromInt(atlas_h);
@@ -3762,6 +3756,34 @@ fn emitInstance(
};
}
+fn cellCodepoint(cell: *const pardes.Cell) u32 {
+ const grapheme = cell.grapheme();
+ return if (cell.default or grapheme.len == 0) ' ' else firstCp(grapheme);
+}
+
+test "insert cursor overlays without replacing the character beneath it" {
+ const cell: pardes.Cell = .{
+ .text = .{ 'x', ' ', ' ', ' ', ' ', ' ', ' ' },
+ .len = 1,
+ .default = false,
+ };
+ try std.testing.expectEqual(@as(u32, 'x'), cellCodepoint(&cell));
+
+ var vertices: [6]OverlayVertex = undefined;
+ var builder: OverlayBuilder = .{
+ .vertices = &vertices,
+ .win_w = 100,
+ .win_h = 100,
+ };
+ addCursorBar(&builder, 2, 3, true, .{
+ .x_off = 0,
+ .y_off = 0,
+ .w = 10,
+ .h = 20,
+ });
+ try std.testing.expectEqual(@as(usize, 6), builder.len);
+}
+
fn palColor(idx: u8) [3]u8 {
const p = ghostty_vt.color.default[idx];
return .{ p.r, p.g, p.b };
@@ -4058,9 +4080,9 @@ fn renderFrameGl(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, debu
);
c.glClear(c.GL_COLOR_BUFFER_BIT);
- const overlay_count = buildTouchOverlay(g, sw, sh, debug_on);
const cells: u32 = @as(u32, surface.cols) * surface.rows;
if (cells == 0) {
+ const overlay_count = buildTouchOverlay(g, sw, sh, debug_on);
drawOverlayGl(g, overlay_count);
_ = c.SDL_GL_SwapWindow(g.window);
return;
@@ -4074,6 +4096,7 @@ fn renderFrameGl(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, debu
const win_w: f32 = @floatFromInt(sw);
const win_h: f32 = @floatFromInt(sh);
const layout = cellLayout(g, surface.cols, surface.rows, sw, sh);
+ const overlay_count = buildOverlay(g, surface, layout, sw, sh, debug_on);
const cursor_idx: u32 = if (surface.cursor) |cu| @as(u32, cu.y) * surface.cols + cu.x else cells;
const cursor_bar = if (surface.cursor) |cu| cu.bar else false;
var row: u16 = 0;
@@ -4081,7 +4104,7 @@ fn renderFrameGl(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, debu
var col: u16 = 0;
while (col < surface.cols) : (col += 1) {
const idx: u32 = @as(u32, row) * surface.cols + col;
- emitInstance(g, g.gl_instances.ptr, idx, col, row, layout, win_w, win_h, surface.at(col, row), idx == cursor_idx, cursor_bar);
+ emitInstance(g, g.gl_instances.ptr, idx, col, row, layout, win_w, win_h, surface.at(col, row), idx == cursor_idx and !cursor_bar);
}
}
const shifted = emitScrollRows(g, g.gl_instances.ptr, cells, surface, layout, win_w, win_h);
@@ -4305,15 +4328,51 @@ fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u
// flash HUD, alpha-blended over the grid only while the Debug builtin is on.
// =====================================================================
-fn buildTouchOverlay(g: *Gui, sw: u32, sh: u32, debug_on: bool) u32 {
- if (!debug_on) {
- g.touch.click_flash_frames = 0;
- return 0;
+fn addCursorBar(builder: *OverlayBuilder, x: u16, y: u16, bar: bool, layout: CellLayout) void {
+ if (!bar) return;
+ const x0 = layout.x_off + @as(f32, @floatFromInt(x)) * layout.w;
+ const y0 = layout.y_off + @as(f32, @floatFromInt(y)) * layout.h;
+ builder.addRect(
+ x0,
+ y0,
+ x0 + @max(1.0, layout.w / 8.0),
+ y0 + layout.h,
+ .{
+ .r = @as(f32, @floatFromInt(fg_default[0])) / 255.0,
+ .g = @as(f32, @floatFromInt(fg_default[1])) / 255.0,
+ .b = @as(f32, @floatFromInt(fg_default[2])) / 255.0,
+ .a = 1.0,
+ },
+ );
+}
+
+fn buildOverlay(g: *Gui, surface: *const pardes.Surface, layout: CellLayout, sw: u32, sh: u32, debug_on: bool) u32 {
+ if (g.overlay_vertices.len == 0 or sw == 0 or sh == 0) return 0;
+ const win_w: f32 = @floatFromInt(sw);
+ const win_h: f32 = @floatFromInt(sh);
+ var builder = OverlayBuilder{ .vertices = g.overlay_vertices, .win_w = win_w, .win_h = win_h };
+ if (surface.cursor) |cursor| {
+ if (cursor.x < surface.cols and cursor.y < surface.rows)
+ addCursorBar(&builder, cursor.x, cursor.y, cursor.bar, layout);
}
+ appendTouchOverlay(g, &builder, debug_on);
+ return @intCast(builder.len);
+}
+
+fn buildTouchOverlay(g: *Gui, sw: u32, sh: u32, debug_on: bool) u32 {
if (g.overlay_vertices.len == 0 or sw == 0 or sh == 0) return 0;
const win_w: f32 = @floatFromInt(sw);
const win_h: f32 = @floatFromInt(sh);
var builder = OverlayBuilder{ .vertices = g.overlay_vertices, .win_w = win_w, .win_h = win_h };
+ appendTouchOverlay(g, &builder, debug_on);
+ return @intCast(builder.len);
+}
+
+fn appendTouchOverlay(g: *Gui, builder: *OverlayBuilder, debug_on: bool) void {
+ if (!debug_on) {
+ g.touch.click_flash_frames = 0;
+ return;
+ }
const t = &g.touch;
if (t.click_flash_frames != 0) {
@@ -4325,7 +4384,7 @@ fn buildTouchOverlay(g: *Gui, sw: u32, sh: u32, debug_on: bool) u32 {
const stripe_w = 4.0;
const label_w = pad * 2.0 + stripe_w + 3.0 + @as(f32, @floatFromInt(label.len)) * char_step - glyph_scale;
const label_h = pad * 2.0 + 5.0 * glyph_scale;
- builder.addRect(0.0, 0.0, win_w, win_h, .{ .r = 1.0, .g = 0.78, .b = 0.18, .a = 0.055 * flash });
+ builder.addRect(0.0, 0.0, builder.win_w, builder.win_h, .{ .r = 1.0, .g = 0.78, .b = 0.18, .a = 0.055 * flash });
builder.addRect(8.0, 8.0, 8.0 + label_w, 8.0 + label_h, .{ .r = 0.0, .g = 0.0, .b = 0.0, .a = 0.54 + 0.22 * flash });
builder.addRect(8.0 + pad, 8.0 + pad, 8.0 + pad + stripe_w, 8.0 + label_h - pad, .{ .r = 1.0, .g = 0.78, .b = 0.18, .a = 0.96 });
builder.addMiniText(label[0..], 8.0 + pad + stripe_w + 3.0, 8.0 + pad, glyph_scale, .{ .r = 1.0, .g = 1.0, .b = 1.0, .a = 0.96 });
@@ -4334,8 +4393,8 @@ fn buildTouchOverlay(g: *Gui, sw: u32, sh: u32, debug_on: bool) u32 {
for (&t.points) |*tp| {
if (!tp.active) continue;
- const cx = std.math.clamp(tp.x, 0.0, 1.0) * win_w;
- const cy = std.math.clamp(tp.y, 0.0, 1.0) * win_h;
+ const cx = std.math.clamp(tp.x, 0.0, 1.0) * builder.win_w;
+ const cy = std.math.clamp(tp.y, 0.0, 1.0) * builder.win_h;
const radius = (12.0 + std.math.clamp(tp.pressure, 0.0, 1.0) * 4.0) * 4.0;
const color = touchColor(tp.id);
@@ -4346,8 +4405,8 @@ fn buildTouchOverlay(g: *Gui, sw: u32, sh: u32, debug_on: bool) u32 {
const fade = @as(f32, @floatFromInt(trail_i + 1)) / @as(f32, @floatFromInt(tp.trail_len));
var trail_color = color;
trail_color.a *= 0.42 * fade;
- const tx = std.math.clamp(sample.x, 0.0, 1.0) * win_w;
- const ty = std.math.clamp(sample.y, 0.0, 1.0) * win_h;
+ const tx = std.math.clamp(sample.x, 0.0, 1.0) * builder.win_w;
+ const ty = std.math.clamp(sample.y, 0.0, 1.0) * builder.win_h;
const trail_radius = (12.0 + std.math.clamp(sample.pressure, 0.0, 1.0) * 4.0) * 2.8;
builder.addCircle(tx, ty, trail_radius, trail_color);
}
@@ -4358,7 +4417,6 @@ fn buildTouchOverlay(g: *Gui, sw: u32, sh: u32, debug_on: bool) u32 {
builder.addCircle(cx, cy, 2.4, .{ .r = 1.0, .g = 1.0, .b = 1.0, .a = 0.92 });
builder.addIdTag(tp.id, cx, cy, color);
}
- return @intCast(builder.len);
}
fn uploadOverlayGpu(g: *Gui, cmd: *c.SDL_GPUCommandBuffer, vertex_count: u32) void {
diff --git a/src/modal.zig b/src/modal.zig
index 424421f6..33c63c52 100644
--- a/src/modal.zig
+++ b/src/modal.zig
@@ -1171,6 +1171,27 @@ pub fn hxIndentString(line: []const u8) []const u8 {
return max[0..@min(level * INDENT_W, max.len)];
}
+/// Indent width for an inserted newline. Keep the current full indent levels,
+/// then add one logical tab after a simple delimiter-shaped line ending.
+/// `)` intentionally includes both ordinary calls and the requested `})`
+/// continuation shape; this is syntax-agnostic and does not try to parse.
+pub fn hxNewlineIndentWidth(line: []const u8, col: usize) usize {
+ const prefix = std.mem.trimEnd(u8, line[0..@min(col, line.len)], " \t");
+ const extra = if (prefix.len == 0) false else switch (prefix[prefix.len - 1]) {
+ '(', '[', '{', ')' => true,
+ else => false,
+ };
+ return hxIndentString(line).len + @as(usize, if (extra) INDENT_W else 0);
+}
+
+test "newline indent keeps levels and adds one after delimiters" {
+ try std.testing.expectEqual(@as(usize, 4), hxNewlineIndentWidth(" value", 9));
+ try std.testing.expectEqual(@as(usize, 8), hxNewlineIndentWidth(" call()", 10));
+ try std.testing.expectEqual(@as(usize, 8), hxNewlineIndentWidth(" callback({}) ", 16));
+ try std.testing.expectEqual(@as(usize, 4), hxNewlineIndentWidth("work(", 5));
+ try std.testing.expectEqual(@as(usize, 4), hxNewlineIndentWidth("list[tail", 5));
+}
+
/// helix Ctrl-a / Ctrl-x: increment the SELECTED text as a decimal integer.
/// Zero-padding is preserved (width follows sign flips, helix-style).
/// Ponytail: no 0x/0o/0b bases, no '_' separators — decimal only.
@@ -1530,8 +1551,13 @@ fn stepInsertFile(alloc: std.mem.Allocator, content: []const u8, st: *FileState,
return new;
},
.enter => {
- const new = try insertAt(alloc, content, st.cur, "\n");
- st.cur = .{ .row = st.cur.row + 1, .col = 0 };
+ const indent = hxNewlineIndentWidth(lineSlice(content, st.cur.row), st.cur.col);
+ const block = try alloc.alloc(u8, 1 + indent);
+ defer alloc.free(block);
+ block[0] = '\n';
+ @memset(block[1..], ' ');
+ const new = try insertAt(alloc, content, st.cur, block);
+ st.cur = .{ .row = st.cur.row + 1, .col = indent };
st.pending = 0;
return new;
},
diff --git a/src/nested.zig b/src/nested.zig
index 0c065d1d..20c42dd0 100644
--- a/src/nested.zig
+++ b/src/nested.zig
@@ -59,17 +59,43 @@ pub fn socketPath(buf: *[108]u8, pid: libc.pid_t) ?[:0]const u8 {
return std.fmt.bufPrintSentinel(buf, "{s}/pardes-{d}.sock", .{ dir, @as(u32, @intCast(pid)) }, 0) catch null;
}
-/// A `/proc/<pid>/exe` readlink with the kernel's `" (deleted)"` suffix taken
-/// off. `zig build` replaces the binary under a running pardes — that is the
-/// daily loop in this repo — and from that moment the OUTER instance's exe
-/// link reads `/path/to/pardes (deleted)` while the freshly built child's
-/// reads `/path/to/pardes`. Comparing them raw made every nested launch after
-/// a rebuild open a second full-screen UI inside the pane.
+/// Normalize the kernel suffix left on a running executable after its file is
+/// replaced. `zig build` does this routinely while an outer session is live.
fn stripDeleted(link: []const u8) []const u8 {
const suffix = " (deleted)";
return if (std.mem.endsWith(u8, link, suffix)) link[0 .. link.len - suffix.len] else link;
}
+/// The tty and SDL builds are sibling frontends of the same program. Their
+/// installed names differ only by `-gui` (and, for cross builds, share the
+/// same `-os-arch` tail), so either one must recognise the other as an outer
+/// pardes. Requiring the same directory retains the executable-identity check:
+/// an unrelated ancestor merely named `pardes` is not enough.
+fn samePardesExecutable(a_raw: []const u8, b_raw: []const u8) bool {
+ const a = stripDeleted(a_raw);
+ const b = stripDeleted(b_raw);
+ if (std.mem.eql(u8, a, b)) return true;
+ const a_dir = std.fs.path.dirname(a) orelse return false;
+ const b_dir = std.fs.path.dirname(b) orelse return false;
+ if (!std.mem.eql(u8, a_dir, b_dir)) return false;
+
+ const a_name = std.fs.path.basename(a);
+ const b_name = std.fs.path.basename(b);
+ const gui = "pardes-gui";
+ const tty = "pardes";
+ const a_gui = std.mem.startsWith(u8, a_name, gui);
+ const b_gui = std.mem.startsWith(u8, b_name, gui);
+ if (a_gui == b_gui) return false;
+ const gui_name = if (a_gui) a_name else b_name;
+ const tty_name = if (a_gui) b_name else a_name;
+ if (!std.mem.startsWith(u8, tty_name, tty)) return false;
+ const gui_tail = gui_name[gui.len..];
+ const tty_tail = tty_name[tty.len..];
+ if ((gui_tail.len != 0 and gui_tail[0] != '-') or
+ (tty_tail.len != 0 and tty_tail[0] != '-')) return false;
+ return std.mem.eql(u8, gui_tail, tty_tail);
+}
+
/// The `PPid:` field of a /proc/<pid>/status blob. Deliberately NOT field 4 of
/// /proc/<pid>/stat: that field is positional after `comm`, and a comm may
/// contain spaces and parentheses — a process named `sh (a b)` shifts every
@@ -94,11 +120,13 @@ fn sweepPid(name: []const u8) ?libc.pid_t {
return std.fmt.parseInt(libc.pid_t, digits, 10) catch null;
}
-/// The pid of the nearest ancestor running THIS executable, or null. Identity
-/// is `readlink("/proc/<pid>/exe")` against our own, not a name: a name match
-/// would call every `vim pardes.zig` an outer pardes. The hop cap is not for
-/// /proc, which cannot loop, but because the walk is driven by numbers read
-/// out of files and should not be able to spin on a surprising one.
+/// The pid of the nearest ancestor running a pardes executable, or null.
+/// Identity is `readlink("/proc/<pid>/exe")` against our own; the tty `pardes`
+/// and SDL `pardes-gui` siblings also match when they live in the same
+/// directory. A name alone would call every unrelated `pardes` ancestor an
+/// outer instance. The hop cap is not for /proc, which cannot loop, but because
+/// the walk is driven by numbers read out of files and should not be able to
+/// spin on a surprising one.
pub fn outer() ?libc.pid_t {
if (comptime builtin.os.tag != .linux) return null;
var self_buf: [4096]u8 = undefined;
@@ -112,7 +140,7 @@ pub fn outer() ?libc.pid_t {
var buf: [4096]u8 = undefined;
const exe = std.fmt.bufPrintSentinel(&name, "/proc/{d}/exe", .{@as(u32, @intCast(pid))}, 0) catch return null;
const n = libc.readlink(exe, &buf, buf.len);
- if (n > 0 and std.mem.eql(u8, stripDeleted(buf[0..@intCast(n)]), self_exe)) return pid;
+ if (n > 0 and samePardesExecutable(buf[0..@intCast(n)], self_exe)) return pid;
const status = std.fmt.bufPrintSentinel(&name, "/proc/{d}/status", .{@as(u32, @intCast(pid))}, 0) catch return null;
const fd = libc.open(status, .{ .ACCMODE = .RDONLY });
if (fd < 0) return null;
@@ -356,6 +384,29 @@ test "a rebuilt binary still matches its own running instance" {
try std.testing.expectEqualStrings("/x (delete)", stripDeleted("/x (delete)"));
}
+test "tty and GUI sibling executables recognise each other" {
+ try std.testing.expect(samePardesExecutable(
+ "/work/zig-out/bin/pardes",
+ "/work/zig-out/bin/pardes-gui",
+ ));
+ try std.testing.expect(samePardesExecutable(
+ "/work/zig-out/bin/pardes-linux-aarch64",
+ "/work/zig-out/bin/pardes-gui-linux-aarch64 (deleted)",
+ ));
+ try std.testing.expect(!samePardesExecutable(
+ "/usr/bin/pardes",
+ "/work/zig-out/bin/pardes-gui",
+ ));
+ try std.testing.expect(!samePardesExecutable(
+ "/work/zig-out/bin/pardes-linux-aarch64",
+ "/work/zig-out/bin/pardes-gui-linux-x86_64",
+ ));
+ try std.testing.expect(!samePardesExecutable(
+ "/work/zig-out/bin/not-pardes",
+ "/work/zig-out/bin/not-pardes-gui",
+ ));
+}
+
test "the sweep only recognises its own socket names" {
try std.testing.expectEqual(@as(libc.pid_t, 7), sweepPid("pardes-7.sock").?);
try std.testing.expectEqual(@as(libc.pid_t, 4194304), sweepPid("pardes-4194304.sock").?);
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)