diff --git a/build/app.zig b/build/app.zig index f6df4f2da..91c4000d1 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 01fcd75d5..52293044f 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 4255e4546..fd8a301ca 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 f469409ba..bba10f45e 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 000000000..26c4b7d79 --- /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 cb947333e..75cbbf2cd 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 000000000..9387e9f92 --- /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 5651bcffa..29790e7b9 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 ee9cb190a..1bc774c97 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