free-launch 0.2.9

A simple fuzzy launcher written in Rust.
Documentation
use std::{
    collections::HashMap,
    ops::Range,
    sync::{
        Arc,
        mpsc::{Receiver, SendError, Sender},
    },
    thread, u32,
};

use niri_ipc::socket::Socket;
use nucleo::{
    Config as NucleoConfig, Injector, Nucleo, Snapshot,
    pattern::{CaseMatching, Normalization},
};
use readlock::{Shared, SharedReadLock};
use tracing::{info, trace};

use crate::{
    free_launch::free_launch::FRAME_DELAY,
    launch_entries::launch_entry::LaunchEntry,
    model::{
        desktop_item::load_desktop_files,
        invocation::load_invocations,
        search_provider::{SearchProvider, load_search_providers},
        window::load_niri_windows,
    },
    signaling::{item_update::ItemUpdate, request::Request, response::Response},
};

pub(crate) type SharedLaunchEntry = Shared<LaunchEntry>;
pub(crate) type ReadableLaunchEntry = SharedReadLock<LaunchEntry>;

pub(crate) type ItemMap = HashMap<String, SharedLaunchEntry>;
pub(crate) type ItemVec = Vec<ReadableLaunchEntry>;

pub struct SearchIndex {
    request_receiver: Receiver<Box<Request>>,
    response_sender: Sender<Box<Response>>,
    matcher: Nucleo<ReadableLaunchEntry>,
    pub(crate) niri_socket: Option<Socket>,
    pub(crate) windows: ItemMap,
    pub(crate) launch_items: ItemMap,
    // icons are loaded into a globally accessible structure so there is no receiver here
    pub(crate) search_providers: Vec<SearchProvider>,
    pub(crate) search_query: String,
    /// our cached count of lines that the UI is capable of displaying
    displayable_lines: Range<u32>,
}

