elbey 0.8.0

A desktop app launcher for Linux
//! Elbey - a desktop app launcher
#![doc(html_logo_url = "https://github.com/kgilmer/elbey/blob/main/elbey.svg")]
mod app;
mod values;

use std::process::exit;
use std::sync::{Arc, Mutex};

use crate::values::*;
use anyhow::Context;
use app::{Elbey, ElbeyFlags};
use argh::FromArgs;
use elbey_cache::{delete_cache_dir, AppDescriptor, Cache};
use freedesktop_desktop_entry::{
    current_desktop, default_paths, get_languages_from_env, DesktopEntry, Iter,
};
use iced::theme::{Custom, Palette};
use iced::{Color, Font, Pixels, Theme};
use iced_layershell::application;
use iced_layershell::reexport::{Anchor, KeyboardInteractivity, Layer};
use iced_layershell::settings::{LayerShellSettings, Settings, StartMode};
use lazy_static::lazy_static;

lazy_static! {
    pub(crate) static ref CACHE: Arc<Mutex<Cache>> =
        Arc::new(Mutex::new(Cache::new(find_all_apps)));
}

#[derive(FromArgs)]
/// Desktop app launcher
struct EbleyArgs {
    /// height
    #[argh(option)]
    height: Option<u32>,

    /// width
    #[argh(option)]
    width: Option<u32>,

    /// theme name: CatppuccinFrappe,CatppuccinLatte,CatppuccinMacchiato,CatppuccinMocha,Dark,Dracula,Ferra,GruvboxDark,GruvboxLight,KanagawaDragon,KanagawaLotus,KanagawaWave,Light,Moonfly,Nightfly,Nord,Oxocarbon,TokyoNight,TokyoNightLight,TokyoNightStorm,AyuMirage
    #[argh(option)]
    theme: Option<String>,

    /// font size for the filter input
    #[argh(option)]
    filter_font_size: Option<u16>,

    /// font size for the entry list
    #[argh(option)]
    entries_font_size: Option<u16>,

    /// icon size
    #[argh(option)]
    icon_size: Option<u16>,

    /// hint string to display in the entry box (max 16 chars)
    #[argh(option)]
    hint: Option<String>,

    /// print the desktop application search paths and exit
    #[argh(switch)]
    list_search_paths: bool,

    /// clear the application cache before loading apps
    #[argh(switch)]
    reset_cache: bool,
}

fn parse_theme(name: &str) -> Option<Theme> {
    match name {
        "CatppuccinFrappe" => Some(Theme::CatppuccinFrappe),
        "CatppuccinLatte" => Some(Theme::CatppuccinLatte),
        "CatppuccinMacchiato" => Some(Theme::CatppuccinMacchiato),
        "CatppuccinMocha" => Some(Theme::CatppuccinMocha),
        "Dark" => Some(Theme::Dark),
        "Dracula" => Some(Theme::Dracula),
        "Ferra" => Some(Theme::Ferra),
        "GruvboxDark" => Some(Theme::GruvboxDark),
        "GruvboxLight" => Some(Theme::GruvboxLight),
        "KanagawaDragon" => Some(Theme::KanagawaDragon),
        "KanagawaLotus" => Some(Theme::KanagawaLotus),
        "KanagawaWave" => Some(Theme::KanagawaWave),
        "Light" => Some(Theme::Light),
        "Moonfly" => Some(Theme::Moonfly),
        "Nightfly" => Some(Theme::Nightfly),
        "Nord" => Some(Theme::Nord),
        "Oxocarbon" => Some(Theme::Oxocarbon),
        "TokyoNight" => Some(Theme::TokyoNight),
        "TokyoNightLight" => Some(Theme::TokyoNightLight),
        "TokyoNightStorm" => Some(Theme::TokyoNightStorm),
        "AyuMirage" => Some(Theme::Custom(Arc::new(Custom::new(
            "AyuMirage".to_string(),
            Palette {
                background: Color::from_rgb8(0x1F, 0x24, 0x30),
                text: Color::from_rgb8(0x63, 0x75, 0x99),
                primary: Color::from_rgb8(0x17, 0x1B, 0x24),
                success: Color::from_rgb8(0xD5, 0xFF, 0x80),
                warning: Color::from_rgb8(0xFF, 0xC1, 0x4E),
                danger: Color::from_rgb8(0x12, 0x15, 0x1C),
            },
        )))),
        _ => None,
    }
}

fn parse_hint(args: &EbleyArgs) -> String {
    if let Some(h) = &args.hint {
        if h.len() > 16 {
            eprintln!("hint string must be 16 characters or fewer");
            exit(1);
        }
        h.clone()
    } else {
        DEFAULT_HINT.to_string()
    }
}

