taskbar: transport buttons under the window preview
Hovering the taskbar button pops a preview, and an app may put buttons under it. Every media player uses them for the transport, and now so do we: previous, play/pause, next, with the middle one swapping its glyph as playback starts and stops. Clicks are emitted as `soulseek:media-button` — the same event the OS media card sends — so the frontend's existing dispatch handles them with no new code and the two surfaces cannot drift apart. Verified by posting the exact WM_COMMAND/THBN_CLICKED the shell posts: next advanced the queue, toggle paused, toggle resumed. Three Win32 facts shaped the implementation. The buttons can only be added ONCE per window and only after the shell has created the taskbar button, which it announces with a registered message — but Tauri shows the window before the setup hook runs, so that announcement is usually already gone by the time we could subclass, and the first build put no buttons up at all. They are added directly at init now, with the first playback as a retry for the case where the taskbar button genuinely does not exist yet. Clicks arrive as WM_COMMAND, so hearing them means subclassing Tauri's window — SetWindowSubclass chains rather than replacing the window procedure, so wry still receives everything it expects. And ITaskbarList3 is apartment-threaded, so it lives in a thread-local touched only on the UI thread, with updates from the player's event thread marshalled through run_on_main_thread. The glyphs are drawn at runtime rather than shipped: they are four flat shapes, and a .ico per glyph per theme would be files to keep in step with a transport that already draws itself in the app. Coverage is sampled on a 4x4 grid per pixel, which is what stops a 16px triangle looking like a staircase. They follow SystemUsesLightTheme — the thumbnail flyout rides the system theme, not the app's — and are rebuilt on WM_SETTINGCHANGE when that flips, so they do not end up dark-on-dark.
This commit is contained in:
1 parent
f6eb9ff02f
commit
f0c2bb5ed3
4 files changed
+436
No files matched your search
@@ -45,6 +45,13 @@ windows = { version = "0.61.3", features = [
|
||||
"Media_Ocr",
|
||||
"Storage_Streams",
|
||||
"Win32_Foundation",
|
||||
# Taskbar thumbnail toolbar — ITaskbarList3 + the window subclass that
|
||||
# hears its clicks, and the GDI/registry bits that draw its icons.
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_System_Com",
|
||||
"Win32_System_Registry",
|
||||
"Win32_UI_Shell",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
"Win32_System_Performance",
|
||||
"Win32_System_ProcessStatus",
|
||||
"Win32_System_Threading",
|
||||
|
||||
@@ -10,6 +10,7 @@ mod playback;
|
||||
mod preview;
|
||||
mod smtc;
|
||||
mod state;
|
||||
mod thumbbar;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
@@ -35,6 +36,9 @@ pub fn run() {
|
||||
// transport state at the card immediately, and the card has to be
|
||||
// registered by then or the first track's status is dropped.
|
||||
smtc::init(app.handle());
|
||||
// Only subclasses the window here; the buttons themselves go up
|
||||
// when the shell says the taskbar button exists.
|
||||
thumbbar::init(app.handle());
|
||||
playback::init(app.handle());
|
||||
playback::prune_cache(app.handle());
|
||||
preview::init(app.handle());
|
||||
|
||||
@@ -274,6 +274,9 @@ pub fn init(app: &tauri::AppHandle) {
|
||||
if let Some((status, position, duration)) = card {
|
||||
crate::smtc::set_status(&handle, &status);
|
||||
crate::smtc::set_timeline(&handle, position, duration);
|
||||
// The taskbar preview's middle button wears play or pause
|
||||
// off the same truth.
|
||||
crate::thumbbar::set_playing(&handle, status == "playing");
|
||||
}
|
||||
let _ = handle.emit("soulseek:player", ui);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
//! 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::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, RegisterWindowMessageW, HICON, ICONINFO, WM_COMMAND,
|
||||
WM_SETTINGCHANGE,
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
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_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()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user