impl<'a> SearchIndex {
    /// Do initial setup of the search index and get ready to enter the update loop
    ///
    /// This method should always return quickly in case we want to split the setup from the loop
    pub fn new(
        request_sender: Sender<Box<Request>>,
        request_receiver: Receiver<Box<Request>>,
        response_sender: Sender<Box<Response>>,
    ) -> Self {
        // search providers should load quick enough that we shouldn't need a separate thread
        let search_providers = load_search_providers();
        info!(
            "Sending {} loaded search providers...",
            search_providers.len()
        );
        response_sender
            .send(Box::new(Response::SearchProviders {
                providers: search_providers.clone(),
            }))
            .expect("should be able to send search providers update");

        // background loading
        // load invocations in separate thread
        let invocation_request_sender = request_sender.clone();
        thread::spawn(move || load_invocations(invocation_request_sender));

        // load desktop items in separate thread

        // Load icons for most recent invocations in the background
        info!("Spawning thread to load icons for invocations...");
        // TODO change this approach since the desktop items are loaded below, in a separate thread, and therefore we'll never end up pre-loading icons
        // load_invocation_icons(&HashMap::default());

        // Load desktop files in the background
        info!("Spawning thread to load desktop items...");
        let desktop_request_sender = request_sender.clone();
        // let desktop_response_sender = response_sender.clone();
        thread::spawn(move || load_desktop_files(desktop_request_sender));

        let matcher = Nucleo::new(NucleoConfig::DEFAULT, Arc::new(|| {}), None, 1);

        let (niri_socket, windows) = load_niri_windows();

        Self {
            request_receiver,
            response_sender,
            matcher,
            niri_socket,
            windows,
            launch_items: Default::default(),
            search_providers,
            search_query: Default::default(),
            displayable_lines: u32::MIN..u32::MAX,
        }
    }

    /// The main loop for the indexer to keep items up to date
    pub fn run(&mut self) {
        // we're going to populate the list with the windows before we enter the event loop
        self.send_windows();

        // enter the main index event loop
        loop {
            // we'll go ahead and block on the receiver here since we're running in a separate thread
            // we can't use the `iter()` form as that borrows immutably
            match self.request_receiver.recv() {
                Ok(request) => match *request {
                    Request::UpdateIndexItem { item } => self.update_launch_entry(item),
                    Request::Search { id, query } => self.run_search(id, query),
                    Request::UpdateWindowInfo { displayable_lines } => {
                        self.displayable_lines = displayable_lines
                    }
                },
                Err(_) => break, // Channel closed, exit loop
            }
        }
    }

    fn send_windows(&mut self) {
        // Collect windows and sort by activation order (most recently focused first)
        let mut window_entries: Vec<_> = self
            .windows
            .iter()
            .map(|(_name, launch_entry)| {
                readlock::Shared::<LaunchEntry>::get_read_lock(launch_entry)
            })
            .collect();

        // Sort by activation_order in descending order (higher = more recent)
        // Windows without activation_order go to the end
        window_entries.sort_by(|a, b| {
            let a_order = a.lock().activation_order;
            let b_order = b.lock().activation_order;
            match (b_order, a_order) {
                // We'll ignore nanoseconds here for simplicity, seconds should be enough for most cases
                (Some(b_val), Some(a_val)) => b_val.secs.cmp(&a_val.secs),
                (Some(_), None) => std::cmp::Ordering::Less,
                (None, Some(_)) => std::cmp::Ordering::Greater,
                (None, None) => std::cmp::Ordering::Equal,
            }
        });

        // we're going to populate the list with the windows before we enter the event loop
        self.send(Response::SearchResults {
            id: 0,
            results: window_entries,
            matches: 0,
            total_items: self.windows.len() as u32,
        })
        .expect("should be able to send search results");
    }

    fn send(&self, response: Response) -> Result<(), SendError<Box<Response>>> {
        self.response_sender.send(Box::new(response))
    }

    pub(crate) fn tick(&mut self, frame_delay: u64) -> nucleo::Status {
        self.matcher.tick(frame_delay)
    }

    /// Adds the given item to the LaunchEntry hash
    pub(crate) fn update_launch_entry(&mut self, item: ItemUpdate) {
        let map_entry = self.launch_items.entry((&item.id()).to_string());
        let entry_occupied = match map_entry {
            std::collections::hash_map::Entry::Occupied(ref _occupied_entry) => true,
            std::collections::hash_map::Entry::Vacant(ref _vacant_entry) => false,
        };

        let launch_entry = map_entry.or_insert(Shared::new(LaunchEntry::new(
            item.id().to_string(),
            item.name().to_owned(),
        )));
        trace!(
            "updating entry: {} (entries: {} invocations: {}): {}",
            item.id(),
            launch_entry.items.len(),
            launch_entry.invocations.len(),
            item.path()
        );

        {
            let mut writable_launch_entry = readlock::Shared::<LaunchEntry>::lock(launch_entry);

            match item {
                ItemUpdate::Invocation(invocation) => {
                    writable_launch_entry.add_invocation(invocation)
                }
                ItemUpdate::DesktopItem(desktop_item) => {
                    writable_launch_entry.add_item(Box::new(desktop_item))
                }
            }
        }

        // add the LaunchEntry to the matcher if it wasn't found in the entry hashmap
        if !entry_occupied {
            let readable_launch_entry =
                readlock::Shared::<LaunchEntry>::get_read_lock(&launch_entry);

            // add an entry to the matcher
            // TODO do we need to move this elsewhere and batch it?
            self.inject_items(|i| {
                i.push(readable_launch_entry, |item, columns| {
                    columns[0] = item.lock().to_string().into()
                });
            });
        }
    }

    pub fn snapshot(&self) -> &Snapshot<ReadableLaunchEntry> {
        self.matcher.snapshot()
    }

    fn run_search(&mut self, id: usize, query: String) {
        // TODO decide what to send back for an empty query, probably frecency ranked items
        if query.is_empty() {
            self.send_windows();
            return;
        }

        // we need to check this before we store the new query
        let query_is_append = query_is_append(&self.search_query, &query);

        info!(
            "Reparsing {} search pattern: {}",
            if query_is_append { "appended" } else { "full" },
            query
        );

        // store the new query
        self.search_query = query;

        // update the matcher
        self.matcher.pattern.reparse(
            0,
            &self.search_query,
            CaseMatching::Smart,
            Normalization::Smart,
            query_is_append,
        );

        // we'll loop until the matcher is done before sending results
        loop {
            // we must call this to keep Nucleo up to date
            let status = self.tick(FRAME_DELAY);
            info!("Called tick, status is: {:?}", status);

            // return if the matcher is empty or passing an inclusive range to matched_items will panic
            let snapshot = self.snapshot();
            let item_count = snapshot.matched_item_count();
            if item_count == 0 {
                self.send(Response::SearchResults {
                    id,
                    results: vec![],
                    matches: 0,
                    total_items: snapshot.item_count(),
                })
                .expect("should be able to send search results");
                break;
            }

            // TODO we could also implement the sending of partial results with a new message type
            // TODO double check that it makes sense to check changed status in this way
            if !(status.running) {
                // can't inline this or we'll have ownership issues
                let item_range = self.displayable_lines.clone();

                // Nucluo will panic if we pass it a range that exceeds the number of items in the snapshot, or a RangeInclusive where the end bound matches the matched_item_count.
                // An issue was filed and subsequently closed as "won't fix, working as intended".
                // More info here: [Panic on Snapshot::matched\_items(RangeInclusive) when no matches · Issue #81 · helix-editor/nucleo](https://github.com/helix-editor/nucleo/issues/81)
                // TODO probably need to adjust range start if it's less than the end bound
                let safe_range = item_range.start..item_range.end.min(item_count);

                let results = snapshot
                    // is important to restrict this to the visible range or things get really slow with lots of items
                    // TODO find out how to properly limit `item_range` to prevent panics
                    .matched_items(safe_range)
                    .map(|i| i.data.clone())
                    .collect::<Vec<_>>();

                // TODO need to sort items here, before sending them

                info!(
                    "Sending {} search results to UI for query: {}",
                    results.len(),
                    self.search_query
                );
                self.send(Response::SearchResults {
                    id,
                    results,
                    matches: item_count,
                    total_items: snapshot.item_count(),
                })
                .expect("should be able to send search results");
                break;
            }
        }

        // send the results to the UI
    }

    pub fn inject_items<F>(&self, f: F)
    where
        F: FnOnce(&Injector<ReadableLaunchEntry>),
    {
        let injector = self.matcher.injector();
        f(&injector);
    }

    pub fn inject_items_threaded<F>(&mut self, f: F)
    where
        F: FnOnce(&Injector<ReadableLaunchEntry>) + Send + 'static,
    {
        let injector = self.matcher.injector();
        let _handle = std::thread::spawn(move || {
            f(&injector);
        });
        // self.join_handles.push(handle);
    }
}

/// This function tests whether the new query is the previous query with characters appended
///
/// We need to know this to set a flag for Nucleo so it can optimize queries.
fn query_is_append(prev_query: &str, query: &str) -> bool {
    if query.is_empty() {
        return false;
    }

    prev_query.len() < query.len() && query.starts_with(prev_query)
}