From 31eb8a579fe45e44f546735a46d8914b39b80196 Mon Sep 17 00:00:00 2001 From: foxnne Date: Mon, 19 Oct 2026 13:59:07 -0500 Subject: [PATCH] automation: a tape plays on its own stage, over unsaved work too A demo no longer refuses to start over unsaved changes. It used to close every open document at its start and reopen them from disk at its end, so unsaved edits and undo history would have been lost; the toast asking the person to save or close first guarded that. Now the person's session is set aside untouched. The workbench holds their panes whole (workspaces, each pane's tabs, the split tree) and the demo plays in a fresh pane of its own. Their documents stay open behind it, their surfaces hidden: never closed, saved, reloaded, or put back by a snapshot. Keyframes, snapshots, seeks and the fingerprint touch only the demo's own documents. At the end the demo's documents close and the person's panes, documents, folder, explorer folders, active document and focus come back as they were. --- docs/AUTOMATION.md | 9 +- docs/PLUGINS.md | 2 +- plugins/workbench/src/Workbench.zig | 45 +++++ plugins/workbench/src/workbench_layout.zig | 108 +++++++++--- sdk/src/services/demos.zig | 4 +- src/editor/Demo.zig | 182 +++++++++++---------- src/editor/Editor.zig | 12 +- src/editor/Keybinds.zig | 4 +- tests/demos.zig | 73 +++++++++ 9 files changed, 318 insertions(+), 121 deletions(-) diff --git a/docs/AUTOMATION.md b/docs/AUTOMATION.md index 82119a89e..e0325ffb4 100644 --- a/docs/AUTOMATION.md +++ b/docs/AUTOMATION.md @@ -55,9 +55,12 @@ While a demo plays: same one a document's history transport hides into), filling with the demo's progress. The pointer held on the dome (or a press on it) grows the bar back out; it goes back soon after the pointer leaves it. Paused or ended it stays out, unless its chevron tab sent it in. -- **Nothing is persisted.** The stage sets the user's session aside when a demo loads (project - folder, open documents, layout as on disk, plugin settings) and gives it back when it is - stopped. A demo refuses to start over unsaved changes. Its files live in memory +- **Nothing is persisted, and nothing of the person's is touched.** A demo plays on a stage of + its own: when it loads, the person's panes are set aside whole and their documents stay open + behind them, hidden — never closed, saved, reloaded or put back by a snapshot, unsaved edits and + history and all — with their project folder, layout as on disk, settings and focus. The demo + opens its own documents in panes of its own, and when it is stopped the person gets back + exactly what they left. So a demo starts over unsaved work. Its files live in memory (`demo://`), so it never touches disk, and its folder never enters Recents. ## How it works diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 82fef918a..54bf1181a 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -1324,7 +1324,7 @@ The SDK ships definitions for the services in this ecosystem, in | `"markdown"` | `markdown` | Render a markdown byte slice into the current dvui parent (native only — absent on web) | | `"wikilink"` | any indexer (e.g. `brain`) | Resolve `[[Note]]` to a file, plus completion candidates and index state | | `"automation"` | the application (fizzy: `app/automation/Service.zig`, over its live driver) | `play` a live tape — input on the app as it is, built with the `tape` module and handed over as ZON or binary bytes — and get a ticket to ask its `outcome` (finished, or interrupted / timed out / stopped before which op); `stop` your own tape; `settled` when nothing is in flight; `snapshot` / `snapshotText` for what is on screen as ZON, a line per widget a person acts on or a tape aims at (role, name, tag, rect, parent — `replay.Snapshot`). One tape at a time, refused while a demo plays; a person's input stops it. For plugin tests, macros, tutorials | -| `"demos"` | the application (fizzy: `src/editor/Demos.zig`, over its demo player) | `list` every demo the app can play — its own, and each one a plugin registered with `host.registerDemo` (a name, a title, the tape as ZON or binary bytes, copied by the host and dropped when the plugin goes) — with each one's length, chapters and where it came from; `play` one by name, from a chapter if asked; `current` names the one playing; `generation` moves when the list does. A demo opens on a keyframe and sets the person's session aside while it plays; refused over unsaved work or while a live tape drives. For a guides browser, a plugin's own demos, an agent | +| `"demos"` | the application (fizzy: `src/editor/Demos.zig`, over its demo player) | `list` every demo the app can play — its own, and each one a plugin registered with `host.registerDemo` (a name, a title, the tape as ZON or binary bytes, copied by the host and dropped when the plugin goes) — with each one's length, chapters and where it came from; `play` one by name, from a chapter if asked; `current` names the one playing; `generation` moves when the list does. A demo opens on a keyframe and sets the person's session aside untouched while it plays, unsaved work and all; refused only while a live tape drives. For a guides browser, a plugin's own demos, an agent | Everything a plugin might reasonably ask *fizzy* for lives on `Host` instead — `openFile`, `closeDocById`, `docFromPath`, `revealPosition`, `setProjectFolder`, `isPathIgnored`, … — where it diff --git a/plugins/workbench/src/Workbench.zig b/plugins/workbench/src/Workbench.zig index f1e39cd60..29e7185d9 100644 --- a/plugins/workbench/src/Workbench.zig +++ b/plugins/workbench/src/Workbench.zig @@ -25,6 +25,9 @@ pub const Workbench = @This(); /// The split tree the panes live in. `core.widgets.DockLayout`, one panel per leaf. pub const Panes = core.widgets.DockLayout; +/// A held pane's tabs (`held_tabs`): its grouping, and the surface ids it held, owned. +pub const HeldTabs = struct { grouping: u64, ids: [][]u8 }; + allocator: std.mem.Allocator, decorators: std.ArrayListUnmanaged(BranchDecorator) = .empty, @@ -44,6 +47,11 @@ holding: bool = false, /// not be made, or before there was one), and the pane they were in. held_panes: ?Panes = null, held_open: u64 = 0, +/// The person's panes themselves while a demo has the window, and what each held (its +/// assignment, copied): set aside whole, with their documents, which stay open and are never +/// seated in the demo's panes (`workbench_layout.holdPanes`). +held_workspaces: std.AutoArrayHashMapUnmanaged(u64, Workspace) = .empty, +held_tabs: std.ArrayListUnmanaged(HeldTabs) = .empty, /// 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. @@ -293,6 +301,35 @@ pub fn documentClosed(self: *Workbench, doc: sdk.DocHandle) void { if (active.id == doc.id) ws.active = null; } } + // One of the person's, closed while a demo has the window: gone from their held panes too. + self.forgetHeld(doc, id); +} + +/// `doc`, whose tab is `id`, from the person's held panes (`holdPanes`): its tab, where `id` is +/// given, and any pane's cached handle of it. +fn forgetHeld(self: *Workbench, doc: sdk.DocHandle, id: ?[]const u8) void { + for (self.held_workspaces.values()) |*ws| { + if (ws.active) |active| { + if (active.id == doc.id) ws.active = null; + } + } + const gone = id orelse return; + for (self.held_tabs.items) |*t| { + const k = for (t.ids, 0..) |held, k| { + if (std.mem.eql(u8, held, gone)) break k; + } else continue; + self.allocator.free(t.ids[k]); + std.mem.copyForwards([]u8, t.ids[k..], t.ids[k + 1 ..]); + t.ids = self.allocator.realloc(t.ids, t.ids.len - 1) catch t.ids[0 .. t.ids.len - 1]; + } +} + +/// Whether `doc` is one of the person's, set aside with their panes while a demo has the window +/// (`holdPanes`). +pub fn holdsDocument(self: *const Workbench, doc: sdk.DocHandle) bool { + if (!self.holding) return false; + const id = sdk.document.surfaceId(runtime.host().arena(), doc.owner.id, doc.owner.documentPath(doc)) catch return false; + return workbench_layout.heldDocument(self, doc, id); } /// The tab naming surface `id` goes from whichever pane holds it: a document that will not come @@ -309,6 +346,7 @@ pub fn documentDetached(self: *Workbench, doc: sdk.DocHandle) void { if (active.id == doc.id) ws.active = null; } } + self.forgetHeld(doc, null); } /// A document's path changed under it (an explorer rename, a Save As): its tab is keyed by @@ -318,6 +356,13 @@ pub fn documentDetached(self: *Workbench, doc: sdk.DocHandle) void { /// starts from `activeDoc()` — Save first among them — quietly does nothing. pub fn documentRenamed(self: *Workbench, doc: sdk.DocHandle, old_id: []const u8) void { const new_id = sdk.document.surfaceId(runtime.host().arena(), doc.owner.id, doc.owner.documentPath(doc)) catch return; + // One of the person's, renamed while a demo has the window: its held tab follows. + for (self.held_tabs.items) |t| for (t.ids, 0..) |held, k| { + if (!std.mem.eql(u8, held, old_id)) continue; + const copy = self.allocator.dupe(u8, new_id) catch break; + self.allocator.free(held); + t.ids[k] = copy; + }; const ws = self.swapTabId(old_id, new_id) orelse return; // Selection is by id too. Only the pane showing this document re-selects, and only // within itself — this is not a focus change, so `open_workspace_grouping` stays. diff --git a/plugins/workbench/src/workbench_layout.zig b/plugins/workbench/src/workbench_layout.zig index 5ce090f74..1ced7ff76 100644 --- a/plugins/workbench/src/workbench_layout.zig +++ b/plugins/workbench/src/workbench_layout.zig @@ -68,6 +68,9 @@ pub fn rebuildWorkspaces(wb: *Workbench) !void { } } if (held) continue; + // One of the person's, set aside with its pane while a demo has the window: it waits + // there for them, never seated in the demo's panes. + if (wb.holding and heldDocument(wb, doc, id)) continue; // The same file under another owner's id (a plugin installed since last session) would // otherwise sit beside it as a tab that never opens. const path = doc.owner.documentPath(doc); @@ -239,29 +242,74 @@ fn adoptPanes(wb: *Workbench, loaded: Panes) 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. +/// Set the person's panes aside while a demo has the window, and save nothing until they are put +/// back (`restorePanes`): the demo plays in a pane of its own, and its splits are not theirs. A +/// second hold keeps the first. /// -/// Nothing is saved while holding, whatever else happens, so the arrangement on disk stays the -/// person's: a copy that could not be made is read back from there instead. Before the first -/// rebuild has restored last session's panes there is nothing of theirs here to copy; that -/// restore waits for the demo to end. +/// Their panes go aside whole — the workspaces, what each holds, and the tree they sit in — and +/// their documents stay open behind them, untouched: a document of a held pane is never seated in +/// the demo's (`rebuildWorkspaces`). Nothing is saved while holding, so the arrangement on disk +/// stays theirs: a tree that could not be copied is read back from there. Before the first rebuild +/// has restored last session's panes there is no tree of theirs to copy; that restore waits for +/// the demo to end. pub fn holdPanes(wb: *Workbench) void { if (wb.holding) return; wb.holding = true; wb.held_open = wb.open_workspace_grouping; - if (!wb.restored) 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 |err| { - dvui.log.err("workbench: could not set the panes aside ({t}); they come back from disk", .{err}); - return; - }; - defer snap.deinit(wb.allocator); - wb.held_panes = Panes.fromSnapshot(wb.allocator, snap) catch |err| blk: { - dvui.log.err("workbench: could not set the panes aside ({t}); they come back from disk", .{err}); - break :blk null; - }; + if (wb.restored) { + // A copy through a snapshot: a snapshot borrows the pane names from the live tree, which + // the demo's own pane replaces below. + if (wb.panes.snapshot(wb.allocator)) |snap| { + defer snap.deinit(wb.allocator); + wb.held_panes = Panes.fromSnapshot(wb.allocator, snap) catch |err| blk: { + dvui.log.err("workbench: could not set the panes aside ({t}); they come back from disk", .{err}); + break :blk null; + }; + } else |err| dvui.log.err("workbench: could not set the panes aside ({t}); they come back from disk", .{err}); + } + const host = runtime.host(); + for (wb.workspaces.values()) |*ws| { + var buf: [32]u8 = undefined; + const ids = host.assignedSurfaces(Workspace.name(&buf, ws.grouping)) orelse continue; + const copy = dupeIds(wb.allocator, ids) catch |err| { + dvui.log.err("workbench: could not set pane {d}'s tabs aside ({t}); they come back from disk", .{ ws.grouping, err }); + continue; + }; + wb.held_tabs.append(wb.allocator, .{ .grouping = ws.grouping, .ids = copy }) catch |err| { + dvui.log.err("workbench: could not set pane {d}'s tabs aside ({t}); they come back from disk", .{ ws.grouping, err }); + freeIds(wb.allocator, copy); + }; + } + wb.held_workspaces = wb.workspaces; + wb.workspaces = .empty; + // The demo's pane: a grouping no pane of theirs has. + const stage = wb.newGroupingID(); + wb.workspaces.put(wb.allocator, stage, Workspace.init(stage)) catch {}; + wb.open_workspace_grouping = stage; + onePane(wb); +} + +/// Whether `doc` (its tab `id`) is one of the person's, set aside with their panes. +pub fn heldDocument(wb: *const Workbench, doc: sdk.DocHandle, id: []const u8) bool { + if (wb.held_workspaces.contains(doc.owner.documentGrouping(doc))) return true; + for (wb.held_tabs.items) |t| for (t.ids) |held| if (std.mem.eql(u8, held, id)) return true; + return false; +} + +fn dupeIds(gpa: std.mem.Allocator, ids: []const []const u8) ![][]u8 { + const out = try gpa.alloc([]u8, ids.len); + var n: usize = 0; + errdefer freeIds(gpa, out[0..n]); + for (ids, out) |id, *o| { + o.* = try gpa.dupe(u8, id); + n += 1; + } + return out; +} + +fn freeIds(gpa: std.mem.Allocator, ids: [][]u8) void { + for (ids) |id| gpa.free(id); + gpa.free(ids); } /// One pane, as a demo's keyframe declares the window: the open one, the others closing with @@ -279,12 +327,30 @@ pub fn onePane(wb: *Workbench) void { adoptPanes(wb, one); } -/// The person's arrangement again (`holdPanes`), and saving with it: the copy, or what is on disk -/// where there is none. Before last session's panes were ever restored, one pane, for the next -/// rebuild to restore them into. +/// The person's panes again (`holdPanes`), and saving with them: the demo's go, theirs come back +/// holding what they held, in their tree — the copy, or what is on disk where there is none. +/// Before last session's panes were ever restored, one pane, for the next rebuild to restore +/// them into. pub fn restorePanes(wb: *Workbench) void { if (!wb.holding) return; wb.holding = false; + const host = runtime.host(); + for (wb.workspaces.values()) |*ws| { + var buf: [32]u8 = undefined; + host.assignSurfaces(Workspace.name(&buf, ws.grouping), null) catch {}; + ws.deinit(); + } + wb.workspaces.deinit(wb.allocator); + wb.workspaces = wb.held_workspaces; + wb.held_workspaces = .empty; + for (wb.held_tabs.items) |t| { + var buf: [32]u8 = undefined; + const ids: []const []const u8 = t.ids; + host.assignSurfaces(Workspace.name(&buf, t.grouping), ids) catch |err| + dvui.log.err("workbench: pane {d}'s tabs did not come back ({t})", .{ t.grouping, err }); + freeIds(wb.allocator, t.ids); + } + wb.held_tabs.clearAndFree(wb.allocator); if (wb.held_panes) |held| { wb.held_panes = null; adoptPanes(wb, held); diff --git a/sdk/src/services/demos.zig b/sdk/src/services/demos.zig index 5bb52d027..87b846e8e 100644 --- a/sdk/src/services/demos.zig +++ b/sdk/src/services/demos.zig @@ -50,7 +50,9 @@ pub const Api = struct { unknown, /// Something else is driving the app (a live tape): try again once it is done. busy, - /// The person has unsaved work open, which a demo would set aside: they are told so. + /// Never answered: a demo plays on a stage of its own, the person's work, saved or not, + /// set aside untouched. Kept so a plugin switching on it still builds; it goes at the + /// next SDK release that moves the boundary for another reason. unsaved, }; diff --git a/src/editor/Demo.zig b/src/editor/Demo.zig index 34ac30e50..68c241b17 100644 --- a/src/editor/Demo.zig +++ b/src/editor/Demo.zig @@ -1,7 +1,7 @@ //! fizzy's stage for demo automation (`app.automation`), and how a demo gets started. //! //! The player delivers a tape's input; this decides what fizzy's state *is* to a demo. A keyframe -//! closes every document without saving, mounts the keyframe's files in memory at its root +//! closes every document of the demo's without saving, mounts the keyframe's files in memory at its root //! (`demo://`, a `core.vfs.Mem`, so nothing touches disk and every replay starts from the //! same bytes), makes that the project folder on the explorer's Files view, resets the layout, and //! opens the files the keyframe names. Seeking back is that, then a replay. @@ -10,15 +10,18 @@ //! explorer put away — and a demo opens the explorer from the rail when it is about to use it and //! puts it away after (`catalog.openFile`), as a person short of room would. //! -//! A demo plays inside the user's own copy of fizzy, so it must not cost them anything: `begin` -//! sets their session aside (project folder, open documents) and flushes any pending layout and -//! settings writes, nothing is persisted while the demo is loaded (`active`), and `end` closes the -//! demo's documents, reloads the layout from `layout.zon` and reopens what the user had: from its -//! path, or from the bytes `begin` kept of one its path cannot open again (a file the web page -//! fetched, a zip whose folder is the project). A demo will not start over unsaved changes. +//! A demo plays inside the person's own copy of fizzy, on a stage of its own, so it costs them +//! nothing and waits for nothing: `begin` sets their session aside — their panes, which the +//! workbench holds whole (`Workbench.holdPanes`), the documents in them, which stay open behind +//! them untouched (hidden, never closed, saved, reloaded or put back by a snapshot), the project +//! folder, the explorer's open folders and the keyboard focus — and flushes any pending layout and +//! settings writes. Nothing is persisted while the demo is loaded (`active`). The demo's documents +//! are only its own: its mounted files (`demo://…`) and any it makes. `end` closes those, reloads +//! the layout from `layout.zon`, and gives the person back their panes, their documents as they +//! left them (unsaved edits and history with them), their folder and their focus. //! //! **Snapshots** (`automation.Stage.capture`) hold what a scene changes as it plays: the mounted -//! files as saved, each open document's state from its owner (`captureDocumentState` — contents, +//! files as saved, each of the demo's open documents' state from its owner (`captureDocumentState` — contents, //! caret, scroll), which is active, the explorer open or shut and its open folders, every plugin's //! settings, and keyboard focus. Restoring one puts them back in place — documents opened since //! are closed, the rest restored where they sit — so a seek back within a scene costs no reload. @@ -30,7 +33,6 @@ const Demo = @This(); const std = @import("std"); -const builtin = @import("builtin"); const dvui = @import("dvui"); const fizzy = @import("../fizzy.zig"); const core = fizzy.core; @@ -102,19 +104,16 @@ const Pending = union(enum) { const Saved = struct { folder: ?[]u8, - /// Open documents, the active one last so it is focused again. - docs: []Doc, - - const Doc = struct { - path: []u8, - /// The pane it was in (`documentGrouping`): it opens there again, in the arrangement put - /// back (`Workbench.restorePanes`), rather than every document in the one pane. - grouping: u64, - /// What it was opened from, when its path cannot open it again (`reopenable`): a file the - /// web page fetched or the person uploaded, a zip whose folder is the project. Null: it - /// reopens from its path. - bytes: ?[]u8 = null, - }; + /// The person's open documents, by id: set aside, open all along. Every other document is the + /// demo's (`ownsDocument`). + docs: []const u64, + /// The one they were in. + active: ?u64, + /// The explorer's open folders, and whether its root had opened (`Workbench.file_tree_root_opened`). + branches: []const dvui.Id, + root_opened: ?usize, + focus: dvui.Id, + focus_subwindow: dvui.Id, }; /// Inert until `attach`: nothing loaded, so nothing in `tick` acts on it. @@ -256,8 +255,8 @@ fn queue(self: *Demo, p: Pending) void { self.pending = p; } -/// A demo replaces the open documents, so it waits for unsaved work to be saved or closed. A -/// demo's own documents are not anyone's work: one demo may replace another. +/// A demo plays on a stage of its own (`begin`), so the person's work, saved or not, never stops +/// one: only another tape driving the app does. fn mayStart(self: *Demo) bool { return self.startable() == .started; } @@ -268,12 +267,6 @@ pub fn startable(self: *Demo) sdk.services.demos.Api.Play { core.dialogs.toast(@src(), .{ .message = "Something is driving fizzy right now — the demo can start once it has finished." }); return .busy; } - for (self.editor.app.open_files.values()) |doc| { - if (!doc.owner.isDirty(doc)) continue; - if (std.mem.startsWith(u8, doc.owner.documentPath(doc), "demo://")) continue; - core.dialogs.toast(@src(), .{ .message = "Save or close your changes first — a demo replaces the open files while it plays." }); - return .unsaved; - } return .started; } @@ -474,35 +467,56 @@ 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. + // The one they are in, before their panes go: the demo's has none. + const in_front = if (editor.activeDoc()) |d| d.id else null; + // Their panes go aside whole, the demo playing in one of its own. editor.workbench.holdPanes(); + // Their documents stay open behind them, hidden: no place shows them while the demo has the + // window, and nothing of the demo's closes, saves or restores them (`ownsDocument`). A demo's + // left open by the last (one whose close failed) is not theirs. const gpa = editor.app.gpa; - var docs: std.ArrayList(Saved.Doc) = .empty; - const active_id = if (editor.activeDoc()) |d| d.id else null; - var active_doc: ?Saved.Doc = null; + var docs: std.ArrayList(u64) = .empty; for (editor.app.open_files.values()) |doc| { - const path = doc.owner.documentPath(doc); - if (path.len == 0 or std.mem.startsWith(u8, path, "demo://")) continue; - // Never saved (`untitled-1`): no file, and clean, since `mayStart` refused unsaved work, so - // nothing to give back. Opened from its bytes it would come back as a file of that name. - if (!reopenable(editor, path) and !doc.owner.documentHasRecognizedSaveExtension(doc)) continue; - var saved: Saved.Doc = .{ .path = gpa.dupe(u8, path) catch continue, .grouping = doc.owner.documentGrouping(doc) }; - if (!reopenable(editor, path)) { - saved.bytes = doc.owner.documentBytes(doc, path, gpa) catch null; - if (saved.bytes == null) dvui.log.warn("demo: {s} cannot be opened again after the demo: its owner gives no bytes for it", .{path}); - } - if (active_id == doc.id) active_doc = saved else docs.append(gpa, saved) catch freeSavedDoc(gpa, saved); + if (std.mem.startsWith(u8, doc.owner.documentPath(doc), "demo://")) continue; + docs.append(gpa, doc.id) catch continue; } - if (active_doc) |d| docs.append(gpa, d) catch freeSavedDoc(gpa, d); + const held = docs.toOwnedSlice(gpa) catch &.{}; + const branches: []const dvui.Id = if (gpa.alloc(dvui.Id, editor.workbench.open_branches.count())) |out| blk: { + var it = editor.workbench.open_branches.keyIterator(); + for (out) |*b| b.* = it.next().?.*; + break :blk out; + } else |_| &.{}; // Nor is a demo's folder: one demo straight after another finds the last one's folder still // open — giving the user theirs back by closing it is deferred to the next frame // (`App.closeProjectFolder`) — and must not give it to them at its end. const folder = editor.app.folder orelse ""; self.saved = .{ .folder = if (folder.len > 0 and !std.mem.startsWith(u8, folder, "demo://")) gpa.dupe(u8, folder) catch null else null, - .docs = docs.toOwnedSlice(gpa) catch &.{}, + .docs = held, + .active = in_front, + .branches = branches, + .root_opened = editor.workbench.file_tree_root_opened, + .focus = dvui.focusedWidgetId() orelse .zero, + .focus_subwindow = dvui.focusedSubwindowId(), }; + for (held) |id| self.hideDocument(id, true); +} + +/// Whether `doc` is the demo's: one it opened or made, not one of the person's — open at `begin`, +/// or landing since in a pane of theirs (an open on its way as the demo started, a document their +/// plugin's reload brought back), which the workbench holds (`Workbench.holdsDocument`). +pub fn ownsDocument(self: *const Demo, doc: sdk.DocHandle) bool { + const saved = self.saved orelse return true; + if (std.mem.indexOfScalar(u64, saved.docs, doc.id) != null) return false; + return !self.editor.workbench.holdsDocument(doc); +} + +/// Hide the person's document `id` from every place while the demo has the window, or show it. +/// One whose surface is made again meanwhile (a rename) is hidden by `Editor.registerDocSurface`. +fn hideDocument(self: *Demo, id: u64, hidden: bool) void { + const ds = self.editor.doc_surfaces.get(id) orelse return; + self.editor.app.host.setSurfaceHidden(ds.id, hidden); } fn end(ctx: *anyopaque) void { @@ -511,46 +525,40 @@ fn end(ctx: *anyopaque) void { const gpa = editor.app.gpa; self.fast = false; if (self.quitting) { + for (editor.app.open_files.keys()) |id| self.hideDocument(id, false); self.freeSaved(); self.freeSavedSettings(); editor.workbench.restorePanes(); return; } self.restoreSettings(); - self.closeAllDocuments(); + self.closeDemoDocuments(); 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); editor.loadSavedLayout(); editor.app.layout.dirty = false; editor.command_palette.finishClose(); + // Their panes, each holding what it held: after the layout's reload, which would otherwise + // put back only what the file had of them. + editor.workbench.restorePanes(); const saved = self.saved orelse return; - // What was opened from bytes first: a zip among them mounts again, so the folder and the - // files opened from inside it can be opened after it. - for (saved.docs) |d| { - const bytes = d.bytes orelse continue; - const path = gpa.dupe(u8, d.path) catch continue; - _ = editor.openFileFromBytes(path, bytes, d.grouping) catch |err| - dvui.log.warn("demo: could not reopen {s}: {t}", .{ d.path, err }); - } + // What is open now is all theirs: the demo's have closed. + for (editor.app.open_files.keys()) |id| self.hideDocument(id, false); if (saved.folder) |f| { editor.setProjectFolder(f) catch |err| dvui.log.warn("demo: could not reopen {s}: {t}", .{ f, err }); } else { editor.app.host.closeProjectFolder(); } - for (saved.docs) |d| { - if (d.bytes != null) continue; - _ = editor.openFilePath(d.path, d.grouping) catch |err| - dvui.log.warn("demo: could not reopen {s}: {t}", .{ d.path, err }); - } - // The active one last, whichever way it came back. - if (saved.docs.len > 0) { - if (openIndex(editor, saved.docs[saved.docs.len - 1].path)) |index| editor.workbench.setActiveDocIndex(index); + editor.workbench.open_branches.clearRetainingCapacity(); + for (saved.branches) |id| editor.workbench.setBranchOpen(id, true); + editor.workbench.file_tree_root_opened = saved.root_opened; + if (saved.active) |id| { + if (editor.app.open_files.getIndex(id)) |index| editor.workbench.setActiveDocIndex(index); } + if (saved.focus != .zero) dvui.focusWidget(saved.focus, saved.focus_subwindow, null); self.freeSaved(); dvui.refresh(null, @src(), null); } @@ -559,26 +567,11 @@ fn freeSaved(self: *Demo) void { const gpa = self.editor.app.gpa; const saved = self.saved orelse return; if (saved.folder) |f| gpa.free(f); - for (saved.docs) |d| freeSavedDoc(gpa, d); gpa.free(saved.docs); + gpa.free(saved.branches); self.saved = null; } -fn freeSavedDoc(gpa: std.mem.Allocator, d: Saved.Doc) void { - gpa.free(d.path); - if (d.bytes) |b| gpa.free(b); -} - -/// Whether `path` opens again from where it is: a file on a mount (one the demo closes comes back -/// when what mounted it does), or on disk. The web has no disk: what is not on a mount there was -/// fetched or uploaded. -fn reopenable(editor: *Editor, path: []const u8) bool { - if (editor.app.file_table.isMounted(path)) return true; - if (comptime builtin.target.cpu.arch == .wasm32) return false; - std.Io.Dir.cwd().access(dvui.io, path, .{}) catch return false; - return true; -} - /// Put fizzy into `kf` outright: nothing the demo did before survives. fn keyframe(ctx: *anyopaque, kf: *const automation.Tape.Keyframe) void { const self = from(ctx); @@ -586,7 +579,7 @@ fn keyframe(ctx: *anyopaque, kf: *const automation.Tape.Keyframe) void { const gpa = editor.app.gpa; editor.command_palette.finishClose(); - self.closeAllDocuments(); + self.closeDemoDocuments(); // 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); @@ -761,16 +754,22 @@ fn unmountFiles(self: *Demo) void { } } -/// Close every document, discarding changes — they are the demo's, or `mayStart` refused. -/// Loads still on their way are dropped too, or they would land in the next keyframe. -fn closeAllDocuments(self: *Demo) void { +/// Close every document of the demo's, discarding changes: none is the person's, whose are set +/// aside (`ownsDocument`). Loads still on their way are dropped too, or they would land in the +/// next keyframe. +fn closeDemoDocuments(self: *Demo) void { const editor = self.editor; editor.cancelAllLoadingJobs(); while (editor.doc_io.loads.count() > 0) editor.doc_io.cancel(editor.doc_io.loads.keys()[0]); while (editor.openings.entries.count() > 0) editor.openings.drop(editor, editor.openings.entries.keys()[0]); - while (editor.app.open_files.count() > 0) { - const id = editor.app.open_files.keys()[editor.app.open_files.count() - 1]; - editor.rawCloseFileID(id) catch break; + var i = editor.app.open_files.count(); + while (i > 0) { + i -= 1; + // A close can take others with it (an archive's, its mount's files). + if (i >= editor.app.open_files.count()) continue; + const doc = editor.app.open_files.values()[i]; + if (!self.ownsDocument(doc)) continue; + editor.rawCloseFileID(doc.id) catch |err| dvui.log.warn("demo: could not close a document of its own: {t}", .{err}); } } @@ -895,6 +894,7 @@ fn fill(self: *Demo, snap: *Snapshot, root: []const u8, mem: *core.vfs.Mem) !boo const active_id = if (editor.activeDoc()) |d| d.id else null; var docs: std.ArrayList(Snapshot.Doc) = .empty; for (editor.app.open_files.values()) |doc| { + if (!self.ownsDocument(doc)) continue; const state = doc.owner.captureDocumentState(doc, a) orelse return false; if (active_id == doc.id) snap.active = docs.items.len; try docs.append(a, .{ @@ -944,6 +944,7 @@ fn restore(ctx: *anyopaque, raw: *anyopaque) bool { while (i > 0) { i -= 1; const doc = editor.app.open_files.values()[i]; + if (!self.ownsDocument(doc)) continue; const path = doc.owner.documentPath(doc); const kept = for (snap.docs) |d| { if (std.mem.eql(u8, d.path, path)) break true; @@ -988,7 +989,7 @@ fn release(ctx: *anyopaque, raw: *anyopaque) void { from(ctx).editor.app.gpa.destroy(snap); } -/// The model a snapshot holds, less what may differ between passes — scroll, focus, and which +/// The model a snapshot holds (the demo's own documents, never the person's), less what may differ between passes — scroll, focus, and which /// folders the explorer shows open, whose root opens on its own when its listing lands: /// documents' fingerprints from their owners, which is active, the explorer shut or not, and the /// files as saved. @@ -997,6 +998,7 @@ fn fingerprint(ctx: *anyopaque) u64 { const editor = self.editor; var h = std.hash.Wyhash.init(0xde40); for (editor.app.open_files.values()) |doc| { + if (!self.ownsDocument(doc)) continue; h.update(doc.owner.documentPath(doc)); const print: u64 = doc.owner.documentFingerprint(doc) orelse 0; h.update(std.mem.asBytes(&print)); diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 9b5a5cbee..9212add9e 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -2975,6 +2975,9 @@ fn registerDocSurface(editor: *Editor, doc: sdk.DocHandle) !void { }); // The owner's own name for it, when it has one, copied in (`Host.documentTitleChanged`). editor.app.host.documentTitleChanged(doc); + // One of the person's, set aside while a demo plays (made again by a rename, or landing in a + // pane of theirs): hidden too. + if (!editor.demo.ownsDocument(doc)) editor.app.host.setSurfaceHidden(ds.id, true); } fn unregisterDocSurface(editor: *Editor, doc_id: u64) void { @@ -5323,9 +5326,7 @@ fn fizzyAssignedSurfaces(ctx: *anyopaque, region: []const u8) ?[]const []const u fn fizzySelectInRegion(ctx: *anyopaque, region: []const u8, id: []const u8) void { const editor = fizzyCtx(ctx); - // Either list: this frame's if the shape has declared it already, else last frame's. A - // region that exists in neither has not been drawn yet, and its first draw selects the - // first thing it shows — which for a pane just created around one document is that one. + // Either list: this frame's if the shape has declared it already, else last frame's. for (editor.app.layout.regions_building.items) |r| if (std.mem.eql(u8, r.name, region)) { editor.app.host.setSelectionForKey(r.selectionKey(), id); return; @@ -5334,6 +5335,11 @@ fn fizzySelectInRegion(ctx: *anyopaque, region: []const u8, id: []const u8) void editor.app.host.setSelectionForKey(r.selectionKey(), id); return; }; + // Not drawn yet — a pane just made, which a place is named for — so kept under that name, for + // its first draw to show. Dropped, the first draw showed the first thing the pane held: a + // demo's pane, new when it starts, came up on its first document there and on its last after + // a seek back. + editor.app.host.setSelectionForKey(Region.namedSelectionKey(region), id); } fn fizzyRevealSurface(ctx: *anyopaque, id: []const u8) bool { diff --git a/src/editor/Keybinds.zig b/src/editor/Keybinds.zig index db3f1b6a7..7ff0dc67e 100644 --- a/src/editor/Keybinds.zig +++ b/src/editor/Keybinds.zig @@ -253,8 +253,8 @@ fn cmdDemoPlayNamed(state: *anyopaque, args: DemoByName.Args, call: *sdk.Command switch (api.play(args.name, args.at)) { .started => {}, .unknown => return call.fail("no demo named '{s}'", .{args.name}), - .busy => return call.fail("something else is driving fizzy: a demo can start once it has finished", .{}), - .unsaved => return call.fail("there is unsaved work open, which a demo would set aside", .{}), + // `unsaved` is never answered (`Demo.startable`). + .busy, .unsaved => return call.fail("something else is driving fizzy: a demo can start once it has finished", .{}), } } diff --git a/tests/demos.zig b/tests/demos.zig index 28f30358a..0ef026bac 100644 --- a/tests/demos.zig +++ b/tests/demos.zig @@ -271,6 +271,79 @@ test "demo: every bundled demo plays to the end through fizzy's stage, a seek la try dvui.testing.settle(headlessFrame); } + // A demo plays on a stage of its own: unsaved work of the person's never stops one, and is + // there after it exactly as it was — still open, still unsaved, its history and its pane + // theirs — through a play, a seek back and an end, with settings.zon never written. + { + const wb = &app.editor.workbench; + const draft = try std.fmt.allocPrint(gpa, "{s}/draft.md", .{app.root_buf[0 .. std.mem.indexOfScalar(u8, &app.root_buf, 0) orelse 0]}); + defer gpa.free(draft); + try std.Io.Dir.cwd().writeFile(dvui.io, .{ .sub_path = draft, .data = "# Draft\n" }); + _ = try app.editor.openFilePath(draft, wb.currentGroupingID()); + frames = 0; + while (host.docFromPath(draft) == null and frames < 200) : (frames += 1) _ = try dvui.testing.step(headlessFrame); + try dvui.testing.settle(headlessFrame); + const doc = host.docFromPath(draft) orelse return error.TestUnexpectedResult; + const write = try std.fmt.allocPrint(gpa, ".{{ .path = \"{s}\", .text = \"# Draft, not saved\\n\" }}", .{draft}); + defer gpa.free(write); + try std.testing.expect(host.callCommand("text.write", write, host.arena()) == .ok); + try dvui.testing.settle(headlessFrame); + try std.testing.expect(doc.owner.isDirty(doc)); + const line_before = doc.owner.historyLine(doc, host.arena()) orelse return error.TestUnexpectedResult; + const steps_before = line_before.entries.len; + const at_before = line_before.at; + const grouping = doc.owner.documentGrouping(doc); + const panes_before = wb.workspaces.count(); + const docs_open = app.editor.app.open_files.count(); + try std.testing.expectEqual(doc.id, app.editor.activeDoc().?.id); + app.editor.flushSettings(); + const settings_path = try std.fs.path.join(gpa, &.{ app.editor.app.config_folder, "settings.zon" }); + defer gpa.free(settings_path); + const settings_before = try std.Io.Dir.cwd().statFile(dvui.io, settings_path, .{}); + + try app.editor.demo.playTape(glassy); + try std.testing.expect(app.editor.demo.active()); + frames = 0; + while (p.state != .ended and frames < 2_000) : (frames += 1) _ = try dvui.testing.step(headlessFrame); + try std.testing.expectEqual(automation.Player.State.ended, p.state); + // On the stage, only the demo's: its pane, its document in front, the draft nowhere. + try std.testing.expectEqual(@as(usize, 1), wb.workspaces.count()); + try std.testing.expect(std.mem.startsWith(u8, activePath(app.editor), "demo://")); + try std.testing.expect(!wb.workspaces.contains(grouping)); + p.seek(0); + frames = 0; + while (p.state == .seeking and frames < 400) : (frames += 1) _ = try dvui.testing.step(headlessFrame); + p.seek(@floatFromInt(p.duration())); + frames = 0; + while (p.state == .seeking and frames < 400) : (frames += 1) _ = try dvui.testing.step(headlessFrame); + p.unload(); + try dvui.testing.settle(headlessFrame); + + // The same document, never closed: unsaved, its text and history as they were, in its pane + // and in front. + const back = host.docFromPath(draft) orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(doc.id, back.id); + try std.testing.expect(back.owner.isDirty(back)); + const read_args = try std.fmt.allocPrint(gpa, ".{{ .path = \"{s}\" }}", .{draft}); + defer gpa.free(read_args); + const out = host.callCommand("text.read", read_args, host.arena()).ok orelse return error.TestUnexpectedResult; + try std.testing.expect(std.mem.indexOf(u8, out, "# Draft, not saved") != null); + const line_after = back.owner.historyLine(back, host.arena()) orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(steps_before, line_after.entries.len); + try std.testing.expectEqual(at_before, line_after.at); + try std.testing.expectEqual(grouping, back.owner.documentGrouping(back)); + try std.testing.expectEqual(panes_before, wb.workspaces.count()); + try std.testing.expectEqual(docs_open, app.editor.app.open_files.count()); + try std.testing.expectEqual(doc.id, app.editor.activeDoc().?.id); + try std.testing.expect(wb.held_tabs.items.len == 0 and wb.held_workspaces.count() == 0); + // Not written while the demo played, nor by its end. + const settings_after = try std.Io.Dir.cwd().statFile(dvui.io, settings_path, .{}); + try std.testing.expectEqual(settings_before.mtime, settings_after.mtime); + try std.testing.expectEqual(settings_before.size, settings_after.size); + try app.editor.rawCloseFileID(back.id); + try dvui.testing.settle(headlessFrame); + } + // 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());