diff --git a/plugins/workbench/src/Workbench.zig b/plugins/workbench/src/Workbench.zig index cdc7d6699..309a55a80 100644 --- a/plugins/workbench/src/Workbench.zig +++ b/plugins/workbench/src/Workbench.zig @@ -38,6 +38,10 @@ workspaces: std.AutoArrayHashMapUnmanaged(u64, Workspace) = .empty, /// plugin's own directory whenever it changes (`workbench_layout.savePanes`). panes: Panes, panes_dirty: bool = false, +/// The person's arrangement while a demo has the window (`holdPanes`), a copy of its own, and the +/// pane they were in: nothing is saved while it is held. +held_panes: ?Panes = null, +held_open: u64 = 0, /// Where the panes are arranged when the app arranges them instead of `panes`: each pane a view /// of the app's, in a place of the app's, beside another by a split of the app's (fizzy's /// `Workspaces`, behind `FIZZY_WORKSPACES=1`). Null: the panes are this tree's leaves, as before. @@ -387,6 +391,18 @@ pub fn rebuildWorkspaces(self: *Workbench) !void { return workbench_layout.rebuildWorkspaces(self); } +pub fn holdPanes(self: *Workbench) void { + workbench_layout.holdPanes(self); +} + +pub fn onePane(self: *Workbench) void { + workbench_layout.onePane(self); +} + +pub fn restorePanes(self: *Workbench) void { + workbench_layout.restorePanes(self); +} + pub fn forgetPanes(self: *Workbench) void { workbench_layout.forgetPanes(self); } diff --git a/plugins/workbench/src/workbench_layout.zig b/plugins/workbench/src/workbench_layout.zig index 55d655c74..64c398642 100644 --- a/plugins/workbench/src/workbench_layout.zig +++ b/plugins/workbench/src/workbench_layout.zig @@ -156,6 +156,8 @@ pub fn drawWorkspaces(wb: *Workbench, index: usize) !dvui.App.Result { /// no plugin directory to offer. fn savePanes(wb: *Workbench) void { wb.panes_dirty = false; + // A demo has the window: what it arranges is not the person's (`holdPanes`). + if (wb.held_panes != null) return; if (comptime builtin.target.cpu.arch == .wasm32) return; const path = panesPath(wb) orelse return; defer wb.allocator.free(path); @@ -214,7 +216,11 @@ fn loadPanes(wb: *Workbench) void { loaded.deinit(); return; } + adoptPanes(wb, loaded); +} +/// Make `loaded` the arrangement, every pane in it a workspace. +fn adoptPanes(wb: *Workbench, loaded: Panes) void { wb.panes.deinit(); wb.panes = loaded; for (wb.panes.nodes.items) |n| { @@ -231,6 +237,41 @@ fn loadPanes(wb: *Workbench) void { } } +/// Keep the person's arrangement while a demo has the window, and save nothing until it is put +/// back (`restorePanes`): the demo's splits are not theirs. A second hold keeps the first. +pub fn holdPanes(wb: *Workbench) void { + if (wb.held_panes != null) return; + // A copy through a snapshot: a snapshot borrows the pane names from the live tree, which the + // demo's first cut replaces (`onePane`). + const snap = wb.panes.snapshot(wb.allocator) catch return; + defer snap.deinit(wb.allocator); + wb.held_panes = Panes.fromSnapshot(wb.allocator, snap) catch return; + wb.held_open = wb.open_workspace_grouping; +} + +/// One pane, as a demo's keyframe declares the window: the open one, the others closing with +/// their leaves (`rebuildWorkspaces`). Its documents are the keyframe's to open. +pub fn onePane(wb: *Workbench) void { + var one = Panes.init(wb.allocator); + one.owns_panel_ids = true; + one.animated = true; + const root = one.allocNodeForRoot() catch return; + var buf: [32]u8 = undefined; + one.insertTabOwned(root, 0, Workspace.name(&buf, wb.open_workspace_grouping)) catch { + one.deinit(); + return; + }; + adoptPanes(wb, one); +} + +/// The person's arrangement again (`holdPanes`), and saving with it. +pub fn restorePanes(wb: *Workbench) void { + const held = wb.held_panes orelse return; + wb.held_panes = null; + adoptPanes(wb, held); + if (wb.workspaces.contains(wb.held_open)) wb.open_workspace_grouping = wb.held_open; +} + /// Delete the saved arrangement: the app has taken it over (`Workbench.places`). pub fn forgetPanes(wb: *Workbench) void { if (comptime builtin.target.cpu.arch == .wasm32) return; diff --git a/src/editor/Demo.zig b/src/editor/Demo.zig index d1267a4b4..28907fd6b 100644 --- a/src/editor/Demo.zig +++ b/src/editor/Demo.zig @@ -460,6 +460,8 @@ fn begin(ctx: *anyopaque, _: *const automation.Tape) void { editor.flushLayout(); editor.flushSettings(); self.saveSettings(); + // The document panes too: kept in memory, and none of the demo's saved over them. + editor.workbench.holdPanes(); const gpa = editor.app.gpa; var docs: std.ArrayList(Saved.Doc) = .empty; @@ -497,11 +499,14 @@ fn end(ctx: *anyopaque) void { if (self.quitting) { self.freeSaved(); self.freeSavedSettings(); + editor.workbench.restorePanes(); return; } self.restoreSettings(); self.closeAllDocuments(); self.unmountFiles(); + // Before the person's documents reopen: into the pane they were in. + editor.workbench.restorePanes(); // The layout as the user left it: `begin` flushed it, and nothing has been written since. editor.app.layout.resetLayout(gpa); @@ -568,6 +573,8 @@ fn keyframe(ctx: *anyopaque, kf: *const automation.Tape.Keyframe) void { editor.command_palette.finishClose(); self.closeAllDocuments(); + // One pane: a split a later moment made is gone at a cut, as on the first pass. + editor.workbench.onePane(); self.applySettings(kf.settings); // Fresh bytes every time: a replay must not see what the last pass typed or saved. diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 3cb74eee2..4490214b9 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -4471,8 +4471,8 @@ pub fn tick(editor: *Editor) !dvui.App.Result { { const t = fizzy.core.hitch.begin(.rebuild_workspaces); defer t.end(); - editor.rebuildWorkspaces() catch { - dvui.log.err("Failed to rebuild workspaces", .{}); + editor.rebuildWorkspaces() catch |err| { + dvui.log.err("Failed to rebuild workspaces: {t}", .{err}); }; // The first rebuild reopened last session's tabs; what the session held beyond them is // kept in recovery files. A frame on, the panes have drawn, and each shows the tab it did. diff --git a/tests/demos.zig b/tests/demos.zig index e06c28986..abb7d575a 100644 --- a/tests/demos.zig +++ b/tests/demos.zig @@ -185,6 +185,29 @@ test "demo: every bundled demo plays to the end through fizzy's stage, a seek la while (p.state == .seeking and frames < 400) : (frames += 1) _ = try dvui.testing.step(headlessFrame); try std.testing.expect(p.seq.now >= @as(f64, @floatFromInt(second))); + // A demo's panes are its own: a cut is one pane again, and the end gives back the person's. + { + const wb = &app.editor.workbench; + p.unload(); + try dvui.testing.settle(headlessFrame); + const panes_before = wb.workspaces.count(); + const split = try splitDemo(); + defer gpa.free(split); + try app.editor.demo.playTape(split); + frames = 0; + while (p.state != .ended and frames < 2_000) : (frames += 1) _ = try dvui.testing.step(headlessFrame); + try std.testing.expectEqual(@as(u32, 0), p.timeouts); + try std.testing.expectEqual(@as(usize, 2), wb.workspaces.count()); + p.seek(0); + frames = 0; + while ((p.state == .seeking or wb.workspaces.count() != 1) and frames < 400) : (frames += 1) _ = try dvui.testing.step(headlessFrame); + try std.testing.expectEqual(@as(usize, 1), wb.workspaces.count()); + p.unload(); + try dvui.testing.settle(headlessFrame); + try std.testing.expectEqual(panes_before, wb.workspaces.count()); + try std.testing.expect(wb.held_panes == null); + } + // A tape loaded some other way is not the one the service started. try app.editor.demo.play("markdown"); try std.testing.expectEqual(@as(?[]const u8, null), api.current()); @@ -220,6 +243,23 @@ fn openZipFromBytes(editor: *fizzy.Editor, name: []const u8, files: []const stru return gpa.dupe(u8, folder); } +/// A demo that drags its second tab onto the editor's right edge, opening a pane beside, encoded. +fn splitDemo() ![]u8 { + const gpa = std.testing.allocator; + var s: automation.Script = .init(gpa, "split", "Split"); + errdefer s.deinit(); + try s.keyframe(.{ + .root = "demo://split", + .files = &.{ .{ .path = "a.md", .text = "# A\n" }, .{ .path = "b.md", .text = "# B\n" } }, + .open = &.{ "a.md", "b.md" }, + }); + try s.drag(.{ .tag = "workbench.tab:demo://split/b.md" }, .{ .tag = "region:Main", .dx = 132 }, .{}); + s.pause(600); + var owned = try s.finish(); + defer owned.deinit(); + return automation.binary.encode(gpa, owned.tape, .{}); +} + /// A tape a plugin might register, encoded: a demo opening on a keyframe of one file, in two /// chapters, or (`demo` false) a live tape of the same pauses, which a demo list leaves out. fn encodedDemo(name: []const u8, demo: bool) ![]u8 {