diff --git a/app/layout/Layout.zig b/app/layout/Layout.zig index 63319f616..abb6ab4a5 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 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 @@ -578,23 +581,161 @@ 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; + 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 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, 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; + 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; +} + +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.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) { + /// 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, .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; + 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. +/// `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(); - 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 place = find: while (it.next()) |r| { + 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; + }, }; - } - return false; + for (self.matchingIn(r)) |m| if (m == s) break :find r; + } else return 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 +/// 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 c159d52c0..6d89b2d8b 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 673762ef0..9f1b2c537 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,20 @@ 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); + // 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 +/// 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 +395,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, @@ -400,14 +423,23 @@ 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. - 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, @@ -421,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 @@ -436,16 +468,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 +574,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, @@ -1123,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; @@ -1133,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)); } @@ -1193,7 +1215,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, @@ -1477,6 +1499,16 @@ 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; 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); +} + /// 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 { @@ -1490,6 +1522,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..e1f9c499a 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,84 @@ 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; +} + +/// 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 +/// 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 +3014,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 9d2a92fb6..2c5d49e02 100644 --- a/core/widgets/Split.zig +++ b/core/widgets/Split.zig @@ -223,18 +223,23 @@ pub fn isClosed(id: dvui.Id) bool { return sizeOf(id) <= 0; } -/// Shut it, remembering how big it was so `open` can put it back. -/// -/// 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) 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) { + 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)); + dvui.refresh(null, @src(), id); + return; + } const cur = sizeOf(id); if (cur <= 0) return; dvui.dataSet(null, id, "_open", cur); @@ -1073,7 +1078,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); @@ -1084,6 +1089,22 @@ 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, 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); + 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" { // 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 @@ -1096,7 +1117,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 c6f79fc63..ceb040591 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -568,6 +568,39 @@ 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, 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 + +```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 4a2db8364..3ea72ff2d 100644 --- a/sdk/src/EditorAPI.zig +++ b/sdk/src/EditorAPI.zig @@ -331,6 +331,9 @@ pub const VTable = struct { /// Bring the surface `id` into sight wherever it lives: selected in the region showing it, /// that region opened if it was shut. False when no region shows it. revealSurface: *const fn (ctx: *anyopaque, id: []const u8) bool, + /// Put the surface `id` 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. /// @@ -656,6 +659,10 @@ pub fn revealSurface(self: EditorAPI, id: []const u8) bool { return self.vtable.revealSurface(self.ctx, id); } +pub fn hideSurface(self: EditorAPI, id: []const u8) bool { + return self.vtable.hideSurface(self.ctx, id); +} + pub fn drawFileKindGlyph(self: EditorAPI, kind: []const u8, color: dvui.Color) bool { return self.vtable.drawFileKindGlyph(self.ctx, kind, color); } diff --git a/sdk/src/Host.zig b/sdk/src/Host.zig index 805813dc8..f8258fcfe 100644 --- a/sdk/src/Host.zig +++ b/sdk/src/Host.zig @@ -681,6 +681,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 222378de1..5c74dc1f2 100644 --- a/sdk/src/version.zig +++ b/sdk/src/version.zig @@ -75,7 +75,7 @@ pub const sdk_version = @import("sdk_version").sdk_version; /// why it is a single target/mode-invariant literal rather than a per-target table. Update this /// value from the `@compileError` it triggers whenever it changes; leave `sdk_version` to the /// next SDK release. -pub const recorded_sdk_shape_fingerprint: u64 = 0xba4664870675e13d; +pub const recorded_sdk_shape_fingerprint: u64 = 0x5cf2c503df2d415d; comptime { if (dylib.sdk_shape_fingerprint != recorded_sdk_shape_fingerprint) { diff --git a/src/editor/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 3b1a3dde4..368a3c729 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -2446,6 +2446,7 @@ const fizzy_api_vtable: sdk.EditorAPI.VTable = .{ .assignedRegionNames = fizzyAssignedRegionNames, .selectInRegion = fizzySelectInRegion, .revealSurface = fizzyRevealSurface, + .hideSurface = fizzyHideSurface, .drawFileKindGlyph = fizzyDrawFileKindGlyph, .closeDocById = fizzyCloseDocById, .setProjectFolder = fizzySetProjectFolder, @@ -4592,9 +4593,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 @@ -4602,6 +4600,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 @@ -5156,11 +5160,28 @@ fn fizzySelectInRegion(ctx: *anyopaque, region: []const u8, id: []const u8) void fn fizzyRevealSurface(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, 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 { + const editor = fizzyCtx(ctx); + // 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` 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 { 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 bf71337bc..c54bc8381 100644 --- a/src/editor/Keybinds.zig +++ b/src/editor/Keybinds.zig @@ -592,8 +592,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); @@ -1382,7 +1383,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..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; - // 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". - const explorer_visible = if (editor.regionFor(fizzy.sdk.keywords.ide.sidebar)) |r| - !r.isClosed() and !r.isFolded() - else - !editor.explorer.closed; - 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 9bbe3f280..7654bb9e7 100644 --- a/src/editor/explorer/Explorer.zig +++ b/src/editor/explorer/Explorer.zig @@ -28,16 +28,31 @@ 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 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; + return r.isClosed(); +} + +/// Whether the explorer is on screen: its sidebar region neither shut nor folded away. What +/// 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(); +} + +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 +60,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 e4de3c69d..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.closed) "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 dc36c1b40..30f60e9a2 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); @@ -5753,6 +5760,257 @@ 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: 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(); + 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(); @@ -6746,12 +7004,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); - explorer.closed = true; - 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" }; @@ -8918,6 +9170,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