From 1f6067ffe5e50f16d3b6f9b11671e189c201618f Mon Sep 17 00:00:00 2001 From: foxnne Date: Sun, 18 Oct 2026 18:18:33 -0500 Subject: [PATCH] workbench: a home surface replaces the built-in home page A surface keyworded `home` (`sdk.keywords.ide.home`) is shown in a `Home` place the workbench declares inside its sole document pane while it is empty, instead of the workbench's logo and buttons. The place exists only while nothing is open, so a document opening, or a session restore landing, puts it away; two home pages are chosen between by assigning the place. Co-Authored-By: Claude Opus 5.5 --- docs/PLUGINS.md | 22 +++++ plugins/workbench/src/Workspace.zig | 25 +++++- sdk/src/keywords.zig | 5 ++ src/editor/Editor.zig | 5 +- tests/integration.zig | 135 ++++++++++++++++++++++++++++ 5 files changed, 189 insertions(+), 3 deletions(-) diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 079218d8f..5adacce7d 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -546,6 +546,7 @@ inventing a kind of panel never touches the ABI. Fizzy's shipped shapes accept t | `sdk.keywords.ide.sidebar` | `sidebar`, `explorer` | things you pick *from* — file trees, outlines | | `sdk.keywords.ide.panel` | `bottom`, `panel`, `output` | things a task *produces* — logs, diagnostics | | `sdk.keywords.ide.main` | `main`, `center`, `workspace` | the thing being worked on | +| `sdk.keywords.ide.home` | `home` | what the main area shows while nothing is open (below) | | `sdk.document.keywords` | `document` | one open document (the app registers these, see below) | A keyword may name a place *inside* another, dot-separated (`main.document`). `keywords.Fit` @@ -628,6 +629,27 @@ if (!host.hideSurface(id)) _ = host.revealSurface(id); // toggle it drawn, since the places it changes are being drawn. `revealSurface` acts at once, so a hide asked first in a frame still lands after a reveal asked later in it: make one call a frame. +**A home page.** What the main area shows while nothing is open (the workbench's logo, New File, +Open Folder) is a place too. While its one document pane is empty, the workbench declares a +region named `Home` inside it with keywords `sdk.keywords.ide.home` (`{"home"}`) and +`shows = .one`, and draws the surface that region accepts. With no such surface the region +declines, and the workbench draws its own page. A start page is just a surface that says what it +is: + +```zig +try host.registerSurface(.{ .id = "graphl.start", .title = "Start", + .keywords = sdk.keywords.ide.home, .draw = drawStart }); +``` + +It needs no startup code. The place exists only while nothing is open, so a document opening +puts the home page away, and so does a restored session landing a few frames after startup. The +last document closing brings it back. None of the shape's own regions accept a `home` surface, +so it never shows up as a tab in Main. When two plugins offer a home page, the first one +registered shows until someone chooses. An app chooses with +`host.assignSurfaces("Home", &.{"graphl.start"})`, the same list the picker writes, and a user +chooses in Settings › Layout. A shape without the workbench has no `Home` place unless it +declares a region that takes `home` itself. + **Dragging a view onto a region is the app's gesture, everywhere.** While a view is dragged, the region under the pointer shows its drop (`core.widgets.DropZones`): a cluster of glass bubbles in its middle — the middle for into the place, one on each side for a split there, and a diff --git a/plugins/workbench/src/Workspace.zig b/plugins/workbench/src/Workspace.zig index 7aee8a5e9..254dc20f6 100644 --- a/plugins/workbench/src/Workspace.zig +++ b/plugins/workbench/src/Workspace.zig @@ -845,10 +845,33 @@ fn drawCanvas(self: *Workspace, region: sdk.Host.Region, has_tabs: bool) !void { dvui.alphaSet(1.0); defer dvui.alphaSet(alpha); - try self.drawHomePage(); + if (!try drawHome(runtime.host(), self.grouping)) try self.drawHomePage(); } } +/// The name of the place an app's home page goes, for `host.assignSurfaces` to choose between two. +pub const home_region = "Home"; + +/// An app's own home page, in place of ours: the `Home` place, which shows a surface keyworded +/// `home` (`sdk.keywords.ide.home`). Declared only from the sole pane while it is empty, so it +/// exists exactly while nothing is open — a document opening, or a restored session's landing +/// a few frames after startup, puts the home away, and the last one closing brings it back. +/// False when no surface is a home page (the place declines), for ours to be drawn instead. +/// +/// Takes its host so a test can declare it without the plugin's runtime (`plugins/AGENTS.md`). +pub fn drawHome(host: *sdk.Host, grouping: u64) !bool { + var home = host.region(.{ + .name = home_region, + .keywords = sdk.keywords.ide.home, + .shows = .one, + .key = grouping, + .hide_when_empty = true, + }) orelse return false; + defer home.deinit(); + _ = try home.drawContents(); + return true; +} + fn dropSnapshot(self: *Workspace) void { if (self.closing_snapshot) |t| dvui.textureDestroyLater(t); self.closing_snapshot = null; diff --git a/sdk/src/keywords.zig b/sdk/src/keywords.zig index b613a6c26..fd3836482 100644 --- a/sdk/src/keywords.zig +++ b/sdk/src/keywords.zig @@ -28,6 +28,11 @@ pub const ide = struct { pub const panel: []const []const u8 = &.{ "bottom", "panel", "output" }; /// The main area: documents, canvases — the thing being worked on. pub const main: []const []const u8 = &.{ "main", "center", "workspace" }; + /// The home page: what the main area shows while nothing is open, in place of the + /// workbench's own (its logo, New File, Open Folder). The workbench declares that place, + /// named `Home`, only while its one document pane is empty, so a document opening puts the + /// home page away (`docs/PLUGINS.md` §3.1.2). + pub const home: []const []const u8 = &.{"home"}; }; /// The studio shape (`src/editor/layout/studio.zig`): no file explorer, a large canvas, a diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index b6b688942..11ac265f8 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -2426,9 +2426,10 @@ fn drawSettingsPane(_: ?*anyopaque) anyerror!dvui.App.Result { } // ---- EditorAPI: fizzy-provided read/utility surface for plugins ---------- -// Installed on the Host in `postInit`; `ctx` is this `*Editor`. +// Installed on the Host in `postInit`; `ctx` is this `*Editor`. Public for a test that drives a +// plugin through `Host` (`tests/integration.zig`, the workbench's home page). -const fizzy_api_vtable: sdk.EditorAPI.VTable = .{ +pub const fizzy_api_vtable: sdk.EditorAPI.VTable = .{ .arena = fizzyArena, .extensionOwnerOverride = fizzyExtensionOwnerOverride, .folder = fizzyFolder, diff --git a/tests/integration.zig b/tests/integration.zig index ac66cdc46..13f2aea75 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -6696,6 +6696,141 @@ test "workbench: where a tab carried over a strip goes in is read as if its open try std.testing.expectEqual(@as(usize, 3), pane.insertIndexAt(500, 3)); } +// An app's home page in place of the workbench's: a surface keyworded `home`, shown in the `Home` +// place the sole document pane declares while it is empty (`Workspace.drawHome`). The pane here is +// declared as `Workspace.draw` declares it, through the plugin's `Host`, so the place is the one +// the workbench makes. +const HomeFrame = struct { + var editor: ?*fizzy.Editor = null; + /// Which home surface drew this frame (its `ctx`), and how many times one did. + var home: ?usize = null; + var home_draws: usize = 0; + var doc_draws: usize = 0; + /// The workbench would have drawn its own page this frame. + var builtin_home = false; + + const pane_key: u64 = 1; + const doc = "test.doc:/notes.txt"; + + fn drawHome(ctx: ?*anyopaque) anyerror!dvui.App.Result { + home = @intFromPtr(ctx); + home_draws += 1; + return .ok; + } + + fn drawDoc(_: ?*anyopaque) anyerror!dvui.App.Result { + doc_draws += 1; + return .ok; + } + + fn registerHome(e: *fizzy.Editor, id: []const u8, n: usize) !void { + try e.app.host.registerSurface(.{ + .id = id, + .title = "Start", + .keywords = fizzy.sdk.keywords.ide.home, + .ctx = @ptrFromInt(n), + .draw = drawHome, + }); + } + + fn frame() anyerror!dvui.App.Result { + const e = editor.?; + home = null; + home_draws = 0; + doc_draws = 0; + builtin_home = false; + var layout = fizzy.Editor.Layout.init(&e.app.host, &e.app.layout, e.app.gpa, dvui.currentWindow().arena()); + e.app.frame_layout = &layout; + defer e.app.frame_layout = null; + { + var main = try layout.region(@src(), .{ + .name = "Main", + .keywords = fizzy.sdk.keywords.ide.main, + }, .{ .expand = .both }); + defer main.deinit(); + + var pane = e.app.host.region(.{ + .name = "Pane 1", + .keywords = fizzy.sdk.document.keywords, + .shows = .many, + .key = pane_key, + }) orelse return error.TestUnexpectedResult; + defer pane.deinit(); + if (pane.matching().len > 0) { + _ = try pane.drawContents(); + } else { + builtin_home = !try workbench.Workspace.drawHome(&e.app.host, pane_key); + } + } + e.app.layout.publishRegions(); + return .ok; + } +}; + +test "workbench: a home surface shows while nothing is open, and a document puts it away" { + var ctx = try shim.init(std.testing.allocator); + defer ctx.deinit(std.testing.allocator); + + const editor = ctx.editor; + const gpa = std.testing.allocator; + editor.app.gpa = gpa; + defer editor.app.layout.regions.deinit(gpa); + defer editor.app.layout.deinitSwaps(gpa); + defer editor.app.layout.regions_building.deinit(gpa); + defer editor.app.layout.deinitQualified(gpa); + editor.app.host.installFizzyApi(.{ .ctx = editor, .vtable = &fizzy.Editor.fizzy_api_vtable }); + + HomeFrame.editor = editor; + defer HomeFrame.editor = null; + + // No plugin offers a home page: the place declines, and the workbench draws its own. + try dvui.testing.settle(HomeFrame.frame); + try std.testing.expect(HomeFrame.builtin_home); + try std.testing.expectEqual(@as(usize, 0), HomeFrame.home_draws); + + // One does: it is the home page, drawn once, in the Home place and not in Main beside it. + try HomeFrame.registerHome(editor, "test.home", 1); + try dvui.testing.settle(HomeFrame.frame); + try std.testing.expect(!HomeFrame.builtin_home); + try std.testing.expectEqual(@as(usize, 1), HomeFrame.home_draws); + try std.testing.expectEqual(@as(?usize, 1), HomeFrame.home); + + // A restored session: the pane already lists a document that has not landed. Nothing is open + // yet, so the home stays... + try editor.app.host.assignSurfaces("Pane 1", &.{HomeFrame.doc}); + try dvui.testing.settle(HomeFrame.frame); + try std.testing.expectEqual(@as(usize, 1), HomeFrame.home_draws); + + // ...until it lands, a few frames later, and the home is put away: drawn nowhere, Main + // included, with nothing on the plugin's side. + try editor.app.host.registerSurface(.{ + .id = HomeFrame.doc, + .title = "notes.txt", + .keywords = fizzy.sdk.document.keywords, + .draw = HomeFrame.drawDoc, + }); + try dvui.testing.settle(HomeFrame.frame); + try std.testing.expectEqual(@as(usize, 1), HomeFrame.doc_draws); + try std.testing.expectEqual(@as(usize, 0), HomeFrame.home_draws); + try std.testing.expect(!HomeFrame.builtin_home); + + // The last document closing brings it back. + editor.app.host.unregisterSurface(HomeFrame.doc); + try dvui.testing.settle(HomeFrame.frame); + try std.testing.expectEqual(@as(usize, 1), HomeFrame.home_draws); + try std.testing.expectEqual(@as(?usize, 1), HomeFrame.home); + + // Two plugins offer one: the first registered shows, until the app chooses the other by + // assigning the place, as the picker would. + try HomeFrame.registerHome(editor, "test.home2", 2); + try dvui.testing.settle(HomeFrame.frame); + try std.testing.expectEqual(@as(?usize, 1), HomeFrame.home); + try editor.app.host.assignSurfaces(workbench.Workspace.home_region, &.{"test.home2"}); + try dvui.testing.settle(HomeFrame.frame); + try std.testing.expectEqual(@as(usize, 1), HomeFrame.home_draws); + try std.testing.expectEqual(@as(?usize, 2), HomeFrame.home); +} + // -- rows carried out of a tree and back -------------------------------------------------------- // A tree of two folders and two files, as the explorer draws one: `F` open with `c` inside it, `G`