relvi 0.1.0

A focused application launcher for Wayland
mod applications;

use std::collections::HashSet;
use std::rc::Rc;

use gtk::{gio, glib};
use polysearch::{
    ALIAS, Config, Entry as SearchEntry, Field, IDENTIFIER, KEYWORD, PRIMARY_NAME, Searcher,
};

use crate::history::History;
use crate::system::{self, SystemAction};

pub enum Target {
    Application(gio::AppInfo),
    SystemAction(&'static SystemAction),
}

pub struct Entry {
    pub target: Target,
    pub id: Option<glib::GString>,
    pub title: glib::GString,
    pub subtitle: Option<glib::GString>,
    pub icon: Option<gio::Icon>,
    pub categories: Vec<&'static str>,
}

fn search_entry(entry: &Entry, id: u64, next_field: &mut u64) -> SearchEntry {
    let mut fields = Vec::new();
    let mut seen = HashSet::new();
    let mut add = |role, text: &str| {
        let text = text.trim();

        if text.is_empty() || !seen.insert(text.to_lowercase()) {
            return;
        }

        let id = u32::try_from(*next_field).unwrap();
        *next_field += 1;
        fields.push(Field {
            id,
            role,
            text: text.to_owned(),
        });
    };

    add(PRIMARY_NAME, &entry.title);

    match &entry.target {
        Target::Application(app) => applications::search_fields(app, &mut add),
        Target::SystemAction(action) => {
            for alias in action.aliases {
                add(ALIAS, alias);
            }
        }
    }

    if let Some(description) = entry.subtitle.as_deref() {
        add(KEYWORD, description);
    }

    if let Some(id) = entry.id.as_deref() {
        add(IDENTIFIER, id);
    }

    SearchEntry { id, fields }
}

pub struct Catalog {
    entries: Vec<Rc<Entry>>,
    searcher: Searcher,
}

impl Catalog {
    pub fn load() -> Self {
        let mut entries = applications::scan();
        entries.extend(system::ACTIONS.iter().map(|action| {
            Rc::new(Entry {
                target: Target::SystemAction(action),
                id: Some(action.id.into()),
                title: action.title.into(),
                subtitle: None,
                icon: Some(gio::ThemedIcon::new(action.icon).into()),
                categories: vec!["System"],
            })
        }));
        entries.sort_unstable_by(|left, right| left.title.cmp(&right.title));

        let mut next_field = 0;
        let search_entries = entries
            .iter()
            .enumerate()
            .map(|(index, entry)| search_entry(entry, index as u64, &mut next_field));
        let searcher = Searcher::new(search_entries, Config::default());

        Self { entries, searcher }
    }

    pub fn entries(&self) -> &[Rc<Entry>] {
        &self.entries
    }

    pub fn search(&self, query: &str, category: Option<&str>, history: &History) -> Vec<usize> {
        let query = query.trim();
        let mut compare_history = history.comparator(query);
        let mut matches = if query.is_empty() {
            let mut matches: Vec<_> = (0..self.entries.len()).collect();
            matches.sort_by(|&left, &right| {
                compare_history(
                    self.entries[left].id.as_deref(),
                    self.entries[right].id.as_deref(),
                )
            });

            matches
        } else {
            self.searcher
                .search(query, self.entries.len(), |left, right| {
                    compare_history(
                        self.entries[left as usize].id.as_deref(),
                        self.entries[right as usize].id.as_deref(),
                    )
                })
                .into_iter()
                .map(|result| result.entry as usize)
                .collect()
        };

        if let Some(category) = category {
            matches.retain(|&index| self.entries[index].categories.contains(&category));
        }

        matches
    }

    pub fn categories(&self) -> Vec<&'static str> {
        applications::MAIN_CATEGORIES
            .iter()
            .copied()
            .filter(|category| {
                self.entries
                    .iter()
                    .any(|entry| entry.categories.contains(category))
            })
            .collect()
    }
}