1#![cfg_attr(not(all(windows, feature = "taskbar")), allow(dead_code))]
32
33#[derive(Clone, Copy, Debug, PartialEq)]
35pub enum TaskbarProgress {
36 Indeterminate,
38 Normal(f32),
40 Paused(f32),
42 Error(f32),
44}
45
46impl TaskbarProgress {
47 pub(crate) fn fraction(self) -> Option<f32> {
49 match self {
50 Self::Indeterminate => None,
51 Self::Normal(f) | Self::Paused(f) | Self::Error(f) => {
52 Some(if f.is_nan() { 0.0 } else { f.clamp(0.0, 1.0) })
53 }
54 }
55 }
56}
57
58#[derive(Clone, Debug, Default, PartialEq)]
60pub struct Taskbar {
61 pub(crate) count: u32,
62 pub(crate) progress: Option<TaskbarProgress>,
63 pub(crate) desktop_entry: Option<String>,
64}
65
66impl Taskbar {
67 pub fn new() -> Self {
69 Self::default()
70 }
71
72 pub fn count(mut self, count: u32) -> Self {
75 self.count = count;
76 self
77 }
78
79 pub fn progress(mut self, fraction: f32) -> Self {
81 self.progress = Some(TaskbarProgress::Normal(fraction));
82 self
83 }
84
85 pub fn indeterminate(mut self) -> Self {
87 self.progress = Some(TaskbarProgress::Indeterminate);
88 self
89 }
90
91 pub fn paused(mut self, fraction: f32) -> Self {
93 self.progress = Some(TaskbarProgress::Paused(fraction));
94 self
95 }
96
97 pub fn error(mut self, fraction: f32) -> Self {
99 self.progress = Some(TaskbarProgress::Error(fraction));
100 self
101 }
102
103 pub fn desktop_entry(mut self, name: impl Into<String>) -> Self {
106 self.desktop_entry = Some(name.into());
107 self
108 }
109}
110
111pub(crate) fn count_text(count: u32) -> Option<String> {
113 match count {
114 0 => None,
115 1..=99 => Some(count.to_string()),
116 _ => Some("99+".to_string()),
117 }
118}
119
120fn glyph(c: char) -> Option<[u8; 5]> {
123 Some(match c {
124 '0' => [0b111, 0b101, 0b101, 0b101, 0b111],
125 '1' => [0b010, 0b110, 0b010, 0b010, 0b111],
126 '2' => [0b111, 0b001, 0b111, 0b100, 0b111],
127 '3' => [0b111, 0b001, 0b111, 0b001, 0b111],
128 '4' => [0b101, 0b101, 0b111, 0b001, 0b001],
129 '5' => [0b111, 0b100, 0b111, 0b001, 0b111],
130 '6' => [0b111, 0b100, 0b111, 0b101, 0b111],
131 '7' => [0b111, 0b001, 0b010, 0b010, 0b010],
132 '8' => [0b111, 0b101, 0b111, 0b101, 0b111],
133 '9' => [0b111, 0b101, 0b111, 0b001, 0b111],
134 '+' => [0b000, 0b010, 0b111, 0b010, 0b000],
135 _ => return None,
136 })
137}
138
139pub(crate) const BADGE_SIDE: u32 = 32;
142
143pub(crate) fn badge_rgba(text: &str) -> Vec<u8> {
147 let side = BADGE_SIDE as i32;
148 let mut rgba = vec![0u8; (side * side * 4) as usize];
149 let centre = (side as f32 - 1.0) / 2.0;
150 let radius = side as f32 / 2.0;
151 for y in 0..side {
152 for x in 0..side {
153 let dx = x as f32 - centre;
154 let dy = y as f32 - centre;
155 let coverage = (radius - (dx * dx + dy * dy).sqrt()).clamp(0.0, 1.0);
157 let at = ((y * side + x) * 4) as usize;
158 rgba[at..at + 4].copy_from_slice(&[0xB3, 0x26, 0x1E, (coverage * 255.0) as u8]);
159 }
160 }
161 let glyphs: Vec<[u8; 5]> = text.chars().filter_map(glyph).collect();
162 if glyphs.is_empty() {
163 return rgba;
164 }
165 let columns = glyphs.len() as i32 * 4 - 1;
167 let scale = ((side - 8) / columns).clamp(1, 4);
168 let (width, height) = (columns * scale, 5 * scale);
169 let (left, top) = ((side - width) / 2, (side - height) / 2);
170 for (i, rows) in glyphs.iter().enumerate() {
171 for (row, bits) in rows.iter().enumerate() {
172 for column in 0..3 {
173 if bits & (0b100 >> column) == 0 {
174 continue;
175 }
176 for py in 0..scale {
177 for px in 0..scale {
178 let x = left + (i as i32 * 4 + column) * scale + px;
179 let y = top + row as i32 * scale + py;
180 let at = ((y * side + x) * 4) as usize;
181 rgba[at..at + 4].copy_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]);
182 }
183 }
184 }
185 }
186 }
187 rgba
188}
189
190#[cfg(all(desktop, feature = "taskbar"))]
192pub(crate) mod native {
193 use super::Taskbar;
194
195 pub(crate) fn apply(
199 window: &winit::window::Window,
200 shown: &Taskbar,
201 wanted: &Taskbar,
202 ) -> Taskbar {
203 if shown == wanted {
204 return wanted.clone();
205 }
206 match imp::apply(window, shown, wanted) {
207 Ok(()) => wanted.clone(),
208 Err(err) => {
209 log::debug!("taskbar: not applied yet ({err})");
210 shown.clone()
211 }
212 }
213 }
214
215 #[cfg(windows)]
216 mod imp {
217 use std::cell::RefCell;
218
219 use windows::core::HSTRING;
220 use windows::Win32::Foundation::HWND;
221 use windows::Win32::Graphics::Gdi::{
222 CreateBitmap, CreateDIBSection, DeleteObject, BITMAPINFO, BITMAPINFOHEADER, BI_RGB,
223 DIB_RGB_COLORS,
224 };
225 use windows::Win32::System::Com::{CoCreateInstance, CLSCTX_INPROC_SERVER};
226 use windows::Win32::UI::Shell::{
227 ITaskbarList3, TaskbarList, TBPF_ERROR, TBPF_INDETERMINATE, TBPF_NOPROGRESS,
228 TBPF_NORMAL, TBPF_PAUSED,
229 };
230 use windows::Win32::UI::WindowsAndMessaging::{
231 CreateIconIndirect, DestroyIcon, HICON, ICONINFO,
232 };
233 use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle};
234
235 use super::super::{badge_rgba, count_text, TaskbarProgress, BADGE_SIDE};
236 use super::Taskbar;
237
238 thread_local! {
239 static LIST: RefCell<Option<ITaskbarList3>> = const { RefCell::new(None) };
241 }
242
243 #[inline(never)]
246 fn list() -> windows::core::Result<ITaskbarList3> {
247 LIST.with(|slot| {
248 if let Some(list) = slot.borrow().as_ref() {
249 return Ok(list.clone());
250 }
251 let list: ITaskbarList3 =
254 unsafe { CoCreateInstance(&TaskbarList, None, CLSCTX_INPROC_SERVER)? };
255 unsafe { list.HrInit()? };
256 *slot.borrow_mut() = Some(list.clone());
257 Ok(list)
258 })
259 }
260
261 fn hwnd(window: &winit::window::Window) -> Option<HWND> {
262 match window.window_handle().ok()?.as_raw() {
263 RawWindowHandle::Win32(handle) => Some(HWND(handle.hwnd.get() as *mut _)),
264 _ => None,
265 }
266 }
267
268 pub(super) fn apply(
269 window: &winit::window::Window,
270 shown: &Taskbar,
271 wanted: &Taskbar,
272 ) -> windows::core::Result<()> {
273 let Some(hwnd) = hwnd(window) else {
274 return Ok(());
275 };
276 let list = list()?;
277 if shown.progress != wanted.progress {
278 let state = match wanted.progress {
279 None => TBPF_NOPROGRESS,
280 Some(TaskbarProgress::Indeterminate) => TBPF_INDETERMINATE,
281 Some(TaskbarProgress::Normal(_)) => TBPF_NORMAL,
282 Some(TaskbarProgress::Paused(_)) => TBPF_PAUSED,
283 Some(TaskbarProgress::Error(_)) => TBPF_ERROR,
284 };
285 unsafe { list.SetProgressState(hwnd, state)? };
287 if let Some(fraction) = wanted.progress.and_then(TaskbarProgress::fraction) {
288 unsafe { list.SetProgressValue(hwnd, (fraction * 1000.0) as u64, 1000)? };
289 }
290 }
291 if count_text(shown.count) != count_text(wanted.count) {
292 match count_text(wanted.count) {
293 None => unsafe { list.SetOverlayIcon(hwnd, HICON::default(), None)? },
294 Some(text) => {
295 let icon = icon(&badge_rgba(&text))?;
296 let said = HSTRING::from(text.as_str());
297 let set = unsafe { list.SetOverlayIcon(hwnd, icon, &said) };
298 unsafe {
300 let _ = DestroyIcon(icon);
301 }
302 set?;
303 }
304 }
305 }
306 Ok(())
307 }
308
309 fn icon(rgba: &[u8]) -> windows::core::Result<HICON> {
311 let side = BADGE_SIDE as i32;
312 let info = BITMAPINFO {
313 bmiHeader: BITMAPINFOHEADER {
314 biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
315 biWidth: side,
316 biHeight: -side,
318 biPlanes: 1,
319 biBitCount: 32,
320 biCompression: BI_RGB.0,
321 ..Default::default()
322 },
323 ..Default::default()
324 };
325 let mut bits = std::ptr::null_mut();
326 unsafe {
329 let colour = CreateDIBSection(None, &info, DIB_RGB_COLORS, &mut bits, None, 0)?;
330 let pixels = std::slice::from_raw_parts_mut(bits as *mut u8, rgba.len());
331 for (to, from) in pixels
332 .as_chunks_mut::<4>()
333 .0
334 .iter_mut()
335 .zip(rgba.as_chunks::<4>().0)
336 {
337 to.copy_from_slice(&[from[2], from[1], from[0], from[3]]);
338 }
339 let mask = CreateBitmap(side, side, 1, 1, None);
340 let made = CreateIconIndirect(&ICONINFO {
341 fIcon: true.into(),
342 xHotspot: 0,
343 yHotspot: 0,
344 hbmMask: mask,
345 hbmColor: colour,
346 });
347 let _ = DeleteObject(colour.into());
348 let _ = DeleteObject(mask.into());
349 made
350 }
351 }
352 }
353
354 #[cfg(target_os = "macos")]
355 mod imp {
356 use objc2::MainThreadMarker;
357 use objc2_app_kit::NSApplication;
358 use objc2_foundation::NSString;
359
360 use super::super::count_text;
361 use super::Taskbar;
362
363 pub(super) fn apply(
364 _window: &winit::window::Window,
365 _shown: &Taskbar,
366 wanted: &Taskbar,
367 ) -> Result<(), &'static str> {
368 let main = MainThreadMarker::new().ok_or("not on the main thread")?;
369 let tile = NSApplication::sharedApplication(main).dockTile();
370 let label = count_text(wanted.count).map(|text| NSString::from_str(&text));
371 tile.setBadgeLabel(label.as_deref());
372 Ok(())
373 }
374 }
375
376 #[cfg(all(unix, not(target_os = "macos")))]
377 mod imp {
378 use std::collections::HashMap;
379 use std::sync::OnceLock;
380
381 use zbus::blocking::Connection;
382 use zbus::zvariant::Value;
383
384 use super::super::TaskbarProgress;
385 use super::Taskbar;
386
387 fn session() -> Option<&'static Connection> {
388 static SESSION: OnceLock<Option<Connection>> = OnceLock::new();
389 SESSION
390 .get_or_init(|| {
391 Connection::session()
392 .map_err(|err| log::warn!("taskbar: no D-Bus session ({err})"))
393 .ok()
394 })
395 .as_ref()
396 }
397
398 fn entry(wanted: &Taskbar) -> String {
400 wanted.desktop_entry.clone().unwrap_or_else(|| {
401 std::env::current_exe()
402 .ok()
403 .and_then(|exe| Some(exe.file_stem()?.to_string_lossy().into_owned()))
404 .unwrap_or_default()
405 })
406 }
407
408 pub(super) fn apply(
409 _window: &winit::window::Window,
410 _shown: &Taskbar,
411 wanted: &Taskbar,
412 ) -> zbus::Result<()> {
413 let Some(session) = session() else {
414 return Ok(());
415 };
416 let mut properties: HashMap<&str, Value> = HashMap::new();
417 properties.insert("count", Value::from(i64::from(wanted.count)));
418 properties.insert("count-visible", Value::from(wanted.count > 0));
419 let fraction = wanted.progress.and_then(TaskbarProgress::fraction);
420 properties.insert("progress", Value::from(f64::from(fraction.unwrap_or(0.0))));
421 properties.insert("progress-visible", Value::from(fraction.is_some()));
422 let uri = format!("application://{}.desktop", entry(wanted));
423 session.emit_signal(
424 None::<&str>,
425 "/com/frus/LauncherEntry",
426 "com.canonical.Unity.LauncherEntry",
427 "Update",
428 &(uri, properties),
429 )
430 }
431 }
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437
438 #[test]
440 fn the_description_is_kept() {
441 let bar = Taskbar::new().count(3).progress(0.4);
442 assert_eq!(bar.count, 3);
443 assert_eq!(bar.progress, Some(TaskbarProgress::Normal(0.4)));
444 assert_eq!(
445 Taskbar::new().indeterminate().progress,
446 Some(TaskbarProgress::Indeterminate)
447 );
448 assert_eq!(
449 Taskbar::new().progress(0.2).paused(0.5).progress,
450 Some(TaskbarProgress::Paused(0.5))
451 );
452 assert_eq!(
453 Taskbar::new().error(1.0).progress,
454 Some(TaskbarProgress::Error(1.0))
455 );
456 assert_eq!(Taskbar::new(), Taskbar::default());
457 }
458
459 #[test]
461 fn a_fraction_is_kept_within_bounds() {
462 assert_eq!(TaskbarProgress::Normal(1.5).fraction(), Some(1.0));
463 assert_eq!(TaskbarProgress::Paused(-0.5).fraction(), Some(0.0));
464 assert_eq!(TaskbarProgress::Error(f32::NAN).fraction(), Some(0.0));
465 assert_eq!(TaskbarProgress::Normal(0.25).fraction(), Some(0.25));
466 assert_eq!(TaskbarProgress::Indeterminate.fraction(), None);
467 }
468
469 #[test]
471 fn a_count_reads_up_to_99_plus() {
472 assert_eq!(count_text(0), None);
473 assert_eq!(count_text(7).as_deref(), Some("7"));
474 assert_eq!(count_text(99).as_deref(), Some("99"));
475 assert_eq!(count_text(100).as_deref(), Some("99+"));
476 }
477
478 fn pixel(rgba: &[u8], x: u32, y: u32) -> [u8; 4] {
479 let at = ((y * BADGE_SIDE + x) * 4) as usize;
480 [rgba[at], rgba[at + 1], rgba[at + 2], rgba[at + 3]]
481 }
482
483 #[test]
487 fn the_overlay_is_a_red_disc_with_the_text() {
488 let seven = badge_rgba("7");
489 assert_eq!(seven.len(), (BADGE_SIDE * BADGE_SIDE * 4) as usize);
490 assert_eq!(pixel(&seven, 0, 0)[3], 0, "a corner is outside the disc");
491 assert_eq!(
492 pixel(&seven, 3, 16),
493 [0xB3, 0x26, 0x1E, 0xFF],
494 "the red inside"
495 );
496 let white = |rgba: &[u8]| {
497 rgba.as_chunks::<4>()
498 .0
499 .iter()
500 .filter(|p| **p == [0xFF, 0xFF, 0xFF, 0xFF])
501 .count()
502 };
503 assert!(white(&seven) > 0, "the digit is drawn");
504 assert!(white(&badge_rgba("99+")) > white(&seven));
505 assert_eq!(white(&badge_rgba("")), 0);
506 for (i, p) in badge_rgba("99+").as_chunks::<4>().0.iter().enumerate() {
507 if *p == [0xFF, 0xFF, 0xFF, 0xFF] {
508 let (x, y) = (i as u32 % BADGE_SIDE, i as u32 / BADGE_SIDE);
509 assert!(x > 2 && x < BADGE_SIDE - 3 && y > 2 && y < BADGE_SIDE - 3);
510 }
511 }
512 }
513}