From 42748f92b77367db415c8b4d0f2e537a9e1ddde7 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 00:13:11 -0300 Subject: Make the column and workspace tags Texts with the pane tag's keys The headers had their own one-line editor with its own keys: Enter always executed, h/l and J/K moved between headers and panes, Ctrl-c/x/v cut and pasted, and TagLine refused a newline that 9P accepted. They are now Texts like a pane's tag, with no prefix and a default until edited, so normal and insert mode, undo and the look and execute keys are the body's. Moving between them is the window keys' job: Up from a pane with nothing above it reaches its column's tag and then the workspace's, Down comes back, and Left and Right walk the column tags. Co-Authored-By: Claude Opus 5.5 --- src/pardes.zig | 471 ++++++++++++++++++++++++--------------------------------- 1 file changed, 198 insertions(+), 273 deletions(-) (limited to 'src/pardes.zig') diff --git a/src/pardes.zig b/src/pardes.zig index ce7c7065..60c41f50 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -258,7 +258,6 @@ 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 const TAG_GAP: u16 = 1; pub const TAG_TEXT_INSET: u16 = config.GUTTER + TAG_GAP; @@ -2702,17 +2701,19 @@ test "tabbed file aligns syntax cursor and mouse at the nearest insertion gap" { try std.testing.expect(look.expandedSel(p, pane, .{ .state = .dragging, .c0 = separator, .c1 = separator, .r0 = BOX_H, .r1 = BOX_H }) == null); } -test "editable workspace and column tags replace selections and persist" { +test "editable workspace and column tags are typed into 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); + // a sweep over a header is its selection, the caret where it ended 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")); + try std.testing.expect(p.global_tag.vsel.active and p.global_tag.vsel.col == 0 and p.global_tag.cur_col == 6); + p.update(.{ .key = .{ .cp = 'H', .text = " Help" } }); + try std.testing.expect(std.mem.startsWith(u8, p.global_tag.own.?, "Newcol Help Joincol")); p.update(.{ .key = .{ .cp = Key.escape } }); 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 } }); @@ -2726,9 +2727,10 @@ test "editable workspace and column tags replace selections and persist" { try dump.dumpState(p); const restored = try dump.restore(p, p.dump_out.?); defer restored.deinit(); - try std.testing.expectEqualStrings(p.global_tag.slice(""), restored.global_tag.slice("")); + 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.expect(p.col_tags[0].set("")); + 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(); @@ -2739,11 +2741,11 @@ 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.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.expect(p.col_tags[1].set("Right Grep")); + 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); @@ -2770,7 +2772,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(u16, 0), p.topbar_col.?); + try std.testing.expectEqual(@as(i32, 0), p.focusedHeader().?.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 } }); @@ -2778,13 +2780,15 @@ test "column tags render edit hover and scroll from the column edge" { 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")); + 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")); const edited = try p.render(frame.allocator()); - try std.testing.expectEqual(x + 4, edited.cursor.?.x); + try std.testing.expectEqual(x + 5, edited.cursor.?.x); // Only the text viewport participates in caret reveal. const long: [80]u8 = @splat('a'); - try std.testing.expect(p.col_tags[column].set(long[0 .. width - 1])); + if (p.col_tags[column].own) |own| p.gpa.free(own); + p.col_tags[column].own = try p.gpa.dupe(u8, 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); @@ -2827,7 +2831,7 @@ 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.col_tags[0].own = try p.gpa.dupe(u8, "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 } }); @@ -2856,21 +2860,21 @@ test "ColumnTags reclaims the row and releases hidden header focus without losin 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.col_tags[0].own = try p.gpa.dupe(u8, "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 = TAG_TEXT_INSET, .row = TOPBAR_H } }); p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = TAG_TEXT_INSET, .row = TOPBAR_H } }); - try std.testing.expect(p.header_column != null and p.topbar_col != null); + try std.testing.expect(p.header_focus and p.header_column != 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.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)); p.update(.{ .command = "ColumnTags" }); @@ -2884,25 +2888,63 @@ test "ColumnTags reclaims the row and releases hidden header focus without losin 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); + 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("unchanged body\n", pane.file.?.content); } -test "normal header Enter executes its command rather than searching for it" { +test "a header's execute key runs the word under its cursor and lets go of the keyboard first" { 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); + p.enterHeader(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); try std.testing.expect(p.dump_out != null); try std.testing.expectEqual(@as(usize, 1), p.col_n[0]); } +test "window keys walk from a pane up through the column and workspace tags and back" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 20, .tty_only = true }); + defer p.deinit(); + _ = try p.setTestFile("body\n"); + p.newScratchColumn(0); + p.sync(); + p.active = p.col_panes[0][0]; + const window = struct { + fn key(core: *Pardes, letter: u21) void { + core.update(.{ .key = .{ .cp = 'w', .ctrl = true } }); + core.update(.{ .key = .{ .cp = letter } }); + } + }; + // Up from the top pane: its column's tag, in normal mode; `:` there is + // the way back to the body. + window.key(p, 'k'); + try std.testing.expect(p.header_focus and p.header_column.? == 0); + try std.testing.expectEqual(Text.Mode.normal, p.col_tags[0].mode); + p.update(.{ .key = .{ .cp = 'w' } }); + try std.testing.expect(p.col_tags[0].cur_col > 0); + const remembered = p.col_tags[0].cur_col; + window.key(p, 'l'); + try std.testing.expect(p.header_focus and p.header_column.? == 1); + window.key(p, 'h'); + try std.testing.expectEqual(remembered, p.col_tags[0].cur_col); + window.key(p, 'k'); + try std.testing.expect(p.header_focus and p.header_column == null); + window.key(p, 'j'); + try std.testing.expect(p.header_focus and p.header_column.? == 0); + window.key(p, 'j'); + try std.testing.expect(!p.header_focus); + try std.testing.expectEqual(p.col_panes[0][0], p.active); + window.key(p, 'k'); + p.update(.{ .key = .{ .cp = ':' } }); + try std.testing.expect(!p.header_focus and p.panes[p.active].?.focus == .body); + try std.testing.expectEqualStrings("body\n", p.panes[0].?.file.?.content); +} + pub const Mouse = struct { /// The host has inverted a compact body layer. Physical col/row still /// route pane chrome; these coordinates address the pane's logical text. @@ -3656,10 +3698,12 @@ 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(.{}), + /// Each column's tag and the workspace's: Texts like a pane's tag, with + /// no prefix, showing a default until someone edits them. + col_tags: [MAX_COLS]Text = @splat(.{ .what = .column }), col_serial: [MAX_COLS]u32 = @splat(0), next_column_serial: u32 = 1, - global_tag: TagLine = .{}, + global_tag: Text = .{ .what = .workspace }, // derived each sync rects: [MAX_PANES]Rect = @splat(.{}), col_x: [MAX_COLS]u16 = undefined, @@ -3716,10 +3760,11 @@ pub const Pardes = struct { leader_on: bool = false, leader_keys: [4]u8 = undefined, leader_n: u8 = 0, - topbar_col: ?u16 = null, + /// The keyboard is on a header: the column tag `header_column` names, or + /// the workspace's when that is null. + header_focus: bool = false, header_column: ?usize = null, - header_insert: bool = false, - header_anchor: ?u16 = null, + /// A mouse sweep is extending the focused header's selection. header_drag: bool = false, header_scroll: usize = 0, ov_pinch_scale: f32 = 1.0, @@ -3951,6 +3996,8 @@ pub const Pardes = struct { slot.* = null; }; const gpa = p.gpa; + for (&p.col_tags) |*t| t.deinit(gpa); + p.global_tag.deinit(gpa); if (p.yank) |y| gpa.free(y); if (p.dump_out) |d| gpa.free(d); p.fallback.deinit(); @@ -3994,13 +4041,21 @@ pub const Pardes = struct { return a; } - /// the topbar line: the fixed builtins, plus `Restore ` 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; + /// 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 ` 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 row's height: `TOPBAR_H` while the setting keeps @@ -4016,43 +4071,32 @@ pub const Pardes = struct { } pub fn columnTag(p: *const Pardes, c: usize) []const u8 { - return p.col_tags[c].slice("New Tty Find Grep Joincol Delcol"); + return p.col_tags[c].own orelse column_tag_default; } - pub 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 ""; + /// 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 cur = p.topbar_col orelse { + const t = p.focusedHeader() orelse { p.header_scroll = 0; return; }; - const text = p.headerText(); + const text = edit.flatSurface(p, t) catch return; + const line = modal.lineSlice(text, 0); 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(text, @min(cur, text.len)); + 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(text, p.header_scroll); - p.header_scroll = panes.File.rawDisplayCol(text, raw); + 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(text, raw); - p.header_scroll = panes.File.rawDisplayCol(text, raw); - } - } - - pub 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; + raw = modal.nextGrapheme(line, raw); + p.header_scroll = panes.File.rawDisplayCol(line, raw); } } @@ -4109,9 +4153,7 @@ pub const Pardes = struct { if (pane.file) |*file| panes.File.deinit(p, pane, file); if (pane.ovl) |o| p.gpa.free(o.text); if (pane.input.own) |text| p.gpa.free(text); - if (pane.tag.own) |text| p.gpa.free(text); - for (pane.tag.ed_undo[0..pane.tag.ed_undo_len]) |sn| if (sn.ovl) |o| p.gpa.free(o.text); - for (pane.tag.ed_redo[0..pane.tag.ed_redo_len]) |sn| if (sn.ovl) |o| p.gpa.free(o.text); + pane.tag.deinit(p.gpa); for (pane.body.ed_undo[0..pane.body.ed_undo_len]) |sn| if (sn.ovl) |o| p.gpa.free(o.text); for (pane.body.ed_redo[0..pane.body.ed_redo_len]) |sn| if (sn.ovl) |o| p.gpa.free(o.text); panes.Terminal.deinitEmulator(pane, p.gpa); @@ -4921,165 +4963,34 @@ pub const Pardes = struct { return true; } - fn lineMotion(text: []const u8, cur: usize, key: Key) ?modal.Selection { - const target: ?usize = if (hit(key, config.line_move_left)) - modal.prevGrapheme(text, cur) - else if (hit(key, config.line_move_right)) - modal.nextGrapheme(text, cur) - else if (hit(key, config.line_start)) - 0 - else if (hit(key, config.line_end)) - modal.prevGrapheme(text, text.len) - else if (hit(key, config.line_first_nonws)) - modal.firstNonWs(text) - else - null; - if (target) |t| return .{ .anchor = t, .head = t }; - const wt: ?modal.WordTarget = if (hit(key, config.next_word_start)) - .next_word_start - else if (hit(key, config.prev_word_start)) - .prev_word_start - else if (hit(key, config.next_word_end)) - .next_word_end - else if (hit(key, config.next_long_word_start)) - .next_long_word_start - else if (hit(key, config.prev_long_word_start)) - .prev_long_word_start - else if (hit(key, config.next_long_word_end)) - .next_long_word_end - else - null; - const t = wt orelse return null; - return modal.moveWord(text, .{ .anchor = cur, .head = modal.nextGrapheme(text, cur) }, 1, t); - } - - pub fn rememberHeader(p: *Pardes) void { - const col = p.topbar_col orelse return; - const line = if (p.header_column) |c| &p.col_tags[c] else &p.global_tag; - line.saved_col = col; - } - pub fn exitHeader(p: *Pardes) void { - p.rememberHeader(); - p.topbar_col = null; + p.header_focus = false; p.header_column = null; - p.header_anchor = null; p.header_drag = false; p.header_scroll = 0; } - fn enterHeader(p: *Pardes, column: ?usize) void { - p.rememberHeader(); + /// 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 line = if (column) |c| &p.col_tags[c] else &p.global_tag; - const text = p.headerText(); - p.topbar_col = @intCast(modal.graphemeStart(text, @min(line.saved_col orelse 0, text.len))); - p.header_insert = false; - p.header_anchor = null; - p.header_drag = false; - p.header_scroll = 0; - } - - fn topbarKey(p: *Pardes, key: Key) void { - const bar = p.headerText(); - // the line GROWS a `Restore ` 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) or (!p.header_insert and hit(key, &.{.{ .cp = 'j' }}))) { - p.exitHeader(); - return; - } - if (hit(key, config.look_key) or hit(key, config.exec_key)) { - 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.exitHeader(); - if (word.len > 0) { - if (!p.header_insert) { - // Preserve the workspace tag's keyboard command semantics. - _ = exec.execute(p, p.active, word); - } else exec.runBuiltin(p, 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) { - edit.setClipboard(p, 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.enterHeader(if (layout.findPane(p, p.active)) |f| f.col else 0); - return; - } - var dest: ?usize = null; - for (p.panes, 0..) |slot, i| { - if (slot == null or p.rects[i].y != p.topBarHeight() + 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; - } - if (dest == null or p.rects[i].x < p.rects[dest.?].x) dest = i; - } - const d = dest orelse return; - const pane = p.panes[d].?; - p.exitHeader(); - p.active = d; - p.enterTag(pane); - return; - } - if (hit(key, config.topbar_up)) { - p.enterHeader(null); - return; - } - const col: ?usize = if (hit(key, config.topbar_left)) - modal.prevGrapheme(bar, cur) - else if (hit(key, config.topbar_right)) - modal.nextGrapheme(bar, cur) - else if (lineMotion(bar, cur, key)) |r| - modal.selectionCursor(bar, r) - else - null; - // never past the last cell: there is nothing to append up here, so the - // block cursor stays ON a character (which is where `$` already lands) - if (col) |c| p.topbar_col = @intCast(@min(c, modal.prevGrapheme(bar, bar.len))); + 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. @@ -5129,9 +5040,9 @@ pub const Pardes = struct { if (hit(key, config.del_below)) exec.runBuiltin(p, .Del, id, "", "j"); return; } - if (p.topbar_col != null) return p.topbarKey(key); const pane = p.panes[p.active] orelse return; - if (pane.isTerminal() and pane.body.mode == .tty and pane.focus == .body and pane.prompt == .none) { + const header = p.focusedHeader(); + 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; if (key.ctrl and !key.alt and key.cp == p.opts.tty_toggle) @@ -5169,14 +5080,15 @@ pub const Pardes = struct { } // insert mode owns Ctrl-w (delete word backward, helix); the focus // prefix applies outside raw tty mode - const t = if (pane.focus == .tag) &pane.tag else &pane.body; - if (hit(key, config.window_prefix) and t.mode != .insert and pane.prompt == .none) { + const t = header orelse if (pane.focus == .tag) &pane.tag else &pane.body; + if (hit(key, config.window_prefix) and t.mode != .insert and (header != null or pane.prompt == .none)) { p.ctrl_w_pending = true; return; } // 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(); const free = p.freeSlot() orelse return; const nt = p.newShell(free, "") catch return; nt.greet = true; @@ -5189,9 +5101,12 @@ pub const Pardes = struct { return; } for (config.jump_keys) |jk| { - if (hit(key, &.{jk.chord})) return exec.runBuiltin(p, jk.cmd, p.active, "", null); + if (!hit(key, &.{jk.chord})) continue; + p.exitHeader(); + return exec.runBuiltin(p, jk.cmd, p.active, "", null); } if (hit(key, config.pane_to_new_column)) { + p.exitHeader(); 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); @@ -5203,7 +5118,7 @@ pub const Pardes = struct { if (pane.isTerminal() and tty_toggle) { return p.toggleTty(p.active); } - if (pane.prompt == .name) { + if (header == null and pane.prompt == .name) { // 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)) { @@ -5218,7 +5133,7 @@ pub const Pardes = struct { edit.handleInsert(p, &pane.input, key); return; } - if (pane.prompt != .none) { + if (header == null and pane.prompt != .none) { if (hit(key, config.search_submit) or hit(key, config.escape)) { const submit = hit(key, config.search_submit); switch (pane.prompt) { @@ -5241,22 +5156,24 @@ pub const Pardes = struct { } if (t.mode == .normal and (hit(key, config.look_key) or hit(key, config.exec_key))) { const cmd = if (hit(key, config.look_key)) config.look_cmd else config.exec_cmd; - if (pane.focus == .tag) { + if (t.what != .body) { // An explicit selection, else the word under the cursor in the - // tag as it is shown: the prefix is part of what a word is. - const tag = p.tagText(p.scratch.allocator(), pane) catch return; - const tail = curTail(pane); - const range = t.primaryRange(tail, 0); + // 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 range = t.primaryRange(own, 0); const lo = @min(range.anchor, range.head); const hi = @max(range.anchor, range.head); const txt = if ((t.vsel.active and t.vsel.explicit) or t.msel.active) - tail[lo..hi] + own[lo..hi] else word: { - const b = config.wordBounds(tag, tag.len - tail.len + modal.selectionCursor(tail, range)); - break :word tag[b.lo..b.hi]; + const b = config.wordBounds(shown, shown.len - own.len + modal.selectionCursor(own, range)); + break :word shown[b.lo..b.hi]; }; - // Out of the tag FIRST: Del frees the pane. - pane.focus = .body; + // 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 (txt.len > 0) exec.runBuiltin(p, cmd, p.active, "", txt); return; } @@ -5899,7 +5816,7 @@ pub const Pardes = struct { const pane = slot.* orelse continue; // The keyboard is on one pane, or on a header: a tag it has left // hands it back to its body, and a name drafted there is dropped. - if (id != p.active or p.topbar_col != null) { + if (id != p.active or p.header_focus) { pane.focus = .body; if (pane.prompt == .name) pane.closePrompt(); } @@ -6134,9 +6051,8 @@ pub const Pardes = struct { // global tagbar: full width, top row — unless a native shell has // taken its commands over, in which case the row is not ours to draw p.revealHeader(); - var tb_buf: [1200]u8 = undefined; - 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; + const global_bar = modal.lineSlice(p.topbar(), 0); + 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, TOPBAR_H, .{ .bg = .{ .rgb = chrome.tag_bg }, @@ -6149,7 +6065,7 @@ pub const Pardes = struct { }); } if (p.pointer_inside and p.hover_row < p.topBarHeight()) { - const bar = p.topbar(&tb_buf); + const bar = global_bar; 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; @@ -6161,10 +6077,6 @@ pub const Pardes = struct { } } } - 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 grip = @min(config.GUTTER, p.col_w[c]); const inset = @min(TAG_TEXT_INSET, p.col_w[c]); @@ -6185,8 +6097,8 @@ pub const Pardes = struct { s.fill(p.col_x[c] + grip, p.topBarHeight(), inset - grip, 1, .{ .bg = .{ .rgb = bg }, .font_role = .tagline }); s.fill(x, p.topBarHeight(), 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); + const bar_scroll = if (p.header_focus and p.header_column == c) p.header_scroll else 0; + const tag = modal.lineSlice(p.columnTag(c), 0); _ = s.print(x, p.topBarHeight(), w, tag[panes.File.rawAtDisplay(tag, bar_scroll)..], .{ .fg = .{ .rgb = fg }, .bg = .{ .rgb = bg }, @@ -6194,7 +6106,7 @@ pub const Pardes = struct { }); // 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 == p.topBarHeight() and p.hover_col >= x and p.hover_col < x + w) { - const bar = p.columnTag(c); + const bar = tag; 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; @@ -6206,21 +6118,24 @@ pub const Pardes = struct { } } }; - if (p.topbar_col) |cur| { - const bar = p.headerText(); + if (p.focusedHeader()) |t| { + const bar = modal.lineSlice(try edit.flatSurface(p, t), 0); 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) p.topBarHeight() 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 }; + if (headerSelection(t, bar)) |span| { + 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); + cell.style.fg = .{ .rgb = th.sel_fg }; + cell.style.bg = .{ .rgb = th.sel_bg }; + } + } + if (t.cur_row == 0) { + 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, .bar = t.mode == .insert }; } - 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 }; } try p.renderTagLayers(arena); @@ -6489,13 +6404,12 @@ pub const Pardes = struct { layer.viewport = .{ .x = r.x + TAG_TEXT_INSET, .y = if (p.settings.tag_bottom) r.y + r.h - BOX_H else r.y, .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.topbar_col == null and temporary.cursor != null) .{ .col = temporary.cursor.?.x - TAG_TEXT_INSET, .bar = temporary.cursor.?.bar } else null; + layer.cursor = if (!p.header_focus and temporary.cursor != null) .{ .col = temporary.cursor.?.x - TAG_TEXT_INSET, .bar = temporary.cursor.?.bar } else null; } - var text_buf: [1200]u8 = undefined; // 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) - try p.renderHeaderLayer(arena, MAX_PANES + MAX_COLS, .workspace, 0, .{ .x = 0, .y = 0, .w = p.screen_w, .h = TOPBAR_H }, p.topbar(&text_buf), chrome.tag_bg, chrome.tag_fg); + try p.renderHeaderLayer(arena, MAX_PANES + MAX_COLS, .workspace, 0, .{ .x = 0, .y = 0, .w = p.screen_w, .h = TOPBAR_H }, modal.lineSlice(p.topbar(), 0), 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 @@ -6558,10 +6472,21 @@ pub const Pardes = struct { 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; - try p.renderHeaderLayer(arena, MAX_PANES + column, .column, @intCast(column), .{ .x = p.col_x[column] + @min(TAG_TEXT_INSET, p.col_w[column]), .y = p.topBarHeight(), .w = p.col_w[column] -| TAG_TEXT_INSET, .h = COLBAR_H }, p.columnTag(column), if (active) chrome.tag_active_bg else chrome.tag_bg, if (active) chrome.tag_active_fg else chrome.tag_fg); + try p.renderHeaderLayer(arena, MAX_PANES + column, .column, @intCast(column), .{ .x = p.col_x[column] + @min(TAG_TEXT_INSET, p.col_w[column]), .y = p.topBarHeight(), .w = p.col_w[column] -| TAG_TEXT_INSET, .h = COLBAR_H }, modal.lineSlice(p.columnTag(column), 0), 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 its first line: up to the + /// caret while typing, and over the cursor's own cell in normal mode, + /// where a cursor is a cell. + pub fn headerSelection(t: *const Text, line: []const u8) ?struct { lo: usize, hi: usize } { + if (!t.vsel.active or t.vsel.row != 0 or t.cur_row != 0) return null; + const anchor = @min(@as(usize, @intCast(@max(0, t.vsel.col))), line.len); + const cursor = @min(@as(usize, @intCast(@max(0, t.cur_col))), line.len); + if (t.mode == .insert) return .{ .lo = @min(anchor, cursor), .hi = @max(anchor, cursor) }; + return .{ .lo = @min(anchor, cursor), .hi = modal.nextGrapheme(line, @max(anchor, cursor)) }; + } + fn renderHeaderLayer(p: *Pardes, arena: std.mem.Allocator, index: usize, kind: TagKind, id: u16, viewport: Rect, text: []const u8, bg: [3]u8, fg: [3]u8) !void { _ = arena; const layer = &p.surface.tag_layers[index]; @@ -6584,25 +6509,25 @@ pub const Pardes = struct { // 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 = p.topbar_col != null and switch (kind) { + const editing: ?*Text = if (p.header_focus and switch (kind) { .workspace => p.header_column == null, .column => p.header_column == id, .pane, .notice => false, - }; - const scroll = if (editing) p.header_scroll else 0; + }) 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) { - const cursor = p.topbar_col.?; - const anchor = p.header_anchor orelse cursor; - var col = panes.File.rawDisplayCol(text, @min(cursor, anchor)) -| scroll; - const end = panes.File.rawDisplayCol(text, @max(cursor, anchor)) -| 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 }; + if (editing) |t| { + if (headerSelection(t, text)) |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, cursor) -| scroll; - if (caret < columns) layer.cursor = .{ .col = @intCast(caret), .bar = p.header_insert }; + const caret = panes.File.rawDisplayCol(text, @min(@as(usize, @intCast(@max(0, t.cur_col))), text.len)) -| scroll; + if (t.cur_row == 0 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| -- cgit v1.3