diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-28 00:01:57 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:14 -0300 |
| commit | 23f430573efe4f6ef55b0180aa0d742ff4eca1d9 (patch) | |
| tree | da2157ba73d6abff4b1674541433294fb6c2daa9 /src/pardes.zig | |
| parent | 9584b0682b45f17aaa42157540947904d867777a (diff) | |
| download | pardes-23f430573efe4f6ef55b0180aa0d742ff4eca1d9.tar.gz pardes-23f430573efe4f6ef55b0180aa0d742ff4eca1d9.zip | |
Make a pane's tag a Text edited with the body's own keys
The tag was a fixed buffer with its own one-line editor, a cursor measured
across the path, and a hijack of the body's mode while it was typed into.
It is now the pane's second Text: its own characters after the computed
prefix (path, dirty marker, PDF page), which is never stored in it, so a
rename or a dirty marker never moves its cursor. Normal and insert mode are
the body's, undo included; `:` moves the keyboard between body and tag, the
tag remembers its cursor and starts on Save, and executing a word from it
hands the keyboard back. The mouse still sees the prefix: a sweep selects
across it and a click on the path drafts a new name.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 633 |
1 files changed, 254 insertions, 379 deletions
diff --git a/src/pardes.zig b/src/pardes.zig index a29973e7..ce7c7065 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -687,9 +687,8 @@ test "pane-tag Exec prefers Tty and argument builtins before shell fallback" { // Argument-taking builtins use the same gesture path: a tag sweep is the // complete command line, and Theme consumes its tail without a PTY write. - pane.tag_tail_len = 0; - try std.testing.expect(pane.appendTag(" Theme dark")); - pane.tag_init = true; + if (pane.tag.own) |own| p.gpa.free(own); + pane.tag.own = try p.gpa.dupe(u8, " Theme dark"); p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .press, .col = tag_x + 1, .row = tag_y } }); p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .drag, .col = tag_x + 10, .row = tag_y } }); p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .release, .col = tag_x + 10, .row = tag_y } }); @@ -707,8 +706,8 @@ test "pane-tag Exec prefers Tty and argument builtins before shell fallback" { // A word absent from the builtin vocabulary takes the fallback, including // the terminating carriage return expected by the shell prompt. - pane.tag_tail_len = 0; - try std.testing.expect(pane.appendTag(" DefinitelyNotABuiltin")); + if (pane.tag.own) |own| p.gpa.free(own); + pane.tag.own = try p.gpa.dupe(u8, " DefinitelyNotABuiltin"); p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .press, .col = tag_x + 1, .row = tag_y } }); p.update(.{ .mouse = .{ .button = config.exec_button, .kind = .release, .col = tag_x + 1, .row = tag_y } }); var sent: [256]u8 = undefined; @@ -743,20 +742,20 @@ test "Collapse builtin keeps content and restores the pane from its default tag" try std.testing.expectEqual(revision, pane.file.?.revision); try std.testing.expectEqualStrings("keep this text\n", pane.file.?.content); try std.testing.expect((try p.render(p.scratch.allocator())).cursor == null); - p.enterTagEdit(pane, -1); + p.enterTag(pane); const tag_cursor = (try p.render(p.scratch.allocator())).cursor orelse return error.MissingTagCursor; try std.testing.expectEqual(p.rects[0].y, tag_cursor.y); - Pardes.exitTagEdit(pane); + pane.focus = .body; try std.testing.expect(p.executeBuiltinLine(0, "Collapse")); p.sync(); try std.testing.expect(!pane.collapsed); try std.testing.expectEqual(height, p.rects[0].h); // Unedited defaults from the preceding version gain the new command. - pane.tag_init = false; - pane.tag_tail_len = 0; + if (pane.tag.own) |own| p.gpa.free(own); + pane.tag.own = null; p.restoreTailAt(pane, pre_collapse_file_pane_tail, "", .file); - try std.testing.expect(!pane.tag_init); + try std.testing.expect(pane.tag.own == null); try std.testing.expectEqualStrings(file_pane_tail, Pardes.curTail(pane)); } @@ -859,8 +858,8 @@ test "TreeContext command and custom tag survive a workspace round trip" { try std.testing.expect(pane.file.?.tree_context); try std.testing.expect(p.executeBuiltinLine(0, "TreeContext invalid")); try std.testing.expect(pane.file.?.tree_context); - try std.testing.expect(pane.appendTag(" Save Keep Del")); - pane.tag_init = true; + if (pane.tag.own) |own| p.gpa.free(own); + pane.tag.own = try p.gpa.dupe(u8, " Save Keep Del"); try dump.dumpState(p); const restored = try dump.initFromDump(p.gpa, .{ .tty_only = true }, p.dump_out.?); defer restored.deinit(); @@ -874,22 +873,23 @@ test "Togglettymode builtin and keyboard chord share mode transitions including defer p.deinit(); const pane = p.panes[0].?; try std.testing.expect(std.mem.indexOf(u8, Pardes.curTail(pane), " Mode ") != null); - p.seedTail(pane); - try std.testing.expect(pane.appendTag(" Togglettymode")); + try pane.appendTag(" Togglettymode"); try std.testing.expectEqual(Text.Mode.tty, pane.body.mode); try std.testing.expect(p.executeBuiltinLine(0, "Togglettymode")); try std.testing.expectEqual(Text.Mode.normal, pane.body.mode); p.update(.{ .key = .{ .cp = p.opts.tty_toggle, .ctrl = true } }); try std.testing.expectEqual(Text.Mode.tty, pane.body.mode); - p.enterTagEdit(pane, -1); - try std.testing.expectEqual(Text.Mode.insert, pane.body.mode); + // The tag has its own mode: the body keeps tty while the tag is typed into. + p.enterTag(pane); + try std.testing.expect(pane.focus == .tag); + try std.testing.expectEqual(Text.Mode.tty, pane.body.mode); try std.testing.expect(p.executeBuiltinLine(0, "Togglettymode")); try std.testing.expectEqual(Text.Mode.normal, pane.body.mode); - try std.testing.expect(!pane.tag_edit); - p.enterTagEdit(pane, -1); + try std.testing.expect(pane.focus == .body); + p.enterTag(pane); p.update(.{ .key = .{ .cp = p.opts.tty_toggle, .ctrl = true } }); try std.testing.expectEqual(Text.Mode.tty, pane.body.mode); - try std.testing.expect(!pane.tag_edit); + try std.testing.expect(pane.focus == .body); p.acknowledgePanelPresentation(&.{}); const tag = try p.tagText(p.scratch.allocator(), pane); @@ -974,10 +974,8 @@ test "compact tag commands stay beside their own prefix across layout changes" { try std.testing.expectEqualStrings(above_tag, try p.tagText(p.scratch.allocator(), above)); p.update(.{ .resize = .{ .cols = 100, .rows = 30 } }); while (p.nextEffect()) |_| {} - - p.seedTail(below); - try std.testing.expectEqualStrings(tail, below.tagSlice()); - try std.testing.expect(below.appendTag(" lots and lots of typing out here")); + try std.testing.expectEqualStrings(tail, Pardes.curTail(below)); + try below.appendTag(" lots and lots of typing out here"); try std.testing.expectEqual(above_tag.len, (try p.tagText(p.scratch.allocator(), above)).len); p.update(.{ .resize = .{ .cols = 100, .rows = 2 } }); @@ -997,14 +995,14 @@ test "legacy default tag tails upgrade while custom tails remain owned" { legacy_pane_tail, }); p.restoreTail(terminal, terminal_old); - try std.testing.expect(!terminal.tag_init); + try std.testing.expect(terminal.tag.own == null); try std.testing.expectEqualStrings(terminal_pane_tail, Pardes.curTail(terminal)); const terminal_previous = try std.fmt.allocPrint(p.scratch.allocator(), "{s} New Del", .{ try p.tagPrefix(terminal), }); p.restoreTail(terminal, terminal_previous); - try std.testing.expect(!terminal.tag_init); + try std.testing.expect(terminal.tag.own == null); try std.testing.expectEqualStrings(terminal_pane_tail, Pardes.curTail(terminal)); const terminal_prefilter = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ @@ -1012,60 +1010,60 @@ test "legacy default tag tails upgrade while custom tails remain owned" { prev_terminal_pane_tail, }); p.restoreTail(terminal, terminal_prefilter); - try std.testing.expect(!terminal.tag_init); + try std.testing.expect(terminal.tag.own == null); try std.testing.expectEqualStrings(terminal_pane_tail, Pardes.curTail(terminal)); for ([_][]const u8{ pre_mode_terminal_pane_tail, pre_tty_first_terminal_pane_tail, pre_collapse_terminal_pane_tail }) |old_tail| { const old = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ try p.tagPrefix(terminal), old_tail }); p.restoreTail(terminal, old); - try std.testing.expect(!terminal.tag_init); + try std.testing.expect(terminal.tag.own == null); try std.testing.expectEqualStrings(terminal_pane_tail, Pardes.curTail(terminal)); } const custom_mode = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s} Keep", .{ try p.tagPrefix(terminal), pre_mode_terminal_pane_tail }); p.restoreTail(terminal, custom_mode); - try std.testing.expect(terminal.tag_init); + try std.testing.expect(terminal.tag.own != null); try std.testing.expectEqualStrings(pre_mode_terminal_pane_tail ++ " Keep", Pardes.curTail(terminal)); - terminal.tag_tail_len = 0; - terminal.tag_init = false; + if (terminal.tag.own) |own| p.gpa.free(own); + terminal.tag.own = null; const terminal_custom = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Keep Del", .{ try p.tagPrefix(terminal), }); p.restoreTail(terminal, terminal_custom); - try std.testing.expect(terminal.tag_init); + try std.testing.expect(terminal.tag.own != null); try std.testing.expectEqualStrings(" Keep Del", Pardes.curTail(terminal)); - terminal.tag_tail_len = 0; - terminal.tag_init = false; + if (terminal.tag.own) |own| p.gpa.free(own); + terminal.tag.own = null; p.restoreDumpTail(terminal, .{ .kind = .terminal, .tag = "TTY /historical/cwd Keep Del", .body = "", .terminal = .{ .cwd = "/historical/cwd" }, }); - try std.testing.expect(terminal.tag_init); + try std.testing.expect(terminal.tag.own != null); try std.testing.expectEqualStrings(" Keep Del", Pardes.curTail(terminal)); - terminal.tag_tail_len = 0; - terminal.tag_init = false; + if (terminal.tag.own) |own| p.gpa.free(own); + terminal.tag.own = null; p.restoreDumpTail(terminal, .{ .kind = .terminal, .tag = "TTY /historical/cwd New Del", .body = "", .terminal = .{ .cwd = "/historical/cwd" }, }); - try std.testing.expect(!terminal.tag_init); + try std.testing.expect(terminal.tag.own == null); try std.testing.expectEqualStrings(terminal_pane_tail, Pardes.curTail(terminal)); // This happens to be the historical FILE default, but on a terminal it is // user-owned text and must not be swallowed by migration. - terminal.tag_tail_len = 0; - terminal.tag_init = false; + if (terminal.tag.own) |own| p.gpa.free(own); + terminal.tag.own = null; const terminal_fileish = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Save Del", .{ try p.tagPrefix(terminal), }); p.restoreTail(terminal, terminal_fileish); - try std.testing.expect(terminal.tag_init); + try std.testing.expect(terminal.tag.own != null); try std.testing.expectEqualStrings(" Save Del", Pardes.curTail(terminal)); // A savable file has a distinct old default. Save remains first after the @@ -1076,25 +1074,25 @@ test "legacy default tag tails upgrade while custom tails remain owned" { legacy_file_pane_tail, }); p.restoreTail(file, file_old); - try std.testing.expect(!file.tag_init); + try std.testing.expect(file.tag.own == null); try std.testing.expectEqualStrings(" Save Tty Collapse Del", Pardes.curTail(file)); const file_custom = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Save Mine Del", .{ try p.tagPrefix(file), }); p.restoreTail(file, file_custom); - try std.testing.expect(file.tag_init); + try std.testing.expect(file.tag.own != null); try std.testing.expectEqualStrings(" Save Mine Del", Pardes.curTail(file)); // Conversely the generic pane's current default is a custom edit on a // savable file. Legacy recognition is scoped to the source pane class. - file.tag_tail_len = 0; - file.tag_init = false; + if (file.tag.own) |own| p.gpa.free(own); + file.tag.own = null; const file_generic = try std.fmt.allocPrint(p.scratch.allocator(), "{s} New Del", .{ try p.tagPrefix(file), }); p.restoreTail(file, file_generic); - try std.testing.expect(file.tag_init); + try std.testing.expect(file.tag.own != null); try std.testing.expectEqualStrings(" New Del", Pardes.curTail(file)); const image_doc = try panes.Image.create(p, 1, "/tmp/legacy image.ppm", &.{}); @@ -1105,8 +1103,8 @@ test "legacy default tag tails upgrade while custom tails remain owned" { .body = "", .image = .{ .path = "/tmp/legacy image.ppm" }, }); - try std.testing.expect(image_doc.tag_init); - try std.testing.expectEqualStrings(" Keep Del", image_doc.tagSlice()); + try std.testing.expect(image_doc.tag.own != null); + try std.testing.expectEqualStrings(" Keep Del", Pardes.curTail(image_doc)); } test "Joincol folds the active column into its right neighbor, keeping its panes" { @@ -1247,11 +1245,11 @@ test "pane defaults keep creation of scratch panes in column tags" { try std.testing.expectEqualStrings("New Tty Find Grep Joincol Delcol", p.columnTag(0)); const terminal = p.panes[0].?; p.restoreTailAt(terminal, pre_column_terminal_pane_tail, "", .terminal); - try std.testing.expect(!terminal.tag_init); + try std.testing.expect(terminal.tag.own == null); try std.testing.expectEqualStrings(terminal_pane_tail, Pardes.curTail(terminal)); const file = try p.setTestFile("unchanged\n"); p.restoreTailAt(file, pre_column_file_pane_tail, "", .file); - try std.testing.expect(!file.tag_init); + try std.testing.expect(file.tag.own == null); try std.testing.expectEqualStrings(file_pane_tail, Pardes.curTail(file)); } @@ -1398,8 +1396,8 @@ test "image dump restores source bytes renderer choices and exact custom tail" { pane.image.?.glyph_art = true; pane.image.?.pmode = .terminal; pane.image.?.ascii = false; - try std.testing.expect(pane.appendTag(" Keep Del")); - pane.tag_init = true; + if (pane.tag.own) |own| p.gpa.free(own); + pane.tag.own = try p.gpa.dupe(u8, " Keep Del"); try dump.dumpState(p); var first_dump = try dump.readZon(gpa, p.dump_out.?, "image-first-dump"); @@ -1416,8 +1414,8 @@ test "image dump restores source bytes renderer choices and exact custom tail" { try std.testing.expect(restored_pane.image.?.glyph_art); try std.testing.expectEqual(image.PaletteMode.terminal, restored_pane.image.?.pmode); try std.testing.expect(!restored_pane.image.?.ascii); - try std.testing.expect(restored_pane.tag_init); - try std.testing.expectEqualStrings(" Keep Del", restored_pane.tagSlice()); + try std.testing.expect(restored_pane.tag.own != null); + try std.testing.expectEqualStrings(" Keep Del", Pardes.curTail(restored_pane)); try std.testing.expectEqualSlices(u8, source, restored_pane.image.?.raw); try dump.dumpState(restored); @@ -2095,20 +2093,20 @@ test "Save reaches every tagline with text behind it and no other" { pane_tail, }); p.restoreTail(out, dumped); - try std.testing.expect(!out.tag_init); + try std.testing.expect(out.tag.own == null); try std.testing.expectEqualStrings(locations_pane_tail, Pardes.curTail(out)); // A scratch is an output buffer that has worn the FILE tails all along, // so its own older default upgrades from that family too. out.file.?.output = .{ .from = .{ .cmd = std.meta.stringToEnum(Builtin, "New").? } }; - out.tag_tail_len = 0; - out.tag_init = false; + if (out.tag.own) |own| p.gpa.free(own); + out.tag.own = null; const scratch_dumped = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{ try p.tagPrefix(out), prev_file_pane_tail, }); p.restoreTail(out, scratch_dumped); - try std.testing.expect(!out.tag_init); + try std.testing.expect(out.tag.own == null); try std.testing.expectEqualStrings(" Save Tty Collapse Del", Pardes.curTail(out)); // An image's bytes on disk already are what they are: nothing of the @@ -2629,24 +2627,25 @@ test "Unicode display cells map back to body and tag byte cursors" { p.update(.{ .mouse = .{ .button = config.select_button, .kind = .release, .col = text_x + 2, .row = body_y } }); try std.testing.expectEqual(@as(i32, 1), pane.body.cur_col); - // A click in the wide path glyph likewise becomes a byte cursor at the + // A click in the wide path glyph drafts a name with its caret at the // grapheme start; arrow motion then advances by the full UTF-8 cluster. - p.enterTagEdit(pane, 1); - try std.testing.expectEqual(@as(u16, 0), pane.tag_col); - p.tagInsertKey(pane, .{ .cp = Key.right }); - try std.testing.expectEqual(@as(u16, 3), pane.tag_col); - p.tagInsertKey(pane, .{ .cp = Key.right }); - try std.testing.expectEqual(@as(u16, 6), pane.tag_col); + p.draftName(pane, 1); + try std.testing.expectEqual(@as(i32, 0), pane.input.cur_col); + edit.insertKey(p, &pane.input, .{ .cp = Key.right }); + try std.testing.expectEqual(@as(i32, 3), pane.input.cur_col); + edit.insertKey(p, &pane.input, .{ .cp = Key.right }); + try std.testing.expectEqual(@as(i32, 6), pane.input.cur_col); + pane.closePrompt(); - const before = try gpa.dupe(u8, pane.tagSlice()); + const before = try gpa.dupe(u8, Pardes.curTail(pane)); defer gpa.free(before); - p.enterTagEdit(pane, -1); - const insertion = pane.tag_col; - p.tagInsertKey(pane, .{ .cp = 'λ', .text = "λ" }); - try std.testing.expectEqual(insertion + 2, pane.tag_col); - p.tagInsertKey(pane, .{ .cp = Key.backspace }); - try std.testing.expectEqual(insertion, pane.tag_col); - try std.testing.expectEqualStrings(before, pane.tagSlice()); + p.enterTag(pane); + const insertion = pane.tag.cur_col; + edit.insertKey(p, &pane.tag, .{ .cp = 'λ', .text = "λ" }); + try std.testing.expectEqual(insertion + 2, pane.tag.cur_col); + edit.insertKey(p, &pane.tag, .{ .cp = Key.backspace }); + try std.testing.expectEqual(insertion, pane.tag.cur_col); + try std.testing.expectEqualStrings(before, Pardes.curTail(pane)); } test "tabbed file aligns syntax cursor and mouse at the nearest insertion gap" { @@ -4110,6 +4109,9 @@ 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); 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); @@ -4762,7 +4764,7 @@ pub const Pardes = struct { ); if (pane.image) |*state| return panes.Image.tagPrefix(arena, state); if (pane.file) |f| { - const name = if (pane.tag_name_len) |len| pane.tag_name[0..len] else f.path; + const name = if (pane.prompt == .name) pane.input.own orelse f.path else f.path; if (panes.Output.fileTraits(f.output).saves and f.revision != f.saved_revision) return std.fmt.allocPrint(arena, "{s}{s}", .{ name, dirty_marker }); return arena.dupe(u8, name); @@ -4770,10 +4772,10 @@ pub const Pardes = struct { return arena.dupe(u8, pane.cwdSlice()); } - /// the editable tail: the user's edited buffer once touched, else defaults - fn curTail(pane: *Pane) []const u8 { - if (pane.tag_init) return pane.tagSlice(); - return defaultTail(pane); + /// The tag's own text: what the user made of it, or until then the + /// default a pane of its kind starts with. + pub fn curTail(pane: *Pane) []const u8 { + return pane.tag.own orelse defaultTail(pane); } pub fn defaultTail(pane: *const Pane) []const u8 { @@ -4793,6 +4795,8 @@ pub const Pardes = struct { return null; } + /// The tag as it is shown, read and pointed at: its computed prefix and + /// its own text after it. pub fn tagText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]u8 { const prefix = try p.tagPrefix(pane); const tail = curTail(pane); @@ -4814,7 +4818,7 @@ pub const Pardes = struct { } var start: usize = 0; const name = if (pane.file) |file| - if (pane.tag_name_len) |len| pane.tag_name[0..len] else file.path + if (pane.prompt == .name) pane.input.own orelse file.path else file.path else if (pane.image) |state| blk: { // Image tags put renderer controls before the path. start = prefix_len -| state.path.len; @@ -4827,88 +4831,76 @@ pub const Pardes = struct { return .{ .lo = start + basename, .hi = start + name.len }; } - pub fn seedTail(_: *Pardes, pane: *Pane) void { - if (pane.tag_init) return; + /// Give the keyboard to the tag in normal mode (`:`), its cursor where + /// the tag last had it. The first time there is nothing to remember, and + /// it starts on Save, so that `:` and the execute key write the file. + pub fn enterTag(p: *Pardes, pane: *Pane) void { + const t = &pane.tag; const tail = curTail(pane); - if (tail.len > pane.tag_tail.len) return; - @memcpy(pane.tag_tail[0..tail.len], tail); - pane.tag_tail_len = tail.len; - pane.tag_init = true; - } - - pub fn enterTagEdit(p: *Pardes, pane: *Pane, col: i32) void { - if (pane.tag_edit) pane.tag_saved_col = pane.tag_col; - const edit0: i32 = @intCast((p.tagPrefix(pane) catch return).len); - p.seedTail(pane); - if (!pane.tag_init) return; - if (!pane.tag_edit) pane.tag_mode = pane.body.mode; - pane.tag_edit = true; - pane.tag_sel = false; - // a one-line tag has no use for normal mode: always insert - pane.body.mode = .insert; - pane.body.normal.clear(); - const end = edit0 + @as(i32, @intCast(pane.tag_tail_len)); - const tail = pane.tagSlice(); - const lead: i32 = @intCast(tail.len - std.mem.trimStart(u8, tail, " ").len); - if (col < 0) { - const text = p.tagText(p.scratch.allocator(), pane) catch return; - if (pane.tag_saved_col) |saved| { - pane.tag_col = @intCast(modal.graphemeStart(text, @min(saved, text.len))); - } else { - pane.tag_col = @intCast(@min(edit0 + lead, end)); - } - if (pane.tag_saved_col == null) if (p.paneIdOf(pane)) |id| { + if (!t.cur_pinned) { + var words = std.mem.tokenizeAny(u8, tail, " \t\n"); + const at = while (words.next()) |word| { + if (std.mem.eql(u8, word, "Save")) break @intFromPtr(word.ptr) - @intFromPtr(tail.ptr); + } else tail.len - std.mem.trimStart(u8, tail, " ").len; + const cursor = modal.positionAt(tail, @min(at, tail.len)); + t.cur_row = @intCast(cursor.row); + t.cur_col = @intCast(cursor.col); + t.cur_pinned = true; + // Reveal the suffix together with its commands, not merely the + // first command letter at the viewport's far right edge. + if (p.paneIdOf(pane)) |id| { + const tag = p.tagText(p.scratch.allocator(), pane) catch return; + const line = tag[0 .. std.mem.indexOfScalar(u8, tag, '\n') orelse tag.len]; const width = p.tagCapacity(p.rects[id].w -| TAG_TEXT_INSET); - // Reveal the suffix together with its commands, not merely the - // first command letter at the viewport's far right edge. - pane.tag_scroll = @intCast(panes.File.displayWidth(text) + 1 -| width); - }; - } else { - const text = p.tagText(p.scratch.allocator(), pane) catch return; - pane.tag_col = @intCast(@min(text.len, panes.File.rawAtDisplay(text, @as(usize, @intCast(col)) + pane.tag_scroll))); + pane.tag_scroll = @intCast(panes.File.displayWidth(line) + 1 -| width); + } } + // The text may have changed since (a 9P write, a rename), so the + // remembered cursor is pulled back inside it. It is the cursor that + // is remembered, not a selection. + const rows = modal.cursorLineCount(tail); + t.cur_row = @intCast(@min(@as(usize, @intCast(@max(0, t.cur_row))), rows - 1)); + const line = modal.lineSlice(tail, @intCast(t.cur_row)); + t.cur_col = @intCast(modal.graphemeStart(line, @min(line.len, @as(usize, @intCast(@max(0, t.cur_col)))))); + t.vsel.active = false; + t.msel.active = false; + t.nsel = 0; + t.select = false; + t.append_at = null; + t.sticky_col = -1; + t.mode = .normal; + t.normal.clear(); + pane.focus = .tag; } - pub fn exitTagEdit(pane: *Pane) void { - if (pane.tag_edit) pane.tag_saved_col = pane.tag_col; - if (pane.isTerminal()) { - if (pane.tag_edit) pane.body.mode = pane.tag_mode; - } else pane.body.mode = .normal; - pane.tag_edit = false; - pane.tag_sel = false; - pane.tag_name_len = null; - pane.tag_scroll = 0; - } - - pub fn tagSelBounds(pane: *Pane) struct { lo: usize, hi: usize } { - const a: usize = pane.tag_anchor; - const c: usize = pane.tag_col; - return .{ .lo = @min(a, c), .hi = @max(a, c) }; - } - - fn tagChordText(p: *Pardes, pane: *Pane) ?[]const u8 { - const text = p.tagText(p.scratch.allocator(), pane) catch return null; - if (pane.tag_sel) { - const b = tagSelBounds(pane); - const hi = modal.nextGrapheme(text, b.hi); - return if (hi > b.lo) text[b.lo..hi] else null; - } - const b = config.wordBounds(text, @min(@as(usize, pane.tag_col), text.len)); - return if (b.hi > b.lo) text[b.lo..b.hi] else null; + /// Where display column `col` of the tagline (tag scroll included) lands + /// in the tag's own text, or null when it is on the prefix. + pub fn tagOffsetAt(p: *Pardes, pane: *Pane, col: usize) ?usize { + const prefix = p.tagPrefix(pane) catch return null; + const width = panes.File.displayWidth(prefix); + if (col < width) return null; + const tail = curTail(pane); + const line = tail[0 .. std.mem.indexOfScalar(u8, tail, '\n') orelse tail.len]; + return modal.graphemeStart(line, panes.File.rawAtDisplay(line, col - width)); } - fn tagNameEnd(pane: *const Pane) ?usize { - const f = pane.file orelse return null; - if (!panes.Output.fileTraits(f.output).saves or f.path.len > pane.tag_name.len) return null; - return if (pane.tag_name_len) |len| len else f.path.len; + /// Start a name draft for a file that can take one, its caret at display + /// column `col` of the path. Nothing changes until it is typed into. + pub fn draftName(p: *Pardes, pane: *Pane, col: usize) void { + const f = pane.file orelse return; + if (!panes.Output.fileTraits(f.output).saves) return; + const name = p.gpa.dupe(u8, f.path) catch return; + pane.closePrompt(); + pane.prompt = .name; + pane.input = .{ .what = .input, .own = name, .mode = .insert, .cur_pinned = true, .cur_col = @intCast(@min(name.len, panes.File.rawAtDisplay(name, col))) }; } - /// Commit a staged buffer name, never a disk rename or write. Keep the + /// Commit a drafted buffer name, never a disk rename or write. Keep the /// draft visible on failure so the user can correct it or cancel with Esc. pub fn commitTagName(p: *Pardes, pane: *Pane) bool { - const len = pane.tag_name_len orelse return true; + if (pane.prompt != .name) return true; const id = p.paneIdOf(pane) orelse return false; - const name = pane.tag_name[0..len]; + const name = pane.input.own orelse ""; if (name.len == 0 or name[name.len - 1] == '/' or !std.unicode.utf8ValidateSlice(name)) { p.reportError(id, "name", error.InvalidName); return false; @@ -4917,8 +4909,6 @@ pub const Pardes = struct { p.reportError(id, "name", error.InvalidName); return false; }; - const old_prefix = (p.tagPrefix(pane) catch return false).len; - const tail_col = @as(usize, pane.tag_col) -| old_prefix; const full = std.fs.path.resolvePosix(p.scratch.allocator(), &.{ paneDir(pane), name }) catch |err| { p.reportError(id, "name", err); return false; @@ -4927,181 +4917,10 @@ pub const Pardes = struct { p.reportError(id, "name", err); return false; }; - pane.tag_name_len = null; - const prefix = (p.tagPrefix(pane) catch return false).len; - pane.tag_col = @intCast(prefix + @min(tail_col, pane.tag_tail_len)); - pane.tag_sel = false; - return true; - } - - /// Replace one range in the rendered tag. The file name and command tail - /// retain separate identities even when a selection crosses their boundary. - pub fn replaceTagText(p: *Pardes, pane: *Pane, start: usize, finish: usize, input: []const u8) bool { - const prefix_len = (p.tagPrefix(pane) catch return false).len; - const end = prefix_len + pane.tag_tail_len; - const lo = @min(start, end); - const hi = @max(lo, @min(finish, end)); - const name_end = tagNameEnd(pane); - const editing_name = if (name_end) |n| lo <= n else false; - if (!editing_name and lo < prefix_len) return false; - var bytes: [limits.max_tag_tail]u8 = undefined; - var count: usize = 0; - for (input) |byte| { - if (byte == '\n' or byte == '\r') continue; - if (count == bytes.len) return false; - bytes[count] = byte; - count += 1; - } - const tail_lo = lo -| prefix_len; - const tail_hi = hi -| prefix_len; - const tail_insert = if (editing_name) 0 else count; - const tail_len = pane.tag_tail_len - (tail_hi - tail_lo) + tail_insert; - if (tail_len > pane.tag_tail.len) return false; - if (editing_name) { - const n = name_end.?; - const name_hi = @min(hi, n); - const name_len = n - (name_hi - lo) + count; - if (name_len > pane.tag_name.len) return false; - if (pane.tag_name_len == null) @memcpy(pane.tag_name[0..n], pane.file.?.path); - if (count > name_hi - lo) - std.mem.copyBackwards(u8, pane.tag_name[lo + count .. name_len], pane.tag_name[name_hi..n]) - else - std.mem.copyForwards(u8, pane.tag_name[lo + count .. name_len], pane.tag_name[name_hi..n]); - @memcpy(pane.tag_name[lo..][0..count], bytes[0..count]); - pane.tag_name_len = @intCast(name_len); - } - if (tail_insert > tail_hi - tail_lo) - std.mem.copyBackwards(u8, pane.tag_tail[tail_lo + tail_insert .. tail_len], pane.tag_tail[tail_hi..pane.tag_tail_len]) - else - std.mem.copyForwards(u8, pane.tag_tail[tail_lo + tail_insert .. tail_len], pane.tag_tail[tail_hi..pane.tag_tail_len]); - if (!editing_name) @memcpy(pane.tag_tail[tail_lo..][0..count], bytes[0..count]); - pane.tag_tail_len = tail_len; - pane.tag_col = @intCast(lo + count); - pane.tag_sel = false; + pane.closePrompt(); return true; } - fn tagInsertKey(p: *Pardes, pane: *Pane, key: Key) void { - if (hit(key, config.escape)) { - exitTagEdit(pane); // the tag is ALWAYS insert; Esc leaves it - return; - } - // the acme chords on the selection or the word at the cursor - if (hit(key, config.look_key) or hit(key, config.exec_key)) { - if (pane.tag_name_len != null) { - const in_name = pane.tag_col < (p.tagPrefix(pane) catch return).len; - if (!p.commitTagName(pane)) return; - if (hit(key, config.look_key) or in_name) { - exitTagEdit(pane); - return; - } - } - if (p.tagChordText(pane)) |txt| { - pane.tag_sel = false; - exec.runBuiltin(p, if (hit(key, config.look_key)) config.look_cmd else config.exec_cmd, p.active, "", txt); - } - return; - } - const text = p.tagText(p.scratch.allocator(), pane) catch return; - const end = text.len; - if (key.text.len > 0) { - const range: @TypeOf(tagSelBounds(pane)) = if (pane.tag_sel) tagSelBounds(pane) else .{ .lo = pane.tag_col, .hi = pane.tag_col }; - const hi = if (pane.tag_sel) modal.nextGrapheme(text, range.hi) else range.hi; - _ = p.replaceTagText(pane, range.lo, hi, key.text); - return; - } - switch (key.cp) { - Key.backspace, Key.delete => { - if (pane.tag_sel) { - const range = tagSelBounds(pane); - _ = p.replaceTagText(pane, range.lo, modal.nextGrapheme(text, range.hi), ""); - } else if (key.cp == Key.backspace and pane.tag_col > 0) { - _ = p.replaceTagText(pane, modal.prevGrapheme(text, pane.tag_col), pane.tag_col, ""); - } else if (key.cp == Key.delete and pane.tag_col < end) { - _ = p.replaceTagText(pane, pane.tag_col, modal.nextGrapheme(text, pane.tag_col), ""); - } - }, - Key.left => if (pane.tag_col > 0) { - pane.tag_col = @intCast(modal.prevGrapheme(text, pane.tag_col)); - }, - Key.right => if (pane.tag_col < end) { - pane.tag_col = @intCast(modal.nextGrapheme(text, pane.tag_col)); - }, - Key.home => pane.tag_col = 0, - Key.end => pane.tag_col = @intCast(end), - else => {}, - } - } - - /// write a helix range back onto the tag cursor + selection: the rendered - /// tag's one-line mirror of setPaneRange. - fn setTagRange(pane: *Pane, text: []const u8, r: modal.Selection) void { - const lo = @min(r.anchor, r.head); - const hi = @max(r.anchor, r.head); - pane.tag_col = @intCast(modal.selectionCursor(text, r)); - pane.tag_sel = modal.nextGrapheme(text, lo) < hi; // one grapheme IS the block cursor - if (pane.tag_sel) pane.tag_anchor = @intCast(if (r.head > r.anchor) lo else modal.prevGrapheme(text, hi)); - } - - fn tagNormalKey(p: *Pardes, pane: *Pane, key: Key) void { - const text = p.tagText(p.scratch.allocator(), pane) catch return; - const cur: usize = @min(@as(usize, pane.tag_col), text.len); - // Esc abandons the command line: back to the body, tail kept as text - if (hit(key, config.escape) or hit(key, &.{.{ .cp = 'j' }})) return exitTagEdit(pane); - if (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; - const txt = p.tagChordText(pane) orelse return; - const id = p.active; - const body_mode = pane.tag_mode; - exitTagEdit(pane); // leave the tag FIRST: Del frees the pane - pane.body.mode = body_mode; - exec.runBuiltin(p, cmd, id, "", txt); - return; - } - if (hit(key, config.tag_yank)) { - if (p.tagChordText(pane)) |txt| edit.setClipboard(p, txt); - 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)) - { - const edit0: usize = if (tagNameEnd(pane) != null) 0 else (p.tagPrefix(pane) catch return).len; - pane.tag_col = @intCast(@max(edit0, if (hit(key, config.append)) - modal.nextGrapheme(text, cur) - else if (hit(key, config.insert_line_start)) - 0 - else if (hit(key, config.insert_line_end)) - text.len - else - cur)); - pane.tag_sel = false; - pane.body.mode = .insert; - return; - } - const dir: ?Builtin = for (config.window_keys) |wk| { - var letter = wk.letter; - letter.cp = std.ascii.toUpper(@intCast(letter.cp)); - if (hit(key, &.{letter})) break wk.cmd; - } else null; - if (dir) |d| { - const from = p.active; - exec.runBuiltin(p, d, from, "", null); - if (p.active == from) { - if (d == .Up) { - exitTagEdit(pane); - p.enterHeader(if (p.columnBarHeight() > 0) (layout.findPane(p, from) orelse return).col else null); - } - return; - } - const dest = p.panes[p.active] orelse return; - exitTagEdit(pane); // the pane we left is fully out of its tag... - p.enterTagEdit(dest, -1); - if (dest.tag_edit) dest.body.mode = .normal; - return; - } - if (lineMotion(text, cur, key)) |r| setTagRange(pane, text, r); - } - 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) @@ -5243,8 +5062,7 @@ pub const Pardes = struct { const pane = p.panes[d].?; p.exitHeader(); p.active = d; - p.enterTagEdit(pane, -1); - if (pane.tag_edit) pane.body.mode = .normal; + p.enterTag(pane); return; } if (hit(key, config.topbar_up)) { @@ -5268,12 +5086,11 @@ pub const Pardes = struct { pub fn cycleMode(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; if (pane.file == null and !pane.isTerminal()) return; - const previous = if (pane.tag_edit) pane.tag_mode else pane.body.mode; + const previous = pane.body.mode; p.active = id; p.exitHeader(); - if (pane.tag_edit) exitTagEdit(pane); + pane.focus = .body; pane.closePrompt(); - pane.body.mode = previous; switch (previous) { .tty => { pane.body.mode = .normal; @@ -5293,7 +5110,7 @@ pub const Pardes = struct { if (!pane.isTerminal()) return; p.active = id; p.exitHeader(); - if (pane.tag_edit) exitTagEdit(pane); + pane.focus = .body; pane.closePrompt(); if (pane.body.mode == .tty) { pane.body.mode = .normal; @@ -5314,7 +5131,7 @@ pub const Pardes = struct { } 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.tag_edit) { + if (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) @@ -5352,7 +5169,8 @@ pub const Pardes = struct { } // insert mode owns Ctrl-w (delete word backward, helix); the focus // prefix applies outside raw tty mode - if (hit(key, config.window_prefix) and pane.body.mode != .insert and pane.prompt == .none) { + 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) { p.ctrl_w_pending = true; return; } @@ -5385,6 +5203,21 @@ pub const Pardes = struct { if (pane.isTerminal() and tty_toggle) { return p.toggleTty(p.active); } + if (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)) { + if (p.commitTagName(pane)) pane.focus = .body; + return; + } + if (hit(key, config.escape)) { + pane.closePrompt(); + pane.focus = .body; + return; + } + edit.handleInsert(p, &pane.input, key); + return; + } if (pane.prompt != .none) { if (hit(key, config.search_submit) or hit(key, config.escape)) { const submit = hit(key, config.search_submit); @@ -5395,7 +5228,7 @@ pub const Pardes = struct { look.submitSearch(p, p.active) else if (normal.selRegexArmed(pane)) |_| normal.applySelRegex(p, pane, "", false), - .none, .del_side => unreachable, + .none, .del_side, .name => unreachable, } pane.closePrompt(); pane.body.mode = .normal; @@ -5406,12 +5239,27 @@ pub const Pardes = struct { if (normal.selRegexArmed(pane)) |a| normal.applySelRegex(p, pane, a.pat, a.split); return; } - if (pane.tag_edit) { - if (pane.body.mode == .normal) p.tagNormalKey(pane, key) else p.tagInsertKey(pane, key); - return; - } - if (pane.body.mode == .normal and (hit(key, config.look_key) or hit(key, config.exec_key))) { + 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) { + // 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); + 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] + else word: { + const b = config.wordBounds(tag, tag.len - tail.len + modal.selectionCursor(tail, range)); + break :word tag[b.lo..b.hi]; + }; + // Out of the tag FIRST: Del frees the pane. + pane.focus = .body; + if (txt.len > 0) exec.runBuiltin(p, cmd, p.active, "", txt); + return; + } edit.pinPaneCursor(p, &pane.body); const explicit = (p.native_images and pane.hasPdfSelection()) or (pane.body.vsel.active and pane.body.vsel.explicit) or pane.body.msel.active; @@ -5430,14 +5278,12 @@ pub const Pardes = struct { exec.runBuiltin(p, cmd, p.active, "", word); return; } - if (pane.body.mode == .normal and pane.hasPdf()) return normal.handlePdfNormal(p, pane, key); - switch (pane.body.mode) { - .normal => { - normal.handleNormal(p, &pane.body, key); - }, + if (t.mode == .normal and t.what == .body and pane.hasPdf()) return normal.handlePdfNormal(p, pane, key); + switch (t.mode) { + .normal => normal.handleNormal(p, t, key), .insert => { - if (hit(key, config.escape)) return edit.exitInsert(p, &pane.body); - edit.handleInsert(p, &pane.body, key); + if (hit(key, config.escape)) return edit.exitInsert(p, t); + edit.handleInsert(p, t, key); }, .tty => panes.Terminal.forwardKey(p, p.active, key), } @@ -5507,7 +5353,7 @@ pub const Pardes = struct { const dir = if (request == .save) paneDir(pane) else ""; const slash: []const u8 = if (request == .save and (dir.len == 0 or dir[dir.len - 1] != '/')) "/" else ""; const answer = std.mem.concat(p.gpa, u8, &.{ marker, dir, slash }) catch return; - if (pane.tag_edit) exitTagEdit(pane); + pane.focus = .body; // Not closePrompt: an s/S snapshot was taken for this very prompt. if (pane.input.own) |old| p.gpa.free(old); pane.prompt = switch (request) { @@ -5806,8 +5652,7 @@ pub const Pardes = struct { pub fn restoreDumpTail(p: *Pardes, pane: *Pane, src: dump.Pane) void { if (src.tag_tail) |tail| { - if (!pane.appendTag(tail)) return; - pane.tag_init = true; + pane.tag.own = p.gpa.dupe(u8, tail) catch return; return; } const tail_class = tailClass(pane); @@ -5855,7 +5700,7 @@ pub const Pardes = struct { } fn restoreTailAt( - _: *Pardes, + p: *Pardes, pane: *Pane, saved_tag: []const u8, pfx: []const u8, @@ -5886,8 +5731,7 @@ pub const Pardes = struct { }; for (defaults) |default| if (std.mem.eql(u8, said, std.mem.trimStart(u8, default, " "))) return; - if (!pane.appendTag(rest)) return; - pane.tag_init = true; + pane.tag.own = p.gpa.dupe(u8, rest) catch return; } /// Close a pane; its rows go to `to`, or where layout.absorbVWeight @@ -6053,8 +5897,18 @@ pub const Pardes = struct { p.trackJump(); for (&p.panes, 0..) |*slot, id| { const pane = slot.* orelse continue; - if (pane.tag_edit and (id != p.active or p.topbar_col != null)) - exitTagEdit(pane); + // 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) { + pane.focus = .body; + if (pane.prompt == .name) pane.closePrompt(); + } + // A sweep over the tag is the tag's while it has the keyboard. + const swept = pane.sel[sel_slot]; + if (pane.focus == .body and swept.state == .done and @min(swept.r0, swept.r1) < BOX_H) { + pane.clearPointerSelection(sel_slot); + pane.sel[sel_slot].state = .none; + } if (comptime terminal_panes) if (pane.terminal) |state| { var off: u16 = 0; while (off < state.reply_len) { @@ -6430,7 +6284,7 @@ pub const Pardes = struct { s.set(d.cur_x, placement.row, "▌", .{ .fg = .{ .rgb = chrome.lineno } }); } }, - .none, .select, .tag => if (p.pointer_inside) { + .none, .select => if (p.pointer_inside) { // hover hints on a pane's own trailing edge var c: usize = 0; while (c + 1 < p.ncol) : (c += 1) { @@ -6801,8 +6655,7 @@ pub const Pardes = struct { .font_role = .tagline, }; s.fill(r.x, tag_y, config.GUTTER, BOX_H, box_style); - const box_mode = if (pane.tag_edit) pane.tag_mode else pane.body.mode; - s.set(r.x, tag_y, switch (box_mode) { + s.set(r.x, tag_y, switch (pane.body.mode) { .normal => config.tag_normal, .insert => config.tag_insert, .tty => config.tag_tty, @@ -6856,9 +6709,24 @@ pub const Pardes = struct { .bg = .{ .rgb = tag_bg }, .font_role = .tagline, }); - const tag = try p.tagText(arena, pane); - if (active and pane.tag_edit) { - const caret = panes.File.rawDisplayCol(tag, pane.tag_col); + const whole = try p.tagText(arena, pane); + const tail = curTail(pane); + const prefix_len = whole.len - tail.len; + // The tagline shows the tag's first line: its prefix and the first + // line of its own text. + const tag = whole[0 .. std.mem.indexOfScalar(u8, whole, '\n') orelse whole.len]; + // The keyboard's caret on it, as a byte offset: in its own text when + // the tag has the keyboard, in the path while a name is drafted. + const caret_at: ?usize = if (!active) + null + else if (pane.prompt == .name) + @min(tag.len, @as(usize, @intCast(@max(0, pane.input.cur_col)))) + else if (pane.focus == .tag and pane.tag.cur_row == 0) + @min(tag.len, prefix_len + @as(usize, @intCast(@max(0, pane.tag.cur_col)))) + else + null; + if (caret_at) |at| { + const caret = panes.File.rawDisplayCol(tag, at); var scroll: usize = pane.tag_scroll; if (caret < scroll) scroll = caret; if (caret >= scroll + capacity) scroll = caret - capacity + 1; @@ -6869,10 +6737,10 @@ pub const Pardes = struct { scroll = panes.File.rawDisplayCol(tag, raw); } pane.tag_scroll = @intCast(scroll); - } else pane.tag_scroll = 0; + } else if (pane.focus != .tag) pane.tag_scroll = 0; const tag_start = panes.File.rawAtDisplay(tag, pane.tag_scroll); _ = s.print(tx, tag_y, tw, tag[tag_start..], tag_style); - if (tagTintRange(pane, tag, tag.len - curTail(pane).len)) |name| { + if (tagTintRange(pane, tag, prefix_len)) |name| { const lo = @max(name.lo, tag_start); const hi = @min(name.hi, tag.len); if (lo < hi) { @@ -6889,23 +6757,31 @@ pub const Pardes = struct { body_layer.paintPointerSelection(p, pane, r, tx, tw, tag_y, body_y, sel, preview_bg, null); }; } - if (pane.tag_edit and pane.tag_sel) { - const b = tagSelBounds(pane); - var col = panes.File.rawDisplayCol(tag, b.lo) -| pane.tag_scroll; - const hi = modal.nextGrapheme(tag, b.hi); - const end = panes.File.rawDisplayCol(tag, hi); - while (col + pane.tag_scroll < end and col < tw) : (col += 1) { - const cell = s.at(tx + @as(u16, @intCast(col)), tag_y); - cell.default = false; - cell.style.bg = .{ .rgb = th.sel_bg }; - cell.style.fg = .{ .rgb = th.sel_fg }; + // The tag's own selections, while it has the keyboard. A range of one + // grapheme is the block cursor, not a selection. + if (pane.focus == .tag) { + var ranges: [Text.max_selections]modal.Selection = undefined; + const got = pane.tag.ranges(tail, 0, &ranges); + const shown = tag.len - prefix_len; + for (ranges[0..got.n]) |range| { + const lo = @min(range.anchor, range.head); + const hi = @max(range.anchor, range.head); + if (modal.nextGrapheme(tail, lo) >= hi or lo >= shown) continue; + var col = panes.File.rawDisplayCol(tag, prefix_len + lo) -| pane.tag_scroll; + const end = panes.File.rawDisplayCol(tag, prefix_len + @min(hi, shown)); + while (col + pane.tag_scroll < end and col < tw) : (col += 1) { + const cell = s.at(tx + @as(u16, @intCast(col)), tag_y); + cell.default = false; + cell.style.bg = .{ .rgb = th.sel_bg }; + cell.style.fg = .{ .rgb = th.sel_fg }; + } } } - // cursor while editing the tag: byte offset mapped to its display cell - if (active and pane.tag_edit) { - // bar while typing, block for `:` normal mode (same rule as a body) - const col = panes.File.rawDisplayCol(tag, pane.tag_col) -| pane.tag_scroll; - if (col < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(col)), .y = tag_y, .bar = pane.body.mode == .insert }; + // the caret: bar while typing, block in normal mode (same rule as a body) + if (caret_at) |at| { + const col = panes.File.rawDisplayCol(tag, at) -| pane.tag_scroll; + const mode = if (pane.prompt == .name) pane.input.mode else pane.tag.mode; + if (col < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(col)), .y = tag_y, .bar = mode == .insert }; } for (pane.sel, 0..) |selection, button| { @@ -7034,10 +6910,10 @@ test "raw terminal cursor obeys visibility without hiding modal and tag cursors" const modal_cursor = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; try std.testing.expectEqual(rect.x + config.GUTTER + 1, modal_cursor.x); try std.testing.expectEqual(body_y, modal_cursor.y); - p.enterTagEdit(pane, 0); + p.enterTag(pane); const tag_cursor = (try p.render(frame.allocator())).cursor orelse return error.MissingCursor; try std.testing.expectEqual(if (p.settings.tag_bottom) rect.y + rect.h - BOX_H else rect.y, tag_cursor.y); - Pardes.exitTagEdit(pane); + pane.focus = .body; pane.body.mode = .tty; try std.testing.expect((try p.render(frame.allocator())).cursor == null); @@ -7193,7 +7069,7 @@ test "a prompt is typed on its own line in the notice band and leaves the tag ro p.update(.{ .key = .{ .cp = '/' } }); p.update(.{ .key = .{ .cp = 't', .text = "tw" } }); p.update(.{ .key = .{ .cp = Key.left } }); - try std.testing.expect(pane.prompt == .search and !pane.tag_edit); + try std.testing.expect(pane.prompt == .search and pane.focus == .body); try std.testing.expectEqualStrings(config.search_marker ++ "tw", pane.promptText().?); _ = frame.reset(.retain_capacity); _ = try p.render(frame.allocator()); @@ -7243,8 +7119,7 @@ test "a stillborn pane gives its column to the first pane that joins it" { try std.testing.expect(p.executeBuiltinLine(shell, "Newcol")); const tagged = p.active; p.sync(); - p.seedTail(p.panes[tagged].?); - try std.testing.expect(p.panes[tagged].?.appendTag(" make")); + try p.panes[tagged].?.appendTag(" make"); try std.testing.expect(p.executeBuiltinLine(tagged, "Tty")); p.update(.tick); try std.testing.expect(p.panes[tagged] != null); |
