alttabway 0.4.5

Alt-tab window switcher for wayland compositors
Documentation
use std::{
    env,
    ffi::OsStr,
    fs,
    path::{Path, PathBuf},
};

use image::{DynamicImage, ImageReader};
use ini::Ini;
use lazy_static::lazy_static;
use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};

pub struct IconHelper;

lazy_static! {
    static ref ICON_DIRS: Vec<PathBuf> = {
        let mut dirs: Vec<PathBuf> = IconHelper::ICON_SYSTEM_DIRS
            .iter()
            .map(|dir| PathBuf::from(dir))
            .collect();

        if let Ok(home_dir) = env::var("HOME") {
            for user_dir in IconHelper::ICON_USER_DIRS {
                dirs.push(Path::new(&home_dir).join(user_dir));
            }
        }

        dirs
    };
    static ref DESKTOP_DIRS: Vec<PathBuf> = {
        let mut dirs: Vec<PathBuf> = IconHelper::DESKTOP_SYSTEM_DIRS
            .iter()
            .map(|dir| PathBuf::from(dir))
            .collect();

        if let Ok(home_dir) = env::var("HOME") {
            for user_dir in IconHelper::DESKTOP_USER_DIRS {
                dirs.push(Path::new(&home_dir).join(user_dir));
            }
        }

        dirs
    };
}

impl IconHelper {
    const ICON_SYSTEM_DIRS: [&str; 3] = [
        "/usr/share/icons/hicolor/256x256/apps",
        "/usr/share/icons/hicolor/48x48/apps",
        "/usr/share/pixmaps",
    ];
    const ICON_USER_DIRS: [&str; 2] = [".local/share/icons", ".icons"];

    const DESKTOP_SYSTEM_DIRS: [&str; 1] = ["/usr/share/applications"];
    const DESKTOP_USER_DIRS: [&str; 1] = [".local/share/applications"];

    fn find_icon_file(file_name: &str) -> Option<PathBuf> {
        for icon_dir in ICON_DIRS.iter() {
            let path = icon_dir.join(file_name);
            if path.exists() {
                return Some(path);
            }
        }

        None
    }

    fn get_desktop_files() -> Vec<PathBuf> {
        let mut paths = vec![];

        for desktop_dir in DESKTOP_DIRS.iter() {
            let Ok(dir_iter) = fs::read_dir(desktop_dir) else {
                continue;
            };
            for entry in dir_iter {
                let Ok(entry) = entry else { continue };

                let path = entry.path();
                if path.is_file() && path.extension() == Some(OsStr::new("desktop")) {
                    paths.push(path);
                }
            }
        }

        paths
    }

    fn get_icon_for_app_id(app_id: &str) -> Option<DynamicImage> {
        for desktop_file in Self::get_desktop_files() {
            let Ok(ini) = Ini::load_from_file(&desktop_file) else {
                continue;
            };

            let file_stem_matches = desktop_file
                .file_stem()
                .and_then(OsStr::to_str)
                .is_some_and(|stem| stem == app_id);

            let exec_matches = ini
                .section(Some("Desktop Entry"))
                .and_then(|section| section.get("Exec"))
                .is_some_and(|exec| Self::exec_matches_app_id(exec, app_id));

            if !file_stem_matches && !exec_matches {
                continue;
            }

            if let Some(icon_path) = ini
                .section(Some("Desktop Entry"))
                .and_then(|section| section.get("Icon"))
                .and_then(|icon_value| Self::resolve_icon_path(icon_value))
            {
                if let Ok(icon) = Self::read_image(icon_path) {
                    return icon.into();
                }
            }
        }

        None
    }

    fn read_image(path: PathBuf) -> anyhow::Result<DynamicImage> {
        Ok(ImageReader::open(path)?.decode()?)
    }

    fn exec_matches_app_id(exec: &str, app_id: &str) -> bool {
        let Some(token) = exec.split_whitespace().next() else {
            return false;
        };

        let token = token.trim_matches('"');
        if token == app_id {
            return true;
        }

        Path::new(token)
            .file_stem()
            .and_then(OsStr::to_str)
            .is_some_and(|stem| stem == app_id)
    }

    fn resolve_icon_path(icon_value: &str) -> Option<PathBuf> {
        let icon_value = icon_value.trim();
        if icon_value.is_empty() {
            return None;
        }

        let icon_path = Path::new(icon_value);
        if icon_path.is_absolute() && icon_path.exists() {
            return Some(icon_path.to_path_buf());
        }

        if icon_path.extension().is_some() {
            return Self::find_icon_file(icon_value);
        }

        let file_name = format!("{}.png", icon_value);
        if let Some(path) = Self::find_icon_file(&file_name) {
            return Some(path);
        }

        None
    }
}

pub struct IconWorker {
    sender: UnboundedSender<(String, DynamicImage)>,
    receiver: UnboundedReceiver<(String, DynamicImage)>,
}

impl IconWorker {
    pub fn new() -> Self {
        let (sender, receiver) = mpsc::unbounded_channel();
        Self { sender, receiver }
    }

    pub fn get_icon(&mut self, app_id: impl Into<String>) {
        let app_id = app_id.into();
        let sender = self.sender.clone();
        tokio::spawn(async move {
            if let Some(icon) = IconHelper::get_icon_for_app_id(&app_id) {
                let _ = sender.send((app_id, icon));
            }
        });
    }

    pub async fn recv(&mut self) -> Option<(String, DynamicImage)> {
        self.receiver.recv().await
    }
}