From e81c5639efd9450f6b1655d2364c45da52c2e26e Mon Sep 17 00:00:00 2001 From: foxnne Date: Sun, 18 Oct 2026 11:24:47 -0500 Subject: [PATCH 1/2] sdk: a plugin can put a surface away, not only reveal it host.hideSurface(id) undoes revealSurface as the user would: the tray showing the view, or the tray a place it is in was split off, shuts; a place in no tray (Main, a place split off it, a document pane, a dockspace cell) is left; a view in a float goes home alone, the float closing only when it was its last, and the tray it lands selected in shuts, folded or not. True when the view was in sight and is not now. Asked mid-frame it is queued and applied once the shape, floats and picker have drawn. A toggle is `if (!hideSurface(id)) revealSurface(id)`. Region.tray is decided in one place (a runtime split's place is resize but no tray); Split.close takes the size to reopen to, so a tray that drew nothing last frame shuts instead of being seeded open, and Region.reopen_extent (the user's persisted extent, else the shape's default) is what it opens back to. The explorer reads the sidebar region instead of a flag of its own. Fingerprint recorded. Part of #530 Co-Authored-By: Claude Opus 5.5 --- app/layout/Layout.zig | 114 ++++++++++++-- app/layout/Picker.zig | 2 +- app/layout/Region.zig | 57 +++++-- app/layout/State.zig | 2 + app/layout/ViewDrag.zig | 98 ++++++++---- core/widgets/Split.zig | 38 ++++- docs/PLUGINS.md | 32 ++++ sdk/src/EditorAPI.zig | 7 + sdk/src/Host.zig | 9 ++ sdk/src/version.zig | 2 +- src/editor/Demo.zig | 6 +- src/editor/Editor.zig | 31 +++- src/editor/Keybinds.zig | 7 +- src/editor/Sidebar.zig | 8 +- src/editor/explorer/Explorer.zig | 25 ++- src/editor/menu_model.zig | 2 +- tests/integration.zig | 256 ++++++++++++++++++++++++++++++- 17 files changed, 613 insertions(+), 83 deletions(-) diff --git a/app/layout/Layout.zig b/app/layout/Layout.zig index 4b9cfd225..1a46433bd 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 put away mid-frame (`hideLater`), for `applyLater` once nothing walks the places. +/// Arena-backed, per frame. +later_hides: std.ArrayListUnmanaged([]const u8) = .empty, /// Open regions, innermost last. Every region pushes; its `deinit` pops. This is what lets /// `split` know which axis it divides and which neighbour it resizes, without a shape having to @@ -578,23 +581,114 @@ pub fn surface(self: *Layout, id: []const u8) ?*Surface { /// Bring `id` into sight wherever it lives: select it in the one region that shows it (the rule /// `claimedElsewhere` states: its assignment, else the place that accepts it best), and open that -/// region if the user shut it or a narrow window folded it away. False when no region shows it: -/// the shape has no place for it, or it is hidden. +/// region, and the tray it was split out of, if the user shut it or a narrow window folded it +/// away. False when no region shows it: the shape has no place for it, or it is hidden. /// /// What a plugin's "Show X" command asks for (`Host.revealSurface`): it names the view, never the /// place, so it lands wherever the user moved the view, in any shape. pub fn reveal(self: *Layout, id: []const u8) bool { const s = self.surface(id) orelse return false; if (s.hidden) return false; - var it = self.declaredRegions(); - while (it.next()) |r| { - for (self.matchingIn(r)) |m| if (m == s) { - self.selectIn(r, id); - if (r.isClosed() or r.isFolded()) r.open(); - return true; - }; + const at = self.locate(s, .any) orelse return false; + self.selectIn(at.place, id); + if (at.place.isClosed() or at.place.isFolded()) at.place.open(); + if (at.tray) |t| if (t.isClosed() or t.isFolded()) t.open(); + return true; +} + +/// Put `id` out of sight, as the user would: `reveal` undone (`Host.hideSurface`; the contract is +/// `docs/PLUGINS.md` §3.1.2, "Showing and putting away"). In a tray, or a place split off one, +/// the tray shuts; in a float, the view alone goes home (`ViewDrag.floatViewHome`) and the tray it +/// lands selected in shuts, folded or not; anywhere else nothing changes. It changes the lists +/// the shape and the floats walk while they draw, so mid-frame it is `hideLater`'s, never this. +pub fn hide(self: *Layout, id: []const u8) bool { + const s = self.surface(id) orelse return false; + switch (self.away(s) orelse return false) { + .shut => |tray| tray.close(), + .float => |float| { + if (!ViewDrag.floatViewHome(self, float, s.id)) return false; + // Last frame's places still list the float's, whose keywords alone might show this + // view again: home is looked for in the main window only. + const home = self.locate(s, .main_window) orelse return true; + const shown = self.selectedIn(home.place) orelse return true; + if (shown != s) return true; + const tray = home.tray orelse return !self.inSight(home, s); + if (!tray.isClosed()) tray.close(); + }, } - return false; + return true; +} + +/// `hide`, asked mid-frame (a button in a surface): kept for `applyLater`, which the application +/// calls once the shape and the floats have drawn. True when it will put the view away: it is in +/// sight in a tray, or in a float it will leave. Whether a view leaving a float lands in sight is +/// known only once it lands, as `hide` says. +pub fn hideLater(self: *Layout, id: []const u8) bool { + const s = self.surface(id) orelse return false; + _ = self.away(s) orelse return false; + const kept = self.arena.dupe(u8, id) catch return false; + self.later_hides.append(self.arena, kept) catch return false; + return true; +} + +/// Apply what `hideLater` kept. The application calls it after the shape, the floats, the drag +/// overlay and the picker have drawn, before it reads `extents_changed`. +pub fn applyLater(self: *Layout) void { + for (self.later_hides.items) |id| _ = self.hide(id); + self.later_hides.clearRetainingCapacity(); +} + +/// What putting `s` away does, worked out before anything changes; null when it is not in sight +/// or nothing round it shuts. +const Away = union(enum) { + /// The tray to shut. + shut: *const Region, + /// The float to take it out of. + float: []const u8, +}; + +fn away(self: *Layout, s: *const Surface) ?Away { + if (s.hidden) return null; + const at = self.locate(s, .any) orelse return null; + if (!self.inSight(at, s)) return null; + if (self.state.floats.rootOf(at.place.name)) |float| { + if (!ViewDrag.canFloatViewHome(self, float, s.id)) return null; + return .{ .float = float }; + } + return .{ .shut = at.tray orelse return null }; +} + +/// Where a view is: the place showing it, and the tray that place is or was split out of. +const Located = struct { place: *const Region, tray: ?*const Region }; + +/// The one place showing `s` (the rule `claimedElsewhere` states: its assignment, else the place +/// that accepts it best), among this frame's places so far and last frame's rest, with its tray: +/// the place itself, or the one a split of it grew from (`SplitTree.Forest.rootOf`). +/// `main_window` leaves out the floats' places. +fn locate(self: *Layout, s: *const Surface, among: enum { any, main_window }) ?Located { + var it = self.declaredRegions(); + const place = find: while (it.next()) |r| { + if (self.state.floats.rootOf(r.name)) |float| { + if (among == .main_window) continue; + // Flying shut: what it held has gone home already. + const i = self.state.floats.find(float) orelse continue; + if (self.state.floats.items.items[i].closing) continue; + } + for (self.matchingIn(r)) |m| if (m == s) break :find r; + } else return null; + if (place.tray) return .{ .place = place, .tray = place }; + const root = self.state.splits.rootOf(place.name) orelse return .{ .place = place, .tray = null }; + const tray = ViewDrag.regionNamed(self.state, root) orelse return .{ .place = place, .tray = null }; + return .{ .place = place, .tray = if (tray.tray) tray else null }; +} + +/// Whether `s` is what the place `at` shows now: its selection, with neither the place nor its +/// tray shut or folded. +fn inSight(self: *Layout, at: Located, s: *const Surface) bool { + if (at.place.isClosed() or at.place.isFolded()) return false; + if (at.tray) |t| if (t.isClosed() or t.isFolded()) return false; + const shown = self.selectedIn(at.place) orelse return false; + return shown == s; } /// Surfaces that appear in no region of the last completed shape — neither assigned to one nor diff --git a/app/layout/Picker.zig b/app/layout/Picker.zig index 52c562d15..661aa2c17 100644 --- a/app/layout/Picker.zig +++ b/app/layout/Picker.zig @@ -841,7 +841,7 @@ fn removeRegion(f: *Layout, region: *const Layout.Region) void { _ = ViewDrag.closeEmptied(f, region.name); } else if (forget) { if (region.id != .zero and (dvui.dataGet(null, region.id, "_size", f32) orelse 0) > 0) { - Split.close(region.id); + Split.close(region.id, region.reopen_extent); f.extents_changed = true; } else if (f.state.splits.collapse(gpa, region.name)) { if (f.state.clearExtent(gpa, region.name)) f.extents_changed = true; diff --git a/app/layout/Region.zig b/app/layout/Region.zig index dae8c493b..4fe9cb2e5 100644 --- a/app/layout/Region.zig +++ b/app/layout/Region.zig @@ -45,6 +45,10 @@ id: dvui.Id = .zero, /// width under a horizontal parent, the height under a vertical one, in points. One number, /// because a region only ever divides its parent one way; the other axis is the parent's. default_extent: f32 = 0, +/// What it opens back to: the extent the user last left it at, persisted by name, else +/// `default_extent`. Filled in as it registers (`State.registerRegion`), so a command between +/// frames shuts and opens it at the user's size. +reopen_extent: f32 = 0, /// Clip set while the region is open, restored on `deinit`. prev_clip: ?dvui.Rect.Physical = null, /// The box this region is. A region **is** a `dvui.box`: same layout mechanics, same @@ -64,6 +68,11 @@ kind_slot: bool = false, pane_cards: bool = false, /// A tray the next split should keep targeting. Center and grouping boxes are not. resize: bool = false, +/// A tray: a place the shape declared `resize`, which shuts and opens again — the one kind of place +/// `Layout.hide` shuts. Not a place a split made (`State.isMinted`), which is `resize` too but goes +/// only by being emptied or joined. Registered with the region, so a command between frames reads +/// it. +tray: bool = false, /// An empty closed tray can disappear — a runtime split, an endless edge. forget_when_empty: bool = false, /// How this place lays its children. @@ -127,7 +136,7 @@ pub fn deinit(self: *Region) void { // Opening and shutting from outside the layout — a rail button, a command, a keybind. These // work on the copy `Editor.regionFor` hands back as well as on a live one, which is why they -// touch only `id` and `default_extent`: everything they need is a persisted extent under an id, +// touch only `id` and `reopen_extent`: everything they need is a persisted extent under an id, // never a widget pointer. pub fn isClosed(self: Region) bool { @@ -141,13 +150,19 @@ pub fn close(self: Region) void { // Deliberately before `Split.close`: shutting a region is also the user withdrawing the peek // that kept it open on a narrow window (see `collapseTarget`). dvui.dataSet(null, self.id, "_peek", false); - Split.close(self.id); + Split.close(self.id, self.openTo()); +} + +/// `reopen_extent`, or `default_extent` for a region that has not registered (the shape's own +/// copy, mid-draw). +fn openTo(self: Region) f32 { + return if (self.reopen_extent > 0) self.reopen_extent else self.default_extent; } pub fn open(self: Region) void { // Opening is an explicit answer to the auto-collapse, and outlives it. dvui.dataSet(null, self.id, "_peek", true); - Split.open(self.id, self.default_extent); + Split.open(self.id, self.openTo()); } /// Auto-collapse is a *default*, not a lock: a `collapsible` region folds itself away when the @@ -379,6 +394,13 @@ pub fn init(self: *Layout, src: std.builtin.SourceLocation, init_opts: InitOptio break :blk regionKeywords(self, if (parent) |p| p.prefix else "", init_opts.keywords); }; + // What a tray opens to when the user has never dragged it: the content the shape's + // `min_size_content` asks for along its parent's axis, with the padding and border round it. + const default_extent: f32 = if (init_opts.resize) switch (axis) { + .horizontal => (opts.min_size_content orelse dvui.Size{}).w, + .vertical => (opts.min_size_content orelse dvui.Size{}).h, + } + alongAxis(opts.paddingGet(), axis) + alongAxis(opts.borderGet(), axis) else 0; + const probe: Region = .{ .name = init_opts.name, .keywords = keywords, @@ -403,11 +425,13 @@ pub fn init(self: *Layout, src: std.builtin.SourceLocation, init_opts: InitOptio // Still a place, even with nothing in it: it stays in the registry so the picker and // the settings table can put something back. Emptied through the picker and then // gone from the picker was a region the user could not get back. - self.state.registerRegion(self.gpa, .{ + register(self, init_opts, .{ .name = init_opts.name, .keywords = keywords, .shows = init_opts.shows, .id = id, + // Folded away empty, it still opens to the shape's size, not to nothing. + .default_extent = default_extent, .by_name = init_opts.by_name, .kind_slot = init_opts.kind_slot, .forget_when_empty = init_opts.forget_when_empty, @@ -436,16 +460,9 @@ pub fn init(self: *Layout, src: std.builtin.SourceLocation, init_opts: InitOptio // frame (mid-animation it is between the two), `default_extent` what it opens to when the // user has never dragged it. var extent: f32 = 0; - var default_extent: f32 = 0; if (init_opts.resize) { const given = opts.min_size_content orelse dvui.Size{}; - // The shape's `min_size_content` is the content it wants to open to; the extent is the - // whole region round it. - const default: f32 = switch (axis) { - .horizontal => given.w, - .vertical => given.h, - } + alongAxis(opts.paddingGet(), axis) + alongAxis(opts.borderGet(), axis); - default_extent = default; + const default = default_extent; // Seeded from what the user last left this region at, by name — so a layout persists // across restarts without the framework knowing which regions an app has. if (dvui.dataGet(null, id, "_size", f32) == null) { @@ -549,7 +566,7 @@ pub fn init(self: *Layout, src: std.builtin.SourceLocation, init_opts: InitOptio // A leftover leaf that kept a minted name must not overwrite the pinned // subtree registered under that name — the parent sash still targets it. const leftover_leaf = init_opts.omit_edge != null and !init_opts.resize; - if (keywords.len > 0 and !tree_origin and !leftover_leaf) self.state.registerRegion(self.gpa, .{ + if (keywords.len > 0 and !tree_origin and !leftover_leaf) register(self, init_opts, .{ .name = init_opts.name, .keywords = keywords, .shows = init_opts.shows, @@ -1070,7 +1087,7 @@ fn initTree( const box = dvui.box(src, .{ .dir = branch.dir }, box_opts); if (init_opts.resize) Split.recordEdges(id, box.data(), axis); - if (keywords.len > 0) self.state.registerRegion(self.gpa, .{ + if (keywords.len > 0) register(self, init_opts, .{ .name = init_opts.name, .keywords = keywords, .shows = init_opts.shows, @@ -1354,6 +1371,15 @@ fn placePrefix(keywords: []const []const u8, parent_prefix: []const u8) []const return keywords[0]; } +/// Register `entry`, a region `init_opts` declared, as a tray when it is one (`Region.tray`): the +/// one place that decides it. +fn register(self: *Layout, init_opts: InitOptions, entry: Region) void { + var r = entry; + // A place a split made is `resize` too, but goes by being emptied or joined, never shut. + r.tray = init_opts.resize and !self.state.isMinted(init_opts.name); + self.state.registerRegion(self.gpa, r); +} + /// Draw a region into an already-open box — `init` minus geometry. Keyword qualification, /// registry, corner button, contents. The box is the caller's (a dockspace leaf cell). pub fn fillInBox(self: *Layout, init_opts: InitOptions, box: *dvui.BoxWidget) !void { @@ -1367,6 +1393,9 @@ pub fn fillInBox(self: *Layout, init_opts: InitOptions, box: *dvui.BoxWidget) !v .id = box.data().id, .by_name = init_opts.by_name, .kind_slot = init_opts.kind_slot, + // Never a tray, whatever `resize` says: the box and its size are the caller's (a + // dockspace cell), so there is no extent here to shut and open again. + .tray = false, .forget_when_empty = init_opts.forget_when_empty, .dir = init_opts.dir, }); diff --git a/app/layout/State.zig b/app/layout/State.zig index cc6acba3f..7d4ad1365 100644 --- a/app/layout/State.zig +++ b/app/layout/State.zig @@ -454,6 +454,8 @@ pub fn joinable(self: *const State, a: []const u8, b: []const u8) ?SplitTree.For pub fn registerRegion(self: *State, gpa: std.mem.Allocator, entry: Region) void { var r = entry; r.layer = self.layer_building; + const kept = self.extent(r.name, 0); + r.reopen_extent = if (kept > 0) kept else r.default_extent; if (r.name.len > 0) r.shows = self.showsOf(r.name, r.shows); self.regions_building.append(gpa, r) catch {}; } diff --git a/app/layout/ViewDrag.zig b/app/layout/ViewDrag.zig index 2848ee76b..7ee162c0c 100644 --- a/app/layout/ViewDrag.zig +++ b/app/layout/ViewDrag.zig @@ -2599,9 +2599,6 @@ pub fn holderOf(l: *Layout, id: []const u8) ?[]const u8 { /// the user had in front of them is in front of them again. pub fn sendHome(l: *Layout, leaves: []const []const u8, home: []const u8) void { const state = l.state; - // No home (a saved float whose home was lost). Not looked up — an unnamed place would answer - // to "". - const r = if (home.len > 0) regionNamed(state, home) else null; // Every view out of the float's places first, so what a place's keywords show is read with // them all free: a view written into a place its keywords fill takes the rest of what they // show there with it, and would leave behind one of the float's still held by it. @@ -2612,29 +2609,76 @@ pub fn sendHome(l: *Layout, leaves: []const []const u8, home: []const u8) void { } state.unassign(l.gpa, leaf); } - for (ids.items) |id| { - const held = holding(l, home); - var landed: ?[]const u8 = if (r != null) home else null; - switch (float_rules.goHome(.{ - .declared = r != null, - .assigned = state.assignment(home) != null, - .shows_many = if (r) |x| x.shows == .many else false, - .empty = held.len == 0, - .keywords_place = keywordsPlace(l, id), - })) { - .add => state.assign(l.gpa, home, idsWith(l.arena, held, id)) catch {}, - .put => state.assign(l.gpa, home, &.{id}) catch {}, - .keywords => {}, - .elsewhere => { - landed = null; - if (mainPlaceFor(l, id)) |other| { - state.assign(l.gpa, other.name, idsWith(l.arena, holding(l, other.name), id)) catch {}; - landed = other.name; - } - }, - } - if (landed) |name| selectNamed(l, name, id); - } + for (ids.items) |id| goHome(l, id, homeLanding(l, id, home) orelse continue); +} + +/// Where view `id` goes back into the main window, as `float_rules.goHome` says: the place and +/// how it takes the view. Null when nowhere would show it (no home, no place of the main window +/// takes it): asked before the view leaves anywhere, so it is not let go to be lost. +const Landing = struct { + /// The place it lands in; null when its keywords bring it back of their own accord. + place: ?[]const u8, + how: float_rules.GoHome, +}; + +fn homeLanding(l: *Layout, id: []const u8, home: []const u8) ?Landing { + const state = l.state; + // No home (a saved float whose home was lost). Not looked up — an unnamed place would answer + // to "". + const r = if (home.len > 0) regionNamed(state, home) else null; + const how = float_rules.goHome(.{ + .declared = r != null, + .assigned = state.assignment(home) != null, + .shows_many = if (r) |x| x.shows == .many else false, + .empty = holding(l, home).len == 0, + .keywords_place = keywordsPlace(l, id), + }); + return switch (how) { + .add, .put => .{ .place = home, .how = how }, + .keywords => .{ .place = if (r != null) home else null, .how = how }, + .elsewhere => .{ .place = (mainPlaceFor(l, id) orelse return null).name, .how = how }, + }; +} + +/// View `id`, held by no place's list now, into the main window where `to` says, selected there. +fn goHome(l: *Layout, id: []const u8, to: Landing) void { + const state = l.state; + const dest = to.place orelse return; + switch (to.how) { + .add, .elsewhere => state.assign(l.gpa, dest, idsWith(l.arena, holding(l, dest), id)) catch {}, + .put => state.assign(l.gpa, dest, &.{id}) catch {}, + .keywords => {}, + } + selectNamed(l, dest, id); +} + +/// Whether `floatViewHome` would take view `id` out of float `float`: the float is there and not +/// already closing, holds the view, can let it go (`withdraw`'s rule, `closable`), and somewhere +/// in the main window will show it (`homeLanding`). The one test for a hide asked now and done at +/// the frame's end (`Layout.hideLater`), and for doing it. +pub fn canFloatViewHome(l: *Layout, float: []const u8, id: []const u8) bool { + const i = l.state.floats.find(float) orelse return false; + const f = l.state.floats.items.items[i]; + if (f.closing) return false; + const holder = holderOf(l, id) orelse return false; + if (!float_rules.isUnder(holder, float)) return false; + if (idsWithout(l.arena, holding(l, holder), id).len == 0 and !closable(l, holder)) return false; + return homeLanding(l, id, f.home) != null; +} + +/// One view out of float `float` and home, as closing the float would send it (`sendHome`): the +/// float's other views stay where the user arranged them, and a place of it left empty closes as +/// any emptied place does (`withdraw`), the float itself when it was its last view. False, with +/// nothing changed, when `canFloatViewHome` says no: the view stays in the float rather than be +/// let go to nowhere. +pub fn floatViewHome(l: *Layout, float: []const u8, id: []const u8) bool { + if (!canFloatViewHome(l, float, id)) return false; + const f = l.state.floats.items.items[l.state.floats.find(float).?]; + // Worked out before the view leaves: `withdraw` may close the float. + const to = homeLanding(l, id, f.home).?; + if (!withdraw(l, id)) return false; + goHome(l, id, to); + return true; } /// Whether a place's keywords would show `id`, held by no place's list, of their own accord: a @@ -2962,7 +3006,7 @@ fn closeMinted(l: *Layout, name: []const u8) void { } } if (regionNamed(l.state, name)) |r| if (r.id != .zero and Split.sizeOf(r.id) > 0) { - Split.close(r.id); + Split.close(r.id, r.reopen_extent); l.extents_changed = true; return; }; diff --git a/core/widgets/Split.zig b/core/widgets/Split.zig index b1c5b5038..6921ac146 100644 --- a/core/widgets/Split.zig +++ b/core/widgets/Split.zig @@ -223,7 +223,10 @@ pub fn isClosed(id: dvui.Id) bool { return sizeOf(id) <= 0; } -/// Shut it, remembering how big it was so `open` can put it back. +/// Shut it, remembering how big it was so `open` can put it back. Something that keeps no size +/// at all (nothing drew it lately: a tray left empty and folded away) is shut as though it had +/// been open at `fallback`, which `open` then brings it back to, with nothing to slide: it was +/// not on screen. Without that it was left as it was, and seeded open the next time it drew. /// /// The remembered extent is *retained*: dvui frees a value nothing touched during a frame, and /// while the region is shut nothing does — the layout reads `_size` and `_shown` every frame but @@ -234,7 +237,20 @@ pub fn isClosed(id: dvui.Id) bool { /// from — the same trick `takeSlideOpen` uses the other way. Without that, a /// missing or leftover-zero `_shown` makes the next frame treat target 0 as /// already there, and the pane vanishes instead of folding. -pub fn close(id: dvui.Id) void { +pub fn close(id: dvui.Id, fallback: f32) void { + if (dvui.dataGet(null, id, "_size", f32) == null) { + if (fallback > 0) { + dvui.dataSet(null, id, "_open", fallback); + dvui.dataRetain(null, id, "_open", .zero); + } + dvui.dataSet(null, id, "_shown", @as(f32, 0)); + dvui.dataSet(null, id, "_size", @as(f32, 0)); + // Kept like `_open`: nothing may draw it for a while, and dvui frees what no frame + // touched, after which it would be seeded open again. + dvui.dataRetain(null, id, "_size", .zero); + dvui.refresh(null, @src(), id); + return; + } const cur = sizeOf(id); if (cur <= 0) return; dvui.dataSet(null, id, "_open", cur); @@ -1070,7 +1086,7 @@ test "reopening a shut region restores the extent it was shut at" { _ = try dvui.testing.step(twoPaneFrame); try testing.expectApproxEqAbs(@as(f32, 220), t_size, 1.0); - close(t_target); + close(t_target, 100); // Several frames shut: one is not enough to show it, because the value survives the frame it // was written in. for (0..5) |_| _ = try dvui.testing.step(twoPaneFrame); @@ -1081,6 +1097,20 @@ test "reopening a shut region restores the extent it was shut at" { try testing.expectApproxEqAbs(@as(f32, 220), t_size, 1.0); } +test "closing something that keeps no size shuts it at the fallback, to open back to" { + // A tray left empty and folded away drew nothing, so it keeps no `_size`: shut, it must stay + // shut when it next draws, and open to the size it would have had. + var t = try dvui.testing.init(.{ .window_size = .{ .w = 400, .h = 300 } }); + defer t.deinit(); + const id = dvui.Id.extendId(null, @src(), 0); + close(id, 180); + // Frames pass with nothing drawing it, and it is still shut, not forgotten. + for (0..3) |_| _ = try dvui.testing.step(twoPaneFrame); + try testing.expectEqual(@as(?f32, 0), dvui.dataGet(null, id, "_size", f32)); + open(id, 1); + try testing.expectEqual(@as(f32, 180), sizeOf(id)); +} + test "opening something already open leaves the width the user dragged it to" { // Clicking a rail icon for another sidebar view is "make sure the sidebar is open". That // must not run the reopen path, which restores the width from the last *close* — a sidebar @@ -1093,7 +1123,7 @@ test "opening something already open leaves the width the user dragged it to" { _ = try dvui.testing.step(twoPaneFrame); // Closed and reopened: back to 140, the width it was closed at. - close(t_target); + close(t_target, 100); for (0..3) |_| _ = try dvui.testing.step(twoPaneFrame); open(t_target, 100); _ = try dvui.testing.step(twoPaneFrame); diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index ef2f78328..3ebd7fce6 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -563,6 +563,38 @@ Pixi's project preview (`keywords = sdk.keywords.ide.main, takeover_when = "pixi the main area while "Project" is the sidebar tab; the store's README fills it while a plugin card is selected. One rule in the layout, declared on the surface, instead of a hook per place it can happen. +**Showing and putting away.** A plugin names its view, never a place, so its "Show X" works +wherever the user moved the view and in any app's shape. `host.revealSurface(id)` selects the +view in the place showing it and opens that place if it was shut or folded away; +`host.hideSurface(id)` is the inverse, the user's own close, and this is its contract. It +returns true when the view was in sight and is not now. "In sight" means its place shows it and +neither the place nor the tray round it is shut, or folded away by a narrow window. + +- In a tray (a sidebar, the bottom panel), or a place the user split off one, the tray shuts, + with whatever else it holds; `revealSurface` opens it again at the size the user left it. +- Anywhere else (the main area or a place split off it, a document pane, a dockspace cell) + nothing happens, and it returns false. +- In a float, the view alone goes back to the place it floated out of, as closing the float + would send it; the float's other views stay, and the float closes only if this was its last. + If the view lands as a tray's selection, the tray shuts, folded or not, so it doesn't come back + as the window widens; in sight anywhere else, it returns false. If nowhere in the main window + would show it (its home is gone and no place takes it), it stays in the float and the answer + is false. +- A view not in sight (another view is its place's selection, its tray is shut or folded) is + left alone, and it returns false. +- Called while a frame draws (a button in your surface), it happens at the frame's end, once + nothing is drawing the places it changes; the answer is then whether it will happen. + +So a toggle is + +```zig +if (!host.hideSurface(id)) _ = host.revealSurface(id); +``` + +Both act on the layout's own state, so the rail, the picker and the saved session agree with +them; there is no separate shown flag to keep in step. (`Surface.hidden`, set with +`host.setSurfaceHidden`, is different: it takes a surface out of every place until it is unset.) + **Documents are surfaces.** When a load lands the app registers one surface per open document — id `sdk.document.surfaceId(owner_id, path)` (`".doc:"`), keywords `{"document"}`, `draw` = a canvas box plus `owner.drawDocument(doc)` — and unregisters it on close diff --git a/sdk/src/EditorAPI.zig b/sdk/src/EditorAPI.zig index ed07e9559..cb133b7d4 100644 --- a/sdk/src/EditorAPI.zig +++ b/sdk/src/EditorAPI.zig @@ -319,6 +319,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` out of sight: shut the tray it is in, or take it out of a float. True + /// when it was in sight and is not now. See `Host.hideSurface`. + 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. /// @@ -640,6 +643,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 0658b4f91..82ec88d24 100644 --- a/sdk/src/Host.zig +++ b/sdk/src/Host.zig @@ -647,6 +647,15 @@ pub fn revealSurface(self: *Host, id: []const u8) bool { return a.revealSurface(id); } +/// Put the surface `id` out of sight, as the user would: `revealSurface` undone. True when it +/// was in sight and is not now (mid-frame: when it will be, at the frame's end). A toggle is +/// `if (!host.hideSurface(id)) _ = host.revealSurface(id);`. What it does in a tray, a float and +/// elsewhere: `docs/PLUGINS.md` §3.1.2, "Showing and putting away". +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 5ed391deb..f17c88c73 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 = 0xcd68cc1a32bd51c9; +pub const recorded_sdk_shape_fingerprint: u64 = 0x513bfd13018901be; comptime { if (dylib.sdk_shape_fingerprint != recorded_sdk_shape_fingerprint) { diff --git a/src/editor/Demo.zig b/src/editor/Demo.zig index 63fc1847d..f47553c7b 100644 --- a/src/editor/Demo.zig +++ b/src/editor/Demo.zig @@ -774,7 +774,7 @@ fn capture(ctx: *anyopaque) ?*anyopaque { .nodes = &.{}, .docs = &.{}, .active = null, - .explorer_closed = editor.explorer.closed, + .explorer_closed = Editor.Explorer.isShut(editor), .branches = &.{}, .root_opened = editor.workbench.file_tree_root_opened, .settings = &.{}, @@ -871,7 +871,7 @@ fn restore(ctx: *anyopaque, raw: *anyopaque) bool { if (openIndex(editor, snap.docs[a].path)) |index| editor.workbench.setActiveDocIndex(index); } - if (snap.explorer_closed != editor.explorer.closed) { + if (snap.explorer_closed != Editor.Explorer.isShut(editor)) { if (snap.explorer_closed) editor.explorer.close(editor) else editor.explorer.open(editor); } editor.workbench.open_branches.clearRetainingCapacity(); @@ -910,7 +910,7 @@ fn fingerprint(ctx: *anyopaque) u64 { h.update(std.mem.asBytes(&print)); } if (editor.activeDoc()) |d| h.update(d.owner.documentPath(d)); - h.update(std.mem.asBytes(&editor.explorer.closed)); + h.update(&.{@intFromBool(Editor.Explorer.isShut(editor))}); if (self.mem) |mem| { for (mem.nodes.keys(), mem.nodes.values()) |path, node| { h.update(path); diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 2fe1d91e7..f6a58aa33 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -2404,6 +2404,7 @@ const fizzy_api_vtable: sdk.EditorAPI.VTable = .{ .assignedRegionNames = fizzyAssignedRegionNames, .selectInRegion = fizzySelectInRegion, .revealSurface = fizzyRevealSurface, + .hideSurface = fizzyHideSurface, .drawFileKindGlyph = fizzyDrawFileKindGlyph, .closeDocById = fizzyCloseDocById, .setProjectFolder = fizzySetProjectFolder, @@ -4493,9 +4494,6 @@ 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 @@ -4503,6 +4501,12 @@ pub fn tick(editor: *Editor) !dvui.App.Result { layout.captureUnplaced(); editor.app.layout.picker.draw(&layout); editor.app.frame_layout = null; + // What was put away mid-frame, now that nothing walks the places (`Layout.hideLater`). + layout.applyLater(); + // 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`. Read last, so nothing this + // frame did is missed. + if (layout.extents_changed) editor.markWindowRatiosDirty(); // A region is a box, so a shape that declares one and never scopes it leaves the box // open and dvui reports the mismatch two widgets later ("not at the top of the widget @@ -5056,12 +5060,25 @@ fn fizzySelectInRegion(ctx: *anyopaque, region: []const u8, id: []const u8) void } fn fizzyRevealSurface(ctx: *anyopaque, id: []const u8) bool { + return onLayout(fizzyCtx(ctx), Layout.reveal, id); +} + +fn fizzyHideSurface(ctx: *anyopaque, id: []const u8) bool { const editor = fizzyCtx(ctx); - if (editor.app.frame_layout) |layout| return layout.reveal(id); - // A command between shapes (a keybind, the palette, an agent): the regions are last frame's, - // published for exactly this (`Layout.State.regions`). + // Mid-frame (a button in a surface), the shape and the floats are walking the lists a hide + // changes: it waits for the frame's end (`Layout.applyLater`). + if (editor.app.frame_layout) |layout| return layout.hideLater(id); + return onLayout(editor, Layout.hide, id); +} + +/// `act` on the shape drawing now, or, for a command between shapes (a keybind, the palette, an +/// agent), on last frame's regions, published for exactly this (`Layout.State.regions`), with +/// what it changed of the extents saved as the frame's own would be. +fn onLayout(editor: *Editor, comptime act: fn (*Layout, []const u8) bool, id: []const u8) bool { + if (editor.app.frame_layout) |layout| return act(layout, id); var layout: Layout = .init(&editor.app.host, &editor.app.layout, editor.app.gpa, editor.app.arena.allocator()); - return layout.reveal(id); + defer if (layout.extents_changed) editor.markWindowRatiosDirty(); + return act(&layout, id); } fn fizzyAssignedRegionNames(ctx: *anyopaque) []const []const u8 { diff --git a/src/editor/Keybinds.zig b/src/editor/Keybinds.zig index 7b54f2fcb..d9e030a54 100644 --- a/src/editor/Keybinds.zig +++ b/src/editor/Keybinds.zig @@ -582,8 +582,9 @@ fn cmdCommandPalette(state: *anyopaque) anyerror!void { fn cmdToggleExplorer(state: *anyopaque) anyerror!void { const editor = editorFromState(state); - // `.closed`, not `paned.split_ratio` — the latter is only valid during draw. - if (editor.explorer.closed) editor.explorer.open(editor) else editor.explorer.close(editor); + // Whether the sidebar region is on screen (`Explorer.inSight`), not `paned.split_ratio`, which is only + // valid during draw. + if (Editor.Explorer.inSight(editor)) editor.explorer.close(editor) else editor.explorer.open(editor); // A native menu click doesn't arrive as an SDL event, so without this nothing requests the // frame the paned needs to animate. dvui.refresh(null, @src(), dvui.currentWindow().data().id); @@ -1347,7 +1348,7 @@ fn projectUserOverrides(editor: *Editor) void { fn currentContext(editor: *Editor) Keymap.When { return .{ .editor_focused = editor.activeDoc() != null, - .explorer_focused = !editor.explorer.closed, + .explorer_focused = !Editor.Explorer.isShut(editor), .modal_open = editor.command_palette.open, // Declared by `Keymap.When` since it was written but never filled in, so any `when` // clause mentioning text input could not match. `dvui.wantTextInput` is the signal. diff --git a/src/editor/Sidebar.zig b/src/editor/Sidebar.zig index 26291822e..f8a8cb93b 100644 --- a/src/editor/Sidebar.zig +++ b/src/editor/Sidebar.zig @@ -170,13 +170,13 @@ const rail_icon: f32 = 20; fn pickAction(editor: *Editor, c: *const Chooser, so_far: Action) Action { if (c.picked() != null) return .open; if (c.reselected() == null) return so_far; - // The region, not `explorer.closed`: on a narrow window the explorer is folded away by - // the layout without anyone having closed it, and a tap there means "show me", not - // "hide it again". + // Folded counts as out of sight here: on a narrow window the explorer is folded away by the + // layout without anyone having closed it, and a tap there means "show me", not "hide it + // again". const explorer_visible = if (editor.regionFor(fizzy.sdk.keywords.ide.sidebar)) |r| !r.isClosed() and !r.isFolded() else - !editor.explorer.closed; + false; return if (explorer_visible) .close else .open; } diff --git a/src/editor/explorer/Explorer.zig b/src/editor/explorer/Explorer.zig index 9bbe3f280..c973c7567 100644 --- a/src/editor/explorer/Explorer.zig +++ b/src/editor/explorer/Explorer.zig @@ -28,16 +28,30 @@ scroll_info: dvui.ScrollInfo = .{ rect: dvui.Rect = .{}, rect_screen: dvui.Rect.Physical = .{}, animations_ratio: f32 = 0.5, -closed: bool = false, collapse_btn_anim_started: bool = false, -pub fn close(explorer: *Explorer, editor: *fizzy.Editor) void { - explorer.closed = true; +/// Whether the user (or a plugin, `Host.hideSurface`) shut the explorer: its sidebar region's own +/// state, which the rail, Toggle Explorer and `hideSurface` all write. Not folded away by a narrow +/// window, which is the window's doing, not anyone's choice: what the menu title, the keybind +/// context and a demo's snapshot read, none of which may change with the window's width. No +/// sidebar declared (before the first frame, a shape without one): not shut. +pub fn isShut(editor: *fizzy.Editor) bool { + const r = editor.regionFor(fizzy.sdk.keywords.ide.sidebar) orelse return false; + return r.isClosed(); +} + +/// Whether the explorer is on screen: its sidebar region neither shut nor folded away. What +/// Toggle Explorer decides by, so a folded explorer opens in one press. +pub fn inSight(editor: *fizzy.Editor) bool { + const r = editor.regionFor(fizzy.sdk.keywords.ide.sidebar) orelse return false; + return !r.isClosed() and !r.isFolded(); +} + +pub fn close(_: *Explorer, editor: *fizzy.Editor) void { if (editor.regionFor(fizzy.sdk.keywords.ide.sidebar)) |r| r.close(); } -pub fn open(explorer: *Explorer, editor: *fizzy.Editor) void { - explorer.closed = false; +pub fn open(_: *Explorer, editor: *fizzy.Editor) void { if (editor.regionFor(fizzy.sdk.keywords.ide.sidebar)) |r| r.open(); } @@ -45,7 +59,6 @@ pub fn open(explorer: *Explorer, editor: *fizzy.Editor) void { /// worth seeing. `Region.close` withdraws the peek, so the narrow layout goes back to collapsing /// it by itself. pub fn peekClose(explorer: *Explorer, editor: *fizzy.Editor) void { - explorer.closed = true; explorer.collapse_btn_anim_started = false; if (editor.regionFor(fizzy.sdk.keywords.ide.sidebar)) |r| r.close(); } diff --git a/src/editor/menu_model.zig b/src/editor/menu_model.zig index 0f9bec040..7b11a4ab3 100644 --- a/src/editor/menu_model.zig +++ b/src/editor/menu_model.zig @@ -162,7 +162,7 @@ fn pasteEnabled(editor: *Editor) bool { } fn explorerTitle(editor: *Editor) [:0]const u8 { - return if (editor.explorer.closed) "Show Explorer" else "Hide Explorer"; + return if (Editor.Explorer.isShut(editor)) "Show Explorer" else "Hide Explorer"; } /// TEMPORARY: only meaningful on macOS, where the in-app dvui menu bar is normally suppressed diff --git a/tests/integration.zig b/tests/integration.zig index 3ca3196a6..ce046cc73 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -2340,6 +2340,13 @@ 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()); + // As fizzy's frame does: the layout published while it draws, and what was put away + // mid-frame applied at its end. + e.app.frame_layout = &layout; + defer { + e.app.frame_layout = null; + layout.applyLater(); + } { var content = try layout.region(@src(), .{ .dir = .vertical }, .{ .expand = .both }); defer content.deinit(); @@ -3167,7 +3174,7 @@ test "split: a column shut by its sash goes only once every place in it is empty // Shut with a view still in its bottom half: the column stays, and so does every place in it. try state.assign(editor.app.gpa, "Main/r1", &.{}); - fizzy.core.widgets.Split.close(column); + fizzy.core.widgets.Split.close(column, 0); try dvui.testing.settle(ColumnCardFrame.frame); try dvui.testing.settle(ColumnCardFrame.frame); try std.testing.expectEqual(@as(usize, 3), state.splits.leavesUnder(dvui.currentWindow().arena(), "Main").len); @@ -5702,6 +5709,233 @@ test "float: closing it loses no view its keywords would show nowhere" { try std.testing.expect(holds(panel, "test.tools")); } +/// `Layout.hide` / `Layout.reveal` between frames, on last frame's places, as a command calls them. +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()); + return layout.hide(id); +} +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()); + return layout.reveal(id); +} + +/// Whether last frame's place `name` is shut, as a command between frames reads it. +fn isShut(editor: *fizzy.Editor, name: []const u8) !bool { + const r = fizzy.Editor.Layout.ViewDrag.regionNamed(&editor.app.layout, name) orelse return error.TestUnexpectedResult; + return r.isClosed(); +} + +test "hide: a view in sight shuts its place, and reveal opens it again" { + var case = try ManyPanelCase.init(); + defer case.deinit(); + const editor = case.ctx.editor; + try std.testing.expect(!try isShut(editor, "Panel")); + + try std.testing.expect(hideView(editor, "test.output")); + try dvui.testing.settle(ManyPanelFrame.frame); + try std.testing.expect(try isShut(editor, "Panel")); + // Out of sight already: nothing to put away. + try std.testing.expect(!hideView(editor, "test.output")); + + try std.testing.expect(revealView(editor, "test.output")); + try dvui.testing.settle(ManyPanelFrame.frame); + try std.testing.expect(!try isShut(editor, "Panel")); + + // Nothing shows an unknown id, and Main is not a place that shuts. + try std.testing.expect(!hideView(editor, "nothing.here")); + try std.testing.expect(!hideView(editor, "test.main")); + try dvui.testing.settle(ManyPanelFrame.frame); + try std.testing.expect(holds(case.shows("Main"), "test.main")); +} + +test "hide: a view behind another in its place is not in sight, and its place stays open" { + var case = try ManyPanelCase.init(); + defer case.deinit(); + const editor = case.ctx.editor; + try editor.app.host.registerSurface(.{ .id = "test.problems", .title = "Problems", .keywords = fizzy.sdk.keywords.ide.panel, .draw = ManyPanelFrame.draw }); + try dvui.testing.settle(ManyPanelFrame.frame); + try std.testing.expect(revealView(editor, "test.problems")); + try dvui.testing.settle(ManyPanelFrame.frame); + + try std.testing.expect(!hideView(editor, "test.output")); + try dvui.testing.settle(ManyPanelFrame.frame); + try std.testing.expect(!try isShut(editor, "Panel")); + // The one showing goes, and takes the place with it. + try std.testing.expect(hideView(editor, "test.problems")); + try dvui.testing.settle(ManyPanelFrame.frame); + try std.testing.expect(try isShut(editor, "Panel")); +} + +test "hide: a floating view goes home and its tray shuts, and the toggle goes round" { + 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(hideView(editor, "test.output")); + try dvui.testing.settle(ManyPanelFrame.frame); + try dvui.testing.settle(ManyPanelFrame.frame); + try std.testing.expectEqual(@as(usize, 0), editor.app.layout.floats.items.items.len); + // Home again, as the float's own close sends it, and its place shut over it. + try std.testing.expect(holds(case.shows("Panel"), "test.output")); + try std.testing.expect(try isShut(editor, "Panel")); + + // Round the toggle: reveal opens it at home, hide shuts it, reveal opens it again. + try std.testing.expect(revealView(editor, "test.output")); + try dvui.testing.settle(ManyPanelFrame.frame); + try std.testing.expect(!try isShut(editor, "Panel")); + try std.testing.expect(hideView(editor, "test.output")); + try dvui.testing.settle(ManyPanelFrame.frame); + try std.testing.expect(try isShut(editor, "Panel")); + try std.testing.expect(revealView(editor, "test.output")); + try dvui.testing.settle(ManyPanelFrame.frame); + try std.testing.expect(!try isShut(editor, "Panel")); + try std.testing.expectEqual(@as(usize, 0), editor.app.layout.floats.items.items.len); +} + +/// A view that puts itself away from inside its own draw, once asked, as a button in it would. +const HidesItself = struct { + var asked: bool = false; + var answer: ?bool = null; + + fn draw(_: ?*anyopaque) anyerror!dvui.App.Result { + if (!asked) return .ok; + asked = false; + const layout = ManyPanelFrame.editor.?.app.frame_layout orelse return error.TestUnexpectedResult; + answer = layout.hideLater("test.hider"); + return .ok; + } +}; + +test "hide: asked from inside a floating view's draw, it waits for the frame's end" { + var case = try ManyPanelCase.init(); + defer case.deinit(); + const editor = case.ctx.editor; + try editor.app.host.registerSurface(.{ .id = "test.hider", .title = "Hider", .keywords = fizzy.sdk.keywords.ide.panel, .draw = HidesItself.draw }); + try dvui.testing.settle(ManyPanelFrame.frame); + try std.testing.expect(revealView(editor, "test.hider")); + try dvui.testing.settle(ManyPanelFrame.frame); + try case.place("Panel", "Panel", .swap); + try std.testing.expect(holds(case.shows("Float 1"), "test.hider")); + + HidesItself.asked = true; + HidesItself.answer = null; + _ = try dvui.testing.step(ManyPanelFrame.frame); + try std.testing.expectEqual(@as(?bool, true), HidesItself.answer); + try dvui.testing.settle(ManyPanelFrame.frame); + try std.testing.expectEqual(@as(usize, 0), openFloats(editor)); + try std.testing.expect(holds(case.shows("Panel"), "test.hider")); + try std.testing.expect(try isShut(editor, "Panel")); +} + +test "hide: one view of several in a float goes home alone, and the float keeps the rest" { + var case = try ManyPanelCase.init(); + defer case.deinit(); + const editor = case.ctx.editor; + try editor.app.host.registerSurface(.{ .id = "test.problems", .title = "Problems", .keywords = fizzy.sdk.keywords.ide.panel, .draw = ManyPanelFrame.draw }); + try dvui.testing.settle(ManyPanelFrame.frame); + try case.place("Panel", "Panel", .swap); + try editor.app.layout.assign(editor.app.gpa, "Float 1", &.{ "test.output", "test.problems" }); + try dvui.testing.settle(ManyPanelFrame.frame); + try std.testing.expect(revealView(editor, "test.output")); + try dvui.testing.settle(ManyPanelFrame.frame); + + try std.testing.expect(hideView(editor, "test.output")); + try dvui.testing.settle(ManyPanelFrame.frame); + try dvui.testing.settle(ManyPanelFrame.frame); + try std.testing.expectEqual(@as(usize, 1), openFloats(editor)); + const floated = case.shows("Float 1"); + try std.testing.expectEqual(@as(usize, 1), floated.len); + try std.testing.expectEqualStrings("test.problems", floated[0]); + try std.testing.expect(holds(case.shows("Panel"), "test.output")); + try std.testing.expect(try isShut(editor, "Panel")); +} + +test "hide: a place split off Main does not shut, and nothing changes" { + var case = try ManyPanelCase.init(); + defer case.deinit(); + const editor = case.ctx.editor; + 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(!hideView(editor, "test.output")); + try dvui.testing.settle(ManyPanelFrame.frame); + try std.testing.expect(holds(case.shows(half), "test.output")); + try std.testing.expect(!try isShut(editor, half)); +} + +test "hide: a place in a dockspace cell (`regionIn`) does not shut, and nothing changes" { + var case = try SplitCase.init(&.{"test.view"}, &.{"test.other"}); + defer case.deinit(); + const editor = case.ctx.editor; + try std.testing.expectEqualStrings("test.other", visibleIn(editor, case.half) orelse return error.TestExpectedEqual); + + try std.testing.expect(!hideView(editor, "test.other")); + try dvui.testing.settle(EndlessFrame.frame); + try std.testing.expectEqualStrings("test.other", visibleIn(editor, case.half) orelse return error.TestExpectedEqual); +} + +test "hide: a floating view nowhere in the main window would show stays in its float" { + var case = try SplitCase.init(&.{"test.view"}, &.{"test.other"}); + defer case.deinit(); + const editor = case.ctx.editor; + // Floated out of the half the user split off, which closes behind it: its home is gone, and + // Center, a place of one view already holding one, takes nothing else. + try case.place(case.half, case.half, .swap); + try std.testing.expectEqual(@as(usize, 1), openFloats(editor)); + try std.testing.expectEqualStrings("test.other", visibleIn(editor, "Float 1") orelse return error.TestExpectedEqual); + + try std.testing.expect(!hideView(editor, "test.other")); + try dvui.testing.settle(EndlessFrame.frame); + try std.testing.expectEqual(@as(usize, 1), openFloats(editor)); + try std.testing.expectEqualStrings("test.other", visibleIn(editor, "Float 1") orelse return error.TestExpectedEqual); + try std.testing.expect(revealView(editor, "test.other")); +} + +test "hide: a view in a place split off a tray shuts the tray, and reveal opens it" { + var case = try ManyPanelCase.init(); + defer case.deinit(); + const editor = case.ctx.editor; + try editor.app.host.registerSurface(.{ .id = "test.problems", .title = "Problems", .keywords = fizzy.sdk.keywords.ide.panel, .draw = ManyPanelFrame.draw }); + try dvui.testing.settle(ManyPanelFrame.frame); + const lifted = visibleIn(editor, "Panel") orelse return error.TestExpectedEqual; + try case.place("Panel", "Panel", .{ .split = .right }); + const half = case.placeNamed(&.{ "Main", "Panel" }) orelse return error.TestExpectedEqual; + try std.testing.expect(holds(case.shows(half), lifted)); + + try std.testing.expect(hideView(editor, lifted)); + try dvui.testing.settle(ManyPanelFrame.frame); + try std.testing.expect(try isShut(editor, "Panel")); + // Shut round it, it is out of sight: nothing more to put away. + try std.testing.expect(!hideView(editor, lifted)); + try std.testing.expect(revealView(editor, lifted)); + try dvui.testing.settle(ManyPanelFrame.frame); + try std.testing.expect(!try isShut(editor, "Panel")); +} + +test "hide: a tray its view floated out of opens again at the size the user left it" { + var case = try ManyPanelCase.init(); + defer case.deinit(); + const editor = case.ctx.editor; + const panel_id = (fizzy.Editor.Layout.ViewDrag.regionNamed(&editor.app.layout, "Panel") orelse return error.TestUnexpectedResult).id; + dvui.dataSet(null, panel_id, "_size", @as(f32, 300)); + try dvui.testing.settle(ManyPanelFrame.frame); + try std.testing.expectEqual(@as(f32, 300), editor.app.layout.extent("Panel", 0)); + + // Its only view floats out, so it draws nothing for a while; then the view is put away. + try case.place("Panel", "Panel", .swap); + for (0..4) |_| try dvui.testing.settle(ManyPanelFrame.frame); + try std.testing.expect(hideView(editor, "test.output")); + try dvui.testing.settle(ManyPanelFrame.frame); + try std.testing.expect(try isShut(editor, "Panel")); + + try std.testing.expect(revealView(editor, "test.output")); + try dvui.testing.settle(ManyPanelFrame.frame); + try std.testing.expectEqual(@as(?f32, 300), dvui.dataGet(null, panel_id, "_size", f32)); +} + test "float: the saved layout brings it back, and Reset Layout takes it away" { var case = try ManyPanelCase.init(); defer case.deinit(); @@ -6696,7 +6930,6 @@ test "a key bound with arguments runs its command with them, and is not its shor // `tick` reads which context the keys land in; the shim's editor has no explorer of its own. var explorer: fizzy.Editor.Explorer = undefined; @memset(std.mem.asBytes(&explorer), 0); - explorer.closed = true; editor.explorer = &explorer; const vtable = sdk.Plugin.VTable{}; @@ -8454,6 +8687,25 @@ test "headless: the whole editor comes up, opens a file, plays the tour and goes try std.testing.expectEqualStrings("fizzy.output", editor.app.host.selectionFor(sdk.keywords.ide.panel) orelse ""); try std.testing.expect(!editor.app.host.revealSurface("nothing.here")); try dvui.testing.settle(headlessFrame); + // And put away again (`Host.hideSurface`): the panel shuts, and a second ask has nothing + // in sight to put away. + try std.testing.expect(editor.app.host.hideSurface("fizzy.output")); + try std.testing.expect(panel.isClosed()); + try std.testing.expect(!editor.app.host.hideSurface("fizzy.output")); + try std.testing.expect(editor.app.host.revealSurface("fizzy.output")); + try dvui.testing.settle(headlessFrame); + } + + // The sidebar's view put away shuts the explorer, and Toggle Explorer, reading the same + // region, opens it again in one press. + { + const host = &editor.app.host; + const files = host.selectionFor(sdk.keywords.ide.sidebar) orelse return error.TestUnexpectedResult; + try std.testing.expect(host.hideSurface(files)); + try std.testing.expect(fizzy.Editor.Explorer.isShut(editor)); + try std.testing.expect(host.callCommand("fizzy.toggleExplorer", "", host.arena()) == .ok); + try std.testing.expect(fizzy.Editor.Explorer.inSight(editor)); + try dvui.testing.settle(headlessFrame); } // The dashboard (`devtools`): Show Profiler reveals it in the bottom panel, where it records From 7ea4a2ee21c897668fe1d564d2ffd8c16f5ad4e9 Mon Sep 17 00:00:00 2001 From: foxnne Date: Sun, 18 Oct 2026 15:33:31 -0500 Subject: [PATCH 2/2] sdk: hide and reveal keep their order mid-frame; only trays shut unsized Sixth review of hideSurface. Mid-frame, revealSurface queues with hideSurface and both apply in the order asked; hideLater answers as hide would, a float view landing in sight where nothing shuts included. Split.close shuts an unsized region only when given a fallback (trays), and nothing is retained past a region's life: a declared but undrawn tray reads its size keys each frame. forget_when_empty places are no trays, initTree shares init's default extent, a view in a closing float stays revealable, and the explorer's menu title and rail tap use inSight like Toggle Explorer. Part of #530 Co-Authored-By: Claude Opus 5.5 --- app/layout/Layout.zig | 103 ++++++++++++++++++++++--------- app/layout/Region.zig | 26 +++++--- app/layout/ViewDrag.zig | 8 +++ core/widgets/Split.zig | 39 +++++------- docs/PLUGINS.md | 3 +- src/editor/Editor.zig | 14 +++-- src/editor/Sidebar.zig | 12 ++-- src/editor/explorer/Explorer.zig | 7 ++- src/editor/menu_model.zig | 3 +- tests/integration.zig | 29 +++++++-- 10 files changed, 159 insertions(+), 85 deletions(-) diff --git a/app/layout/Layout.zig b/app/layout/Layout.zig index e8f8d08c2..abb6ab4a5 100644 --- a/app/layout/Layout.zig +++ b/app/layout/Layout.zig @@ -158,9 +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 put away mid-frame (`hideLater`), for `applyLater` once nothing walks the places. -/// Arena-backed, per frame. -later_hides: std.ArrayListUnmanaged([]const u8) = .empty, +/// Views put away or shown mid-frame (`hideLater`, `revealLater`), in the order asked, for +/// `applyLater` once nothing walks the places. Arena-backed, per frame. +later: std.ArrayListUnmanaged(Later) = .empty, /// Open regions, innermost last. Every region pushes; its `deinit` pops. This is what lets /// `split` know which axis it divides and which neighbour it resizes, without a shape having to @@ -620,24 +620,62 @@ pub fn hide(self: *Layout, id: []const u8) bool { } /// `hide`, asked mid-frame (a button in a surface): kept for `applyLater`, which the application -/// calls once the shape and the floats have drawn. True when it will put the view away: it is in -/// sight in a tray, or in a float it will leave. Whether a view leaving a float lands in sight is -/// known only once it lands, as `hide` says. +/// calls once the shape and the floats have drawn, in the order asked with `revealLater`'s. The +/// answer is the one `hide` would give: it is in sight in a tray, or in a float it will leave +/// for a place that does not leave it in sight. pub fn hideLater(self: *Layout, id: []const u8) bool { const s = self.surface(id) orelse return false; - _ = self.away(s) orelse return false; - const kept = self.arena.dupe(u8, id) catch return false; - self.later_hides.append(self.arena, kept) catch return false; + const will = switch (self.away(s) orelse return false) { + .shut => true, + .float => |float| self.floatHomeHides(float, s), + }; + self.keepLater(id, .hide) catch return false; + return will; +} + +/// `reveal`, asked mid-frame: kept for `applyLater` with the hides, so a hide and then a reveal +/// in one frame end with the view shown. The answer is the one `reveal` would give. +pub fn revealLater(self: *Layout, id: []const u8) bool { + const s = self.surface(id) orelse return false; + if (s.hidden) return false; + _ = self.locate(s, .any) orelse return false; + self.keepLater(id, .reveal) catch return false; return true; } -/// Apply what `hideLater` kept. The application calls it after the shape, the floats, the drag -/// overlay and the picker have drawn, before it reads `extents_changed`. +fn keepLater(self: *Layout, id: []const u8, act: Later.Act) !void { + try self.later.append(self.arena, .{ .id = try self.arena.dupe(u8, id), .act = act }); +} + +/// Apply what `hideLater` and `revealLater` kept, in the order they were asked. The application +/// calls it after the shape, the floats, the drag overlay and the picker have drawn, before it +/// reads `extents_changed`. pub fn applyLater(self: *Layout) void { - for (self.later_hides.items) |id| _ = self.hide(id); - self.later_hides.clearRetainingCapacity(); + for (self.later.items) |l| switch (l.act) { + .hide => _ = self.hide(l.id), + .reveal => _ = self.reveal(l.id), + }; + self.later.clearRetainingCapacity(); } +/// Whether taking `s` out of float `float` leaves it out of sight, as `hide` finds once it has: +/// where it will land (`ViewDrag.floatHomeFor`) is a tray, which shuts, or a place shut or folded. +/// Landing where no place names it (its keywords bring it back), it is not selected anywhere. +fn floatHomeHides(self: *Layout, float: []const u8, s: *const Surface) bool { + const name = ViewDrag.floatHomeFor(self, float, s.id) orelse return true; + const r = ViewDrag.regionNamed(self.state, name) orelse return true; + if (self.trayOf(r) != null) return true; + return r.isClosed() or r.isFolded(); +} + +/// A hide or reveal asked mid-frame. +const Later = struct { + id: []const u8, + act: Act, + + const Act = enum { hide, reveal }; +}; + /// What putting `s` away does, worked out before anything changes; null when it is not in sight /// or nothing round it shuts. const Away = union(enum) { @@ -649,7 +687,7 @@ const Away = union(enum) { fn away(self: *Layout, s: *const Surface) ?Away { if (s.hidden) return null; - const at = self.locate(s, .any) orelse return null; + const at = self.locate(s, .open) orelse return null; if (!self.inSight(at, s)) return null; if (self.state.floats.rootOf(at.place.name)) |float| { if (!ViewDrag.canFloatViewHome(self, float, s.id)) return null; @@ -662,24 +700,33 @@ fn away(self: *Layout, s: *const Surface) ?Away { const Located = struct { place: *const Region, tray: ?*const Region }; /// The one place showing `s` (the rule `claimedElsewhere` states: its assignment, else the place -/// that accepts it best), among this frame's places so far and last frame's rest, with its tray: -/// the place itself, or the one a split of it grew from (`SplitTree.Forest.rootOf`). -/// `main_window` leaves out the floats' places. -fn locate(self: *Layout, s: *const Surface, among: enum { any, main_window }) ?Located { +/// that accepts it best), among this frame's places so far and last frame's rest, with its tray. +/// `main_window` leaves out the floats' places, `open` only the places of a float flying shut +/// (what it holds is going home: there is nothing there to put away, though `reveal` still finds +/// a view not home yet). +fn locate(self: *Layout, s: *const Surface, among: enum { any, open, main_window }) ?Located { var it = self.declaredRegions(); const place = find: while (it.next()) |r| { - if (self.state.floats.rootOf(r.name)) |float| { - if (among == .main_window) continue; - // Flying shut: what it held has gone home already. - const i = self.state.floats.find(float) orelse continue; - if (self.state.floats.items.items[i].closing) continue; - } + if (self.state.floats.rootOf(r.name)) |float| switch (among) { + .any => {}, + .main_window => continue, + .open => { + const i = self.state.floats.find(float) orelse continue; + if (self.state.floats.items.items[i].closing) continue; + }, + }; for (self.matchingIn(r)) |m| if (m == s) break :find r; } else return null; - if (place.tray) return .{ .place = place, .tray = place }; - const root = self.state.splits.rootOf(place.name) orelse return .{ .place = place, .tray = null }; - const tray = ViewDrag.regionNamed(self.state, root) orelse return .{ .place = place, .tray = null }; - return .{ .place = place, .tray = if (tray.tray) tray else null }; + return .{ .place = place, .tray = self.trayOf(place) }; +} + +/// The tray `place` is, or the one a split of it grew from (`SplitTree.Forest.rootOf`); null in +/// none. +fn trayOf(self: *Layout, place: *const Region) ?*const Region { + if (place.tray) return place; + const root = self.state.splits.rootOf(place.name) orelse return null; + const tray = ViewDrag.regionNamed(self.state, root) orelse return null; + return if (tray.tray) tray else null; } /// Whether `s` is what the place `at` shows now: its selection, with neither the place nor its diff --git a/app/layout/Region.zig b/app/layout/Region.zig index 3b55671f6..9f1b2c537 100644 --- a/app/layout/Region.zig +++ b/app/layout/Region.zig @@ -150,7 +150,8 @@ pub fn close(self: Region) void { // Deliberately before `Split.close`: shutting a region is also the user withdrawing the peek // that kept it open on a narrow window (see `collapseTarget`). dvui.dataSet(null, self.id, "_peek", false); - Split.close(self.id, self.openTo()); + // Only a tray is shut when it keeps no size; anything else is left as it was. + Split.close(self.id, if (self.tray) self.openTo() else null); } /// `reopen_extent`, or `default_extent` for a region that has not registered (the shape's own @@ -422,6 +423,13 @@ pub fn init(self: *Layout, src: std.builtin.SourceLocation, init_opts: InitOptio if (init_opts.hide_when_empty and keywords.len > 0 and matches.len == 0 and !cleared) { // A boundary with nothing on one side is not a boundary. if (parent) |p| p.pending_split = null; + // Not drawn, but still declared: what it is sized and shut at is read, so dvui keeps it + // for as long as the place is here, and it comes back as it was rather than seeded open. + if (init_opts.resize) { + _ = dvui.dataGet(null, id, "_size", f32); + _ = dvui.dataGet(null, id, "_open", f32); + _ = dvui.dataGet(null, id, "_shown", f32); + } // Still a place, even with nothing in it: it stays in the registry so the picker and // the settings table can put something back. Emptied through the picker and then // gone from the picker was a region the user could not get back. @@ -445,7 +453,7 @@ pub fn init(self: *Layout, src: std.builtin.SourceLocation, init_opts: InitOptio const tree_origin = init_opts.omit_edge != null and self.state.splits.root(init_opts.name) != null; if (!tree_origin and init_opts.name.len > 0) { if (self.state.splits.root(init_opts.name)) |node| { - if (node.kind == .branch) return try initTree(self, src, init_opts, opts, node); + if (node.kind == .branch) return try initTree(self, src, init_opts, opts, node, default_extent); } } // The split declared before this region divides it from the one before: drawn now, with @@ -1140,6 +1148,8 @@ fn initTree( init_opts: InitOptions, opts: dvui.Options, node: *SplitTree.Node, + /// `init`'s: what this tray opens to, padding and border included. + default_extent: f32, ) anyerror!Region { const parent: ?*Layout.Container = if (self.depth == 0) null else &self.containers[self.depth - 1]; const axis: dvui.enums.Direction = if (parent) |p| p.dir else .horizontal; @@ -1150,17 +1160,12 @@ fn initTree( const branch = node.kind.branch; var box_opts = opts; - var default_extent: f32 = 0; // Forgotten this frame, the tree `branch` points into may be freed: its children wait for // the next frame (`persistExtent` asked for it). var dropped = false; if (init_opts.resize) { const given = opts.min_size_content orelse dvui.Size{}; - const default: f32 = switch (axis) { - .horizontal => given.w, - .vertical => given.h, - }; - default_extent = default; + const default = default_extent; if (dvui.dataGet(null, id, "_size", f32) == null) { dvui.dataSet(null, id, "_size", self.state.extent(init_opts.name, default)); } @@ -1498,8 +1503,9 @@ fn placePrefix(keywords: []const []const u8, parent_prefix: []const u8) []const /// one place that decides it. fn register(self: *Layout, init_opts: InitOptions, entry: Region) void { var r = entry; - // A place a split made is `resize` too, but goes by being emptied or joined, never shut. - r.tray = init_opts.resize and !self.state.isMinted(init_opts.name); + // A place a split made is `resize` too, but goes by being emptied or joined, never shut; one + // that forgets itself empty (`forget_when_empty`) could not be opened again once forgotten. + r.tray = init_opts.resize and !init_opts.forget_when_empty and !self.state.isMinted(init_opts.name); self.state.registerRegion(self.gpa, r); } diff --git a/app/layout/ViewDrag.zig b/app/layout/ViewDrag.zig index 7ee162c0c..e1f9c499a 100644 --- a/app/layout/ViewDrag.zig +++ b/app/layout/ViewDrag.zig @@ -2666,6 +2666,14 @@ pub fn canFloatViewHome(l: *Layout, float: []const u8, id: []const u8) bool { return homeLanding(l, id, f.home) != null; } +/// The place view `id` would land in, taken out of float `float` now (`homeLanding`): null when no +/// place is named for it (its keywords bring it back) or the float is gone. +pub fn floatHomeFor(l: *Layout, float: []const u8, id: []const u8) ?[]const u8 { + const i = l.state.floats.find(float) orelse return null; + const to = homeLanding(l, id, l.state.floats.items.items[i].home) orelse return null; + return to.place; +} + /// One view out of float `float` and home, as closing the float would send it (`sendHome`): the /// float's other views stay where the user arranged them, and a place of it left empty closes as /// any emptied place does (`withdraw`), the float itself when it was its last view. False, with diff --git a/core/widgets/Split.zig b/core/widgets/Split.zig index d930dcc3b..2c5d49e02 100644 --- a/core/widgets/Split.zig +++ b/core/widgets/Split.zig @@ -223,31 +223,20 @@ pub fn isClosed(id: dvui.Id) bool { return sizeOf(id) <= 0; } -/// Shut it, remembering how big it was so `open` can put it back. Something that keeps no size -/// at all (nothing drew it lately: a tray left empty and folded away) is shut as though it had -/// been open at `fallback`, which `open` then brings it back to, with nothing to slide: it was -/// not on screen. Without that it was left as it was, and seeded open the next time it drew. -/// -/// The remembered extent is *retained*: dvui frees a value nothing touched during a frame, and -/// while the region is shut nothing does — the layout reads `_size` and `_shown` every frame but -/// has no reason to read `_open`. Without the retain the memory of how wide the explorer was -/// evaporated a frame after it closed, and reopening fell back to `default_extent`, which is why -/// it always came back the same width. -/// Seeds `_shown` from the current extent so `eased` has somewhere to travel -/// from — the same trick `takeSlideOpen` uses the other way. Without that, a -/// missing or leftover-zero `_shown` makes the next frame treat target 0 as -/// already there, and the pane vanishes instead of folding. -pub fn close(id: dvui.Id, fallback: f32) void { +/// Shut it, remembering how big it was so `open` can put it back. With a `fallback`, something +/// that keeps no size at all (nothing drew it lately: a tray left empty and folded away) is shut +/// as though it had been open at `fallback`, which `open` then brings it back to, with nothing to +/// slide: it was not on screen. Without one (a region that is not a tray), it is left as it was. +/// Nothing here outlives its owner: the region reads these keys every frame it is declared. +pub fn close(id: dvui.Id, fallback: ?f32) void { if (dvui.dataGet(null, id, "_size", f32) == null) { - if (fallback > 0) { - dvui.dataSet(null, id, "_open", fallback); + const to = fallback orelse return; + if (to > 0) { + dvui.dataSet(null, id, "_open", to); dvui.dataRetain(null, id, "_open", .zero); } dvui.dataSet(null, id, "_shown", @as(f32, 0)); dvui.dataSet(null, id, "_size", @as(f32, 0)); - // Kept like `_open`: nothing may draw it for a while, and dvui frees what no frame - // touched, after which it would be seeded open again. - dvui.dataRetain(null, id, "_size", .zero); dvui.refresh(null, @src(), id); return; } @@ -1100,18 +1089,20 @@ test "reopening a shut region restores the extent it was shut at" { try testing.expectApproxEqAbs(@as(f32, 220), t_size, 1.0); } -test "closing something that keeps no size shuts it at the fallback, to open back to" { +test "closing something that keeps no size shuts it at the fallback, to open back to, or not at all without one" { // A tray left empty and folded away drew nothing, so it keeps no `_size`: shut, it must stay // shut when it next draws, and open to the size it would have had. var t = try dvui.testing.init(.{ .window_size = .{ .w = 400, .h = 300 } }); defer t.deinit(); const id = dvui.Id.extendId(null, @src(), 0); close(id, 180); - // Frames pass with nothing drawing it, and it is still shut, not forgotten. - for (0..3) |_| _ = try dvui.testing.step(twoPaneFrame); - try testing.expectEqual(@as(?f32, 0), dvui.dataGet(null, id, "_size", f32)); + try testing.expect(isClosed(id)); open(id, 1); try testing.expectEqual(@as(f32, 180), sizeOf(id)); + // Without a fallback (not a tray), something that keeps no size is left as it was. + const other = dvui.Id.extendId(null, @src(), 1); + close(other, null); + try testing.expectEqual(@as(?f32, null), dvui.dataGet(null, other, "_size", f32)); } test "opening something already open leaves the width the user dragged it to" { diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 7cb8b5d88..99798eee7 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -588,7 +588,8 @@ neither the place nor the tray round it is shut, or folded away by a narrow wind - A view not in sight (another view is its place's selection, its tray is shut or folded) is left alone, and it returns false. - Called while a frame draws (a button in your surface), it happens at the frame's end, once - nothing is drawing the places it changes; the answer is then whether it will happen. + nothing is drawing the places it changes, and `revealSurface` waits with it, so the two keep + the order they were called in; each answers then as it would between frames. So a toggle is diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index f8a7895c5..d4943b848 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -5105,7 +5105,11 @@ fn fizzySelectInRegion(ctx: *anyopaque, region: []const u8, id: []const u8) void } fn fizzyRevealSurface(ctx: *anyopaque, id: []const u8) bool { - return onLayout(fizzyCtx(ctx), Layout.reveal, id); + const editor = fizzyCtx(ctx); + // Mid-frame, after the hides asked this frame (`Layout.revealLater`): a hide then a reveal + // ends shown. + if (editor.app.frame_layout) |layout| return layout.revealLater(id); + return onLayout(editor, Layout.reveal, id); } fn fizzyHideSurface(ctx: *anyopaque, id: []const u8) bool { @@ -5116,11 +5120,11 @@ fn fizzyHideSurface(ctx: *anyopaque, id: []const u8) bool { return onLayout(editor, Layout.hide, id); } -/// `act` on the shape drawing now, or, for a command between shapes (a keybind, the palette, an -/// agent), on last frame's regions, published for exactly this (`Layout.State.regions`), with -/// what it changed of the extents saved as the frame's own would be. +/// `act` between shapes (a keybind, the palette, an agent), on last frame's regions, published +/// for exactly this (`Layout.State.regions`), with what it changed of the extents saved as the +/// frame's own would be. Mid-frame the two calls queue instead (`Layout.hideLater`, +/// `revealLater`). fn onLayout(editor: *Editor, comptime act: fn (*Layout, []const u8) bool, id: []const u8) bool { - if (editor.app.frame_layout) |layout| return act(layout, id); var layout: Layout = .init(&editor.app.host, &editor.app.layout, editor.app.gpa, editor.app.arena.allocator()); defer if (layout.extents_changed) editor.markWindowRatiosDirty(); return act(&layout, id); diff --git a/src/editor/Sidebar.zig b/src/editor/Sidebar.zig index f8a8cb93b..dd75e1058 100644 --- a/src/editor/Sidebar.zig +++ b/src/editor/Sidebar.zig @@ -170,14 +170,10 @@ const rail_icon: f32 = 20; fn pickAction(editor: *Editor, c: *const Chooser, so_far: Action) Action { if (c.picked() != null) return .open; if (c.reselected() == null) return so_far; - // Folded counts as out of sight here: on a narrow window the explorer is folded away by the - // layout without anyone having closed it, and a tap there means "show me", not "hide it - // again". - const explorer_visible = if (editor.regionFor(fizzy.sdk.keywords.ide.sidebar)) |r| - !r.isClosed() and !r.isFolded() - else - false; - return if (explorer_visible) .close else .open; + // Folded counts as out of sight (`Explorer.inSight`): on a narrow window the explorer is + // folded away by the layout without anyone having closed it, and a tap there means "show + // me", not "hide it again". + return if (Editor.Explorer.inSight(editor)) .close else .open; } /// One rail icon: the chooser's item, drawn as the view's glyph with the store's attention badge. diff --git a/src/editor/explorer/Explorer.zig b/src/editor/explorer/Explorer.zig index c973c7567..7654bb9e7 100644 --- a/src/editor/explorer/Explorer.zig +++ b/src/editor/explorer/Explorer.zig @@ -32,8 +32,8 @@ collapse_btn_anim_started: bool = false, /// Whether the user (or a plugin, `Host.hideSurface`) shut the explorer: its sidebar region's own /// state, which the rail, Toggle Explorer and `hideSurface` all write. Not folded away by a narrow -/// window, which is the window's doing, not anyone's choice: what the menu title, the keybind -/// context and a demo's snapshot read, none of which may change with the window's width. No +/// window, which is the window's doing, not anyone's choice: what the keybind context and a +/// demo's snapshot read, neither of which may change with the window's width. No /// sidebar declared (before the first frame, a shape without one): not shut. pub fn isShut(editor: *fizzy.Editor) bool { const r = editor.regionFor(fizzy.sdk.keywords.ide.sidebar) orelse return false; @@ -41,7 +41,8 @@ pub fn isShut(editor: *fizzy.Editor) bool { } /// Whether the explorer is on screen: its sidebar region neither shut nor folded away. What -/// Toggle Explorer decides by, so a folded explorer opens in one press. +/// Toggle Explorer, its menu title and a rail tap decide by, so a folded explorer opens in one +/// press and the title says so. pub fn inSight(editor: *fizzy.Editor) bool { const r = editor.regionFor(fizzy.sdk.keywords.ide.sidebar) orelse return false; return !r.isClosed() and !r.isFolded(); diff --git a/src/editor/menu_model.zig b/src/editor/menu_model.zig index 74768d5a5..5539c6d33 100644 --- a/src/editor/menu_model.zig +++ b/src/editor/menu_model.zig @@ -162,7 +162,8 @@ fn pasteEnabled(editor: *Editor) bool { } fn explorerTitle(editor: *Editor) [:0]const u8 { - return if (Editor.Explorer.isShut(editor)) "Show Explorer" else "Hide Explorer"; + // What Toggle Explorer will do: show one shut or folded away. + return if (Editor.Explorer.inSight(editor)) "Hide Explorer" else "Show Explorer"; } /// TEMPORARY: only meaningful on macOS, where the in-app dvui menu bar is normally suppressed diff --git a/tests/integration.zig b/tests/integration.zig index cf77448ab..b5496287c 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -5811,6 +5811,30 @@ const HidesItself = struct { } }; +test "hide: a hide and then a reveal asked in one frame end with the view shown" { + var case = try ManyPanelCase.init(); + defer case.deinit(); + const editor = case.ctx.editor; + var layout = fizzy.Editor.Layout.init(&editor.app.host, &editor.app.layout, editor.app.gpa, dvui.currentWindow().arena()); + try std.testing.expect(layout.hideLater("test.output")); + try std.testing.expect(layout.revealLater("test.output")); + try std.testing.expect(!try isShut(editor, "Panel")); + layout.applyLater(); + try dvui.testing.settle(ManyPanelFrame.frame); + try std.testing.expect(!try isShut(editor, "Panel")); +} + +test "hide: a floating view that lands in sight where nothing shuts answers false, asked now or later" { + var case = try ManyPanelCase.init(); + defer case.deinit(); + const editor = case.ctx.editor; + try case.place("Main", "Main", .swap); + try std.testing.expect(holds(case.shows("Float 1"), "test.main")); + var layout = fizzy.Editor.Layout.init(&editor.app.host, &editor.app.layout, editor.app.gpa, dvui.currentWindow().arena()); + try std.testing.expect(!layout.hideLater("test.main")); + try std.testing.expect(!hideView(editor, "test.main")); +} + test "hide: asked from inside a floating view's draw, it waits for the frame's end" { var case = try ManyPanelCase.init(); defer case.deinit(); @@ -6932,11 +6956,6 @@ test "a key bound with arguments runs its command with them, and is not its shor const gpa = editor.app.host.allocator; defer editor.app.keymap.deinit(gpa); PaletteArgs.got = null; - // `tick` reads which context the keys land in; the shim's editor has no explorer of its own. - var explorer: fizzy.Editor.Explorer = undefined; - @memset(std.mem.asBytes(&explorer), 0); - editor.explorer = &explorer; - const vtable = sdk.Plugin.VTable{}; var state: u8 = 0; var plugin = sdk.Plugin{ .state = &state, .vtable = &vtable, .id = "t", .display_name = "T" };