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 b9c72a495..35682ee79 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 c572dc6f9..079218d8f 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 3142140b1..65b59a66e 100644 --- a/sdk/src/EditorAPI.zig +++ b/sdk/src/EditorAPI.zig @@ -330,6 +330,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. /// @@ -655,6 +658,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 fdac6d480..40f9b9da9 100644 --- a/sdk/src/Host.zig +++ b/sdk/src/Host.zig @@ -682,6 +682,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 47baec4da..c7393d891 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 = 0x23ccb903d7a4fb41; +pub const recorded_sdk_shape_fingerprint: u64 = 0x1dd3a67d781503a7; comptime { if (dylib.sdk_shape_fingerprint != recorded_sdk_shape_fingerprint) { diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 84c17635b..b6b688942 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -2479,6 +2479,7 @@ const fizzy_api_vtable: sdk.EditorAPI.VTable = .{ .assignedRegionNames = fizzyAssignedRegionNames, .selectInRegion = fizzySelectInRegion, .revealSurface = fizzyRevealSurface, + .hideSurface = fizzyHideSurface, .drawFileKindGlyph = fizzyDrawFileKindGlyph, .closeDocById = fizzyCloseDocById, .setProjectFolder = fizzySetProjectFolder, @@ -4641,15 +4642,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 @@ -5212,6 +5216,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 8c526bd4d..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); }; } @@ -2332,6 +2336,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 +2345,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 +2367,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 +5763,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();