From f5ccde290aec3d867a11ef1f88abe2faa5926305 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 13:21:58 +0000 Subject: [PATCH] linux: the desktop's blur behind the window, behind FIZZY_BLUR_BEHIND=1 On Wayland the window asks for a blur behind its frame through ext-background-effect-v1, the protocol GNOME (Mutter 51) and KDE (Plasma 6.7) now share, or KDE's older org_kde_kwin_blur. Where the compositor blurs, the window's base is translucent over it at the window opacity, as it is over macOS's vibrancy and Windows' Acrylic (`Editor.windowBase`, `LiquidField.publishOpaqueWindow`). Off unless `FIZZY_BLUR_BEHIND=1` until it has been seen on a desktop that blurs. Step 4 of docs/WINDOWS_LINUX_GLASS_PLAN.md. - `platform/wayland_blur.zig`: SDL's own libwayland-client (`RTLD_NOLOAD`), the two protocols declared by hand and marshalled from argument arrays, on an event queue of its own so nothing here dispatches SDL's events. The region is surface state, sent only when it changes and taken with the commit the frame's present makes, so a resize changes the blur and the picture together. "None" is an empty region, not a null one, which would hand the surface back to the compositor's default. On KDE's protocol, where KWin (through Plasma 6.6) blurs the whole window behind a blur object with an empty region, "none" is the object unset and released. Where the compositor reports no blur, nothing is asked. The first probe logs what it found. - `platform/blur_region.zig` (std-only, `fizzy-blur-region-tests`): the frame's rounded corners stepped into rows, since the protocols take whole rectangles. - `linux_titlebar.blurBehind`: the frame inside the shadow's margin (`frameInsets`); the Editor asks for it while the window is translucent, square when maximized. Checked: the region tests; native, Linux, Windows and web builds and the test gates; the protocols' opcodes against wayland.app and plasma-wayland-protocols; KWin's empty-region rule in its Plasma/6.6 source; a stub compositor speaking both protocols (cloud session); and fizzy on Ubuntu 26.04 / GNOME 50 (aarch64 VM, Wayland), which has neither protocol: one probe on fizzy's own queue, `ext-background-effect-v1 absent, org_kde_kwin_blur absent` logged, no protocol error, window opaque. Not seen on GNOME 51 or Plasma 6.7, where the blur would show. Needs the `envSwitch` fix below it: before it, `FIZZY_BLUR_BEHIND` read as `FIZZY_POPOUT`. wayland-protocols 1.45 numbered the blur capability 0; later releases made it the bit 1, which this follows. Co-Authored-By: Claude Opus 5.5 --- build/app.zig | 2 + docs/WINDOWS_LINUX_GLASS_PLAN.md | 15 +- src/backend/backend_native.zig | 6 + src/backend/backend_web.zig | 4 + src/backend/native/platform/blur_region.zig | 143 +++++++ .../native/platform/linux_titlebar.zig | 33 ++ src/backend/native/platform/wayland_blur.zig | 353 ++++++++++++++++++ src/editor/Editor.zig | 29 +- src/editor/Popout.zig | 2 +- 9 files changed, 578 insertions(+), 9 deletions(-) create mode 100644 src/backend/native/platform/blur_region.zig create mode 100644 src/backend/native/platform/wayland_blur.zig diff --git a/build/app.zig b/build/app.zig index f6df4f2d..91c4000d 100644 --- a/build/app.zig +++ b/build/app.zig @@ -553,6 +553,8 @@ pub fn construct(b: *std.Build, target: std.Build.ResolvedTarget, optimize: std. .{ "fizzy-window-layout-tests", "src/backend/native/platform/window_layout.zig" }, // The hit test Windows' WM_NCHITTEST and Linux's SDL hit test both answer from. .{ "fizzy-titlebar-tests", "src/backend/native/platform/titlebar.zig" }, + // The rounded frame Linux's blur behind the window is stepped into (`wayland_blur`). + .{ "fizzy-blur-region-tests", "src/backend/native/platform/blur_region.zig" }, // Where a viewport's OS window is on the desktop and where its part of the frame lies. // std-only (see viewport_map.zig); `SDLBackend` applies it. .{ "fizzy-viewport-map-tests", "src/backend/native/viewport_map.zig" }, diff --git a/docs/WINDOWS_LINUX_GLASS_PLAN.md b/docs/WINDOWS_LINUX_GLASS_PLAN.md index 01fcd75d..52293044 100644 --- a/docs/WINDOWS_LINUX_GLASS_PLAN.md +++ b/docs/WINDOWS_LINUX_GLASS_PLAN.md @@ -368,7 +368,20 @@ Each step is useful by itself, and the spikes come before anything that depends thing on Windows to the OS's own UI. 4. **Linux: the window material** through `ext-background-effect-v1` (GNOME 51, Plasma 6.7, niri), and **Wayland popups** for menus and dialogs. A protocol binding on SDL's `wl_display`, and the - region set before the present. + region set before the present. *The material is built, behind `FIZZY_BLUR_BEHIND=1`, not yet + seen on a real desktop:* `platform/wayland_blur.zig` binds either protocol on SDL's display + (libwayland-client opened at runtime, its own event queue), `blur_region.zig` steps the rounded + frame into rows (unit tested), and the Editor asks for it while the window is translucent and + draws the base translucent where the compositor blurs. Run against a stub compositor speaking + both protocols: the region is applied with the surface commit, sent only when it changes, + cleared when the window goes opaque, and not asked for where the compositor reports no blur. + Run in fizzy on Ubuntu 26.04's GNOME 50, which has neither protocol: the probe binds once on a + queue of its own, logs what it found (`wayland_blur`), asks for nothing, and the window stays + opaque. KWin through Plasma 6.6 blurs a whole window whose blur object has an empty region + (`plugins/blur/blur.cpp`), so on KDE's protocol "no blur" is the object unset and released. + One trap: wayland-protocols 1.45, the release that added the protocol, numbered the `blur` + capability 0; later releases made it the bit 1. fizzy follows the current definition, so a + compositor built against 1.45 reads as having no blur. The Wayland popups are not built. 5. **Windows spikes**, in a scratch app before fizzy, on a hardware GPU and on WARP, Windows 11 23H2 and 24H2: 1. a Windows.UI.Composition target that is not topmost, under SDL's topmost DirectComposition diff --git a/src/backend/backend_native.zig b/src/backend/backend_native.zig index 4255e454..fd8a301c 100644 --- a/src/backend/backend_native.zig +++ b/src/backend/backend_native.zig @@ -75,6 +75,12 @@ pub fn frameInsets(win: *dvui.Window) dvui.Rect { const in = platform.linux_titlebar.frameInsets(win.backend.impl.window); return .{ .x = in.left, .y = in.top, .w = in.right, .h = in.bottom }; } +/// Linux: the desktop's blur behind the window's frame this frame — inside its shadow's margin, +/// its corners rounded by `radius` points — or none (null). Whether the compositor blurs behind +/// windows at all (`platform.linux_titlebar.blurBehind`); false elsewhere. +pub fn blurBehind(win: *dvui.Window, radius: ?f32) bool { + return platform.linux_titlebar.blurBehind(win.backend.impl.window, radius); +} const getWin32Hwnd = platform.win32_titlebar.getWin32Hwnd; /// Files the OS asks fizzy to open while it runs go to the single-instance queue, which opens diff --git a/src/backend/backend_web.zig b/src/backend/backend_web.zig index f469409b..bba10f45 100644 --- a/src/backend/backend_web.zig +++ b/src/backend/backend_web.zig @@ -60,6 +60,10 @@ pub fn frameInsets(_: *dvui.Window) dvui.Rect { return .{}; } +pub fn blurBehind(_: *dvui.Window, _: ?f32) bool { + return false; +} + pub fn getHoveredTitleBarButton() ?TitleBarButton { return null; } diff --git a/src/backend/native/platform/blur_region.zig b/src/backend/native/platform/blur_region.zig new file mode 100644 index 00000000..26c4b7d7 --- /dev/null +++ b/src/backend/native/platform/blur_region.zig @@ -0,0 +1,143 @@ +//! The blur behind a window as a desktop takes it: a region of whole rectangles (Wayland's +//! `wl_region`), here a window's frame with its corners rounded, stepped into rows along each +//! corner's arc. The protocols take no radius, so the rows are the corners; under a blur a step +//! of one surface unit does not show. std-only, so it is tested on any host +//! (`fizzy-blur-region-tests`); `wayland_blur` hands it to the compositor. +const std = @import("std"); + +/// Surface units (points), whole as the protocols take them. +pub const Rect = struct { + x: i32 = 0, + y: i32 = 0, + w: i32 = 0, + h: i32 = 0, +}; + +/// The roundest corner stepped: a row per unit of it. +pub const max_radius = 64; + +/// The most rectangles `rounded` makes: a row per unit of the radius above the middle and below +/// it, and the middle. +pub const max_rects = 2 * max_radius + 1; + +/// `frame` with its corners rounded by `radius` (surface units), as rectangles in `out`: the +/// middle whole, and above and below it a row per unit of the radius, each in from the frame's +/// sides by as far as the corner's arc is at the row's middle. Rows the arc leaves the same width +/// are one rectangle. Nothing for an empty frame. +pub fn rounded(frame: Rect, radius: f32, out: *[max_rects]Rect) []Rect { + if (frame.w <= 0 or frame.h <= 0) return out[0..0]; + const half: f32 = @floatFromInt(@divTrunc(@min(frame.w, frame.h), 2)); + const r_units: i32 = @intFromFloat(@round(std.math.clamp(radius, 0, @min(half, @as(f32, max_radius))))); + if (r_units <= 0) { + out[0] = frame; + return out[0..1]; + } + const r: f32 = @floatFromInt(r_units); + var n: usize = 0; + // From the top: the top corners' rows, then the middle, then the bottom corners' rows. Row + // `i` of a corner lies `i` units in from the frame's edge. + var y = frame.y; + var i: i32 = 0; + while (i < r_units) : (i += 1) { + n = addRow(out, n, frame, y, 1, inset(r, i)); + y += 1; + } + const middle_h = frame.h - 2 * r_units; + if (middle_h > 0) { + n = addRow(out, n, frame, y, middle_h, 0); + y += middle_h; + } + i = r_units - 1; + while (i >= 0) : (i -= 1) { + n = addRow(out, n, frame, y, 1, inset(r, i)); + y += 1; + } + return out[0..n]; +} + +/// How far in from the frame's side row `i` of a corner of radius `r` starts: where the arc is +/// at the row's middle, to the nearest unit. +fn inset(r: f32, i: i32) i32 { + const dy = r - (@as(f32, @floatFromInt(i)) + 0.5); + const dx = @sqrt(@max(r * r - dy * dy, 0)); + return @intFromFloat(@round(r - dx)); +} + +/// A row of `h` units at `y`, `in` from each side, onto the last rectangle when it is the same +/// width and runs on from it. +fn addRow(out: *[max_rects]Rect, n: usize, frame: Rect, y: i32, h: i32, in: i32) usize { + const row: Rect = .{ .x = frame.x + in, .y = y, .w = frame.w - 2 * in, .h = h }; + if (row.w <= 0) return n; + if (n > 0) { + const last = &out[n - 1]; + if (last.x == row.x and last.w == row.w and last.y + last.h == row.y) { + last.h += row.h; + return n; + } + } + out[n] = row; + return n + 1; +} + +test "a square frame is itself" { + var out: [max_rects]Rect = undefined; + const rs = rounded(.{ .x = 3, .y = 4, .w = 100, .h = 80 }, 0, &out); + try std.testing.expectEqual(@as(usize, 1), rs.len); + try std.testing.expectEqual(Rect{ .x = 3, .y = 4, .w = 100, .h = 80 }, rs[0]); +} + +test "an empty frame is nothing" { + var out: [max_rects]Rect = undefined; + try std.testing.expectEqual(@as(usize, 0), rounded(.{ .w = 0, .h = 10 }, 12, &out).len); +} + +test "rounded rows cover the frame's height, in from its sides at the corners, symmetric" { + var out: [max_rects]Rect = undefined; + const frame: Rect = .{ .x = 10, .y = 20, .w = 300, .h = 200 }; + const rs = rounded(frame, 12, &out); + try std.testing.expect(rs.len > 3); + // Top to bottom without gaps or overlaps. + var y = frame.y; + for (rs) |r| { + try std.testing.expectEqual(y, r.y); + try std.testing.expect(r.x >= frame.x and r.x + r.w <= frame.x + frame.w); + // Centred: in by as much on the right as on the left. + try std.testing.expectEqual(frame.x + frame.w - (r.x + r.w), r.x - frame.x); + y += r.h; + } + try std.testing.expectEqual(frame.y + frame.h, y); + // The first row is well in at the corner; the middle is the whole width. + try std.testing.expect(rs[0].x - frame.x >= 6); + var widest: i32 = 0; + for (rs) |r| widest = @max(widest, r.w); + try std.testing.expectEqual(frame.w, widest); + // Mirrored top and bottom. + for (0..rs.len) |k| { + const a = rs[k]; + const b = rs[rs.len - 1 - k]; + try std.testing.expectEqual(a.x, b.x); + try std.testing.expectEqual(a.h, b.h); + } +} + +test "the area is the rounded rect's, near enough" { + var out: [max_rects]Rect = undefined; + const r: f32 = 12; + const rs = rounded(.{ .w = 300, .h = 200 }, r, &out); + var area: f32 = 0; + for (rs) |x| area += @floatFromInt(x.w * x.h); + const want = 300 * 200 - (4 - std.math.pi) * r * r; + // Within a unit of each corner's arc. + try std.testing.expect(@abs(area - want) <= 4 * r); +} + +test "a radius past half the shorter side is clamped" { + var out: [max_rects]Rect = undefined; + const rs = rounded(.{ .w = 40, .h = 20 }, 500, &out); + var y: i32 = 0; + for (rs) |x| { + try std.testing.expect(x.w > 0); + y += x.h; + } + try std.testing.expectEqual(@as(i32, 20), y); +} diff --git a/src/backend/native/platform/linux_titlebar.zig b/src/backend/native/platform/linux_titlebar.zig index cb947333..75cbbf2c 100644 --- a/src/backend/native/platform/linux_titlebar.zig +++ b/src/backend/native/platform/linux_titlebar.zig @@ -18,6 +18,7 @@ const dvui = @import("dvui"); const backend = @import("backend"); const c = backend.c; const titlebar = @import("titlebar.zig"); +const wayland_blur = @import("wayland_blur.zig"); /// The margins round the frame for its shadow, in window coordinates (points). pub const Insets = struct { left: f32 = 0, top: f32 = 0, right: f32 = 0, bottom: f32 = 0 }; @@ -63,6 +64,38 @@ pub fn frameInsets(window: *c.SDL_Window) Insets { return .{}; } +/// The desktop's blur behind the window's frame (`wayland_blur`), inside the margin its shadow is +/// drawn in (`frameInsets`), its corners rounded by `radius` points — or none, null. Whether the +/// compositor blurs behind windows at all: Wayland with `ext-background-effect-v1` (GNOME 51, +/// Plasma 6.7, niri) or KDE's older `org_kde_kwin_blur`. Not X11, nor a compositor without either. +/// Cheap to call every frame; it is sent to the compositor only when it changes, and taken with +/// the frame presented next. +pub fn blurBehind(window: *c.SDL_Window, radius: ?f32) bool { + if (comptime builtin.os.tag != .linux) return false; + const props = c.SDL_GetWindowProperties(window); + const display = c.SDL_GetPointerProperty(props, c.SDL_PROP_WINDOW_WAYLAND_DISPLAY_POINTER, null) orelse return false; + const surface = c.SDL_GetPointerProperty(props, c.SDL_PROP_WINDOW_WAYLAND_SURFACE_POINTER, null) orelse return false; + const want: ?wayland_blur.Want = if (radius) |r| want: { + // The surface is the window SDL reports (points), the frame and its shadow's margin. + var w: c_int = 0; + var h: c_int = 0; + _ = c.SDL_GetWindowSize(window, &w, &h); + const in = frameInsets(window); + const left: i32 = @intFromFloat(@round(in.left)); + const top: i32 = @intFromFloat(@round(in.top)); + break :want .{ + .frame = .{ + .x = left, + .y = top, + .w = w - left - @as(i32, @intFromFloat(@round(in.right))), + .h = h - top - @as(i32, @intFromFloat(@round(in.bottom))), + }, + .radius = r, + }; + } else null; + return wayland_blur.behind(display, surface, want); +} + /// The window whose chrome is in place. var styled_window: ?*c.SDL_Window = null; diff --git a/src/backend/native/platform/wayland_blur.zig b/src/backend/native/platform/wayland_blur.zig new file mode 100644 index 00000000..9387e9f9 --- /dev/null +++ b/src/backend/native/platform/wayland_blur.zig @@ -0,0 +1,353 @@ +//! The desktop's blur behind a Wayland window: `ext-background-effect-v1`, the protocol GNOME +//! (Mutter 51) and KDE (Plasma 6.7) both have, and before it KDE's own `org_kde_kwin_blur` +//! (`docs/WINDOWS_LINUX_GLASS_PLAN.md`). The window asks for a region of its surface to be +//! blurred behind; the compositor draws the blur, at a strength of its own. +//! +//! The region is surface state, double-buffered: set before a frame is presented, it changes +//! with the commit that frame's present makes (Vulkan's WSI commits the surface, on the same +//! connection, after these requests), so a resized window's blur and picture change together. +//! +//! libwayland-client is opened at runtime, as SDL opens it, and what is called of it is declared +//! here by hand: the two protocols' few requests are marshalled from argument arrays, and their +//! objects live on an event queue of their own, so nothing here dispatches SDL's events or SDL +//! ours. Linux only; a no-op elsewhere. +const std = @import("std"); +const builtin = @import("builtin"); +const blur_region = @import("blur_region.zig"); + +const log = std.log.scoped(.wayland_blur); + +/// Where the blur goes: `frame` (surface units, from the surface's top left) with its corners +/// rounded by `radius`. +pub const Want = struct { + frame: blur_region.Rect, + radius: f32, +}; + +/// Blur behind `surface` (a `wl_surface` of `display`, SDL's) as `want` says, or none: whether +/// the compositor blurs behind windows at all — it has one of the protocols, and its blur is on. +/// Cheap to call every frame: the region is sent only when it changes. The first call binds the +/// protocols, two round trips to the compositor. +pub fn behind(display: *anyopaque, surface: *anyopaque, want: ?Want) bool { + if (comptime builtin.os.tag != .linux) return false; + const s = ready(display) orelse return false; + // What the compositor has said since (the blur turned off or on). + _ = s.lib.display_dispatch_queue_pending(s.display, s.queue); + const kind: Kind = if (s.effect_manager != null and s.effect_blur) .ext else if (s.kde_manager != null) .kde else { + // Gone: when it comes back, the region is sent again (`ext-background-effect-v1` drops + // what was set while it was away). + s.sent = null; + return false; + }; + if (s.surface != surface or s.effect_kind != kind) { + // First, or another window: the effect object is made for this surface as it is needed. + if (s.effect) |e| destroyEffect(s, e); + s.effect_kind = kind; + s.surface = surface; + s.sent = null; + } + if (s.sent) |sent| if (std.meta.eql(sent, want)) return true; + if (want == null and kind == .kde) { + // KWin blurs the whole window behind a blur object with an empty region: none is no + // blur object at all. + if (s.effect) |e| { + var args = [_]Argument{.{ .o = @ptrCast(surface) }}; + _ = s.lib.proxy_marshal_array_flags(s.kde_manager.?, 1, null, s.lib.proxy_get_version(s.kde_manager.?), 0, &args); + destroyEffect(s, e); + } + } else { + if (s.effect == null) s.effect = createEffect(s, kind, @ptrCast(surface)) orelse return false; + setRegion(s, want); + } + s.sent = want; + return true; +} + +// ---- libwayland-client, by hand ------------------------------------------------------------ + +const Proxy = opaque {}; +const EventQueue = opaque {}; + +const Message = extern struct { + name: [*:0]const u8, + signature: [*:0]const u8, + types: [*]const ?*const Interface, +}; + +const Interface = extern struct { + name: [*:0]const u8, + version: c_int, + method_count: c_int, + methods: ?[*]const Message, + event_count: c_int, + events: ?[*]const Message, +}; + +const Argument = extern union { + i: i32, + u: u32, + f: i32, + s: ?[*:0]const u8, + o: ?*Proxy, + n: u32, + a: ?*anyopaque, + h: i32, +}; + +/// `WL_MARSHAL_FLAG_DESTROY`: the request is the object's destructor. +const marshal_destroy: u32 = 1; + +/// What is called of libwayland-client: each field is the symbol `wl_`. +const Lib = struct { + display_create_queue: *const fn (*Proxy) callconv(.c) ?*EventQueue, + proxy_create_wrapper: *const fn (*Proxy) callconv(.c) ?*Proxy, + proxy_wrapper_destroy: *const fn (*Proxy) callconv(.c) void, + proxy_set_queue: *const fn (*Proxy, ?*EventQueue) callconv(.c) void, + proxy_marshal_array_flags: *const fn (*Proxy, u32, ?*const Interface, u32, u32, ?[*]Argument) callconv(.c) ?*Proxy, + proxy_add_listener: *const fn (*Proxy, *const anyopaque, ?*anyopaque) callconv(.c) c_int, + proxy_get_version: *const fn (*Proxy) callconv(.c) u32, + display_roundtrip_queue: *const fn (*Proxy, *EventQueue) callconv(.c) c_int, + display_dispatch_queue_pending: *const fn (*Proxy, *EventQueue) callconv(.c) c_int, + registry_interface: *const Interface, + compositor_interface: *const Interface, + region_interface: *const Interface, + surface_interface: *const Interface, +}; + +fn load() ?Lib { + // SDL's own copy, already loaded for the window: never a second one. + const handle = std.c.dlopen("libwayland-client.so.0", .{ .LAZY = true, .NOLOAD = true }) orelse return null; + var lib: Lib = undefined; + inline for (@typeInfo(Lib).@"struct".fields) |f| { + const sym = std.c.dlsym(handle, "wl_" ++ f.name) orelse return null; + @field(lib, f.name) = @ptrCast(@alignCast(sym)); + } + return lib; +} + +// ---- the two protocols, as wayland-scanner would declare them -------------------------------- +// +// From `ext-background-effect-v1.xml` (wayland-protocols, staging) and KDE's `blur.xml` +// (plasma-wayland-protocols). The core interfaces their requests name are libwayland's, found +// when it is opened (`ready`). + +var no_types = [_]?*const Interface{ null, null, null, null }; +var effect_get_types = [_]?*const Interface{ &effect_surface_interface, null }; +var region_types = [_]?*const Interface{null}; +var kde_create_types = [_]?*const Interface{ &kde_blur_interface, null }; +var kde_surface_types = [_]?*const Interface{null}; + +const effect_manager_requests = [_]Message{ + .{ .name = "destroy", .signature = "", .types = &no_types }, + .{ .name = "get_background_effect", .signature = "no", .types = &effect_get_types }, +}; +const effect_manager_events = [_]Message{ + .{ .name = "capabilities", .signature = "u", .types = &no_types }, +}; +const effect_manager_interface: Interface = .{ + .name = "ext_background_effect_manager_v1", + .version = 1, + .method_count = effect_manager_requests.len, + .methods = &effect_manager_requests, + .event_count = effect_manager_events.len, + .events = &effect_manager_events, +}; +const effect_surface_requests = [_]Message{ + .{ .name = "destroy", .signature = "", .types = &no_types }, + .{ .name = "set_blur_region", .signature = "?o", .types = ®ion_types }, +}; +const effect_surface_interface: Interface = .{ + .name = "ext_background_effect_surface_v1", + .version = 1, + .method_count = effect_surface_requests.len, + .methods = &effect_surface_requests, + .event_count = 0, + .events = null, +}; +const kde_manager_requests = [_]Message{ + .{ .name = "create", .signature = "no", .types = &kde_create_types }, + .{ .name = "unset", .signature = "o", .types = &kde_surface_types }, +}; +const kde_manager_interface: Interface = .{ + .name = "org_kde_kwin_blur_manager", + .version = 1, + .method_count = kde_manager_requests.len, + .methods = &kde_manager_requests, + .event_count = 0, + .events = null, +}; +const kde_blur_requests = [_]Message{ + .{ .name = "commit", .signature = "", .types = &no_types }, + .{ .name = "set_region", .signature = "?o", .types = ®ion_types }, + .{ .name = "release", .signature = "", .types = &no_types }, +}; +const kde_blur_interface: Interface = .{ + .name = "org_kde_kwin_blur", + .version = 1, + .method_count = kde_blur_requests.len, + .methods = &kde_blur_requests, + .event_count = 0, + .events = null, +}; + +/// `ext_background_effect_manager_v1.capability.blur`. +const capability_blur: u32 = 1; + +// ---- state ------------------------------------------------------------------------------------ + +const Kind = enum { ext, kde }; + +const State = struct { + lib: Lib, + display: *Proxy, + queue: *EventQueue, + compositor: ?*Proxy = null, + effect_manager: ?*Proxy = null, + /// The compositor blurs (`capabilities`); false until it says. + effect_blur: bool = false, + kde_manager: ?*Proxy = null, + /// The surface the effect object is for, and the object. + surface: ?*anyopaque = null, + effect: ?*Proxy = null, + effect_kind: Kind = .ext, + /// The region last sent (null in it: none), or null when nothing has been sent since the + /// effect object was made or the blur came back. + sent: ??Want = null, +}; + +var state: State = undefined; +var tried = false; +var bound = false; + +/// The protocols bound on `display`, once: null where libwayland or a compositor to ask is not +/// there. +fn ready(display: *anyopaque) ?*State { + if (tried) return if (bound) &state else null; + tried = true; + const lib = load() orelse { + log.info("no blur behind the window: libwayland-client is not loaded, or lacks a symbol", .{}); + return null; + }; + region_types[0] = lib.region_interface; + effect_get_types[1] = lib.surface_interface; + kde_create_types[1] = lib.surface_interface; + kde_surface_types[0] = lib.surface_interface; + const d: *Proxy = @ptrCast(display); + const queue = lib.display_create_queue(d) orelse { + log.info("no blur behind the window: no event queue", .{}); + return null; + }; + state = .{ .lib = lib, .display = d, .queue = queue }; + // The registry on our queue: made through a wrapper of the display that carries it, so + // SDL's own queue sees nothing of it. + const wrapper = lib.proxy_create_wrapper(d) orelse return null; + lib.proxy_set_queue(wrapper, queue); + var get_registry_args = [_]Argument{.{ .n = 0 }}; + const registry = lib.proxy_marshal_array_flags(wrapper, 1, lib.registry_interface, lib.proxy_get_version(wrapper), 0, &get_registry_args); + lib.proxy_wrapper_destroy(wrapper); + const reg = registry orelse return null; + _ = lib.proxy_add_listener(reg, ®istry_listener, null); + // The globals (bound as they are announced), then what the effect manager says it can do. + if (lib.display_roundtrip_queue(d, queue) < 0) { + log.info("no blur behind the window: the compositor did not answer", .{}); + return null; + } + if (state.effect_manager != null) _ = lib.display_roundtrip_queue(d, queue); + bound = state.compositor != null and (state.effect_manager != null or state.kde_manager != null); + log.info("blur behind the window: ext-background-effect-v1 {s}, org_kde_kwin_blur {s}", .{ + if (state.effect_manager == null) "absent" else if (state.effect_blur) "blurs" else "present, no blur", + if (state.kde_manager == null) "absent" else "present", + }); + return if (bound) &state else null; +} + +const RegistryListener = extern struct { + global: *const fn (?*anyopaque, ?*Proxy, u32, [*:0]const u8, u32) callconv(.c) void, + global_remove: *const fn (?*anyopaque, ?*Proxy, u32) callconv(.c) void, +}; +const registry_listener: RegistryListener = .{ .global = onGlobal, .global_remove = onGlobalRemove }; + +fn onGlobal(_: ?*anyopaque, registry: ?*Proxy, name: u32, interface: [*:0]const u8, version: u32) callconv(.c) void { + const reg = registry orelse return; + const iface = std.mem.span(interface); + if (std.mem.eql(u8, iface, "wl_compositor")) { + // `create_region` is in every version. + state.compositor = bind(reg, name, state.lib.compositor_interface, 1); + } else if (std.mem.eql(u8, iface, "ext_background_effect_manager_v1")) { + const m = bind(reg, name, &effect_manager_interface, @min(version, 1)) orelse return; + state.effect_manager = m; + _ = state.lib.proxy_add_listener(m, &effect_manager_listener, null); + } else if (std.mem.eql(u8, iface, "org_kde_kwin_blur_manager")) { + state.kde_manager = bind(reg, name, &kde_manager_interface, @min(version, 1)); + } +} + +/// A global going away mid-session (a compositor restarting its blur): not followed. The +/// effect manager's `capabilities` says when the blur itself is off. +fn onGlobalRemove(_: ?*anyopaque, _: ?*Proxy, _: u32) callconv(.c) void {} + +const EffectManagerListener = extern struct { + capabilities: *const fn (?*anyopaque, ?*Proxy, u32) callconv(.c) void, +}; +const effect_manager_listener: EffectManagerListener = .{ .capabilities = onCapabilities }; + +fn onCapabilities(_: ?*anyopaque, _: ?*Proxy, flags: u32) callconv(.c) void { + state.effect_blur = flags & capability_blur != 0; +} + +/// `wl_registry.bind`. +fn bind(registry: *Proxy, name: u32, interface: *const Interface, version: u32) ?*Proxy { + var args = [_]Argument{ .{ .u = name }, .{ .s = interface.name }, .{ .u = version }, .{ .n = 0 } }; + return state.lib.proxy_marshal_array_flags(registry, 0, interface, version, 0, &args); +} + +fn createEffect(s: *State, kind: Kind, surface: *Proxy) ?*Proxy { + var args = [_]Argument{ .{ .n = 0 }, .{ .o = surface } }; + return switch (kind) { + // `ext_background_effect_manager_v1.get_background_effect`. + .ext => s.lib.proxy_marshal_array_flags(s.effect_manager.?, 1, &effect_surface_interface, s.lib.proxy_get_version(s.effect_manager.?), 0, &args), + // `org_kde_kwin_blur_manager.create`. + .kde => s.lib.proxy_marshal_array_flags(s.kde_manager.?, 0, &kde_blur_interface, s.lib.proxy_get_version(s.kde_manager.?), 0, &args), + }; +} + +fn destroyEffect(s: *State, effect: *Proxy) void { + // `destroy` and `release`: the region goes with the next commit. + const opcode: u32 = switch (s.effect_kind) { + .ext => 0, + .kde => 2, + }; + _ = s.lib.proxy_marshal_array_flags(effect, opcode, null, s.lib.proxy_get_version(effect), marshal_destroy, null); + s.effect = null; +} + +/// The effect's region as `want` says, made and let go: the protocols copy it. None is an empty +/// region, not a null one: null hands the surface back to the compositor's own choice, and some +/// blur every translucent window by default (Hyprland). +fn setRegion(s: *State, want: ?Want) void { + const effect = s.effect orelse return; + const version = s.lib.proxy_get_version(effect); + // `wl_compositor.create_region`, then `wl_region.add` for each row. + const compositor = s.compositor orelse return; + var create_args = [_]Argument{.{ .n = 0 }}; + const region = s.lib.proxy_marshal_array_flags(compositor, 1, s.lib.region_interface, s.lib.proxy_get_version(compositor), 0, &create_args) orelse return; + if (want) |w| { + var rects: [blur_region.max_rects]blur_region.Rect = undefined; + for (blur_region.rounded(w.frame, w.radius, &rects)) |r| { + var add_args = [_]Argument{ .{ .i = r.x }, .{ .i = r.y }, .{ .i = r.w }, .{ .i = r.h } }; + _ = s.lib.proxy_marshal_array_flags(region, 1, null, s.lib.proxy_get_version(region), 0, &add_args); + } + } + var set_args = [_]Argument{.{ .o = region }}; + switch (s.effect_kind) { + // `ext_background_effect_surface_v1.set_blur_region`. + .ext => _ = s.lib.proxy_marshal_array_flags(effect, 1, null, version, 0, &set_args), + // `org_kde_kwin_blur.set_region`, then its `commit`: it takes effect with the surface's. + .kde => { + _ = s.lib.proxy_marshal_array_flags(effect, 1, null, version, 0, &set_args); + _ = s.lib.proxy_marshal_array_flags(effect, 0, null, version, 0, null); + }, + } + // `wl_region.destroy`. + _ = s.lib.proxy_marshal_array_flags(region, 0, null, s.lib.proxy_get_version(region), marshal_destroy, null); +} diff --git a/src/editor/Editor.zig b/src/editor/Editor.zig index 5651bcff..29790e7b 100644 --- a/src/editor/Editor.zig +++ b/src/editor/Editor.zig @@ -180,6 +180,9 @@ window_opacity: f32 = 1.0, /// OS's fullscreen transitions instead of snapping. Snaps to its target on the first frame, so /// there is no fade at launch. window_opacity_anim: WindowOpacity = .{}, +/// Linux: the compositor blurs behind the window (`backend.blurBehind`), so the window's base is +/// translucent over it, as over macOS's vibrancy and Windows' Acrylic (`windowBase`). +blurred_behind: bool = false, /// The window's chrome hidden last frame — a fullscreen Space its end state — and until when, on the /// way back out, frames keep coming for its opacity to fade (`tick`). chrome_was_hidden: bool = false, @@ -3586,15 +3589,17 @@ pub fn windowGlassLook(opacity: f32, size: dvui.Size.Natural) fizzy.backend.Wind } /// A window's base at `opacity`: the theme's content fill over the OS's material where the window -/// has one (macOS, Windows), lightened as it goes see-through so the material behind reads as the +/// has one (macOS, Windows; Linux where the compositor blurs behind it), lightened as it goes see-through so the material behind reads as the /// same tone; opaque where there is none. Every window fizzy draws stands on it — the main window, /// a float's own window, the carry window (`Popout.base`) — so side by side they are one colour. pub fn windowBase(opacity: f32) dvui.Color { const fill = dvui.themeGet().color(.content, .fill); - return switch (builtin.os.tag) { - .macos, .windows => fill.opacity(opacity).lighten((1.0 - opacity) * 4.0), - else => fill, + const material = switch (builtin.os.tag) { + .macos, .windows => true, + .linux => fizzy.editor().blurred_behind, + else => false, }; + return if (material) fill.opacity(opacity).lighten((1.0 - opacity) * 4.0) else fill; } /// Whether a setting the app takes only at launch has been changed since (`Popout.restartPending`): @@ -3640,9 +3645,6 @@ pub fn tick(editor: *Editor) !dvui.App.Result { fizzy.core.motion.publish(editor.app.settings.motion, editor.app.settings.motion_speed, dvui.currentWindow().backend.prefersReducedMotion() or editor.demo.fast); fizzy.core.programs.publishHost(); fizzy.core.LiquidField.publishEnabled(editor.app.settings.glass_shader); - // Translucent over the desktop's material on macOS and Windows; opaque elsewhere, where the - // window's alpha is only its shape (`LiquidField.publishOpaqueWindow`). - fizzy.core.LiquidField.publishOpaqueWindow(builtin.os.tag != .macos and builtin.os.tag != .windows); if (comptime builtin.target.cpu.arch == .wasm32) { // Plugins the page has finished linking since last frame register now. PluginLoader.pump(); @@ -3663,6 +3665,19 @@ pub fn tick(editor: *Editor) !dvui.App.Result { // The draw uses `window_opacity_anim`. const covers = fizzy.backend.coversDesktop(dvui.currentWindow()); easeWindowOpacity(&editor.window_opacity_anim, covers, fizzy.backend.enteringSpace(dvui.currentWindow()), fizzy.backend.spaceFullness(dvui.currentWindow()), editor.window_opacity); + // Linux: the desktop's blur behind the window's frame, where the compositor has one + // (`backend.blurBehind`), asked for while the window is translucent at all, its corners the + // frame's (square maximized, as `draw` draws it). Behind `FIZZY_BLUR_BEHIND=1` until it has been + // seen on GNOME and KDE (`docs/WINDOWS_LINUX_GLASS_PLAN.md`). + if (builtin.os.tag == .linux and Popout.envSwitch("FIZZY_BLUR_BEHIND") orelse false) { + const win = dvui.currentWindow(); + const radius: f32 = if (fizzy.backend.isMaximized(win)) 0 else Constants.linux_window_radius; + editor.blurred_behind = fizzy.backend.blurBehind(win, if (editor.window_opacity_anim.value < 1) radius else null); + } + // Translucent over the desktop's material on macOS and Windows, and on Linux where the + // compositor blurs behind the window; opaque elsewhere, where the window's alpha is only its + // shape (`LiquidField.publishOpaqueWindow`). + fizzy.core.LiquidField.publishOpaqueWindow(builtin.os.tag != .macos and builtin.os.tag != .windows and !editor.blurred_behind); // Out of a fullscreen Space, the window stops covering the desktop on AppKit's and SDL's own // time, after the transition, and nothing wakes a frame for it: it sat opaque until the mouse // moved (the user). From the moment it sets out of the Space, frames keep coming until it no diff --git a/src/editor/Popout.zig b/src/editor/Popout.zig index ee9cb190..1bc774c9 100644 --- a/src/editor/Popout.zig +++ b/src/editor/Popout.zig @@ -140,7 +140,7 @@ pub fn restartPending() bool { /// An environment switch, read once: `NAME=0` off, any other value on, unset null — the setting /// rules then. For testing and sandboxes, over the settings. -fn envSwitch(comptime name: [:0]const u8) ?bool { +pub fn envSwitch(comptime name: [:0]const u8) ?bool { if (comptime builtin.target.cpu.arch == .wasm32) return null; // One cache per variable: the struct names `name`, so each switch gets a type of its own. // Without it Zig makes one type for every switch, and the first one read answered for all