diff --git a/app/layout/Floats.zig b/app/layout/Floats.zig index a1a2c439..08b2caec 100644 --- a/app/layout/Floats.zig +++ b/app/layout/Floats.zig @@ -30,6 +30,10 @@ pub const Landing = struct { photo: ?dvui.Texture = null, /// The size it was taken at, physical, for its proportions. photo_size: dvui.Size.Physical = .{}, + /// Where the whole of it lay in the drop it was carried in, physical, where the app showed it + /// on the drag's glass (`ViewDrag.photo_last`): it grows from there to where the view lies in + /// the window (`Popout.growFrame`). Null where it was not shown so. + photo_from: ?dvui.Rect.Physical = null, }; pub const Float = struct { @@ -119,6 +123,9 @@ pub const Viewport = struct { /// window's frame, this frame (`Popout`) — for a drag to read the window where it lies over the /// main window's places (`ViewDrag.mapOccluders`). main_delta: dvui.Point.Physical = .{}, + /// Its window lies under the main window in the OS's stacking (`Popout`): a float a window like + /// any other, clicked behind the main one. Over the main window it covers nothing there. + under_main: bool = false, }; diff --git a/app/layout/SPLITS.md b/app/layout/SPLITS.md index 14bcab38..50f306eb 100644 --- a/app/layout/SPLITS.md +++ b/app/layout/SPLITS.md @@ -192,10 +192,14 @@ by again, the drop zones, splits, the picker. A place of several views keeps the - **Moving and stacking.** A float moves by its header and resizes from its edges, no smaller than 160×96. A press anywhere in it brings it to the front, as on an OS window. - **Back again.** Dragged out by its corner button, a view lands like any other, and a float its - last view leaves closes behind it. Closed from its header (or the picker's Remove), a float - sends each view home — into the list of the place it floated out of when the user had arranged - that place, otherwise let go for its keywords to place, which never freezes a place its keywords - fill. Every place a split of it made goes with it. + last view leaves closes behind it. Closed from its header, its window's close button (or the + picker's Remove), a float moves everything it holds back into the main window and loses + nothing (`float_rules.goHome`): each view into the list of the place it floated out of when the + user had arranged that place; let go where its keywords show it, which never freezes a place + its keywords fill; and a view they would show nowhere — one merged in from elsewhere, a + plugin's tools beside Files — into that place's list all the same, as a drop there writes it, + or the first place of the main window's that shows several where that place is gone or holds + something else. Every place a split of it made goes with it. - **Remembered.** `layout.zon` keeps each float's window, its place in the stack and its home. One with nothing left to show is not brought back, and one saved on a bigger window comes back onto the window it opens in. A float remembers only where the user put it: a window that shrinks shows diff --git a/app/layout/ViewDrag.zig b/app/layout/ViewDrag.zig index a6a509ad..5eee7e8f 100644 --- a/app/layout/ViewDrag.zig +++ b/app/layout/ViewDrag.zig @@ -53,6 +53,22 @@ from: dvui.Size.Physical = .{}, texture: ?dvui.Texture = null, /// Where it was taken. texture_rect: dvui.Rect.Physical = .{}, +/// Where in it what the view shows is, as shares of its width and height (`contentBox`): the drop +/// fits its picture to it (`drawDrop`). +photo_box: dvui.Rect = .{ .x = 0, .y = 0, .w = 1, .h = 1 }, +/// Its pixels, premultiplied RGBA rows of `photo_size`, kept where the app shows the picture under +/// the OS's glass (`core.dialogs.carry_windows`) — handed over once, as an image (`photo_gen` new +/// with each picture) — and freed with the drag (`discard`). Null elsewhere. +photo_pixels: ?[]dvui.Color.PMA = null, +photo_size: [2]u32 = .{ 0, 0 }, +photo_gen: u32 = 0, +/// The drop's picture this frame, handed to the app to draw beneath the OS's glass rather than over +/// it (`core.native_glass.under`): null when it is drawn here, or not at all. +photo_under: ?PhotoUnder = null, +/// Where the whole of the drop's picture lay the last frame it was carried as a drop handed over +/// (`PhotoUnder.image`), physical; null once it is carried as anything else. A float it is let go +/// as grows its picture out of there (`Floats.Landing.photo_from`). +photo_last: ?dvui.Rect.Physical = null, start_ns: i128 = 0, /// Surface lifted from the source: what the card under the pointer shows, and what lands. moved_id: []const u8 = "", @@ -61,6 +77,9 @@ mode: Mode = .preview, morph_from_mode: Mode = .preview, /// Not carried as anything yet: what it is first carried as is what was grabbed (`noteMode`). lifting: bool = false, +/// Carried as a drop at some point in this drag: the point it was grabbed by means nothing since, +/// and a card or a tab after it hangs from the pointer's corner (`drawFloat`). +been_drop: bool = false, /// The carried shape as last drawn, whatever it was drawn as: its rect and corner radius, /// physical. A change of what the view is carried as grows the new shape out of this one — /// position, size and corners — so the lift from what was grabbed, the tab over a strip and the @@ -71,21 +90,43 @@ shape_radius: f32 = 0, morph_rect: dvui.Rect.Physical = .{}, morph_radius: f32 = 0, card_start_ns: i128 = 0, -/// The view carried as a drop of glass (`dropShapes`), where the glass program draws: its head -/// following the pointer and its tail the head, each on a spring, so it stretches as it is -/// dragged and swings when it stops. This frame's shapes, head then tail. -drop_head: core.Spring = .{}, -drop_tail: core.Spring = .{}, +/// A loose drag's hold on what it lifted: the middle of what was grabbed, from the pointer, when it +/// was lifted (`beginLoose`). The card keeps it while it is under the pointer — the face of a row +/// lifted out of a list stays where it was in the row — and is brought in under the pointer where +/// it is not (`drawFloat`). dvui's own drag offset is the list's, from a press the lift may have +/// come long after, and of a rect the card is not. +grab: dvui.Point.Physical = .{}, +/// Where the hand holds the drop (`holdHand`): the pointer, kept still through a tremble of it. +/// What the drop rides off and aims with, while it is carried as one; null when it is not. +hand: ?dvui.Point.Physical = null, +/// The middle of the bubble the drop is drawn toward (`drop_pull`) — moving onto a bubble newly +/// aimed at from `aim_from` to `aim_to`, `aim_move` of the way there (`pour`), by time, not by the +/// hand; null once it is joined to none. +aim_at: ?dvui.Point.Physical = null, +aim_from: dvui.Point.Physical = .{}, +aim_to: ?dvui.Point.Physical = null, +aim_move: f32 = 1, +/// How far the drop is drawn into the bubble it is aimed at, 0…1 and past 1 as it pours in +/// (`drop_pull` of the way at 1): from `pull_from` toward `pull_to`, `pull_move` of the way there. +pull: f32 = 0, +pull_from: f32 = 0, +pull_to: f32 = 0, +pull_move: f32 = 1, /// Where the pointer was last frame, for a jump from one window's part of the frame to another's /// (`followAcross`). last_mouse: ?dvui.Point.Physical = null, drop_ns: i128 = 0, -drop_shapes: [2]core.LiquidField.Shape = undefined, +/// The view carried as a drop of glass (`dropShapes`), where the glass program draws: one drop, +/// where the hand holds it. This frame's shapes. +drop_shapes: [1]core.LiquidField.Shape = undefined, drop_n: usize = 0, /// The head's corner radius this frame (physical), for the photograph inside it. drop_radius: f32 = 0, /// Carried by a finger: the drop rides up and left of it, where the finger does not cover it. drop_touch: bool = false, +/// How far the drop is joined to the bubble it is aimed at, 0…1, eased: its photograph fades by +/// `aim_fade` of it (`drawDrop`). +drop_aim: f32 = 0, /// The places this drag can land on, and where they were, frozen at lift. targets: [max_targets]Target = undefined, @@ -418,6 +459,7 @@ pub fn discard(self: *ViewDrag) void { // Destroyed with a frame, and only in one: discarded at teardown (quitting mid-drag) there is // no window, and the GPU goes with the process. if (self.texture) |tex| if (dvui.current_window != null) dvui.Texture.destroyLater(tex); + self.forgetPixels(); // The drops still showing outlive the drag: they run back together where they were drawn // (`drawOverlay`), whatever the drop has just done to their places. const finishing = self.last_pending; @@ -429,12 +471,159 @@ pub fn discard(self: *ViewDrag) void { pub fn takePicture(self: *ViewDrag, pic: *dvui.Picture) void { pic.stop(); + self.photo_box = .{ .x = 0, .y = 0, .w = 1, .h = 1 }; + self.forgetPixels(); + if (dvui.Texture.readTarget(dvui.currentWindow().arena(), pic.texture)) |px| { + const w = pic.texture.width; + const h = pic.texture.height; + self.photo_box = contentBox(px, w, h); + // What the drop shows of it, closer where it is too wide to read shrunk into the drop. + self.photo_box = focusBox(dvui.currentWindow().arena(), px, w, h, self.photo_box, 2 * drop_r * dvui.currentWindow().natural_scale * std.math.sqrt1_2); + if (core.dialogs.carry_windows and px.len >= @as(usize, w) * h) { + if (std.heap.page_allocator.dupe(dvui.Color.PMA, px[0 .. @as(usize, w) * h])) |kept| { + self.photo_pixels = kept; + self.photo_size = .{ w, h }; + self.photo_gen +%= 1; + if (self.photo_gen == 0) self.photo_gen = 1; + } else |_| {} + } + } else |_| {} const tex = dvui.textureFromTarget(pic.texture) catch return; if (self.texture) |old| dvui.Texture.destroyLater(old); self.texture = tex; self.texture_rect = pic.r; } +fn forgetPixels(self: *ViewDrag) void { + if (self.photo_pixels) |px| std.heap.page_allocator.free(px); + self.photo_pixels = null; +} + +/// The carried view's picture under the OS's glass (`ViewDrag.photo_under`): where its head is in +/// the frame, its corners, how much of it shows, and where the whole picture lies (`photoFit`), +/// physical — past the head, cut off there. +pub const PhotoUnder = struct { + rect: dvui.Rect.Physical, + radius: f32, + alpha: f32, + image: dvui.Rect.Physical, +}; + +/// Where in the captured view what it shows is, as shares of the picture's width and height: the +/// box round everything it drew, read once from the capture's pixels at lift — a capture of the +/// view alone is clear wherever it draws nothing. A drop showed the middle of its picture, and +/// Files, whose rows are at its top, showed nothing (the user). The whole where nothing could be +/// read. +fn contentBox(px: []const dvui.Color.PMA, w_: u32, h_: u32) dvui.Rect { + const whole: dvui.Rect = .{ .x = 0, .y = 0, .w = 1, .h = 1 }; + const w: usize = w_; + const h: usize = h_; + if (w == 0 or h == 0 or px.len < w * h) return whole; + const step = @max(1, @min(w, h) / 160); + var lo_x = w; + var lo_y = h; + var hi_x: usize = 0; + var hi_y: usize = 0; + var y: usize = 0; + while (y < h) : (y += step) { + var x: usize = 0; + while (x < w) : (x += step) { + if (px[y * w + x].a < 24) continue; + lo_x = @min(lo_x, x); + lo_y = @min(lo_y, y); + hi_x = @max(hi_x, x); + hi_y = @max(hi_y, y); + } + } + if (hi_x < lo_x or hi_y < lo_y) return whole; + const fw: f32 = @floatFromInt(w); + const fh: f32 = @floatFromInt(h); + const x0: f32 = @floatFromInt(lo_x); + const y0: f32 = @floatFromInt(lo_y); + const x1: f32 = @floatFromInt(@min(w, hi_x + step)); + const y1: f32 = @floatFromInt(@min(h, hi_y + step)); + return .{ .x = x0 / fw, .y = y0 / fh, .w = (x1 - x0) / fw, .h = (y1 - y0) / fh }; +} + +/// The least a drop shrinks the carried view's picture by, picture pixels to the drop's: past it, +/// its text could not be read, and what was dragged could not be told (the user). +const photo_zoom_least: f32 = 0.6; + +/// What of the carried view's picture its drop shows (`photoFit`), as shares of the picture: what +/// the view shows (`box`, `contentBox`), where that is narrow enough to read shrunk into the square +/// inside the round drop (`side`, physical) — and where it is not, the square of it that reads at +/// `photo_zoom_least` with the most drawn in it: of those with nearly the most, the highest and +/// then the furthest left, as a view reads — its heading and first rows, an editor's first lines, +/// the middle of a canvas, rather than the whole of it too small to make out. Graded once, at +/// lift, on a coarse grid of how much each part of the picture draws. +fn focusBox(arena: std.mem.Allocator, px: []const dvui.Color.PMA, w_: u32, h_: u32, box: dvui.Rect, side: f32) dvui.Rect { + const w: usize = w_; + const h: usize = h_; + if (w == 0 or h == 0 or px.len < w * h or side <= 0) return box; + const fw: f32 = @floatFromInt(w); + const fh: f32 = @floatFromInt(h); + const reach = side / photo_zoom_least; + if (box.w * fw <= reach) return box; + // Cells about a twenty-fourth of the square across, summed so any square's count is four reads. + const cell: usize = @max(4, @as(usize, @intFromFloat(@ceil(reach / 24)))); + const gw = (w + cell - 1) / cell; + const gh = (h + cell - 1) / cell; + const sums = arena.alloc(u32, (gw + 1) * (gh + 1)) catch return box; + @memset(sums, 0); + const step = @max(1, cell / 4); + for (0..gh) |gy| for (0..gw) |gx| { + var n: u32 = 0; + var y = gy * cell; + while (y < @min(h, (gy + 1) * cell)) : (y += step) { + var x = gx * cell; + while (x < @min(w, (gx + 1) * cell)) : (x += step) { + if (px[y * w + x].a >= 24) n += 1; + } + } + sums[(gy + 1) * (gw + 1) + gx + 1] = n + sums[gy * (gw + 1) + gx + 1] + sums[(gy + 1) * (gw + 1) + gx] - sums[gy * (gw + 1) + gx]; + }; + const span = @min(@max(1, @as(usize, @intFromFloat(@round(reach / @as(f32, @floatFromInt(cell)))))), @min(gw, gh)); + // Within what the view shows. + const x0: usize = @min(gw - span, @as(usize, @intFromFloat(box.x * fw)) / cell); + const y0: usize = @min(gh - span, @as(usize, @intFromFloat(box.y * fh)) / cell); + const x1: usize = @max(x0, @min(gw - span, @as(usize, @intFromFloat((box.x + box.w) * fw)) / cell -| span + 1)); + const y1: usize = @max(y0, @min(gh - span, @as(usize, @intFromFloat((box.y + box.h) * fh)) / cell -| span + 1)); + const Sum = struct { + fn at(t: []const u32, stride: usize, gx: usize, gy: usize, n: usize) u32 { + return t[(gy + n) * stride + gx + n] + t[gy * stride + gx] - t[gy * stride + gx + n] - t[(gy + n) * stride + gx]; + } + }; + var most: u32 = 0; + for (y0..y1 + 1) |gy| for (x0..x1 + 1) |gx| { + most = @max(most, Sum.at(sums, gw + 1, gx, gy, span)); + }; + if (most == 0) return box; + const enough = most - most / 5; + for (y0..y1 + 1) |gy| for (x0..x1 + 1) |gx| { + if (Sum.at(sums, gw + 1, gx, gy, span) < enough) continue; + const c: f32 = @floatFromInt(cell); + const n: f32 = @floatFromInt(span); + return .{ .x = @as(f32, @floatFromInt(gx)) * c / fw, .y = @as(f32, @floatFromInt(gy)) * c / fh, .w = n * c / fw, .h = n * c / fh }; + }; + return box; +} + +/// Where the whole of the carried view's picture lies for a drop's head `r` (physical): what it +/// shows (`photo_box`) as wide as the square inside the round head, its top at that square's top +/// and its middle on the head's — Files' rows across the head from its top rather than a strip of +/// them lost in its middle (the user). Never larger than it was taken. Past the head, it is cut +/// off there. +fn photoFit(d: ViewDrag, r: dvui.Rect.Physical) dvui.Rect.Physical { + const tw = d.texture_rect.w; + const th = d.texture_rect.h; + const box = d.photo_box; + const side = @min(r.w, r.h) * std.math.sqrt1_2; + const scale = @min(1, side / @max(1, box.w * tw)); + const w = tw * scale; + const h = th * scale; + return .{ .x = r.x + r.w / 2 - (box.x + box.w / 2) * w, .y = r.y + (r.h - side) / 2 - box.y * h, .w = w, .h = h }; +} + /// What photograph this place owes the drag this frame. /// /// A photograph is taken *from the place's own draw*, never from a second @@ -521,8 +710,12 @@ fn backed(tex: dvui.Texture, r: dvui.Rect.Physical) dvui.Texture { /// Begin carrying the view out of `name`. The place keeps drawing it throughout. pub fn begin(l: *Layout, name: []const u8, from: dvui.Rect.Physical, grabbed: dvui.Rect.Physical) void { var d = &l.state.view_drag; - d.drop_head = .{}; - d.drop_tail = .{}; + d.hand = null; + d.aim_at = null; + d.aim_to = null; + d.pull = 0; + d.pull_to = 0; + d.pull_move = 1; d.last_mouse = null; d.drop_ns = 0; d.drop_n = 0; @@ -554,6 +747,7 @@ fn liftShape(d: *ViewDrag, from: dvui.Rect.Physical) void { d.morph_radius = radius; d.card_start_ns = d.start_ns; d.lifting = true; + d.been_drop = false; } /// Begin carrying surface `id` from the picker, or from a plugin's own list (`Host.beginViewDrag`): @@ -563,8 +757,12 @@ fn liftShape(d: *ViewDrag, from: dvui.Rect.Physical) void { /// caller hands over (`State.stealSnapshot`) and the drag destroys. pub fn beginLoose(l: *Layout, id: []const u8, from: dvui.Rect.Physical, texture: ?dvui.Texture) void { var d = &l.state.view_drag; - d.drop_head = .{}; - d.drop_tail = .{}; + d.hand = null; + d.aim_at = null; + d.aim_to = null; + d.pull = 0; + d.pull_to = 0; + d.pull_move = 1; d.last_mouse = null; d.drop_ns = 0; d.drop_n = 0; @@ -575,6 +773,8 @@ pub fn beginLoose(l: *Layout, id: []const u8, from: dvui.Rect.Physical, texture: d.name = loose_source; d.from = from.size(); d.start_ns = dvui.currentWindow().frame_time_ns; + const mouse = dvui.currentWindow().mouse_pt; + d.grab = .{ .x = from.x + from.w / 2 - mouse.x, .y = from.y + from.h / 2 - mouse.y }; liftShape(d, from); d.moved_id = moved; d.texture = texture; @@ -629,12 +829,17 @@ fn mapOccluders(l: *Layout, d: *ViewDrag) void { d.occluders[d.occluder_count] = .{ .layer = @intCast(i + 1), .bounds = f.bounds, .header = f.header, .source = carriedOutOf(l, f.name) }; // In an OS window of its own: where that window lies over the main window's places, and // where the float is drawn in its band besides (`Occluder.band_bounds`). + // Under the main window in the OS's stacking (`Floats.Viewport.under_main`), it covers + // nothing of it: read there it is the main window's places, whole — where its window + // still shows, past the main window's edge, it is read in its band as ever. Taken as over + // it, a float clicked behind the main window cut its places' drops down to the strip + // beside it (the user). if (f.viewport) |vp| { const o = &d.occluders[d.occluder_count]; o.band_bounds = f.bounds; o.band_header = f.header; - o.bounds = f.bounds.offsetPoint(vp.main_delta); - o.header = f.header.offsetPoint(vp.main_delta); + o.bounds = if (vp.under_main) .{} else f.bounds.offsetPoint(vp.main_delta); + o.header = if (vp.under_main) .{} else f.header.offsetPoint(vp.main_delta); } d.occluder_count += 1; } @@ -683,15 +888,43 @@ pub fn aim(l: *Layout) Aim { /// `aim` for the pointer at `mouse` — a release's own point. pub fn aimFor(l: *Layout, mouse: dvui.Point.Physical) Aim { + const d = &l.state.view_drag; + if (d.active() and carriedAsDrop(l.state, mouse)) _ = holdHand(d, mouse); return aimAt(l.state, mouse); } +/// A drop aims from where the hand holds it (`holdHand`), so the bubble it is aimed at does not +/// change under a trembling hand either. fn aimAt(state: *const Layout.State, mouse: dvui.Point.Physical) Aim { const d = &state.view_drag; const cw = dvui.currentWindow(); if (!d.active() or !carriedAsDrop(state, mouse)) return .{ .p = mouse }; const R = drop_r * cw.natural_scale; - return .{ .p = dropCenter(mouse, R, d.drop_touch), .r = R }; + return .{ .p = dropCenter(d.hand orelse mouse, R, d.drop_touch), .r = R }; +} + +/// Points the pointer moves before the drop does (`holdHand`). +const hand_still: f32 = 3; + +/// Where the hand holds the drop, the pointer at `mouse`: where it was, until the pointer is more +/// than `hand_still` from it, and then dragged along behind the pointer at that distance. A hand +/// holding a press still trembles, and the drop moved with each tremble: joined to a bubble, the +/// OS re-formed the join each frame, and it shimmered (the user). Nothing eases or swings — it +/// moves only as far as the hand does, and at once. Called as often as a frame likes: the same +/// pointer leaves it where it is. +fn holdHand(d: *ViewDrag, mouse: dvui.Point.Physical) dvui.Point.Physical { + const still = hand_still * dvui.currentWindow().natural_scale; + const h = d.hand orelse { + d.hand = mouse; + return mouse; + }; + const dx = mouse.x - h.x; + const dy = mouse.y - h.y; + const dist = @sqrt(dx * dx + dy * dy); + if (dist <= still) return h; + const k = (dist - still) / dist; + d.hand = .{ .x = h.x + dx * k, .y = h.y + dy * k }; + return d.hand.?; } /// Whether the view is carried as a drop of glass at `mouse`: where the glass program draws, with @@ -793,8 +1026,10 @@ pub fn zoneBounds(state: *const Layout.State, name: []const u8) ?dvui.Rect.Physi /// The least share of the size it would have with no ghost there that a drop shrinks to, to stay /// clear of the ghost of the float the view is carried out of (`zoneBounds`); any smaller, and it -/// keeps that size, under the ghost. -pub const ghost_min_share: f32 = 0.75; +/// keeps that size, under the ghost. Two thirds or so: the drop's bubbles rest apart now, and the +/// wider wheel shrinks further to fit beside a ghost — at three quarters, chosen for the narrower +/// one, it sat under the ghost where it had fitted beside it. +pub const ghost_min_share: f32 = 0.65; /// `zoneBounds` with the float the view is carried out of covering (`ghost`) or not. fn zoneBoundsAs(state: *const Layout.State, name: []const u8, ghost: bool) ?dvui.Rect.Physical { @@ -1109,16 +1344,28 @@ pub fn drawOverlay(l: *Layout) void { // bubble it is aimed at lights, as one under a pointer does (`ownWindowLayer`). if (!taken and over and !core.dialogs.carry_windows) look.carried = carried; // The icons go over the carried view, which is laid on the drop after it: the bubble it - // is about to be dropped in says what it does through it. - if (d.active()) look.icons = .later; + // is about to be dropped in says what it does through it. Where the OS draws the glass, + // over its glass too — after the drag as well, while the drops go (below). + if (d.active() or core.native_glass.on()) look.icons = .later; // Carrying the view, the drop is not held to its place: the carried drop reaches past it. clips[i] = if (look.carried.len > 0) prev_clip else p.clip; dvui.clipSet(clips[i]); if (DropZones.draw(p.key, p.wheel, scale, look) and look.carried.len > 0) taken = true; } dvui.clipSet(prev_clip); - if (!d.active()) return; - drawFloat(l, taken); + const native = core.native_glass.on(); + if (!d.active() and !native) return; + if (d.active()) drawFloat(l, taken); + // Where the OS draws the glass (`core.native_glass`), its glass is over everything the app's + // windows draw: the icons go in a layer of their own that its overlay takes, over its glass, as + // it takes the carried view (`core.screens.markCarried`). Under it they were seen only + // through the glass, blurred. + var icon_layer: dvui.FloatingWidget = undefined; + if (native) { + layerOver(&icon_layer, @src()); + core.screens.markCarried(icon_layer.data().id); + } + defer if (native) icon_layer.deinit(); for (drops[0..n], clips[0..n]) |p, clip| { dvui.clipSet(clip); DropZones.drawIcons(p.key, scale); @@ -1183,13 +1430,21 @@ fn handOffDrops(drops: []PendingDrop) usize { return n; } -/// Points: the radius of the view carried as a drop — the drop zones' middle bubble's, so what is -/// carried reads as big as where it goes, and is still seen beside a finger — and its tail's share -/// of it. +/// Points: the radius of the view carried as a drop — near the drop zones' middle bubble's (a +/// quarter under it), so what is carried reads about as big as where it goes, and is still seen +/// beside a finger. const drop_r: f32 = 52; -const drop_tail_share: f32 = 0.62; + /// How far toward the bubble it is aimed at the drop is drawn, so the two run together. -const drop_pull: f32 = 0.45; +const drop_pull: f32 = 0.55; + +/// Before it is aimed at one, the drop leans toward the nearest bubble of the place under the +/// pointer as it comes within `lean_reach` points of it, edge to edge — up to `drop_lean` of the +/// way there as they touch — so it is drawn in from a little way off rather than only once it is +/// on the bubble (the user: more attraction near the bubbles). Where the hand holds it decides it +/// (`holdHand`), nothing else: nothing eases or swings. +const drop_lean: f32 = 0.22; +const lean_reach: f32 = 56; /// The farthest the pointer moves in a frame within one window's part of the frame, physical /// pixels: past it, it went from one window to another — a float out of the main window is drawn @@ -1197,8 +1452,8 @@ const drop_pull: f32 = 0.45; const across_jump: f32 = 30000; /// The pointer gone from one window's part of the frame to another's in one frame — out of a -/// float's window over the main window, or back — the carried view goes with it as it is: its -/// springs and the shape it is changing from move by the same jump. Left, they swept back across +/// float's window over the main window, or back — the carried view goes with it as it is: where +/// the hand holds it and the shape it is changing from move by the same jump. Left, they swept back across /// the band and the view showed for a frame or two in the window it had left. fn followAcross(d: *ViewDrag, mouse: dvui.Point.Physical) void { defer d.last_mouse = mouse; @@ -1206,9 +1461,13 @@ fn followAcross(d: *ViewDrag, mouse: dvui.Point.Physical) void { const dx = mouse.x - was.x; const dy = mouse.y - was.y; if (@abs(dx) < across_jump and @abs(dy) < across_jump) return; - for ([_]*core.Spring{ &d.drop_head, &d.drop_tail }) |sp| { - sp.pos.x += dx; - sp.pos.y += dy; + if (d.hand) |*h| { + h.x += dx; + h.y += dy; + } + if (d.aim_at) |*a| { + a.x += dx; + a.y += dy; } d.morph_rect.x += dx; d.morph_rect.y += dy; @@ -1216,7 +1475,7 @@ fn followAcross(d: *ViewDrag, mouse: dvui.Point.Physical) void { d.shape_rect.y += dy; } -/// The view carried as a drop this frame — its head and tail, stepped on their springs — or none +/// The view carried as a drop this frame, where the hand holds it (`holdHand`) — or none /// where it is carried as a card (`drawFloat`): no glass program, no photograph, or over a list. fn dropShapes(l: *Layout, drops: []const PendingDrop) []const core.LiquidField.Shape { const d = &l.state.view_drag; @@ -1227,8 +1486,13 @@ fn dropShapes(l: *Layout, drops: []const PendingDrop) []const core.LiquidField.S noteMode(d, modeAt(l, mouse), now); if (d.mode != .drop) { d.drop_ns = 0; - d.drop_head = .{}; - d.drop_tail = .{}; + d.hand = null; + d.aim_at = null; + d.aim_to = null; + d.pull = 0; + d.pull_to = 0; + d.pull_move = 1; + d.drop_aim = 0; return d.drop_shapes[0..0]; } const scale = cw.natural_scale; @@ -1244,45 +1508,101 @@ fn dropShapes(l: *Layout, drops: []const PendingDrop) []const core.LiquidField.S }, else => {}, }; - // Off the pointer, so the bubble under it stays in view — below and right of a mouse; up and - // left of a finger, the finger at the drop's bottom-right corner, where the hand holding it - // covers none of it — and drawn toward the bubble it is aimed at, far enough that the two - // run together. - var target = dropCenter(mouse, R, d.drop_touch); + // Off where the hand holds it (`holdHand`), so the bubble under the pointer stays in view — + // below and right of a mouse; up and left of a finger, the finger at the drop's bottom-right + // corner, where the hand holding it covers none of it. + const at = dropCenter(holdHand(d, mouse), R, d.drop_touch); + // And drawn toward the bubble it is aimed at, far enough that the two run together: by time, + // never by the hand — joined, it moves only as the hand does. A bubble newly aimed at is eased + // onto, without swinging past it. + var aimed: ?dvui.Point.Physical = null; for (drops) |p| { if (!p.look.target or !p.clip.contains(mouse)) continue; const z = p.look.hovered orelse continue; - const b = p.wheel.bubble(z); - target = .{ .x = target.x + (b.c.x - target.x) * drop_pull, .y = target.y + (b.c.y - target.y) * drop_pull }; - } - var moving = d.drop_head.step(target, dt, .{ .hz = 9, .playful_damping = 0.55 }); - moving = d.drop_tail.step(d.drop_head.pos, dt, .{ .hz = 4.5, .playful_damping = 0.4 }) or moving; - // The tail stays on the drop: pulled out a little way, not off it. - const tx = d.drop_tail.pos.x - d.drop_head.pos.x; - const ty = d.drop_tail.pos.y - d.drop_head.pos.y; - const reach = 1.1 * R; - const len = @sqrt(tx * tx + ty * ty); - if (len > reach) { - d.drop_tail.pos = .{ .x = d.drop_head.pos.x + tx / len * reach, .y = d.drop_head.pos.y + ty / len * reach }; + aimed = p.wheel.bubble(z).c; + } + const ease = std.math.clamp(dt / aim_s, 0, 1); + var moving = false; + // Drawn into a bubble newly aimed at, and over to another, as one motion from where it was — + // past the bubble and back where motion is playful, as it pours in (`pour`) — and let go + // smoothly: by time, so a hand held still sets nothing going (the user: as strong and snappy + // as the app's own glass). + const pull_want: f32 = if (aimed != null) 1 else 0; + if (pull_want != d.pull_to) { + d.pull_from = d.pull; + d.pull_to = pull_want; + d.pull_move = 0; + } + if (d.pull_move < 1) { + d.pull_move = @min(1, d.pull_move + joinStep(dt)); + moving = true; + } + const pk = if (d.pull_to > d.pull_from) pour(d.pull_move) else smooth(d.pull_move); + d.pull = std.math.lerp(d.pull_from, d.pull_to, pk); + if (aimed) |c| { + const new = if (d.aim_to) |to| @abs(to.x - c.x) > 1 or @abs(to.y - c.y) > 1 else true; + if (new) { + d.aim_from = d.aim_at orelse c; + d.aim_move = if (d.aim_at == null) 1 else 0; + d.aim_to = c; + } + } + if (d.aim_to) |to| { + if (d.aim_move < 1) { + d.aim_move = @min(1, d.aim_move + joinStep(dt)); + moving = true; + } + const k = pour(d.aim_move); + d.aim_at = .{ .x = std.math.lerp(d.aim_from.x, to.x, k), .y = std.math.lerp(d.aim_from.y, to.y, k) }; + } + // Joined to a bubble, its photograph fades (`aim_fade`), eased in and out. + const aim_want: f32 = if (aimed != null) 1 else 0; + d.drop_aim += (aim_want - d.drop_aim) * ease; + if (@abs(aim_want - d.drop_aim) < 0.01) d.drop_aim = aim_want; + if (d.drop_aim != aim_want) moving = true; + if (d.pull_to == 0 and d.pull_move >= 1) { + d.aim_at = null; + d.aim_to = null; } if (moving) dvui.refresh(null, @src(), null); + // Leaning toward the nearest bubble as it comes near (`drop_lean`), as much less as it is joined + // to one; and drawn toward the one it is joined to as much as it is (`drop_pull`). + var lean_to: ?dvui.Point.Physical = null; + var lean: f32 = 0; + for (drops) |p| { + if (!p.look.target or !p.clip.contains(mouse)) continue; + for (DropZones.all) |z| { + if (z == .remove and !p.wheel.remove) continue; + const b = p.wheel.bubble(z); + const gx = b.c.x - at.x; + const gy = b.c.y - at.y; + const gap = @sqrt(gx * gx + gy * gy) - R - b.r; + const w = std.math.clamp(1 - gap / (lean_reach * scale), 0, 1); + if (w > lean) { + lean = w; + lean_to = b.c; + } + } + } + var centre = at; + if (lean_to) |t| { + const k = drop_lean * lean * lean * (3 - 2 * lean) * (1 - d.drop_aim); + centre = .{ .x = at.x + (t.x - at.x) * k, .y = at.y + (t.y - at.y) * k }; + } + const pull = drop_pull * d.pull; + if (d.aim_at) |a| centre = .{ .x = centre.x + (a.x - centre.x) * pull, .y = centre.y + (a.y - centre.y) * pull }; // From the shape it was last drawn as — what was grabbed at the lift, the tab it was over a - // strip — to the drop, on the card's own curve: that rounded rect closing into a circle round - // the head. + // strip — to the drop, on the card's own curve: that rounded rect closing into a circle. const t = morphProgress(d.*, now); - const to: dvui.Rect.Physical = .{ .x = d.drop_head.pos.x - R, .y = d.drop_head.pos.y - R, .w = 2 * R, .h = 2 * R }; + const grown = std.math.clamp(t, 0, 1); + const to: dvui.Rect.Physical = .{ .x = centre.x - R, .y = centre.y - R, .w = 2 * R, .h = 2 * R }; const head = lerpRect(d.morph_rect, to, t); - d.drop_radius = std.math.lerp(d.morph_radius, R, std.math.clamp(t, 0, 1)); + d.drop_radius = std.math.lerp(d.morph_radius, R, grown); d.shape_rect = head; d.shape_radius = d.drop_radius; d.drop_shapes[0] = .{ .rect = head, .radii = @splat(d.drop_radius), .round = true }; d.drop_n = 1; - const tr = R * drop_tail_share * std.math.clamp(t, 0, 1); - if (tr > 1) { - d.drop_shapes[1] = core.LiquidField.Shape.circle(d.drop_tail.pos, tr); - d.drop_n = 2; - } return d.drop_shapes[0..d.drop_n]; } @@ -1300,6 +1620,7 @@ fn modeAt(l: *Layout, mouse: dvui.Point.Physical) Mode { fn noteMode(d: *ViewDrag, mode: Mode, now: i128) void { const first = d.lifting; d.lifting = false; + if (mode == .drop) d.been_drop = true; if (mode == d.mode) return; d.morph_from_mode = if (first) mode else d.mode; d.mode = mode; @@ -1333,15 +1654,21 @@ fn morphProgress(d: ViewDrag, now: i128) f32 { fn drawDrop(l: *Layout, taken: bool) void { const d = &l.state.view_drag; const scale = dvui.currentWindow().natural_scale; - // In a window of its own (`ownWindowLayer`), its photograph is all the app draws of it. + // In a window of its own (`ownWindowLayer`), its photograph is all the app draws of it. Where + // the OS draws the drag's glass (`core.native_glass`), the drop is the OS's, run together with + // the bubble it is aimed at. var own_layer: dvui.FloatingWidget = undefined; const own_window = ownWindowLayer(&own_layer, @src()); defer if (own_window) own_layer.deinit(); + if (own_window and core.native_glass.on()) { + // Frosted glass as the bubbles are, its picture over the frost (`photo_under`): the drop + // matches the bubbles it is carried among and runs into (the user). + for (d.drop_shapes[0..d.drop_n]) |sh| core.native_glass.add(.{ .rect = sh.rect, .radius = d.drop_radius }); + } if (!taken and !own_window) { - // At the merge it is drawn at inside a place's drop (`DropZones.merge`). Its head and tail - // overlap, and the join between them swells the outline by up to a quarter of the merge: - // drawn alone at a wider one, between two places — over the sash between them — the drop - // grew by some 8% of its radius, and shrank back as the pointer reached either side. + // At the merge it is drawn at inside a place's drop (`DropZones.merge`): drawn alone at a + // wider one, between two places — over the sash between them — the drop grew by some 8% of + // its radius, and shrank back as the pointer reached either side. var field: core.LiquidField = .{ .merge_px = DropZones.merge * scale }; for (d.drop_shapes[0..d.drop_n]) |sh| field.add(sh); _ = core.dialogs.carriedFieldWhole(dvui.Id.update(.zero, "view_drag_drop"), field, scale); @@ -1351,24 +1678,31 @@ fn drawDrop(l: *Layout, taken: bool) void { const pad = card_padding * scale * 0.5; const r = head.insetAll(pad); if (r.w < 2 or r.h < 2) return; - // Cover: the photograph's middle, as much of it as keeps its proportions in the head. + // Cover: as much of the photograph as keeps its proportions in the head, round what the view + // shows (`photo_box`) rather than its middle. const pw = d.texture_rect.w; const ph = d.texture_rect.h; var uv: dvui.Rect = .{ .x = 0, .y = 0, .w = 1, .h = 1 }; if (pw > 0 and ph > 0) { const a_img = pw / ph; const a_box = r.w / r.h; - if (a_img > a_box) { - uv.w = a_box / a_img; - uv.x = (1 - uv.w) / 2; - } else { - uv.h = a_img / a_box; - uv.y = (1 - uv.h) / 2; - } + if (a_img > a_box) uv.w = a_box / a_img else uv.h = a_img / a_box; + uv.x = std.math.clamp(d.photo_box.x + d.photo_box.w / 2 - uv.w / 2, 0, 1 - uv.w); + uv.y = std.math.clamp(d.photo_box.y + d.photo_box.h / 2 - uv.h / 2, 0, 1 - uv.h); } - const radius = @max(0, d.drop_radius - pad) / scale; const shown = contentIn(d.*, morphProgress(d.*, dvui.currentWindow().frame_time_ns)); - dvui.renderTexture(tex, .{ .r = r, .s = scale }, .{ .corners = .round(radius), .colormod = dvui.Color.white.opacity(photo_opacity * shown), .uv = uv }) catch {}; + // On the OS's glass, where the app hands it over (`core.native_glass.under`) and has its pixels + // to: what the view shows, fitted to it (`photoFit`), over the drop's frost — clear what is + // carried, and the drop the bubbles' glass (the user). + if (own_window and core.native_glass.on() and core.native_glass.under() and d.photo_pixels != null) { + d.photo_under = .{ .rect = head, .radius = d.drop_radius, .alpha = shown * (1 - aim_fade_under * d.drop_aim), .image = photoFit(d.*, head) }; + d.photo_last = d.photo_under.?.image; + drawDropLabel(l, head, shown); + return; + } + const radius = @max(0, d.drop_radius - pad) / scale; + const photo = photo_opacity * shown * (1 - aim_fade * d.drop_aim); + dvui.renderTexture(tex, .{ .r = r, .s = scale }, .{ .corners = .round(radius), .colormod = dvui.Color.white.opacity(photo), .uv = uv }) catch {}; drawDropLabel(l, head, shown); } @@ -1388,10 +1722,14 @@ const drop_label_drop: f32 = 0.42; /// width with an ellipsis. `shown` fades it in with the drop's content. fn drawDropLabel(l: *Layout, head: dvui.Rect.Physical, shown: f32) void { const d = &l.state.view_drag; - const title = dropTitle(l, d.*) orelse return; - if (title.len == 0 or shown <= 0.01) return; + const title_raw = dropTitle(l, d.*) orelse return; + if (title_raw.len == 0 or shown <= 0.01) return; const hn = head.toNatural(); - const font = dvui.Font.theme(.body).larger(-2); + // As the explorer's headings are (`Chooser.label`): uppercase, in the heading font — in the + // window's text colour, not the heading's highlight (the user). + var upper: [128]u8 = undefined; + const title = if (title_raw.len <= upper.len) std.ascii.upperString(&upper, title_raw) else title_raw; + const font = dvui.Font.theme(.heading); const line_h = font.lineHeight(); const max_w = hn.w * 0.72; if (max_w < 16) return; @@ -1455,7 +1793,9 @@ pub fn zonesShowing(l: *Layout, name: []const u8, key: dvui.Id) bool { pub fn drawFloat(l: *Layout, taken: bool) void { tick(l); const d = &l.state.view_drag; + d.photo_under = null; if (!d.active()) return; + if (d.drop_n == 0) d.photo_last = null; if (d.drop_n > 0) { drawDrop(l, taken); // Frames while it is still turning from what was grabbed into the drop. @@ -1488,8 +1828,23 @@ pub fn drawFloat(l: *Layout, taken: bool) void { // shows — so what the tab was held by is still under the pointer, and the card grows away // from the place it is aimed at rather than over it. Pulled in only as far as keeps the // pointer on the card: a place grabbed far from its corner shrinks to a card far smaller. + // Once it has been a drop, the grab point is long gone: the card hangs down and right from the + // pointer, just inside its top left, as the drop did — kept at the grab point, a place grabbed + // far from its corner hung up and left of the pointer, anchored at its bottom right (the user). const inset = 8 * scale; - const tl: dvui.Point.Physical = .{ + const tl: dvui.Point.Physical = if (d.been_drop) .{ .x = mouse.x - inset, .y = mouse.y - inset } else if (d.loose()) blk: { + // Lifted out of a list — a row of a file tree, a card of the picker — it keeps its middle + // where the middle of what was grabbed was, so a row's name stays where it was in the row + // as the row narrows to it. Grabbed off what it shows — out along the row past its name, + // rows lifted together far from the one under the pointer — it is brought in under the + // pointer as it grows into itself (`morphProgress`), the pointer kept off its very end: a + // pill's, the middle of its round end. + const keep_x = if (show_photo) inset else @min(target.h, target.w) / 2; + const keep_y = @min(inset, target.h / 2); + const cx = mouse.x + std.math.clamp(d.grab.x, -@max(0, target.w / 2 - keep_x), @max(0, target.w / 2 - keep_x)); + const cy = mouse.y + std.math.clamp(d.grab.y, -@max(0, target.h / 2 - keep_y), @max(0, target.h / 2 - keep_y)); + break :blk .{ .x = cx - target.w / 2, .y = cy - target.h / 2 }; + } else .{ .x = mouse.x + std.math.clamp(off.x, -@max(0, target.w - inset), 0), .y = mouse.y + std.math.clamp(off.y, -@max(0, target.h - inset), 0), }; @@ -1514,6 +1869,7 @@ pub fn drawFloat(l: *Layout, taken: bool) void { .border = .all(0), }); defer fw.deinit(); + if (own_window and core.native_glass.on()) core.native_glass.add(.{ .rect = rect, .radius = radius }); if (!own_window) { // Glass, like every floating surface. Where the glass program draws it is the same glass // the drop is (`drawDrop`), under one id, so a tab becoming the drop and back is one @@ -1549,6 +1905,41 @@ pub fn drawFloat(l: *Layout, taken: bool) void { const card_padding: f32 = 6; /// How opaque the card's photograph is over its glass. const photo_opacity: f32 = 0.8; +/// How much of the drop's photograph goes while it is joined to the bubble it is aimed at: nearly +/// opaque over the bubble it read as a disc under it, not glass running into glass (the user) — the +/// bubble lights itself (`DropZones`). +const aim_fade: f32 = 0.75; +/// How much of the drop's photograph goes as it joins a bubble where it lies under the OS's glass: +/// little — the bubble's glass bends and blurs it where the drop runs into it, and it reads through +/// that (the user) — only enough that the bubble's icon reads over it. +const aim_fade_under: f32 = 0.3; +/// Seconds the drop's photograph takes to fade as it joins a bubble, and to come back. +const aim_s: f32 = 0.09; + +/// Milliseconds the drop takes to be drawn into a bubble, over to another, or let go of one +/// (`pour`), as written: at the motion speed the user set (`core.motion.durationMs`). +const join_ms: f32 = 240; + +/// How much of a join `dt` seconds is: all of it where motion is off. +fn joinStep(dt: f32) f32 { + const ms = core.motion.durationMs(join_ms); + return if (ms <= 0) 1 else dt * 1000 / ms; +} + +/// How far through being drawn into a bubble the drop is at `u` of `join_ms`: quick, then past it +/// and back, as water pours in, as far past as motion is playful (`core.motion`) — none at the low +/// end, an ease out only. Time alone moves it. +fn pour(u: f32) f32 { + const play = std.math.clamp((core.motion.level() - 0.5) * 2, 0, 1); + const k: f32 = 1.8 * play; + const v = std.math.clamp(u, 0, 1) - 1; + return 1 + (k + 1) * v * v * v + k * v * v; +} + +fn smooth(u: f32) f32 { + const x = std.math.clamp(u, 0, 1); + return x * x * (3 - 2 * x); +} /// Points: the tab face on a card with no photograph — a file icon, the title and, when there /// are unsaved changes, the dirty dot — and the gaps between them. @@ -1566,9 +1957,6 @@ fn draggedDoc(l: *Layout, d: ViewDrag) ?struct { path: []const u8, dirty: bool } } /// A card with no photograph: the tab's face in glass, tab-sized. -/// Points: the widest a carried tab keeps the width it was lifted at (`pillSize`). -const max_lifted_w: f32 = 480; - fn pillSize(l: *Layout, d: ViewDrag, title: []const u8, scale: f32) dvui.Size.Physical { const text = dvui.Font.theme(.body).textSize(title); const doc = draggedDoc(l, d); @@ -1578,11 +1966,8 @@ fn pillSize(l: *Layout, d: ViewDrag, title: []const u8, scale: f32) dvui.Size.Ph w += face_gap + face_dot; }; const h = @max(face_icon, text.h) + 2 * face_pad_y; - // As wide as what it was lifted from, when that was wider than what it shows — an explorer row - // runs on past its name: it is held where it was grabbed (`drawFloat`), and narrowed to its - // name, a row grabbed toward its end was carried off to the side of the pointer. A loose drag - // only: one lifted from a place is that place's size, not a tab's. - if (d.loose()) w = @max(w, @min(d.from.w / scale, max_lifted_w)); + // Only what it shows, whatever it was lifted from: a row lifted out of a file tree narrows to + // its icon and its name, and rows lifted together run into the one (`drawFloat`). // Tab-sized, with no card padding round it: the same as a tab carried along its strip, which // is the tab itself in glass. return .{ .w = w * scale, .h = h * scale }; @@ -1886,6 +2271,9 @@ fn floatOut(l: *Layout, source: []const u8, moved: []const u8, at: ?dvui.Point.P // was carried (the picker, a test). const d = &state.view_drag; const carried = d.active() and std.mem.eql(u8, d.name, source) and d.shape_rect.w > 0; + // Carried with its picture, it opens as its place was, there (`float_rules.asTaken`): the + // picture growing with it out of the drop lands on the view as the float shows it. + if (carried and d.texture != null and out_of == null) rect = float_rules.asTaken(Floats.toRules(src.toNatural()), window); // Let go over no window of the app's (`floatAway`): its size, out there — not held on the main // window — its top left where the carried glass's was, which rode below and right of the // pointer: the glass grows into it right and down from where it was let go, rather than out @@ -1910,6 +2298,7 @@ fn floatOut(l: *Layout, source: []const u8, moved: []const u8, at: ?dvui.Point.P // The float has the photograph now; the drag's discard must not destroy it. landing.photo = d.texture; landing.photo_size = d.texture_rect.size(); + landing.photo_from = d.photo_last; d.texture = null; } _ = state.floats.add(l.gpa, .{ @@ -1937,36 +2326,77 @@ fn holderOf(l: *Layout, id: []const u8) ?[]const u8 { return null; } -/// Send the views of a closing float's places (`leaves`) back to `home`, the place the float came -/// out of (`float_rules.goHome`): into its list when the user had arranged it, otherwise let go, -/// for its keywords to place — which, for a place its keywords fill, is home again. Each is -/// selected there, so the view the user had in front of them is in front of them again. +/// Send the views of a closing float's places (`leaves`) back into the main window +/// (`float_rules.goHome`): into the list of `home`, the place the float came out of, when the user +/// had arranged it; let go where their keywords show them, which for a place its keywords fill is +/// home again; and any other view into home's list all the same, or another place where home +/// cannot take it — a view merged into the float from elsewhere may have keywords no place +/// answers, and let go it would be shown nowhere. Each is selected where it lands, so the view +/// 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): every view is let go. Not looked up — an - // unnamed place would answer to "". + // 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. + var ids: std.ArrayListUnmanaged([]const u8) = .empty; for (leaves) |leaf| { - // A copy: assigning a view home takes it out of the leaf's list, freeing the one read. - const ids = l.arena.dupe([]const u8, state.assignment(leaf) orelse continue) catch continue; - for (ids) |raw| { - const id = ownId(l.arena, raw) orelse continue; - const held = holding(l, home); - 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, - })) { - .add => state.assign(l.gpa, home, idsWith(l.arena, held, id)) catch {}, - .put => state.assign(l.gpa, home, &.{id}) catch {}, - .keywords => {}, - } - if (r != null) selectNamed(l, home, id); + for (state.assignment(leaf) orelse continue) |raw| { + ids.append(l.arena, ownId(l.arena, raw) orelse continue) catch {}; } + 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); } } +/// Whether a place's keywords would show `id`, held by no place's list, of their own accord: a +/// place left to its keywords that accepts it. +fn keywordsPlace(l: *Layout, id: []const u8) bool { + const s = l.host.surfaceById(id) orelse return false; + if (s.keywords.len == 0) return false; + for (l.state.regions.items) |*r| { + if (l.state.assignment(r.name) != null) continue; + if (l.offers(r, s) and sdk.keywords.accepts(r.keywords, s.keywords)) return true; + } + return false; +} + +/// Where a closing float's view goes that its home cannot take and its keywords show nowhere +/// (`sendHome`): the first place of the main window's, in the shape's order, that shows several +/// views and may show it. +fn mainPlaceFor(l: *Layout, id: []const u8) ?*const Region { + const s = l.host.surfaceById(id) orelse return null; + for (l.state.regions.items) |*r| { + if (r.shows != .many or l.state.floatRoot(r.name) != null) continue; + if (l.offers(r, s)) return r; + } + return null; +} + /// An empty place carried somewhere — the place itself is what moves, there being nothing in it. /// The trash or another place's middle: it goes, closing into the half beside it (an empty place /// dropped on an empty one leaves one empty place). Another place's edge: it goes from where it diff --git a/app/layout/float_rules.zig b/app/layout/float_rules.zig index 64f0470d..5843d3ef 100644 --- a/app/layout/float_rules.zig +++ b/app/layout/float_rules.zig @@ -59,6 +59,21 @@ pub fn initialRect(source: Rect, window: Rect) Rect { return inside(.{ .x = source.centerX() - w / 2, .y = source.centerY() - h / 2, .w = w, .h = h }, window); } +/// Where a float opens for a view carried out of `source` and let go as a drop with its picture: +/// the place itself, its size and where it was, kept inside the window and no smaller than a float +/// may be resized to — so the picture it grows with lands on the view as the float shows it (the +/// user: the size the popped out region was, aligned with its snapshot). +pub fn asTaken(source: Rect, window: Rect) Rect { + const cap_w = @max(0, window.w - 2 * margin); + const cap_h = @max(0, window.h - 2 * margin); + return inside(.{ + .x = source.x, + .y = source.y, + .w = @min(@max(source.w, resize_min_w), cap_w), + .h = @min(@max(source.h, resize_min_h), cap_h), + }, window); +} + /// A float opened from the float `from`: the same size, a step down and right, kept inside. pub fn nudged(from: Rect, window: Rect) Rect { return inside(.{ .x = from.x + nudge, .y = from.y + nudge, .w = from.w, .h = from.h }, window); @@ -171,9 +186,13 @@ pub const Home = struct { shows_many: bool, /// It shows nothing now. empty: bool, + /// Let go, the view's keywords would show it in a place of the main window's of their own + /// accord — a view merged into the float from elsewhere may have keywords no place answers. + keywords_place: bool, }; -/// What closing a float does with one of its views. +/// What closing a float does with one of its views. Nothing a float holds is lost: every view +/// comes back into the main window. pub const GoHome = enum { /// Added to its home's list, beside what is there. add, @@ -183,12 +202,25 @@ pub const GoHome = enum { /// its keywords fill. Writing it into such a place would freeze the place's list against /// every view a plugin registers from then on. keywords, + /// Into another place of the main window's that shows several: its home is gone or holds + /// something else, and its keywords would show it nowhere. + elsewhere, }; pub fn goHome(home: Home) GoHome { - if (!home.declared or !home.assigned) return .keywords; - if (home.shows_many) return .add; - return if (home.empty) .put else .keywords; + // A home the user arranged takes it back as it left: beside what is there, or alone where + // nothing is. + if (home.declared and home.assigned) { + if (home.shows_many) return .add; + if (home.empty) return .put; + } + // Brought back by its keywords: let go, so no place's list is written down for it. + if (home.keywords_place) return .keywords; + // Let go, it would be shown nowhere: written into its home all the same, as a drop there + // writes it; where the home is gone or full, into another place. + if (home.declared and home.shows_many) return .add; + if (home.declared and home.empty) return .put; + return .elsewhere; } // ── Tests ────────────────────────────────────────────────────────────────────────────────────── @@ -203,6 +235,22 @@ test "a float opens at a share of its place, centred on it" { try std.testing.expectApproxEqAbs(@as(f32, 500), r.y + r.h / 2, 0.01); } +test "a float let go with its picture opens as its place was, kept on the window" { + const r = asTaken(.{ .x = 400, .y = 100, .w = 1000, .h = 800 }, test_window); + try std.testing.expectEqual(@as(f32, 400), r.x); + try std.testing.expectEqual(@as(f32, 100), r.y); + try std.testing.expectEqual(@as(f32, 1000), r.w); + try std.testing.expectEqual(@as(f32, 800), r.h); + // A sidebar's narrow place keeps its width, above what a float may be resized to. + const side = asTaken(.{ .x = 0, .y = 40, .w = 240, .h = 900 }, test_window); + try std.testing.expectEqual(@as(f32, 240), side.w); + try std.testing.expect(side.x >= margin and side.y + side.h <= test_window.h - margin); + // Nothing smaller than that. + const tiny = asTaken(.{ .x = 300, .y = 300, .w = 50, .h = 40 }, test_window); + try std.testing.expectEqual(resize_min_w, tiny.w); + try std.testing.expectEqual(resize_min_h, tiny.h); +} + test "a float out of a small place opens at the minimum" { const r = initialRect(.{ .x = 0, .y = 0, .w = 200, .h = 120 }, test_window); try std.testing.expectEqual(open_min_w, r.w); @@ -296,13 +344,24 @@ test "a view floats out of its own place's middle, unless it has no place or is test "closing a float sends a view home without freezing a place its keywords fill" { // The sidebar, filled by keywords: let go, and the keywords bring it back. - try std.testing.expectEqual(GoHome.keywords, goHome(.{ .declared = true, .assigned = false, .shows_many = true, .empty = false })); + try std.testing.expectEqual(GoHome.keywords, goHome(.{ .declared = true, .assigned = false, .shows_many = true, .empty = false, .keywords_place = true })); // A panel the user arranged: added beside what is there. - try std.testing.expectEqual(GoHome.add, goHome(.{ .declared = true, .assigned = true, .shows_many = true, .empty = false })); + try std.testing.expectEqual(GoHome.add, goHome(.{ .declared = true, .assigned = true, .shows_many = true, .empty = false, .keywords_place = true })); // A slot the user arranged, emptied by the float: the view goes back in. - try std.testing.expectEqual(GoHome.put, goHome(.{ .declared = true, .assigned = true, .shows_many = false, .empty = true })); + try std.testing.expectEqual(GoHome.put, goHome(.{ .declared = true, .assigned = true, .shows_many = false, .empty = true, .keywords_place = true })); // Something else went there since: the view does not evict it. - try std.testing.expectEqual(GoHome.keywords, goHome(.{ .declared = true, .assigned = true, .shows_many = false, .empty = false })); + try std.testing.expectEqual(GoHome.keywords, goHome(.{ .declared = true, .assigned = true, .shows_many = false, .empty = false, .keywords_place = true })); // The place is gone (a split since closed). - try std.testing.expectEqual(GoHome.keywords, goHome(.{ .declared = false, .assigned = true, .shows_many = true, .empty = true })); + try std.testing.expectEqual(GoHome.keywords, goHome(.{ .declared = false, .assigned = true, .shows_many = true, .empty = true, .keywords_place = true })); +} + +test "closing a float loses no view its keywords would show nowhere" { + // Merged in from elsewhere, into a float out of the sidebar its keywords fill: the sidebar + // takes it, beside what its keywords show there. + try std.testing.expectEqual(GoHome.add, goHome(.{ .declared = true, .assigned = false, .shows_many = true, .empty = false, .keywords_place = false })); + // A slot something else went into since, or a home gone: another place of the main window's. + try std.testing.expectEqual(GoHome.elsewhere, goHome(.{ .declared = true, .assigned = true, .shows_many = false, .empty = false, .keywords_place = false })); + try std.testing.expectEqual(GoHome.elsewhere, goHome(.{ .declared = false, .assigned = false, .shows_many = false, .empty = true, .keywords_place = false })); + // An empty slot takes it back in. + try std.testing.expectEqual(GoHome.put, goHome(.{ .declared = true, .assigned = false, .shows_many = false, .empty = true, .keywords_place = false })); } diff --git a/app/restart.zig b/app/restart.zig new file mode 100644 index 00000000..51f323b7 --- /dev/null +++ b/app/restart.zig @@ -0,0 +1,83 @@ +//! Restarting the application: quitting the way it always quits, then starting again. +//! +//! One path for every reason to restart — a setting the app takes only at launch, an update +//! downloaded and waiting to be swapped in. `request` posts the app's ordinary quit, so unsaved +//! documents are asked about first and a quit the user calls off calls the restart off with it. +//! Once the app has torn down, `relaunch` starts it again: through Velopack when an update is +//! waiting (it swaps the install, then starts the new version), as this executable otherwise. + +const std = @import("std"); +const builtin = @import("builtin"); +const dvui = @import("dvui"); +const core = @import("core"); +const update_install = @import("update/update_install.zig"); + +/// A browser tab has nothing to start again. +pub const supported = builtin.target.cpu.arch != .wasm32; + +var requested_: bool = false; +/// Frames since the restart's quit was posted (`tick`). +var quit_frames: u8 = 0; + +/// Quit and start again. From any handler on the GUI thread. +pub fn request() void { + if (comptime !supported) return; + requested_ = true; + quit_frames = 0; + dvui.refresh(null, @src(), null); +} + +/// Whether a restart is under way: its quit posted, the app not yet called back from it. +pub fn requested() bool { + return requested_; +} + +/// Once a frame, before the app reads the frame's quit events. `quitting`: the app is still in +/// the middle of a quit — a question about unsaved documents open, saves running. +/// +/// The restart's quit goes in ahead of the frame's own, so it is answered like any other: with +/// nothing unsaved the frame ends closing, and `relaunch` runs at teardown. Still here two frames +/// on and no longer quitting, the user kept the app open — and called the restart off with it. +pub fn tick(quitting: bool) void { + if (comptime !supported) return; + // An update the user asked for has finished downloading: installing it is a restart. + if (update_install.takeDownloaded()) request(); + if (!requested_) return; + if (quit_frames == 0) { + dvui.currentWindow().addEventApp(.{ .action = .quit }) catch {}; + } else if (quit_frames > 1 and !quitting) { + requested_ = false; + } + quit_frames +|= 1; +} + +/// At the very end of teardown — after the app's windows, its documents and its single-instance +/// listener are gone, so the new instance owns the lock rather than handing its launch to this +/// one. No-op unless a restart was asked for. +pub fn relaunch(io: std.Io, gpa: std.mem.Allocator) void { + if (comptime !supported) return; + if (!requested_) return; + // Velopack waits for this process to exit, swaps the install and starts the new version. + if (update_install.applyAtExit()) return; + relaunchSelf(io, gpa); +} + +/// This executable again, as a process of its own that nothing waits on. On macOS through +/// `core.darwin_spawn` — std's spawn crashes after any `unsetenv`. +fn relaunchSelf(io: std.Io, gpa: std.mem.Allocator) void { + var buf: [std.fs.max_path_bytes]u8 = undefined; + const n = std.process.executablePath(io, &buf) catch |err| { + dvui.log.err("restart: could not find this executable: {s}", .{@errorName(err)}); + return; + }; + const argv: []const []const u8 = &.{buf[0..n]}; + if (comptime builtin.os.tag.isDarwin()) { + _ = core.darwin_spawn.spawn(gpa, .{ .argv = argv, .stdin = .discard, .stdout = .discard, .stderr = .discard }, null) catch |err| { + dvui.log.err("restart: could not launch again: {s}", .{@errorName(err)}); + }; + return; + } + _ = std.process.spawn(io, .{ .argv = argv, .stdin = .ignore, .stdout = .ignore, .stderr = .ignore }) catch |err| { + dvui.log.err("restart: could not launch again: {s}", .{@errorName(err)}); + }; +} diff --git a/app/root.zig b/app/root.zig index 5116c098..e961e234 100644 --- a/app/root.zig +++ b/app/root.zig @@ -91,6 +91,11 @@ pub const update = struct { pub const update_notify = @import("update/update_notify.zig"); }; +/// Quitting the way the app always quits — unsaved documents asked about first — then starting +/// again: for a setting the app takes only at launch, and for installing a downloaded `update`. +/// The app asks with `request`, runs `tick` each frame and `relaunch` at the end of teardown. +pub const restart = @import("restart.zig"); + /// Demos that drive the real app — a tape of timed input played into dvui, which a viewer can /// pause, take over, rewind and resume. `Tape` is the format, `Sequencer` the deterministic engine /// that replays it, `Script` the high-level way to write one, `Player` + `overlay` the dvui half diff --git a/app/settings/Settings.zig b/app/settings/Settings.zig index b8a62601..527a485c 100644 --- a/app/settings/Settings.zig +++ b/app/settings/Settings.zig @@ -96,6 +96,26 @@ motion_speed: f32 = core.motion.default_speed, /// compare the two. glass_shader: bool = true, +/// Whether a float can leave the main window into an OS window of its own, and a view be carried +/// past the main window to open one there. Taken at launch: a change waits for a restart, which the +/// app says (`Editor.restartPending`). On by default on macOS; `FIZZY_POPOUT` overrides it. +float_windows: bool = builtin.os.tag == .macos, + +/// Whether a view drag's glass — the carried view and the drop zones — is the OS's own (Liquid Glass, +/// macOS 26) rather than the app's, where floats are windows. Applied at once; `FIZZY_NATIVE_GLASS` +/// overrides it. +native_glass: bool = true, + +/// Whether menus open in OS windows of their own — the OS's material, shadow and corners, over every +/// window, past the window's edge — rather than drawn inside the window they open from, where floats +/// are windows. Applied at once; `FIZZY_NATIVE_MENUS` overrides it. +native_menus: bool = true, + +/// Whether dialogs open in OS windows of their own — the OS's material, shadow and corners, riding +/// on the main window — rather than drawn inside it, where floats are windows. Applied at once; +/// `FIZZY_NATIVE_DIALOGS` overrides it. +native_dialogs: bool = true, + /// Canvas zoom/pan control scheme shared by the image viewer, pixi, and any other /// `CanvasWidget` consumer. `auto` picks mouse vs trackpad from `dvui.mouseType()`. input_scheme: InputScheme = .auto, diff --git a/app/update/auto_update.zig b/app/update/auto_update.zig index 49f38f94..bb04b740 100644 --- a/app/update/auto_update.zig +++ b/app/update/auto_update.zig @@ -272,9 +272,5 @@ pub const UpdateInstallError = error{ InstallLayoutUnsupported, CheckFailed, DownloadFailed, - /// `vpkc_wait_exit_then_apply_updates` returned false — the helper rejected - /// the asset (commonly: code-signing mismatch, channel mismatch, or the helper - /// binary couldn't be spawned). See `vpkc_get_last_error` in the logs. - ApplyFailed, OutOfMemory, }; diff --git a/app/update/update_install.zig b/app/update/update_install.zig index 33dac794..24a1adb9 100644 --- a/app/update/update_install.zig +++ b/app/update/update_install.zig @@ -6,13 +6,15 @@ //! singleton) that runs both steps off-thread and publishes phase + 0–100 progress //! back through atomics so the dialog can render a status line and a progress bar. //! -//! On success the worker calls `std.process.exit(0)` after Velopack's helper takes -//! over — same final step as the original synchronous path. On failure the job -//! latches `.failed` with an error code; callers wipe the singleton when the user -//! dismisses the dialog. +//! The worker only downloads. Once the update is on disk the job latches +//! `.downloaded` and the app restarts the way it always does (`restart`): unsaved +//! documents are asked about first, and at teardown `applyAtExit` hands the waiting +//! update to Velopack, which swaps the install once this process has exited and +//! starts the new version. A restart called off leaves it downloaded — Velopack +//! applies it on the next launch on its own. On failure the job latches `.failed` +//! with an error code; callers wipe the singleton when the user dismisses the dialog. const std = @import("std"); -const builtin = @import("builtin"); const dvui = @import("dvui"); const auto_update = @import("auto_update.zig"); const build_opts = @import("build_opts"); @@ -29,7 +31,8 @@ const UpdateJob = @This(); pub const Phase = enum(u8) { queued = 0, downloading = 1, - applying = 2, + /// On disk, waiting for the restart that installs it (`applyAtExit`). + downloaded = 2, failed = 3, no_update = 4, }; @@ -47,6 +50,12 @@ done: std.atomic.Value(bool) = .init(false), err: ?auto_update.UpdateInstallError = null, err_msg_buf: [320]u8 = undefined, err_msg_len: usize = 0, +/// Set with `.downloaded`, read on the GUI thread once `done` is: the manager and the release +/// it downloaded, kept for `applyAtExit`. +manager: ?*anyopaque = null, +update_info: ?*anyopaque = null, +/// Raised with `.downloaded` for `takeDownloaded`, which lowers it. +downloaded_unseen: std.atomic.Value(bool) = .init(false), // ---------------------------------------------------------------------------- // Singleton — one in-flight install at a time. Both the About dialog and the @@ -83,14 +92,53 @@ pub fn startOrGet(allocator: std.mem.Allocator, io: std.Io) !*UpdateJob { } /// Drop the singleton if the worker has published `done`. No-op while the worker -/// is still running. Safe to call repeatedly. +/// is still running, and for a downloaded update — that one waits for `applyAtExit`. +/// Safe to call repeatedly. pub fn clearIfFinished() void { const j = current orelse return; if (!j.done.load(.acquire)) return; + if (j.currentPhase() == .downloaded) return; current = null; j.allocator.destroy(j); } +/// True once, on the GUI thread, when an update the user asked for has finished downloading — +/// the cue to restart into it (`restart.tick`). +pub fn takeDownloaded() bool { + const j = current orelse return false; + return j.downloaded_unseen.swap(false, .acq_rel); +} + +/// Whether an update is downloaded and waiting for a restart. +pub fn downloaded() bool { + const j = current orelse return false; + return j.done.load(.acquire) and j.currentPhase() == .downloaded; +} + +/// At teardown, on a restart: hand a downloaded update to Velopack, which waits for this process +/// to exit, swaps the install and starts the new version. False when there is none, or Velopack +/// would not take it — the app then starts itself again as it is. +pub fn applyAtExit() bool { + if (!auto_update.impl) return false; + if (!downloaded()) return false; + const j = current.?; + const mgr: *Vpk.vpkc_update_manager_t = @ptrCast(@alignCast(j.manager orelse return false)); + const info: *Vpk.vpkc_update_info_t = @ptrCast(@alignCast(j.update_info orelse return false)); + defer { + Vpk.vpkc_free_update_info(info); + auto_update.freeUpdateManager(j.manager); + j.manager = null; + j.update_info = null; + } + // silent=false allows the elevation prompt — /Applications is root-owned, so the bundle swap + // needs admin rights; restart=true starts the new version once it is in place. + if (!Vpk.vpkc_wait_exit_then_apply_updates(mgr, info.TargetFullRelease, false, true, null, 0)) { + auto_update.logVpkError("fizzy autoupdate: wait_exit_then_apply_updates failed"); + return false; + } + return true; +} + pub fn currentPhase(job: *const UpdateJob) Phase { const raw = job.phase.load(.acquire); return @enumFromInt(raw); @@ -109,7 +157,7 @@ pub fn phaseLabel(p: Phase) []const u8 { // too instead of leaking a "Checking…" / "Preparing…" state that // would contradict the prior step. .queued, .downloading => "Downloading update…", - .applying => "Applying update — relaunching…", + .downloaded => "Restarting to update…", .failed => "Update failed", .no_update => "You're up to date.", }; @@ -164,7 +212,9 @@ fn workerMain(job: *UpdateJob) void { job.setFailed(error.NoFeed); return; }; - defer auto_update.freeUpdateManager(mgr_opaque); + // Kept past a successful download, for `applyAtExit`. + var keep = false; + defer if (!keep) auto_update.freeUpdateManager(mgr_opaque); const mgr: *Vpk.vpkc_update_manager_t = @ptrCast(@alignCast(mgr_opaque)); @@ -188,33 +238,18 @@ fn workerMain(job: *UpdateJob) void { } const u = update_info.?; - defer Vpk.vpkc_free_update_info(u); + defer if (!keep) Vpk.vpkc_free_update_info(u); if (!Vpk.vpkc_download_updates(mgr, u, progressCb, job)) { auto_update.logVpkError("fizzy autoupdate: download failed"); job.setFailed(error.DownloadFailed); return; } + // On disk: the app restarts into it (`restart`), Velopack applying it at teardown. + keep = true; + job.manager = mgr_opaque; + job.update_info = u; job.progress.store(100, .monotonic); - job.phase.store(@intFromEnum(Phase.applying), .release); - dvui.refresh(job.window, @src(), null); - - const target_asset = u.TargetFullRelease; - // args: manager, asset, silent=false (allow elevation prompt — /Applications - // is root-owned so the bundle swap needs admin rights), restart=true, - // restartArgs=null, len=0. - const applied = Vpk.vpkc_wait_exit_then_apply_updates(mgr, target_asset, false, true, null, 0); - if (!applied) { - auto_update.logVpkError("fizzy autoupdate: wait_exit_then_apply_updates failed"); - job.setFailed(error.ApplyFailed); - return; - } - if (builtin.os.tag == .windows) { - const win32 = @import("win32"); - win32.system.threading.Sleep(2000); - } else { - const ts: std.c.timespec = .{ .sec = 2, .nsec = 0 }; - _ = std.c.nanosleep(&ts, null); - } - std.process.exit(0); + job.phase.store(@intFromEnum(Phase.downloaded), .release); + job.downloaded_unseen.store(true, .release); } diff --git a/app/update/update_notify.zig b/app/update/update_notify.zig index 1a258358..195ffc12 100644 --- a/app/update/update_notify.zig +++ b/app/update/update_notify.zig @@ -7,6 +7,7 @@ const std = @import("std"); const dvui = @import("dvui"); const auto_update = @import("auto_update.zig"); const update_install = @import("update_install.zig"); +const restart = @import("../restart.zig"); const Phase = enum(u8) { pending, @@ -35,9 +36,9 @@ var toast_armed: bool = false; var progress_toast_armed: bool = false; const TOAST_TIMEOUT_US: i32 = 10 * std.time.us_per_s; -/// Used for the progress toast: well over any plausible download/apply window, -/// so the toast never fades out before the worker either restarts the process -/// or moves to `.failed` / `.no_update` and the user dismisses it explicitly. +/// Used for the progress toast: well over any plausible download window, so the +/// toast never fades out before the download ends in a restart or in `.failed` / +/// `.no_update`, and the user dismisses it explicitly. const PROGRESS_TOAST_TIMEOUT_US: i32 = std.math.maxInt(i32); /// Stable, non-null subwindow id for this toast. Using a non-null id makes DVUI's @@ -245,9 +246,9 @@ fn displayUpdateToast(id: dvui.Id) !void { .padding = .{ .x = 12, .y = 6, .w = 12, .h = 6 }, })) { // Kick the background updater on a worker thread and swap the launch - // toast for a progress toast in the same slot. The worker runs to either - // `std.process.exit(0)` (success) or `.failed`/`.no_update` (the progress - // toast lets the user dismiss it). + // toast for a progress toast in the same slot. The download ends in a + // restart into the new version (`restart`) or in `.failed`/`.no_update` + // (the progress toast lets the user dismiss it). kickInstall(); dvui.toastRemove(id); return; @@ -334,7 +335,11 @@ fn displayProgressToast(id: dvui.Id) !void { }); defer box.deinit(); - const label = update_install.phaseLabel(cur_phase); + // Downloaded, and the restart into it called off (an unsaved document kept open): offered + // again here, and in the app's own restart affordances; Velopack installs it on the next + // launch regardless. + const waiting = cur_phase == .downloaded and !restart.requested(); + const label = if (waiting) "Update downloaded" else update_install.phaseLabel(cur_phase); dvui.labelNoFmt(@src(), label, .{}, .{ .gravity_y = 0.5, .color_text = .{ .color = dvui.themeGet().color(.content, .text) }, @@ -354,10 +359,28 @@ fn displayProgressToast(id: dvui.Id) !void { } // Terminal states get a dismiss button so the user can clear the toast. - // Active phases (`checking`/`downloading`/`applying`) don't — the worker - // is in-flight and the process will exit shortly on success. + // Active phases (`queued`/`downloading`, and `downloaded` while its restart + // runs) don't — the app is on its way to the new version. const terminal = cur_phase == .failed or cur_phase == .no_update; - if (terminal) { + if (waiting) { + if (dvui.button(@src(), "Restart to update", .{}, .{ + .gravity_y = 0.5, + .style = .highlight, + .corners = dvui.CornerRect.all(1000), + .padding = .{ .x = 12, .y = 6, .w = 12, .h = 6 }, + })) restart.request(); + if (dvui.button(@src(), "Later", .{}, .{ + .gravity_y = 0.5, + .style = .control, + .corners = dvui.CornerRect.all(1000), + .padding = .{ .x = 10, .y = 4, .w = 10, .h = 4 }, + .margin = .{ .x = 6 }, + })) { + progress_toast_armed = false; + dvui.toastRemove(id); + return; + } + } else if (terminal) { if (dvui.button(@src(), "Dismiss", .{}, .{ .gravity_y = 0.5, .style = .control, @@ -369,7 +392,7 @@ fn displayProgressToast(id: dvui.Id) !void { dvui.toastRemove(id); return; } - } else { + } else if (cur_phase != .downloaded) { // While the worker is running, force a frame each tick so the progress // value updates smoothly even when nothing else triggers a redraw. dvui.refresh(null, @src(), null); diff --git a/build/app.zig b/build/app.zig index 6336c120..e18caf86 100644 --- a/build/app.zig +++ b/build/app.zig @@ -619,6 +619,9 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. // stacking and where a closed float's views go. std-only (see float_rules.zig); // `Floats.zig` and `ViewDrag.zig` apply it, covered by `tests/integration.zig`. .{ "fizzy-float-rules-tests", "app/layout/float_rules.zig" }, + // One material on one slider: what every glass is at the window opacity, the OS's form + // and the app's. std-only (see glass_look.zig); `LiquidField` and `Popout` apply it. + .{ "fizzy-glass-look-tests", "core/gfx/glass_look.zig" }, // Demo automation's std-only core (`sdk/tape/`): the tape format and its codecs, the // sequencer that replays it deterministically, and the script builder. The dvui half // (`app/automation/Player.zig`) is covered by `tests/integration.zig`. diff --git a/core/core.zig b/core/core.zig index d0becdee..4bbcf82c 100644 --- a/core/core.zig +++ b/core/core.zig @@ -54,6 +54,8 @@ pub const corners = @import("corners.zig"); /// Frosted glass that bends what it shows — lens at the rim, ripples through it. pub const liquid_glass = @import("gfx/liquid_glass.zig"); pub const liquid_blob = @import("gfx/liquid_blob.zig"); +/// One material on one slider: every glass at the window opacity, the OS's form and the app's. +pub const glass_look = @import("gfx/glass_look.zig"); pub const LiquidField = @import("gfx/LiquidField.zig"); pub const programs = @import("gfx/programs.zig"); pub const Spring = @import("Spring.zig"); @@ -68,6 +70,8 @@ pub const keycaps = @import("keycaps.zig"); pub const anchor = @import("anchor.zig"); /// The screens floating things are placed on: the main window's, and a popped-out float's. pub const screens = @import("screens.zig"); +/// Glass the OS draws in place of the app's: a view drag's, as Liquid Glass on macOS 26. +pub const native_glass = @import("native_glass.zig"); /// Generic momentum/fling helper (pan, scrub, cover-flow). pub const Fling = @import("Fling.zig"); diff --git a/core/dialogs.zig b/core/dialogs.zig index 8011cf0b..9fbaaaef 100644 --- a/core/dialogs.zig +++ b/core/dialogs.zig @@ -19,6 +19,7 @@ const platform = @import("platform.zig"); const widgets = @import("widgets.zig"); const anim = @import("anim.zig"); const draw = @import("draw.zig"); +const screens = @import("screens.zig"); /// Core-owned dialog chrome state, set by the dialog framework and read by /// fizzy so core stays decoupled from the editor. When a modal is open fizzy @@ -644,6 +645,9 @@ pub fn dialog(src: std.builtin.SourceLocation, opts: DialogOptions) dvui.IdMutex const id = id_mutex.id; dvui.dataSet(opts.window, id, "_modal", opts.modal); + // Where dialogs are windows of their own, a dialog belongs to the window it was asked from — a + // float that is out, or the main window — and opens over it (`screens.screenFor`). + if (screens.nativeDialogs()) dvui.dataSet(opts.window, id, "_center_on", screens.screenFor((opts.window orelse dvui.currentWindow()).subwindows.current_rect)); dvui.dataSetSlice(opts.window, id, "_title", opts.title); //dvui.dataSet(opts.window, id, "_center_on", (opts.window orelse dvui.currentWindow()).subwindows.current_rect); dvui.dataSetSlice(opts.window, id, "_ok_label", opts.ok_label); @@ -673,14 +677,48 @@ pub fn closeFloatingDialogAnchored() void { dvui.dataSet(null, sub.id, "_close_rect", close_rect); } +/// The dialogs `drawEarly` drew this frame, which dvui's own pass then finds drawn. +var early: struct { frame: i128 = 0, n: u8 = 0, ids: [8]dvui.Id = undefined } = .{}; +var drawing_early = false; + +/// Draw this frame's dialogs now rather than where dvui draws them, at the very end of the frame +/// (`Window.endRendering`): where dialogs are windows of their own (`screens.nativeDialogs`), the +/// app takes each one's drawing into its window before that (`Popout`). Only the dialogs drawn by +/// this file's frame (`dialogWindow`); dvui's pass skips each of them, and draws any other as ever. +pub fn drawEarly() void { + const win = dvui.currentWindow(); + early = .{ .frame = win.frame_time_ns }; + drawing_early = true; + defer drawing_early = false; + var it = win.dialogs.iterator(null); + while (it.next()) |d| { + const ours = d.display == &dialogWindow or if (host_chrome) |h| d.display == h.dialog_window else false; + if (!ours or early.n >= early.ids.len) continue; + early.ids[early.n] = d.id; + early.n += 1; + d.display(d.id) catch |err| dvui.logError(@src(), err, "Dialog {x}", .{d.id}); + } +} + +/// Whether dialog `id` was drawn by `drawEarly` this frame. +fn drawnEarly(id: dvui.Id) bool { + if (early.frame != dvui.currentWindow().frame_time_ns) return false; + for (early.ids[0..early.n]) |i| if (i == id) return true; + return false; +} + pub fn dialogWindow(id: dvui.Id) anyerror!void { + // Drawn already this frame (`drawEarly`): dvui's own pass, at the end of the frame. + if (!drawing_early and drawnEarly(id)) return; const modal = dvui.dataGet(null, id, "_modal", bool) orelse { dvui.log.err("dialogDisplay lost data for dialog {x}\n", .{id}); dvui.dialogRemove(id); return; }; - if (modal) { + // In a window of its own (`screens.nativeDialogs`), the main window is left as it is. + const native = screens.nativeDialogs(); + if (modal and !native) { modal_dim_titlebar = true; } @@ -698,7 +736,7 @@ pub fn dialogWindow(id: dvui.Id) anyerror!void { const resizeable = dvui.dataGet(null, id, "_resizeable", bool) orelse false; - const center_on = dvui.currentWindow().subwindows.current_rect; + const center_on = dvui.dataGet(null, id, "_center_on", dvui.Rect.Natural) orelse dvui.currentWindow().subwindows.current_rect; const cancel_label = dvui.dataGetSlice(null, id, "_cancel_label", []u8); const default = dvui.dataGet(null, id, "_default", dvui.enums.DialogResponse); @@ -717,6 +755,7 @@ pub fn dialogWindow(id: dvui.Id) anyerror!void { .process_events_in_deinit = true, .resize = if (resizeable) .all else .none, .frost = dialogFrost(), + .native = native, }, .{ .id_extra = id.asUsize(), .color_text = .black, diff --git a/core/gfx/LiquidField.zig b/core/gfx/LiquidField.zig index 6018ba58..d12c302e 100644 --- a/core/gfx/LiquidField.zig +++ b/core/gfx/LiquidField.zig @@ -28,6 +28,7 @@ const std = @import("std"); const dvui = @import("dvui"); const programs = @import("programs.zig"); const liquid_glass = @import("liquid_glass.zig"); +const glass_look = @import("glass_look.zig"); const LiquidField = @This(); @@ -71,6 +72,9 @@ mix: f32 = 0, lift: f32 = 0, /// The user's dialog refraction, 0 (none) to 2 (`liquid_glass.Look.refraction`). refraction: f32 = 1, +/// It carries text — a dialog, a menu — and keeps some frost over a clear lens to read +/// (`glass_look.forText`). +text: bool = false, pub fn add(self: *LiquidField, shape: Shape) void { if (self.len >= max_shapes) return; @@ -151,11 +155,14 @@ pub const Uniforms = extern struct { tint: [4]f32, /// Mix, lift, has a tint, has the sharp picture. face: [4]f32, - /// Dither amplitude. + /// Dither amplitude; then Apple's lens (`applyLook`): the bending band's share of the shape's + /// shorter half, how far it bends in bands, how much darker the folded band is. A band share of + /// 0 is the earlier glass, pulled from outside. dither: [4]f32, /// 1 where the window is opaque behind its content (`publishOpaqueWindow`): the glass is /// then opaque, whatever the alpha of the picture it covers. The window's, not the field's: - /// `draw` sets it, in the frame; `pack` leaves it 0. + /// `draw` sets it, in the frame; `pack` leaves it 0. Then Apple's lens: its colour fringe, the + /// light across its band, the band's widest (physical pixels). backdrop: [4]f32 = @splat(0), shapes: [max_shapes][shape_vec4s][4]f32, @@ -262,6 +269,39 @@ fn opaqueWindow() bool { return dvui.dataGet(null, enabled_id, "_liquid_opaque_window", bool) orelse false; } +/// The app's glass at the window opacity this frame (`glass_look.inApp`), published by the app +/// before anything draws: every field — dialogs, menus, drops — is drawn as it says, Apple's lens +/// on the one slider. Null (nothing published, a plugin's own window): the earlier glass, as each +/// field's own tint, lift and blur say. The precompiled Vulkan and D3D12 programs draw the earlier +/// glass whatever is published until they are compiled again from the HLSL (the commands are at +/// its top); the GLSL (the web) and the Metal draw the lens. +pub fn publishLook(slider_look: ?glass_look.InApp) void { + if (dvui.current_window == null) return; + if (slider_look) |l| dvui.dataSet(null, enabled_id, "_liquid_look", l) else dvui.dataRemove(null, enabled_id, "_liquid_look"); +} + +fn publishedLook() ?glass_look.InApp { + return dvui.dataGet(null, enabled_id, "_liquid_look", glass_look.InApp); +} + +/// `look` into what the program reads: the lens's own uniforms, and the tint, lift, clarity, rim +/// light and frost the slider sets in place of the field's own. The bend follows the user's +/// refraction setting (`refraction`, 2 the default). +fn applyLook(self: *const LiquidField, u: *Uniforms, l: glass_look.InApp) void { + const bend_scale = std.math.clamp(self.refraction / 2, 0, 1); + u.dither[1] = @max(l.bevel, 0.0001); + u.dither[2] = l.bend * bend_scale; + u.dither[3] = l.shade; + u.backdrop[1] = l.dispersion; + u.backdrop[2] = l.bevel_light; + u.backdrop[3] = l.bevel_cap * self.scale; + u.rim[1] = l.clarity * bend_scale; + u.rim[3] = l.rim; + u.face[0] = std.math.clamp(l.mix, 0, 1); + u.face[1] = std.math.clamp(l.lift, 0, 1); + for (u.shapes[0..self.len]) |*sh| sh[2][0] *= l.frost; +} + /// Whether `draw` would draw now: programs here, switched on, compiled. pub fn ready() bool { if (!enabled()) return false; @@ -284,6 +324,8 @@ pub fn draw(self: *const LiquidField, frost: dvui.Texture, covered: dvui.Rect.Ph var u = self.pack(covered, sharp != null, groups.order[0..self.len]); // The window behind the glass, published for this frame. u.backdrop[0] = if (opaqueWindow()) 1 else 0; + // And the glass the slider makes of it this frame (`publishLook`). + if (publishedLook()) |l| self.applyLook(&u, if (self.text) glass_look.forText(l) else glass_look.forDrops(l)); const textures = [_]?*anyopaque{programs.handle(sharp)}; if (!h.begin(id, &textures, textures.len, u.vec4s(), uniform_vec4s)) return false; defer h.end(); diff --git a/core/gfx/glass_look.zig b/core/gfx/glass_look.zig new file mode 100644 index 00000000..22b6bf8d --- /dev/null +++ b/core/gfx/glass_look.zig @@ -0,0 +1,396 @@ +//! One material on one slider (`docs/NATIVE_WINDOWS_PLAN.md`, "One material, one slider"): what +//! every glass surface is at the window opacity `t`, read as the glass's roughness. At 0 it is clear +//! glass, a lens bending what is behind it toward a bright rim; going up it grows rougher — a +//! smooth frost, whole by `rough_by`, so the middle of the way is frosted glass with its shine at +//! the edge — and the window's colour comes in over the frost after it, from `tint_start`; at 1 it +//! is the window's colour all over, no translucency, and only a little of the glass's shine left at +//! its edge. Colour coming in ahead of the frost read as a feathered fill fading in, not glass +//! growing rougher (the user). Two forms read it: the OS's glass where it has one (`native`, +//! `window`; Liquid Glass on macOS 26) and the app's own (`inApp`, the glass program of +//! `LiquidField`) — on the same breakpoints, so a surface looks alike in either. +//! +//! Every glass is frosted glass with a clearing bevel: its middle takes the frost and the window's +//! colour, which fade out across a band along its edge (`band`) so the edge stays clear glass, +//! bending the surface behind it — the band a share of the shape's size, so it hugs the rim of +//! small glass. +//! +//! std-only: the mapping is pure, and tested here. The app publishes the in-app look each frame +//! (`LiquidField.publishLook`); fizzy's pop-out overlay reads the native one. +const std = @import("std"); + +/// Where the frost is whole: the glass as rough as it gets. +pub const rough_by: f32 = 0.6; +/// Where the window's colour starts coming in over the frost, whole at 1. +pub const tint_start: f32 = 0.3; +/// Where the colour starts covering the edge too, and the glass's shine starts going… +pub const shine_start: f32 = 0.8; +/// …to this much at 1. +pub const shine_min: f32 = 0.25; + +/// How far along each part of the way `t` is. +pub const Way = struct { + /// How rough the glass is: frost over the lens, 0…1. + frost: f32, + /// The window's colour over the frost, 0…1. + tint: f32, + /// The glass's shine, 1 until `shine_start`, `shine_min` at 1. + shine: f32, + /// The colour over everything, the edge too: 0 until `shine_start`, 1 at 1. + top: f32, +}; + +pub fn way(t: f32) Way { + const o = std.math.clamp(t, 0, 1); + const top = smoothstep(std.math.clamp((o - shine_start) / (1 - shine_start), 0, 1)); + return .{ + .frost = smoothstep(std.math.clamp(o / rough_by, 0, 1)), + .tint = smoothstep(std.math.clamp((o - tint_start) / (1 - tint_start), 0, 1)), + .shine = 1 - (1 - shine_min) * top, + .top = top, + }; +} + +// ── The clearing bevel ────────────────────────────────────────────────────────────────────────── + +/// The bevel's width: a share of a shape's shorter half… +pub const bevel: f32 = 0.29; +/// …and at most this many points. +pub const bevel_cap: f32 = 20; +/// How much of the bevel, from the edge in, is clear before the middle's frost and colour start +/// coming in across the rest of it: none — the frost reaches the edge, thinning to nothing just +/// there, rather than stopping short of it at a clear band (the user). +pub const bevel_clear: f32 = 0; + +/// The clearing bevel along the edge of a shape of `shorter_half` points, in points: clear for +/// `clear`, then the middle coming in over `feather`, quickly at first — strong most of the way to +/// the edge, gone at it. +pub const Band = struct { + clear: f32, + feather: f32, +}; + +pub fn band(shorter_half: f32) Band { + const w = @min(bevel * @max(shorter_half, 0), bevel_cap); + return .{ .clear = w * bevel_clear, .feather = w * (1 - bevel_clear) }; +} + +// ── The OS's glass ────────────────────────────────────────────────────────────────────────────── + +/// Liquid Glass's variants and styles, as measured on macOS 26.5 (`visual_effect_view.m`). +pub const Material = struct { + /// `_variant`: 11 the clear lens, 2 the glass's own frost. + variant: i32, + /// `NSGlassEffectViewStyle`: 1 Clear (light frost), 0 Regular (heavier). + style: i32, +}; +pub const lens_material: Material = .{ .variant = 11, .style = 1 }; +pub const frost_material: Material = .{ .variant = 2, .style = 1 }; + +/// The OS's glass at `t` — a drop, the carried view: the lens whole, bending the surface behind it; +/// over it frost at `over_share` and the window's colour `fill` opaque over that — both fading out +/// across each piece's clearing bevel (`band`), so the rim stays the lens; the glass `glass` there, +/// and the colour flat over everything `top_fill` opaque at the top. A drop takes the window's +/// colour only at the top, as the app's glass does (`InApp.mix`): coming in from `tint_start` it was +/// a disc of colour over the surface being dragged across, not rougher glass (the user). +pub const Native = struct { + under: Material, + over: Material, + over_share: f32, + fill: f32, + glass: f32, + top_fill: f32, + /// Points of plain blur over the lens in place of the frost, where the OS can draw one + /// (`drop_blur`). + blur: f32, +}; + +/// How much of the OS's frost a drop takes over its lens, at most. The frost is a blur, and the lens +/// under it the sharp picture: at half, the sharp picture showed through it only lightened — a bloom +/// with every line still in it, not frost (the user, against the app's own glass, which blurs); +/// whole, nothing behind it showed at all, a muddy disc rather than frosted glass — a canvas's +/// checkerboard gone from under it (the user). Mostly blur, a little of the picture through it. +pub const drop_frost: f32 = 0.75; + +/// The least frost a drop keeps, at the bottom of the slider: every drop carries something to read +/// — a drop zone's icon — and over a busy background a clear lens left it hard to make out (the +/// user). Still water, the lens bending what is under it, softened. +pub const drop_frost_min: f32 = 0.45; + +/// Points of plain blur over a drop's lens, at the top of its way and at the bottom, where the OS +/// can blur what is behind a window without a material's tint (macOS: Core Animation's backdrop +/// blur). The OS's frost is not a blur but a material: measured, it pulls whatever is under it some +/// 29% toward one mid grey — a dark window's colour lifted, a light one dulled — and blurs past any +/// detail, so a bubble over the window's colour read as a grey disc, and over a canvas as a flat +/// one, nothing under it showing (the user). A plain blur keeps the colour under it and a little of +/// its shape: clear glass, slightly frosted. +pub const drop_blur: f32 = 6; +pub const drop_blur_min: f32 = 2; + +/// The carried view's picture in its drop, a touch out of focus, points: frosted with the glass it is +/// in rather than printed on it, and far less than the bubbles' backdrop (`drop_blur`), so what the +/// view shows still reads (the user: "a very slight frost"). +pub const drop_photo_blur: f32 = 1.25; + +/// The clearing bevel of a drop of the OS's glass, a share of its shorter half (`bevel_cap` still +/// the most): narrower than the app's own, its frost reaching nearer the rim (the user) — the OS's +/// lens has a bright rim of its own, which the app's glass draws across its whole band. +pub const drop_bevel: f32 = 0.22; + +pub fn native(t: f32) Native { + const w = way(t); + return .{ .under = lens_material, .over = frost_material, .over_share = std.math.lerp(drop_frost_min, drop_frost, w.frost), .fill = w.top, .glass = w.shine, .top_fill = w.top, .blur = std.math.lerp(drop_blur_min, drop_blur, w.frost) }; +} + +/// A window of the OS's glass at `t` — the main window, a float's own. A window is read through, +/// not looked at: at 0 it is clear glass with a slight frost (`window_frost`), and its roughness +/// goes on past the glass frost into the plain blur behind the window (the vibrancy fizzy's windows +/// wore before), smooth and heavy, whole a little after the frost is. Over the lens its body takes +/// the frost, the blur over that, and the window's colour over both — all fading out across its +/// clearing bevel (`band`), so its edge stays the clear lens bending the desktop. The frost is under +/// the blur and the colour: over them its light lifted the whole window. At the top the colour +/// comes in under the lens too (`top_fill`), all over — the bevel opaque, the lens's last shine over +/// it — while the blur and the frost go: the blur shows the desktop whatever is under it, and +/// faded across the bevel over an opaque colour it was a ring of desktop inside the edge. +pub const Window = struct { + under: Material, + over: Material, + /// Glass frost over the lens, in the body. + frost: f32, + /// The plain blur over the frost, in the body. + blur: f32, + /// The window's colour over the blur, in the body. + fill: f32, + /// The lens, `shine_min` of it left at 1. + glass: f32, + /// The window's colour under the lens, all over, at the top. + top_fill: f32, +}; + +/// How much frost a window has at the bottom of the slider: clear glass, a little frosted. +pub const window_frost: f32 = 0.35; + +pub fn window(t: f32) Window { + const o = std.math.clamp(t, 0, 1); + const w = way(o); + const going = 1 - w.top; + return .{ + .under = lens_material, + .over = frost_material, + .frost = std.math.lerp(window_frost, 1, smoothstep(std.math.clamp(o / (rough_by / 2), 0, 1))) * going, + .blur = smoothstep(std.math.clamp((o - 0.1) / (rough_by - 0.05), 0, 1)) * going, + .fill = w.tint, + .glass = w.shine, + .top_fill = w.top, + }; +} + +// ── The app's glass ───────────────────────────────────────────────────────────────────────────── + +/// The app's glass at `t`, as the glass program (`shaders/liquid_glass.glsl`) reads it. Shaped +/// after Apple's lens, measured over a striped pattern: the backdrop pulled *inward* from a band +/// near the rim — `bevel` of the shape's shorter half, at most `bevel_cap` points — by up to `bend` +/// times that band, which magnifies it and, past 1, folds it into a mirrored, darker band just +/// inside the edge; the middle sharp and unblurred; a thin bright rim with a little colour fringe. +pub const InApp = struct { + /// Frost over the sharp picture, 0…1: each shape's own blur is multiplied by it. + frost: f32, + /// The window's colour over it, 0…1 (the program's tint mix, whatever the blur): only at the + /// top for glass that is a thing of its own — a drop, the carried view — which grows rough + /// before it takes the window's colour; a surface carrying text takes `text_mix` (`forText`). + mix: f32, + /// The colour a surface carrying text takes — a dialog, a menu — from `tint_start`. + text_mix: f32, + /// White over it, 0…1. + lift: f32, + /// The bending band's width: a share of the shape's shorter half… + bevel: f32, + /// …and at most this many points. + bevel_cap: f32, + /// How far the band pulls inward, in band widths at the rim; past 1 the picture folds there. + bend: f32, + /// How clear the band is: the sharp picture over the frost there, 0…1 — the rim stays clear + /// glass while the middle frosts. + clarity: f32, + /// The rim's light, 0…1. + rim: f32, + /// How much darker the folded band is, 0…1. + shade: f32, + /// Light across the bending band, lighter facing the top left and darker away — the glass's + /// bevel, which a full tint does not hide. + bevel_light: f32, + /// The colour fringe where the band bends hardest, 0…1. + dispersion: f32, +}; + +pub fn inApp(t: f32) InApp { + const w = way(t); + return .{ + .frost = w.frost, + .mix = w.top, + .text_mix = w.tint, + .lift = 0.03 * w.shine, + .bevel = bevel, + .bevel_cap = bevel_cap, + .bend = std.math.lerp(1.1, 0.55, w.frost) * w.shine, + .clarity = std.math.lerp(0.9, 0.3, w.frost) * w.shine, + .rim = std.math.lerp(1.0, 0.8, w.frost) * w.shine, + .shade = std.math.lerp(0.15, 0.05, w.frost) * w.shine, + .bevel_light = 0.08 * w.shine, + .dispersion = std.math.lerp(1.0, 0.25, w.frost) * w.shine, + }; +} + +/// The least frost a surface carrying text keeps — a dialog, a menu — so it still reads over a +/// clear lens; drops and the carried bubble follow the slider as it is. +pub const text_frost: f32 = 0.35; + +/// `look` for a drop — a drop zone's bubble, the carried view: frosted at least as much as the OS's +/// glass keeps at the bottom of the slider (`native`, `drop_frost_min`), on the same way up. +pub fn forDrops(look: InApp) InApp { + var l = look; + l.frost = std.math.lerp(drop_frost_min / drop_frost, 1, l.frost); + return l; +} + +/// `look` for a surface carrying text: its colour from `tint_start` (`text_mix`), and at least +/// `text_frost`. +pub fn forText(look: InApp) InApp { + var l = look; + l.mix = l.text_mix; + l.frost = @max(l.frost, text_frost * (1 - std.math.clamp(l.mix, 0, 1))); + return l; +} + +fn smoothstep(x: f32) f32 { + return x * x * (3 - 2 * x); +} + +// ── Tests ─────────────────────────────────────────────────────────────────────────────────────── + +test "the bottom of the slider is clear glass: lens, no colour, a drop only softened" { + const n = native(0); + try std.testing.expectEqual(lens_material, n.under); + try std.testing.expectApproxEqAbs(drop_frost_min, n.over_share, 1e-6); + try std.testing.expectApproxEqAbs(drop_frost_min, forDrops(inApp(0)).frost * drop_frost, 1e-6); + try std.testing.expectEqual(@as(f32, 0), n.fill); + try std.testing.expectEqual(@as(f32, 1), n.glass); + const a = inApp(0); + try std.testing.expectEqual(@as(f32, 0), a.frost); + try std.testing.expectEqual(@as(f32, 0), a.mix); + try std.testing.expect(a.bend > 1); // folds, as Apple's lens does +} + +test "the middle of the slider is frosted glass with its shine, the colour only a hint" { + const w = way(0.5); + try std.testing.expect(w.frost > 0.9); + try std.testing.expect(w.tint > 0 and w.tint < 0.3); + try std.testing.expectEqual(@as(f32, 1), w.shine); + try std.testing.expectEqual(@as(f32, 0), w.top); +} + +test "the top of the slider is the window's colour all over, a little shine left at the edge" { + const n = native(1); + try std.testing.expectEqual(@as(f32, 1), n.fill); + try std.testing.expectEqual(@as(f32, 1), n.top_fill); + try std.testing.expectEqual(shine_min, n.glass); + const a = inApp(1); + try std.testing.expectEqual(@as(f32, 1), a.mix); + try std.testing.expect(a.rim > 0 and a.rim < 0.3); +} + +test "the way is monotonic: frost and colour only come in, the shine only goes" { + var prev = way(0); + var i: usize = 1; + while (i <= 100) : (i += 1) { + const w = way(@as(f32, @floatFromInt(i)) / 100); + try std.testing.expect(w.frost >= prev.frost); + try std.testing.expect(w.tint >= prev.tint); + try std.testing.expect(w.top >= prev.top); + try std.testing.expect(w.shine <= prev.shine); + prev = w; + } +} + +test "the glass grows rough before the colour comes in" { + // Colour ahead of the frost read as a feathered fill fading in, not glass growing rougher. + var i: usize = 1; + while (i < 100) : (i += 1) { + const w = way(@as(f32, @floatFromInt(i)) / 100); + try std.testing.expect(w.frost >= w.tint); + } +} + +test "a surface carrying text keeps some frost over a clear lens, none once it is opaque" { + try std.testing.expectApproxEqAbs(text_frost, forText(inApp(0)).frost, 1e-6); + try std.testing.expectEqual(inApp(1).frost, forText(inApp(1)).frost); +} + +test "a drop's plain blur is light, growing with the glass's roughness" { + try std.testing.expectApproxEqAbs(drop_blur_min, native(0).blur, 1e-6); + try std.testing.expectApproxEqAbs(drop_blur, native(1).blur, 1e-6); + var prev = native(0).blur; + var i: usize = 1; + while (i <= 20) : (i += 1) { + const b = native(@as(f32, @floatFromInt(i)) / 20).blur; + try std.testing.expect(b >= prev); + prev = b; + } +} + +test "a drop is rough glass until the top, not a disc of colour; text takes its colour sooner" { + try std.testing.expectEqual(@as(f32, 0), native(0.7).fill); + try std.testing.expectEqual(@as(f32, 0), inApp(0.7).mix); + // Water: a light frost over the lens at most. + try std.testing.expectApproxEqAbs(drop_frost, native(0.7).over_share, 1e-6); + try std.testing.expectEqual(way(0.7).tint, forText(inApp(0.7)).mix); +} + +test "a window is clear glass a little frosted at the bottom, and opaque all over at the top" { + const low = window(0); + try std.testing.expectApproxEqAbs(window_frost, low.frost, 1e-6); + try std.testing.expectEqual(@as(f32, 0), low.blur); + try std.testing.expectEqual(@as(f32, 0), low.fill); + try std.testing.expectEqual(@as(f32, 1), low.glass); + const mid = window(0.5); + try std.testing.expectEqual(@as(f32, 1), mid.frost); + try std.testing.expect(mid.blur > 0.8); + try std.testing.expectEqual(@as(f32, 0), mid.top_fill); + const top = window(1); + try std.testing.expectEqual(@as(f32, 1), top.fill); + try std.testing.expectEqual(@as(f32, 1), top.top_fill); + try std.testing.expectEqual(shine_min, top.glass); + // The blur shows the desktop whatever is under it: gone by the top, so nothing does. + try std.testing.expectEqual(@as(f32, 0), top.blur); +} + +test "a window's colour only comes in, and its edge stays clear until the top" { + var prev = window(0); + var i: usize = 1; + while (i <= 100) : (i += 1) { + const t = @as(f32, @floatFromInt(i)) / 100; + const w = window(t); + try std.testing.expect(w.fill >= prev.fill); + try std.testing.expect(w.top_fill >= prev.top_fill); + try std.testing.expect(w.glass <= prev.glass); + if (t <= shine_start) try std.testing.expectEqual(@as(f32, 0), w.top_fill); + prev = w; + } +} + +test "the clearing bevel hugs the rim of small glass and stops growing on large" { + const small = band(20); + try std.testing.expectApproxEqAbs(@as(f32, 0.29 * 20), small.clear + small.feather, 1e-4); + const large = band(400); + try std.testing.expectApproxEqAbs(bevel_cap, large.clear + large.feather, 1e-4); + try std.testing.expect(large.clear <= large.feather); + try std.testing.expectEqual(@as(f32, 0), band(0).feather); +} + +test "both forms share the way" { + var i: usize = 0; + while (i <= 20) : (i += 1) { + const t = @as(f32, @floatFromInt(i)) / 20; + try std.testing.expectApproxEqAbs(native(t).over_share, forDrops(inApp(t)).frost * drop_frost, 1e-6); + try std.testing.expectEqual(native(t).fill, inApp(t).mix); + } +} diff --git a/core/gfx/liquid_blob.zig b/core/gfx/liquid_blob.zig index 9ec16cb7..bd738c31 100644 --- a/core/gfx/liquid_blob.zig +++ b/core/gfx/liquid_blob.zig @@ -179,8 +179,61 @@ pub fn draw(tex: dvui.Texture, tex_bounds: dvui.Rect.Physical, discs: []const Di const ColorCtx = struct { c: dvui.Color, x: f32 = 0, y: f32 = 1, z: f32 = 0, w: f32 = 0, dark: bool = false }; -/// The whole shape, inside and fringe, untextured, each vertex coloured by `color`. +/// The most discs `fill` takes. +pub const max_fill_discs = 48; + +/// The union of `discs` (bridging over `k`, physical) as a flat `color`, and each disc lit by its +/// `lit` toward `lit_toward` by `lit_amount`, running across the union as the glass's light does — +/// no frost, no refraction, no rim: what stands on glass something else draws, in its shape (the +/// window's base over the OS's Liquid Glass, `core.native_glass`). Each group of discs that run +/// together is meshed on its own, so discs far apart do not share one coarse grid. +pub fn fill(discs: []const Disc, k: f32, scale: f32, color: dvui.Color, lit_amount: f32, lit_toward: dvui.Color) void { + const n = @min(discs.len, max_fill_discs); + if (n == 0) return; + const arena = dvui.currentWindow().arena(); + // Groups: discs whose edges come within `k` of each other, transitively. + var group: [max_fill_discs]usize = undefined; + for (0..n) |i| group[i] = i; + for (0..n) |i| for (i + 1..n) |j| { + const dx = discs[i].c.x - discs[j].c.x; + const dy = discs[i].c.y - discs[j].c.y; + if (@sqrt(dx * dx + dy * dy) - discs[i].r - discs[j].r >= k) continue; + const gi = group[i]; + const gj = group[j]; + if (gi == gj) continue; + for (0..n) |m| if (group[m] == gj) { + group[m] = gi; + }; + }; + var done: [max_fill_discs]bool = @splat(false); + var members: [max_fill_discs]Disc = undefined; + for (0..n) |i| { + if (done[i]) continue; + var count: usize = 0; + for (i..n) |m| if (group[m] == group[i]) { + members[count] = discs[m]; + count += 1; + done[m] = true; + }; + var mesh = build(arena, members[0..count], @max(k, 0.5), scale) orelse continue; + defer mesh.deinit(arena); + if (color.a > 0) paint(arena, &mesh, struct { + fn paintColor(v: Vert, ctx: ColorCtx) dvui.Color { + return ctx.c.opacity(@as(f32, @floatFromInt(ctx.c.a)) / 255 * v.a); + } + }.paintColor, .{ .c = color }); + if (lit_amount > 0) paint(arena, &mesh, struct { + fn paintColor(v: Vert, ctx: ColorCtx) dvui.Color { + return ctx.c.opacity(std.math.clamp(ctx.x * v.s.lit * v.a, 0, 1)); + } + }.paintColor, .{ .c = lit_toward, .x = lit_amount }); + } +} + +/// The whole shape, inside and fringe, untextured, each vertex coloured by `color`. Nothing for a +/// mesh with no triangle in it — discs shrunk to nothing as they come and go. fn paint(arena: std.mem.Allocator, mesh: *const Mesh, comptime color: fn (Vert, ColorCtx) dvui.Color, ctx: ColorCtx) void { + if (mesh.verts.items.len < 3 or mesh.inner.items.len + mesh.fringe.items.len == 0) return; var b = dvui.Triangles.Builder.init(arena, mesh.verts.items.len, mesh.inner.items.len + mesh.fringe.items.len) catch return; defer b.deinit(arena); for (mesh.verts.items) |v| b.appendVertex(.{ .pos = v.p, .col = dvui.Color.PMA.fromColor(color(v, ctx)) }); diff --git a/core/gfx/shaders/liquid_glass.fragment.hlsl b/core/gfx/shaders/liquid_glass.fragment.hlsl index f159bf39..91a35cb7 100644 --- a/core/gfx/shaders/liquid_glass.fragment.hlsl +++ b/core/gfx/shaders/liquid_glass.fragment.hlsl @@ -80,9 +80,12 @@ float4 main(PSInput input) : SV_Target0 float4 g2 = uData[2]; float4 g3 = uData[3]; float4 g4 = uData[4]; - float4 g5 = uData[5]; + float4 g5 = uData[5]; // dither; the lens: its band's share of the shape, its bend, its shade + float4 g6 = uData[6]; // opaque window; the lens: dispersion, bevel light, the band's cap + bool inward = g5.y > 0.0; float d1 = 1e9; float d2 = 1e9; float4 m1 = float4(0.0, 0.0, 0.0, 0.0); float4 m2 = float4(0.0, 0.0, 0.0, 0.0); + float h1 = 1e9; float h2 = 1e9; float f1 = 1e9; float f2 = 1e9; float2 o1 = float2(0.0, 0.0); float2 o2 = float2(0.0, 0.0); for (int i = first; i < first + count && i < MAX_SHAPES; i++) { float4 s0 = uData[G + 3 * i]; @@ -90,7 +93,8 @@ float4 main(PSInput input) : SV_Target0 float4 s2 = uData[G + 3 * i + 2]; float2 g; float d = sdRoundBox(p - s0.xy, s0.zw, min(s1, (float4)min(s0.z, s0.w)), g); - if (d < d1) { d2 = d1; m2 = m1; d1 = d; m1 = s2; } else if (d < d2) { d2 = d; m2 = s2; } + float hs = min(s0.z, s0.w); + if (d < d1) { d2 = d1; m2 = m1; h2 = h1; d1 = d; m1 = s2; h1 = hs; } else if (d < d2) { d2 = d; m2 = s2; h2 = hs; } float2 o; float f = d; if (s2.w < 0.5) { @@ -118,9 +122,33 @@ float4 main(PSInput input) : SV_Target0 // Level 0 explicitly: the frost has no mips, and an implicit level after `discard` is a // derivative in divergent control flow, which Metal and GLSL let pass and HLSL does not. - float2 uv = clamp((p + outv * (g2.x * lens * steepR) - g0.xy) * g0.zw, 0.0, 1.0); + float t = 0.0; + float disp = 0.0; + float2 uv; + if (inward) { + float W = max(min(g5.y * lerp(h1, h2, wm), g6.w), 1.0); + t = clamp(1.0 - soft / W, 0.0, 1.0); + disp = g5.z * W * lens * t * t; + uv = clamp((p - outv * disp - g0.xy) * g0.zw, 0.0, 1.0); + } else { + uv = clamp((p + outv * (g2.x * lens * steepR) - g0.xy) * g0.zw, 0.0, 1.0); + } float4 frost = Frost.SampleLevel(FrostSampler, uv, 0.0); float4 sharp = g4.w > 0.5 ? Sharp.SampleLevel(SharpSampler, uv, 0.0) : frost; + if (inward && g6.y > 0.0 && disp > 0.5) { + float2 du = outv * (disp * 0.04 * g6.y) * g0.zw; + float2 uvr = clamp(uv + du, 0.0, 1.0); + float2 uvb = clamp(uv - du, 0.0, 1.0); + frost.r = Frost.SampleLevel(FrostSampler, uvr, 0.0).r; + frost.b = Frost.SampleLevel(FrostSampler, uvb, 0.0).b; + if (g4.w > 0.5) { + sharp.r = Sharp.SampleLevel(SharpSampler, uvr, 0.0).r; + sharp.b = Sharp.SampleLevel(SharpSampler, uvb, 0.0).b; + } else { + sharp.r = frost.r; + sharp.b = frost.b; + } + } // Worked out over an opaque picture of what the glass covers, then made exactly as // see-through as that is (`under`): over a translucent window (vibrancy, Acrylic) the // desktop's material shows through the glass as much as through the window round it. Laid @@ -131,22 +159,24 @@ float4 main(PSInput input) : SV_Target0 frost = float4(frost.rgb / max(frost.a, 1e-4), 1.0); sharp = float4(sharp.rgb / max(sharp.a, 1e-4), 1.0); - float mixv = g4.z > 0.5 ? g4.x * b : 0.0; + float mixv = g4.z > 0.5 ? g4.x * (inward ? 1.0 : b) : 0.0; float4 c = lerp(sharp, frost, b) * (1.0 - mixv); - float a = clamp(g2.y * lens * steepR * steepR * (1.0 - mixv), 0.0, 1.0); + float a = clamp(g2.y * lens * (inward ? t : steepR * steepR) * (1.0 - mixv), 0.0, 1.0); c = sharp * a + c * (1.0 - sharp.a * a); c += g3 * mixv; // A translucent tint lies over the frost, not over what is behind the window: the glass is // opaque here, and `under` alone says how much of the desktop's material shows through it. c += frost * (1.0 - c.a); + if (inward) c.rgb *= 1.0 - g5.w * t * t; float facing = dot(outv, float2(-0.70710678, -0.70710678)); float toward = max(facing, 0.0); float away = max(-facing, 0.0); - float spec = 0.45 * (toward * sqrt(toward) + 0.75 * away * sqrt(away)) + 0.06; + float spec = 0.45 * (toward * sqrt(toward) + 0.75 * away * sqrt(away)) + (inward ? 0.15 : 0.06); float line_ = exp(-max(0.0, -D) / g2.z); - float lit = line_ * spec + 0.04 * steepL * toward; - float lift = (g4.z > 0.5 ? g4.y * b : 0.0) + mat.z; + float lit = line_ * spec + (inward ? 0.0 : 0.04 * steepL * toward); + float lift = (g4.z > 0.5 ? g4.y * (inward ? 1.0 : b) : 0.0) + mat.z; c += (float4)clamp(lift + lens * g2.w * lit, 0.0, 1.0); + if (inward) c.rgb += g6.z * lens * t * facing; c = clamp(c, 0.0, 1.0); c.rgb += (noise(p) - 0.5) * g5.x * c.a; return clamp(c, 0.0, 1.0) * (under * cov); diff --git a/core/gfx/shaders/liquid_glass.glsl b/core/gfx/shaders/liquid_glass.glsl index d69bf4ec..e6c0bb6c 100644 --- a/core/gfx/shaders/liquid_glass.glsl +++ b/core/gfx/shaders/liquid_glass.glsl @@ -66,10 +66,15 @@ void main() { vec4 g2 = uData[2]; // reach, clarity, rim line width, light vec4 g3 = uData[3]; // tint, premultiplied vec4 g4 = uData[4]; // mix, lift, has tint, has sharp - vec4 g5 = uData[5]; // dither + vec4 g5 = uData[5]; // dither; the lens: its band's share of the shape, its bend, its shade + vec4 g6 = uData[6]; // opaque window; the lens: dispersion, bevel light, the band's cap + // Apple's lens (`glass_look.inApp`): the picture pulled inward from a band near the rim, + // magnified and, past a bend of 1, folded there. Off, the earlier glass: pulled from outside. + bool inward = g5.y > 0.0; // The two nearest shapes, for the outline and for the way out. float d1 = 1e9; float d2 = 1e9; vec4 m1 = vec4(0.0); vec4 m2 = vec4(0.0); + float h1 = 1e9; float h2 = 1e9; float f1 = 1e9; float f2 = 1e9; vec2 o1 = vec2(0.0); vec2 o2 = vec2(0.0); for (int i = 0; i < MAX_SHAPES; i++) { if (i < first) continue; @@ -79,7 +84,8 @@ void main() { vec4 s2 = uData[G + 3 * i + 2]; vec2 g; float d = sdRoundBox(p - s0.xy, s0.zw, min(s1, vec4(min(s0.z, s0.w))), g); - if (d < d1) { d2 = d1; m2 = m1; d1 = d; m1 = s2; } else if (d < d2) { d2 = d; m2 = s2; } + float hs = min(s0.z, s0.w); + if (d < d1) { d2 = d1; m2 = m1; h2 = h1; d1 = d; m1 = s2; h1 = hs; } else if (d < d2) { d2 = d; m2 = s2; h2 = hs; } vec2 o; float f = d; if (s2.w < 0.5) { @@ -108,10 +114,39 @@ void main() { float b = clamp(mat.x, 0.0, 1.0); float lens = mat.y; - // What the glass shows here: further out along the way out, as steep as it is (`seen`). - vec2 uv = clamp((p + outv * (g2.x * lens * steepR) - g0.xy) * g0.zw, 0.0, 1.0); + // What the glass shows here. Apple's lens: from further in, within the band near the rim — `t` + // 1 at the edge, 0 a band in — by `bend` bands at the edge, falling off as t², so the picture + // magnifies toward the rim and folds where the slope passes 1. The earlier glass: further out + // along the way out, as steep as it is (`seen`). + float t = 0.0; + float disp = 0.0; + vec2 uv; + if (inward) { + float W = max(min(g5.y * mix(h1, h2, wm), g6.w), 1.0); + t = clamp(1.0 - soft / W, 0.0, 1.0); + disp = g5.z * W * lens * t * t; + uv = clamp((p - outv * disp - g0.xy) * g0.zw, 0.0, 1.0); + } else { + uv = clamp((p + outv * (g2.x * lens * steepR) - g0.xy) * g0.zw, 0.0, 1.0); + } vec4 frost = TEX(uSampler, uv); vec4 sharp = g4.w > 0.5 ? TEX(uTex1, uv) : frost; + // A little colour fringe where the band bends hardest: red from a touch less far in, blue a + // touch further. + if (inward && g6.y > 0.0 && disp > 0.5) { + vec2 du = outv * (disp * 0.04 * g6.y) * g0.zw; + vec2 uvr = clamp(uv + du, 0.0, 1.0); + vec2 uvb = clamp(uv - du, 0.0, 1.0); + frost.r = TEX(uSampler, uvr).r; + frost.b = TEX(uSampler, uvb).b; + if (g4.w > 0.5) { + sharp.r = TEX(uTex1, uvr).r; + sharp.b = TEX(uTex1, uvb).b; + } else { + sharp.r = frost.r; + sharp.b = frost.b; + } + } // Worked out over an opaque picture of what the glass covers, then made exactly as // see-through as that is (`under`): over a translucent window (vibrancy, Acrylic) the // desktop's material shows through the glass as much as through the window round it. Laid @@ -122,26 +157,34 @@ void main() { frost = vec4(frost.rgb / max(frost.a, 1e-4), 1.0); sharp = vec4(sharp.rgb / max(sharp.a, 1e-4), 1.0); - // The frost at its share of a frost / tint mix, the blur coming in from sharp. - float mixv = g4.z > 0.5 ? g4.x * b : 0.0; + // The frost at its share of a frost / tint mix, the blur coming in from sharp. Apple's lens + // takes the window's colour whatever the blur: it comes in under clear glass too. + float mixv = g4.z > 0.5 ? g4.x * (inward ? 1.0 : b) : 0.0; vec4 c = mix(sharp, frost, b) * (1.0 - mixv); - // The rim's clearer glass over it (`drawClear`). - float a = clamp(g2.y * lens * steepR * steepR * (1.0 - mixv), 0.0, 1.0); + // The rim's clearer glass over it (`drawClear`): for Apple's lens, the band, so the rim stays + // clear glass while the middle frosts. + float a = clamp(g2.y * lens * (inward ? t : steepR * steepR) * (1.0 - mixv), 0.0, 1.0); c = sharp * a + c * (1.0 - sharp.a * a); // The tint (`addTint`). c += g3 * mixv; // A translucent tint lies over the frost, not over what is behind the window: the glass is // opaque here, and `under` alone says how much of the desktop's material shows through it. c += frost * (1.0 - c.a); - // The lift and the rim's light (`drawLift`), brightest facing the top left. + // Apple's lens: the folded band a little darker. + if (inward) c.rgb *= 1.0 - g5.w * t * t; + // The lift and the rim's light (`drawLift`), brightest facing the top left — for Apple's lens + // bright all round, as its rim is. float facing = dot(outv, vec2(-0.70710678, -0.70710678)); float toward = max(facing, 0.0); float away = max(-facing, 0.0); - float spec = 0.45 * (toward * sqrt(toward) + 0.75 * away * sqrt(away)) + 0.06; + float spec = 0.45 * (toward * sqrt(toward) + 0.75 * away * sqrt(away)) + (inward ? 0.15 : 0.06); float line = exp(-max(0.0, -D) / g2.z); - float lit = line * spec + 0.04 * steepL * toward; - float lift = (g4.z > 0.5 ? g4.y * b : 0.0) + mat.z; + float lit = line * spec + (inward ? 0.0 : 0.04 * steepL * toward); + float lift = (g4.z > 0.5 ? g4.y * (inward ? 1.0 : b) : 0.0) + mat.z; c += vec4(clamp(lift + lens * g2.w * lit, 0.0, 1.0)); + // Apple's lens: light across the band, lighter facing the top left and darker away — the + // glass's bevel, which shows through a full tint. + if (inward) c.rgb += g6.z * lens * t * facing; c = clamp(c, 0.0, 1.0); c.rgb += (noise(gl_FragCoord.xy) - 0.5) * g5.x * c.a; FRAG_COLOR = clamp(c, 0.0, 1.0) * (under * cov); diff --git a/core/gfx/shaders/liquid_glass.metal b/core/gfx/shaders/liquid_glass.metal index 32cd725e..6cd03f2e 100644 --- a/core/gfx/shaders/liquid_glass.metal +++ b/core/gfx/shaders/liquid_glass.metal @@ -75,9 +75,12 @@ fragment main0_out main0( float4 g2 = uData[2]; float4 g3 = uData[3]; float4 g4 = uData[4]; - float4 g5 = uData[5]; + float4 g5 = uData[5]; // dither; the lens: its band's share of the shape, its bend, its shade + float4 g6 = uData[6]; // opaque window; the lens: dispersion, bevel light, the band's cap + bool inward = g5.y > 0.0; float d1 = 1e9; float d2 = 1e9; float4 m1 = float4(0.0); float4 m2 = float4(0.0); + float h1 = 1e9; float h2 = 1e9; float f1 = 1e9; float f2 = 1e9; float2 o1 = float2(0.0); float2 o2 = float2(0.0); for (int i = first; i < first + count && i < MAX_SHAPES; i++) { float4 s0 = uData[G + 3 * i]; @@ -85,7 +88,8 @@ fragment main0_out main0( float4 s2 = uData[G + 3 * i + 2]; float2 g; float d = sdRoundBox(p - s0.xy, s0.zw, min(s1, float4(min(s0.z, s0.w))), g); - if (d < d1) { d2 = d1; m2 = m1; d1 = d; m1 = s2; } else if (d < d2) { d2 = d; m2 = s2; } + float hs = min(s0.z, s0.w); + if (d < d1) { d2 = d1; m2 = m1; h2 = h1; d1 = d; m1 = s2; h1 = hs; } else if (d < d2) { d2 = d; m2 = s2; h2 = hs; } float2 o; float f = d; if (s2.w < 0.5) { @@ -114,9 +118,33 @@ fragment main0_out main0( float b = clamp(mat.x, 0.0, 1.0); float lens = mat.y; - float2 uv = clamp((p + outv * (g2.x * lens * steepR) - g0.xy) * g0.zw, 0.0, 1.0); + float t = 0.0; + float disp = 0.0; + float2 uv; + if (inward) { + float W = max(min(g5.y * mix(h1, h2, wm), g6.w), 1.0); + t = clamp(1.0 - soft / W, 0.0, 1.0); + disp = g5.z * W * lens * t * t; + uv = clamp((p - outv * disp - g0.xy) * g0.zw, 0.0, 1.0); + } else { + uv = clamp((p + outv * (g2.x * lens * steepR) - g0.xy) * g0.zw, 0.0, 1.0); + } float4 frost = Frost.sample(FrostSampler, uv); float4 sharp = g4.w > 0.5 ? Sharp.sample(SharpSampler, uv) : frost; + if (inward && g6.y > 0.0 && disp > 0.5) { + float2 du = outv * (disp * 0.04 * g6.y) * g0.zw; + float2 uvr = clamp(uv + du, 0.0, 1.0); + float2 uvb = clamp(uv - du, 0.0, 1.0); + frost.r = Frost.sample(FrostSampler, uvr).r; + frost.b = Frost.sample(FrostSampler, uvb).b; + if (g4.w > 0.5) { + sharp.r = Sharp.sample(SharpSampler, uvr).r; + sharp.b = Sharp.sample(SharpSampler, uvb).b; + } else { + sharp.r = frost.r; + sharp.b = frost.b; + } + } // Worked out over an opaque picture of what the glass covers, then made exactly as // see-through as that is (`under`): over a translucent window (vibrancy, Acrylic) the // desktop's material shows through the glass as much as through the window round it. Laid @@ -127,22 +155,24 @@ fragment main0_out main0( frost = float4(frost.rgb / max(frost.a, 1e-4), 1.0); sharp = float4(sharp.rgb / max(sharp.a, 1e-4), 1.0); - float mixv = g4.z > 0.5 ? g4.x * b : 0.0; + float mixv = g4.z > 0.5 ? g4.x * (inward ? 1.0 : b) : 0.0; float4 c = mix(sharp, frost, b) * (1.0 - mixv); - float a = clamp(g2.y * lens * steepR * steepR * (1.0 - mixv), 0.0, 1.0); + float a = clamp(g2.y * lens * (inward ? t : steepR * steepR) * (1.0 - mixv), 0.0, 1.0); c = sharp * a + c * (1.0 - sharp.a * a); c += g3 * mixv; // A translucent tint lies over the frost, not over what is behind the window: the glass is // opaque here, and `under` alone says how much of the desktop's material shows through it. c += frost * (1.0 - c.a); + if (inward) c.rgb *= 1.0 - g5.w * t * t; float facing = dot(outv, float2(-0.70710678, -0.70710678)); float toward = max(facing, 0.0); float away = max(-facing, 0.0); - float spec = 0.45 * (toward * sqrt(toward) + 0.75 * away * sqrt(away)) + 0.06; + float spec = 0.45 * (toward * sqrt(toward) + 0.75 * away * sqrt(away)) + (inward ? 0.15 : 0.06); float line = exp(-max(0.0, -D) / g2.z); - float lit = line * spec + 0.04 * steepL * toward; - float lift = (g4.z > 0.5 ? g4.y * b : 0.0) + mat.z; + float lit = line * spec + (inward ? 0.0 : 0.04 * steepL * toward); + float lift = (g4.z > 0.5 ? g4.y * (inward ? 1.0 : b) : 0.0) + mat.z; c += float4(clamp(lift + lens * g2.w * lit, 0.0, 1.0)); + if (inward) c.rgb += g6.z * lens * t * facing; c = clamp(c, 0.0, 1.0); c.rgb += (noise(frag.xy) - 0.5) * g5.x * c.a; out.color = clamp(c, 0.0, 1.0) * (under * cov); diff --git a/core/native_glass.zig b/core/native_glass.zig new file mode 100644 index 00000000..688b792d --- /dev/null +++ b/core/native_glass.zig @@ -0,0 +1,102 @@ +//! Glass the OS draws in place of the app's: Liquid Glass on macOS 26, where a view drag's glass — +//! the carried view and the drop zones' bubbles — is one overlay of the OS's glass over every window +//! (fizzy's `Popout`), merging and refracting as the OS's glass does. Where it is on this frame, the +//! glass that would be drawn is declared here instead (`add`), in the main window's frame, and the +//! app reconciles the OS's glass with it at the end of the frame; nothing of it is drawn by the app. +//! +//! In dvui's data, as `screens` is, so whatever draws the glass — fizzy, a plugin's drop zones — +//! declares it the same way, with no SDK between them. +const std = @import("std"); +const dvui = @import("dvui"); + +/// A piece of the OS's glass: a rounded rect in the main window's frame (a float's window's band +/// past it, where its places are drawn), physical pixels. +pub const Shape = struct { + rect: dvui.Rect.Physical, + /// Physical pixels. + radius: f32, + /// How lit it is (0…1): the bubble a carried view is aimed at, as a hovered bubble lights. + lit: f32 = 0, + /// How much of it there is (0…1): coming in, going. + alpha: f32 = 1, + /// How much of the frost's blur it takes in its middle (0…1): whole for a bubble carrying an + /// icon, which reads through a blur of what is under it, as the app's own glass blurs; none + /// for the carried view, a clear lens over its picture beneath (`publishUnder`). + frost: f32 = 1, +}; + +/// The most pieces of glass in a frame: a carried drop's head and tail, and the bubbles of the +/// drops showing (a wheel's six each). +pub const max_shapes = 48; + +const key_id = dvui.Id.update(.zero, "core.native_glass"); + +const Frame = struct { + frame: i128 = 0, + on: bool = false, + n: u8 = 0, + shapes: [max_shapes]Shape = undefined, + /// Physical pixels; 0 while nothing has said. + merge_px: f32 = 0, +}; + +fn current() *Frame { + const now = dvui.currentWindow().frame_time_ns; + const f = dvui.dataGetPtrDefault(null, key_id, "_frame", Frame, .{}); + if (f.frame != now) f.* = .{ .frame = now, .on = f.on }; + return f; +} + +/// Whether the OS draws a view drag's glass this frame, set by the app before anything draws. +pub fn publishOn(on_: bool) void { + current().on = on_; +} + +/// Whether the carried view's picture goes under the OS's glass this frame, in a window of its own +/// beneath the drag's (the app's, each frame): the glass then bends it, a lens over what is carried. +/// The drag hands it over rather than drawing it over the glass. +pub fn publishUnder(on_: bool) void { + dvui.dataSet(null, key_id, "_under", on_); +} + +/// Whether the carried view's picture goes under the OS's glass this frame (`publishUnder`). +pub fn under() bool { + return dvui.dataGet(null, key_id, "_under", bool) orelse false; +} + +/// Whether the OS draws a view drag's glass this frame (`publishOn`): declare it (`add`), draw none. +pub fn on() bool { + const f = dvui.dataGetPtr(null, key_id, "_frame", Frame) orelse return false; + return f.on; +} + +/// A piece of glass for the OS to draw this frame. +pub fn add(s: Shape) void { + const f = current(); + if (f.n >= max_shapes) return; + f.shapes[f.n] = s; + f.n += 1; +} + +/// How far apart the pieces declared with it still run together, physical pixels: the merge the +/// app's own glass would have drawn them at — a drop's, which shrinks with its bubbles where a +/// small place fits them smaller (`DropZones`). The OS runs every piece together at one distance; +/// the smallest said this frame is it. +pub fn mergeWithin(px: f32) void { + const f = current(); + if (px > 0 and (f.merge_px == 0 or px < f.merge_px)) f.merge_px = px; +} + +/// This frame's merge distance (`mergeWithin`), physical pixels; null while nothing has said. +pub fn merge() ?f32 { + const f = dvui.dataGetPtr(null, key_id, "_frame", Frame) orelse return null; + if (f.frame != dvui.currentWindow().frame_time_ns or f.merge_px <= 0) return null; + return f.merge_px; +} + +/// This frame's glass, as declared so far (`add`). +pub fn shapes() []const Shape { + const f = dvui.dataGetPtr(null, key_id, "_frame", Frame) orelse return &.{}; + if (f.frame != dvui.currentWindow().frame_time_ns) return &.{}; + return f.shapes[0..f.n]; +} diff --git a/core/screens.zig b/core/screens.zig index 39645e11..72e33f27 100644 --- a/core/screens.zig +++ b/core/screens.zig @@ -79,19 +79,164 @@ const Everywhere = struct { /// the app's own windows draw none of it. A copy left in them, under the carry window, was drawn /// at a different moment from it — the window server moves the carry window, the app presents its /// windows — and trailed behind it as it moved. +/// Several a frame: the carried view, and what goes over the OS's glass with it (a drop's icons). pub fn markCarried(id: dvui.Id) void { - dvui.dataSet(null, key_id, "_carried", Carried{ .frame = dvui.currentWindow().frame_time_ns, .id = id }); + const now = dvui.currentWindow().frame_time_ns; + const c = dvui.dataGetPtrDefault(null, key_id, "_carried", Carried, .{}); + if (c.frame != now) c.* = .{ .frame = now }; + if (c.n < c.ids.len) { + c.ids[c.n] = id; + c.n += 1; + } } -/// Whether `id` is what is carried this frame (`markCarried`). +/// Whether `id` is carried this frame (`markCarried`). pub fn isCarried(id: dvui.Id) bool { - const c = dvui.dataGet(null, key_id, "_carried", Carried) orelse return false; - return c.frame == dvui.currentWindow().frame_time_ns and c.id == id; + const c = dvui.dataGetPtr(null, key_id, "_carried", Carried) orelse return false; + if (c.frame != dvui.currentWindow().frame_time_ns) return false; + for (c.ids[0..c.n]) |i| if (i == id) return true; + return false; } const Carried = struct { - frame: i128, + frame: i128 = 0, + n: u8 = 0, + ids: [4]dvui.Id = undefined, +}; + +/// Menus in windows of their own this frame (`markMenu`): the app's, each frame, with the display +/// the main window is on — natural, in the main window's frame — as the screen they are kept on, so +/// one near the window's edge hangs past it. Null: menus are drawn in the window they open from, on +/// its screen. In dvui's data, as `publish`, so a plugin's menus read it too. +pub fn publishMenus(display: ?dvui.Rect.Natural) void { + if (display) |d| dvui.dataSet(null, key_id, "_menus", d) else dvui.dataRemove(null, key_id, "_menus"); +} + +/// Whether menus are windows of their own this frame (`publishMenus`): a menu draws no frost, fill +/// or shadow of its own then — its window wears the OS's material and shadow. +pub fn nativeMenus() bool { + return dvui.dataGet(null, key_id, "_menus", dvui.Rect.Natural) != null; +} + +/// The screen a menu at `r` is kept on: the display, where menus are windows of their own and `r`'s +/// middle is on it (`publishMenus`); else `screenFor`. +pub fn menuScreenFor(r: dvui.Rect.Natural) dvui.Rect.Natural { + if (dvui.dataGet(null, key_id, "_menus", dvui.Rect.Natural)) |d| { + if (d.contains(r.center())) return d; + } + return screenFor(r); +} + +/// `menuScreenFor`, physical: what a menu clips its drawing to. +pub fn menuPixelsFor(r: dvui.Rect.Natural) dvui.Rect.Physical { + const s = menuScreenFor(r); + const m = dvui.windowNaturalScale(); + return .{ .x = s.x * m, .y = s.y * m, .w = s.w * m, .h = s.h * m }; +} + +/// Whether every menu should close this frame: where menus are windows of their own, kept above +/// every window, the app is not the active one — as an OS menu closes when its app is left +/// (`core.widgets.FloatingMenuWidget`). The app's, each frame. +pub fn publishMenusDismissed(dismissed: bool) void { + if (dismissed) dvui.dataSet(null, key_id, "_menus_dismissed", true) else dvui.dataRemove(null, key_id, "_menus_dismissed"); +} + +/// Whether every menu should close this frame (`publishMenusDismissed`). +pub fn menusDismissed() bool { + return dvui.dataGet(null, key_id, "_menus_dismissed", bool) orelse false; +} + +/// A menu, in a subwindow of its own this frame: where menus are windows of their own +/// (`publishMenus`), the app shows each in one (`Popout`). Each frame it is drawn. +pub fn markMenu(id: dvui.Id) void { + const now = dvui.currentWindow().frame_time_ns; + const m = dvui.dataGetPtrDefault(null, key_id, "_menu_ids", Marked, .{}); + if (m.frame != now) m.* = .{ .frame = now }; + if (m.n < m.ids.len) { + m.ids[m.n] = id; + m.n += 1; + } +} + +/// Whether `id` is a menu this frame (`markMenu`). +pub fn isMenu(id: dvui.Id) bool { + const m = dvui.dataGetPtr(null, key_id, "_menu_ids", Marked) orelse return false; + if (m.frame != dvui.currentWindow().frame_time_ns) return false; + for (m.ids[0..m.n]) |i| if (i == id) return true; + return false; +} + +/// Dialogs in windows of their own this frame (`markDialog`), as menus are (`publishMenus`): the +/// app's, each frame, with the display the main window is on as the screen they are kept on. Null: +/// dialogs are drawn in the window they open in. +pub fn publishDialogs(display: ?dvui.Rect.Natural) void { + if (display) |d| dvui.dataSet(null, key_id, "_dialogs", d) else dvui.dataRemove(null, key_id, "_dialogs"); +} + +/// Whether dialogs are windows of their own this frame (`publishDialogs`): a dialog draws no frost, +/// fill, shadow or dimming of its own then — its window wears the OS's material and shadow. +pub fn nativeDialogs() bool { + return dvui.dataGet(null, key_id, "_dialogs", dvui.Rect.Natural) != null; +} + +/// The screen a dialog at `r` is kept on and sized to. Where dialogs are windows of their own +/// (`publishDialogs`), the display: the one the main window is on, for a dialog over the main +/// window; for one opened in a float that is out — in that float's band — the display's size round +/// the dialog itself, its window being its own and no bigger than a display. Else `screenFor`. +pub fn dialogScreenFor(r: dvui.Rect.Natural) dvui.Rect.Natural { + const d = dvui.dataGet(null, key_id, "_dialogs", dvui.Rect.Natural) orelse return screenFor(r); + if (d.contains(r.center())) return d; + const s = screenFor(r); + if (s.equals(dvui.windowRect())) return s; + const c = r.center(); + return .{ .x = c.x - d.w / 2, .y = c.y - d.h / 2, .w = d.w, .h = d.h }; +} + +/// `dialogScreenFor`, physical: what a dialog clips its drawing to. +pub fn dialogPixelsFor(r: dvui.Rect.Natural) dvui.Rect.Physical { + const s = dialogScreenFor(r); + const m = dvui.windowNaturalScale(); + return .{ .x = s.x * m, .y = s.y * m, .w = s.w * m, .h = s.h * m }; +} + +/// A dialog, in a subwindow of its own this frame: where dialogs are windows of their own +/// (`publishDialogs`), the app shows each in one (`Popout`). Each frame it is drawn; `radius` is +/// its corners, natural, which its window's material is rounded to, and `alpha` how far it has +/// faded on its way shut. +pub fn markDialog(id: dvui.Id, radius: f32, alpha: f32) void { + const now = dvui.currentWindow().frame_time_ns; + const m = dvui.dataGetPtrDefault(null, key_id, "_dialog_ids", MarkedDialogs, .{}); + if (m.frame != now) m.* = .{ .frame = now }; + if (m.n < m.ids.len) { + m.ids[m.n] = .{ .id = id, .radius = radius, .alpha = alpha }; + m.n += 1; + } +} + +/// `id` as a dialog this frame (`markDialog`), if it is one. +pub fn dialog(id: dvui.Id) ?MarkedDialog { + const m = dvui.dataGetPtr(null, key_id, "_dialog_ids", MarkedDialogs) orelse return null; + if (m.frame != dvui.currentWindow().frame_time_ns) return null; + for (m.ids[0..m.n]) |d| if (d.id == id) return d; + return null; +} + +pub const MarkedDialog = struct { id: dvui.Id, + radius: f32, + alpha: f32, +}; + +const MarkedDialogs = struct { + frame: i128 = 0, + n: u8 = 0, + ids: [4]MarkedDialog = undefined, +}; + +const Marked = struct { + frame: i128 = 0, + n: u8 = 0, + ids: [8]dvui.Id = undefined, }; /// While something drawn across every screen may be carried past every window of the app's — a diff --git a/core/widgets/BlurBackdrop.zig b/core/widgets/BlurBackdrop.zig index acdd20cc..cbb94e73 100644 --- a/core/widgets/BlurBackdrop.zig +++ b/core/widgets/BlurBackdrop.zig @@ -1234,6 +1234,9 @@ const FrostJob = struct { .mix = self.mix, .lift = self.lift, .refraction = self.refraction, + // A pane is a dialog's, a menu's, a popover's: it carries text, and keeps some frost + // over a clear lens to read (`glass_look.forText`). + .text = true, }; field.add(.{ .rect = self.rect, diff --git a/core/widgets/ContextWidget.zig b/core/widgets/ContextWidget.zig index 90332696..da09c348 100644 --- a/core/widgets/ContextWidget.zig +++ b/core/widgets/ContextWidget.zig @@ -1,4 +1,4 @@ -//! dvui's `ContextWidget`, copied (like the menu chain beside it) for two differences: +//! dvui's `ContextWidget`, copied (like the menu chain beside it) for these differences: //! //! - **A touch hold starts even when the press was already handled.** A context area is //! declared *after* what it covers — it takes that widget's rect, and may not have children — @@ -11,6 +11,9 @@ //! pause counted as a full hold on its first frame: the menu opened and ate the tap. //! - **It registers as the root of fizzy's menu chain** (`menu/Menu.zig`'s `Root`) as well as //! dvui's, so choosing a row closes it whichever chain drew the menu it opened. +//! - **Opening its menu focuses its window** (`openMenuAt`), as a left press would: dvui focuses a +//! subwindow only for a pointer button, and a menu whose window is not the focused one closes as +//! it opens — a right-click in a float other than the focused one did nothing. const std = @import("std"); const dvui = @import("dvui"); const Menu = @import("menu/Menu.zig"); @@ -126,6 +129,12 @@ pub fn minSizeForChild(self: *ContextWidget, s: Size) void { } fn openMenuAt(self: *ContextWidget, physical_pt: Point.Physical, event_num: u16) void { + // Its window focused too, as a left press focuses it: dvui focuses a subwindow only for a + // pointer button's press, and the menu opening here closes at once when its parent window is + // not the focused one (`FloatingMenu.chainFocused`) — a right-click on a float other than the + // focused one did nothing until a left click had focused it. Not raised: a right-click leaves + // a window where it is in the stacking, as on the OS. + dvui.focusSubwindow(self.winId, event_num); dvui.focusWidget(self.data().id, null, event_num); self.focused = true; diff --git a/core/widgets/DropZones.zig b/core/widgets/DropZones.zig index 5ab488b5..b6e83236 100644 --- a/core/widgets/DropZones.zig +++ b/core/widgets/DropZones.zig @@ -38,6 +38,7 @@ const std = @import("std"); const dvui = @import("dvui"); +const native_glass = @import("../native_glass.zig"); const dialogs = @import("../dialogs.zig"); const widgets = @import("../widgets.zig"); const BlurBackdrop = @import("BlurBackdrop.zig"); @@ -130,21 +131,45 @@ pub const Wheel = struct { /// Every zone, in the order they are drawn and stored. pub const all = [_]Zone{ .center, .{ .edge = .left }, .{ .edge = .right }, .{ .edge = .top }, .{ .edge = .bottom }, .remove }; -/// Points: the bubbles' layout as a wheel — the middle, the four sides at `side_d` from it, the -/// trash on the diagonal between the right and the bottom — about 300 across: big enough to aim at -/// without looking, small enough to leave the place in view round it. -const center_r: f32 = 52; +/// Points: the bubbles' layout as a wheel — the middle, the four sides round it, the trash in the +/// crook between the right and the bottom — about 345 across: big enough to aim at without +/// looking, small enough to leave the place in view round it. Each bubble rests `gap` from the +/// ones beside it. +/// The middle a quarter bigger than it was (52): the drop reads as one glass round it, not a +/// cross of equals (the user). +const center_r: f32 = 65; const side_r: f32 = 40; -const side_d: f32 = 108; const remove_r: f32 = 30; -const remove_d: f32 = 88; +/// Points between a bubble and the ones beside it, at rest: past where two run together (half of +/// `merge`, for the glass's smooth union and the OS's container alike, measured), so each bubble is +/// born inside the middle, pinches off it on its way out (`grow`) and rests a drop of its own — +/// until the carried view is aimed at one, which swells (`join_swell`) until it runs into those +/// beside it. Resting joined, they read as merged all the time; just past half the merge, they +/// still reached for each other (the user). +const gap: f32 = merge * 0.75; +const side_d: f32 = center_r + side_r + gap; +/// How much bigger the bubble the carried view is aimed at grows, as it lights: enough to close the +/// gap to the bubbles beside it, so it runs into them — a side into the middle, the middle into +/// every side — and into what is carried. The bubble alone, not the whole drop (the user). +pub const join_swell: f32 = 0.3; +/// Points the trash keeps from the others even while one beside it swells: a drop of its own, +/// joining none. +const remove_apart: f32 = merge / 2 + 1; +/// The trash on the diagonal, `remove_apart` from the right and the bottom swollen — +/// |(side_d − x, x)| is d = side_r·(1 + join_swell) + remove_r + remove_apart at +/// x = (side_d + √(2·d² − side_d²)) / 2, the root out from the middle — and from the middle swollen. +const remove_d: f32 = blk: { + const d = side_r * (1 + join_swell) + remove_r + remove_apart; + const from_sides = (side_d + @sqrt(2 * d * d - side_d * side_d)) / 2; + const from_middle = (center_r * (1 + join_swell) + remove_r + remove_apart) / std.math.sqrt2; + break :blk @max(from_sides, from_middle); +}; /// Points: as a strip, one line with the wheel's own gap between each bubble and the next — the /// two sides the place's ends are at `end_d`, the other two beside the middle at `side_d`, and the -/// trash past the end on its side. Across a place: left, top, middle, bottom, right, trash; down +/// trash apart past the end on its side. Across a place: left, top, middle, bottom, right, trash; down /// one: top, left, middle, right, bottom, trash. The icons say which edge each is. -const gap: f32 = side_d - center_r - side_r; const end_d: f32 = side_d + 2 * side_r + gap; -const strip_remove_d: f32 = end_d + side_r + gap + remove_r; +const strip_remove_d: f32 = end_d + side_r * (1 + join_swell) + remove_apart + remove_r; /// Points: a bubble's icon, as a share of its radius. const bubble_icon: f32 = 0.6; /// How far the cluster may reach either side of the middle, as a share of the place's size that @@ -158,6 +183,10 @@ pub const strip_gain: f32 = 1.15; /// How far past its edge a bubble still takes the pointer, as a share of its radius: a drop /// aimed at a bubble's rim is aimed at the bubble. const reach: f32 = 1.25; +/// How near a drop takes a bubble, as a share of the two radii together between their middles: a +/// little before they touch, so the drop reaches for the bubble rather than having to be pushed +/// into it (the user: more attraction near the bubbles). +const disc_reach: f32 = 1.2; /// Where the drop sits over `bounds`: in its middle, as a wheel — or, where a strip along the /// place would make the bubbles `strip_gain` bigger than a wheel fitted to it, as that strip. @@ -367,13 +396,13 @@ pub fn at(w: Wheel, p: dvui.Point.Physical) ?Zone { } /// The zone a drop of radius `r` centred on `c` reads as — a carried drop of glass, aimed by -/// where it is rather than by the finger beside it: of the bubbles it touches, the one it is most -/// into (nearest for their sizes together), and nothing touching none. A point (`r` 0) reads as -/// `at`. +/// where it is rather than by the finger beside it: of the bubbles it touches or nearly does +/// (`disc_reach`), the one it is most into (nearest for their sizes together), and nothing near +/// none. A point (`r` 0) reads as `at`. pub fn atDisc(w: Wheel, c: dvui.Point.Physical, r: f32) ?Zone { if (r <= 0) return at(w, c); var best: ?Zone = null; - var best_d: f32 = 1; + var best_d: f32 = disc_reach; for (all) |z| { if (z == .remove and !w.remove) continue; const b = w.bubble(z); @@ -564,7 +593,9 @@ pub fn draw(id: dvui.Id, w: Wheel, scale: f32, look: Look) bool { const t = orbTime(st.shown, j, order.len); const k = grow(t); const b = drawn.bubble(all[zi]); - const r = b.r * k; + // The bubble the carried view is aimed at swells as it lights (`join_swell`) — the + // trash, a drop of its own, keeps its size. + const r = b.r * k * (if (all[zi] == .remove) 1 else 1 + join_swell * st.lit[zi]); if (r < 0.5) continue; // Out of the middle to where it settles, on the same curve as its size: each bubble // is born inside the drop and pinches off it on its way out (`merge`), past its place @@ -577,7 +608,13 @@ pub fn draw(id: dvui.Id, w: Wheel, scale: f32, look: Look) bool { } // Changing shape, what is read moves and grows with the bubbles: at a size kept while it // fits, as for the carried drop, so it is not new targets every frame of the change. - took = glassCarrying(id, panes[0..n], swingRect(drawn), g, scale, merge * drawn.unit, look.carried, reshaping or sliding); + // Where the OS draws a view drag's glass (`native_glass`), the bubbles are declared for it + // instead: its glass runs them together, and into the carried drop, itself. + took = if (native_glass.on()) native: { + for (panes[0..n]) |pane| native_glass.add(.{ .rect = pane.r, .radius = pane.r.w / 2, .lit = pane.lit, .alpha = g }); + native_glass.mergeWithin(merge * drawn.unit); + break :native false; + } else glassCarrying(id, panes[0..n], swingRect(drawn), g, scale, merge * drawn.unit, look.carried, reshaping or sliding); st.icon_n = 0; st.icon_frame = now; for (panes[0..n], zones[0..n], times[0..n]) |pane, i, t| { @@ -620,11 +657,12 @@ fn orbTime(shown: f32, j: usize, n: usize) f32 { return std.math.clamp((shown - stagger * @as(f32, @floatFromInt(j))) / span, 0, 1); } -/// Points: how far apart two bubbles still run together (`LiquidField.merge_px`) — about half a -/// side bubble across, so one leaving the drop draws a neck out of it that thins and lets go well -/// before it settles. A view carried as a drop is drawn at it too, over a place or between them -/// (`ViewDrag`): a join's swell is part of a shape's size, so glass drawn at two merges is two sizes. -pub const merge: f32 = 24; +/// Points: how far apart two shapes of glass start to run together (`LiquidField.merge_px`; they +/// join below half of it) — a side bubble's radius or so, so the carried drop reaches for a bubble +/// from well off and one leaving the drop draws a long neck out of it. A view carried as a drop is +/// drawn at it too, over a place or between them (`ViewDrag`): a join's swell is part of a shape's +/// size, so glass drawn at two merges is two sizes. +pub const merge: f32 = 36; /// How far behind the one before each orb starts, as a share of `shown`. const stagger: f32 = 0.06; diff --git a/core/widgets/FloatingWindowWidget.zig b/core/widgets/FloatingWindowWidget.zig index ce9d1b6d..95b74d23 100644 --- a/core/widgets/FloatingWindowWidget.zig +++ b/core/widgets/FloatingWindowWidget.zig @@ -139,6 +139,11 @@ pub const InitOptions = struct { /// Natural units a `detached` window draws past its own rect: its shadow's reach, which its OS /// window holds round it (a clear margin), so it is drawn as it is in the main window. detached_reach: f32 = 0, + /// Shown in an OS window of its own, where dialogs are (`core.screens.nativeDialogs`): marked + /// for the app to take its drawing there (`core.screens.markDialog`), and drawn with none of the + /// dimming, frost, shadow or fill a window inside the main one has — its OS window wears the + /// OS's material and shadow, and the main window stays as it is beside it. + native: bool = false, window_avoid: enum { none, @@ -190,6 +195,8 @@ prev_rendering: bool = undefined, /// Window alpha before the close-flight fade replaced it, restored in `deinit`. Null while no /// close animation is running, which is every frame of a window's normal life. prev_alpha: ?f32 = null, +/// How far a `native` window has faded along its close flight, for its OS window to fade with it. +native_alpha: f32 = 1, wd: WidgetData, init_options: InitOptions, /// options is for our embedded BoxWidget @@ -395,7 +402,7 @@ pub fn init(self: *FloatingWindowWidget, src: std.builtin.SourceLocation, init_o self.auto_size_refresh_prev_value = dvui.currentWindow().extra_frames_needed; dvui.currentWindow().extra_frames_needed = 0; - const ms = Size.min(Size.max(min_size, self.options.min_sizeGet()), .cast(screens.screenFor(.cast(self.wd.rect)).size())); + const ms = Size.min(Size.max(min_size, self.options.min_sizeGet()), .cast(self.keptOn().size())); if (self.init_options.auto_size_axes.animatesWidth() and ms.w != self.wd.rect.w) { if (dvui.animationGet(self.wd.id, "_auto_width")) |a| { @@ -492,7 +499,7 @@ pub fn init(self: *FloatingWindowWidget, src: std.builtin.SourceLocation, init_o // always make sure we are on the screen — the one it is on (`core.screens`: the main // window's, or a popped-out float's), unless it has a window of its own if (!self.init_options.detached) { - var screen = screens.screenFor(.cast(self.wd.rect)); + var screen = self.keptOn(); // okay if we are off the left or right but still see some const offleft = self.wd.rect.w - 48; screen.x -= offleft; @@ -571,6 +578,7 @@ pub fn init(self: *FloatingWindowWidget, src: std.builtin.SourceLocation, init_o // opacity loss in the final moments and lands on zero exactly at the destination. const t = std.math.clamp((travelled - 0.55) / 0.45, 0, 1); self.prev_alpha = dvui.alpha(1 - t * t); + self.native_alpha = 1 - t * t; } } @@ -583,10 +591,18 @@ pub fn init(self: *FloatingWindowWidget, src: std.builtin.SourceLocation, init_o } } +/// The screen the window is kept on and sized to (`core.screens`): its window's own, for one in an +/// OS window of its own (`native`, `screens.dialogScreenFor`). +fn keptOn(self: *FloatingWindowWidget) dvui.Rect.Natural { + if (self.init_options.native) return screens.dialogScreenFor(.cast(self.wd.rect)); + return screens.screenFor(.cast(self.wd.rect)); +} + /// The OS window the window is drawn in, physical: the screen it is on (`core.screens`: the main /// window's, or a popped-out float's — a popover or dialog opened in it), or — `detached` — the /// window's own rect, which is all of its OS window there is. fn windowClip(self: *FloatingWindowWidget) Rect.Physical { + if (self.init_options.native) return screens.dialogPixelsFor(.cast(self.data().rect)); if (self.init_options.detached) { const rs = self.data().rectScale(); return rs.r.outsetAll(self.init_options.detached_reach * rs.s); @@ -598,8 +614,10 @@ pub fn drawBackground(self: *FloatingWindowWidget) void { const rs = self.data().rectScale(); dvui.subwindowAdd(self.data().id, self.data().rect, rs.r, self.init_options.modal, if (self.init_options.stay_above_parent_window) self.prev_windowInfo.id else null, true); dvui.captureMouseMaintain(.{ .id = self.data().id, .rect = rs.r, .subwindow_id = self.data().id }); + const native = self.init_options.native; + if (native) screens.markDialog(self.data().id, self.options.cornersGet().finalize(self.options.themeGet()).tl.radius(), self.native_alpha); - if (self.init_options.modal and !dvui.firstFrame(self.data().id)) { + if (self.init_options.modal and !native and !dvui.firstFrame(self.data().id)) { // paint over everything below var col = self.options.color(.text).toColor(); col.a = self.init_options.modal_alpha orelse (if (dvui.themeGet().dark) 60 else 80); @@ -615,9 +633,10 @@ pub fn drawBackground(self: *FloatingWindowWidget) void { // window's own interior too; drawn after the frost (as `borderAndBackground` would) it laid // its black over the glass. The frost replaces what is under the window, so a shadow drawn // first survives only outside it — where a shadow belongs. - if (self.init_options.frost != null) box_opts.box_shadow = null; + if (self.init_options.frost != null or native) box_opts.box_shadow = null; + if (native) box_opts.background = false; self.layout.init(@src(), .{ .dir = .vertical }, box_opts); - if (self.init_options.frost) |frost| { + if (if (native) null else self.init_options.frost) |frost| { self.drawFrost(frost); // The shadow as a ring round the glass, after it, so the glass never blurs it in // (`core.dialogs.glassShadow`) — a box shadow under glass darkened its middle and ringed diff --git a/core/widgets/TreeWidget.zig b/core/widgets/TreeWidget.zig index b5b1bf3f..cdc23606 100644 --- a/core/widgets/TreeWidget.zig +++ b/core/widgets/TreeWidget.zig @@ -474,13 +474,6 @@ pub const Branch = struct { return self.floating_widget != null; } - /// Where the row floats under the pointer while it is dragged, its glass included: what a - /// drag carrying it on out of the tree grows out of. Null when it is not floating. - pub fn floatingRect(self: *Branch) ?dvui.Rect.Physical { - if (!self.floating()) return null; - return self.floating_widget.?.data().borderRectScale().r; - } - pub fn install(self: *Branch) void { self.installed = true; var check_button_hovered: bool = false; @@ -512,8 +505,9 @@ pub const Branch = struct { npt.y += @as(f32, @floatFromInt(stack_i)) * (drag_min.h + row_gap); // Carried, a row is glass, as a tab carried along its strip is - // (`dialogs.carriedGlass`): the look a file keeps when it is carried out of the - // tree as the app's view drag. As big as the row was when it was lifted: it is + // (`dialogs.carriedGlass`). (A list whose rows another drag can carry hands them + // to it as they are lifted, and reads them back with `carriedOver`: fizzy's file + // tree, a file to the app's view drag.) As big as the row was when it was lifted: it is // held where it was grabbed (`dragOffset`), and sized to its name instead, a short // name grabbed toward the row's right end floated off to the left of the pointer. // Its rect, not a minimum: given no size, a floating widget takes its children's, diff --git a/core/widgets/menu/FloatingMenu.zig b/core/widgets/menu/FloatingMenu.zig index a26a3909..eefbf653 100644 --- a/core/widgets/menu/FloatingMenu.zig +++ b/core/widgets/menu/FloatingMenu.zig @@ -162,13 +162,14 @@ pub fn init(self: *FloatingMenu, src: std.builtin.SourceLocation, init_opts: Ini } if (init_opts.from) |fr| { - // On the screen it opens from (`core.screens`): a float's own window, when that is out. - self.data().rect = .cast(dvui.placeOnScreen(screens.screenFor(fr).insetAll(screen_margin), fr, avoid, .cast(self.data().rect))); + // On the screen it opens from (`core.screens`): a float's own window, when that is out — or + // the display, where menus are windows of their own, hanging past the window's edge. + self.data().rect = .cast(dvui.placeOnScreen(screens.menuScreenFor(fr).insetAll(screen_margin), fr, avoid, .cast(self.data().rect))); } else { const centering: Rect.Natural = dvui.currentWindow().subwindows.current_rect; self.wd.rect.x = centering.x + (centering.w - self.wd.rect.w) / 2; self.wd.rect.y = centering.y + (centering.h - self.wd.rect.h) / 2; - self.wd.rect = .cast(dvui.placeOnScreen(screens.screenFor(centering).insetAll(screen_margin), .{}, .none, .cast(self.data().rect))); + self.wd.rect = .cast(dvui.placeOnScreen(screens.menuScreenFor(centering).insetAll(screen_margin), .{}, .none, .cast(self.data().rect))); } if (dvui.snapToPixels()) { @@ -188,11 +189,13 @@ pub fn init(self: *FloatingMenu, src: std.builtin.SourceLocation, init_opts: Ini self.render_ftb.initReset(); self.prev_windowInfo = dvui.subwindowCurrentSet(self.data().id, null); dvui.subwindowAdd(self.data().id, self.data().rect, rs.r, self.style == .popup, null, true); + // A window of its own, where menus are (`core.screens.publishMenus`). + screens.markMenu(self.data().id); dvui.captureMouseMaintain(.{ .id = self.data().id, .rect = rs.r, .subwindow_id = self.data().id }); self.prevClip = dvui.clipGet(); // Break out of whatever clipping we were in — onto its screen (`core.screens`): clipped to // the main window, a menu in a float that is out drew nothing. - dvui.clipSet(screens.pixelsFor(.cast(self.data().rect))); + dvui.clipSet(screens.menuPixelsFor(.cast(self.data().rect))); self.prev_scroll = dvui.ScrollContainerWidget.scrollSet(null); } @@ -222,7 +225,10 @@ pub fn init(self: *FloatingMenu, src: std.builtin.SourceLocation, init_opts: Ini // what is under the panel: a shadow painted after it would lay its black over the glass, // where a shadow drawn first survives only outside the panel, which is where it belongs. // The fill then goes over the frost as `color_fill`, translucent, so the blur reads through. - if (init_opts.frost) |frost| { + // In a window of its own (`core.screens.nativeMenus`) the window wears the OS's material and + // shadow: none of either here, and no fill. + const native = screens.nativeMenus(); + if (if (native) null else init_opts.frost) |frost| { const brs = self.data().borderRectScale(); // Finalized before scaling: a `CornerRect` carries the theme's corner *kind* at a size, // and `WidgetData.init` is what normally resolves it. An unresolved corner draws square @@ -238,10 +244,11 @@ pub fn init(self: *FloatingMenu, src: std.builtin.SourceLocation, init_opts: Ini // we are using scroll to do border/background but floating windows // don't have margin, so turn that off var scroll_opts = options.override(.{ .margin = .{}, .expand = .both }); - if (init_opts.frost != null) { + if (init_opts.frost != null or native) { // The shadow is already down; drawing it again from the scroll area would double it. scroll_opts.box_shadow = null; } + if (native) scroll_opts.background = false; // No scrollbar while it slides open: shorter than its rows for those frames, it would show // one for a list that fits. A menu too long for the window still gets one once it is open. self.scroll.init(@src(), .{ .horizontal = .none, .vertical_bar = if (self.revealing) .hide else .auto }, scroll_opts); @@ -371,6 +378,13 @@ pub fn deinit(self: *FloatingMenu) void { dvui.refresh(null, @src(), self.data().id); } + // In a window of its own over every window, a menu closes when the app is left, as an OS + // menu does (`core.screens.menusDismissed`): left open, it stood over the other app. + if (screens.menusDismissed()) { + self.menu.close_chain(.unintentional); + dvui.refresh(null, @src(), self.data().id); + } + self.menu.deinit(); self.scroll.deinit(); self.scaler.deinit(); diff --git a/docs/POPOUT_WINDOWS_PLAN.md b/docs/POPOUT_WINDOWS_PLAN.md index 4dbbfce2..331eb892 100644 --- a/docs/POPOUT_WINDOWS_PLAN.md +++ b/docs/POPOUT_WINDOWS_PLAN.md @@ -6,7 +6,9 @@ expensive part to re-derive. ## Where it stands -**P6, floats are windows, is under way (2026-10-04), behind `FIZZY_POPOUT=1`.** After testing the +**P6, floats are windows, is on by default on macOS (2026-10-05; `FIZZY_POPOUT=0` turns it off), +and behind `FIZZY_POPOUT=1` elsewhere.** There, a view drag's glass is the OS's Liquid Glass where +it exists (`docs/NATIVE_WINDOWS_PLAN.md`; `FIZZY_NATIVE_GLASS=0` turns it off). After testing the P3 gesture, the user's call: "we want native os windows where we can have them" — a float that is a window from the frame it is made in, as VS Code's floating windows are, rather than one that splits out at the main window's edge. Nearly everything that went wrong in P3's polish lived in that diff --git a/plugins/workbench/src/files.zig b/plugins/workbench/src/files.zig index d73b4d04..264bda04 100644 --- a/plugins/workbench/src/files.zig +++ b/plugins/workbench/src/files.zig @@ -15,7 +15,9 @@ pub var selected_id: ?usize = null; pub var edit_id: ?usize = null; /// The tree's own drag, a row moved among the others. A name of its own, so nothing else takes -/// it: a file carried out of the tree is handed to the app's view drag first (`carryOut`). +/// it. A file is the app's view drag from the moment it is lifted (`carryOut`), moved among the +/// rows or carried out of them alike; the tree's own drag is for what the view drag cannot carry — +/// a folder, a file nothing opens. const tree_drag = "workbench.file_row"; /// What of the tree is on screen this frame, and the window it is drawn in: a file carried past /// either has left the tree (`leftTree`). @@ -25,6 +27,11 @@ var tree_window: dvui.Id = .zero; /// id it is carried by. While that drag lasts, the file brought back over the tree is a row again /// (`carryBack`). Heap-owned (`runtime.allocator`); null when nothing is carried out. var carried: ?struct { path: []u8, id: []u8 } = null; +/// The selected rows' icons and names together, as drawn this frame and the last (`rowContent`): +/// rows lifted with the one under the pointer run into its glass from where they all were. Last +/// frame's, because the rows below the one lifted are not drawn yet when it is. +var selection_rect: ?dvui.Rect.Physical = null; +var selection_rect_last: ?dvui.Rect.Physical = null; /// Multi-selection for the file tree. Maps `id_extra` (hash of absolute path) to the heap-owned /// absolute path string. The primary `selected_id` is always a key here when set. Paths are @@ -83,6 +90,8 @@ pub const Extension = enum { }; pub fn draw() !void { + selection_rect_last = selection_rect; + selection_rect = null; var tree = core.widgets.TreeWidget.tree(@src(), .{ .enable_reordering = true, .drag_name = tree_drag }, .{ .background = false, .expand = .both }); defer tree.deinit(); tree_rect = tree.data().borderRectScale().r.intersect(dvui.clipGet()); @@ -955,13 +964,6 @@ pub fn recurseFiles(root_directory: []const u8, root_label: []const u8, outer_tr if (branch.floating()) { if (dvui.dataGetSlice(null, inner_unique_id, "removed_path", []u8) == null) dvui.dataSetSlice(null, inner_unique_id, "removed_path", abs_path); - - // Out of the tree, a file is carried as a document tab is off its strip. - if (entry.kind == .file and tree.id_branch == inner_id_extra.*) { - // From the row as it floats, its glass just laid, so the carried - // glass grows out of it rather than out of the row's slot in the tree. - if (leftTree(dvui.currentWindow().mouse_pt)) carryOut(tree, abs_path, branch.floatingRect() orelse branch.data().borderRectScale().r); - } } if (branch.insertBefore()) { @@ -1015,11 +1017,13 @@ pub fn recurseFiles(root_directory: []const u8, root_label: []const u8, outer_tr // filesystem icons when no plugin claims it. A plugin's icon is arbitrary // art at an arbitrary aspect ratio, so it is boxed to the shared row-glyph // size like every other glyph rather than being trusted to behave. + var file_icon: dvui.RectScale = .{}; { const prof_icon = core.profile.section("row icon"); defer prof_icon.end(); var icon_slot = core.widgets.treeRowGlyph(@src(), .{ .margin = .{ .w = 2 } }); defer icon_slot.deinit(); + file_icon = icon_slot.data().borderRectScale(); if (!runtime.host().drawFileIcon(std.fs.path.extension(entry.name), abs_path, icon_color)) { const icon = switch (ext) { @@ -1051,6 +1055,13 @@ pub fn recurseFiles(root_directory: []const u8, root_label: []const u8, outer_tr dvui.log.err("Failed to draw editable label", .{}); }; + const content = rowContent(branch, file_icon, file_label); + if (selected) selection_rect = unionRect(selection_rect, content); + // Lifted, a file is carried as a document tab is off its strip, the + // same glass over the tree and off it: out of its icon and its name. + if (liftedHere(tree, branch, inner_id_extra.*)) + carryOut(tree, abs_path, if (tree.drag_branch_ids != null) unionRect(selection_rect_last, content).? else content); + if (doc) |d| { if (d.owner.showsSaveStatusIndicator(d)) { core.dialogs.bubbleSpinner(@src(), .{ @@ -1090,6 +1101,7 @@ pub fn recurseFiles(root_directory: []const u8, root_label: []const u8, outer_tr { var icon_slot = core.widgets.treeRowGlyph(@src(), .{ .margin = .{ .w = 2 } }); defer icon_slot.deinit(); + if (selected and root == null) selection_rect = unionRect(selection_rect, rowContent(branch, icon_slot.data().borderRectScale(), folder_name)); _ = core.icon.icon( @src(), "FolderIcon", @@ -1452,6 +1464,27 @@ fn selectionBranchIdsForMultiDrag(arena: std.mem.Allocator) ![]const usize { return out; } +/// The tree's own drag of row `id` began this frame: the row is still in its place, drawn there +/// as it was, not floating yet (`TreeWidget.Branch.floating`) — and not carried back over the tree. +fn liftedHere(tree: *core.widgets.TreeWidget, branch: *core.widgets.TreeWidget.Branch, id: usize) bool { + if (tree.carried or tree.drag_ending or branch.floating()) return false; + return tree.drag_point != null and tree.id_branch == id; +} + +/// A row's icon and name, where they are drawn: what a row lifted out of the tree narrows to. +/// `icon` is the row's glyph slot; the name runs on from it as `editableLabel` lays it out — past +/// the slot's margin, inside the label's padding — and stops at the row's end. +fn rowContent(branch: *core.widgets.TreeWidget.Branch, icon: dvui.RectScale, label: []const u8) dvui.Rect.Physical { + const row = branch.button.data().borderRectScale().r; + const text = dvui.Font.theme(.body).textSize(label).w; + const right = @min(icon.r.x + icon.r.w + (2 + 3 + text + 3) * icon.s, row.x + row.w); + return .{ .x = icon.r.x, .y = row.y, .w = @max(icon.r.w, right - icon.r.x), .h = row.h }; +} + +fn unionRect(a: ?dvui.Rect.Physical, b: dvui.Rect.Physical) ?dvui.Rect.Physical { + return if (a) |r| r.unionWith(b) else b; +} + /// Whether a row carried to `p` has left the tree: past what of it is on screen, or over a window /// that lies over it there — a float — which is not the tree's to take a drop for. fn leftTree(p: dvui.Point.Physical) bool { @@ -1463,8 +1496,9 @@ fn leftTree(p: dvui.Point.Physical) bool { /// over a strip, landing through the pane's drop (`Workspace.paneDrop`). Open, it is its document /// — the pane showing it photographs it for the drag. Not open, it is the id its document will /// have, from the plugin that will open it (`Host.pluginForExtension`): carried as its file's -/// icon, and opened where it is let go. `from` is the row as it floats under the pointer, which -/// the drag grows out of. A file nothing can open stays a row being moved in the tree. +/// icon, and opened where it is let go. `from` is what the drag grows out of: the row's icon and +/// name where they were when it was lifted (`rowContent`), the selected rows' together when it is +/// lifted with them. A file nothing can open stays a row being moved in the tree. /// /// The tree's own drag is put down, not ended (`TreeWidget.cancelDrag`): ended, the tree would /// drop the row next frame on whatever row was last under the pointer. Brought back over the tree diff --git a/src/Entry.zig b/src/Entry.zig index 19b4c77d..0dc02f1e 100644 --- a/src/Entry.zig +++ b/src/Entry.zig @@ -16,6 +16,7 @@ const auto_update = @import("app").update.auto_update; const file_assoc = @import("backend/file_assoc.zig"); const update_notify = @import("app").update.update_notify; const singleton = @import("app").single_instance; +const restart = @import("app").restart; const automation = @import("app").automation; const paths = fizzy.core.paths; const Constants = @import("editor/Constants.zig"); @@ -367,6 +368,10 @@ pub fn AppInit(win: *dvui.Window) !void { // Run as app is shutting down before dvui.Window.deinit() pub fn AppDeinit(_: *dvui.Window) void { + // A restart asked for (the Restart command, a downloaded update): started again once + // everything is saved and the single-instance listener is down, so the new instance owns it + // rather than handing its launch to this one. + defer restart.relaunch(dvui.io, appAllocator()); // Persist the current windowed frame while the window still exists. No-op off macOS. fizzy.backend.saveWindowGeometry(fizzy.entry().window); // `editor.deinit` runs each plugin's `deinit` first (pixi's persists its `.fizproject` and @@ -404,7 +409,7 @@ fn frameOnce() !dvui.App.Result { // seen (see `app.automation.Player.frame`). fizzy.editor().demo.frame(); // A float asked out of the main window, or back into it, goes before anything is drawn - // (`Editor.Popout`, behind `FIZZY_POPOUT`). + // (`Editor.Popout`: on by default on macOS, `FIZZY_POPOUT` elsewhere). Editor.Popout.beginFrame(&fizzy.editor().app.layout); // The whole frame draws into a texture — see `core.FrameTarget` for why. { diff --git a/src/backend/backend_native.zig b/src/backend/backend_native.zig index 233fd4b5..4255e454 100644 --- a/src/backend/backend_native.zig +++ b/src/backend/backend_native.zig @@ -38,6 +38,9 @@ pub const installTrackpadGestureMonitor = platform.gestures.installTrackpadGestu pub const takeTrackpadPinchRatio = platform.gestures.takeTrackpadPinchRatio; pub const isMaximized = platform.window.isMaximized; +pub const coversDesktop = platform.window.coversDesktop; +pub const enteringSpace = platform.window.enteringSpace; +pub const spaceFullness = platform.window.spaceFullness; pub const isFullscreenChromeHidden = platform.window.isFullscreenChromeHidden; pub const setWindowStyle = platform.window.setStyle; pub const setTitlebarColor = platform.window.setBackground; @@ -120,6 +123,52 @@ pub fn saveWindowGeometry(win: *dvui.Window) void { /// Called at the end of AppInit: the monitor may drive frames through window animations now. pub const macosLaunchComplete = platform.macos_monitor.launchComplete; +/// A window's Liquid Glass this frame (`windowGlass`, `viewports.windowGlass`): what +/// `core.glass_look.window` says at the window's opacity, the window's colour, and the clearing +/// bevel `core.glass_look.band` gives its size. +pub const WindowGlassLook = struct { + under_variant: i32, + under_style: i32, + over_variant: i32, + over_style: i32, + frost: f32, + blur: f32, + glass: f32, + fill: dvui.Color, + /// The colour's opacity in the body… + fill_opacity: f32, + /// …and over all of it, the bevel too. + top_fill: f32, + /// Points, each. + radius: f32, + clear: f32, + feather: f32, + + fn native(self: WindowGlassLook) platform.window.WindowGlass { + return .{ + .under_variant = self.under_variant, + .under_style = self.under_style, + .over_variant = self.over_variant, + .over_style = self.over_style, + .frost = self.frost, + .blur = self.blur, + .glass = self.glass, + .fill = .{ @as(f64, @floatFromInt(self.fill.r)) / 255, @as(f64, @floatFromInt(self.fill.g)) / 255, @as(f64, @floatFromInt(self.fill.b)) / 255, self.fill_opacity }, + .top_fill = self.top_fill, + .radius = self.radius, + .clear = self.clear, + .feather = self.feather, + }; + } +}; + +/// The main window's Liquid Glass this frame, where it is a window of Liquid Glass (macOS 26): +/// whether it is — its colour is then under the glass, and the frame draws no base of its own. +pub fn windowGlass(win: *dvui.Window, look: WindowGlassLook) bool { + if (comptime builtin.os.tag != .macos) return false; + return platform.window.liquidGlassLook(win.backend.impl.window, look.native()); +} + /// OS windows besides the main one, each showing a part of the one frame — a float popped out /// (`docs/POPOUT_WINDOWS_PLAN.md`, the backend's `Viewport`). Fizzy's own backend only: on dvui's /// SDL3 backend (`-Dnative-backend=sdl3`) there are none, as on the web, and floats stay in. @@ -152,6 +201,154 @@ pub const viewports = struct { return dvui.currentWindow().backend.impl.viewportOpenCarry(at); } + /// Whether one of the app's windows has the keyboard: the app is the active one. + pub fn appActive() bool { + if (comptime !supported) return true; + return Impl.appActive(); + } + + /// Whether menus can be windows of their own (`openMenu`): macOS, where viewports are. + pub const menus = supported and builtin.os.tag == .macos; + + /// A window for a menu (`Popout`'s menus): borderless, above every window, never focused, the + /// OS's material in it — rounded by `radius` points — and the pointer over it read in the main + /// window's frame (`mainOffset`). Placed with `placeRiding`, drawn as any viewport. `ride`: the + /// window the OS moves it with — smoothly, through a drag. `dialog`: a dialog's window, the same + /// but in its window's stacking, not above every window. + pub fn openMenu(at: Rect, radius: f32, ride: Ride, dialog: bool) ?*Viewport { + if (comptime !supported) return null; + return dvui.currentWindow().backend.impl.viewportOpenMenu(at, radius, ride, dialog); + } + + pub const Ride = if (supported) Impl.Ride else union(enum) { none, main, viewport: *Viewport }; + + /// `placeMain` for a window riding on another (`openMenu`'s `ride`), put somewhere new only when + /// `key` — where it lies over that window — changes. + pub fn placeRiding(vp: *Viewport, frame: Rect, key: Rect) Rect { + if (comptime !supported) return frame; + return dvui.currentWindow().backend.impl.viewportPlaceRiding(vp, frame, key); + } + + /// Where menu `vp` is drawn in the frame from where its window lies over the main window, + /// physical pixels: none for a menu of the main window's. + pub fn mainOffset(vp: *Viewport, offset: dvui.Point.Physical) void { + if (comptime !supported) return; + dvui.currentWindow().backend.impl.viewportMainOffset(vp, .{ .x = offset.x, .y = offset.y }); + } + + /// A piece of the OS's glass in an overlay (`overlayGlass`): a rounded rect, points from the + /// overlay window's top left. + pub const GlassShape = if (supported) Impl.GlassShape else extern struct { x: f64, y: f64, w: f64, h: f64, radius: f64, lit: f64, alpha: f64 }; + + /// Whether the OS has Liquid Glass to draw a view drag's glass with (`openOverlay`): macOS 26. + pub fn liquidGlass() bool { + if (comptime !supported) return false; + return Impl.liquidGlassAvailable(); + } + + /// A window over the display the main window is on (`displayInMain`) holding the OS's glass + /// (`overlayGlass`) under the picture presented into it, the pointer passing through it. Where + /// `liquidGlass`. + pub fn openOverlay(at: Rect) ?*Viewport { + if (comptime !supported) return null; + return dvui.currentWindow().backend.impl.viewportOpenOverlay(at); + } + + /// One of Liquid Glass's materials: its variant and style. + pub const GlassMaterial = struct { variant: i32, style: i32 }; + /// What an overlay's glass is: two layers of the same pieces, `under` and `over`, crossfaded by + /// `over_share` — each layer's pieces alike, for the OS to run them together. + pub const GlassLook = struct { + under: GlassMaterial, + over: GlassMaterial, + over_share: f32, + /// How much of the glass there is. + glass: f32 = 1, + /// The window's colour under the glass, each piece's shape, `fill_opacity` opaque; a lit + /// piece's lit by `lit_amount` toward `lit_toward`. + fill: dvui.Color = .black, + fill_opacity: f32 = 0, + lit_toward: dvui.Color = .white, + lit_amount: f32 = 0, + /// Each piece's clearing bevel (`core.glass_look.band`): a share of its shorter half, at + /// most `bevel_cap` points, clear for `bevel_clear` of it before the frost and the colour + /// come in. + bevel: f32 = 0, + bevel_cap: f32 = 0, + bevel_clear: f32 = 0, + /// Points of plain blur over the lens in each piece's body, in place of the `over` frost, + /// where the OS can blur what is behind a window without its materials' tint (macOS: Core + /// Animation's backdrop blur): 0, or none to draw it with, and the frost it is. + blur: f32 = 0, + }; + + /// Overlay `vp`'s glass this frame (`openOverlay`), run together within `spacing` points, as + /// `look`. + pub fn overlayGlass(vp: *Viewport, shapes: []const GlassShape, spacing: f32, look: GlassLook) void { + if (comptime !supported) return; + const f = look.fill; + const t = look.lit_toward; + dvui.currentWindow().backend.impl.viewportOverlayGlass(vp, shapes, spacing, .{ + .under_variant = look.under.variant, + .under_style = look.under.style, + .over_variant = look.over.variant, + .over_style = look.over.style, + .over_share = look.over_share, + .glass = look.glass, + .fill = .{ @as(f64, @floatFromInt(f.r)) / 255, @as(f64, @floatFromInt(f.g)) / 255, @as(f64, @floatFromInt(f.b)) / 255, look.fill_opacity }, + .lit_toward = .{ @as(f64, @floatFromInt(t.r)) / 255, @as(f64, @floatFromInt(t.g)) / 255, @as(f64, @floatFromInt(t.b)) / 255, look.lit_amount }, + .bevel = look.bevel, + .bevel_cap = look.bevel_cap, + .bevel_clear = look.bevel_clear, + .blur = look.blur, + }); + } + + /// The carried view's picture under an overlay's glass (`overlayPhoto`). + pub const OverlayPhoto = struct { + /// The rounded rect it shows in, points from the overlay window's top left. + rect: Rect, + radius: f32, + /// Where its image lies, points from `rect`'s top left: it may reach past it. + image: Rect, + fill: dvui.Color, + alpha: f32 = 1, + /// Points of plain blur on it. + blur: f32 = 0, + }; + + /// Overlay `vp`'s image of the carried view's picture: premultiplied RGBA rows, `w` by `h`, + /// copied; null, none. Once a drag (`overlayPhoto` places it). + pub fn overlayPhotoImage(vp: *Viewport, rgba: ?[]const u8, w: u32, h: u32) void { + if (comptime !supported) return; + dvui.currentWindow().backend.impl.viewportOverlayPhotoImage(vp, rgba, w, h); + } + + /// Where overlay `vp` shows the carried view's picture this frame, under its glass, so the + /// glass over it bends it; null, nowhere. Applied with the glass. + pub fn overlayPhoto(vp: *Viewport, photo: ?OverlayPhoto) void { + if (comptime !supported) return; + const p = photo orelse return dvui.currentWindow().backend.impl.viewportOverlayPhoto(vp, null); + const f = p.fill; + dvui.currentWindow().backend.impl.viewportOverlayPhoto(vp, .{ + .x = p.rect.x, + .y = p.rect.y, + .w = p.rect.w, + .h = p.rect.h, + .radius = p.radius, + .image = .{ p.image.x, p.image.y, p.image.w, p.image.h }, + .fill = .{ @as(f64, @floatFromInt(f.r)) / 255, @as(f64, @floatFromInt(f.g)) / 255, @as(f64, @floatFromInt(f.b)) / 255, @as(f64, @floatFromInt(f.a)) / 255 }, + .alpha = p.alpha, + .blur = p.blur, + }); + } + + /// The display the main window is on, in its frame (physical pixels from its top left). + pub fn displayInMain() Rect { + if (comptime !supported) return .{}; + return dvui.currentWindow().backend.impl.viewportDisplayInMain(); + } + /// A carry window's shape this frame (`openCarry`): what it carries fills it, rounded by /// `radius` physical pixels — the OS's material and shadow in that shape — `alpha` opaque. /// Null: hidden. @@ -160,6 +357,25 @@ pub const viewports = struct { dvui.currentWindow().backend.impl.viewportCarryShape(vp, radius, alpha); } + /// `vp`'s window at `other`'s level and just above it — a float born of a drop, over the drag's + /// glass while that goes — until `settle` puts it back at its own. macOS. + pub fn lift(vp: *Viewport, other: *Viewport) void { + if (comptime !supported) return; + dvui.currentWindow().backend.impl.viewportLift(vp, other); + } + + pub fn settle(vp: *Viewport) void { + if (comptime !supported) return; + dvui.currentWindow().backend.impl.viewportSettle(vp); + } + + /// `vp`'s window put just above `other`'s in the stacking (a growing float's picture over the + /// glass it grows out of). macOS. + pub fn orderAbove(vp: *Viewport, other: *Viewport) void { + if (comptime !supported) return; + dvui.currentWindow().backend.impl.viewportOrderAbove(vp, other); + } + /// A carry window's glass as the lens — the carried view, a drop of water — or as frost, as a /// window's material is (a float's window growing out of it). Liquid Glass only. pub fn carryLens(vp: *Viewport, lens: bool) void { @@ -167,6 +383,13 @@ pub const viewports = struct { dvui.currentWindow().backend.impl.viewportCarryLens(vp, lens); } + /// Whether `vp`'s window lies under the main window in the OS's stacking — a float's, clicked + /// behind it — as last read (macOS; never elsewhere, where floats stay over it). + pub fn underMain(vp: *Viewport) bool { + if (comptime !supported) return false; + return dvui.currentWindow().backend.impl.viewportUnderMain(vp); + } + /// Points from `vp`'s window's left edge past the OS's own buttons in its title bar (macOS's /// traffic lights, `os_buttons`): 0 without them. pub fn buttonsWidth(vp: *Viewport) f32 { @@ -174,6 +397,13 @@ pub const viewports = struct { return dvui.currentWindow().backend.impl.viewportButtonsWidth(vp); } + /// `vp`'s window's Liquid Glass this frame, as the main window's (`windowGlass`). Whether it + /// has any: its float then draws no base of its own. + pub fn windowGlass(vp: *Viewport, look: WindowGlassLook) bool { + if (comptime !supported) return false; + return dvui.currentWindow().backend.impl.viewportLiquidGlass(vp, look.native()); + } + /// A float's window's corner radius, points: the OS's for a titled window (`os_frame`). pub fn windowRadius() f32 { if (comptime !supported) return 0; @@ -194,6 +424,25 @@ pub const viewports = struct { return dvui.currentWindow().backend.impl.viewportMaximized(vp); } + /// Whether `vp`'s window covers the desktop, or will once the transition it is in ends, as the + /// main window's is asked (`coversDesktop`). + pub fn coversDesktop(vp: *const Viewport) bool { + if (comptime !supported) return false; + return dvui.currentWindow().backend.impl.viewportCovers(vp); + } + + /// Whether `vp`'s window is on its way into a fullscreen Space (`enteringSpace`). + pub fn enteringSpace(vp: *const Viewport) bool { + if (comptime !supported) return false; + return dvui.currentWindow().backend.impl.viewportEnteringSpace(vp); + } + + /// How far `vp`'s window is into full screen as it moves itself there or back (`spaceFullness`). + pub fn spaceFullness(vp: *const Viewport) ?f32 { + if (comptime !supported) return null; + return dvui.currentWindow().backend.impl.viewportSpaceFullness(vp); + } + /// `vp`'s window `alpha` opaque, all of it — its material too. pub fn fade(vp: *Viewport, alpha: f32) void { if (comptime !supported) return; @@ -365,14 +614,16 @@ fn layoutStoreSave(_: ?*anyopaque, g: platform.geometry.Geometry) void { pub fn titlebarStripHeight(win: *dvui.Window) f32 { if (builtin.os.tag != .macos) return Constants.titlebar_height; const t = platform.macos_monitor.titlebarState(win); - return platform.window_layout.chooseTitlebarStrip(.{ + const in: platform.window_layout.StripInputs = .{ .collapsed = t.collapsed, .restoring_chrome = t.restoring_chrome, .live_inset = t.live_inset, .saved_inset = t.saved_inset, .titlebar_height = Constants.titlebar_height, .titlebar_top_buffer = Constants.titlebar_top_buffer, - }); + }; + if (platform.window.spaceFullness(win)) |f| return platform.window_layout.titlebarStripAtFullness(in, f); + return platform.window_layout.chooseTitlebarStrip(in); } // ---- The native menu bar: fizzy's menus (`menu_model`) on the platform's (`platform.menu`) ---- diff --git a/src/backend/backend_web.zig b/src/backend/backend_web.zig index efaa1a96..f469409b 100644 --- a/src/backend/backend_web.zig +++ b/src/backend/backend_web.zig @@ -68,6 +68,18 @@ pub fn isMaximized(_: *dvui.Window) bool { return true; } +pub fn coversDesktop(_: *dvui.Window) bool { + return true; +} + +pub fn enteringSpace(_: *dvui.Window) bool { + return false; +} + +pub fn spaceFullness(_: *dvui.Window) ?f32 { + return null; +} + pub fn setWindowStyle(_: *dvui.Window) void {} /// Symmetric with the native API: a browser tab cannot take focus for itself, and the OAuth @@ -153,8 +165,23 @@ pub const viewports = struct { pub fn maximized(_: *const Viewport) bool { return false; } + pub fn coversDesktop(_: *const Viewport) bool { + return false; + } + pub fn enteringSpace(_: *const Viewport) bool { + return false; + } + pub fn spaceFullness(_: *const Viewport) ?f32 { + return null; + } pub fn carryShape(_: *Viewport, _: ?f32, _: f32) void {} pub fn carryLens(_: *Viewport, _: bool) void {} + pub fn windowGlass(_: *Viewport, _: WindowGlassLook) bool { + return false; + } + pub fn underMain(_: *Viewport) bool { + return false; + } pub fn buttonsWidth(_: *Viewport) f32 { return 0; } @@ -164,6 +191,50 @@ pub const viewports = struct { pub fn openCarry(_: Rect) ?*Viewport { return null; } + pub const menus = false; + pub fn appActive() bool { + return true; + } + pub const Ride = union(enum) { none, main, viewport: *Viewport }; + pub fn orderAbove(_: *Viewport, _: *Viewport) void {} + pub fn lift(_: *Viewport, _: *Viewport) void {} + pub fn settle(_: *Viewport) void {} + pub fn placeRiding(_: *Viewport, frame: Rect, _: Rect) Rect { + return frame; + } + pub fn openMenu(_: Rect, _: f32, _: Ride, _: bool) ?*Viewport { + return null; + } + pub fn mainOffset(_: *Viewport, _: dvui.Point.Physical) void {} + pub const GlassShape = extern struct { x: f64, y: f64, w: f64, h: f64, radius: f64, lit: f64, alpha: f64, frost: f64 }; + pub fn liquidGlass() bool { + return false; + } + pub fn openOverlay(_: Rect) ?*Viewport { + return null; + } + pub const GlassMaterial = struct { variant: i32, style: i32 }; + pub const GlassLook = struct { + under: GlassMaterial, + over: GlassMaterial, + over_share: f32, + glass: f32 = 1, + fill: dvui.Color = .black, + fill_opacity: f32 = 0, + lit_toward: dvui.Color = .white, + lit_amount: f32 = 0, + bevel: f32 = 0, + bevel_cap: f32 = 0, + bevel_clear: f32 = 0, + blur: f32 = 0, + }; + pub fn overlayGlass(_: *Viewport, _: []const GlassShape, _: f32, _: GlassLook) void {} + pub const OverlayPhoto = struct { rect: Rect, radius: f32, image: Rect, fill: dvui.Color, alpha: f32 = 1, blur: f32 = 0 }; + pub fn overlayPhotoImage(_: *Viewport, _: ?[]const u8, _: u32, _: u32) void {} + pub fn overlayPhoto(_: *Viewport, _: ?OverlayPhoto) void {} + pub fn displayInMain() Rect { + return .{}; + } pub const Hints = struct { drag: Rect, keep: Rect, glass: Rect, edge: f32, app_side: f32 = 0, app_corner: f32 = 0 }; pub fn hints(_: *Viewport, _: ?Hints) void {} pub fn osPlaced(_: *Viewport) ?Rect { @@ -197,6 +268,25 @@ pub const viewports = struct { } }; +pub const WindowGlassLook = struct { + under_variant: i32, + under_style: i32, + over_variant: i32, + over_style: i32, + frost: f32, + blur: f32, + glass: f32, + fill: dvui.Color, + fill_opacity: f32, + top_fill: f32, + radius: f32, + clear: f32, + feather: f32, +}; +pub fn windowGlass(_: *dvui.Window, _: WindowGlassLook) bool { + return false; +} + pub fn titlebarStripHeight(_: *dvui.Window) f32 { return 0; } diff --git a/src/backend/native/SDLBackend.zig b/src/backend/native/SDLBackend.zig index 04fea07a..0da61914 100644 --- a/src/backend/native/SDLBackend.zig +++ b/src/backend/native/SDLBackend.zig @@ -52,6 +52,10 @@ gpu: *GpuRenderer, /// Optional hook invoked during `Window.begin` just before pixel/window sizes are queried. /// Useful to sync AppKit window state into SDL (e.g. during Space / zoom animations). begin_hook: ?*const fn (*SDLBackend) void = null, +/// Optional hook invoked at the very end of `renderPresent`, after every window's picture is +/// presented: closes what `begin_hook` opened for the frame (a Core Animation transaction a window +/// moved in, its picture presented with it). +present_hook: ?*const fn (*SDLBackend) void = null, touch_mouse_events: bool = false, log_events: bool = false, @@ -94,7 +98,11 @@ pointer_pin: PointerPin = .none, /// back over it at the next present (`renderPresent`). Asking AppKit for the order is a trip to the /// window server — 2 ms a frame on average, 10 at worst, asked every frame — so it is asked only /// then. -order_pending: bool = false, +/// The main window has been focused once (`noteMainForward`). +main_focused_once: bool = false, +/// Some window was shown, hidden or came forward since the floats' stacking against the main +/// window was last read (`Viewport.under_main`): read it again at the next present. +stack_stale: bool = true, /// The main window's skin, as last set (`platform.window.setBackground`): each float's own window /// is skinned so as it opens (`viewportGlass`). window_skin: ?dvui.Color = null, @@ -169,6 +177,97 @@ pub const Viewport = struct { /// How opaque it is, asked and as last applied — with its shape, in the same transaction. carry_alpha: f32 = 1, carry_alpha_shown: f32 = -1, + /// An overlay (`viewportOpenOverlay`): the OS's glass in it as last asked + /// (`viewportOverlayGlass`), applied with its picture, in the same transaction. + overlay: bool = false, + glass: [max_glass]GlassShape = undefined, + glass_n: usize = 0, + glass_spacing: f32 = 0, + glass_look: GlassLook = .{}, + glass_dirty: bool = false, + /// The carried view's picture under the overlay's glass (`viewportOverlayPhoto`) as last asked, + /// applied with the glass; null, none. + photo: ?OverlayPhoto = null, + photo_dirty: bool = false, + /// A menu's window (`viewportOpenMenu`): placed in the main window's frame, and the pointer + /// over it read there too, plus `main_offset` — where the menu is drawn in the frame from where + /// its window lies over the main one: nothing for a menu of the main window's, the way to its + /// float's band for one opened in a float that is out (`viewportMainOffset`). + menu: bool = false, + main_offset: viewport_map.Point = .{ .x = 0, .y = 0 }, + /// To be put over the main window before the frame presents, once (`noteMainForward`). + over_main: bool = false, + /// Its window lies under the main window in the OS's stacking (a float's, clicked behind it): + /// read once whenever the stacking may have changed (`stack_stale`), not each frame. + under_main: bool = false, + /// The window it rides on (`viewportOpenMenu`): the main window, for a menu or dialog of the + /// main window's; a float's, for a dialog opened in it. Made that window's child once shown, so + /// the OS moves it with it — smoothly through a drag, which the app's frames could only follow + /// a frame behind — and put somewhere new only when where it lies over that window changes + /// (`viewportPlaceRiding`): that place as last placed (`ride_key`), and how the part of the + /// frame it showed then lay from the frame asked (`ride_off`, `ride_size`). + ride: ?*c.SDL_Window = null, + ride_key: ?viewport_map.Rect = null, + ride_off: viewport_map.Point = .{ .x = 0, .y = 0 }, + ride_size: viewport_map.Point = .{ .x = 0, .y = 0 }, + /// A dialog's window (`viewportOpenMenu`): a menu's, but in its window's stacking. + dialog: bool = false, +}; + +/// A piece of the OS's glass in an overlay (`viewportOverlayGlass`): a rounded rect, points from the +/// window's top left. As `fizzy_macos_viewport_overlay_glass` reads it. +pub const GlassShape = extern struct { + x: f64, + y: f64, + w: f64, + h: f64, + radius: f64, + lit: f64, + alpha: f64, + /// How much of the frost's blur it takes in its middle (`core.native_glass.Shape.frost`). + frost: f64, +}; + +/// The most pieces of glass an overlay holds. +pub const max_glass = 48; + +/// The carried view's picture under an overlay's glass (`viewportOverlayPhoto`): in a rounded rect +/// (`x`…`radius`, points from the window's top left) on `fill`, its image laid at `image` (points +/// from that rect's top left; it may reach past it, cut off there), `blur` points of plain blur on +/// it, `alpha` opaque. As `fizzy_macos_viewport_overlay_photo` reads it. +pub const OverlayPhoto = extern struct { + x: f64, + y: f64, + w: f64, + h: f64, + radius: f64, + image: [4]f64, + fill: [4]f64, + alpha: f64, + blur: f64, +}; + +/// What an overlay's glass is (`viewportOverlayGlass`): two layers of the same pieces, each its own +/// Liquid Glass variant and style, crossfaded by `over_share`. As +/// `fizzy_macos_viewport_overlay_glass` reads it. +pub const GlassLook = extern struct { + under_variant: c_long = 2, + under_style: c_long = 1, + over_variant: c_long = 2, + over_style: c_long = 1, + over_share: f64 = 0, + /// How much of the glass there is, both layers. + glass: f64 = 1, + /// The window's colour under the glass (0…1 each), its opacity the last. + fill: [4]f64 = .{ 0, 0, 0, 0 }, + /// What a lit piece's colour goes toward, and how far (the last). + lit_toward: [4]f64 = .{ 1, 1, 1, 0 }, + /// Each piece's clearing bevel (`core.glass_look.band`). + bevel: f64 = 0, + bevel_cap: f64 = 0, + bevel_clear: f64 = 0, + /// Points of plain blur over the lens in place of the over layer's frost: 0, the frost. + blur: f64 = 0, }; pub const InitOptions = struct { @@ -733,17 +832,28 @@ pub fn refresh(_: *SDLBackend) void { toErr(c.SDL_PushEvent(&ue), "SDL_PushEvent in refresh") catch {}; } -/// The main window come forward — focused (the app activated, at launch too), pressed, shown, -/// restored — maybe over a viewport's window: they are put back over it before the frame presents -/// (`order_pending`). From either way events arrive: polled (`addAllEvents`) or through SDL's -/// callbacks (`appEvent`), which is how fizzy runs on macOS — where this only ever ran from the -/// first, and a float's window opened at launch went behind the main window as the app activated. +/// The main window shown, restored, or first focused — the app activating at launch — over the +/// floats' windows: they are put over it once, before the frame presents (`Viewport.over_main`). +/// Not when it is pressed, or focused again: a float's window stacks as any window does (the user), +/// and one put back over the main window at every press on it left the part under it unworkable. +/// From either way events arrive: polled (`addAllEvents`) or through SDL's callbacks (`appEvent`), +/// which is how fizzy runs on macOS. fn noteMainForward(self: *SDLBackend, target: ?*c.SDL_Window, event_type: u32) void { - if (target == null or target != self.window) return; switch (event_type) { - c.SDL_EVENT_WINDOW_FOCUS_GAINED, c.SDL_EVENT_MOUSE_BUTTON_DOWN, c.SDL_EVENT_WINDOW_SHOWN, c.SDL_EVENT_WINDOW_RESTORED => self.order_pending = true, + c.SDL_EVENT_WINDOW_SHOWN, c.SDL_EVENT_WINDOW_HIDDEN, c.SDL_EVENT_WINDOW_RESTORED, c.SDL_EVENT_WINDOW_MINIMIZED, c.SDL_EVENT_WINDOW_FOCUS_GAINED => self.stack_stale = true, else => {}, } + if (target == null or target != self.window) return; + const over = switch (event_type) { + c.SDL_EVENT_WINDOW_SHOWN, c.SDL_EVENT_WINDOW_RESTORED => true, + c.SDL_EVENT_WINDOW_FOCUS_GAINED => !self.main_focused_once, + else => false, + }; + if (event_type == c.SDL_EVENT_WINDOW_FOCUS_GAINED) self.main_focused_once = true; + if (!over) return; + for (&self.viewports) |*slot| if (slot.*) |*v| { + v.over_main = true; + }; } pub fn addAllEvents(self: *SDLBackend, win: *dvui.Window) !void { @@ -806,7 +916,72 @@ fn addEventWinRecursive(self: *SDLBackend, event: *c.SDL_Event, win: *dvui.Windo /// Hidden until a frame is presented into it. Null when it cannot be made, or `max_viewports` /// are open. pub fn viewportOpen(self: *SDLBackend, at: viewport_map.Rect, title_text: [:0]const u8) ?*Viewport { - return self.openViewport(at, title_text, false); + return self.openViewport(at, title_text, .window); +} + +const ViewportKind = enum { window, carry, overlay, menu }; + +/// A menu in a window of its own (`Popout`'s menus): borderless, kept above every window by its +/// level, in no window list, never made key — the window it opened from keeps the keyboard, and +/// its first click acts. Placed in the main window's frame (`viewportPlaceMain`), the pointer over it +/// read there (`viewportMainOffset`). Its material is the OS's (`viewportLiquidGlass`, vibrancy +/// before macOS 26). macOS. +/// `radius`, points: the menu's corners, which vibrancy is masked to (Liquid Glass takes them from +/// its look each frame). `ride`: the window it moves with (`Viewport.ride`) — none for a menu opened +/// in a float that is out. `dialog`: a dialog's window (`Viewport.dialog`). +pub fn viewportOpenMenu(self: *SDLBackend, at: viewport_map.Rect, radius: f32, ride: Ride, dialog: bool) ?*Viewport { + if (comptime builtin.os.tag != .macos) return null; + const vp = self.openViewport(at, "", .menu) orelse return null; + _ = fizzy_macos_viewport_menu(cocoaWindow(vp.window), cocoaWindow(self.window), platform.window.ns_visual_effect_material, radius, @intFromBool(dialog)); + vp.ride = switch (ride) { + .none => null, + .main => self.window, + .viewport => |parent| parent.window, + }; + vp.dialog = dialog; + return vp; +} + +/// `vp`'s window just above `other`'s, where it is not already (macOS). +pub fn viewportOrderAbove(_: *SDLBackend, vp: *Viewport, other: *Viewport) void { + if (comptime builtin.os.tag != .macos) return; + fizzy_macos_viewport_order_above(cocoaWindow(vp.window), cocoaWindow(other.window)); +} + +/// `vp`'s window at `other`'s level, just above it (macOS). +pub fn viewportLift(_: *SDLBackend, vp: *Viewport, other: *Viewport) void { + if (comptime builtin.os.tag != .macos) return; + fizzy_macos_viewport_lift(cocoaWindow(vp.window), cocoaWindow(other.window)); +} + +/// `vp`'s window back at a window's own level, after `viewportLift` (macOS). +pub fn viewportSettle(self: *SDLBackend, vp: *Viewport) void { + if (comptime builtin.os.tag != .macos) return; + fizzy_macos_viewport_settle(cocoaWindow(vp.window), cocoaWindow(self.window)); + self.stack_stale = true; +} + +/// Whether `vp`'s window lies under the main window in the OS's stacking (`Viewport.under_main`). +pub fn viewportUnderMain(_: *SDLBackend, vp: *const Viewport) bool { + return vp.under_main; +} + +/// The window a menu's or dialog's window moves with (`viewportOpenMenu`). +pub const Ride = union(enum) { + none, + main, + viewport: *Viewport, +}; + +/// Whether one of the app's windows has the keyboard: the app is the active one. +pub fn appActive() bool { + return c.SDL_GetKeyboardFocus() != null; +} + +/// Where menu `vp` is drawn in the frame from where its window lies over the main window, physical +/// pixels (`Viewport.main_offset`). +pub fn viewportMainOffset(_: *SDLBackend, vp: *Viewport, offset: viewport_map.Point) void { + vp.main_offset = offset; } /// A window that carries a view past every window of the app's, over the desktop (`Popout`'s carry @@ -814,10 +989,105 @@ pub fn viewportOpen(self: *SDLBackend, at: viewport_map.Rect, title_text: [:0]co /// window, in no window list, never focused. macOS. Placed and drawn as any viewport. pub fn viewportOpenCarry(self: *SDLBackend, at: viewport_map.Rect) ?*Viewport { if (comptime builtin.os.tag != .macos) return null; - return self.openViewport(at, "", true); + return self.openViewport(at, "", .carry); } -fn openViewport(self: *SDLBackend, at: viewport_map.Rect, title_text: [:0]const u8, carry: bool) ?*Viewport { +/// A window over a whole display that holds the OS's glass (`viewportOverlayGlass`) — Liquid Glass, +/// its pieces run together where they come close — under the picture drawn into it: a carry window +/// grown to its display, clear but for its glass, the pointer passing through it. macOS 26, where +/// `liquidGlassAvailable`. +pub fn viewportOpenOverlay(self: *SDLBackend, at: viewport_map.Rect) ?*Viewport { + if (comptime builtin.os.tag != .macos) return null; + if (!liquidGlassAvailable()) return null; + return self.openViewport(at, "", .overlay); +} + +/// Whether the OS has Liquid Glass to draw (`viewportOpenOverlay`): macOS 26. +pub fn liquidGlassAvailable() bool { + if (comptime builtin.os.tag != .macos) return false; + return fizzy_macos_liquid_glass_available() != 0; +} + +/// The OS's glass in overlay `vp` (`viewportOpenOverlay`) this frame, applied with its picture: each +/// a rounded rect, points from the window's top left, run together within `spacing` points, as +/// `look`. +pub fn viewportOverlayGlass(_: *SDLBackend, vp: *Viewport, shapes: []const GlassShape, spacing: f32, look: GlassLook) void { + const n = @min(shapes.len, max_glass); + // Where the glass merges, its necks swing at the least change of the gap between two pieces — + // a carried drop's spring settling, a hand's tremor through a held press — and the OS + // re-forms the merge each time it is handed a change, so a piece still under a pointer held + // still shimmered at its joins. A piece's place and size go to the OS only once they have + // moved half a point from what it was last handed; motion that means anything moves more. + var next: [max_glass]GlassShape = undefined; + @memcpy(next[0..n], shapes[0..n]); + if (n == vp.glass_n) for (next[0..n], vp.glass[0..n]) |*to, was| { + inline for (.{ "x", "y", "w", "h", "radius" }) |f| { + if (@abs(@field(to.*, f) - @field(was, f)) < glass_still) @field(to.*, f) = @field(was, f); + } + }; + if (n == vp.glass_n and spacing == vp.glass_spacing and std.meta.eql(look, vp.glass_look) and + std.mem.eql(u8, std.mem.sliceAsBytes(vp.glass[0..n]), std.mem.sliceAsBytes(next[0..n]))) return; + @memcpy(vp.glass[0..n], next[0..n]); + vp.glass_n = n; + vp.glass_spacing = spacing; + vp.glass_look = look; + vp.glass_dirty = true; +} + +/// The image of the carried view's picture in overlay `vp` (`viewportOverlayPhoto`): premultiplied +/// RGBA rows, `w` by `h`, copied; null, none. Once a drag: its place each frame is cheap, as an +/// image under the glass rather than a window of its own presented every frame. +pub fn viewportOverlayPhotoImage(_: *SDLBackend, vp: *Viewport, rgba: ?[]const u8, w: u32, h: u32) void { + if (comptime builtin.os.tag != .macos) return; + const px = rgba orelse { + fizzy_macos_viewport_overlay_photo_image(cocoaWindow(vp.window), null, 0, 0); + return; + }; + if (px.len < @as(usize, w) * h * 4) return; + fizzy_macos_viewport_overlay_photo_image(cocoaWindow(vp.window), px.ptr, w, h); +} + +/// Where the carried view's picture is under overlay `vp`'s glass this frame, or null: none. +/// Applied with the glass, in the same transaction — and kept to its drop's glass as the OS is +/// handed it: moved only once its drop has moved half a point (`glass_still`), as the glass is. +/// Moved every frame under glass that was not, the picture slid about inside its lens. +pub fn viewportOverlayPhoto(_: *SDLBackend, vp: *Viewport, photo: ?OverlayPhoto) void { + var next = photo orelse { + if (vp.photo != null) vp.photo_dirty = true; + vp.photo = null; + return; + }; + if (vp.photo) |was| inline for (.{ "x", "y", "w", "h", "radius" }) |f| { + if (@abs(@field(next, f) - @field(was, f)) < glass_still) @field(next, f) = @field(was, f); + }; + if (vp.photo != null and std.meta.eql(vp.photo.?, next)) return; + vp.photo = next; + vp.photo_dirty = true; +} + +/// Points a piece of an overlay's glass moves or grows before the OS is handed it +/// (`viewportOverlayGlass`). +const glass_still: f64 = 0.5; + +/// The display the main window is on, in the main window's frame (physical pixels from its top +/// left): what an overlay covers. +pub fn viewportDisplayInMain(self: *SDLBackend) viewport_map.Rect { + const display = c.SDL_GetDisplayForWindow(self.window); + var r: c.SDL_Rect = .{}; + if (display == 0 or !c.SDL_GetDisplayBounds(display, &r)) return .{}; + const at = self.mainOnScreen(); + const d = self.density(); + return .{ + .x = (@as(f32, @floatFromInt(r.x)) - at.x) * d, + .y = (@as(f32, @floatFromInt(r.y)) - at.y) * d, + .w = @as(f32, @floatFromInt(r.w)) * d, + .h = @as(f32, @floatFromInt(r.h)) * d, + }; +} + +fn openViewport(self: *SDLBackend, at: viewport_map.Rect, title_text: [:0]const u8, role: ViewportKind) ?*Viewport { + const carry = role != .window; + const menu = role == .menu; if (!viewportsAvailable()) return null; const slot = for (self.viewports, 0..) |v, i| { if (v == null) break i; @@ -841,9 +1111,12 @@ fn openViewport(self: *SDLBackend, at: viewport_map.Rect, title_text: [:0]const // (`fizzy_macos_viewport_glass`). Elsewhere borderless, framed by the OS (Windows: DWM) or by // the float (X11). // A carry window is neither: borderless, and not resizable — the app puts it where the view is. + // A menu's window is a carry window that takes the pointer, never focused: the window it opened + // from keeps the keyboard, as an OS menu leaves it. const frame_flag: c.SDL_WindowFlags = if (builtin.os.tag == .macos and !carry) 0 else c.SDL_WINDOW_BORDERLESS; const size_flag: c.SDL_WindowFlags = if (carry) 0 else c.SDL_WINDOW_RESIZABLE; - const flags: c.SDL_WindowFlags = c.SDL_WINDOW_HIDDEN | frame_flag | c.SDL_WINDOW_TRANSPARENT | c.SDL_WINDOW_HIGH_PIXEL_DENSITY | size_flag; + const focus_flag: c.SDL_WindowFlags = if (menu) c.SDL_WINDOW_NOT_FOCUSABLE else 0; + const flags: c.SDL_WindowFlags = c.SDL_WINDOW_HIDDEN | frame_flag | c.SDL_WINDOW_TRANSPARENT | c.SDL_WINDOW_HIGH_PIXEL_DENSITY | size_flag | focus_flag; _ = c.SDL_SetStringProperty(props, c.SDL_PROP_WINDOW_CREATE_TITLE_STRING, title_text.ptr); _ = c.SDL_SetNumberProperty(props, c.SDL_PROP_WINDOW_CREATE_X_NUMBER, placed.screen.x); _ = c.SDL_SetNumberProperty(props, c.SDL_PROP_WINDOW_CREATE_Y_NUMBER, placed.screen.y); @@ -864,8 +1137,14 @@ fn openViewport(self: *SDLBackend, at: viewport_map.Rect, title_text: [:0]const // where SDL makes it a child window that moves with its parent, and the windows that left // the main one stay where they are when it moves (`docs/POPOUT_WINDOWS_PLAN.md`, decision 2). if (comptime builtin.os.tag != .macos) _ = c.SDL_SetWindowParent(window, self.window); - self.viewports[slot] = .{ .window = window, .band = b, .anchor = anchor, .density = d, .screen = placed.screen, .frame = placed.frame, .passive = carry }; + self.viewports[slot] = .{ .window = window, .band = b, .anchor = anchor, .density = d, .screen = placed.screen, .frame = placed.frame, .passive = carry and !menu, .overlay = role == .overlay, .menu = menu }; const vp = &self.viewports[slot].?; + // Dressed by `viewportOpenMenu`, and no hit test: nothing in it moves or resizes it. + if (menu) return vp; + if (role == .overlay) { + if (comptime builtin.os.tag == .macos) fizzy_macos_viewport_overlay(cocoaWindow(window), cocoaWindow(self.window)); + return vp; + } if (carry) { if (comptime builtin.os.tag == .macos) fizzy_macos_viewport_carry(cocoaWindow(window), cocoaWindow(self.window), platform.window.ns_visual_effect_material); return vp; @@ -911,6 +1190,23 @@ pub fn viewportMaximized(_: *SDLBackend, vp: *const Viewport) bool { return platform.window.windowMaximized(vp.window); } +/// Whether `vp`'s window covers the desktop, or will once the transition it is in ends, as the main +/// window's is asked (`platform.window.windowCovers`). +pub fn viewportCovers(_: *SDLBackend, vp: *const Viewport) bool { + return platform.window.windowCovers(vp.window); +} + +/// Whether `vp`'s window is on its way into a fullscreen Space (`platform.window.windowEnteringSpace`). +pub fn viewportEnteringSpace(_: *SDLBackend, vp: *const Viewport) bool { + return platform.window.windowEnteringSpace(vp.window); +} + +/// How far `vp`'s window is into full screen as it moves itself there or back +/// (`platform.window.windowSpaceFullness`). +pub fn viewportSpaceFullness(_: *SDLBackend, vp: *const Viewport) ?f32 { + return platform.window.windowSpaceFullness(vp.window); +} + /// A held pointer over `vp`'s window is read as over the main window beneath it while `on` /// (`heldPoint`): its float gone to its ghost, a view carried out of it aimed at the places under it. pub fn viewportSeeThrough(_: *SDLBackend, vp: *Viewport, on: bool) void { @@ -943,6 +1239,15 @@ pub fn viewportClose(self: *SDLBackend, vp: *Viewport) void { }, else => {}, } + // What rides on it lets go first (`Viewport.ride`): SDL destroys a window's children with it, + // and the slot of each still holds its window. The app moves a dialog left behind back over + // the main window (`Popout`). + for (&self.viewports) |*slot| { + if (slot.*) |*other| if (other != vp and other.ride != null and other.ride == vp.window) { + _ = c.SDL_SetWindowParent(other.window, null); + other.ride = null; + }; + } for (&self.viewports) |*slot| { if (slot.*) |*v| if (v == vp) { // Holding the keyboard as it goes — a float that merged back into the main window, or @@ -1027,6 +1332,19 @@ pub fn viewportPlaceMain(self: *SDLBackend, vp: *Viewport, frame: viewport_map.R return placed.frame; } +/// `viewportPlaceMain` for a window riding on another (`Viewport.ride`), put somewhere new only +/// when `key` — where it lies over that window — changes. Otherwise it is already there: the OS +/// carried it with that window, ahead of the app's idea of where the window is. +pub fn viewportPlaceRiding(self: *SDLBackend, vp: *Viewport, frame: viewport_map.Rect, key: viewport_map.Rect) viewport_map.Rect { + if (vp.ride != null and vp.mapped) if (vp.ride_key) |k| if (std.meta.eql(k, key)) + return .{ .x = frame.x + vp.ride_off.x, .y = frame.y + vp.ride_off.y, .w = vp.ride_size.x, .h = vp.ride_size.y }; + const shown = self.viewportPlaceMain(vp, frame); + vp.ride_key = key; + vp.ride_off = .{ .x = shown.x - frame.x, .y = shown.y - frame.y }; + vp.ride_size = .{ .x = shown.w, .y = shown.h }; + return shown; +} + /// `frame` of the main window's frame, as the same place on the desktop in `vp`'s band: a float /// split out under a drag, let go out there, settling into its band where it is. pub fn viewportBandFromMain(self: *SDLBackend, vp: *const Viewport, frame: viewport_map.Rect) viewport_map.Rect { @@ -1124,13 +1442,25 @@ pub fn viewportMinSize(_: *SDLBackend, vp: *Viewport, w: f32, h: f32) void { } extern fn fizzy_macos_viewport_glass(nswindow: ?*anyopaque, main: ?*anyopaque, inset: f64, radius: f64, material: c_long) void; +extern fn fizzy_macos_viewport_dress(nswindow: ?*anyopaque, main: ?*anyopaque) void; +extern fn fizzy_macos_viewport_menu(nswindow: ?*anyopaque, main: ?*anyopaque, material: c_long, radius: f64, dialog: c_int) c_int; +extern fn fizzy_macos_viewport_menu_attached(nswindow: ?*anyopaque) void; +extern fn fizzy_macos_viewport_order_above(nswindow: ?*anyopaque, other: ?*anyopaque) void; +extern fn fizzy_macos_viewport_lift(nswindow: ?*anyopaque, other: ?*anyopaque) void; +extern fn fizzy_macos_viewport_settle(nswindow: ?*anyopaque, main_nswindow: ?*anyopaque) void; extern fn fizzy_macos_viewport_carry(nswindow: ?*anyopaque, main: ?*anyopaque, material: c_long) void; extern fn fizzy_macos_viewport_carry_shape(nswindow: ?*anyopaque, radius: f64, w: f64, h: f64, alpha: f64) void; extern fn fizzy_macos_viewport_carry_lens(nswindow: ?*anyopaque, lens: c_int) void; extern fn fizzy_macos_window_buttons_width(nswindow: ?*anyopaque) f64; +extern fn fizzy_macos_liquid_glass_available() c_int; +extern fn fizzy_macos_viewport_overlay(nswindow: ?*anyopaque, main: ?*anyopaque) void; +extern fn fizzy_macos_viewport_overlay_photo_image(nswindow: ?*anyopaque, rgba: ?[*]const u8, w: c_long, h: c_long) void; +extern fn fizzy_macos_viewport_overlay_photo(nswindow: ?*anyopaque, photo: ?*const OverlayPhoto) void; +extern fn fizzy_macos_viewport_overlay_glass(nswindow: ?*anyopaque, shapes: [*]const GlassShape, n: c_long, spacing: f64, look: *const GlassLook) void; extern fn fizzy_macos_window_corner_radius() f64; extern fn fizzy_macos_viewport_unglass(nswindow: ?*anyopaque) void; -extern fn fizzy_macos_viewport_keep_above(nswindow: ?*anyopaque, main_nswindow: ?*anyopaque) void; +extern fn fizzy_macos_viewport_over_main(nswindow: ?*anyopaque, main_nswindow: ?*anyopaque) void; +extern fn fizzy_macos_viewport_under_main(nswindow: ?*anyopaque, main_nswindow: ?*anyopaque) c_int; extern fn fizzy_macos_viewport_windows_item(nswindow: ?*anyopaque, title: [*:0]const u8) void; /// Give `vp`'s window a material behind the float's glass, so the float's frost reads the desktop @@ -1150,7 +1480,12 @@ pub fn viewportGlass(self: *SDLBackend, vp: *Viewport, inset: f32, radius: f32, .macos => { const ns = c.SDL_GetPointerProperty(props, c.SDL_PROP_WINDOW_COCOA_WINDOW_POINTER, null) orelse return false; const main_ns = c.SDL_GetPointerProperty(c.SDL_GetWindowProperties(self.window), c.SDL_PROP_WINDOW_COCOA_WINDOW_POINTER, null); - fizzy_macos_viewport_glass(ns, main_ns, inset / vp.density, radius / vp.density, platform.window.ns_visual_effect_material); + // Liquid Glass where the OS has it (macOS 26), as the main window; vibrancy before. + // Dressed as the main window either way: its shadow and outline, no OS animation. + if (platform.window.liquidGlass(ns)) + fizzy_macos_viewport_dress(ns, main_ns) + else + fizzy_macos_viewport_glass(ns, main_ns, inset / vp.density, radius / vp.density, platform.window.ns_visual_effect_material); // Skinned as the main window is (`platform.window.skin`). if (self.window_skin) |color| platform.window.skin(ns, color, dark); // Through Spaces, zooms and live resizes as the main window goes (`macos_monitor`). @@ -1166,6 +1501,11 @@ pub fn viewportGlass(self: *SDLBackend, vp: *Viewport, inset: f32, radius: f32, } } +/// `vp`'s window's Liquid Glass this frame (`platform.window.liquidGlassLook`). Whether it has any. +pub fn viewportLiquidGlass(_: *SDLBackend, vp: *Viewport, look: platform.window.WindowGlass) bool { + return platform.window.liquidGlassLook(vp.window, look); +} + /// Where `vp`'s window is now, in the main window's part of the frame: physical pixels from the /// main window's top left, wherever either window has been moved since it opened. pub fn viewportInMain(self: *SDLBackend, vp: *const Viewport) viewport_map.Rect { @@ -1241,18 +1581,24 @@ fn heldPoint(self: *SDLBackend) dvui.Point.Physical { return .{ .x = p.x, .y = p.y }; }, } + // Over the main window, a window under it in the stacking (`Viewport.under_main`) is not + // what the pointer is over: the main window is. + const origin = self.mainOnScreen(); + var mw: c_int = 0; + var mh: c_int = 0; + _ = c.SDL_GetWindowSize(self.window, &mw, &mh); + const over_main = gx >= origin.x and gy >= origin.y and gx < origin.x + @as(f32, @floatFromInt(mw)) and gy < origin.y + @as(f32, @floatFromInt(mh)); for (&self.viewports) |*slot| { const vp = if (slot.*) |*v| v else continue; if (vp.passive or vp.see_through) continue; + if (vp.under_main and over_main) continue; const s = vp.screen; if (gx >= @as(f32, @floatFromInt(s.x)) and gy >= @as(f32, @floatFromInt(s.y)) and gx < @as(f32, @floatFromInt(s.x + s.w)) and gy < @as(f32, @floatFromInt(s.y + s.h))) { - const p = viewport_map.frameFromScreen(vp.band, vp.anchor, vp.density, .{ .x = gx, .y = gy }); - return .{ .x = p.x, .y = p.y }; + return self.viewportPoint(vp); } } - const origin = self.mainOnScreen(); const d = self.density(); return .{ .x = (gx - origin.x) * d, .y = (gy - origin.y) * d }; } @@ -1264,10 +1610,17 @@ fn heldPoint(self: *SDLBackend) dvui.Point.Physical { /// move: the edge overshot, was pulled back, and overshot again, shaking the window and what it /// shows. The plan's "while held, the window follows `SDL_GetGlobalMouseState`". Every event of a /// frame's burst reads the latest place, which is the one that matters. -fn viewportPoint(vp: *const Viewport) dvui.Point.Physical { +fn viewportPoint(self: *SDLBackend, vp: *const Viewport) dvui.Point.Physical { var gx: f32 = 0; var gy: f32 = 0; _ = c.SDL_GetGlobalMouseState(&gx, &gy); + // A menu's window lies over the main window's frame: read there, and on to where the menu is + // drawn (`Viewport.main_offset`). + if (vp.menu) { + const origin = self.mainOnScreen(); + const d = self.density(); + return .{ .x = (gx - origin.x) * d + vp.main_offset.x, .y = (gy - origin.y) * d + vp.main_offset.y }; + } const p = viewport_map.frameFromScreen(vp.band, vp.anchor, vp.density, .{ .x = gx, .y = gy }); return .{ .x = p.x, .y = p.y }; } @@ -1279,12 +1632,12 @@ fn addViewportEvent(self: *SDLBackend, win: *dvui.Window, vp: *Viewport, event: switch (event.type) { c.SDL_EVENT_MOUSE_MOTION => { if (event.motion.which == c.SDL_TOUCH_MOUSEID and !self.touch_mouse_events) return false; - return try win.addEventMouseMotion(.{ .pt = viewportPoint(vp) }); + return try win.addEventMouseMotion(.{ .pt = self.viewportPoint(vp) }); }, c.SDL_EVENT_MOUSE_BUTTON_DOWN, c.SDL_EVENT_MOUSE_BUTTON_UP, c.SDL_EVENT_MOUSE_WHEEL => { // dvui presses and scrolls where the pointer last moved, which may have been over // another window: it is moved here first. - const pt = viewportPoint(vp); + const pt = self.viewportPoint(vp); if (pt.x != win.mouse_pt.x or pt.y != win.mouse_pt.y) _ = try win.addEventMouseMotion(.{ .pt = pt }); return try self.addEvent(win, event); }, @@ -1475,6 +1828,8 @@ pub fn deinit(self: *SDLBackend) void { } pub fn renderPresent(self: *SDLBackend) void { + // First, so it runs last: after the viewports' own transaction below is committed into it. + defer if (self.present_hook) |hook| hook(self); // macOS: a window put somewhere new this frame goes there in the transaction its picture is // presented in, so the two change together (`placeWindow`). Before the pictures are copied // into the windows, so a resized window's drawable is already its new size. A window's first @@ -1487,8 +1842,9 @@ pub fn renderPresent(self: *SDLBackend) void { for (&self.viewports, 0..) |*slot, i| { const vp = if (slot.*) |*v| v else continue; const first = !vp.shown and vp.pending != null; - const reshaped = vp.passive and (vp.carry_radius != vp.carry_radius_shown or vp.carry_alpha != vp.carry_alpha_shown or vp.carry_size_shown[0] != vp.screen.w or vp.carry_size_shown[1] != vp.screen.h); - if (!vp.frame_pending and !first and !reshaped) continue; + const reshaped = vp.passive and !vp.overlay and (vp.carry_radius != vp.carry_radius_shown or vp.carry_alpha != vp.carry_alpha_shown or vp.carry_size_shown[0] != vp.screen.w or vp.carry_size_shown[1] != vp.screen.h); + const reglassed = vp.overlay and (vp.glass_dirty or vp.photo_dirty); + if (!vp.frame_pending and !first and !reshaped and !reglassed) continue; if (!any) fizzy_native_transaction_begin(); any = true; transacted[i] = true; @@ -1504,6 +1860,12 @@ pub fn renderPresent(self: *SDLBackend) void { vp.carry_size_shown = .{ vp.screen.w, vp.screen.h }; fizzy_macos_viewport_carry_shape(ns, vp.carry_radius, @floatFromInt(vp.screen.w), @floatFromInt(vp.screen.h), vp.carry_alpha); } + if (reglassed) { + if (vp.glass_dirty) fizzy_macos_viewport_overlay_glass(ns, &vp.glass, @intCast(vp.glass_n), vp.glass_spacing, &vp.glass_look); + if (vp.photo_dirty) fizzy_macos_viewport_overlay_photo(ns, if (vp.photo) |*p| p else null); + vp.glass_dirty = false; + vp.photo_dirty = false; + } } } defer if (comptime builtin.os.tag == .macos) if (any) { @@ -1517,6 +1879,11 @@ pub fn renderPresent(self: *SDLBackend) void { // Each viewport's part of the frame goes in with the main window's, one submission for all. var first_frame: [max_viewports]bool = @splat(false); var map_empty: [max_viewports]bool = @splat(false); + // The main window moving itself into or out of full screen (`platform.window.windowMovingItself`): + // a float's or a dialog's window keeps its picture until it is over. AppKit hands their layers + // drawables back slowly while the main window goes through a Space transition, and waiting on + // them every frame halved the move's rate with one open. + const main_moving = platform.window.windowMovingItself(self.window); for (&self.viewports, 0..) |*slot, i| { const vp = if (slot.*) |*v| v else continue; const target = vp.pending orelse continue; @@ -1525,6 +1892,7 @@ pub fn renderPresent(self: *SDLBackend) void { // for up to a second: skipped, once it has been shown (it is created hidden, and covered // then by its own account). if (vp.shown and c.SDL_GetWindowFlags(vp.window) & (c.SDL_WINDOW_MINIMIZED | c.SDL_WINDOW_OCCLUDED) != 0) continue; + if (vp.shown and main_moving and !platform.window.windowMovingItself(vp.window)) continue; const presented = self.gpu.presentInto(vp.window, target); if (vp.shown) continue; if (presented) { @@ -1546,21 +1914,39 @@ pub fn renderPresent(self: *SDLBackend) void { _ = c.SDL_ShowWindow(vp.window); _ = c.SDL_ResetHint(c.SDL_HINT_WINDOW_ACTIVATE_WHEN_SHOWN); vp.mapped = true; + // Riding on its window from here (`Viewport.ride`) — not before it shows: made a child + // window, AppKit shows it at once, before it has a picture. + if (comptime builtin.os.tag == .macos) if (vp.ride) |parent| { + _ = c.SDL_SetWindowParent(vp.window, parent); + if (!vp.dialog) fizzy_macos_viewport_menu_attached(cocoaWindow(vp.window)); + }; } if (show) vp.shown = true; - self.order_pending = true; + vp.over_main = true; } - // Over the main window, as on Windows, where it is owned by it (`viewportOpen`): SDL showed it - // below the key window, and a press on the main window brings that forward. - if (comptime builtin.os.tag == .macos) if (self.order_pending) { - self.order_pending = false; + // Over the main window as it first shows: SDL showed it below the key window. Once + // (`noteMainForward`): from there it stacks as any window does. + if (comptime builtin.os.tag == .macos) { const main_ns = cocoaWindow(self.window); for (&self.viewports) |*slot| { const vp = if (slot.*) |*v| v else continue; - if (!vp.mapped or vp.passive) continue; - fizzy_macos_viewport_keep_above(cocoaWindow(vp.window), main_ns); + if (!vp.over_main) continue; + vp.over_main = false; + if (!vp.mapped or vp.passive or vp.menu) continue; + fizzy_macos_viewport_over_main(cocoaWindow(vp.window), main_ns); + self.stack_stale = true; } - }; + // Which floats' windows lie under the main window now, for a drag over the main window to + // read nothing of them there (`viewportUnderMain`). A query of the window server, so only + // when the stacking may have changed. + if (self.stack_stale) { + self.stack_stale = false; + for (&self.viewports) |*slot| { + const vp = if (slot.*) |*v| v else continue; + vp.under_main = vp.mapped and !vp.passive and !vp.menu and fizzy_macos_viewport_under_main(cocoaWindow(vp.window), main_ns) != 0; + } + } + } self.manage_backend_tracking.check(.renderPresent); } @@ -2600,6 +2986,30 @@ const CallbackState = struct { /// used when doing sdl callbacks var appState: CallbackState = .{ .win = undefined, .back = undefined, .gpa = undefined, .io = undefined }; +/// A frame of the app is being built or presented (`appIterate`, from `Window.begin` to its +/// present). AppKit calls back into the app at once from what a frame asks of it — +/// `toggleFullScreen:` posts its will-enter before it returns — and a frame run from such a +/// callback would nest in this one: dvui's frame state is not reentrant. The waiting after the present is +/// outside it, where SDL itself runs frames from callbacks (`appIterate`'s `no_wait`). +var app_frame_open = false; + +/// Whether a frame is open now (`app_frame_open`): a caller that would run a frame from a +/// callback wakes the loop instead (`refresh`), and the frame follows this one. +pub fn appFrameOpen() bool { + return app_frame_open; +} + +/// A frame is being run from inside an OS callback (`callbackFrames`), not by SDL's loop. +var app_callback_frames = false; + +/// Frames run from here on are run from inside an OS callback — the macOS window monitor's pump, +/// from AppKit's timers and notifications through a transition — until `on` is false. Such a +/// frame never waits for events after it presents: the wait would be an event loop nested in +/// AppKit's callback, holding up the transition that called it (`appIterate`). +pub fn callbackFrames(on: bool) void { + app_callback_frames = on; +} + // sdl3 callback fn appInit(appstate: ?*?*anyopaque, argc: c_int, argv: ?[*:null]?[*:0]u8) callconv(.c) c.SDL_AppResult { _ = appstate; @@ -2742,6 +3152,7 @@ fn appIterate(_: ?*anyopaque) callconv(.c) c.SDL_AppResult { // beginWait coordinates with waitTime below to run frames only when needed const nstime = appState.win.beginWait(appState.interrupted or appState.no_wait); + app_frame_open = true; // marks the beginning of a frame for dvui, can call dvui functions after this appState.win.begin(nstime) catch |err| { log.err("dvui.Window.begin failed: {any}", .{err}); @@ -2767,6 +3178,7 @@ fn appIterate(_: ?*anyopaque) callconv(.c) c.SDL_AppResult { appState.back.setCursor(appState.win.cursorRequested()); appState.back.textInputRect(appState.win.textInputRequested()); appState.back.renderPresent(); + app_frame_open = false; if (res != .ok) return c.SDL_APP_SUCCESS; @@ -2790,8 +3202,11 @@ fn appIterate(_: ?*anyopaque) callconv(.c) c.SDL_AppResult { // say so, and a wait there takes the tracking loop's own mouse events. // NOTE: on iOS, SDL_WaitEventTimeout stalls in UITrackingRunLoopMode during a // touch, so we throttle via ios_next_frame_ns above instead of waiting here. - if (appState.no_wait or appState.have_resize or in_live_resize or builtin.target.os.tag == .ios) { + if (appState.no_wait or appState.have_resize or in_live_resize or app_callback_frames or builtin.target.os.tag == .ios) { appState.have_resize = false; + // Run from a callback, it may sit inside SDL's own wait for events, which knows nothing of + // the frame this one asked for: wake that wait, so SDL's loop runs it. + if (app_callback_frames and wait_event_micros != std.math.maxInt(u32)) appState.back.refresh(); if (builtin.target.os.tag == .ios) { appState.ios_next_frame_ns = appState.win.backend.nanoTime() + @as(i128, wait_event_micros) * 1000; } diff --git a/src/backend/native/platform/macos/visual_effect_view.m b/src/backend/native/platform/macos/visual_effect_view.m index 35faabe4..7a91982a 100644 --- a/src/backend/native/platform/macos/visual_effect_view.m +++ b/src/backend/native/platform/macos/visual_effect_view.m @@ -1,4 +1,7 @@ #import +#import +#import +#import /** * Subclass of NSVisualEffectView that passes hit-testing through to its content subview @@ -109,34 +112,51 @@ - (NSView *)hitTest:(NSPoint)point { */ static NSImage *glassMask(double inset, double radius); +/* + * A popped-out float's window dressed as the main window is, whatever it stands on — Liquid Glass + * or vibrancy (`fizzy_macos_viewport_glass`). Skipped with Liquid Glass, a float's window had no + * shadow, and with it none of the 1-point outline the OS draws round a titled window — dark against + * a light background, light against a dark one — that the main window has (the user). + */ +static void viewportDress(NSWindow *window, NSWindow *main) { + /* The vibrancy reads light or dark by the window's appearance: the main window's, which + * follows the app's theme, not the system's. */ + if (main != nil) [window setAppearance:[main appearance]]; + [window setOpaque:NO]; + [window setBackgroundColor:[NSColor clearColor]]; + /* Titled (`SDLBackend.viewportOpen`): dressed as the main window is — its content under a + * transparent title bar, the title's text hidden (the float's header shows it), the OS's + * shadow, outline and corners. Borderless: no AppKit shadow; the float draws its own in the + * clear margin round its glass. */ + const BOOL titled = ([window styleMask] & NSWindowStyleMaskTitled) != 0; + if (titled) { + [window setStyleMask:[window styleMask] | NSWindowStyleMaskFullSizeContentView]; + [window setTitlebarAppearsTransparent:YES]; + [window setTitleVisibility:NSWindowTitleHidden]; + /* Full screen in a Space of its own, as the main window goes: the main window's monitor + * follows it there and back (`macos_monitor.watch`, `window_monitor.m`). */ + } + if ([window hasShadow] != titled) [window setHasShadow:titled]; + /* No OS animation as it shows or closes: it appears and goes exactly where its float is + * drawn, in the frame it changes in. AppKit's show and close animations never finished + * under fizzy's frame loop, and the window they stood in for stayed on screen — shrunk + * while the float was out, and after it had gone, where it first opened. */ + [window setAnimationBehavior:NSWindowAnimationBehaviorNone]; +} + +void fizzy_macos_viewport_dress(void *nswindow, void *main_nswindow) { + @autoreleasepool { + NSWindow *window = (__bridge NSWindow *)nswindow; + if (window == nil) return; + viewportDress(window, (__bridge NSWindow *)main_nswindow); + } +} + void fizzy_macos_viewport_glass(void *nswindow, void *main_nswindow, double inset, double radius, long material) { @autoreleasepool { NSWindow *window = (__bridge NSWindow *)nswindow; if (window == nil) return; - /* The vibrancy reads light or dark by the window's appearance: the main window's, which - * follows the app's theme, not the system's. */ - NSWindow *main = (__bridge NSWindow *)main_nswindow; - if (main != nil) [window setAppearance:[main appearance]]; - [window setOpaque:NO]; - [window setBackgroundColor:[NSColor clearColor]]; - /* Titled (`SDLBackend.viewportOpen`): dressed as the main window is — its content under a - * transparent title bar, the title's text hidden (the float's header shows it), the OS's - * shadow and corners. Borderless: no AppKit shadow; the float draws its own in the clear - * margin round its glass. */ - const BOOL titled = ([window styleMask] & NSWindowStyleMaskTitled) != 0; - if (titled) { - [window setStyleMask:[window styleMask] | NSWindowStyleMaskFullSizeContentView]; - [window setTitlebarAppearsTransparent:YES]; - [window setTitleVisibility:NSWindowTitleHidden]; - /* Full screen in a Space of its own, as the main window goes: the main window's monitor - * follows it there and back (`macos_monitor.watch`, `window_monitor.m`). */ - } - [window setHasShadow:titled]; - /* No OS animation as it shows or closes: it appears and goes exactly where its float is - * drawn, in the frame it changes in. AppKit's show and close animations never finished - * under fizzy's frame loop, and the window they stood in for stayed on screen — shrunk - * while the float was out, and after it had gone, where it first opened. */ - [window setAnimationBehavior:NSWindowAnimationBehaviorNone]; + viewportDress(window, (__bridge NSWindow *)main_nswindow); NSView *content = [window contentView]; NSView *frame = [content superview]; if (content == nil || frame == nil) return; @@ -203,15 +223,15 @@ void fizzy_macos_viewport_unglass(void *nswindow) { } /* - * A popped-out float's window stays over the main window, as a window the main one owns does on - * Windows — without being made its child window, which AppKit would move with it (the pop-out - * plan's decision 2: the main window moving leaves the windows that came out of it where they - * are). SDL orders a window it shows without activating it *below* the key window, the main one: - * this orders it back above, and again whenever the main window has come in front of it (a press - * on the main window brings it forward). Cheap when nothing is out of order: one comparison of the - * app's window order. Other apps' windows still go over it, as over the main window. + * A popped-out float's window put over the main window, once (`SDLBackend`): as it first shows — + * SDL orders a window it shows without activating it *below* the key window, the main one — and as + * the main window first shows, the app activating at launch bringing it over the floats restored + * with it. Not kept there: a float's window stacks as any window does (the user) — a press on the + * main window brings it in front of the floats, and a press on a float brings that one forward. A + * float kept over the main window came back over it at every press there, and the part of the main + * window under it could not be worked in. */ -void fizzy_macos_viewport_keep_above(void *nswindow, void *main_nswindow) { +void fizzy_macos_viewport_over_main(void *nswindow, void *main_nswindow) { @autoreleasepool { NSWindow *window = (__bridge NSWindow *)nswindow; NSWindow *main = (__bridge NSWindow *)main_nswindow; @@ -225,6 +245,21 @@ void fizzy_macos_viewport_keep_above(void *nswindow, void *main_nswindow) { } } +/* + * Whether `nswindow` lies under the main window in the app's stacking: a float's window clicked + * behind it. Not while either is hidden or in a fullscreen Space of its own. + */ +int fizzy_macos_viewport_under_main(void *nswindow, void *main_nswindow) { + @autoreleasepool { + NSWindow *window = (__bridge NSWindow *)nswindow; + NSWindow *main = (__bridge NSWindow *)main_nswindow; + if (window == nil || main == nil) return 0; + if (![window isVisible] || ![main isVisible] || [main isMiniaturized]) return 0; + if ((([window styleMask] | [main styleMask]) & NSWindowStyleMaskFullScreen) != 0) return 0; + return [window orderedIndex] > [main orderedIndex]; + } +} + /* * A popped-out float's window in the Window menu — and so in the Dock's menu for the app — * called `title`, as a titled window is listed by itself: AppKit lists no borderless window @@ -363,7 +398,9 @@ void fizzy_macos_viewport_carry(void *nswindow, void *main_nswindow, long materi * macOS 26, 10 before. */ double fizzy_macos_window_corner_radius(void) { - if (@available(macOS 26.0, *)) return 16; + /* A titled window with a compact toolbar (`fizzy_macos_window_liquid_glass`, macOS 26): 20; a + * titled window with none, 16 — measured from the windows' own pictures. */ + if (@available(macOS 26.0, *)) return 20; return 10; } @@ -383,6 +420,1026 @@ void fizzy_macos_viewport_carry_lens(void *nswindow, int lens) { } } +/* + * The drag's glass as one overlay of Liquid Glass (macOS 26): a window over a whole display + * (`SDLBackend.viewportOpenOverlay`) holding an `NSGlassEffectContainerView`, its pieces of glass + * one view each, which the container runs together where they come within its spacing — the + * carried drop into the bubble it is aimed at, its head into its tail. Under SDL's view, which holds + * what only fizzy draws: the carried photograph. + */ +@interface FizzyOverlayGlassHolder : NSView +@end + +@implementation FizzyOverlayGlassHolder +/* From the top left, as fizzy places the glass. */ +- (BOOL)isFlipped { + return YES; +} +- (NSView *)hitTest:(NSPoint)point { + (void)point; + return nil; +} +@end + +/* The overlay's two layers of glass, under and over (`fizzy_macos_viewport_overlay_glass`), and + * the window's colour beneath them both. */ +static NSString *const overlay_glass_ids[2] = {@"fizzy.overlay.glass.under", @"fizzy.overlay.glass.over"}; +static NSString *const overlay_fill_id = @"fizzy.overlay.fill"; +static NSString *const overlay_blur_id = @"fizzy.overlay.blur"; +static NSString *const overlay_photo_id = @"fizzy.overlay.photo"; + +/* + * A plain blur of what is behind the window: Core Animation's backdrop layer, which the OS's own + * materials are built from, with a Gaussian blur on it and nothing else — none of a material's + * tint, which pulls everything toward its own grey (`core.glass_look.drop_blur`). Private: nil + * where the OS has no such layer, or it does not take these, and the glass keeps its frost. + */ +static CALayer *overlayBlurLayer(void) { + Class backdrop = NSClassFromString(@"CABackdropLayer"); + Class filter = NSClassFromString(@"CAFilter"); + const SEL make = sel_registerName("filterWithType:"); + const SEL server = sel_registerName("setWindowServerAware:"); + if (backdrop == nil || filter == nil || ![filter respondsToSelector:make]) return nil; + @try { + CALayer *layer = [backdrop layer]; + /* What is behind the window, not only what is under it in it. */ + if (![layer respondsToSelector:server]) return nil; + ((void (*)(id, SEL, BOOL))objc_msgSend)(layer, server, YES); + id blur = ((id (*)(id, SEL, id))objc_msgSend)(filter, make, @"gaussianBlur"); + if (blur == nil) return nil; + [blur setValue:@(0) forKey:@"inputRadius"]; + [blur setValue:@YES forKey:@"inputNormalizeEdges"]; + [layer setFilters:@[ blur ]]; + return layer; + } @catch (NSException *e) { + (void)e; + return nil; + } +} + +/* Whether the OS has Liquid Glass, and the container that merges it: macOS 26. */ +int fizzy_macos_liquid_glass_available(void) { + if (@available(macOS 26.0, *)) { + return NSClassFromString(@"NSGlassEffectView") != nil && NSClassFromString(@"NSGlassEffectContainerView") != nil; + } + return 0; +} + +void fizzy_macos_viewport_overlay(void *nswindow, void *main_nswindow) { + @autoreleasepool { + NSWindow *window = (__bridge NSWindow *)nswindow; + if (window == nil) return; + NSWindow *main = (__bridge NSWindow *)main_nswindow; + if (main != nil) [window setAppearance:[main appearance]]; + [window setOpaque:NO]; + [window setBackgroundColor:[NSColor clearColor]]; + /* Clear but for its glass, which draws its own edge: no window shadow round the display. */ + [window setHasShadow:NO]; + NSView *content = [window contentView]; + NSView *frame = [content superview]; + Class container_class = NSClassFromString(@"NSGlassEffectContainerView"); + /* From the bottom, each added directly under SDL's view so over the one before: the lens, + * frost — two layers of the same glass, crossfaded to blend two of its materials — and the + * window's colour over them (`fizzy_macos_viewport_overlay_glass`). */ + for (int k = 0; k < 3 && content != nil && frame != nil; k++) { + if (k == 2) { + /* The plain blur over the frost's place (`overlayBlurLayer`), sized to the glass each + * frame; hidden until then. */ + CALayer *blur_layer = overlayBlurLayer(); + if (blur_layer != nil) { + NSView *blur = [[NSView alloc] initWithFrame:NSZeroRect]; + [blur setIdentifier:overlay_blur_id]; + [blur setLayer:blur_layer]; + [blur setWantsLayer:YES]; + [blur setHidden:YES]; + [frame addSubview:blur positioned:NSWindowBelow relativeTo:content]; +#if !__has_feature(objc_arc) + [blur release]; +#endif + } + /* Over the glass's frost, the carried view's picture (`fizzy_macos_viewport_overlay_photo`): + * its content on the drop's frosted glass, as a bubble's icon is on its own. */ + FizzyOverlayGlassHolder *photo = [[FizzyOverlayGlassHolder alloc] initWithFrame:[content frame]]; + [photo setIdentifier:overlay_photo_id]; + [photo setAutoresizingMask:NSViewWidthSizable | NSViewHeightSizable]; + [photo setWantsLayer:YES]; + [frame addSubview:photo positioned:NSWindowBelow relativeTo:content]; +#if !__has_feature(objc_arc) + [photo release]; +#endif + FizzyOverlayGlassHolder *fill = [[FizzyOverlayGlassHolder alloc] initWithFrame:[content frame]]; + [fill setIdentifier:overlay_fill_id]; + [fill setAutoresizingMask:NSViewWidthSizable | NSViewHeightSizable]; + [fill setWantsLayer:YES]; + [frame addSubview:fill positioned:NSWindowBelow relativeTo:content]; +#if !__has_feature(objc_arc) + [fill release]; +#endif + continue; + } + if (container_class == nil) continue; + NSView *container = [[container_class alloc] initWithFrame:[content frame]]; + [container setIdentifier:overlay_glass_ids[k]]; + [container setAutoresizingMask:NSViewWidthSizable | NSViewHeightSizable]; + FizzyOverlayGlassHolder *holder = [[FizzyOverlayGlassHolder alloc] initWithFrame:[container bounds]]; + [holder setAutoresizingMask:NSViewWidthSizable | NSViewHeightSizable]; + [container setValue:holder forKey:@"contentView"]; + [frame addSubview:container positioned:NSWindowBelow relativeTo:content]; +#if !__has_feature(objc_arc) + [holder release]; + [container release]; +#endif + } + [window setIgnoresMouseEvents:YES]; + [window setAnimationBehavior:NSWindowAnimationBehaviorNone]; + [window setLevel:NSPopUpMenuWindowLevel]; + [window setExcludedFromWindowsMenu:YES]; + [window setCollectionBehavior:NSWindowCollectionBehaviorCanJoinAllSpaces | NSWindowCollectionBehaviorTransient | + NSWindowCollectionBehaviorIgnoresCycle | NSWindowCollectionBehaviorFullScreenAuxiliary]; + } +} + +/* A piece of the overlay's glass, as `SDLBackend.GlassShape` lays it out: points from the window's + * top left. */ +typedef struct { + double x, y, w, h, radius, lit, alpha, frost; +} FizzyGlassShape; + +/* What the overlay's glass is, as `SDLBackend.GlassLook` lays it out: each layer's variant and + * style, how much of the over one there is, how much glass there is at all, the window's colour + * over the lens (its opacity last), what a lit piece goes toward (how far last), and each piece's + * clearing bevel (`core.glass_look.band`): a share of its shorter half, at most a cap in points, + * clear for a share of that. */ +typedef struct { + long under_variant, under_style, over_variant, over_style; + double over_share; + double glass; + double fill[4]; + double lit_toward[4]; + double bevel, bevel_cap, bevel_clear; + double blur; +} FizzyGlassLook; + +/* + * The window's colour in the overlay's glass: one shape layer for the union of every piece that + * is all there (the first), so where pieces overlap — the carried drop's head and tail, the drop + * snapped onto a bubble — the colour is drawn once, as the glass runs them into one; a layer of + * its own for a piece still coming or going (its opacity its own), and in a lit piece a soft glow + * toward `lit_toward`, gone before its edge. A layer per piece drew overlaps twice: the tail read as a + * darker circle inside the head (the user). Over the lens and the frost, faded out across each + * piece's clearing bevel (`overlayBevelMask`): under the lens it was a disc the lens bent, where the + * surface being dragged over should be; flat over all the glass it muted the rim's shine; under the + * frost, the frost's light lifted it, and the glass grew darker as it went at the top. + * Nothing between pieces where the glass bridges them: a neck drawn there showed past the bridge + * as a dark bar. + */ +static void overlayFill(NSView *holder, const FizzyGlassShape *shapes, long n, double spacing, const FizzyGlassLook *look) { + (void)spacing; + CALayer *root = [holder layer]; + if (root == nil) return; + [root setGeometryFlipped:YES]; + NSMutableArray *pool = [NSMutableArray arrayWithArray:[root sublayers] ?: @[]]; + while ([pool count] < (NSUInteger)(n + 1)) { + CAShapeLayer *layer = [CAShapeLayer layer]; + [root addSublayer:layer]; + [pool addObject:layer]; + } + const double opacity = fmin(fmax(look->fill[3], 0), 1); + const double lit_amount = fmin(fmax(look->lit_toward[3], 0), 1); + CGColorSpaceRef srgb = CGColorSpaceCreateWithName(kCGColorSpaceSRGB); + CGMutablePathRef whole = CGPathCreateMutable(); + for (long i = 0; i < n; i++) { + const FizzyGlassShape a = shapes[i]; + const double ra = fmin(a.radius, fmin(a.w, a.h) / 2); + const CGRect rect = CGRectMake(a.x, a.y, a.w, a.h); + CAShapeLayer *layer = (CAShapeLayer *)pool[(NSUInteger)i + 1]; + const double mix = lit_amount * fmin(fmax(a.lit, 0), 1); + const BOOL coming = a.alpha < 0.99; + if (!coming && a.alpha > 0.01) CGPathAddRoundedRect(whole, NULL, rect, ra, ra); + /* Its own colour while it comes or goes, at its own opacity; lit, a soft glow in it, + * brightest in the middle and gone before the edge — a filled light read as a disc under + * the glass (the user). */ + CAGradientLayer *glow = (CAGradientLayer *)[[layer sublayers] firstObject]; + if (glow == nil) { + glow = [CAGradientLayer layer]; + [glow setType:kCAGradientLayerRadial]; + [glow setStartPoint:CGPointMake(0.5, 0.5)]; + [glow setEndPoint:CGPointMake(1, 1)]; + [glow setLocations:@[@0, @0.5, @1]]; + [layer addSublayer:glow]; + } + const double fill_alpha = coming ? opacity : 0; + const BOOL lit = mix > 0.004; + [layer setHidden:a.alpha <= 0.01 || (fill_alpha <= 0.004 && !lit)]; + if ([layer isHidden]) continue; + [layer setFrame:[root bounds]]; + CGPathRef path = CGPathCreateWithRoundedRect(rect, ra, ra, NULL); + [layer setPath:path]; + CGPathRelease(path); + const CGFloat fill_comps[4] = {(CGFloat)look->fill[0], (CGFloat)look->fill[1], (CGFloat)look->fill[2], (CGFloat)fill_alpha}; + CGColorRef fill_color = CGColorCreate(srgb, fill_comps); + [layer setFillColor:fill_color]; + CGColorRelease(fill_color); + [glow setHidden:!lit]; + if (lit) { + [glow setFrame:rect]; + CGColorRef stops[3]; + const double alphas[3] = {0.55 * mix, 0.28 * mix, 0}; + for (int k = 0; k < 3; k++) { + const CGFloat c[4] = {(CGFloat)look->lit_toward[0], (CGFloat)look->lit_toward[1], (CGFloat)look->lit_toward[2], (CGFloat)alphas[k]}; + stops[k] = CGColorCreate(srgb, c); + } + [glow setColors:@[(__bridge id)stops[0], (__bridge id)stops[1], (__bridge id)stops[2]]]; + for (int k = 0; k < 3; k++) CGColorRelease(stops[k]); + } + [layer setOpacity:coming ? (float)fmin(fmax(a.alpha, 0), 1) : 1]; + } + CAShapeLayer *union_layer = (CAShapeLayer *)pool[0]; + [union_layer setFrame:[root bounds]]; + [union_layer setPath:whole]; + [union_layer setFillRule:kCAFillRuleNonZero]; + [union_layer setHidden:opacity <= 0.004]; + const CGFloat union_comps[4] = {(CGFloat)look->fill[0], (CGFloat)look->fill[1], (CGFloat)look->fill[2], (CGFloat)opacity}; + CGColorRef union_color = CGColorCreate(srgb, union_comps); + [union_layer setFillColor:union_color]; + CGColorRelease(union_color); + CGPathRelease(whole); + CGColorSpaceRelease(srgb); + for (NSUInteger i = (NSUInteger)n + 1; i < [pool count]; i++) [pool[i] setHidden:YES]; +} + +/* One layer of the overlay's glass: a view per shape in `container`'s holder, made as needed and + * kept for the next frame, the rest hidden; all of them `variant` and `style`. */ +static void overlayGlassLayer(NSView *container, const FizzyGlassShape *shapes, long n, double spacing, long variant, long style) { + NSView *holder = [container valueForKey:@"contentView"]; + if (holder == nil) return; + ((void (*)(id, SEL, CGFloat))objc_msgSend)(container, sel_registerName("setSpacing:"), (CGFloat)spacing); + NSArray *pool = [[holder subviews] copy]; + const SEL set_radius = sel_registerName("setCornerRadius:"); + const SEL get_variant = sel_registerName("_variant"); + for (long i = 0; i < n; i++) { + NSView *glass = (NSUInteger)i < [pool count] ? pool[(NSUInteger)i] : nil; + if (glass == nil) { + glass = carryLiquidGlass(NSZeroRect); + if (glass == nil) break; + [glass setAutoresizingMask:NSViewNotSizable]; + [holder addSubview:glass]; + } + const FizzyGlassShape sh = shapes[i]; + [glass setHidden:sh.alpha <= 0.01]; + [glass setFrame:NSMakeRect(sh.x, sh.y, sh.w, sh.h)]; + ((void (*)(id, SEL, CGFloat))objc_msgSend)(glass, set_radius, (CGFloat)fmin(sh.radius, fmin(sh.w, sh.h) / 2)); + [glass setAlphaValue:(CGFloat)sh.alpha]; + if ([[glass valueForKey:@"style"] longValue] != style) [glass setValue:@(style) forKey:@"style"]; + if (![glass respondsToSelector:get_variant] || ((long (*)(id, SEL))objc_msgSend)(glass, get_variant) != variant) + carryGlassVariant(glass, variant); + } + for (NSUInteger i = (NSUInteger)(n < 0 ? 0 : n); i < [pool count]; i++) [pool[i] setHidden:YES]; +} + +static CGImageRef bevelImage(double radius, double clear, double feather, double scale, double *edge); + +/* A rounded rect's clearing bevel (`bevelImage`), kept by its shape for the frames after: a + * carried card's corners, which a radial gradient left clear. A few kept at a time — a shape + * morphing between a drop and a card makes a new one each frame. */ +static CGImageRef overlayBevelImage(double radius, double clear, double feather, double scale, double *edge) { + static NSMutableDictionary *kept = nil; + if (kept == nil) kept = [[NSMutableDictionary alloc] init]; + NSString *key = [NSString stringWithFormat:@"%.2f/%.2f/%.2f/%.2f", radius, clear, feather, scale]; + *edge = ceil(clear + feather + radius); + id image = [kept objectForKey:key]; + if (image != nil) return (__bridge CGImageRef)image; + CGImageRef made = bevelImage(radius, clear, feather, scale, edge); + if (made == NULL) return NULL; + if ([kept count] >= 16) [kept removeAllObjects]; + [kept setObject:(__bridge id)made forKey:key]; + CGImageRelease(made); + return (__bridge CGImageRef)[kept objectForKey:key]; +} + +/* + * `view`'s layer faded out across each piece's clearing bevel (`core.glass_look.band`, from + * `look`): a mask with a slot per piece, opaque in its middle and clear along its edge — a share of + * its shorter half, at most a cap in points, so the band hugs the rim of a small piece — so the + * glass's rim is the clear lens beneath, bending the surface behind it, its rim light bright, and + * only its middle takes the frost and the colour, as the app's own glass does. A round piece's slot + * is a radial gradient; a rounded rect's, the bevel's nine-sliced image (`overlayBevelImage`). On + * the frost's container, not on each piece, so the container still runs the pieces together; a + * bridge between two, near both their edges, stays clear. The colour's view masked the same way. + */ +static void overlayBevelMask(NSView *view, const FizzyGlassShape *shapes, long n, const FizzyGlassLook *look, BOOL by_frost, double ox, double oy) { + CALayer *root = [view layer]; + if (root == nil) return; + CALayer *mask = [root mask]; + if (mask == nil) { + mask = [CALayer layer]; + [root setMask:mask]; + } + [mask setFrame:[root bounds]]; + /* The pieces are placed from the top left: a layer flipped already (the colour's holder) is + * not flipped again. */ + [mask setGeometryFlipped:![root isGeometryFlipped]]; + const double scale = [[view window] backingScaleFactor] > 0 ? [[view window] backingScaleFactor] : 2; + NSMutableArray *pool = [NSMutableArray arrayWithArray:[mask sublayers] ?: @[]]; + while ([pool count] < (NSUInteger)(n < 0 ? 0 : n)) { + CALayer *slot = [CALayer layer]; + CAGradientLayer *g = [CAGradientLayer layer]; + [g setType:kCAGradientLayerRadial]; + [g setStartPoint:CGPointMake(0.5, 0.5)]; + [g setEndPoint:CGPointMake(1, 1)]; + [g setColors:@[(id)[[NSColor blackColor] CGColor], (id)[[NSColor blackColor] CGColor], (id)[[NSColor clearColor] CGColor]]]; + [slot addSublayer:g]; + CALayer *sliced = [CALayer layer]; + [sliced setContentsGravity:kCAGravityResize]; + [slot addSublayer:sliced]; + [mask addSublayer:slot]; + [pool addObject:slot]; + } + for (long i = 0; i < n; i++) { + const FizzyGlassShape sh = shapes[i]; + CALayer *slot = pool[(NSUInteger)i]; + CAGradientLayer *g = (CAGradientLayer *)[[slot sublayers] objectAtIndex:0]; + CALayer *sliced = [[slot sublayers] objectAtIndex:1]; + const double r = fmin(sh.w, sh.h) / 2; + const double band = fmin(look->bevel * fmax(r, 0), look->bevel_cap); + const double clear = band * look->bevel_clear; + /* The frost's own: as much of it as the piece takes (`FizzyGlassShape.frost`) — none over + * the carried view, a clear lens over its picture. */ + const double frost = by_frost ? fmin(fmax(sh.frost, 0), 1) : 1; + [slot setHidden:sh.alpha <= 0.01 || r <= clear || frost <= 0.01]; + [slot setOpacity:(float)frost]; + [slot setFrame:CGRectMake(sh.x - ox, sh.y - oy, sh.w, sh.h)]; + const double corner = fmin(fmax(sh.radius, 0), r); + double edge = ceil(band + corner); + CGImageRef image = NULL; + if (corner < r - 0.5 && 2 * edge + 1 <= 2 * r) image = overlayBevelImage(corner, clear, band - clear, scale, &edge); + [g setHidden:image != NULL]; + [sliced setHidden:image == NULL]; + if (image != NULL) { + const CGFloat side = (CGFloat)(2 * edge + 1); + if ((__bridge id)image != [sliced contents]) [sliced setContents:(__bridge id)image]; + [sliced setContentsScale:(CGFloat)scale]; + [sliced setContentsCenter:CGRectMake(edge / side, edge / side, 1 / side, 1 / side)]; + [sliced setFrame:[slot bounds]]; + } else { + [g setFrame:[slot bounds]]; + const double outer = r > 0 ? fmax(0, (r - clear) / r) : 0; + const double inner = r > 0 ? fmin(outer, fmax(0, (r - band) / r)) : 0; + [g setLocations:@[@0, @(inner), @(outer)]]; + } + } + for (NSUInteger i = (NSUInteger)(n < 0 ? 0 : n); i < [pool count]; i++) [pool[i] setHidden:YES]; +} + +/* + * The overlay's glass this frame: two layers of the same pieces (`overlayGlassLayer`), the under + * one `look`'s under material (the lens) whole and the over one its over material (frost) over it + * at its share, the window's colour over both, frost and colour faded out across each piece's + * clearing bevel (`overlayBevelMask`) — the glass has no blur to turn, so the way from the clear lens to + * frost is the frost coming in over it (`Popout.glassLook`), its rim the lens throughout; a layer + * with nothing to show is hidden, costing nothing. Each layer's pieces alike — the container runs together only glass that is: a piece + * in the glass's pressed look (`_interactionState`) never merged with its neighbours, so a lit + * piece is lit in fizzy's picture over it instead (`Popout.glassBase`). Called in the transaction + * the window's picture is presented in (`SDLBackend.renderPresent`), with implicit animations off, + * so glass and picture change together. + */ +/* Points inside its drop's edge the carried view's picture stops, so the rim stays glass. */ +static const double photo_rim = 4; + +/* The carried view's picture in the overlay's glass, as `SDLBackend.OverlayPhoto` lays it out. */ +typedef struct { + double x, y, w, h, radius; + double image[4]; + double fill[4]; + double alpha, blur; +} FizzyOverlayPhoto; + +static NSView *overlayPart(NSWindow *window, NSString *ident) { + NSView *frame = [[window contentView] superview]; + for (NSView *v in [frame subviews]) { + if ([[v identifier] isEqualToString:ident]) return v; + } + return nil; +} + +/* The photo's two layers in its holder: the rounded rect it shows in, and the image in that. */ +static CALayer *overlayPhotoLayers(NSView *holder, CALayer **image) { + CALayer *root = [holder layer]; + if (root == nil) return nil; + [root setGeometryFlipped:YES]; + CALayer *shape = [[root sublayers] firstObject]; + if (shape == nil) { + shape = [CALayer layer]; + [shape setMasksToBounds:YES]; + [shape setHidden:YES]; + CALayer *pic = [CALayer layer]; + [pic setContentsGravity:kCAGravityResize]; + /* Shrunk to the drop several times over: mipmapped, or every move of a fraction of a pixel + * picks other pixels of it, and it shimmers. */ + [pic setMinificationFilter:kCAFilterTrilinear]; + [shape addSublayer:pic]; + [root addSublayer:shape]; + } + *image = [[shape sublayers] firstObject]; + return shape; +} + +/* + * The image of the carried view's picture under overlay `nswindow`'s glass: premultiplied RGBA rows, + * `w` by `h`, copied into an image the layer keeps; NULL clears it. Set once a drag — after that + * the picture only moves (`fizzy_macos_viewport_overlay_photo`), nothing presented for it. + */ +void fizzy_macos_viewport_overlay_photo_image(void *nswindow, const unsigned char *rgba, long w, long h) { + @autoreleasepool { + NSWindow *window = (__bridge NSWindow *)nswindow; + if (window == nil) return; + NSView *holder = overlayPart(window, overlay_photo_id); + if (holder == nil) return; + CALayer *image = nil; + if (overlayPhotoLayers(holder, &image) == nil || image == nil) return; + [CATransaction setDisableActions:YES]; + if (rgba == NULL || w <= 0 || h <= 0) { + [image setContents:nil]; + return; + } + CFDataRef data = CFDataCreate(NULL, rgba, (CFIndex)(w * h * 4)); + CGDataProviderRef provider = CGDataProviderCreateWithCFData(data); + CGColorSpaceRef srgb = CGColorSpaceCreateWithName(kCGColorSpaceSRGB); + CGImageRef made = CGImageCreate((size_t)w, (size_t)h, 8, 32, (size_t)w * 4, srgb, + kCGImageAlphaPremultipliedLast | kCGBitmapByteOrderDefault, provider, NULL, false, + kCGRenderingIntentDefault); + [image setContents:(__bridge id)made]; + if (made != NULL) CGImageRelease(made); + CGColorSpaceRelease(srgb); + CGDataProviderRelease(provider); + CFRelease(data); + } +} + +/* + * Where the carried view's picture is on overlay `nswindow`'s glass this frame: in its drop's rounded + * rect, a little inside its rim, on its fill (none, for a picture on frosted glass), its image laid + * where it is told — fitted to what it shows, so it may reach past the rect, cut off there — + * `blur` points blurred, as opaque as it is told. NULL hides it. Over the glass's frost, in the + * same window: it moves with the glass in one transaction, as a window of its own presented every + * frame did not. + */ +void fizzy_macos_viewport_overlay_photo(void *nswindow, const FizzyOverlayPhoto *p) { + @autoreleasepool { + NSWindow *window = (__bridge NSWindow *)nswindow; + if (window == nil) return; + NSView *holder = overlayPart(window, overlay_photo_id); + if (holder == nil) return; + CALayer *image = nil; + CALayer *shape = overlayPhotoLayers(holder, &image); + if (shape == nil || image == nil) return; + [CATransaction setDisableActions:YES]; + if (p == NULL || p->alpha <= 0.01) { + [shape setHidden:YES]; + return; + } + [shape setHidden:NO]; + /* Inside the drop's rim, which stays its glass. */ + const double in = fmin(photo_rim, fmin(p->w, p->h) / 4); + [shape setFrame:CGRectMake(p->x + in, p->y + in, fmax(p->w - 2 * in, 1), fmax(p->h - 2 * in, 1))]; + [shape setCornerRadius:fmax(0, fmin(fmax(p->radius - in, 0), fmin(p->w, p->h) / 2 - in))]; + [shape setOpacity:(float)fmin(p->alpha, 1)]; + CGColorSpaceRef srgb = CGColorSpaceCreateWithName(kCGColorSpaceSRGB); + const CGFloat comps[4] = {(CGFloat)p->fill[0], (CGFloat)p->fill[1], (CGFloat)p->fill[2], (CGFloat)p->fill[3]}; + CGColorRef fill = CGColorCreate(srgb, comps); + [shape setBackgroundColor:fill]; + CGColorRelease(fill); + CGColorSpaceRelease(srgb); + [image setFrame:CGRectMake(p->image[0] - in, p->image[1] - in, p->image[2], p->image[3])]; + /* Blurred as the bubbles' glass blurs (`overlayBlurLayer`'s filter, on the picture itself): + * a plain blur, none of a material's grey — `blur` points of the image as it lies here. */ + const double radius = fmax(p->blur, 0); + @try { + if (radius <= 0.01) { + if ([[image filters] count] > 0) [image setFilters:nil]; + } else { + if ([[image filters] count] == 0) { + Class filter = NSClassFromString(@"CAFilter"); + const SEL make = sel_registerName("filterWithType:"); + id blur = (filter != nil && [filter respondsToSelector:make]) ? ((id (*)(id, SEL, id))objc_msgSend)(filter, make, @"gaussianBlur") : nil; + /* Not normalised at its edges, as the backdrop's is: over a picture mostly clear — + * a view captured alone — that turned everything clear in it black. */ + if (blur != nil) [image setFilters:@[ blur ]]; + } + NSNumber *was = [image valueForKeyPath:@"filters.gaussianBlur.inputRadius"]; + if ([[image filters] count] > 0 && (was == nil || fabs([was doubleValue] - radius) > 0.01)) + [image setValue:@(radius) forKeyPath:@"filters.gaussianBlur.inputRadius"]; + } + } @catch (NSException *e) { + (void)e; + } + } +} + +/* + * The plain blur in the overlay's glass (`overlayBlurLayer`): one view over every piece that takes + * frost, as big as they are together — the blur is of all it covers, every frame — `radius` points, + * `alpha` opaque, through the frost's fade toward each piece's edge (`overlayBevelMask`). None, and + * it is hidden. `area` is SDL's view's frame, which the pieces are placed in from its top left. + */ +static void overlayBlur(NSView *blur, NSRect area, const FizzyGlassShape *shapes, long n, const FizzyGlassLook *look, double radius, double alpha) { + CGRect u = CGRectNull; + for (long i = 0; i < n; i++) { + const FizzyGlassShape sh = shapes[i]; + if (sh.alpha <= 0.01 || sh.frost <= 0.01) continue; + u = CGRectUnion(u, CGRectMake(sh.x, sh.y, sh.w, sh.h)); + } + const BOOL shows = radius > 0 && alpha > 0.01 && !CGRectIsNull(u); + [blur setHidden:!shows]; + if (!shows) return; + u = CGRectIntegral(CGRectInset(u, -2, -2)); + [blur setFrame:NSMakeRect(area.origin.x + u.origin.x, area.origin.y + area.size.height - CGRectGetMaxY(u), u.size.width, u.size.height)]; + CALayer *layer = [blur layer]; + @try { + NSNumber *was = [layer valueForKeyPath:@"filters.gaussianBlur.inputRadius"]; + if (was == nil || fabs([was doubleValue] - radius) > 0.01) [layer setValue:@(radius) forKeyPath:@"filters.gaussianBlur.inputRadius"]; + } @catch (NSException *e) { + (void)e; + } + [layer setOpacity:(float)alpha]; + overlayBevelMask(blur, shapes, n, look, YES, u.origin.x, u.origin.y); +} + +void fizzy_macos_viewport_overlay_glass(void *nswindow, const FizzyGlassShape *shapes, long n, double spacing, const FizzyGlassLook *look) { + @autoreleasepool { + NSWindow *window = (__bridge NSWindow *)nswindow; + if (window == nil || look == NULL) return; + NSView *frame = [[window contentView] superview]; + NSView *layers[2] = {nil, nil}; + NSView *fill = nil; + NSView *blur = nil; + for (NSView *v in [frame subviews]) { + for (int k = 0; k < 2; k++) { + if ([[v identifier] isEqualToString:overlay_glass_ids[k]]) layers[k] = v; + } + if ([[v identifier] isEqualToString:overlay_fill_id]) fill = v; + if ([[v identifier] isEqualToString:overlay_blur_id]) blur = v; + } + [CATransaction setDisableActions:YES]; + if (fill != nil) { + overlayFill(fill, shapes, n, spacing, look); + overlayBevelMask(fill, shapes, n, look, NO, 0, 0); + } + const double share = fmin(fmax(look->over_share, 0), 1); + const double glass = fmin(fmax(look->glass, 0), 1); + /* The under layer whole; over it in each piece's body a plain blur (`overlayBlurLayer`) where + * there is one, the frost at its share (`glass_look`'s `drop_frost`) where there is not — + * either faded out toward the pieces' edges (`overlayBevelMask`) so the under one's rim shows + * round it, and over each piece as much as it takes. */ + const double radius = fmax(look->blur, 0); + const BOOL blurs = blur != nil && radius > 0; + const double alphas[2] = {glass, blurs ? 0 : share * glass}; + if (blur != nil) overlayBlur(blur, [[window contentView] frame], shapes, n, look, blurs ? radius : 0, glass); + const long variants[2] = {look->under_variant, look->over_variant}; + const long styles[2] = {look->under_style, look->over_style}; + for (int k = 0; k < 2; k++) { + if (layers[k] == nil) continue; + const BOOL shows = alphas[k] > 0.01; + [layers[k] setHidden:!shows]; + [layers[k] setAlphaValue:(CGFloat)alphas[k]]; + overlayGlassLayer(layers[k], shapes, shows ? n : 0, spacing, variants[k], styles[k]); + if (k == 1) overlayBevelMask(layers[k], shapes, shows ? n : 0, look, YES, 0, 0); + } + } +} + +/* A titled window's Liquid Glass (`fizzy_macos_window_liquid_glass`), from the bottom: the window's + * colour all over, for the top of the slider; the lens; and in the body frost, the plain blur + * behind the window and the colour again. */ +enum { window_glass_top, window_glass_under, window_glass_over, window_glass_blur, window_glass_fill, window_glass_parts }; +static NSString *const window_glass_ids[window_glass_parts] = {@"fizzy.window.top", @"fizzy.window.glass.under", @"fizzy.window.glass.over", @"fizzy.window.blur", @"fizzy.window.fill"}; + +static NSView *windowGlassPart(NSWindow *window, int k) { + NSView *frame = [[window contentView] superview]; + for (NSView *v in [frame subviews]) { + if ([[v identifier] isEqualToString:window_glass_ids[k]]) return v; + } + return nil; +} + +/* The plain blur behind a window of Liquid Glass, in its body: the vibrancy fizzy's windows wore + * before (`platform.window.ns_visual_effect_material`). Never in the way of a press. */ +@interface FizzyWindowBlurView : NSVisualEffectView +@end + +@implementation FizzyWindowBlurView +- (NSView *)hitTest:(NSPoint)point { + (void)point; + return nil; +} +@end + +/* + * A window with a toolbar keeps it in full screen — a bar across the top of the Space, over the + * app — unless the window's delegate asks for it to hide with the menu bar. SDL's delegate passes + * on the options AppKit proposes; fizzy's answer, over it, adds the toolbar's hiding where AppKit + * allows that: in full screen, with the menu bar hiding by itself. + */ +static IMP g_sdl_full_screen_options = NULL; + +static NSApplicationPresentationOptions fizzy_full_screen_options(id self, SEL cmd, NSWindow *window, NSApplicationPresentationOptions proposed) { + typedef NSApplicationPresentationOptions (*OptionsFn)(id, SEL, NSWindow *, NSApplicationPresentationOptions); + NSApplicationPresentationOptions options = g_sdl_full_screen_options != NULL + ? ((OptionsFn)g_sdl_full_screen_options)(self, cmd, window, proposed) + : proposed; + if ([window toolbar] != nil && (options & NSApplicationPresentationFullScreen) != 0 && + (options & NSApplicationPresentationAutoHideMenuBar) != 0) + options |= NSApplicationPresentationAutoHideToolbar; + return options; +} + +/* Once per delegate class (idempotent): `window`'s toolbar hides with the menu bar in full screen. */ +static void windowAutoHidesToolbar(NSWindow *window) { + id delegate = [window delegate]; + if (delegate == nil) return; + const SEL sel = @selector(window:willUseFullScreenPresentationOptions:); + Class cls = [delegate class]; + if (class_getMethodImplementation(cls, sel) == (IMP)fizzy_full_screen_options) return; + Method existing = class_getInstanceMethod(cls, sel); + const char *types = existing != NULL ? method_getTypeEncoding(existing) : "Q@:@Q"; + IMP was = class_replaceMethod(cls, sel, (IMP)fizzy_full_screen_options, types); + if (g_sdl_full_screen_options == NULL) g_sdl_full_screen_options = was; +} + +/* Whether `nswindow` stands on Liquid Glass (`fizzy_macos_window_liquid_glass`). */ +int fizzy_macos_window_has_liquid_glass(void *nswindow) { + NSWindow *window = (__bridge NSWindow *)nswindow; + return window != nil && windowGlassPart(window, window_glass_under) != nil; +} + +/* + * One of fizzy's titled windows — the main window, a float's — as a window of Liquid Glass (macOS + * 26): beside SDL's view in the window's frame view, under it, the window's colour all over, which + * comes in only at the top of the slider; the clear lens over it; and over the lens the body — + * frost, the plain blur behind the window, the colour again — fading out across the window's + * clearing bevel, so its edge stays the lens bending the desktop. + * `fizzy_macos_window_liquid_glass_look` sets them each frame from the one slider. The window clear but + * for them, and a compact toolbar's corners — what macOS 26 rounds a window by is whether it has a + * toolbar: none, 16 points; compact, 20 (and a 40-point title bar for 32); unified, 27 (and 66). + * Any vibrancy beside SDL's view (a float's) goes. Once per window; 1 where it is (or was already) + * Liquid Glass, 0 where the OS has none. + */ +/* The glass parts beside SDL's view in `window`'s frame view (`window_glass_ids`), once; 1 where they + * are (or already were), 0 where the OS has no Liquid Glass. A titled window's and a menu's alike. */ +static int windowGlassInstall(NSWindow *window, long blur_material) { + if (window == nil || !fizzy_macos_liquid_glass_available()) return 0; + if (windowGlassPart(window, window_glass_under) != nil) return 1; + NSView *content = [window contentView]; + NSView *frame = [content superview]; + if (content == nil || frame == nil) return 0; + for (NSView *v in [[frame subviews] copy]) { + if ([v isKindOfClass:[FizzyViewportGlassView class]]) [v removeFromSuperview]; + } + [window setOpaque:NO]; + [window setBackgroundColor:[NSColor clearColor]]; + /* Each added directly under SDL's view, so over the one before. */ + for (int k = 0; k < window_glass_parts; k++) { + NSView *part = nil; + if (k == window_glass_under || k == window_glass_over) { + part = carryLiquidGlass([content frame]); + if (part == nil) return 0; + if (k == window_glass_over) carryGlassVariant(part, carry_glass_frost_variant); + } else if (k == window_glass_blur) { + FizzyWindowBlurView *blur = [[FizzyWindowBlurView alloc] initWithFrame:[content frame]]; + [blur setBlendingMode:NSVisualEffectBlendingModeBehindWindow]; + [blur setState:NSVisualEffectStateActive]; + [blur setMaterial:(NSVisualEffectMaterial)blur_material]; + [blur setHidden:YES]; + part = blur; +#if !__has_feature(objc_arc) + [blur autorelease]; +#endif + } else { + FizzyOverlayGlassHolder *fill = [[FizzyOverlayGlassHolder alloc] initWithFrame:[content frame]]; + part = fill; +#if !__has_feature(objc_arc) + [fill autorelease]; +#endif + } + [part setIdentifier:window_glass_ids[k]]; + [part setAutoresizingMask:NSViewWidthSizable | NSViewHeightSizable]; + /* Its own layer, which keeps the feather's mask: one AppKit lends a view not asking + * for a layer (the glass, the blur) drops the mask set on it. */ + [part setWantsLayer:YES]; + [frame addSubview:part positioned:NSWindowBelow relativeTo:content]; + } + return 1; +} + +int fizzy_macos_window_liquid_glass(void *nswindow, long blur_material) { + @autoreleasepool { + NSWindow *window = (__bridge NSWindow *)nswindow; + if (window == nil || !fizzy_macos_liquid_glass_available()) return 0; + if (windowGlassPart(window, window_glass_under) != nil) return 1; + if (!windowGlassInstall(window, blur_material)) return 0; + if ([window toolbar] == nil) { + /* Added, a toolbar grows the window by its height to keep the content's area: kept + * where it was instead, or a float's window — restored from its saved frame each + * launch — grew by 30 points every time. */ + const NSRect was = [window frame]; + NSToolbar *toolbar = [[NSToolbar alloc] initWithIdentifier:@"fizzy.window"]; + [window setToolbar:toolbar]; + [window setToolbarStyle:NSWindowToolbarStyleUnifiedCompact]; + if (!NSEqualRects([window frame], was)) [window setFrame:was display:NO]; +#if !__has_feature(objc_arc) + [toolbar release]; +#endif + } + [window setTitlebarSeparatorStyle:NSTitlebarSeparatorStyleNone]; + windowAutoHidesToolbar(window); + return 1; + } +} + +/* + * A menu's window (`SDLBackend.viewportOpenMenu`): dressed as a float's window is + * (`viewportDress`), but borderless, with the OS's shadow round its shape, kept above every window + * by its level, on every Space, in no window list — and taking the pointer, unlike a carry window. + * On Liquid Glass where the OS has it — the window's glass parts, whose look the app sets each frame + * (`fizzy_macos_window_liquid_glass_look`), rounded to the menu's corners — 1; vibrancy before, its + * mask rounded by `radius` points, 0. + */ +int fizzy_macos_viewport_menu(void *nswindow, void *main_nswindow, long material, double radius, int dialog) { + @autoreleasepool { + NSWindow *window = (__bridge NSWindow *)nswindow; + if (window == nil) return 0; + viewportDress(window, (__bridge NSWindow *)main_nswindow); + [window setHasShadow:YES]; + /* A dialog rides in the main window's stacking, as its child (`SDLBackend`), other apps' + * windows going over both; a menu is above every window, on every Space. */ + [window setLevel:dialog ? NSNormalWindowLevel : NSPopUpMenuWindowLevel]; + [window setExcludedFromWindowsMenu:YES]; + const NSWindowCollectionBehavior every_space = dialog ? 0 : NSWindowCollectionBehaviorCanJoinAllSpaces; + [window setCollectionBehavior:every_space | NSWindowCollectionBehaviorTransient | + NSWindowCollectionBehaviorIgnoresCycle | NSWindowCollectionBehaviorFullScreenAuxiliary]; + [window setIgnoresMouseEvents:NO]; + [NSApp removeWindowsItem:window]; + if (windowGlassInstall(window, material)) return 1; + /* A hair of inset: with none the vibrancy is left whole for the OS to round, as a titled + * window's is, and a borderless one it does not round. */ + fizzy_macos_viewport_glass(nswindow, main_nswindow, 0.01, radius, material); + return 0; + } +} + +/* + * `nswindow` just above `other` in the stacking, where it is not already: a growing float's + * picture over the overlay holding the glass it grows out of (`Popout.growDrops`), both at a popup's + * level, where whichever was ordered last is in front. + */ +void fizzy_macos_viewport_order_above(void *nswindow, void *other) { + @autoreleasepool { + NSWindow *window = (__bridge NSWindow *)nswindow; + NSWindow *below = (__bridge NSWindow *)other; + if (window == nil || below == nil || ![window isVisible]) return; + NSArray *order = [NSWindow windowNumbersWithOptions:0]; + const NSUInteger mine = [order indexOfObject:@([window windowNumber])]; + const NSUInteger theirs = [order indexOfObject:@([below windowNumber])]; + if (mine != NSNotFound && theirs != NSNotFound && mine < theirs) return; + [window orderWindow:NSWindowAbove relativeTo:[below windowNumber]]; + } +} + +/* + * `nswindow` at `other`'s level and just above it: a float's window born of a drop, over the drag's + * overlay while the glass in it goes (`Popout.liftOver`) — under it, the overlay's lens bent the + * window's picture. `fizzy_macos_viewport_settle` puts it back at a window's own level. + */ +void fizzy_macos_viewport_lift(void *nswindow, void *other) { + @autoreleasepool { + NSWindow *window = (__bridge NSWindow *)nswindow; + NSWindow *below = (__bridge NSWindow *)other; + if (window == nil || below == nil) return; + if ([window level] < [below level]) [window setLevel:[below level]]; + fizzy_macos_viewport_order_above(nswindow, other); + } +} + +/* + * Back at a window's own level, from over the drag's glass (`fizzy_macos_viewport_lift`): and over + * the main window, as a window just opened is. Dropped to its level and left there, it went under + * the main window it had grown over — the float a view let go over its own place opens (the user). + */ +void fizzy_macos_viewport_settle(void *nswindow, void *main_nswindow) { + @autoreleasepool { + NSWindow *window = (__bridge NSWindow *)nswindow; + if (window == nil || [window level] == NSNormalWindowLevel) return; + [window setLevel:NSNormalWindowLevel]; + /* It was over every window up there, and comes down over the main one, whatever + * `orderedIndex` says: just after the level changes it still reads the window as in front, + * while AppKit has put it back where it was shown among the normal windows — under the + * main one (SDL shows a window it does not activate below the key window). Asked first, a + * float let go from a drop came up and went behind the main window as it settled. */ + NSWindow *main = (__bridge NSWindow *)main_nswindow; + if (main == nil || ![window isVisible] || ![main isVisible] || [main isMiniaturized]) return; + if ((([window styleMask] | [main styleMask]) & NSWindowStyleMaskFullScreen) != 0) return; + [window orderWindow:NSWindowAbove relativeTo:[main windowNumber]]; + } +} + +/* + * A menu's window made its parent's child (`SDLBackend`, `Viewport.follow_main`), so the OS moves it + * with the parent: AppKit put it at the parent's level as it did, under every window kept above the + * main one. Back at a menu's. + */ +void fizzy_macos_viewport_menu_attached(void *nswindow) { + @autoreleasepool { + NSWindow *window = (__bridge NSWindow *)nswindow; + if (window == nil) return; + [window setLevel:NSPopUpMenuWindowLevel]; + } +} + +/* + * A window of Liquid Glass's toolbar (`fizzy_macos_window_liquid_glass`) is there for the corners + * a compact toolbar gives a window, and in full screen there are none: shown there, it stayed at + * the top after the window had gone full screen, then went (the user). Hidden from the moment the + * window sets out for a Space (`on` 0) and shown again once it is back (`on` 1), the window's frame + * kept as it was — showing or hiding it, AppKit grows or shrinks the window by its height. + */ +void fizzy_macos_window_glass_toolbar(void *nswindow, int on) { + @autoreleasepool { + NSWindow *window = (__bridge NSWindow *)nswindow; + if (window == nil || windowGlassPart(window, window_glass_under) == nil) return; + NSToolbar *toolbar = [window toolbar]; + if (toolbar == nil || [toolbar isVisible] == (on != 0)) return; + const BOOL full = ([window styleMask] & NSWindowStyleMaskFullScreen) != 0; + const NSRect was = [window frame]; + [toolbar setVisible:on != 0]; + if (!full && !NSEqualRects([window frame], was)) [window setFrame:was display:NO]; + } +} + +/* A window's Liquid Glass this frame, as `platform.window.WindowGlass` lays it out + * (`core.glass_look.Window`): each glass layer's variant and style; the body's frost and blur; how + * much glass there is; the window's colour (its opacity in the body last) and its opacity over all + * of it; the window's corner radius, and its clearing bevel — clear, then the body coming in over + * the feather — in points. */ +typedef struct { + long under_variant, under_style, over_variant, over_style; + double frost, blur, glass; + double fill[4]; + double top_fill, radius, clear, feather; +} FizzyWindowGlass; + +/* + * A body fading out across its clearing bevel — a window's, a carried card's: alpha 0 `rim` points + * in from the edge, rising quickly and then easing to 1 `feather` points further in, its rounded + * corners following the shape's (and tightening inward, as nested rounded rects do). A small image, nine-sliced — its middle pixel + * stretched to the window's size — so it is drawn again only when the radius, the band or the + * window's scale change, never as the window resizes. `edge` is each slice's side, points. + */ +static CGImageRef bevelImage(double radius, double rim, double feather, double scale, double *edge) { + *edge = ceil(rim + feather + radius); + const double side = 2 * *edge + 1; + const size_t px = (size_t)ceil(side * scale); + CGContextRef ctx = CGBitmapContextCreate(NULL, px, px, 8, 0, NULL, (CGBitmapInfo)kCGImageAlphaOnly); + if (ctx == NULL) return NULL; + CGContextScaleCTM(ctx, (CGFloat)scale, (CGFloat)scale); + CGContextSetBlendMode(ctx, kCGBlendModeCopy); + const CGRect all = CGRectMake(0, 0, side, side); + const int steps = 32; + for (int i = 0; i <= steps; i++) { + const double t = (double)i / steps; + const double inset = rim + feather * t; + const double r = fmax(1, radius - inset * 0.5); + CGPathRef path = CGPathCreateWithRoundedRect(CGRectInset(all, inset, inset), r, r, NULL); + CGContextAddPath(ctx, path); + /* Out of the edge quickly: strong most of the way to it, gone at it. */ + CGContextSetGrayFillColor(ctx, 0, 1 - (1 - t) * (1 - t)); + CGContextFillPath(ctx); + CGPathRelease(path); + } + CGImageRef image = CGBitmapContextCreateImage(ctx); + CGContextRelease(ctx); + return image; +} + +/* `layer` masked by the feather (`bevelImage`), the mask kept to its bounds. */ +static void windowFeatherMask(CALayer *layer, CGImageRef image, double edge, double scale) { + CALayer *mask = [layer mask]; + if (image != NULL || mask == nil) { + if (mask == nil) { + mask = [CALayer layer]; + [layer setMask:mask]; + } + const CGFloat side = (CGFloat)(2 * edge + 1); + [mask setContents:(__bridge id)image]; + [mask setContentsScale:(CGFloat)scale]; + [mask setContentsGravity:kCAGravityResize]; + [mask setContentsCenter:CGRectMake(edge / side, edge / side, 1 / side, 1 / side)]; + } + if (!CGRectEqualToRect([mask frame], [layer bounds])) [mask setFrame:[layer bounds]]; +} + +/* + * `nswindow`'s Liquid Glass this frame (`fizzy_macos_window_liquid_glass`): the colour all over at + * the top of the slider, the lens over it, and the body over that — frost, the plain blur, the + * window's colour — fading out across the clearing bevel. Its corners the window's, every part + * where SDL's view is: made before a float's window had its full-size content, they stayed a title + * bar lower than it, the title bar clear of glass and the glass past the bottom. + */ +void fizzy_macos_window_liquid_glass_look(void *nswindow, const FizzyWindowGlass *g) { + @autoreleasepool { + NSWindow *window = (__bridge NSWindow *)nswindow; + if (window == nil || g == NULL) return; + NSView *parts[window_glass_parts]; + for (int k = 0; k < window_glass_parts; k++) { + parts[k] = windowGlassPart(window, k); + if (parts[k] == nil) return; + } + [CATransaction begin]; + [CATransaction setDisableActions:YES]; + /* All of the window: in full screen SDL's view stops short of its top. Square there. */ + const NSRect content = [[[window contentView] superview] bounds]; + const BOOL full = ([window styleMask] & NSWindowStyleMaskFullScreen) != 0; + const double radius = full ? 0 : g->radius; + BOOL moved = NO; + for (int k = 0; k < window_glass_parts; k++) { + if (!NSEqualRects([parts[k] frame], content)) { + [parts[k] setFrame:content]; + moved = YES; + } + } + /* A clear window's shadow and edge are the OS's reading of what it showed: read again, + * or a float's kept the shape its glass had a title bar out of place. A borderless window's + * (a menu's) is read from its picture, so again whenever its size changes — a menu sliding + * open — where a titled window's follows its frame. */ + if (([window styleMask] & NSWindowStyleMaskTitled) == 0) { + NSString *size = NSStringFromSize(content.size); + CALayer *top_layer = [parts[window_glass_top] layer]; + if (![[top_layer valueForKey:@"fizzySize"] isEqual:size]) { + [top_layer setValue:size forKey:@"fizzySize"]; + moved = YES; + } + } + if (moved) [window invalidateShadow]; + CGColorSpaceRef srgb = CGColorSpaceCreateWithName(kCGColorSpaceSRGB); + const double fills[2] = {g->fill[3], g->top_fill}; + NSView *colored[2] = {parts[window_glass_fill], parts[window_glass_top]}; + for (int k = 0; k < 2; k++) { + const CGFloat comps[4] = {(CGFloat)g->fill[0], (CGFloat)g->fill[1], (CGFloat)g->fill[2], (CGFloat)fmin(fmax(fills[k], 0), 1)}; + CGColorRef color = CGColorCreate(srgb, comps); + [[colored[k] layer] setBackgroundColor:color]; + CGColorRelease(color); + } + CGColorSpaceRelease(srgb); + + /* The bevel's mask, drawn again only when its key changes; the key kept on the frost's mask. */ + const double scale = [window backingScaleFactor] > 0 ? [window backingScaleFactor] : 2; + NSString *key = [NSString stringWithFormat:@"%.2f/%.2f/%.2f/%.2f", radius, g->clear, g->feather, scale]; + CALayer *over_layer = [parts[window_glass_over] layer]; + double edge = ceil(g->clear + g->feather + radius); + CGImageRef bevel = NULL; + if (over_layer != nil && ![[[over_layer mask] valueForKey:@"fizzyBevel"] isEqual:key]) { + bevel = bevelImage(radius, g->clear, g->feather, scale, &edge); + } + if (over_layer != nil) { + windowFeatherMask(over_layer, bevel, edge, scale); + [[over_layer mask] setValue:key forKey:@"fizzyBevel"]; + } + windowFeatherMask([parts[window_glass_fill] layer], bevel, edge, scale); + if (bevel != NULL) { + NSImage *mask = [[NSImage alloc] initWithCGImage:bevel size:NSMakeSize(2 * edge + 1, 2 * edge + 1)]; + [mask setCapInsets:NSEdgeInsetsMake(edge, edge, edge, edge)]; + [mask setResizingMode:NSImageResizingModeStretch]; + [(NSVisualEffectView *)parts[window_glass_blur] setMaskImage:mask]; +#if !__has_feature(objc_arc) + [mask release]; +#endif + CGImageRelease(bevel); + } + const double blur = fmin(fmax(g->blur, 0), 1); + [parts[window_glass_blur] setHidden:blur <= 0.01]; + [parts[window_glass_blur] setAlphaValue:(CGFloat)blur]; + + const double glass = fmin(fmax(g->glass, 0), 1); + const double alphas[2] = {glass, fmin(fmax(g->frost, 0), 1) * glass}; + const long variants[2] = {g->under_variant, g->over_variant}; + const long styles[2] = {g->under_style, g->over_style}; + NSView *layers[2] = {parts[window_glass_under], parts[window_glass_over]}; + const SEL set_radius = sel_registerName("setCornerRadius:"); + const SEL get_radius = sel_registerName("cornerRadius"); + const SEL get_variant = sel_registerName("_variant"); + for (int k = 0; k < 2; k++) { + NSView *v = layers[k]; + [v setHidden:alphas[k] <= 0.01]; + [v setAlphaValue:(CGFloat)alphas[k]]; + if (![v respondsToSelector:get_radius] || ((CGFloat (*)(id, SEL))objc_msgSend)(v, get_radius) != (CGFloat)radius) + ((void (*)(id, SEL, CGFloat))objc_msgSend)(v, set_radius, (CGFloat)radius); + if ([[v valueForKey:@"style"] longValue] != styles[k]) [v setValue:@(styles[k]) forKey:@"style"]; + if (![v respondsToSelector:get_variant] || ((long (*)(id, SEL))objc_msgSend)(v, get_variant) != variants[k]) + carryGlassVariant(v, variants[k]); + } + [CATransaction commit]; + } +} + /* * The carry window's shape: its material masked to a rounded rect of `radius` points filling the * window — a circle for a drop, a card for a tab — and its shadow made again round it. Called in the diff --git a/src/backend/native/platform/macos/window_monitor.m b/src/backend/native/platform/macos/window_monitor.m index 35a1c8a3..3e0969e4 100644 --- a/src/backend/native/platform/macos/window_monitor.m +++ b/src/backend/native/platform/macos/window_monitor.m @@ -18,9 +18,11 @@ extern void fizzy_macos_window_resize_cb(void *nswindow); extern void fizzy_macos_window_pump_sync(void *nswindow); extern void fizzy_macos_window_pump_render(void); +extern void fizzy_macos_window_wake(void); extern void fizzy_macos_window_reset_sync_cache(void *nswindow); extern void fizzy_macos_window_request_clear_frames(void *nswindow, int frames); extern void fizzy_macos_window_commit_steady_state(void *nswindow); +extern void fizzy_macos_window_glass_toolbar(void *nswindow, int on); extern void fizzy_macos_window_live_resize_vsync(void *nswindow, int active); extern bool SDL_GetHintBoolean(const char *name, bool default_value); extern void fizzy_live_resize_trace_step(void *nswindow); @@ -54,6 +56,23 @@ extern int fizzy_macos_constrain_is_menu_bar_nudge(double rx, double ry, double NSRect exit_window_frame; BOOL exit_window_frame_valid; double windowed_titlebar_inset; + /* The window's own way into or out of a fullscreen Space (`install_space_animation`): its frame + * from `anim_from` to `anim_to` over `anim_duration` seconds from `anim_start`, a step each pump + * tick. `anim_follow` from the moment AppKit asks for it until the transition ends, with + * `anim_fullness` how far the window is into full screen, 0 to 1: its opacity follows that. */ + BOOL anim_follow; + BOOL anim_active; + BOOL anim_entering; + double anim_fullness; + NSRect anim_from; + NSRect anim_to; + CFTimeInterval anim_start; + CFTimeInterval anim_duration; + /* The content size AppKit proposes for the window in full screen + * (`window:willUseFullScreenContentSize:`), where the window's own way into it ends. */ + NSSize fullscreen_size; + /* Until when the window is held where its way out of full screen landed (`hold_landing`). */ + CFTimeInterval land_until; /* Its notification observers, retained, removed when it is forgotten. */ id observers[12]; int observer_count; @@ -200,6 +219,8 @@ static void stop_pump_if_idle(void) { } static void pump_tick_inner(void); +static void step_space_animation(WindowMonitor *m); +static void hold_landing(WindowMonitor *m); static void pump_tick(void) { if (g_in_pump) return; @@ -218,6 +239,7 @@ static void pump_tick_inner(void) { // already collapsed via space_entering, so there is no strip-gap to morph. BOOL any = NO; BOOL manual = NO; + BOOL own = NO; for (int i = 0; i < FIZZY_MAX_MONITORS; i++) { WindowMonitor *m = &g_monitors[i]; if (!monitor_active(m)) continue; @@ -238,9 +260,12 @@ static void pump_tick_inner(void) { m->exit_origin_guard--; restore_pre_fullscreen_origin_if_nudged((__bridge NSWindow *)m->window); } + if (m->anim_active) step_space_animation(m); + hold_landing(m); fizzy_macos_window_sync_content_views(m->window); fizzy_macos_window_pump_sync(m->window); if (m->manual_live_resize) manual = YES; + if (m->anim_follow) own = YES; } if (!any) { stop_pump_if_idle(); @@ -248,8 +273,15 @@ static void pump_tick_inner(void) { } /* One frame of the app a tick, whichever windows are animating: it draws them all. A manual * drag is already driven by SDL's own live-resize timer; a second frame per tick from here - * only blocks the tracking loop on present. */ - if (!manual) fizzy_macos_window_pump_render(); + * only blocks the tracking loop on present. A window moving itself into or out of full screen + * is drawn by the app's own frames, each taking its step (`fizzy_macos_window_space_step`) at + * the display's rate: a frame of the pump's between them waited on the same drawables, and + * halved it. The tick only keeps the app awake. */ + if (own) { + fizzy_macos_window_wake(); + } else if (!manual) { + fizzy_macos_window_pump_render(); + } } static void request_resize_pump(void *nswindow, int frames) { @@ -318,6 +350,225 @@ static void schedule_transition_watchdog(void *nswindow) { }); } +/* ---- The window's own way into and out of a fullscreen Space ---- + * + * AppKit's own animation is of pictures: one of the window as it was and one as it will be, taken + * as the transition starts, cross-faded while they grow — not the window. The picture of the window + * as it was showed through the one growing (its edge above the full screen one until the end), and + * the window could not fade between translucent and opaque as it went: only the pictures moved. As + * the window's delegate asks for the animation (`customWindowsToEnterFullScreenForWindow:` and the + * rest, added to SDL's listener class), AppKit takes no pictures: the monitor's pump moves the window + * itself a step a tick, and the app draws each step live, at its size and its way to opaque. */ + +/* The frame a fullscreen Space gives a window on `screen`: the screen, below its camera housing + * (`safeAreaInsets`), as AppKit places a full screen window that does not ask for that band. */ +static NSRect fullscreen_frame_on(NSScreen *screen) { + NSRect f = screen.frame; + if (@available(macOS 12.0, *)) { + const CGFloat inset = screen.safeAreaInsets.top; + if (inset > 0) { + /* Below the menu bar band beside the housing, a point taller than the inset (39 against + * 38 on a 14" panel): the visible frame's top while the menu bar shows, as it does as the + * window sets out. Under a menu bar that hides itself, the inset. */ + CGFloat top = NSMaxY(f) - ceil(inset); + const CGFloat visible_top = NSMaxY(screen.visibleFrame); + if (visible_top < top && visible_top > NSMaxY(f) - inset - 4) top = visible_top; + f.size.height = top - f.origin.y; + } + } + return f; +} + +/* AppKit's own size for the window in full screen, asked of the delegate as the transition starts: + * kept as the end of the window's way there (`fizzy_start_enter_animation`), and left as it is. */ +static NSSize fizzy_will_use_fullscreen_content_size(__unused id self, __unused SEL cmd, NSWindow *window, NSSize proposed) { + WindowMonitor *m = monitor_of((__bridge void *)window); + if (m) m->fullscreen_size = proposed; + return proposed; +} + +static double space_ease(double t) { + if (t <= 0) return 0; + if (t >= 1) return 1; + return t * t * (3.0 - 2.0 * t); +} + +/* The traffic lights' own view: on the window's own way they fade with it — out as it grows into + * full screen, in as it shrinks back — rather than vanish and pop up at the ends. */ +static void set_traffic_lights_alpha(NSWindow *window, double alpha) { + NSView *lights = [window standardWindowButton:NSWindowCloseButton].superview; + if (lights) lights.alphaValue = alpha; +} + +/* Seconds the window is held where its way out of full screen landed. AppKit and SDL finish the + * transition after it — SDL puts its own style back, fizzy's goes on again over it — and each style + * change keeps the content rect, not the frame: the window landed a title bar or a toolbar off + * where it set out from, a little more each time. */ +static const CFTimeInterval landing_hold_s = 0.5; + +static void hold_landing(WindowMonitor *m) { + if (m->land_until <= 0 || m->anim_active) return; + if (CACurrentMediaTime() > m->land_until || m->manual_live_resize) { + m->land_until = 0; + return; + } + NSWindow *window = (__bridge NSWindow *)m->window; + if (!NSEqualRects(window.frame, m->anim_to)) [window setFrame:m->anim_to display:NO]; +} + +static void step_space_animation(WindowMonitor *m) { + NSWindow *window = (__bridge NSWindow *)m->window; + double t = (CACurrentMediaTime() - m->anim_start) / m->anim_duration; + if (t >= 1.0) { + t = 1.0; + m->anim_active = NO; + } + const double k = space_ease(t); + m->anim_fullness = m->anim_entering ? k : 1.0 - k; + set_traffic_lights_alpha(window, 1.0 - m->anim_fullness); + if (!m->anim_active && !m->anim_entering) m->land_until = CACurrentMediaTime() + landing_hold_s; + NSRect r = NSMakeRect(m->anim_from.origin.x + (m->anim_to.origin.x - m->anim_from.origin.x) * k, + m->anim_from.origin.y + (m->anim_to.origin.y - m->anim_from.origin.y) * k, + m->anim_from.size.width + (m->anim_to.size.width - m->anim_from.size.width) * k, + m->anim_from.size.height + (m->anim_to.size.height - m->anim_from.size.height) * k); + /* Whole points: a fractional size is a fractional drawable, scaled. */ + r = NSMakeRect(round(r.origin.x), round(r.origin.y), round(r.size.width), round(r.size.height)); + if (!NSEqualRects(window.frame, r)) [window setFrame:r display:NO]; +} + +/* Where the window lands, at did-enter or did-exit, however far the pump got. */ +static void finish_space_animation(WindowMonitor *m) { + if (!m || !m->anim_follow) return; + if (m->anim_active) { + m->anim_start = CACurrentMediaTime() - m->anim_duration; + step_space_animation(m); + } + /* Out: there. In: hidden with the title bar now, there for its reveal at the top of the screen. */ + set_traffic_lights_alpha((__bridge NSWindow *)m->window, 1.0); + if (!m->anim_entering) { + m->land_until = CACurrentMediaTime() + landing_hold_s; + hold_landing(m); + } + m->anim_active = NO; + m->anim_follow = NO; +} + +static void start_space_animation(NSWindow *window, NSRect to, NSTimeInterval duration, BOOL entering) { + WindowMonitor *m = monitor_of((__bridge void *)window); + if (!m) return; + m->anim_from = window.frame; + m->anim_to = to; + m->anim_start = CACurrentMediaTime(); + m->anim_duration = duration > 0.05 ? duration : 0.05; + m->anim_entering = entering; + m->anim_fullness = entering ? 0.0 : 1.0; + m->anim_follow = YES; + m->anim_active = YES; + request_resize_pump((__bridge void *)window, 30); + pump_now(); +} + +static NSArray *fizzy_custom_windows_for_space(NSWindow *window, BOOL entering) { + WindowMonitor *m = monitor_of((__bridge void *)window); + if (!m) return nil; + /* The window as it is, before AppKit or SDL touch it for the transition: where its way in starts + * and its way out ends, and its title bar's height for the way back. */ + if (entering) { + store_exit_target(window); + const double inset = titlebar_inset_for_window(window); + m->windowed_titlebar_inset = (inset > 0 && inset <= 100.0) ? inset : 0; + } + /* From here the window's opacity follows its own way (`fizzy_macos_window_space_fullness`), not + * the transition's start: it does not jump to opaque before it moves. */ + m->anim_follow = YES; + m->anim_active = NO; + m->anim_entering = entering; + m->anim_fullness = entering ? 0.0 : 1.0; + m->anim_from = NSZeroRect; + m->fullscreen_size = NSZeroSize; + return @[ window ]; +} + +static NSArray *fizzy_custom_windows_to_enter(__unused id self, __unused SEL cmd, NSWindow *window) { + return fizzy_custom_windows_for_space(window, YES); +} + +static NSArray *fizzy_custom_windows_to_exit(__unused id self, __unused SEL cmd, NSWindow *window) { + return fizzy_custom_windows_for_space(window, NO); +} + +static void fizzy_start_enter_animation(__unused id self, __unused SEL cmd, NSWindow *window, NSTimeInterval duration) { + WindowMonitor *m = monitor_of((__bridge void *)window); + NSScreen *screen = window.screen ?: [NSScreen mainScreen]; + NSRect to = fullscreen_frame_on(screen); + /* The size AppKit gave, from the screen's bottom left as AppKit places it. */ + if (m && m->fullscreen_size.width >= 1.0 && m->fullscreen_size.height >= 1.0) { + to.size = m->fullscreen_size; + } + start_space_animation(window, to, duration, YES); +} + +static void fizzy_start_exit_animation(__unused id self, __unused SEL cmd, NSWindow *window, NSTimeInterval duration) { + WindowMonitor *m = monitor_of((__bridge void *)window); + if (!m) return; + /* The window's own shape on its way back — its corners, its title bar with the traffic lights + * fading in — as Apple's own custom animation does it: AppKit leaves the full screen style on + * until the transition is over, and the window shrank square. */ + [window setStyleMask:window.styleMask & ~NSWindowStyleMaskFullScreen]; + /* Its toolbar back now too (hidden at will-enter): its corners on the way, and the title bar's + * height the content keeps clear of is the windowed one from the first step to the last. Given + * back at did-exit, the landing frame laid the content out under the bar without it, 4 pt + * higher, and the next one dropped it back. */ + fizzy_macos_window_glass_toolbar((__bridge void *)window, 1); + set_traffic_lights_alpha(window, 0.0); + /* Back to the frame it had as it set out (kept at will-enter), as AppKit lets a window that is + * no longer full screen have it: a top above the menu bar comes down below it, and the window + * landed there at the end instead of on the way. */ + NSRect to = m->exit_window_frame_valid ? m->exit_window_frame : window.frame; + NSScreen *screen = window.screen ?: [NSScreen mainScreen]; + if (screen) to = [window constrainFrameRect:to toScreen:screen]; + start_space_animation(window, to, duration, NO); +} + +/* The window's delegate (SDL's listener) asks AppKit for the window's own animation into and out of + * a fullscreen Space. Added to the delegate's class once; a window the monitor does not follow + * answers nil there, and AppKit animates it as it would. */ +static void install_space_animation(NSWindow *window) { + id delegate = window.delegate; + if (!delegate) return; + Class cls = object_getClass(delegate); + class_addMethod(cls, @selector(customWindowsToEnterFullScreenForWindow:), (IMP)fizzy_custom_windows_to_enter, "@@:@"); + class_addMethod(cls, @selector(customWindowsToExitFullScreenForWindow:), (IMP)fizzy_custom_windows_to_exit, "@@:@"); + class_addMethod(cls, @selector(window:startCustomAnimationToEnterFullScreenWithDuration:), (IMP)fizzy_start_enter_animation, "v@:@d"); + class_addMethod(cls, @selector(window:startCustomAnimationToExitFullScreenWithDuration:), (IMP)fizzy_start_exit_animation, "v@:@d"); + class_addMethod(cls, @selector(window:willUseFullScreenContentSize:), (IMP)fizzy_will_use_fullscreen_content_size, "{CGSize=dd}@:@{CGSize=dd}"); +} + +/* A step of `nswindow`'s own way into or out of full screen, now: from before each of the app's + * frames (`macos_monitor.zig`'s begin hook), so the window moves as often as the app draws — the + * pump's ticks alone moved it at 60 Hz under frames drawn at 120. */ +void fizzy_macos_window_space_step(void *nswindow) { + WindowMonitor *m = monitor_of(nswindow); + if (!m) return; + if (m->anim_active) step_space_animation(m); + hold_landing(m); +} + +/* Whether `nswindow` is moving itself into or out of full screen: its step this frame goes in the + * transaction its picture is presented in (`macos_monitor.zig`). */ +int fizzy_macos_window_space_moving(void *nswindow) { + WindowMonitor *m = monitor_of(nswindow); + return m && m->anim_active ? 1 : 0; +} + +/* How far `nswindow` is into full screen on its own way there or back, 0 to 1; below 0 when it is + * on no such way (`fizzy_macos_window_space_transition_active` then says whether it is in AppKit's). */ +double fizzy_macos_window_space_fullness(void *nswindow) { + WindowMonitor *m = monitor_of(nswindow); + if (!m || !m->anim_follow) return -1.0; + return m->anim_fullness; +} + void fizzy_macos_window_space_stage(int stage, void *nswindow) { WindowMonitor *m = monitor_of(nswindow); if (!m) return; @@ -332,15 +583,32 @@ void fizzy_macos_window_space_stage(int stage, void *nswindow) { schedule_transition_watchdog(win); { NSWindow *w = (__bridge NSWindow *)win; - store_exit_target(w); - double inset = titlebar_inset_for_window(w); - m->windowed_titlebar_inset = (inset > 0 && inset <= 100.0) ? inset : 0; + if (m->anim_follow) { + /* SDL's listener has just put its own style on (titled, the content not under the + * title bar), and AppKit kept the content rect: the window grew by its title bar, + * and kept as the frame to come back to, it came back that much taller every + * time. Fizzy's style again, and the frame it had when AppKit asked for its own + * way (`fizzy_custom_windows_for_space`), which is where that way starts. */ + if (!(w.styleMask & NSWindowStyleMaskFullSizeContentView)) { + w.styleMask = w.styleMask | NSWindowStyleMaskFullSizeContentView; + } + if (m->exit_window_frame_valid && !NSEqualRects(w.frame, m->exit_window_frame)) { + [w setFrame:m->exit_window_frame display:NO]; + } + } else { + store_exit_target(w); + double inset = titlebar_inset_for_window(w); + m->windowed_titlebar_inset = (inset > 0 && inset <= 100.0) ? inset : 0; + } } + /* After the windowed title bar's height is kept for the way back. */ + fizzy_macos_window_glass_toolbar(win, 0); fizzy_macos_window_reset_sync_cache(win); fizzy_macos_window_request_clear_frames(win, 5); request_resize_pump(win, 90); break; case 1: // didEnter + finish_space_animation(m); m->space_transition = NO; m->space_entering = NO; fizzy_macos_window_commit_steady_state(win); @@ -357,6 +625,7 @@ void fizzy_macos_window_space_stage(int stage, void *nswindow) { schedule_transition_watchdog(win); break; case 3: // didExit + finish_space_animation(m); m->space_transition = NO; m->space_entering = NO; m->unzoom_animating = NO; @@ -373,9 +642,13 @@ void fizzy_macos_window_space_stage(int stage, void *nswindow) { if (!monitor_of(win)) return; NSWindow *exit_win = (__bridge NSWindow *)win; restore_pre_fullscreen_origin_if_nudged(exit_win); + fizzy_macos_window_glass_toolbar(win, 1); store_exit_target(exit_win); fizzy_macos_window_commit_steady_state(win); fizzy_macos_window_resize_cb(win); + /* Frames for the window's opacity to fade in once AppKit has settled it: drawn only + * through the transition, the window sat opaque until the mouse moved. */ + request_resize_pump(win, 40); }); break; default: @@ -657,6 +930,36 @@ static void install_constrain_override(NSWindow *window) { } } +/* SDL puts its own style on the window at will-enter and did-exit of a fullscreen Space (titled, + * the content not under the title bar), and fizzy puts its own back the next frame (`styleTitled`). + * AppKit keeps the content rect across each change, so the window grew by its title bar and shrank + * back: kept at will-enter it came back from full screen a title bar taller each time, and at + * did-exit it jumped up a title bar and back down in a frame. A window the monitor follows keeps its + * content under the title bar through any style it is given. */ +static IMP g_nswindow_style_mask_imp = NULL; + +static void fizzy_set_style_mask(id self, SEL _cmd, NSWindowStyleMask mask) { + if (monitor_of((__bridge void *)self)) mask |= NSWindowStyleMaskFullSizeContentView; + ((void (*)(id, SEL, NSWindowStyleMask))g_nswindow_style_mask_imp)(self, _cmd, mask); +} + +/* On the concrete (SDL) window class, as `install_constrain_override`. Idempotent. */ +static void install_style_mask_override(NSWindow *window) { + Class cls = object_getClass(window); + SEL sel = @selector(setStyleMask:); + if (class_getMethodImplementation(cls, sel) == (IMP)fizzy_set_style_mask) return; + Method base = class_getInstanceMethod([NSWindow class], sel); + if (!base) return; + g_nswindow_style_mask_imp = method_getImplementation(base); + if (!class_addMethod(cls, sel, (IMP)fizzy_set_style_mask, method_getTypeEncoding(base))) { + Method existing = class_getInstanceMethod(cls, sel); + if (existing) { + g_nswindow_style_mask_imp = method_getImplementation(existing); + method_setImplementation(existing, (IMP)fizzy_set_style_mask); + } + } +} + /* Keep `token`, an observer `m` added, to remove when the window is forgotten. */ static void keep_observer(WindowMonitor *m, id token) { if (!m || !token || m->observer_count >= (int)(sizeof(m->observers) / sizeof(m->observers[0]))) return; @@ -680,6 +983,8 @@ void fizzy_macos_window_install_resize_observer(void *nswindow) { m->window = nswindow; NSWindow *window = (__bridge NSWindow *)nswindow; install_constrain_override(window); + install_style_mask_override(window); + install_space_animation(window); store_exit_target(window); NSNotificationCenter *center = [NSNotificationCenter defaultCenter]; NSOperationQueue *main = [NSOperationQueue mainQueue]; @@ -712,6 +1017,34 @@ void fizzy_macos_window_install_resize_observer(void *nswindow) { fizzy_macos_window_space_stage(3, nswindow); fizzy_macos_window_resize_cb(nswindow); }]); + /* AppKit gave up on the transition (it posts these by name only, as SDL's listener hears them): + * the window goes back to where its own way set out from. */ + for (NSString *name in @[ @"NSWindowDidFailToEnterFullScreenNotification", @"NSWindowDidFailToExitFullScreenNotification" ]) { + keep_observer(m, [center addObserverForName:name + object:window + queue:main + usingBlock:^(__unused NSNotification *note) { + WindowMonitor *wm = monitor_of(nswindow); + if (!wm) return; + const BOOL entering = wm->space_entering; + if (wm->anim_follow) { + const BOOL moved = wm->anim_active || !NSIsEmptyRect(wm->anim_from); + wm->anim_active = NO; + wm->anim_follow = NO; + if (moved) [(__bridge NSWindow *)nswindow setFrame:wm->anim_from display:NO]; + } + /* As the watchdog would, three seconds on (it covered the desktop until then): the window + * as it is, its toolbar back if it never got in. */ + wm->transition_gen++; + wm->space_transition = NO; + wm->space_entering = NO; + wm->unzoom_animating = NO; + if (entering) fizzy_macos_window_glass_toolbar(nswindow, 1); + fizzy_macos_window_commit_steady_state(nswindow); + request_resize_pump(nswindow, 30); + fizzy_macos_window_resize_cb(nswindow); + }]); + } for (NSString *name in @[ NSWindowDidResizeNotification, @@ -733,6 +1066,8 @@ void fizzy_macos_window_install_resize_observer(void *nswindow) { if (wm->exit_origin_guard > 0) { restore_pre_fullscreen_origin_if_nudged(w); } + /* As a style change at the end of the way out re-frames it, before it is drawn so. */ + hold_landing(wm); if ([name isEqualToString:NSWindowDidResizeNotification]) fizzy_live_resize_trace_step(nswindow); if ([name isEqualToString:NSWindowWillStartLiveResizeNotification]) { wm->manual_live_resize = YES; diff --git a/src/backend/native/platform/macos_monitor.zig b/src/backend/native/platform/macos_monitor.zig index d2a10a3f..56eb59b0 100644 --- a/src/backend/native/platform/macos_monitor.zig +++ b/src/backend/native/platform/macos_monitor.zig @@ -45,6 +45,12 @@ extern fn fizzy_macos_copy_screen_frames(out: [*]f64, max: c_int) c_int; extern fn fizzy_macos_window_sync_content_views(cocoa_window: ?*anyopaque) void; extern fn fizzy_macos_window_install_resize_observer(cocoa_window: ?*anyopaque) void; extern fn fizzy_macos_window_sdl_draws_live_resize(cocoa_window: ?*anyopaque) c_int; +extern fn fizzy_macos_window_space_step(cocoa_window: ?*anyopaque) void; +extern fn fizzy_macos_window_space_moving(cocoa_window: ?*anyopaque) c_int; +extern fn fizzy_native_transaction_begin() void; +extern fn fizzy_native_transaction_commit() void; +extern fn fizzy_native_viewport_transact(nswindow: ?*anyopaque) void; +extern fn fizzy_native_viewport_presented(nswindow: ?*anyopaque) void; // SDL internals (linked but not in public headers) — the same hooks SDL uses // for macOS live resize while the window frame is animating. @@ -66,6 +72,8 @@ const Watched = struct { /// Its place is the app's to follow through an animation: a float's window, which its float /// follows (`SDLBackend.viewportFollowWindow` reads where SDL says it is). follow_position: bool, + /// Its picture this frame is presented in the frame's transaction (`macos_frame_transaction`). + transacted: bool = false, /// Last sizes, and place, pushed into SDL during an AppKit animation. last_point: [2]c_int = .{ 0, 0 }, last_pixel: [2]c_int = .{ 0, 0 }, @@ -144,8 +152,22 @@ fn macosSyncSizes(w: *Watched) void { fn macosLiveResizeUpdate(window: *c.SDL_Window) void { if (macos_in_live_resize) return; + // From inside a frame — AppKit calls back at once from what a frame asks of it, as + // `toggleFullScreen:` asked from a command did — a frame run here would nest in that one, and + // dvui's state does not survive it: every later frame began on the nested one's, and the app + // slept through the rest of the transition. Wake the loop instead; the next frame follows. + if (comptime @hasDecl(Backend, "appFrameOpen")) { + if (Backend.appFrameOpen()) { + var ue = std.mem.zeroes(c.SDL_Event); + ue.type = c.SDL_EVENT_USER; + _ = c.SDL_PushEvent(&ue); + return; + } + } macos_in_live_resize = true; defer macos_in_live_resize = false; + if (comptime @hasDecl(Backend, "callbackFrames")) Backend.callbackFrames(true); + defer if (comptime @hasDecl(Backend, "callbackFrames")) Backend.callbackFrames(false); SDL_OnWindowLiveResizeUpdate(window); } @@ -162,6 +184,14 @@ export fn fizzy_macos_window_resize_cb(cocoa: ?*anyopaque) void { _ = c.SDL_PushEvent(&ue); } +/// Called from the monitor's 60Hz NSTimer while a window moves itself into or out of full screen: +/// the app draws that from its own frames, and only needs keeping awake. +export fn fizzy_macos_window_wake() void { + var ue = std.mem.zeroes(c.SDL_Event); + ue.type = c.SDL_EVENT_USER; + _ = c.SDL_PushEvent(&ue); +} + /// Called from the monitor's 60Hz NSTimer for each window animating: its live sizes into SDL. export fn fizzy_macos_window_pump_sync(cocoa: ?*anyopaque) void { if (comptime builtin.os.tag == .macos) { @@ -171,8 +201,9 @@ export fn fizzy_macos_window_pump_sync(cocoa: ?*anyopaque) void { } /// Called from the monitor's 60Hz NSTimer once a tick while any window animates — same approach -/// SDL itself uses for live resize: one frame of the app, which draws every window. Runs outside -/// appIterate, so SDL_OnWindowLiveResizeUpdate is safe here. +/// SDL itself uses for live resize: one frame of the app, which draws every window. The timer runs +/// it between frames; a transition's stage, run from inside one, only wakes the loop +/// (`macosLiveResizeUpdate`). export fn fizzy_macos_window_pump_render() void { if (comptime builtin.os.tag == .macos) { if (!macos_pump_ready) return; @@ -181,6 +212,28 @@ export fn fizzy_macos_window_pump_render() void { } } +/// A Core Animation transaction is open across this frame (`macosAppPreBeginSync` to +/// `macosAppPresented`): a window moving itself into or out of full screen takes its step in it, and +/// its picture is presented in it, so the window's new frame and the picture drawn for it reach the +/// screen together. Apart, the frame went in with the run loop's own commit and the picture whenever +/// the GPU had it: a window a step ahead of or behind what was drawn in it, every frame of the way. +var macos_frame_transaction = false; + +/// Close this frame's transaction (`macos_frame_transaction`), its pictures presented. Registered +/// with `macosAppPreBeginSync`. +fn macosAppPresented(back: *Backend.SDLBackend) void { + _ = back; + if (!macos_frame_transaction) return; + for (&watched) |*slot| { + const w = if (slot.*) |*w| w else continue; + if (!w.transacted) continue; + w.transacted = false; + fizzy_native_viewport_presented(w.cocoa); + } + macos_frame_transaction = false; + fizzy_native_transaction_commit(); +} + /// Sync AppKit → SDL before `Window.begin` during Space / zoom animations only, for every window /// animating. Registered on the SDL backend from `restoreWindowState`. fn macosAppPreBeginSync(back: *Backend.SDLBackend) void { @@ -192,6 +245,17 @@ fn macosAppPreBeginSync(back: *Backend.SDLBackend) void { // (window.zon) and disabled in dvui, so there is nothing to toggle here. for (&watched) |*slot| { const w = if (slot.*) |*w| w else continue; + // A window moving itself into or out of full screen takes its step for this frame first, + // in the frame's transaction (`macos_frame_transaction`). + if (fizzy_macos_window_space_moving(w.cocoa) != 0) { + if (!macos_frame_transaction) { + fizzy_native_transaction_begin(); + macos_frame_transaction = true; + } + fizzy_native_viewport_transact(w.cocoa); + w.transacted = true; + } + fizzy_macos_window_space_step(w.cocoa); if (!macosTransitionSyncActive(w)) continue; fizzy_macos_window_sync_content_views(w.cocoa); macosSyncRendererSize(w, true); @@ -305,6 +369,7 @@ pub fn install(win: *dvui.Window) void { const cocoa = cocoaWindowOf(back.window) orelse return; macos_monitor_window = back.window; back.begin_hook = macosAppPreBeginSync; + back.present_hook = macosAppPresented; addWatched(.{ .window = back.window, .cocoa = cocoa, .follow_position = false }); fizzy_macos_window_install_resize_observer(cocoa); // Draw each step of a live resize from inside it, presented with the Core Animation diff --git a/src/backend/native/platform/window.zig b/src/backend/native/platform/window.zig index 216c01c8..649d9fd1 100644 --- a/src/backend/native/platform/window.zig +++ b/src/backend/native/platform/window.zig @@ -3,7 +3,8 @@ const std = @import("std"); const builtin = @import("builtin"); const dvui = @import("dvui"); -const c = @import("backend").c; +const Backend = @import("backend"); +const c = Backend.c; const objc = @import("objc"); const titlebar = @import("titlebar.zig"); const win32_titlebar = @import("win32_titlebar.zig"); @@ -22,7 +23,54 @@ pub const ns_visual_effect_material: c_long = 15; extern fn fizzy_macos_window_is_zoomed(cocoa_window: ?*anyopaque) c_int; extern fn fizzy_macos_window_in_fullscreen_space(cocoa_window: ?*anyopaque) c_int; extern fn fizzy_macos_window_chrome_hidden(cocoa_window: ?*anyopaque) c_int; +extern fn fizzy_macos_window_space_transition_active(cocoa_window: ?*anyopaque) c_int; +extern fn fizzy_macos_window_space_entering(cocoa_window: ?*anyopaque) c_int; +extern fn fizzy_macos_window_space_fullness(cocoa_window: ?*anyopaque) f64; +extern fn fizzy_macos_window_space_moving(cocoa_window: ?*anyopaque) c_int; extern fn fizzy_macos_titlebar_hit_test_install(cocoa_window: ?*anyopaque, interactive_at: *const fn (f64, f64) callconv(.c) bool) void; +extern fn fizzy_macos_window_liquid_glass(cocoa_window: ?*anyopaque, blur_material: c_long) c_int; +extern fn fizzy_macos_window_has_liquid_glass(cocoa_window: ?*anyopaque) c_int; +extern fn fizzy_macos_window_liquid_glass_look(cocoa_window: ?*anyopaque, look: *const WindowGlass) void; + +/// A window's Liquid Glass this frame (`liquidGlassLook`), as `fizzy_macos_window_liquid_glass_look` +/// reads it (`core.glass_look.Window`): each glass layer's variant and style; the body's frost and +/// plain blur; how much glass there is; the window's colour (0…1 each, its opacity in the body +/// last) and its opacity over all of it; the window's corner radius, and its clearing bevel — +/// clear, then the body coming in over the feather — in points. +pub const WindowGlass = extern struct { + under_variant: c_long, + under_style: c_long, + over_variant: c_long, + over_style: c_long, + frost: f64, + blur: f64, + glass: f64, + fill: [4]f64, + top_fill: f64, + radius: f64, + clear: f64, + feather: f64, +}; + +/// Make one of fizzy's titled windows (`raw_ptr`, its `NSWindow`) a window of Liquid Glass — over +/// the clear lens a body of frost, the plain blur (the vibrancy it wore before, +/// `ns_visual_effect_material`) and the window's colour, fading out across a clearing bevel along +/// its edge, a compact toolbar's corners — where the OS has it (macOS 26). Once per window. +/// Whether it is. +pub fn liquidGlass(raw_ptr: *anyopaque) bool { + if (comptime builtin.os.tag != .macos) return false; + return fizzy_macos_window_liquid_glass(raw_ptr, ns_visual_effect_material) != 0; +} + +/// `window`'s Liquid Glass this frame (`liquidGlass`). Whether it has any: false, and nothing set, +/// where it stands on vibrancy instead. +pub fn liquidGlassLook(window: *c.SDL_Window, look: WindowGlass) bool { + if (comptime builtin.os.tag != .macos) return false; + const ns = c.SDL_GetPointerProperty(c.SDL_GetWindowProperties(window), c.SDL_PROP_WINDOW_COCOA_WINDOW_POINTER, null) orelse return false; + if (fizzy_macos_window_has_liquid_glass(ns) == 0) return false; + fizzy_macos_window_liquid_glass_look(ns, &look); + return true; +} /// For AppKit's titlebar region: whether a press at this pixel is the app's (`titlebar.interactiveAt`). fn titlebarInteractiveAt(x: f64, y: f64) callconv(.c) bool { @@ -95,6 +143,64 @@ pub fn windowMaximized(window: *c.SDL_Window) bool { return flags & c.SDL_WINDOW_FULLSCREEN != 0; } +/// Whether `win` covers the desktop (`windowCovers`). +pub fn coversDesktop(win: *dvui.Window) bool { + return windowCovers(win.backend.impl.window); +} + +/// Whether `window` covers the desktop: maximized (`windowMaximized`), from the moment it sets out +/// for a fullscreen Space and through the whole of its way back out of one. AppKit animates a +/// window into and out of a Space as pictures of it, not the window: one fading to see-through on +/// the way out was two see-through pictures, a double window, and the window itself, already +/// see-through, popped in at the end (the user). +pub fn windowCovers(window: *c.SDL_Window) bool { + return windowMaximized(window) or windowInSpaceTransition(window); +} + +/// Whether `window` is on its way into a fullscreen Space or out of one. AppKit takes the window +/// out of its full screen style already at will-exit, so on the way out only the monitor's word +/// (`space_transition`, until did-exit) says it is still in the transition: by the style alone it +/// faded while AppKit showed opaque pictures of it, and popped in see-through as they went. +fn windowInSpaceTransition(window: *c.SDL_Window) bool { + if (builtin.os.tag != .macos) return false; + const raw_ptr = c.SDL_GetPointerProperty(c.SDL_GetWindowProperties(window), c.SDL_PROP_WINDOW_COCOA_WINDOW_POINTER, null) orelse return false; + return fizzy_macos_window_space_transition_active(raw_ptr) != 0; +} + +/// Whether `window` is on its way into a fullscreen Space: it goes opaque at once +/// (`Editor.easeWindowOpacity`), so the pictures AppKit animates it as are opaque too. +pub fn windowEnteringSpace(window: *c.SDL_Window) bool { + if (builtin.os.tag != .macos) return false; + const raw_ptr = c.SDL_GetPointerProperty(c.SDL_GetWindowProperties(window), c.SDL_PROP_WINDOW_COCOA_WINDOW_POINTER, null) orelse return false; + return fizzy_macos_window_space_transition_active(raw_ptr) != 0 and fizzy_macos_window_space_entering(raw_ptr) != 0; +} + +pub fn enteringSpace(win: *dvui.Window) bool { + return windowEnteringSpace(win.backend.impl.window); +} + +/// How far `window` is into full screen, 0 to 1, while it moves itself into a fullscreen Space or +/// out of one (`macos/window_monitor.m`'s own animation, where AppKit animates no pictures of it): +/// its opacity follows this, the fade and the move one motion. Null otherwise. +pub fn windowSpaceFullness(window: *c.SDL_Window) ?f32 { + if (builtin.os.tag != .macos) return null; + const raw_ptr = c.SDL_GetPointerProperty(c.SDL_GetWindowProperties(window), c.SDL_PROP_WINDOW_COCOA_WINDOW_POINTER, null) orelse return null; + const f = fizzy_macos_window_space_fullness(raw_ptr); + return if (f < 0) null else @floatCast(std.math.clamp(f, 0, 1)); +} + +pub fn spaceFullness(win: *dvui.Window) ?f32 { + return windowSpaceFullness(win.backend.impl.window); +} + +/// Whether `window` is moving itself into or out of a fullscreen Space this frame +/// (`macos/window_monitor.m`'s own animation). +pub fn windowMovingItself(window: *c.SDL_Window) bool { + if (builtin.os.tag != .macos) return false; + const raw_ptr = c.SDL_GetPointerProperty(c.SDL_GetWindowProperties(window), c.SDL_PROP_WINDOW_COCOA_WINDOW_POINTER, null) orelse return false; + return fizzy_macos_window_space_moving(raw_ptr) != 0; +} + /// True while the macOS window chrome (traffic lights / titlebar area) is hidden, i.e. while /// the layout's end state is a fullscreen Space: entering or steady fullscreen. Flips false /// already at willExitFullScreen so the titlebar strip is back in the layout before the @@ -216,8 +322,10 @@ pub fn skin(raw_ptr: *anyopaque, color: dvui.Color, dark: bool) void { @as(f64, @floatFromInt(color.b)) / 255.0, @as(f64, @floatFromInt(color.a)) / 255.0, }); - // This sets both the titlebar and the window background color. - window.msgSend(void, "setBackgroundColor:", .{new_color.value}); + // This sets both the titlebar and the window background color — clear on a window of Liquid + // Glass (`liquidGlass`), whose colour is under its glass. + const glass = fizzy_macos_window_has_liquid_glass(raw_ptr) != 0; + window.msgSend(void, "setBackgroundColor:", .{if (glass) NSColor.msgSend(objc.Object, "clearColor", .{}).value else new_color.value}); // Set window NSAppearance so the app (title bar, traffic lights, vibrancy) matches dvui theme. if (objc.getClass("NSAppearance")) |NSAppearance| { @@ -249,8 +357,9 @@ pub fn setBackground(win: *dvui.Window, color: dvui.Color) void { setStyle(win); - // Wrap content view in NSVisualEffectView once for vibrancy (blur behind window). - wrapContentViewWithVibrancy(window); + // Liquid Glass where the OS has it (macOS 26), beside SDL's view as a float's window + // is; else the content view wrapped in an NSVisualEffectView once, for vibrancy. + if (!liquidGlass(raw_ptr.?)) wrapContentViewWithVibrancy(window); skin(raw_ptr.?, color, dvui.themeGet().dark); // Every float's own window too, and each opened from now on (`SDLBackend.viewportGlass`): @@ -341,8 +450,20 @@ fn wrapContentViewWithVibrancy(window: objc.Object) void { pub fn toggleFullscreen() void { const w = main() orelse return; if (builtin.os.tag == .macos) { - const ns = cocoaWindowOf(w) orelse return; - objc.Object.fromId(ns).msgSend(void, "toggleFullScreen:", .{@as(?*anyopaque, null)}); + const ns = objc.Object.fromId(cocoaWindowOf(w) orelse return); + // From the run loop, as the green button asks, never from inside a frame (a command, a + // key): AppKit pictures the window as it will be in full screen from inside + // `toggleFullScreen:`, and the monitor draws the frame it pictures from there. With a + // frame open none could be drawn, so AppKit pictured the last one — laid out at the + // window's old size in the corner of the new one — and grew that to the screen, the real + // window popping in at the end. + if (comptime @hasDecl(Backend, "appFrameOpen")) { + if (Backend.appFrameOpen()) { + ns.msgSend(void, "performSelector:withObject:afterDelay:", .{ objc.sel("toggleFullScreen:").value, @as(?*anyopaque, null), @as(f64, 0) }); + return; + } + } + ns.msgSend(void, "toggleFullScreen:", .{@as(?*anyopaque, null)}); return; } const on = (c.SDL_GetWindowFlags(w) & c.SDL_WINDOW_FULLSCREEN) != 0; diff --git a/src/backend/native/platform/window_layout.zig b/src/backend/native/platform/window_layout.zig index 5c3811dd..19c21c0e 100644 --- a/src/backend/native/platform/window_layout.zig +++ b/src/backend/native/platform/window_layout.zig @@ -39,6 +39,22 @@ pub fn chooseTitlebarStrip(in: StripInputs) f32 { return @max(min_strip, appkit); } +/// The strip while the window moves itself into a fullscreen Space or out of one (the monitor's own +/// animation): `fullness` of the way — 0 windowed, 1 full screen — from the windowed strip to the +/// collapsed one. The content closes up as the window fills the screen and the traffic lights fade, +/// and opens again as it shrinks back, where a strip collapsed at will-enter slid the content under +/// lights still showing, and one restored at will-exit dropped it at once. The windowed strip takes +/// the inset kept from before the window set out (`restoring_chrome`): the live one reads full +/// screen's mid-way. +pub fn titlebarStripAtFullness(in: StripInputs, fullness: f32) f32 { + var open = in; + open.collapsed = false; + open.restoring_chrome = true; + var shut = in; + shut.collapsed = true; + return std.math.lerp(chooseTitlebarStrip(open), chooseTitlebarStrip(shut), std.math.clamp(fullness, 0, 1)); +} + /// AppKit screen-coordinate rectangle (bottom-left origin). A frame's top edge /// is `y + h`. Matches `NSRect` field order/semantics so the C-ABI wrapper in /// `backend_native.zig` can forward `window.frame` straight through. @@ -254,3 +270,22 @@ test "originNudged ignores a large move" { // 200px move is the user repositioning, not the nudge. try std.testing.expect(!originNudged(100, 200, 100, 0, 64)); } + +test "titlebarStripAtFullness runs from the windowed strip to the buffer" { + const in: StripInputs = .{ + .collapsed = false, + .restoring_chrome = false, + .live_inset = 0, + .saved_inset = 38, + .titlebar_height = 24, + .titlebar_top_buffer = 6, + }; + try std.testing.expectEqual(@as(f32, 38), titlebarStripAtFullness(in, 0)); + try std.testing.expectEqual(@as(f32, 22), titlebarStripAtFullness(in, 0.5)); + try std.testing.expectEqual(@as(f32, 6), titlebarStripAtFullness(in, 1)); + // Whatever the monitor says of the chrome at the moment, the fullness decides. + var hidden = in; + hidden.collapsed = true; + try std.testing.expectEqual(@as(f32, 38), titlebarStripAtFullness(hidden, 0)); + try std.testing.expectEqual(@as(f32, 6), titlebarStripAtFullness(in, 2)); +} diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index ff3e31c4..5651bcff 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -31,6 +31,7 @@ const fizzy = @import("../fizzy.zig"); const dvui = @import("dvui"); const core = @import("core"); const update_notify = @import("app").update.update_notify; +const restart = @import("app").restart; const Entry = fizzy.Entry; const Editor = @This(); @@ -92,8 +93,8 @@ pub const sdk = fizzy.sdk; pub const Profiler = @import("Profiler.zig"); /// Demo automation: fizzy's stage for `app.automation`, and the demos it ships with. pub const Demo = @import("Demo.zig"); -/// A float taken out of the main window into an OS window of its own: the spike behind -/// `FIZZY_POPOUT=1` (`docs/POPOUT_WINDOWS_PLAN.md`). +/// A float taken out of the main window into an OS window of its own: on by default on macOS, +/// `FIZZY_POPOUT=1` elsewhere (`docs/POPOUT_WINDOWS_PLAN.md`). pub const Popout = @import("Popout.zig"); pub const Host = sdk.Host; @@ -174,12 +175,15 @@ last_load_request_path: ?[]const u8 = null, window_opacity: f32 = 1.0, -/// Animated window-background opacity multiplier. Eases toward the windowed -/// target (translucent, vibrancy shows through) or 1.0 (opaque) when -/// maximized/fullscreen, so the vibrancy fades in/out across fullscreen -/// transitions instead of snapping. `< 0` is a sentinel meaning "snap to the -/// target on the first frame" so there is no fade at launch. -window_opacity_anim: f32 = -1.0, +/// The window's opacity as drawn (`easeWindowOpacity`): toward the windowed target (translucent, +/// the glass or vibrancy showing through) or opaque while it covers the desktop, fading with the +/// OS's fullscreen transitions instead of snapping. Snaps to its target on the first frame, so +/// there is no fade at launch. +window_opacity_anim: WindowOpacity = .{}, +/// The window's chrome hidden last frame — a fullscreen Space its end state — and until when, on the +/// way back out, frames keep coming for its opacity to fade (`tick`). +chrome_was_hidden: bool = false, +space_exit_watch_ns: i128 = 0, /// Menu-bar clicks waiting for a safe point in the frame. Each is a `menu_model` tag. pending_native_menu_actions: [16]fizzy.backend.NativeMenuAction = undefined, @@ -2297,8 +2301,11 @@ fn fizzyFrostPane(_: *anyopaque, id: dvui.Id, rect: dvui.Rect.Physical, corners: return fizzy.core.dialogs.frostPane(id, rect, corners, scale); } +/// The cards' opacity: the setting while windowed, opaque while the window covers the desktop, +/// eased between them with the window's own opacity (`easeWindowOpacity`). fn fizzyContentOpacity(ctx: *anyopaque) f32 { - return fizzyCtx(ctx).app.settings.content_opacity; + const editor = fizzyCtx(ctx); + return std.math.lerp(editor.app.settings.content_opacity, 1.0, std.math.clamp(editor.window_opacity_anim.cover, 0, 1)); } fn fizzyIsMaximized(ctx: *anyopaque) bool { _ = ctx; @@ -3176,6 +3183,11 @@ pub fn reconcileExternalSettingsChange(editor: *Editor) void { editor.app.settings.corner_roundness = parsed.corner_roundness; editor.app.settings.motion = parsed.motion; editor.app.settings.motion_speed = parsed.motion_speed; + editor.app.settings.glass_shader = parsed.glass_shader; + editor.app.settings.float_windows = parsed.float_windows; + editor.app.settings.native_glass = parsed.native_glass; + editor.app.settings.native_menus = parsed.native_menus; + editor.app.settings.native_dialogs = parsed.native_dialogs; editor.app.settings.input_scheme = parsed.input_scheme; editor.app.settings.plugin_update_mode = parsed.plugin_update_mode; @@ -3480,24 +3492,99 @@ pub fn flushSettings(editor: *Editor) void { const handle_size = 10; const handle_dist = 60; -/// Ease a window's background (`anim`, `windowBase`'s opacity; below 0 before its first frame) -/// between translucent while windowed (`windowed`) and fully opaque while `maximized` — zoomed or -/// full screen, through the whole of the way out of a fullscreen Space — so the vibrancy fades in -/// and out across those transitions rather than snapping. The main window's and every float's own -/// window's (`Popout`), alike. -pub fn easeWindowOpacity(anim: *f32, maximized: bool, windowed: f32) void { - const target: f32 = if (maximized) 1.0 else windowed; - if (anim.* < 0) { - anim.* = target; - } else if (anim.* != target) { - const dt = dvui.secondsSinceLastFrame(); - const t = std.math.clamp(dt * 6.0, 0.0, 1.0); - anim.* += (target - anim.*) * t; - if (@abs(target - anim.*) < 0.004) anim.* = target; - dvui.refresh(null, @src(), null); +/// A window's opacity on its way between translucent while windowed and opaque while it covers +/// the desktop (`easeWindowOpacity`) — the main window's, and every float's own window's. +pub const WindowOpacity = struct { + /// What the window is drawn at (`windowBase`, `windowGlassLook`); below 0 before its first frame. + value: f32 = -1, + /// How far it is to covering the desktop: 0 windowed, 1 zoomed or full screen. + cover: f32 = 0, + covers: bool = false, + from: f32 = 0, + from_cover: f32 = 0, + since_ns: i128 = 0, +}; + +/// How long a window takes between translucent and opaque as it comes to cover the desktop or +/// stops: about as long as macOS takes to move it into a fullscreen Space or out of one. +const cover_ms = 500; + +/// Seconds, at most, frames keep coming after the window sets out of a fullscreen Space, for it to +/// let go of covering the desktop (`tick`). +const space_exit_watch_s = 4; + +/// Ease a window's opacity (`o`) toward `windowed` or, while it `covers` the desktop — zoomed, full +/// screen (`backend.coversDesktop`) — opaque. While the window moves itself into a fullscreen Space +/// or out of one (`fullness`, `backend.spaceFullness`) it is as opaque as it is far into full +/// screen: the fade and the move are one motion. Across any other change of `covers` it takes +/// `cover_ms` on a smoothstep from the moment the change is seen; otherwise — the opacity slider +/// moving — it follows closely. Where AppKit animates a Space transition as pictures of the window +/// (`snap` into one) it is opaque at once, and out of one it stays opaque until the window has +/// landed and then fades: see-through pictures were a double window. Each step wakes the backend +/// for the next: asked from a frame the window monitor drew through a transition, a plain refresh +/// woke nothing, and the window sat opaque after leaving full screen until the mouse moved (the +/// user). +pub fn easeWindowOpacity(o: *WindowOpacity, covers: bool, snap: bool, fullness: ?f32, windowed: f32) void { + const target: f32 = if (covers) 1.0 else windowed; + const cover_target: f32 = if (covers) 1.0 else 0.0; + const now = dvui.currentWindow().frame_time_ns; + const span: i128 = cover_ms * std.time.ns_per_ms; + if (fullness) |f| { + o.* = .{ .value = std.math.lerp(windowed, 1.0, f), .cover = f, .covers = covers, .since_ns = now - span }; + dvui.refresh(dvui.currentWindow(), @src(), null); + return; + } + if (o.value < 0) { + o.* = .{ .value = target, .cover = cover_target, .covers = covers, .since_ns = now - span }; + return; + } + if (covers != o.covers) { + o.* = .{ .value = o.value, .cover = o.cover, .covers = covers, .from = o.value, .from_cover = o.cover, .since_ns = now }; + if (snap) o.* = .{ .value = target, .cover = cover_target, .covers = covers, .since_ns = now - span }; + } + const elapsed = now - o.since_ns; + if (elapsed < span) { + const x: f32 = @floatCast(@as(f64, @floatFromInt(@max(elapsed, 0))) / @as(f64, @floatFromInt(span))); + const k = x * x * (3 - 2 * x); + o.value = std.math.lerp(o.from, target, k); + o.cover = std.math.lerp(o.from_cover, cover_target, k); + dvui.refresh(dvui.currentWindow(), @src(), null); + return; + } + o.cover = cover_target; + if (o.value != target) { + const t = std.math.clamp(dvui.secondsSinceLastFrame() * 6.0, 0.0, 1.0); + o.value += (target - o.value) * t; + if (@abs(target - o.value) < 0.004) o.value = target; + dvui.refresh(dvui.currentWindow(), @src(), null); } } +/// A window of Liquid Glass at `opacity` (`backend.windowGlass`), `size` points: the glass +/// `core.glass_look.window` makes of it, in the content fill as the window's colour, its clearing +/// bevel the one every glass has for its size (`core.glass_look.band`). The main window's and +/// every float's own window's alike. +pub fn windowGlassLook(opacity: f32, size: dvui.Size.Natural) fizzy.backend.WindowGlassLook { + const glass_look = fizzy.core.glass_look; + const look = glass_look.window(opacity); + const band = glass_look.band(@min(size.w, size.h) / 2); + return .{ + .under_variant = look.under.variant, + .under_style = look.under.style, + .over_variant = look.over.variant, + .over_style = look.over.style, + .frost = look.frost, + .blur = look.blur, + .glass = look.glass, + .fill = dvui.themeGet().color(.content, .fill), + .fill_opacity = look.fill, + .top_fill = look.top_fill, + .radius = fizzy.backend.viewports.windowRadius(), + .clear = band.clear, + .feather = band.feather, + }; +} + /// A window's base at `opacity`: the theme's content fill over the OS's material where the window /// has one (macOS, Windows), lightened as it goes see-through so the material behind reads as the /// same tone; opaque where there is none. Every window fizzy draws stands on it — the main window, @@ -3510,6 +3597,12 @@ pub fn windowBase(opacity: f32) dvui.Color { }; } +/// Whether a setting the app takes only at launch has been changed since (`Popout.restartPending`): +/// the settings pane and the info bar offer a restart (`app.restart`). +pub fn restartPending(_: *const Editor) bool { + return Popout.restartPending(); +} + pub fn tick(editor: *Editor) !dvui.App.Result { // (A playing demo has already had its turn: `Entry.frameOnce` runs `Demo.frame` first.) // Finger or mouse: how far a tap may drift, here and (through the context sync) in every @@ -3532,6 +3625,14 @@ pub fn tick(editor: *Editor) !dvui.App.Result { .has_chrome = true, }); fizzy.core.dialogs.publishRefraction(editor.app.settings.dialog_refraction); + // Every glass the app draws, on the one slider — the window opacity as the user set it, + // not as it eases to opaque when maximized (`core.glass_look`): Apple's lens, frosting + // and taking the window's colour up the slider, as the OS's Liquid Glass beside it does. + // Not on the web, where there is no OS glass to match: there the glass stays the clear + // liquid glass it was, each field's own tint, lift and blur (the user: it lost its glassy + // look, and the lens's bright rim read as a border). + const slider = if (dvui.themeGet().dark) editor.app.settings.window_opacity_dark else editor.app.settings.window_opacity_light; + fizzy.core.LiquidField.publishLook(if (comptime builtin.target.cpu.arch == .wasm32) null else fizzy.core.glass_look.inApp(slider)); fizzy.core.corners.publish(editor.app.settings.corner_roundness); } // How things move this frame, for every animation here and in every plugin (`core.motion`). @@ -3560,7 +3661,21 @@ pub fn tick(editor: *Editor) !dvui.App.Result { editor.window_opacity = if (dvui.themeGet().dark) editor.app.settings.window_opacity_dark else editor.app.settings.window_opacity_light; // The draw uses `window_opacity_anim`. - easeWindowOpacity(&editor.window_opacity_anim, fizzy.backend.isMaximized(dvui.currentWindow()), editor.window_opacity); + const covers = fizzy.backend.coversDesktop(dvui.currentWindow()); + easeWindowOpacity(&editor.window_opacity_anim, covers, fizzy.backend.enteringSpace(dvui.currentWindow()), fizzy.backend.spaceFullness(dvui.currentWindow()), editor.window_opacity); + // Out of a fullscreen Space, the window stops covering the desktop on AppKit's and SDL's own + // time, after the transition, and nothing wakes a frame for it: it sat opaque until the mouse + // moved (the user). From the moment it sets out of the Space, frames keep coming until it no + // longer covers the desktop and its fade can start by itself — a few seconds at most. + { + const now = dvui.currentWindow().frame_time_ns; + const hidden = fizzy.backend.isFullscreenChromeHidden(dvui.currentWindow()); + if (editor.chrome_was_hidden and !hidden) editor.space_exit_watch_ns = now + space_exit_watch_s * std.time.ns_per_s; + editor.chrome_was_hidden = hidden; + if (now < editor.space_exit_watch_ns) { + if (covers) dvui.refresh(dvui.currentWindow(), @src(), null) else editor.space_exit_watch_ns = 0; + } + } // Drain any "Save and Close" requests whose async save has settled. editor.tickPendingSaveCloses(); @@ -3600,6 +3715,11 @@ pub fn tick(editor: *Editor) !dvui.App.Result { if (needs_save_status_anim_tick and dvui.timerDoneOrNone(wd.id)) { dvui.timer(wd.id, 16_000); } + // A restart asked for (the Restart command, a downloaded update): its quit posted before the + // frame's quit is read below, so unsaved documents are asked about as for any quit; with none + // the frame ends closing, and `Entry.AppDeinit` starts fizzy again. + restart.tick(Dialogs.AppQuitUnsaved.active(dvui.currentWindow()) or editor.app.quit_in_progress or + editor.app.quit_save_all_ids.items.len > 0 or editor.app.quit_saves_in_flight.count() > 0); for (dvui.events()) |*e| { if (e.handled) continue; if (!dvui.eventMatchSimple(e, wd)) continue; @@ -3725,8 +3845,11 @@ pub fn tick(editor: *Editor) !dvui.App.Result { { // `window_opacity_anim` eases between the windowed opacity and 1.0 (opaque) across - // fullscreen transitions; at 1.0 the base is the opaque fill. - const window_color = windowBase(editor.window_opacity_anim); + // fullscreen transitions; at 1.0 the base is the opaque fill. On a window of Liquid Glass + // (macOS 26, `backend.windowGlass`) the base is the glass's, on the one slider + // (`core.glass_look.window`), and the frame draws none of its own. + const glass_window = fizzy.backend.windowGlass(dvui.currentWindow(), windowGlassLook(editor.window_opacity_anim.value, dvui.windowRect().size())); + const window_color = if (glass_window) dvui.Color{ .r = 0, .g = 0, .b = 0, .a = 0 } else windowBase(editor.window_opacity_anim.value); // Linux: the window is transparent and undecorated (`linux_titlebar`), so its shape is // this fill's — rounded while windowed, as the desktop rounds its own, with a hairline diff --git a/src/editor/Infobar.zig b/src/editor/Infobar.zig index e7b70271..c7e891e8 100644 --- a/src/editor/Infobar.zig +++ b/src/editor/Infobar.zig @@ -5,6 +5,8 @@ const dvui = @import("dvui"); const icons = @import("icons"); const assets = @import("assets"); const update_notify = @import("app").update.update_notify; +const update_install = @import("app").update.update_install; +const restart = @import("app").restart; const Dialogs = fizzy.Editor.Dialogs; /// Font, height, icon side and spacing — fizzy draws every item with these, including /// plugin `Entry` chips, so the bar stays uniform as the font setting changes. @@ -137,6 +139,29 @@ pub fn draw(_: Infobar, editor: *fizzy.Editor) !void { dvui.label(@src(), "{s}", .{std.fs.path.basename(folder)}, .{ .font = font, .gravity_y = 0.5 }); } + // An update downloaded and waiting, or a setting taken only at launch changed since + // (`Editor.restartPending`): said here, and the restart one click away. + const update_waiting = update_install.downloaded(); + if (update_waiting or editor.restartPending()) { + _ = dvui.spacer(@src(), .{ .min_size_content = .{ .w = infobar.item_spacing } }); + const accent = dvui.themeGet().color(.highlight, .fill); + fizzy.core.icon.icon( + @src(), + "restart_pending_icon", + icons.tvg.lucide.@"rotate-ccw", + .{ .stroke_color = .{ .color = accent } }, + .{ + .gravity_y = 0.5, + .min_size_content = .{ .w = infobar.iconSide(), .h = infobar.iconSide() }, + .max_size_content = .size(.{ .w = infobar.iconSide(), .h = infobar.iconSide() }), + }, + ); + const why = if (update_waiting) "Restart to update" else "Restart to apply settings"; + if (dvui.labelClick(@src(), "{s}", .{why}, .{}, .{ .font = font, .gravity_y = 0.5, .color_text = .{ .color = accent } })) { + restart.request(); + } + } + drawPluginEntries(bar_h); } diff --git a/src/editor/Keybinds.zig b/src/editor/Keybinds.zig index c3e15d28..afae2c5f 100644 --- a/src/editor/Keybinds.zig +++ b/src/editor/Keybinds.zig @@ -20,6 +20,7 @@ const icons = @import("icons"); const sdk = @import("fizzy_sdk"); const Keymap = @import("app").keymap.Keymap; const adapter = @import("app").keymap.dvui_adapter; +const restart = @import("app").restart; pub const Keybinds = @This(); @@ -163,6 +164,7 @@ const fizzy_commands = [_]FizzyCommand{ // `NativeMenuAction` switch arms — so they were unreachable from the palette and unbindable. .{ .id = "fizzy.debugToggleDvuiMenuOnMacOS", .title = "Show DVUI Menu (macOS)", .bind = null, .run = cmdToggleDvuiMenuOnMacOS }, .{ .id = "fizzy.about", .title = "About Fizzy", .bind = null, .run = cmdAbout, .icon = icons.tvg.lucide.download }, + .{ .id = "fizzy.restart", .title = "Restart Fizzy", .bind = null, .run = cmdRestart, .isEnabled = canRestart, .icon = icons.tvg.lucide.@"rotate-ccw" }, .{ .id = "fizzy.reportBug", .title = "Report a Bug", .bind = null, .run = cmdReportBug, .icon = icons.tvg.lucide.bug }, .{ .id = "fizzy.toggleProfiler", .title = "Toggle Profiler", .bind = null, .run = cmdToggleProfiler, .icon = icons.tvg.lucide.gauge }, } ++ open_folder_commands ++ demo_commands ++ automation_commands; @@ -418,6 +420,16 @@ fn cmdAbout(_: *anyopaque) anyerror!void { Editor.Dialogs.AboutFizzy.request(); } +/// Quit and start again (`app.restart`) — for a setting taken only at launch, and installing an +/// update that has downloaded. +fn cmdRestart(_: *anyopaque) anyerror!void { + restart.request(); +} + +fn canRestart(_: *anyopaque) bool { + return restart.supported; +} + fn cmdToggleProfiler(_: *anyopaque) anyerror!void { Editor.Profiler.toggle(); } diff --git a/src/editor/Popout.zig b/src/editor/Popout.zig index 10c6f1b1..06acbcd3 100644 --- a/src/editor/Popout.zig +++ b/src/editor/Popout.zig @@ -1,5 +1,5 @@ -//! The pop-out plan's viewports (`docs/POPOUT_WINDOWS_PLAN.md`), behind `FIZZY_POPOUT=1` on -//! fizzy's native backend: where the platform has OS windows a float is one, from the frame it is +//! The pop-out plan's viewports (`docs/POPOUT_WINDOWS_PLAN.md`), on by default on macOS and behind +//! `FIZZY_POPOUT=1` elsewhere, on fizzy's native backend: where the platform has OS windows a float is one, from the frame it is //! made in — every float its own window (a viewport, `fizzy.backend.viewports`), titled and framed by //! the OS where it can be (macOS), moved, snapped and resized by the OS as any window. Its views go //! back into the main window by being carried there, as any view is; the window itself never @@ -42,7 +42,35 @@ var carry: ?Carry = null; var covers: [max_out]Cover = undefined; var cover_count: usize = 0; +/// Each open menu's window (`menuFrame`), by its subwindow — a context menu and the submenus out of +/// it. +var menus: [max_menus]?MenuOut = @splat(null); +/// Menus and dialogs at once: a menu's submenus, a dialog with a menu open in it. +const max_menus = 6; + +const MenuOut = struct { + id: dvui.Id, + viewport: *viewports.Viewport, + target: ?dvui.Texture.Target = null, + /// Drawn this frame: a menu not drawn has closed, and its window goes. + seen: bool = false, + /// On Liquid Glass (`viewports.windowGlass`), else vibrancy with the menu's colour drawn over it. + glass: bool = false, + /// Opened in a float that is out: where that float's window showed its band then. Its window + /// moved, a menu closes (`menuFrame`) and a dialog goes with it (`dialogsRide`). + float_at: ?dvui.Point.Physical = null, + /// That float's window, for a dialog, which rides on it. + float_vp: ?*viewports.Viewport = null, + /// A dialog's window, not a menu's. + dialog: bool = false, +}; + +/// A float holding a menu moved its window: every menu closes next frame (`menuFrame`). +var menus_left_behind = false; + const Cover = struct { + /// The float's window. + viewport: *viewports.Viewport, /// Where the window lies over the main window's frame, physical. in_main: dvui.Rect.Physical, /// The part of the frame it shows: its band. @@ -73,25 +101,58 @@ const Out = struct { title_len: u8 = 0, /// The carried glass it is growing out of, landing (`growFrame`). grow: ?Grow = null, + /// Born of a drop: its window kept over the drag's overlay while that still holds glass + /// (`growFrame`), at the overlay's level, then back at its own. + lifted: bool = false, /// Its base's opacity, eased between windowed and maximized as the main window's is - /// (`Editor.easeWindowOpacity`); below 0 before its first frame. - opacity: f32 = -1, + /// (`Editor.easeWindowOpacity`). + opacity: Editor.WindowOpacity = .{}, }; const Grow = struct { carry: Carry, }; -/// `FIZZY_POPOUT=1`, on a backend with viewports, where this run can open them (not Wayland). +/// On a backend with viewports, where this run can open them (not Wayland): by default on macOS, +/// where floats as windows are fully supported (`FIZZY_POPOUT=0` keeps floats in the main window); +/// elsewhere with `FIZZY_POPOUT=1`, until their windows are dressed there too. pub fn enabled() bool { if (comptime builtin.target.cpu.arch == .wasm32 or !viewports.supported) return false; if (env_on == null) { - const raw = std.c.getenv("FIZZY_POPOUT"); - env_on = if (raw) |r| !std.mem.eql(u8, std.mem.span(r), "0") and viewports.available() else false; + launched_float_windows = fizzy.editor().app.settings.float_windows; + const asked = envSwitch("FIZZY_POPOUT") orelse launched_float_windows.?; + env_on = asked and viewports.available(); } return env_on.?; } +/// The float windows setting (`Settings.float_windows`) as the app launched with it: floats move +/// in and out of windows only at launch, so a change to it waits for a restart. +var launched_float_windows: ?bool = null; + +/// Whether a setting fizzy takes only at launch has been changed since (`Editor.restartPending`): +/// floats as windows. +pub fn restartPending() bool { + if (comptime builtin.target.cpu.arch == .wasm32 or !viewports.supported) return false; + const launched = launched_float_windows orelse return false; + return launched != fizzy.editor().app.settings.float_windows and envSwitch("FIZZY_POPOUT") == null; +} + +/// An environment switch, read once: `NAME=0` off, any other value on, unset null — the setting +/// rules then. For testing and sandboxes, over the settings. +fn envSwitch(comptime name: [:0]const u8) ?bool { + if (comptime builtin.target.cpu.arch == .wasm32) return null; + const Cache = struct { + var read = false; + var value: ?bool = null; + }; + if (!Cache.read) { + Cache.read = true; + Cache.value = if (std.c.getenv(name)) |v| !std.mem.eql(u8, std.mem.span(v), "0") else null; + } + return Cache.value; +} + /// Before the frame draws anything: a window whose float has gone goes too, each window the OS /// moved or resized takes its float with it, one the OS asked to close closes its float — and every /// float not in a window yet (made last frame, or brought back from a saved layout) goes into one, @@ -104,7 +165,19 @@ pub fn beginFrame(state: *State) void { // Carried things are windows of their own here (`carryFrame`): photographed without their // place's background (`ViewDrag.photographFromFrame`). fizzy.core.dialogs.carry_windows = viewports.carries; + // And a view drag's glass is the OS's where it has Liquid Glass (`overlayFrame`): while the drag + // is on, and after it while its drops are still running back together (`ViewDrag.last_pending`) + // — their going, as their coming, is the OS's glass too. + fizzy.core.native_glass.publishOn(nativeGlass() and (state.view_drag.active() or state.view_drag.last_pending_count > 0)); + // And the carried view's picture under that glass, in a window of its own (`photoFrame`). + fizzy.core.native_glass.publishUnder(nativeGlass() and viewports.carries); fizzy.core.screens.publishBeyond(viewports.carries and state.view_drag.active()); + // Menus in windows of their own (`menuFrame`), kept on the display rather than the window. + fizzy.core.screens.publishMenus(if (nativeMenus()) displayNatural() else null); + fizzy.core.screens.publishMenusDismissed(nativeMenus() and (!viewports.appActive() or menus_left_behind)); + // And dialogs, each riding on the window it opened in. + fizzy.core.screens.publishDialogs(if (nativeDialogs()) displayNatural() else null); + menus_left_behind = false; // Whatever happened, the screens floating things are placed on this frame: each window's, // besides the main window's (`core.screens`). defer publishScreens(); @@ -247,7 +320,15 @@ pub fn endFrame(state: *State) void { for (&outs) |*slot| { if (slot.*) |*o| windowFrame(state, o); } + // The dialogs drawn now, for `menuFrame` to take them into windows of their own: dvui draws + // them later, at the very end of the frame. After the floats have their places for this frame + // (`covers`), which a dialog opened in one goes along with (`dialogsRide`). + if (nativeDialogs()) { + dialogsRide(); + fizzy.core.dialogs.drawEarly(); + } carryFrame(state); + menuFrame(); // A drag's carry window no float grew out of in the frame after the drag: gone. if (spare) |*sp| { if (spare_kept) { @@ -269,6 +350,7 @@ pub fn endFrame(state: *State) void { /// one. It goes when the drag does. fn carryFrame(state: *State) void { if (!viewports.carries) return; + if (nativeGlass()) return overlayFrame(state); const d = &state.view_drag; if (!d.active()) { // Kept a frame: a float the drop made grows out of it (`growFrame`). @@ -281,10 +363,8 @@ fn carryFrame(state: *State) void { return; } const cw = dvui.currentWindow(); - // What it is carried as: the card or tab, or a drop — round its head, drawn out toward its - // tail as that lags on its spring, so a drop carried fast stretches out behind the pointer and - // swings back past it when it stops, as it did run together in the app's glass. - const shape = if (d.drop_n > 1) d.shape_rect.unionWith(d.drop_shapes[1].rect) else d.shape_rect; + // What it is carried as: the card or tab, or a drop. + const shape = d.shape_rect; const main_px = dvui.windowRectPixels(); // Where the carry window goes, in the main window's frame. Over a float's window the view is // drawn in that window's band, far past the main window (`Floats.Viewport`): the carry window @@ -341,6 +421,254 @@ fn carryFrame(state: *State) void { /// How opaque the window's base is under a carried view (`carryFrame`). const carried_backing: f32 = 0.2; +/// A view drag's glass — the carried view and the drop zones' bubbles — is one overlay of the OS's +/// Liquid Glass over the display the main window is on, wherever the OS has it +/// (`viewports.liquidGlass`, macOS 26): the OS's glass where it can be, the app's where it cannot +/// (`docs/NATIVE_WINDOWS_PLAN.md`) — as the setting says (`Settings.native_glass`, +/// `FIZZY_NATIVE_GLASS` over it). Off, the app's glass. +fn nativeGlass() bool { + if (comptime builtin.target.cpu.arch == .wasm32 or !viewports.carries) return false; + if (!(envSwitch("FIZZY_NATIVE_GLASS") orelse fizzy.editor().app.settings.native_glass)) return false; + if (liquid_glass == null) liquid_glass = viewports.liquidGlass(); + return liquid_glass.?; +} +var liquid_glass: ?bool = null; + +/// The drag's overlay of the OS's glass (`overlayFrame`), while a view is carried. +var overlay: ?Carry = null; + +/// Points within which the overlay's glass runs together (`NSGlassEffectContainerView`'s spacing), +/// where the frame says nothing else: the app's own merge distance (`DropZones.merge`). A drop's +/// bubbles rest just inside half of it from each other, so they rest joined by a thin neck, as the +/// app's glass draws them; a drop fitted smaller to a small place says its own, smaller +/// (`overlaySpacing`). +const overlay_spacing: f32 = fizzy.core.widgets.DropZones.merge; + +/// Points within which the overlay's glass runs together this frame: what the frame declared +/// with its glass (`core.native_glass.mergeWithin`, physical), else `overlay_spacing`. +fn overlaySpacing(s: f32) f32 { + const m = fizzy.core.native_glass.merge() orelse return overlay_spacing; + return m / s; +} + +/// A view drag's glass as the OS's (`nativeGlass`): an overlay window round the glass, on the main +/// window's display (`viewports.openOverlay`, `overlayArea`), holds a piece of Liquid Glass for each +/// piece the frame declared in place of drawing it (`core.native_glass`) — the drop zones' bubbles, +/// the carried drop, or its card — which the OS runs together where they come close, as it refracts +/// what is under them. The carried view's photograph lies under its drop's glass (`overlayPhoto`); +/// what goes over the glass — the drop's name, the bubbles' icons (`core.screens.markCarried`) — is +/// taken into the overlay's picture. Glass and pictures change in one transaction. It goes when the +/// drag does; a float the drop opens grows out of a carry window of its own (`growFrame`). +fn overlayFrame(state: *State) void { + const d = &state.view_drag; + // Kept past the drag while its drops still go, and gone once there is no glass left. + if (!d.active() and fizzy.core.native_glass.shapes().len == 0) { + if (overlay) |*o| releaseCarry(o); + overlay = null; + photo_sent = 0; + overlay_area = null; + return; + } + const cw = dvui.currentWindow(); + const display = viewports.displayInMain(); + if (display.w <= 0 or display.h <= 0) return; + const main_px = dvui.windowRectPixels(); + const s = dvui.windowNaturalScale(); + const area = overlayArea(display, main_px, s) orelse return; + if (overlay == null) { + const vp = viewports.openOverlay(.{ .x = area.x, .y = area.y, .w = area.w, .h = area.h }) orelse return; + overlay = .{ .viewport = vp }; + } + const o = &overlay.?; + const placed = viewports.placeMain(o.viewport, .{ .x = area.x, .y = area.y, .w = area.w, .h = area.h }); + const shown: dvui.Rect.Physical = .{ .x = placed.x, .y = placed.y, .w = placed.w, .h = placed.h }; + + // The glass, in the overlay window's points — and where it lies over the main window's frame, + // for the base over it. + var glass: [fizzy.core.native_glass.max_shapes]viewports.GlassShape = undefined; + var placed_shapes: [fizzy.core.native_glass.max_shapes]fizzy.core.native_glass.Shape = undefined; + var n: usize = 0; + for (fizzy.core.native_glass.shapes()) |sh| { + const r = inMainFrame(sh.rect, main_px) orelse continue; + placed_shapes[n] = sh; + placed_shapes[n].rect = r; + glass[n] = .{ + .x = (r.x - shown.x) / s, + .y = (r.y - shown.y) / s, + .w = r.w / s, + .h = r.h / s, + .radius = sh.radius / s, + .lit = sh.lit, + .alpha = sh.alpha, + .frost = sh.frost, + }; + n += 1; + } + const look = glassLook(std.math.clamp(fizzy.editor().window_opacity, 0, 1)); + const window_colour = base(false); + const lit_toward = if (dvui.themeGet().dark) dvui.Color.white else dvui.Color.black; + const spacing = overlaySpacing(s); + viewports.overlayGlass(o.viewport, glass[0..n], spacing, .{ + .under = .{ .variant = look.under.variant, .style = look.under.style }, + .over = .{ .variant = look.over.variant, .style = look.over.style }, + .over_share = look.over_share, + .glass = look.glass, + .fill = window_colour, + .fill_opacity = look.fill, + .lit_toward = lit_toward, + .lit_amount = glass_lit, + .bevel = fizzy.core.glass_look.drop_bevel, + .blur = look.blur, + .bevel_cap = fizzy.core.glass_look.bevel_cap, + .bevel_clear = fizzy.core.glass_look.bevel_clear, + }); + overlayPhoto(o, d, shown, main_px, s); + + // The picture: what goes over the glass — the carried view, the drops' icons — each layer of it + // (`core.screens.markCarried`) taken from the frame and replayed at the main window's part of it + // and at each float window's band, where it lies over the main window: what is drawn in one of + // them lands in the overlay, the rest outside it. + const w: u32 = @intFromFloat(@max(1, @round(shown.w))); + const h: u32 = @intFromFloat(@max(1, @round(shown.h))); + if (o.target) |t| if (t.width != w or t.height != h) { + t.destroyLater(); + o.target = null; + }; + if (o.target == null) o.target = dvui.textureCreateTarget(.{ .width = w, .height = h, .interpolation = .nearest }) catch return; + const target = o.target.?; + target.clear(); + var rt = cw.render_target; + rt.texture = target; + rt.offset = .{ .x = shown.x, .y = shown.y }; + rt.rendering = true; + const prev = dvui.renderTarget(rt); + glassBase(placed_shapes[0..n], shown, s, spacing, look.top_fill); + var offsets: [max_out + 1]dvui.Point.Physical = undefined; + offsets[0] = .{ .x = shown.x, .y = shown.y }; + var n_off: usize = 1; + for (covers[0..cover_count]) |cv| { + offsets[n_off] = .{ .x = shown.x + (cv.band.x - cv.in_main.x), .y = shown.y + (cv.band.y - cv.in_main.y) }; + n_off += 1; + } + for (cw.subwindows.stack.items) |*sw| { + if (!fizzy.core.screens.isCarried(sw.id)) continue; + const cmds = sw.render_cmds; + const after = sw.render_cmds_after; + sw.render_cmds = .empty; + sw.render_cmds_after = .empty; + for (offsets[0..n_off]) |off| { + rt.offset = off; + _ = dvui.renderTarget(rt); + cw.renderCommands(cmds.items) catch |err| dvui.logError(@src(), err, "replaying over the overlay's glass", .{}); + cw.renderCommands(after.items) catch |err| dvui.logError(@src(), err, "replaying over the overlay's glass", .{}); + } + } + _ = dvui.renderTarget(prev); + viewports.present(o.viewport, target); +} + +/// The window's colour over the OS's glass, in its shape, `fill` opaque (`glassLook`): only at the +/// very top of the slider, as the glass hands over to flat opaque colour — below that the colour +/// is under the glass, which bends and lights it (`viewports.overlayGlass`). In the shape the +/// pieces run together in (`core.liquid_blob.fill`, its smooth union over the overlay's spacing) and +/// in a little from their edges, where the OS's glass bends and lights its rim. The bubble a carried +/// view is aimed at lights in it, as a hovered bubble does — not by the glass's pressed look, which +/// the OS will not run together with its neighbours. `area` is the overlay's part of the frame. +fn glassBase(shapes: []const fizzy.core.native_glass.Shape, area: dvui.Rect.Physical, s: f32, spacing: f32, fill: f32) void { + if (shapes.len == 0 or fill <= 0.002) return; + const prev_clip = dvui.clipGet(); + defer dvui.clipSet(prev_clip); + dvui.clipSet(area); + const prev_alpha = dvui.alpha(1); + defer dvui.alphaSet(prev_alpha); + var color = base(false); + color.a = @intFromFloat(@round(255 * std.math.clamp(fill, 0, 1))); + // In from the glass's edge while there is glass to light it, out to it as the glass goes. + const inset = glass_rim * s * (1 - std.math.clamp(fill, 0, 1)); + var discs: [fizzy.core.native_glass.max_shapes]fizzy.core.liquid_blob.Disc = undefined; + var nd: usize = 0; + for (shapes) |sh| { + const r = sh.rect; + const half = @min(r.w, r.h) / 2; + var c = color; + c.a = @intFromFloat(@round(@as(f32, @floatFromInt(c.a)) * std.math.clamp(sh.alpha, 0, 1))); + if (sh.radius >= half - 0.5) { + // Round: a disc of the union. + if (half - inset <= 0) continue; + discs[nd] = .{ .c = .{ .x = r.x + r.w / 2, .y = r.y + r.h / 2 }, .r = half - inset, .lit = sh.lit }; + nd += 1; + } else if (c.a > 0) { + // A card or a tab: a rounded rect of its own, nothing to run into. + r.insetAll(inset).fill(.all(@max(0, sh.radius - inset)), .{ .color = .{ .color = c } }); + } + } + fizzy.core.liquid_blob.fill(discs[0..nd], spacing * s, s, color, 0, .white); +} + +/// What the OS's glass is at the window's opacity (`Editor.window_opacity`): the one mapping every +/// glass reads (`core.glass_look`), the app's own glass on the same breakpoints. +const glassLook = fizzy.core.glass_look.native; + +/// Points in from the OS's glass's edge the window's base stops (`glassBase`): its bent, lit rim. +const glass_rim: f32 = 2; +/// How much the bubble a carried view is aimed at lights, under the glass: lighter on a dark theme, +/// darker on a light one, as a hovered fill. +const glass_lit: f32 = 0.12; + +/// `r` (physical, in the frame) where it lies over the main window's frame: as it is, or — drawn in +/// the band of a float's window, past the main window — where that part of the band lies, by the +/// window it overlaps most (`covers`). Null in a band no window shows. +/// What of the display the drag's overlay covers, in the main window's frame: round all its glass +/// (`core.native_glass.shapes`) — and the picture over it, which lies inside the glass — with room to +/// spare, on the display. Not the whole display: its picture is presented every frame of a drag, and +/// one the display's size, cleared and drawn and composited each frame, held a drag to half the +/// display's rate (60 frames a second where it could have 120). With the room to spare it moves +/// only when the glass reaches past it, or it is far bigger than the glass needs, rather than +/// every frame the drop moves. Null with no glass to cover. +fn overlayArea(display: anytype, main_px: dvui.Rect.Physical, s: f32) ?dvui.Rect.Physical { + var glass: ?dvui.Rect.Physical = null; + for (fizzy.core.native_glass.shapes()) |sh| { + const r = inMainFrame(sh.rect, main_px) orelse continue; + glass = if (glass) |g| g.unionWith(r) else r; + } + const need_core = glass orelse return overlay_area; + const disp: dvui.Rect.Physical = .{ .x = display.x, .y = display.y, .w = display.w, .h = display.h }; + // Past the glass itself: its rim's light and the shadow it casts. + const need = need_core.outsetAll(overlay_margin * s).intersect(disp); + if (overlay_area) |a| { + const inside = need.x >= a.x and need.y >= a.y and need.x + need.w <= a.x + a.w and need.y + need.h <= a.y + a.h; + if (inside and a.w * a.h <= overlay_most * need.w * need.h) return a; + } + const room = @max(overlay_room * s, @max(need.w, need.h) * 0.25); + const a = need.outsetAll(room).intersect(disp); + overlay_area = a; + return a; +} + +/// Points round the glass the overlay covers (`overlayArea`), for what the glass draws past its +/// edge; and the room to spare it is given past that, at least; and how many times the area the +/// glass needs it may cover before it is made smaller. +const overlay_margin: f32 = 32; +const overlay_room: f32 = 160; +const overlay_most: f32 = 6; + +var overlay_area: ?dvui.Rect.Physical = null; + +fn inMainFrame(r: dvui.Rect.Physical, main_px: dvui.Rect.Physical) ?dvui.Rect.Physical { + if (r.x <= main_px.x + main_px.w + 40000) return r; + var best: ?Cover = null; + var best_area: f32 = 0; + for (covers[0..cover_count]) |cv| { + const ov = cv.band.intersect(r); + if (ov.w * ov.h > best_area) { + best = cv; + best_area = ov.w * ov.h; + } + } + const cv = best orelse return null; + return r.offsetPoint(.{ .x = cv.in_main.x - cv.band.x, .y = cv.in_main.y - cv.band.y }); +} + /// A carry window's picture under way (`carryBegin`): the frame's own target to go back to, and /// the part of the frame the window shows. const CarryDrawing = struct { @@ -441,18 +769,193 @@ fn growFrame(o: *Out, f: *const Floats.Float) f32 { const radius = std.math.lerp(land.radius, viewports.windowRadius() * dvui.windowNaturalScale(), std.math.clamp(t, 0, 1)); // The float's picture over the first part of the growth, the photograph going as it comes. const arrive = smoothstep(std.math.clamp(Floats.landingFraction(land) / picture_share, 0, 1)); - // The base comes in with the picture, which has its own. - if (!drawGrow(o, g, rect, radius, 1, 0, arrive, if (land.photo) |tex| .{ .tex = tex, .size = land.photo_size, .fade = 1 - arrive } else null)) return 1; - return 0; + // Where the OS draws the drag's glass, the window grows out of drops in it (`growDrops`), + // and its own window comes in over the last of it, the drop's picture growing with it in + // the overlay. Elsewhere its picture and colour are the carry window's, over the glass. + const drops = nativeGlass(); + const window_in: f32 = if (drops) growDrops(land, rect, radius, to) else 0; + if (drops) if (overlay) |*ov| liftOver(o, ov); + // The window's colour comes in with the picture, as much of it as the window it grows into + // will have (`windowShade`): with none, the glass ended lighter than that window, which + // popped darker on the last frame as it took over (the user). + const keep = 1 - window_in; + if (drops) { + // The drop's picture grows with it, from where it lay in the drop to the window's top + // left at the size it was taken — the view as the window will show it, its own heading + // where the window's header is — on the glass's own way there, and goes as the window + // comes in over it (the user: it went as the drop was let go). The overlay holds its + // image (`overlayPhoto`); the carry window has nothing of its own to show here. + if (land.photo_from) |pf| { + const b = to.insetAll(reach() * dvui.windowNaturalScale()); + const sz = land.photo_size; + const body: dvui.Rect.Physical = .{ .x = b.x, .y = b.y, .w = sz.w, .h = sz.h }; + const k = std.math.clamp(t, 0, 1); + // Frosted over as the glass grows round it, and clear again as the window comes in — + // the view itself, there (the user: it then unblurs). + const frosted = smoothstep(std.math.clamp(Floats.landingFraction(land) / frost_share, 0, 1)); + const look = glassLook(std.math.clamp(fizzy.editor().window_opacity, 0, 1)); + grow_photo = .{ + .rect = rect, + .radius = radius, + // From the drag's touch of frost (`drop_photo_blur`), not from sharp. + .blur = @max(fizzy.core.glass_look.drop_photo_blur, look.blur * frosted) * keep, + .image = .{ .x = std.math.lerp(pf.x, body.x, k), .y = std.math.lerp(pf.y, body.y, k), .w = std.math.lerp(pf.w, body.w, k), .h = std.math.lerp(pf.h, body.h, k) }, + .alpha = keep, + }; + } + return window_in; + } + if (!drawGrow(o, g, rect, radius, 1, windowShade() * arrive * keep, arrive * keep, if (land.photo) |tex| .{ .tex = tex, .size = land.photo_size, .fade = 1 - arrive } else null)) return 1; + return window_in; } // Landed: the window shows, and the glass goes with it. if (o.grow) |*g| { releaseCarry(&g.carry); o.grow = null; } + // Over the overlay for as long as it holds glass — the drop zones it was let go over still + // going — and back at its own level once it is gone. + if (o.lifted) { + if (overlay) |*ov| liftOver(o, ov) else { + viewports.settle(o.viewport); + o.lifted = false; + } + } return 1; } +/// The carried view's picture on the drag's glass (`core.native_glass.publishUnder`, +/// `ViewDrag.photo_under`): in the overlay, over its drop's frost — what the view shows and nothing +/// of its ground, on frosted glass as a bubble's icon is — fitted to what the view shows. Its image +/// goes over once a drag (`photo_gen`); after that it only moves, in the glass's transaction. A +/// window of its own for it, presented every frame, cost the compositor a third window a frame (the +/// user saw 40 fps). +fn overlayPhoto(o: *Carry, d: *const @FieldType(State, "view_drag"), shown: dvui.Rect.Physical, main_px: dvui.Rect.Physical, s: f32) void { + defer grow_photo = null; + // In the drop, as the drag carries it; or, the drag over, growing with the float its drop was + // let go as (`growFrame`), from the image the drag handed over. + const carried: ?PhotoPlace = if (d.active()) if (d.photo_under) |pu| if (d.photo_pixels) |px| blk: { + if (photo_sent != d.photo_gen) { + viewports.overlayPhotoImage(o.viewport, std.mem.sliceAsBytes(px), d.photo_size[0], d.photo_size[1]); + photo_sent = d.photo_gen; + } + break :blk .{ .rect = pu.rect, .radius = pu.radius, .image = pu.image, .alpha = pu.alpha, .blur = fizzy.core.glass_look.drop_photo_blur }; + } else null else null else null; + const p = carried orelse grow_photo orelse return hidePhoto(o); + if (photo_sent == 0) return hidePhoto(o); + const r = inMainFrame(p.rect, main_px) orelse return hidePhoto(o); + viewports.overlayPhoto(o.viewport, .{ + .rect = .{ .x = (r.x - shown.x) / s, .y = (r.y - shown.y) / s, .w = r.w / s, .h = r.h / s }, + .radius = p.radius / s, + .image = .{ .x = (p.image.x - p.rect.x) / s, .y = (p.image.y - p.rect.y) / s, .w = p.image.w / s, .h = p.image.h / s }, + // Its content alone, on the drop's frosted glass: no ground of its own, a touch out of focus + // with the glass it is in (`glass_look.drop_photo_blur`), the frost under it the drop's, as + // a bubble's is (the user). + .fill = .{ .r = 0, .g = 0, .b = 0, .a = 0 }, + .alpha = p.alpha, + .blur = p.blur, + }); +} + +/// Where the carried view's picture is on the drag's glass this frame (`overlayPhoto`), physical: +/// the glass it is in, its corners, where the whole picture lies, and how much of it shows. +const PhotoPlace = struct { rect: dvui.Rect.Physical, radius: f32, image: dvui.Rect.Physical, alpha: f32, blur: f32 = 0 }; + +/// The picture growing with a float out of the drop it was let go as (`growFrame`), this frame — +/// for the overlay's pass after the windows' (`overlayPhoto`). Null when none is. +var grow_photo: ?PhotoPlace = null; + +/// Nothing of the picture shows this frame. Its image is kept: the frame a drop is let go as a float +/// shows none of it, and the frames after grow it with the float (`grow_photo`). It goes with the +/// overlay (`overlayFrame`), or for the next drag's. +fn hidePhoto(o: *Carry) void { + viewports.overlayPhoto(o.viewport, null); +} + +/// The picture whose image the overlay holds (`ViewDrag.photo_gen`); 0, none. +var photo_sent: u32 = 0; + +/// A float's window born of a drop, over the drag's overlay (`overlayFrame`): under it, the glass +/// still in the overlay — the window growing out of it, the drop zones it was let go over going, in +/// the very place the window opens — bent the window's picture with its lens, its title folded back +/// on itself for a frame (the user). Over it, they go beneath the window. +fn liftOver(o: *Out, ov: *Carry) void { + viewports.lift(o.viewport, ov.viewport); + o.lifted = true; +} + +/// How much of a float's window its colour covers, settled, at the window opacity: a window of +/// Liquid Glass's colour in its body and, at the top, over all of it (`core.glass_look.window`); +/// a window on vibrancy, its base's opacity (`Editor.windowBase`). +/// Drops a float's window grows out of, where the OS draws the drag's glass (`growDrops`): each the +/// point of the window it swells toward — shares of its width and height — its size a share of the +/// window's shorter side, and when in the growth it starts and how long it takes, shares of it. They +/// bud out of the growing glass at different times and sizes, ahead of it, and it takes them in as +/// it fills its window: a liquid growing rather than a rectangle scaling (the user). +const GrowSeed = struct { + at: [2]f32, + share: f32, + start: f32, + len: f32, +}; + +const grow_seeds = [_]GrowSeed{ + .{ .at = .{ 0.76, 0.24 }, .share = 0.2, .start = 0, .len = 0.55 }, + .{ .at = .{ 0.28, 0.74 }, .share = 0.16, .start = 0.08, .len = 0.5 }, + .{ .at = .{ 0.76, 0.76 }, .share = 0.23, .start = 0.16, .len = 0.55 }, + .{ .at = .{ 0.3, 0.3 }, .share = 0.13, .start = 0.04, .len = 0.45 }, +}; + +/// How far through the growth its own window starts coming in over the glass, which goes as it +/// does: the window's glass is not the drag's, and a cut between them showed. +const grow_window_from: f32 = 0.7; + +/// A float's growth as the OS's glass (`growFrame`): the growing glass `rect` (corners `radius`) and +/// the drops budding out of it toward the window `to` (`grow_seeds`), run together by the OS where +/// they come close, in the drag's overlay. Returns how far the window itself has come in over it, +/// all of the glass going by the same. +fn growDrops(land: Floats.Landing, rect: dvui.Rect.Physical, radius: f32, to: dvui.Rect.Physical) f32 { + const tl = std.math.clamp(Floats.landingFraction(land), 0, 1); + const window_in = smoothstep(std.math.clamp((tl - grow_window_from) / (1 - grow_window_from), 0, 1)); + const alpha = 1 - window_in; + if (alpha <= 0.001) return window_in; + const centre = rect.center(); + fizzy.core.native_glass.add(.{ .rect = rect, .radius = radius, .alpha = alpha }); + const side = @min(to.w, to.h); + for (grow_seeds) |seed| { + const u = std.math.clamp((tl - seed.start) / seed.len, 0, 1); + if (u <= 0) continue; + const r = side * seed.share * easeOutBack(u); + if (r < 1) continue; + const goal: dvui.Point.Physical = .{ .x = to.x + to.w * seed.at[0], .y = to.y + to.h * seed.at[1] }; + const e = smoothstep(u); + const c: dvui.Point.Physical = .{ .x = std.math.lerp(centre.x, goal.x, e), .y = std.math.lerp(centre.y, goal.y, e) }; + fizzy.core.native_glass.add(.{ .rect = .{ .x = c.x - r, .y = c.y - r, .w = 2 * r, .h = 2 * r }, .radius = r, .alpha = alpha }); + } + if (window_in < 1) dvui.refresh(null, @src(), null); + return window_in; +} + +/// Past 1 and back on the way in, as a drop swells and settles: by how much, at the app's motion +/// level (`core.motion`) — none where motion is minimal. +fn easeOutBack(u: f32) f32 { + const play = std.math.clamp((fizzy.core.motion.level() - 0.5) * 2, 0, 1); + const k: f32 = 1.4 * play; + const v = u - 1; + return 1 + (k + 1) * v * v * v + k * v * v; +} + +fn windowShade() f32 { + const op = std.math.clamp(fizzy.editor().window_opacity, 0, 1); + if (!viewports.liquidGlass()) return op; + const w = fizzy.core.glass_look.window(op); + return 1 - (1 - w.fill) * (1 - w.top_fill); +} + +/// The share of a float's growth out of the drop over which the drop's picture, growing with it, +/// frosts over (`growFrame`), before it clears as the window comes in. +const frost_share: f32 = 0.3; + /// The share of a float's growth out of the carried glass over which its picture takes over from /// the carried photograph (`growFrame`). const picture_share: f32 = 0.25; @@ -470,6 +973,8 @@ const GrowPhoto = struct { /// into. fn drawGrow(o: *Out, g: *Grow, rect: dvui.Rect.Physical, radius: f32, alpha: f32, fill: f32, picture: f32, photo: ?GrowPhoto) bool { const drawing = carryBegin(&g.carry, rect, rect, radius, alpha, fill) orelse return false; + // Its glass in the overlay (`growDrops`): none of its own. + if (alpha <= 0) viewports.carryShape(g.carry.viewport, null, 1); defer carryEnd(&g.carry, drawing); const prev_clip = dvui.clipGet(); defer dvui.clipSet(prev_clip); @@ -534,6 +1039,9 @@ fn windowFrame(state: *State, o: *Out) void { const at = viewports.inMain(o.viewport); f.viewport.?.main_delta = .{ .x = at.x - band.x, .y = at.y - band.y }; } + // Whether its window lies under the main window, for a drag over the main window to take no + // account of it there (`Floats.Viewport.under_main`). + f.viewport.?.under_main = viewports.underMain(o.viewport); // A view carried out of it: while it is its own ghost (`ViewDrag.settleGhost`), its window fades // to the ghost, material and all, and a held pointer over it reads the main window beneath — the // places it lies over can be seen and aimed at. Firm again, the pointer is the window's. @@ -547,6 +1055,7 @@ fn windowFrame(state: *State, o: *Out) void { const m = viewports.inMain(o.viewport); const band = viewports.frameOf(o.viewport); covers[cover_count] = .{ + .viewport = o.viewport, .in_main = .{ .x = m.x, .y = m.y, .w = m.w, .h = m.h }, .band = .{ .x = band.x, .y = band.y, .w = band.w, .h = band.h }, }; @@ -590,11 +1099,14 @@ fn windowFrame(state: *State, o: *Out) void { rt.rendering = true; const prev = dvui.renderTarget(rt); defer _ = dvui.renderTarget(prev); - // Zoomed or full screen there is no desktop behind it: opaque, eased there and back as the main - // window's base is — opaque through the whole of the way out of a fullscreen Space. Where it has - // no material it is opaque throughout. - Editor.easeWindowOpacity(&o.opacity, viewports.maximized(o.viewport), if (material) std.math.clamp(fizzy.editor().window_opacity, 0, 1) else 1); - backing(.{ .x = shown.x, .y = shown.y, .w = shown.w, .h = shown.h }, b, o.opacity); + // Zoomed or full screen there is no desktop behind it: opaque, eased there and back with the + // OS's transitions as the main window's base is. Where it has no material it is opaque + // throughout. + Editor.easeWindowOpacity(&o.opacity, viewports.coversDesktop(o.viewport), viewports.enteringSpace(o.viewport), viewports.spaceFullness(o.viewport), if (material) std.math.clamp(fizzy.editor().window_opacity, 0, 1) else 1); + // A window of Liquid Glass (macOS 26) stands on its glass, its colour under it on the one + // slider, as the main window does (`Editor.windowGlassLook`): no base of its own in the frame. + if (!viewports.windowGlass(o.viewport, Editor.windowGlassLook(o.opacity.value, .{ .w = shown.w / s, .h = shown.h / s }))) + backing(.{ .x = shown.x, .y = shown.y, .w = shown.w, .h = shown.h }, b, o.opacity.value); // The float and everything opened in it — its menus, tooltips, popovers, placed on its // window's screen (`core.screens`), each a subwindow of its own — in the order dvui stacks // them, every one whose middle is in the window's part of the frame. Taken from each, so @@ -604,8 +1116,9 @@ fn windowFrame(state: *State, o: *Out) void { // of the frame falls outside its target. const area: dvui.Rect.Physical = .{ .x = shown.x, .y = shown.y, .w = shown.w, .h = shown.h }; for (cw.subwindows.stack.items) |*sw| { - // A carried view is its carry window's (`carryFrame`). + // A carried view is its carry window's (`carryFrame`); a menu, its own (`menuFrame`). if (fizzy.core.screens.isCarried(sw.id)) continue; + if (fizzy.core.screens.isMenu(sw.id) and nativeMenus()) continue; const mine = area.contains(sw.rect_pixels.center()); if (!mine and !fizzy.core.screens.isEverywhere(sw.id)) continue; const cmds = sw.render_cmds; @@ -639,6 +1152,204 @@ fn backing(target: dvui.Rect.Physical, window: dvui.Rect.Physical, opacity: f32) bounds.fill(corners, .{ .color = .{ .color = Editor.windowBase(opacity) } }); } +/// Whether menus are windows of their own (`menuFrame`): where the OS can show them (macOS), with +/// floats as windows, as the setting says (`Settings.native_menus`, `FIZZY_NATIVE_MENUS` over it). +fn nativeMenus() bool { + if (comptime !viewports.menus) return false; + if (!viewports.available()) return false; + return envSwitch("FIZZY_NATIVE_MENUS") orelse fizzy.editor().app.settings.native_menus; +} + +/// Whether dialogs are windows of their own (`menuFrame`): as menus are, as the setting says +/// (`Settings.native_dialogs`, `FIZZY_NATIVE_DIALOGS` over it). +fn nativeDialogs() bool { + if (comptime !viewports.menus) return false; + if (!viewports.available()) return false; + return envSwitch("FIZZY_NATIVE_DIALOGS") orelse fizzy.editor().app.settings.native_dialogs; +} + +/// The display the main window is on, natural, in the main window's frame: the screen menus are kept +/// on where they are windows of their own (`core.screens.publishMenus`). +fn displayNatural() dvui.Rect.Natural { + const d = viewports.displayInMain(); + const s = dvui.windowNaturalScale(); + if (d.w <= 0 or d.h <= 0) return dvui.windowRect(); + return .{ .x = d.x / s, .y = d.y / s, .w = d.w / s, .h = d.h / s }; +} + +/// The least window opacity a menu's glass takes: a menu carries text, and over a clear window +/// there was too little behind it to read it by. +const menu_opacity_floor: f32 = 0.6; + +/// Each open menu (`core.screens.markMenu`) in a window of its own (`viewports.openMenu`): the OS's +/// material in it, its shadow round it and its corners the menu's, above every window — the menu +/// drawn in it as the app draws it, its drawing taken whole out of the frame, so the main window and +/// the float windows draw none of it. In the main window's frame, past its edge too — its window the +/// main window's child, which the OS moves with it; or in the band of a float that is out, its +/// window over where that part of the band lies. A menu is drawn where it opened in its band, and +/// the float's window moving leaves it behind, so it closes then, as an OS menu closes on a press +/// outside it. A menu that closed takes its window with it. +/// +/// Each open dialog (`core.screens.markDialog`) the same way, but in the main window's stacking +/// rather than above every window, its corners its own, and its window fading with it as it flies +/// shut. It stays where it is when a float it opened in moves; it has a place of its own. +fn menuFrame() void { + for (&menus) |*slot| if (slot.*) |*m| { + m.seen = false; + }; + defer for (&menus) |*slot| if (slot.*) |*m| if (!m.seen) { + releaseMenu(m); + slot.* = null; + }; + if (!nativeMenus() and !nativeDialogs()) return; + const cw = dvui.currentWindow(); + const main_px = dvui.windowRectPixels(); + const s = dvui.windowNaturalScale(); + const theme = dvui.themeGet(); + const radius = fizzy.core.dialogs.surfaceCorners().finalize(&theme).tl.radius(); + for (cw.subwindows.stack.items) |*sw| { + const as_dialog = fizzy.core.screens.dialog(sw.id); + if (as_dialog == null and !fizzy.core.screens.isMenu(sw.id)) continue; + const corner = if (as_dialog) |d| d.radius else radius; + const frame = sw.rect_pixels; + if (frame.w < 1 or frame.h < 1) continue; + // Where its window lies over the main window: where the menu is, or — in a float's band — + // where that part of the band lies on the screen. + var place = frame; + var float_at: ?dvui.Point.Physical = null; + var float_vp: ?*viewports.Viewport = null; + if (frame.x > main_px.x + main_px.w + 40000) { + const cover = for (covers[0..cover_count]) |cv| { + if (cv.band.contains(frame.center())) break cv; + } else continue; + place = frame.offsetPoint(.{ .x = cover.in_main.x - cover.band.x, .y = cover.in_main.y - cover.band.y }); + float_at = cover.band.topLeft(); + float_vp = cover.viewport; + } + // The window it moves with: the main window's, or for a dialog its float's; a menu in a + // float is left behind by its window and closes (below). Where it lies over that window is + // what places it again (`viewports.placeRiding`). + const ride: viewports.Ride = if (float_vp) |fv| (if (as_dialog != null) .{ .viewport = fv } else .none) else .main; + const key: dvui.Rect.Physical = if (float_at) |at| .{ .x = frame.x - at.x, .y = frame.y - at.y, .w = frame.w, .h = frame.h } else frame; + const m = menuOut(sw.id, place, corner, ride, as_dialog != null) orelse { + // No window for a dialog out in a float's band: over the main window instead, where it + // is drawn in the main window — out there nothing would show it, modal over all. + if (as_dialog != null and float_at != null) { + var rect = dvui.dataGet(null, sw.id, "_rect", dvui.Rect) orelse continue; + const main = dvui.windowRect(); + rect.x = main.x + (main.w - rect.w) / 2; + rect.y = main.y + (main.h - rect.h) / 2; + dvui.dataSet(null, sw.id, "_rect", rect); + } + continue; + }; + m.seen = true; + m.dialog = as_dialog != null; + if (m.dialog) { + if (m.float_at == null) m.float_at = float_at; + m.float_vp = float_vp; + } + if (as_dialog) |d| viewports.fade(m.viewport, d.alpha); + if (as_dialog == null) if (float_at) |at| { + const was = m.float_at orelse at; + m.float_at = at; + if (@abs(at.x - was.x) > 0.5 or @abs(at.y - was.y) > 0.5) menus_left_behind = true; + }; + viewports.mainOffset(m.viewport, .{ .x = frame.x - place.x, .y = frame.y - place.y }); + const placed = viewports.placeRiding(m.viewport, .{ .x = place.x, .y = place.y, .w = place.w, .h = place.h }, .{ .x = key.x, .y = key.y, .w = key.w, .h = key.h }); + const shown: dvui.Rect.Physical = .{ .x = frame.x + (placed.x - place.x), .y = frame.y + (placed.y - place.y), .w = placed.w, .h = placed.h }; + // Its material: Liquid Glass on the slider, no lighter than a menu's text needs; vibrancy + // before macOS 26, with the window's colour drawn under the menu. + const op = @max(std.math.clamp(fizzy.editor().window_opacity, 0, 1), menu_opacity_floor); + var look = Editor.windowGlassLook(op, .{ .w = shown.w / s, .h = shown.h / s }); + look.radius = corner; + m.glass = viewports.windowGlass(m.viewport, look); + const w: u32 = @intFromFloat(@max(1, @round(shown.w))); + const h: u32 = @intFromFloat(@max(1, @round(shown.h))); + if (m.target) |t| if (t.width != w or t.height != h) { + t.destroyLater(); + m.target = null; + }; + if (m.target == null) m.target = dvui.textureCreateTarget(.{ .width = w, .height = h, .interpolation = .nearest }) catch continue; + const target = m.target.?; + target.clear(); + var rt = cw.render_target; + rt.texture = target; + rt.offset = .{ .x = shown.x, .y = shown.y }; + rt.rendering = true; + const prev = dvui.renderTarget(rt); + defer _ = dvui.renderTarget(prev); + if (!m.glass) { + const prev_clip = dvui.clipGet(); + defer dvui.clipSet(prev_clip); + dvui.clipSet(shown); + const prev_alpha = cw.alpha; + dvui.alphaSet(1); + defer dvui.alphaSet(prev_alpha); + frame.fill(dvui.CornerRect.Physical.all(corner * s), .{ .color = .{ .color = Editor.windowBase(op) } }); + } + const cmds = sw.render_cmds; + const after = sw.render_cmds_after; + sw.render_cmds = .empty; + sw.render_cmds_after = .empty; + cw.renderCommands(cmds.items) catch |err| dvui.logError(@src(), err, "replaying a menu into its window", .{}); + cw.renderCommands(after.items) catch |err| dvui.logError(@src(), err, "replaying a menu into its window", .{}); + _ = dvui.renderTarget(prev); + viewports.present(m.viewport, target); + } +} + +/// A dialog opened in a float that is out goes with it, as a dialog belongs to the window it opened +/// in: its float's window moved — dragged, snapped — and the dialog is put where it lies over the +/// float as before, before it draws, so its window, the float's child, is where the OS carried it +/// (`viewports.placeRiding`). Its float gone, it comes back over the main window rather than be +/// left where no window shows it, modal over everything. +fn dialogsRide() void { + const s = dvui.windowNaturalScale(); + for (&menus) |*slot| if (slot.*) |*m| { + if (!m.dialog) continue; + const was = m.float_at orelse continue; + const vp = m.float_vp orelse continue; + var rect = dvui.dataGet(null, m.id, "_rect", dvui.Rect) orelse continue; + const cover = for (covers[0..cover_count]) |cv| { + if (cv.viewport == vp) break cv; + } else { + const main = dvui.windowRect(); + rect.x = main.x + (main.w - rect.w) / 2; + rect.y = main.y + (main.h - rect.h) / 2; + dvui.dataSet(null, m.id, "_rect", rect); + m.float_at = null; + m.float_vp = null; + continue; + }; + const at = cover.band.topLeft(); + if (at.x == was.x and at.y == was.y) continue; + rect.x += (at.x - was.x) / s; + rect.y += (at.y - was.y) / s; + dvui.dataSet(null, m.id, "_rect", rect); + m.float_at = at; + }; +} + +/// Menu `id`'s window (`menuFrame`), opened at `place` (physical, in the main window's frame) where +/// it has none yet — riding on `ride`; a dialog's (`dialog`) in that window's stacking. Null where +/// no more windows can be opened: the menu is drawn in its window then. +fn menuOut(id: dvui.Id, place: dvui.Rect.Physical, radius: f32, ride: viewports.Ride, dialog: bool) ?*MenuOut { + for (&menus) |*slot| if (slot.*) |*m| if (m.id == id) return m; + for (&menus) |*slot| if (slot.* == null) { + const vp = viewports.openMenu(.{ .x = place.x, .y = place.y, .w = place.w, .h = place.h }, radius, ride, dialog) orelse return null; + slot.* = .{ .id = id, .viewport = vp }; + return &slot.*.?; + }; + return null; +} + +fn releaseMenu(m: *MenuOut) void { + viewports.close(m.viewport); + if (m.target) |t| t.destroyLater(); + m.target = null; +} + /// The main window's base (`Editor.windowBase`) at the window's opacity where it has a material, /// opaque where it has none — the same colour as the main window's, side by side. The opacity as it /// is windowed (`Editor.window_opacity`), not the main window's eased one, which goes opaque while diff --git a/src/editor/dialogs/AboutFizzy.zig b/src/editor/dialogs/AboutFizzy.zig index 91b26be1..b829c8dc 100644 --- a/src/editor/dialogs/AboutFizzy.zig +++ b/src/editor/dialogs/AboutFizzy.zig @@ -52,7 +52,9 @@ pub fn request() void { .resizeable = false, .default = .cancel, .hide_footer = true, - .max_size = .{ .w = 440, .h = 400 }, + // Its width only: a height of 400 was less than the body grew to (the update line, the + // spinner's slot), and the dialog cut its own Close button in half. + .max_size = .width(440), .header_kind = .info, }); mutex.mutex.unlock(dvui.io); diff --git a/src/editor/dialogs/AppQuitUnsaved.zig b/src/editor/dialogs/AppQuitUnsaved.zig index a8f66fa5..22600052 100644 --- a/src/editor/dialogs/AppQuitUnsaved.zig +++ b/src/editor/dialogs/AppQuitUnsaved.zig @@ -23,7 +23,9 @@ pub fn request() void { .resizeable = false, .default = .cancel, .hide_footer = true, - .max_size = .{ .w = 520, .h = 280 }, + // Its width only, its height its body's: a capped height cut off the buttons, the last row of + // the body, whenever the text above them ran longer (the About dialog did). + .max_size = .width(520), .header_kind = .err, }); mutex.mutex.unlock(dvui.io); diff --git a/src/editor/dialogs/FileChangedOnDisk.zig b/src/editor/dialogs/FileChangedOnDisk.zig index 02b98384..7d534338 100644 --- a/src/editor/dialogs/FileChangedOnDisk.zig +++ b/src/editor/dialogs/FileChangedOnDisk.zig @@ -15,7 +15,9 @@ pub fn request(file_id: u64) void { .resizeable = false, .default = .cancel, .hide_footer = true, - .max_size = .{ .w = 520, .h = 280 }, + // Its width only, its height its body's: a capped height cut off the buttons, the last row of + // the body, whenever the text above them ran longer (the About dialog did). + .max_size = .width(520), .header_kind = .warning, }); dvui.dataSet(null, mutex.id, "_file_changed_id", file_id); diff --git a/src/editor/dialogs/FileTypeDefaults.zig b/src/editor/dialogs/FileTypeDefaults.zig index 1a75e5ee..de7c1ed2 100644 --- a/src/editor/dialogs/FileTypeDefaults.zig +++ b/src/editor/dialogs/FileTypeDefaults.zig @@ -111,7 +111,9 @@ pub fn request(id: []const u8, display_name: []const u8, new_rows: []Row) void { .resizeable = false, .default = .cancel, .hide_footer = true, - .max_size = .{ .w = 560, .h = 480 }, + // Its width only: the list caps its own height (`max_list_h`) and scrolls, and a capped + // window could only cut off the buttons under it. + .max_size = .width(560), .header_kind = .info, }); mutex.mutex.unlock(dvui.io); diff --git a/src/editor/dialogs/PluginUpdates.zig b/src/editor/dialogs/PluginUpdates.zig index 4191891f..9f57f91b 100644 --- a/src/editor/dialogs/PluginUpdates.zig +++ b/src/editor/dialogs/PluginUpdates.zig @@ -52,7 +52,9 @@ pub fn request() void { .resizeable = false, .default = .cancel, .hide_footer = true, - .max_size = .{ .w = 560, .h = 480 }, + // Its width only: the list caps its own height (`max_list_h`) and scrolls, and a capped + // window could only cut off the buttons under it. + .max_size = .width(560), .header_kind = .info, }); mutex.mutex.unlock(dvui.io); diff --git a/src/editor/dialogs/UnsavedClose.zig b/src/editor/dialogs/UnsavedClose.zig index 1f511437..866908d9 100644 --- a/src/editor/dialogs/UnsavedClose.zig +++ b/src/editor/dialogs/UnsavedClose.zig @@ -13,7 +13,9 @@ pub fn request(file_id: u64) void { .resizeable = false, .default = .cancel, .hide_footer = true, - .max_size = .{ .w = 520, .h = 280 }, + // Its width only, its height its body's: a capped height cut off the buttons, the last row of + // the body, whenever the text above them ran longer (the About dialog did). + .max_size = .width(520), .header_kind = .warning, }); dvui.dataSet(null, mutex.id, "_unsaved_file_id", file_id); diff --git a/src/editor/explorer/Explorer.zig b/src/editor/explorer/Explorer.zig index 17a261b4..5b4d668c 100644 --- a/src/editor/explorer/Explorer.zig +++ b/src/editor/explorer/Explorer.zig @@ -103,18 +103,25 @@ pub fn draw( // Left over from a surface that did scroll sideways; a pane that cannot scroll has // nowhere to be scrolled to. explorer.scroll_info.viewport.x = 0; - if (explorer.scroll_info.viewport.h > 0) { - explorer.scroll_info.virtual_size.h = explorer.scroll_info.viewport.h; - } + explorer.scroll_info.viewport.y = 0; } else { explorer.scroll_info.vertical = .auto; explorer.scroll_info.horizontal = .auto; } - var scroll = dvui.scrollArea(@src(), .{ .scroll_info = &explorer.scroll_info, .horizontal_bar = .auto_overlay, .vertical_bar = .auto_overlay }, .{ + // No bars at all around a surface that scrolls itself, rather than bars decided against a size + // that only says there is nothing to scroll: the scroll area decides on its bars from the size + // it was last given, a frame old — while the window shrank, every frame of it, last frame's + // height overflowed this frame's, and a bar showed beside the pane's own until it stopped. + const bar: dvui.ScrollInfo.ScrollBarMode = if (self_scrolling) .hide else .auto_overlay; + var scroll = dvui.scrollArea(@src(), .{ .scroll_info = &explorer.scroll_info, .horizontal_bar = bar, .vertical_bar = bar }, .{ .expand = .both, .background = false, }); + // This frame's viewport, measured just now, before the surface lays out in it: given last + // frame's, it was a frame too tall as the window shrank (its foot clipped) and a frame too short + // as it grew. + if (self_scrolling) explorer.scroll_info.virtual_size = .{ .w = explorer.scroll_info.viewport.w, .h = explorer.scroll_info.viewport.h }; // Through the layout, not `Host.selectedSurface`: the host remembers which // view was chosen here and hands it back whether or not this place still @@ -144,10 +151,6 @@ pub fn draw( scroll.deinit(); - if (self_scrolling) { - explorer.scroll_info.virtual_size = .{ .w = explorer.scroll_info.viewport.w, .h = explorer.scroll_info.viewport.h }; - } - // Two calls rather than one: `pane_vbox` has to deinit between the vertical and horizontal // hints, since the horizontal ones are drawn over the outer `vbox` instead. fizzy.core.draw.drawScrollEdgeShadows(pane_vbox.data().contentRectScale(), null, &explorer.scroll_info, .{}); diff --git a/src/editor/explorer/settings.zig b/src/editor/explorer/settings.zig index 2cce2293..5e0802e2 100644 --- a/src/editor/explorer/settings.zig +++ b/src/editor/explorer/settings.zig @@ -17,6 +17,7 @@ const fizzy = @import("../../fizzy.zig"); const icons = @import("icons"); const dvui = @import("dvui"); const core = @import("core"); +const restart = @import("app").restart; const Editor = fizzy.Editor; const KeybindSettings = @import("../KeybindSettings.zig"); const FileTypeSettings = @import("../FileTypeSettings.zig"); @@ -204,6 +205,52 @@ pub const groups = [_]Group{ }, }, }, + .{ + .title = "Windows", + .icon = icons.tvg.lucide.@"app-window", + .items = &.{ + .{ + .label = "Floats in their own windows", + .key = "float_windows", + .description = "A view floated out of its place can leave the main window into a " ++ + "window of its own, and a view carried past the main window opens one there. " ++ + "Takes effect after a restart.", + .keywords = "float popout pop out window detach tear off native restart", + .draw = drawFloatWindows, + .inline_control = true, + }, + .{ + .label = "Native glass", + .key = "native_glass", + .description = "A dragged view and the drop zones are the system's own glass " ++ + "(Liquid Glass on macOS 26) rather than fizzy's, where floats have windows " ++ + "of their own.", + .keywords = "glass liquid native drag drop zones blur material", + .draw = drawNativeGlass, + .inline_control = true, + }, + .{ + .label = "Native menus", + .key = "native_menus", + .description = "Menus open in windows of their own — the system's material, " ++ + "shadow and corners, over every window and past the window's edge — rather " ++ + "than drawn inside the window, where floats have windows of their own.", + .keywords = "menu context native window popup material", + .draw = drawNativeMenus, + .inline_control = true, + }, + .{ + .label = "Native dialogs", + .key = "native_dialogs", + .description = "Dialogs open in windows of their own — the system's material, " ++ + "shadow and corners, moving with the main window — rather than drawn inside " ++ + "it, where floats have windows of their own.", + .keywords = "dialog modal alert native window material", + .draw = drawNativeDialogs, + .inline_control = true, + }, + }, + }, .{ .title = "Input", .icon = icons.tvg.lucide.mouse, @@ -662,6 +709,46 @@ fn drawPluginUpdateMode() void { } } +// ---- Windows ---------------------------------------------------------------------------- + +fn drawFloatWindows() void { + const editor = fizzy.editor(); + var row = dvui.box(@src(), .{ .dir = .horizontal }, .{ .gravity_y = 0.5 }); + defer row.deinit(); + drawToggle(&editor.app.settings.float_windows); + // Taken at launch: changed, it waits for a restart, offered here and in the info bar. + if (editor.restartPending()) { + if (dvui.button(@src(), "Restart", .{}, .{ .gravity_y = 0.5, .margin = .{ .x = 6 }, .padding = .{ .x = 8, .w = 8, .y = 2, .h = 2 } })) { + restart.request(); + } + } +} + +fn drawNativeGlass() void { + drawToggle(&fizzy.editor().app.settings.native_glass); +} + +fn drawNativeMenus() void { + drawToggle(&fizzy.editor().app.settings.native_menus); +} + +fn drawNativeDialogs() void { + drawToggle(&fizzy.editor().app.settings.native_dialogs); +} + +/// A setting's checkbox, saved and applied as it changes. +fn drawToggle(value: *bool) void { + if (dvui.checkbox(@src(), value, null, .{ + .expand = .none, + .gravity_y = 0.5, + .margin = .{ .x = 4, .w = 4 }, + .padding = .{ .x = 2, .w = 4, .y = 2, .h = 2 }, + })) { + fizzy.editor().markSettingsDirty(); + dvui.refresh(null, @src(), null); + } +} + // ---- Debugging -------------------------------------------------------------------------- fn drawGlassShader() void { diff --git a/src/editor/layout.zig b/src/editor/layout.zig index ca987d5c..fd864def 100644 --- a/src/editor/layout.zig +++ b/src/editor/layout.zig @@ -136,7 +136,8 @@ const edge_gutter: f32 = 10; fn placeCard(editor: *fizzy.Editor, extra: dvui.Options) dvui.Options { var fill = dvui.themeGet().color(.window, .fill); - if (editor.app.host.appliesNativeWindowOpacity() and !editor.app.host.isMaximized()) { + // Opaque while the window covers the desktop: `contentOpacity` eases there with the window. + if (editor.app.host.appliesNativeWindowOpacity()) { fill = fill.opacity(editor.app.host.contentOpacity()); } var opts = extra; diff --git a/tests/integration.zig b/tests/integration.zig index 1935bd06..5c9b8360 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -4230,7 +4230,7 @@ test "drop: a place with room for the wheel keeps it" { // Small but not skinny: a strip would be smaller still. try std.testing.expectEqual(@as(f32, 0), DZ.wheel(.{ .x = 0, .y = 0, .w = 200, .h = 200 }, 1, true).strip); // Narrow, but a wheel there is all but as big as a strip would be. - const near = DZ.wheel(.{ .x = 0, .y = 0, .w = 310, .h = 1000 }, 1, true); + const near = DZ.wheel(.{ .x = 0, .y = 0, .w = 340, .h = 1000 }, 1, true); try std.testing.expectEqual(@as(f32, 0), near.strip); try std.testing.expect(near.shaped(1, .vertical).unit > near.unit); } @@ -4336,9 +4336,10 @@ test "drop: a place's drop sits in the part of it no window lies over, where it } test "drop: settled, as a wheel or a strip, no two bubbles are near enough to run together" { - // Two bubbles closer than half the merge run together (`LiquidField`'s smooth minimum): at - // rest each zone is a bubble of its own, whichever the shape and the way it runs, with the - // trash and without. On the way from one shape to the other they may run together. + // Two bubbles closer than half the merge run together (`LiquidField`'s smooth minimum, and the + // OS's glass container alike): at rest each zone is a bubble of its own, whichever the shape + // and the way it runs, with the trash and without. On the way from one shape to the other, and + // swollen while the carried view is aimed at one, they may run together. const room = DZ.wheel(.{ .x = 0, .y = 0, .w = 1e5, .h = 1e5 }, 1, true); try std.testing.expectEqual(@as(f32, 1), room.unit); for ([_]bool{ true, false }) |remove| for ([_]dvui.enums.Direction{ .horizontal, .vertical }) |dir| for ([_]f32{ 0, 1 }) |strip| { @@ -4356,6 +4357,33 @@ test "drop: settled, as a wheel or a strip, no two bubbles are near enough to ru }; } +test "drop: aimed at, a bubble swells into one beside it, overlapping none, and the trash joins none" { + // The bubble the carried view is aimed at grows by `join_swell` — the trash keeps its size: + // swollen, it runs into at least one bubble beside it and overlaps none, and the trash stays a + // drop of its own. + const room = DZ.wheel(.{ .x = 0, .y = 0, .w = 1e5, .h = 1e5 }, 1, true); + for ([_]dvui.enums.Direction{ .horizontal, .vertical }) |dir| for ([_]f32{ 0, 1 }) |strip| { + const w = room.shaped(strip, dir); + for (DZ.all, 0..) |aimed, ai| { + if (aimed == .remove) continue; + var joins = false; + for (DZ.all, 0..) |a, i| for (DZ.all[i + 1 ..], i + 1..) |b, j| { + const p = w.bubble(a); + const q = w.bubble(b); + const pr = p.r * (if (i == ai) 1 + DZ.join_swell else 1); + const qr = q.r * (if (j == ai) 1 + DZ.join_swell else 1); + const d = @sqrt((p.c.x - q.c.x) * (p.c.x - q.c.x) + (p.c.y - q.c.y) * (p.c.y - q.c.y)); + const gap = d - pr - qr; + try std.testing.expect(gap > 0); + if (a == .remove or b == .remove) { + try std.testing.expect(gap > DZ.merge / 2); + } else if ((i == ai or j == ai) and gap < DZ.merge / 2) joins = true; + }; + try std.testing.expect(joins); + } + }; +} + test "liquid blob: far apart it is its discs; close together it bridges them" { const LB = fizzy.core.liquid_blob; const two = [_]LB.Disc{ .{ .c = .{ .x = 0, .y = 0 }, .r = 10 }, .{ .c = .{ .x = 100, .y = 0 }, .r = 10 } }; @@ -5143,6 +5171,31 @@ test "float: closing it sends its view home" { try std.testing.expect(editor.app.layout.assignment("Float 1") == null); } +test "float: closing it loses no view its keywords would show nowhere" { + var case = try ManyPanelCase.init(); + defer case.deinit(); + const editor = case.ctx.editor; + // A view whose keywords no place answers — a plugin's tools, merged into a float that came out + // of the panel its keywords fill. + try editor.app.host.registerSurface(.{ .id = "test.tools", .title = "Tools", .keywords = &.{"tools"}, .draw = ManyPanelFrame.draw }); + try case.place("Panel", "Panel", .swap); + try editor.app.layout.assign(editor.app.gpa, "Float 1", &.{ "test.output", "test.tools" }); + try dvui.testing.settle(ManyPanelFrame.frame); + try std.testing.expectEqual(@as(usize, 2), case.shows("Float 1").len); + + var layout = fizzy.Editor.Layout.init(&editor.app.host, &editor.app.layout, editor.app.gpa, dvui.currentWindow().arena()); + layout.closeFloat("Float 1"); + 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); + // Both back in the main window: Output where its keywords show it, Tools beside it in the + // place the float came out of, rather than let go to be shown nowhere. + const panel = case.shows("Panel"); + try std.testing.expect(holds(panel, "test.output")); + try std.testing.expect(holds(panel, "test.tools")); +} + test "float: the saved layout brings it back, and Reset Layout takes it away" { var case = try ManyPanelCase.init(); defer case.deinit();