diff --git a/plugin-desktop2/Cargo.toml b/plugin-desktop2/Cargo.toml new file mode 100644 index 0000000..23f8f5c --- /dev/null +++ b/plugin-desktop2/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "desktop2" +version = "0.0.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +freedesktop-desktop-entry = "0.5.0" +freedesktop-icons = "0.2.3" +log = "0.4.19" +once_cell = "1.18.0" +regex = "1.9.1" +rmenu-plugin = { version = "0.0.0", path = "../rmenu-plugin" } +rust-ini = "0.19.0" +serde_json = "1.0.104" +shellexpand = "3.1.0" +thiserror = "1.0.44" +walkdir = "2.3.3" diff --git a/plugin-desktop2/src/icons.rs b/plugin-desktop2/src/icons.rs new file mode 100644 index 0000000..50afbc9 --- /dev/null +++ b/plugin-desktop2/src/icons.rs @@ -0,0 +1,306 @@ +use std::collections::{BTreeMap, HashMap}; +use std::fs::{read_dir, read_to_string}; +use std::path::PathBuf; + +use freedesktop_desktop_entry::DesktopEntry; +use ini::Ini; +use once_cell::sync::Lazy; +use thiserror::Error; +use walkdir::WalkDir; + +type ThemeSource<'a> = (&'a str, &'a str, &'a str); + +static INDEX_MAIN: &'static str = "Icon Theme"; +static INDEX_NAME: &'static str = "Name"; +static INDEX_SIZE: &'static str = "Size"; +static INDEX_DIRS: &'static str = "Directories"; +static INDEX_FILE: &'static str = "index.theme"; + +static DEFAULT_INDEX: &'static str = "default/index.theme"; +static DEFAULT_THEME: &'static str = "Hicolor"; + +static PIXMAPS: Lazy = Lazy::new(|| PathBuf::from("/usr/share/pixmaps/")); +static THEME_SOURCES: Lazy> = Lazy::new(|| { + vec![ + ("kdeglobals", "Icons", "Theme"), + ("gtk-4.0/settings.ini", "Settings", "gtk-icon-theme-name"), + ("gtk-3.0/settings.ini", "Settings", "gtk-icon-theme-name"), + ] +}); + +/// Title String +#[inline] +fn title(s: &str) -> String { + let mut c = s.chars(); + match c.next() { + None => String::new(), + Some(f) => f.to_uppercase().collect::() + c.as_str(), + } +} + +/// Collect Theme Definitions in Common GUI Configurations +fn theme_inis(cfgdir: &PathBuf) -> Vec { + THEME_SOURCES + .iter() + .filter_map(|(path, sec, key)| { + let path = cfgdir.join(path); + let ini = Ini::load_from_file(path).ok()?; + ini.get_from(Some(sec.to_owned()), key).map(|s| title(s)) + }) + .collect() +} + +/// Parse FreeDesktop Theme-Name from Index File +fn get_theme_name(path: &PathBuf) -> Option { + let content = read_to_string(path).ok()?; + let config = DesktopEntry::decode(&path, &content).ok()?; + config + .groups + .get(INDEX_MAIN) + .and_then(|g| g.get(INDEX_NAME)) + .map(|key| key.0.to_owned()) +} + +/// Determine XDG Icon Theme based on Preexisting Configuration Files +pub fn active_themes(cfgdir: &PathBuf, icondirs: &Vec) -> Vec { + let mut themes: Vec = icondirs + .iter() + .map(|d| d.join(DEFAULT_INDEX)) + .filter(|p| p.exists()) + .filter_map(|p| get_theme_name(&p)) + .collect(); + themes.extend(theme_inis(cfgdir)); + let default = DEFAULT_THEME.to_string(); + if !themes.contains(&default) { + themes.push(default); + } + themes +} + +#[derive(Debug, Error)] +pub enum ThemeError { + #[error("Failed to Read Index")] + FileError(#[from] std::io::Error), + #[error("Failed to Parse Index")] + IndexError(#[from] freedesktop_desktop_entry::DecodeError), + #[error("No Such Group")] + NoSuchGroup(&'static str), + #[error("No Such Key")] + NoSuchKey(&'static str), + #[error("Unselected Theme")] + UnselectedTheme, + #[error("Invalid Path Name")] + BadPathName(PathBuf), +} + +/// Track Paths and their Priority according to Sizes preference +struct PathPriority { + path: PathBuf, + priority: usize, +} + +impl PathPriority { + fn new(path: PathBuf, priority: usize) -> Self { + Self { path, priority } + } +} + +/// Track Theme Information w/ Name/Priority/SubPaths +struct ThemeInfo { + name: String, + priority: usize, + paths: Vec, +} + +/// Single Theme Specification +struct ThemeSpec<'a> { + root: &'a PathBuf, + themes: &'a Vec, + sizes: &'a Vec, +} + +impl<'a> ThemeSpec<'a> { + fn new(root: &'a PathBuf, themes: &'a Vec, sizes: &'a Vec) -> Self { + Self { + root, + themes, + sizes, + } + } +} + +/// Sort Theme Directories by Priority, Append Root, and Collect Names Only +#[inline] +fn sort_dirs(base: &PathBuf, dirs: &mut Vec) -> Vec { + dirs.sort_by_key(|p| p.priority); + dirs.push(PathPriority::new("".into(), 0)); + dirs.into_iter().map(|p| p.path.to_owned()).collect() +} + +/// Parse Theme Index and Sort Directories based on Size Preference +fn parse_index(spec: &ThemeSpec) -> Result { + // parse file content + let index = spec.root.join(INDEX_FILE); + let content = read_to_string(&index)?; + let config = DesktopEntry::decode(&index, &content)?; + let main = config + .groups + .get(INDEX_MAIN) + .ok_or_else(|| ThemeError::NoSuchGroup(INDEX_MAIN))?; + // retrieve name and directories + let name = main + .get(INDEX_NAME) + .ok_or_else(|| ThemeError::NoSuchKey(INDEX_NAME))? + .0; + // check if name in supported themes + let index = spec + .themes + .iter() + .position(|t| t == &name) + .ok_or_else(|| ThemeError::UnselectedTheme)?; + // sort directories based on size preference + let mut directories = main + .get(INDEX_DIRS) + .ok_or_else(|| ThemeError::NoSuchKey(INDEX_DIRS))? + .0 + .split(',') + .into_iter() + .filter_map(|dir| { + let group = config.groups.get(dir)?; + let size = group + .get(INDEX_SIZE) + .and_then(|e| Some(e.0.to_owned())) + .and_then(|s| spec.sizes.iter().position(|is| &s == is)); + Some(match size { + Some(num) => PathPriority::new(spec.root.join(dir), num), + None => PathPriority::new(spec.root.join(dir), 99), + }) + }) + .collect(); + Ok(ThemeInfo { + priority: index, + name: name.to_owned(), + paths: sort_dirs(spec.root, &mut directories), + }) +} + +/// Guess Theme when Index is Missing +fn guess_index(spec: &ThemeSpec) -> Result { + // parse name and confirm active theme + let name = title( + spec.root + .file_name() + .ok_or_else(|| ThemeError::BadPathName(spec.root.to_owned()))? + .to_str() + .ok_or_else(|| ThemeError::BadPathName(spec.root.to_owned()))?, + ); + let index = spec + .themes + .iter() + .position(|t| t == &name) + .ok_or_else(|| ThemeError::UnselectedTheme)?; + // retrieve directories and include priority + let mut directories: Vec = read_dir(spec.root)? + .into_iter() + .filter_map(|e| e.ok()) + .filter_map(|e| { + let name = e.file_name().to_str().map(|n| n.to_owned())?; + Some(match name.split_once("x") { + Some((size, _)) => { + let index = spec.sizes.iter().position(|is| &size == is); + PathPriority::new(e.path(), index.unwrap_or(99)) + } + None => PathPriority::new(e.path(), 99), + }) + }) + .collect(); + // sort by priorty and only include matches + Ok(ThemeInfo { + name, + priority: index, + paths: sort_dirs(spec.root, &mut directories), + }) +} + +/// Specification for a Single Theme Path +pub struct IconSpec { + paths: Vec, + themes: Vec, + sizes: Vec, +} + +impl IconSpec { + pub fn new(paths: Vec, themes: Vec, sizes: Vec) -> Self { + Self { + paths, + themes, + sizes: sizes.into_iter().map(|i| i.to_string()).collect(), + } + } + + pub fn standard(cfg: &PathBuf, sizes: Vec) -> Self { + let mut icon_paths = crate::data_dirs("icons"); + let themes = active_themes(cfg, &icon_paths); + Self::new(icon_paths, themes, sizes) + } +} + +/// Parse and Collect a list of Directories to Find Icons in Order of Preference +fn parse_themes(icons: IconSpec) -> Vec { + // retrieve supported theme information + let mut infos: Vec = icons + .paths + // retrieve icon directories within main icon data paths + .into_iter() + .filter_map(|p| Some(read_dir(&p).ok()?.into_iter().filter_map(|d| d.ok()))) + .flatten() + .map(|readdir| readdir.path()) + // parse or guess index themes + .filter_map(|icondir| { + let spec = ThemeSpec::new(&icondir, &icons.themes, &icons.sizes); + parse_index(&spec) + .map(|r| Ok(r)) + .unwrap_or_else(|_| guess_index(&spec)) + .ok() + }) + .collect(); + // sort results by theme index + infos.sort_by_key(|i| i.priority); + // combine results from multiple directories for the same theme + let mut map = BTreeMap::new(); + for info in infos.into_iter() { + map.entry(info.name).or_insert(vec![]).extend(info.paths); + } + // finalize results from values + map.insert("pixmaps".to_owned(), vec![PIXMAPS.to_owned()]); + map.into_values().flatten().collect() +} + +pub type IconMap = HashMap; + +#[inline] +fn is_icon(fname: &str) -> bool { + fname.ends_with("png") || fname.ends_with("svg") || fname.ends_with("xpm") +} + +/// Collect Unique Icon Map based on Preffered Paths +pub fn collect_icons(spec: IconSpec) -> IconMap { + let mut map = HashMap::new(); + for path in parse_themes(spec).into_iter() { + let icons = WalkDir::new(path) + .follow_links(true) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_file()); + for icon in icons { + let Some(fname) = icon.file_name().to_str() else { continue }; + if !is_icon(&fname) { + continue; + } + let Some((name, _)) = fname.rsplit_once(".") else { continue }; + map.entry(name.to_owned()) + .or_insert_with(|| icon.path().to_owned()); + } + } + map +} diff --git a/plugin-desktop2/src/main.rs b/plugin-desktop2/src/main.rs new file mode 100644 index 0000000..f03e845 --- /dev/null +++ b/plugin-desktop2/src/main.rs @@ -0,0 +1,142 @@ +use std::fs::read_to_string; +use std::path::PathBuf; + +use freedesktop_desktop_entry::{DesktopEntry, Iter}; +use freedesktop_icons::lookup; +use once_cell::sync::Lazy; +use regex::Regex; +use rmenu_plugin::{Action, Entry, Method}; + +mod icons; + +static XDG_DATA_ENV: &'static str = "XDG_DATA_DIRS"; +static XDG_CONFIG_ENV: &'static str = "XDG_CONFIG_HOME"; +static XDG_DATA_DEFAULT: &'static str = "/usr/share:/usr/local/share"; +static XDG_CONFIG_DEFAULT: &'static str = "~/.config"; + +static EXEC_RGX: Lazy = + Lazy::new(|| Regex::new(r"%\w").expect("Failed to Build Exec Regex")); + +/// Retrieve XDG-CONFIG-HOME Directory +#[inline] +fn config_dir() -> PathBuf { + let path = std::env::var(XDG_CONFIG_ENV).unwrap_or_else(|_| XDG_CONFIG_DEFAULT.to_string()); + PathBuf::from(shellexpand::tilde(&path).to_string()) +} + +/// Retrieve XDG-DATA Directories +fn data_dirs(dir: &str) -> Vec { + std::env::var(XDG_DATA_ENV) + .unwrap_or_else(|_| XDG_DATA_DEFAULT.to_string()) + .split(":") + .map(|p| shellexpand::tilde(p).to_string()) + .map(PathBuf::from) + .map(|p| p.join(dir.to_owned())) + .filter(|p| p.exists()) + .collect() +} + +/// Find Freedesktop Default Theme +fn default_theme() -> String { + data_dirs("icons") + .into_iter() + .map(|p| p.join("default/index.theme")) + .filter(|p| p.exists()) + .find_map(|p| { + let content = read_to_string(&p).ok()?; + let config = DesktopEntry::decode(&p, &content).ok()?; + config + .groups + .get("Icon Theme") + .and_then(|g| g.get("Name")) + .map(|key| key.0.to_owned()) + }) + .unwrap_or_else(|| "Hicolor".to_string()) +} + +/// Modify Exec Statements to Remove %u/%f/etc... +#[inline(always)] +fn fix_exec(exec: &str) -> String { + EXEC_RGX.replace_all(exec, "").trim().to_string() +} + +/// Parse XDG Desktop Entry into RMenu Entry +fn parse_desktop(path: &PathBuf, locale: Option<&str>) -> Option { + let bytes = read_to_string(path).ok()?; + let entry = DesktopEntry::decode(&path, &bytes).ok()?; + let name = entry.name(locale)?.to_string(); + let icon = entry.icon().map(|i| i.to_string()); + let comment = entry.comment(locale).map(|s| s.to_string()); + let terminal = entry.terminal(); + let mut actions = match entry.exec() { + Some(exec) => vec![Action { + name: "main".to_string(), + exec: Method::new(fix_exec(exec), terminal), + comment: None, + }], + None => vec![], + }; + actions.extend( + entry + .actions() + .unwrap_or("") + .split(";") + .into_iter() + .filter(|a| a.len() > 0) + .filter_map(|a| { + let name = entry.action_name(a, locale)?; + let exec = entry.action_exec(a)?; + Some(Action { + name: name.to_string(), + exec: Method::new(fix_exec(exec), terminal), + comment: None, + }) + }), + ); + Some(Entry { + name, + actions, + comment, + icon, + }) +} + +/// Assign XDG Icon based on Desktop-Entry +fn assign_icon(icon: String, map: &icons::IconMap) -> Option { + if !icon.contains("/") { + if let Some(icon) = map.get(&icon) { + if let Some(path) = icon.to_str() { + return Some(path.to_owned()); + } + } + } + Some(icon) +} + +fn main() { + let locale = Some("en"); + let sizes = vec![64, 32, 96, 22, 128]; + + // collect icons + let cfg = config_dir(); + let spec = icons::IconSpec::standard(&cfg, sizes); + let icons = icons::collect_icons(spec); + + // collect applications + let app_paths = data_dirs("applications"); + let mut desktops: Vec = Iter::new(app_paths) + .into_iter() + .filter_map(|f| parse_desktop(&f, locale)) + .map(|mut e| { + e.icon = e.icon.and_then(|s| assign_icon(s, &icons)); + e + }) + .collect(); + + desktops.sort_by_cached_key(|e| e.name.to_owned()); + desktops + .into_iter() + .filter_map(|e| serde_json::to_string(&e).ok()) + .map(|s| println!("{}", s)) + .last(); +} diff --git a/rmenu/Cargo.toml b/rmenu/Cargo.toml index 9d12acc..940d06d 100644 --- a/rmenu/Cargo.toml +++ b/rmenu/Cargo.toml @@ -6,7 +6,6 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -base64 = "0.21.2" cached = "0.44.0" clap = { version = "4.3.15", features = ["derive"] } dioxus = "0.3.2" @@ -15,6 +14,7 @@ env_logger = "0.10.0" heck = "0.4.1" keyboard-types = "0.6.2" log = "0.4.19" +once_cell = "1.18.0" png = "0.17.9" quick-xml = "0.30.0" regex = { version = "1.9.1" } diff --git a/rmenu/src/image.rs b/rmenu/src/image.rs index d6b9c0c..ff87319 100644 --- a/rmenu/src/image.rs +++ b/rmenu/src/image.rs @@ -1,11 +1,17 @@ //! GUI Image Processing -use std::fs::read_to_string; +use std::fs::{create_dir_all, read_to_string, write}; +use std::io; +use std::path::PathBuf; +use std::sync::Mutex; -use base64::{engine::general_purpose, Engine as _}; use cached::proc_macro::cached; +use once_cell::sync::Lazy; use resvg::usvg::TreeParsing; use thiserror::Error; +static TEMP_EXISTS: Lazy>> = Lazy::new(|| Mutex::new(vec![])); +static TEMP_DIR: Lazy = Lazy::new(|| PathBuf::from("/tmp/rmenu")); + #[derive(Debug, Error)] enum SvgError { #[error("Invalid SVG Filepath")] @@ -13,12 +19,23 @@ enum SvgError { #[error("Invalid Document")] InvalidTree(#[from] resvg::usvg::Error), #[error("Failed to Alloc PixBuf")] - NoPixBuf, + NoPixBuf(u32, u32, u32), #[error("Failed to Convert SVG to PNG")] PngError(#[from] png::EncodingError), } -fn svg_to_png(path: &str, pixels: u32) -> Result { +/// Make Temporary Directory for Generated PNGs +fn make_temp() -> Result<(), io::Error> { + let mut temp = TEMP_EXISTS.lock().expect("Failed to Access Global Mutex"); + if temp.len() == 0 { + create_dir_all(TEMP_DIR.to_owned())?; + temp.push(true); + } + Ok(()) +} + +/// Convert SVG to PNG Image +fn svg_to_png(path: &str, dest: &PathBuf, pixels: u32) -> Result<(), SvgError> { // read and convert to resvg document tree let xml = read_to_string(path)?; let opt = resvg::usvg::Options::default(); @@ -26,22 +43,35 @@ fn svg_to_png(path: &str, pixels: u32) -> Result { let rtree = resvg::Tree::from_usvg(&tree); // generate pixel-buffer and scale according to size preference let size = rtree.size.to_int_size(); - let scale = pixels / size.width(); - let width = size.width() * scale; - let height = size.height() * scale; - let fscale = scale as f32; - let mut pixmap = - resvg::tiny_skia::Pixmap::new(width, height).ok_or_else(|| SvgError::NoPixBuf)?; - let form = resvg::tiny_skia::Transform::from_scale(fscale, fscale); + let scale = pixels as f32 / size.width() as f32; + let width = (size.width() as f32 * scale) as u32; + let height = (size.height() as f32 * scale) as u32; + let mut pixmap = resvg::tiny_skia::Pixmap::new(width, height) + .ok_or_else(|| SvgError::NoPixBuf(width, height, pixels))?; + let form = resvg::tiny_skia::Transform::from_scale(scale, scale); // render as png to memory rtree.render(form, &mut pixmap.as_mut()); - let mut png = pixmap.encode_png()?; + let png = pixmap.encode_png()?; // base64 encode png - let encoded = general_purpose::STANDARD.encode(&mut png); - Ok(format!("data:image/png;base64, {encoded}")) + Ok(write(dest, png)?) } #[cached] pub fn convert_svg(path: String) -> Option { - svg_to_png(&path, 64).ok() + // ensure temporary directory exists + let _ = make_temp(); + // convert path to new temporary png filepath + let (_, fname) = path.rsplit_once('/')?; + let (name, _) = fname.rsplit_once(".")?; + let name = format!("{name}.png"); + let new_path = TEMP_DIR.join(name); + // generate png if it doesnt already exist + if !new_path.exists() { + log::debug!("generating png {new_path:?}"); + match svg_to_png(&path, &new_path, 64) { + Err(err) => log::error!("failed svg->png: {err:?}"), + _ => {} + } + } + Some(new_path.to_str()?.to_string()) }