From 16b03bf182e173d744cc8569c6127b08ccf940a4 Mon Sep 17 00:00:00 2001 From: foxnne Date: Sun, 18 Oct 2026 15:53:50 -0500 Subject: [PATCH 1/2] sdk: a plugin can put a tray's or a lone float's view away `Host.hideSurface(id)` is `revealSurface`'s other half, so a button can toggle its view. It acts in two cases only: the view is what an open tray (a resizable place the shape declared) shows, and the tray shuts as its own close does; or the view is alone in a float, and the float closes as its close button does. Anything else returns false and changes nothing. Asked during a frame it answers at once and acts once the frame has drawn (`Layout.hideLater` / `applyHides`). Supersedes #566, narrowed. Part of #530 Co-Authored-By: Claude Opus 5.5 --- app/layout/Layout.zig | 69 +++++++++++++++++++ app/layout/Region.zig | 1 + docs/PLUGINS.md | 25 +++++++ sdk/src/EditorAPI.zig | 7 ++ sdk/src/Host.zig | 17 +++++ sdk/src/version.zig | 2 +- src/editor/Editor.zig | 28 +++++++- tests/integration.zig | 156 ++++++++++++++++++++++++++++++++++++++++-- 8 files changed, 294 insertions(+), 11 deletions(-) diff --git a/app/layout/Layout.zig b/app/layout/Layout.zig index c3f8379b0..353b882fd 100644 --- a/app/layout/Layout.zig +++ b/app/layout/Layout.zig @@ -158,6 +158,9 @@ extents_changed: bool = false, /// Every surface `draw` ran this frame, so `captureUnplaced` knows which ones genuinely drew /// nowhere — as opposed to drew before the picker asked. Arena-backed, per frame. drawn: std.ArrayListUnmanaged(*Surface) = .empty, +/// Views a plugin asked to put away during this frame (`hideLater`), put away once it has drawn +/// (`applyHides`). Arena-backed, per frame. +hides: std.ArrayListUnmanaged([]const u8) = .empty, /// Surfaces drawn this frame in a place with no room, so not captured (`drawCaptured`): no frame /// is asked for them until they have some. Arena-backed, per frame. unsized: std.ArrayListUnmanaged(*Surface) = .empty, @@ -600,6 +603,72 @@ pub fn reveal(self: *Layout, id: []const u8) bool { return false; } +/// What putting a view away does (`hide`). +pub const Hide = union(enum) { + /// Shut this tray, which shows the view. + tray: Region, + /// Close this float, the view's only place. + float: []const u8, +}; + +/// What `hide` would do for `id`, from the one region that shows it (as `reveal` finds it). A +/// tray: a place the shape declared `resize` (not one a split made, which is `forget_when_empty`), +/// in the main window, open and not folded, with `id` the view it shows. A float: one not closing, +/// with `id` its only view. Null for anything else — Main, a split's half, a plugin's pane, a view +/// behind another or out of sight, a float with other views — and `hide` then changes nothing. +pub fn hideTarget(self: *Layout, id: []const u8) ?Hide { + const s = self.surface(id) orelse return null; + if (s.hidden) return null; + var it = self.declaredRegions(); + const r = find: while (it.next()) |place| { + for (self.matchingIn(place)) |m| if (m == s) break :find place; + } else return null; + if (r.layer > 0) { + const name = self.state.floatRoot(r.name) orelse return null; + const i = self.state.floats.find(name) orelse return null; + if (self.state.floats.items.items[i].closing) return null; + var views: usize = 0; + for (self.state.splits.leavesUnder(self.arena, name)) |leaf| { + if (self.state.assignment(leaf)) |ids| views += ids.len; + } + return if (views == 1) .{ .float = name } else null; + } + if (!r.resize or r.forget_when_empty or r.isClosed() or r.isFolded()) return null; + const shown = self.selectedIn(r) orelse return null; + return if (shown == s) .{ .tray = r.* } else null; +} + +/// Put `id` away now, as the user would (`Host.hideSurface`): shut its tray as the tray's own +/// close does, or close its float as the float's close button does, the view going home. False, +/// changing nothing, where `hideTarget` finds nothing to do. Not while the shape or the floats +/// draw, which walk what this changes: `hideLater` is for then. +pub fn hide(self: *Layout, id: []const u8) bool { + switch (self.hideTarget(id) orelse return false) { + .tray => |r| r.close(), + .float => |name| self.closeFloat(name), + } + return true; +} + +/// `hide` asked during the frame: answered now, by the same `hideTarget`, and done by +/// `applyHides` once the shape, the floats and the picker have drawn. A `reveal` asked later in +/// the same frame acts at once, so it comes first: a caller toggling a view makes one call a frame. +pub fn hideLater(self: *Layout, id: []const u8) bool { + if (self.hideTarget(id) == null) return false; + const kept = self.arena.dupe(u8, id) catch return false; + self.hides.append(self.arena, kept) catch return false; + return true; +} + +/// Do the hides asked during this frame (`hideLater`), in order, each asking `hideTarget` again. +/// The application calls this once its frame has drawn. True when any of them acted. +pub fn applyHides(self: *Layout) bool { + var any = false; + for (self.hides.items) |id| any = self.hide(id) or any; + self.hides.clearRetainingCapacity(); + return any; +} + /// Surfaces that appear in no region of the last completed shape — neither assigned to one nor /// attracted by keywords to an unassigned one. Never silently lost: the settings UI lists these /// so a user (or the plugin author) can see the gap and fix it. diff --git a/app/layout/Region.zig b/app/layout/Region.zig index 673762ef0..87768b25b 100644 --- a/app/layout/Region.zig +++ b/app/layout/Region.zig @@ -557,6 +557,7 @@ pub fn init(self: *Layout, src: std.builtin.SourceLocation, init_opts: InitOptio .default_extent = default_extent, .by_name = init_opts.by_name, .kind_slot = init_opts.kind_slot, + .resize = init_opts.resize, .forget_when_empty = init_opts.forget_when_empty, .dir = init_opts.dir, .on_drop = init_opts.on_drop, diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index c6f79fc63..b236566f9 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -603,6 +603,31 @@ Outside a region's draw, `host.assignSurfaces(name, ids)` / `assignedSurfaces(na the picker uses — a tab dragged to another pane is an assignment edit, and session restore is the assignment list read back. +**Showing and putting away a view.** A plugin names its view, never a place, so a "Show X" +command works wherever the user moved it, in any app's shape: + +```zig +_ = host.revealSurface(id); // show it +if (!host.hideSurface(id)) _ = host.revealSurface(id); // toggle it +``` + +- `revealSurface(id)` selects `id` in the region that shows it (where the user moved it, floats + included) and opens that region if it was shut or folded away. False when no region shows it. +- `hideSurface(id)` puts it away as the user would, in two cases only: + - **It is what a tray shows.** A tray is a place the shape declared resizable and the user did + not make by splitting (fizzy's sidebar and bottom panel). If it is open and showing `id`, it + shuts, with whatever else it holds, as its own close does. `revealSurface` opens it again at + the size the user left it. + - **It is alone in a float.** The float closes as its close button does, and the view goes back + to the place it floated out of. There it may well be in sight again. + + Anything else returns false and changes nothing: a view in Main, in a place a split made (of + a tray too), in a plugin's pane, behind another view, in a float with others, or already out + of sight (its tray shut or folded). +- **Asked from a draw.** Mid-frame, `hideSurface` answers at once and acts when the frame has + 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. + **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/sdk/src/EditorAPI.zig b/sdk/src/EditorAPI.zig index 4a2db8364..96cb146f4 100644 --- a/sdk/src/EditorAPI.zig +++ b/sdk/src/EditorAPI.zig @@ -331,6 +331,9 @@ pub const VTable = struct { /// Bring the surface `id` into sight wherever it lives: selected in the region showing it, /// that region opened if it was shut. False when no region shows it. revealSurface: *const fn (ctx: *anyopaque, id: []const u8) bool, + /// Put the surface `id` away: shut the open tray showing it, or close the float it is alone + /// in. False, changing nothing, otherwise. During a frame it acts once the frame has drawn. + hideSurface: *const fn (ctx: *anyopaque, id: []const u8) bool, /// Draw the app's glyph for a declared file kind ("image", "source", …), or return false if /// this app has no glyph for it. /// @@ -656,6 +659,10 @@ pub fn revealSurface(self: EditorAPI, id: []const u8) bool { return self.vtable.revealSurface(self.ctx, id); } +pub fn hideSurface(self: EditorAPI, id: []const u8) bool { + return self.vtable.hideSurface(self.ctx, id); +} + pub fn drawFileKindGlyph(self: EditorAPI, kind: []const u8, color: dvui.Color) bool { return self.vtable.drawFileKindGlyph(self.ctx, kind, color); } diff --git a/sdk/src/Host.zig b/sdk/src/Host.zig index 09accc4b4..6b2794e7c 100644 --- a/sdk/src/Host.zig +++ b/sdk/src/Host.zig @@ -681,6 +681,23 @@ pub fn revealSurface(self: *Host, id: []const u8) bool { return a.revealSurface(id); } +/// Put the surface `id` away, as the user would: `revealSurface`'s other half, so a button can +/// toggle its view (`if (!host.hideSurface(id)) _ = host.revealSurface(id);`). Only two cases +/// act, and anything else returns false and changes nothing: +/// +/// - the view is what a tray shows (a place the shape declared resizable: fizzy's sidebar and +/// bottom panel), and the tray is open: the tray shuts, with whatever else it holds, as its own +/// close does. `revealSurface` opens it again at the size the user left it; +/// - the view is alone in a float: the float closes as its close button does, and the view goes +/// back to the place it floated out of. +/// +/// Asked during a frame (from a surface's draw), it answers at once and acts once the frame has +/// drawn. Full contract: `docs/PLUGINS.md` §3.1.2, "Showing and putting away a view". +pub fn hideSurface(self: *Host, id: []const u8) bool { + const a = self.fizzy_api orelse return false; + return a.hideSurface(id); +} + pub fn revealPosition(self: *Host, path: []const u8, line: u32, character: u32, open_side: bool) !bool { return if (self.fizzy_api) |a| a.revealPosition(path, line, character, open_side) else false; } diff --git a/sdk/src/version.zig b/sdk/src/version.zig index 222378de1..5c74dc1f2 100644 --- a/sdk/src/version.zig +++ b/sdk/src/version.zig @@ -75,7 +75,7 @@ pub const sdk_version = @import("sdk_version").sdk_version; /// why it is a single target/mode-invariant literal rather than a per-target table. Update this /// value from the `@compileError` it triggers whenever it changes; leave `sdk_version` to the /// next SDK release. -pub const recorded_sdk_shape_fingerprint: u64 = 0xba4664870675e13d; +pub const recorded_sdk_shape_fingerprint: u64 = 0x5cf2c503df2d415d; comptime { if (dylib.sdk_shape_fingerprint != recorded_sdk_shape_fingerprint) { diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index c94fba659..79c663133 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -2454,6 +2454,7 @@ const fizzy_api_vtable: sdk.EditorAPI.VTable = .{ .assignedRegionNames = fizzyAssignedRegionNames, .selectInRegion = fizzySelectInRegion, .revealSurface = fizzyRevealSurface, + .hideSurface = fizzyHideSurface, .drawFileKindGlyph = fizzyDrawFileKindGlyph, .closeDocById = fizzyCloseDocById, .setProjectFolder = fizzySetProjectFolder, @@ -4600,15 +4601,18 @@ pub fn tick(editor: *Editor) !dvui.App.Result { // `regionFor` answered from the previous frame, which is what lets a command // dispatched between frames drive a region (see `Layout.State.regions`). editor.app.layout.publishRegions(); - // A drag or a collapse moved a region: fizzy's answer to "remember that" is a - // debounced write to `layout.zon`. - if (layout.extents_changed) editor.markWindowRatiosDirty(); // Over every place: a drag's join spans two of them. layout.drawDragOverlay(); // The picker photographs surfaces that drew nowhere this frame, and floats above // everything the shape drew. layout.captureUnplaced(); editor.app.layout.picker.draw(&layout); + // Everything that walks the places has drawn: what plugins asked to put away during + // the frame (`Host.hideSurface`) goes now. + if (layout.applyHides()) editor.afterHide(); + // A drag, a collapse, the picker or a hide moved a region: fizzy's answer to + // "remember that" is a debounced write to `layout.zon`. + if (layout.extents_changed) editor.markWindowRatiosDirty(); editor.app.frame_layout = null; // A region is a box, so a shape that declares one and never scopes it leaves the box @@ -5171,6 +5175,24 @@ fn fizzyRevealSurface(ctx: *anyopaque, id: []const u8) bool { return layout.reveal(id); } +fn fizzyHideSurface(ctx: *anyopaque, id: []const u8) bool { + const editor = fizzyCtx(ctx); + // Mid-frame the shape and the floats are walking what a hide changes: done at the frame's end. + if (editor.app.frame_layout) |layout| return layout.hideLater(id); + var layout: Layout = .init(&editor.app.host, &editor.app.layout, editor.app.gpa, editor.app.arena.allocator()); + if (!layout.hide(id)) return false; + editor.afterHide(); + if (layout.extents_changed) editor.markWindowRatiosDirty(); + return true; +} + +/// A hide acted (`Layout.hide`). One that shut the sidebar goes through the explorer's own close, +/// as the rail's does, so the `closed` that Toggle Explorer and its menu title read agrees. +fn afterHide(editor: *Editor) void { + const side = editor.regionFor(sdk.keywords.ide.sidebar) orelse return; + if (side.isClosed() and !editor.explorer.closed) editor.explorer.close(editor); +} + fn fizzyAssignedRegionNames(ctx: *anyopaque) []const []const u8 { const editor = fizzyCtx(ctx); const arena = editor.app.arena.allocator(); diff --git a/tests/integration.zig b/tests/integration.zig index 9750aaa5e..7e1efdf69 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -2332,6 +2332,8 @@ const EmptyPanelFrame = struct { /// Main above a keyword-matched Panel showing several, as fizzy's own shape has them. const ManyPanelFrame = struct { var editor: ?*fizzy.Editor = null; + /// This frame's layout, while it draws: what a view asking for it mid-frame reaches. + var layout: ?*fizzy.Editor.Layout = null; fn draw(_: ?*anyopaque) anyerror!dvui.App.Result { return .ok; @@ -2339,17 +2341,19 @@ const ManyPanelFrame = struct { fn frame() anyerror!dvui.App.Result { const e = editor.?; - var layout = fizzy.Editor.Layout.init(&e.app.host, &e.app.layout, e.app.gpa, dvui.currentWindow().arena()); + var l = fizzy.Editor.Layout.init(&e.app.host, &e.app.layout, e.app.gpa, dvui.currentWindow().arena()); + layout = &l; + defer layout = null; { - var content = try layout.region(@src(), .{ .dir = .vertical }, .{ .expand = .both }); + var content = try l.region(@src(), .{ .dir = .vertical }, .{ .expand = .both }); defer content.deinit(); { - var main = try layout.region(@src(), .{ .name = "Main", .keywords = fizzy.sdk.keywords.ide.main }, .{ .expand = .both }); + var main = try l.region(@src(), .{ .name = "Main", .keywords = fizzy.sdk.keywords.ide.main }, .{ .expand = .both }); defer main.deinit(); } - layout.split(@src(), .{}); + l.split(@src(), .{}); { - var panel = try layout.region(@src(), .{ + var panel = try l.region(@src(), .{ .name = "Panel", .keywords = fizzy.sdk.keywords.ide.panel, .shows = .many, @@ -2359,9 +2363,11 @@ const ManyPanelFrame = struct { defer panel.deinit(); } } - // As fizzy's frame does: the floats over the shape, before its regions are published. - layout.drawFloats(); + // As fizzy's frame does: the floats over the shape, before its regions are published, + // and what a view asked to put away once all of it has drawn. + l.drawFloats(); e.app.layout.publishRegions(); + _ = l.applyHides(); return .ok; } }; @@ -5753,6 +5759,142 @@ fn holds(ids: []const []const u8, id: []const u8) bool { return false; } +// ── Putting a view away (`Host.hideSurface`) ────────────────────────────────────────────────── + +/// `Layout.hide` between frames, as `Host.hideSurface` runs it, then the frames after. +fn hideView(editor: *fizzy.Editor, id: []const u8) !bool { + var layout = fizzy.Editor.Layout.init(&editor.app.host, &editor.app.layout, editor.app.gpa, dvui.currentWindow().arena()); + const acted = layout.hide(id); + try dvui.testing.settle(ManyPanelFrame.frame); + return acted; +} + +fn revealView(editor: *fizzy.Editor, id: []const u8) !bool { + var layout = fizzy.Editor.Layout.init(&editor.app.host, &editor.app.layout, editor.app.gpa, dvui.currentWindow().arena()); + const acted = layout.reveal(id); + try dvui.testing.settle(ManyPanelFrame.frame); + return acted; +} + +fn placeShut(editor: *fizzy.Editor, name: []const u8) bool { + for (editor.app.layout.regions.items) |r| { + if (std.mem.eql(u8, r.name, name)) return r.isClosed(); + } + return false; +} + +test "hide: a tray's view shuts the tray, and reveal opens it again" { + var case = try ManyPanelCase.init(); + defer case.deinit(); + const editor = case.ctx.editor; + try std.testing.expect(!placeShut(editor, "Panel")); + + try std.testing.expect(try hideView(editor, "test.output")); + try std.testing.expect(placeShut(editor, "Panel")); + // Out of sight already: nothing to put away. + try std.testing.expect(!try hideView(editor, "test.output")); + try std.testing.expect(!try hideView(editor, "test.nothing")); + + try std.testing.expect(try revealView(editor, "test.output")); + try std.testing.expect(!placeShut(editor, "Panel")); + // Round again: a toggle. + try std.testing.expect(try hideView(editor, "test.output")); + try std.testing.expect(placeShut(editor, "Panel")); +} + +test "hide: a view in Main, or in a place a split made, stays and nothing changes" { + var case = try ManyPanelCase.init(); + defer case.deinit(); + const editor = case.ctx.editor; + try std.testing.expect(!try hideView(editor, "test.main")); + try std.testing.expect(!placeShut(editor, "Main")); + + // Split off Main. + try case.place("Panel", "Main", .{ .split = .right }); + const half = case.placeNamed(&.{ "Main", "Panel" }) orelse return error.TestExpectedEqual; + try std.testing.expect(holds(case.shows(half), "test.output")); + try std.testing.expect(!try hideView(editor, "test.output")); + try std.testing.expect(!placeShut(editor, half)); + try std.testing.expect(holds(case.shows(half), "test.output")); +} + +test "hide: a view in a tray the user split stays, and nothing changes" { + var case = try ManyPanelCase.init(); + defer case.deinit(); + const editor = case.ctx.editor; + try case.place("Panel", "Panel", .{ .split = .right }); + try std.testing.expect(holds(case.shows("Panel"), "test.output")); + try std.testing.expect(!try hideView(editor, "test.output")); + try std.testing.expect(!placeShut(editor, "Panel")); + try std.testing.expect(holds(case.shows("Panel"), "test.output")); +} + +test "hide: a view alone in a float closes the float, and it goes home" { + var case = try ManyPanelCase.init(); + defer case.deinit(); + const editor = case.ctx.editor; + try case.place("Panel", "Panel", .swap); + try std.testing.expectEqual(@as(usize, 1), openFloats(editor)); + + try std.testing.expect(try hideView(editor, "test.output")); + try dvui.testing.settle(ManyPanelFrame.frame); + try std.testing.expectEqual(@as(usize, 0), editor.app.layout.floats.items.items.len); + try std.testing.expect(holds(case.shows("Panel"), "test.output")); +} + +test "hide: a view in a float with others stays, and nothing changes" { + var case = try ManyPanelCase.init(); + defer case.deinit(); + const editor = case.ctx.editor; + try editor.app.host.registerSurface(.{ .id = "test.tools", .title = "Tools", .keywords = &.{"tools"}, .draw = ManyPanelFrame.draw }); + try case.place("Panel", "Panel", .swap); + try editor.app.layout.assign(editor.app.gpa, "Float 1", &.{ "test.output", "test.tools" }); + try dvui.testing.settle(ManyPanelFrame.frame); + + try std.testing.expect(!try hideView(editor, "test.output")); + try std.testing.expect(!try hideView(editor, "test.tools")); + try std.testing.expectEqual(@as(usize, 1), openFloats(editor)); + try std.testing.expectEqual(@as(usize, 2), case.shows("Float 1").len); +} + +/// A view that asks, from its own draw, to be put away (`hideLater`, as `Host.hideSurface` does +/// during a frame), and what it saw when it asked. +const HidingView = struct { + var ask = false; + var answer = false; + var floats_then: usize = 0; + + fn draw(_: ?*anyopaque) anyerror!dvui.App.Result { + if (!ask) return .ok; + ask = false; + const l = ManyPanelFrame.layout orelse return .ok; + answer = l.hideLater("test.hider"); + floats_then = openFloats(ManyPanelFrame.editor.?); + return .ok; + } +}; + +test "hide: asked from a floating view's own draw, it answers then and acts once the frame has drawn" { + var case = try ManyPanelCase.init(); + defer case.deinit(); + const editor = case.ctx.editor; + try editor.app.host.registerSurface(.{ .id = "test.hider", .title = "Hider", .keywords = &.{"tools"}, .draw = HidingView.draw }); + try case.place("Panel", "Panel", .swap); + try editor.app.layout.assign(editor.app.gpa, "Float 1", &.{"test.hider"}); + try dvui.testing.settle(ManyPanelFrame.frame); + try std.testing.expectEqual(@as(usize, 1), case.shows("Float 1").len); + + HidingView.ask = true; + _ = try dvui.testing.step(ManyPanelFrame.frame); + try std.testing.expect(!HidingView.ask); // it drew, and asked + try std.testing.expect(HidingView.answer); + // Nothing moved while the float was drawing; by the frame's end it is closing. + try std.testing.expectEqual(@as(usize, 1), HidingView.floats_then); + try std.testing.expectEqual(@as(usize, 0), openFloats(editor)); + try dvui.testing.settle(ManyPanelFrame.frame); + try std.testing.expectEqual(@as(usize, 0), editor.app.layout.floats.items.items.len); +} + test "float: a view dropped on the middle of its own place floats, and the place keeps the rest" { var case = try ManyPanelCase.init(); defer case.deinit(); From 7e2027d1aa277e72836afc5180835f2bc3deaf83 Mon Sep 17 00:00:00 2001 From: foxnne Date: Sun, 18 Oct 2026 17:17:54 -0500 Subject: [PATCH 2/2] tests: the off-screen layout total is compared within f32's precision The markdown preview test lays out docs/PLUGINS.md and compares the sum of its block heights with the scroll area's total to 0.01 pt. Both are f32 sums near 41000 pt, where one rounding step is 0.004, so the contract section this PR adds to PLUGINS.md tipped it over (41430.58 vs 41430.59, on macOS and on CI's Linux run). Compared relatively, at 1e-6. Co-Authored-By: Claude Opus 5.5 --- tests/integration.zig | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/integration.zig b/tests/integration.zig index 4fbfe104d..ac66cdc46 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -759,9 +759,13 @@ test "markdown preview: skipping off-screen blocks lays the document out identic // each table's grid — a scroll container in its own right — ask the scroll area for more // room than the block actually occupies, which is why that number comes out ~20% larger // than the document really is. + // + // Within f32's precision at the document's size: both totals are f32 sums of hundreds of + // heights, and at ~40000 pt one rounding step is already 0.004, so an absolute tolerance + // failed whenever docs/PLUGINS.md (this sample) grew. var sum: f32 = 0; for (virtualized.heights) |h| sum += h; - try std.testing.expectApproxEqAbs(sum + 16, virtualized.virtual_h, 0.01); + try std.testing.expectApproxEqRel(sum + 16, virtualized.virtual_h, 1e-6); }; }