From 5ae6688f256cd323f1c36fa1681e871746b23f25 Mon Sep 17 00:00:00 2001 From: foxnne Date: Mon, 5 Oct 2026 08:55:36 -0500 Subject: [PATCH 01/16] popout: float windows go back over the main window from SDL's callbacks too A float restored at launch opened behind the main window and stayed there. Float windows are put back over the main window whenever it comes forward: focused (the app activating, at launch too), pressed, shown or restored (`order_pending`). That check sat only in the polled event loop (`addAllEvents`). Fizzy runs on SDL's callbacks on macOS, where events arrive through `appEvent`, so it never ran there, and a float window was ordered over the main window only on its first show. At launch that often comes before the main window is visible, and the main window then showed over it. `noteMainForward` is called from both event paths. In a sandbox launched with a saved float, the main window's `SDL_EVENT_WINDOW_SHOWN` now reaches it (it was dropped before), and the float stays in front. Co-Authored-By: Claude Opus 5.5 --- src/backend/native/SDLBackend.zig | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/backend/native/SDLBackend.zig b/src/backend/native/SDLBackend.zig index 315658c5..cf2ab798 100644 --- a/src/backend/native/SDLBackend.zig +++ b/src/backend/native/SDLBackend.zig @@ -733,15 +733,24 @@ 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. +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, + else => {}, + } +} + pub fn addAllEvents(self: *SDLBackend, win: *dvui.Window) !void { var event: c.SDL_Event = undefined; while (c.SDL_PollEvent(&event)) { const target_sdl_window = getWindowFromEvent(&event); - // The main window come forward, maybe over a viewport's window (`order_pending`). - if (target_sdl_window == self.window) 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, - else => {}, - }; + self.noteMainForward(target_sdl_window, event.type); if (target_sdl_window) |target_win| { _ = try self.addEventWinRecursive(&event, win, target_win); } else { @@ -2673,6 +2682,7 @@ fn appEvent(_: ?*anyopaque, event: ?*c.SDL_Event) callconv(.c) c.SDL_AppResult { const e = &event.?.*; const target_sdl_window = getWindowFromEvent(e); + appState.back.noteMainForward(target_sdl_window, e.type); if (target_sdl_window) |target_win| { _ = appState.back.addEventWinRecursive(e, &appState.win, target_win) catch |err| { log.err("dvui.Window.addEvent failed: {any}", .{err}); From a55f9cb8d3387e6c9c8f996d65d446cf6869677b Mon Sep 17 00:00:00 2001 From: foxnne Date: Mon, 5 Oct 2026 08:55:36 -0500 Subject: [PATCH 02/16] popout: a view dragged out of the place picker floats where it is let go, as a corner-button drag does A view carried out of the place picker couldn't be dropped outside the window: `floatAway` (and `floatOut` under it) took only a drag with a source place, and the picker's is a loose drag. A loose drag of a view now floats the view where it is let go over no window. It comes out of whichever place holds it now (`holderOf`). That place is its home when the float closes, and it shuts if the view leaves it empty. A view held nowhere goes home by its keywords. Documents stay out until workspaces (`docs/WORKSPACES_PLAN.md`). Each picker card publishes a `picker:` demo anchor. In a sandbox, a tape opens the Panel's picker and drags Output past the main window's edge: the carry window follows it out there, and a float window holding Output opens where it is let go. Co-Authored-By: Claude Opus 5.5 --- app/layout/Picker.zig | 2 ++ app/layout/ViewDrag.zig | 31 ++++++++++++++++++++++++------- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/app/layout/Picker.zig b/app/layout/Picker.zig index 0d3dd953..efa7b704 100644 --- a/app/layout/Picker.zig +++ b/app/layout/Picker.zig @@ -411,6 +411,8 @@ fn card(f: *Layout, s: *const sdk.Surface, on: bool, id_extra: usize) Hit { }); defer bw.deinit(); const id = bw.data().id; + // For demo tapes (`docs/AUTOMATION.md`): the card, to lift a view out of the picker by. + core.anchor.mark(bw.data(), "picker:{s}", .{s.id}); // The tile's rect is remembered from last frame: the press that starts a // drag is read here, before the tile is laid out. diff --git a/app/layout/ViewDrag.zig b/app/layout/ViewDrag.zig index 4f88c2bf..79c745bf 100644 --- a/app/layout/ViewDrag.zig +++ b/app/layout/ViewDrag.zig @@ -1804,12 +1804,15 @@ fn overNoWindow(mouse: dvui.Point.Physical) bool { /// its own. Any view that may float — a view alone in a float's window too: its window closes, and /// one opens where it was let go. fn floatAway(l: *Layout, source: []const u8, mouse: dvui.Point.Physical) void { - if (std.mem.eql(u8, source, loose_source)) return; - const moved = ownId(l.arena, movedFrom(l, source) orelse return) orelse return; + // Out of the picker too (a loose drag): the view it carries, wherever it is now. + const from_picker = std.mem.eql(u8, source, loose_source); + const carried = if (from_picker) l.state.view_drag.moved_id else movedFrom(l, source) orelse return; + const moved = ownId(l.arena, carried) orelse return; const s = l.host.surfaceById(moved) orelse return; if (!float_rules.canFloat(.{ .slotted = l.slotted(s), .alone_in_float = false })) return; + const left = if (from_picker) holderOf(l, moved) else source; floatOut(l, source, moved, mouse); - shutIfEmptied(l, source); + if (left) |name| shutIfEmptied(l, name); l.state.markDirty(); dvui.refresh(null, @src(), null); } @@ -1833,8 +1836,11 @@ fn floatOut(l: *Layout, source: []const u8, moved: []const u8, at: ?dvui.Point.P var buf: [32]u8 = undefined; const name = state.internName(l.gpa, float_rules.nextName(&buf, state.floats.names(l.arena))); const window = Floats.toRules(dvui.windowRect()); - const src = placeBounds(state, source) orelse dvui.windowRectPixels(); - const out_of: ?usize = if (state.floats.rootOf(source)) |root| state.floats.find(root) else null; + // Carried out of the picker (a loose drag), it floats out of whichever place holds it now — + // its home when the float closes — or, held nowhere, out of none: its keywords take it home. + const holder: ?[]const u8 = if (std.mem.eql(u8, source, loose_source)) holderOf(l, moved) else source; + const src = (if (holder) |h| placeBounds(state, h) else null) orelse dvui.windowRectPixels(); + const out_of: ?usize = if (holder) |h| (if (state.floats.rootOf(h)) |root| state.floats.find(root) else null) else null; var rect = if (out_of) |i| float_rules.nudged(Floats.toRules(state.floats.items.items[i].rect), window) else @@ -1847,7 +1853,7 @@ fn floatOut(l: *Layout, source: []const u8, moved: []const u8, at: ?dvui.Point.P } // Out of a float, home is still where that float came from: the place it opened over is a // float's, and goes with it. - const home = if (out_of) |i| state.floats.items.items[i].home else state.internName(l.gpa, source); + const home = if (out_of) |i| state.floats.items.items[i].home else if (holder) |h| state.internName(l.gpa, h) else ""; // The glass it was carried in, when a drag let go of it here; the place itself, when nothing // was carried (the picker, a test). const d = &state.view_drag; @@ -1873,7 +1879,18 @@ fn floatOut(l: *Layout, source: []const u8, moved: []const u8, at: ?dvui.Point.P }; state.assign(l.gpa, name, &.{moved}) catch {}; selectNamed(l, name, moved); - takeOut(l, source, moved, null); + takeOut(l, holder orelse source, moved, null); +} + +/// The declared place showing `id` now, if one is: where a view carried out of the picker comes +/// from (`floatOut`). +fn holderOf(l: *Layout, id: []const u8) ?[]const u8 { + for (l.state.regions.items) |r| { + for (holding(l, r.name)) |held| { + if (std.mem.eql(u8, held, id)) return r.name; + } + } + return null; } /// Send the views of a closing float's places (`leaves`) back to `home`, the place the float came From 040d4361b47b87c39ad23f9f707150f993e51a59 Mon Sep 17 00:00:00 2001 From: foxnne Date: Mon, 5 Oct 2026 08:55:36 -0500 Subject: [PATCH 03/16] menus: a floating menu stays 8 points in from the edges of its screen The place picker opened from a corner button at the window's right edge sat flush against that edge. Every floating menu was placed (`dvui.placeOnScreen`) against the bare rect of the screen it opens on: the main window's, or a float's own window's (`core.screens`). `FloatingMenu` now insets that screen by `screen_margin` (8 points), for menus pushed in from an edge and for centred ones. In a frame dump the Panel's picker sits 8 points in from the window's right edge. Co-Authored-By: Claude Opus 5.5 --- core/widgets/menu/FloatingMenu.zig | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/core/widgets/menu/FloatingMenu.zig b/core/widgets/menu/FloatingMenu.zig index ca748eee..a26a3909 100644 --- a/core/widgets/menu/FloatingMenu.zig +++ b/core/widgets/menu/FloatingMenu.zig @@ -18,6 +18,11 @@ const dialogs = @import("../../dialogs.zig"); const BlurBackdrop = @import("../BlurBackdrop.zig"); const screens = @import("../../screens.zig"); +/// Natural units a menu stays in from the edges of the screen it opens on — the main window's, or +/// a float's own window's (`core.screens`): pushed in from an edge it sat flush against it, the +/// place picker opened from a corner button at the window's right edge among them. +const screen_margin: f32 = 8; + const Event = dvui.Event; const Options = dvui.Options; const Rect = dvui.Rect; @@ -158,12 +163,12 @@ 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), fr, avoid, .cast(self.data().rect))); + self.data().rect = .cast(dvui.placeOnScreen(screens.screenFor(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), .{}, .none, .cast(self.data().rect))); + self.wd.rect = .cast(dvui.placeOnScreen(screens.screenFor(centering).insetAll(screen_margin), .{}, .none, .cast(self.data().rect))); } if (dvui.snapToPixels()) { From c8bf16f7871d705d6bc0e1fe51db1582054c6f2c Mon Sep 17 00:00:00 2001 From: foxnne Date: Mon, 5 Oct 2026 09:45:04 -0500 Subject: [PATCH 04/16] settings: a row back in view keeps its height for its first frame, so the pane no longer rubber-bands Scrolling up through the settings (Appearance among them), the pane snapped back every few ticks. Rows out of view are skipped and keep the height they were last drawn at. A row coming back into view is drawn in full, and on that first frame it measured short: what its widgets knew of their own sizes was forgotten while they weren't drawn. The next frame it was its own height again. The content's height dipped by 40 to 140 px for one frame and came back, and everything under the row moved with it. A row back in view after being skipped now keeps its last height for that frame, at the same width. Scrolled up 3000 px in 30 px ticks in a sandbox, with every section open, the pane's content height is now the same on every frame (4772.4 px, 96 of 96). Before it was 4634.9 to 4772.4, changing every few ticks. Co-Authored-By: Claude Opus 5.5 --- src/editor/SettingsTree.zig | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/editor/SettingsTree.zig b/src/editor/SettingsTree.zig index cce7d3c8..f4135019 100644 --- a/src/editor/SettingsTree.zig +++ b/src/editor/SettingsTree.zig @@ -436,9 +436,21 @@ fn drawLeaves(branch: *const Branch, query: *const fuzzy.Query) !void { if (drawn_w) |w| if (out_of_view and @abs(w - row.data().rect.w) < 0.5) { if (dvui.minSizeGet(row.data().id)) |ms| { row.data().minSizeMax(ms); + dvui.dataSet(null, row_id, "_skipped", true); continue; } }; + // Back in view after being skipped: drawn in full, it measures short on this first + // frame — what its widgets knew of their own sizes went while they were not drawn — + // and the next frame it is its own height again. Everything under it moved by the + // difference and back: the pane rubber-banded as it scrolled, snapping back every few + // ticks. It keeps the height it had for that frame, at the width it had then. + if (dvui.dataGet(null, row_id, "_skipped", bool) orelse false) { + dvui.dataSet(null, row_id, "_skipped", false); + if (drawn_w) |w| if (@abs(w - row.data().rect.w) < 0.5) { + if (dvui.minSizeGet(row.data().id)) |ms| row.data().minSizeMax(ms); + }; + } } dvui.dataSet(null, row_id, "_drawn_w", row.data().rect.w); From 08bb454678104e5705a879d33551033a2d2238a9 Mon Sep 17 00:00:00 2001 From: foxnne Date: Mon, 5 Oct 2026 09:50:49 -0500 Subject: [PATCH 05/16] popout: a carried view is a window of its own for the whole drag With floats as windows, a carried view was drawn in whichever window it was over. Dragged off a float's window, it was cut off at that window's edge until it crossed, then drawn in the main window, behind the float. Where the carry window took it over (past the main window, under a ghost), its glass turned from glass refracting the app under it into the window's material, and back, at every crossing. Showing the carry window only where the bubble wasn't wholly in one unobstructed window still popped near edges, and the change of look stayed jarring. On macOS, with float windows, the carry window now shows the carried view for the whole drag, wherever it is (`Popout.carryFrame`): - over the main window, at its place in the main window's frame; - over a float's window, at the screen position of that window's band, its picture still read from the band (`Cover`, the window it overlaps most, since near an edge its middle is past it while the pointer is still on the window). The copy left in the app's own windows under it casts no shadow of its own beside the OS's (`core.dialogs.carried_in_window`). Polled in a sandbox, the carry window follows both drags from lift to drop with no gap: Explorer across the main window and into the float it opens, and a view out of a float's window along its edge and on over the main window. Co-Authored-By: Claude Opus 5.5 --- core/dialogs.zig | 9 +++- src/editor/Popout.zig | 98 ++++++++++++++++++++++++++++++------------- 2 files changed, 76 insertions(+), 31 deletions(-) diff --git a/core/dialogs.zig b/core/dialogs.zig index 57776b90..3028f7bf 100644 --- a/core/dialogs.zig +++ b/core/dialogs.zig @@ -174,9 +174,16 @@ pub fn carriedGlass(id: dvui.Id, r: dvui.Rect.Physical, scale: f32) void { if (!frostPane(id, r, corners, scale)) { r.fill(corners.scale(scale, dvui.CornerRect.Physical), .{ .color = .{ .color = dialogFill() }, .fade = 1 }); } - glassShadow(r, corners, scale, surfaceShadow(), 1); + // Shown in a window of its own, the OS's shadow is round it already. + if (!carried_in_window) glassShadow(r, corners, scale, surfaceShadow(), 1); } +/// Whether what is carried is shown in a window of its own this frame, over every window of the +/// app's (fizzy's carry window, `Popout.carryFrame`): the copy drawn under it in the app's own +/// windows casts no shadow of its own beside the OS's. Set by the app each frame; this module's +/// copy in the app, which is the one drawing the drag. +pub var carried_in_window: bool = false; + /// `carriedGlass` for glass whose shapes run together (`LiquidField`) — the dragged view as a /// drop — frosted at the dialog style. False when the style has the blur off, or the glass program /// is not there to draw it: carry a card instead. diff --git a/src/editor/Popout.zig b/src/editor/Popout.zig index f65f2823..438fa4e8 100644 --- a/src/editor/Popout.zig +++ b/src/editor/Popout.zig @@ -34,10 +34,20 @@ var outs: [max_out]?Out = @splat(null); /// The window a carried view is shown in past every window of the app's, for as long as a view /// drag goes on (`carryFrame`). var carry: ?Carry = null; -/// Where a float's window gone to its ghost lies over the main window's frame this frame -/// (`windowFrame`): the drag drawn there in the main window shows only faintly through it, so the -/// carry window shows it over it instead (`carryFrame`). -var ghost_over: ?viewports.Rect = null; +/// Each float's window this frame (`windowFrame`): where it lies over the main window's frame, and +/// the band of the frame it shows. A view carried over the main window is drawn under every one of +/// them, and one carried over a float's window is drawn in its band, cut at its edge: where it is +/// not wholly inside the window drawing it, unobstructed, the carry window shows it, over them all +/// (`carryFrame`). +var covers: [max_out]Cover = undefined; +var cover_count: usize = 0; + +const Cover = struct { + /// 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. + band: dvui.Rect.Physical, +}; /// The carry window of a drag that has just ended, kept one frame for a float the drop made to grow /// out of (`growFrame`) — already where the drop is, in its shape — and let go after it otherwise. @@ -238,7 +248,7 @@ fn release(o: *Out) void { /// put where it was drawn, and handed the picture. pub fn endFrame(state: *State) void { if (!enabled()) return; - ghost_over = null; + cover_count = 0; for (&outs) |*slot| { if (slot.*) |*o| windowFrame(state, o); } @@ -252,18 +262,18 @@ pub fn endFrame(state: *State) void { } } -/// A view carried past every window of the app's — out over the desktop, where letting go opens a -/// float window (`ViewDrag.apply`) — is shown in a round window of its own there -/// (`viewports.openCarry`): a window in the shape of what it is carried as, its glass on the main -/// window's base over the OS's material and the OS's shadow round it (`viewports.carryShape`), so -/// out there it is a window as the float it would open is. The drag's drawing, in the main window's -/// frame past its edge, is copied into it. Once any of it is past the main window it shows all of -/// it, over the main window too — the main window can show only what lies inside it — and over a -/// float's window gone to its ghost, which the main window's drawing shows only faintly through; -/// while it is wholly inside the main window, or over a float's window (in its band, which that -/// window shows), the carry window shows nothing; it goes when the drag does. A drop's tail stays -/// in the drop's own shape out there. +/// A carried view is shown in a round window of its own (`viewports.openCarry`) for the whole of +/// the drag, wherever it is: a window in the shape of what it is carried as, its glass on the main +/// window's base over the OS's material and the OS's shadow round it (`viewports.carryShape`) — +/// a window as the float it would open is. The drag's drawing — in the main window's frame, past +/// its edge too, or in the band of the float's window it is over — is copied into it. Floats being +/// windows, it was drawn in whichever window it was over until it crossed an edge: cut off at a +/// float window's edge, under a float's window from the main one, and its glass turned from +/// glass reading the app under it into the window's material each time it crossed into the carry +/// window and back. Now it is the window throughout. The copy left in the app's windows, under it, +/// casts no shadow (`core.dialogs.carried_in_window`). It goes when the drag does. fn carryFrame(state: *State) void { + fizzy.core.dialogs.carried_in_window = false; if (!viewports.carries) return; const d = &state.view_drag; if (!d.active()) { @@ -280,11 +290,28 @@ fn carryFrame(state: *State) void { // What it is carried as: a drop's head, or the card or tab it is carried as. const shape = d.shape_rect; const main_px = dvui.windowRectPixels(); - const inside = shape.x >= main_px.x and shape.y >= main_px.y and shape.x + shape.w <= main_px.x + main_px.w and shape.y + shape.h <= main_px.y + main_px.h; - // Over a float's window it is in that window's band, far past the main window (`Floats.Viewport`). - const banded = shape.x > main_px.x + main_px.w + 40000; - const under_ghost = if (ghost_over) |g| shape.x < g.x + g.w and g.x < shape.x + shape.w and shape.y < g.y + g.h and g.y < shape.y + shape.h else false; - const want = shape.w > 0 and shape.h > 0 and (!inside or under_ghost) and !banded; + // 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 + // goes where that part of the band lies on the screen, its picture still read from the band. + var place = shape; + const want = if (shape.w <= 0 or shape.h <= 0) false else if (shape.x > main_px.x + main_px.w + 40000) banded: { + // The window whose band it lies on — the one it overlaps most: near an edge its middle is + // already past it while the pointer is still on the window. + var best: ?Cover = null; + var best_area: f32 = 0; + for (covers[0..cover_count]) |cv| { + const o = cv.band.intersect(shape); + const area = o.w * o.h; + if (area > best_area) { + best = cv; + best_area = area; + } + } + const cover = best orelse break :banded false; + place = shape.offsetPoint(.{ .x = cover.in_main.x - cover.band.x, .y = cover.in_main.y - cover.band.y }); + break :banded true; + } else true; + fizzy.core.dialogs.carried_in_window = want; if (!want) { if (carry) |*c| if (c.target) |t| { t.clear(); @@ -294,11 +321,11 @@ fn carryFrame(state: *State) void { return; } if (carry == null) { - const vp = viewports.openCarry(.{ .x = shape.x, .y = shape.y, .w = shape.w, .h = shape.h }) orelse return; + const vp = viewports.openCarry(.{ .x = place.x, .y = place.y, .w = place.w, .h = place.h }) orelse return; carry = .{ .viewport = vp }; } const c = &carry.?; - const drawing = carryBegin(c, shape, d.shape_radius, 1) orelse return; + const drawing = carryBegin(c, place, shape, d.shape_radius, 1) orelse return; defer carryEnd(c, drawing); // What the drag draws across every screen (`core.screens.markEverywhere`), copied in, left for // the main window's replay: what of it lies past the window falls outside. @@ -316,14 +343,16 @@ const CarryDrawing = struct { shown: dvui.Rect.Physical, }; -/// Put `c`'s window where it shows `shape` of the main window's frame, in its shape (`radius`, -/// physical), `alpha` opaque, and start its picture: the main window's base under it, over the +/// Put `c`'s window where it shows `place` of the main window's frame, in its shape (`radius`, +/// physical), `alpha` opaque, and start its picture, read from `shape` of the frame (the same rect, +/// or the band of a float's window it lies over): the main window's base under it, over the /// window's material, for glass to read — glass over the window's clear pixels draws nothing — as a /// float's window stands on it. Drawn into it until `carryEnd`; null with nothing to draw into. -fn carryBegin(c: *Carry, shape: dvui.Rect.Physical, radius: f32, alpha: f32) ?CarryDrawing { +fn carryBegin(c: *Carry, place: dvui.Rect.Physical, shape: dvui.Rect.Physical, radius: f32, alpha: f32) ?CarryDrawing { const cw = dvui.currentWindow(); - const placed = viewports.placeMain(c.viewport, .{ .x = shape.x, .y = shape.y, .w = shape.w, .h = shape.h }); - const shown: dvui.Rect.Physical = .{ .x = placed.x, .y = placed.y, .w = placed.w, .h = placed.h }; + const placed = viewports.placeMain(c.viewport, .{ .x = place.x, .y = place.y, .w = place.w, .h = place.h }); + // The part of the frame it shows: where it was put, read from where its picture is drawn. + const shown: dvui.Rect.Physical = .{ .x = shape.x + (placed.x - place.x), .y = shape.y + (placed.y - place.y), .w = placed.w, .h = placed.h }; viewports.carryShape(c.viewport, radius, alpha); const w: u32 = @intFromFloat(@max(1, @round(shown.w))); const h: u32 = @intFromFloat(@max(1, @round(shown.h))); @@ -395,7 +424,7 @@ fn growFrame(o: *Out, f: *const Floats.Float) bool { .h = @max(1, std.math.lerp(from.h, to.h, t)), }; g.radius = std.math.lerp(land.radius, viewports.windowRadius() * dvui.windowNaturalScale(), std.math.clamp(t, 0, 1)); - const drawing = carryBegin(&g.carry, rect, g.radius, 1) orelse return false; + const drawing = carryBegin(&g.carry, rect, rect, g.radius, 1) orelse return false; defer carryEnd(&g.carry, drawing); if (land.photo) |tex| { const prev_clip = dvui.clipGet(); @@ -460,7 +489,16 @@ fn windowFrame(state: *State, o: *Out) void { const carried_out = d.active() and !d.loose() and if (state.floatRoot(d.name)) |root| std.mem.eql(u8, root, f.name) else false; const see_through = carried_out and !d.ghost_firm; viewports.seeThrough(o.viewport, see_through); - if (see_through) ghost_over = viewports.inMain(o.viewport); + // Where the window lies, for a view carried under or out of it (`carryFrame`). + if (cover_count < covers.len) { + const m = viewports.inMain(o.viewport); + const band = viewports.frameOf(o.viewport); + covers[cover_count] = .{ + .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 }, + }; + cover_count += 1; + } // Nothing of it while it grows out of the carried glass (`growFrame`). const growing = growFrame(o, f); viewports.fade(o.viewport, if (growing) 0 else Floats.ghostLook(f.aside.at()).alpha); From 80257905a3c8c22fde201a15de348fb1c55f5783 Mon Sep 17 00:00:00 2001 From: foxnne Date: Mon, 5 Oct 2026 10:11:49 -0500 Subject: [PATCH 06/16] popout: the carried bubble is bare material, and a float's window forms out of the glass as it opens As asked: the carried view's window draws no base fill, so while dragged it is the OS's frosted material and what it carries: lighter, more glass-like. Dialogs and float windows keep their opacity. As a float's window grows out of the drop (`Popout.growFrame`), its base now fades in with the growth, from the carried glass's none to the window's own opacity, which it reaches as the real window takes over under it. The window forms out of the glass, as the in-window glass used to, instead of arriving with its fill already on. `carryBegin` takes the share of the base to lay down (`fill`). Co-Authored-By: Claude Opus 5.5 --- src/editor/Popout.zig | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/src/editor/Popout.zig b/src/editor/Popout.zig index 438fa4e8..3168ec81 100644 --- a/src/editor/Popout.zig +++ b/src/editor/Popout.zig @@ -263,9 +263,9 @@ pub fn endFrame(state: *State) void { } /// A carried view is shown in a round window of its own (`viewports.openCarry`) for the whole of -/// the drag, wherever it is: a window in the shape of what it is carried as, its glass on the main -/// window's base over the OS's material and the OS's shadow round it (`viewports.carryShape`) — -/// a window as the float it would open is. The drag's drawing — in the main window's frame, past +/// the drag, wherever it is: a window in the shape of what it is carried as, the OS's material in +/// it with no base over it — lighter than a window, which dialogs and float windows keep — and the +/// OS's shadow round it (`viewports.carryShape`). The drag's drawing — in the main window's frame, past /// its edge too, or in the band of the float's window it is over — is copied into it. Floats being /// windows, it was drawn in whichever window it was over until it crossed an edge: cut off at a /// float window's edge, under a float's window from the main one, and its glass turned from @@ -325,7 +325,9 @@ fn carryFrame(state: *State) void { carry = .{ .viewport = vp }; } const c = &carry.?; - const drawing = carryBegin(c, place, shape, d.shape_radius, 1) orelse return; + // No base under it: carried, it is the OS's material and what it carries, lighter than a + // window — dialogs and float windows keep theirs. + const drawing = carryBegin(c, place, shape, d.shape_radius, 1, 0) orelse return; defer carryEnd(c, drawing); // What the drag draws across every screen (`core.screens.markEverywhere`), copied in, left for // the main window's replay: what of it lies past the window falls outside. @@ -345,10 +347,11 @@ const CarryDrawing = struct { /// Put `c`'s window where it shows `place` of the main window's frame, in its shape (`radius`, /// physical), `alpha` opaque, and start its picture, read from `shape` of the frame (the same rect, -/// or the band of a float's window it lies over): the main window's base under it, over the -/// window's material, for glass to read — glass over the window's clear pixels draws nothing — as a -/// float's window stands on it. Drawn into it until `carryEnd`; null with nothing to draw into. -fn carryBegin(c: *Carry, place: dvui.Rect.Physical, shape: dvui.Rect.Physical, radius: f32, alpha: f32) ?CarryDrawing { +/// or the band of a float's window it lies over): `fill` (0…1) of the main window's base under it, +/// over the window's material, as a float's window stands on it — none, and it is the material +/// alone, which glass in it reads as nothing. Drawn into it until `carryEnd`; null with nothing to +/// draw into. +fn carryBegin(c: *Carry, place: dvui.Rect.Physical, shape: dvui.Rect.Physical, radius: f32, alpha: f32, fill: f32) ?CarryDrawing { const cw = dvui.currentWindow(); const placed = viewports.placeMain(c.viewport, .{ .x = place.x, .y = place.y, .w = place.w, .h = place.h }); // The part of the frame it shows: where it was put, read from where its picture is drawn. @@ -375,7 +378,11 @@ fn carryBegin(c: *Carry, place: dvui.Rect.Physical, shape: dvui.Rect.Physical, r const prev_alpha = cw.alpha; dvui.alphaSet(1); defer dvui.alphaSet(prev_alpha); - shape.fill(dvui.CornerRect.Physical.all(radius), .{ .color = .{ .color = base(true) } }); + if (fill > 0) { + var color = base(true); + color.a = @intFromFloat(@round(@as(f32, @floatFromInt(color.a)) * std.math.clamp(fill, 0, 1))); + shape.fill(dvui.CornerRect.Physical.all(radius), .{ .color = .{ .color = color } }); + } } return .{ .prev = prev, .shown = shown }; } @@ -424,7 +431,9 @@ fn growFrame(o: *Out, f: *const Floats.Float) bool { .h = @max(1, std.math.lerp(from.h, to.h, t)), }; g.radius = std.math.lerp(land.radius, viewports.windowRadius() * dvui.windowNaturalScale(), std.math.clamp(t, 0, 1)); - const drawing = carryBegin(&g.carry, rect, rect, g.radius, 1) orelse return false; + // The base comes in as it grows, from the carried glass's none to the window's own by the + // time the window shows under it: the window forms out of the glass. + const drawing = carryBegin(&g.carry, rect, rect, g.radius, 1, std.math.clamp(t, 0, 1)) orelse return false; defer carryEnd(&g.carry, drawing); if (land.photo) |tex| { const prev_clip = dvui.clipGet(); From 6105a2f7de65e68f877b4f7e7b3b948e7d56ccc6 Mon Sep 17 00:00:00 2001 From: foxnne Date: Mon, 5 Oct 2026 10:19:46 -0500 Subject: [PATCH 07/16] popout: the carried view's content over the material, and its window coming in as it grows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit You saw the bubble's middle as opaque, with only its rim showing the material. The fill fading in as a float's window opened couldn't be seen, and the window seemed to appear first and fill in after. **The photograph.** It was taken from the frame as drawn, the place's background and the window's under it. It was then laid over an opaque fill (`backed`), so text never floated in the frost. In a window of its own that picture covered the window's material. With carry windows (`core.dialogs.carry_windows`, set by `Popout`), the view is now captured alone (`photographFromFrame` declines, and `keepShot` doesn't back it). Its content sits over the material, which shows between, and the base fading in under it as the window opens can be seen. A frosted pane inside a view draws dark in that capture. **The overlap.** Over the second half of the growth (`landingFraction` past ½), the window comes in under the growing glass while the glass fades off it, one into the other. Before, the window came in only after the growth ended. Polled in a sandbox, the window's opacity rises from 0 to 1 as the glass's falls from 1 to 0, both finishing as the glass settles on the window's frame. Co-Authored-By: Claude Opus 5.5 --- app/layout/Floats.zig | 8 ++++++-- app/layout/ViewDrag.zig | 8 +++++++- core/dialogs.zig | 5 +++++ src/editor/Popout.zig | 42 +++++++++++++++++++++++++++-------------- 4 files changed, 46 insertions(+), 17 deletions(-) diff --git a/app/layout/Floats.zig b/app/layout/Floats.zig index dcf49a93..aae1c4e9 100644 --- a/app/layout/Floats.zig +++ b/app/layout/Floats.zig @@ -428,8 +428,12 @@ const landing_ms: f32 = 300; /// How opaque the landing photograph is at its start — the carried card's own (`ViewDrag`). const photo_opacity: f32 = 0.8; -/// How far a landing has got, on the arrival curve: 0 at the release, 1 in its window — past it -/// and back on the way, when motion is playful. +/// How far a landing has got in time: 0 at the release, 1 in its window. +pub fn landingFraction(land: Landing) f32 { + return std.math.clamp(land.clock.fraction(core.motion.durationMs(landing_ms)), 0, 1); +} + +/// `landingFraction` on the arrival curve, past 1 and back on the way when motion is playful. pub fn landedAt(land: Landing) f32 { const frac = land.clock.fraction(core.motion.durationMs(landing_ms)); return if (frac >= 1) 1 else core.motion.enter(frac); diff --git a/app/layout/ViewDrag.zig b/app/layout/ViewDrag.zig index 79c745bf..bc1722eb 100644 --- a/app/layout/ViewDrag.zig +++ b/app/layout/ViewDrag.zig @@ -465,7 +465,9 @@ pub fn keepShot(l: *Layout, shot: Shot, pic: *dvui.Picture) void { d.takePicture(pic); if (d.texture) |tex| { core.anim.blit(tex, null, d.texture_rect, 0, 1); - d.texture = backed(tex, d.texture_rect); + // Carried in a window of its own, it is the view's content over that window's + // material, which shows through between: not laid over an opaque fill. + if (!core.dialogs.carry_windows) d.texture = backed(tex, d.texture_rect); } return; } @@ -478,6 +480,10 @@ pub fn keepShot(l: *Layout, shot: Shot, pic: *dvui.Picture) void { /// place flashed dark for the frame it was lifted on. False where there is no last frame to copy /// (`FrameTarget.snapshot`: no targets, or a web frame nothing read); the caller captures then. pub fn photographFromFrame(l: *Layout, rect: dvui.Rect.Physical) bool { + // The frame as drawn has the place's background in it, and the window's under that: carried in + // a window of its own, the view is captured alone instead (`keepShot`), its content over that + // window's material. A frosted pane in it draws dark in that capture. + if (core.dialogs.carry_windows) return false; const d = &l.state.view_drag; const r = rect.intersect(dvui.windowRectPixels()); const tex = core.FrameTarget.snapshot(r) orelse return false; diff --git a/core/dialogs.zig b/core/dialogs.zig index 3028f7bf..9baddeff 100644 --- a/core/dialogs.zig +++ b/core/dialogs.zig @@ -184,6 +184,11 @@ pub fn carriedGlass(id: dvui.Id, r: dvui.Rect.Physical, scale: f32) void { /// copy in the app, which is the one drawing the drag. pub var carried_in_window: bool = false; +/// Whether carried things are shown in windows of their own this run (fizzy's float windows on +/// macOS): a carried view's photograph is taken without the place's background, its content over +/// the window's material — set by the app before anything draws. +pub var carry_windows: bool = false; + /// `carriedGlass` for glass whose shapes run together (`LiquidField`) — the dragged view as a /// drop — frosted at the dialog style. False when the style has the blur off, or the glass program /// is not there to draw it: carry a card instead. diff --git a/src/editor/Popout.zig b/src/editor/Popout.zig index 3168ec81..9bbe70af 100644 --- a/src/editor/Popout.zig +++ b/src/editor/Popout.zig @@ -86,6 +86,8 @@ const Grow = struct { carry: Carry, /// Its corner radius, physical, as last shaped. radius: f32 = 0, + /// How opaque it is, as last shown: it fades as the window it grows into comes in. + alpha: f32 = 1, /// Landed: when the window it grew into began to show under it, and it to fade off it. landed_ns: ?i128 = null, }; @@ -109,6 +111,9 @@ pub fn beginFrame(state: *State) void { // A view let go over no window of the app's opens a float there (`ViewDrag.apply`), and what a // view drag draws reaches past every window, for the carry window (`carryFrame`). state.floats_windowed = true; + // Carried things are windows of their own here (`carryFrame`): photographed without their + // place's background (`ViewDrag.photographFromFrame`). + fizzy.core.dialogs.carry_windows = viewports.carries; fizzy.core.screens.publishBeyond(viewports.carries and state.view_drag.active()); // Whatever happened, the screens floating things are placed on this frame: each window's, // besides the main window's (`core.screens`). @@ -406,15 +411,15 @@ fn releaseCarry(c: *Carry) void { /// photograph fading out in it. Landed, the window shows under it, and it fades off the window and /// goes. Whether the window shows nothing yet. Where there are no carry windows, the window shows /// at once. -fn growFrame(o: *Out, f: *const Floats.Float) bool { - if (!viewports.carries) return false; +fn growFrame(o: *Out, f: *const Floats.Float) f32 { + if (!viewports.carries) return 1; if (f.landing) |land| { if (o.grow == null) { const c = if (spare) |sp| blk: { spare = null; break :blk sp; } else blk: { - const vp = viewports.openCarry(.{ .x = land.from.x, .y = land.from.y, .w = land.from.w, .h = land.from.h }) orelse return false; + const vp = viewports.openCarry(.{ .x = land.from.x, .y = land.from.y, .w = land.from.w, .h = land.from.h }) orelse return 1; break :blk Carry{ .viewport = vp }; }; o.grow = .{ .carry = c }; @@ -431,33 +436,42 @@ fn growFrame(o: *Out, f: *const Floats.Float) bool { .h = @max(1, std.math.lerp(from.h, to.h, t)), }; g.radius = std.math.lerp(land.radius, viewports.windowRadius() * dvui.windowNaturalScale(), std.math.clamp(t, 0, 1)); + // Over the second half of the growth the window comes in under it and it goes, one into + // the other — the window's content arriving as the glass grows into it, not after. + const reveal = smoothstep(std.math.clamp((Floats.landingFraction(land) - 0.5) / 0.5, 0, 1)); + g.alpha = 1 - reveal; // The base comes in as it grows, from the carried glass's none to the window's own by the // time the window shows under it: the window forms out of the glass. - const drawing = carryBegin(&g.carry, rect, rect, g.radius, 1, std.math.clamp(t, 0, 1)) orelse return false; + const drawing = carryBegin(&g.carry, rect, rect, g.radius, g.alpha, std.math.clamp(t, 0, 1)) orelse return 1; defer carryEnd(&g.carry, drawing); if (land.photo) |tex| { const prev_clip = dvui.clipGet(); defer dvui.clipSet(prev_clip); dvui.clipSet(drawing.shown); - Floats.drawPhoto(tex, land.photo_size, rect, .{ .x = rect.x, .y = rect.y, .w = rect.w }, g.radius, 1 - std.math.clamp(t, 0, 1)); + Floats.drawPhoto(tex, land.photo_size, rect, .{ .x = rect.x, .y = rect.y, .w = rect.w }, g.radius, 1); } - return true; + return reveal; } - const g = if (o.grow) |*g| g else return false; + const g = if (o.grow) |*g| g else return 1; + // Landed: what is left of it fades off the window it grew into, and goes. const now = dvui.currentWindow().frame_time_ns; const start = g.landed_ns orelse now; g.landed_ns = start; const dur = fizzy.core.motion.durationMs(grow_fade_ms); const frac: f32 = if (dur <= 0) 1 else @as(f32, @floatFromInt(now - start)) / (dur * std.time.ns_per_ms); - if (frac >= 1) { + if (frac >= 1 or g.alpha <= 0.01) { releaseCarry(&g.carry); o.grow = null; - return false; + return 1; } - viewports.carryShape(g.carry.viewport, g.radius, 1 - frac); + viewports.carryShape(g.carry.viewport, g.radius, g.alpha * (1 - frac)); if (g.carry.target) |t| viewports.present(g.carry.viewport, t); dvui.refresh(null, @src(), null); - return false; + return 1; +} + +fn smoothstep(x: f32) f32 { + return x * x * (3 - 2 * x); } fn windowFrame(state: *State, o: *Out) void { @@ -508,9 +522,9 @@ fn windowFrame(state: *State, o: *Out) void { }; cover_count += 1; } - // Nothing of it while it grows out of the carried glass (`growFrame`). - const growing = growFrame(o, f); - viewports.fade(o.viewport, if (growing) 0 else Floats.ghostLook(f.aside.at()).alpha); + // Coming in under the carried glass as that grows into it (`growFrame`). + const shown_share = growFrame(o, f); + viewports.fade(o.viewport, shown_share * Floats.ghostLook(f.aside.at()).alpha); } // Where a press is the OS's: its header moves the window and its glass's edges resize it, so // the OS snaps, tiles and maximizes it as any window. From 15681fd067572c3266b5579b6d7a83a86f872988 Mon Sep 17 00:00:00 2001 From: foxnne Date: Mon, 5 Oct 2026 10:34:18 -0500 Subject: [PATCH 08/16] popout: a carried view is drawn by its carry window alone, and stretches toward its tail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app's own windows drew a copy of the carried view under the carry window — its glass, a drop's tail, the drop it ran into — presented at another moment than the window server moved the carry window, so a fast drag left it trailing behind. Where carried things are windows of their own, what is carried (card, tab or drop) is now drawn in a layer of its own (`core.screens.markCarried`) that the carry window takes whole; the main window and float windows draw none of it, and no in-app glass is drawn for it: the OS's material is its glass. The drop zones no longer run the carried drop into their glass there; the bubble it is aimed at lights. A drop's carry window is the union of its head and its tail as that lags on its spring, so it stretches behind a fast drag and swings back when it stops — the wobble, from the window itself. `core.dialogs.carried_in_window` is gone with the copy it kept from casting a second shadow. Co-Authored-By: Claude Opus 5.5 --- app/layout/ViewDrag.zig | 59 ++++++++++++++++++++++++++++++----------- core/dialogs.zig | 9 +------ core/screens.zig | 20 ++++++++++++++ src/editor/Popout.zig | 46 ++++++++++++++++++-------------- 4 files changed, 90 insertions(+), 44 deletions(-) diff --git a/app/layout/ViewDrag.zig b/app/layout/ViewDrag.zig index bc1722eb..b638203d 100644 --- a/app/layout/ViewDrag.zig +++ b/app/layout/ViewDrag.zig @@ -1080,22 +1080,13 @@ pub fn drawOverlay(l: *Layout) void { // Nothing to lay over the window. if (n == 0 and !d.active()) return; var layer: dvui.FloatingWidget = undefined; - layer.init(@src(), .{ .mouse_events = false }, .{ .rect = .cast(dvui.windowRect()), .background = false }); + layerOver(&layer, @src()); defer layer.deinit(); - // Over every float. A floating widget stays just above the window it was made in — this one - // the app's own, which every float is over, so the drops on a float's place and the view - // carried over a float were drawn under its glass — and is re-added as a window of its own - // and raised, as the demo overlay's layers are (`automation/overlay.zig`). It takes no pointer - // events: what is under it still takes them, the drag's hold on the pointer included. - const wd = layer.data(); - dvui.subwindowAdd(wd.id, wd.rect, wd.rectScale().r, false, null, false); - dvui.raiseSubwindow(wd.id); // On every screen (`core.screens`): a place in a float popped out into its own window has // its drop drawn here, at that window's part of the frame, and so does the carried view // when the pointer is there — clipped to the main window, both were dropped as they were // drawn, and the app copies this layer into every such window as well as the main one. - core.screens.markEverywhere(wd.id); - dvui.clipSet(core.screens.allPixels()); + core.screens.markEverywhere(layer.data().id); // The drops, then the card over them: one layer, so their order is the order drawn — the // card's glass showing the drop it is aimed at blurred through it, its top left just off the // pointer so the bubble under the pointer stays in view. @@ -1110,7 +1101,9 @@ pub fn drawOverlay(l: *Layout) void { for (drops[0..n], 0..) |p, i| { var look = p.look; const over = p.look.target and p.clip.contains(mouse); - if (!taken and over) look.carried = carried; + // In a window of its own, the view is not the app's glass to run in with the drop's: the + // 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; @@ -1129,6 +1122,33 @@ pub fn drawOverlay(l: *Layout) void { dvui.clipSet(prev_clip); } +/// A layer of the drag's over every float, drawn on every screen (`core.screens.allPixels`). A +/// floating widget stays just above the window it was made in — this one the app's own, which every +/// float is over, so the drops on a float's place and the view carried over a float were drawn under +/// its glass — and is re-added as a window of its own and raised, as the demo overlay's layers are +/// (`automation/overlay.zig`). It takes no pointer events: what is under it still takes them, the +/// drag's hold on the pointer included. +fn layerOver(layer: *dvui.FloatingWidget, src: std.builtin.SourceLocation) void { + layer.init(src, .{ .mouse_events = false }, .{ .rect = .cast(dvui.windowRect()), .background = false }); + const wd = layer.data(); + dvui.subwindowAdd(wd.id, wd.rect, wd.rectScale().r, false, null, false); + dvui.raiseSubwindow(wd.id); + dvui.clipSet(core.screens.allPixels()); +} + +/// Where carried things are windows of their own (`core.dialogs.carry_windows`: fizzy's carry +/// window), what is carried — a card, a tab, a drop — is drawn in a layer of its own that only its +/// window draws (`core.screens.markCarried`): the window is all of it, the OS's material its +/// glass, and nothing in the app's own windows follows it. A copy there — its glass, a drop's tail, +/// the drop it ran into — was drawn at another moment than the window server moved the window, and +/// trailed behind it. Whether it is; the layer is the caller's to `deinit` then. +fn ownWindowLayer(layer: *dvui.FloatingWidget, src: std.builtin.SourceLocation) bool { + if (!core.dialogs.carry_windows) return false; + layerOver(layer, src); + core.screens.markCarried(layer.data().id); + return true; +} + /// A drop coming in where another is going — the place the view is aimed at now lying under the /// one it was, as a float's place does under the place beneath it when the float firms up from its /// ghost — takes over the going one's glass and changes into itself (`DropZones.handOff`), rather @@ -1309,7 +1329,11 @@ 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; - if (!taken) { + // In a window of its own (`ownWindowLayer`), its photograph is all the app draws of it. + var own_layer: dvui.FloatingWidget = undefined; + const own_window = ownWindowLayer(&own_layer, @src()); + defer if (own_window) own_layer.deinit(); + 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 @@ -1472,8 +1496,11 @@ pub fn drawFloat(l: *Layout, taken: bool) void { d.shape_radius = radius; const nat = rect.toNatural(); - // A box in the drag's own layer (`drawOverlay`), not a floating window of its own: the drops - // go over it in the same layer, drawn after it. + // In a window of its own (`ownWindowLayer`), or a box in the drag's own layer (`drawOverlay`): + // the drops' icons go over it in the same layer, drawn after it. + var own_layer: dvui.FloatingWidget = undefined; + const own_window = ownWindowLayer(&own_layer, @src()); + defer if (own_window) own_layer.deinit(); const fw = dvui.box(@src(), .{}, .{ .rect = .{ .x = nat.x, .y = nat.y, .w = nat.w, .h = nat.h }, // The photograph sits inset in its glass; a tab is the glass. @@ -1483,7 +1510,7 @@ pub fn drawFloat(l: *Layout, taken: bool) void { .border = .all(0), }); defer fw.deinit(); - { + 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 // piece of glass changing shape; elsewhere the carried look (`core.dialogs.carriedGlass`), diff --git a/core/dialogs.zig b/core/dialogs.zig index 9baddeff..8011cf0b 100644 --- a/core/dialogs.zig +++ b/core/dialogs.zig @@ -174,16 +174,9 @@ pub fn carriedGlass(id: dvui.Id, r: dvui.Rect.Physical, scale: f32) void { if (!frostPane(id, r, corners, scale)) { r.fill(corners.scale(scale, dvui.CornerRect.Physical), .{ .color = .{ .color = dialogFill() }, .fade = 1 }); } - // Shown in a window of its own, the OS's shadow is round it already. - if (!carried_in_window) glassShadow(r, corners, scale, surfaceShadow(), 1); + glassShadow(r, corners, scale, surfaceShadow(), 1); } -/// Whether what is carried is shown in a window of its own this frame, over every window of the -/// app's (fizzy's carry window, `Popout.carryFrame`): the copy drawn under it in the app's own -/// windows casts no shadow of its own beside the OS's. Set by the app each frame; this module's -/// copy in the app, which is the one drawing the drag. -pub var carried_in_window: bool = false; - /// Whether carried things are shown in windows of their own this run (fizzy's float windows on /// macOS): a carried view's photograph is taken without the place's background, its content over /// the window's material — set by the app before anything draws. diff --git a/core/screens.zig b/core/screens.zig index 19e8cd7b..39645e11 100644 --- a/core/screens.zig +++ b/core/screens.zig @@ -74,6 +74,26 @@ const Everywhere = struct { ids: [8]dvui.Id = undefined, }; +/// What is carried, in a layer of its own this frame, where the app shows it in a window of its +/// own over every other (fizzy's carry window): that window takes the layer's drawing whole, and +/// 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. +pub fn markCarried(id: dvui.Id) void { + dvui.dataSet(null, key_id, "_carried", Carried{ .frame = dvui.currentWindow().frame_time_ns, .id = id }); +} + +/// Whether `id` is what 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 Carried = struct { + frame: i128, + id: dvui.Id, +}; + /// While something drawn across every screen may be carried past every window of the app's — a /// view drag where floats are OS windows of their own, and a window carries the view over the /// desktop — it reaches across the whole desktop (`allPixels`). The app's, each frame. diff --git a/src/editor/Popout.zig b/src/editor/Popout.zig index 9bbe70af..9e4ff442 100644 --- a/src/editor/Popout.zig +++ b/src/editor/Popout.zig @@ -267,18 +267,17 @@ pub fn endFrame(state: *State) void { } } -/// A carried view is shown in a round window of its own (`viewports.openCarry`) for the whole of -/// the drag, wherever it is: a window in the shape of what it is carried as, the OS's material in -/// it with no base over it — lighter than a window, which dialogs and float windows keep — and the -/// OS's shadow round it (`viewports.carryShape`). The drag's drawing — in the main window's frame, past -/// its edge too, or in the band of the float's window it is over — is copied into it. Floats being -/// windows, it was drawn in whichever window it was over until it crossed an edge: cut off at a -/// float window's edge, under a float's window from the main one, and its glass turned from -/// glass reading the app under it into the window's material each time it crossed into the carry -/// window and back. Now it is the window throughout. The copy left in the app's windows, under it, -/// casts no shadow (`core.dialogs.carried_in_window`). It goes when the drag does. +/// A carried view is shown in a round window of its own (`viewports.openCarry`), +/// wherever it is: a window in the shape of what it is carried as, the OS's material in it with no +/// base over it — lighter than a window, which dialogs and float windows keep — and the OS's shadow +/// round it (`viewports.carryShape`). Its drawing — in the main window's frame, past its edge +/// too, or in the band of the float's window it is over — is in a layer of its own +/// (`core.screens.markCarried`), taken whole into it: the app's own windows draw nothing of it. Left +/// in them under the carry window, a copy drawn at another moment than the window server moved the +/// window trailed behind it. Floats being windows, it was drawn in whichever window it was over until +/// it crossed an edge: cut off at a float window's edge, and under a float's window from the main +/// one. It goes when the drag does. fn carryFrame(state: *State) void { - fizzy.core.dialogs.carried_in_window = false; if (!viewports.carries) return; const d = &state.view_drag; if (!d.active()) { @@ -292,8 +291,10 @@ fn carryFrame(state: *State) void { return; } const cw = dvui.currentWindow(); - // What it is carried as: a drop's head, or the card or tab it is carried as. - const shape = d.shape_rect; + // 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; 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 @@ -316,7 +317,6 @@ fn carryFrame(state: *State) void { place = shape.offsetPoint(.{ .x = cover.in_main.x - cover.band.x, .y = cover.in_main.y - cover.band.y }); break :banded true; } else true; - fizzy.core.dialogs.carried_in_window = want; if (!want) { if (carry) |*c| if (c.target) |t| { t.clear(); @@ -334,12 +334,16 @@ fn carryFrame(state: *State) void { // window — dialogs and float windows keep theirs. const drawing = carryBegin(c, place, shape, d.shape_radius, 1, 0) orelse return; defer carryEnd(c, drawing); - // What the drag draws across every screen (`core.screens.markEverywhere`), copied in, left for - // the main window's replay: what of it lies past the window falls outside. + // The carried view's own layer (`core.screens.markCarried`), taken from it: dvui's replay into the main + // window, and the float windows' (`windowFrame`), draw nothing of it. for (cw.subwindows.stack.items) |*sw| { - if (!fizzy.core.screens.isEverywhere(sw.id)) continue; - cw.renderCommands(sw.render_cmds.items) catch |err| dvui.logError(@src(), err, "replaying a carried view into its window", .{}); - cw.renderCommands(sw.render_cmds_after.items) catch |err| dvui.logError(@src(), err, "replaying a carried view into its window", .{}); + 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; + cw.renderCommands(cmds.items) catch |err| dvui.logError(@src(), err, "replaying a carried view into its window", .{}); + cw.renderCommands(after.items) catch |err| dvui.logError(@src(), err, "replaying a carried view into its window", .{}); } } @@ -569,11 +573,13 @@ fn windowFrame(state: *State, o: *Out) void { // 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 // dvui's replay into the main window draws nothing of them. And a layer drawn across every - // screen (`core.screens.markEverywhere`: a view drag's drops and carried glass) is copied in + // screen (`core.screens.markEverywhere`: a view drag's drops) is copied in // too, left in place for the main window's replay — what of it lies outside the window's part // 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`). + if (fizzy.core.screens.isCarried(sw.id)) continue; const mine = area.contains(sw.rect_pixels.center()); if (!mine and !fizzy.core.screens.isEverywhere(sw.id)) continue; const cmds = sw.render_cmds; From c7a86bc57ca508ae5685d409f9959f8b7303cd75 Mon Sep 17 00:00:00 2001 From: foxnne Date: Mon, 5 Oct 2026 10:43:26 -0500 Subject: [PATCH 09/16] popout: on macOS 26 the carry window is Liquid Glass The carried bubble's window, and the window a float grows into as it opens, use Apple's Liquid Glass (`NSGlassEffectView`, Clear style) from macOS 26: its rim, its continuous corners, set from the window's shape. Before macOS 26 they keep the vibrancy material. The class is looked up by name and its properties set by key, so fizzy still builds against older SDKs (CI's macos-14). Co-Authored-By: Claude Opus 5.5 --- .../platform/macos/visual_effect_view.m | 88 ++++++++++++++----- 1 file changed, 64 insertions(+), 24 deletions(-) diff --git a/src/backend/native/platform/macos/visual_effect_view.m b/src/backend/native/platform/macos/visual_effect_view.m index 7df26876..44ae6cf2 100644 --- a/src/backend/native/platform/macos/visual_effect_view.m +++ b/src/backend/native/platform/macos/visual_effect_view.m @@ -241,12 +241,41 @@ void fizzy_macos_viewport_windows_item(void *nswindow, const char *title) { } } +/* The identifier the carry window's Liquid Glass goes by (`carryLiquidGlass`). */ +static NSString *const carry_glass_id = @"fizzy.carry.glass"; + +/* + * The carry window's material from macOS 26: Apple's Liquid Glass (`NSGlassEffectView`), its Clear + * style — the light one, what is carried seen through it rather than set on it — with its own rim + * and its continuous corners, which the window's shape sets (`fizzy_macos_viewport_carry_shape`). + * Nil before macOS 26, for the vibrancy material instead. Looked up by name, and its properties set + * by key, so fizzy builds against SDKs from before it: Liquid Glass is the OS's, not the SDK's. + */ +static NSView *carryLiquidGlass(NSRect rect) { + if (@available(macOS 26.0, *)) { + Class cls = NSClassFromString(@"NSGlassEffectView"); + if (cls == nil) return nil; + NSView *glass = [[cls alloc] initWithFrame:rect]; + if (glass == nil) return nil; + /* NSGlassEffectViewStyleClear. */ + [glass setValue:@(1) forKey:@"style"]; + [glass setIdentifier:carry_glass_id]; + [glass setAutoresizingMask:NSViewWidthSizable | NSViewHeightSizable]; +#if !__has_feature(objc_arc) + [glass autorelease]; +#endif + return glass; + } + return nil; +} + /* * A window that carries a view past every window of the app's, over the desktop - * (`SDLBackend.viewportOpenCarry`): a round window of its own — the main window's material behind - * it, in the shape of what is carried (`fizzy_macos_viewport_carry_shape`), the OS's shadow round - * it — the pointer passing through it to what is under it, above every window (a pop-up menu's - * level), on every Space, in no window list or switcher, and no OS animation. + * (`SDLBackend.viewportOpenCarry`): a round window of its own — Liquid Glass from macOS 26 + * (`carryLiquidGlass`), the main window's material before — in the shape of what is carried + * (`fizzy_macos_viewport_carry_shape`), the OS's shadow round it — the pointer passing through it + * to what is under it, above every window (a pop-up menu's level), on every Space, in no window list + * or switcher, and no OS animation. */ void fizzy_macos_viewport_carry(void *nswindow, void *main_nswindow, long material) { @autoreleasepool { @@ -261,15 +290,20 @@ void fizzy_macos_viewport_carry(void *nswindow, void *main_nswindow, long materi NSView *content = [window contentView]; NSView *frame = [content superview]; if (content != nil && frame != nil) { - NSVisualEffectView *effect = [[FizzyViewportGlassView alloc] initWithFrame:[content frame]]; - [effect setBlendingMode:NSVisualEffectBlendingModeBehindWindow]; - [effect setState:NSVisualEffectStateActive]; - [effect setMaterial:(NSVisualEffectMaterial)material]; - [effect setAutoresizingMask:NSViewWidthSizable | NSViewHeightSizable]; - [frame addSubview:effect positioned:NSWindowBelow relativeTo:content]; + NSView *glass = carryLiquidGlass([content frame]); + if (glass != nil) { + [frame addSubview:glass positioned:NSWindowBelow relativeTo:content]; + } else { + NSVisualEffectView *effect = [[FizzyViewportGlassView alloc] initWithFrame:[content frame]]; + [effect setBlendingMode:NSVisualEffectBlendingModeBehindWindow]; + [effect setState:NSVisualEffectStateActive]; + [effect setMaterial:(NSVisualEffectMaterial)material]; + [effect setAutoresizingMask:NSViewWidthSizable | NSViewHeightSizable]; + [frame addSubview:effect positioned:NSWindowBelow relativeTo:content]; #if !__has_feature(objc_arc) - [effect release]; + [effect release]; #endif + } } [window setIgnoresMouseEvents:YES]; [window setAnimationBehavior:NSWindowAnimationBehaviorNone]; @@ -309,23 +343,29 @@ void fizzy_macos_viewport_carry_shape(void *nswindow, double radius, double w, d NSView *frame = [[window contentView] superview]; if (frame == nil) return; NSVisualEffectView *effect = nil; + NSView *glass = nil; for (NSView *v in [frame subviews]) { if ([v isKindOfClass:[FizzyViewportGlassView class]]) effect = (NSVisualEffectView *)v; + if ([[v identifier] isEqualToString:carry_glass_id]) glass = v; } - if (effect == nil) return; - /* Drawn at the window's size, not stretched: a circle's corners are all of it, and a - * stretchable image's caps came to more than the window — no mask, the material square. */ - const NSSize size = NSMakeSize(w, h); const CGFloat r = (CGFloat)fmin(radius, fmin(w, h) / 2); - NSImage *mask = [NSImage imageWithSize:size - flipped:NO - drawingHandler:^BOOL(NSRect dst) { - [[NSColor blackColor] set]; - [[NSBezierPath bezierPathWithRoundedRect:dst xRadius:r yRadius:r] fill]; - return YES; - }]; - [effect setMaskImage:mask]; - [effect displayIfNeeded]; + if (glass != nil) { + /* Liquid Glass draws its own shape: its corners, and the rim along them. */ + [glass setValue:@(r) forKey:@"cornerRadius"]; + } else if (effect != nil) { + /* Drawn at the window's size, not stretched: a circle's corners are all of it, and a + * stretchable image's caps came to more than the window — no mask, the material square. */ + const NSSize size = NSMakeSize(w, h); + NSImage *mask = [NSImage imageWithSize:size + flipped:NO + drawingHandler:^BOOL(NSRect dst) { + [[NSColor blackColor] set]; + [[NSBezierPath bezierPathWithRoundedRect:dst xRadius:r yRadius:r] fill]; + return YES; + }]; + [effect setMaskImage:mask]; + [effect displayIfNeeded]; + } else return; /* And the picture, SDL's view: what is carried fills the window only to its shape, but a * glass's frost writes the rect it reads, a little past the shape — over the app, in the main * window, that is the app again; here, the blur of the window's own base, a square round the From 60082e31114040d8043d81c4c46ff9bb8c4366bb Mon Sep 17 00:00:00 2001 From: foxnne Date: Mon, 5 Oct 2026 11:10:06 -0500 Subject: [PATCH 10/16] popout: a float's window shows once the glass it grows out of is its size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The window a float grows into faded in over the second half of the growth while the carried glass still grew — past the window's frame and back, at the playful motion level — so two sizes of one window showed at once, and the photograph scaled to the overshoot doubled what the window showed (the user's capture). The window now shows nothing until the glass has landed at its frame; what the glass carries comes in as it grows instead: the carried view's photograph goes, and the float's own picture (last frame's) arrives in its place, scaled to the glass, its base with it. Landed, the glass is the window's size and shows what the window does: the window shows under it at once, and it fades off it. Polled: the float window opens at alpha 0, stays so while the glass overshoots to 386×479 and settles at 356×439, and shows at 356×439 as the glass fades. Co-Authored-By: Claude Opus 5.5 --- src/editor/Popout.zig | 80 +++++++++++++++++++++++++++---------------- 1 file changed, 50 insertions(+), 30 deletions(-) diff --git a/src/editor/Popout.zig b/src/editor/Popout.zig index 9e4ff442..13910117 100644 --- a/src/editor/Popout.zig +++ b/src/editor/Popout.zig @@ -84,10 +84,6 @@ const Out = struct { const Grow = struct { carry: Carry, - /// Its corner radius, physical, as last shaped. - radius: f32 = 0, - /// How opaque it is, as last shown: it fades as the window it grows into comes in. - alpha: f32 = 1, /// Landed: when the window it grew into began to show under it, and it to fade off it. landed_ns: ?i128 = null, }; @@ -411,12 +407,18 @@ fn releaseCarry(c: *Carry) void { /// A float made by a drop grows out of the carried glass into its window, as it grows into its /// glass in the main window (`Floats.Landing`): its window shows nothing while the carried glass — /// a carry window (`viewports.openCarry`), the drag's own when it had one, already where the drop -/// is — grows from the drop to the window's frame and rounds to its corners, the carried view's -/// photograph fading out in it. Landed, the window shows under it, and it fades off the window and -/// goes. Whether the window shows nothing yet. Where there are no carry windows, the window shows -/// at once. +/// is — grows from the drop to the window's frame and rounds to its corners. What it carries comes +/// in as it grows: the carried view's photograph goes, and the float's own picture — what its window +/// will show, last frame's — arrives in its place, scaled to the glass, and the window's base with +/// it. Landed, the glass is the window's size and shows what the window does: the window shows under +/// it at once, and it fades off the window and goes. The window showing while it still grew — past +/// the window's frame and back, when motion is playful — showed two sizes of one window at once. +/// How much of the window shows. Where there are no carry windows, the window shows at once. fn growFrame(o: *Out, f: *const Floats.Float) f32 { if (!viewports.carries) return 1; + const into = viewports.inMain(o.viewport); + const to: dvui.Rect.Physical = .{ .x = into.x, .y = into.y, .w = into.w, .h = into.h }; + const window_radius = viewports.windowRadius() * dvui.windowNaturalScale(); if (f.landing) |land| { if (o.grow == null) { const c = if (spare) |sp| blk: { @@ -430,8 +432,6 @@ fn growFrame(o: *Out, f: *const Floats.Float) f32 { } const g = &o.grow.?; const t = Floats.landedAt(land); - const into = viewports.inMain(o.viewport); - const to: dvui.Rect.Physical = .{ .x = into.x, .y = into.y, .w = into.w, .h = into.h }; const from = land.from; const rect: dvui.Rect.Physical = .{ .x = std.math.lerp(from.x, to.x, t), @@ -439,41 +439,61 @@ fn growFrame(o: *Out, f: *const Floats.Float) f32 { .w = @max(1, std.math.lerp(from.w, to.w, t)), .h = @max(1, std.math.lerp(from.h, to.h, t)), }; - g.radius = std.math.lerp(land.radius, viewports.windowRadius() * dvui.windowNaturalScale(), std.math.clamp(t, 0, 1)); - // Over the second half of the growth the window comes in under it and it goes, one into - // the other — the window's content arriving as the glass grows into it, not after. - const reveal = smoothstep(std.math.clamp((Floats.landingFraction(land) - 0.5) / 0.5, 0, 1)); - g.alpha = 1 - reveal; - // The base comes in as it grows, from the carried glass's none to the window's own by the - // time the window shows under it: the window forms out of the glass. - const drawing = carryBegin(&g.carry, rect, rect, g.radius, g.alpha, std.math.clamp(t, 0, 1)) orelse return 1; - defer carryEnd(&g.carry, drawing); - if (land.photo) |tex| { - const prev_clip = dvui.clipGet(); - defer dvui.clipSet(prev_clip); - dvui.clipSet(drawing.shown); - Floats.drawPhoto(tex, land.photo_size, rect, .{ .x = rect.x, .y = rect.y, .w = rect.w }, g.radius, 1); - } - return reveal; + const radius = std.math.lerp(land.radius, window_radius, std.math.clamp(t, 0, 1)); + // The float's picture arrives over the middle of the growth, the photograph going as it does. + const arrive = smoothstep(std.math.clamp((Floats.landingFraction(land) - 0.3) / 0.5, 0, 1)); + // The base comes in as it grows, from the carried glass's none, and hands over to the + // picture's own as that arrives: the window forms out of the glass. + if (!drawGrow(o, g, rect, radius, 1, std.math.clamp(t, 0, 1) * (1 - arrive), arrive, if (land.photo) |tex| .{ .tex = tex, .size = land.photo_size, .fade = 1 - arrive } else null)) return 1; + return 0; } const g = if (o.grow) |*g| g else return 1; - // Landed: what is left of it fades off the window it grew into, and goes. + // Landed: at the window's frame, showing what the window shows, it fades off the window, which + // shows under it, and goes. const now = dvui.currentWindow().frame_time_ns; const start = g.landed_ns orelse now; g.landed_ns = start; const dur = fizzy.core.motion.durationMs(grow_fade_ms); const frac: f32 = if (dur <= 0) 1 else @as(f32, @floatFromInt(now - start)) / (dur * std.time.ns_per_ms); - if (frac >= 1 or g.alpha <= 0.01) { + if (frac >= 1) { releaseCarry(&g.carry); o.grow = null; return 1; } - viewports.carryShape(g.carry.viewport, g.radius, g.alpha * (1 - frac)); - if (g.carry.target) |t| viewports.present(g.carry.viewport, t); + _ = drawGrow(o, g, to, window_radius, 1 - frac, 0, 1, null); dvui.refresh(null, @src(), null); return 1; } +/// A photograph a growing glass carries (`drawGrow`): the carried view's, at `fade`. +const GrowPhoto = struct { + tex: dvui.Texture, + size: dvui.Size.Physical, + fade: f32, +}; + +/// `g`'s window at `rect` (physical, in the main window's frame) in `radius` corners, `alpha` +/// opaque: `fill` of the window's base over its material, the photograph, and `picture` (0…1) of +/// the float's own picture (`Out.target`, last frame's) scaled to it. False with nothing to draw +/// 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; + defer carryEnd(&g.carry, drawing); + const prev_clip = dvui.clipGet(); + defer dvui.clipSet(prev_clip); + dvui.clipSet(drawing.shown); + if (photo) |p| Floats.drawPhoto(p.tex, p.size, rect, .{ .x = rect.x, .y = rect.y, .w = rect.w }, radius, p.fade); + if (picture > 0.01) if (o.target) |target| { + const tex = dvui.Texture.fromTargetTemp(target) catch return true; + const scale = dvui.windowNaturalScale(); + dvui.renderTexture(tex, .{ .r = rect, .s = scale }, .{ + .corners = .round(radius / scale), + .colormod = dvui.Color.white.opacity(picture), + }) catch {}; + }; + return true; +} + fn smoothstep(x: f32) f32 { return x * x * (3 - 2 * x); } From 95ef756e510439eca244bd4de435e39ac3c9e932 Mon Sep 17 00:00:00 2001 From: foxnne Date: Mon, 5 Oct 2026 11:15:51 -0500 Subject: [PATCH 11/16] popout: the carried bubble is a lens of Liquid Glass, as a drop of water MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user saw the bubble's glass as a frost, without the refracted edges of Control Center's. Both of AppKit's public glass styles are frosted, in a window and behind one. One of the glass's private variants (`_variant` 11, measured on macOS 26.5) is a clear lens: unblurred, bending and drawing in what is behind it toward its rim — what is behind another window included, the window server doing the lensing — as a drop of water, as the app's own glass is. The carry window's glass takes it, through its typed setter, only on macOS 26 and only where the setter is there: a later macOS that renumbered the variants could leave the bubble with no glass (13 draws none), and key-value coding an unexpected value into the glass's Swift enums kills the process. Elsewhere it keeps the public Clear style. Co-Authored-By: Claude Opus 5.5 --- .../platform/macos/visual_effect_view.m | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/src/backend/native/platform/macos/visual_effect_view.m b/src/backend/native/platform/macos/visual_effect_view.m index 44ae6cf2..5addebfa 100644 --- a/src/backend/native/platform/macos/visual_effect_view.m +++ b/src/backend/native/platform/macos/visual_effect_view.m @@ -35,6 +35,7 @@ - (NSMenu *)menuForEvent:(NSEvent *)event { * view under the pointer now answers NO there, as Chromium's and Electron's no-drag regions do, * and SDL's own answer everywhere else — the empty strip still drags and double-click zooms. */ +#import #import static bool (*g_titlebar_interactive_at)(double x_px, double y_px) = NULL; @@ -245,11 +246,29 @@ void fizzy_macos_viewport_windows_item(void *nswindow, const char *title) { static NSString *const carry_glass_id = @"fizzy.carry.glass"; /* - * The carry window's material from macOS 26: Apple's Liquid Glass (`NSGlassEffectView`), its Clear - * style — the light one, what is carried seen through it rather than set on it — with its own rim - * and its continuous corners, which the window's shape sets (`fizzy_macos_viewport_carry_shape`). - * Nil before macOS 26, for the vibrancy material instead. Looked up by name, and its properties set - * by key, so fizzy builds against SDKs from before it: Liquid Glass is the OS's, not the SDK's. + * Liquid Glass's lens: what is behind it refracted through it, unblurred, bent and drawn in at its + * rim — a drop of water, as the app's own glass is (`core.LiquidField`). AppKit's public styles are + * both frosted (Regular dark, Clear light); this is one of the glass's private variants + * (`_variant`, 2 by default), measured on macOS 26.5 over a striped window, in the window and + * behind it alike: 11 the lens, 6 a bright frost, 13 no glass at all. Set only on the macOS it was + * measured on — a variant renumbered later would leave the carried view with no glass — and only + * where the setter is there. + */ +static const long carry_glass_lens_variant = 11; + +static void carryGlassLens(NSView *glass) { + if ([[NSProcessInfo processInfo] operatingSystemVersion].majorVersion != 26) return; + SEL set_variant = sel_registerName("set_variant:"); + if (![glass respondsToSelector:set_variant]) return; + ((void (*)(id, SEL, long))objc_msgSend)(glass, set_variant, carry_glass_lens_variant); +} + +/* + * The carry window's material from macOS 26: Apple's Liquid Glass (`NSGlassEffectView`) as a lens + * (`carryGlassLens`), else its Clear style — the light one — with its own rim and its continuous + * corners, which the window's shape sets (`fizzy_macos_viewport_carry_shape`). Nil before macOS 26, + * for the vibrancy material instead. Looked up by name, and its properties set by key, so fizzy + * builds against SDKs from before it: Liquid Glass is the OS's, not the SDK's. */ static NSView *carryLiquidGlass(NSRect rect) { if (@available(macOS 26.0, *)) { @@ -259,6 +278,7 @@ void fizzy_macos_viewport_windows_item(void *nswindow, const char *title) { if (glass == nil) return nil; /* NSGlassEffectViewStyleClear. */ [glass setValue:@(1) forKey:@"style"]; + carryGlassLens(glass); [glass setIdentifier:carry_glass_id]; [glass setAutoresizingMask:NSViewWidthSizable | NSViewHeightSizable]; #if !__has_feature(objc_arc) From 490515624fd42cefc77e5fe5d33d17a967a5d8d1 Mon Sep 17 00:00:00 2001 From: foxnne Date: Mon, 5 Oct 2026 11:45:36 -0500 Subject: [PATCH 12/16] popout: the glass a float's window grows out of shows the window's picture uncropped, and hands over in one frame MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things the user still saw as a float's window opened out of a drop. The text doubled near the growth's peak: the carried photograph (cropped to keep its proportions) crossed with the float's picture (stretched to the glass), each laid out at a different size. The picture now takes over in the first quarter of the growth, while the glass is small, and fills the glass with its proportions kept, so at the window's size it is the window's picture exactly. And the window flared opaque at the very end: it showed under the glass as the glass faded off it over 120 ms, both with the window's base. Now the window shows and the glass goes in the same frame (polled: the float window goes to alpha 1 at 356×439 in the poll the glass, at 356×439, is gone). The glass grows as frost (Liquid Glass's own variant), as the window's material is, not as the carried lens (`viewports.carryLens`). Co-Authored-By: Claude Opus 5.5 --- src/backend/backend_native.zig | 7 ++ src/backend/backend_web.zig | 1 + src/backend/native/SDLBackend.zig | 8 ++ .../platform/macos/visual_effect_view.m | 26 ++++++- src/editor/Popout.zig | 73 ++++++++++--------- 5 files changed, 79 insertions(+), 36 deletions(-) diff --git a/src/backend/backend_native.zig b/src/backend/backend_native.zig index fb72e302..857aa43c 100644 --- a/src/backend/backend_native.zig +++ b/src/backend/backend_native.zig @@ -160,6 +160,13 @@ pub const viewports = struct { dvui.currentWindow().backend.impl.viewportCarryShape(vp, radius, alpha); } + /// 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 { + if (comptime !supported) return; + dvui.currentWindow().backend.impl.viewportCarryLens(vp, lens); + } + /// 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; diff --git a/src/backend/backend_web.zig b/src/backend/backend_web.zig index b57c94cc..59876ea8 100644 --- a/src/backend/backend_web.zig +++ b/src/backend/backend_web.zig @@ -154,6 +154,7 @@ pub const viewports = struct { return false; } pub fn carryShape(_: *Viewport, _: ?f32, _: f32) void {} + pub fn carryLens(_: *Viewport, _: bool) void {} pub fn windowRadius() f32 { return 0; } diff --git a/src/backend/native/SDLBackend.zig b/src/backend/native/SDLBackend.zig index cf2ab798..52e90321 100644 --- a/src/backend/native/SDLBackend.zig +++ b/src/backend/native/SDLBackend.zig @@ -883,6 +883,13 @@ pub fn viewportCarryShape(_: *SDLBackend, vp: *Viewport, radius: ?f32, alpha: f3 vp.carry_alpha = std.math.clamp(alpha, 0, 1); } +/// A carry window's Liquid Glass as the lens (the carried view) or as frost (a float's window growing +/// out of it, as its own material is). macOS 26; nothing elsewhere. +pub fn viewportCarryLens(_: *SDLBackend, vp: *Viewport, lens: bool) void { + if (comptime builtin.os.tag != .macos) return; + fizzy_macos_viewport_carry_lens(cocoaWindow(vp.window), @intFromBool(lens)); +} + /// The corner radius of a titled window, points: what a float's window is rounded by /// (`viewportCarryShape` grows the carried glass into it). pub fn windowCornerRadius(_: *SDLBackend) f32 { @@ -1111,6 +1118,7 @@ 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_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_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; diff --git a/src/backend/native/platform/macos/visual_effect_view.m b/src/backend/native/platform/macos/visual_effect_view.m index 5addebfa..80001000 100644 --- a/src/backend/native/platform/macos/visual_effect_view.m +++ b/src/backend/native/platform/macos/visual_effect_view.m @@ -255,12 +255,18 @@ void fizzy_macos_viewport_windows_item(void *nswindow, const char *title) { * where the setter is there. */ static const long carry_glass_lens_variant = 11; +/* The glass's own variant, its Clear style's frost: what a window's material is closest to. */ +static const long carry_glass_frost_variant = 2; -static void carryGlassLens(NSView *glass) { +static void carryGlassVariant(NSView *glass, long variant) { if ([[NSProcessInfo processInfo] operatingSystemVersion].majorVersion != 26) return; SEL set_variant = sel_registerName("set_variant:"); if (![glass respondsToSelector:set_variant]) return; - ((void (*)(id, SEL, long))objc_msgSend)(glass, set_variant, carry_glass_lens_variant); + ((void (*)(id, SEL, long))objc_msgSend)(glass, set_variant, variant); +} + +static void carryGlassLens(NSView *glass) { + carryGlassVariant(glass, carry_glass_lens_variant); } /* @@ -344,6 +350,22 @@ void fizzy_macos_viewport_carry(void *nswindow, void *main_nswindow, long materi return 10; } +/* + * The carry window's Liquid Glass as the lens (`carryGlassLens`) or as frost — a float's window + * growing out of it is frost, as its own material is. Nothing where it is not Liquid Glass. + */ +void fizzy_macos_viewport_carry_lens(void *nswindow, int lens) { + @autoreleasepool { + NSWindow *window = (__bridge NSWindow *)nswindow; + if (window == nil) return; + NSView *frame = [[window contentView] superview]; + for (NSView *v in [frame subviews]) { + if (![[v identifier] isEqualToString:carry_glass_id]) continue; + carryGlassVariant(v, lens ? carry_glass_lens_variant : carry_glass_frost_variant); + } + } +} + /* * 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/editor/Popout.zig b/src/editor/Popout.zig index 13910117..814fc04f 100644 --- a/src/editor/Popout.zig +++ b/src/editor/Popout.zig @@ -61,10 +61,6 @@ const Carry = struct { }; var env_on: ?bool = null; -/// How long the carried glass, grown into a float's window, takes to fade off the window it grew -/// into (`growFrame`). -const grow_fade_ms: f32 = 120; - const Out = struct { /// Which float: its serial, never handed to another (`Floats.Float.serial`) — its name is /// the next float's once it closes. @@ -84,8 +80,6 @@ const Out = struct { const Grow = struct { carry: Carry, - /// Landed: when the window it grew into began to show under it, and it to fade off it. - landed_ns: ?i128 = null, }; /// `FIZZY_POPOUT=1`, on a backend with viewports, where this run can open them (not Wayland). @@ -407,18 +401,17 @@ fn releaseCarry(c: *Carry) void { /// A float made by a drop grows out of the carried glass into its window, as it grows into its /// glass in the main window (`Floats.Landing`): its window shows nothing while the carried glass — /// a carry window (`viewports.openCarry`), the drag's own when it had one, already where the drop -/// is — grows from the drop to the window's frame and rounds to its corners. What it carries comes -/// in as it grows: the carried view's photograph goes, and the float's own picture — what its window -/// will show, last frame's — arrives in its place, scaled to the glass, and the window's base with -/// it. Landed, the glass is the window's size and shows what the window does: the window shows under -/// it at once, and it fades off the window and goes. The window showing while it still grew — past -/// the window's frame and back, when motion is playful — showed two sizes of one window at once. -/// How much of the window shows. Where there are no carry windows, the window shows at once. +/// is — grows from the drop to the window's frame and rounds to its corners. What it carries changes +/// as it begins to grow, while it is small: the carried view's photograph goes, and the float's own +/// picture — what its window will show, last frame's — arrives in its place, filling the glass +/// with its proportions kept, so at the window's size it is the window's picture exactly, base and +/// all. Crossing the two at a size where each was laid out differently showed the text of both, at +/// two sizes. The glass grows as frost, as the window's own material is, not as the carried lens. +/// Landed, the window shows and the glass goes in the same frame: faded off a window already +/// showing under it, both bases at once made the window flare opaque at the end. How much of the +/// window shows. Where there are no carry windows, the window shows at once. fn growFrame(o: *Out, f: *const Floats.Float) f32 { if (!viewports.carries) return 1; - const into = viewports.inMain(o.viewport); - const to: dvui.Rect.Physical = .{ .x = into.x, .y = into.y, .w = into.w, .h = into.h }; - const window_radius = viewports.windowRadius() * dvui.windowNaturalScale(); if (f.landing) |land| { if (o.grow == null) { const c = if (spare) |sp| blk: { @@ -428,9 +421,12 @@ fn growFrame(o: *Out, f: *const Floats.Float) f32 { const vp = viewports.openCarry(.{ .x = land.from.x, .y = land.from.y, .w = land.from.w, .h = land.from.h }) orelse return 1; break :blk Carry{ .viewport = vp }; }; + viewports.carryLens(c.viewport, false); o.grow = .{ .carry = c }; } const g = &o.grow.?; + const into = viewports.inMain(o.viewport); + const to: dvui.Rect.Physical = .{ .x = into.x, .y = into.y, .w = into.w, .h = into.h }; const t = Floats.landedAt(land); const from = land.from; const rect: dvui.Rect.Physical = .{ @@ -439,32 +435,25 @@ fn growFrame(o: *Out, f: *const Floats.Float) f32 { .w = @max(1, std.math.lerp(from.w, to.w, t)), .h = @max(1, std.math.lerp(from.h, to.h, t)), }; - const radius = std.math.lerp(land.radius, window_radius, std.math.clamp(t, 0, 1)); - // The float's picture arrives over the middle of the growth, the photograph going as it does. - const arrive = smoothstep(std.math.clamp((Floats.landingFraction(land) - 0.3) / 0.5, 0, 1)); - // The base comes in as it grows, from the carried glass's none, and hands over to the - // picture's own as that arrives: the window forms out of the glass. - if (!drawGrow(o, g, rect, radius, 1, std.math.clamp(t, 0, 1) * (1 - arrive), arrive, if (land.photo) |tex| .{ .tex = tex, .size = land.photo_size, .fade = 1 - arrive } else null)) return 1; + 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; } - const g = if (o.grow) |*g| g else return 1; - // Landed: at the window's frame, showing what the window shows, it fades off the window, which - // shows under it, and goes. - const now = dvui.currentWindow().frame_time_ns; - const start = g.landed_ns orelse now; - g.landed_ns = start; - const dur = fizzy.core.motion.durationMs(grow_fade_ms); - const frac: f32 = if (dur <= 0) 1 else @as(f32, @floatFromInt(now - start)) / (dur * std.time.ns_per_ms); - if (frac >= 1) { + // Landed: the window shows, and the glass goes with it. + if (o.grow) |*g| { releaseCarry(&g.carry); o.grow = null; - return 1; } - _ = drawGrow(o, g, to, window_radius, 1 - frac, 0, 1, null); - dvui.refresh(null, @src(), null); return 1; } +/// 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; + /// A photograph a growing glass carries (`drawGrow`): the carried view's, at `fade`. const GrowPhoto = struct { tex: dvui.Texture, @@ -486,9 +475,25 @@ fn drawGrow(o: *Out, g: *Grow, rect: dvui.Rect.Physical, radius: f32, alpha: f32 if (picture > 0.01) if (o.target) |target| { const tex = dvui.Texture.fromTargetTemp(target) catch return true; const scale = dvui.windowNaturalScale(); + // Its proportions kept, its middle filling the glass: at the window's size, all of it. + var uv: dvui.Rect = .{ .x = 0, .y = 0, .w = 1, .h = 1 }; + const tw: f32 = @floatFromInt(target.width); + const th: f32 = @floatFromInt(target.height); + if (tw > 0 and th > 0 and rect.w > 0 and rect.h > 0) { + const a_img = tw / th; + const a_box = rect.w / rect.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; + } + } dvui.renderTexture(tex, .{ .r = rect, .s = scale }, .{ .corners = .round(radius / scale), .colormod = dvui.Color.white.opacity(picture), + .uv = uv, }) catch {}; }; return true; From 092a34761bdef47fd63c524999c9ad950a907bf7 Mon Sep 17 00:00:00 2001 From: foxnne Date: Mon, 5 Oct 2026 11:54:53 -0500 Subject: [PATCH 13/16] popout: a float let go over no window opens with its top left where the carried glass's was The carried glass rides below and right of the pointer, but a float let go over no window opened centred on the pointer, so the glass grew out round it rather than down from where it was. The float now opens with its top left at the carried glass's (the drop's head, or its card), and the glass grows into it right and down; the float's picture fills the growing glass from its top left too. With nothing carried (the picker, a test) it still opens round where it was let go. Co-Authored-By: Claude Opus 5.5 --- app/layout/ViewDrag.zig | 23 +++++++++++++++-------- src/editor/Popout.zig | 11 +++-------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/app/layout/ViewDrag.zig b/app/layout/ViewDrag.zig index b638203d..e446dab3 100644 --- a/app/layout/ViewDrag.zig +++ b/app/layout/ViewDrag.zig @@ -1878,19 +1878,26 @@ fn floatOut(l: *Layout, source: []const u8, moved: []const u8, at: ?dvui.Point.P float_rules.nudged(Floats.toRules(state.floats.items.items[i].rect), window) else float_rules.initialRect(Floats.toRules(src.toNatural()), window); - // Let go over no window of the app's (`floatAway`): its size, round where it was let go, out - // there — not held on the main window. + // The glass it was carried in, when a drag let go of it here; the place itself, when nothing + // 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; + // 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 + // round the pointer. With nothing carried, round where it was let go. if (at) |p| { - rect.x = p.x / scale - rect.w / 2; - rect.y = p.y / scale - rect.h / 2; + if (carried) { + rect.x = d.shape_rect.x / scale; + rect.y = d.shape_rect.y / scale; + } else { + rect.x = p.x / scale - rect.w / 2; + rect.y = p.y / scale - rect.h / 2; + } } // Out of a float, home is still where that float came from: the place it opened over is a // float's, and goes with it. const home = if (out_of) |i| state.floats.items.items[i].home else if (holder) |h| state.internName(l.gpa, h) else ""; - // The glass it was carried in, when a drag let go of it here; the place itself, when nothing - // 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; var landing: Floats.Landing = .{ .from = if (carried) d.shape_rect else src, .radius = if (carried) d.shape_radius else core.corners.scaled(core.corners.card) * scale, diff --git a/src/editor/Popout.zig b/src/editor/Popout.zig index 814fc04f..4cd1d814 100644 --- a/src/editor/Popout.zig +++ b/src/editor/Popout.zig @@ -475,20 +475,15 @@ fn drawGrow(o: *Out, g: *Grow, rect: dvui.Rect.Physical, radius: f32, alpha: f32 if (picture > 0.01) if (o.target) |target| { const tex = dvui.Texture.fromTargetTemp(target) catch return true; const scale = dvui.windowNaturalScale(); - // Its proportions kept, its middle filling the glass: at the window's size, all of it. + // Its proportions kept, filling the glass from its top left — where the window grows from + // (`ViewDrag.floatOut`): at the window's size, all of it. var uv: dvui.Rect = .{ .x = 0, .y = 0, .w = 1, .h = 1 }; const tw: f32 = @floatFromInt(target.width); const th: f32 = @floatFromInt(target.height); if (tw > 0 and th > 0 and rect.w > 0 and rect.h > 0) { const a_img = tw / th; const a_box = rect.w / rect.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; } dvui.renderTexture(tex, .{ .r = rect, .s = scale }, .{ .corners = .round(radius / scale), From 88faca5aaaabb72457a00c8026c90c45bdfe4c74 Mon Sep 17 00:00:00 2001 From: foxnne Date: Mon, 5 Oct 2026 11:56:30 -0500 Subject: [PATCH 14/16] popout: the place a view is lifted from keeps its window's base on the frame it is photographed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Where carried things are windows, the lift's photograph is captured from the place's own draw without its background (6105a2f7), and that capture is put back on the screen for the frame. It was put back with `blit`, a dissolve that writes the capture's pixels exactly, clear ones included, so for the frame a view was lifted on the window was see-through under the place — the user saw the desktop through the sidebar for one frame. It is put back source-over (`blitOpaque`) now, over what the frame already has there, as the place's draw would have gone. Co-Authored-By: Claude Opus 5.5 --- app/layout/ViewDrag.zig | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/layout/ViewDrag.zig b/app/layout/ViewDrag.zig index e446dab3..a6a509ad 100644 --- a/app/layout/ViewDrag.zig +++ b/app/layout/ViewDrag.zig @@ -464,7 +464,11 @@ pub fn keepShot(l: *Layout, shot: Shot, pic: *dvui.Picture) void { if (shot.card) { d.takePicture(pic); if (d.texture) |tex| { - core.anim.blit(tex, null, d.texture_rect, 0, 1); + // Over what the frame already has under the place, as its draw would have gone: the + // capture is clear where the place draws nothing — the window's base shows there — and + // put back as a dissolve (`blit`) it replaced that base with nothing, the window + // see-through under the place for the frame it was lifted on. + core.anim.blitOpaque(tex, null, d.texture_rect, 0, 1); // Carried in a window of its own, it is the view's content over that window's // material, which shows through between: not laid over an opaque fill. if (!core.dialogs.carry_windows) d.texture = backed(tex, d.texture_rect); From 18a371af509b081bcc3390014db9773171043d3b Mon Sep 17 00:00:00 2001 From: foxnne Date: Mon, 5 Oct 2026 12:02:14 -0500 Subject: [PATCH 15/16] popout: a float's window shows no move cursor over its traffic lights MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A float's header is the area it is moved by, and dvui shows the move cursor over all of it — over its OS window's traffic lights too, which lie on the header where the OS frames the window. The header's drag area now starts past them: the window's own buttons measured each frame (`viewports.buttonsWidth`, `fizzy_macos_window_buttons_width`: the zoom button's right edge and as much again as the close button stands in), handed to the float (`Floats.Viewport.buttons_w`). Co-Authored-By: Claude Opus 5.5 --- app/layout/Floats.zig | 10 ++++++++-- src/backend/backend_native.zig | 7 +++++++ src/backend/backend_web.zig | 3 +++ src/backend/native/SDLBackend.zig | 9 +++++++++ .../native/platform/macos/visual_effect_view.m | 17 +++++++++++++++++ src/editor/Popout.zig | 3 +++ 6 files changed, 47 insertions(+), 2 deletions(-) diff --git a/app/layout/Floats.zig b/app/layout/Floats.zig index aae1c4e9..a1a2c439 100644 --- a/app/layout/Floats.zig +++ b/app/layout/Floats.zig @@ -110,6 +110,9 @@ pub const Viewport = struct { /// Its OS window has the OS's own buttons for close, minimize and zoom (macOS's traffic lights): /// the float's header draws no close button of its own. os_buttons: bool = false, + /// Natural units from its window's left edge past the OS's own buttons (`os_buttons`), this + /// frame (`Popout`): the part of its header that is theirs, not the float's to be moved by. + buttons_w: f32 = 0, /// The OS asked to close its window (its close button, ⌘W): it closes as from its header. close_asked: bool = false, /// Physical: from where the float is drawn in its band to where its window is over the main @@ -660,8 +663,11 @@ fn drawOne(l: *Layout, i: usize) bool { core.anchor.markRect(win_id, header, true, "float-header:{s}", .{first.name}); if (header_close) |r| core.anchor.markRect(win_id, r, true, "float-close:{s}", .{first.name}); // Moved by its header only: the rest is the view's. Not while it lands — it is going where - // the drop put it — nor while it is out of the way. - win.dragAreaSet(if (landing or aside > 0) .{} else header); + // the drop put it — nor while it is out of the way. Not over its OS window's own buttons + // either, which are theirs: the move cursor showed over the traffic lights. + const buttons_w = if (first.viewport) |vp| (if (vp.os_buttons) vp.buttons_w * scale else 0) else 0; + const drag_area: dvui.Rect.Physical = .{ .x = header.x + buttons_w, .y = header.y, .w = @max(0, header.w - buttons_w), .h = header.h }; + win.dragAreaSet(if (landing or aside > 0) .{} else drag_area); if (!fresh) { // The view fades with the window round it: in over the photograph it grew out of as it diff --git a/src/backend/backend_native.zig b/src/backend/backend_native.zig index 857aa43c..233fd4b5 100644 --- a/src/backend/backend_native.zig +++ b/src/backend/backend_native.zig @@ -167,6 +167,13 @@ pub const viewports = struct { dvui.currentWindow().backend.impl.viewportCarryLens(vp, lens); } + /// 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 { + if (comptime !supported) return 0; + return dvui.currentWindow().backend.impl.viewportButtonsWidth(vp); + } + /// 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; diff --git a/src/backend/backend_web.zig b/src/backend/backend_web.zig index 59876ea8..efaa1a96 100644 --- a/src/backend/backend_web.zig +++ b/src/backend/backend_web.zig @@ -155,6 +155,9 @@ pub const viewports = struct { } pub fn carryShape(_: *Viewport, _: ?f32, _: f32) void {} pub fn carryLens(_: *Viewport, _: bool) void {} + pub fn buttonsWidth(_: *Viewport) f32 { + return 0; + } pub fn windowRadius() f32 { return 0; } diff --git a/src/backend/native/SDLBackend.zig b/src/backend/native/SDLBackend.zig index 52e90321..04fea07a 100644 --- a/src/backend/native/SDLBackend.zig +++ b/src/backend/native/SDLBackend.zig @@ -890,6 +890,14 @@ pub fn viewportCarryLens(_: *SDLBackend, vp: *Viewport, lens: bool) void { fizzy_macos_viewport_carry_lens(cocoaWindow(vp.window), @intFromBool(lens)); } +/// Points from `vp`'s window's left edge past the OS's own buttons in its title bar: the traffic +/// lights of a titled macOS window, with the margin before them. 0 elsewhere. +pub fn viewportButtonsWidth(_: *SDLBackend, vp: *Viewport) f32 { + if (comptime builtin.os.tag != .macos) return 0; + if (vp.passive) return 0; + return @floatCast(fizzy_macos_window_buttons_width(cocoaWindow(vp.window))); +} + /// The corner radius of a titled window, points: what a float's window is rounded by /// (`viewportCarryShape` grows the carried glass into it). pub fn windowCornerRadius(_: *SDLBackend) f32 { @@ -1119,6 +1127,7 @@ extern fn fizzy_macos_viewport_glass(nswindow: ?*anyopaque, main: ?*anyopaque, i 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_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; diff --git a/src/backend/native/platform/macos/visual_effect_view.m b/src/backend/native/platform/macos/visual_effect_view.m index 80001000..35faabe4 100644 --- a/src/backend/native/platform/macos/visual_effect_view.m +++ b/src/backend/native/platform/macos/visual_effect_view.m @@ -340,6 +340,23 @@ void fizzy_macos_viewport_carry(void *nswindow, void *main_nswindow, long materi } } +/* + * Points from a titled window's left edge past its traffic lights: the zoom button's right edge, + * and as much again as the close button stands in from the window's edge. 0 with none showing. + */ +double fizzy_macos_window_buttons_width(void *nswindow) { + @autoreleasepool { + NSWindow *window = (__bridge NSWindow *)nswindow; + if (window == nil) return 0; + NSButton *close = [window standardWindowButton:NSWindowCloseButton]; + NSButton *zoom = [window standardWindowButton:NSWindowZoomButton]; + if (close == nil || zoom == nil || [zoom isHidden] || [zoom superview] == nil) return 0; + const NSRect c = [close convertRect:[close bounds] toView:nil]; + const NSRect z = [zoom convertRect:[zoom bounds] toView:nil]; + return NSMaxX(z) + NSMinX(c); + } +} + /* * A titled window's corner radius, points — what the carried glass grows into as a float's window * opens out of it. AppKit says it nowhere; measured from the windows' own pictures: 16 points from diff --git a/src/editor/Popout.zig b/src/editor/Popout.zig index 4cd1d814..bbc4054a 100644 --- a/src/editor/Popout.zig +++ b/src/editor/Popout.zig @@ -521,6 +521,9 @@ fn windowFrame(state: *State, o: *Out) void { @memcpy(o.title_buf[0..title.len], title); o.title_len = @intCast(title.len); } + // How far in from its window's left edge the OS's own buttons reach, for its header to leave + // them be (`Floats.Viewport.buttons_w`). + f.viewport.?.buttons_w = viewports.buttonsWidth(o.viewport); // Where its window lies over the main window's frame, from where it is drawn in its band, for a // drag to read it there (`Floats.Viewport.main_delta`). { From 5ca36b3fab697e0629cea8ae8e017f7539dd10ee Mon Sep 17 00:00:00 2001 From: foxnne Date: Mon, 5 Oct 2026 12:04:34 -0500 Subject: [PATCH 16/16] popout: the carried bubble stands on a fifth of the window's base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the carried bubble's glass a clear lens, the view's picture in it, over whatever it passed, did not read (the user). The bubble now has the window's base under its picture at 20% opacity — lighter than a window still. `carryBegin`'s `fill` is that opacity outright now, no longer a share of the window's (nothing else passed one). Co-Authored-By: Claude Opus 5.5 --- src/editor/Popout.zig | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/editor/Popout.zig b/src/editor/Popout.zig index bbc4054a..10c6f1b1 100644 --- a/src/editor/Popout.zig +++ b/src/editor/Popout.zig @@ -320,9 +320,10 @@ fn carryFrame(state: *State) void { carry = .{ .viewport = vp }; } const c = &carry.?; - // No base under it: carried, it is the OS's material and what it carries, lighter than a - // window — dialogs and float windows keep theirs. - const drawing = carryBegin(c, place, shape, d.shape_radius, 1, 0) orelse return; + // A little of the window's base under what it carries (`carried_backing`): its glass is clear + // (the lens), and the view's picture alone, over whatever the bubble passes, did not read. + // Lighter than a window still — dialogs and float windows keep their own. + const drawing = carryBegin(c, place, shape, d.shape_radius, 1, carried_backing) orelse return; defer carryEnd(c, drawing); // The carried view's own layer (`core.screens.markCarried`), taken from it: dvui's replay into the main // window, and the float windows' (`windowFrame`), draw nothing of it. @@ -337,6 +338,9 @@ fn carryFrame(state: *State) void { } } +/// How opaque the window's base is under a carried view (`carryFrame`). +const carried_backing: f32 = 0.2; + /// 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 { @@ -346,10 +350,9 @@ const CarryDrawing = struct { /// Put `c`'s window where it shows `place` of the main window's frame, in its shape (`radius`, /// physical), `alpha` opaque, and start its picture, read from `shape` of the frame (the same rect, -/// or the band of a float's window it lies over): `fill` (0…1) of the main window's base under it, -/// over the window's material, as a float's window stands on it — none, and it is the material -/// alone, which glass in it reads as nothing. Drawn into it until `carryEnd`; null with nothing to -/// draw into. +/// or the band of a float's window it lies over): the main window's base under it `fill` (0…1) +/// opaque, over the window's material — none, and it is the material alone, which glass in it +/// reads as nothing. Drawn into it until `carryEnd`; null with nothing to draw into. fn carryBegin(c: *Carry, place: dvui.Rect.Physical, shape: dvui.Rect.Physical, radius: f32, alpha: f32, fill: f32) ?CarryDrawing { const cw = dvui.currentWindow(); const placed = viewports.placeMain(c.viewport, .{ .x = place.x, .y = place.y, .w = place.w, .h = place.h }); @@ -378,8 +381,8 @@ fn carryBegin(c: *Carry, place: dvui.Rect.Physical, shape: dvui.Rect.Physical, r dvui.alphaSet(1); defer dvui.alphaSet(prev_alpha); if (fill > 0) { - var color = base(true); - color.a = @intFromFloat(@round(@as(f32, @floatFromInt(color.a)) * std.math.clamp(fill, 0, 1))); + var color = base(false); + color.a = @intFromFloat(@round(255 * std.math.clamp(fill, 0, 1))); shape.fill(dvui.CornerRect.Physical.all(radius), .{ .color = .{ .color = color } }); } }