From 9584b0682b45f17aaa42157540947904d867777a Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 27 Sep 2026 23:42:53 -0300 Subject: Give prompts their own line to type into instead of the tag's tail A search, pipe or Save prompt wrote its marker and answer onto the end of the tag, so the tag's cursor, its selection, its 9P writes and a prompt abandoned by a click all shared one buffer: the abandoned text stayed in the tag and a 9P write landed inside the prompt. The answer is now its own Text on the pane, typed in insert mode and shown on the notice band where it was already drawn, and the tag never holds any of it. Co-Authored-By: Claude Opus 5.5 --- src/pardes.zig | 168 ++++++++++++++++++++++++++++++--------------------------- 1 file changed, 90 insertions(+), 78 deletions(-) (limited to 'src/pardes.zig') diff --git a/src/pardes.zig b/src/pardes.zig index 15b2c49b..a29973e7 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -1824,10 +1824,10 @@ test "Save on a scratch asks for a path in its inherited dir and makes it a file // Save on a scratch arms a PATH input, prefilled with the inherited dir try std.testing.expect(p.executeBuiltinLine(id, "Save")); try std.testing.expect(np.prompt == .save); - try std.testing.expect(std.mem.endsWith(u8, np.tagSlice(), " Save /tmp/pardes-save-dir/")); + try std.testing.expectEqualStrings(" Save /tmp/pardes-save-dir/", np.promptText().?); // typing the filename and submitting converts it into an ordinary file - try std.testing.expect(np.appendTag("note.txt")); + edit.insertKey(p, &np.input, .{ .cp = 'n', .text = "note.txt" }); exec.submitSave(p, id); try std.testing.expect(np.file.?.output == null); try std.testing.expectEqualStrings("/tmp/pardes-save-dir/note.txt", np.file.?.path); @@ -1852,7 +1852,7 @@ test "Save on a terminal writes its plaintext scrollback and stays a terminal" { try std.testing.expect(p.executeBuiltinLine(0, "Save")); try std.testing.expect(pane.prompt == .save); - try std.testing.expect(pane.appendTag("log.txt")); + edit.insertKey(p, &pane.input, .{ .cp = 'l', .text = "log.txt" }); exec.submitSave(p, 0); // the pane is untouched: still a terminal, no file behind it @@ -1901,7 +1901,7 @@ test "Save on an output buffer writes its rows out and leaves the buffer alone" try std.testing.expectEqualStrings(file_pane_tail, Pardes.curTail(out)); try std.testing.expect(p.executeBuiltinLine(id, "Save")); try std.testing.expect(out.prompt == .save); - try std.testing.expect(out.appendTag("help.txt")); + edit.insertKey(p, &out.input, .{ .cp = 'h', .text = "help.txt" }); exec.submitSave(p, id); // the buffer itself is untouched: same origin, same name, still refillable @@ -4109,6 +4109,7 @@ pub const Pardes = struct { if (comptime pdf_enabled) if (pane.pdf) |*pv| panes.Pdf.deinitPane(p, pane, pv); 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); 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); @@ -4836,7 +4837,7 @@ pub const Pardes = struct { } pub fn enterTagEdit(p: *Pardes, pane: *Pane, col: i32) void { - if (pane.tag_edit and pane.prompt == .none) pane.tag_saved_col = pane.tag_col; + 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; @@ -4869,7 +4870,7 @@ pub const Pardes = struct { } pub fn exitTagEdit(pane: *Pane) void { - if (pane.tag_edit and pane.prompt == .none) pane.tag_saved_col = pane.tag_col; + 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; @@ -4877,8 +4878,6 @@ pub const Pardes = struct { pane.tag_sel = false; pane.tag_name_len = null; pane.tag_scroll = 0; - pane.prompt = .none; // an abandoned prompt stays as tag text - pane.nsel_snap = 0; // ...and its s/S preview keeps whatever it previewed } pub fn tagSelBounds(pane: *Pane) struct { lo: usize, hi: usize } { @@ -5273,6 +5272,7 @@ pub const Pardes = struct { p.active = id; p.exitHeader(); if (pane.tag_edit) exitTagEdit(pane); + pane.closePrompt(); pane.body.mode = previous; switch (previous) { .tty => { @@ -5294,6 +5294,7 @@ pub const Pardes = struct { p.active = id; p.exitHeader(); if (pane.tag_edit) exitTagEdit(pane); + pane.closePrompt(); if (pane.body.mode == .tty) { pane.body.mode = .normal; pane.body.normal.clear(); @@ -5351,7 +5352,7 @@ 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) { + if (hit(key, config.window_prefix) and pane.body.mode != .insert and pane.prompt == .none) { p.ctrl_w_pending = true; return; } @@ -5384,34 +5385,29 @@ pub const Pardes = struct { if (pane.isTerminal() and tty_toggle) { return p.toggleTty(p.active); } - if (pane.prompt != .none and (hit(key, config.search_submit) or hit(key, config.escape))) { - const submit = hit(key, config.search_submit); - switch (pane.prompt) { - .pipe => |pipe| { - if (submit) Pipe.submitPipe(p, p.active); - pane.tag_tail_len = @min(pipe.at, pane.tag_tail_len); - }, - .save => |at| { - if (submit) exec.submitSave(p, p.active) else pane.tag_tail_len = @min(at, pane.tag_tail_len); - }, - .search => |at| { - if (submit) + if (pane.prompt != .none) { + if (hit(key, config.search_submit) or hit(key, config.escape)) { + const submit = hit(key, config.search_submit); + switch (pane.prompt) { + .pipe => if (submit) Pipe.submitPipe(p, p.active), + .save => if (submit) exec.submitSave(p, p.active), + .search => if (submit) look.submitSearch(p, p.active) else if (normal.selRegexArmed(pane)) |_| - normal.applySelRegex(p, pane, "", false); - pane.tag_tail_len = @min(at, pane.tag_tail_len); - }, - .none, .del_side => unreachable, + normal.applySelRegex(p, pane, "", false), + .none, .del_side => unreachable, + } + pane.closePrompt(); + pane.body.mode = .normal; + pane.body.normal.clear(); + return; } - exitTagEdit(pane); - pane.body.mode = .normal; - pane.body.normal.clear(); + edit.handleInsert(p, &pane.input, key); + 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); - const pn = p.panes[p.active] orelse return; - if (normal.selRegexArmed(pn)) |a| normal.applySelRegex(p, pn, a.pat, a.split); return; } if (pane.body.mode == .normal and (hit(key, config.look_key) or hit(key, config.exec_key))) { @@ -5506,33 +5502,24 @@ pub const Pardes = struct { break :pipe Pipe.pipeMarker(how); }, }; - p.seedTail(pane); - if (!pane.tag_init) return; + // Save asks for a path, so its answer starts out as the pane's own + // directory for the name to be typed onto. const dir = if (request == .save) paneDir(pane) else ""; - const slash = request == .save and (dir.len == 0 or dir[dir.len - 1] != '/'); - var room = pane.tag_tail.len - pane.tag_tail_len; - if (marker.len > room) return; - room -= marker.len; - if (dir.len > room) return; - room -= dir.len; - if (slash and room == 0) return; - const prefix_len = (p.tagPrefix(pane) catch return).len; - const prompt_at: u16 = @intCast(pane.tag_tail_len); - _ = pane.appendTag(marker); - if (request == .save) { - _ = pane.appendTag(dir); - if (slash) _ = pane.appendTag("/"); - } + 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); + // 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) { - .search => .{ .search = prompt_at }, - .pipe => |how| .{ .pipe = .{ .at = prompt_at, .how = how } }, - .save => .{ .save = prompt_at }, + .search => .search, + .pipe => |how| .{ .pipe = how }, + .save => .save, }; - pane.tag_edit = true; - pane.tag_sel = false; - pane.body.mode = .insert; + pane.input = .{ .what = .input, .own = answer, .mode = .insert, .cur_pinned = true, .cur_col = @intCast(answer.len) }; + // Whatever mode the body was in, it is in normal mode once the + // prompt is answered or dropped. + pane.body.mode = .normal; pane.body.normal.clear(); - pane.tag_col = @intCast(prefix_len + pane.tag_tail_len); } pub const SavePath = Effect.Buf(effect_path_cap); @@ -6066,7 +6053,7 @@ pub const Pardes = struct { p.trackJump(); for (&p.panes, 0..) |*slot, id| { const pane = slot.* orelse continue; - if (pane.tag_edit and pane.prompt == .none and (id != p.active or p.topbar_col != null)) + if (pane.tag_edit and (id != p.active or p.topbar_col != null)) exitTagEdit(pane); if (comptime terminal_panes) if (pane.terminal) |state| { var off: u16 = 0; @@ -6279,18 +6266,14 @@ pub const Pardes = struct { // the detached wire will encode -- which drops every frame for // as long as the prompt is up. const printed = Messages.printRight(s, cx, row, chip -| 1, text, style); - if (kind != .prompt or id != p.active) continue; - const at = pane.promptAt() orelse continue; - const prompt0 = (p.tagPrefix(pane) catch continue).len + at; - const col = @as(usize, pane.tag_col); - if (col < prompt0) continue; + if (kind != .prompt or id != p.active or pane.prompt == .del_side) continue; // The cursor follows the text to wherever it landed; a caret // in the part a narrow band dropped has nowhere to be. - const prompt_col = panes.File.displayWidth(text[0..@min(col - prompt0, text.len)]); + const prompt_col = panes.File.displayWidth(text[0..@min(@as(usize, @intCast(@max(0, pane.input.cur_col))), text.len)]); if (prompt_col < printed.dropped) continue; const caret = printed.left + (prompt_col - printed.dropped); if (caret < tx + tw) - s.cursor = .{ .x = @intCast(caret), .y = row, .bar = pane.body.mode == .insert }; + s.cursor = .{ .x = @intCast(caret), .y = row, .bar = pane.input.mode == .insert }; } } @@ -6652,7 +6635,7 @@ 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 pane.promptAt() == null and temporary.cursor != null) .{ .col = temporary.cursor.?.x - TAG_TEXT_INSET, .bar = temporary.cursor.?.bar } else null; + layer.cursor = if (p.topbar_col == null 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 @@ -6711,14 +6694,11 @@ pub const Pardes = struct { p.surface.tag_layers[layer_index].fade = fade; // The caret rides the band, where the prompt's glyphs are, // at the same place in the text the grid pass puts it. - if (kind != .prompt or id != p.active) continue; - const at = pane.promptAt() orelse continue; - const prompt0 = (p.tagPrefix(pane) catch continue).len + at; - if (pane.tag_col < prompt0) continue; - const typed = panes.File.displayWidth(text[0..@min(pane.tag_col - prompt0, text.len)]); + if (kind != .prompt or id != p.active or pane.prompt == .del_side) continue; + const typed = panes.File.displayWidth(text[0..@min(@as(usize, @intCast(@max(0, pane.input.cur_col))), text.len)]); const dropped = shown - panes.File.displayWidth(kept); if (typed < dropped) continue; - p.surface.tag_layers[layer_index].cursor = .{ .col = @intCast(pad + typed - dropped), .bar = pane.body.mode == .insert }; + p.surface.tag_layers[layer_index].cursor = .{ .col = @intCast(pad + typed - dropped), .bar = pane.input.mode == .insert }; } } if (p.columnBarHeight() > 0) for (0..p.ncol) |column| { @@ -6877,8 +6857,7 @@ pub const Pardes = struct { .font_role = .tagline, }); const tag = try p.tagText(arena, pane); - const prompt_at = pane.promptAt(); - if (active and pane.tag_edit and prompt_at == null) { + if (active and pane.tag_edit) { const caret = panes.File.rawDisplayCol(tag, pane.tag_col); var scroll: usize = pane.tag_scroll; if (caret < scroll) scroll = caret; @@ -6890,16 +6869,12 @@ pub const Pardes = struct { scroll = panes.File.rawDisplayCol(tag, raw); } pane.tag_scroll = @intCast(scroll); - } else if (!pane.tag_edit or prompt_at != null) pane.tag_scroll = 0; - const tag_cut = if (prompt_at) |at| - @min(tag.len, tag.len - @min(tag.len, pane.tag_tail_len) + at) - else - tag.len; - const tag_start = @min(tag_cut, panes.File.rawAtDisplay(tag, pane.tag_scroll)); - _ = s.print(tx, tag_y, tw, tag[tag_start..tag_cut], tag_style); + } else 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| { const lo = @max(name.lo, tag_start); - const hi = @min(name.hi, tag_cut); + const hi = @min(name.hi, tag.len); if (lo < hi) { var col = panes.File.rawDisplayCol(tag, lo) -| pane.tag_scroll; const end = panes.File.rawDisplayCol(tag, hi) -| pane.tag_scroll; @@ -7204,6 +7179,43 @@ test "a prompt chip is a band with its caret, still on the grid, and only the ch for (pane.sel) |selection| try std.testing.expect(selection.state == .none); } +test "a prompt is typed on its own line in the notice band and leaves the tag row alone" { + if (platform == .web) return; + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + const pane = try p.setTestFile("one two\n"); + var frame = std.heap.ArenaAllocator.init(std.testing.allocator); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + const tag_before = try std.testing.allocator.dupe(Cell, p.surface.tag_layers[0].cells); + defer std.testing.allocator.free(tag_before); + + 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.expectEqualStrings(config.search_marker ++ "tw", pane.promptText().?); + _ = frame.reset(.retain_capacity); + _ = try p.render(frame.allocator()); + // The tagline is exactly what it was: nothing of the prompt is in it. + try std.testing.expectEqual(tag_before.len, p.surface.tag_layers[0].cells.len); + for (tag_before, p.surface.tag_layers[0].cells) |was, is| try std.testing.expect(was.visuallyEqual(&is)); + try std.testing.expect(p.surface.tag_layers[0].cursor == null); + // The band carries the answer and the caret, one cell back from its end. + const band = for (p.surface.tagLayers()) |*layer| { + if (layer.cols != 0 and layer.kind == .notice) break layer; + } else return error.NoPromptBand; + const caret = band.cursor orelse return error.MissingBandCaret; + try std.testing.expectEqualStrings("t", band.cells[caret.col - 1].grapheme()); + try std.testing.expectEqualStrings("w", band.cells[caret.col].grapheme()); + const grid = p.surface.cursor orelse return error.MissingPromptCursor; + try std.testing.expectEqual(p.bodyTop(p.rects[0]), grid.y); + + // Submitting searches for what was typed and gives the prompt its line back. + p.update(.{ .key = .{ .cp = Key.enter } }); + try std.testing.expect(pane.prompt == .none and pane.input.own == null); +} + test "a stillborn pane gives its column to the first pane that joins it" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 40 }); -- cgit v1.3