diff --git a/app/layout/Floats.zig b/app/layout/Floats.zig index dcf49a93..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 @@ -428,8 +431,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); @@ -656,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/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..a6a509ad 100644 --- a/app/layout/ViewDrag.zig +++ b/app/layout/ViewDrag.zig @@ -464,8 +464,14 @@ 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); - d.texture = backed(tex, d.texture_rect); + // 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); } return; } @@ -478,6 +484,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; @@ -1074,22 +1084,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. @@ -1104,7 +1105,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; @@ -1123,6 +1126,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 @@ -1303,7 +1333,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 @@ -1466,8 +1500,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. @@ -1477,7 +1514,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`), @@ -1804,12 +1841,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,25 +1873,35 @@ 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 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. - if (at) |p| { - 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 state.internName(l.gpa, source); // 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| { + 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 ""; 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, @@ -1873,7 +1923,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 diff --git a/core/dialogs.zig b/core/dialogs.zig index 57776b90..8011cf0b 100644 --- a/core/dialogs.zig +++ b/core/dialogs.zig @@ -177,6 +177,11 @@ pub fn carriedGlass(id: dvui.Id, r: dvui.Rect.Physical, scale: f32) void { glassShadow(r, corners, scale, surfaceShadow(), 1); } +/// 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/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/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()) { diff --git a/src/backend/backend_native.zig b/src/backend/backend_native.zig index fb72e302..233fd4b5 100644 --- a/src/backend/backend_native.zig +++ b/src/backend/backend_native.zig @@ -160,6 +160,20 @@ 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); + } + + /// 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 b57c94cc..efaa1a96 100644 --- a/src/backend/backend_web.zig +++ b/src/backend/backend_web.zig @@ -154,6 +154,10 @@ pub const viewports = struct { return false; } 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 315658c5..04fea07a 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 { @@ -874,6 +883,21 @@ 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)); +} + +/// 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 { @@ -1102,6 +1126,8 @@ 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_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; @@ -2673,6 +2699,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}); diff --git a/src/backend/native/platform/macos/visual_effect_view.m b/src/backend/native/platform/macos/visual_effect_view.m index 7df26876..35faabe4 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; @@ -241,12 +242,66 @@ 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"; + +/* + * 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; +/* 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 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, variant); +} + +static void carryGlassLens(NSView *glass) { + carryGlassVariant(glass, 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, *)) { + 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"]; + carryGlassLens(glass); + [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 +316,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]; @@ -280,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 @@ -290,6 +367,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 @@ -309,23 +402,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 diff --git a/src/editor/Popout.zig b/src/editor/Popout.zig index f65f2823..10c6f1b1 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. @@ -51,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. @@ -74,10 +80,6 @@ const Out = struct { const Grow = struct { carry: Carry, - /// Its corner radius, physical, as last shaped. - radius: f32 = 0, - /// 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). @@ -99,6 +101,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`). @@ -238,7 +243,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,17 +257,16 @@ 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`), +/// 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 { if (!viewports.carries) return; const d = &state.view_drag; @@ -277,14 +281,32 @@ 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(); - 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; if (!want) { if (carry) |*c| if (c.target) |t| { t.clear(); @@ -294,21 +316,31 @@ 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; + // 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); - // 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", .{}); } } +/// 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 { @@ -316,14 +348,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 -/// 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 { +/// 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 `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 = 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))); @@ -346,7 +380,11 @@ fn carryBegin(c: *Carry, shape: dvui.Rect.Physical, radius: f32, alpha: f32) ?Ca 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(false); + color.a = @intFromFloat(@round(255 * std.math.clamp(fill, 0, 1))); + shape.fill(dvui.CornerRect.Physical.all(radius), .{ .color = .{ .color = color } }); + } } return .{ .prev = prev, .shown = shown }; } @@ -366,27 +404,33 @@ 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. -fn growFrame(o: *Out, f: *const Floats.Float) bool { - if (!viewports.carries) return false; +/// 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; 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 }; }; + viewports.carryLens(c.viewport, false); o.grow = .{ .carry = c }; } 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 t = Floats.landedAt(land); const from = land.from; const rect: dvui.Rect.Physical = .{ .x = std.math.lerp(from.x, to.x, t), @@ -394,32 +438,67 @@ fn growFrame(o: *Out, f: *const Floats.Float) bool { .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)); - const drawing = carryBegin(&g.carry, rect, g.radius, 1) orelse return false; - 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)); - } - return true; + 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 false; - 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 false; } - viewports.carryShape(g.carry.viewport, g.radius, 1 - frac); - if (g.carry.target) |t| viewports.present(g.carry.viewport, t); - dvui.refresh(null, @src(), null); - return false; + 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, + 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(); + // 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 else uv.h = a_img / a_box; + } + dvui.renderTexture(tex, .{ .r = rect, .s = scale }, .{ + .corners = .round(radius / scale), + .colormod = dvui.Color.white.opacity(picture), + .uv = uv, + }) catch {}; + }; + return true; +} + +fn smoothstep(x: f32) f32 { + return x * x * (3 - 2 * x); } fn windowFrame(state: *State, o: *Out) void { @@ -445,6 +524,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`). { @@ -460,10 +542,19 @@ 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); - // 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); + // 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; + } + // 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. @@ -508,11 +599,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; 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);