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/File.zig | 1 + src/Text.zig | 19 ++- src/builtins.zig | 37 ++++- src/config.zig | 9 -- src/dump.zig | 12 +- src/edit.zig | 45 ++++-- src/gui/gui.zig | 5 +- src/layout.zig | 29 ++-- src/mouse.zig | 44 +++--- src/normal.zig | 23 ++- src/pardes.zig | 471 +++++++++++++++++++++++-------------------------------- src/tag_line.zig | 42 ----- 12 files changed, 342 insertions(+), 395 deletions(-) delete mode 100644 src/tag_line.zig (limited to 'src') diff --git a/src/File.zig b/src/File.zig index 2bf9f57d..22e3c434 100644 --- a/src/File.zig +++ b/src/File.zig @@ -654,6 +654,7 @@ pub fn cursorLines(arena: std.mem.Allocator, pane: *Pane, f: *State) ![]const [] /// Use the file's line index only when `text` is its complete live content. /// Edit-buffer fragments and other temporary text retain modal's scan path. fn contentIndex(t: *Text, text: []const u8) ?[]const usize { + if (t.what != .body) return null; const pane = t.pane(); const f = if (pane.file) |*file| file else return null; if (text.ptr != f.content.ptr or text.len != f.content.len) return null; diff --git a/src/Text.zig b/src/Text.zig index 3e48a42e..d4b6d048 100644 --- a/src/Text.zig +++ b/src/Text.zig @@ -1,7 +1,8 @@ //! One editable text, acme's Text (editors/acme/dat.h:171-190, text.c): the //! cursor, the selections and the modal state that edit it, and the undo of -//! an edit buffer. A pane's body is one, its tag is another, and so is the -//! answer typed into a pane's prompt. Where a body's characters come from -- +//! an edit buffer. A pane's body is one, its tag is another, and so are the +//! answer typed into a pane's prompt and the tags of the columns and the +//! workspace. Where a body's characters come from -- //! a file's content, a terminal's edit overlay, a PDF's text -- is the //! window's business (edit.zig's editText and flatSurface), the way acme's //! Text reads its File; every other text owns its characters. Everything @@ -19,7 +20,7 @@ pub const Mode = enum { normal, insert, tty }; /// Which text of its window this is, acme's `what`. It says where the /// characters live and whether moving the cursor scrolls a body. -pub const What = enum { body, tag, input }; +pub const What = enum { body, tag, input, column, workspace }; /// A modal line selection (helix `x`): whole rows [r0, r1], absolute. pub const LineSel = struct { @@ -74,15 +75,25 @@ ed_undo_len: usize = 0, ed_redo: [Terminal.history_max]Terminal.Snapshot = undefined, ed_redo_len: usize = 0, -/// The window this text belongs to (acme's t->w). +/// The window this text belongs to (acme's t->w). A column's or the +/// workspace's tag belongs to no pane; nothing asks it for one. pub fn pane(t: *Text) *Pane { return switch (t.what) { .body => @alignCast(@fieldParentPtr("body", t)), .tag => @alignCast(@fieldParentPtr("tag", t)), .input => @alignCast(@fieldParentPtr("input", t)), + .column, .workspace => unreachable, }; } +/// Free what a text owns: its characters and the buffers its undo keeps. +pub fn deinit(t: *Text, gpa: std.mem.Allocator) void { + if (t.own) |own| gpa.free(own); + for (t.ed_undo[0..t.ed_undo_len]) |item| if (item.ovl) |overlay| gpa.free(overlay.text); + for (t.ed_redo[0..t.ed_redo_len]) |item| if (item.ovl) |overlay| gpa.free(overlay.text); + t.* = .{ .what = t.what }; +} + /// Bring the cursor into view (acme's textshow). Only a body scrolls to it; /// the tagline follows its caret where it is drawn, and a prompt's answer is /// drawn whole. diff --git a/src/builtins.zig b/src/builtins.zig index 743573e4..74645376 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -735,27 +735,56 @@ pub const Grep = struct { // ---- the window group ---- +// The headers are above the panes in the same layout: Up from a pane with +// nothing above it reaches its column's tag, then the workspace's, and Down +// comes back the same way. Left and Right walk the column tags. + pub const Left = struct { pub fn run(c: Ctx) void { - layout.focusDir(c.p, c.id, .left); + if (c.p.header_focus) { + if (c.p.header_column) |column| if (column > 0) c.p.enterHeader(column - 1); + return; + } + _ = layout.focusDir(c.p, c.id, .left); } }; pub const Down = struct { pub fn run(c: Ctx) void { - layout.focusDir(c.p, c.id, .down); + if (c.p.header_focus) { + const column = layout.findPane(c.p, c.id); + if (c.p.header_column == null and c.p.columnBarHeight() > 0) + return c.p.enterHeader(if (column) |at| at.col else 0); + // Onto the top pane of the column the header stands over. + const col = c.p.header_column orelse if (column) |at| at.col else return; + c.p.exitHeader(); + if (col < c.p.ncol and c.p.col_n[col] > 0) c.p.active = c.p.col_panes[col][0]; + return; + } + _ = layout.focusDir(c.p, c.id, .down); } }; pub const Up = struct { pub fn run(c: Ctx) void { - layout.focusDir(c.p, c.id, .up); + if (c.p.header_focus) { + if (c.p.header_column != null and c.p.topBarHeight() > 0) c.p.enterHeader(null); + return; + } + if (layout.focusDir(c.p, c.id, .up)) return; + if (c.p.columnBarHeight() > 0) { + if (layout.findPane(c.p, c.id)) |at| c.p.enterHeader(at.col); + } else if (c.p.topBarHeight() > 0) c.p.enterHeader(null); } }; pub const Right = struct { pub fn run(c: Ctx) void { - layout.focusDir(c.p, c.id, .right); + if (c.p.header_focus) { + if (c.p.header_column) |column| if (column + 1 < c.p.ncol) c.p.enterHeader(column + 1); + return; + } + _ = layout.focusDir(c.p, c.id, .right); } }; diff --git a/src/config.zig b/src/config.zig index 38b87011..8c37a7ce 100644 --- a/src/config.zig +++ b/src/config.zig @@ -215,11 +215,6 @@ pub const jump_keys = [_]struct { chord: Chord, cmd: Builtin }{ .{ .chord = .{ .cp = 'i', .ctrl = true }, .cmd = .Forward }, }; -pub const topbar_down: []const Chord = &.{ .{ .cp = 'J' }, .{ .cp = Key.down } }; -pub const topbar_up: []const Chord = &.{ .{ .cp = 'K' }, .{ .cp = Key.up } }; -pub const topbar_left: []const Chord = &.{.{ .cp = 'h' }}; -pub const topbar_right: []const Chord = &.{.{ .cp = 'l' }}; - pub const leave_pane: []const Chord = &.{.{ .cp = Key.escape, .shift = true }}; pub const tty_toggle_default: u21 = 'b'; @@ -255,8 +250,6 @@ pub const tag_insert = "^"; pub const tag_tty = "$"; pub const tag_image = "img"; -pub const tag_yank: []const Chord = &.{.{ .cp = 'y' }}; - pub const command_line: []const Chord = &.{.{ .cp = ':' }}; pub const search: []const Chord = &.{.{ .cp = '/' }}; @@ -458,8 +451,6 @@ pub const line_end: []const Chord = &.{ .{ .cp = '$' }, .{ .cp = Key.end } }; pub const line_first_nonws: []const Chord = &.{.{ .cp = '^' }}; // Bare G does nothing; ge reaches the last line. pub const goto_line: []const Chord = &.{.{ .cp = 'G' }}; -pub const line_move_left: []const Chord = move_left; -pub const line_move_right: []const Chord = move_right; pub const half_page_down: []const Chord = &.{.{ .cp = 'd', .ctrl = true }}; pub const half_page_up: []const Chord = &.{.{ .cp = 'u', .ctrl = true }}; diff --git a/src/dump.zig b/src/dump.zig index 7112e1f0..aeb25a90 100644 --- a/src/dump.zig +++ b/src/dump.zig @@ -191,8 +191,10 @@ pub const State = struct { mounts: []const Mount = &.{}, }; +/// A header is a text like any other: it may hold newlines, but not a +/// carriage return or a NUL. fn validateTag(bar: []const u8) !void { - if (bar.len > limits.max_tag_tail or std.mem.indexOfAny(u8, bar, "\r\n\x00") != null or !std.unicode.utf8ValidateSlice(bar)) return error.BadDumpTag; + if (bar.len > limits.max_tag_tail or std.mem.indexOfAny(u8, bar, "\r\x00") != null or !std.unicode.utf8ValidateSlice(bar)) return error.BadDumpTag; } pub fn validate(state: State) !void { @@ -637,7 +639,7 @@ pub fn dumpState(p: *Pardes) !void { }; if (ids_len > 0) { columns[columns_len] = .{ - .tag = if (p.col_tags[c].custom) p.columnTag(c) else null, + .tag = p.col_tags[c].own, .weight = @as(f32, @floatFromInt(p.col_weight[c])) / @as(f32, @floatFromInt(column_weight_unit)), .panes = column_ids[c][0..ids_len], }; @@ -651,7 +653,7 @@ pub fn dumpState(p: *Pardes) !void { .screen = .{ .cols = p.screen_w, .rows = p.screen_h }, .active = slot_to_pane[p.active] orelse 0, .topbar = config.topbar_str, - .topbar_custom = if (p.global_tag.custom) p.global_tag.slice("") else null, + .topbar_custom = p.global_tag.own, .theme = p.theme().name, .locations_config = p.locations_config, .tree_context_tag_style = p.settings.tree_context_tag_style, @@ -788,9 +790,9 @@ fn initDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8, previo } p.ncol = @min(st.columns.len, MAX_COLS); p.col_serial = @splat(0); - if (st.topbar_custom) |bar| _ = p.global_tag.set(bar); + if (st.topbar_custom) |bar| p.global_tag.own = try p.gpa.dupe(u8, bar); for (st.columns[0..p.ncol], 0..) |col, c| { - if (col.tag) |bar| _ = p.col_tags[c].set(bar); + if (col.tag) |bar| p.col_tags[c].own = try p.gpa.dupe(u8, bar); const scaled = @as(f64, @floatCast(col.weight)) * @as(f64, @floatFromInt(column_weight_unit)); const bounded = @min(scaled, @as(f64, @floatFromInt(max_column_weight))); p.col_weight[c] = @max(1, @as(u64, @intFromFloat(@round(bounded)))); diff --git a/src/edit.zig b/src/edit.zig index 02e138e8..1822db31 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -72,10 +72,9 @@ pub fn typeToTty(p: *Pardes, id: usize, pane: *const Pane, text: []const u8) voi } pub fn applyPaste(p: *Pardes, bytes: []const u8) void { - if (p.topbar_col != null) { + if (p.focusedHeader()) |t| { p.clip_pending = null; - p.headerReplace(bytes); - return; + return pasteInto(p, t, bytes); } const req = p.clip_pending; p.clip_pending = null; @@ -96,15 +95,23 @@ pub fn applyPaste(p: *Pardes, bytes: []const u8) void { if (normal.selRegexArmed(pane)) |a| normal.applySelRegex(p, pane, a.pat, a.split); return; } - const t = if (pane.focus == .tag) &pane.tag else &pane.body; - if (t.what == .body and pane.isTerminal() and pane.body.mode == .tty) return typeToTty(p, id, pane, bytes); + if (pane.focus == .tag) return pasteInto(p, &pane.tag, bytes); + if (pane.isTerminal() and pane.body.mode == .tty) return typeToTty(p, id, pane, bytes); switch (if (req) |r| r.mode else .after) { - .after => pasteText(p, t, bytes, false, 1), - .before => pasteText(p, t, bytes, true, 1), - .replace => replaceWithText(p, t, bytes), + .after => pasteText(p, &pane.body, bytes, false, 1), + .before => pasteText(p, &pane.body, bytes, true, 1), + .replace => replaceWithText(p, &pane.body, bytes), } } +/// A paste into a tag or a header: typed at the caret while it is typed +/// into, after the cursor's cell in normal mode, as a body's paste is. +fn pasteInto(p: *Pardes, t: *Text, bytes: []const u8) void { + if (bytes.len == 0) return; + if (t.mode == .insert) return insertKey(p, t, .{ .cp = bytes[0], .text = bytes }); + pasteText(p, t, bytes, false, 1); +} + pub fn clipYank(p: *Pardes, pane: *Pane, main_only: bool) void { if (comptime pdf_enabled) if (pane.pdf) |pv| { if (pv.selection_text.len > 0) setClipboard(p, pv.selection_text); @@ -340,8 +347,12 @@ pub fn pinPaneCursor(p: *Pardes, t: *Text) void { } pub fn flatSurface(p: *Pardes, t: *Text) ![]const u8 { - if (t.what == .tag) return Pardes.curTail(t.pane()); - if (t.what != .body) return t.own orelse ""; + switch (t.what) { + .body => {}, + .tag => return Pardes.curTail(t.pane()), + .input => return t.own orelse "", + .column, .workspace => return t.own orelse p.headerDefault(t.what), + } const pane = t.pane(); if (pane.file) |f| return f.content; if (pane.hasPdf()) { @@ -362,10 +373,10 @@ pub fn paneWrapWidth(p: *const Pardes, t: *Text) usize { /// Route shared edit operations to the characters `t` edits: its own, or /// for a body its file's content or its terminal's overlay. fn editText(p: *Pardes, t: *Text, lo: i32, hi: i32, col: i32) ?panes.EditText { - // A tag nobody has changed shows its default; the first edit makes that - // default its own text. - if (t.what == .tag and t.own == null) - t.own = p.gpa.dupe(u8, Pardes.defaultTail(t.pane())) catch return null; + // A tag or header nobody has changed shows its default; the first edit + // makes that default its own text. + if (t.what != .body and t.what != .input and t.own == null) + t.own = p.gpa.dupe(u8, flatSurface(p, t) catch return null) catch return null; if (t.what != .body) return .{ .text = t.own orelse &.{}, .row0 = 0 }; const pane = t.pane(); if (pane.file) |f| return .{ .text = f.content, .row0 = 0 }; @@ -1545,7 +1556,7 @@ pub fn pushUndo(p: *Pardes, t: *Text) void { if (p.multi_on and !p.multi_first) return; switch (t.what) { .body => {}, - .tag => return t.remember(p.gpa, if (t.own) |own| .{ .text = own } else null), + .tag, .column, .workspace => return t.remember(p.gpa, if (t.own) |own| .{ .text = own } else null), // a prompt's answer is typed once and gone; it keeps no history .input => return, } @@ -1555,7 +1566,7 @@ pub fn pushUndo(p: *Pardes, t: *Text) void { } pub fn doUndo(p: *Pardes, t: *Text) void { - if (t.what == .tag) return stepOwn(p, t, true); + if (t.what == .tag or t.what == .column or t.what == .workspace) return stepOwn(p, t, true); if (t.what != .body) return; const pane = t.pane(); if (pane.file != null) return panes.File.undo(p, pane); @@ -1563,7 +1574,7 @@ pub fn doUndo(p: *Pardes, t: *Text) void { } pub fn doRedo(p: *Pardes, t: *Text) void { - if (t.what == .tag) return stepOwn(p, t, false); + if (t.what == .tag or t.what == .column or t.what == .workspace) return stepOwn(p, t, false); if (t.what != .body) return; const pane = t.pane(); if (pane.file != null) return panes.File.redo(p, pane); diff --git a/src/gui/gui.zig b/src/gui/gui.zig index 97bbc816..dfd528a3 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -7360,7 +7360,7 @@ fn appendWorkspacePet(g: *Gui, core: ?*const pardes.Pardes, surface: *const pard for (surface.cells[0..surface.cols], 0..) |*cell, i| { if (cellCodepoint(cell) != ' ') occupied = i + 1; } - const editing = p.topbar_col != null and p.header_column == null; + const editing = p.header_focus and p.header_column == null; const band_top: f32 = @floatFromInt(taglineBandOffset(0, builder.win_h, g.cell_h, g.tagline_height)); const region = if (occupied >= surface.cols) null else pet.lane( occupied, @@ -7412,8 +7412,7 @@ test "pet overlay stays beyond workspace text and vanishes while its tag is edit try std.testing.expect(x >= 20 * 8 - 0.01); try std.testing.expect(y >= 0 and y < 20); } - core.topbar_col = 0; - core.header_column = null; + core.enterHeader(null); builder.len = 0; appendWorkspacePet(&g, core, &surface, &builder); try std.testing.expectEqual(@as(usize, 0), builder.len); diff --git a/src/layout.zig b/src/layout.zig index 49da0207..54b89081 100644 --- a/src/layout.zig +++ b/src/layout.zig @@ -674,7 +674,8 @@ pub const MovePlacement = struct { above_h: u16, }; -pub fn focusDir(p: *Pardes, from: usize, dir: enum { left, right, up, down }) void { +/// Move the keyboard to the nearest pane in `dir`; false when there is none. +pub fn focusDir(p: *Pardes, from: usize, dir: enum { left, right, up, down }) bool { const a = p.rects[from]; var best: ?usize = null; var best_d: i32 = 0; @@ -701,11 +702,11 @@ pub fn focusDir(p: *Pardes, from: usize, dir: enum { left, right, up, down }) vo best_d = d; } } - if (best) |b| { - p.active = b; - // a count typed before the hop was meant for the pane you left - p.panes[b].?.body.normal.clear(); - } + const b = best orelse return false; + p.active = b; + // a count typed before the hop was meant for the pane you left + p.panes[b].?.body.normal.clear(); + return true; } pub fn columnBySerial(p: *const Pardes, serial: u32) ?usize { @@ -935,6 +936,7 @@ pub fn removePane(p: *Pardes, id: usize) void { if (p.col_n[c] == 0) { p.exitHeader(); if (p.ncol > 1) p.col_weight[if (c > 0) c - 1 else c + 1] +|= p.col_weight[c]; + p.col_tags[c].deinit(p.gpa); var j = c; while (j + 1 < p.ncol) : (j += 1) { p.col_panes[j] = p.col_panes[j + 1]; @@ -944,7 +946,7 @@ pub fn removePane(p: *Pardes, id: usize) void { p.col_serial[j] = p.col_serial[j + 1]; } p.ncol -= 1; - p.col_tags[p.ncol] = .{}; + p.col_tags[p.ncol] = .{ .what = .column }; p.col_serial[p.ncol] = 0; } } @@ -958,6 +960,7 @@ pub fn joinCol(p: *Pardes) void { p.col_weight[dst] +|= p.col_weight[c]; for (0..p.col_n[c]) |k| p.col_panes[dst][p.col_n[dst] + k] = p.col_panes[c][k]; p.col_n[dst] += p.col_n[c]; + p.col_tags[c].deinit(p.gpa); var j = c; while (j + 1 < p.ncol) : (j += 1) { p.col_panes[j] = p.col_panes[j + 1]; @@ -967,7 +970,7 @@ pub fn joinCol(p: *Pardes) void { p.col_serial[j] = p.col_serial[j + 1]; } p.ncol -= 1; - p.col_tags[p.ncol] = .{}; + p.col_tags[p.ncol] = .{ .what = .column }; p.col_serial[p.ncol] = 0; } @@ -1017,7 +1020,7 @@ pub fn splitColumn(p: *Pardes, source_id: usize, id: usize, before: bool) bool { p.col_serial[j] = p.col_serial[j - 1]; } p.col_weight[c] = new_weight; - p.col_tags[c] = .{}; + p.col_tags[c] = .{ .what = .column }; p.col_serial[c] = 0; p.col_panes[c][0] = id; p.col_n[c] = 1; @@ -2245,25 +2248,25 @@ test "column reorder moves only crossed state and preserves weights" { p.col_weight[c] = c * 13 + 7; p.col_serial[c] = @intCast(c + 10); p.col_panes[c][0] = c; - p.col_tags[c].saved_col = @intCast(c + 20); + p.col_tags[c].cur_col = @intCast(c + 20); } compute(p); const original_weights = p.col_weight; const outer_x = p.col_x[0]; const outer_w = p.col_w[0]; + p.header_focus = true; p.header_column = 1; - p.topbar_col = 23; p.header_scroll = 9; reorderColumn(p, 1, 3); try std.testing.expectEqual(original_weights[1], p.col_weight[3]); try std.testing.expectEqual(original_weights[2], p.col_weight[1]); try std.testing.expectEqual(@as(u32, 11), p.col_serial[3]); try std.testing.expectEqual(@as(?usize, 3), p.header_column); - try std.testing.expectEqual(@as(?u16, 23), p.topbar_col); + try std.testing.expectEqual(@as(i32, 21), p.focusedHeader().?.cur_col); try std.testing.expectEqual(@as(usize, 9), p.header_scroll); try std.testing.expectEqual(outer_x, p.col_x[0]); try std.testing.expectEqual(outer_w, p.col_w[0]); reorderColumn(p, 3, 1); try std.testing.expectEqualSlices(u64, original_weights[0..4], p.col_weight[0..4]); - try std.testing.expectEqual(@as(?u16, 21), p.col_tags[1].saved_col); + try std.testing.expectEqual(@as(i32, 21), p.col_tags[1].cur_col); } diff --git a/src/mouse.zig b/src/mouse.zig index ae047c58..343051b0 100644 --- a/src/mouse.zig +++ b/src/mouse.zig @@ -292,9 +292,17 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void { } } if (p.header_drag and m.button == config.select_button and (m.kind == .drag or m.kind == .release)) { + const t = p.focusedHeader() orelse { + p.header_drag = false; + return; + }; const x = if (p.header_column) |c| p.col_x[c] + TAG_TEXT_INSET else 0; - const bar = p.headerText(); - p.topbar_col = @intCast(@min(bar.len, panes.File.rawAtDisplay(bar, (tagColumn(p, if (p.header_column != null) .column else .workspace, p.header_column orelse 0, p.pointer_tag_hit, p.header_drag) orelse (mcol -| x)) + p.header_scroll))); + const bar = modal.lineSlice(edit.flatSurface(p, t) catch return, 0); + const at = @min(bar.len, panes.File.rawAtDisplay(bar, (tagColumn(p, if (p.header_column != null) .column else .workspace, p.header_column orelse 0, p.pointer_tag_hit, p.header_drag) orelse (mcol -| x)) + p.header_scroll)); + // the anchor stays where the press put it + t.cur_row = 0; + t.cur_col = @intCast(at); + t.vsel.active = t.vsel.col != t.cur_col; if (m.kind == .release) p.header_drag = false; return; } @@ -318,25 +326,25 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void { }; if (column == null or mcol < x) return; } - if (p.header_column != column) { - p.rememberHeader(); - p.topbar_col = null; - p.header_anchor = null; - p.header_scroll = 0; - } - p.header_column = column; - const bar = p.headerText(); - const at = @min(bar.len, panes.File.rawAtDisplay(bar, (tagColumn(p, if (p.header_column != null) .column else .workspace, p.header_column orelse 0, p.pointer_tag_hit, p.header_drag) orelse (mcol -| x)) + p.header_scroll)); + const t = if (column) |c| &p.col_tags[c] else &p.global_tag; + const focused = p.header_focus and p.header_column == column; + const bar = modal.lineSlice(edit.flatSurface(p, t) catch return, 0); + const scroll = if (focused) p.header_scroll else 0; + const at = @min(bar.len, panes.File.rawAtDisplay(bar, (tagColumn(p, if (column != null) .column else .workspace, column orelse 0, p.pointer_tag_hit, p.header_drag) orelse (mcol -| x)) + scroll)); if (m.button == config.select_button) { - p.topbar_col = @intCast(at); - p.header_anchor = @intCast(at); - p.header_insert = true; + // A clicked header is typed straight into, as a clicked tag is. + if (!focused) p.enterHeader(column); + t.mode = .insert; + t.cur_row = 0; + t.cur_col = @intCast(at); + t.vsel = .{ .active = false, .row = 0, .col = @intCast(at) }; p.header_drag = true; } else { - const selected = if (p.topbar_col) |cur| blk: { - const a = p.header_anchor orelse cur; - break :blk if (at >= @min(a, cur) and at < @max(a, cur)) bar[@min(a, cur)..@max(a, cur)] else wordAtCol(bar, at); - } else wordAtCol(bar, at); + const span = if (focused) Pardes.headerSelection(t, bar) else null; + const selected = if (span) |sp| + (if (at >= sp.lo and at < sp.hi) bar[sp.lo..sp.hi] else wordAtCol(bar, at)) + else + wordAtCol(bar, at); const word = p.scratch.allocator().dupe(u8, selected) catch return; p.exitHeader(); if (word.len > 0) { diff --git a/src/normal.zig b/src/normal.zig index e4fa075d..630f7689 100644 --- a/src/normal.zig +++ b/src/normal.zig @@ -173,7 +173,9 @@ const Replay = union(enum) { pub fn replaySels(p: *Pardes, t: *Text, what: Replay) void { const id = p.active; - const serial = t.pane().serial; + // a header belongs to no pane and cannot go away under the replay + const owner: ?*Pane = if (t.what == .column or t.what == .workspace) null else t.pane(); + const serial = if (owner) |pane| pane.serial else 0; // the whole selection in text coordinates, document order. t.sels // is already ordered, so this only slots the primary into place. var list: [Text.max_selections]Text.SelRange = undefined; @@ -256,8 +258,11 @@ pub fn replaySels(p: *Pardes, t: *Text, what: Replay) void { p.multi_first = false; if (p.multi_stop) { p.multi_stop = false; - const pn = p.panes[id] orelse return; - if (pn.serial == serial) t.nsel = 0; + if (owner != null) { + const pn = p.panes[id] orelse return; + if (pn.serial != serial) return; + } + t.nsel = 0; return; } const text = edit.flatSurface(p, t) catch return; @@ -284,7 +289,9 @@ pub fn handleNormal(p: *Pardes, t: *Text, key: Key) void { } fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void { - const pane = t.pane(); + // What reaches past the text -- a builtin, a search, a language query -- + // acts for the pane the text belongs to; a header's is the active one. + const pane = if (t.what == .column or t.what == .workspace) p.panes[p.active] orelse return else t.pane(); const raw_selection = t.what == .body and pane.rawPointerText(pane.sel[sel_slot]) != null; const before = .{ t.cur_row, t.cur_col, t.vsel, t.msel, t.select, t.nsel, t.mode }; defer if (raw_selection and !std.meta.eql(before, .{ t.cur_row, t.cur_col, t.vsel, t.msel, t.select, t.nsel, t.mode })) { @@ -547,9 +554,11 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void }, // `:` is the one key a tag and a body do not share: it moves the // keyboard between them. - .command_line => if (t.what == .tag) { - pane.focus = .body; - } else p.enterTag(pane), + .command_line => switch (t.what) { + .tag => pane.focus = .body, + .column, .workspace => p.exitHeader(), + .body, .input => p.enterTag(pane), + }, .pipe_selection => |how| return p.startPrompt(pane, .{ .pipe = how }), .search => return p.startPrompt(pane, .{ .search = config.search_marker }), .search_step => |direction| return look.lookWalk( 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| diff --git a/src/tag_line.zig b/src/tag_line.zig deleted file mode 100644 index d3bdf59c..00000000 --- a/src/tag_line.zig +++ /dev/null @@ -1,42 +0,0 @@ -//! Bounded, single-line editable chrome. Offsets are UTF-8 byte offsets. -const std = @import("std"); -const modal = @import("modal.zig"); -pub const Line = struct { - text: [@import("memory.zig").limits.max_tag_tail]u8 = undefined, - len: usize = 0, - custom: bool = false, - saved_col: ?u16 = null, - - pub fn slice(self: *const Line, fallback: []const u8) []const u8 { - return if (self.custom) self.text[0..self.len] else fallback; - } - pub fn set(self: *Line, bytes: []const u8) bool { - if (bytes.len > self.text.len or std.mem.indexOfAny(u8, bytes, "\r\n\x00") != null or !std.unicode.utf8ValidateSlice(bytes)) return false; - @memcpy(self.text[0..bytes.len], bytes); - self.len = bytes.len; - self.custom = true; - return true; - } - pub fn replace(self: *Line, fallback: []const u8, lo: usize, hi: usize, bytes: []const u8) bool { - const current = self.slice(fallback); - if (lo > hi or hi > current.len) return false; - var next: [@import("memory.zig").limits.max_tag_tail]u8 = undefined; - const size = lo + bytes.len + current.len - hi; - if (size > next.len) return false; - @memcpy(next[0..lo], current[0..lo]); - @memcpy(next[lo..][0..bytes.len], bytes); - @memcpy(next[lo + bytes.len ..][0 .. current.len - hi], current[hi..]); - return self.set(next[0..size]); - } -}; - -test "tag replacement is atomic and keeps UTF-8 intact" { - var line: Line = .{}; - try std.testing.expect(line.replace("New Find", 4, 8, "Grep")); - try std.testing.expectEqualStrings("New Grep", line.slice("")); - try std.testing.expect(!line.replace("", 0, 0, "bad\nline")); - try std.testing.expectEqualStrings("New Grep", line.slice("")); - try std.testing.expect(line.set("é界")); - try std.testing.expect(line.replace("", 0, modal.nextGrapheme(line.slice(""), 0), "")); - try std.testing.expectEqualStrings("界", line.slice("")); -} -- cgit v1.3