diff options
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 1025 |
1 files changed, 124 insertions, 901 deletions
diff --git a/src/pardes.zig b/src/pardes.zig index d07539ba..c802f0ea 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -11,6 +11,7 @@ pub const syntax = @import("syntax.zig"); pub const locations_config = @import("locations_config.zig"); const tracy = @import("tracy.zig"); pub const panes = @import("panes.zig"); +pub const tagline = @import("tagline.zig"); pub const exec = @import("exec.zig"); pub const mouse = @import("mouse.zig"); pub const normal = @import("normal.zig"); @@ -445,6 +446,7 @@ pub const Loc = struct { }; test { + _ = @import("tagline.zig"); _ = @import("exec.zig"); _ = @import("look.zig"); _ = @import("mouse.zig"); @@ -459,48 +461,9 @@ test { _ = @import("tag_layer.zig"); } -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 context_pane_tail = file_pane_tail ++ " TreeContext"; -const locations_pane_tail = file_pane_tail ++ " LocationsConfig"; -const terminal_pane_tail = " " ++ config.terminal_pane_builtins_str; -const dirty_marker = " *"; -const legacy_pane_tail = " Del"; -const legacy_file_pane_tail = " Save Del"; -// The defaults from the release before Newtty joined every tagline. Recognized -// so a dump written then upgrades instead of keeping the old word as a tail. -const prev_pane_tail = " New Del"; -const prev_file_pane_tail = " Save New Del"; -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 pre_tty_first_terminal_pane_tail = " Save Tty Del Togglettymode Filter Collapse"; -const pre_mode_terminal_pane_tail = " Tty Save Del Togglettymode Filter Collapse"; -// ...and the defaults from before the closing word moved to the end of every -// tagline, where a click cannot land on it by overshooting the one before. -const pre_close_last_pane_tail = " Tty Del Collapse"; -const pre_close_last_file_pane_tail = " Save Tty Del Collapse"; -const pre_close_last_pdf_pane_tail = " Tty Del PdfSections PdfTint Collapse"; -const pre_close_last_terminal_pane_tail = " Tty Save Del Mode Filter Collapse"; - pub const Builtin = builtins.registry.Builtin(); pub const test_api = if (@import("builtin").is_test) struct { - pub fn tagText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]u8 { - return p.tagText(arena, pane); - } - pub fn runBuiltin( p: *Pardes, name: []const u8, @@ -669,11 +632,11 @@ test "pane-tag Exec prefers Tty and argument builtins before shell fallback" { p.acknowledgeShell(0, "/bin/sh", false); const pane = p.panes[0].?; - try std.testing.expectEqualStrings(terminal_pane_tail, Pardes.curTail(pane)); + try std.testing.expectEqualStrings(tagline.terminal_pane_tail, tagline.curTail(pane)); const tag_x = p.rects[0].x + TAG_TEXT_INSET; const tag_y = p.rects[0].y; - const rendered = try p.tagText(p.scratch.allocator(), pane); + const rendered = try tagline.tagText(p, p.scratch.allocator(), pane); 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) { @@ -738,7 +701,7 @@ test "Collapse builtin keeps content and restores the pane from its default tag" 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| + for ([_][]const u8{ tagline.pane_tail, tagline.file_pane_tail, tagline.pdf_pane_tail, tagline.terminal_pane_tail }) |tail| try std.testing.expect(std.mem.endsWith(u8, tail, " Collapse Del")); try std.testing.expect(p.executeBuiltinLine(0, "Collapse")); @@ -749,7 +712,7 @@ test "Collapse builtin keeps content and restores the pane from its default tag" 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.enterTag(pane); + tagline.enterTag(p, pane); 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); pane.focus = .body; @@ -761,9 +724,9 @@ test "Collapse builtin keeps content and restores the pane from its default tag" // Unedited defaults from the preceding version gain the new command. if (pane.tag.own) |own| p.gpa.free(own); pane.tag.own = null; - p.restoreTailAt(pane, pre_collapse_file_pane_tail, "", .file); + tagline.restoreTailAt(p, pane, tagline.pre_collapse_file_pane_tail, "", .file); try std.testing.expect(pane.tag.own == null); - try std.testing.expectEqualStrings(file_pane_tail, Pardes.curTail(pane)); + try std.testing.expectEqualStrings(tagline.file_pane_tail, tagline.curTail(pane)); } test "LocationsConfig command reports partial updates and survives restore" { @@ -850,12 +813,12 @@ test "TreeContext command and custom tag survive a workspace round trip" { defer p.deinit(); const pane = try p.setTestFile("pub fn main() void {}\n"); try std.testing.expect(!panes.File.supportsContext(pane)); - try std.testing.expectEqualStrings(file_pane_tail, Pardes.curTail(pane)); + try std.testing.expectEqualStrings(tagline.file_pane_tail, tagline.curTail(pane)); const path = try p.gpa.dupe(u8, "/test.zig"); p.gpa.free(pane.file.?.path); pane.file.?.path = path; if (!panes.File.supportsContext(pane)) return error.SkipZigTest; - try std.testing.expectEqualStrings(context_pane_tail, Pardes.curTail(pane)); + try std.testing.expectEqualStrings(tagline.context_pane_tail, tagline.curTail(pane)); try std.testing.expect(!pane.file.?.tree_context); try std.testing.expect(p.executeBuiltinLine(0, "TreeContext on")); try std.testing.expect(pane.file.?.tree_context); @@ -872,14 +835,14 @@ test "TreeContext command and custom tag survive a workspace round trip" { defer restored.deinit(); const saved = restored.panes[0].?; try std.testing.expect(saved.file.?.tree_context); - try std.testing.expectEqualStrings(" Save Keep Del", Pardes.curTail(saved)); + try std.testing.expectEqualStrings(" Save Keep Del", tagline.curTail(saved)); } 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), " Mode ") != null); + try std.testing.expect(std.mem.indexOf(u8, tagline.curTail(pane), " Mode ") != null); try pane.appendTag(" Togglettymode"); try std.testing.expectEqual(Text.Mode.tty, pane.body.mode); try std.testing.expect(p.executeBuiltinLine(0, "Togglettymode")); @@ -887,19 +850,19 @@ test "Togglettymode builtin and keyboard chord share mode transitions including p.update(.{ .key = .{ .cp = p.opts.tty_toggle, .ctrl = true } }); try std.testing.expectEqual(Text.Mode.tty, pane.body.mode); // The tag has its own mode: the body keeps tty while the tag is typed into. - p.enterTag(pane); + tagline.enterTag(p, pane); try std.testing.expect(pane.focus == .tag); try std.testing.expectEqual(Text.Mode.tty, pane.body.mode); try std.testing.expect(p.executeBuiltinLine(0, "Togglettymode")); try std.testing.expectEqual(Text.Mode.normal, pane.body.mode); try std.testing.expect(pane.focus == .body); - p.enterTag(pane); + tagline.enterTag(p, pane); p.update(.{ .key = .{ .cp = p.opts.tty_toggle, .ctrl = true } }); try std.testing.expectEqual(Text.Mode.tty, pane.body.mode); try std.testing.expect(pane.focus == .body); p.acknowledgePanelPresentation(&.{}); - const tag = try p.tagText(p.scratch.allocator(), pane); + const tag = try tagline.tagText(p, p.scratch.allocator(), pane); const x = p.rects[0].x + TAG_TEXT_INSET + @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 } }); @@ -965,30 +928,30 @@ test "compact tag commands stay beside their own prefix across layout changes" { const above = p.panes[0].?; try std.testing.expectEqual(p.rects[0].x, p.rects[below_id].x); - const above_tag = try p.tagText(gpa, above); + const above_tag = try tagline.tagText(p, gpa, above); defer gpa.free(above_tag); - const below_tag = try p.tagText(p.scratch.allocator(), below); + const below_tag = try tagline.tagText(p, p.scratch.allocator(), below); try std.testing.expect(below_tag.len > above_tag.len); - const tail = terminal_pane_tail; + const tail = tagline.terminal_pane_tail; try std.testing.expectEqualStrings(tail, above_tag[above_tag.len - tail.len ..]); try std.testing.expectEqual(above.cwdSlice().len + tail.len, above_tag.len); p.update(.{ .resize = .{ .cols = 88, .rows = 30 } }); while (p.nextEffect()) |_| {} - try std.testing.expectEqualStrings(above_tag, try p.tagText(p.scratch.allocator(), above)); + try std.testing.expectEqualStrings(above_tag, try tagline.tagText(p, p.scratch.allocator(), above)); p.update(.{ .resize = .{ .cols = 87, .rows = 30 } }); while (p.nextEffect()) |_| {} - try std.testing.expectEqualStrings(above_tag, try p.tagText(p.scratch.allocator(), above)); + try std.testing.expectEqualStrings(above_tag, try tagline.tagText(p, p.scratch.allocator(), above)); p.update(.{ .resize = .{ .cols = 100, .rows = 30 } }); while (p.nextEffect()) |_| {} - try std.testing.expectEqualStrings(tail, Pardes.curTail(below)); + try std.testing.expectEqualStrings(tail, tagline.curTail(below)); try below.appendTag(" lots and lots of typing out here"); - try std.testing.expectEqual(above_tag.len, (try p.tagText(p.scratch.allocator(), above)).len); + try std.testing.expectEqual(above_tag.len, (try tagline.tagText(p, 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.expectEqualStrings(above_tag, try p.tagText(p.scratch.allocator(), above)); + try std.testing.expectEqualStrings(above_tag, try tagline.tagText(p, p.scratch.allocator(), above)); } test "legacy default tag tails upgrade while custom tails remain owned" { @@ -998,120 +961,120 @@ test "legacy default tag tails upgrade while custom tails remain owned" { const terminal = p.panes[0].?; const terminal_old = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ - try p.tagPrefix(terminal), - legacy_pane_tail, + try tagline.tagPrefix(p, terminal), + tagline.legacy_pane_tail, }); - p.restoreTail(terminal, terminal_old); + tagline.restoreTail(p, terminal, terminal_old); try std.testing.expect(terminal.tag.own == null); - try std.testing.expectEqualStrings(terminal_pane_tail, Pardes.curTail(terminal)); + try std.testing.expectEqualStrings(tagline.terminal_pane_tail, tagline.curTail(terminal)); const terminal_previous = try std.fmt.allocPrint(p.scratch.allocator(), "{s} New Del", .{ - try p.tagPrefix(terminal), + try tagline.tagPrefix(p, terminal), }); - p.restoreTail(terminal, terminal_previous); + tagline.restoreTail(p, terminal, terminal_previous); try std.testing.expect(terminal.tag.own == null); - try std.testing.expectEqualStrings(terminal_pane_tail, Pardes.curTail(terminal)); + try std.testing.expectEqualStrings(tagline.terminal_pane_tail, tagline.curTail(terminal)); const terminal_prefilter = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ - try p.tagPrefix(terminal), - prev_terminal_pane_tail, + try tagline.tagPrefix(p, terminal), + tagline.prev_terminal_pane_tail, }); - p.restoreTail(terminal, terminal_prefilter); + tagline.restoreTail(p, terminal, terminal_prefilter); try std.testing.expect(terminal.tag.own == null); - try std.testing.expectEqualStrings(terminal_pane_tail, Pardes.curTail(terminal)); + try std.testing.expectEqualStrings(tagline.terminal_pane_tail, tagline.curTail(terminal)); - for ([_][]const u8{ pre_mode_terminal_pane_tail, pre_tty_first_terminal_pane_tail, pre_collapse_terminal_pane_tail }) |old_tail| { - const old = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ try p.tagPrefix(terminal), old_tail }); - p.restoreTail(terminal, old); + for ([_][]const u8{ tagline.pre_mode_terminal_pane_tail, tagline.pre_tty_first_terminal_pane_tail, tagline.pre_collapse_terminal_pane_tail }) |old_tail| { + const old = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ try tagline.tagPrefix(p, terminal), old_tail }); + tagline.restoreTail(p, terminal, old); try std.testing.expect(terminal.tag.own == null); - try std.testing.expectEqualStrings(terminal_pane_tail, Pardes.curTail(terminal)); + try std.testing.expectEqualStrings(tagline.terminal_pane_tail, tagline.curTail(terminal)); } - const custom_mode = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s} Keep", .{ try p.tagPrefix(terminal), pre_mode_terminal_pane_tail }); - p.restoreTail(terminal, custom_mode); + const custom_mode = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s} Keep", .{ try tagline.tagPrefix(p, terminal), tagline.pre_mode_terminal_pane_tail }); + tagline.restoreTail(p, terminal, custom_mode); try std.testing.expect(terminal.tag.own != null); - try std.testing.expectEqualStrings(pre_mode_terminal_pane_tail ++ " Keep", Pardes.curTail(terminal)); + try std.testing.expectEqualStrings(tagline.pre_mode_terminal_pane_tail ++ " Keep", tagline.curTail(terminal)); if (terminal.tag.own) |own| p.gpa.free(own); terminal.tag.own = null; const terminal_custom = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Keep Del", .{ - try p.tagPrefix(terminal), + try tagline.tagPrefix(p, terminal), }); - p.restoreTail(terminal, terminal_custom); + tagline.restoreTail(p, terminal, terminal_custom); try std.testing.expect(terminal.tag.own != null); - try std.testing.expectEqualStrings(" Keep Del", Pardes.curTail(terminal)); + try std.testing.expectEqualStrings(" Keep Del", tagline.curTail(terminal)); if (terminal.tag.own) |own| p.gpa.free(own); terminal.tag.own = null; - p.restoreDumpTail(terminal, .{ + tagline.restoreDumpTail(p, terminal, .{ .kind = .terminal, .tag = "TTY /historical/cwd Keep Del", .body = "", .terminal = .{ .cwd = "/historical/cwd" }, }); try std.testing.expect(terminal.tag.own != null); - try std.testing.expectEqualStrings(" Keep Del", Pardes.curTail(terminal)); + try std.testing.expectEqualStrings(" Keep Del", tagline.curTail(terminal)); if (terminal.tag.own) |own| p.gpa.free(own); terminal.tag.own = null; - p.restoreDumpTail(terminal, .{ + tagline.restoreDumpTail(p, terminal, .{ .kind = .terminal, .tag = "TTY /historical/cwd New Del", .body = "", .terminal = .{ .cwd = "/historical/cwd" }, }); try std.testing.expect(terminal.tag.own == null); - try std.testing.expectEqualStrings(terminal_pane_tail, Pardes.curTail(terminal)); + try std.testing.expectEqualStrings(tagline.terminal_pane_tail, tagline.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. if (terminal.tag.own) |own| p.gpa.free(own); terminal.tag.own = null; const terminal_fileish = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Save Del", .{ - try p.tagPrefix(terminal), + try tagline.tagPrefix(p, terminal), }); - p.restoreTail(terminal, terminal_fileish); + tagline.restoreTail(p, terminal, terminal_fileish); try std.testing.expect(terminal.tag.own != null); - try std.testing.expectEqualStrings(" Save Del", Pardes.curTail(terminal)); + try std.testing.expectEqualStrings(" Save Del", tagline.curTail(terminal)); // A savable file has a distinct old default. Save remains first after the // migration so the tag's established `:w<Tab>` route is unchanged. const file = try p.setTestFile(""); const file_old = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ - try p.tagPrefix(file), - legacy_file_pane_tail, + try tagline.tagPrefix(p, file), + tagline.legacy_file_pane_tail, }); - p.restoreTail(file, file_old); + tagline.restoreTail(p, file, file_old); try std.testing.expect(file.tag.own == null); - try std.testing.expectEqualStrings(" Save Tty Collapse Del", Pardes.curTail(file)); + try std.testing.expectEqualStrings(" Save Tty Collapse Del", tagline.curTail(file)); const file_custom = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Save Mine Del", .{ - try p.tagPrefix(file), + try tagline.tagPrefix(p, file), }); - p.restoreTail(file, file_custom); + tagline.restoreTail(p, file, file_custom); try std.testing.expect(file.tag.own != null); - try std.testing.expectEqualStrings(" Save Mine Del", Pardes.curTail(file)); + try std.testing.expectEqualStrings(" Save Mine Del", tagline.curTail(file)); // Conversely the generic pane's current default is a custom edit on a // savable file. Legacy recognition is scoped to the source pane class. if (file.tag.own) |own| p.gpa.free(own); file.tag.own = null; const file_generic = try std.fmt.allocPrint(p.scratch.allocator(), "{s} New Del", .{ - try p.tagPrefix(file), + try tagline.tagPrefix(p, file), }); - p.restoreTail(file, file_generic); + tagline.restoreTail(p, file, file_generic); try std.testing.expect(file.tag.own != null); - try std.testing.expectEqualStrings(" New Del", Pardes.curTail(file)); + try std.testing.expectEqualStrings(" New Del", tagline.curTail(file)); const image_doc = try panes.Image.create(p, 1, "/tmp/legacy image.ppm", &.{}); - try std.testing.expectEqualStrings(" Tty Collapse Del", Pardes.curTail(image_doc)); - p.restoreDumpTail(image_doc, .{ + try std.testing.expectEqualStrings(" Tty Collapse Del", tagline.curTail(image_doc)); + tagline.restoreDumpTail(p, image_doc, .{ .kind = .image, .tag = "img /tmp/legacy image.ppm Keep Del", .body = "", .image = .{ .path = "/tmp/legacy image.ppm" }, }); try std.testing.expect(image_doc.tag.own != null); - try std.testing.expectEqualStrings(" Keep Del", Pardes.curTail(image_doc)); + try std.testing.expectEqualStrings(" Keep Del", tagline.curTail(image_doc)); } test "Joincol folds the active column into its right neighbor, keeping its panes" { @@ -1245,19 +1208,19 @@ test "first document keeps its originating shell and only opens a column when it 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| { + for ([_][]const u8{ tagline.pane_tail, tagline.file_pane_tail, tagline.pdf_pane_tail, tagline.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 Delcol", p.columnTag(0)); + try std.testing.expectEqualStrings("New Tty Find Grep Joincol Delcol", tagline.columnTag(p, 0)); const terminal = p.panes[0].?; - p.restoreTailAt(terminal, pre_column_terminal_pane_tail, "", .terminal); + tagline.restoreTailAt(p, terminal, tagline.pre_column_terminal_pane_tail, "", .terminal); try std.testing.expect(terminal.tag.own == null); - try std.testing.expectEqualStrings(terminal_pane_tail, Pardes.curTail(terminal)); + try std.testing.expectEqualStrings(tagline.terminal_pane_tail, tagline.curTail(terminal)); const file = try p.setTestFile("unchanged\n"); - p.restoreTailAt(file, pre_column_file_pane_tail, "", .file); + tagline.restoreTailAt(p, file, tagline.pre_column_file_pane_tail, "", .file); try std.testing.expect(file.tag.own == null); - try std.testing.expectEqualStrings(file_pane_tail, Pardes.curTail(file)); + try std.testing.expectEqualStrings(tagline.file_pane_tail, tagline.curTail(file)); } test "an unsaved file marker sits between its path and builtins until Save" { @@ -1267,29 +1230,29 @@ test "an unsaved file marker sits between its path and builtins until Save" { while (p.nextEffect()) |_| {} const pane = try p.setTestFile("before\n"); - const clean = try p.tagText(p.scratch.allocator(), pane); + const clean = try tagline.tagText(p, p.scratch.allocator(), pane); try std.testing.expect(std.mem.indexOf(u8, clean, "/test.txt *") == null); panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "after\n")); - const dirty = try p.tagText(p.scratch.allocator(), pane); + const dirty = try tagline.tagText(p, p.scratch.allocator(), pane); const marker_at = std.mem.indexOf(u8, dirty, "/test.txt *") orelse return error.MissingDirtyMarker; const save_at = std.mem.indexOf(u8, dirty, "Save") orelse return error.MissingSaveBuiltin; try std.testing.expect(marker_at < save_at); try std.testing.expect(p.executeBuiltinLine(0, "Save")); while (p.nextEffect()) |effect| p.perform(effect); - const saved = try p.tagText(p.scratch.allocator(), pane); + const saved = try tagline.tagText(p, p.scratch.allocator(), pane); try std.testing.expect(std.mem.indexOf(u8, saved, "/test.txt *") == null); panes.File.changed(p, 0, "external\n"); - const reloaded = try p.tagText(p.scratch.allocator(), pane); + const reloaded = try tagline.tagText(p, p.scratch.allocator(), pane); try std.testing.expect(std.mem.indexOf(u8, reloaded, "/test.txt *") == null); // A generated output is file-shaped and Save can write it to a path, but // there is no file of its own for it to be dirty against. pane.file.?.output = .{ .from = .search }; panes.File.setContent(p, &pane.file.?, try gpa.dupe(u8, "result\n")); - const output = try p.tagText(p.scratch.allocator(), pane); + const output = try tagline.tagText(p, p.scratch.allocator(), pane); try std.testing.expect(std.mem.indexOf(u8, output, "/test.txt *") == null); } @@ -1422,7 +1385,7 @@ test "image dump restores source bytes renderer choices and exact custom tail" { try std.testing.expectEqual(image.PaletteMode.terminal, restored_pane.image.?.pmode); try std.testing.expect(!restored_pane.image.?.ascii); try std.testing.expect(restored_pane.tag.own != null); - try std.testing.expectEqualStrings(" Keep Del", Pardes.curTail(restored_pane)); + try std.testing.expectEqualStrings(" Keep Del", tagline.curTail(restored_pane)); try std.testing.expectEqualSlices(u8, source, restored_pane.image.?.raw); try dump.dumpState(restored); @@ -1903,7 +1866,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(file_pane_tail, Pardes.curTail(out)); + try std.testing.expectEqualStrings(tagline.file_pane_tail, tagline.curTail(out)); try std.testing.expect(p.executeBuiltinLine(id, "Save")); try std.testing.expect(out.prompt == .save); edit.insertKey(p, &out.input, .{ .cp = 'h', .text = "help.txt" }); @@ -2091,17 +2054,17 @@ 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(locations_pane_tail, Pardes.curTail(out)); + try std.testing.expectEqualStrings(tagline.locations_pane_tail, tagline.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. const dumped = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ - try p.tagPrefix(out), - pane_tail, + try tagline.tagPrefix(p, out), + tagline.pane_tail, }); - p.restoreTail(out, dumped); + tagline.restoreTail(p, out, dumped); try std.testing.expect(out.tag.own == null); - try std.testing.expectEqualStrings(locations_pane_tail, Pardes.curTail(out)); + try std.testing.expectEqualStrings(tagline.locations_pane_tail, tagline.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. @@ -2109,18 +2072,18 @@ test "Save reaches every tagline with text behind it and no other" { if (out.tag.own) |own| p.gpa.free(own); out.tag.own = null; const scratch_dumped = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ - try p.tagPrefix(out), - prev_file_pane_tail, + try tagline.tagPrefix(p, out), + tagline.prev_file_pane_tail, }); - p.restoreTail(out, scratch_dumped); + tagline.restoreTail(p, out, scratch_dumped); try std.testing.expect(out.tag.own == null); - try std.testing.expectEqualStrings(" Save Tty Collapse Del", Pardes.curTail(out)); + try std.testing.expectEqualStrings(" Save Tty Collapse Del", tagline.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(" Tty Collapse Del", Pardes.curTail(img)); + try std.testing.expectEqualStrings(" Tty Collapse Del", tagline.curTail(img)); try std.testing.expect(p.executeBuiltinLine(img_id, "Save")); try std.testing.expect(img.prompt != .save); var buf: [256]u8 = undefined; @@ -2545,7 +2508,7 @@ comptime { const WordBounds = struct { lo: usize, hi: usize }; -fn wordBoundsAtCol(str: []const u8, col: usize) ?WordBounds { +pub fn wordBoundsAtCol(str: []const u8, col: usize) ?WordBounds { if (col >= str.len or str[col] == ' ') return null; var lo: usize = col; while (lo > 0 and str[lo - 1] != ' ') lo -= 1; @@ -2636,7 +2599,7 @@ test "Unicode display cells map back to body and tag byte cursors" { // A click in the wide path glyph drafts a name with its caret at the // grapheme start; arrow motion then advances by the full UTF-8 cluster. - p.draftName(pane, 1); + tagline.draftName(p, pane, 1); try std.testing.expectEqual(@as(i32, 0), pane.input.cur_col); edit.insertKey(p, &pane.input, .{ .cp = Key.right }); try std.testing.expectEqual(@as(i32, 3), pane.input.cur_col); @@ -2644,15 +2607,15 @@ test "Unicode display cells map back to body and tag byte cursors" { try std.testing.expectEqual(@as(i32, 6), pane.input.cur_col); pane.closePrompt(); - const before = try gpa.dupe(u8, Pardes.curTail(pane)); + const before = try gpa.dupe(u8, tagline.curTail(pane)); defer gpa.free(before); - p.enterTag(pane); + tagline.enterTag(p, pane); const insertion = pane.tag.cur_col; edit.insertKey(p, &pane.tag, .{ .cp = 'λ', .text = "λ" }); try std.testing.expectEqual(insertion + 2, pane.tag.cur_col); edit.insertKey(p, &pane.tag, .{ .cp = Key.backspace }); try std.testing.expectEqual(insertion, pane.tag.cur_col); - try std.testing.expectEqualStrings(before, Pardes.curTail(pane)); + try std.testing.expectEqualStrings(before, tagline.curTail(pane)); } test "tabbed file aligns syntax cursor and mouse at the nearest insertion gap" { @@ -2726,23 +2689,23 @@ test "editable workspace and column tags are typed into and persist" { p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = TAG_TEXT_INSET, .row = TOPBAR_H } }); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = TAG_TEXT_INSET, .row = TOPBAR_H } }); p.update(.{ .key = .{ .cp = 'é', .text = "é" } }); - try std.testing.expect(std.mem.startsWith(u8, p.columnTag(0), "éNew")); + try std.testing.expect(std.mem.startsWith(u8, tagline.columnTag(p, 0), "éNew")); p.update(.{ .key = .{ .cp = Key.backspace } }); - try std.testing.expect(std.mem.startsWith(u8, p.columnTag(0), "New")); + try std.testing.expect(std.mem.startsWith(u8, tagline.columnTag(p, 0), "New")); p.update(.{ .paste = "Grep " }); - try std.testing.expect(std.mem.startsWith(u8, p.columnTag(0), "Grep New")); + try std.testing.expect(std.mem.startsWith(u8, tagline.columnTag(p, 0), "Grep New")); try std.testing.expectEqualStrings("untouched\n", p.panes[0].?.file.?.content); try dump.dumpState(p); const restored = try dump.restore(p, p.dump_out.?); defer restored.deinit(); try std.testing.expectEqualStrings(p.global_tag.own.?, restored.global_tag.own.?); - try std.testing.expectEqualStrings(p.columnTag(0), restored.columnTag(0)); + try std.testing.expectEqualStrings(tagline.columnTag(p, 0), tagline.columnTag(restored, 0)); p.gpa.free(p.col_tags[0].own.?); p.col_tags[0].own = try p.gpa.dupe(u8, ""); try dump.dumpState(p); const empty = try dump.restore(p, p.dump_out.?); defer empty.deinit(); - try std.testing.expectEqualStrings("", empty.columnTag(0)); + try std.testing.expectEqualStrings("", tagline.columnTag(empty, 0)); } test "column tags follow their column through split and close" { @@ -2752,12 +2715,12 @@ test "column tags follow their column through split and close" { p.col_tags[0].own = try p.gpa.dupe(u8, "Left Find"); _ = exec.execute(p, 0, "Newcol"); try std.testing.expectEqual(@as(usize, 2), p.ncol); - try std.testing.expectEqualStrings("Left Find", p.columnTag(0)); + try std.testing.expectEqualStrings("Left Find", tagline.columnTag(p, 0)); p.col_tags[1].own = try p.gpa.dupe(u8, "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)); + try std.testing.expectEqualStrings("Right Grep", tagline.columnTag(p, 0)); } test "column tags render edit hover and scroll from the column edge" { @@ -2780,7 +2743,7 @@ test "column tags render edit hover and scroll from the column edge" { 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(i32, 0), p.focusedHeader().?.cur_col); + try std.testing.expectEqual(@as(i32, 0), tagline.focusedHeader(p).?.cur_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 } }); @@ -2790,7 +2753,7 @@ test "column tags render edit hover and scroll from the column edge" { 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 = Key.home } }); p.update(.{ .key = .{ .cp = 'H', .text = "Help " } }); - try std.testing.expect(std.mem.startsWith(u8, p.columnTag(column), "Help New Tty")); + try std.testing.expect(std.mem.startsWith(u8, tagline.columnTag(p, column), "Help New Tty")); const edited = try p.render(frame.allocator()); try std.testing.expectEqual(x + 5, edited.cursor.?.x); // Only the text viewport participates in caret reveal. @@ -2844,7 +2807,7 @@ test "header clicks beyond text append and long UTF-8 tags reveal their caret" { 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)); + try std.testing.expectEqualStrings("New extended", tagline.columnTag(p, 0)); p.update(.{ .paste = " 界界界界界界界界界界界界界界界界界界界界界界界界界界界界界界" }); const frame = try p.render(p.scratch.allocator()); try std.testing.expect(p.header_scroll > 0); @@ -2866,7 +2829,7 @@ test "a column tag with a newline in it takes a row per line and pushes its pane p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = 40, .row = TOPBAR_H } }); p.update(.{ .key = .{ .cp = Key.enter } }); p.update(.{ .key = .{ .cp = 'S', .text = "Snarf" } }); - try std.testing.expectEqualStrings("New\nSnarf", p.columnTag(0)); + try std.testing.expectEqualStrings("New\nSnarf", tagline.columnTag(p, 0)); try std.testing.expectEqual(@as(u16, 2), p.columnBarHeight()); const frame = try p.render(p.scratch.allocator()); try std.testing.expectEqual(TOPBAR_H + 2, p.rects[0].y); @@ -2884,7 +2847,7 @@ test "a column tag with a newline in it takes a row per line and pushes its pane // Taking the newline back out gives the panes their row again. p.update(.{ .key = .{ .cp = Key.home } }); p.update(.{ .key = .{ .cp = Key.backspace } }); - try std.testing.expectEqualStrings("NewSnarf", p.columnTag(0)); + try std.testing.expectEqualStrings("NewSnarf", tagline.columnTag(p, 0)); _ = try p.render(p.scratch.allocator()); try std.testing.expectEqual(TOPBAR_H + COLBAR_H, p.rects[0].y); } @@ -2916,7 +2879,7 @@ test "ColumnTags reclaims the row and releases hidden header focus without losin try std.testing.expectEqual(shown.h + COLBAR_H, p.rects[0].h); try std.testing.expect(!p.header_focus and p.header_column == null); try std.testing.expect(!p.header_drag); - try std.testing.expectEqualStrings("New Help", p.columnTag(0)); + try std.testing.expectEqualStrings("New Help", tagline.columnTag(p, 0)); p.update(.{ .command = "ColumnTags" }); try std.testing.expect(p.settings.column_tags); @@ -2932,7 +2895,7 @@ test "ColumnTags reclaims the row and releases hidden header focus without losin try std.testing.expect(!p.header_focus and p.header_column == 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("New Help", tagline.columnTag(p, 0)); try std.testing.expectEqualStrings("unchanged body\n", pane.file.?.content); } @@ -2940,7 +2903,7 @@ test "a header's execute key runs the word under its cursor and lets go of the k const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 12, .tty_only = true }); defer p.deinit(); _ = try p.setTestFile("body\n"); - p.enterHeader(null); + tagline.enterHeader(p, null); p.global_tag.cur_col = @intCast(std.mem.indexOf(u8, config.topbar_str, "Dump").?); p.update(.{ .key = .{ .cp = Key.tab } }); try std.testing.expect(!p.header_focus); @@ -4082,23 +4045,6 @@ pub const Pardes = struct { return a; } - /// What a column's tag says until someone edits it. - pub const column_tag_default = "New Tty Find Grep Joincol Delcol"; - - /// What a header says until someone edits it: for the workspace the - /// fixed builtins, plus `Restore <path>` once a dump exists. - pub fn headerDefault(p: *Pardes, what: Text.What) []const u8 { - if (what == .column) return column_tag_default; - const d = p.last_dump orelse return config.topbar_str; - return std.fmt.allocPrint(p.scratch.allocator(), config.topbar_str ++ " Restore {s}", .{d}) catch config.topbar_str; - } - - /// the topbar line -- render and click dispatch must agree on this exact - /// string - fn topbar(p: *Pardes) []const u8 { - return p.global_tag.own orelse p.headerDefault(.workspace); - } - /// The workspace tag's height: a row per line of it while the setting /// keeps it, zero once a native shell takes its commands over. Layout and /// hit testing read this rather than the constant, the way they read @@ -4119,36 +4065,6 @@ pub const Pardes = struct { return @intCast(@min(lines, MAX_TAG_ROWS, @max(COLBAR_H, p.screen_h / 3))); } - pub fn columnTag(p: *const Pardes, c: usize) []const u8 { - return p.col_tags[c].own orelse column_tag_default; - } - - /// The header with the keyboard, when one has it. - pub fn focusedHeader(p: *Pardes) ?*Text { - if (!p.header_focus) return null; - if (p.header_column) |c| return if (c < p.ncol) &p.col_tags[c] else null; - return &p.global_tag; - } - - fn revealHeader(p: *Pardes) void { - const t = p.focusedHeader() orelse { - p.header_scroll = 0; - return; - }; - const text = edit.flatSurface(p, t) catch return; - const line = modal.lineSlice(text, @intCast(@max(0, t.cur_row))); - const width = p.tagCapacity(if (p.header_column) |c| p.col_w[c] -| TAG_TEXT_INSET else p.screen_w); - const col = panes.File.rawDisplayCol(line, @min(@as(usize, @intCast(@max(0, t.cur_col))), line.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(line, p.header_scroll); - p.header_scroll = panes.File.rawDisplayCol(line, raw); - if (width > 0 and col >= p.header_scroll + width) { - raw = modal.nextGrapheme(line, raw); - p.header_scroll = panes.File.rawDisplayCol(line, raw); - } - } - 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); @@ -4834,7 +4750,7 @@ pub const Pardes = struct { for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; if (pane.fs.readers == 0) continue; - const tag = p.tagText(p.scratch.allocator(), pane) catch continue; + const tag = tagline.tagText(p, p.scratch.allocator(), pane) catch continue; const snap = &pane.fs.tag_snap; if (std.mem.eql(u8, snap.items, tag)) continue; if (snap.capacity != 0 or snap.items.len != 0) @@ -4846,39 +4762,6 @@ pub const Pardes = struct { // ---- tag + selection text (chord sources) ---- - pub fn tagPrefix(p: *Pardes, pane: *Pane) ![]u8 { - const arena = p.scratch.allocator(); - if (comptime pdf_enabled) if (pane.pdf) |pv| return std.fmt.allocPrint( - arena, - "{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.prompt == .name) pane.input.own orelse f.path else f.path; - if (panes.Output.fileTraits(f.output).saves and f.revision != f.saved_revision) - return std.fmt.allocPrint(arena, "{s}{s}", .{ name, dirty_marker }); - return arena.dupe(u8, name); - } - return arena.dupe(u8, pane.cwdSlice()); - } - - /// The tag's own text: what the user made of it, or until then the - /// default a pane of its kind starts with. - pub fn curTail(pane: *Pane) []const u8 { - return pane.tag.own orelse defaultTail(pane); - } - - pub fn defaultTail(pane: *const Pane) []const u8 { - if (comptime pdf_enabled) if (pane.pdf != null) return pdf_pane_tail; - if (pane.file) |file| if (file.output) |out| - if (panes.Output.usesLocationsConfig(out.from)) return locations_pane_tail; - if (panes.File.supportsContext(pane)) return context_pane_tail; - if (pane.file != null) return file_pane_tail; - if (pane.isTerminal()) return terminal_pane_tail; - return pane_tail; - } - pub fn paneIdOf(p: *const Pardes, pane: *const Pane) ?usize { for (p.panes, 0..) |slot, id| if (slot) |sp| { if (sp == pane) return id; @@ -4886,168 +4769,13 @@ pub const Pardes = struct { return null; } - /// The tag as it is shown, read and pointed at: its computed prefix and - /// its own text after it. - pub fn tagText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]u8 { - const prefix = try p.tagPrefix(pane); - const tail = curTail(pane); - const out = try arena.alloc(u8, prefix.len + tail.len); - @memcpy(out[0..prefix.len], prefix); - @memcpy(out[prefix.len..], tail); - return out; - } - - fn tagTintRange(pane: *const Pane, tag: []const u8, prefix_len: usize) ?struct { lo: usize, hi: usize } { - if (pane.isTerminal()) { - var words = std.mem.tokenizeAny(u8, tag[prefix_len..], " \t\r\n"); - while (words.next()) |word| { - if (!std.mem.eql(u8, word, "Tty")) continue; - const lo = @intFromPtr(word.ptr) - @intFromPtr(tag.ptr); - return .{ .lo = lo, .hi = lo + word.len }; - } - return null; - } - var start: usize = 0; - const name = if (pane.file) |file| - if (pane.prompt == .name) pane.input.own orelse file.path else file.path - else if (pane.image) |state| blk: { - // Image tags put renderer controls before the path. - start = prefix_len -| state.path.len; - break :blk state.path; - } else if (comptime pdf_enabled) blk: { - break :blk if (pane.pdf) |state| state.path else return null; - } else return null; - const basename = if (std.mem.lastIndexOfScalar(u8, name, '/')) |slash| slash + 1 else 0; - if (basename == name.len) return null; - return .{ .lo = start + basename, .hi = start + name.len }; - } - - /// Give the keyboard to the tag in normal mode (`:`), its cursor where - /// the tag last had it. The first time there is nothing to remember, and - /// it starts on Save, so that `:` and the execute key write the file. - pub fn enterTag(p: *Pardes, pane: *Pane) void { - const t = &pane.tag; - const tail = curTail(pane); - if (!t.cur_pinned) { - var words = std.mem.tokenizeAny(u8, tail, " \t\n"); - const at = while (words.next()) |word| { - if (std.mem.eql(u8, word, "Save")) break @intFromPtr(word.ptr) - @intFromPtr(tail.ptr); - } else tail.len - std.mem.trimStart(u8, tail, " ").len; - const cursor = modal.positionAt(tail, @min(at, tail.len)); - t.cur_row = @intCast(cursor.row); - t.cur_col = @intCast(cursor.col); - t.cur_pinned = true; - // Reveal the suffix together with its commands, not merely the - // first command letter at the viewport's far right edge. - if (p.paneIdOf(pane)) |id| { - const tag = p.tagText(p.scratch.allocator(), pane) catch return; - const line = tag[0 .. std.mem.indexOfScalar(u8, tag, '\n') orelse tag.len]; - const width = p.tagCapacity(p.rects[id].w -| TAG_TEXT_INSET); - pane.tag_scroll = @intCast(panes.File.displayWidth(line) + 1 -| width); - } - } - // The text may have changed since (a 9P write, a rename), so the - // remembered cursor is pulled back inside it. It is the cursor that - // is remembered, not a selection. - const rows = modal.cursorLineCount(tail); - t.cur_row = @intCast(@min(@as(usize, @intCast(@max(0, t.cur_row))), rows - 1)); - const line = modal.lineSlice(tail, @intCast(t.cur_row)); - t.cur_col = @intCast(modal.graphemeStart(line, @min(line.len, @as(usize, @intCast(@max(0, t.cur_col)))))); - t.vsel.active = false; - t.msel.active = false; - t.nsel = 0; - t.select = false; - t.append_at = null; - t.sticky_col = -1; - t.mode = .normal; - t.normal.clear(); - pane.focus = .tag; - } - - /// Where display column `col` of tag line `row` (tag scroll included) - /// lands in the tag's own text, or null when it is on the prefix, which - /// only the first line has. - pub fn tagOffsetAt(p: *Pardes, pane: *Pane, row: usize, col: usize) ?modal.Cursor { - const width = if (row == 0) panes.File.displayWidth(p.tagPrefix(pane) catch return null) else 0; - if (col < width) return null; - const line = modal.lineSlice(curTail(pane), row); - return .{ .row = row, .col = modal.graphemeStart(line, panes.File.rawAtDisplay(line, col - width)) }; - } - - /// Start a name draft for a file that can take one, its caret at display - /// column `col` of the path. Nothing changes until it is typed into. - pub fn draftName(p: *Pardes, pane: *Pane, col: usize) void { - const f = pane.file orelse return; - if (!panes.Output.fileTraits(f.output).saves) return; - const name = p.gpa.dupe(u8, f.path) catch return; - pane.closePrompt(); - pane.prompt = .name; - pane.input = .{ .what = .input, .own = name, .mode = .insert, .cur_pinned = true, .cur_col = @intCast(@min(name.len, panes.File.rawAtDisplay(name, col))) }; - } - - /// Commit a drafted 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 { - if (pane.prompt != .name) return true; - const id = p.paneIdOf(pane) orelse return false; - const name = pane.input.own orelse ""; - 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 full = std.fs.path.resolvePosix(p.scratch.allocator(), &.{ paneDir(pane), name }) catch |err| { - p.reportError(id, "name", err); - return false; - }; - ctlfs.pane.nameBuffer(p, id, full, true) catch |err| { - p.reportError(id, "name", err); - return false; - }; - pane.closePrompt(); - return true; - } - - pub fn exitHeader(p: *Pardes) void { - p.header_focus = false; - p.header_column = null; - p.header_drag = false; - p.header_scroll = 0; - } - - /// Give the keyboard to a column's tag, or the workspace's for null, in - /// normal mode, its cursor where that header last had it. - pub fn enterHeader(p: *Pardes, column: ?usize) void { - p.exitHeader(); - if (p.panes[p.active]) |pane| pane.focus = .body; - p.header_focus = true; - p.header_column = column; - const t = p.focusedHeader() orelse return p.exitHeader(); - const text = edit.flatSurface(p, t) catch ""; - const rows = modal.cursorLineCount(text); - t.cur_row = @intCast(@min(@as(usize, @intCast(@max(0, t.cur_row))), rows - 1)); - const line = modal.lineSlice(text, @intCast(t.cur_row)); - t.cur_col = @intCast(modal.graphemeStart(line, @min(line.len, @as(usize, @intCast(@max(0, t.cur_col)))))); - t.cur_pinned = true; - t.vsel.active = false; - t.msel.active = false; - t.nsel = 0; - t.select = false; - t.append_at = null; - t.mode = .normal; - t.normal.clear(); - } - /// Cycle only the body modes supported by this pane. pub fn cycleMode(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; if (pane.file == null and !pane.isTerminal()) return; const previous = pane.body.mode; p.active = id; - p.exitHeader(); + tagline.exitHeader(p); pane.focus = .body; pane.closePrompt(); switch (previous) { @@ -5068,7 +4796,7 @@ pub const Pardes = struct { const pane = p.panes[id] orelse return; if (!pane.isTerminal()) return; p.active = id; - p.exitHeader(); + tagline.exitHeader(p); pane.focus = .body; pane.closePrompt(); if (pane.body.mode == .tty) { @@ -5089,7 +4817,7 @@ pub const Pardes = struct { return; } const pane = p.panes[p.active] orelse return; - const header = p.focusedHeader(); + const header = tagline.focusedHeader(p); if (header == null and pane.isTerminal() and pane.body.mode == .tty and pane.focus == .body and pane.prompt == .none) { p.leader_on = false; p.ctrl_w_pending = false; @@ -5136,7 +4864,7 @@ pub const Pardes = struct { // global window ops (any mode): Alt-n new terminal below, Alt-c move // the active terminal into a fresh column if (hit(key, config.new_shell_below)) { - p.exitHeader(); + tagline.exitHeader(p); const free = p.freeSlot() orelse return; const nt = p.newShell(free, "") catch return; nt.greet = true; @@ -5150,11 +4878,11 @@ pub const Pardes = struct { } for (config.jump_keys) |jk| { if (!hit(key, &.{jk.chord})) continue; - p.exitHeader(); + tagline.exitHeader(p); return exec.runBuiltin(p, jk.cmd, p.active, "", null); } if (hit(key, config.pane_to_new_column)) { - p.exitHeader(); + tagline.exitHeader(p); const f = layout.findPane(p, p.active).?; if (p.ncol < MAX_COLS and p.col_n[f.col] > 1) { _ = layout.splitColumn(p, p.active, p.active, false); @@ -5170,7 +4898,7 @@ pub const Pardes = struct { // Enter or the execute key commits the draft and the keyboard // goes back to the body; Esc drops it. if (hit(key, config.look_key) or hit(key, config.exec_key)) { - if (p.commitTagName(pane)) pane.focus = .body; + if (tagline.commitTagName(p, pane)) pane.focus = .body; return; } if (hit(key, config.escape)) { @@ -5209,7 +4937,7 @@ pub const Pardes = struct { // text as it is shown: a tag's prefix is part of what a word is. const arena = p.scratch.allocator(); const own = arena.dupe(u8, edit.flatSurface(p, t) catch return) catch return; - const shown = if (t.what == .tag) p.tagText(arena, pane) catch return else own; + const shown = if (t.what == .tag) tagline.tagText(p, arena, pane) catch return else own; const range = t.primaryRange(own, 0); const lo = @min(range.anchor, range.head); const hi = @max(range.anchor, range.head); @@ -5221,7 +4949,7 @@ pub const Pardes = struct { }; // Out of the tag or header FIRST: Del frees the pane, Kill // and Restore the whole session. - if (header != null) p.exitHeader() else pane.focus = .body; + if (header != null) tagline.exitHeader(p) else pane.focus = .body; if (txt.len > 0) exec.runBuiltin(p, cmd, p.active, "", txt); return; } @@ -5615,90 +5343,6 @@ pub const Pardes = struct { // ---- dump / load (acme-style: `pardes -l state.zon` restores a session) ---- - pub fn restoreDumpTail(p: *Pardes, pane: *Pane, src: dump.Pane) void { - if (src.tag_tail) |tail| { - pane.tag.own = p.gpa.dupe(u8, tail) catch return; - 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)) - return p.restoreTailAt(pane, src.tag, legacy, .terminal); - } - const current = p.tagPrefix(pane) catch return; - if (std.mem.startsWith(u8, src.tag, current)) - return p.restoreTailAt(pane, src.tag, savedPrefix(src.tag, current, tail_class), tail_class); - if (src.kind != .image) return; - const saved = src.image.?; - if (pane.image) |*state| if (panes.Image.legacySavedPrefix(state, src.tag)) |legacy| - return p.restoreTailAt(pane, src.tag, legacy, .generic); - if (panes.Pdf.isPath(saved.path)) if (panes.Pdf.legacySavedPrefix(saved.path, src.tag)) |legacy| - p.restoreTailAt(pane, src.tag, legacy, .generic); - } - - fn restoreTail(p: *Pardes, pane: *Pane, saved_tag: []const u8) void { - const pfx = p.tagPrefix(pane) catch return; - const class = tailClass(pane); - p.restoreTailAt(pane, saved_tag, savedPrefix(saved_tag, pfx, class), class); - } - - const TailClass = enum { generic, file, output, terminal }; - - fn tailClass(pane: *const Pane) TailClass { - if (pane.file) |f| return if (f.output == null) .file else .output; - if (pane.isTerminal()) return .terminal; - return .generic; - } - - fn savedPrefix(saved_tag: []const u8, live: []const u8, class: TailClass) []const u8 { - if (class == .generic or class == .terminal) return live; - if (!std.mem.startsWith(u8, saved_tag, live)) return live; - const marked_len = live.len + dirty_marker.len; - if (saved_tag.len >= marked_len and std.mem.eql(u8, saved_tag[live.len..marked_len], dirty_marker)) - return saved_tag[0..marked_len]; - return live; - } - - fn restoreTailAt( - p: *Pardes, - pane: *Pane, - saved_tag: []const u8, - pfx: []const u8, - class: TailClass, - ) void { - if (!std.mem.startsWith(u8, saved_tag, pfx)) return; - const rest = saved_tag[pfx.len..]; - const said = std.mem.trimStart(u8, rest, " "); - const defaults: []const []const u8 = switch (class) { - .generic => &.{ pdf_pane_tail, pane_tail, pre_close_last_pdf_pane_tail, pre_close_last_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 => &.{ context_pane_tail, file_pane_tail, pre_close_last_file_pane_tail, pre_collapse_file_pane_tail, pre_column_file_pane_tail, prev_file_pane_tail, legacy_file_pane_tail }, - .output => &.{ - locations_pane_tail, - file_pane_tail, - pre_close_last_file_pane_tail, - pre_close_last_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, pre_close_last_terminal_pane_tail, pre_close_last_pane_tail, pre_mode_terminal_pane_tail, pre_tty_first_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; - pane.tag.own = p.gpa.dupe(u8, rest) catch return; - } - /// Close a pane; its rows go to `to`, or where layout.absorbVWeight /// sends them when that is null. pub fn removePane(p: *Pardes, id: usize, to: ?usize) !void { @@ -5854,7 +5498,7 @@ pub const Pardes = struct { /// deferred greetings. The mirror of the prototype's loop epilogue. pub fn sync(p: *Pardes) void { if (p.header_column != null and p.columnBarHeight() == 0) { - p.exitHeader(); + tagline.exitHeader(p); } p.reapPanes(); layout.compute(p); @@ -5886,7 +5530,7 @@ pub const Pardes = struct { const r = p.rects[id]; // The tag takes a row per line, leaving the body one row if it // had any; a folded pane shows its first line only. - const lines = std.mem.count(u8, curTail(pane), "\n") + 1; + const lines = std.mem.count(u8, tagline.curTail(pane), "\n") + 1; pane.tag_rows = @intCast(@min(lines, MAX_TAG_ROWS, @max(BOX_H, r.h -| 1))); if (pane.collapsed) pane.tag_rows = BOX_H; const cols = @max(1, r.w -| config.GUTTER); @@ -5976,7 +5620,7 @@ pub const Pardes = struct { return if (p.settings.tag_bottom) r.y + r.h -| pane.tag_rows else r.y; } - fn tagCapacity(p: *const Pardes, physical_width: u16) u16 { + pub fn tagCapacity(p: *const Pardes, physical_width: u16) u16 { const layer: TagLayer = .{ .viewport = .{ .x = 0, .y = 0, .w = physical_width, .h = 1 } }; return layer.capacity(p.row_metrics); } @@ -6107,110 +5751,8 @@ pub const Pardes = struct { } } - // global tagbar: full width, top rows — unless a native shell has - // taken its commands over, in which case the rows are not ours to draw - p.revealHeader(); - const global = p.topbar(); - const global_rows = p.topBarHeight(); - const global_scroll = if (p.header_focus and p.header_column == null) p.header_scroll else 0; - if (p.settings.workspace_tag) { - s.fill(0, 0, s.cols, global_rows, .{ - .bg = .{ .rgb = chrome.tag_bg }, - .font_role = .tagline, - }); - for (0..global_rows) |row| { - const line = modal.lineSlice(global, row); - _ = s.print(0, @intCast(row), s.cols, line[panes.File.rawAtDisplay(line, global_scroll)..], .{ - .fg = .{ .rgb = chrome.tag_fg }, - .bg = .{ .rgb = chrome.tag_bg }, - .font_role = .tagline, - }); - } - } - if (p.pointer_inside and p.hover_row < global_rows) { - const bar = modal.lineSlice(global, p.hover_row); - 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), p.hover_row); - cell.default = false; - cell.style.bg = .{ .rgb = th.sel_bg }; - cell.style.fg = .{ .rgb = th.sel_fg }; - } - } - } - const column_rows = p.columnBarHeight(); - if (column_rows > 0) for (0..p.ncol) |c| { - const grip = @min(config.GUTTER, p.col_w[c]); - const inset = @min(TAG_TEXT_INSET, p.col_w[c]); - const x = p.col_x[c] + inset; - const w = p.col_w[c] - inset; - const y0 = global_rows; - if (p.col_w[c] == 0 or p.col_x[c] >= 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; - // A grip is muted until it is grabbed; then it lights, as a pane's - // box does for the active pane, so the drag shows what it holds. - const grabbed = p.drag == .column_move and layout.columnBySerial(p, p.drag.column_move.serial) == c; - const grip_bg = if (grabbed) chrome.column_box else chrome.column_box_dim; - const lum = (@as(u16, grip_bg[0]) * 3 + @as(u16, grip_bg[1]) * 6 + @as(u16, grip_bg[2])) / 10; - const ink: [3]u8 = if (lum > 140) .{ 0, 0, 0 } else .{ 255, 255, 255 }; - const grip_style: CellStyle = .{ .bg = .{ .rgb = grip_bg }, .fg = .{ .rgb = ink }, .font_role = .tagline }; - s.fill(p.col_x[c], y0, grip, column_rows, grip_style); - s.fill(p.col_x[c] + grip, y0, inset - grip, column_rows, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); - - s.fill(x, y0, w, column_rows, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); - const bar_scroll = if (p.header_focus and p.header_column == c) p.header_scroll else 0; - const tag = p.columnTag(c); - for (0..column_rows) |row| { - const line = modal.lineSlice(tag, row); - _ = s.print(x, y0 + @as(u16, @intCast(row)), w, line[panes.File.rawAtDisplay(line, bar_scroll)..], .{ - .fg = .{ .rgb = fg }, - .bg = .{ .rgb = bg }, - .font_role = .tagline, - }); - } - // A carried column's pointer is aiming at a place, not a word. - if (p.pointer_inside and p.drag != .column_move and p.hover_row >= y0 and p.hover_row < y0 + column_rows and p.hover_col >= x and p.hover_col < x + w) { - const bar = modal.lineSlice(tag, p.hover_row - y0); - 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)), p.hover_row); - cell.style.bg = .{ .rgb = th.sel_bg }; - cell.style.fg = .{ .rgb = th.sel_fg }; - } - } - } - }; - if (p.focusedHeader()) |t| { - const text = try edit.flatSurface(p, t); - const x = if (p.header_column) |c| p.col_x[c] + TAG_TEXT_INSET else 0; - const w = if (p.header_column) |c| p.col_w[c] -| TAG_TEXT_INSET else s.cols; - const y: u16 = if (p.header_column != null) global_rows else 0; - const rows = if (p.header_column != null) column_rows else global_rows; - for (0..rows) |row| { - const bar = modal.lineSlice(text, row); - const span = headerSelection(t, text, row) orelse continue; - var col = panes.File.rawDisplayCol(bar, span.lo) -| p.header_scroll; - const hi = panes.File.rawDisplayCol(bar, span.hi) -| p.header_scroll; - while (col < hi and col < w) : (col += 1) { - const cell = s.at(x + @as(u16, @intCast(col)), y + @as(u16, @intCast(row))); - cell.style.fg = .{ .rgb = th.sel_fg }; - cell.style.bg = .{ .rgb = th.sel_bg }; - } - } - if (t.cur_row >= 0 and t.cur_row < rows) { - const bar = modal.lineSlice(text, @intCast(t.cur_row)); - const caret = panes.File.rawDisplayCol(bar, @min(@as(usize, @intCast(@max(0, t.cur_col))), bar.len)) -| p.header_scroll; - if (caret < w) s.cursor = .{ .x = x + @as(u16, @intCast(caret)), .y = y + @as(u16, @intCast(t.cur_row)), .bar = t.mode == .insert }; - } - } - - try p.renderTagLayers(arena); + try body_layer.paintHeaders(p); + try body_layer.renderTagLayers(p, arena); switch (p.drag) { .border_v => |d| { @@ -6450,204 +5992,6 @@ pub const Pardes = struct { return !cell.default and !(cell.len == 1 and cell.text[0] == ' '); } - fn renderTagLayers(p: *Pardes, arena: std.mem.Allocator) !void { - const chrome = p.chromeTheme(); - for (p.panes, 0..) |slot, id| { - const pane = slot orelse continue; - const r = p.rects[id]; - if (r.w <= TAG_TEXT_INSET or r.h == 0) continue; - const tag = try p.tagText(arena, pane); - const rows = pane.tag_rows; - // As wide as the widest line it shows, and never narrower than - // its viewport. - var widest: usize = 0; - var split = std.mem.splitScalar(u8, tag, '\n'); - for (0..rows) |_| widest = @max(widest, panes.File.displayWidth(split.next() orelse "")); - const columns: u16 = @intCast(@min(std.math.maxInt(u16) - TAG_TEXT_INSET, @max(p.tagCapacity(r.w - TAG_TEXT_INSET), widest + 1))); - const pitch = @as(usize, columns) + TAG_TEXT_INSET; - const storage = try arena.alloc(Cell, pitch * rows); - @memset(storage, .{}); - var temporary: Surface = .{ .cols = columns + TAG_TEXT_INSET, .rows = rows, .cells = storage }; - std.mem.swap(Surface, &p.surface, &temporary); - p.paintPaneTag(arena, pane, .{ .x = 0, .y = 0, .w = columns + TAG_TEXT_INSET, .h = rows }, id, id == p.active, p.tagCapacity(r.w - TAG_TEXT_INSET)) catch |err| { - std.mem.swap(Surface, &p.surface, &temporary); - return err; - }; - std.mem.swap(Surface, &p.surface, &temporary); - // A layer to a line: the first in the pane's own slot, the rest - // in the slots kept for the lines of taller tags. - for (0..rows) |line| { - const layer = &p.surface.tag_layers[if (line == 0) id else TAG_LINE_LAYER_BASE + id * (MAX_TAG_ROWS - 1) + line - 1]; - if (layer.cells.len != columns) layer.cells = try p.gpa.realloc(layer.cells, columns); - @memcpy(layer.cells, storage[line * pitch + TAG_TEXT_INSET ..][0..columns]); - layer.kind = .pane; - layer.id = @intCast(id); - layer.serial = pane.serial; - layer.line = @intCast(line); - layer.viewport = .{ .x = r.x + TAG_TEXT_INSET, .y = p.tagTop(pane, r) + @as(u16, @intCast(line)), .w = r.w - TAG_TEXT_INSET, .h = BOX_H }; - layer.cols = columns; - layer.bg = if (id == p.active and p.settings.focus_tint) chrome.tag_active_bg else chrome.tag_bg; - layer.cursor = if (p.header_focus) null else if (temporary.cursor) |cursor| - (if (cursor.y == line) .{ .col = cursor.x - TAG_TEXT_INSET, .bar = cursor.bar } else null) - else - null; - } - } - // With the workspace tag row handed to native chrome there is no - // workspace layer to render; the menu bar carries its commands. - if (p.settings.workspace_tag) { - for (0..p.topBarHeight()) |line| { - const index = if (line == 0) MAX_PANES + MAX_COLS else HEADER_LINE_LAYER_BASE + MAX_COLS * (MAX_TAG_ROWS - 1) + line - 1; - try p.renderHeaderLayer(arena, index, .workspace, 0, @intCast(line), .{ .x = 0, .y = @intCast(line), .w = p.screen_w, .h = TOPBAR_H }, modal.lineSlice(p.topbar(), line), chrome.tag_bg, chrome.tag_fg); - } - } - // Each notice is its own tagline-shaped chip over the top of the body. - // Going through the same layer machinery the pane and column tags use - // is what gives it the tagline's height, its small-font metrics, the - // band offset and the border -- none of which a body-grid row can have - // just by wearing a tagline font. The geometry is the one the grid - // pass already chose and recorded, so the two cannot disagree and the - // GUI's "skip the cells a tag layer covers" leaves no stub behind. - // A prompt is one of them too: left on the grid, a pixel shell drew - // its tagline glyphs one to a BODY cell, spaced out like a banner. - for (p.panes, 0..) |slot, id| { - const pane = slot orelse continue; - const r = p.rects[id]; - if (r.w <= TAG_TEXT_INSET or r.h <= pane.tag_rows) continue; - const first = p.bodyTop(pane, r); - var leader_buf: [16]u8 = undefined; - for (pane.notices.slice(), 0..) |kind, i| { - const text = Messages.noticeText(p, pane, i, &leader_buf); - if (text.len == 0) continue; - const cx = pane.notices.left[i]; - if (cx == std.math.maxInt(u16) or cx >= r.x + r.w) continue; - const chip = r.x + r.w - cx; - // Right aligned inside the chip, a blank cell short of its - // edge: the same one the grid pass leaves for a prompt caret. - // Wider than the chip, the text loses its head, as on the grid. - const room = p.tagCapacity(chip) -| 1; - const shown = panes.File.displayWidth(text); - var kept = text; - if (shown > room) { - var start = panes.File.rawAtDisplay(text, shown - room); - if (panes.File.rawDisplayCol(text, start) < shown - room) start = panes.File.rawAtDisplay(text, shown - room + 1); - kept = text[start..]; - } - const pad = room -| panes.File.displayWidth(kept); - const line = try arena.alloc(u8, pad + kept.len); - @memset(line[0..pad], ' '); - @memcpy(line[pad..], kept); - // A pixel shell slides the message in as it fades up, and - // fades it on the way out. - const motion: Messages.MessageMotion = if (Messages.noticeLife(pane, i)) |life| Messages.messageMotion(p, life) else .{}; - const fade = motion.alpha; - const page = p.theme().bg orelse chrome.tag_bg; - const layer_index = NOTICE_LAYER_BASE + id * Pane.Notices.max + i; - try p.renderHeaderLayer(arena, layer_index, .notice, @intCast(id), 0, .{ - .x = cx, - .y = first + @as(u16, pane.notices.row[i]), - .w = chip, - .h = BOX_H, - }, line, Messages.blendRgb(chrome.tag_bg, page, fade), Messages.blendRgb(chrome.tag_fg, page, fade)); - p.surface.tag_layers[layer_index].slide = motion.slide; - p.surface.tag_layers[layer_index].fade = fade; - // The caret rides the band, where the prompt's glyphs are, - // at the same place in the text the grid pass puts it. - if (kind != .prompt or id != p.active or pane.prompt == .del_side) continue; - const typed = panes.File.displayWidth(text[0..@min(@as(usize, @intCast(@max(0, pane.input.cur_col))), text.len)]); - const dropped = shown - panes.File.displayWidth(kept); - if (typed < dropped) continue; - p.surface.tag_layers[layer_index].cursor = .{ .col = @intCast(pad + typed - dropped), .bar = pane.input.mode == .insert }; - } - } - if (p.columnBarHeight() > 0) for (0..p.ncol) |column| { - if (p.col_w[column] == 0) continue; - const active = if (layout.findPane(p, p.active)) |found| found.col == column and p.settings.focus_tint else false; - for (0..p.columnBarHeight()) |line| { - const index = if (line == 0) MAX_PANES + column else HEADER_LINE_LAYER_BASE + column * (MAX_TAG_ROWS - 1) + line - 1; - try p.renderHeaderLayer(arena, index, .column, @intCast(column), @intCast(line), .{ .x = p.col_x[column] + @min(TAG_TEXT_INSET, p.col_w[column]), .y = p.topBarHeight() + @as(u16, @intCast(line)), .w = p.col_w[column] -| TAG_TEXT_INSET, .h = COLBAR_H }, modal.lineSlice(p.columnTag(column), line), if (active) chrome.tag_active_bg else chrome.tag_bg, if (active) chrome.tag_active_fg else chrome.tag_fg); - } - }; - } - - /// The span a header's selection covers on line `row` of its text, in - /// that line's bytes: up to the caret while typing, and over the - /// cursor's own cell too in normal mode, where a cursor is a cell. - pub fn headerSelection(t: *const Text, text: []const u8, row: usize) ?struct { lo: usize, hi: usize } { - if (!t.vsel.active) return null; - const anchor = modal.offsetAt(text, .{ .row = @intCast(@max(0, t.vsel.row)), .col = @intCast(@max(0, t.vsel.col)) }); - const cursor = modal.offsetAt(text, .{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) }); - const lo = @min(anchor, cursor); - const hi = if (t.mode == .insert) @max(anchor, cursor) else modal.nextGrapheme(text, @max(anchor, cursor)); - const start = modal.lineStartOffset(text, row); - const end = modal.lineEndOffset(text, row); - if (hi <= start or lo >= end) return null; - return .{ .lo = @max(lo, start) - start, .hi = @min(hi, end) - start }; - } - - /// One row of a header or a notice band as a tag layer: `text` is line - /// `line` of what it shows. - fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, index: usize, kind: TagKind, id: u16, line: u16, viewport: Rect, text: []const u8, bg: [3]u8, fg: [3]u8) !void { - _ = arena; - const layer = &p.surface.tag_layers[index]; - if (viewport.w == 0) { - layer.cols = 0; - return; - } - const columns: u16 = @intCast(@min(std.math.maxInt(u16), @max(p.tagCapacity(viewport.w), panes.File.displayWidth(text) + 1))); - if (layer.cells.len != columns) layer.cells = try p.gpa.realloc(layer.cells, columns); - layer.kind = kind; - layer.id = id; - layer.line = line; - layer.serial = if (kind == .column) p.col_serial[id] else 0; - layer.viewport = viewport; - layer.cols = columns; - layer.bg = bg; - layer.cursor = null; - layer.slide = 0; - layer.fade = 1; - var surface: Surface = .{ .cols = columns, .rows = 1, .cells = layer.cells }; - // A notice band carries a PANE id, not a column index, so it must not - // answer to the column being edited -- that would scroll and select - // its text with the column tag's offsets and give it a second caret. - const editing: ?*Text = if (p.header_focus and switch (kind) { - .workspace => p.header_column == null, - .column => p.header_column == id, - .pane, .notice => false, - }) p.focusedHeader() else null; - const scroll = if (editing != null) p.header_scroll else 0; - surface.fill(0, 0, columns, 1, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); - _ = surface.print(0, 0, columns, text[panes.File.rawAtDisplay(text, scroll)..], .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, .font_role = .tagline }); - if (editing) |t| { - if (headerSelection(t, try edit.flatSurface(p, t), line)) |span| { - var col = panes.File.rawDisplayCol(text, span.lo) -| scroll; - const end = panes.File.rawDisplayCol(text, span.hi) -| scroll; - while (col < end and col < columns) : (col += 1) { - surface.at(@intCast(col), 0).style.bg = .{ .rgb = p.theme().sel_bg }; - surface.at(@intCast(col), 0).style.fg = .{ .rgb = p.theme().sel_fg }; - } - } - const caret = panes.File.rawDisplayCol(text, @min(@as(usize, @intCast(@max(0, t.cur_col))), text.len)) -| scroll; - if (t.cur_row == line and caret < columns) layer.cursor = .{ .col = @intCast(caret), .bar = t.mode == .insert }; - } else if (p.pointer_inside) { - const tag_hit = p.pointer_tag_hit; - const col: ?usize = if (tag_hit) |pointer| - (if (pointer.kind == kind and pointer.id == id and pointer.line == line) pointer.col else null) - else if (p.hover_row == viewport.y and p.hover_col >= viewport.x and p.hover_col - viewport.x < viewport.w) - p.hover_col - viewport.x - else - null; - if (col) |hover| if (wordBoundsAtCol(text, panes.File.rawAtDisplay(text, hover + scroll))) |bounds| { - var cell = panes.File.rawDisplayCol(text, bounds.lo) -| scroll; - const end = panes.File.rawDisplayCol(text, bounds.hi) -| scroll; - while (cell < end and cell < columns) : (cell += 1) { - surface.at(@intCast(cell), 0).style.bg = .{ .rgb = p.theme().sel_bg }; - surface.at(@intCast(cell), 0).style.fg = .{ .rgb = p.theme().sel_fg }; - } - }; - } - } - fn renderPane(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool) !void { const tz = tracy.zone(@src(), "renderPane"); defer tz.end(); @@ -6687,7 +6031,7 @@ pub const Pardes = struct { .tty => config.tag_tty, }, box_style); - try p.paintPaneTag(arena, pane, r, id, active, p.tagCapacity(r.w -| TAG_TEXT_INSET)); + try body_layer.paintPaneTag(p, arena, pane, r, id, active, p.tagCapacity(r.w -| TAG_TEXT_INSET)); // Folded panes retain their body state, but neither body pixels nor // its cursor belong outside the one-row tag rectangle. @@ -6714,127 +6058,6 @@ pub const Pardes = struct { try body_layer.renderBody(p, arena, pane, r, id, active, true); } - fn paintPaneTag(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id: usize, active: bool, capacity: u16) !void { - const s = &p.surface; - const th = p.theme(); - const chrome = p.chromeTheme(); - const tx = r.x + @min(TAG_TEXT_INSET, r.w); - const tw = r.w -| TAG_TEXT_INSET; - const tag_y = p.tagTop(pane, r); - const body_y = p.bodyTop(pane, r); - const rows = pane.tag_rows; - const page_rgb = th.bg orelse th.tag_bg; - // 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(r.x + config.GUTTER, tag_y, @min(TAG_GAP, r.w -| config.GUTTER), rows, .{ .bg = .{ .rgb = tag_bg }, .font_role = .tagline }); - s.fill(tx, tag_y, tw, rows, .{ - .bg = .{ .rgb = tag_bg }, - .font_role = .tagline, - }); - const whole = try p.tagText(arena, pane); - const tail = curTail(pane); - const prefix_len = whole.len - tail.len; - // A row per line: the first carries the prefix, the rest are the - // tag's own text alone. - var lines: [MAX_TAG_ROWS][]const u8 = @splat(""); - var split = std.mem.splitScalar(u8, whole, '\n'); - for (lines[0..rows]) |*line| line.* = split.next() orelse ""; - // The keyboard's caret, as a row and a byte offset in that row's - // line: in the tag's own text when the tag has the keyboard, in the - // path while a name is drafted. - const Caret = struct { row: usize, at: usize }; - const caret: ?Caret = if (!active) - null - else if (pane.prompt == .name) - .{ .row = 0, .at = @min(lines[0].len, @as(usize, @intCast(@max(0, pane.input.cur_col)))) } - else if (pane.focus == .tag and pane.tag.cur_row < rows) caret: { - const row: usize = @intCast(@max(0, pane.tag.cur_row)); - const lead: usize = if (row == 0) prefix_len else 0; - break :caret .{ .row = row, .at = @min(lines[row].len, lead + @as(usize, @intCast(@max(0, pane.tag.cur_col)))) }; - } else null; - if (caret) |at| { - const line = lines[at.row]; - const col = panes.File.rawDisplayCol(line, at.at); - var scroll: usize = pane.tag_scroll; - if (col < scroll) scroll = col; - if (col >= scroll + capacity) scroll = col - capacity + 1; - var raw = panes.File.rawAtDisplay(line, scroll); - scroll = panes.File.rawDisplayCol(line, raw); - if (col >= scroll + capacity) { - raw = modal.nextGrapheme(line, raw); - scroll = panes.File.rawDisplayCol(line, raw); - } - pane.tag_scroll = @intCast(scroll); - } else if (pane.focus != .tag) pane.tag_scroll = 0; - for (lines[0..rows], 0..) |line, row| { - const start = panes.File.rawAtDisplay(line, pane.tag_scroll); - _ = s.print(tx, tag_y + @as(u16, @intCast(row)), tw, line[start..], tag_style); - } - const tag = lines[0]; - if (tagTintRange(pane, tag, prefix_len)) |name| { - const lo = @max(name.lo, panes.File.rawAtDisplay(tag, pane.tag_scroll)); - const hi = @min(name.hi, tag.len); - if (lo < hi) { - var col = panes.File.rawDisplayCol(tag, lo) -| pane.tag_scroll; - const end = panes.File.rawDisplayCol(tag, hi) -| pane.tag_scroll; - const name_fg = if (active and p.settings.focus_tint) chrome.tag_active_name_fg else chrome.tag_name_fg; - while (col < end and col < tw) : (col += 1) - s.at(tx + @as(u16, @intCast(col)), tag_y).style.fg = .{ .rgb = name_fg }; - } - } - 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) < rows) { - const preview_bg = colors.mix(page_rgb, colors.mix(page_rgb, th.sel_bg)); - body_layer.paintPointerSelection(p, pane, r, tx, tw, tag_y, body_y, sel, preview_bg, null); - }; - } - // The tag's own selections, while it has the keyboard, a line at a - // time. A range of one grapheme is the block cursor, not a selection. - if (pane.focus == .tag) { - var ranges: [Text.max_selections]modal.Selection = undefined; - const got = pane.tag.ranges(tail, 0, &ranges); - for (ranges[0..got.n]) |range| { - const lo = @min(range.anchor, range.head); - const hi = @max(range.anchor, range.head); - if (modal.nextGrapheme(tail, lo) >= hi) continue; - const first = modal.positionAt(tail, lo); - const last = modal.positionAt(tail, hi); - var row = first.row; - while (row <= last.row and row < rows) : (row += 1) { - const line = lines[row]; - const lead: usize = if (row == 0) prefix_len else 0; - const from = lead + if (row == first.row) first.col else 0; - const to = @min(line.len, lead + if (row == last.row) last.col else modal.lineSlice(tail, row).len); - var col = panes.File.rawDisplayCol(line, from) -| pane.tag_scroll; - const end = panes.File.rawDisplayCol(line, to); - const y = tag_y + @as(u16, @intCast(row)); - while (col + pane.tag_scroll < end and col < tw) : (col += 1) { - const cell = s.at(tx + @as(u16, @intCast(col)), y); - cell.default = false; - cell.style.bg = .{ .rgb = th.sel_bg }; - cell.style.fg = .{ .rgb = th.sel_fg }; - } - } - } - } - // the caret: bar while typing, block in normal mode (same rule as a body) - if (caret) |at| { - const col = panes.File.rawDisplayCol(lines[at.row], at.at) -| pane.tag_scroll; - const mode = if (pane.prompt == .name) pane.input.mode else pane.tag.mode; - if (col < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(col)), .y = tag_y + @as(u16, @intCast(at.row)), .bar = mode == .insert }; - } - - for (pane.sel, 0..) |selection, button| { - if (selection.state == .none or (selection.state == .done and button != 0)) continue; - if (@min(selection.r0, selection.r1) >= rows) continue; - body_layer.paintPointerSelection(p, pane, r, tx, tw, tag_y, body_y, selection, th.sel_bg, th.sel_fg); - } - } - pub fn appendImagePlace(p: *Pardes, place: ImagePlace) bool { const s = &p.surface; if (s.nimages == s.images.len) { @@ -6954,7 +6177,7 @@ test "raw terminal cursor obeys visibility without hiding modal and tag cursors" const modal_cursor = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; try std.testing.expectEqual(rect.x + config.GUTTER + 1, modal_cursor.x); try std.testing.expectEqual(body_y, modal_cursor.y); - p.enterTag(pane); + tagline.enterTag(p, pane); const tag_cursor = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; try std.testing.expectEqual(if (p.settings.tag_bottom) rect.y + rect.h - BOX_H else rect.y, tag_cursor.y); pane.focus = .body; |
