diff --git a/Cargo.toml b/Cargo.toml index 204ea5d..f94d8c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -120,6 +120,7 @@ tokio = { version = "1.45.0", features = [ "sync", "io-util", "net", + "fs" ] } tracing = "0.1.41" tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } diff --git a/src/channels.rs b/src/channels.rs index 69317d5..0d48625 100644 --- a/src/channels.rs +++ b/src/channels.rs @@ -125,6 +125,12 @@ where fn recv_glib(self, f: F) where F: FnMut(T) + 'static; + + /// Like [`BroadcastReceiverExt::recv_glib`], but the closure must return a [`Future`]. + fn recv_glib_async(self, f: Fn) + where + Fn: FnMut(T) -> F + 'static, + F: Future; } impl BroadcastReceiverExt for broadcast::Receiver @@ -152,4 +158,29 @@ where } }); } + + fn recv_glib_async(mut self, mut f: Fn) + where + Fn: FnMut(T) -> F + 'static, + F: Future, + { + glib::spawn_future_local(async move { + loop { + match self.recv().await { + Ok(val) => { + f(val).await; + } + Err(broadcast::error::RecvError::Lagged(count)) => { + tracing::warn!( + "Channel lagged behind by {count}, this may result in unexpected or broken behaviour" + ); + } + Err(err) => { + tracing::error!("{err:?}"); + break; + } + } + } + }); + } } diff --git a/src/config/mod.rs b/src/config/mod.rs index 39d2713..088fdb3 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -295,12 +295,6 @@ pub struct BarConfig { #[serde(default)] pub autohide: Option, - /// The name of the GTK icon theme to use. - /// Leave unset to use the default Adwaita theme. - /// - /// **Default**: `null` - pub icon_theme: Option, - /// An array of modules to append to the start of the bar. /// Depending on the orientation, this is either the top of the left edge. /// @@ -348,7 +342,6 @@ impl Default for BarConfig { height: default_bar_height(), start_hidden: None, autohide: None, - icon_theme: None, #[cfg(feature = "label")] start: Some(vec![ModuleConfig::Label( LabelModule::new("ℹ️ Using default config".to_string()).into(), @@ -403,6 +396,12 @@ pub struct Config { /// Providing this option overrides the single, global `bar` option. pub monitors: Option>, + /// The name of the GTK icon theme to use. + /// Leave unset to use the default Adwaita theme. + /// + /// **Default**: `null` + pub icon_theme: Option, + /// Map of app IDs (or classes) to icon names, /// overriding the app's default icon. /// diff --git a/src/desktop_file.rs b/src/desktop_file.rs index 558743e..2d788df 100644 --- a/src/desktop_file.rs +++ b/src/desktop_file.rs @@ -1,36 +1,267 @@ -use std::collections::{HashMap, HashSet}; +use crate::spawn; +use color_eyre::Result; +use std::collections::HashMap; use std::env; -use std::fs; use std::path::{Path, PathBuf}; -use std::sync::{Mutex, OnceLock}; -use tracing::warn; +use std::sync::Arc; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio::sync::Mutex; +use tracing::debug; use walkdir::{DirEntry, WalkDir}; -use crate::lock; - -type DesktopFile = HashMap>; - -fn desktop_files() -> &'static Mutex> { - static DESKTOP_FILES: OnceLock>> = OnceLock::new(); - DESKTOP_FILES.get_or_init(|| Mutex::new(HashMap::new())) +#[derive(Debug, Clone)] +enum DesktopFileRef { + Unloaded(PathBuf), + Loaded(DesktopFile), } -fn desktop_files_look_out_keys() -> &'static HashSet<&'static str> { - static DESKTOP_FILES_LOOK_OUT_KEYS: OnceLock> = OnceLock::new(); - DESKTOP_FILES_LOOK_OUT_KEYS - .get_or_init(|| HashSet::from(["Name", "StartupWMClass", "Exec", "Icon"])) +impl DesktopFileRef { + async fn get(&mut self) -> Result { + match self { + DesktopFileRef::Unloaded(path) => { + let (tx, rx) = tokio::sync::oneshot::channel(); + let path = path.clone(); + + spawn(async move { tx.send(Self::load(&path).await) }); + + let file = rx.await??; + *self = DesktopFileRef::Loaded(file.clone()); + + Ok(file) + } + DesktopFileRef::Loaded(file) => Ok(file.clone()), + } + } + + async fn load(file_path: &Path) -> Result { + debug!("loading applications file: {}", file_path.display()); + + let file = tokio::fs::File::open(file_path).await?; + + let mut desktop_file = DesktopFile::new( + file_path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .to_string(), + ); + + let mut lines = BufReader::new(file).lines(); + + let mut has_name = false; + let mut has_type = false; + let mut has_wm_class = false; + let mut has_exec = false; + let mut has_icon = false; + + while let Ok(Some(line)) = lines.next_line().await { + let Some((key, value)) = line.split_once('=') else { + continue; + }; + + match key { + "Name" => { + desktop_file.name = Some(value.to_string()); + has_name = true; + } + "Type" => { + desktop_file.app_type = Some(value.to_string()); + has_type = true; + } + "StartupWMClass" => { + desktop_file.startup_wm_class = Some(value.to_string()); + has_wm_class = true; + } + "Exec" => { + desktop_file.exec = Some(value.to_string()); + has_exec = true; + } + "Icon" => { + desktop_file.icon = Some(value.to_string()); + has_icon = true; + } + _ => {} + } + + // parsing complete - don't bother with the rest of the lines + if has_name && has_type && has_wm_class && has_exec && has_icon { + break; + } + } + + Ok(desktop_file) + } } -/// Finds directories that should contain `.desktop` files -/// and exist on the filesystem. -fn find_application_dirs() -> Vec { +#[derive(Debug, Clone)] +pub struct DesktopFile { + pub file_name: String, + pub name: Option, + pub app_type: Option, + pub startup_wm_class: Option, + pub exec: Option, + pub icon: Option, +} + +impl DesktopFile { + fn new(file_name: String) -> Self { + Self { + file_name, + name: None, + app_type: None, + startup_wm_class: None, + exec: None, + icon: None, + } + } +} + +type FileMap = HashMap, DesktopFileRef>; + +/// Desktop file cache and resolver. +/// +/// Files are lazy-loaded as required on resolution. +#[derive(Debug, Clone)] +pub struct DesktopFiles { + files: Arc>, +} + +impl Default for DesktopFiles { + fn default() -> Self { + Self::new() + } +} + +impl DesktopFiles { + /// Creates a new instance, + /// scanning disk to generate a list of (unloaded) file refs in the process. + pub fn new() -> Self { + let desktop_files: FileMap = dirs() + .iter() + .flat_map(|path| files(path)) + .map(|file| { + ( + file.file_stem() + .unwrap_or_default() + .to_str() + .unwrap_or_default() + .to_string() + .into(), + DesktopFileRef::Unloaded(file), + ) + }) + .collect(); + + debug!("resolved {} files", desktop_files.len()); + + Self { + files: Arc::new(Mutex::new(desktop_files)), + } + } + + /// Attempts to locate a applications file by file name or contents. + /// + /// Input should typically be the app id, app name or icon. + pub async fn find(&self, input: &str) -> Result> { + let mut res = self.find_by_file_name(input).await?; + if res.is_none() { + res = self.find_by_file_contents(input).await?; + } + + debug!("found match for app_id {input}: {}", res.is_some()); + + Ok(res) + } + + /// Checks file names for an exact or partial match of the provided input. + async fn find_by_file_name(&self, input: &str) -> Result> { + let mut files = self.files.lock().await; + + let mut file_ref = files + .iter_mut() + .find(|&(name, _)| name.eq_ignore_ascii_case(input)); + + if file_ref.is_none() { + file_ref = files.iter_mut().find( + |&(name, _)| // this will attempt to find flatpak apps that are in the format + // `com.company.app` or `com.app.something` + input + .split(&[' ', ':', '@', '.', '_'][..]) + .any(|part| name.eq_ignore_ascii_case(part)), + ); + } + + let file_ref = file_ref.map(|(_, file)| file); + + if let Some(file_ref) = file_ref { + let file = file_ref.get().await?; + Ok(Some(file)) + } else { + Ok(None) + } + } + + /// Checks file contents for an exact or partial match of the provided input. + async fn find_by_file_contents(&self, app_id: &str) -> Result> { + let mut files = self.files.lock().await; + + // first pass - check name for exact match + for (_, file_ref) in files.iter_mut() { + let file = file_ref.get().await?; + if let Some(name) = &file.name { + if name.eq_ignore_ascii_case(app_id) { + return Ok(Some(file)); + } + } + } + + // second pass - check name for partial match + for (_, file_ref) in files.iter_mut() { + let file = file_ref.get().await?; + if let Some(name) = &file.name { + if name.to_lowercase().contains(app_id) { + return Ok(Some(file)); + } + } + } + + // third pass - check remaining fields for partial match + for (_, file_ref) in files.iter_mut() { + let file = file_ref.get().await?; + + if let Some(name) = &file.exec { + if name.to_lowercase().contains(app_id) { + return Ok(Some(file)); + } + } + + if let Some(name) = &file.startup_wm_class { + if name.to_lowercase().contains(app_id) { + return Ok(Some(file)); + } + } + + if let Some(name) = &file.icon { + if name.to_lowercase().contains(app_id) { + return Ok(Some(file)); + } + } + } + + Ok(None) + } +} + +/// Gets a list of paths to all directories +/// containing `.applications` files. +fn dirs() -> Vec { let mut dirs = vec![ PathBuf::from("/usr/share/applications"), // system installed apps PathBuf::from("/var/lib/flatpak/exports/share/applications"), // flatpak apps ]; - let xdg_dirs = env::var_os("XDG_DATA_DIRS"); - if let Some(xdg_dirs) = xdg_dirs { + let xdg_dirs = env::var("XDG_DATA_DIRS"); + if let Ok(xdg_dirs) = xdg_dirs { for mut xdg_dir in env::split_paths(&xdg_dirs) { xdg_dir.push("applications"); dirs.push(xdg_dir); @@ -43,157 +274,66 @@ fn find_application_dirs() -> Vec { dirs.push(user_dir); } - dirs.into_iter().filter(|dir| dir.exists()).collect() + dirs.into_iter().filter(|dir| dir.exists()).rev().collect() } -/// Finds all the desktop files -fn find_desktop_files() -> Vec { - let dirs = find_application_dirs(); - dirs.into_iter() - .flat_map(|dir| { - WalkDir::new(dir) - .max_depth(5) - .into_iter() - .filter_map(Result::ok) - .map(DirEntry::into_path) - .filter(|file| file.is_file() && file.extension().unwrap_or_default() == "desktop") - }) +/// Gets a list of all `.applications` files in the provided directory. +/// +/// The directory is recursed to a maximum depth of 5. +fn files(dir: &Path) -> Vec { + WalkDir::new(dir) + .max_depth(5) + .into_iter() + .filter_map(Result::ok) + .map(DirEntry::into_path) + .filter(|file| file.is_file() && file.extension().unwrap_or_default() == "desktop") .collect() } -/// Attempts to locate a `.desktop` file for an app id -pub fn find_desktop_file(app_id: &str) -> Option { - // this is necessary to invalidate the cache - let files = find_desktop_files(); +#[cfg(test)] +mod tests { + use super::*; - find_desktop_file_by_filename(app_id, &files) - .or_else(|| find_desktop_file_by_filedata(app_id, &files)) -} - -/// Finds the correct desktop file using a simple condition check -fn find_desktop_file_by_filename(app_id: &str, files: &[PathBuf]) -> Option { - let with_names = files - .iter() - .map(|f| { - ( - f, - f.file_stem() - .unwrap_or_default() - .to_string_lossy() - .to_lowercase(), - ) - }) - .collect::>(); - - with_names - .iter() - // first pass - check for exact match - .find(|(_, name)| name.eq_ignore_ascii_case(app_id)) - // second pass - check for substring - .or_else(|| { - with_names.iter().find(|(_, name)| { - // this will attempt to find flatpak apps that are in the format - // `com.company.app` or `com.app.something` - app_id - .split(&[' ', ':', '@', '.', '_'][..]) - .any(|part| name.eq_ignore_ascii_case(part)) - }) - }) - .map(|(file, _)| file.into()) -} - -/// Finds the correct desktop file using the keys in `DESKTOP_FILES_LOOK_OUT_KEYS` -fn find_desktop_file_by_filedata(app_id: &str, files: &[PathBuf]) -> Option { - let app_id = &app_id.to_lowercase(); - let mut desktop_files_cache = lock!(desktop_files()); - - let files = files - .iter() - .filter_map(|file| { - let parsed_desktop_file = parse_desktop_file(file)?; - - desktop_files_cache.insert(file.clone(), parsed_desktop_file.clone()); - Some((file.clone(), parsed_desktop_file)) - }) - .collect::>(); - - let file = files - .iter() - // first pass - check name key for exact match - .find(|(_, desktop_file)| { - desktop_file - .get("Name") - .is_some_and(|names| names.iter().any(|name| name.eq_ignore_ascii_case(app_id))) - }) - // second pass - check name key for substring - .or_else(|| { - files.iter().find(|(_, desktop_file)| { - desktop_file.get("Name").is_some_and(|names| { - names - .iter() - .any(|name| name.to_lowercase().contains(app_id)) - }) - }) - }) - // third pass - check all keys for substring - .or_else(|| { - files.iter().find(|(_, desktop_file)| { - desktop_file - .values() - .flatten() - .any(|value| value.to_lowercase().contains(app_id)) - }) - }); - - file.map(|(path, _)| path).cloned() -} - -/// Parses a desktop file into a hashmap of keys/vector(values). -fn parse_desktop_file(path: &Path) -> Option { - let Ok(file) = fs::read_to_string(path) else { - warn!("Couldn't Open File: {}", path.display()); - return None; - }; - - let mut desktop_file: DesktopFile = DesktopFile::new(); - - file.lines() - .filter_map(|line| { - let (key, value) = line.split_once('=')?; - - let key = key.trim(); - let value = value.trim(); - - if desktop_files_look_out_keys().contains(key) { - Some((key, value)) - } else { - None - } - }) - .for_each(|(key, value)| { - desktop_file - .entry(key.to_string()) - .or_default() - .push(value.to_string()); - }); - - Some(desktop_file) -} - -/// Attempts to get the icon name from the app's `.desktop` file. -pub fn get_desktop_icon_name(app_id: &str) -> Option { - let path = find_desktop_file(app_id)?; - - let mut desktop_files_cache = lock!(desktop_files()); - - let desktop_file = match desktop_files_cache.get(&path) { - Some(desktop_file) => desktop_file, - _ => desktop_files_cache - .entry(path.clone()) - .or_insert_with(|| parse_desktop_file(&path).expect("desktop_file")), - }; - - let mut icons = desktop_file.get("Icon").into_iter().flatten(); - - icons.next().map(std::string::ToString::to_string) + fn setup() { + unsafe { + let pwd = env::current_dir().unwrap(); + env::set_var("XDG_DATA_DIRS", format!("{}/test-configs", pwd.display())); + } + } + + #[tokio::test] + async fn find_by_filename() { + setup(); + + let desktop_files = DesktopFiles::new(); + let file = desktop_files.find_by_file_name("firefox").await.unwrap(); + + assert!(file.is_some()); + assert_eq!(file.unwrap().file_name, "firefox.desktop"); + } + + #[tokio::test] + async fn find_by_file_contents() { + setup(); + + let desktop_files = DesktopFiles::new(); + + let file = desktop_files.find_by_file_contents("427520").await.unwrap(); + + assert!(file.is_some()); + assert_eq!(file.unwrap().file_name, "Factorio.desktop"); + } + + #[tokio::test] + async fn parser() { + let mut file_ref = + DesktopFileRef::Unloaded(PathBuf::from("test-configs/applications/firefox.desktop")); + let file = file_ref.get().await.unwrap(); + + assert_eq!(file.name, Some("Firefox".to_string())); + assert_eq!(file.icon, Some("firefox".to_string())); + assert_eq!(file.exec, Some("/usr/lib/firefox/firefox %u".to_string())); + assert_eq!(file.startup_wm_class, Some("firefox".to_string())); + assert_eq!(file.app_type, Some("Application".to_string())); + } } diff --git a/src/image/gtk.rs b/src/image/gtk.rs index 04ed103..2f601eb 100644 --- a/src/image/gtk.rs +++ b/src/image/gtk.rs @@ -1,7 +1,7 @@ -use super::ImageProvider; use crate::gtk_helpers::{IronbarGtkExt, IronbarLabelExt}; +use crate::image; use gtk::prelude::*; -use gtk::{Button, IconTheme, Image, Label, Orientation}; +use gtk::{Button, Image, Label, Orientation}; use std::ops::Deref; #[derive(Debug, Clone)] @@ -29,27 +29,33 @@ pub struct IconButton { feature = "workspaces", ))] impl IconButton { - pub fn new(input: &str, icon_theme: &IconTheme, size: i32) -> Self { + pub fn new(input: &str, size: i32, image_provider: image::Provider) -> Self { let button = Button::new(); let image = Image::new(); let label = Label::new(Some(input)); - if ImageProvider::is_definitely_image_input(input) { + if image::Provider::is_explicit_input(input) { image.add_class("image"); image.add_class("icon"); - match ImageProvider::parse(input, icon_theme, false, size) - .map(|provider| provider.load_into_image(&image)) - { - Some(_) => { + let image = image.clone(); + let label = label.clone(); + let button = button.clone(); + + let input = input.to_string(); // ew + + glib::spawn_future_local(async move { + if let Ok(true) = image_provider + .load_into_image(&input, size, false, &image) + .await + { button.set_image(Some(&image)); button.set_always_show_image(true); - } - None => { + } else { button.set_child(Some(&label)); label.show(); } - } + }); } else { button.set_child(Some(&label)); label.show(); @@ -82,17 +88,17 @@ impl Deref for IconButton { #[cfg(any(feature = "keyboard", feature = "music", feature = "workspaces"))] pub struct IconLabel { + provider: image::Provider, container: gtk::Box, label: Label, image: Image, - icon_theme: IconTheme, size: i32, } #[cfg(any(feature = "keyboard", feature = "music", feature = "workspaces"))] impl IconLabel { - pub fn new(input: &str, icon_theme: &IconTheme, size: i32) -> Self { + pub fn new(input: &str, size: i32, image_provider: &image::Provider) -> Self { let container = gtk::Box::new(Orientation::Horizontal, 0); let label = Label::builder().use_markup(true).build(); @@ -106,21 +112,34 @@ impl IconLabel { container.add(&image); container.add(&label); - if ImageProvider::is_definitely_image_input(input) { - ImageProvider::parse(input, icon_theme, false, size) - .map(|provider| provider.load_into_image(&image)); + if image::Provider::is_explicit_input(input) { + let image = image.clone(); + let label = label.clone(); + let image_provider = image_provider.clone(); - image.show(); + let input = input.to_string(); + + glib::spawn_future_local(async move { + let res = image_provider + .load_into_image(&input, size, false, &image) + .await; + if matches!(res, Ok(true)) { + image.show(); + } else { + label.set_text(&input); + label.show(); + } + }); } else { label.set_text(input); label.show(); } Self { + provider: image_provider.clone(), container, label, image, - icon_theme: icon_theme.clone(), size, } } @@ -130,12 +149,26 @@ impl IconLabel { let image = &self.image; if let Some(input) = input { - if ImageProvider::is_definitely_image_input(input) { - ImageProvider::parse(input, &self.icon_theme, false, self.size) - .map(|provider| provider.load_into_image(image)); + if image::Provider::is_explicit_input(input) { + let provider = self.provider.clone(); + let size = self.size; - label.hide(); - image.show(); + let label = label.clone(); + let image = image.clone(); + let input = input.to_string(); + + glib::spawn_future_local(async move { + let res = provider.load_into_image(&input, size, false, &image).await; + if matches!(res, Ok(true)) { + label.hide(); + image.show(); + } else { + label.set_label_escaped(&input); + + image.hide(); + label.show(); + } + }); } else { label.set_label_escaped(input); diff --git a/src/image/mod.rs b/src/image/mod.rs index 0d75b93..447f706 100644 --- a/src/image/mod.rs +++ b/src/image/mod.rs @@ -16,4 +16,4 @@ mod provider; feature = "workspaces", ))] pub use self::gtk::*; -pub use provider::ImageProvider; +pub use provider::{Provider, create_and_load_surface}; diff --git a/src/image/provider.rs b/src/image/provider.rs index 7b46e35..0cdd33a 100644 --- a/src/image/provider.rs +++ b/src/image/provider.rs @@ -1,115 +1,198 @@ -use crate::channels::{AsyncSenderExt, MpscReceiverExt}; -use crate::desktop_file::get_desktop_icon_name; -#[cfg(feature = "http")] -use crate::spawn; -use cfg_if::cfg_if; +use crate::desktop_file::DesktopFiles; +use crate::{arc_mut, lock}; use color_eyre::{Help, Report, Result}; use gtk::cairo::Surface; use gtk::gdk::ffi::gdk_cairo_surface_create_from_pixbuf; use gtk::gdk_pixbuf::Pixbuf; +use gtk::gio::{Cancellable, MemoryInputStream}; use gtk::prelude::*; -use gtk::{IconLookupFlags, IconTheme}; -use std::path::{Path, PathBuf}; -#[cfg(feature = "http")] -use tokio::sync::mpsc; -use tracing::{debug, warn}; +use gtk::{IconLookupFlags, IconTheme, Image}; +use std::cell::RefCell; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use tracing::{debug, trace, warn}; -cfg_if!( - if #[cfg(feature = "http")] { - use gtk::gio::{Cancellable, MemoryInputStream}; - use tracing::error; +#[derive(Debug, Clone, Eq, Hash, PartialEq)] +struct ImageRef { + size: i32, + location: Option, + theme: IconTheme, +} + +impl ImageRef { + fn new(size: i32, location: Option, theme: IconTheme) -> Self { + Self { + size, + location, + theme, + } } -); +} -#[derive(Debug)] -enum ImageLocation<'a> { - Icon { - name: String, - theme: &'a IconTheme, - }, +#[derive(Debug, Clone, Eq, Hash, PartialEq)] +enum ImageLocation { + Icon(String), Local(PathBuf), Steam(String), #[cfg(feature = "http")] Remote(reqwest::Url), } -pub struct ImageProvider<'a> { - location: ImageLocation<'a>, - size: i32, +#[derive(Debug)] +struct Cache { + location_cache: HashMap<(Box, i32), ImageRef>, + pixbuf_cache: HashMap>, } -impl<'a> ImageProvider<'a> { - /// Attempts to parse the image input to find its location. - /// Errors if no valid location type can be found. +impl Cache { + fn new() -> Self { + Self { + location_cache: HashMap::new(), + pixbuf_cache: HashMap::new(), + } + } +} + +#[derive(Debug, Clone)] +pub struct Provider { + desktop_files: DesktopFiles, + icon_theme: RefCell>, + overrides: HashMap, + cache: Arc>, +} + +impl Provider { + pub fn new(desktop_files: DesktopFiles, overrides: &mut HashMap) -> Self { + let mut overrides_map = HashMap::with_capacity(overrides.len()); + overrides_map.extend(overrides.drain()); + + Self { + desktop_files, + icon_theme: RefCell::new(None), + overrides: overrides_map, + cache: arc_mut!(Cache::new()), + } + } + + /// Attempts to resolve the provided input into a `Pixbuf`, + /// and load that `Pixbuf` into the provided `Image` widget. /// - /// Note this checks that icons exist in theme, or files exist on disk - /// but no other check is performed. - pub fn parse(input: &str, theme: &'a IconTheme, use_fallback: bool, size: i32) -> Option { - let location = Self::get_location(input, theme, size, use_fallback, 0)?; - debug!("Resolved {input} --> {location:?} (size: {size})"); - - Some(Self { location, size }) - } - - /// Returns true if the input starts with a prefix - /// that is supported by the parser - /// (ie the parser would not fallback to checking the input). - pub fn is_definitely_image_input(input: &str) -> bool { - input.starts_with("icon:") - || input.starts_with("file://") - || input.starts_with("http://") - || input.starts_with("https://") - || input.starts_with('/') - } - - fn get_location( + /// If `use_fallback` is `true`, a fallback icon will be used + /// where an image cannot be found. + /// + /// Returns `true` if the image was successfully loaded, + /// or `false` if the image could not be found. + /// May also return an error if the resolution or loading process failed. + pub async fn load_into_image( + &self, input: &str, - theme: &'a IconTheme, size: i32, use_fallback: bool, - recurse_depth: usize, - ) -> Option> { - macro_rules! fallback { - () => { - if use_fallback { - Some(Self::get_fallback_icon(theme)) - } else { - None - } - }; + image: &Image, + ) -> Result { + let image_ref = self.get_ref(input, size).await?; + debug!("image ref for {input}: {:?}", image_ref); + + let pixbuf = if let Some(pixbuf) = lock!(self.cache).pixbuf_cache.get(&image_ref) { + pixbuf.clone() + } else { + let pixbuf = Self::get_pixbuf(&image_ref, image.scale_factor(), use_fallback).await?; + + lock!(self.cache) + .pixbuf_cache + .insert(image_ref, pixbuf.clone()); + + pixbuf + }; + + if let Some(ref pixbuf) = pixbuf { + create_and_load_surface(pixbuf, image)?; } - const MAX_RECURSE_DEPTH: usize = 2; + Ok(pixbuf.is_some()) + } - let should_parse_desktop_file = !Self::is_definitely_image_input(input); + /// Like [`Provider::load_into_image`], but does not return an error if the image could not be found. + /// + /// If an image is not resolved, a warning is logged. Errors are also logged. + pub async fn load_into_image_silent( + &self, + input: &str, + size: i32, + use_fallback: bool, + image: &Image, + ) { + match self.load_into_image(input, size, use_fallback, image).await { + Ok(true) => {} + Ok(false) => warn!("failed to resolve image: {input}"), + Err(e) => warn!("failed to load image: {input}: {e:?}"), + } + } + + /// Returns the `ImageRef` for the provided input. + /// + /// This contains the location of the image if it can be resolved. + /// The ref will be loaded from cache if present. + async fn get_ref(&self, input: &str, size: i32) -> Result { + let key = (input.into(), size); + + if let Some(location) = lock!(self.cache).location_cache.get(&key) { + Ok(location.clone()) + } else { + let location = self.resolve_location(input, size, 0).await?; + let image_ref = ImageRef::new(size, location, self.icon_theme()); + + lock!(self.cache) + .location_cache + .insert(key, image_ref.clone()); + Ok(image_ref) + } + } + + /// Attempts to resolve the provided input into an `ImageLocation`. + /// + /// This will resolve all of: + /// - The current icon theme + /// - The file on disk + /// - Steam icons + /// - Desktop files (`Icon` keys) + /// - HTTP(S) URLs + async fn resolve_location( + &self, + input: &str, + size: i32, + recurse_depth: u8, + ) -> Result> { + const MAX_RECURSE_DEPTH: u8 = 2; + + let input = self + .overrides + .get(input) + .map_or(input, String::as_str); + + let should_parse_desktop_file = !Self::is_explicit_input(input); let (input_type, input_name) = input .split_once(':') .map_or((None, input), |(t, n)| (Some(t), n)); - match input_type { - Some(input_type) if input_type == "icon" => Some(ImageLocation::Icon { - name: input_name.to_string(), - theme, - }), - Some(input_type) if input_type == "file" => Some(ImageLocation::Local(PathBuf::from( + let location = match input_type { + Some(_t @ "icon") => Some(ImageLocation::Icon(input.to_string())), + Some(_t @ "file") => Some(ImageLocation::Local(PathBuf::from( input_name[2..].to_string(), ))), #[cfg(feature = "http")] - Some(input_type) if input_type == "http" || input_type == "https" => { - input.parse().ok().map(ImageLocation::Remote) - } + Some(_t @ ("http" | "https")) => input.parse().ok().map(ImageLocation::Remote), None if input.starts_with("steam_app_") => Some(ImageLocation::Steam( input_name.chars().skip("steam_app_".len()).collect(), )), - None if theme + None if self + .icon_theme() .lookup_icon(input, size, IconLookupFlags::empty()) .is_some() => { - Some(ImageLocation::Icon { - name: input_name.to_string(), - theme, - }) + Some(ImageLocation::Icon(input.to_string())) } Some(input_type) => { warn!( @@ -117,173 +200,154 @@ impl<'a> ImageProvider<'a> { Report::msg(format!("Unsupported image type: {input_type}")) .note("You may need to recompile with support if available") ); - fallback!() + None } None if PathBuf::from(input_name).is_file() => { Some(ImageLocation::Local(PathBuf::from(input_name))) } - None if recurse_depth == MAX_RECURSE_DEPTH => fallback!(), + None if recurse_depth == MAX_RECURSE_DEPTH => None, None if should_parse_desktop_file => { - if let Some(location) = get_desktop_icon_name(input_name).map(|input| { - Self::get_location(&input, theme, size, use_fallback, recurse_depth + 1) - }) { - location - } else { - warn!("Failed to find image: {input}"); - fallback!() - } - } - None => { - warn!("Failed to find image: {input}"); - fallback!() - } - } - } + let location = self + .desktop_files + .find(input_name) + .await? + .and_then(|input| input.icon); - /// Attempts to fetch the image from the location - /// and load it into the provided `GTK::Image` widget. - pub fn load_into_image(&self, image: >k::Image) -> Result<()> { - // handle remote locations async to avoid blocking UI thread while downloading - #[cfg(feature = "http")] - if let ImageLocation::Remote(url) = &self.location { - let url = url.clone(); - let (tx, rx) = mpsc::channel(64); - - spawn(async move { - let bytes = Self::get_bytes_from_http(url).await; - if let Ok(bytes) = bytes { - tx.send_expect(bytes).await; - } - }); - - { - let size = self.size; - let image = image.clone(); - rx.recv_glib(move |bytes| { - let stream = MemoryInputStream::from_bytes(&bytes); - - let scale = image.scale_factor(); - let scaled_size = size * scale; - - let pixbuf = Pixbuf::from_stream_at_scale( - &stream, - scaled_size, - scaled_size, - true, - Some(&Cancellable::new()), - ); - - // Different error types makes this a bit awkward - match pixbuf.map(|pixbuf| Self::create_and_load_surface(&pixbuf, &image)) { - Ok(Err(err)) => error!("{err:?}"), - Err(err) => error!("{err:?}"), - _ => {} + if let Some(location) = location { + if location == input { + None + } else { + Box::pin(self.resolve_location(&location, size, recurse_depth + 1)).await? } - }); + } else { + None + } } - } else { - self.load_into_image_sync(image)?; + None => None, }; - #[cfg(not(feature = "http"))] - self.load_into_image_sync(image)?; - - Ok(()) + Ok(location) } - /// Attempts to synchronously fetch an image from location - /// and load into into the image. - fn load_into_image_sync(&self, image: >k::Image) -> Result<()> { - let scale = image.scale_factor(); - - let pixbuf = match &self.location { - ImageLocation::Icon { name, theme } => self.get_from_icon(name, theme, scale), - ImageLocation::Local(path) => self.get_from_file(path, scale), - ImageLocation::Steam(steam_id) => self.get_from_steam_id(steam_id, scale), - #[cfg(feature = "http")] - _ => unreachable!(), // handled above - }?; - - Self::create_and_load_surface(&pixbuf, image) - } - - /// Attempts to create a Cairo surface from the provided `Pixbuf`, - /// using the provided scaling factor. - /// The surface is then loaded into the provided image. + /// Attempts to load the provided `ImageRef` into a `Pixbuf`. /// - /// This is necessary for HiDPI since `Pixbuf`s are always treated as scale factor 1. - pub fn create_and_load_surface(pixbuf: &Pixbuf, image: >k::Image) -> Result<()> { - let surface = unsafe { - let ptr = gdk_cairo_surface_create_from_pixbuf( - pixbuf.as_ptr(), - image.scale_factor(), - std::ptr::null_mut(), - ); - Surface::from_raw_full(ptr) + /// If `use_fallback` is `true`, a fallback icon will be used + /// where an image cannot be found. + async fn get_pixbuf( + image_ref: &ImageRef, + scale: i32, + use_fallback: bool, + ) -> Result> { + const FALLBACK_ICON_NAME: &str = "dialog-question-symbolic"; + + let buf = match &image_ref.location { + Some(ImageLocation::Icon(name)) => image_ref.theme.load_icon_for_scale( + name, + image_ref.size, + scale, + IconLookupFlags::FORCE_SIZE, + ), + Some(ImageLocation::Local(path)) => { + let scaled_size = image_ref.size * scale; + Pixbuf::from_file_at_scale(path, scaled_size, scaled_size, true).map(Some) + } + Some(ImageLocation::Steam(app_id)) => { + let path = dirs::data_dir().map_or_else( + || Err(Report::msg("Missing XDG data dir")), + |dir| Ok(dir.join(format!("icons/hicolor/32x32/apps/steam_icon_{app_id}.png"))), + )?; + + let scaled_size = image_ref.size * scale; + Pixbuf::from_file_at_scale(path, scaled_size, scaled_size, true).map(Some) + } + #[cfg(feature = "http")] + Some(ImageLocation::Remote(uri)) => { + let res = reqwest::get(uri.clone()).await?; + + let status = res.status(); + let bytes = if status.is_success() { + let bytes = res.bytes().await?; + Ok(glib::Bytes::from_owned(bytes)) + } else { + Err(Report::msg(format!( + "Received non-success HTTP code ({status})" + ))) + }?; + + let stream = MemoryInputStream::from_bytes(&bytes); + let scaled_size = image_ref.size * scale; + + Pixbuf::from_stream_at_scale( + &stream, + scaled_size, + scaled_size, + true, + Some(&Cancellable::new()), + ) + .map(Some) + } + None if use_fallback => image_ref.theme.load_icon_for_scale( + FALLBACK_ICON_NAME, + image_ref.size, + scale, + IconLookupFlags::empty(), + ), + None => Ok(None), }?; - image.set_from_surface(Some(&surface)); - - Ok(()) + Ok(buf) } - /// Attempts to get a `Pixbuf` from the GTK icon theme. - fn get_from_icon(&self, name: &str, theme: &IconTheme, scale: i32) -> Result { - let pixbuf = - match theme.lookup_icon_for_scale(name, self.size, scale, IconLookupFlags::empty()) { - Some(_) => theme.load_icon(name, self.size * scale, IconLookupFlags::FORCE_SIZE), - None => Ok(None), - }?; - - pixbuf.map_or_else( - || Err(Report::msg("Icon theme does not contain icon '{name}'")), - Ok, - ) + /// Returns true if the input starts with a prefix + /// that is supported by the parser + /// (i.e. the parser would not fall back to checking the input). + pub fn is_explicit_input(input: &str) -> bool { + input.starts_with("icon:") + || input.starts_with("file://") + || input.starts_with("http://") + || input.starts_with("https://") + || input.starts_with('/') } - /// Attempts to get a `Pixbuf` from a local file. - fn get_from_file(&self, path: &Path, scale: i32) -> Result { - let scaled_size = self.size * scale; - let pixbuf = Pixbuf::from_file_at_scale(path, scaled_size, scaled_size, true)?; - Ok(pixbuf) + pub fn icon_theme(&self) -> IconTheme { + self.icon_theme + .borrow() + .clone() + .expect("theme should be set at startup") } - /// Attempts to get a `Pixbuf` from a local file, - /// using the Steam game ID to look it up. - fn get_from_steam_id(&self, steam_id: &str, scale: i32) -> Result { - // TODO: Can we load this from icon theme with app id `steam_icon_{}`? - let path = dirs::data_dir().map_or_else( - || Err(Report::msg("Missing XDG data dir")), - |dir| { - Ok(dir.join(format!( - "icons/hicolor/32x32/apps/steam_icon_{steam_id}.png" - ))) - }, - )?; + /// Sets the custom icon theme name. + /// If no name is provided, the system default is used. + pub fn set_icon_theme(&self, theme: Option<&str>) { + trace!("Setting icon theme to {:?}", theme); - self.get_from_file(&path, scale) - } - - /// Attempts to get `Bytes` from an HTTP resource asynchronously. - #[cfg(feature = "http")] - async fn get_bytes_from_http(url: reqwest::Url) -> Result { - let res = reqwest::get(url).await?; - - let status = res.status(); - if status.is_success() { - let bytes = res.bytes().await?; - Ok(glib::Bytes::from_owned(bytes)) + *self.icon_theme.borrow_mut() = if theme.is_some() { + let icon_theme = IconTheme::new(); + icon_theme.set_custom_theme(theme); + Some(icon_theme) } else { - Err(Report::msg(format!( - "Received non-success HTTP code ({status})" - ))) - } - } - - fn get_fallback_icon(theme: &'a IconTheme) -> ImageLocation<'a> { - ImageLocation::Icon { - name: "dialog-question-symbolic".to_string(), - theme, - } + IconTheme::default() + }; } } + +/// Attempts to create a Cairo `Surface` from the provided `Pixbuf`, +/// using the provided scaling factor. +/// The surface is then loaded into the provided image. +/// +/// This is necessary for HiDPI since `Pixbuf`s are always treated as scale factor 1. +pub fn create_and_load_surface(pixbuf: &Pixbuf, image: &Image) -> Result<()> { + let surface = unsafe { + let ptr = gdk_cairo_surface_create_from_pixbuf( + pixbuf.as_ptr(), + image.scale_factor(), + std::ptr::null_mut(), + ); + + Surface::from_raw_full(ptr) + }?; + + image.set_from_surface(Some(&surface)); + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index c2981dd..760d83c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,6 +29,7 @@ use crate::channels::SyncSenderExt; use crate::clients::Clients; use crate::clients::wayland::OutputEventType; use crate::config::{Config, MonitorConfig}; +use crate::desktop_file::DesktopFiles; use crate::error::ExitCode; #[cfg(feature = "ipc")] use crate::ironvar::{VariableManager, WritableNamespace}; @@ -106,7 +107,7 @@ fn run_with_args() { error!("{err:#}"); exit(ExitCode::IpcResponseError as i32) } - }; + } }); } None => start_ironbar(), @@ -119,17 +120,26 @@ pub struct Ironbar { clients: Rc>, config: Rc>, config_dir: PathBuf, + + desktop_files: DesktopFiles, + image_provider: image::Provider, } impl Ironbar { fn new() -> Self { - let (config, config_dir) = load_config(); + let (mut config, config_dir) = load_config(); + + let desktop_files = DesktopFiles::new(); + let image_provider = + image::Provider::new(desktop_files.clone(), &mut config.icon_overrides); Self { bars: Rc::new(RefCell::new(vec![])), clients: Rc::new(RefCell::new(Clients::new())), config: Rc::new(RefCell::new(config)), config_dir, + desktop_files, + image_provider, } } @@ -215,6 +225,10 @@ impl Ironbar { let _hold = activate_rx.recv().expect("to receive activation signal"); debug!("Received activation signal, initialising bars"); + instance + .image_provider + .set_icon_theme(instance.config.borrow().icon_theme.as_deref()); + while let Ok(event) = rx_outputs.recv().await { match event.event_type { OutputEventType::New => { @@ -270,6 +284,16 @@ impl Ironbar { .clone() } + #[must_use] + pub fn desktop_files(&self) -> DesktopFiles { + self.desktop_files.clone() + } + + #[must_use] + pub fn image_provider(&self) -> image::Provider { + self.image_provider.clone() + } + /// Gets clones of bars by their name. /// /// Since the bars contain mostly GTK objects, diff --git a/src/modules/clipboard.rs b/src/modules/clipboard.rs index 7686adc..9c0b367 100644 --- a/src/modules/clipboard.rs +++ b/src/modules/clipboard.rs @@ -147,7 +147,8 @@ impl Module