Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions build/app.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
15 changes: 14 additions & 1 deletion docs/WINDOWS_LINUX_GLASS_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions src/backend/backend_native.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/backend/backend_web.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
143 changes: 143 additions & 0 deletions src/backend/native/platform/blur_region.zig
Original file line number Diff line number Diff line change
@@ -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);
}
33 changes: 33 additions & 0 deletions src/backend/native/platform/linux_titlebar.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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;

Expand Down
Loading
Loading