free-launch 0.2.9

A simple fuzzy launcher written in Rust.
Documentation
use chrono::Local;
use niri_ipc::{Timestamp, Window as NiriWindow};
use std::fmt;
use std::fs::create_dir_all;
use std::path::Path;
use tracing::{error, info, warn};

use crate::free_launch::free_launch::PROJECT_DIRS;
use crate::launch_entries::action_name::ActionName;
use crate::launch_entries::action_result::ActionResult;
use crate::launch_entries::launch_entry_trait::Launchable;
use crate::model::invocation::Invocation;
use crate::model::window::Window;

/// Represents an item that can be launched in the item list
pub struct LaunchEntry {
    pub id: String,
    pub name: String,
    pub selected: bool,
    pub(crate) items: Vec<Box<dyn Launchable>>,
    // TODO consider changing to Rc<Invocation>
    pub invocations: Vec<Invocation>,
    /// Activation order for sorting (higher = more recently activated)
    /// Used primarily for window entries to sort by focus history
    pub activation_order: Option<Timestamp>,
}

impl LaunchEntry {
    /// Create a new LaunchEntry with the given id
    pub fn new(id: String, name: String) -> Self {
        Self {
            id,
            name,
            selected: false,
            items: Vec::new(),
            invocations: Vec::new(),
            activation_order: None,
        }
    }

    pub fn name(&self) -> &str {
        &self.name
    }

    /// Add an item to this launch entry
    pub(crate) fn add_item(&mut self, item: Box<dyn Launchable>) {
        self.items.push(item);
    }

    /// Add an invocation to this launch entry
    pub(crate) fn add_invocation(&mut self, invocation: Invocation) {
        self.invocations.push(invocation);
    }

    /// Determine whether this entry is vaild for the display and launch of associated items
    pub fn is_valid(&self) -> bool {
        !self.id.is_empty() && !self.items.is_empty()
    }

    /// A `usize` to be used for sorting based on frecency
    ///
    /// This creates a value that is a combination of how recently something was invoked as well as how many times it was invoked.
    /// It may need more tweaking for the best user experience.
    pub fn sort_key(&self) -> u64 {
        let now = Local::now().timestamp() as u64;
        self.invocations
            .iter()
            .fold(0, |acc, i| acc + (i.timestamp / (now - i.timestamp).max(1)))
    }

    pub(crate) fn toggle_selected(&mut self) {
        self.selected = !self.selected
    }

    // TODO return an option to prevent crashes
    pub(crate) fn preferred_item(&self) -> Option<&Box<dyn Launchable>> {
        self.items.first()
    }

    pub(crate) fn id(&self) -> Option<String> {
        self.preferred_item().map(|i| i.id())
    }

    pub(crate) fn file_path(&self) -> Option<&Path> {
        self.preferred_item().map(|i| i.file_path())
    }

    pub(crate) fn comment(&self) -> Option<&str> {
        self.preferred_item().and_then(|i| i.comment())
    }

    pub(crate) fn icon_name(&self) -> Option<&str> {
        self.preferred_item().and_then(|i| i.icon_name())
    }

    pub(crate) fn invoke(&self, action: &ActionName, search_query: &str) -> ActionResult {
        info!("Launching entry: {}", self.name());

        match self.preferred_item().map(|i| i.invoke(action)) {
            Some(result) => {
                // TODO log the ActionResult as well
                self.log_invocation(action, search_query, &result);
                result
            }
            None => ActionResult::Error("could not find a preferred item".to_owned()),
        }
    }

    pub(crate) fn invoke_default(&self, search_query: &str) -> ActionResult {
        self.invoke(&ActionName::Default, search_query)
    }

    pub(crate) fn invoke_secondary(&self, search_query: &str) -> ActionResult {
        match self.preferred_item() {
            Some(item) => {
                let secondary_action_name = item.secondary_action();
                self.invoke(
                    &ActionName::Action(secondary_action_name.to_owned()),
                    search_query,
                )
            }
            None => ActionResult::Error("could not find a preferred item".to_owned()),
        }
    }

    fn log_invocation(&self, action: &ActionName, search_query: &str, result: &ActionResult) {
        // TODO make this a lazy loaded static
        let cache_dir = PROJECT_DIRS.cache_dir();

        // Create cache directory if it doesn't exist
        if let Err(e) = create_dir_all(cache_dir) {
            error!(
                "Failed to create cache directory {}: {}",
                cache_dir.display(),
                e
            );
            return;
        }

        // Write to log file
        // TODO do we need to make the appends atomic?
        // TODO handle errors rather than ignoring them
        if let Err(e) = Invocation::log_entry(search_query, action.to_string(), self) {
            warn!("Could not log invocation:{}", e)
        }
    }
}

impl From<Window> for LaunchEntry {
    fn from(window: Window) -> Self {
        let id = window.id();
        let name = window.name().to_owned();
        let activation_order = window.focus_timestamp();

        // TODO get rid of the unwrap_or
        let mut launch_entry = LaunchEntry::new(id, name.unwrap_or("UNNAMED".to_owned()));
        launch_entry.activation_order = activation_order;
        launch_entry.add_item(Box::new(window));
        launch_entry
    }
}

impl From<NiriWindow> for LaunchEntry {
    fn from(niri_window: NiriWindow) -> Self {
        let window: Window = niri_window.into();
        window.into()
    }
}

impl fmt::Display for LaunchEntry {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.name)
    }
}

// TODO implement the from trait for NiriWindow