From 5e10e89675c204251d16080282e9bc122d768432 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 12:44:33 +0000 Subject: [PATCH 1/8] Glass over a picture of the caller's own: LiquidField.drawPicture, for pixi's dropper magnifier The first plugin consumer in the native windows plan (#226) is pixi's colour dropper magnifier as liquid glass. Its glass shows pixi's own pixel-exact zoom, not what lies behind it, so it reads nothing from the frame: - `LiquidField.drawPicture(picture, covered)` queues the field over a texture the caller drew (`dvui.deferRender`, at full alpha, as every glass is), and returns false where there is no glass program, so the caller can draw a fallback. `pictureMargin` is how far past the shapes the picture should reach for the earlier glass, whose rim pulls from just outside (the web, where no look is published). - `LiquidField.lens` and `glass_look.forLens`: the slider's in-app look with no frost, colour or lift, so the middle is the picture as it is and a colour picked through it is the colour under it. Only the edge's band bends and is lit, following the slider. Tested beside the other looks. No SDK ABI change: `LiquidField` crosses no boundary. pixi picks it up from the next SDK release, and builds against SDKs without it (it checks `@hasDecl`). Checked: `zig test core/gfx/glass_look.zig` (13 pass), `zig fmt`, `zig ast-check`. The full build was not run: dependency downloads are blocked in the environment this was written in. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Euk6iCavQdW7MG5FN2vvFw --- core/gfx/LiquidField.zig | 56 ++++++++++++++++++++++++++++++++++++++-- core/gfx/glass_look.zig | 31 ++++++++++++++++++++++ 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/core/gfx/LiquidField.zig b/core/gfx/LiquidField.zig index d12c302e..d0b7bccb 100644 --- a/core/gfx/LiquidField.zig +++ b/core/gfx/LiquidField.zig @@ -20,7 +20,8 @@ //! //! **Drawing.** Inside a layer's `dvui.deferRender` job, after its capture: `draw` with the //! frost and what it covers. False where the backend has no programs (`core.gfx.programs`) or the -//! program is not ready yet, and the caller draws its meshes instead, without the joins. +//! program is not ready yet, and the caller draws its meshes instead, without the joins. A lens +//! over a picture of the caller's own (a magnifier) needs no capture: `drawPicture` queues itself. //! //! Layers do not join each other: a dialog over a drop is two layers, the dialog's capture taken //! with the drop already on the frame. @@ -75,6 +76,9 @@ refraction: f32 = 1, /// It carries text — a dialog, a menu — and keeps some frost over a clear lens to read /// (`glass_look.forText`). text: bool = false, +/// It is a lens over a picture of the caller's own (`drawPicture`): its middle the picture as it +/// is, only the band along its edge glass (`glass_look.forLens`). +lens: bool = false, pub fn add(self: *LiquidField, shape: Shape) void { if (self.len >= max_shapes) return; @@ -325,7 +329,7 @@ pub fn draw(self: *const LiquidField, frost: dvui.Texture, covered: dvui.Rect.Ph // The window behind the glass, published for this frame. u.backdrop[0] = if (opaqueWindow()) 1 else 0; // And the glass the slider makes of it this frame (`publishLook`). - if (publishedLook()) |l| self.applyLook(&u, if (self.text) glass_look.forText(l) else glass_look.forDrops(l)); + if (publishedLook()) |l| self.applyLook(&u, if (self.lens) glass_look.forLens(l) else if (self.text) glass_look.forText(l) else glass_look.forDrops(l)); const textures = [_]?*anyopaque{programs.handle(sharp)}; if (!h.begin(id, &textures, textures.len, u.vec4s(), uniform_vec4s)) return false; defer h.end(); @@ -336,6 +340,54 @@ pub fn draw(self: *const LiquidField, frost: dvui.Texture, covered: dvui.Rect.Ph return true; } +/// The shapes as a lens over a picture of the caller's own — a magnifier's zoom, a loupe — rather +/// than over what is behind them. `picture` is a picture of `covered` (physical pixels, window +/// coordinates), reaching `pictureMargin` past the shapes. Its middle is the picture as it is, +/// unblurred, untinted and unlit, so a colour picked through it is the colour under it; only the +/// band along its edge is glass (`lens`, `glass_look.forLens`). Where the app publishes no look +/// (the web), it is the earlier glass, its rim pulling from just outside the shape, which is what +/// the margin is for. Nothing under it is read, so it costs no capture. +/// +/// Queued in order with the frame (`dvui.deferRender`), as every glass is, and drawn at full +/// alpha: glass replaces what it covers, and at part alpha it left a hole. `picture` must live +/// until the frame ends. False, having queued nothing, where there is no glass program (`ready`): +/// draw the picture some other way. +pub fn drawPicture(self: *const LiquidField, picture: dvui.Texture, covered: dvui.Rect.Physical) bool { + if (self.len == 0) return true; + if (!ready()) return false; + const job = dvui.currentWindow().arena().create(PictureJob) catch return false; + job.* = .{ .field = self.*, .picture = picture, .covered = covered }; + job.field.lens = true; + job.field.tint = null; + job.field.mix = 0; + job.field.lift = 0; + for (job.field.shapes[0..job.field.len]) |*sh| sh.blur = 0; + dvui.deferRender(job, PictureJob.run); + return true; +} + +/// Physical pixels the picture `drawPicture` lays the glass over should reach past the shapes: as +/// far as the earlier glass's rim reaches out (`liquid_glass.margin`). Apple's lens pulls only +/// from inside the shape and needs none of it. +pub fn pictureMargin(self: *const LiquidField) f32 { + return liquid_glass.margin(self.look(), self.scale); +} + +/// What `drawPicture` hands to the replay, in the frame's arena. +const PictureJob = struct { + field: LiquidField, + picture: dvui.Texture, + covered: dvui.Rect.Physical, + + fn run(ctx: ?*anyopaque) void { + const self: *PictureJob = @ptrCast(@alignCast(ctx orelse return)); + const prev_alpha = dvui.currentWindow().alpha; + dvui.alphaSet(1); + defer dvui.alphaSet(prev_alpha); + _ = self.field.draw(self.picture, self.covered, null); + } +}; + /// One quad per group, its vertex colour telling the program which pass and which shapes. fn quads(self: *const LiquidField, groups: Clusters, glass: bool, frost: dvui.Texture) void { const arena = dvui.currentWindow().arena(); diff --git a/core/gfx/glass_look.zig b/core/gfx/glass_look.zig index 22b6bf8d..e746dd56 100644 --- a/core/gfx/glass_look.zig +++ b/core/gfx/glass_look.zig @@ -261,6 +261,21 @@ pub fn forText(look: InApp) InApp { return l; } +/// `look` for a lens over a picture of the caller's own — a magnifier's zoom, a loupe — rather than +/// over what is behind it (`LiquidField.drawPicture`): the picture is what it shows, as it is, so +/// its middle takes no frost, none of the window's colour and no lift, at any point on the slider +/// — a colour picked through it is the colour under it. Only the band along its edge is glass, +/// bending the picture toward its rim and lit there, and that follows the slider as every glass +/// does: a clear lens at the bottom, its shine going at the top. +pub fn forLens(look: InApp) InApp { + var l = look; + l.frost = 0; + l.mix = 0; + l.text_mix = 0; + l.lift = 0; + return l; +} + fn smoothstep(x: f32) f32 { return x * x * (3 - 2 * x); } @@ -325,6 +340,22 @@ test "a surface carrying text keeps some frost over a clear lens, none once it i try std.testing.expectEqual(inApp(1).frost, forText(inApp(1)).frost); } +test "a lens over a picture leaves its middle as it is, everywhere on the slider" { + var i: usize = 0; + while (i <= 20) : (i += 1) { + const t = @as(f32, @floatFromInt(i)) / 20; + const l = forLens(inApp(t)); + try std.testing.expectEqual(@as(f32, 0), l.frost); + try std.testing.expectEqual(@as(f32, 0), l.mix); + try std.testing.expectEqual(@as(f32, 0), l.lift); + // Its edge is the slider's glass all the same. + try std.testing.expectEqual(inApp(t).bend, l.bend); + try std.testing.expectEqual(inApp(t).rim, l.rim); + } + // A clear lens at the bottom, folding at its rim as Apple's does. + try std.testing.expect(forLens(inApp(0)).bend > 1); +} + test "a drop's plain blur is light, growing with the glass's roughness" { try std.testing.expectApproxEqAbs(drop_blur_min, native(0).blur, 1e-6); try std.testing.expectApproxEqAbs(drop_blur, native(1).blur, 1e-6); From 31c828f2a26b4f3c7dbab04f4cee978eaf35d31d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 12:56:13 +0000 Subject: [PATCH 2/8] sdk 0.2.18: glass over a picture of the caller's own reaches the plugins that build core in No change to the plugin boundary: the shape fingerprint is 0.2.17's, so every installed plugin keeps loading. A plugin that builds `core` into itself (pixi) picks these up by repinning to 0.2.18: - `LiquidField.drawPicture` and `glass_look.forLens`: glass over a picture the plugin drew, for pixi's colour dropper magnifier; - the glass on one slider since 0.2.17 (#227: `glass_look`, Apple's lens in the in-app glass, `core.native_glass`), and the pop-out window pieces of #223 and #224. Merging this to main tags sdk-v0.2.18 and publishes its tarball (`sdk-tag.yml`). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Euk6iCavQdW7MG5FN2vvFw --- sdk/sdk_version.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/sdk_version.zig b/sdk/sdk_version.zig index f60a54cd..63a60846 100644 --- a/sdk/sdk_version.zig +++ b/sdk/sdk_version.zig @@ -22,5 +22,5 @@ const std = @import("std"); pub const sdk_version = std.SemanticVersion{ .major = 0, .minor = 2, - .patch = 17, + .patch = 18, }; From b66d2411248814497520acda836fffe48624e365 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 13:13:20 +0000 Subject: [PATCH 3/8] tests: LiquidField.drawPicture compiled in fizzy's own build, pictureMargin checked Nothing in fizzy calls `drawPicture` (pixi's dropper magnifier does), and Zig compiles only what is called, so a green CI said nothing about it. This test takes its address, which compiles it, and checks that the picture reaches as far past the shape as the earlier glass's rim pulls from, and further at a stronger refraction. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Euk6iCavQdW7MG5FN2vvFw --- tests/integration.zig | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/integration.zig b/tests/integration.zig index 5c9b8360..f4b51e19 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -4454,6 +4454,21 @@ test "liquid field: groups are the shapes that touch, each drawn as its own quad try std.testing.expectEqual(@as(f32, 22), u.shapes[1][0][0]); } +test "liquid field: a lens's picture reaches as far past its shapes as the earlier glass's rim pulls from" { + const LF = fizzy.core.LiquidField; + var f: LF = .{ .scale = 2, .refraction = 1 }; + f.add(LF.Shape.circle(.{ .x = 100, .y = 100 }, 60)); + // The earlier glass (the web's) pulls its rim from `liquid_glass.refraction` points out at full + // strength: a picture reaching that far past the shape has something there to show. + try std.testing.expect(f.pictureMargin() >= fizzy.core.liquid_glass.refraction * 2); + // Further at the strongest refraction setting. + var strong = f; + strong.refraction = 2; + try std.testing.expect(strong.pictureMargin() > f.pictureMargin()); + // Nothing in fizzy draws one (pixi's dropper magnifier does): compiled here all the same. + _ = &LF.drawPicture; +} + test "liquid glass: the rings of a pane run the way dvui's paths do" { const r: dvui.Rect.Physical = .{ .x = 0, .y = 0, .w = 200, .h = 100 }; var pts: [4 * 7 + 2 * (8 - 1) + 2 * (4 - 1)]dvui.Point.Physical = undefined; From 66c0d03d71b7f2e98c191551935711d0428e27d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 14:41:51 +0000 Subject: [PATCH 4/8] Take LiquidField.drawPicture back out: pixi's magnifier wears the drop zones' glass Tried on a Mac with pixi's branch, the dropper's orb read as a zoomed picture with a bent edge, nothing like the drop zones. The lens over a picture stripped what makes the app's glass look like glass: the frost, the window's colour and the lift. Pixi's orb now uses the drop zones' own glass over the canvas (`dialogs.carriedField`, already in the SDK) and carries its zoom over it, as a drop zone carries its icon. So `drawPicture`, `pictureMargin`, `lens`, `glass_look.forLens` and their tests have no caller, and go. This PR is now the SDK bump alone. It ships the one-slider glass of #227 to plugins that build `core` in, so pixi's orb matches the host's drop zones. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Euk6iCavQdW7MG5FN2vvFw --- core/gfx/LiquidField.zig | 56 ++-------------------------------------- core/gfx/glass_look.zig | 31 ---------------------- tests/integration.zig | 15 ----------- 3 files changed, 2 insertions(+), 100 deletions(-) diff --git a/core/gfx/LiquidField.zig b/core/gfx/LiquidField.zig index d0b7bccb..d12c302e 100644 --- a/core/gfx/LiquidField.zig +++ b/core/gfx/LiquidField.zig @@ -20,8 +20,7 @@ //! //! **Drawing.** Inside a layer's `dvui.deferRender` job, after its capture: `draw` with the //! frost and what it covers. False where the backend has no programs (`core.gfx.programs`) or the -//! program is not ready yet, and the caller draws its meshes instead, without the joins. A lens -//! over a picture of the caller's own (a magnifier) needs no capture: `drawPicture` queues itself. +//! program is not ready yet, and the caller draws its meshes instead, without the joins. //! //! Layers do not join each other: a dialog over a drop is two layers, the dialog's capture taken //! with the drop already on the frame. @@ -76,9 +75,6 @@ refraction: f32 = 1, /// It carries text — a dialog, a menu — and keeps some frost over a clear lens to read /// (`glass_look.forText`). text: bool = false, -/// It is a lens over a picture of the caller's own (`drawPicture`): its middle the picture as it -/// is, only the band along its edge glass (`glass_look.forLens`). -lens: bool = false, pub fn add(self: *LiquidField, shape: Shape) void { if (self.len >= max_shapes) return; @@ -329,7 +325,7 @@ pub fn draw(self: *const LiquidField, frost: dvui.Texture, covered: dvui.Rect.Ph // The window behind the glass, published for this frame. u.backdrop[0] = if (opaqueWindow()) 1 else 0; // And the glass the slider makes of it this frame (`publishLook`). - if (publishedLook()) |l| self.applyLook(&u, if (self.lens) glass_look.forLens(l) else if (self.text) glass_look.forText(l) else glass_look.forDrops(l)); + if (publishedLook()) |l| self.applyLook(&u, if (self.text) glass_look.forText(l) else glass_look.forDrops(l)); const textures = [_]?*anyopaque{programs.handle(sharp)}; if (!h.begin(id, &textures, textures.len, u.vec4s(), uniform_vec4s)) return false; defer h.end(); @@ -340,54 +336,6 @@ pub fn draw(self: *const LiquidField, frost: dvui.Texture, covered: dvui.Rect.Ph return true; } -/// The shapes as a lens over a picture of the caller's own — a magnifier's zoom, a loupe — rather -/// than over what is behind them. `picture` is a picture of `covered` (physical pixels, window -/// coordinates), reaching `pictureMargin` past the shapes. Its middle is the picture as it is, -/// unblurred, untinted and unlit, so a colour picked through it is the colour under it; only the -/// band along its edge is glass (`lens`, `glass_look.forLens`). Where the app publishes no look -/// (the web), it is the earlier glass, its rim pulling from just outside the shape, which is what -/// the margin is for. Nothing under it is read, so it costs no capture. -/// -/// Queued in order with the frame (`dvui.deferRender`), as every glass is, and drawn at full -/// alpha: glass replaces what it covers, and at part alpha it left a hole. `picture` must live -/// until the frame ends. False, having queued nothing, where there is no glass program (`ready`): -/// draw the picture some other way. -pub fn drawPicture(self: *const LiquidField, picture: dvui.Texture, covered: dvui.Rect.Physical) bool { - if (self.len == 0) return true; - if (!ready()) return false; - const job = dvui.currentWindow().arena().create(PictureJob) catch return false; - job.* = .{ .field = self.*, .picture = picture, .covered = covered }; - job.field.lens = true; - job.field.tint = null; - job.field.mix = 0; - job.field.lift = 0; - for (job.field.shapes[0..job.field.len]) |*sh| sh.blur = 0; - dvui.deferRender(job, PictureJob.run); - return true; -} - -/// Physical pixels the picture `drawPicture` lays the glass over should reach past the shapes: as -/// far as the earlier glass's rim reaches out (`liquid_glass.margin`). Apple's lens pulls only -/// from inside the shape and needs none of it. -pub fn pictureMargin(self: *const LiquidField) f32 { - return liquid_glass.margin(self.look(), self.scale); -} - -/// What `drawPicture` hands to the replay, in the frame's arena. -const PictureJob = struct { - field: LiquidField, - picture: dvui.Texture, - covered: dvui.Rect.Physical, - - fn run(ctx: ?*anyopaque) void { - const self: *PictureJob = @ptrCast(@alignCast(ctx orelse return)); - const prev_alpha = dvui.currentWindow().alpha; - dvui.alphaSet(1); - defer dvui.alphaSet(prev_alpha); - _ = self.field.draw(self.picture, self.covered, null); - } -}; - /// One quad per group, its vertex colour telling the program which pass and which shapes. fn quads(self: *const LiquidField, groups: Clusters, glass: bool, frost: dvui.Texture) void { const arena = dvui.currentWindow().arena(); diff --git a/core/gfx/glass_look.zig b/core/gfx/glass_look.zig index e746dd56..22b6bf8d 100644 --- a/core/gfx/glass_look.zig +++ b/core/gfx/glass_look.zig @@ -261,21 +261,6 @@ pub fn forText(look: InApp) InApp { return l; } -/// `look` for a lens over a picture of the caller's own — a magnifier's zoom, a loupe — rather than -/// over what is behind it (`LiquidField.drawPicture`): the picture is what it shows, as it is, so -/// its middle takes no frost, none of the window's colour and no lift, at any point on the slider -/// — a colour picked through it is the colour under it. Only the band along its edge is glass, -/// bending the picture toward its rim and lit there, and that follows the slider as every glass -/// does: a clear lens at the bottom, its shine going at the top. -pub fn forLens(look: InApp) InApp { - var l = look; - l.frost = 0; - l.mix = 0; - l.text_mix = 0; - l.lift = 0; - return l; -} - fn smoothstep(x: f32) f32 { return x * x * (3 - 2 * x); } @@ -340,22 +325,6 @@ test "a surface carrying text keeps some frost over a clear lens, none once it i try std.testing.expectEqual(inApp(1).frost, forText(inApp(1)).frost); } -test "a lens over a picture leaves its middle as it is, everywhere on the slider" { - var i: usize = 0; - while (i <= 20) : (i += 1) { - const t = @as(f32, @floatFromInt(i)) / 20; - const l = forLens(inApp(t)); - try std.testing.expectEqual(@as(f32, 0), l.frost); - try std.testing.expectEqual(@as(f32, 0), l.mix); - try std.testing.expectEqual(@as(f32, 0), l.lift); - // Its edge is the slider's glass all the same. - try std.testing.expectEqual(inApp(t).bend, l.bend); - try std.testing.expectEqual(inApp(t).rim, l.rim); - } - // A clear lens at the bottom, folding at its rim as Apple's does. - try std.testing.expect(forLens(inApp(0)).bend > 1); -} - test "a drop's plain blur is light, growing with the glass's roughness" { try std.testing.expectApproxEqAbs(drop_blur_min, native(0).blur, 1e-6); try std.testing.expectApproxEqAbs(drop_blur, native(1).blur, 1e-6); diff --git a/tests/integration.zig b/tests/integration.zig index f4b51e19..5c9b8360 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -4454,21 +4454,6 @@ test "liquid field: groups are the shapes that touch, each drawn as its own quad try std.testing.expectEqual(@as(f32, 22), u.shapes[1][0][0]); } -test "liquid field: a lens's picture reaches as far past its shapes as the earlier glass's rim pulls from" { - const LF = fizzy.core.LiquidField; - var f: LF = .{ .scale = 2, .refraction = 1 }; - f.add(LF.Shape.circle(.{ .x = 100, .y = 100 }, 60)); - // The earlier glass (the web's) pulls its rim from `liquid_glass.refraction` points out at full - // strength: a picture reaching that far past the shape has something there to show. - try std.testing.expect(f.pictureMargin() >= fizzy.core.liquid_glass.refraction * 2); - // Further at the strongest refraction setting. - var strong = f; - strong.refraction = 2; - try std.testing.expect(strong.pictureMargin() > f.pictureMargin()); - // Nothing in fizzy draws one (pixi's dropper magnifier does): compiled here all the same. - _ = &LF.drawPicture; -} - test "liquid glass: the rings of a pane run the way dvui's paths do" { const r: dvui.Rect.Physical = .{ .x = 0, .y = 0, .w = 200, .h = 100 }; var pts: [4 * 7 + 2 * (8 - 1) + 2 * (4 - 1)]dvui.Point.Physical = undefined; From 1f687626741f65961854d8c0e3c321f2bfb87c06 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 15:07:50 +0000 Subject: [PATCH 5/8] A plugin's glass is the OS's outside a view drag: native_glass.offered, for pixi's dropper magnifier Fizzy's overlay of the OS's Liquid Glass (macOS 26, floats as windows, native glass on) showed only a view drag's glass. Pixi's dropper magnifier wants the same glass as the drop zones, so the app now publishes each frame whether the OS draws glass declared outside a drag (`core.native_glass.publishOffered` / `offered`). A plugin that sees it declares its glass with `native_glass.add`, and marks what goes over it with `core.screens.markCarried`. The overlay already stays while any glass is declared and takes carried layers over its glass, so it shows them as it shows the drag's drops and their icons. The app's windows draw neither. Off macOS 26 nothing is offered, and plugins draw the app's glass. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Euk6iCavQdW7MG5FN2vvFw --- core/native_glass.zig | 18 +++++++++++++++++- src/editor/Popout.zig | 8 +++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/core/native_glass.zig b/core/native_glass.zig index 688b792d..b7905b44 100644 --- a/core/native_glass.zig +++ b/core/native_glass.zig @@ -5,7 +5,8 @@ //! app reconciles the OS's glass with it at the end of the frame; nothing of it is drawn by the app. //! //! In dvui's data, as `screens` is, so whatever draws the glass — fizzy, a plugin's drop zones — -//! declares it the same way, with no SDK between them. +//! declares it the same way, with no SDK between them. Outside a drag, glass a plugin declares +//! shows in the same overlay wherever the OS offers it (`offered`). const std = @import("std"); const dvui = @import("dvui"); @@ -52,6 +53,21 @@ pub fn publishOn(on_: bool) void { current().on = on_; } +/// Whether the OS can draw glass declared outside a view drag this frame (a plugin's own, such as +/// pixi's dropper magnifier), set by the app before anything draws. While it can, glass declared +/// with `add` shows in the app's overlay of the OS's glass whatever else is on, and the layers +/// marked to go over it (`core.screens.markCarried`) are taken into the overlay's picture. The +/// app's own windows draw none of either. Where it cannot, draw the app's own glass. +pub fn publishOffered(on_: bool) void { + dvui.dataSet(null, key_id, "_offered", on_); +} + +/// Whether the OS draws glass declared this frame outside a view drag (`publishOffered`): declare +/// it (`add`) and draw none. +pub fn offered() bool { + return dvui.dataGet(null, key_id, "_offered", bool) orelse false; +} + /// Whether the carried view's picture goes under the OS's glass this frame, in a window of its own /// beneath the drag's (the app's, each frame): the glass then bends it, a lens over what is carried. /// The drag hands it over rather than drawing it over the glass. diff --git a/src/editor/Popout.zig b/src/editor/Popout.zig index 06acbcd3..400f65ae 100644 --- a/src/editor/Popout.zig +++ b/src/editor/Popout.zig @@ -169,6 +169,10 @@ pub fn beginFrame(state: *State) void { // is on, and after it while its drops are still running back together (`ViewDrag.last_pending`) // — their going, as their coming, is the OS's glass too. fizzy.core.native_glass.publishOn(nativeGlass() and (state.view_drag.active() or state.view_drag.last_pending_count > 0)); + // And glass declared outside a drag, such as a plugin's (pixi's dropper magnifier): the OS's + // wherever it has it, in the same overlay, which stays while any glass is declared + // (`overlayFrame`). + fizzy.core.native_glass.publishOffered(nativeGlass()); // And the carried view's picture under that glass, in a window of its own (`photoFrame`). fizzy.core.native_glass.publishUnder(nativeGlass() and viewports.carries); fizzy.core.screens.publishBeyond(viewports.carries and state.view_drag.active()); @@ -458,7 +462,9 @@ fn overlaySpacing(s: f32) f32 { /// what is under them. The carried view's photograph lies under its drop's glass (`overlayPhoto`); /// what goes over the glass — the drop's name, the bubbles' icons (`core.screens.markCarried`) — is /// taken into the overlay's picture. Glass and pictures change in one transaction. It goes when the -/// drag does; a float the drop opens grows out of a carry window of its own (`growFrame`). +/// drag does; a float the drop opens grows out of a carry window of its own (`growFrame`). Glass +/// declared outside a drag (`core.native_glass.offered`: pixi's dropper magnifier) keeps it up the +/// same way, with what is marked to go over it. fn overlayFrame(state: *State) void { const d = &state.view_drag; // Kept past the drag while its drops still go, and gone once there is no glass left. From db35c41998751cd8a4492e1237b1e8d3f3052b3f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 15:20:00 +0000 Subject: [PATCH 6/8] LiquidField.drawPicture again: pixi's orb is a clear lens over its zoom, refracting hard The dropper's orb in the drop zones' frosted glass, with the zoom laid over it, showed only a rim of glass: flat on the web, and with nothing refracting under the native glass. The user wants it the other way: no frost and a lot of refraction, a liquid orb over the zoom. So the glass goes over the picture again. - In the app's glass that is `drawPicture`, a lens over the plugin's own picture, its middle exact, its rim bending it. It is restored from b66d241. - Natively the plugin declares a clear piece (`frost = 0`) over a zoom it draws in the window, and the OS's lens refracts the window beneath. `pictureMargin` now reckons with a shape's lens past 1, which the orb uses to bend harder. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Euk6iCavQdW7MG5FN2vvFw --- core/gfx/LiquidField.zig | 58 ++++++++++++++++++++++++++++++++++++++-- core/gfx/glass_look.zig | 31 +++++++++++++++++++++ tests/integration.zig | 19 +++++++++++++ 3 files changed, 106 insertions(+), 2 deletions(-) diff --git a/core/gfx/LiquidField.zig b/core/gfx/LiquidField.zig index d12c302e..89631dc9 100644 --- a/core/gfx/LiquidField.zig +++ b/core/gfx/LiquidField.zig @@ -20,7 +20,8 @@ //! //! **Drawing.** Inside a layer's `dvui.deferRender` job, after its capture: `draw` with the //! frost and what it covers. False where the backend has no programs (`core.gfx.programs`) or the -//! program is not ready yet, and the caller draws its meshes instead, without the joins. +//! program is not ready yet, and the caller draws its meshes instead, without the joins. A lens +//! over a picture of the caller's own (a magnifier) needs no capture: `drawPicture` queues itself. //! //! Layers do not join each other: a dialog over a drop is two layers, the dialog's capture taken //! with the drop already on the frame. @@ -75,6 +76,9 @@ refraction: f32 = 1, /// It carries text — a dialog, a menu — and keeps some frost over a clear lens to read /// (`glass_look.forText`). text: bool = false, +/// It is a lens over a picture of the caller's own (`drawPicture`): its middle the picture as it +/// is, only the band along its edge glass (`glass_look.forLens`). +lens: bool = false, pub fn add(self: *LiquidField, shape: Shape) void { if (self.len >= max_shapes) return; @@ -325,7 +329,7 @@ pub fn draw(self: *const LiquidField, frost: dvui.Texture, covered: dvui.Rect.Ph // The window behind the glass, published for this frame. u.backdrop[0] = if (opaqueWindow()) 1 else 0; // And the glass the slider makes of it this frame (`publishLook`). - if (publishedLook()) |l| self.applyLook(&u, if (self.text) glass_look.forText(l) else glass_look.forDrops(l)); + if (publishedLook()) |l| self.applyLook(&u, if (self.lens) glass_look.forLens(l) else if (self.text) glass_look.forText(l) else glass_look.forDrops(l)); const textures = [_]?*anyopaque{programs.handle(sharp)}; if (!h.begin(id, &textures, textures.len, u.vec4s(), uniform_vec4s)) return false; defer h.end(); @@ -336,6 +340,56 @@ pub fn draw(self: *const LiquidField, frost: dvui.Texture, covered: dvui.Rect.Ph return true; } +/// The shapes as a lens over a picture of the caller's own — a magnifier's zoom, a loupe — rather +/// than over what is behind them. `picture` is a picture of `covered` (physical pixels, window +/// coordinates), reaching `pictureMargin` past the shapes. Its middle is the picture as it is, +/// unblurred, untinted and unlit, so a colour picked through it is the colour under it; only the +/// band along its edge is glass (`lens`, `glass_look.forLens`). Where the app publishes no look +/// (the web), it is the earlier glass, its rim pulling from just outside the shape, which is what +/// the margin is for. Nothing under it is read, so it costs no capture. +/// +/// Queued in order with the frame (`dvui.deferRender`), as every glass is, and drawn at full +/// alpha: glass replaces what it covers, and at part alpha it left a hole. `picture` must live +/// until the frame ends. False, having queued nothing, where there is no glass program (`ready`): +/// draw the picture some other way. +pub fn drawPicture(self: *const LiquidField, picture: dvui.Texture, covered: dvui.Rect.Physical) bool { + if (self.len == 0) return true; + if (!ready()) return false; + const job = dvui.currentWindow().arena().create(PictureJob) catch return false; + job.* = .{ .field = self.*, .picture = picture, .covered = covered }; + job.field.lens = true; + job.field.tint = null; + job.field.mix = 0; + job.field.lift = 0; + for (job.field.shapes[0..job.field.len]) |*sh| sh.blur = 0; + dvui.deferRender(job, PictureJob.run); + return true; +} + +/// Physical pixels the picture `drawPicture` lays the glass over should reach past the shapes: as +/// far as the earlier glass's rim reaches out (`liquid_glass.margin`), at the strongest of its +/// shapes' lenses. Apple's lens pulls only from inside the shape and needs none of it. +pub fn pictureMargin(self: *const LiquidField) f32 { + var lens: f32 = 0; + for (self.shapes[0..self.len]) |sh| lens = @max(lens, sh.lens); + return liquid_glass.margin(.{ .lens = lens, .refraction = self.refraction }, self.scale); +} + +/// What `drawPicture` hands to the replay, in the frame's arena. +const PictureJob = struct { + field: LiquidField, + picture: dvui.Texture, + covered: dvui.Rect.Physical, + + fn run(ctx: ?*anyopaque) void { + const self: *PictureJob = @ptrCast(@alignCast(ctx orelse return)); + const prev_alpha = dvui.currentWindow().alpha; + dvui.alphaSet(1); + defer dvui.alphaSet(prev_alpha); + _ = self.field.draw(self.picture, self.covered, null); + } +}; + /// One quad per group, its vertex colour telling the program which pass and which shapes. fn quads(self: *const LiquidField, groups: Clusters, glass: bool, frost: dvui.Texture) void { const arena = dvui.currentWindow().arena(); diff --git a/core/gfx/glass_look.zig b/core/gfx/glass_look.zig index 22b6bf8d..e746dd56 100644 --- a/core/gfx/glass_look.zig +++ b/core/gfx/glass_look.zig @@ -261,6 +261,21 @@ pub fn forText(look: InApp) InApp { return l; } +/// `look` for a lens over a picture of the caller's own — a magnifier's zoom, a loupe — rather than +/// over what is behind it (`LiquidField.drawPicture`): the picture is what it shows, as it is, so +/// its middle takes no frost, none of the window's colour and no lift, at any point on the slider +/// — a colour picked through it is the colour under it. Only the band along its edge is glass, +/// bending the picture toward its rim and lit there, and that follows the slider as every glass +/// does: a clear lens at the bottom, its shine going at the top. +pub fn forLens(look: InApp) InApp { + var l = look; + l.frost = 0; + l.mix = 0; + l.text_mix = 0; + l.lift = 0; + return l; +} + fn smoothstep(x: f32) f32 { return x * x * (3 - 2 * x); } @@ -325,6 +340,22 @@ test "a surface carrying text keeps some frost over a clear lens, none once it i try std.testing.expectEqual(inApp(1).frost, forText(inApp(1)).frost); } +test "a lens over a picture leaves its middle as it is, everywhere on the slider" { + var i: usize = 0; + while (i <= 20) : (i += 1) { + const t = @as(f32, @floatFromInt(i)) / 20; + const l = forLens(inApp(t)); + try std.testing.expectEqual(@as(f32, 0), l.frost); + try std.testing.expectEqual(@as(f32, 0), l.mix); + try std.testing.expectEqual(@as(f32, 0), l.lift); + // Its edge is the slider's glass all the same. + try std.testing.expectEqual(inApp(t).bend, l.bend); + try std.testing.expectEqual(inApp(t).rim, l.rim); + } + // A clear lens at the bottom, folding at its rim as Apple's does. + try std.testing.expect(forLens(inApp(0)).bend > 1); +} + test "a drop's plain blur is light, growing with the glass's roughness" { try std.testing.expectApproxEqAbs(drop_blur_min, native(0).blur, 1e-6); try std.testing.expectApproxEqAbs(drop_blur, native(1).blur, 1e-6); diff --git a/tests/integration.zig b/tests/integration.zig index 5c9b8360..1c7ed35d 100644 --- a/tests/integration.zig +++ b/tests/integration.zig @@ -4454,6 +4454,25 @@ test "liquid field: groups are the shapes that touch, each drawn as its own quad try std.testing.expectEqual(@as(f32, 22), u.shapes[1][0][0]); } +test "liquid field: a lens's picture reaches as far past its shapes as the earlier glass's rim pulls from" { + const LF = fizzy.core.LiquidField; + var f: LF = .{ .scale = 2, .refraction = 1 }; + f.add(LF.Shape.circle(.{ .x = 100, .y = 100 }, 60)); + // The earlier glass (the web's) pulls its rim from `liquid_glass.refraction` points out at full + // strength: a picture reaching that far past the shape has something there to show. + try std.testing.expect(f.pictureMargin() >= fizzy.core.liquid_glass.refraction * 2); + // Further at the strongest refraction setting. + var strong = f; + strong.refraction = 2; + try std.testing.expect(strong.pictureMargin() > f.pictureMargin()); + // And further for a stronger lens on its shape. + var bent = f; + bent.shapes[0].lens = 1.5; + try std.testing.expect(bent.pictureMargin() > f.pictureMargin()); + // Nothing in fizzy draws one (pixi's dropper magnifier does): compiled here all the same. + _ = &LF.drawPicture; +} + test "liquid glass: the rings of a pane run the way dvui's paths do" { const r: dvui.Rect.Physical = .{ .x = 0, .y = 0, .w = 200, .h = 100 }; var pts: [4 * 7 + 2 * (8 - 1) + 2 * (4 - 1)]dvui.Point.Physical = undefined; From e9bc9ece0ceed66e589ac2746cffde44e6c350ec Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 15:26:57 +0000 Subject: [PATCH 7/8] core: back to sdk 0.2.17; the release is its own PR CONTRIBUTING.md (merged with main) has a feature PR leave `sdk_version` alone: only an `sdk: release 0.2.N` PR bumps it. This PR's core changes reach pixi at that release. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Euk6iCavQdW7MG5FN2vvFw --- sdk/sdk_version.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/sdk_version.zig b/sdk/sdk_version.zig index 7fe028be..541df375 100644 --- a/sdk/sdk_version.zig +++ b/sdk/sdk_version.zig @@ -23,5 +23,5 @@ const std = @import("std"); pub const sdk_version = std.SemanticVersion{ .major = 0, .minor = 2, - .patch = 18, + .patch = 17, }; From ef9530104e8ba1702faaa35ce718839774a3112f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 15:47:06 +0000 Subject: [PATCH 8/8] sdk: release 0.2.18, with this PR's glass for pixi's dropper orb MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user wants this PR to carry the release, so the orb can be tried on main as soon as it merges: `sdk_version` 0.2.17 → 0.2.18 again. It ships `LiquidField.drawPicture`, `native_glass.offered` and the unreleased core changes since `sdk-v0.2.17` (#223, #224, #227) to plugins that build `core` in. The fingerprint has not moved. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Euk6iCavQdW7MG5FN2vvFw --- sdk/sdk_version.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/sdk_version.zig b/sdk/sdk_version.zig index 541df375..7fe028be 100644 --- a/sdk/sdk_version.zig +++ b/sdk/sdk_version.zig @@ -23,5 +23,5 @@ const std = @import("std"); pub const sdk_version = std.SemanticVersion{ .major = 0, .minor = 2, - .patch = 17, + .patch = 18, };