|
| 1 | +use windows::Win32::Foundation::*; |
| 2 | +use windows::Win32::Graphics::Gdi::*; |
| 3 | +use windows::Win32::UI::Shell::{ |
| 4 | + NIF_ICON, NIF_MESSAGE, NIF_TIP, NIM_ADD, NIM_DELETE, NIM_MODIFY, NOTIFYICONDATAW, |
| 5 | + Shell_NotifyIconW, |
| 6 | +}; |
| 7 | +use windows::Win32::UI::WindowsAndMessaging::*; |
| 8 | +use windows::core::PCWSTR; |
| 9 | + |
| 10 | +use crate::native_interop::{self, Color, WM_APP_TRAY}; |
| 11 | + |
| 12 | +const TRAY_ICON_ID: u32 = 1; |
| 13 | + |
| 14 | +/// Menu item ID for toggling widget visibility (used by window.rs context menu). |
| 15 | +pub const IDM_TOGGLE_WIDGET: u16 = 50; |
| 16 | + |
| 17 | +/// Actions the tray message handler can request from the main window. |
| 18 | +pub enum TrayAction { |
| 19 | + None, |
| 20 | + ToggleWidget, |
| 21 | + ShowContextMenu, |
| 22 | +} |
| 23 | + |
| 24 | +/// Create a rounded-rectangle tray icon badge showing the usage percentage. |
| 25 | +/// `percent` = None means "no data" (gray "?"), Some(p) is the usage level. |
| 26 | +pub fn create_icon(percent: Option<f64>) -> HICON { |
| 27 | + let size = 64_i32; |
| 28 | + let margin = 4_i32; |
| 29 | + let radius = 14_i32; |
| 30 | + let outline = 2_i32; |
| 31 | + |
| 32 | + let (fill, outline_col, text_col) = match percent { |
| 33 | + None => ( |
| 34 | + Color::from_hex("#6c757d"), |
| 35 | + Color::from_hex("#495057"), |
| 36 | + Color::from_hex("#FFFFFF"), |
| 37 | + ), |
| 38 | + Some(p) if p < 50.0 => ( |
| 39 | + Color::from_hex("#28a745"), |
| 40 | + Color::from_hex("#1e7e34"), |
| 41 | + Color::from_hex("#FFFFFF"), |
| 42 | + ), |
| 43 | + Some(p) if p < 75.0 => ( |
| 44 | + Color::from_hex("#ffc107"), |
| 45 | + Color::from_hex("#e0a800"), |
| 46 | + Color::from_hex("#1a1a1a"), |
| 47 | + ), |
| 48 | + Some(p) if p < 90.0 => ( |
| 49 | + Color::from_hex("#fd7e14"), |
| 50 | + Color::from_hex("#d9650a"), |
| 51 | + Color::from_hex("#FFFFFF"), |
| 52 | + ), |
| 53 | + _ => ( |
| 54 | + Color::from_hex("#dc3545"), |
| 55 | + Color::from_hex("#bd2130"), |
| 56 | + Color::from_hex("#FFFFFF"), |
| 57 | + ), |
| 58 | + }; |
| 59 | + |
| 60 | + let display_text = match percent { |
| 61 | + None => "?".to_string(), |
| 62 | + Some(p) => format!("{}", p as u32), |
| 63 | + }; |
| 64 | + |
| 65 | + let font_h = match display_text.len() { |
| 66 | + 1 => -50, |
| 67 | + 2 => -42, |
| 68 | + _ => -30, |
| 69 | + }; |
| 70 | + |
| 71 | + unsafe { |
| 72 | + let screen_dc = GetDC(HWND::default()); |
| 73 | + let mem_dc = CreateCompatibleDC(screen_dc); |
| 74 | + |
| 75 | + let bmi = BITMAPINFO { |
| 76 | + bmiHeader: BITMAPINFOHEADER { |
| 77 | + biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32, |
| 78 | + biWidth: size, |
| 79 | + biHeight: -size, |
| 80 | + biPlanes: 1, |
| 81 | + biBitCount: 32, |
| 82 | + biCompression: 0, |
| 83 | + ..Default::default() |
| 84 | + }, |
| 85 | + ..Default::default() |
| 86 | + }; |
| 87 | + |
| 88 | + let mut bits: *mut std::ffi::c_void = std::ptr::null_mut(); |
| 89 | + let dib = CreateDIBSection(mem_dc, &bmi, DIB_RGB_COLORS, &mut bits, None, 0) |
| 90 | + .unwrap_or_default(); |
| 91 | + |
| 92 | + if dib.is_invalid() { |
| 93 | + let _ = DeleteDC(mem_dc); |
| 94 | + ReleaseDC(HWND::default(), screen_dc); |
| 95 | + return HICON::default(); |
| 96 | + } |
| 97 | + |
| 98 | + let old_bmp = SelectObject(mem_dc, dib); |
| 99 | + |
| 100 | + // Zero-fill (transparent background) |
| 101 | + let pixel_data = |
| 102 | + std::slice::from_raw_parts_mut(bits as *mut u32, (size * size) as usize); |
| 103 | + for px in pixel_data.iter_mut() { |
| 104 | + *px = 0; |
| 105 | + } |
| 106 | + |
| 107 | + // Draw rounded rectangle badge |
| 108 | + let null_pen = GetStockObject(NULL_PEN); |
| 109 | + let old_pen = SelectObject(mem_dc, null_pen); |
| 110 | + |
| 111 | + // Outer rounded rect = outline colour |
| 112 | + let br_outline = CreateSolidBrush(COLORREF(outline_col.to_colorref())); |
| 113 | + let old_brush = SelectObject(mem_dc, br_outline); |
| 114 | + let _ = RoundRect( |
| 115 | + mem_dc, |
| 116 | + margin, |
| 117 | + margin, |
| 118 | + size - margin + 1, |
| 119 | + size - margin + 1, |
| 120 | + radius * 2, |
| 121 | + radius * 2, |
| 122 | + ); |
| 123 | + |
| 124 | + // Inner rounded rect = fill colour |
| 125 | + let br_fill = CreateSolidBrush(COLORREF(fill.to_colorref())); |
| 126 | + SelectObject(mem_dc, br_fill); |
| 127 | + let _ = RoundRect( |
| 128 | + mem_dc, |
| 129 | + margin + outline, |
| 130 | + margin + outline, |
| 131 | + size - margin - outline + 1, |
| 132 | + size - margin - outline + 1, |
| 133 | + (radius - 1) * 2, |
| 134 | + (radius - 1) * 2, |
| 135 | + ); |
| 136 | + |
| 137 | + SelectObject(mem_dc, old_brush); |
| 138 | + SelectObject(mem_dc, old_pen); |
| 139 | + let _ = DeleteObject(br_outline); |
| 140 | + let _ = DeleteObject(br_fill); |
| 141 | + |
| 142 | + // Draw centered percentage text |
| 143 | + let font_name = native_interop::wide_str("Arial Bold"); |
| 144 | + let font = CreateFontW( |
| 145 | + font_h, |
| 146 | + 0, |
| 147 | + 0, |
| 148 | + 0, |
| 149 | + FW_BOLD.0 as i32, |
| 150 | + 0, |
| 151 | + 0, |
| 152 | + 0, |
| 153 | + DEFAULT_CHARSET.0 as u32, |
| 154 | + OUT_TT_PRECIS.0 as u32, |
| 155 | + CLIP_DEFAULT_PRECIS.0 as u32, |
| 156 | + ANTIALIASED_QUALITY.0 as u32, |
| 157 | + (DEFAULT_PITCH.0 | FF_DONTCARE.0) as u32, |
| 158 | + PCWSTR::from_raw(font_name.as_ptr()), |
| 159 | + ); |
| 160 | + let old_font = SelectObject(mem_dc, font); |
| 161 | + let _ = SetBkMode(mem_dc, TRANSPARENT); |
| 162 | + let _ = SetTextColor(mem_dc, COLORREF(text_col.to_colorref())); |
| 163 | + |
| 164 | + let mut text_rect = RECT { |
| 165 | + left: margin, |
| 166 | + top: margin, |
| 167 | + right: size - margin, |
| 168 | + bottom: size - margin, |
| 169 | + }; |
| 170 | + let mut text_wide: Vec<u16> = display_text.encode_utf16().collect(); |
| 171 | + let _ = DrawTextW( |
| 172 | + mem_dc, |
| 173 | + &mut text_wide, |
| 174 | + &mut text_rect, |
| 175 | + DT_CENTER | DT_VCENTER | DT_SINGLELINE, |
| 176 | + ); |
| 177 | + |
| 178 | + SelectObject(mem_dc, old_font); |
| 179 | + let _ = DeleteObject(font); |
| 180 | + |
| 181 | + // Set alpha: non-zero BGR pixel -> fully opaque; background stays transparent |
| 182 | + for px in pixel_data.iter_mut() { |
| 183 | + if *px != 0 { |
| 184 | + *px = (*px & 0x00FF_FFFF) | 0xFF00_0000; |
| 185 | + } |
| 186 | + } |
| 187 | + |
| 188 | + // Monochrome mask (per-pixel alpha from colour bitmap) |
| 189 | + let mask_bytes = vec![0u8; ((size * size + 7) / 8) as usize]; |
| 190 | + let mask_bmp = CreateBitmap( |
| 191 | + size, |
| 192 | + size, |
| 193 | + 1, |
| 194 | + 1, |
| 195 | + Some(mask_bytes.as_ptr() as *const std::ffi::c_void), |
| 196 | + ); |
| 197 | + |
| 198 | + let icon_info = ICONINFO { |
| 199 | + fIcon: TRUE, |
| 200 | + xHotspot: 0, |
| 201 | + yHotspot: 0, |
| 202 | + hbmMask: mask_bmp, |
| 203 | + hbmColor: dib, |
| 204 | + }; |
| 205 | + let hicon = CreateIconIndirect(&icon_info).unwrap_or_default(); |
| 206 | + |
| 207 | + let _ = DeleteObject(mask_bmp); |
| 208 | + SelectObject(mem_dc, old_bmp); |
| 209 | + let _ = DeleteObject(dib); |
| 210 | + let _ = DeleteDC(mem_dc); |
| 211 | + ReleaseDC(HWND::default(), screen_dc); |
| 212 | + |
| 213 | + hicon |
| 214 | + } |
| 215 | +} |
| 216 | + |
| 217 | +/// Register the tray icon with the shell. |
| 218 | +pub fn add(hwnd: HWND, percent: Option<f64>, tooltip: &str) { |
| 219 | + let hicon = create_icon(percent); |
| 220 | + unsafe { |
| 221 | + let mut nid: NOTIFYICONDATAW = std::mem::zeroed(); |
| 222 | + nid.cbSize = std::mem::size_of::<NOTIFYICONDATAW>() as u32; |
| 223 | + nid.hWnd = hwnd; |
| 224 | + nid.uID = TRAY_ICON_ID; |
| 225 | + nid.uFlags = NIF_ICON | NIF_MESSAGE | NIF_TIP; |
| 226 | + nid.uCallbackMessage = WM_APP_TRAY; |
| 227 | + nid.hIcon = hicon; |
| 228 | + copy_to_tip(tooltip, &mut nid.szTip); |
| 229 | + let _ = Shell_NotifyIconW(NIM_ADD, &nid); |
| 230 | + if !hicon.is_invalid() { |
| 231 | + let _ = DestroyIcon(hicon); |
| 232 | + } |
| 233 | + } |
| 234 | +} |
| 235 | + |
| 236 | +/// Update the tray icon colour and tooltip to reflect current usage. |
| 237 | +pub fn update(hwnd: HWND, percent: Option<f64>, tooltip: &str) { |
| 238 | + let hicon = create_icon(percent); |
| 239 | + unsafe { |
| 240 | + let mut nid: NOTIFYICONDATAW = std::mem::zeroed(); |
| 241 | + nid.cbSize = std::mem::size_of::<NOTIFYICONDATAW>() as u32; |
| 242 | + nid.hWnd = hwnd; |
| 243 | + nid.uID = TRAY_ICON_ID; |
| 244 | + nid.uFlags = NIF_ICON | NIF_TIP; |
| 245 | + nid.hIcon = hicon; |
| 246 | + copy_to_tip(tooltip, &mut nid.szTip); |
| 247 | + let _ = Shell_NotifyIconW(NIM_MODIFY, &nid); |
| 248 | + if !hicon.is_invalid() { |
| 249 | + let _ = DestroyIcon(hicon); |
| 250 | + } |
| 251 | + } |
| 252 | +} |
| 253 | + |
| 254 | +/// Remove the tray icon from the shell. |
| 255 | +pub fn remove(hwnd: HWND) { |
| 256 | + unsafe { |
| 257 | + let mut nid: NOTIFYICONDATAW = std::mem::zeroed(); |
| 258 | + nid.cbSize = std::mem::size_of::<NOTIFYICONDATAW>() as u32; |
| 259 | + nid.hWnd = hwnd; |
| 260 | + nid.uID = TRAY_ICON_ID; |
| 261 | + let _ = Shell_NotifyIconW(NIM_DELETE, &nid); |
| 262 | + } |
| 263 | +} |
| 264 | + |
| 265 | +/// Interpret a tray callback message and return the action to take. |
| 266 | +pub fn handle_message(lparam: LPARAM) -> TrayAction { |
| 267 | + let mouse_msg = lparam.0 as u32; |
| 268 | + match mouse_msg { |
| 269 | + WM_LBUTTONUP => TrayAction::ToggleWidget, |
| 270 | + WM_RBUTTONUP => TrayAction::ShowContextMenu, |
| 271 | + _ => TrayAction::None, |
| 272 | + } |
| 273 | +} |
| 274 | + |
| 275 | +/// Copy a string into the fixed-size szTip field (max 127 chars + null). |
| 276 | +fn copy_to_tip(s: &str, tip: &mut [u16; 128]) { |
| 277 | + let wide: Vec<u16> = s.encode_utf16().collect(); |
| 278 | + let mut len = wide.len().min(127); |
| 279 | + // Don't leave a lone high surrogate at the truncation point |
| 280 | + if len > 0 && (0xD800..=0xDBFF).contains(&wide[len - 1]) { |
| 281 | + len -= 1; |
| 282 | + } |
| 283 | + tip[..len].copy_from_slice(&wide[..len]); |
| 284 | + tip[len] = 0; |
| 285 | +} |
0 commit comments