diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/File.zig | 3 | ||||
| -rw-r--r-- | src/Pipe.zig | 19 | ||||
| -rw-r--r-- | src/Text.zig | 33 | ||||
| -rw-r--r-- | src/body_layer.zig | 4 | ||||
| -rw-r--r-- | src/edit.zig | 116 | ||||
| -rw-r--r-- | src/exec.zig | 8 | ||||
| -rw-r--r-- | src/mouse.zig | 4 | ||||
| -rw-r--r-- | src/normal.zig | 11 | ||||
| -rw-r--r-- | src/panes.zig | 36 | ||||
| -rw-r--r-- | src/pardes.zig | 168 |
10 files changed, 234 insertions, 168 deletions
diff --git a/src/File.zig b/src/File.zig index 239aa4ba..bf4bf08c 100644 --- a/src/File.zig +++ b/src/File.zig @@ -1129,7 +1129,8 @@ pub fn drawGutter(p: *Pardes, pane: *Pane, r: pardes.Rect, tx: u16, tw: u16, bod const goff = pane.scroll(); const gcur = Terminal.gridCursor(pane); const gcrow = if (pane.body.cur_pinned) pane.body.cur_row else @as(i32, gcur.y) + goff; - const cur_line: i32 = if (active and !pane.tag_edit) gcrow else std.math.minInt(i32); + const typing_body = !pane.tag_edit and (pane.prompt == .none or pane.prompt == .del_side); + const cur_line: i32 = if (active and typing_body) gcrow else std.math.minInt(i32); // the body's first row, the way renderPane derives it (Tagbottom) const body_y = p.bodyTop(r); const context_bg = p.theme().tag_bg; diff --git a/src/Pipe.zig b/src/Pipe.zig index c2b49f31..3e6752a8 100644 --- a/src/Pipe.zig +++ b/src/Pipe.zig @@ -73,13 +73,12 @@ pub fn submitPipe(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; const f = pane.file orelse return; if (!panes.Output.fileTraits(f.output).saves) return; - const prompt = switch (pane.prompt) { + const how = switch (pane.prompt) { .pipe => |pipe| pipe, else => return, }; - const tail = pane.tagSlice(); - const armed = tail[@min(prompt.at, tail.len)..]; - const marker = pipeMarker(prompt.how); + const armed = pane.promptText() orelse return; + const marker = pipeMarker(how); if (!std.mem.startsWith(u8, armed, marker)) return; const command = armed[marker.len..]; if (command.len == 0) return; @@ -87,11 +86,11 @@ pub fn submitPipe(p: *Pardes, id: usize) void { var ranges: [panes.Text.max_selections]modal.Selection = undefined; const got = pane.body.ranges(f.content, 0, &ranges); // `!`/`A-!` take no stdin and run ONCE — see `PendingPipe.nranges`. - const ninputs = if (prompt.how.pipes()) got.n else 1; + const ninputs = if (how.pipes()) got.n else 1; const inputs = p.gpa.alloc(selection_pipe.Input, ninputs) catch return; var made: usize = 0; for (ranges[0..ninputs], 0..) |range, i| { - const bytes = if (prompt.how.pipes()) bytes: { + const bytes = if (how.pipes()) bytes: { const lo = @min(range.anchor, range.head); const hi = @max(range.anchor, range.head); if (hi > f.content.len) break; @@ -131,7 +130,7 @@ pub fn submitPipe(p: *Pardes, id: usize) void { .ranges = ranges, .primary = @intCast(got.pri), .explicit = (pane.body.vsel.active and pane.body.vsel.explicit) or pane.body.msel.active, - .how = prompt.how, + .how = how, .nranges = @intCast(got.n), }; p.emit(.{ .pipe = .{ .id = p.pipe.seq } }); @@ -295,12 +294,12 @@ test "selection pipe prompt submits exact request and Escape cancels" { pane.body.vsel = .{ .active = true, .row = 0, .col = 0, .explicit = true }; p.update(.{ .key = .{ .cp = '|' } }); - try std.testing.expect(pane.tag_edit and pane.prompt == .pipe); - try std.testing.expect(std.mem.endsWith(u8, pane.tagSlice(), config.pipe_marker)); + try std.testing.expect(!pane.tag_edit and pane.prompt == .pipe); + try std.testing.expectEqualStrings(config.pipe_marker, pane.promptText().?); try std.testing.expect(nextPipeEffect(p) == null); p.update(.{ .key = .{ .cp = 't', .text = "tr a-z A-Z" } }); p.update(.{ .key = .{ .cp = Key.enter } }); - try std.testing.expect(!pane.tag_edit and pane.prompt != .pipe); + try std.testing.expect(!pane.tag_edit and pane.prompt != .pipe and pane.input.own == null); const id = nextPipeEffect(p) orelse return error.MissingPipeEffect; const request = p.pipe.pipeRequest(id) orelse return error.MissingPipeRequest; try std.testing.expectEqualStrings("tr a-z A-Z", request.command); diff --git a/src/Text.zig b/src/Text.zig index ad5aa186..aaa6dc88 100644 --- a/src/Text.zig +++ b/src/Text.zig @@ -1,9 +1,11 @@ //! One editable text, acme's Text (editors/acme/dat.h:171-190, text.c): the //! cursor, the selections and the modal state that edit it, and the undo of -//! an edit buffer. A pane's body is one. Where its characters come from -- a -//! file's content, a terminal's edit overlay, a PDF's text -- is the window's -//! business (edit.zig's editText and flatSurface), the way acme's Text reads -//! its File; everything here works on the flat text it is handed. +//! an edit buffer. A pane's body is one, and so is the answer typed into a +//! pane's prompt. Where a body's characters come from -- a file's content, a +//! terminal's edit overlay, a PDF's text -- is the window's business +//! (edit.zig's editText and flatSurface), the way acme's Text reads its File; +//! every other text owns its characters. Everything here works on the flat +//! text it is handed. const std = @import("std"); const panes = @import("panes.zig"); const modal = @import("modal.zig"); @@ -15,6 +17,10 @@ const Text = @This(); pub const Mode = enum { normal, insert, tty }; +/// Which text of its window this is, acme's `what`. It says where the +/// characters live and whether moving the cursor scrolls a body. +pub const What = enum { body, input }; + /// A modal line selection (helix `x`): whole rows [r0, r1], absolute. pub const LineSel = struct { active: bool = false, @@ -41,6 +47,10 @@ pub const SelRange = struct { sticky: i32 = -1, }; +what: What = .body, +/// The characters of a text that holds its own (gpa-owned): everything but +/// a body, which reads its pane's. +own: ?[]u8 = null, mode: Mode = .normal, cur_pinned: bool = false, cur_row: i32 = 0, @@ -65,7 +75,16 @@ ed_redo_len: usize = 0, /// The window this text belongs to (acme's t->w). pub fn pane(t: *Text) *Pane { - return @alignCast(@fieldParentPtr("body", t)); + return switch (t.what) { + .body => @alignCast(@fieldParentPtr("body", t)), + .input => @alignCast(@fieldParentPtr("input", t)), + }; +} + +/// Bring the cursor into view (acme's textshow). Only a body scrolls to it; +/// a prompt's answer is drawn whole. +pub fn show(t: *Text) void { + if (t.what == .body) t.pane().ensureCursorVisible(); } pub fn toModalCursor(t: *Text) modal.Cursor { @@ -143,7 +162,7 @@ pub fn setRange(t: *Text, text: []const u8, row0: i32, r0: modal.Selection, expl t.cur_pinned = true; t.sticky_col = -1; t.normal.clear(); - t.pane().ensureCursorVisible(); + t.show(); } pub fn ranges(t: *Text, text: []const u8, row0: i32, out: *[max_selections]modal.Selection) struct { n: usize, pri: usize } { @@ -602,5 +621,5 @@ pub fn clampCursor(t: *Text, text: []const u8, row0: i32) void { t.cur_pinned = true; t.vsel.active = false; t.msel.active = false; - t.pane().ensureCursorVisible(); + t.show(); } diff --git a/src/body_layer.zig b/src/body_layer.zig index 71529326..1f210220 100644 --- a/src/body_layer.zig +++ b/src/body_layer.zig @@ -460,8 +460,8 @@ pub fn renderBody(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, id } // cursor: tracks the shell cursor until pinned by a click or a key - // (the tag cursor above wins while the tag is focused) - if (active and !pane.tag_edit) { + // (the tag's or a prompt's cursor wins while that is being typed into) + if (active and !pane.tag_edit and (pane.prompt == .none or pane.prompt == .del_side)) { if (pane.body.mode != .tty) { const cur = panes.Terminal.gridCursor(pane); const goff = panes.Terminal.gridOffset(pane); diff --git a/src/edit.zig b/src/edit.zig index f1b381d6..5249a64c 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -84,12 +84,23 @@ pub fn applyPaste(p: *Pardes, bytes: []const u8) void { const pane = p.panes[id] orelse return; if (req) |r| if (pane.serial != r.serial) return; p.active = id; + if (pane.prompt != .none and pane.prompt != .del_side) { + // an answer is one line: a newline in it would only be submitting it + const line = p.scratch.allocator().alloc(u8, bytes.len) catch return; + var n: usize = 0; + for (bytes) |byte| if (byte != '\n' and byte != '\r') { + line[n] = byte; + n += 1; + }; + if (n > 0) insertKey(p, &pane.input, .{ .cp = line[0], .text = line[0..n] }); + if (normal.selRegexArmed(pane)) |a| normal.applySelRegex(p, pane, a.pat, a.split); + return; + } if (pane.tag_edit) { const text = p.tagText(p.scratch.allocator(), pane) catch return; const range: @TypeOf(Pardes.tagSelBounds(pane)) = if (pane.tag_sel) Pardes.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, bytes); - if (normal.selRegexArmed(pane)) |a| normal.applySelRegex(p, pane, a.pat, a.split); return; } if (pane.isTerminal() and pane.body.mode == .tty) return typeToTty(p, id, pane, bytes); @@ -274,7 +285,7 @@ pub fn exitInsert(p: *Pardes, t: *Text) void { t.cur_col = @intCast(bc.col); t.vsel = .{ .active = a_off != back, .row = aa.row, .col = aa.col, .explicit = false }; t.cur_pinned = true; - t.pane().ensureCursorVisible(); + t.show(); for (t.sels[0..t.nsel]) |*s| { const sgap = modal.offsetAt(text, .{ .row = @intCast(@max(0, s.row)), .col = @intCast(@max(0, s.col)) }); const b2 = if (back == gap) sgap else modal.prevGrapheme(text, sgap); @@ -288,8 +299,15 @@ pub fn exitInsert(p: *Pardes, t: *Text) void { } pub fn paneCursorLines(p: *Pardes, t: *Text) ![]const []const u8 { - const pane = t.pane(); const arena = p.scratch.allocator(); + if (t.what != .body) { + const text = t.own orelse ""; + const lines = try arena.alloc([]const u8, std.mem.count(u8, text, "\n") + 1); + var it = std.mem.splitScalar(u8, text, '\n'); + for (lines) |*line| line.* = it.next().?; + return lines; + } + const pane = t.pane(); if (pane.file) |*f| return panes.File.cursorLines(arena, pane, f); if (pane.hasPdf()) { if (comptime pdf_enabled) return panes.Pdf.textLines(&pane.pdf.?, p.pdf_gpa, arena); @@ -299,9 +317,8 @@ pub fn paneCursorLines(p: *Pardes, t: *Text) ![]const []const u8 { } pub fn paneByteAtDisplay(p: *Pardes, t: *Text, row: i32, from_raw: i32, display_col: i32) i32 { - const pane = t.pane(); - if (pane.file != null) - return panes.File.byteAtRowDisplay(pane, row, from_raw, display_col); + if (t.what == .body and t.pane().file != null) + return panes.File.byteAtRowDisplay(t.pane(), row, from_raw, display_col); const lines = paneCursorLines(p, t) catch return @max(0, from_raw + display_col); const local = row; if (local < 0 or @as(usize, @intCast(local)) >= lines.len) @@ -316,14 +333,20 @@ pub fn paneByteAtDisplay(p: *Pardes, t: *Text, row: i32, from_raw: i32, display_ /// Freeze the live terminal cursor into the modal coordinate space. The /// emulator reports screen cells; editing state stores UTF-8 byte offsets. pub fn pinPaneCursor(p: *Pardes, t: *Text) void { - const pane = t.pane(); if (t.cur_pinned) return; + // Only a body has a live cursor of someone else's to freeze. + if (t.what != .body) { + t.cur_pinned = true; + return; + } + const pane = t.pane(); pane.pinCursor(); if (pane.file == null and !pane.hasPdf()) t.cur_col = paneByteAtDisplay(p, t, t.cur_row, 0, t.cur_col); } pub fn flatSurface(p: *Pardes, t: *Text) ![]const u8 { + if (t.what != .body) return t.own orelse ""; const pane = t.pane(); if (pane.file) |f| return f.content; if (pane.hasPdf()) { @@ -335,13 +358,16 @@ pub fn flatSurface(p: *Pardes, t: *Text) ![]const u8 { } pub fn paneWrapWidth(p: *const Pardes, t: *Text) usize { + if (t.what != .body) return 0; const pane = t.pane(); if (pane.file == null) return 0; return panes.File.wrapWidth(pane, p.settings.wrap); } -/// Route shared edit operations to a file's content or a terminal overlay. +/// Route shared edit operations to the characters `t` edits: its own, or +/// for a body its file's content or its terminal's overlay. fn editText(p: *Pardes, t: *Text, lo: i32, hi: i32, col: i32) ?panes.EditText { + if (t.what != .body) return .{ .text = t.own orelse &.{}, .row0 = 0 }; const pane = t.pane(); if (pane.file) |f| return .{ .text = f.content, .row0 = 0 }; if (pane.image != null or pane.hasPdf()) return null; @@ -358,6 +384,11 @@ fn editTextEol(p: *Pardes, t: *Text, b: Bounds) ?panes.EditText { /// install a rewritten editable text (frees the old one) fn setEditText(p: *Pardes, t: *Text, new: []u8) void { + if (t.what != .body) { + if (t.own) |old| p.gpa.free(old); + t.own = new; + return; + } const pane = t.pane(); if (pane.file) |*f| return panes.File.setContent(p, f, new); panes.Terminal.setEditText(p, pane, new); @@ -368,6 +399,7 @@ pub const InsertAt = enum { at, append, line_start, line_end, open_below, open_a /// Files insert only inside existing rows and at grapheme boundaries. /// Terminal overlays can materialize virtual rows/columns, so keep those. pub fn clampFileCursor(t: *Text) bool { + if (t.what != .body) return false; const pane = t.pane(); const file = if (pane.file) |*file| file else return false; const old_row = t.cur_row; @@ -380,8 +412,7 @@ pub fn clampFileCursor(t: *Text) bool { } pub fn enterInsert(p: *Pardes, t: *Text, where: InsertAt, cnt: usize) void { - const pane = t.pane(); - if (pane.hasPdf()) return; + if (t.what == .body and t.pane().hasPdf()) return; pinPaneCursor(p, t); _ = clampFileCursor(t); // snapshot once per insert session (WITH the pre-insert selection) so @@ -478,7 +509,7 @@ pub fn enterInsert(p: *Pardes, t: *Text, where: InsertAt, cnt: usize) void { t.mode = .insert; t.msel.active = false; t.normal.clear(); - pane.ensureCursorVisible(); + t.show(); } pub fn handleInsert(p: *Pardes, t: *Text, key: Key) void { @@ -487,7 +518,6 @@ pub fn handleInsert(p: *Pardes, t: *Text, key: Key) void { } pub fn insertKey(p: *Pardes, t: *Text, key: Key) void { - const pane = t.pane(); // helix aliases: normalize to the base key and re-dispatch if (hit(key, config.insert_backspace_alias)) return insertKey(p, t, .{ .cp = Key.backspace }); if (hit(key, config.insert_enter_alias)) return insertKey(p, t, .{ .cp = Key.enter }); @@ -507,7 +537,7 @@ pub fn insertKey(p: *Pardes, t: *Text, key: Key) void { else => cur0, }; t.fromModalCursor(nc); - pane.ensureCursorVisible(); + t.show(); return; }, Key.page_up, Key.page_down => { @@ -515,14 +545,15 @@ pub fn insertKey(p: *Pardes, t: *Text, key: Key) void { // scroll + cursor snap as normal mode const flat = flatSurface(p, t) catch return; const range = t.primaryRange(flat, 0); - const step: i32 = @intCast(@max(1, pane.rows)); + if (t.what != .body) return; + const step: i32 = @intCast(@max(1, t.pane().rows)); t.scrollViewMove(flat, range, if (key.cp == Key.page_down) step else -step); return; }, Key.home => { t.cur_col = 0; t.cur_pinned = true; - pane.ensureCursorVisible(); + t.show(); return; }, Key.end => { @@ -531,7 +562,7 @@ pub fn insertKey(p: *Pardes, t: *Text, key: Key) void { const cur0 = t.toModalCursor(); t.cur_col = @intCast(panes.File.textLine(t, text, cur0.row).len); t.cur_pinned = true; - pane.ensureCursorVisible(); + t.show(); return; }, else => {}, @@ -549,7 +580,7 @@ pub fn insertKey(p: *Pardes, t: *Text, key: Key) void { } else t.cur_col += 1; } t.cur_pinned = true; - pane.ensureCursorVisible(); + t.show(); return; } // helix insert-mode kills (word/line; deleteSpan is exclusive) @@ -569,7 +600,7 @@ pub fn insertKey(p: *Pardes, t: *Text, key: Key) void { t.cur_row = @as(i32, @intCast(fc.row)) + e2.row0; t.cur_col = @intCast(fc.col); t.cur_pinned = true; - pane.ensureCursorVisible(); + t.show(); return; } if (hit(key, config.delete_word_forward)) { @@ -592,7 +623,7 @@ pub fn insertKey(p: *Pardes, t: *Text, key: Key) void { setEditText(p, t, new); t.cur_col = 0; t.cur_pinned = true; - pane.ensureCursorVisible(); + t.show(); return; } if (hit(key, config.kill_to_line_end)) { @@ -615,7 +646,7 @@ pub fn insertKey(p: *Pardes, t: *Text, key: Key) void { t.cur_row += 1; t.cur_col = @intCast(indent); t.cur_pinned = true; - pane.ensureCursorVisible(); + t.show(); }, Key.backspace => { if (t.cur_col > 0) { @@ -625,7 +656,7 @@ pub fn insertKey(p: *Pardes, t: *Text, key: Key) void { setEditText(p, t, new); t.cur_col = @intCast(prev); t.cur_pinned = true; - pane.ensureCursorVisible(); + t.show(); return; } // at col 0: eat the newline above (join up) — a terminal grows @@ -640,7 +671,7 @@ pub fn insertKey(p: *Pardes, t: *Text, key: Key) void { t.cur_row -= 1; t.cur_col = @intCast(prevlen); t.cur_pinned = true; - pane.ensureCursorVisible(); + t.show(); }, Key.delete => { // delete forward; at line end eat the newline (join), which on @@ -661,7 +692,8 @@ pub fn insertKey(p: *Pardes, t: *Text, key: Key) void { Key.tab => { const ln = modal.lineSlice(text, c.row); if (!p.multi_on and c.col > 0 and c.col <= ln.len and ln[c.col - 1] == '.') dot: { - const f = pane.file orelse break :dot; + if (t.what != .body) break :dot; + const f = t.pane().file orelse break :dot; if (f.output != null or !lsp.speaks(f.path)) break :dot; const seq = p.lsp_seq; p.lspRequest(p.active, .completion, ""); @@ -684,7 +716,7 @@ pub fn insertTab(p: *Pardes, t: *Text) void { setEditText(p, t, new); t.cur_col += @intCast(pad); t.cur_pinned = true; - t.pane().ensureCursorVisible(); + t.show(); } const Bounds = struct { lo_row: i32, lo_col: i32, hi_row: i32, hi_col: i32 }; @@ -779,7 +811,7 @@ pub fn normalDelete(p: *Pardes, t: *Text, yank: bool) void { t.cur_col = 0; t.msel.active = false; t.cur_pinned = true; - t.pane().ensureCursorVisible(); + t.show(); } else { pushUndo(p, t); const eb = editTextEol(p, t, .{ .lo_row = t.cur_row, .lo_col = t.cur_col, .hi_row = t.cur_row, .hi_col = t.cur_col }) orelse return; @@ -799,12 +831,11 @@ pub fn normalDelete(p: *Pardes, t: *Text, yank: bool) void { } pub fn normalYank(p: *Pardes, t: *Text) void { - const pane = t.pane(); t.select = false; - if (pane.rawPointerText(pane.sel[sel_slot])) |text| { + if (t.what == .body) if (t.pane().rawPointerText(t.pane().sel[sel_slot])) |text| { setYank(p, text); return; - } + }; if (t.vsel.active) { setYank(p, vselText(p, t)); return; @@ -882,11 +913,10 @@ pub fn pasteText(p: *Pardes, t: *Text, y0: []const u8, before: bool, count: usiz } t.msel.active = false; t.cur_pinned = true; - t.pane().ensureCursorVisible(); + t.show(); } pub fn normalChange(p: *Pardes, t: *Text) void { - const pane = t.pane(); t.select = false; t.sticky_col = -1; if (t.vsel.active) { @@ -913,7 +943,7 @@ pub fn normalChange(p: *Pardes, t: *Text) void { t.cur_pinned = true; t.mode = .insert; t.normal.clear(); - pane.ensureCursorVisible(); + t.show(); return; } setYank(p, vselText(p, t)); @@ -974,7 +1004,7 @@ pub fn normalChange(p: *Pardes, t: *Text) void { t.cur_pinned = true; t.mode = .insert; t.normal.clear(); - pane.ensureCursorVisible(); + t.show(); } else { pushUndo(p, t); const eb = editTextEol(p, t, .{ .lo_row = t.cur_row, .lo_col = t.cur_col, .hi_row = t.cur_row, .hi_col = t.cur_col }) orelse return; @@ -1075,7 +1105,7 @@ fn replaceWithText(p: *Pardes, t: *Text, y: []const u8) void { } t.cur_pinned = true; t.sticky_col = -1; - t.pane().ensureCursorVisible(); + t.show(); } /// `~` / `` ` `` / ``Alt-` ``: case-map the selection (or the cursor char), @@ -1174,7 +1204,7 @@ pub fn normalJoin(p: *Pardes, t: *Text) void { t.msel.active = false; t.cur_pinned = true; t.sticky_col = -1; - t.pane().ensureCursorVisible(); + t.show(); } pub fn normalIndent(p: *Pardes, t: *Text, cnt: usize, add: bool) void { @@ -1242,7 +1272,7 @@ pub fn normalIndent(p: *Pardes, t: *Text, cnt: usize, add: bool) void { t.msel.active = false; t.cur_pinned = true; t.sticky_col = -1; - t.pane().ensureCursorVisible(); + t.show(); } pub fn normalToggleComment(p: *Pardes, t: *Text) void { @@ -1277,7 +1307,7 @@ pub fn normalToggleComment(p: *Pardes, t: *Text) void { } next = @max(next, end); } - const ext = if (t.pane().file) |f| std.fs.path.extension(f.path) else ""; + const ext = if (t.what != .body) "" else if (t.pane().file) |f| std.fs.path.extension(f.path) else ""; var token: []const u8 = config.comment_token_default; lang: for (config.comment_tokens) |row| { for (row.exts) |e| if (std.ascii.eqlIgnoreCase(ext, e)) { @@ -1359,7 +1389,7 @@ pub fn normalAdjustNumber(p: *Pardes, t: *Text, delta: i64) void { t.cur_col = @intCast(cc.col); t.cur_pinned = true; t.sticky_col = -1; - t.pane().ensureCursorVisible(); + t.show(); } /// `]Space` / `[Space`: add blank lines below/above, the cursor staying @@ -1381,7 +1411,7 @@ pub fn addNewline(p: *Pardes, t: *Text, below: bool, cnt: usize) void { t.vsel.row += @intCast(cnt); } t.cur_pinned = true; - t.pane().ensureCursorVisible(); + t.show(); } /// pair mapping for m-mode: either bracket names the pair; anything else @@ -1450,7 +1480,7 @@ pub fn surroundAdd(p: *Pardes, t: *Text, ch: u21) void { t.cur_col = @intCast(close_col + @as(usize, if (r.a.row == r.b.row) 1 else 0)); t.cur_pinned = true; t.sticky_col = -1; - t.pane().ensureCursorVisible(); + t.show(); } /// `md<ch>`: delete the enclosing pair's chars; the cursor maps through @@ -1487,7 +1517,7 @@ pub fn surroundDelete(p: *Pardes, t: *Text, ch: u21) void { t.vsel.active = false; t.msel.active = false; t.cur_pinned = true; - t.pane().ensureCursorVisible(); + t.show(); } /// `mr<from><to>`: swap the enclosing <from> pair's chars for <to>'s @@ -1512,20 +1542,24 @@ pub fn surroundReplace(p: *Pardes, t: *Text, from: u21, to: u21) void { } pub fn pushUndo(p: *Pardes, t: *Text) void { - const pane = t.pane(); // one keystroke, one undo step — even when it edited at ten cursors. if (p.multi_on and !p.multi_first) return; + // a prompt's answer is typed once and gone; it keeps no history + if (t.what != .body) return; + const pane = t.pane(); if (pane.file != null) return panes.File.pushUndo(p, pane); panes.Terminal.pushUndo(p, pane); } pub fn doUndo(p: *Pardes, t: *Text) void { + if (t.what != .body) return; const pane = t.pane(); if (pane.file != null) return panes.File.undo(p, pane); panes.Terminal.undo(p, pane); } pub fn doRedo(p: *Pardes, t: *Text) void { + if (t.what != .body) return; const pane = t.pane(); if (pane.file != null) return panes.File.redo(p, pane); panes.Terminal.redo(p, pane); diff --git a/src/exec.zig b/src/exec.zig index 0a9d6f31..a2dbac7c 100644 --- a/src/exec.zig +++ b/src/exec.zig @@ -219,12 +219,8 @@ pub fn withArg(p: *Pardes, txt: []const u8, arg: ?[]const u8) []const u8 { /// Enter on a save input: the path is everything past the marker. pub fn submitSave(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; - const at = switch (pane.prompt) { - .save => |a| a, - else => return, - }; - const armed = pane.tag_tail[at..pane.tag_tail_len]; - pane.tag_tail_len = at; // drop the prompt back to the seeded tail + if (pane.prompt != .save) return; + const armed = pane.promptText() orelse return; if (!std.mem.startsWith(u8, armed, config.save_marker)) return; const path = std.mem.trim(u8, armed[config.save_marker.len..], " \t\r\n"); if (path.len == 0) return; diff --git a/src/mouse.zig b/src/mouse.zig index a840c8fc..5f77634b 100644 --- a/src/mouse.zig +++ b/src/mouse.zig @@ -304,6 +304,7 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void { { if (m.button == config.select_button) if (p.panes[p.active]) |pane| { if (pane.tag_edit) Pardes.exitTagEdit(pane); + pane.closePrompt(); }; var column: ?usize = null; var x: u16 = 0; @@ -439,6 +440,7 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void { const pane = p.panes[id] orelse return; if (id != p.active) if (p.panes[p.active]) |previous| { if (previous.tag_edit) Pardes.exitTagEdit(previous); + previous.closePrompt(); }; const tag_y = if (p.settings.tag_bottom) r.y + r.h -| BOX_H else r.y; const body_y = p.bodyTop(r); @@ -449,12 +451,14 @@ pub fn handleMouse(p: *Pardes, m: Mouse) void { } else if (mrow >= tag_y and mrow < tag_y + BOX_H) { if (mcol < r.x + TAG_TEXT_INSET) return; p.active = id; + pane.closePrompt(); p.enterTagEdit(pane, if (tagColumn(p, .pane, id, p.pointer_tag_hit, false)) |value| @as(i32, value) else @as(i32, mcol) - @as(i32, r.x + TAG_TEXT_INSET)); pane.tag_anchor = pane.tag_col; p.drag = .{ .tag = .{ .id = id } }; } else { p.active = id; if (pane.tag_edit) Pardes.exitTagEdit(pane); + pane.closePrompt(); const pointed = edit.pointerTextSelection(p, id, mcol, mrow, m.body_hit, p.pointer_tag_hit) orelse return; pane.clearPointerSelection(sel_slot); pane.sel[sel_slot] = pointed.sel; diff --git a/src/normal.zig b/src/normal.zig index 192fd933..a77f27a7 100644 --- a/src/normal.zig +++ b/src/normal.zig @@ -76,13 +76,8 @@ fn startSelRegex(p: *Pardes, pane: *Pane, split: bool) void { } pub fn selRegexArmed(pane: *Pane) ?struct { pat: []const u8, split: bool } { - const prompt_at = switch (pane.prompt) { - .search => |at| at, - else => return null, - }; - if (pane.nsel_snap == 0) return null; - const tail = pane.tagSlice(); - const armed = tail[@min(prompt_at, tail.len)..]; + if (pane.prompt != .search or pane.nsel_snap == 0) return null; + const armed = pane.input.own orelse ""; const split = std.mem.startsWith(u8, armed, config.split_marker); if (!split and !std.mem.startsWith(u8, armed, config.select_marker)) return null; const slash = std.mem.indexOfScalar(u8, armed, '/') orelse return null; @@ -274,7 +269,7 @@ pub fn replaySels(p: *Pardes, t: *Text, what: Replay) void { } t.setRanges(text, rs[0..n], st[0..n], pri, after_expl); after.into(t); - t.pane().ensureCursorVisible(); // the view follows the PRIMARY, not the last pass + t.show(); // the view follows the PRIMARY, not the last pass } pub fn handleNormal(p: *Pardes, t: *Text, key: Key) void { diff --git a/src/panes.zig b/src/panes.zig index e6634583..c26065ca 100644 --- a/src/panes.zig +++ b/src/panes.zig @@ -59,12 +59,13 @@ pub const Pane = struct { const PdfSlot = if (Pdf.enabled) ?Pdf.State else void; + /// What an open prompt is asking. The answer is typed into `input`. pub const Prompt = union(enum) { none, - search: u16, - pipe: struct { at: u16, how: modal.Normal.PipeBehavior }, - /// Save on a scratch buffer or a terminal: the tail is a path to write to. - save: u16, + search, + pipe: modal.Normal.PipeBehavior, + /// Save on a scratch buffer or a terminal: the answer is a path to write to. + save, /// Del from the keyboard with open panes both above and below: which /// one takes the rows. Nothing is typed; one key answers. del_side, @@ -94,9 +95,11 @@ pub const Pane = struct { file: ?File.State = null, image: ?Image.State = null, pdf: PdfSlot = if (Pdf.enabled) null else {}, - /// Tag-tail input state. The tag text is presentation; this tag carries - /// which operation owns it and the tail offset restored on submit/cancel. prompt: Prompt = .none, + /// The answer to an open prompt: its marker and what is typed after it, + /// edited in insert mode and shown on the notice band. It is its own text + /// so a prompt never touches the tag. + input: Text = .{ .what = .input }, search_pane: ?usize = null, search_row: ?usize = null, look_at: ?LookSpot = null, @@ -297,20 +300,23 @@ pub const Pane = struct { pub fn tagSlice(p: *const Pane) []const u8 { return p.tag_tail[0..p.tag_tail_len]; } - pub fn promptAt(p: *const Pane) ?u16 { - return switch (p.prompt) { - .none, .del_side => null, - .search, .save => |at| at, - .pipe => |pipe| pipe.at, - }; + /// Close an open prompt, answered or not. Its answer goes with it, and an + /// s/S preview keeps whatever it last previewed. + pub fn closePrompt(pane: *Pane) void { + if (pane.input.own) |text| pane.gpa.free(text); + pane.input = .{ .what = .input }; + pane.prompt = .none; + pane.nsel_snap = 0; } /// What an open prompt shows on its notice band: the one answer every /// painter of the band reads. pub fn promptText(p: *const Pane) ?[]const u8 { - if (p.prompt == .del_side) return config.del_question; - const at = p.promptAt() orelse return null; - return p.tagSlice()[@min(at, p.tag_tail_len)..]; + return switch (p.prompt) { + .none => null, + .del_side => config.del_question, + else => p.input.own orelse "", + }; } pub fn appendTag(p: *Pane, text: []const u8) bool { 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 }); |
