diff --git a/docs/WINDOWS_LINUX_GLASS_PLAN.md b/docs/WINDOWS_LINUX_GLASS_PLAN.md index 01fcd75d..8c1519e5 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 4255e454..5871e82d 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 0da61914..b46029f3 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 6da9c5c7..e7d44cd6 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 ee9cb190..b604f336 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;