yofi 0.2.1

minimalistic menu for wayland
Documentation
use std::ffi::OsStr;
use std::fs::{self, DirEntry};
use std::path::{Path, PathBuf};

use freedesktop_icon_lookup::{Cache, LookupParam};
use once_cell::sync::OnceCell;
use xdg::BaseDirectories;

use crate::icon::Icon;

mod locale;

pub static XDG_DIRS: OnceCell<BaseDirectories> = OnceCell::new();

pub struct ExecEntry {
    pub name: String,
    pub exec: String,
    pub icon: Option<Icon>,
}

pub struct Entry {
    pub entry: ExecEntry,
    pub actions: Vec<ExecEntry>,
    pub desktop_fname: String,
    pub path: PathBuf,
    pub name: String,
    pub is_terminal: bool,
}

impl Entry {
    pub fn subname(&self, action: usize) -> Option<&str> {
        self.actions
            .get(action.checked_sub(1)?)
            .map(|a| a.name.as_ref())
    }

    pub fn icon(&self, action: usize) -> Option<&Icon> {
        if action == 0 {
            self.entry.icon.as_ref()
        } else {
            self.actions[action - 1]
                .icon
                .as_ref()
                .or(self.entry.icon.as_ref())
        }
    }
}

pub fn xdg_dirs<'a>() -> &'a BaseDirectories {
    XDG_DIRS.get_or_init(|| BaseDirectories::new().expect("failed to get xdg dirs"))
}

pub struct IconConfig {
    pub icon_size: u16,
    pub theme: Option<String>,
}

pub struct Traverser<F> {
    icon_config: Option<(IconConfig, Cache)>,
    filter: F,
}

impl<F> Traverser<F> {
    pub fn new(icon_config: Option<IconConfig>, filter: F) -> anyhow::Result<Self> {
        Ok(Self {
            icon_config: icon_config
                .map(|icon_config| -> anyhow::Result<_> {
                    let mut lookup = Cache::new()?;
                    if let Some(theme) = &icon_config.theme {
                        lookup.load(theme)
                    } else {
                        lookup.load_default()
                    }?;
                    Ok((icon_config, lookup))
                })
                .transpose()?,
            filter,
        })
    }

    fn find_icon(&self, name: &str) -> Option<Icon> {
        let (config, lookup) = self.icon_config.as_ref()?;

        let icon_path = Path::new(name);
        let path: Option<PathBuf>;

        let path = if icon_path.is_absolute() {
            Some(icon_path)
        } else {
            let lookup_icon = |name: &str, icon_size: u16| {
                lookup.lookup_param(
                    LookupParam::new(name)
                        .with_size(icon_size)
                        .with_theme(config.theme.as_deref()),
                )
            };

            path = lookup_icon(name, config.icon_size)
                .or_else(|| lookup_icon(name, config.icon_size + 8))
                .or_else(|| lookup_icon(name, config.icon_size + 16))
                .or_else(|| lookup_icon(name, 512))
                .or_else(|| lookup_icon(name, config.icon_size - 8))
                .or_else(|| {
                    let name = format!("{name}-symbolic");
                    lookup_icon(&name, config.icon_size)
                });

            path.as_deref()
        };

        path.map(Icon::new)
    }

