From daac2691bcfa7db9df5d69622878f9f4d01f1d5c Mon Sep 17 00:00:00 2001 From: Tassilo Horn Date: Sat, 3 Jul 2021 00:44:38 +0200 Subject: [PATCH] New {app_icon} replacement for window_format --- Cargo.lock | 34 +++++++++++++ Cargo.toml | 3 ++ README.md | 11 +++++ src/con.rs | 37 ++++++++++++-- src/config.rs | 7 +++ src/util.rs | 134 +++++++++++++++++++++++++++++++++++++++++++++++++- 6 files changed, 222 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9c55c19..3143076 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,15 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "aho-corasick" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f" +dependencies = [ + "memchr", +] + [[package]] name = "atty" version = "0.2.14" @@ -146,6 +155,12 @@ version = "0.2.97" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12b8adadd720df158f4d70dfe7ccc6adb0472d7c55ca83445f6a5ab3e36f8fb6" +[[package]] +name = "memchr" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b16bd47d9e329435e309c58469fe0791c2d0d1ba96ec0954152a5ae2b04387dc" + [[package]] name = "os_str_bytes" version = "2.4.0" @@ -213,6 +228,23 @@ dependencies = [ "redox_syscall", ] +[[package]] +name = "regex" +version = "1.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d07a8629359eb56f1e2fb1652bb04212c072a87ba68546a04065d525673ac461" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.6.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b" + [[package]] name = "ryu" version = "1.0.5" @@ -284,6 +316,8 @@ version = "0.4.4" dependencies = [ "clap", "directories", + "lazy_static", + "regex", "serde", "serde_json", "swayipc", diff --git a/Cargo.toml b/Cargo.toml index 99926c3..e9c9a21 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,3 +15,6 @@ clap = "3.0.0-beta.2" swayipc = "3.0.0-alpha.3" toml = "0.5.8" directories = "3.0" +regex = "1.5.4" +lazy_static = "1.4.0" + diff --git a/README.md b/README.md index e7415e2..2822285 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,8 @@ window_format = '{urgency_start}“{title}”{urgency_end} — {app_na workspace_format = 'Workspace {name} ({id})' urgency_start = '' urgency_end = '' +icon_dirs = ['/usr/share/icons/Adwaita/48x48/apps', '/usr/share/icons/hicolor/48x48/apps', '/usr/share/pixmaps'] +fallback_icon = '/usr/share/icons/gnome/48x48/apps/kwin.png' ``` In the `[menu]` section, you can specify the menu program using the @@ -162,6 +164,7 @@ to style the text using HTML and CSS. The following formats are supported right now. * `window_format` defines how windows are displayed. The placeholder `{title}` is replaced with the window's title, `{app_name}` with the application name, + `{app_icon}` with the application's icon (a path to a PNG or SVG file), `{workspace_name}` with the name or number of the workspace the window is shown, and `{id}` is the window's sway-internal con id. There are also the placeholders `{urcency_start}` and `{urgency_end}` which get replaced by the @@ -175,6 +178,10 @@ right now. in `window_format`. * `urgency_end` is a string which replaces the `{urgency_end}` placeholder in `window_format`. +* `icon_dirs` is a vector of directories in which to look for application icons + in order to compute the `{app_icon}` replacement. +* `fallback_icon` is a path to some PNG/SVG icon which will be used as + `{app_icon}` if no application-specific icon can be determined. It is crucial that during selection (using wofi or some other menu program) each window has a different display string. Therefore, it is highly @@ -182,6 +189,10 @@ recommended to include the `{id}` placeholder at least in `window_format`. Otherwise, e.g., two terminals (of the same terminal app) with the same working directory (and therefore, the same title) wouldn't be distinguishable. +Hint: `wofi` supports icons with the syntax `img::text:`, so +a suitable `window_format` with application icon should start with +`img:{app_icon}:text:`. + ## Questions & Patches For asking questions, sending feedback, or patches, refer to [my public inbox diff --git a/src/con.rs b/src/con.rs index 25b8cb6..a25666e 100644 --- a/src/con.rs +++ b/src/con.rs @@ -19,6 +19,7 @@ use crate::config as cfg; use crate::ipc; use crate::ipc::NodeMethods; use crate::util; +use lazy_static::lazy_static; use std::cmp; use std::collections::HashMap; use std::fmt; @@ -120,6 +121,11 @@ impl<'a> fmt::Display for Window<'a> { } } +lazy_static! { + static ref APP_NAME_AND_VERSION_RX: regex::Regex = + regex::Regex::new("(.+)(-[0-9.]+)").unwrap(); +} + impl<'a> DisplayFormat for Window<'a> { fn format_for_display(&self, cfg: &cfg::Config) -> String { let default_format = cfg::Format::default(); @@ -138,6 +144,19 @@ impl<'a> DisplayFormat for Window<'a> { .as_ref() .and_then(|f| f.urgency_end.as_ref()) .unwrap_or_else(|| default_format.urgency_end.as_ref().unwrap()); + let icon_dirs = cfg + .format + .as_ref() + .and_then(|f| f.icon_dirs.as_ref()) + .unwrap_or_else(|| default_format.icon_dirs.as_ref().unwrap()); + // fallback_icon has no default value. + let fallback_icon: Option<&String> = + cfg.format.as_ref().and_then(|f| f.fallback_icon.as_ref()); + + // Some apps report, e.g., Gimp-2.10 but the icon is still named + // gimp.png. + let app_name_no_version = + APP_NAME_AND_VERSION_RX.replace(self.get_app_name(), "$1"); fmt.replace("{id}", format!("{}", self.get_id()).as_str()) .replace( @@ -161,6 +180,20 @@ impl<'a> DisplayFormat for Window<'a> { "{workspace_name}", self.workspace.name.as_ref().unwrap().as_str(), ) + .replace( + "{app_icon}", + util::get_icon(self.get_app_name(), icon_dirs) + .or_else(|| util::get_icon(&app_name_no_version, icon_dirs)) + .or_else(|| { + util::get_icon( + &app_name_no_version.to_lowercase(), + icon_dirs, + ) + }) + .or_else(|| fallback_icon.map(|f| f.to_string())) + .unwrap_or_else(String::new) + .as_str(), + ) .replace("{title}", self.get_title()) } } @@ -280,9 +313,7 @@ impl DisplayFormat for WsOrWin<'_> { fn format_for_display(&self, cfg: &cfg::Config) -> String { match self { WsOrWin::Ws { ws } => ws.format_for_display(cfg), - WsOrWin::Win { win } => { - "\t".to_owned() + &win.format_for_display(cfg) - } + WsOrWin::Win { win } => win.format_for_display(cfg), } } } diff --git a/src/config.rs b/src/config.rs index c8892aa..24491ae 100644 --- a/src/config.rs +++ b/src/config.rs @@ -40,6 +40,8 @@ pub struct Format { pub workspace_format: Option, pub urgency_start: Option, pub urgency_end: Option, + pub icon_dirs: Option>, + pub fallback_icon: Option, } impl Default for Menu { @@ -78,6 +80,11 @@ impl Default for Format { .to_string(), ), urgency_end: Some("".to_string()), + icon_dirs: Some(vec![ + "/usr/share/icons/hicolor/48x48/apps".to_string(), + "/usr/share/pixmaps".to_string(), + ]), + fallback_icon: None, } } } diff --git a/src/util.rs b/src/util.rs index 053c6a7..e69d08a 100644 --- a/src/util.rs +++ b/src/util.rs @@ -17,8 +17,9 @@ use crate::con::DisplayFormat; use crate::config as cfg; +use lazy_static::lazy_static; use std::collections::HashMap; -use std::io::Write; +use std::io::{BufRead, Write}; use std::process as proc; pub fn get_swayr_socket_path() -> String { @@ -43,6 +44,137 @@ pub fn get_swayr_socket_path() -> String { ) } +fn desktop_entries() -> Vec { + let mut dirs = vec![]; + if let Some(dd) = directories::BaseDirs::new() + .map(|b| b.data_local_dir().to_string_lossy().to_string()) + { + dirs.push(dd); + } + dirs.push(String::from("/usr/share/applications/")); + + let mut entries = vec![]; + for dir in dirs { + if let Ok(readdir) = std::fs::read_dir(dir) { + for entry in readdir.flatten() { + let path = entry.path(); + if path.is_file() + && path.extension().map(|ext| ext == "desktop") + == Some(true) + { + entries.push(path.as_path().to_string_lossy().to_string()); + } + } + } + } + entries +} + +fn find_icon(icon_name: &str, icon_dirs: &[String]) -> Option { + if std::path::Path::new(icon_name).is_file() { + return Some(String::from(icon_name)); + } + + for dir in icon_dirs { + for ext in &["png", "svg"] { + let mut pb = std::path::PathBuf::from(dir); + pb.push(icon_name); + pb.set_extension(ext); + let icon_file = pb.as_path(); + if icon_file.is_file() { + return Some(String::from(icon_file.to_str().unwrap())); + } + } + } + + None +} + +lazy_static! { + static ref WM_CLASS_OR_ICON_RX: regex::Regex = + regex::Regex::new("(StartupWMClass|Icon)=(.+)").unwrap(); +} + +fn get_app_id_to_icon_map(icon_dirs: &[String]) -> HashMap { + let mut map = HashMap::new(); + + for e in desktop_entries() { + if let Ok(f) = std::fs::File::open(&e) { + let buf = std::io::BufReader::new(f); + let mut wm_class: Option = None; + let mut icon: Option = None; + + // Get App-Id and Icon from desktop file. + for line in buf.lines() { + if wm_class.is_some() && icon.is_some() { + break; + } + if let Ok(line) = line { + if let Some(cap) = WM_CLASS_OR_ICON_RX.captures(&line) { + if "StartupWMClass" == cap.get(1).unwrap().as_str() { + wm_class.replace( + cap.get(2).unwrap().as_str().to_string(), + ); + } else if let Some(icon_file) = + find_icon(cap.get(2).unwrap().as_str(), icon_dirs) + { + icon.replace(icon_file); + } + } + } + } + + if let Some(icon) = icon { + if let Some(wm_class) = wm_class { + map.insert(wm_class, icon.clone()); + } + map.insert( + String::from( + std::path::Path::new(&e) + .with_extension("") + .file_name() + .unwrap() + .to_string_lossy(), + ), + icon, + ); + } + } + } + + map +} + +lazy_static! { + static ref APP_ID_TO_ICON_MAP: std::sync::Mutex>> = + std::sync::Mutex::new(None); +} + +pub fn get_icon(app_id: &str, icon_dirs: &[String]) -> Option { + let mut opt = APP_ID_TO_ICON_MAP.lock().unwrap(); + + if opt.is_none() { + opt.replace(get_app_id_to_icon_map(icon_dirs)); + } + + opt.as_ref().unwrap().get(app_id).map(String::from) +} + +#[test] +fn test_desktop_entries() { + let icon_dirs = vec![ + String::from("/usr/share/icons/hicolor/48x48/apps"), + String::from("/usr/share/pixmaps"), + ]; + let m = get_app_id_to_icon_map(&icon_dirs); + println!("Found {} icon entries:\n{:#?}", m.len(), m); + + let apps = vec!["Emacs", "Alacritty", "firefoxdeveloperedition", "gimp"]; + for app in apps { + println!("Icon for {}: {:?}", app, get_icon(app, &icon_dirs)) + } +} + pub fn select_from_menu<'a, 'b, TS>( prompt: &'a str, choices: &'b [TS],