From 8d669105d64837e39f947a2ad5910628b6ebfa60 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 13:22:21 +0000 Subject: [PATCH] windows: menus and dialogs in windows of their own, as Acrylic popups With floats as windows (`FIZZY_POPOUT=1` on Windows), a menu or dialog now leaves the main window as it does on macOS (`viewports.menus`): a borderless window owned by the window it opens from, never activated, dressed by DWM (`win32_titlebar.viewportMenuChrome`) with Acrylic (DWMSBT_TRANSIENTWINDOW, the material of Windows 11's menus and flyouts), DWM's rounded corners and shadow, no border, no system menu and no DWM transitions. The window's base is drawn under the menu at the window opacity, as over the main window's Acrylic. Step 3 of docs/WINDOWS_LINUX_GLASS_PLAN.md. An owned window is not carried when its owner moves, so on Windows a menu's window rides on nothing and is placed each frame (`viewportPlaceRiding`); a dialog of a float that is out is owned by that float's window, for its stacking. DWM draws a never-activated window's backdrop as its solid fallback, so the menu's window is kept looking active to it (`WM_NCACTIVATE`). Checked in the Windows 11 ARM VM (WARP, aarch64-windows-gnu Debug), with real input (`SendInput`) and the window list (`EnumWindows`, DWM attributes): - File and Help menus, and the "Unsaved changes" dialog, each open in a window of their own: owned by the main window, directly above it, `WS_EX_NOACTIVATE`, no `WS_SYSMENU`, backdrop type Acrylic, corner preference round. The main window stays the foreground window. - The first click on an item acts (New File, Open Files), and lands on the menu, not on what is under it: a temporary log showed the press arriving on the menu's window and mapping into the menu's subwindow. - The dialog's window hides with a minimized main window and comes back on restore. Not checked: the look, since the VM's DWM draws every backdrop as its solid fallback; the kept-active trick, for the same reason; a menu over a float's window. Those need a hardware GPU. Co-Authored-By: Claude Opus 5.5 --- docs/WINDOWS_LINUX_GLASS_PLAN.md | 11 +++- src/backend/backend_native.zig | 5 +- src/backend/native/SDLBackend.zig | 41 ++++++++---- .../native/platform/win32_titlebar.zig | 66 +++++++++++++++++++ src/editor/Popout.zig | 7 +- 5 files changed, 113 insertions(+), 17 deletions(-) diff --git a/docs/WINDOWS_LINUX_GLASS_PLAN.md b/docs/WINDOWS_LINUX_GLASS_PLAN.md index 01fcd75d5..8c1519e53 100644 --- a/docs/WINDOWS_LINUX_GLASS_PLAN.md +++ b/docs/WINDOWS_LINUX_GLASS_PLAN.md @@ -365,7 +365,16 @@ Each step is useful by itself, and the spikes come before anything that depends `win32_titlebar` state, minimize and close with the main window, and the look checked on a hardware GPU. This is what lets `float_windows` default to on for Windows, as it does on macOS. 3. **Windows: menus and dialogs as Acrylic popups** (`viewportOpenMenu`). Small, and the closest - thing on Windows to the OS's own UI. + thing on Windows to the OS's own UI. *Built, run in a Windows 11 ARM VM on WARP:* + `win32_titlebar.viewportMenuChrome` dresses the menu's window (Acrylic, DWM's corners and shadow, + no system menu, no transitions), owned by the window it opens from, and keeps it looking active + to DWM, which otherwise draws a never-activated window's backdrop as its solid fallback. On with + floats as windows (`FIZZY_POPOUT=1`), as on macOS. In the VM, menus and the unsaved-changes + dialog open in windows of their own, stacked just above the main window, which keeps the + keyboard; the first click on an item acts; a dialog's window hides and comes back with a + minimized and restored main window. The look itself (Acrylic rather than its fallback, DWM's + corners) and whether the kept-active trick holds need a hardware GPU **(check)**: the VM's DWM + draws every backdrop as its fallback. 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. diff --git a/src/backend/backend_native.zig b/src/backend/backend_native.zig index 4255e4546..5871e82de 100644 --- a/src/backend/backend_native.zig +++ b/src/backend/backend_native.zig @@ -207,8 +207,9 @@ pub const viewports = struct { return Impl.appActive(); } - /// Whether menus can be windows of their own (`openMenu`): macOS, where viewports are. - pub const menus = supported and builtin.os.tag == .macos; + /// Whether menus can be windows of their own (`openMenu`): macOS, and Windows (Acrylic popups), + /// where viewports are. + pub const menus = supported and (builtin.os.tag == .macos or builtin.os.tag == .windows); /// A window for a menu (`Popout`'s menus): borderless, above every window, never focused, the /// OS's material in it — rounded by `radius` points — and the pointer over it read in the main diff --git a/src/backend/native/SDLBackend.zig b/src/backend/native/SDLBackend.zig index 0da61914d..b46029f37 100644 --- a/src/backend/native/SDLBackend.zig +++ b/src/backend/native/SDLBackend.zig @@ -924,20 +924,39 @@ const ViewportKind = enum { window, carry, overlay, menu }; /// A menu in a window of its own (`Popout`'s menus): borderless, kept above every window by its /// level, in no window list, never made key — the window it opened from keeps the keyboard, and /// its first click acts. Placed in the main window's frame (`viewportPlaceMain`), the pointer over it -/// read there (`viewportMainOffset`). Its material is the OS's (`viewportLiquidGlass`, vibrancy -/// before macOS 26). macOS. +/// read there (`viewportMainOffset`). Its material is the OS's: on macOS Liquid Glass +/// (`viewportLiquidGlass`, vibrancy before macOS 26), on Windows Acrylic +/// (`win32_titlebar.viewportMenuChrome`), with the window's base drawn under the menu. /// `radius`, points: the menu's corners, which vibrancy is masked to (Liquid Glass takes them from -/// its look each frame). `ride`: the window it moves with (`Viewport.ride`) — none for a menu opened -/// in a float that is out. `dialog`: a dialog's window (`Viewport.dialog`). +/// its look each frame; DWM rounds as near it as it rounds). `ride`: the window it moves with +/// (`Viewport.ride`) — none for a menu opened in a float that is out. `dialog`: a dialog's window +/// (`Viewport.dialog`). pub fn viewportOpenMenu(self: *SDLBackend, at: viewport_map.Rect, radius: f32, ride: Ride, dialog: bool) ?*Viewport { - if (comptime builtin.os.tag != .macos) return null; + if (comptime builtin.os.tag != .macos and builtin.os.tag != .windows) return null; const vp = self.openViewport(at, "", .menu) orelse return null; - _ = fizzy_macos_viewport_menu(cocoaWindow(vp.window), cocoaWindow(self.window), platform.window.ns_visual_effect_material, radius, @intFromBool(dialog)); - vp.ride = switch (ride) { - .none => null, - .main => self.window, - .viewport => |parent| parent.window, - }; + switch (comptime builtin.os.tag) { + .macos => { + _ = fizzy_macos_viewport_menu(cocoaWindow(vp.window), cocoaWindow(self.window), platform.window.ns_visual_effect_material, radius, @intFromBool(dialog)); + vp.ride = switch (ride) { + .none => null, + .main => self.window, + .viewport => |parent| parent.window, + }; + }, + .windows => { + // Owned by the window it opens from, as `openViewport` makes every viewport the main + // window's: over it, hidden and minimized with it. An owned window is not carried + // when its owner moves, so it rides on nothing (`viewportPlaceRiding` places it each + // frame). + switch (ride) { + .none, .main => {}, + .viewport => |parent| _ = c.SDL_SetWindowParent(vp.window, parent.window), + } + if (c.SDL_GetPointerProperty(c.SDL_GetWindowProperties(vp.window), c.SDL_PROP_WINDOW_WIN32_HWND_POINTER, null)) |hwnd| + _ = platform.win32_titlebar.viewportMenuChrome(hwnd, dvui.themeGet().dark, radius); + }, + else => unreachable, + } vp.dialog = dialog; return vp; } diff --git a/src/backend/native/platform/win32_titlebar.zig b/src/backend/native/platform/win32_titlebar.zig index 6da9c5c77..e7d44cd67 100644 --- a/src/backend/native/platform/win32_titlebar.zig +++ b/src/backend/native/platform/win32_titlebar.zig @@ -417,6 +417,72 @@ pub fn viewportChrome(hwnd: *anyopaque, main_hwnd: ?*anyopaque, dark: bool, radi const viewport_ws_sysmenu: isize = 0x00080000; const viewport_ws_ex_appwindow: isize = 0x00040000; +const menu_subclass_id: usize = 0x50584933; // "PXI3" + +/// Dress a menu's or a dialog's window (`SDLBackend.viewportOpenMenu`): Acrylic behind it, the +/// material of Windows 11's own menus and flyouts, in the app's light or dark (`dark`); its corners +/// rounded by DWM as near `radius_pt` (points) as DWM rounds; DWM's shadow, no border, no DWM +/// animation. It is never activated, and DWM draws an inactive window's backdrop as a solid +/// fallback, so it is kept looking active (`menuSubclassProc`). True when DWM gives it the +/// backdrop (Windows 11 22H2 on). +pub fn viewportMenuChrome(hwnd: *anyopaque, dark: bool, radius_pt: f32) bool { + if (builtin.os.tag != .windows) return false; + const h: win32.foundation.HWND = @ptrCast(hwnd); + const dwm = win32.graphics.dwm; + const wm = win32.ui.windows_and_messaging; + const corner: u32 = if (radius_pt < 1) DWMWCP_DONOTROUND else if (radius_pt < 6) DWMWCP_ROUNDSMALL else DWMWCP_ROUND; + _ = dwm.DwmSetWindowAttribute(h, @enumFromInt(DWMWA_WINDOW_CORNER_PREFERENCE), &corner, @sizeOf(u32)); + const none: u32 = dwm.DWMWA_COLOR_NONE; + _ = dwm.DwmSetWindowAttribute(h, dwm.DWMWA_BORDER_COLOR, &none, @sizeOf(u32)); + const dark_value: u32 = @intFromBool(dark); + _ = dwm.DwmSetWindowAttribute(h, @enumFromInt(DWMWA_USE_IMMERSIVE_DARK_MODE), &dark_value, @sizeOf(u32)); + const no_transitions: u32 = 1; + _ = dwm.DwmSetWindowAttribute(h, @enumFromInt(DWMWA_TRANSITIONS_FORCEDISABLED), &no_transitions, @sizeOf(u32)); + _ = win32.ui.shell.SetWindowSubclass(h, menuSubclassProc, menu_subclass_id, 0); + // No system menu, whose caption buttons DWM would draw over the menu with the frame extended + // (as for a float's window, `viewportChrome`); SDL's caption style stays, collapsed by its + // `WM_NCCALCSIZE`, for DWM to treat the window as framed and give it a backdrop at all. + const style = wm.GetWindowLongPtrW(h, wm.GWL_STYLE); + if (style & viewport_ws_sysmenu != 0) { + _ = wm.SetWindowLongPtrW(h, wm.GWL_STYLE, style & ~viewport_ws_sysmenu); + const SWP_NOSIZE: u32 = 0x0001; + const SWP_NOMOVE: u32 = 0x0002; + const SWP_NOZORDER: u32 = 0x0004; + const SWP_NOACTIVATE: u32 = 0x0010; + const SWP_FRAMECHANGED: u32 = 0x0020; + _ = wm.SetWindowPos(h, null, 0, 0, 0, 0, @bitCast(SWP_NOSIZE | SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED)); + } + const ok = applyViewportBackdrop(h); + // Drawn active from the start: it will never be sent an activation of its own. + _ = wm.SendMessageW(h, wm.WM_NCACTIVATE, 1, 0); + return ok; +} + +fn menuSubclassProc( + hWnd: ?win32.foundation.HWND, + uMsg: u32, + wParam: win32.foundation.WPARAM, + lParam: win32.foundation.LPARAM, + uIdSubclass: usize, + dwRefData: usize, +) callconv(.winapi) win32.foundation.LRESULT { + _ = uIdSubclass; + _ = dwRefData; + const wm = win32.ui.windows_and_messaging; + // Always active to DWM, so its backdrop stays Acrylic rather than the inactive fallback. + if (uMsg == wm.WM_NCACTIVATE) return win32.ui.shell.DefSubclassProc(hWnd, uMsg, 1, lParam); + // SDL sets the window's style again as it likes: never the system menu. + if (uMsg == wm.WM_STYLECHANGING and @as(isize, @bitCast(wParam)) == @intFromEnum(wm.GWL_STYLE)) { + const ss: *wm.STYLESTRUCT = @ptrFromInt(@as(usize, @bitCast(lParam))); + ss.styleNew &= ~@as(u32, @intCast(viewport_ws_sysmenu)); + } + if (uMsg == wm.WM_DWMCOMPOSITIONCHANGED) if (hWnd) |h| { + _ = applyViewportBackdrop(h); + }; + if (uMsg == wm.WM_NCDESTROY) _ = win32.ui.shell.RemoveWindowSubclass(hWnd, menuSubclassProc, menu_subclass_id); + return win32.ui.shell.DefSubclassProc(hWnd, uMsg, wParam, lParam); +} + /// The backdrop, and the frame extended over the whole window for it to show through — again on /// every activation, as DWM wants (and as `win32MicaSubclassProc` does for the main window). fn applyViewportBackdrop(h: win32.foundation.HWND) bool { diff --git a/src/editor/Popout.zig b/src/editor/Popout.zig index ee9cb190a..b604f3363 100644 --- a/src/editor/Popout.zig +++ b/src/editor/Popout.zig @@ -1162,8 +1162,9 @@ fn backing(target: dvui.Rect.Physical, window: dvui.Rect.Physical, opacity: f32) bounds.fill(corners, .{ .color = .{ .color = Editor.windowBase(opacity) } }); } -/// Whether menus are windows of their own (`menuFrame`): where the OS can show them (macOS), with -/// floats as windows, as the setting says (`Settings.native_menus`, `FIZZY_NATIVE_MENUS` over it). +/// Whether menus are windows of their own (`menuFrame`): where the OS can show them (macOS; Windows, +/// as Acrylic popups), with floats as windows, as the setting says (`Settings.native_menus`, +/// `FIZZY_NATIVE_MENUS` over it). fn nativeMenus() bool { if (comptime !viewports.menus) return false; if (!viewports.available()) return false; @@ -1269,7 +1270,7 @@ fn menuFrame() void { const placed = viewports.placeRiding(m.viewport, .{ .x = place.x, .y = place.y, .w = place.w, .h = place.h }, .{ .x = key.x, .y = key.y, .w = key.w, .h = key.h }); const shown: dvui.Rect.Physical = .{ .x = frame.x + (placed.x - place.x), .y = frame.y + (placed.y - place.y), .w = placed.w, .h = placed.h }; // Its material: Liquid Glass on the slider, no lighter than a menu's text needs; vibrancy - // before macOS 26, with the window's colour drawn under the menu. + // before macOS 26, or Acrylic on Windows, with the window's colour drawn under the menu. const op = @max(std.math.clamp(fizzy.editor().window_opacity, 0, 1), menu_opacity_floor); var look = Editor.windowGlassLook(op, .{ .w = shown.w / s, .h = shown.h / s }); look.radius = corner;