diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-09 23:43:06 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-10 09:17:07 -0300 |
| commit | 0d66575a3c888498c7929e2ec345133628c5d63d (patch) | |
| tree | 3be80051fd505f9675b36f9b570c46a955ca3445 /src | |
| parent | 4b5e793f48f6e721c39ab6ddcf755604df73b643 (diff) | |
| download | pardes-0d66575a3c888498c7929e2ec345133628c5d63d.tar.gz pardes-0d66575a3c888498c7929e2ec345133628c5d63d.zip | |
fix tab rendering - waybe wreckless
Diffstat (limited to 'src')
| -rw-r--r-- | src/config.zig | 6 | ||||
| -rw-r--r-- | src/file_pane.zig | 69 | ||||
| -rw-r--r-- | src/gui/gui.zig | 116 | ||||
| -rw-r--r-- | src/modal.zig | 30 | ||||
| -rw-r--r-- | src/nested.zig | 75 | ||||
| -rw-r--r-- | src/pardes.zig | 228 |
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) |
