diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index bbb0890..df13a50 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -4907,12 +4907,28 @@ pub async fn mini_close(app: tauri::AppHandle) -> Result<(), String> { /// scroll blinked the glass dark for a beat (Kyle, 2026-08-26). The legacy /// accent-policy acrylic below is composed with the window itself and holds /// through interaction. -#[tauri::command] -pub fn window_glass(window: tauri::WebviewWindow, on: bool) -> Result<(), String> { - // Clear any system backdrop a previous toggle/build may have applied. - let _ = window.set_effects(None::); - let hwnd = window.hwnd().map_err(|e| e.to_string())?.0 as *mut std::ffi::c_void; +/// Whether dark-glass is currently ON for the MAIN window. Written by +/// window_glass, read by the window subclass in thumbbar.rs so it can +/// re-assert the accent after the gestures where Windows drops it: the +/// modal move/size loop, and a maximize/restore (Kyle photographed the +/// desktop punching through a maximized window on 2026-08-28 — the accent +/// did not survive the transition on its own). +pub(crate) static GLASS_ON: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); +/// The accent states this window uses. `Blur` is the pre-acrylic +/// ACCENT_ENABLE_BLURBEHIND: visually close, but composited by DWM through +/// the modal move/size loop where the acrylic is SUSPENDED — the subclass +/// swaps down to it for the length of a gesture and back up on release +/// (see thumbbar.rs). Same struct, one field different. +#[derive(Clone, Copy, PartialEq)] +pub(crate) enum Accent { + Off, + Blur, + Acrylic, +} + +pub(crate) fn apply_accent(hwnd: *mut std::ffi::c_void, accent: Accent) -> Result<(), String> { #[repr(C)] struct AccentPolicy { state: u32, @@ -4949,11 +4965,15 @@ pub fn window_glass(window: tauri::WebviewWindow, on: bool) -> Result<(), String } let f: Swca = std::mem::transmute(f); let mut policy = AccentPolicy { - state: if on { 4 } else { 0 }, // ACCENT_ENABLE_ACRYLICBLURBEHIND / ACCENT_DISABLED + state: match accent { + Accent::Off => 0, // ACCENT_DISABLED + Accent::Blur => 3, // ACCENT_ENABLE_BLURBEHIND + Accent::Acrylic => 4, // ACCENT_ENABLE_ACRYLICBLURBEHIND + }, flags: 0, // A whisper of black in the OS layer (acrylic treats alpha 0 as // "don't draw"); the real darkening is the CSS canvas tint. - gradient: if on { 0x2E00_0000 } else { 0 }, + gradient: if matches!(accent, Accent::Off) { 0 } else { 0x2E00_0000 }, animation: 0, }; let mut data = AttribData { @@ -4966,6 +4986,58 @@ pub fn window_glass(window: tauri::WebviewWindow, on: bool) -> Result<(), String Ok(()) } +/// The DWM maximize/restore zoom animation. The accent visual detaches for +/// its length and takes until ~650ms after the call to recompose (measured +/// 2026-08-28 at 43ms/frame: desktop icons legible straight through the +/// maximized window). Disabling it removed the punch-through outright, but +/// also the animation — the window snapped to size — and Kyle chose the +/// animation back with the canvas grounded across the recomposition instead +/// (2026-08-29). So transitions are now explicitly forced ON (clearing any +/// disable a previous build left on this window), and the discrete bracket +/// in thumbbar.rs + GROUND_DISCRETE in glass.js covers the detach window. +fn set_transitions_disabled(hwnd: *mut std::ffi::c_void, disabled: bool) { + #[allow(non_snake_case)] + #[link(name = "kernel32")] + extern "system" { + fn LoadLibraryA(name: *const u8) -> *mut std::ffi::c_void; + fn GetProcAddress( + module: *mut std::ffi::c_void, + name: *const u8, + ) -> *mut std::ffi::c_void; + } + type DwmSet = + unsafe extern "system" fn(*mut std::ffi::c_void, u32, *const std::ffi::c_void, u32) -> i32; + unsafe { + let dwm = LoadLibraryA(b"dwmapi.dll".as_ptr()); + if dwm.is_null() { + return; + } + let f = GetProcAddress(dwm, b"DwmSetWindowAttribute".as_ptr()); + if f.is_null() { + return; + } + let f: DwmSet = std::mem::transmute(f); + let val: i32 = if disabled { 1 } else { 0 }; + // 3 = DWMWA_TRANSITIONS_FORCEDISABLED + let _ = f(hwnd, 3, &val as *const _ as _, 4); + } +} + +#[tauri::command] +pub fn window_glass(window: tauri::WebviewWindow, on: bool) -> Result<(), String> { + // Clear any system backdrop a previous toggle/build may have applied. + let _ = window.set_effects(None::); + let hwnd = window.hwnd().map_err(|e| e.to_string())?.0 as *mut std::ffi::c_void; + // The mini calls this too (window_glass acts on the CALLING window), and + // the subclass re-asserting from thumbbar.rs is on the MAIN window only — + // so only the main window's state is tracked. + if window.label() == "main" { + GLASS_ON.store(on, std::sync::atomic::Ordering::Relaxed); + } + set_transitions_disabled(hwnd, false); + apply_accent(hwnd, if on { Accent::Acrylic } else { Accent::Off }) +} + #[cfg(test)] mod hero_processing_tests { use super::*; diff --git a/src-tauri/src/thumbbar.rs b/src-tauri/src/thumbbar.rs index 4387066..6a93811 100644 --- a/src-tauri/src/thumbbar.rs +++ b/src-tauri/src/thumbbar.rs @@ -36,6 +36,7 @@ pub fn set_playing(_app: &tauri::AppHandle, _playing: bool) {} #[cfg(windows)] mod imp { + use crate::commands::{apply_accent, Accent, GLASS_ON}; use crate::smtc::MediaButton; use std::cell::RefCell; use std::sync::atomic::{AtomicBool, Ordering}; @@ -55,8 +56,10 @@ mod imp { THB_ICON, THB_TOOLTIP, THUMBBUTTON, THBN_CLICKED, }; use windows::Win32::UI::WindowsAndMessaging::{ - CreateIconIndirect, DestroyIcon, RegisterWindowMessageW, HICON, ICONINFO, WM_COMMAND, - WM_SETTINGCHANGE, + CreateIconIndirect, DestroyIcon, PostMessageW, RegisterWindowMessageW, HICON, HTBOTTOM, + HTBOTTOMLEFT, HTBOTTOMRIGHT, HTLEFT, HTRIGHT, HTTOP, HTTOPLEFT, HTTOPRIGHT, ICONINFO, + KillTimer, SetTimer, SC_SIZE, WM_APP, WM_COMMAND, WM_ENTERSIZEMOVE, WM_EXITSIZEMOVE, + WM_NCLBUTTONDOWN, WM_SETTINGCHANGE, WM_SIZE, WM_SIZING, WM_SYSCOMMAND, WM_TIMER, }; const ID_PREV: u32 = 1; @@ -72,6 +75,36 @@ mod imp { static PLAYING: AtomicBool = AtomicBool::new(false); static ADDED: AtomicBool = AtomicBool::new(false); + /// Private message, posted to ourselves — see the WM_RESIZE_DRAG arm in + /// subclass_proc for why the emit cannot happen inline. + const WM_RESIZE_DRAG: u32 = WM_APP + 0x11; + + /// Set between WM_NCLBUTTONDOWN-on-a-border (or SC_SIZE) and + /// WM_EXITSIZEMOVE: "the modal loop we are about to enter is a RESIZE". + static SIZING_LATCH: AtomicBool = AtomicBool::new(false); + /// Set while a resize drag is actually in flight, so the enter/exit pair + /// is emitted exactly once each however the messages interleave. + static IN_SIZE_DRAG: AtomicBool = AtomicBool::new(false); + /// Set for the length of ANY modal move/size loop (move drags included), + /// so the WM_SIZE re-assert below can tell a discrete size change from + /// the per-frame WM_SIZE stream inside a drag. + static IN_MODAL: AtomicBool = AtomicBool::new(false); + /// One-shot timer for the post-gesture accent re-assert; any stable value. + const REAPPLY_TIMER_ID: usize = 0x50_1A_12; + + /// Tells the frontend to ground the glass canvas for the length of a live + /// resize drag. Windows drops the accent-policy acrylic inside the modal + /// size loop while the window stays transparent, so the desktop punches + /// through razor-sharp; see armGlassResizeGuard in src/lib/glass.js. + #[derive(Clone, serde::Serialize)] + pub struct ResizeDrag { + pub active: bool, + /// "drag" for the modal move/size loop, "discrete" for a + /// maximize/restore/snap/DPI size jump — the frontend grounds them + /// under separate policies (see armGlassResizeGuard in glass.js). + pub kind: &'static str, + } + thread_local! { /// UI-thread only, by the apartment rule in the module docs. static TASKBAR: RefCell> = const { RefCell::new(None) }; @@ -192,6 +225,126 @@ mod imp { if let Some(button) = button { let _ = app.emit("soulseek:media-button", MediaButton { button }); } + } else if msg == WM_NCLBUTTONDOWN { + // Latch WHAT KIND of non-client drag is starting. WM_ENTERSIZEMOVE + // on its own cannot tell a sizing drag from moving the window by + // its titlebar — data-tauri-drag-region calls StartDragging, which + // enters the very same modal loop — and only the sizing one is + // known to cost the acrylic. wparam IS the hit-test code here. + let ht = (wparam.0 & 0xFFFF) as u32; + SIZING_LATCH.store( + matches!( + ht, + HTLEFT + | HTRIGHT + | HTTOP + | HTTOPLEFT + | HTTOPRIGHT + | HTBOTTOM + | HTBOTTOMLEFT + | HTBOTTOMRIGHT + ), + Ordering::Relaxed, + ); + } else if msg == WM_SYSCOMMAND && (wparam.0 & 0xFFF0) == SC_SIZE as usize { + // Keyboard resize (Alt+Space, Size) enters the same loop with no + // click, so there is no hit-test to latch from. + SIZING_LATCH.store(true, Ordering::Relaxed); + } else if msg == WM_ENTERSIZEMOVE { + IN_MODAL.store(true, Ordering::Relaxed); + // DWM suspends the ACRYLIC accent for the whole modal loop but + // keeps compositing the plain BLURBEHIND one, so the window swaps + // down to Blur for the gesture instead of going see-through. + // Every modal loop gets this — move drags included, since a + // suspended acrylic punches through on those exactly the same. + if GLASS_ON.load(Ordering::Relaxed) { + let _ = apply_accent(hwnd.0, Accent::Blur); + } + // Arm on the LOOP, not on WM_SIZING. Press a border and hold still + // and Windows sends no WM_SIZING at all, yet the acrylic is + // already gone — keying on movement would leave exactly that case + // see-through, which is the artifact the guard exists to hide. + if SIZING_LATCH.load(Ordering::Relaxed) && !IN_SIZE_DRAG.swap(true, Ordering::Relaxed) { + unsafe { + let _ = PostMessageW(Some(hwnd), WM_RESIZE_DRAG, WPARAM(1), LPARAM(0)); + } + } + } else if msg == WM_SIZING { + // Belt and braces for the latch above. If WM_NCLBUTTONDOWN never + // reached us carrying a sizing hit-test — this window is + // undecorated and tao does its own NCHITTEST, and the exact path + // has never been confirmed by a real human drag — a WM_SIZING is + // still proof we are inside a live resize, because Windows sends + // it nowhere else. A titlebar MOVE drag never sends it, so this + // cannot arm on a move. It arms one mouse-move later than the + // latch does, which is why the latch is still the primary. + if !IN_SIZE_DRAG.swap(true, Ordering::Relaxed) { + unsafe { + let _ = PostMessageW(Some(hwnd), WM_RESIZE_DRAG, WPARAM(1), LPARAM(0)); + } + } + } else if msg == WM_EXITSIZEMOVE { + IN_MODAL.store(false, Ordering::Relaxed); + if GLASS_ON.load(Ordering::Relaxed) { + let _ = apply_accent(hwnd.0, Accent::Acrylic); + } + SIZING_LATCH.store(false, Ordering::Relaxed); + if IN_SIZE_DRAG.swap(false, Ordering::Relaxed) { + unsafe { + let _ = PostMessageW(Some(hwnd), WM_RESIZE_DRAG, WPARAM(0), LPARAM(0)); + } + } + } else if msg == WM_RESIZE_DRAG { + // POSTED to ourselves, never emitted inline. Emitting from inside + // DefWindowProc's modal size loop would reenter the wry runtime's + // window store while that loop holds it; posting lands the emit at + // the top of the loop's own pump with nothing borrowed. The loop + // does pump posted messages, so the arm still reaches the page + // during the drag rather than after it. + let app = unsafe { &*(refdata as *const tauri::AppHandle) }; + let _ = app.emit( + "soulseek:resize-drag", + ResizeDrag { + active: wparam.0 != 0, + kind: if lparam.0 == 1 { "discrete" } else { "drag" }, + }, + ); + } else if msg == WM_SIZE { + // A DISCRETE size change: maximize (wparam 2), restore/snap/DPI + // (0). Kyle photographed the desktop punching through a maximized + // glass window (2026-08-28): the acrylic does not reliably + // survive the transition, so it is re-asserted — once now, once + // more after the animation settles (the immediate one can land + // while DWM is still mid-transition and be dropped again). + // Gated out inside a modal loop, where WM_SIZE streams per frame + // and the accent is deliberately parked at Blur. + if !IN_MODAL.load(Ordering::Relaxed) + && GLASS_ON.load(Ordering::Relaxed) + && (wparam.0 == 2 || wparam.0 == 0) + { + let _ = apply_accent(hwnd.0, Accent::Acrylic); + unsafe { + // Ground the canvas for the zoom animation plus the accent + // recomposition after it; the timer's arm below both + // re-asserts and releases. 650ms: recovery measured clean + // at ~613ms after the maximize call with the animation on + // (2026-08-28 vis_max, 43ms/frame), and WM_SIZE lands + // ~60-100ms after the call — the release then trails the + // last punch-through frame by a comfortable ~170ms. + let _ = PostMessageW(Some(hwnd), WM_RESIZE_DRAG, WPARAM(1), LPARAM(1)); + let _ = SetTimer(Some(hwnd), REAPPLY_TIMER_ID, 650, None); + } + } + } else if msg == WM_TIMER && wparam.0 == REAPPLY_TIMER_ID { + unsafe { + let _ = KillTimer(Some(hwnd), REAPPLY_TIMER_ID); + } + if GLASS_ON.load(Ordering::Relaxed) { + let _ = apply_accent(hwnd.0, Accent::Acrylic); + } + unsafe { + let _ = PostMessageW(Some(hwnd), WM_RESIZE_DRAG, WPARAM(0), LPARAM(1)); + } } else if msg == WM_SETTINGCHANGE { // Covers the light/dark flip: the glyphs are drawn for a // background they no longer sit on, so redraw them. diff --git a/src/lib/glass.js b/src/lib/glass.js index 4eefd9e..fa99c32 100644 --- a/src/lib/glass.js +++ b/src/lib/glass.js @@ -9,7 +9,7 @@ // The mini must never import this — its applier would re-emit into its own // listener. import { emit } from "@tauri-apps/api/event"; -import { windowGlass } from "./api.js"; +import { onEvent, windowGlass } from "./api.js"; let current = false; export const glassOn = () => current; @@ -21,7 +21,7 @@ export function applyGlass(on) { void emit("mini:glass", { on: current }); } -/** Ground the canvas while the window is being resized. +/** Ground the canvas while the window is being DRAGGED to a new size. * * WHY THIS EXISTS. The OS half of glass mode is the LEGACY accent-policy * acrylic — `SetWindowCompositionAttribute` with ACCENT_ENABLE_ACRYLICBLURBEHIND @@ -31,8 +31,9 @@ export function applyGlass(on) { * it is not something the app can switch off. The moment it drops, the window * is still TRANSPARENT — so the desktop behind it stops being a soft wash and * punches through razor-sharp, wallpaper and icons and all. Measured - * 2026-08-27 by toggling `window_glass` alone: with the acrylic off you can - * read desktop icon labels through the titlebar and the sidebar. + * 2026-08-27 by toggling `window_glass` alone; the reference shot is + * research/scroll-diag/glass-refs/c-acrylic-off.png, and it is genuinely worse + * than the grounded state beside it. * * So the fix is not to keep the blur — Windows will not let us. It is to stop * relying on it for the length of the gesture: the canvas goes opaque, and @@ -40,24 +41,131 @@ export function applyGlass(on) { * window reads as an ordinary solid dark window while you drag, and comes back * to glass when you let go. * - * `resize` fires continuously through a drag and once for a maximize, a - * restore or a DPI change, so all of those are covered by the same guard. The - * class goes on unconditionally and the CSS gates it on `.glass`, which keeps - * this correct if the mode is toggled mid-gesture. + * HOW GLASS SURVIVES A GESTURE (2026-08-28, three layers, Rust-first): + * + * 1. Modal move/size loops (border drags, titlebar drags): the subclass in + * src-tauri/src/thumbbar.rs swaps the accent from ACRYLIC down to plain + * BLURBEHIND for the length of the loop and back on release. DWM + * suspends the acrylic inside the loop but keeps compositing the plain + * blur, so the window stays glassy instead of see-through or black. + * + * 2. Discrete jumps (maximize, restore, snap, DPI): the DWM zoom animation + * scales a SNAPSHOT of the window, not the window — the accent visual + * detaches for its length and recomposes only ~613ms after the call + * (measured 2026-08-28 at 43ms/frame: desktop icons read sharply THROUGH + * the maximized window until then). Disabling the animation removed the + * punch-through outright, but Kyle chose keeping the animation and + * grounding across the recomposition instead (2026-08-29) — so the + * subclass brackets the jump: active:true at WM_SIZE, a re-assert of the + * accent immediately and again at 650ms, active:false with the second + * re-assert. GROUND_DISCRETE below paints the canvas opaque for exactly + * that bracket. + * + * 3. This file: the subclass reports both kinds as soulseek:resize-drag + * ({active, kind: "drag"|"discrete"}), and the policy constants below + * decide whether the canvas grounds opaque for the bracket. DRAG is off + * (the blur swap carries it — verified by Kyle by hand, 2026-08-29); + * DISCRETE is on, covering the animation trade above. + * + * HISTORY, kept because both failures looked right when they shipped. + * `resize` fires ONCE for a discrete change + * (measured 2026-08-27 driving Tauri's own maximize) and the first guard + * grounded on it, which bought nothing: the settled maximized window sat flat + * black for ~70ms AFTER the animation had finished, hiding glass that was + * already back. Kyle reported it as the blur "being masked by just going + * opaque". + * + * The attempt in between is recorded because it looked right and was not. It + * discriminated on event CADENCE — ground on the second `resize` inside a + * short window, since a drag streams and a maximize does not. On a 4-core + * laptop dragging this app, re-rasterising every backdrop-filter at each new + * size pushes `resize` deliveries far apart: measured 2026-08-28, events + * 150ms apart never grounded AT ALL, so a real drag stayed see-through and + * shipped the exact artifact the guard exists to prevent. Cadence is not a + * proxy for the loop. Do not try it again. + * + * FALLBACK. If the native signal never arrives — the subclass failed to + * install (thumbbar.rs only warns), or a build without it — the DOM `resize` + * listener reverts to the ORIGINAL behaviour: ground on any resize, release + * after RESIZE_SETTLE_MS. That grounds maximize again, which is the complaint + * this file exists to fix, but it fails toward "too opaque" rather than toward + * "see-through", and see-through is the one that is actually ugly. The moment + * a single native event is heard the fallback stands down for good. + * + * The class goes on unconditionally and the CSS gates it on `.glass`, which + * keeps this correct if the mode is toggled mid-gesture. */ const RESIZE_SETTLE_MS = 180; +// The acrylic is back by WM_EXITSIZEMOVE, but the repaint at the final size +// can trail it by a frame or two. Long enough to cover that, short enough that +// nobody reads it as a black tail hanging off mouse-up. +const EXIT_SETTLE_MS = 60; +// Insurance only, and deliberately far longer than any real gesture: if an +// EXITSIZEMOVE is ever lost the canvas must not stay grounded for the rest of +// the session. Nothing should ever reach this. +const DRAG_HARD_CAP_MS = 20_000; +// Ground policy per native gesture kind: true = opaque canvas for the +// bracket (deterministic, dark), false = live glass, trusting the Rust +// layers above. DRAG is off: the blur swap carries border drags, verified +// by Kyle by hand (2026-08-29). DISCRETE is ON, by choice, same day: the +// alternative was disabling the DWM zoom animation (measured clean, +// t2_noground) but Kyle wanted the animation, and the animation scales a +// snapshot the accent cannot ride — so the canvas grounds for the ~650ms +// bracket instead. Flipping DISCRETE off without also disabling +// transitions in window_glass reintroduces the photographed punch-through. +const GROUND_DRAG = false; +const GROUND_DISCRETE = true; + export function armGlassResizeGuard() { if (typeof window === "undefined") return () => {}; - let timer = 0; + const root = document.documentElement; + let settle = 0; + let cap = 0; + // Latches on the first native event and never unlatches: one report is + // proof the subclass is alive, and the fallback must not fight it. + let native = false; + + const ground = () => { + clearTimeout(settle); + root.classList.add("resizing"); + }; + const release = (delay) => { + clearTimeout(settle); + settle = setTimeout(() => { + clearTimeout(cap); + root.classList.remove("resizing"); + }, delay); + }; + + const stopNative = onEvent("soulseek:resize-drag", (p) => { + native = true; + // Separate policies per gesture kind, both JS-side so they can be + // tuned without a Rust rebuild. "drag" is the modal move/size loop; + // "discrete" is a maximize/restore/snap/DPI jump, bracketed by the + // subclass around the accent recomposition window. + const wanted = p?.kind === "discrete" ? GROUND_DISCRETE : GROUND_DRAG; + if (!wanted) return; + if (p?.active) { + ground(); + clearTimeout(cap); + cap = setTimeout(() => release(0), DRAG_HARD_CAP_MS); + } else { + release(EXIT_SETTLE_MS); + } + }); + const onResize = () => { - document.documentElement.classList.add("resizing"); - clearTimeout(timer); - timer = setTimeout(() => document.documentElement.classList.remove("resizing"), RESIZE_SETTLE_MS); + if (native) return; + ground(); + release(RESIZE_SETTLE_MS); }; window.addEventListener("resize", onResize, { passive: true }); + return () => { - clearTimeout(timer); + clearTimeout(settle); + clearTimeout(cap); + stopNative(); window.removeEventListener("resize", onResize); - document.documentElement.classList.remove("resizing"); + root.classList.remove("resizing"); }; }