diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 6eb63d0..1932245 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -15,12 +15,14 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - name: Format - run: cargo fmt --check - - name: Clippy - run: cargo clippy - - name: Build - run: cargo build --verbose - - name: Run tests - run: cargo test --verbose + - uses: actions/checkout@v4 + - uses: actions-rust-lang/setup-rust-toolchain@v1 + + - name: Formatting + run: cargo fmt --all -- --check + - name: Clippy warnings + run: cargo clippy -- -D warnings + - name: Build + run: cargo build --verbose + - name: Run tests + run: cargo test -- --show-output diff --git a/Cargo.lock b/Cargo.lock index b1c3533..8cef9e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -277,12 +277,6 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" -[[package]] -name = "configparser" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe1d7dcda7d1da79e444bdfba1465f2f849a58b07774e1df473ee77030cb47a7" - [[package]] name = "crossbeam" version = "0.8.4" @@ -912,15 +906,6 @@ dependencies = [ "hashbrown 0.15.2", ] -[[package]] -name = "ini" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a9271a5dfd4228fa56a78d7508a35c321639cc71f783bb7a5723552add87bce" -dependencies = [ - "configparser", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -1686,7 +1671,6 @@ dependencies = [ "gtk4-layer-shell", "home", "hyprland", - "ini", "libc", "log", "regex", diff --git a/Cargo.toml b/Cargo.toml index b3486b4..87f2c10 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,24 @@ name = "worf" version = "0.1.0" edition = "2024" +[lints.clippy] +# enable pedantic +pedantic = { level = "warn", priority = -1 } +## exclude some too pedantic lints for now +similar_names = "allow" + +# additional lints +clone_on_ref_ptr = "warn" + +[lib] +name = "worf_lib" +path = "src/lib/mod.rs" + +[[bin]] +name = "worf" +path = "src/main.rs" + + [dependencies] gtk4 = { version = "0.9.5", default-features = true, features = ["v4_6"] } gtk4-layer-shell = "0.5.0" @@ -13,7 +31,6 @@ home = "0.5.11" log = "0.4.27" regex = "1.11.1" hyprland = "0.4.0-beta.2" -ini = "1.3.0" clap = { version = "4.5.35", features = ["derive"] } thiserror = "2.0.12" serde = { version = "1.0.219", features = ["derive"] } @@ -21,6 +38,6 @@ toml = "0.8.20" serde_json = "1.0.140" crossbeam = "0.8.4" libc = "0.2.171" -freedesktop-file-parser = "0.1.0" +freedesktop-file-parser = "0.1.3" strsim = "0.11.1" dirs = "6.0.0" diff --git a/src/lib/config.rs b/src/lib/config.rs index 805d414..7c903a2 100644 --- a/src/lib/config.rs +++ b/src/lib/config.rs @@ -1,15 +1,11 @@ -use crate::lib::system; -use anyhow::{anyhow, Context}; -use clap::builder::TypedValueParser; -use clap::{Parser, ValueEnum}; -use gtk4::prelude::ToValue; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::collections::HashMap; -use std::env::Args; use std::path::PathBuf; use std::str::FromStr; use std::{env, fs}; + +use anyhow::anyhow; +use clap::{Parser, ValueEnum}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; use thiserror::Error; #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug, Serialize, Deserialize)] @@ -34,10 +30,10 @@ pub enum Align { #[derive(Clone, Debug, Serialize, Deserialize)] pub enum Mode { - /// searches $PATH for executables and allows them to be run by selecting them. + /// searches `$PATH` for executables and allows them to be run by selecting them. Run, - /// searches $XDG_DATA_HOME/applications and $XDG_DATA_DIRS/applications f - /// or desktop files and allows them to be run by selecting them. + /// searches `$XDG_DATA_HOME/applications` and `$XDG_DATA_DIRS/applications` + /// for desktop files and allows them to be run by selecting them. Drun, /// reads from stdin and displays options which when selected will be output to stdout. @@ -85,8 +81,8 @@ pub struct Config { pub version: Option, /// Defines the style sheet to be loaded. - /// Defaults to $XDG_CONF_DIR/worf/style.css - /// or $HOME/.config/worf/style.css if XDG_CONF_DIR is not set. + /// Defaults to `$XDG_CONF_DIR/worf/style.css` + /// or `$HOME/.config/worf/style.css` if `$XDG_CONF_DIR` is not set. #[serde(default = "default_style")] #[clap(long = "style")] pub style: Option, @@ -252,8 +248,6 @@ pub struct Config { #[serde(default = "default_text_wrap_length")] #[clap(long = "text-wrap-length")] pub text_wrap_length: Option, - - } impl Default for Config { @@ -327,28 +321,45 @@ impl Default for Config { } } } - -fn default_row_box_orientation() -> Option { +// allowed because option is needed for serde macro +#[allow(clippy::unnecessary_wraps)] +#[must_use] +pub fn default_row_box_orientation() -> Option { Some(Orientation::Horizontal) } -pub(crate) fn default_orientation() -> Option { +// allowed because option is needed for serde macro +#[allow(clippy::unnecessary_wraps)] +#[must_use] +pub fn default_orientation() -> Option { Some(Orientation::Vertical) } -fn default_halign() -> Option { +// allowed because option is needed for serde macro +#[allow(clippy::unnecessary_wraps)] +#[must_use] +pub fn default_halign() -> Option { Some(Align::Fill) } -fn default_content_halign() -> Option { +// allowed because option is needed for serde macro +#[allow(clippy::unnecessary_wraps)] +#[must_use] +pub fn default_content_halign() -> Option { Some(Align::Fill) } -fn default_columns() -> Option { +// allowed because option is needed for serde macro +#[allow(clippy::unnecessary_wraps)] +#[must_use] +pub fn default_columns() -> Option { Some(1) } -fn default_normal_window() -> bool { +// allowed because option is needed for serde macro +#[allow(clippy::unnecessary_wraps)] +#[must_use] +pub fn default_normal_window() -> bool { false } @@ -429,77 +440,112 @@ fn default_normal_window() -> bool { // key_default = "Ctrl-c"; // char* key_copy = (i == 0) ? key_default : config_get(config, "key_copy", key_default); -fn default_style() -> Option { +// allowed because option is needed for serde macro +#[allow(clippy::unnecessary_wraps)] +#[must_use] +pub fn default_style() -> Option { style_path(None) .ok() - .and_then(|pb| Some(pb.display().to_string())) + .map(|pb| pb.display().to_string()) .or_else(|| { log::error!("no stylesheet found, using system styles"); None }) } +// allowed because option is needed for serde macro +#[allow(clippy::unnecessary_wraps)] +#[must_use] pub fn default_height() -> Option { Some("40%".to_owned()) } +// allowed because option is needed for serde macro +#[allow(clippy::unnecessary_wraps)] +#[must_use] pub fn default_width() -> Option { Some("50%".to_owned()) } +// allowed because option is needed for serde macro +#[allow(clippy::unnecessary_wraps)] +#[must_use] pub fn default_password_char() -> Option { Some("*".to_owned()) } +// allowed because option is needed for serde macro +#[allow(clippy::unnecessary_wraps)] +#[must_use] pub fn default_fuzzy_min_length() -> Option { Some(10) } +// allowed because option is needed for serde macro +#[allow(clippy::unnecessary_wraps)] +#[must_use] pub fn default_fuzzy_min_score() -> Option { Some(0.1) } +// allowed because option is needed for serde macro +#[allow(clippy::unnecessary_wraps)] +#[must_use] pub fn default_match_method() -> Option { Some(MatchMethod::Contains) } +// allowed because option is needed for serde macro +#[allow(clippy::unnecessary_wraps)] +#[must_use] pub fn default_image_size() -> Option { Some(32) } +// allowed because option is needed for serde macro +#[allow(clippy::unnecessary_wraps)] +#[must_use] pub fn default_text_wrap_length() -> Option { Some(15) } +// allowed because option is needed for serde macro +#[allow(clippy::unnecessary_wraps)] +#[must_use] pub fn default_text_wrap() -> Option { Some(false) } +#[must_use] pub fn parse_args() -> Config { Config::parse() } - +/// # Errors +/// +/// Will return Err when it cannot resolve any path or no style is found pub fn style_path(full_path: Option) -> Result { - let alternative_paths = path_alternatives(vec![dirs::config_dir()], PathBuf::from("worf").join("style.css")); - resolve_path( - full_path, - alternative_paths - .into_iter() - .collect(), - ) + let alternative_paths = path_alternatives( + vec![dirs::config_dir()], + &PathBuf::from("worf").join("style.css"), + ); + resolve_path(full_path, alternative_paths.into_iter().collect()) } -pub fn path_alternatives(base_paths: Vec>, sub_path: PathBuf) -> Vec { +#[must_use] +pub fn path_alternatives(base_paths: Vec>, sub_path: &PathBuf) -> Vec { base_paths .into_iter() - .filter_map(|s| s) - .map(|pb| pb.join(&sub_path)) + .flatten() + .map(|pb| pb.join(sub_path)) .filter_map(|pb| pb.canonicalize().ok()) .filter(|c| c.exists()) .collect() } +/// # Errors +/// +/// Will return `Err` if it is not able to find any valid path pub fn resolve_path( full_path: Option, alternatives: Vec, @@ -513,16 +559,21 @@ pub fn resolve_path( .filter(|p| p.exists()) .find_map(|pb| pb.canonicalize().ok().filter(|c| c.exists())) }) - .ok_or_else(|| anyhow!("Could not find a valid config file.")) + .ok_or_else(|| anyhow!("Could not find a valid file.")) } +/// # Errors +/// +/// Will return Err when it +/// * cannot read the config file +/// * cannot parse the config file +/// * no config file exists +/// * config file and args cannot be merged pub fn load_config(args_opt: Option) -> Result { let home_dir = env::var("HOME")?; let config_path = args_opt.as_ref().map(|c| { - c.config - .as_ref() - .and_then(|p| Some(PathBuf::from(p))) - .unwrap_or_else(|| { + c.config.as_ref().map_or_else( + || { env::var("XDG_CONF_HOME") .map_or( PathBuf::from(home_dir.clone()).join(".config"), @@ -530,7 +581,9 @@ pub fn load_config(args_opt: Option) -> Result { ) .join("worf") .join("config") - }) + }, + PathBuf::from, + ) }); match config_path { @@ -549,6 +602,9 @@ pub fn load_config(args_opt: Option) -> Result { } } +/// # Errors +/// +/// Will return Err when it fails to merge the config with the arguments. pub fn merge_config_with_args(config: &mut Config, args: &Config) -> anyhow::Result { let args_json = serde_json::to_value(args)?; let mut config_json = serde_json::to_value(config)?; diff --git a/src/lib/desktop.rs b/src/lib/desktop.rs index dae2a79..62b947b 100644 --- a/src/lib/desktop.rs +++ b/src/lib/desktop.rs @@ -1,8 +1,8 @@ +use anyhow::anyhow; use freedesktop_file_parser::DesktopFile; use gtk4::prelude::*; use gtk4::{IconLookupFlags, IconTheme, TextDirection}; use home::home_dir; -use ini::configparser::ini::Ini; use log::{debug, info, warn}; use regex::Regex; use std::collections::HashMap; @@ -14,13 +14,21 @@ pub struct IconResolver { cache: HashMap, } +impl Default for IconResolver { + #[must_use] + fn default() -> IconResolver { + Self::new() + } +} + impl IconResolver { - #![allow(clippy::single_call_fn)] + #[must_use] pub fn new() -> IconResolver { IconResolver { cache: HashMap::new(), } } + pub fn icon_path(&mut self, icon_name: &str) -> String { if let Some(icon_path) = self.cache.get(icon_name) { info!("Fetching {icon_name} from cache"); @@ -28,47 +36,55 @@ impl IconResolver { } info!("Loading icon for {icon_name}"); - let icon = fetch_icon_from_theme(icon_name) - .or_else(|| fetch_icon_from_common_dirs(icon_name)) - .or_else(|| fetch_icon_from_desktop_file(icon_name)) - .unwrap_or_else(|| { + .or_else(|_| { + fetch_icon_from_common_dirs(icon_name).map_or_else( + || Err(anyhow::anyhow!("Missing file")), // Return an error here + Ok, + ) + }) + .or_else(|_| { warn!("Missing icon for {icon_name}, using fallback"); default_icon() }); - self.cache.insert(icon_name.to_owned(), icon.clone()); - self.cache.get(icon_name).unwrap().to_owned() + self.cache + .entry(icon_name.to_owned()) + .or_insert_with(|| icon.unwrap_or_default()) + .to_owned() } } -pub fn default_icon() -> String { - fetch_icon_from_theme("image-missing").unwrap() +/// # Errors +/// +/// Will return `Err` if no icon can be found +pub fn default_icon() -> anyhow::Result { + fetch_icon_from_theme("image-missing") } -fn fetch_icon_from_desktop_file(icon_name: &str) -> Option { - // find_desktop_files().into_iter().find_map(|desktop_file| { - // desktop_file - // .get("Desktop Entry") - // .filter(|desktop_entry| { - // desktop_entry - // .get("Exec") - // .and_then(|opt| opt.as_ref()) - // .is_some_and(|exec| exec.to_lowercase().contains(icon_name)) - // }) - // .map(|desktop_entry| { - // desktop_entry - // .get("Icon") - // .and_then(|opt| opt.as_ref()) - // .map(ToOwned::to_owned) - // .unwrap_or_default() - // }) - // }) - //todo - None -} +// fn fetch_icon_from_desktop_file(icon_name: &str) -> Option { +// // find_desktop_files().into_iter().find_map(|desktop_file| { +// // desktop_file +// // .get("Desktop Entry") +// // .filter(|desktop_entry| { +// // desktop_entry +// // .get("Exec") +// // .and_then(|opt| opt.as_ref()) +// // .is_some_and(|exec| exec.to_lowercase().contains(icon_name)) +// // }) +// // .map(|desktop_entry| { +// // desktop_entry +// // .get("Icon") +// // .and_then(|opt| opt.as_ref()) +// // .map(ToOwned::to_owned) +// // .unwrap_or_default() +// // }) +// // }) +// //todo +// None +// } -fn fetch_icon_from_theme(icon_name: &str) -> Option { +fn fetch_icon_from_theme(icon_name: &str) -> anyhow::Result { let display = gtk4::gdk::Display::default(); if display.is_none() { log::error!("Failed to get display"); @@ -85,9 +101,14 @@ fn fetch_icon_from_theme(icon_name: &str) -> Option { IconLookupFlags::empty(), ); - icon.file() + match icon + .file() .and_then(|file| file.path()) .and_then(|path| path.to_str().map(string::ToString::to_string)) + { + None => Err(anyhow!("Cannot find file")), + Some(i) => Ok(i), + } } fn fetch_icon_from_common_dirs(icon_name: &str) -> Option { @@ -125,15 +146,17 @@ fn find_file_case_insensitive(folder: &Path, file_name: &Regex) -> Option Vec { +/// # Errors +/// +/// Will return Err when it cannot parse the internal regex +pub fn find_desktop_files() -> anyhow::Result> { let mut paths = vec![ PathBuf::from("/usr/share/applications"), PathBuf::from("/usr/local/share/applications"), @@ -144,24 +167,33 @@ pub(crate) fn find_desktop_files() -> Vec { paths.push(home.join(".local/share/applications")); } + if let Ok(xdg_data_home) = env::var("XDG_DATA_HOME") { + paths.push(PathBuf::from(xdg_data_home).join(".applications")); + } + + if let Ok(xdg_data_dir) = env::var("XDG_DATA_DIRS") { + paths.push(PathBuf::from(xdg_data_dir).join(".applications")); + } + + let regex = &Regex::new("(?i).*\\.desktop$")?; + let p: Vec<_> = paths .into_iter() - .filter(|icon_dir| icon_dir.exists()) - .filter_map(|icon_dir| { - find_file_case_insensitive(&icon_dir, &Regex::new("(?i).*\\.desktop$").unwrap()) - }) + .filter(|desktop_dir| desktop_dir.exists()) + .filter_map(|icon_dir| find_file_case_insensitive(&icon_dir, regex)) .flat_map(|desktop_files| { desktop_files.into_iter().filter_map(|desktop_file| { - debug!("loading desktop file {:?}", desktop_file); + debug!("loading desktop file {desktop_file:?}"); fs::read_to_string(desktop_file) .ok() .and_then(|content| freedesktop_file_parser::parse(&content).ok()) }) }) .collect(); - p + Ok(p) } +#[must_use] pub fn get_locale_variants() -> Vec { let locale = env::var("LC_ALL") .or_else(|_| env::var("LC_MESSAGES")) @@ -172,7 +204,7 @@ pub fn get_locale_variants() -> Vec { let mut variants = vec![]; if let Some((lang_part, region)) = lang.split_once('_') { - variants.push(format!("{}_{region}", lang_part)); // en_us + variants.push(format!("{lang_part}_{region}")); // en_us variants.push(lang_part.to_string()); // en } else { variants.push(lang.clone()); // e.g. "fr" @@ -181,52 +213,17 @@ pub fn get_locale_variants() -> Vec { variants } -pub fn extract_desktop_fields( - category: &str, - //keys: Vec, - desktop_map: &HashMap>>, -) -> HashMap { - let mut result: HashMap = HashMap::new(); - let category_map = desktop_map.get(category); - if category_map.is_none() { - debug!("No desktop map for category {category}, map data: {desktop_map:?}"); - return result; - } - - let keys_needed = ["name", "exec", "icon"]; - let locale_variants = get_locale_variants(); - - for (map_key, map_value) in category_map.unwrap() { - for key in keys_needed { - if result.contains_key(key) || map_value.is_none() { - continue; - } - - let (k, v) = locale_variants - .iter() - .find(|locale| { - let localized_key = format!("{}[{}]", key, locale); - key == localized_key - }) - .map(|_| (Some(key), map_value)) - .unwrap_or_else(|| { - if key == map_key { - (Some(key), map_value) - } else { - (None, &None) - } - }); - if let Some(k) = k { - if let Some(v) = v { - result.insert(k.to_owned(), v.clone()); - } - } - } - - if result.len() == keys_needed.len() { - break; - } - } - - result +// implicit hasher does not make sense here, it is only for desktop files +#[allow(clippy::implicit_hasher)] +#[must_use] +pub fn lookup_name_with_locale( + locale_variants: &[String], + variants: &HashMap, + fallback: &str, +) -> Option { + locale_variants + .iter() + .find_map(|local| variants.get(local)) + .map(std::borrow::ToOwned::to_owned) + .or_else(|| Some(fallback.to_owned())) } diff --git a/src/lib/gui.rs b/src/lib/gui.rs index d9d6bcc..e48b119 100644 --- a/src/lib/gui.rs +++ b/src/lib/gui.rs @@ -1,46 +1,43 @@ -use crate::lib::config; -use crate::lib::config::{Config, MatchMethod}; -use anyhow::{Context, anyhow}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use anyhow::anyhow; use crossbeam::channel; use crossbeam::channel::Sender; -use gdk4::gio::{File, Menu}; -use gdk4::glib::{GString, Propagation, Unichar}; -use gdk4::prelude::{Cast, DisplayExt, ListModelExtManual, MonitorExt}; -use gdk4::{pango, Display, Key}; +use gdk4::gio::File; +use gdk4::glib::Propagation; +use gdk4::prelude::{Cast, DisplayExt, MonitorExt}; +use gdk4::{Display, Key}; use gtk4::prelude::{ - ApplicationExt, ApplicationExtManual, BoxExt, ButtonExt, EditableExt, EntryExt, FileChooserExt, - FlowBoxChildExt, GestureSingleExt, GtkWindowExt, ListBoxRowExt, NativeExt, OrientableExt, - WidgetExt, + ApplicationExt, ApplicationExtManual, BoxExt, EditableExt, FlowBoxChildExt, GestureSingleExt, + GtkWindowExt, ListBoxRowExt, NativeExt, WidgetExt, }; use gtk4::{ - Align, Entry, EventControllerKey, Expander, FlowBox, FlowBoxChild, GestureClick, Image, Label, - ListBox, ListBoxRow, Ordering, PolicyType, Revealer, ScrolledWindow, SearchEntry, Widget, gdk, + Align, EventControllerKey, Expander, FlowBox, FlowBoxChild, GestureClick, Image, Label, + ListBox, ListBoxRow, Ordering, PolicyType, ScrolledWindow, SearchEntry, Widget, gdk, }; use gtk4::{Application, ApplicationWindow, CssProvider, Orientation}; use gtk4_layer_shell::{KeyboardMode, LayerShell}; -use hyprland::ctl::output::create; -use hyprland::ctl::plugin::list; -use std::collections::HashMap; +use log; -use log::{debug, error, info}; -use std::process::exit; -use std::sync::{Arc, Mutex, MutexGuard}; +use crate::config; +use crate::config::{Config, MatchMethod}; type ArcMenuMap = Arc>>>; type MenuItemSender = Sender, anyhow::Error>>; -impl Into for config::Orientation { - fn into(self) -> Orientation { - match self { +impl From for Orientation { + fn from(orientation: config::Orientation) -> Self { + match orientation { config::Orientation::Vertical => Orientation::Vertical, config::Orientation::Horizontal => Orientation::Horizontal, } } } -impl Into for config::Align { - fn into(self) -> Align { - match self { +impl From for Align { + fn from(align: config::Align) -> Self { + match align { config::Align::Fill => Align::Fill, config::Align::Start => Align::Start, config::Align::Center => Align::Center, @@ -62,39 +59,46 @@ pub struct MenuItem { pub data: Option, } -pub fn show(config: Config, elements: Vec>) -> Result, anyhow::Error> where T: Clone + 'static { +/// # Errors +/// +/// Will return Err when the channel between the UI and this is broken +pub fn show(config: Config, elements: Vec>) -> Result, anyhow::Error> +where + T: Clone + 'static, +{ if let Some(ref css) = config.style { let provider = CssProvider::new(); let css_file_path = File::for_path(css); provider.load_from_file(&css_file_path); - let display = Display::default().expect("Could not connect to a display"); - gtk4::style_context_add_provider_for_display( - &display, - &provider, - gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, - ); + if let Some(display) = Display::default() { + gtk4::style_context_add_provider_for_display( + &display, + &provider, + gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + } } let app = Application::builder().application_id("worf").build(); let (sender, receiver) = channel::bounded(1); app.connect_activate(move |app| { - build_ui(&config, &elements, sender.clone(), app); + build_ui(&config, &elements, &sender, app); }); let gtk_args: [&str; 0] = []; app.run_with_args(>k_args); - let selection = receiver.recv()?; - selection + receiver.recv()? } fn build_ui( config: &Config, elements: &Vec>, - sender: Sender, anyhow::Error>>, + sender: &Sender, anyhow::Error>>, app: &Application, -) where T: Clone + 'static { - // Create a toplevel undecorated window +) where + T: Clone + 'static, +{ let window = ApplicationWindow::builder() .application(app) .decorated(false) @@ -130,8 +134,7 @@ fn build_ui( scroll.set_hexpand(true); scroll.set_vexpand(true); - let hide_scroll = false; // todo - if hide_scroll { + if config.hide_scroll.is_some_and(|hs| hs) { scroll.set_policy(PolicyType::External, PolicyType::External); } @@ -148,37 +151,28 @@ fn build_ui( if let Some(valign) = config.valign { inner_box.set_valign(valign.into()); + } else if config.orientation.unwrap() == config::Orientation::Horizontal { + inner_box.set_valign(Align::Center); } else { - if config.orientation.unwrap() == config::Orientation::Horizontal { - inner_box.set_valign(Align::Center); - } else { - inner_box.set_valign(Align::Start); - } + inner_box.set_valign(Align::Start); } inner_box.set_selection_mode(gtk4::SelectionMode::Browse); inner_box.set_max_children_per_line(config.columns.unwrap()); inner_box.set_activate_on_single_click(true); - let mut list_items: ArcMenuMap = Arc::new(Mutex::new(HashMap::new())); + let list_items: ArcMenuMap = Arc::new(Mutex::new(HashMap::new())); for entry in elements { list_items .lock() .unwrap() // panic here ok? deadlock? .insert( - add_menu_item( - &inner_box, - &entry, - &config, - sender.clone(), - list_items.clone(), - app.clone(), - ), + add_menu_item(&inner_box, entry, config, sender, &list_items, app), entry.clone(), ); } - let items_clone = list_items.clone(); + let items_clone = Arc::>>>::clone(&list_items); inner_box.set_sort_func(move |child1, child2| sort_menu_items(child1, child2, &items_clone)); // Set focus after everything is realized @@ -197,29 +191,28 @@ fn build_ui( inner_box, app.clone(), sender.clone(), - list_items.clone(), + Arc::>>>::clone(&list_items), config.clone(), ); window.show(); let display = window.display(); - window.surface().map(|surface| { + if let Some(surface) = window.surface() { // todo this does not work for multi monitor systems let monitor = display.monitor_at_surface(&surface); if let Some(monitor) = monitor { let geometry = monitor.geometry(); config.width.as_ref().map(|width| { - percent_or_absolute(&width, geometry.width()).map(|w| window.set_width_request(w)) + percent_or_absolute(width, geometry.width()).map(|w| window.set_width_request(w)) }); config.height.as_ref().map(|height| { - percent_or_absolute(&height, geometry.height()) - .map(|h| window.set_height_request(h)) + percent_or_absolute(height, geometry.height()).map(|h| window.set_height_request(h)) }); } else { log::error!("failed to get monitor to init window size"); } - }); + } } fn setup_key_event_handler( @@ -287,12 +280,10 @@ fn sort_menu_items( } else { Ordering::Larger } + } else if menu1.initial_sort_score < menu2.initial_sort_score { + Ordering::Smaller } else { - if menu1.initial_sort_score < menu2.initial_sort_score { - Ordering::Smaller - } else { - Ordering::Larger - } + Ordering::Larger } } (Some(_), None) => Ordering::Larger, @@ -306,8 +297,11 @@ fn handle_selected_item( app: &Application, inner_box: &FlowBox, lock_arc: &ArcMenuMap, -) -> Result<(), String> where T: Clone { - for s in inner_box.selected_children() { +) -> Result<(), String> +where + T: Clone, +{ + if let Some(s) = inner_box.selected_children().into_iter().next() { let list_items = lock_arc.lock().unwrap(); let item = list_items.get(&s); if let Some(item) = item { @@ -325,19 +319,30 @@ fn add_menu_item( inner_box: &FlowBox, entry_element: &MenuItem, config: &Config, - sender: MenuItemSender, - lock_arc: ArcMenuMap, - app: Application, + sender: &MenuItemSender, + lock_arc: &ArcMenuMap, + app: &Application, ) -> FlowBoxChild { - let parent: Widget = if !entry_element.sub_elements.is_empty() { + let parent: Widget = if entry_element.sub_elements.is_empty() { + create_menu_row( + entry_element, + config, + Arc::>>>::clone(lock_arc), + sender.clone(), + app.clone(), + inner_box.clone(), + ) + .upcast() + } else { let expander = Expander::new(None); expander.set_widget_name("expander-box"); expander.set_hexpand(true); + // todo deduplicate this snippet let menu_row = create_menu_row( entry_element, config, - lock_arc.clone(), + Arc::>>>::clone(lock_arc), sender.clone(), app.clone(), inner_box.clone(), @@ -352,7 +357,7 @@ fn add_menu_item( let sub_row = create_menu_row( sub_item, config, - lock_arc.clone(), + Arc::>>>::clone(lock_arc), sender.clone(), app.clone(), inner_box.clone(), @@ -365,16 +370,6 @@ fn add_menu_item( expander.set_child(Some(&list_box)); expander.upcast() - } else { - create_menu_row( - entry_element, - config, - lock_arc.clone(), - sender.clone(), - app.clone(), - inner_box.clone(), - ) - .upcast() }; parent.set_halign(Align::Fill); @@ -435,7 +430,7 @@ fn create_menu_row( } // todo make max length configurable - let text = if config.text_wrap.is_some_and(|x| x == true) { + let text = if config.text_wrap.is_some_and(|x| x) { &wrap_text(&menu_item.label, config.text_wrap_length) } else { menu_item.label.as_str() @@ -462,9 +457,10 @@ fn filter_widgets( inner_box: &FlowBox, ) { if items.is_empty() { - items.iter().for_each(|(child, _)| { + for (child, _) in items.iter() { child.set_visible(true); - }); + } + if let Some(child) = inner_box.first_child() { child.grab_focus(); let fb = child.downcast::(); @@ -476,9 +472,8 @@ fn filter_widgets( } let query = query.to_owned().to_lowercase(); - let mut highest_score = -1.0; let mut fb: Option<&FlowBoxChild> = None; - items.iter_mut().for_each(|(flowbox_child, mut menu_item)| { + for (flowbox_child, menu_item) in items.iter_mut() { let menu_item_search = format!( "{} {}", menu_item @@ -519,12 +514,11 @@ fn filter_widgets( menu_item.search_sort_score = search_sort_score; if visible { - highest_score = search_sort_score; fb = Some(flowbox_child); } flowbox_child.set_visible(visible); - }); + } if let Some(top_item) = fb { inner_box.select_child(top_item); @@ -532,9 +526,12 @@ fn filter_widgets( } } -fn percent_or_absolute(value: &String, base_value: i32) -> Option { - if value.contains("%") { - let value = value.replace("%", "").trim().to_string(); +// allowed because truncating is fine, we do no need the precision +#[allow(clippy::cast_possible_truncation)] +#[allow(clippy::cast_precision_loss)] +fn percent_or_absolute(value: &str, base_value: i32) -> Option { + if value.contains('%') { + let value = value.replace('%', "").trim().to_string(); match value.parse::() { Ok(n) => Some(((n as f32 / 100.0) * base_value as f32) as i32), Err(_) => None, @@ -544,7 +541,9 @@ fn percent_or_absolute(value: &String, base_value: i32) -> Option { } } -pub fn initialize_sort_scores(items: &mut Vec>) { +// highly unlikely that we are dealing with > i64 items +#[allow(clippy::cast_possible_wrap)] +pub fn initialize_sort_scores(items: &mut [MenuItem]) { let mut regular_score = items.len() as i64; items.sort_by(|l, r| l.label.cmp(&r.label)); @@ -562,19 +561,17 @@ fn wrap_text(text: &str, line_length: Option) -> String { let len = line_length.unwrap_or(text.len()); for word in text.split_whitespace() { - if line.len() + word.len() + 1 > len { - if !line.is_empty() { - result.push_str(&line.trim_end()); - result.push('\n'); - line.clear(); - } + if line.len() + word.len() + 1 > len && !line.is_empty() { + result.push_str(line.trim_end()); + result.push('\n'); + line.clear(); } line.push_str(word); line.push(' '); } if !line.is_empty() { - result.push_str(&line.trim_end()); + result.push_str(line.trim_end()); } result diff --git a/src/lib/mod.rs b/src/lib/mod.rs index e02e7e3..6b1769e 100644 --- a/src/lib/mod.rs +++ b/src/lib/mod.rs @@ -1,5 +1,4 @@ pub mod config; pub mod desktop; pub mod gui; -pub mod system; pub mod mode; diff --git a/src/lib/mode.rs b/src/lib/mode.rs index c57b9ba..798f50e 100644 --- a/src/lib/mode.rs +++ b/src/lib/mode.rs @@ -1,8 +1,9 @@ -use crate::lib::config::Config; -use crate::lib::desktop::{default_icon, find_desktop_files, get_locale_variants}; -use crate::lib::gui; -use crate::lib::gui::MenuItem; -use crate::lookup_name_with_locale; +use crate::config::Config; +use crate::desktop::{ + default_icon, find_desktop_files, get_locale_variants, lookup_name_with_locale, +}; +use crate::gui; +use crate::gui::MenuItem; use anyhow::{Context, anyhow}; use freedesktop_file_parser::EntryType; use serde::{Deserialize, Serialize}; @@ -18,9 +19,12 @@ struct DRunCache { run_count: usize, } -pub fn d_run(mut config: Config) -> anyhow::Result<()> { +/// # Errors +/// +/// Will return `Err` if it was not able to spawn the process +pub fn d_run(config: &Config) -> anyhow::Result<()> { let locale_variants = get_locale_variants(); - let default_icon = default_icon(); + let default_icon = default_icon().unwrap_or_default(); let cache_path = dirs::cache_dir().map(|x| x.join("worf-drun")); let mut d_run_cache = { @@ -30,29 +34,26 @@ pub fn d_run(mut config: Config) -> anyhow::Result<()> { } } - load_cache_file(&cache_path).unwrap_or_default() + load_cache_file(cache_path.as_ref()).unwrap_or_default() }; let mut entries: Vec> = Vec::new(); - for file in find_desktop_files().iter().filter(|f| { - f.entry.hidden.map_or(true, |hidden| !hidden) - && f.entry.no_display.map_or(true, |no_display| !no_display) + for file in find_desktop_files().ok().iter().flatten().filter(|f| { + f.entry.hidden.is_none_or(|hidden| !hidden) + && f.entry.no_display.is_none_or(|no_display| !no_display) }) { let (action, working_dir) = match &file.entry.entry_type { EntryType::Application(app) => (app.exec.clone(), app.path.clone()), _ => (None, None), }; - let name = match lookup_name_with_locale( + let Some(name) = lookup_name_with_locale( &locale_variants, &file.entry.name.variants, &file.entry.name.default, - ) { - Some(name) => name, - None => { - log::debug!("Skipping desktop entry without name {file:?}"); - continue; - } + ) else { + log::debug!("Skipping desktop entry without name {file:?}"); + continue; }; let icon = file @@ -76,30 +77,31 @@ pub fn d_run(mut config: Config) -> anyhow::Result<()> { }; file.actions.iter().for_each(|(_, action)| { - let action_name = lookup_name_with_locale( + if let Some(action_name) = lookup_name_with_locale( &locale_variants, &action.name.variants, &action.name.default, - ); - let action_icon = action - .icon - .as_ref() - .map(|s| s.content.clone()) - .or(icon.as_ref().map(|s| s.clone())); + ) { + let action_icon = action + .icon + .as_ref() + .map(|s| s.content.clone()) + .or(icon.clone()); - log::debug!("sub, action_name={action_name:?}, action_icon={action_icon:?}"); + log::debug!("sub, action_name={action_name:?}, action_icon={action_icon:?}"); - let sub_entry = MenuItem { - label: action_name.unwrap().trim().to_owned(), - icon_path: action_icon, - action: action.exec.clone(), - sub_elements: Vec::default(), - working_dir: working_dir.clone(), - initial_sort_score: 0, // subitems are never sorted right now. - search_sort_score: 0.0, - data: None, - }; - entry.sub_elements.push(sub_entry); + let sub_entry = MenuItem { + label: action_name, + icon_path: action_icon, + action: action.exec.clone(), + sub_elements: Vec::default(), + working_dir: working_dir.clone(), + initial_sort_score: 0, // subitems are never sorted right now. + search_sort_score: 0.0, + data: None, + }; + entry.sub_elements.push(sub_entry); + } }); entries.push(entry); @@ -113,13 +115,13 @@ pub fn d_run(mut config: Config) -> anyhow::Result<()> { Ok(selected_item) => { if let Some(cache) = cache_path { *d_run_cache.entry(selected_item.label).or_insert(0) += 1; - if let Err(e) = save_cache_file(&cache, d_run_cache) { + if let Err(e) = save_cache_file(&cache, &d_run_cache) { log::warn!("cannot save drun cache {e:?}"); } } if let Some(action) = selected_item.action { - spawn_fork(&action, &selected_item.working_dir)? + spawn_fork(&action, selected_item.working_dir.as_ref())?; } } Err(e) => { @@ -130,16 +132,15 @@ pub fn d_run(mut config: Config) -> anyhow::Result<()> { Ok(()) } -fn save_cache_file(path: &PathBuf, data: HashMap) -> anyhow::Result<()> { +fn save_cache_file(path: &PathBuf, data: &HashMap) -> anyhow::Result<()> { // Convert the HashMap to TOML string let toml_string = toml::ser::to_string(&data).map_err(|e| anyhow::anyhow!(e))?; fs::write(path, toml_string).map_err(|e| anyhow::anyhow!(e)) } -fn load_cache_file(cache_path: &Option) -> anyhow::Result> { - let path = match cache_path { - Some(p) => p, - None => return Err(anyhow!("Cache is missing")), +fn load_cache_file(cache_path: Option<&PathBuf>) -> anyhow::Result> { + let Some(path) = cache_path else { + return Err(anyhow!("Cache is missing")); }; let toml_content = fs::read_to_string(path)?; @@ -151,7 +152,7 @@ fn load_cache_file(cache_path: &Option) -> anyhow::Result anyhow::Result<()> { let file = fs::OpenOptions::new() .write(true) .create_new(true) - .open(&path); + .open(path); match file { Ok(_) => Ok(()), @@ -172,7 +173,7 @@ fn create_file_if_not_exists(path: &PathBuf) -> anyhow::Result<()> { } } -fn spawn_fork(cmd: &str, working_dir: &Option) -> anyhow::Result<()> { +fn spawn_fork(cmd: &str, working_dir: Option<&String>) -> anyhow::Result<()> { // todo probably remove arguments? // todo support working dir // todo fix actions @@ -192,7 +193,7 @@ fn spawn_fork(cmd: &str, working_dir: &Option) -> anyhow::Result<()> { let args: Vec<_> = parts .iter() .skip(1) - .filter(|arg| !arg.starts_with("%")) + .filter(|arg| !arg.starts_with('%')) .collect(); unsafe { diff --git a/src/lib/system.rs b/src/lib/system.rs deleted file mode 100644 index 0d5e58e..0000000 --- a/src/lib/system.rs +++ /dev/null @@ -1,3 +0,0 @@ -use anyhow::anyhow; -use std::env; -use std::path::PathBuf; diff --git a/src/main.rs b/src/main.rs index fad1824..418f3f0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,33 +1,7 @@ -#![warn(clippy::pedantic)] -#![allow(clippy::implicit_return)] - -// todo resolve paths like ~/ - -use crate::lib::config::Config; -use crate::lib::desktop::{default_icon, find_desktop_files, get_locale_variants}; -use crate::lib::{config, gui, mode}; -use crate::lib::gui::MenuItem; -use anyhow::{Error, anyhow}; -use clap::Parser; -use freedesktop_file_parser::{DesktopAction, EntryType}; -use gdk4::prelude::Cast; -use gtk4::prelude::{ - ApplicationExt, ApplicationExtManual, BoxExt, ButtonExt, EditableExt, EntryExt, - FlowBoxChildExt, GtkWindowExt, ListBoxRowExt, NativeExt, ObjectExt, SurfaceExt, WidgetExt, -}; -use gtk4_layer_shell::LayerShell; -use log::{debug, info, warn}; -use std::collections::HashMap; -use std::ops::Deref; -use std::os::unix::process::CommandExt; -use std::path::PathBuf; -use std::process::{Command, Stdio}; -use std::sync::Arc; -use std::thread::sleep; -use std::{env, fs, time}; - -mod lib; +use std::env; +use anyhow::anyhow; +use worf_lib::{config, mode}; fn main() -> anyhow::Result<()> { gtk4::init()?; @@ -42,11 +16,15 @@ fn main() -> anyhow::Result<()> { if let Some(show) = &config.show { match show { - config::Mode::Run => {} - config::Mode::Drun => { - mode::d_run(config)?; + config::Mode::Run => { + todo!("run not implemented") + } + config::Mode::Drun => { + mode::d_run(&config)?; + } + config::Mode::Dmenu => { + todo!("dmenu not implemented") } - config::Mode::Dmenu => {} } Ok(()) @@ -55,20 +33,6 @@ fn main() -> anyhow::Result<()> { } } -fn lookup_name_with_locale( - locale_variants: &Vec, - variants: &HashMap, - fallback: &str, -) -> Option { - locale_variants - .iter() - .filter_map(|local| variants.get(local)) - .next() - .map(|name| name.to_owned()) - .or_else(|| Some(fallback.to_owned())) -} - - // // fn main() -> anyhow::Result<()> { // env_logger::Builder::new() diff --git a/src/mod.rs b/src/mod.rs deleted file mode 100644 index 6e10f4a..0000000 --- a/src/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod args;