#![cfg_attr(not(all(windows, feature = "taskbar")), allow(dead_code))]
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum TaskbarProgress {
Indeterminate,
Normal(f32),
Paused(f32),
Error(f32),
}
impl TaskbarProgress {
pub(crate) fn fraction(self) -> Option<f32> {
match self {
Self::Indeterminate => None,
Self::Normal(f) | Self::Paused(f) | Self::Error(f) => {
Some(if f.is_nan() { 0.0 } else { f.clamp(0.0, 1.0) })
}
}
}
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Taskbar {
pub(crate) count: u32,
pub(crate) progress: Option<TaskbarProgress>,
pub(crate) desktop_entry: Option<String>,
}
impl Taskbar {
pub fn new() -> Self {
Self::default()
}
pub fn count(mut self, count: u32) -> Self {
self.count = count;
self
}
pub fn progress(mut self, fraction: f32) -> Self {
self.progress = Some(TaskbarProgress::Normal(fraction));
self
}
pub fn indeterminate(mut self) -> Self {
self.progress = Some(TaskbarProgress::Indeterminate);
self
}
pub fn paused(mut self, fraction: f32) -> Self {
self.progress = Some(TaskbarProgress::Paused(fraction));
self
}
pub fn error(mut self, fraction: f32) -> Self {
self.progress = Some(TaskbarProgress::Error(fraction));
self
}
pub fn desktop_entry(mut self, name: impl Into<String>) -> Self {
self.desktop_entry = Some(name.into());
self
}
}
pub(crate) fn count_text(count: u32) -> Option<String> {
match count {
0 => None,
1..=99 => Some(count.to_string()),
_ => Some("99+".to_string()),
}
}
fn glyph(c: char) -> Option<[u8; 5]> {
Some(match c {
'0' => [0b111, 0b101, 0b101, 0b101, 0b111],
'1' => [0b010, 0b110, 0b010, 0b010, 0b111],
'2' => [0b111, 0b001, 0b111, 0b100, 0b111],
'3' => [0b111, 0b001, 0b111, 0b001, 0b111],
'4' => [0b101, 0b101, 0b111, 0b001, 0b001],
'5' => [0b111, 0b100, 0b111, 0b001, 0b111],
'6' => [0b111, 0b100, 0b111, 0b101, 0b111],
'7' => [0b111, 0b001, 0b010, 0b010, 0b010],
'8' => [0b111, 0b101, 0b111, 0b101, 0b111],
'9' => [0b111, 0b101, 0b111, 0b001, 0b111],
'+' => [0b000, 0b010, 0b111, 0b010, 0b000],
_ => return None,
})
}
pub(crate) const BADGE_SIDE: u32 = 32;
pub(crate) fn badge_rgba(text: &str) -> Vec<u8> {
let side = BADGE_SIDE as i32;
let mut rgba = vec![0u8; (side * side * 4) as usize];
let centre = (side as f32 - 1.0) / 2.0;
let radius = side as f32 / 2.0;
for y in 0..side {
for x in 0..side {
let dx = x as f32 - centre;
let dy = y as f32 - centre;
let coverage = (radius - (dx * dx + dy * dy).sqrt()).clamp(0.0, 1.0);
let at = ((y * side + x) * 4) as usize;
rgba[at..at + 4].copy_from_slice(&[0xB3, 0x26, 0x1E, (coverage * 255.0) as u8]);
}
}
let glyphs: Vec<[u8; 5]> = text.chars().filter_map(glyph).collect();
if glyphs.is_empty() {
return rgba;
}
let columns = glyphs.len() as i32 * 4 - 1;
let scale = ((side - 8) / columns).clamp(1, 4);
let (width, height) = (columns * scale, 5 * scale);
let (left, top) = ((side - width) / 2, (side - height) / 2);
for (i, rows) in glyphs.iter().enumerate() {
for (row, bits) in rows.iter().enumerate() {
for column in 0..3 {
if bits & (0b100 >> column) == 0 {
continue;
}
for py in 0..scale {
for px in 0..scale {
let x = left + (i as i32 * 4 + column) * scale + px;
let y = top + row as i32 * scale + py;
let at = ((y * side + x) * 4) as usize;
rgba[at..at + 4].copy_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]);
}
}
}
}
}
rgba
}
#[cfg(all(desktop, feature = "taskbar"))]
pub(crate) mod native {
use super::Taskbar;
pub(crate) fn apply(
window: &winit::window::Window,
shown: &Taskbar,
wanted: &Taskbar,
) -> Taskbar {
if shown == wanted {
return wanted.clone();
}
match imp::apply(window, shown, wanted) {
Ok(()) => wanted.clone(),
Err(err) => {
log::debug!("taskbar: not applied yet ({err})");
shown.clone()
}
}
}
#[cfg(windows)]
mod imp {
use std::cell::RefCell;
use windows::core::HSTRING;
use windows::Win32::Foundation::HWND;
use windows::Win32::Graphics::Gdi::{
CreateBitmap, CreateDIBSection, DeleteObject, BITMAPINFO, BITMAPINFOHEADER, BI_RGB,
DIB_RGB_COLORS,
};
use windows::Win32::System::Com::{CoCreateInstance, CLSCTX_INPROC_SERVER};
use windows::Win32::UI::Shell::{
ITaskbarList3, TaskbarList, TBPF_ERROR, TBPF_INDETERMINATE, TBPF_NOPROGRESS,
TBPF_NORMAL, TBPF_PAUSED,
};
use windows::Win32::UI::WindowsAndMessaging::{
CreateIconIndirect, DestroyIcon, HICON, ICONINFO,
};
use winit::raw_window_handle::{HasWindowHandle, RawWindowHandle};
use super::super::{badge_rgba, count_text, TaskbarProgress, BADGE_SIDE};
use super::Taskbar;
thread_local! {
static LIST: RefCell<Option<ITaskbarList3>> = const { RefCell::new(None) };
}
#[inline(never)]
fn list() -> windows::core::Result<ITaskbarList3> {
LIST.with(|slot| {
if let Some(list) = slot.borrow().as_ref() {
return Ok(list.clone());
}
let list: ITaskbarList3 =
unsafe { CoCreateInstance(&TaskbarList, None, CLSCTX_INPROC_SERVER)? };
unsafe { list.HrInit()? };
*slot.borrow_mut() = Some(list.clone());
Ok(list)
})
}
fn hwnd(window: &winit::window::Window) -> Option<HWND> {
match window.window_handle().ok()?.as_raw() {
RawWindowHandle::Win32(handle) => Some(HWND(handle.hwnd.get() as *mut _)),
_ => None,
}
}
pub(super) fn apply(
window: &winit::window::Window,
shown: &Taskbar,
wanted: &Taskbar,
) -> windows::core::Result<()> {
let Some(hwnd) = hwnd(window) else {
return Ok(());
};
let list = list()?;
if shown.progress != wanted.progress {
let state = match wanted.progress {
None => TBPF_NOPROGRESS,
Some(TaskbarProgress::Indeterminate) => TBPF_INDETERMINATE,
Some(TaskbarProgress::Normal(_)) => TBPF_NORMAL,
Some(TaskbarProgress::Paused(_)) => TBPF_PAUSED,
Some(TaskbarProgress::Error(_)) => TBPF_ERROR,
};
unsafe { list.SetProgressState(hwnd, state)? };
if let Some(fraction) = wanted.progress.and_then(TaskbarProgress::fraction) {
unsafe { list.SetProgressValue(hwnd, (fraction * 1000.0) as u64, 1000)? };
}
}
if count_text(shown.count) != count_text(wanted.count) {
match count_text(wanted.count) {
None => unsafe { list.SetOverlayIcon(hwnd, HICON::default(), None)? },
Some(text) => {
let icon = icon(&badge_rgba(&text))?;
let said = HSTRING::from(text.as_str());
let set = unsafe { list.SetOverlayIcon(hwnd, icon, &said) };
unsafe {
let _ = DestroyIcon(icon);
}
set?;
}
}
}
Ok(())
}
fn icon(rgba: &[u8]) -> windows::core::Result<HICON> {
let side = BADGE_SIDE as i32;
let info = BITMAPINFO {
bmiHeader: BITMAPINFOHEADER {
biSize: std::mem::size_of::<BITMAPINFOHEADER>() as u32,
biWidth: side,
biHeight: -side,
biPlanes: 1,
biBitCount: 32,
biCompression: BI_RGB.0,
..Default::default()
},
..Default::default()
};
let mut bits = std::ptr::null_mut();
unsafe {
let colour = CreateDIBSection(None, &info, DIB_RGB_COLORS, &mut bits, None, 0)?;
let pixels = std::slice::from_raw_parts_mut(bits as *mut u8, rgba.len());
for (to, from) in pixels
.as_chunks_mut::<4>()
.0
.iter_mut()
.zip(rgba.as_chunks::<4>().0)
{
to.copy_from_slice(&[from[2], from[1], from[0], from[3]]);
}
let mask = CreateBitmap(side, side, 1, 1, None);
let made = CreateIconIndirect(&ICONINFO {
fIcon: true.into(),
xHotspot: 0,
yHotspot: 0,
hbmMask: mask,
hbmColor: colour,
});
let _ = DeleteObject(colour.into());
let _ = DeleteObject(mask.into());
made
}
}
}
#[cfg(target_os = "macos")]
mod imp {
use objc2::MainThreadMarker;
use objc2_app_kit::NSApplication;
use objc2_foundation::NSString;
use super::super::count_text;
use super::Taskbar;
pub(super) fn apply(
_window: &winit::window::Window,
_shown: &Taskbar,
wanted: &Taskbar,
) -> Result<(), &'static str> {
let main = MainThreadMarker::new().ok_or("not on the main thread")?;
let tile = NSApplication::sharedApplication(main).dockTile();
let label = count_text(wanted.count).map(|text| NSString::from_str(&text));
tile.setBadgeLabel(label.as_deref());
Ok(())
}
}
#[cfg(all(unix, not(target_os = "macos")))]
mod imp {
use std::collections::HashMap;
use std::sync::OnceLock;
use zbus::blocking::Connection;
use zbus::zvariant::Value;
use super::super::TaskbarProgress;
use super::Taskbar;
fn session() -> Option<&'static Connection> {
static SESSION: OnceLock<Option<Connection>> = OnceLock::new();
SESSION
.get_or_init(|| {
Connection::session()
.map_err(|err| log::warn!("taskbar: no D-Bus session ({err})"))
.ok()
})
.as_ref()
}
fn entry(wanted: &Taskbar) -> String {
wanted.desktop_entry.clone().unwrap_or_else(|| {
std::env::current_exe()
.ok()
.and_then(|exe| Some(exe.file_stem()?.to_string_lossy().into_owned()))
.unwrap_or_default()
})
}
pub(super) fn apply(
_window: &winit::window::Window,
_shown: &Taskbar,
wanted: &Taskbar,
) -> zbus::Result<()> {
let Some(session) = session() else {
return Ok(());
};
let mut properties: HashMap<&str, Value> = HashMap::new();
properties.insert("count", Value::from(i64::from(wanted.count)));
properties.insert("count-visible", Value::from(wanted.count > 0));
let fraction = wanted.progress.and_then(TaskbarProgress::fraction);
properties.insert("progress", Value::from(f64::from(fraction.unwrap_or(0.0))));
properties.insert("progress-visible", Value::from(fraction.is_some()));
let uri = format!("application://{}.desktop", entry(wanted));
session.emit_signal(
None::<&str>,
"/com/frus/LauncherEntry",
"com.canonical.Unity.LauncherEntry",
"Update",
&(uri, properties),
)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_description_is_kept() {
let bar = Taskbar::new().count(3).progress(0.4);
assert_eq!(bar.count, 3);
assert_eq!(bar.progress, Some(TaskbarProgress::Normal(0.4)));
assert_eq!(
Taskbar::new().indeterminate().progress,
Some(TaskbarProgress::Indeterminate)
);
assert_eq!(
Taskbar::new().progress(0.2).paused(0.5).progress,
Some(TaskbarProgress::Paused(0.5))
);
assert_eq!(
Taskbar::new().error(1.0).progress,
Some(TaskbarProgress::Error(1.0))
);
assert_eq!(Taskbar::new(), Taskbar::default());
}
#[test]
fn a_fraction_is_kept_within_bounds() {
assert_eq!(TaskbarProgress::Normal(1.5).fraction(), Some(1.0));
assert_eq!(TaskbarProgress::Paused(-0.5).fraction(), Some(0.0));
assert_eq!(TaskbarProgress::Error(f32::NAN).fraction(), Some(0.0));
assert_eq!(TaskbarProgress::Normal(0.25).fraction(), Some(0.25));
assert_eq!(TaskbarProgress::Indeterminate.fraction(), None);
}
#[test]
fn a_count_reads_up_to_99_plus() {
assert_eq!(count_text(0), None);
assert_eq!(count_text(7).as_deref(), Some("7"));
assert_eq!(count_text(99).as_deref(), Some("99"));
assert_eq!(count_text(100).as_deref(), Some("99+"));
}
fn pixel(rgba: &[u8], x: u32, y: u32) -> [u8; 4] {
let at = ((y * BADGE_SIDE + x) * 4) as usize;
[rgba[at], rgba[at + 1], rgba[at + 2], rgba[at + 3]]
}
#[test]
fn the_overlay_is_a_red_disc_with_the_text() {
let seven = badge_rgba("7");
assert_eq!(seven.len(), (BADGE_SIDE * BADGE_SIDE * 4) as usize);
assert_eq!(pixel(&seven, 0, 0)[3], 0, "a corner is outside the disc");
assert_eq!(
pixel(&seven, 3, 16),
[0xB3, 0x26, 0x1E, 0xFF],
"the red inside"
);
let white = |rgba: &[u8]| {
rgba.as_chunks::<4>()
.0
.iter()
.filter(|p| **p == [0xFF, 0xFF, 0xFF, 0xFF])
.count()
};
assert!(white(&seven) > 0, "the digit is drawn");
assert!(white(&badge_rgba("99+")) > white(&seven));
assert_eq!(white(&badge_rgba("")), 0);
for (i, p) in badge_rgba("99+").as_chunks::<4>().0.iter().enumerate() {
if *p == [0xFF, 0xFF, 0xFF, 0xFF] {
let (x, y) = (i as u32 % BADGE_SIDE, i as u32 / BADGE_SIDE);
assert!(x > 2 && x < BADGE_SIDE - 3 && y > 2 && y < BADGE_SIDE - 3);
}
}
}
}