Skip to main content

frus_shell/
taskbar.rs

1//! **The taskbar's badge and progress** (milestone 708, part of #219): a count on the
2//! application's taskbar button or Dock icon, and the progress of something long — a download,
3//! an export — on that same button, where the person sees it from any other window.
4//!
5//! Declared from the state, like the title and the tray: [`Application::taskbar`](crate::Application::taskbar),
6//! or `FrusApp::taskbar` for an application of components. The shell changes the system's
7//! button only when the description changes.
8//!
9//! ```
10//! # use frus_shell::Taskbar;
11//! let downloading = Taskbar::new().count(3).progress(0.4);
12//! let waiting = Taskbar::new().indeterminate();
13//! let nothing = Taskbar::new();
14//! # let _ = (downloading, waiting, nothing);
15//! ```
16//!
17//! - **Windows**: the progress is the taskbar button's own bar — green, yellow when paused, red
18//!   for an error, moving when indeterminate. The count is an **overlay** on the button's icon:
19//!   a red disc with the number, drawn here, as the system draws none.
20//! - **macOS**: the count is the Dock icon's badge. The Dock has no progress bar of its own; it
21//!   is not shown there.
22//! - **Linux**: both go to the desktop's launcher — the dock or panel that shows the
23//!   application — through the `com.canonical.Unity.LauncherEntry` signal that KDE, Ubuntu's
24//!   dock and others read. The launcher finds the application by its desktop entry, named
25//!   after the program (`<program>.desktop`) unless [`Taskbar::desktop_entry`] says otherwise.
26//!
27//! Behind the `taskbar` feature, on the desktop.
28
29// Without the system's taskbar the description is read by nobody, and the overlay's drawing
30// is Windows' alone; all of it is tested on the host either way.
31#![cfg_attr(not(all(windows, feature = "taskbar")), allow(dead_code))]
32
33/// The progress shown on the taskbar button.
34#[derive(Clone, Copy, Debug, PartialEq)]
35pub enum TaskbarProgress {
36    /// Something is under way, and how far is not known.
37    Indeterminate,
38    /// This far along, from 0 to 1.
39    Normal(f32),
40    /// Stopped at this point, to go on later.
41    Paused(f32),
42    /// Stopped at this point by an error.
43    Error(f32),
44}
45
46impl TaskbarProgress {
47    /// The fraction it shows, kept within 0 and 1; `None` for an indeterminate progress.
48    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/// What the application's taskbar button shows: a count, a progress, or neither.
59#[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    /// A taskbar button showing nothing of the application's.
68    pub fn new() -> Self {
69        Self::default()
70    }
71
72    /// A count on the button: unread messages, waiting tasks. `0` shows none, so a count
73    /// straight out of a model needs no `if`.
74    pub fn count(mut self, count: u32) -> Self {
75        self.count = count;
76        self
77    }
78
79    /// A progress `fraction` of the way, from 0 to 1.
80    pub fn progress(mut self, fraction: f32) -> Self {
81        self.progress = Some(TaskbarProgress::Normal(fraction));
82        self
83    }
84
85    /// Something under way, how far not known.
86    pub fn indeterminate(mut self) -> Self {
87        self.progress = Some(TaskbarProgress::Indeterminate);
88        self
89    }
90
91    /// A progress stopped at `fraction`, to go on later.
92    pub fn paused(mut self, fraction: f32) -> Self {
93        self.progress = Some(TaskbarProgress::Paused(fraction));
94        self
95    }
96
97    /// A progress stopped at `fraction` by an error.
98    pub fn error(mut self, fraction: f32) -> Self {
99        self.progress = Some(TaskbarProgress::Error(fraction));
100        self
101    }
102
103    /// Linux: the desktop entry the launcher knows the application by, without `.desktop`
104    /// (`org.example.Tasks`); the program's name otherwise.
105    pub fn desktop_entry(mut self, name: impl Into<String>) -> Self {
106        self.desktop_entry = Some(name.into());
107        self
108    }
109}
110
111/// What a count reads as on a badge too small for more: the number up to 99, then `99+`.
112pub(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
120/// The digits and the plus sign, three pixels wide and five tall, a row to a `u8` (the three
121/// low bits, the leftmost the highest).
122fn 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
139/// The side of the overlay badge drawn for Windows, in pixels: the size the taskbar asks an
140/// overlay to be at twice the base density, which it scales down cleanly.
141pub(crate) const BADGE_SIDE: u32 = 32;
142
143/// The overlay badge for `text`: a red disc, the text in white over it, as `BADGE_SIDE`²
144/// straight (not premultiplied) RGBA. The red is the reference scheme's error, the colour a
145/// badge has everywhere else in frus.
146pub(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            // The disc's edge softened over a pixel, so it is not jagged when scaled down.
156            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    // The biggest whole scale at which the text, a pixel apart, fits across the disc's middle.
166    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/// The taskbar as the system has it (desktop, `taskbar` feature).
191#[cfg(all(desktop, feature = "taskbar"))]
192pub(crate) mod native {
193    use super::Taskbar;
194
195    /// Makes the system show `wanted` on `window`'s button, where it differs from `shown`;
196    /// what is shown afterwards. A system that would not take it — the button not made yet —
197    /// leaves `shown` as it was, to be tried again at the next change.
198    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            /// The shell's taskbar object, made once on the interface's thread.
240            static LIST: RefCell<Option<ITaskbarList3>> = const { RefCell::new(None) };
241        }
242
243        // Not inlined, nor generic: compiled once, in frus, so that a hot patch reaches the same
244        // thread-local as the rest of the program (milestone 669).
245        #[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                // SAFETY: COM is initialised on this thread by the event loop (winit takes the
252                // single-threaded apartment for drag and drop); the object is used here only.
253                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                // SAFETY: a valid window of this process, on its own thread.
286                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                        // The taskbar keeps its own copy.
299                        unsafe {
300                            let _ = DestroyIcon(icon);
301                        }
302                        set?;
303                    }
304                }
305            }
306            Ok(())
307        }
308
309        /// A `BADGE_SIDE`² icon from straight RGBA.
310        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                    // Negative: the rows top to bottom, as the RGBA is.
317                    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            // SAFETY: the DIB's memory is `side`² BGRA pixels, written once below; the bitmaps
327            // are deleted once the icon has been made from them.
328            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        /// The desktop entry the launcher knows the application by.
399        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    /// **The description keeps what it is told**, the last progress winning.
439    #[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    /// **A fraction is kept within 0 and 1**, and an indeterminate progress has none.
460    #[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    /// **A count reads as the number, up to `99+`**, and `0` as nothing.
470    #[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    /// **The overlay is a red disc with white text in its middle**: transparent at the corners,
484    /// the error red inside, and white pixels where the digits are — more of them for `99+`
485    /// than for `7`, and within the disc.
486    #[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}