summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-28 00:13:11 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:14 -0300
commit42748f92b77367db415c8b4d0f2e537a9e1ddde7 (patch)
tree3ff6606d33149581d5954dcc900eb6ab3e4f0f17
parent23f430573efe4f6ef55b0180aa0d742ff4eca1d9 (diff)
downloadpardes-42748f92b77367db415c8b4d0f2e537a9e1ddde7.tar.gz
pardes-42748f92b77367db415c8b4d0f2e537a9e1ddde7.zip
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 <[email protected]>
-rw-r--r--src/File.zig1
-rw-r--r--src/Text.zig19
-rw-r--r--src/builtins.zig37
-rw-r--r--src/config.zig9
-rw-r--r--src/dump.zig12
-rw-r--r--src/edit.zig45
-rw-r--r--src/gui/gui.zig5
-rw-r--r--src/layout.zig29
-rw-r--r--src/mouse.zig44
-rw-r--r--src/normal.zig23
-rw-r--r--src/pardes.zig471
-rw-r--r--src/tag_line.zig42
-rw-r--r--test/column_drag.zig22
-rw-r--r--test/panes.zig12
-rw-r--r--test/snapshots/tagnav.golden24
-rw-r--r--test/snapshots/tagnav.snap43
16 files changed, 426 insertions, 412 deletions
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 <path>` once a dump
- /// exists — render and click dispatch must agree on this exact string
- fn topbar(p: *Pardes, buf: []u8) []const u8 {
- if (p.global_tag.custom) return p.global_tag.slice("");
- if (p.last_dump) |d|
- return std.fmt.bufPrint(buf, config.topbar_str ++ " Restore {s}", .{d}) catch config.topbar_str;
- return config.topbar_str;
+ /// What a column's tag says until someone edits it.
+ pub const column_tag_default = "New Tty Find Grep Joincol Delcol";
+
+ /// What a header says until someone edits it: for the workspace the
+ /// fixed builtins, plus `Restore <path>` once a dump exists.
+ pub fn headerDefault(p: *Pardes, what: Text.What) []const u8 {
+ if (what == .column) return column_tag_default;
+ const d = p.last_dump orelse return config.topbar_str;
+ return std.fmt.allocPrint(p.scratch.allocator(), config.topbar_str ++ " Restore {s}", .{d}) catch config.topbar_str;
+ }
+
+ /// the topbar line -- render and click dispatch must agree on this exact
+ /// string
+ fn topbar(p: *Pardes) []const u8 {
+ return p.global_tag.own orelse p.headerDefault(.workspace);
}
/// The workspace tag 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 <path>` word the moment a dump lands, so
- // the column is clamped against the live line on every use
- const cur: usize = @min(@as(usize, p.topbar_col orelse return), bar.len);
- if (hit(key, config.escape) 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(""));
-}
diff --git a/test/column_drag.zig b/test/column_drag.zig
index b2b8f35d..f128aa61 100644
--- a/test/column_drag.zig
+++ b/test/column_drag.zig
@@ -26,7 +26,7 @@ const Snapshot = struct {
serial: u32,
panes: [pardes.MAX_PANES]usize = undefined,
count: usize,
- tag: pardes.TagLine,
+ tag: []const u8,
};
fn expectOrder(p: *Pardes, before: []const Snapshot, order: []const usize) !void {
@@ -37,7 +37,7 @@ fn expectOrder(p: *Pardes, before: []const Snapshot, order: []const usize) !void
try std.testing.expectEqual(saved.serial, p.col_serial[column]);
try std.testing.expectEqual(saved.count, p.col_n[column]);
try std.testing.expectEqualSlices(usize, saved.panes[0..saved.count], p.col_panes[column][0..p.col_n[column]]);
- try std.testing.expectEqualStrings(saved.tag.slice(""), p.col_tags[column].slice(""));
+ try std.testing.expectEqualStrings(saved.tag, p.columnTag(column));
}
}
@@ -59,7 +59,7 @@ test "column grip drag moves only its interval and keeps tag caret with the colu
try std.testing.expectEqual(@as(usize, 2), p.col_n[1]);
for ([_]u64{ 2, 3, 5, 7 }, 0..) |weight, column| {
p.col_weight[column] = weight;
- try std.testing.expect(p.col_tags[column].set(([_][]const u8{ "zero column header", "one column header", "two column header", "three column header" })[column]));
+ p.col_tags[column].own = try p.gpa.dupe(u8, ([_][]const u8{ "zero column header", "one column header", "two column header", "three column header" })[column]);
}
p.update(.tick);
var frame = std.heap.ArenaAllocator.init(p.gpa);
@@ -67,7 +67,7 @@ test "column grip drag moves only its interval and keeps tag caret with the colu
_ = try p.render(frame.allocator());
var before: [4]Snapshot = undefined;
for (&before, 0..) |*saved, column| {
- saved.* = .{ .weight = p.col_weight[column], .serial = p.col_serial[column], .count = p.col_n[column], .tag = p.col_tags[column] };
+ saved.* = .{ .weight = p.col_weight[column], .serial = p.col_serial[column], .count = p.col_n[column], .tag = p.columnTag(column) };
@memcpy(saved.panes[0..saved.count], p.col_panes[column][0..saved.count]);
}
const layer = try tagLayer(p, 1);
@@ -75,8 +75,8 @@ test "column grip drag moves only its interval and keeps tag caret with the colu
p.update(.{ .mouse = headerMouse(layer, 10, .press) });
p.update(.{ .mouse = headerMouse(layer, 10, .release) });
try std.testing.expectEqual(@as(?usize, 1), p.header_column);
- const caret = p.topbar_col;
- const anchor = p.header_anchor;
+ const caret = p.focusedHeader().?.cur_col;
+ const anchor = p.focusedHeader().?.vsel;
const scroll = p.header_scroll;
const target = p.col_x[3] + p.col_w[3] - 2;
const last_rect = p.rects[p.col_panes[3][0]];
@@ -93,14 +93,14 @@ test "column grip drag moves only its interval and keeps tag caret with the colu
try expectOrder(p, &before, &.{ 0, 2, 3, 1 });
try std.testing.expectEqual(active, p.active);
try std.testing.expectEqual(@as(?usize, 3), p.header_column);
- try std.testing.expectEqual(caret, p.topbar_col);
- try std.testing.expectEqual(anchor, p.header_anchor);
+ try std.testing.expectEqual(caret, p.focusedHeader().?.cur_col);
+ try std.testing.expectEqual(anchor, p.focusedHeader().?.vsel);
try std.testing.expectEqual(scroll, p.header_scroll);
// The old tag envelope must not edit the new occupant of its old index,
// including the interval before hosts request another rendered frame.
p.update(.{ .mouse = stale });
try std.testing.expectEqual(@as(?usize, 3), p.header_column);
- try std.testing.expectEqual(caret, p.topbar_col);
+ try std.testing.expectEqual(caret, p.focusedHeader().?.cur_col);
_ = try p.render(frame.allocator());
try std.testing.expectEqual(before[1].serial, (try tagLayer(p, 3)).serial);
try std.testing.expectEqual(p.col_w[3] - pardes.TAG_TEXT_INSET, (try tagLayer(p, 3)).viewport.w);
@@ -110,7 +110,7 @@ test "column grip drag moves only its interval and keeps tag caret with the colu
mouse(p, .release, back);
try expectOrder(p, &before, &.{ 0, 1, 2, 3 });
try std.testing.expectEqual(@as(?usize, 1), p.header_column);
- try std.testing.expectEqual(caret, p.topbar_col);
+ try std.testing.expectEqual(caret, p.focusedHeader().?.cur_col);
try std.testing.expectEqual(active, p.active);
// A structural change between press and release must not retarget the
// in-flight drag to whichever column now occupies the original index.
@@ -165,7 +165,7 @@ test "column grip is separate from compact text and the resize seam" {
try std.testing.expect(p.drag != .column_move);
try std.testing.expect(p.header_drag);
p.update(.{ .mouse = headerMouse(layer, 0, .release) });
- try std.testing.expectEqual(@as(?u16, 0), p.topbar_col);
+ try std.testing.expectEqual(@as(i32, 0), p.focusedHeader().?.cur_col);
mouse(p, .press, p.col_x[1] + config.GUTTER - 1);
try std.testing.expect(p.drag == .column_move);
mouse(p, .release, p.col_x[1] + config.GUTTER - 1);
diff --git a/test/panes.zig b/test/panes.zig
index a391dba2..b9fa9a88 100644
--- a/test/panes.zig
+++ b/test/panes.zig
@@ -152,8 +152,8 @@ const TagLayerTests = struct {
try std.testing.expect(left.sel[0].state == .done and left.sel[0].r0 == 0);
p.update(.{ .key = .{ .cp = Key.escape } });
const long = "a" ** 60 ++ "界END";
- try std.testing.expect(p.global_tag.set(long));
- for (p.col_tags[0..p.ncol]) |*tag| try std.testing.expect(tag.set(long));
+ p.global_tag.own = try p.gpa.dupe(u8, long);
+ for (p.col_tags[0..p.ncol]) |*tag| tag.own = try p.gpa.dupe(u8, long);
_ = try p.render(frame.allocator());
for ([_]Kind{ .workspace, .column }) |kind| {
const id: usize = if (kind == .column) 1 else 0;
@@ -161,7 +161,7 @@ const TagLayerTests = struct {
const x = @as(f32, @floatFromInt(header.viewport.x)) * 10 + 62.5 * 5;
p.update(.{ .mouse = mouse(header, x, .press) });
p.update(.{ .mouse = mouse(header, x, .release) });
- try std.testing.expectEqual(@as(?u16, 63), p.topbar_col);
+ try std.testing.expectEqual(@as(i32, 63), p.focusedHeader().?.cur_col);
if (kind == .column) try std.testing.expectEqual(@as(?usize, 1), p.header_column) else try std.testing.expect(p.header_column == null);
p.update(.{ .key = .{ .cp = Key.escape } });
}
@@ -334,7 +334,7 @@ const TagNavigationTests = struct {
key(p, 'j');
try std.testing.expectEqual(@as(i32, 0), pane.tag.cur_col);
try std.testing.expectEqual(@as(usize, 0), p.active);
- try std.testing.expect(pane.focus == .tag and p.topbar_col == null);
+ try std.testing.expect(pane.focus == .tag and !p.header_focus);
window(p, 'l');
try std.testing.expectEqual(right, p.active);
try std.testing.expect(pane.focus == .body and p.panes[right].?.focus == .body);
@@ -1291,13 +1291,13 @@ test "leaving a pane or entering a header cancels an uncommitted filename" {
const pane = try p.setTestFile("untouched\n");
for ([_]bool{ false, true }) |header| {
p.active = 0;
- p.topbar_col = null;
+ p.exitHeader();
p.update(.tick);
clickPathEnd(p, pane);
p.update(.{ .paste = "/draft" });
try std.testing.expect(pane.prompt == .name);
if (header) {
- p.topbar_col = 0;
+ p.enterHeader(null);
p.update(.tick);
} else {
try std.testing.expect(p.executeBuiltinLine(0, "New"));
diff --git a/test/snapshots/tagnav.golden b/test/snapshots/tagnav.golden
index 3fdba9f2..45d64b42 100644
--- a/test/snapshots/tagnav.golden
+++ b/test/snapshots/tagnav.golden
@@ -39,3 +39,27 @@
|5: 3 gamma $
== snap tty-tag grid=100x31 cursor=90,2
== snap tty-restored grid=100x31 cursor=54,5
+== snap column-tag grid=100x31 cursor=3,1
+|2: /tmp/pardes-snap/tagnav/cwd/w.txt Save Tty Coll /tmp/pardes-snap/tagnav/cwd Tty Save Mode Filte
+|3: 1 alpha Up ls
+|5: 3 gamma
+== snap column-word grid=100x31 cursor=6,1
+|3: 1 alpha ls
+== snap workspace-tag grid=100x31 cursor=0,0
+|3: 1 alpha Up ls
+== snap topbar-dollar grid=100x31 cursor=70,0
+|3: 1 alpha ls
+== snap topbar-dump-word grid=100x31 cursor=46,0
+== snap topbar-exec grid=100x31 cursor=7,3
+|0: Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill Restore /tmp/pardes-snap/tag
+|3: 1 alpha Dump ls
+== snap topbar-restore-word grid=100x31 cursor=72,0
+|3: 1 alpha ls
+== snap topbar-restored grid=100x31 cursor=7,3
+|0: Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill
+|3: 1 alpha $ ls
+|5: 3 gamma $
+|6: ── restored history ──
+|18: $ ls
+|20: $
+|21: ── restored history ──
diff --git a/test/snapshots/tagnav.snap b/test/snapshots/tagnav.snap
index eed808de..c9ac9605 100644
--- a/test/snapshots/tagnav.snap
+++ b/test/snapshots/tagnav.snap
@@ -77,3 +77,46 @@ settle 100
key :
stable 400 5000
snap tty-restored
+# The column and workspace tags are Texts in the same layout, with the same
+# keys: Up from a pane with nothing above it reaches its column's tag, in
+# normal mode, then the workspace's; Down comes back the same way.
+key c-b
+stable 700 15000
+key c-w h
+stable 400 5000
+key c-w k
+stable 400 5000
+snap column-tag
+key w
+stable 400 5000
+snap column-word
+key c-w k
+stable 400 5000
+snap workspace-tag
+# `$` is the last cell; four long-word steps back land on `Dump`, and the
+# execute key runs it and gives the keyboard back to the body FIRST -- `Kill`
+# lives up here too, so a builtin that frees the session must never return
+# into a focused header. Dump grows a `Restore <path>` word, which is also the
+# case where the line gets longer than the column you were parked on.
+key $
+stable 400 5000
+snap topbar-dollar
+key B B B B
+stable 400 5000
+snap topbar-dump-word
+key tab
+stable 700 15000
+snap topbar-exec
+# THE DELETION HAZARD, from the bar itself: `Restore` frees every pane and
+# swaps the core out from under the caller. Walk back up onto the word the
+# Dump just grew -- B twice off the tail, because the LONG word motions are the
+# ones that step over a path in one go -- and run it.
+key c-w k
+key c-w k
+stable 400 5000
+key $ B B
+stable 400 5000
+snap topbar-restore-word
+key tab
+stable 900 15000
+snap topbar-restored