    fn parse_entry(&self, dir_entry: &DirEntry, dir_entry_path: PathBuf) -> Option<Entry> {
        let entry = match fep::parse_entry(&dir_entry_path) {
            Ok(e) => e,
            Err(err) => {
                log::warn!("cannot parse {:?}: {}, skipping", dir_entry, err);
                return None;
            }
        };

        let main_section = entry.section("Desktop Entry");
        let locale = locale::Locale::current();

        if main_section.attr("NoDisplay") == Some("true") {
            log::trace!("Skipping NoDisplay entry {:?}", dir_entry);
            return None;
        }

        let localized_entry = |attr_name: &str| {
            locale
                .keys()
                .filter_map(|key| main_section.attr_with_param(attr_name, key))
                .next()
                .or_else(|| main_section.attr(attr_name))
        };

        match (localized_entry("Name"), main_section.attr("Exec")) {
            (Some(n), Some(e)) => {
                let filename = dir_entry_path.file_name().unwrap();
                let desktop_fname = if let Some(f) = filename.to_str() {
                    f.to_owned()
                } else {
                    log::error!("found non-UTF8 desktop file: {:?}, skipping", filename);
                    return None;
                };

                let actions = entry
                    .sections()
                    .filter_map(|s| {
                        if !s.name().starts_with("Desktop Action ") {
                            return None;
                        }
                        let name = s.attr("Name")?.to_owned();
                        let exec = s.attr("Exec")?.to_owned();
                        Some(ExecEntry {
                            name,
                            exec,
                            icon: localized_entry("Icon").and_then(|name| self.find_icon(name)),
                        })
                    })
                    .collect::<Vec<_>>();

                let entry = ExecEntry {
                    name: n.to_owned(),
                    exec: e.to_owned(),
                    icon: localized_entry("Icon").and_then(|name| self.find_icon(name)),
                };

                return Some(Entry {
                    entry,
                    actions,
                    desktop_fname,
                    path: dir_entry_path,
                    name: n.to_owned(),
                    is_terminal: main_section
                        .attr("Terminal")
                        .map(|s| s == "true")
                        .unwrap_or(false),
                });
            }
            (n, e) => {
                if n.is_none() && e.is_none() {
                    log::debug!(
                        r#"entry {:?} has no "Name" nor "Exec" attribute"#,
                        dir_entry_path
                    );
                } else if n.is_none() {
                    log::debug!(r#"entry {:?} has no "Name" attribute"#, dir_entry_path);
                } else if e.is_none() {
                    log::debug!(r#"entry {:?} has no "Exec" attribute"#, dir_entry_path);
                }
                None
            }
        }
    }
}

impl<F> Traverser<F>
where
    F: Fn(&OsStr) -> bool,
{
    pub fn find_entries(&self) -> Vec<Entry> {
        let xdg_dirs = xdg_dirs();

        let dirs = std::iter::once(xdg_dirs.get_data_home());
        let dirs = dirs.chain(xdg_dirs.get_data_dirs());
        let mut entries = self.traverse_dirs(dirs);
        entries.sort_by(|x, y| x.entry.name.cmp(&y.entry.name));
        entries.dedup_by(|x, y| x.entry.name == y.entry.name);
        entries
    }

    fn traverse_dirs(&self, paths: impl IntoIterator<Item = PathBuf>) -> Vec<Entry> {
        let mut entries = vec![];
        for path in paths.into_iter() {
            let apps_dir = path.join("applications");
            if !apps_dir.exists() {
                continue;
            }

            for dir_entry in read_dir(&apps_dir).filter(|e| (self.filter)(&e.file_name())) {
                self.traverse_dir_entry(&mut entries, dir_entry);
            }
        }
        entries
    }

    fn traverse_dir_entry(&self, entries: &mut Vec<Entry>, dir_entry: DirEntry) {
        let dir_entry_path = dir_entry.path();

        if dir_entry_path.extension().and_then(|s| s.to_str()) != Some("desktop") {
            return;
        }

        match dir_entry.file_type() {
            Err(err) => log::warn!("failed to get `{:?}` file type: {}", dir_entry_path, err),
            Ok(tp) if tp.is_dir() => {
                for dir_entry in read_dir(&dir_entry_path).filter(|e| (self.filter)(&e.file_name()))
                {
                    self.traverse_dir_entry(entries, dir_entry);
                }

                return;
            }
            _ => {}
        }

        if let Some(entry) = self.parse_entry(&dir_entry, dir_entry_path) {
            entries.push(entry);
        }
    }
}

fn read_dir(path: &Path) -> impl Iterator<Item = DirEntry> {
    fs::read_dir(path)
        .map_err(|e| log::debug!("cannot read {:?} folder: {}, skipping", path, e))
        .into_iter()
        .flatten()
        .filter_map(|e| {
            if let Err(err) = &e {
                log::warn!("failed to read file: {}", err);
            }

            e.ok()
        })
}