/// Program entrypoint.  Just configures the app, window, and kicks off the iced runtime.
fn main() -> Result<(), iced_layershell::Error> {
    let args: EbleyArgs = argh::from_env();

    if args.list_search_paths {
        for path in default_paths() {
            println!("{}", path.display());
        }
        return Ok(());
    }

    if args.reset_cache {
        if let Err(err) = delete_cache_dir() {
            eprintln!("Failed to delete cache: {err}");
        }
        return Ok(());
    }

    let theme = args
        .theme
        .as_deref()
        .and_then(parse_theme)
        .unwrap_or(DEFAULT_THEME);

    let flags = ElbeyFlags {
        apps_loader: load_apps,
        app_launcher: launch_app,
        theme,
        icon_size: args.icon_size.unwrap_or(DEFAULT_ICON_SIZE),
        hint: parse_hint(&args),
        filter_font_size: args.filter_font_size.unwrap_or(DEFAULT_TEXT_SIZE),
        entries_font_size: args.entries_font_size.unwrap_or(DEFAULT_TEXT_SIZE),
    };

    let iced_settings = Settings {
        layer_settings: LayerShellSettings {
            size: Some((
                args.width.unwrap_or(DEFAULT_WINDOW_WIDTH),
                args.height.unwrap_or(DEFAULT_WINDOW_HEIGHT),
            )),
            exclusive_zone: DEFAULT_WINDOW_HEIGHT as i32,
            anchor: Anchor::all(),
            start_mode: StartMode::Active,
            layer: Layer::Overlay,
            margin: (0, 0, 0, 0),
            keyboard_interactivity: KeyboardInteractivity::Exclusive,
            events_transparent: false,
        },
        id: Some(PROGRAM_NAME.to_string()),
        fonts: vec![],
        default_font: Font::DEFAULT,
        default_text_size: Pixels::from(u32::from(
            args.filter_font_size.unwrap_or(DEFAULT_TEXT_SIZE),
        )),
        antialiasing: true,
        ..Settings::default()
    };

    let flags_for_boot = flags.clone();

    application(
        move || Elbey::new(flags_for_boot.clone()),
        Elbey::namespace,
        Elbey::update,
        Elbey::view,
    )
    .subscription(Elbey::subscription)
    .theme(|state: &Elbey| Some(state.theme()))
    .settings(iced_settings)
    .run()
}

/// Launch an app described by `entry`.  This implementation exits the process upon successful launch.
fn launch_app(entry: &AppDescriptor) -> anyhow::Result<()> {
    let exec = entry.exec.as_deref().context("Missing exec command")?;
    let args = shell_words::split(exec)?;
    let args = args
        .iter()
        // Filter out special freedesktop syntax
        .filter(|entry| !entry.starts_with('%'))
        .collect::<Vec<&String>>();

    std::process::Command::new(args[0])
        .args(&args[1..])
        .spawn()
        .context("Failed to spawn app")
        .map(|_| ())?;

    if let Ok(cache) = CACHE.lock().as_mut() {
        cache.update(entry)?;
    } else {
        eprint!("Failed to acquire cache");
    }

    exit(0);
}

fn load_apps() -> Vec<AppDescriptor> {
    let mut cache = CACHE.lock().expect("Failed to acquire cache");
    cache.load_from_apps_loader()
}

/// Load DesktopEntry's from `DesktopIter`
fn find_all_apps() -> Vec<AppDescriptor> {
    let locales = get_languages_from_env();

    let app_list_iter = Iter::new(default_paths())
        .entries(Some(&locales))
        .filter(|entry| !entry.no_display())
        .filter(|entry| entry.desktop_entry("Name").is_some()) // Ignore apps w/out titles
        .filter(|entry| entry.exec().is_some());

    // If current desktop is known, filter items that only apply to that desktop
    let mut app_list = if let Some(current_desktop) = current_desktop() {
        app_list_iter
            .filter(|entry| matching_show_in_filter(entry, &current_desktop))
            .filter(|entry| matching_no_show_in_filter(entry, &current_desktop))
            .map(AppDescriptor::from)
            .collect::<Vec<_>>()
    } else {
        app_list_iter.map(AppDescriptor::from).collect::<Vec<_>>()
    };

    app_list.sort_by(|a, b| a.title.cmp(&b.title));

    app_list
}

// Return true if the entry and current desktop have a matching element, or if no desktop is available or the entry has no desktop spec.  False otherwise.
fn matching_show_in_filter(entry: &DesktopEntry, current_desktop: &[String]) -> bool {
    if let Some(show_in) = entry.only_show_in() {
        for show_in_desktop in show_in {
            for desktop in current_desktop.iter() {
                if show_in_desktop == desktop {
                    return true;
                }
            }
        }
        false
    } else {
        true
    }
}

// Return false if the entry and current desktop have a matching element.  Return true if no desktop is available or the entry has no desktop spec.
fn matching_no_show_in_filter(entry: &DesktopEntry, current_desktop: &[String]) -> bool {
    if let Some(no_show_in) = entry.not_show_in() {
        for show_in_desktop in no_show_in {
            for desktop in current_desktop.iter() {
                if show_in_desktop == desktop {
                    return false;
                }
            }
        }
        true
    } else {
        true
    }
}