summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig471
1 files changed, 198 insertions, 273 deletions
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|