Kyle: "when clicking and dragging the window the window gets light." It did, by design that no longer had a reason: WM_ENTERSIZEMOVE parked the accent at plain BLURBEHIND for EVERY modal loop, move drags included, on the old assumption that a suspended acrylic punches through on moves the same way it does on resizes. Plain blur ignores the gradient tint and has none of the acrylic's material dimming — measured over a striped backdrop, every glass surface stepped ~15% brighter (band luma 45.5 -> 52.2) for the whole of every titlebar drag. And no compensation was possible: move loops emit no resize-drag event, the frontend never hears about them. Measured on this machine (research/glass-drag harness, SC_MOVE posted so no synthetic mouse goes near the UI, GUI_INMOVESIZE as ground truth): DWM composites the acrylic straight through a move loop. Re-asserted mid-loop it restored rest luma EXACTLY (45.5/31.8/29.4), stationary and through 28 frames of real mouse-speed motion — no punch-through, no trailing. So the swap is now gated on SIZING_LATCH: a titlebar move keeps the acrylic, a latched resize parks at Blur exactly as before (DWM genuinely suspends acrylic in live resizes — that half is load-bearing and stays). The WM_SIZING belt-and-braces arm swaps the accent too, so a resize the hit-test latch misses still degrades one mouse-move late instead of never. Verified after: entering a move loop changes nothing to the decimal; entering a resize loop still swaps (+6.7 band luma, the intended trade) and restores on release.
590 lines
26 KiB
Rust
590 lines
26 KiB
Rust
//! The taskbar thumbnail toolbar — transport buttons under the window preview.
|
|
//!
|
|
//! Hovering an app's taskbar button pops a preview, and an app may put up to
|
|
//! seven buttons under it. Every media player uses them for the transport, and
|
|
//! that is what this puts there: previous, play/pause, next.
|
|
//!
|
|
//! Three Win32 facts shape everything below.
|
|
//!
|
|
//! 1. **The buttons can only be added once per window**, and only after the
|
|
//! shell has actually created the taskbar button — signalled by the
|
|
//! registered `TaskbarButtonCreated` message. Adding them before that
|
|
//! silently does nothing, which is why this waits for the message rather
|
|
//! than running at startup like the other init hooks. Afterwards the set is
|
|
//! changed with `ThumbBarUpdateButtons`, never added again.
|
|
//! 2. **Clicks arrive as `WM_COMMAND`** on the window, so hearing them means
|
|
//! subclassing the window Tauri owns. `SetWindowSubclass` is the polite way
|
|
//! — it chains rather than replacing the window procedure, so wry keeps
|
|
//! receiving everything it expects.
|
|
//! 3. **`ITaskbarList3` is apartment-threaded.** It is created and used only
|
|
//! on the UI thread; updates arriving from the player's event thread are
|
|
//! marshalled with `run_on_main_thread`. That is also why it lives in a
|
|
//! thread-local rather than in Tauri's shared state.
|
|
//!
|
|
//! Clicks are emitted as `soulseek:media-button` — the same event the OS media
|
|
//! card sends (see smtc.rs), so the frontend's existing dispatch handles these
|
|
//! with no extra code, and the two surfaces cannot drift apart.
|
|
|
|
#[cfg(windows)]
|
|
pub use imp::{init, set_playing};
|
|
|
|
#[cfg(not(windows))]
|
|
pub fn init(_app: &tauri::AppHandle) {}
|
|
|
|
#[cfg(not(windows))]
|
|
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};
|
|
use tauri::{Emitter, Manager};
|
|
use windows::core::BOOL;
|
|
use windows::Win32::Foundation::{HWND, LPARAM, LRESULT, WPARAM};
|
|
use windows::Win32::Graphics::Gdi::{
|
|
CreateBitmap, CreateDIBSection, DeleteObject, BITMAPINFO, BITMAPINFOHEADER, BI_RGB,
|
|
DIB_RGB_COLORS, HGDIOBJ,
|
|
};
|
|
use windows::Win32::System::Com::{CoCreateInstance, CLSCTX_INPROC_SERVER};
|
|
use windows::Win32::System::Registry::{
|
|
RegGetValueW, HKEY_CURRENT_USER, RRF_RT_REG_DWORD,
|
|
};
|
|
use windows::Win32::UI::Shell::{
|
|
DefSubclassProc, ITaskbarList3, SetWindowSubclass, TaskbarList, THBF_ENABLED, THB_FLAGS,
|
|
THB_ICON, THB_TOOLTIP, THUMBBUTTON, THBN_CLICKED,
|
|
};
|
|
use windows::Win32::UI::WindowsAndMessaging::{
|
|
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;
|
|
const ID_TOGGLE: u32 = 2;
|
|
const ID_NEXT: u32 = 3;
|
|
/// Any value, as long as it is ours and stable: it identifies this
|
|
/// subclass among any others on the same window.
|
|
const SUBCLASS_ID: usize = 0x50_1A_11;
|
|
|
|
/// Whether the transport is currently playing, so the middle button knows
|
|
/// which glyph it should be wearing. An atomic because it is written from
|
|
/// the player's event thread and read on the UI thread.
|
|
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<Option<ITaskbarList3>> = const { RefCell::new(None) };
|
|
/// The button icons outlive every call that shows them — the shell
|
|
/// keeps referencing an HICON after ThumbBarAddButtons returns, so
|
|
/// these are held for the process's life and replaced (with the old
|
|
/// ones destroyed) only when the system theme flips.
|
|
static ICONS: RefCell<Option<Icons>> = const { RefCell::new(None) };
|
|
}
|
|
|
|
struct Icons {
|
|
prev: HICON,
|
|
play: HICON,
|
|
pause: HICON,
|
|
next: HICON,
|
|
/// The theme these were drawn for, so a WM_SETTINGCHANGE that is not
|
|
/// a theme change does not needlessly rebuild them.
|
|
light: bool,
|
|
}
|
|
|
|
impl Icons {
|
|
fn destroy(&self) {
|
|
for icon in [self.prev, self.play, self.pause, self.next] {
|
|
unsafe { let _ = DestroyIcon(icon); }
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn init(app: &tauri::AppHandle) {
|
|
let Some(window) = app.get_webview_window("main") else {
|
|
log::warn!("thumbbar: no main window; taskbar transport buttons will be absent");
|
|
return;
|
|
};
|
|
let Ok(hwnd) = window.hwnd() else {
|
|
log::warn!("thumbbar: no HWND; taskbar transport buttons will be absent");
|
|
return;
|
|
};
|
|
// Same version-independent handle crossing as smtc.rs: tauri and this
|
|
// crate may resolve to different `windows` releases.
|
|
let hwnd = HWND(hwnd.0 as isize as *mut core::ffi::c_void);
|
|
|
|
// Leaked on purpose. The subclass lives as long as the window, which
|
|
// is as long as the process, and a handle behind a raw pointer must
|
|
// not be freed while the window procedure can still reach it.
|
|
let handle: &'static tauri::AppHandle = Box::leak(Box::new(app.clone()));
|
|
let ok = unsafe {
|
|
SetWindowSubclass(
|
|
hwnd,
|
|
Some(subclass_proc),
|
|
SUBCLASS_ID,
|
|
handle as *const _ as usize,
|
|
)
|
|
};
|
|
if !ok.as_bool() {
|
|
log::warn!("thumbbar: could not subclass the window; no taskbar transport buttons");
|
|
return;
|
|
}
|
|
|
|
// The window is already on screen by the time setup runs, so the
|
|
// shell has very likely broadcast TaskbarButtonCreated already and
|
|
// this subclass missed it. Try straight away — we are on the UI thread
|
|
// here, which is the only place ITaskbarList3 may be touched. If the
|
|
// taskbar button genuinely does not exist yet this fails harmlessly
|
|
// and either the message or the first playback retry picks it up.
|
|
add_buttons(hwnd);
|
|
}
|
|
|
|
/// Called from the player's event pump when playback starts or stops.
|
|
/// Cheap and idempotent: only a real change reaches the shell, and the
|
|
/// hop to the UI thread only happens when there is something to say.
|
|
pub fn set_playing(app: &tauri::AppHandle, playing: bool) {
|
|
if PLAYING.swap(playing, Ordering::Relaxed) == playing {
|
|
return;
|
|
}
|
|
let handle = app.clone();
|
|
let _ = app.run_on_main_thread(move || {
|
|
if let Some(window) = handle.get_webview_window("main") {
|
|
if let Ok(hwnd) = window.hwnd() {
|
|
let hwnd = HWND(hwnd.0 as isize as *mut core::ffi::c_void);
|
|
// Doubles as the retry: if the buttons never went up —
|
|
// the shell had not made the taskbar button yet when we
|
|
// started — the first thing the user plays puts them there.
|
|
if ADDED.load(Ordering::Relaxed) {
|
|
update_buttons(hwnd);
|
|
} else {
|
|
add_buttons(hwnd);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
unsafe extern "system" fn subclass_proc(
|
|
hwnd: HWND,
|
|
msg: u32,
|
|
wparam: WPARAM,
|
|
lparam: LPARAM,
|
|
_id: usize,
|
|
refdata: usize,
|
|
) -> LRESULT {
|
|
// Registered once by the shell; the value is stable per session, so
|
|
// resolving it lazily here costs one call.
|
|
let created = unsafe { RegisterWindowMessageW(windows::core::w!("TaskbarButtonCreated")) };
|
|
if msg != 0 && msg == created {
|
|
add_buttons(hwnd);
|
|
} else if msg == WM_COMMAND && (wparam.0 >> 16) as u32 == THBN_CLICKED {
|
|
let app = unsafe { &*(refdata as *const tauri::AppHandle) };
|
|
let button = match (wparam.0 & 0xFFFF) as u32 {
|
|
ID_PREV => Some("previous"),
|
|
// One button for both states, so it says "toggle" and lets the
|
|
// frontend read the status it actually has — the alternative
|
|
// is trusting this side's idea of what is playing, which is a
|
|
// frame behind by construction.
|
|
ID_TOGGLE => Some("toggle"),
|
|
ID_NEXT => Some("next"),
|
|
_ => None,
|
|
};
|
|
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 inside a live RESIZE loop but
|
|
// keeps compositing the plain BLURBEHIND one, so a resize swaps
|
|
// down to Blur for the gesture instead of going see-through.
|
|
// A MOVE loop is different: DWM composites the acrylic straight
|
|
// through one — measured 2026-08-31 on this machine, stationary
|
|
// and at mouse-speed motion over a striped backdrop — while the
|
|
// Blur fallback ignores the gradient tint and skips the acrylic
|
|
// dimming, landing ~15% brighter over a bright backdrop. That
|
|
// brightness step for the whole of every titlebar drag is what
|
|
// "the window gets light when I drag it" was. So only a latched
|
|
// RESIZE parks the accent at Blur; a move keeps the acrylic. If
|
|
// the latch ever misses a real resize, the WM_SIZING arm below
|
|
// swaps the accent one mouse-move later.
|
|
if GLASS_ON.load(Ordering::Relaxed) && SIZING_LATCH.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) {
|
|
// The latch missed, so ENTERSIZEMOVE kept the acrylic; a
|
|
// WM_SIZING is proof this loop is a resize after all, so the
|
|
// accent parks at Blur here instead.
|
|
if GLASS_ON.load(Ordering::Relaxed) {
|
|
let _ = apply_accent(hwnd.0, Accent::Blur);
|
|
}
|
|
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.
|
|
if ADDED.load(Ordering::Relaxed) && system_uses_light_theme() != icons_are_light() {
|
|
update_buttons(hwnd);
|
|
}
|
|
}
|
|
unsafe { DefSubclassProc(hwnd, msg, wparam, lparam) }
|
|
}
|
|
|
|
fn taskbar() -> Option<ITaskbarList3> {
|
|
TASKBAR.with(|slot| {
|
|
let mut slot = slot.borrow_mut();
|
|
if slot.is_none() {
|
|
let created: Option<ITaskbarList3> =
|
|
unsafe { CoCreateInstance(&TaskbarList, None, CLSCTX_INPROC_SERVER).ok() };
|
|
if let Some(list) = &created {
|
|
if unsafe { list.HrInit() }.is_err() {
|
|
return None;
|
|
}
|
|
}
|
|
*slot = created;
|
|
}
|
|
slot.clone()
|
|
})
|
|
}
|
|
|
|
fn buttons() -> Option<[THUMBBUTTON; 3]> {
|
|
let light = system_uses_light_theme();
|
|
let (prev, play, pause, next) = ICONS.with(|slot| {
|
|
let mut slot = slot.borrow_mut();
|
|
if slot.as_ref().map_or(true, |i| i.light != light) {
|
|
if let Some(old) = slot.take() {
|
|
old.destroy();
|
|
}
|
|
*slot = build_icons(light);
|
|
}
|
|
slot.as_ref().map(|i| (i.prev, i.play, i.pause, i.next))
|
|
})?;
|
|
|
|
let playing = PLAYING.load(Ordering::Relaxed);
|
|
Some([
|
|
button(ID_PREV, prev, "Previous"),
|
|
button(
|
|
ID_TOGGLE,
|
|
if playing { pause } else { play },
|
|
if playing { "Pause" } else { "Play" },
|
|
),
|
|
button(ID_NEXT, next, "Next"),
|
|
])
|
|
}
|
|
|
|
fn button(id: u32, icon: HICON, tip: &str) -> THUMBBUTTON {
|
|
let mut b = THUMBBUTTON {
|
|
dwMask: THB_ICON | THB_TOOLTIP | THB_FLAGS,
|
|
iId: id,
|
|
hIcon: icon,
|
|
dwFlags: THBF_ENABLED,
|
|
..Default::default()
|
|
};
|
|
// szTip is a fixed 260-wide buffer, and the struct arrived zeroed, so
|
|
// writing the text without a terminator still leaves one behind it.
|
|
for (slot, ch) in b.szTip.iter_mut().zip(tip.encode_utf16()) {
|
|
*slot = ch;
|
|
}
|
|
b
|
|
}
|
|
|
|
fn add_buttons(hwnd: HWND) {
|
|
let (Some(list), Some(buttons)) = (taskbar(), buttons()) else {
|
|
return;
|
|
};
|
|
match unsafe { list.ThumbBarAddButtons(hwnd, &buttons) } {
|
|
Ok(()) => {
|
|
ADDED.store(true, Ordering::Relaxed);
|
|
log::info!("thumbbar: transport buttons added to the taskbar preview");
|
|
}
|
|
Err(e) => log::warn!("thumbbar: ThumbBarAddButtons failed ({e})"),
|
|
}
|
|
}
|
|
|
|
fn update_buttons(hwnd: HWND) {
|
|
let (Some(list), Some(buttons)) = (taskbar(), buttons()) else {
|
|
return;
|
|
};
|
|
if let Err(e) = unsafe { list.ThumbBarUpdateButtons(hwnd, &buttons) } {
|
|
log::debug!("thumbbar: ThumbBarUpdateButtons failed ({e})");
|
|
}
|
|
}
|
|
|
|
fn icons_are_light() -> bool {
|
|
ICONS.with(|slot| slot.borrow().as_ref().is_some_and(|i| i.light))
|
|
}
|
|
|
|
/// The thumbnail flyout follows the SYSTEM theme, not the app's, so this
|
|
/// reads SystemUsesLightTheme rather than AppsUseLightTheme. Absent (older
|
|
/// builds, or a policy-managed machine) means dark, which is the default
|
|
/// the flyout has always had.
|
|
fn system_uses_light_theme() -> bool {
|
|
let mut value: u32 = 0;
|
|
let mut size = std::mem::size_of::<u32>() as u32;
|
|
let status = unsafe {
|
|
RegGetValueW(
|
|
HKEY_CURRENT_USER,
|
|
windows::core::w!("Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"),
|
|
windows::core::w!("SystemUsesLightTheme"),
|
|
RRF_RT_REG_DWORD,
|
|
None,
|
|
Some(&mut value as *mut u32 as *mut core::ffi::c_void),
|
|
Some(&mut size),
|
|
)
|
|
};
|
|
status.is_ok() && value != 0
|
|
}
|
|
|
|
// ---- the glyphs ----
|
|
//
|
|
// Drawn rather than shipped as assets: they are four flat shapes, and a
|
|
// .ico per glyph per theme per DPI is four files to keep in step with a
|
|
// transport that already draws itself in the app.
|
|
|
|
const ICON_PX: i32 = 16;
|
|
/// Coverage is sampled on a 4x4 grid per pixel, which is what turns a
|
|
/// hard-edged triangle into one that does not look like a staircase at
|
|
/// 16px. Cheap: this runs four times per theme change.
|
|
const SUPERSAMPLE: i32 = 4;
|
|
|
|
fn build_icons(light: bool) -> Option<Icons> {
|
|
Some(Icons {
|
|
prev: draw(light, &|x, y| skip(x, y, true))?,
|
|
play: draw(light, &|x, y| triangle(x, y, 0.30, 0.80))?,
|
|
pause: draw(light, &|x, y| {
|
|
y >= 0.18 && y <= 0.82 && ((0.30..=0.44).contains(&x) || (0.56..=0.70).contains(&x))
|
|
})?,
|
|
next: draw(light, &|x, y| skip(x, y, false))?,
|
|
light,
|
|
})
|
|
}
|
|
|
|
/// A filled triangle pointing right, spanning `left`..`right` horizontally
|
|
/// and tapering to a point at the vertical centre.
|
|
fn triangle(x: f32, y: f32, left: f32, right: f32) -> bool {
|
|
if x < left || x > right {
|
|
return false;
|
|
}
|
|
let t = (x - left) / (right - left);
|
|
let half = 0.32 * (1.0 - t);
|
|
(y - 0.5).abs() <= half
|
|
}
|
|
|
|
/// Previous/next: a triangle with a bar closing it off. `back` mirrors the
|
|
/// whole glyph horizontally, so the two are one shape.
|
|
fn skip(x: f32, y: f32, back: bool) -> bool {
|
|
let x = if back { 1.0 - x } else { x };
|
|
let bar = (0.70..=0.80).contains(&x) && (y - 0.5).abs() <= 0.32;
|
|
bar || triangle(x, y, 0.22, 0.70)
|
|
}
|
|
|
|
fn draw(light: bool, shape: &dyn Fn(f32, f32) -> bool) -> Option<HICON> {
|
|
// Premultiplied BGRA, top-down (the negative height): the glyph is a
|
|
// single colour at varying alpha, so premultiplying is just scaling
|
|
// the colour by the same coverage.
|
|
let (r, g, b) = if light { (0x1a, 0x1a, 0x1c) } else { (0xff, 0xff, 0xff) };
|
|
let mut header: BITMAPINFO = unsafe { std::mem::zeroed() };
|
|
header.bmiHeader = BITMAPINFOHEADER {
|
|
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
|
|
biWidth: ICON_PX,
|
|
biHeight: -ICON_PX,
|
|
biPlanes: 1,
|
|
biBitCount: 32,
|
|
biCompression: BI_RGB.0,
|
|
..Default::default()
|
|
};
|
|
|
|
let mut bits: *mut core::ffi::c_void = std::ptr::null_mut();
|
|
let color = unsafe {
|
|
CreateDIBSection(None, &header, DIB_RGB_COLORS, &mut bits, None, 0).ok()?
|
|
};
|
|
if bits.is_null() {
|
|
unsafe { let _ = DeleteObject(HGDIOBJ(color.0)); }
|
|
return None;
|
|
}
|
|
|
|
let pixels = unsafe {
|
|
std::slice::from_raw_parts_mut(bits as *mut u8, (ICON_PX * ICON_PX * 4) as usize)
|
|
};
|
|
let step = 1.0 / SUPERSAMPLE as f32;
|
|
for py in 0..ICON_PX {
|
|
for px in 0..ICON_PX {
|
|
let mut hits = 0;
|
|
for sy in 0..SUPERSAMPLE {
|
|
for sx in 0..SUPERSAMPLE {
|
|
let fx = (px as f32 + (sx as f32 + 0.5) * step) / ICON_PX as f32;
|
|
let fy = (py as f32 + (sy as f32 + 0.5) * step) / ICON_PX as f32;
|
|
if shape(fx, fy) {
|
|
hits += 1;
|
|
}
|
|
}
|
|
}
|
|
let a = (hits as f32 / (SUPERSAMPLE * SUPERSAMPLE) as f32 * 255.0) as u32;
|
|
let i = ((py * ICON_PX + px) * 4) as usize;
|
|
pixels[i] = (b as u32 * a / 255) as u8;
|
|
pixels[i + 1] = (g as u32 * a / 255) as u8;
|
|
pixels[i + 2] = (r as u32 * a / 255) as u8;
|
|
pixels[i + 3] = a as u8;
|
|
}
|
|
}
|
|
|
|
// A 32bpp icon still needs a mask bitmap; an all-zero one means "the
|
|
// alpha channel decides", which is what the colour bitmap above says.
|
|
let mask = unsafe { CreateBitmap(ICON_PX, ICON_PX, 1, 1, None) };
|
|
let info = ICONINFO {
|
|
fIcon: BOOL(1),
|
|
xHotspot: 0,
|
|
yHotspot: 0,
|
|
hbmMask: mask,
|
|
hbmColor: color,
|
|
};
|
|
let icon = unsafe { CreateIconIndirect(&info) };
|
|
// CreateIconIndirect copies both bitmaps, so they are ours to release
|
|
// whether it succeeded or not.
|
|
unsafe {
|
|
let _ = DeleteObject(HGDIOBJ(color.0));
|
|
let _ = DeleteObject(HGDIOBJ(mask.0));
|
|
}
|
|
icon.ok()
|
|
}
|
|
}
|