diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-11 12:38:56 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-09-15 17:24:42 -0300 |
| commit | 682e237df7e8b22f820d14a4adee58e6e2f84268 (patch) | |
| tree | 5d888cb46f1a49e89fc55e2a4c232d3af0265b1d /src/pardes.zig | |
| parent | 6878e1c309172d624c0d7a3555f6f0217ae6770e (diff) | |
| download | pardes-682e237df7e8b22f820d14a4adee58e6e2f84268.tar.gz pardes-682e237df7e8b22f820d14a4adee58e6e2f84268.zip | |
trunk: resume before the Reload experiment
Empty marker on the last pre-Reload change. Keep the Reload experiment on reload (3801914), its first change on reload-start (200a1fc), and the unfinished performance investigation on reload-perf-wip.
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 1335 |
1 files changed, 1156 insertions, 179 deletions
diff --git a/src/pardes.zig b/src/pardes.zig index 8d5f0ba7..35080163 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -74,6 +74,8 @@ const column_weight_unit = layout.column_weight_unit; const max_column_weight = layout.max_column_weight; pub const MAX_JUMPS = 64; pub const TOPBAR_H: u16 = 1; +pub const COLBAR_H: u16 = 1; +pub const TagLine = @import("tag_line.zig").Line; pub const BOX_H: u16 = 1; pub fn taglineBandOffset(row: u16, canvas_h: f32, cell_h: u32, tagline_h: u32) u32 { @@ -93,6 +95,10 @@ pub fn topbarPaneBorderPixels(cell_h: u32, tagline_h: u32) u32 { pub fn taglineOriginCol(p: *const Pardes, col: u16, row: u16, track: ?layout.Track) f32 { if (row < TOPBAR_H) return 0; + if (row < TOPBAR_H + p.columnBarHeight()) { + for (p.col_x[0..p.ncol], p.col_w[0..p.ncol]) |x, w| + if (col >= x and col - x < w) return @floatFromInt(x); + } if (track) |active| { const box = active.contentBox(); const tag_y = if (p.settings.tag_bottom) box.y + box.h - @as(f32, @floatFromInt(BOX_H)) else box.y; @@ -149,6 +155,18 @@ pub fn gridColAt(p: ?*const Pardes, x: f32, row: u16, body_w: f32, tagline_w: f3 const body = @max(body_w, 1); const tag = @max(tagline_w, 1); if (row < TOPBAR_H) return colFromSpan(x, tag); + if (p) |core| if (row < TOPBAR_H + core.columnBarHeight()) { + for (core.col_x[0..core.ncol], core.col_w[0..core.ncol]) |origin, width| { + if (width == 0) continue; + const left = @as(f32, @floatFromInt(origin)) * body; + const right = @as(f32, @floatFromInt(origin + width)) * body; + if (x < left or x >= right) continue; + return origin + @as(u16, @intFromFloat(@min( + @floor(@max(0, x - left) / tag), + @as(f32, @floatFromInt(width - 1)), + ))); + } + }; if (p) |core| for (core.panes, 0..) |slot, id| { if (slot == null) continue; const r = core.rects[id]; @@ -195,6 +213,13 @@ test "a compact tagline anchors at its own pane, and both shells step from the s const body_w: f32 = 10; const tag_w: f32 = 8; + for (p.col_x[0..p.ncol], p.col_w[0..p.ncol]) |origin, width| { + if (width == 0) continue; + const col = origin; + try std.testing.expectEqual(@as(f32, @floatFromInt(origin)), taglineOriginCol(p, col, TOPBAR_H, null)); + const glyph_x = @as(f32, @floatFromInt(origin)) * body_w; + try std.testing.expectEqual(col, gridColAt(p, glyph_x + tag_w / 2, TOPBAR_H, body_w, tag_w)); + } for ([_]u16{ 0, 1, 5, 40, 119 }) |col| { const origin = taglineOriginCol(p, col, left.y, null); const sdl = origin * (body_w - tag_w) + @as(f32, @floatFromInt(col)) * tag_w; @@ -230,6 +255,7 @@ pub const Loc = struct { test {} const pane_tail = " " ++ config.pane_builtins_str; +const pdf_pane_tail = " " ++ config.pdf_pane_builtins_str; const file_pane_tail = " " ++ config.file_pane_builtins_str; const terminal_pane_tail = " " ++ config.terminal_pane_builtins_str; const dirty_marker = " *"; @@ -243,6 +269,15 @@ const legacy_terminal_pane_tail = " New Del Filter"; // ...and the terminal default from the release before Save reached it, when a // scrollback was not yet something you could write to a path. const prev_terminal_pane_tail = " New Newtty Del Filter"; +const pre_tty_terminal_pane_tail = " Save New Newtty Del Filter"; +const pre_column_pane_tail = " New Newtty Del"; +const pre_column_file_pane_tail = " Save New Newtty Del"; +const pre_column_pdf_pane_tail = " New Newtty Del PdfSections PdfTint"; +const pre_column_terminal_pane_tail = " Save New Newtty Del Tty Filter"; +const pre_collapse_pane_tail = " Tty Del"; +const pre_collapse_file_pane_tail = " Save Tty Del"; +const pre_collapse_pdf_pane_tail = " Tty Del PdfSections PdfTint"; +const pre_collapse_terminal_pane_tail = " Save Tty Del Togglettymode Filter"; const Builtin = builtins.registry.Builtin(); @@ -929,14 +964,14 @@ test "topbar hover highlights exactly the executable word" { p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = 1, .row = 0 } }); _ = frame.reset(.retain_capacity); const hovered = try p.render(frame.allocator()); - for (0.."New".len) |col| { + for (0.."Newcol".len) |col| { const cell = hovered.at(@intCast(col), 0); try std.testing.expectEqual(Color{ .rgb = p.theme().sel_bg }, cell.style.bg); try std.testing.expectEqual(Color{ .rgb = p.theme().sel_fg }, cell.style.fg); } - try std.testing.expectEqual(Color{ .rgb = p.chromeTheme().tag_bg }, hovered.at(3, 0).style.bg); + try std.testing.expectEqual(Color{ .rgb = p.chromeTheme().tag_bg }, hovered.at(6, 0).style.bg); - p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = 3, .row = 0 } }); + p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = 6, .row = 0 } }); _ = frame.reset(.retain_capacity); const whitespace = try p.render(frame.allocator()); try std.testing.expectEqual(Color{ .rgb = p.chromeTheme().tag_bg }, whitespace.at(1, 0).style.bg); @@ -947,7 +982,7 @@ test "topbar hover highlights exactly the executable word" { try std.testing.expectEqual(Color{ .rgb = p.chromeTheme().tag_bg }, left.at(1, 0).style.bg); } -test "pane-tag Exec prefers New and argument builtins before shell fallback" { +test "pane-tag Exec prefers Tty and argument builtins before shell fallback" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 100, .rows = 30 }); defer p.deinit(); @@ -955,12 +990,12 @@ test "pane-tag Exec prefers New and argument builtins before shell fallback" { p.acknowledgeShell(0, "/bin/sh", false); const pane = p.panes[0].?; - try std.testing.expectEqualStrings(" Save New Newtty Del Filter", Pardes.curTail(pane)); + try std.testing.expectEqualStrings(" Save Tty Del Togglettymode Filter Collapse", Pardes.curTail(pane)); const tag_x = p.rects[0].x + config.GUTTER; const tag_y = p.rects[0].y; const rendered = try p.tagText(p.scratch.allocator(), pane); - const new_x = tag_x + @as(u16, @intCast(std.mem.indexOf(u8, rendered, "New").?)) + 1; + const new_x = tag_x + @as(u16, @intCast(std.mem.indexOf(u8, rendered, "Tty").?)) + 1; var panes_before: usize = 0; for (p.panes) |s| if (s != null) { panes_before += 1; @@ -976,7 +1011,7 @@ test "pane-tag Exec prefers New and argument builtins before shell fallback" { panes_after += 1; }; try std.testing.expectEqual(panes_before + 1, panes_after); - try std.testing.expect(p.panes[p.active].?.file.?.output != null); // the scratch + try std.testing.expect(p.panes[p.active].?.isTerminal()); // Argument-taking builtins use the same gesture path: a tag sweep is the // complete command line, and Theme consumes its tail without a PTY write. @@ -1018,6 +1053,74 @@ test "pane-tag Exec prefers New and argument builtins before shell fallback" { try std.testing.expectEqualStrings("DefinitelyNotABuiltin\r", sent[0..sent_len]); } +test "Collapse builtin keeps content and restores the pane from its default tag" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 24, .tty_only = true }); + defer p.deinit(); + const pane = try p.setTestFile("keep this text\n"); + p.sync(); + const height = p.rects[0].h; + const revision = pane.file.?.revision; + for ([_][]const u8{ pane_tail, file_pane_tail, pdf_pane_tail, terminal_pane_tail }) |tail| + try std.testing.expect(std.mem.endsWith(u8, tail, " Collapse")); + + try std.testing.expect(p.executeBuiltinLine(0, "Collapse")); + p.sync(); + try std.testing.expect(pane.collapsed); + try std.testing.expectEqual(@as(u16, BOX_H), p.rects[0].h); + try std.testing.expectEqual(@as(f32, @floatFromInt(height)), pane.vweight); + try std.testing.expectEqual(revision, pane.file.?.revision); + try std.testing.expectEqualStrings("keep this text\n", pane.file.?.content); + try std.testing.expect((try p.render(p.scratch.allocator())).cursor == null); + p.enterTagEdit(pane, -1); + const tag_cursor = (try p.render(p.scratch.allocator())).cursor orelse return error.MissingTagCursor; + try std.testing.expectEqual(p.rects[0].y, tag_cursor.y); + Pardes.exitTagEdit(pane); + try std.testing.expect(p.executeBuiltinLine(0, "Collapse")); + p.sync(); + try std.testing.expect(!pane.collapsed); + try std.testing.expectEqual(height, p.rects[0].h); + + // Unedited defaults from the preceding version gain the new command. + pane.tag_init = false; + pane.tag_tail_len = 0; + p.restoreTailAt(pane, pre_collapse_file_pane_tail, "", .file); + try std.testing.expect(!pane.tag_init); + try std.testing.expectEqualStrings(file_pane_tail, Pardes.curTail(pane)); +} + +test "Togglettymode builtin and keyboard chord share mode transitions including edited tags" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true }); + defer p.deinit(); + const pane = p.panes[0].?; + try std.testing.expect(std.mem.indexOf(u8, Pardes.curTail(pane), " Togglettymode ") != null); + try std.testing.expectEqual(Pane.Mode.tty, pane.mode); + try std.testing.expect(p.executeBuiltinLine(0, "Togglettymode")); + try std.testing.expectEqual(Pane.Mode.normal, pane.mode); + p.update(.{ .key = .{ .cp = p.opts.tty_toggle, .ctrl = true } }); + try std.testing.expectEqual(Pane.Mode.tty, pane.mode); + p.enterTagEdit(pane, -1); + try std.testing.expectEqual(Pane.Mode.insert, pane.mode); + try std.testing.expect(p.executeBuiltinLine(0, "Togglettymode")); + try std.testing.expectEqual(Pane.Mode.normal, pane.mode); + try std.testing.expect(!pane.tag_edit); + p.enterTagEdit(pane, -1); + p.update(.{ .key = .{ .cp = p.opts.tty_toggle, .ctrl = true } }); + try std.testing.expectEqual(Pane.Mode.tty, pane.mode); + try std.testing.expect(!pane.tag_edit); + + p.acknowledgePanelPresentation(&.{}); + const tag = try p.tagText(p.scratch.allocator(), pane); + const x = p.rects[0].x + config.GUTTER + @as(u16, @intCast(std.mem.indexOf(u8, tag, "Togglettymode").?)); + const y = p.rects[0].y; + p.update(.{ .mouse = .{ .button = .middle, .kind = .press, .col = x, .row = y } }); + p.update(.{ .mouse = .{ .button = .middle, .kind = .release, .col = x, .row = y } }); + try std.testing.expectEqual(Pane.Mode.normal, pane.mode); + const file = try p.setTestFile("unchanged\n"); + try std.testing.expect(p.executeBuiltinLine(0, "Togglettymode")); + try std.testing.expectEqual(Pane.Mode.normal, file.mode); + try std.testing.expectEqualStrings("unchanged\n", file.file.?.content); +} + test "Filter is ordered after Del and toggles only its terminal pane" { const testing = std.testing; const del = std.meta.stringToEnum(Builtin, "Del") orelse return error.MissingDelBuiltin; @@ -1054,14 +1157,13 @@ test "Filter is ordered after Del and toggles only its terminal pane" { try testing.expect(!second.tty_filter); } -test "an untouched tagline ends where its layout column's widest one does" { +test "compact tag commands stay beside their own prefix across layout changes" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} // initial shell spawn/geometry - // a second shell BELOW the first — what Alt-n does — sitting in a - // directory too long to leave tag_right_pad columns free + // A longer sibling never introduces invisible padding into this tag. const below_id = p.freeSlot().?; const below = try p.newShell(below_id, ""); const f = layout.findPane(p, p.active).?; @@ -1073,34 +1175,32 @@ test "an untouched tagline ends where its layout column's widest one does" { const above = p.panes[0].?; try std.testing.expectEqual(p.rects[0].x, p.rects[below_id].x); - const above_tag = try p.tagText(p.scratch.allocator(), above); + const above_tag = try p.tagText(gpa, above); + defer gpa.free(above_tag); const below_tag = try p.tagText(p.scratch.allocator(), below); - try std.testing.expectEqual(below_tag.len, above_tag.len); - const tail = " Save New Newtty Del Filter"; + try std.testing.expect(below_tag.len > above_tag.len); + const tail = " Save Tty Del Togglettymode Filter Collapse"; try std.testing.expectEqualStrings(tail, above_tag[above_tag.len - tail.len ..]); - try std.testing.expect(above_tag.len > @as(usize, p.rects[0].w) - config.GUTTER - config.tag_right_pad); - try std.testing.expect(above_tag.len <= @as(usize, p.rects[0].w) - config.GUTTER); + try std.testing.expectEqual(above.cwdSlice().len + tail.len, above_tag.len); p.update(.{ .resize = .{ .cols = 88, .rows = 30 } }); while (p.nextEffect()) |_| {} - const fits = (try p.tagText(p.scratch.allocator(), above)).len; + try std.testing.expectEqualStrings(above_tag, try p.tagText(p.scratch.allocator(), above)); p.update(.{ .resize = .{ .cols = 87, .rows = 30 } }); while (p.nextEffect()) |_| {} - try std.testing.expectEqual(fits - 1, (try p.tagText(p.scratch.allocator(), above)).len); + try std.testing.expectEqualStrings(above_tag, try p.tagText(p.scratch.allocator(), above)); p.update(.{ .resize = .{ .cols = 100, .rows = 30 } }); while (p.nextEffect()) |_| {} p.seedTail(below); + try std.testing.expectEqualStrings(tail, below.tagSlice()); try std.testing.expect(below.appendTag(" lots and lots of typing out here")); try std.testing.expectEqual(above_tag.len, (try p.tagText(p.scratch.allocator(), above)).len); p.update(.{ .resize = .{ .cols = 100, .rows = 2 } }); while (p.nextEffect()) |_| {} try std.testing.expectEqual(@as(u16, 0), p.rects[below_id].h); - try std.testing.expectEqual( - @as(usize, p.rects[0].w) - config.GUTTER - config.tag_right_pad, - (try p.tagText(p.scratch.allocator(), above)).len, - ); + try std.testing.expectEqualStrings(above_tag, try p.tagText(p.scratch.allocator(), above)); } test "legacy default tag tails upgrade while custom tails remain owned" { @@ -1115,14 +1215,14 @@ test "legacy default tag tails upgrade while custom tails remain owned" { }); p.restoreTail(terminal, terminal_old); try std.testing.expect(!terminal.tag_init); - try std.testing.expectEqualStrings(" Save New Newtty Del Filter", Pardes.curTail(terminal)); + try std.testing.expectEqualStrings(" Save Tty Del Togglettymode Filter Collapse", Pardes.curTail(terminal)); const terminal_previous = try std.fmt.allocPrint(p.scratch.allocator(), "{s} New Del", .{ try p.tagPrefix(terminal), }); p.restoreTail(terminal, terminal_previous); try std.testing.expect(!terminal.tag_init); - try std.testing.expectEqualStrings(" Save New Newtty Del Filter", Pardes.curTail(terminal)); + try std.testing.expectEqualStrings(" Save Tty Del Togglettymode Filter Collapse", Pardes.curTail(terminal)); const terminal_prefilter = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ try p.tagPrefix(terminal), @@ -1130,7 +1230,7 @@ test "legacy default tag tails upgrade while custom tails remain owned" { }); p.restoreTail(terminal, terminal_prefilter); try std.testing.expect(!terminal.tag_init); - try std.testing.expectEqualStrings(" Save New Newtty Del Filter", Pardes.curTail(terminal)); + try std.testing.expectEqualStrings(" Save Tty Del Togglettymode Filter Collapse", Pardes.curTail(terminal)); const terminal_custom = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Keep Del", .{ try p.tagPrefix(terminal), @@ -1159,7 +1259,7 @@ test "legacy default tag tails upgrade while custom tails remain owned" { .terminal = .{ .cwd = "/historical/cwd" }, }); try std.testing.expect(!terminal.tag_init); - try std.testing.expectEqualStrings(" Save New Newtty Del Filter", Pardes.curTail(terminal)); + try std.testing.expectEqualStrings(" Save Tty Del Togglettymode Filter Collapse", Pardes.curTail(terminal)); // This happens to be the historical FILE default, but on a terminal it is // user-owned text and must not be swallowed by migration. @@ -1181,7 +1281,7 @@ test "legacy default tag tails upgrade while custom tails remain owned" { }); p.restoreTail(file, file_old); try std.testing.expect(!file.tag_init); - try std.testing.expectEqualStrings(" Save New Newtty Del", Pardes.curTail(file)); + try std.testing.expectEqualStrings(" Save Tty Del Collapse", Pardes.curTail(file)); const file_custom = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Save Mine Del", .{ try p.tagPrefix(file), @@ -1202,7 +1302,7 @@ test "legacy default tag tails upgrade while custom tails remain owned" { try std.testing.expectEqualStrings(" New Del", Pardes.curTail(file)); const image_doc = try panes.Image.create(p, 1, "/tmp/legacy image.ppm", &.{}); - try std.testing.expectEqualStrings(" New Newtty Del", Pardes.curTail(image_doc)); + try std.testing.expectEqualStrings(" Tty Del Collapse", Pardes.curTail(image_doc)); p.restoreDumpTail(image_doc, .{ .kind = .image, .tag = "img /tmp/legacy image.ppm Keep Del", @@ -1236,14 +1336,14 @@ test "Joincol folds the active column into its right neighbor, keeping its panes try std.testing.expectEqual(@as(usize, 1), p.ncol); } -test "Newtty spawns a raw shell in the caller's directory" { +test "Tty spawns a raw shell in the caller's directory" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); defer p.deinit(); while (p.nextEffect()) |_| {} p.setCwd(0, "/tmp/newtty-dir"); - p.spawnTty(0); + try std.testing.expect(p.executeBuiltinLine(0, "Tty")); const free = p.active; try std.testing.expect(free != 0); const nt = p.panes[free] orelse return error.NoNewPane; @@ -1261,6 +1361,85 @@ test "Newtty spawns a raw shell in the caller's directory" { try std.testing.expect(found); } +test "first document fallback splits like Tty from the originating pane" { + for ([_]u16{ 100, 198 }) |width| for (0..3) |from| { + for ([_]bool{ false, true }) |collapsed| { + const p = try Pardes.init(std.testing.allocator, .{ .cols = width, .rows = 62, .shells = 3 }); + defer p.deinit(); + const tty = try Pardes.init(std.testing.allocator, .{ .cols = width, .rows = 62, .shells = 3 }); + defer tty.deinit(); + if (collapsed) { + layout.toggleCollapse(p, from); + layout.toggleCollapse(tty, from); + } + p.sync(); + tty.sync(); + const before = p.rects; + const parent = layout.splitParent(p, from); + const at = layout.findPane(p, parent).?; + // The Look origin need not be the currently active pane. + p.active = (from + 1) % 3; + const free = p.freeSlot().?; + const doc = try p.openTutorView(free); + p.placeDoc(from, free, doc); + tty.spawnTty(from); + p.sync(); + tty.sync(); + try std.testing.expectEqual(@as(usize, 2), p.ncol); + try std.testing.expectEqual(free, p.active); + try std.testing.expectEqual(free, p.col_panes[at.col][at.idx + 1]); + for (0..3) |id| { + try std.testing.expect(p.panes[id] != null); + if (id != parent) try std.testing.expectEqualDeep(before[id], p.rects[id]); + } + for (0..4) |id| try std.testing.expectEqualDeep(tty.rects[id], p.rects[id]); + for (0..p.ncol) |col| { + try std.testing.expectEqualSlices(usize, tty.col_panes[col][0..tty.col_n[col]], p.col_panes[col][0..p.col_n[col]]); + } + } + }; +} + +test "first document keeps its originating shell and only opens a column when it fits" { + for ([_]u16{ 199, 200 }) |width| { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = width, .rows = 32 }); + defer p.deinit(); + const shell = p.panes[0].?; + const doc = try p.openTutorView(1); + p.placeDoc(0, 1, doc); + p.sync(); + try std.testing.expectEqual(shell, p.panes[0].?); + try std.testing.expectEqual(@as(usize, 1), p.active); + if (width < 200) { + try std.testing.expectEqual(@as(usize, 1), p.ncol); + try std.testing.expectEqualSlices(usize, &.{ 0, 1 }, p.col_panes[0][0..p.col_n[0]]); + try std.testing.expectEqual(p.rects[0].y + p.rects[0].h, p.rects[1].y); + } else { + try std.testing.expectEqual(@as(usize, 2), p.ncol); + try std.testing.expectEqual(@as(usize, 1), p.col_panes[0][0]); + try std.testing.expectEqual(@as(usize, 0), p.col_panes[1][0]); + } + } +} + +test "pane defaults keep creation of scratch panes in column tags" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + for ([_][]const u8{ pane_tail, file_pane_tail, pdf_pane_tail, terminal_pane_tail, config.topbar_str }) |tag| { + var words = std.mem.tokenizeScalar(u8, tag, ' '); + while (words.next()) |word| try std.testing.expect(!std.mem.eql(u8, word, "New")); + } + try std.testing.expectEqualStrings("New Tty Find Grep Joincol", p.columnTag(0)); + const terminal = p.panes[0].?; + p.restoreTailAt(terminal, pre_column_terminal_pane_tail, "", .terminal); + try std.testing.expect(!terminal.tag_init); + try std.testing.expectEqualStrings(terminal_pane_tail, Pardes.curTail(terminal)); + const file = try p.setTestFile("unchanged\n"); + p.restoreTailAt(file, pre_column_file_pane_tail, "", .file); + try std.testing.expect(!file.tag_init); + try std.testing.expectEqualStrings(file_pane_tail, Pardes.curTail(file)); +} + test "an unsaved file marker sits between its path and builtins until Save" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 }); @@ -1904,7 +2083,7 @@ test "Save on an output buffer writes its rows out and leaves the buffer alone" defer gpa.free(rows); // Save leads its tagline now, and the path is REQUIRED: a bare Save asks - try std.testing.expectEqualStrings(" Save New Newtty Del", Pardes.curTail(out)); + try std.testing.expectEqualStrings(" Save Tty Del Collapse", Pardes.curTail(out)); try std.testing.expect(p.executeBuiltinLine(id, "Save")); try std.testing.expect(out.prompt == .save); try std.testing.expect(out.appendTag("help.txt")); @@ -2092,7 +2271,7 @@ test "Save reaches every tagline with text behind it and no other" { const out = try p.setTestFile("build.zig:1:1 pub fn main\n"); out.file.?.output = .{ .from = .search }; - try std.testing.expectEqualStrings(" Save New Newtty Del", Pardes.curTail(out)); + try std.testing.expectEqualStrings(" Save Tty Del Collapse", Pardes.curTail(out)); // A dump written before Save reached output buffers carries the generic // tail. That is a DEFAULT and not something typed, so it upgrades. @@ -2102,7 +2281,7 @@ test "Save reaches every tagline with text behind it and no other" { }); p.restoreTail(out, dumped); try std.testing.expect(!out.tag_init); - try std.testing.expectEqualStrings(" Save New Newtty Del", Pardes.curTail(out)); + try std.testing.expectEqualStrings(" Save Tty Del Collapse", Pardes.curTail(out)); // A scratch is an output buffer that has worn the FILE tails all along, // so its own older default upgrades from that family too. @@ -2115,13 +2294,13 @@ test "Save reaches every tagline with text behind it and no other" { }); p.restoreTail(out, scratch_dumped); try std.testing.expect(!out.tag_init); - try std.testing.expectEqualStrings(" Save New Newtty Del", Pardes.curTail(out)); + try std.testing.expectEqualStrings(" Save Tty Del Collapse", Pardes.curTail(out)); // An image's bytes on disk already are what they are: nothing of the // pane's own is unwritten, so the word is absent and inert. const img_id = p.freeSlot().?; const img = try panes.Image.create(p, img_id, "/tmp/pardes-tag.ppm", &.{}); - try std.testing.expectEqualStrings(" New Newtty Del", Pardes.curTail(img)); + try std.testing.expectEqualStrings(" Tty Del Collapse", Pardes.curTail(img)); try std.testing.expect(p.executeBuiltinLine(img_id, "Save")); try std.testing.expect(img.prompt != .save); var buf: [256]u8 = undefined; @@ -2254,6 +2433,7 @@ test "Font keeps requested and acknowledged faces as separate plain state" { try std.testing.expect(p.settings.font.pending); try std.testing.expectEqualStrings(installed[0].name, p.settings.font.requested_name.get()); try std.testing.expectEqualStrings(installed[0].path, p.settings.font.requested_path.get()); + try std.testing.expectEqual(@as(u16, 0), p.settings.font.requested_size_hundredths); try std.testing.expectEqualStrings("", p.settings.font.effective_name.get()); // Boot/zoom/display-scale reports describe the face already being drawn; @@ -2302,6 +2482,48 @@ test "Font keeps requested and acknowledged faces as separate plain state" { try std.testing.expectEqualStrings(installed[0].name, p.settings.font.effective_name.get()); } +test "Font size suffix reaches native request and invalid input leaves it intact" { + if (comptime !font_picker) return; + const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); + defer p.deinit(); + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const installed = fonts.list(arena.allocator(), null); + if (installed.len == 0) return; + var line: [512]u8 = undefined; + try std.testing.expect(p.executeBuiltinLine(p.active, try std.fmt.bufPrint(&line, "Font {s}:18.5", .{installed[0].name}))); + try std.testing.expectEqual(@as(u16, 1850), p.settings.font.requested_size_hundredths); + try std.testing.expectEqualStrings(installed[0].path, p.takeFontRequest().?); + try std.testing.expect(p.acknowledgeFont(installed[0].name, 1850, .pixels)); + try std.testing.expect(p.executeBuiltinLine(p.active, try std.fmt.bufPrint(&line, "Font {s}:nan", .{installed[0].name}))); + try std.testing.expectEqual(@as(u16, 1850), p.settings.font.requested_size_hundredths); + try std.testing.expect(p.takeFontRequest() == null); + try std.testing.expect(p.executeBuiltinLine(p.active, "Font zzz-no-such-face:20")); + try std.testing.expectEqual(@as(u16, 1850), p.settings.font.requested_size_hundredths); + try std.testing.expect(p.takeFontRequest() == null); + try std.testing.expect(p.executeBuiltinLine(p.active, try std.fmt.bufPrint(&line, "Font {s}", .{installed[0].name}))); + try std.testing.expectEqual(@as(u16, 0), p.settings.font.requested_size_hundredths); + try std.testing.expectEqual(@as(u16, 1850), p.settings.font.effective_size_hundredths); +} + +test "Pet command is SDL-only optional and validates its argument" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 24 }); + defer p.deinit(); + try std.testing.expectEqual(.off, p.settings.pet); + if (comptime platform != .gui) { + try std.testing.expect(std.meta.stringToEnum(builtins.registry.Builtin(), "Pet") == null); + return; + } + try std.testing.expect(p.executeBuiltinLine(p.active, "Pet cat")); + try std.testing.expectEqual(.cat, p.settings.pet); + try std.testing.expect(p.executeBuiltinLine(p.active, "Pet dog")); + try std.testing.expectEqual(.cat, p.settings.pet); + try std.testing.expect(p.executeBuiltinLine(p.active, "Pet frog")); + try std.testing.expectEqual(.frog, p.settings.pet); + try std.testing.expect(p.executeBuiltinLine(p.active, "Pet off")); + try std.testing.expectEqual(.off, p.settings.pet); +} + test "startup config overrides restored styling before initFromDump returns" { const source = try Pardes.init(std.testing.allocator, .{ .startup_config = "Theme dark\n" }); defer source.deinit(); @@ -2589,9 +2811,39 @@ pub const Theme = struct { scroll_track: [3]u8, scroll_thumb: [3]u8, palette: ?[16][3]u8, + + // Pardes UI roles. Optional so existing ThemeFile documents and imported + // editor palettes keep working; ChromeTheme resolves their legacy defaults. + tag_active_bg: ?[3]u8 = null, + tag_active_fg: ?[3]u8 = null, + border: ?[3]u8 = null, + search_bg: ?[3]u8 = null, + search_fg: ?[3]u8 = null, + diagnostic_error: ?[3]u8 = null, + diagnostic_warning: ?[3]u8 = null, + diagnostic_info: ?[3]u8 = null, + diagnostic_hint: ?[3]u8 = null, + // The current line number is a quiet foreground cue, not a tag-colored + // background block. Older themes fall back to their normal gutter ink. + lineno_active: ?[3]u8 = null, }; const curated = struct { + pub const orchard = @import("themes/orchard.zig"); + pub const dusk = @import("themes/dusk.zig"); + pub const ink = @import("themes/ink.zig"); + pub const paper = @import("themes/paper.zig"); + pub const daybreak = @import("themes/daybreak.zig"); + pub const atelier = @import("themes/atelier.zig"); + pub const forge = @import("themes/forge.zig"); + pub const lagoon = @import("themes/lagoon.zig"); + pub const solarium = @import("themes/solarium.zig"); + pub const spectrum = @import("themes/spectrum.zig"); + pub const harvest = @import("themes/harvest.zig"); + pub const clay = @import("themes/clay.zig"); + pub const forge_black = @import("themes/forge_black.zig"); + pub const forge_soft = @import("themes/forge_soft.zig"); + pub const orchard_black = @import("themes/orchard_black.zig"); pub const helix = @import("themes/helix.zig"); pub const dark = @import("themes/dark.zig"); pub const acme = @import("themes/acme.zig"); @@ -2603,8 +2855,15 @@ fn fold(comptime C: type) [@typeInfo(C).@"struct".decls.len]Theme { var out: [@typeInfo(C).@"struct".decls.len]Theme = undefined; for (@typeInfo(C).@"struct".decls, 0..) |d, i| { var t: Theme = undefined; - for (@typeInfo(Theme).@"struct".fields) |f| - @field(t, f.name) = @field(@field(C, d.name).theme, f.name); + const source = @field(C, d.name).theme; + for (@typeInfo(Theme).@"struct".fields) |f| { + @field(t, f.name) = if (@hasField(@TypeOf(source), f.name)) + @field(source, f.name) + else if (f.default_value_ptr) |ptr| + @as(*const f.type, @ptrCast(@alignCast(ptr))).* + else + @compileError("theme " ++ d.name ++ " is missing " ++ f.name); + } out[i] = t; } return out; @@ -2613,6 +2872,134 @@ fn fold(comptime C: type) [@typeInfo(C).@"struct".decls.len]Theme { pub const themes = fold(curated) ++ fold(@import("generated_themes")); +/// Original and classic-inspired Pardes palettes precede retained imports. +pub const native_theme_count = 15; + +fn themeContrast(a: [3]u8, b: [3]u8) f64 { + var luminance: [2]f64 = .{ 0, 0 }; + for ([2][3]u8{ a, b }, 0..) |rgb, i| { + for (rgb, [3]f64{ 0.2126, 0.7152, 0.0722 }) |channel, weight| { + const srgb = @as(f64, @floatFromInt(channel)) / 255; + const linear = if (srgb <= 0.04045) srgb / 12.92 else std.math.pow(f64, (srgb + 0.055) / 1.055, 2.4); + luminance[i] += weight * linear; + } + } + return (@max(luminance[0], luminance[1]) + 0.05) / (@min(luminance[0], luminance[1]) + 0.05); +} + +test "native themes keep text and UI roles readable on their own surfaces" { + const names = [_][]const u8{ "orchard", "dusk", "ink", "paper", "daybreak", "atelier", "forge", "lagoon", "solarium", "spectrum", "harvest", "clay", "forge_black", "forge_soft", "orchard_black" }; + try std.testing.expectEqual(names.len, native_theme_count); + for (themes[0..names.len], names) |th, name| { + try std.testing.expectEqualStrings(name, th.name); + const chrome = ChromeTheme.fromTheme(&th); + const minimum: f64 = if (std.mem.eql(u8, name, "ink") or std.mem.eql(u8, name, "daybreak")) 7 else 4.5; + const text_colors = [_][3]u8{ + th.fg.?, th.kw, th.str, th.num, th.comment, + chrome.diagnostic_error, chrome.diagnostic_warning, chrome.diagnostic_info, chrome.diagnostic_hint, + }; + for (text_colors) |fg| try std.testing.expect(themeContrast(fg, th.bg.?) >= minimum); + // Gutters are orientation aids, not source text: stay visible without + // competing with comments or pulling attention away from the body. + try std.testing.expect(themeContrast(th.lineno, th.bg.?) >= 2.5); + try std.testing.expect(themeContrast(th.lineno, th.bg.?) <= 4); + try std.testing.expect(themeContrast(th.lineno_active.?, th.bg.?) >= themeContrast(th.lineno, th.bg.?)); + try std.testing.expect(themeContrast(th.lineno_active.?, th.bg.?) <= 4.5); + try std.testing.expect(themeContrast(th.comment, th.bg.?) >= 5); + try std.testing.expect(themeContrast(th.comment, th.bg.?) >= themeContrast(th.lineno, th.bg.?) + 2); + try std.testing.expect(themeContrast(th.tag_fg, th.tag_bg) >= minimum); + try std.testing.expect(themeContrast(chrome.tag_active_fg, chrome.tag_active_bg) >= minimum); + // Focus stays on the same tonal surface, rather than inverting a + // whole command strip. Text remains readable in both states. + try std.testing.expect(themeContrast(chrome.tag_active_bg, th.tag_bg) <= 1.6); + try std.testing.expect(themeContrast(chrome.tag_active_fg, th.tag_fg) <= 1.6); + try std.testing.expect(themeContrast(th.sel_fg, th.sel_bg) >= minimum); + try std.testing.expect(themeContrast(chrome.search_fg, chrome.search_bg) >= minimum); + // ANSI black can intentionally match the page (for backgrounds). All + // other ANSI foregrounds, including bright black, remain legible. + for (th.palette.?[1..]) |fg| try std.testing.expect(themeContrast(fg, th.bg.?) >= minimum); + } +} + +test "native dark palettes choose near-black or deliberately soft contrast" { + for (themes[0..native_theme_count]) |th| { + if (themeContrast(th.bg.?, .{ 0, 0, 0 }) > 10) continue; // light paper + const soft = std.mem.eql(u8, th.name, "dusk") or std.mem.eql(u8, th.name, "lagoon") or + std.mem.eql(u8, th.name, "solarium") or std.mem.eql(u8, th.name, "forge_soft"); + if (soft) { + try std.testing.expect(themeContrast(th.fg.?, th.bg.?) >= 4.5); + try std.testing.expect(themeContrast(th.fg.?, th.bg.?) <= 7); + } else { + for (th.bg.?) |channel| try std.testing.expect(channel <= 0x14); + try std.testing.expect(themeContrast(th.fg.?, th.bg.?) >= 14); + } + } + const forge = curated.forge.theme; + try std.testing.expectEqual(forge.bg[0], forge.bg[1]); + // Forge chrome is cool-neutral, never the old green-over-blue mint cast. + for ([_][3]u8{ forge.tag_bg, forge.tag_active_bg, forge.box, forge.border }) |rgb| + try std.testing.expect(rgb[2] >= rgb[1]); + try std.testing.expectEqual([3]u8{ 0, 0, 0 }, @as([3]u8, curated.forge_black.theme.bg)); + try std.testing.expectEqual([3]u8{ 0, 0, 0 }, @as([3]u8, curated.orchard_black.theme.bg)); +} + +test "line numbers stay quiet without a current-line tag background" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + const pane = try p.setTestFile("first\nsecond\n"); + pane.cur_pinned = true; + pane.cur_row = 0; + var custom = themes[0]; + custom.name = try gpa.dupe(u8, "quiet-gutter"); + custom.lineno = .{ 70, 70, 70 }; + custom.lineno_active = .{ 95, 95, 95 }; + p.custom_theme = custom; + p.sync(); + const x = p.rects[0].x + config.GUTTER; + const y = p.rects[0].y + BOX_H; + for ([_]bool{ false, true }) |focus_tint| { + p.settings.focus_tint = focus_tint; + const surface = try p.render(p.scratch.allocator()); + try std.testing.expectEqual(Color{ .rgb = .{ 95, 95, 95 } }, surface.at(x, y).style.fg); + try std.testing.expectEqual(Color{ .rgb = .{ 70, 70, 70 } }, surface.at(x, y + 1).style.fg); + try std.testing.expectEqual(Color{ .rgb = custom.bg.? }, surface.at(x, y).style.bg); + try std.testing.expect(surface.at(x, y).style.bold); + try std.testing.expect(!surface.at(x, y + 1).style.bold); + } + // Old ThemeFile palettes need no new field, and do not regain the block. + p.custom_theme.?.lineno_active = null; + const fallback = try p.render(p.scratch.allocator()); + try std.testing.expectEqual(Color{ .rgb = .{ 70, 70, 70 } }, fallback.at(x, y).style.fg); + try std.testing.expectEqual(Color{ .rgb = custom.bg.? }, fallback.at(x, y).style.bg); + try std.testing.expect(fallback.at(x, y).style.bold); + // Moving the caret clears the previous cue; editing the tag parks it. + pane.cur_row = 1; + const moved = try p.render(p.scratch.allocator()); + try std.testing.expect(!moved.at(x, y).style.bold); + try std.testing.expect(moved.at(x, y + 1).style.bold); + p.enterTagEdit(pane, -1); + const tag_edit = try p.render(p.scratch.allocator()); + try std.testing.expect(!tag_edit.at(x, y + 1).style.bold); +} + +test "legacy ThemeFile documents inherit Pardes UI roles without new fields" { + @setEvalBranchQuota(10000); + var encoded: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer encoded.deinit(); + try std.zon.stringify.serialize(curated.helix.theme, .{ .whitespace = true }, &encoded.writer); + const source = try std.testing.allocator.dupeZ(u8, encoded.written()); + defer std.testing.allocator.free(source); + const parsed = try std.zon.parse.fromSliceAlloc(Theme, std.testing.allocator, source, null, .{}); + defer std.zon.parse.free(std.testing.allocator, parsed); + try std.testing.expectEqual(@as(?[3]u8, null), parsed.tag_active_bg); + try std.testing.expectEqual(@as(?[3]u8, null), parsed.search_bg); + const chrome = ChromeTheme.fromTheme(&parsed); + try std.testing.expectEqual(parsed.tag_bg, chrome.tag_active_bg); + try std.testing.expectEqual(parsed.sel_bg, chrome.search_bg); + try std.testing.expectEqual(parsed.fg.?, chrome.diagnostic_error); +} + comptime { if (themes.len > std.math.maxInt(u16) + 1) @compileError("runtime config theme index no longer fits u16"); @@ -2667,6 +3054,15 @@ test "every line of the board's boot buffer renders whole" { pub const ChromeTheme = struct { tag_bg: [3]u8, tag_fg: [3]u8, + tag_active_bg: [3]u8, + tag_active_fg: [3]u8, + border: [3]u8, + search_bg: [3]u8, + search_fg: [3]u8, + diagnostic_error: [3]u8, + diagnostic_warning: [3]u8, + diagnostic_info: [3]u8, + diagnostic_hint: [3]u8, box: [3]u8, box_dim: [3]u8, lineno: [3]u8, @@ -2677,6 +3073,15 @@ pub const ChromeTheme = struct { return .{ .tag_bg = th.tag_bg, .tag_fg = th.tag_fg, + .tag_active_bg = th.tag_active_bg orelse th.tag_bg, + .tag_active_fg = th.tag_active_fg orelse th.tag_fg, + .border = th.border orelse th.scroll_track, + .search_bg = th.search_bg orelse th.sel_bg, + .search_fg = th.search_fg orelse th.sel_fg, + .diagnostic_error = th.diagnostic_error orelse (th.fg orelse th.tag_fg), + .diagnostic_warning = th.diagnostic_warning orelse (th.fg orelse th.tag_fg), + .diagnostic_info = th.diagnostic_info orelse (th.fg orelse th.tag_fg), + .diagnostic_hint = th.diagnostic_hint orelse (th.fg orelse th.tag_fg), .box = th.box, .box_dim = th.box_dim, .lineno = th.lineno, @@ -3459,6 +3864,8 @@ test "tabbed file aligns syntax cursor and mouse while preserving virtual column const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 60, .rows = 12 }); defer p.deinit(); + // Bold is an explicit preference; use it to expose the glyph boundaries. + try std.testing.expect(p.executeBuiltinLine(0, "SyntaxBold")); const pane = try p.setTestFile("\treturn x\n"); const f = &pane.file.?; f.highlights = try p.tree_sitter_gpa.alloc(u8, f.content.len); @@ -3685,6 +4092,207 @@ test "selection drag and queued release do not enter panel pending state" { try std.testing.expectEqual(.done, pane.sel[sel_slot].state); } +test "editable workspace and column tags replace selections and persist" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 20, .tty_only = true }); + defer p.deinit(); + _ = try p.setTestFile("untouched\n"); + p.acknowledgePanelPresentation(&.{}); + try std.testing.expectEqual(TOPBAR_H + COLBAR_H, p.rects[0].y); + p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = 0, .row = 0 } }); + p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = 6, .row = 0 } }); + p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = 6, .row = 0 } }); + p.update(.{ .key = .{ .cp = 'H', .text = "Help" } }); + try std.testing.expect(std.mem.startsWith(u8, p.global_tag.slice(""), "Help Joincol")); + p.update(.{ .key = .{ .cp = Key.escape } }); + p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = 0, .row = TOPBAR_H } }); + p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = 0, .row = TOPBAR_H } }); + p.update(.{ .key = .{ .cp = 'é', .text = "é" } }); + try std.testing.expect(std.mem.startsWith(u8, p.columnTag(0), "éNew")); + p.update(.{ .key = .{ .cp = Key.backspace } }); + try std.testing.expect(std.mem.startsWith(u8, p.columnTag(0), "New")); + p.update(.{ .paste = "Grep " }); + try std.testing.expect(std.mem.startsWith(u8, p.columnTag(0), "Grep New")); + try std.testing.expectEqualStrings("untouched\n", p.panes[0].?.file.?.content); + try p.dumpState(); + const restored = try p.restore(p.dump_out.?); + defer restored.deinit(); + try std.testing.expectEqualStrings(p.global_tag.slice(""), restored.global_tag.slice("")); + try std.testing.expectEqualStrings(p.columnTag(0), restored.columnTag(0)); + try std.testing.expect(p.col_tags[0].set("")); + try p.dumpState(); + const empty = try p.restore(p.dump_out.?); + defer empty.deinit(); + try std.testing.expectEqualStrings("", empty.columnTag(0)); +} + +test "column tags follow their column through split and close" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20, .tty_only = true }); + defer p.deinit(); + _ = try p.setTestFile("left\n"); + try std.testing.expect(p.col_tags[0].set("Left Find")); + _ = p.execute(0, "Newcol"); + try std.testing.expectEqual(@as(usize, 2), p.ncol); + try std.testing.expectEqualStrings("Left Find", p.columnTag(0)); + try std.testing.expect(p.col_tags[1].set("Right Grep")); + p.active = p.col_panes[0][0]; + layout.joinCol(p); + try std.testing.expectEqual(@as(usize, 1), p.ncol); + try std.testing.expectEqualStrings("Right Grep", p.columnTag(0)); +} + +test "column tags render edit hover and scroll from the column edge" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 20, .tty_only = true }); + defer p.deinit(); + _ = try p.setTestFile("untouched\n"); + p.newScratchColumn(0); + p.sync(); + p.acknowledgePanelPresentation(&.{}); + var frame = std.heap.ArenaAllocator.init(std.testing.allocator); + defer frame.deinit(); + for (0..p.ncol) |column| { + const x = p.col_x[column]; + const width = p.col_w[column]; + const initial = try p.render(frame.allocator()); + for ("New", 0..) |byte, offset| + try std.testing.expectEqual(byte, initial.at(x + @as(u16, @intCast(offset)), TOPBAR_H).text[0]); + p.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = x, .row = TOPBAR_H } }); + const hovered = try p.render(frame.allocator()); + try std.testing.expectEqual(Color{ .rgb = p.theme().sel_bg }, hovered.at(x, TOPBAR_H).style.bg); + p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = x, .row = TOPBAR_H } }); + try std.testing.expectEqual(column, p.header_column.?); + try std.testing.expectEqual(@as(u16, 0), p.topbar_col.?); + const at_start = try p.render(frame.allocator()); + try std.testing.expectEqual(x, at_start.cursor.?.x); + p.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = x + 3, .row = TOPBAR_H } }); + p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = x + 3, .row = TOPBAR_H } }); + const selected = try p.render(frame.allocator()); + for (0..3) |offset| + try std.testing.expectEqual(Color{ .rgb = p.theme().sel_bg }, selected.at(x + @as(u16, @intCast(offset)), TOPBAR_H).style.bg); + p.update(.{ .key = .{ .cp = 'H', .text = "Help" } }); + try std.testing.expect(std.mem.startsWith(u8, p.columnTag(column), "Help Tty")); + const edited = try p.render(frame.allocator()); + try std.testing.expectEqual(x + 4, edited.cursor.?.x); + // The two reclaimed cells belong to text and caret reveal, too. + const long: [80]u8 = @splat('a'); + try std.testing.expect(p.col_tags[column].set(long[0 .. width - 1])); + p.update(.{ .key = .{ .cp = Key.end } }); + const at_end = try p.render(frame.allocator()); + try std.testing.expectEqual(@as(usize, 0), p.header_scroll); + try std.testing.expectEqual(x + width - 1, at_end.cursor.?.x); + p.update(.{ .key = .{ .cp = 'b', .text = "b" } }); + const revealed = try p.render(frame.allocator()); + try std.testing.expectEqual(@as(usize, 1), p.header_scroll); + try std.testing.expectEqual(x + width - 1, revealed.cursor.?.x); + p.update(.{ .key = .{ .cp = Key.home } }); + const home = try p.render(frame.allocator()); + try std.testing.expectEqual(@as(usize, 0), p.header_scroll); + try std.testing.expectEqual(x, home.cursor.?.x); + p.update(.{ .key = .{ .cp = Key.escape } }); + p.update(.pointer_leave); + _ = frame.reset(.retain_capacity); + } + try std.testing.expectEqualStrings("untouched\n", p.panes[0].?.file.?.content); +} + +test "column tag first-cell execution keeps New local to the clicked column" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 20, .tty_only = true }); + defer p.deinit(); + _ = try p.setTestFile("untouched\n"); + p.newScratchColumn(0); + p.sync(); + for (0..2) |column| { + p.active = p.col_panes[1 - column][0]; + p.acknowledgePanelPresentation(&.{}); + const before = p.col_n; + p.update(.{ .mouse = .{ .button = .middle, .kind = .press, .col = p.col_x[column], .row = TOPBAR_H } }); + p.update(.{ .mouse = .{ .button = .middle, .kind = .release, .col = p.col_x[column], .row = TOPBAR_H } }); + try std.testing.expectEqual(before[column] + 1, p.col_n[column]); + try std.testing.expectEqual(before[1 - column], p.col_n[1 - column]); + try std.testing.expectEqual(column, layout.findPane(p, p.active).?.col); + } + try std.testing.expectEqualStrings("untouched\n", p.panes[0].?.file.?.content); +} + +test "header clicks beyond text append and long UTF-8 tags reveal their caret" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 50, .rows = 12, .tty_only = true }); + defer p.deinit(); + _ = try p.setTestFile("body\n"); + try std.testing.expect(p.col_tags[0].set("New")); + p.acknowledgePanelPresentation(&.{}); + p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = 40, .row = TOPBAR_H } }); + p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = 40, .row = TOPBAR_H } }); + p.update(.{ .key = .{ .cp = 'x', .text = " extended" } }); + try std.testing.expectEqualStrings("New extended", p.columnTag(0)); + p.update(.{ .paste = " 界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界" }); + const frame = try p.render(p.scratch.allocator()); + try std.testing.expect(p.header_scroll > 0); + try std.testing.expect(frame.cursor != null); + try std.testing.expectEqual(TOPBAR_H, frame.cursor.?.y); + try std.testing.expect(frame.cursor.?.x < p.screen_w); + p.update(.{ .key = .{ .cp = Key.home } }); + _ = try p.render(p.scratch.allocator()); + try std.testing.expectEqual(@as(usize, 0), p.header_scroll); +} + +test "tiny screens keep pane space before adding a column tag" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 8, .rows = 2, .tty_only = true }); + defer p.deinit(); + try std.testing.expectEqual(@as(u16, 0), p.columnBarHeight()); + try std.testing.expectEqual(TOPBAR_H, p.rects[0].y); + _ = try p.render(p.scratch.allocator()); +} + +test "ColumnTags reclaims the row and releases hidden header focus without losing commands" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 60, .rows = 12, .tty_only = true }); + defer p.deinit(); + const pane = try p.setTestFile("unchanged body\n"); + try std.testing.expect(p.col_tags[0].set("New Help")); + p.acknowledgePanelPresentation(&.{}); + const shown = p.rects[0]; + try std.testing.expectEqual(TOPBAR_H + COLBAR_H, shown.y); + p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = config.GUTTER, .row = TOPBAR_H } }); + p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = config.GUTTER, .row = TOPBAR_H } }); + try std.testing.expect(p.header_column != null and p.topbar_col != null); + + p.update(.{ .command = "ColumnTags" }); + try std.testing.expect(!p.settings.column_tags); + try std.testing.expectEqual(@as(u16, 0), p.columnBarHeight()); + try std.testing.expectEqual(TOPBAR_H, p.rects[0].y); + try std.testing.expectEqual(shown.h + COLBAR_H, p.rects[0].h); + try std.testing.expect(p.header_column == null and p.topbar_col == null); + try std.testing.expect(p.header_anchor == null and !p.header_drag); + try std.testing.expectEqualStrings("New Help", p.columnTag(0)); + + p.update(.{ .command = "ColumnTags" }); + try std.testing.expect(p.settings.column_tags); + try std.testing.expectEqual(shown, p.rects[0]); + p.acknowledgePanelPresentation(&.{}); + p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = config.GUTTER, .row = TOPBAR_H } }); + p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = config.GUTTER, .row = TOPBAR_H } }); + try std.testing.expect(p.header_column != null); + p.update(.{ .resize = .{ .cols = 60, .rows = 2 } }); + try std.testing.expect(p.settings.column_tags); + try std.testing.expectEqual(@as(u16, 0), p.columnBarHeight()); + try std.testing.expectEqual(TOPBAR_H, p.rects[0].y); + try std.testing.expect(p.header_column == null and p.topbar_col == null); + p.update(.{ .resize = .{ .cols = 60, .rows = 12 } }); + try std.testing.expectEqual(shown, p.rects[0]); + try std.testing.expectEqualStrings("New Help", p.columnTag(0)); + try std.testing.expectEqualStrings("unchanged body\n", pane.file.?.content); +} + +test "normal header Enter executes its command rather than searching for it" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 12, .tty_only = true }); + defer p.deinit(); + _ = try p.setTestFile("body\n"); + p.topbar_col = @intCast(std.mem.indexOf(u8, config.topbar_str, "Dump").?); + p.header_insert = false; + p.update(.{ .key = .{ .cp = Key.enter } }); + try std.testing.expect(p.topbar_col == null); + try std.testing.expect(p.dump_out != null); + try std.testing.expectEqual(@as(usize, 1), p.col_n[0]); +} + 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 }; @@ -5041,6 +5649,8 @@ pub const Pardes = struct { col_weight: [MAX_COLS]u64 = @splat(column_weight_unit), col_panes: [MAX_COLS][MAX_PANES]usize = undefined, col_n: [MAX_COLS]usize = @splat(0), + col_tags: [MAX_COLS]TagLine = @splat(.{}), + global_tag: TagLine = .{}, // derived each sync rects: [MAX_PANES]Rect = @splat(.{}), col_x: [MAX_COLS]u16 = undefined, @@ -5087,6 +5697,11 @@ pub const Pardes = struct { leader_keys: [4]u8 = undefined, leader_n: u8 = 0, topbar_col: ?u16 = null, + header_column: ?usize = null, + header_insert: bool = false, + header_anchor: ?u16 = null, + header_drag: bool = false, + header_scroll: usize = 0, ov_pinch_scale: f32 = 1.0, ov_touch_scroll_delta: f32 = 0.0, /// middle+left chord argument: a kept left selection captured by a left @@ -5329,11 +5944,57 @@ pub const Pardes = struct { /// the topbar line: the fixed builtins, plus `Restore <path>` once a dump /// exists — render and click dispatch must agree on this exact string fn topbar(p: *Pardes, buf: []u8) []const u8 { + if (p.global_tag.custom) return p.global_tag.slice(""); if (p.last_dump) |d| return std.fmt.bufPrint(buf, config.topbar_str ++ " Restore {s}", .{d}) catch config.topbar_str; return config.topbar_str; } + pub fn columnBarHeight(p: *const Pardes) u16 { + return if (p.settings.column_tags and p.screen_h >= 3) COLBAR_H else 0; + } + + pub fn columnTag(p: *const Pardes, c: usize) []const u8 { + return p.col_tags[c].slice("New Tty Find Grep Joincol"); + } + + fn headerText(p: *Pardes) []const u8 { + if (p.header_column) |c| return if (c < p.ncol) p.columnTag(c) else ""; + var buf: [1200]u8 = undefined; + return p.scratch.allocator().dupe(u8, p.topbar(&buf)) catch ""; + } + + fn revealHeader(p: *Pardes) void { + const cur = p.topbar_col orelse { + p.header_scroll = 0; + return; + }; + const text = p.headerText(); + const width = if (p.header_column) |c| p.col_w[c] else p.screen_w; + const col = panes.File.rawDisplayCol(text, @min(cur, text.len)); + if (col < p.header_scroll) p.header_scroll = col; + if (col >= p.header_scroll + width) p.header_scroll = col -| (width -| 1); + var raw = panes.File.rawAtDisplay(text, p.header_scroll); + p.header_scroll = panes.File.rawDisplayCol(text, raw); + if (width > 0 and col >= p.header_scroll + width) { + raw = modal.nextGrapheme(text, raw); + p.header_scroll = panes.File.rawDisplayCol(text, raw); + } + } + + fn headerReplace(p: *Pardes, bytes: []const u8) void { + const cur = p.topbar_col orelse return; + const anchor = p.header_anchor orelse cur; + const lo = @min(cur, anchor); + const hi = @max(cur, anchor); + const fallback = p.headerText(); + const line = if (p.header_column) |c| &p.col_tags[c] else &p.global_tag; + if (line.replace(fallback, lo, hi, bytes)) { + p.topbar_col = @intCast(lo + bytes.len); + p.header_anchor = null; + } + } + fn detachCwds(p: *Pardes, parents: []const *Pane) !void { var copies: [MAX_PANES]?[]u8 = @splat(null); errdefer for (copies) |copy| if (copy) |bytes| p.gpa.free(bytes); @@ -5673,6 +6334,8 @@ pub const Pardes = struct { } pub fn loadThemeFile(p: *Pardes, generation: u32, bytes: []const u8, animate: bool) bool { + // ZON's generated field dispatch grows with the complete theme schema. + @setEvalBranchQuota(10000); const request = p.themeFileRequest(generation) orelse return false; if (bytes.len > 1024 * 1024) { p.reportError(request.pane, "theme file", error.ThemeFileTooLarge); @@ -6243,6 +6906,11 @@ pub const Pardes = struct { } fn applyPaste(p: *Pardes, bytes: []const u8) void { + if (p.topbar_col != null) { + p.clip_pending = null; + p.headerReplace(bytes); + return; + } const req = p.clip_pending; p.clip_pending = null; if (bytes.len == 0) return; @@ -6250,6 +6918,14 @@ pub const Pardes = struct { const pane = p.panes[id] orelse return; if (req) |r| if (pane.serial != r.serial) return; p.active = id; + if (pane.tag_edit) { + const text = p.tagText(p.scratch.allocator(), pane) catch return; + const range: @TypeOf(tagSelBounds(pane)) = if (pane.tag_sel) tagSelBounds(pane) else .{ .lo = pane.tag_col, .hi = pane.tag_col }; + const hi = if (pane.tag_sel) modal.nextGrapheme(text, range.hi) else range.hi; + _ = p.replaceTagText(pane, range.lo, hi, bytes); + if (selRegexArmed(pane)) |a| p.applySelRegex(pane, a.pat, a.split); + return; + } if (pane.isTerminal() and pane.mode == .tty) return p.typeToTty(id, pane, bytes); switch (if (req) |r| r.mode else .after) { .after => p.pasteText(pane, bytes, false, 1), @@ -6286,14 +6962,15 @@ pub const Pardes = struct { const arena = p.scratch.allocator(); if (comptime pdf_enabled) if (pane.pdf) |pv| return std.fmt.allocPrint( arena, - "pdf {d}/{d} {s} PdfFit {s} PdfTint PdfSections {s}", - .{ pv.page + 1, pv.page_count, @tagName(pv.fit), @tagName(pv.tint), pv.path }, + "{s} [{d}/{d}]", + .{ pv.path, pv.page + 1, pv.page_count }, ); if (pane.image) |*state| return panes.Image.tagPrefix(arena, state); if (pane.file) |f| { + const name = if (pane.tag_name_len) |len| pane.tag_name[0..len] else f.path; if (panes.Output.fileTraits(f.output).saves and f.revision != f.saved_revision) - return std.fmt.allocPrint(arena, "{s}{s}", .{ f.path, dirty_marker }); - return arena.dupe(u8, f.path); + return std.fmt.allocPrint(arena, "{s}{s}", .{ name, dirty_marker }); + return arena.dupe(u8, name); } return arena.dupe(u8, pane.cwdSlice()); } @@ -6305,6 +6982,7 @@ pub const Pardes = struct { } fn defaultTail(pane: *const Pane) []const u8 { + if (comptime pdf_enabled) if (pane.pdf != null) return pdf_pane_tail; if (pane.file != null) return file_pane_tail; if (pane.isTerminal()) return terminal_pane_tail; return pane_tail; @@ -6317,45 +6995,21 @@ pub const Pardes = struct { return null; } - fn tagGap(p: *Pardes, pane: *const Pane, used: usize) usize { - if (pane.tag_init) return 0; - const id = p.paneIdOf(pane) orelse return 0; - const r = p.rects[id]; - if (r.w <= config.GUTTER) return 0; - const tw: usize = r.w - config.GUTTER; - var end: usize = tw -| config.tag_right_pad; - for (p.panes, 0..) |slot, qid| if (slot) |q| { - const qr = p.rects[qid]; - if (qr.h == 0 or qr.x != r.x or qr.w != r.w) continue; - const words = defaultTail(q); - const laid = if (q.tag_init) q.tagSlice() else words; - const lead = laid.len - std.mem.trimStart(u8, laid, " ").len; - const q_end = panes.File.displayWidth(p.tagPrefix(q) catch continue) + lead + panes.File.displayWidth(std.mem.trimStart(u8, words, " ")); - end = @max(end, @min(q_end, tw)); - }; - return end -| used; - } - pub fn tagText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]u8 { const prefix = try p.tagPrefix(pane); const tail = curTail(pane); - const gap = p.tagGap(pane, panes.File.displayWidth(prefix) + panes.File.displayWidth(tail)); - const out = try arena.alloc(u8, prefix.len + gap + tail.len); + const out = try arena.alloc(u8, prefix.len + tail.len); @memcpy(out[0..prefix.len], prefix); - @memset(out[prefix.len..][0..gap], ' '); - @memcpy(out[prefix.len + gap ..], tail); + @memcpy(out[prefix.len..], tail); return out; } - pub fn seedTail(p: *Pardes, pane: *Pane) void { + pub fn seedTail(_: *Pardes, pane: *Pane) void { if (pane.tag_init) return; const tail = curTail(pane); - const prefix = (p.tagPrefix(pane) catch return); - const gap = p.tagGap(pane, panes.File.displayWidth(prefix) + panes.File.displayWidth(tail)); - if (gap + tail.len > pane.tag_tail.len) return; - @memset(pane.tag_tail[0..gap], ' '); - @memcpy(pane.tag_tail[gap..][0..tail.len], tail); - pane.tag_tail_len = gap + tail.len; + if (tail.len > pane.tag_tail.len) return; + @memcpy(pane.tag_tail[0..tail.len], tail); + pane.tag_tail_len = tail.len; pane.tag_init = true; } @@ -6374,9 +7028,16 @@ pub const Pardes = struct { const lead: i32 = @intCast(tail.len - std.mem.trimStart(u8, tail, " ").len); if (col < 0) { pane.tag_col = @intCast(@min(edit0 + lead, end)); + const text = p.tagText(p.scratch.allocator(), pane) catch return; + if (p.paneIdOf(pane)) |id| { + const width = @max(1, p.rects[id].w -| config.GUTTER); + // Reveal the suffix together with its commands, not merely the + // first command letter at the viewport's far right edge. + pane.tag_scroll = @intCast(panes.File.displayWidth(text) + 1 -| width); + } } else { const text = p.tagText(p.scratch.allocator(), pane) catch return; - pane.tag_col = @intCast(@min(text.len, panes.File.rawAtDisplay(text, @intCast(col)))); + pane.tag_col = @intCast(@min(text.len, panes.File.rawAtDisplay(text, @as(usize, @intCast(col)) + pane.tag_scroll))); } } @@ -6386,6 +7047,8 @@ pub const Pardes = struct { } else pane.mode = .normal; pane.tag_edit = false; pane.tag_sel = false; + pane.tag_name_len = null; + pane.tag_scroll = 0; pane.prompt = .none; // an abandoned prompt stays as tag text pane.nsel_snap = 0; // ...and its s/S preview keeps whatever it previewed } @@ -6407,6 +7070,90 @@ pub const Pardes = struct { return if (b.hi > b.lo) text[b.lo..b.hi] else null; } + fn tagNameEnd(pane: *const Pane) ?usize { + const f = pane.file orelse return null; + if (!panes.Output.fileTraits(f.output).saves or f.path.len > pane.tag_name.len) return null; + return if (pane.tag_name_len) |len| len else f.path.len; + } + + /// Commit a staged buffer name, never a disk rename or write. Keep the + /// draft visible on failure so the user can correct it or cancel with Esc. + pub fn commitTagName(p: *Pardes, pane: *Pane) bool { + const len = pane.tag_name_len orelse return true; + const id = p.paneIdOf(pane) orelse return false; + const name = pane.tag_name[0..len]; + if (name.len == 0 or name[name.len - 1] == '/' or !std.unicode.utf8ValidateSlice(name)) { + p.reportError(id, "name", error.InvalidName); + return false; + } + for (name) |byte| if (byte < ' ') { + p.reportError(id, "name", error.InvalidName); + return false; + }; + const old_prefix = (p.tagPrefix(pane) catch return false).len; + const tail_col = @as(usize, pane.tag_col) -| old_prefix; + const full = std.fs.path.resolvePosix(p.scratch.allocator(), &.{ paneDir(pane), name }) catch |err| { + p.reportError(id, "name", err); + return false; + }; + filesystem.nameBuffer(p, id, full, true) catch |err| { + p.reportError(id, "name", err); + return false; + }; + pane.tag_name_len = null; + const prefix = (p.tagPrefix(pane) catch return false).len; + pane.tag_col = @intCast(prefix + @min(tail_col, pane.tag_tail_len)); + pane.tag_sel = false; + return true; + } + + /// Replace one range in the rendered tag. The file name and command tail + /// retain separate identities even when a selection crosses their boundary. + fn replaceTagText(p: *Pardes, pane: *Pane, start: usize, finish: usize, input: []const u8) bool { + const prefix_len = (p.tagPrefix(pane) catch return false).len; + const end = prefix_len + pane.tag_tail_len; + const lo = @min(start, end); + const hi = @max(lo, @min(finish, end)); + const name_end = tagNameEnd(pane); + const editing_name = if (name_end) |n| lo <= n else false; + if (!editing_name and lo < prefix_len) return false; + var bytes: [limits.max_tag_tail]u8 = undefined; + var count: usize = 0; + for (input) |byte| { + if (byte == '\n' or byte == '\r') continue; + if (count == bytes.len) return false; + bytes[count] = byte; + count += 1; + } + const tail_lo = lo -| prefix_len; + const tail_hi = hi -| prefix_len; + const tail_insert = if (editing_name) 0 else count; + const tail_len = pane.tag_tail_len - (tail_hi - tail_lo) + tail_insert; + if (tail_len > pane.tag_tail.len) return false; + if (editing_name) { + const n = name_end.?; + const name_hi = @min(hi, n); + const name_len = n - (name_hi - lo) + count; + if (name_len > pane.tag_name.len) return false; + if (pane.tag_name_len == null) @memcpy(pane.tag_name[0..n], pane.file.?.path); + if (count > name_hi - lo) + std.mem.copyBackwards(u8, pane.tag_name[lo + count .. name_len], pane.tag_name[name_hi..n]) + else + std.mem.copyForwards(u8, pane.tag_name[lo + count .. name_len], pane.tag_name[name_hi..n]); + @memcpy(pane.tag_name[lo..][0..count], bytes[0..count]); + pane.tag_name_len = @intCast(name_len); + } + if (tail_insert > tail_hi - tail_lo) + std.mem.copyBackwards(u8, pane.tag_tail[tail_lo + tail_insert .. tail_len], pane.tag_tail[tail_hi..pane.tag_tail_len]) + else + std.mem.copyForwards(u8, pane.tag_tail[tail_lo + tail_insert .. tail_len], pane.tag_tail[tail_hi..pane.tag_tail_len]); + if (!editing_name) @memcpy(pane.tag_tail[tail_lo..][0..count], bytes[0..count]); + pane.tag_tail_len = tail_len; + pane.tag_col = @intCast(lo + count); + pane.tag_sel = false; + return true; + } + fn tagInsertKey(p: *Pardes, pane: *Pane, key: Key) void { if (hit(key, config.escape)) { exitTagEdit(pane); // the tag is ALWAYS insert; Esc leaves it @@ -6414,44 +7161,47 @@ pub const Pardes = struct { } // the acme chords on the selection or the word at the cursor if (hit(key, config.look_key) or hit(key, config.exec_key)) { + if (pane.tag_name_len != null) { + const in_name = pane.tag_col < (p.tagPrefix(pane) catch return).len; + if (!p.commitTagName(pane)) return; + if (hit(key, config.look_key) or in_name) { + exitTagEdit(pane); + return; + } + } if (p.tagChordText(pane)) |txt| { pane.tag_sel = false; p.runBuiltin(if (hit(key, config.look_key)) config.look_cmd else config.exec_cmd, p.active, "", txt); } return; } - const edit0: u16 = @intCast((p.tagPrefix(pane) catch return).len); - const end: u16 = edit0 + @as(u16, @intCast(pane.tag_tail_len)); + const text = p.tagText(p.scratch.allocator(), pane) catch return; + const end = text.len; if (key.text.len > 0) { - if (pane.tag_col < edit0) return; // typing inside the path: inert - var insert_len: usize = 0; - for (key.text) |ch| insert_len += @intFromBool(ch != '\n' and ch != '\r'); - if (insert_len > pane.tag_tail.len - pane.tag_tail_len) return; - for (key.text) |ch| { - if (ch == '\n' or ch == '\r') continue; // one-line tag - if (!pane.insertTagByte(pane.tag_col - edit0, ch)) return; - pane.tag_col += 1; - } + const range: @TypeOf(tagSelBounds(pane)) = if (pane.tag_sel) tagSelBounds(pane) else .{ .lo = pane.tag_col, .hi = pane.tag_col }; + const hi = if (pane.tag_sel) modal.nextGrapheme(text, range.hi) else range.hi; + _ = p.replaceTagText(pane, range.lo, hi, key.text); return; } switch (key.cp) { - Key.backspace => if (pane.tag_col > edit0) { - const text = p.tagText(p.scratch.allocator(), pane) catch return; - const prev = @max(@as(usize, edit0), modal.prevGrapheme(text, pane.tag_col)); - const count = @as(usize, pane.tag_col) - prev; - for (0..count) |_| pane.removeTagByte(prev - edit0); - pane.tag_col = @intCast(prev); + Key.backspace, Key.delete => { + if (pane.tag_sel) { + const range = tagSelBounds(pane); + _ = p.replaceTagText(pane, range.lo, modal.nextGrapheme(text, range.hi), ""); + } else if (key.cp == Key.backspace and pane.tag_col > 0) { + _ = p.replaceTagText(pane, modal.prevGrapheme(text, pane.tag_col), pane.tag_col, ""); + } else if (key.cp == Key.delete and pane.tag_col < end) { + _ = p.replaceTagText(pane, pane.tag_col, modal.nextGrapheme(text, pane.tag_col), ""); + } }, Key.left => if (pane.tag_col > 0) { - const text = p.tagText(p.scratch.allocator(), pane) catch return; pane.tag_col = @intCast(modal.prevGrapheme(text, pane.tag_col)); }, Key.right => if (pane.tag_col < end) { - const text = p.tagText(p.scratch.allocator(), pane) catch return; pane.tag_col = @intCast(modal.nextGrapheme(text, pane.tag_col)); }, Key.home => pane.tag_col = 0, - Key.end => pane.tag_col = end, + Key.end => pane.tag_col = @intCast(end), else => {}, } } @@ -6486,7 +7236,7 @@ pub const Pardes = struct { if (hit(key, config.insert) or hit(key, config.append) or hit(key, config.insert_line_start) or hit(key, config.insert_line_end)) { - const edit0: usize = (p.tagPrefix(pane) catch return).len; + const edit0: usize = if (tagNameEnd(pane) != null) 0 else (p.tagPrefix(pane) catch return).len; pane.tag_col = @intCast(@max(edit0, if (hit(key, config.append)) modal.nextGrapheme(text, cur) else if (hit(key, config.insert_line_start)) @@ -6509,6 +7259,10 @@ pub const Pardes = struct { if (d == .Up) { exitTagEdit(pane); p.topbar_col = 0; + p.header_column = if (p.columnBarHeight() > 0) (layout.findPane(p, from) orelse return).col else null; + p.header_insert = false; + p.header_anchor = null; + p.header_drag = false; } return; } @@ -6554,25 +7308,83 @@ pub const Pardes = struct { } fn topbarKey(p: *Pardes, key: Key) void { - var tb_buf: [1200]u8 = undefined; - const bar = p.topbar(&tb_buf); + const bar = p.headerText(); // the line GROWS a `Restore <path>` word the moment a dump lands, so // the column is clamped against the live line on every use const cur: usize = @min(@as(usize, p.topbar_col orelse return), bar.len); if (hit(key, config.escape)) { p.topbar_col = null; // the active pane still has its body focus + p.header_column = null; + p.header_anchor = null; + p.header_drag = false; + p.header_scroll = 0; return; } if (hit(key, config.look_key) or hit(key, config.exec_key)) { - const word = wordAtCol(bar, cur); + const a = p.header_anchor orelse @as(u16, @intCast(cur)); + const word = if (a != cur) bar[@min(a, cur)..@max(a, cur)] else wordAtCol(bar, cur); p.topbar_col = null; - if (word.len > 0) _ = p.execute(p.active, word); + if (word.len > 0) { + if (!p.header_insert) { + // Preserve the workspace tag's keyboard command semantics. + _ = p.execute(p.active, word); + } else p.runBuiltin(if (hit(key, config.look_key)) config.look_cmd else config.exec_cmd, p.active, "", word); + } + return; + } + if (p.header_insert) { + if (key.ctrl and key.cp == 'v') { + p.emit(.read_clipboard); + return; + } + if (key.ctrl and (key.cp == 'c' or key.cp == 'x')) { + const anchor = p.header_anchor orelse @as(u16, @intCast(cur)); + if (anchor != cur) { + p.setClipboard(bar[@min(anchor, cur)..@max(anchor, cur)]); + if (key.cp == 'x') p.headerReplace(""); + } + return; + } + if (key.text.len > 0 and !key.ctrl and !key.alt) { + p.headerReplace(key.text); + return; + } + if (key.cp == Key.backspace or key.cp == Key.delete) { + if (p.header_anchor == null or p.header_anchor.? == cur) p.header_anchor = @intCast(if (key.cp == Key.backspace) modal.prevGrapheme(bar, cur) else modal.nextGrapheme(bar, cur)); + p.headerReplace(""); + return; + } + const next: ?usize = switch (key.cp) { + Key.left => modal.prevGrapheme(bar, cur), + Key.right => modal.nextGrapheme(bar, cur), + Key.home => 0, + Key.end => bar.len, + else => null, + }; + if (next) |at| { + if (key.shift) { + if (p.header_anchor == null) p.header_anchor = @intCast(cur); + } else p.header_anchor = null; + p.topbar_col = @intCast(at); + } + return; + } + if (hit(key, config.insert) or hit(key, config.append) or hit(key, config.insert_line_start) or hit(key, config.insert_line_end)) { + p.header_insert = true; + p.topbar_col = @intCast(if (hit(key, config.insert_line_end)) bar.len else if (hit(key, config.insert_line_start)) 0 else if (hit(key, config.append)) modal.nextGrapheme(bar, cur) else cur); return; } if (hit(key, config.topbar_down)) { + if (p.header_column == null and p.columnBarHeight() > 0) { + p.header_column = if (layout.findPane(p, p.active)) |f| f.col else 0; + p.header_scroll = 0; + p.topbar_col = 0; + return; + } var dest: ?usize = null; for (p.panes, 0..) |slot, i| { - if (slot == null or p.rects[i].y != TOPBAR_H) continue; + if (slot == null or p.rects[i].y != TOPBAR_H + p.columnBarHeight()) continue; + if (p.header_column) |c| if (p.rects[i].x != p.col_x[c]) continue; if (i == p.active) { dest = i; break; @@ -6582,11 +7394,18 @@ pub const Pardes = struct { const d = dest orelse return; const pane = p.panes[d].?; p.topbar_col = null; + p.header_column = null; p.active = d; p.enterTagEdit(pane, -1); if (pane.tag_edit) pane.mode = .normal; return; } + if (key.cp == 'k' or key.cp == Key.up) { + p.header_column = null; + p.header_anchor = null; + p.topbar_col = 0; + return; + } const col: ?usize = if (hit(key, config.topbar_left)) modal.prevGrapheme(bar, cur) else if (hit(key, config.topbar_right)) @@ -6861,6 +7680,19 @@ pub const Pardes = struct { return std.fmt.allocPrint(p.scratch.allocator(), "{s} {s}", .{ t, a }) catch txt; } + /// One mode transition for the terminal tag command and the Ctrl-B chord. + pub fn toggleTty(p: *Pardes, id: usize) void { + const pane = p.panes[id] orelse return; + if (!pane.isTerminal()) return; + p.active = id; + p.topbar_col = null; + if (pane.tag_edit) exitTagEdit(pane); + if (pane.mode == .tty) { + pane.mode = .normal; + pane.normal.clear(); + } else panes.Terminal.enterTty(p, id); + } + fn handleKey(p: *Pardes, key: Key) void { if (p.topbar_col != null) return p.topbarKey(key); const pane = p.panes[p.active] orelse return; @@ -6904,12 +7736,8 @@ pub const Pardes = struct { const tty_alt = hit(key, config.tty_toggle_alt); const tty_toggle = (key.ctrl and key.cp == p.opts.tty_toggle) or tty_alt; if (pane.isTerminal() and tty_toggle) { - if (pane.mode == .tty) { - if (tty_alt) return p.runBuiltin(.Last, p.active, "", null); - pane.mode = .normal; - pane.normal.clear(); - } else panes.Terminal.enterTty(p, p.active); - return; + if (pane.mode == .tty and tty_alt) return p.runBuiltin(.Last, p.active, "", null); + return p.toggleTty(p.active); } // A shell prompt is a pane you can leave: plain Esc there is Shift-Esc. if (pane.isTerminal() and pane.mode == .tty and hit(key, config.escape) and p.takesCommandLine(p.active)) @@ -8400,6 +9228,7 @@ pub const Pardes = struct { if (budget.* == 0) return null; budget.* -= 1; const ln = lines[@intCast(r)]; + if (pane.file) |f| if (!panes.Output.isWalkRow(f.output, ln)) continue; const on_start = r == start; switch (grain) { .word => { @@ -9640,7 +10469,7 @@ pub const Pardes = struct { pub const ChromeTarget = struct { col: u16, row: u16 }; pub fn chromeTarget(p: *const Pardes, col: u16, row: u16) ?ChromeTarget { - if (row < TOPBAR_H) return .{ .col = col, .row = row }; + if (row < TOPBAR_H + p.columnBarHeight()) return .{ .col = col, .row = row }; for (p.panes, 0..) |slot, id| { if (slot == null) continue; const rect = p.rects[id]; @@ -9703,7 +10532,8 @@ pub const Pardes = struct { const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y; const body_y = if (p.settings.tag_bottom) r.y else r.y + BOX_H; const on_tag = row >= tag_y and row < tag_y + BOX_H; - const c: i32 = @as(i32, col) - @as(i32, r.x + config.GUTTER); + const c: i32 = @as(i32, col) - @as(i32, r.x + config.GUTTER) + + (if (on_tag) @as(i32, p.panes[id].?.tag_scroll) else 0); const v: i32 = if (on_tag) 0 else @as(i32, row) - @as(i32, body_y) + @as(i32, BOX_H); return .{ .sel = .{ .state = .dragging, .c0 = c, .c1 = c, .r0 = v, .r1 = v }, @@ -9854,7 +10684,66 @@ pub const Pardes = struct { p.chord_arg = null; } } - if (m.kind == .press) p.topbar_col = null; + if (p.header_drag and m.button == config.select_button and (m.kind == .drag or m.kind == .release)) { + const x = if (p.header_column) |c| p.col_x[c] else 0; + const bar = p.headerText(); + p.topbar_col = @intCast(@min(bar.len, panes.File.rawAtDisplay(bar, (mcol -| x) + p.header_scroll))); + if (m.kind == .release) p.header_drag = false; + return; + } + if (mrow < TOPBAR_H + p.columnBarHeight() and m.kind == .press and + (m.button == config.select_button or m.button == config.exec_button or m.button == config.look_button)) + { + var column: ?usize = null; + var x: u16 = 0; + if (mrow >= TOPBAR_H) { + for (0..p.ncol) |c| if (mcol >= p.col_x[c] and mcol < p.col_x[c] + p.col_w[c]) { + column = c; + x = p.col_x[c]; + if (layout.findPane(p, p.active)) |active_col| { + if (active_col.col != c and p.col_n[c] > 0) p.active = p.col_panes[c][0]; + } + break; + }; + if (column == null) return; + } + if (p.header_column != column) { + p.topbar_col = null; + p.header_anchor = null; + p.header_scroll = 0; + } + p.header_column = column; + const bar = p.headerText(); + const at = @min(bar.len, panes.File.rawAtDisplay(bar, (mcol -| x) + p.header_scroll)); + if (m.button == config.select_button) { + if (p.panes[p.active]) |pane| exitTagEdit(pane); + p.topbar_col = @intCast(at); + p.header_anchor = @intCast(at); + p.header_insert = true; + p.header_drag = true; + } else { + const selected = if (p.topbar_col) |cur| blk: { + const a = p.header_anchor orelse cur; + break :blk if (at >= @min(a, cur) and at < @max(a, cur)) bar[@min(a, cur)..@max(a, cur)] else wordAtCol(bar, at); + } else wordAtCol(bar, at); + const word = p.scratch.allocator().dupe(u8, selected) catch return; + p.topbar_col = null; + if (word.len > 0) { + if (m.button == config.exec_button) { + const named = std.meta.stringToEnum(Builtin, word); + const held = if (named) |b| (if (builtins.registry.takesArg(b)) p.heldSelection(p.active) else null) else p.heldSelection(p.active); + _ = p.execute(p.active, p.withArg(word, held)); + } else p.runBuiltin(config.look_cmd, p.active, "", word); + } + } + return; + } + if (m.kind == .press) { + p.topbar_col = null; + p.header_column = null; + p.header_anchor = null; + p.header_drag = false; + } switch (m.button) { .none => {}, // hover tracked above; motion has no other meaning @@ -9910,7 +10799,6 @@ pub const Pardes = struct { p.drag = .none; return; } - if (mrow < TOPBAR_H) return; // topbar: a select click is deliberately inert if (hovered) |mid| { const mr = p.rects[mid]; const mtag = if (p.settings.tag_bottom) mr.y + mr.h -| BOX_H else mr.y; @@ -9996,22 +10884,6 @@ pub const Pardes = struct { p.drag = .none; return; } - if (mrow < TOPBAR_H) { - // global tagbar: EXECUTE runs the command under the - // click (a stray select click must never Kill) - if (m.button == config.exec_button) { - var tb_buf: [1200]u8 = undefined; - const bar = p.topbar(&tb_buf); - const word = wordAtCol(bar, panes.File.rawAtDisplay(bar, mcol)); - const named = std.meta.stringToEnum(Builtin, word); - const held = if (named) |b| - (if (builtins.registry.takesArg(b)) p.heldSelection(p.active) else null) - else - p.heldSelection(p.active); // not a builtin: an ordinary acme 2-1 - if (word.len > 0) _ = p.execute(p.active, p.withArg(word, held)); - } - return; - } const id = hovered orelse return; const r = p.rects[id]; const pane = p.panes[id] orelse return; @@ -10100,7 +10972,8 @@ pub const Pardes = struct { 0 else @as(i32, @min(@max(mrow, r.y), r.y + body_h - 1)) - @as(i32, r.y) + @as(i32, BOX_H); - } else pane.sel[b].r1 = @as(i32, mrow) - @as(i32, r.y); + } else pane.sel[b].r1 = if (pane.sel[b].r0 < BOX_H) 0 else @as(i32, mrow) - @as(i32, r.y); + if (pane.sel[b].r1 < BOX_H) pane.sel[b].c1 += pane.tag_scroll; if (comptime pdf_enabled) panes.Pdf.pointerUpdate(&s.pdf, p, pane, mcol, mrow); } @@ -10109,7 +10982,7 @@ pub const Pardes = struct { const pane = p.panes[d.id] orelse return; const c = @as(i32, mcol) - @as(i32, p.rects[d.id].x + config.GUTTER); const text = p.tagText(p.scratch.allocator(), pane) catch return; - pane.tag_col = @intCast(@min(text.len, panes.File.rawAtDisplay(text, @intCast(@max(0, c))))); + pane.tag_col = @intCast(@min(text.len, panes.File.rawAtDisplay(text, @as(usize, @intCast(@max(0, c))) + pane.tag_scroll))); pane.tag_sel = pane.tag_col != pane.tag_anchor; }, .none => {}, @@ -10281,7 +11154,7 @@ pub const Pardes = struct { } } else { const clk = pane.sel[b]; - const operand = p.pointerOperand(pane, clk); + var operand = p.pointerOperand(pane, clk); if (operand.expanded) |expanded| pane.sel[b] = expanded else if (operand.text == null) @@ -10292,6 +11165,26 @@ pub const Pardes = struct { } const txt = operand.text; const cmd = if (s.button == config.look_button) config.look_cmd else config.exec_cmd; + if (clk.r0 < BOX_H and s.button == config.exec_button and pane.tag_name_len != null) { + const prefix_width = panes.File.displayWidth(p.tagPrefix(pane) catch return); + const on_name = @as(usize, @intCast(@max(0, clk.c0))) < prefix_width; + if (!p.commitTagName(pane)) return; + // Executing a staged filename commits it; executing its + // command tail uses the new identity (especially Save). + if (on_name) return; + const new_width = panes.File.displayWidth(p.tagPrefix(pane) catch return); + const shift = @as(i32, @intCast(new_width)) - @as(i32, @intCast(prefix_width)); + if (operand.expanded) |*sel| { + sel.c0 += shift; + sel.c1 += shift; + } + if (operand.preview) |*sel| { + sel.c0 += shift; + sel.c1 += shift; + } + pane.sel[b].c0 += shift; + pane.sel[b].c1 += shift; + } p.dispatchPointerBuiltin(s.id, cmd, txt, .{ .on_tag = clk.r0 < BOX_H, .operand = operand, @@ -10391,7 +11284,7 @@ pub const Pardes = struct { } else p.pasteText(pane, p.yank orelse return, true, 1); } - /// Newtty: a shell in the caller's directory, raw from the first frame, + /// Tty: a shell in the caller's directory, raw from the first frame, /// stacked below the caller like Alt-n's new shell. pub fn spawnTty(p: *Pardes, from: usize) void { const src = p.panes[from] orelse return; @@ -10528,19 +11421,6 @@ pub const Pardes = struct { } } - fn stackDocLeft(p: *Pardes, free: usize, nt: *Pane) void { - const lc = 0; - if (p.col_n[lc] > 0) if (p.panes[p.col_panes[lc][0]]) |top| { - layout.snapColWeights(p, lc); - const h = p.rects[p.col_panes[lc][0]].h; - nt.vweight = @floatFromInt(@max(1, h / 2)); - top.vweight = @floatFromInt(@max(1, h -| h / 2)); - }; - layout.insert(p, lc, 0, free); - p.active = free; - p.evictLonePristineTty(lc, free); - } - // ---- the ONE dispatcher: look (right/Enter) and execute (middle/Tab) ---- pub fn focusPaneLine(p: *Pardes, id: usize, at: look.Spot, landing: enum { center, keep }) void { @@ -10766,6 +11646,7 @@ pub const Pardes = struct { const previous_colors = p.settings.colors; const previous_transition = p.settings.panel_transition; const previous_tagline_percent = p.settings.font.tagline_percent; + const previous_column_tags = p.settings.column_tags; switch (setting.action) { .theme => { const want = std.mem.trim(u8, arg orelse return, " \t\r\n"); @@ -10776,10 +11657,10 @@ pub const Pardes = struct { }, .font => { if (comptime !font_picker) return; - const want = std.mem.trim(u8, arg orelse return, " \t\r\n"); - const matches = fonts.list(p.scratch.allocator(), want); + const spec = config.Runtime.FontSpec.parse(arg orelse return) orelse return; + const matches = fonts.list(p.scratch.allocator(), spec.name); if (matches.len == 0) return; - if (!p.settings.requestFont(matches[0].path, matches[0].name)) return; + if (!p.settings.requestFont(matches[0].path, matches[0].name, spec.size_hundredths)) return; p.font_request_taken = false; }, else => _ = p.settings.apply(setting, arg), @@ -10794,7 +11675,16 @@ pub const Pardes = struct { p.settings.panel_transition != previous_transition; const tagline_metrics_changed = setting.action == .tagline_size and p.settings.font.tagline_percent != previous_tagline_percent; - if (transition_changed or tagline_metrics_changed) { + if (p.settings.column_tags != previous_column_tags) { + if (p.header_column != null) { + p.topbar_col = null; + p.header_column = null; + p.header_anchor = null; + p.header_drag = false; + p.header_scroll = 0; + } + } + if (transition_changed or tagline_metrics_changed or p.settings.column_tags != previous_column_tags) { p.abandonPanelAnimations(); } } @@ -10829,20 +11719,19 @@ pub const Pardes = struct { p.active = free; return; }; - if (src_id) |sid| { - const src = layout.splitParent(p, sid); - const sf = layout.findPane(p, src).?; - layout.insert(p, sf.col, sf.idx + 1, free); - layout.splitBelow(p, src, nt); - p.active = free; - return; - } - if (doc and p.ncol < MAX_COLS and layout.columnFitsHalves(p, from_id, 100)) { - if (!layout.splitColumn(p, from_id, free, true)) return p.stackDocLeft(free, nt); + if (src_id == null and doc and p.ncol < MAX_COLS and + layout.columnFitsHalves(p, from_id, 100) and layout.splitColumn(p, from_id, free, true)) + { p.active = free; return; } - p.stackDocLeft(free, nt); + // Without room for a first-document column, split the Look origin + // just like Tty. Never displace (or evict) a top-left bystander. + const src = layout.splitParent(p, src_id orelse from_id); + const sf = layout.findPane(p, src).?; + layout.insert(p, sf.col, sf.idx + 1, free); + layout.splitBelow(p, src, nt); + p.active = free; } // ---- dump / load (acme-style: `pardes -l state.zon` restores a session) ---- @@ -10891,6 +11780,7 @@ pub const Pardes = struct { }; if (ids_len > 0) { columns[columns_len] = .{ + .tag = if (p.col_tags[c].custom) p.columnTag(c) else null, .weight = @as(f32, @floatFromInt(p.col_weight[c])) / @as(f32, @floatFromInt(column_weight_unit)), .panes = column_ids[c][0..ids_len], }; @@ -10904,6 +11794,7 @@ pub const Pardes = struct { .screen = .{ .cols = p.screen_w, .rows = p.screen_h }, .active = slot_to_pane[p.active] orelse 0, .topbar = config.topbar_str, + .topbar_custom = if (p.global_tag.custom) p.global_tag.slice("") else null, .theme = p.theme().name, .columns = columns[0..columns_len], .panes = dump_panes[0..panes_len], @@ -11033,9 +11924,12 @@ pub const Pardes = struct { }; p.restoreDumpTail(pane, src); pane.vweight = src.vweight; + pane.collapsed = src.collapsed; } p.ncol = @min(st.columns.len, MAX_COLS); + if (st.topbar_custom) |bar| _ = p.global_tag.set(bar); for (st.columns[0..p.ncol], 0..) |col, c| { + if (col.tag) |bar| _ = p.col_tags[c].set(bar); const scaled = @as(f64, @floatCast(col.weight)) * @as(f64, @floatFromInt(column_weight_unit)); const bounded = @min(scaled, @as(f64, @floatFromInt(max_column_weight))); p.col_weight[c] = @max(1, @as(u64, @intFromFloat(@round(bounded)))); @@ -11059,6 +11953,10 @@ pub const Pardes = struct { return; } const tail_class = tailClass(pane); + if (src.kind == .image and panes.Pdf.isPath(src.image.?.path)) { + if (panes.Pdf.legacySavedPrefix(src.image.?.path, src.tag)) |prefix| + return p.restoreTailAt(pane, src.tag, prefix, .generic); + } if (src.kind == .terminal and std.mem.startsWith(u8, src.tag, "TTY ")) { const legacy = std.fmt.allocPrint(p.scratch.allocator(), "TTY {s}", .{src.terminal.?.cwd}) catch return; if (std.mem.startsWith(u8, src.tag, legacy)) @@ -11109,13 +12007,15 @@ pub const Pardes = struct { const rest = saved_tag[pfx.len..]; const said = std.mem.trimStart(u8, rest, " "); const defaults: []const []const u8 = switch (class) { - .generic => &.{ pane_tail, prev_pane_tail, legacy_pane_tail }, - .file => &.{ file_pane_tail, prev_file_pane_tail, legacy_file_pane_tail }, + .generic => &.{ pdf_pane_tail, pane_tail, pre_collapse_pdf_pane_tail, pre_collapse_pane_tail, pre_column_pdf_pane_tail, pre_column_pane_tail, prev_pane_tail, legacy_pane_tail }, + .file => &.{ file_pane_tail, pre_collapse_file_pane_tail, pre_column_file_pane_tail, prev_file_pane_tail, legacy_file_pane_tail }, .output => &.{ - file_pane_tail, prev_file_pane_tail, legacy_file_pane_tail, - pane_tail, prev_pane_tail, legacy_pane_tail, + file_pane_tail, prev_file_pane_tail, legacy_file_pane_tail, + pane_tail, prev_pane_tail, legacy_pane_tail, + pre_column_file_pane_tail, pre_column_pane_tail, pre_collapse_file_pane_tail, + pre_collapse_pane_tail, }, - .terminal => &.{ terminal_pane_tail, prev_terminal_pane_tail, legacy_terminal_pane_tail, pane_tail, prev_pane_tail, legacy_pane_tail }, + .terminal => &.{ terminal_pane_tail, pre_collapse_terminal_pane_tail, pre_column_terminal_pane_tail, pre_tty_terminal_pane_tail, prev_terminal_pane_tail, legacy_terminal_pane_tail, pane_tail, pre_collapse_pane_tail, pre_column_pane_tail, prev_pane_tail, legacy_pane_tail }, }; for (defaults) |default| if (std.mem.eql(u8, said, std.mem.trimStart(u8, default, " "))) return; @@ -11231,12 +12131,21 @@ pub const Pardes = struct { /// Recompute geometry, push grid-size changes to each emulator + pty, fire /// deferred greetings. The mirror of the prototype's loop epilogue. fn sync(p: *Pardes) void { + if (p.header_column != null and p.columnBarHeight() == 0) { + p.topbar_col = null; + p.header_column = null; + p.header_anchor = null; + p.header_drag = false; + p.header_scroll = 0; + } p.reapPanes(); layout.compute(p); p.presentation.sync(p); p.trackJump(); for (&p.panes, 0..) |*slot, id| { const pane = slot.* orelse continue; + if (pane.tag_name_len != null and (id != p.active or p.topbar_col != null)) + exitTagEdit(pane); if (comptime terminal_panes) if (pane.terminal) |state| { var off: u16 = 0; while (off < state.reply_len) { @@ -11359,8 +12268,8 @@ pub const Pardes = struct { s.cell_diffs = &.{}; const chrome = p.chromeTheme(); const th = p.theme(); // the message row paints in the editor's colours - // gaps between panes read as chrome (scrollbar track), not raw default - s.fill(0, 0, s.cols, s.rows, .{ .bg = .{ .rgb = chrome.scroll_track } }); + // Separators have their own role; a scrollbar need not frame the page. + s.fill(0, 0, s.cols, s.rows, .{ .bg = .{ .rgb = chrome.border } }); for (&p.panes, 0..) |*slot, id| { const pane = slot.* orelse continue; @@ -11411,21 +12320,24 @@ pub const Pardes = struct { } // global tagbar: full width, top row + p.revealHeader(); s.fill(0, 0, s.cols, TOPBAR_H, .{ .bg = .{ .rgb = chrome.tag_bg }, .font_role = .tagline, }); var tb_buf: [1200]u8 = undefined; - _ = s.print(0, 0, s.cols, p.topbar(&tb_buf), .{ + const global_bar = p.topbar(&tb_buf); + const global_scroll = if (p.topbar_col != null and p.header_column == null) p.header_scroll else 0; + _ = s.print(0, 0, s.cols, global_bar[panes.File.rawAtDisplay(global_bar, global_scroll)..], .{ .fg = .{ .rgb = chrome.tag_fg }, .bg = .{ .rgb = chrome.tag_bg }, .font_role = .tagline, }); if (p.pointer_inside and p.hover_row < TOPBAR_H) { const bar = p.topbar(&tb_buf); - if (wordBoundsAtCol(bar, panes.File.rawAtDisplay(bar, p.hover_col))) |bounds| { - var col = panes.File.rawDisplayCol(bar, bounds.lo); - const hi = panes.File.rawDisplayCol(bar, bounds.hi); + if (wordBoundsAtCol(bar, panes.File.rawAtDisplay(bar, p.hover_col + global_scroll))) |bounds| { + var col = panes.File.rawDisplayCol(bar, bounds.lo) -| global_scroll; + const hi = panes.File.rawDisplayCol(bar, bounds.hi) -| global_scroll; while (col < hi and col < s.cols) : (col += 1) { const cell = s.at(@intCast(col), 0); cell.default = false; @@ -11434,9 +12346,53 @@ pub const Pardes = struct { } } } - if (p.topbar_col) |c| { - const col = panes.File.rawDisplayCol(p.topbar(&tb_buf), c); - if (col < s.cols) s.cursor = .{ .x = @intCast(col), .y = 0, .bar = false }; + if (p.topbar_col) |c| if (p.header_column == null) { + const col = panes.File.rawDisplayCol(p.topbar(&tb_buf), c) -| global_scroll; + if (col < s.cols) s.cursor = .{ .x = @intCast(col), .y = 0, .bar = p.header_insert }; + }; + if (p.columnBarHeight() > 0) for (0..p.ncol) |c| { + const x = p.col_x[c]; + const w = p.col_w[c]; + if (w == 0 or x >= s.cols) continue; + const active_col = if (layout.findPane(p, p.active)) |f| f.col == c else false; + const bg = if (active_col and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; + const fg = if (active_col and p.settings.focus_tint) chrome.tag_active_fg else chrome.tag_fg; + s.fill(x, TOPBAR_H, w, 1, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); + const bar_scroll = if (p.topbar_col != null and p.header_column == c) p.header_scroll else 0; + const tag = p.columnTag(c); + _ = s.print(x, TOPBAR_H, w, tag[panes.File.rawAtDisplay(tag, bar_scroll)..], .{ + .fg = .{ .rgb = fg }, + .bg = .{ .rgb = bg }, + .font_role = .tagline, + }); + if (p.pointer_inside and p.hover_row == TOPBAR_H and p.hover_col >= x and p.hover_col < x + w) { + const bar = p.columnTag(c); + if (wordBoundsAtCol(bar, panes.File.rawAtDisplay(bar, p.hover_col - x + bar_scroll))) |bounds| { + var col = panes.File.rawDisplayCol(bar, bounds.lo) -| bar_scroll; + const hi = panes.File.rawDisplayCol(bar, bounds.hi) -| bar_scroll; + while (col < hi and col < w) : (col += 1) { + const cell = s.at(x + @as(u16, @intCast(col)), TOPBAR_H); + cell.style.bg = .{ .rgb = th.sel_bg }; + cell.style.fg = .{ .rgb = th.sel_fg }; + } + } + } + }; + if (p.topbar_col) |cur| { + const bar = p.headerText(); + const x = if (p.header_column) |c| p.col_x[c] else 0; + const w = if (p.header_column) |c| p.col_w[c] else s.cols; + const y: u16 = if (p.header_column != null) TOPBAR_H else 0; + const anchor = p.header_anchor orelse cur; + var col = panes.File.rawDisplayCol(bar, @min(cur, anchor)) -| p.header_scroll; + const hi = panes.File.rawDisplayCol(bar, @max(cur, anchor)) -| p.header_scroll; + while (col < hi and col < w) : (col += 1) { + const cell = s.at(x + @as(u16, @intCast(col)), y); + cell.style.fg = .{ .rgb = th.sel_fg }; + cell.style.bg = .{ .rgb = th.sel_bg }; + } + const caret = panes.File.rawDisplayCol(bar, @min(cur, bar.len)) -| p.header_scroll; + if (caret < w) s.cursor = .{ .x = x + @as(u16, @intCast(caret)), .y = y, .bar = p.header_insert }; } switch (p.drag) { @@ -11665,8 +12621,9 @@ pub const Pardes = struct { const sy = if (row < BOX_H) tag_y else body_y + row - BOX_H; // File line numbers occupy a gutter only in the body. The tag is // row zero in Sel space and starts at its real first text cell. - var col: i32 = if (pane.file != null and row >= BOX_H) @max(c0, @as(i32, panes.File.gutterWidth(pane))) else c0; - while (col <= c1 and col < tw) : (col += 1) { + const scroll: i32 = if (row < BOX_H) pane.tag_scroll else 0; + var col: i32 = if (pane.file != null and row >= BOX_H) @max(c0, @as(i32, panes.File.gutterWidth(pane))) else @max(0, c0 - scroll); + while (col <= c1 - scroll and col < tw) : (col += 1) { const cell = s.at(tx + @as(u16, @intCast(col)), sy); cell.default = false; cell.style.bg = .{ .rgb = bg }; @@ -11717,21 +12674,37 @@ pub const Pardes = struct { .tty => config.tag_tty, }, box_style); - // tag: live prefix (cwd/path) + editable tail, on a dim bar - const tag_style: CellStyle = .{ .fg = .{ .rgb = chrome.tag_fg }, .bg = .{ .rgb = chrome.tag_bg }, .font_role = .tagline }; + // Focus is visible along the tag without changing its live text grid. + const tag_bg = if (active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; + const tag_fg = if (active and p.settings.focus_tint) chrome.tag_active_fg else chrome.tag_fg; + const tag_style: CellStyle = .{ .fg = .{ .rgb = tag_fg }, .bg = .{ .rgb = tag_bg }, .font_role = .tagline }; // Padding belongs to the same font row but keeps its default ink: a // role must not manufacture a foreground color for blank cells. s.fill(tx, tag_y, tw, BOX_H, .{ - .bg = .{ .rgb = chrome.tag_bg }, + .bg = .{ .rgb = tag_bg }, .font_role = .tagline, }); const tag = try p.tagText(arena, pane); const prompt_at = pane.promptAt(); + if (active and pane.tag_edit and prompt_at == null) { + const caret = panes.File.rawDisplayCol(tag, pane.tag_col); + var scroll: usize = pane.tag_scroll; + if (caret < scroll) scroll = caret; + if (caret >= scroll + tw) scroll = caret - tw + 1; + var raw = panes.File.rawAtDisplay(tag, scroll); + scroll = panes.File.rawDisplayCol(tag, raw); + if (caret >= scroll + tw) { + raw = modal.nextGrapheme(tag, raw); + scroll = panes.File.rawDisplayCol(tag, raw); + } + pane.tag_scroll = @intCast(scroll); + } else if (!pane.tag_edit or prompt_at != null) pane.tag_scroll = 0; const tag_cut = if (prompt_at) |at| @min(tag.len, tag.len - @min(tag.len, pane.tag_tail_len) + at) else tag.len; - _ = s.print(tx, tag_y, tw, tag[0..tag_cut], tag_style); + const tag_start = @min(tag_cut, panes.File.rawAtDisplay(tag, pane.tag_scroll)); + _ = s.print(tx, tag_y, tw, tag[tag_start..tag_cut], tag_style); if (p.look_hover_preview) |preview| { if (preview.pane == id and preview.serial == pane.serial) if (preview.sel) |sel| if (@min(sel.r0, sel.r1) < BOX_H) { const preview_bg = mix(page_rgb, mix(page_rgb, th.sel_bg)); @@ -11740,10 +12713,10 @@ pub const Pardes = struct { } if (pane.tag_edit and pane.tag_sel) { const b = tagSelBounds(pane); - var col = panes.File.rawDisplayCol(tag, b.lo); + var col = panes.File.rawDisplayCol(tag, b.lo) -| pane.tag_scroll; const hi = modal.nextGrapheme(tag, b.hi); - const end = panes.File.rawDisplayCol(tag, hi) -| 1; - while (col <= end and col < tw) : (col += 1) { + const end = panes.File.rawDisplayCol(tag, hi); + while (col + pane.tag_scroll < end and col < tw) : (col += 1) { const cell = s.at(tx + @as(u16, @intCast(col)), tag_y); cell.default = false; cell.style.bg = .{ .rgb = th.sel_bg }; @@ -11753,10 +12726,14 @@ pub const Pardes = struct { // cursor while editing the tag: byte offset mapped to its display cell if (active and pane.tag_edit) { // bar while typing, block for `:` normal mode (same rule as a body) - const col = panes.File.rawDisplayCol(tag, pane.tag_col); + const col = panes.File.rawDisplayCol(tag, pane.tag_col) -| pane.tag_scroll; if (col < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(col)), .y = tag_y, .bar = pane.mode == .insert }; } + // Folded panes retain their body state, but neither body pixels nor + // its cursor belong outside the one-row tag rectangle. + if (pane.collapsed) return; + if (comptime pdf_enabled) if (pane.hasPdf() and panes.Pdf.draw(p, pane, r, id, tx, tw)) return; |
