diff options
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 539 |
1 files changed, 374 insertions, 165 deletions
diff --git a/src/pardes.zig b/src/pardes.zig index 8a6fa589..6294b6cb 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -1092,13 +1092,13 @@ test "Joincol folds the active column into its right neighbor, keeping its panes try std.testing.expectEqual(@as(usize, 2), p.ncol); p.active = 0; // the left column is current - layout.joinCol(p); + layout.joinCol(p, 0); try std.testing.expectEqual(@as(usize, 1), p.ncol); const lf = layout.findPane(p, 0) orelse return error.LostLeftPane; const rf = layout.findPane(p, right) orelse return error.LostRightPane; try std.testing.expectEqual(lf.col, rf.col); - layout.joinCol(p); // no right neighbor left: inert + layout.joinCol(p, 0); // no right neighbor left: inert try std.testing.expectEqual(@as(usize, 1), p.ncol); } @@ -1179,6 +1179,8 @@ test "first document fallback splits like Tty from the originating pane" { defer p.deinit(); const tty = try Pardes.init(std.testing.allocator, .{ .cols = width, .rows = 62, .shells = 3 }); defer tty.deinit(); + p.settings.placement = .pardes; + tty.settings.placement = .pardes; if (collapsed) { layout.toggleCollapse(p, from); layout.toggleCollapse(tty, from); @@ -1191,8 +1193,8 @@ test "first document fallback splits like Tty from the originating pane" { // The Look origin need not be the currently active pane. p.active = (from + 1) % 3; const free = p.freeSlot().?; - const doc = try p.openTutorView(free); - exec.placeDoc(p, from, free, doc); + _ = try p.openTutorView(free); + exec.placeNew(p, from, from, free, .doc); _ = exec.spawnTty(tty, from); p.sync(); tty.sync(); @@ -1215,9 +1217,10 @@ test "first document keeps its originating shell and only opens a column when it for ([_]u16{ 199, 200 }) |width| { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = width, .rows = 32 }); defer p.deinit(); + p.settings.placement = .pardes; const shell = p.panes[0].?; - const doc = try p.openTutorView(1); - exec.placeDoc(p, 0, 1, doc); + _ = try p.openTutorView(1); + exec.placeNew(p, 0, 0, 1, .doc); p.sync(); try std.testing.expectEqual(shell, p.panes[0].?); try std.testing.expectEqual(@as(usize, 1), p.active); @@ -1233,6 +1236,167 @@ test "first document keeps its originating shell and only opens a column when it } } +test "acme placement: an empty active column takes the new pane whole" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 32 }); + defer p.deinit(); + try std.testing.expectEqual(config.Runtime.Placement.acme, p.settings.placement); + p.newColumn(0); // its tag has the keyboard: it is the active column + tagline.exitHeader(p); + p.sync(); + // A Look from the shell's text still lands in the active column, as + // acme's makenewwindow puts it in activecol first (util.c:456). + _ = try p.openTutorView(1); + exec.placeNew(p, 0, 0, 1, .doc); + try std.testing.expectEqualSlices(usize, &.{1}, p.col_panes[1][0..p.col_n[1]]); + try std.testing.expectEqual(@as(usize, 1), p.active); + try std.testing.expectEqual(@as(?usize, 1), p.activeColumn()); + // Never a new column. + try std.testing.expectEqual(@as(usize, 2), p.ncol); +} + +test "acme placement: under a pane's text when it has many blank rows, else halving the biggest" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 60 }); + defer p.deinit(); + const top = p.active; + _ = try p.setTestFile("one\ntwo\nthree\n"); + p.sync(); + p.setActiveColumn(0); + // Most of the pane is blank: a document looked from it starts under its + // three lines (makenewwindow, util.c:482-486). + const second = p.freeSlot().?; + _ = try p.openTutorView(second); + exec.placeNew(p, top, top, second, .doc); + p.sync(); + const text_end = p.bodyTop(p.panes[top].?, p.rects[top]) + 3; + try std.testing.expectEqual(text_end, p.rects[second].y); + // Fill both with text: no blank space is big, so the biggest pane is + // halved. + const full = "x\n" ** 80; + for ([_]usize{ top, second }) |id| panes.File.setContent(p, &p.panes[id].?.file.?, try p.gpa.dupe(u8, full)); + p.sync(); + const big = if (p.rects[second].h >= p.rects[top].h) second else top; + const was = p.rects[big]; + const third = p.freeSlot().?; + _ = try p.openTutorView(third); + exec.placeNew(p, second, second, third, .doc); + p.sync(); + try std.testing.expectEqual(big, p.col_panes[0][layout.findPane(p, third).?.idx - 1]); + try std.testing.expectEqual(was.y + was.h / 2, p.rects[third].y); + try std.testing.expectEqual(was.y + was.h, p.rects[third].y + p.rects[third].h); +} + +test "acme placement: New and 9P's new take the bottom half of the last pane" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 40 }); + defer p.deinit(); + p.newScratchBelow(p.active); + const last = p.active; + p.sync(); + const was = p.rects[last]; + p.newScratchFor(last, .fs); + const made = p.active; + p.sync(); + try std.testing.expectEqual(layout.findPane(p, last).?.idx + 1, layout.findPane(p, made).?.idx); + try std.testing.expectEqual(was.y + was.h, p.rects[made].y + p.rects[made].h); + try std.testing.expect(p.rects[made].y > was.y + BOX_H); +} + +test "Placement pardes brings back the old rules, and says so in Config" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 40 }); + defer p.deinit(); + try std.testing.expect(p.executeBuiltinLine(p.active, "Placement pardes")); + try std.testing.expectEqual(config.Runtime.Placement.pardes, p.settings.placement); + const top = p.active; + p.sync(); + const was = p.rects[top]; + p.newScratchBelow(top); + p.sync(); + // Directly under the asking pane, as New always put it. + try std.testing.expectEqual(@as(usize, 1), layout.findPane(p, p.active).?.idx); + try std.testing.expectEqual(was.y + was.h, p.rects[p.active].y + p.rects[p.active].h); + try std.testing.expect(p.executeBuiltinLine(p.active, "Placement")); + try std.testing.expectEqual(config.Runtime.Placement.acme, p.settings.placement); +} + +test "an empty column shows its theme's empty_col under its tag: acme's white, else the border" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 120, .rows = 20 }); + defer p.deinit(); + p.newColumn(0); + tagline.exitHeader(p); + inline for (.{ "acme", "dark" }) |name| { + try std.testing.expect(p.executeBuiltinLine(0, "Theme " ++ name)); + p.chrome_animation.displayed = colors.ChromeTheme.fromTheme(p.theme()); + p.sync(); + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + const s = try p.render(arena.allocator()); + const cell = s.at(p.col_x[1] + 3, p.screen_h - 2); + const want = p.chromeTheme().empty_col; + try std.testing.expectEqual(want, cell.style.bg.rgb); + if (std.mem.eql(u8, name, "acme")) try std.testing.expectEqual([3]u8{ 0xff, 0xff, 0xff }, want) else try std.testing.expectEqual(p.chromeTheme().border, want); + } +} + +test "BootShell keep leaves the untouched boot shell a dragged document joins; replace closes it" { + inline for (.{ "keep", "replace" }) |word| { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 100, .rows = 30 }); + defer p.deinit(); + const shell = p.active; + try std.testing.expect(p.panes[shell].?.isTerminal()); + try std.testing.expect(p.executeBuiltinLine(shell, "BootShell " ++ word)); + _ = try p.openTutorView(1); + layout.insert(p, 0, 1, 1); + p.sync(); + exec.evictLonePristineTty(p, 0, 1); + p.sync(); + const kept = std.mem.eql(u8, word, "keep"); + try std.testing.expectEqual(kept, p.panes[shell] != null); + try std.testing.expectEqual(@as(usize, if (kept) 2 else 1), p.col_n[0]); + } +} + +test "a pane its whole text fits in never scrolls to keep a margin" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 30 }); + defer p.deinit(); + const pane = try p.setTestFile("one\ntwo\nthree\n"); + // A second pane under it, as acme's placement puts one: right under the + // three lines, which leaves the file exactly three body rows. + const below = p.freeSlot().?; + _ = try p.openTutorView(below); + exec.placeNew(p, 0, 0, below, .doc); + p.active = 0; + p.sync(); + try std.testing.expectEqual(@as(u16, 3), p.rects[0].h - pane.tag_rows); + for ("jj") |c| p.update(.{ .key = .{ .cp = c, .text = &.{c} } }); + p.sync(); + try std.testing.expectEqual(@as(i32, 2), pane.body.cur_row); + try std.testing.expectEqual(@as(i32, 0), pane.scroll()); +} + +test "dragging a column's only pane to another leaves that column in place, empty" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 32 }); + defer p.deinit(); + p.newColumn(0); + p.newScratchIn(0, p.activeColumn().?); + const moved = p.active; + p.sync(); + const weights = p.col_weight; + const tag_serial = p.col_serial[1]; + layout.movePane(p, moved, p.col_x[0] + 4, p.rects[0].y + p.rects[0].h / 2); + p.sync(); + // acme's coldragwin: colclose then coladd (cols.c:516-517); the column + // left behind keeps its place, width and tag. + try std.testing.expectEqual(@as(usize, 2), p.ncol); + try std.testing.expectEqual(@as(usize, 0), p.col_n[1]); + try std.testing.expectEqual(tag_serial, p.col_serial[1]); + try std.testing.expectEqualDeep(weights, p.col_weight); + try std.testing.expectEqual(@as(usize, 2), p.col_n[0]); + // ...and a pane dragged into the empty column takes it whole. + layout.movePane(p, moved, p.col_x[1] + 4, p.screen_h / 2); + p.sync(); + try std.testing.expectEqualSlices(usize, &.{moved}, p.col_panes[1][0..p.col_n[1]]); + try std.testing.expectEqual(@as(usize, 1), p.col_n[0]); +} + test "pane defaults keep creation of scratch panes in column tags" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); @@ -3044,7 +3208,7 @@ test "column tags follow their column through split and close" { try std.testing.expectEqualStrings("Left Find", tagline.columnTag(p, 0)); p.col_tags[1].own = try p.gpa.dupe(u8, "Right Grep"); p.active = p.col_panes[0][0]; - layout.joinCol(p); + layout.joinCol(p, 0); try std.testing.expectEqual(@as(usize, 1), p.ncol); try std.testing.expectEqualStrings("Right Grep", tagline.columnTag(p, 0)); } @@ -3053,7 +3217,8 @@ test "column tags render edit hover and scroll from the column edge" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 20, .tty_only = true }); defer p.deinit(); _ = try p.setTestFile("untouched\n"); - p.newScratchColumn(0); + p.newColumn(0); + p.newScratchIn(0, p.activeColumn().?); p.sync(); p.acknowledgePanelPresentation(&.{}); var frame = std.heap.ArenaAllocator.init(std.testing.allocator); @@ -3109,7 +3274,8 @@ test "column tag first-cell execution keeps New local to the clicked column" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 20, .tty_only = true }); defer p.deinit(); _ = try p.setTestFile("untouched\n"); - p.newScratchColumn(0); + p.newColumn(0); + p.newScratchIn(0, p.activeColumn().?); p.sync(); for (0..2) |column| { p.active = p.col_panes[1 - column][0]; @@ -3169,7 +3335,7 @@ test "tiny screens keep pane space before adding a column tag" { _ = try p.render(p.scratch.allocator()); } -test "ColumnTags reclaims the row and releases hidden header focus without losing commands" { +test "a screen too short for the column row releases a column tag's focus without losing its commands" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 60, .rows = 12, .tty_only = true }); defer p.deinit(); const pane = try p.setTestFile("unchanged body\n"); @@ -3181,24 +3347,7 @@ test "ColumnTags reclaims the row and releases hidden header focus without losin p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = TAG_TEXT_INSET, .row = TOPBAR_H } }); try std.testing.expect(p.header_focus and p.header_column != null); - p.update(.{ .command = "ColumnTags" }); - try std.testing.expect(!p.settings.column_tags); - try std.testing.expectEqual(@as(u16, 0), p.columnBarHeight()); - try std.testing.expectEqual(TOPBAR_H, p.rects[0].y); - try std.testing.expectEqual(shown.h + COLBAR_H, p.rects[0].h); - try std.testing.expect(!p.header_focus and p.header_column == null); - try std.testing.expect(!p.header_drag); - try std.testing.expectEqualStrings("New Help", tagline.columnTag(p, 0)); - - p.update(.{ .command = "ColumnTags" }); - try std.testing.expect(p.settings.column_tags); - try std.testing.expectEqual(shown, p.rects[0]); - p.acknowledgePanelPresentation(&.{}); - p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = TAG_TEXT_INSET, .row = TOPBAR_H } }); - p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = TAG_TEXT_INSET, .row = TOPBAR_H } }); - try std.testing.expect(p.header_column != null); p.update(.{ .resize = .{ .cols = 60, .rows = 2 } }); - try std.testing.expect(p.settings.column_tags); try std.testing.expectEqual(@as(u16, 0), p.columnBarHeight()); try std.testing.expectEqual(TOPBAR_H, p.rects[0].y); try std.testing.expect(!p.header_focus and p.header_column == null); @@ -3248,15 +3397,17 @@ test "Enter in a header runs the word under its cursor, as the execute key does" p.global_tag.cur_col = @intCast(std.mem.indexOf(u8, config.topbar_str, "Newcol").?); p.update(.{ .key = .{ .cp = Key.enter } }); try std.testing.expectEqual(@as(usize, 2), p.ncol); - // and the header lets go of the keyboard, as a pane tag does - try std.testing.expect(!p.header_focus); + // and the workspace tag lets go of the keyboard: the new, empty + // column's tag has it + try std.testing.expect(p.header_focus and p.header_column.? == 1); } test "window keys walk from a pane up through the column and workspace tags and back" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 20, .tty_only = true }); defer p.deinit(); _ = try p.setTestFile("body\n"); - p.newScratchColumn(0); + p.newColumn(0); + p.newScratchIn(0, p.activeColumn().?); p.sync(); p.active = p.col_panes[0][0]; const window = struct { @@ -3543,6 +3694,7 @@ test "Newcol refuses an unsplittable restored weight before spawning" { const pane = p.panes[id].?; try p.deinitPane(pane); layout.removePane(p, id); + layout.dropColumn(p, id); p.panes[id] = null; } while (p.nextEffect()) |_| {} @@ -3561,6 +3713,8 @@ test "Newcol refuses an unsplittable restored weight before spawning" { test "layout commits publish finite tracks only for changed panes" { const p = try Pardes.init(std.testing.allocator, .{ .cols = 120, .rows = 20 }); defer p.deinit(); + // The mechanism, at a fixed flavour, not the default\'s tuning. + p.settings.motion = .crisp; _ = try p.newShell(1, ""); try std.testing.expect(layout.splitColumn(p, 0, 1, false)); @@ -3624,6 +3778,8 @@ test "vertical close samples only a canonical baseline containing that pane" { { const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 16, .tty_only = true }); defer p.deinit(); + // The mechanism, at a fixed flavour, not the default\'s tuning. + p.settings.motion = .crisp; var frame = std.heap.ArenaAllocator.init(std.testing.allocator); defer frame.deinit(); p.settings.panel_transition = .vertical; @@ -3647,6 +3803,8 @@ test "vertical close samples only a canonical baseline containing that pane" { { const p = try Pardes.init(std.testing.allocator, .{ .cols = 100, .rows = 16, .tty_only = true }); defer p.deinit(); + // The mechanism, at a fixed flavour, not the default\'s tuning. + p.settings.motion = .crisp; var frame = std.heap.ArenaAllocator.init(std.testing.allocator); defer frame.deinit(); p.settings.panel_transition = .vertical; @@ -4079,9 +4237,15 @@ pub const Pardes = struct { look_walk_owner: ?u32 = null, /// hands out Pane.serial; monotonic, never reused next_serial: u32 = 0, - /// The pane (by serial) that joined a column this step; `replaceStillborn` - /// checks it once the step is over and nothing holds the column's panes. - stillborn_joiner: ?u32 = null, + /// The column whose tag the running command came from, while it runs: + /// the column acme's commands take from their Text's col. + exec_column: ?usize = null, + /// acme's activecol (dat.c:37), by column serial, 0 for none: the + /// column last typed in (acme.c:487), clicked in with the select button + /// (acme.c:659), dropped into by a grip (acme.c:640), or given a new + /// pane (util.c:467). Where the acme placement puts new panes + /// (exec.placeNew). A Look click moves the keyboard but not this. + active_column: u32 = 0, settings: config.Runtime = .{ .font = .{ .tagline_percent = config.gui_tagline_font_percent } }, locations_config: locations_config.Config = .{}, locations_cache: @import("locations_cache.zig").Cache = .{}, @@ -4092,6 +4256,10 @@ pub const Pardes = struct { theme_file_generation: u32 = 0, theme_file_pane: u8 = 0, chrome_animation: colors.ChromeAnimation = colors.ChromeAnimation.init(colors.initial_chrome), + /// Each pane's lift off the page (Lift): 1 while it has the focus, on a + /// spring. `lift_serials` says whose it is, so a reused slot starts flat. + lifts: [MAX_PANES]animation.Spring = @splat(.{}), + lift_serials: [MAX_PANES]u32 = @splat(0), animate_theme_changes: bool = false, native_images: bool = false, /// The shell can take a changed image a few rows at a time (a texture row @@ -4334,10 +4502,12 @@ pub const Pardes = struct { p.panes[0].?.body.mode = .tty; p.oneColumn(); // A bare tty is still an editor: it opens with somewhere to put - // text under the shell. The shell keeps the keyboard. + // text under the shell. The shell keeps the keyboard. A boot + // layout, like .classic's, not a placement. if (p.boot == .tty) { - p.newScratchBelow(0); - p.active = 0; + const np = try p.newScratch(0, 1); + layout.insert(p, 0, 1, 1); + layout.splitBelow(p, 0, np); } }, .classic => { @@ -4457,7 +4627,7 @@ pub const Pardes = struct { /// gives a column tag one font height); a newline in one is a space /// (tagline.flattenHeaders). pub fn columnBarHeight(p: *const Pardes) u16 { - if (!p.settings.column_tags or p.screen_h < 3) return 0; + if (p.screen_h < 3) return 0; return COLBAR_H; } @@ -4593,34 +4763,56 @@ pub const Pardes = struct { return np; } - /// The empty pane that stands in for one that closed, keeping its column - /// alive and its directory. Unlike `newScratch` it inherits nothing: the - /// pane it replaces is on its way out. - fn newScratchIn(p: *Pardes, free: usize, dir: []const u8) !*Pane { - const content = try p.gpa.dupe(u8, ""); - errdefer p.gpa.free(content); - const np = try panes.Output.open(p, free, dir, .{ .cmd = .New }, "", content); - np.body.cur_pinned = true; - return np; - } - - /// New: a scratch below the calling pane, in its column. + /// New: an empty scratch in `from_id`'s directory, placed as exec.placeNew + /// places it. pub fn newScratchBelow(p: *Pardes, from_id: usize) void { - const free = p.freeSlot() orelse return; - const sf = layout.findPane(p, from_id) orelse return; - const np = p.newScratch(from_id, free) catch return; - layout.insert(p, sf.col, sf.idx + 1, free); - layout.splitBelow(p, from_id, np); - p.active = free; + p.newScratchFor(from_id, .scratch); } - /// Newcol: a scratch in a fresh column beside the calling pane. - pub fn newScratchColumn(p: *Pardes, from_id: usize) void { + /// A scratch for New (`.scratch`) or 9P's pane/new (`.fs`). + pub fn newScratchFor(p: *Pardes, from_id: usize, kind: exec.NewPane) void { const free = p.freeSlot() orelse return; - if (!layout.canSplitColumn(p, from_id)) return; _ = p.newScratch(from_id, free) catch return; - std.debug.assert(layout.splitColumn(p, from_id, free, false)); - p.active = free; + exec.placeNew(p, from_id, from_id, free, kind); + } + + /// Newcol: an empty column right of the calling pane's, the keyboard + /// on its tag, for the next pane made to fill. + pub fn newColumn(p: *Pardes, from_id: usize) void { + const col = p.keyboardColumn(from_id) orelse return; + const c = layout.insertColumn(p, col, false) orelse return; + tagline.enterHeader(p, c); + } + + pub fn setActiveColumn(p: *Pardes, c: usize) void { + if (c < p.ncol) p.active_column = layout.columnSerial(p, c); + } + + /// The active column, while it is still there. + pub fn activeColumn(p: *const Pardes) ?usize { + return layout.columnBySerial(p, p.active_column); + } + + /// The column the keyboard is in: its tag's, or its pane's. + pub fn noteTyped(p: *Pardes) void { + if (p.header_focus) { + if (p.header_column) |c| p.setActiveColumn(c); + } else if (layout.findPane(p, p.active)) |f| p.setActiveColumn(f.col); + } + + /// New, run from column `c`'s tag. + pub fn newScratchIn(p: *Pardes, from_id: usize, c: usize) void { + p.exec_column = c; + defer p.exec_column = null; + p.newScratchBelow(from_id); + } + + /// The column a command acts on: the one whose tag it came from, else + /// the one whose tag has the keyboard, else `id`'s. + pub fn keyboardColumn(p: *Pardes, id: usize) ?usize { + if (p.exec_column) |c| return c; + if (p.header_focus) if (p.header_column) |c| return c; + return if (layout.findPane(p, id)) |f| f.col else null; } pub fn freeSlot(p: *Pardes) ?usize { @@ -5029,7 +5221,9 @@ pub const Pardes = struct { if (v.wait_input) |f| { // Asleep until input or until core animation next needs a // frame; 0 is no timeout at all. - const timeout: u32 = if (p.nextWake()) |due| wake: { + // A render-time animation draws every loop: the wait only takes + // what input is there. + const timeout: u32 = if (p.continuous()) 1 else if (p.nextWake()) |due| wake: { const now = if (v.now) |clock| clock(h.ctx) else p.now_ns; const ms = ((due -| now) + std.time.ns_per_ms - 1) / std.time.ns_per_ms; break :wake @intCast(std.math.clamp(ms, 1, std.math.maxInt(u32))); @@ -5048,7 +5242,7 @@ pub const Pardes = struct { // rendering and nothing else. // A shell with a clock draws what `advance` stepped; one without // steps its own `.tick`s and draws while anything animates. - if (!p.needs_frame and (v.now != null or !p.animationActive())) return; + if (!p.needs_frame and !p.continuous() and (v.now != null or !p.animationActive())) return; _ = p.frame_arena.reset(.retain_capacity); const surface = try p.render(p.frame_arena.allocator()); if (v.present) |f| f(h.ctx, surface); @@ -5072,10 +5266,6 @@ pub const Pardes = struct { look.cancelLookHover(p); const reply = ctlfs.handle(p, req); if (reply.status == .err and (req.op == .write or req.op == .setattr)) ctlfs.events.noteError(p, req, reply); - // A pane that joined a column holding only a stillborn scratch takes - // it now, before the answer: one read of index after the look shows - // the replacement done, and the log has its del with the new. - exec.replaceStillborn(p); // The request was a whole step of its own, so it settles the way a // step does: the cursor and scroll reconciled, the scripted panes // told, and the panes it made announced to /log now rather than at @@ -5087,7 +5277,6 @@ pub const Pardes = struct { } pub fn update(p: *Pardes, ev: Event) void { - defer exec.replaceStillborn(p); // A tick with nothing animating is the one event that cannot change // the screen. p.needs_frame = p.needs_frame or ev != .tick; @@ -5358,6 +5547,7 @@ pub const Pardes = struct { return; } const pane = p.panes[p.active] orelse return; + p.noteTyped(); // Alt-Down and Alt-Up in a pane's tag expand and collapse it, in // either mode: acme's Tagdown and Tagup (plan9port text.c:776). if (pane.focus == .tag and key.alt and !key.ctrl and (key.cp == Key.down or key.cp == Key.up) and tagline.focusedHeader(p) == null) { @@ -5412,16 +5602,11 @@ pub const Pardes = struct { // global window ops (any mode): Alt-n new terminal below, Alt-c move // the active terminal into a fresh column if (hit(key, config.new_shell_below)) { - tagline.exitHeader(p); const free = p.freeSlot() orelse return; const nt = p.newShell(free, "") catch return; nt.greet = true; - const src = layout.splitParent(p, p.active); - const f = layout.findPane(p, src).?; - layout.insert(p, f.col, f.idx + 1, free); - layout.splitBelow(p, src, nt); - p.active = free; - p.stillborn_joiner = nt.serial; + // From a tag, as a 9P new is: acme's t->w == nil. + exec.placeNew(p, p.active, if (p.header_focus) null else p.active, free, .shell); return; } for (config.jump_keys) |jk| { @@ -5500,6 +5685,9 @@ pub const Pardes = struct { const b = config.wordBounds(shown, modal.selectionCursor(shown, range)); break :word shown[b.lo..b.hi]; }; + // A column tag's command acts on that column, empty or not. + p.exec_column = if (header != null) p.header_column else null; + defer p.exec_column = null; // Out of the tag or header FIRST: Del frees the pane, Kill // and Restore the whole session. if (header != null) tagline.exitHeader(p) else pane.focus = .body; @@ -5964,59 +6152,38 @@ pub const Pardes = struct { // ---- dump / load (acme-style: `pardes -l state.zon` restores a session) ---- /// Close a pane; its rows go to `to`, or where layout.absorbVWeight - /// sends them when that is null. Focus on it follows its rows: to the - /// pane that grew into them, or to the scratch left standing in an - /// emptied column. Every route that closes a pane comes through here or - /// removeColumn, so none picks focus for itself, and none by recency. + /// sends them when that is null. Focus on it follows its rows to the + /// pane that grew into them. A column outlives its panes, as acme's + /// does (cols.c:186): closing its last one leaves it empty, the keyboard + /// on its tag; only Delcol takes a column away. The session's last pane + /// closing ends the session. Every route that closes a pane comes + /// through here or removeColumn, so none picks focus for itself, and + /// none by recency. pub fn removePane(p: *Pardes, id: usize, to: ?usize) !void { const pane = p.panes[id] orelse return; - // A column outlives its panes. Closing the last one leaves an empty - // pane standing in its place and in its directory; only Delcol ever - // takes a column away. const place = layout.findPane(p, id); - const last_in_column = if (place) |f| p.col_n[f.col] == 1 else false; - var dir_buf: [limits.host_path_cap]u8 = undefined; - var dir: []const u8 = ""; - if (last_in_column) { - const had = paneDir(pane); - const n = @min(had.len, dir_buf.len); - @memcpy(dir_buf[0..n], had[0..n]); - dir = dir_buf[0..n]; - } + const emptied = if (place) |f| (if (p.col_n[f.col] == 1) f.col else null) else null; // Before anything is torn down, so a failure here changes nothing. try p.deinitPane(pane); - // ...and after it, so the stand-in inherits none of the closing pane. - const heir: ?usize = heir: { - if (!last_in_column) break :heir null; - const f = place.?; - const free = p.freeSlot() orelse break :heir null; - _ = p.newScratchIn(free, dir) catch break :heir null; - layout.insert(p, f.col, f.idx + 1, free); - break :heir free; - }; const gainer = layout.absorbVWeight(p, id, to); - const beside = if (last_in_column) p.besideColumn(place.?.col, id) else null; + const beside = if (emptied) |c| p.besideColumn(c, p.rects[id]) else null; layout.removePane(p, id); p.panes[id] = null; - if (p.active == id) p.active = heir orelse gainer orelse beside orelse p.anyPane() orelse { - p.quit = true; - p.emit(.quit); - return; - }; + if (p.active != id) return; + p.active = gainer orelse beside orelse p.anyPane() orelse return p.quitEmpty(); + if (emptied) |c| if (!p.header_focus) tagline.enterHeader(p, c); } - pub fn removeColumn(p: *Pardes, id: usize) !void { - const place = layout.findPane(p, id) orelse return; - const had_focus = layout.findPane(p, p.active); - const beside = if (had_focus != null and had_focus.?.col == place.col) - p.besideColumn(place.col, p.active) - else - null; + /// Delcol: close column `col` and every pane in it. + pub fn removeColumn(p: *Pardes, col: usize) !void { + if (col >= p.ncol) return; + const had_focus = p.keyboardColumn(p.active) == col; + const beside = if (had_focus) p.besideColumn(col, if (p.header_focus) null else p.rects[p.active]) else null; var ids: [MAX_PANES]usize = undefined; var parents: [MAX_PANES]*Pane = undefined; - const count = p.col_n[place.col]; + const count = p.col_n[col]; for (0..count) |i| { - ids[i] = p.col_panes[place.col][i]; + ids[i] = p.col_panes[col][i]; parents[i] = p.panes[ids[i]].?; } try p.detachCwds(parents[0..count]); @@ -6025,11 +6192,14 @@ pub const Pardes = struct { layout.removePane(p, closed); p.panes[closed] = null; } - if (p.panes[p.active] == null) p.active = beside orelse p.anyPane() orelse { - p.quit = true; - p.emit(.quit); - return; - }; + layout.dropColumn(p, col); + if (p.panes[p.active] == null) p.active = beside orelse p.anyPane() orelse return p.quitEmpty(); + } + + /// No pane is left: the session is over. + fn quitEmpty(p: *Pardes) void { + p.quit = true; + p.emit(.quit); } /// The pane Last should fall back to when the jumplist has nothing: the @@ -6048,14 +6218,13 @@ pub const Pardes = struct { return null; } - /// The pane that takes over `id`'s place when column `col` goes: in the - /// column layout.removePane gives the width to, the one level with the - /// middle of `id`. Asked before the column goes, while rects still hold. - fn besideColumn(p: *const Pardes, col: usize, id: usize) ?usize { + /// The pane that takes over the keyboard from column `col`: in the + /// column layout.dropColumn gives the width to, the one level with the + /// middle of `r` (of the screen without one). Asked while rects hold. + fn besideColumn(p: *const Pardes, col: usize, r: ?Rect) ?usize { if (p.ncol < 2) return null; const next = if (col > 0) col - 1 else col + 1; - const r = p.rects[id]; - const mid = r.y + r.h / 2; + const mid = if (r) |at| at.y + at.h / 2 else p.screen_h / 2; var pick: ?usize = null; for (p.col_panes[next][0..p.col_n[next]]) |k| { if (p.panes[k] == null) continue; @@ -6272,10 +6441,19 @@ pub const Pardes = struct { return if (frames) |n| p.stepped_ns + n * animation.frame_ns else null; } + /// A render-time animation is moving (a pane's lift): it is sampled at + /// each frame's own `now`, so while it moves the shell draws every loop + /// and its present (vsync) paces it, at the display's rate. `nextWake` + /// is for the stepped animations, which count frames of `frame_ns`. + pub fn continuous(p: *const Pardes) bool { + for (p.lifts) |spring| if (!spring.settled) return true; + return false; + } + /// For shells that only need a yes or no: macOS and the board, which /// keep their own frame banks. pub fn animationActive(p: *const Pardes) bool { - return p.nextWake() != null; + return p.nextWake() != null or p.continuous(); } /// Bring core animation up to `now_ns`, the shell's monotonic clock: one @@ -6686,43 +6864,67 @@ test "a prompt is typed on its own line in the notice band and leaves the tag ro 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" { +test "Newcol makes an empty column, and the next pane made from its tag fills it" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 40 }); defer p.deinit(); - try std.testing.expect(p.executeBuiltinLine(0, "Newcol")); - const blank = p.active; - p.sync(); // Tty splits by the rects a frame has laid out - try std.testing.expect(p.executeBuiltinLine(blank, "Tty")); + const first = p.active; + const panes_before = p.col_n[0]; + try std.testing.expect(p.executeBuiltinLine(first, "Newcol")); + try std.testing.expectEqual(@as(usize, 2), p.ncol); + try std.testing.expectEqual(@as(usize, 0), p.col_n[1]); + try std.testing.expectEqual(panes_before, p.col_n[0]); + // The keyboard is on the new column's tag; no pane has it. + try std.testing.expect(p.header_focus and p.header_column.? == 1); + try std.testing.expectEqual(first, p.active); + p.sync(); + try std.testing.expect(p.executeBuiltinLine(first, "Tty")); const shell = p.active; - p.update(.tick); // the step ends: the placeholder goes - try std.testing.expect(p.panes[blank] == null); - try std.testing.expectEqual(@as(usize, 1), p.col_n[layout.findPane(p, shell).?.col]); - try std.testing.expectEqual(shell, p.active); + try std.testing.expect(shell != first); + try std.testing.expectEqual(@as(usize, 1), p.col_n[1]); + try std.testing.expectEqual(shell, p.col_panes[1][0]); + try std.testing.expect(!p.header_focus); - // Typed into, body or tag, it is a pane like any other. - try std.testing.expect(p.executeBuiltinLine(shell, "Newcol")); - const typed = p.active; + // The next Tty splits as it always has: nothing is empty any more. p.sync(); - for ("ix") |c| p.update(.{ .key = .{ .cp = c, .text = &.{c} } }); - try std.testing.expect(p.executeBuiltinLine(typed, "Tty")); - p.update(.tick); - try std.testing.expect(p.panes[typed] != null); - try std.testing.expectEqual(@as(usize, 2), p.col_n[layout.findPane(p, typed).?.col]); + try std.testing.expect(p.executeBuiltinLine(shell, "Tty")); + try std.testing.expectEqual(@as(usize, 2), p.col_n[1]); +} - try std.testing.expect(p.executeBuiltinLine(shell, "Newcol")); - const tagged = p.active; +test "a column tag's command acts on that column" { + if (platform == .web) return; + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 160, .rows = 40 }); + defer p.deinit(); + const first = p.active; + try std.testing.expect(p.executeBuiltinLine(first, "Newcol")); + try std.testing.expect(p.executeBuiltinLine(first, "Newcol")); + try std.testing.expectEqual(@as(usize, 3), p.ncol); p.sync(); - 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); + // New from the middle column's tag lands in it, not below `first`. + p.exec_column = 1; + try std.testing.expect(p.executeBuiltinLine(first, "New")); + p.exec_column = null; + try std.testing.expectEqual(@as(usize, 1), p.col_n[1]); + try std.testing.expectEqual(p.active, p.col_panes[1][0]); + // Delcol from the empty right column's tag closes that one. + p.exec_column = 2; + try std.testing.expect(p.executeBuiltinLine(first, "Delcol")); + p.exec_column = null; + try std.testing.expectEqual(@as(usize, 2), p.ncol); + try std.testing.expectEqual(@as(usize, 1), p.col_n[1]); + // A pane's own Delcol closes its column whatever tag has the keyboard. + try std.testing.expect(p.executeBuiltinLine(first, "Newcol")); + try std.testing.expect(p.header_focus); + try std.testing.expect(p.executeBuiltinLine(p.col_panes[1][0], "Delcol")); + try std.testing.expectEqual(@as(usize, 2), p.ncol); + try std.testing.expectEqual(@as(usize, 0), p.col_n[1]); } test "Del from a key asks which neighbour takes the rows; nothing else asks" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 40 }); defer p.deinit(); + p.settings.placement = .pardes; // the neighbours below are what is under test var frame = std.heap.ArenaAllocator.init(std.testing.allocator); defer frame.deinit(); const top = p.active; @@ -6841,7 +7043,8 @@ test "focus on a closed pane goes to the pane that takes its place, not the one const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 120, .rows = 40 }); defer p.deinit(); const shell = p.active; - p.newScratchColumn(shell); + p.newColumn(shell); + p.newScratchIn(shell, p.activeColumn().?); const right = p.active; p.sync(); p.newScratchBelow(shell); @@ -6873,45 +7076,50 @@ test "focus on a closed pane goes to the pane that takes its place, not the one p.sync(); p.active = left_low; p.sync(); - try p.removeColumn(left_low); + try p.removeColumn(layout.findPane(p, left_low).?.col); p.sync(); try std.testing.expectEqual(right_low, p.active); } -test "closing the last pane in a column leaves an empty one in its place" { +test "closing the last pane in a column leaves the column empty, its tag focused" { if (platform == .web) return; const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 120, .rows = 24 }); defer p.deinit(); const shell = p.active; - p.newScratchColumn(shell); + p.newColumn(shell); + p.newScratchIn(shell, p.activeColumn().?); const lone = p.active; try std.testing.expect(lone != shell); try std.testing.expectEqual(@as(usize, 2), p.ncol); const at = layout.findPane(p, lone).?; try std.testing.expectEqual(@as(usize, 1), p.col_n[at.col]); - const had = try std.testing.allocator.dupe(u8, p.panes[lone].?.cwdSlice()); - defer std.testing.allocator.free(had); + p.sync(); try p.removePane(lone, null); p.sync(); - // The column outlives the pane: still two columns, and the one that was - // emptied holds a fresh empty pane in the same directory. + // The column outlives the pane, as acme's does: still two columns, the + // emptied one with no pane and the keyboard on its tag. try std.testing.expectEqual(@as(usize, 2), p.ncol); - try std.testing.expectEqual(@as(usize, 1), p.col_n[at.col]); - const heir = p.col_panes[at.col][0]; - try std.testing.expect(heir != lone); + try std.testing.expectEqual(@as(usize, 0), p.col_n[at.col]); try std.testing.expect(p.panes[lone] == null); - const hp = p.panes[heir].?; - try std.testing.expectEqualStrings("", hp.file.?.content); - try std.testing.expectEqualStrings(had, hp.cwdSlice()); - try std.testing.expectEqual(heir, p.active); + try std.testing.expect(p.header_focus and p.header_column.? == at.col); + try std.testing.expectEqual(shell, p.active); try std.testing.expect(!p.quit); + // Down from it goes nowhere: nothing is below. + try std.testing.expect(p.executeBuiltinLine(shell, "Down")); + try std.testing.expect(p.header_focus and p.header_column.? == at.col); + // ...and Delcol is what actually takes the column away. - try p.removeColumn(heir); + try p.removeColumn(at.col); p.sync(); try std.testing.expectEqual(@as(usize, 1), p.ncol); + try std.testing.expectEqual(shell, p.active); + + // The session's last pane closing ends it. + try p.removePane(shell, null); + try std.testing.expect(p.quit); } test "the bare tty startup opens a text pane under the shell, unfocused" { @@ -7077,6 +7285,7 @@ test "hopping between two panes does not grow the jump stack" { if (platform == .web) return; const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 24, .file = "mise.toml" }); + p.settings.placement = .pardes; // two panes of room, not acme's fit to the doc's two lines defer p.deinit(); p.update(.{ .resize = .{ .cols = 80, .rows = 24 } }); p.update(.{ .key = .{ .cp = 'n', .alt = true } }); // a shell under the doc |
