free-launch 0.2.9

A simple fuzzy launcher written in Rust.
Documentation
use niri_ipc::{
    Action::FocusWindow, Request as NiriRequest, Timestamp, Window as NiriWindow, socket::Socket,
};
use readlock::Shared;
use std::{collections::HashMap, path::Path, sync::LazyLock};
use tracing::{error, warn};

use crate::launch_entries::{
    action_name::ActionName,
    action_result::ActionResult,
    launch_entry::LaunchEntry,
    launch_entry_trait::{LaunchAction, LaunchId, Launchable},
};

pub(crate) struct Window {
    window: NiriWindow,
}

impl Window {
    pub(crate) fn name(&self) -> Option<String> {
        self.window.title.clone()
    }

    pub(crate) fn id(&self) -> String {
        format!("window-{}", self.window.id)
    }

    /// Returns the focus history ID, which indicates activation order.
    /// Higher values mean more recently focused.
    pub(crate) fn focus_timestamp(&self) -> Option<Timestamp> {
        self.window.focus_timestamp
    }

    fn focus(&self) {
        // Focus the window using niri IPC
        match Socket::connect() {
            Ok(mut socket) => {
                let request = NiriRequest::Action(FocusWindow { id: self.window.id });
                if let Err(e) = socket.send(request) {
                    warn!("Failed to focus window {}: {}", self.window.id, e);
                }
            }
            Err(e) => {
                warn!("Failed to connect to niri socket: {}", e);
            }
        }
    }
}

impl From<NiriWindow> for Window {
    fn from(window: NiriWindow) -> Self {
        Window { window }
    }
}

impl LaunchId for Window {
    fn id(&self) -> String {
        format!("window-{}", self.window.id)
    }

    fn launchable(&self) -> bool {
        // We'll just return true for now as windows don't exist on disk
        // TODO We may want to check if the window is still open in the future
        true
    }

    fn file_path(&self) -> &Path {
        // Windows don't have file paths, return a dummy path
        Path::new("")
    }

    fn icon_name(&self) -> Option<&str> {
        // Windows don't have icon names in this context
        None
    }

    fn comment(&self) -> Option<&str> {
        // Windows don't have comments
        None
    }

    fn debug_ui(&self, ui: &mut egui::Ui, count: usize) {
        ui.label(format!("  [{}] ID: {}", count, self.id()));
        if let Some(title) = &self.window.title {
            ui.label(format!("      Title: {}", title));
        }
        if let Some(app_id) = &self.window.app_id {
            ui.label(format!("      App ID: {}", app_id));
        }
        ui.label(format!("      Window ID: {}", self.window.id));
        if let Some(focus_id) = self.window.focus_timestamp {
            ui.label(format!("      Focus History ID: {:?}", focus_id));
        }
    }
}

static DISPATCH_TABLE: LazyLock<HashMap<&'static str, fn(&Window)>> = LazyLock::new(|| {
    let mut m: HashMap<&'static str, fn(&Window)> = HashMap::new();
    m.insert("focus", Window::focus);
    m
});

impl LaunchAction for Window {
    fn actions(&self) -> Vec<&'static str> {
        DISPATCH_TABLE.keys().copied().collect()
    }

    fn default_action(&self) -> &'static str {
        "focus"
    }

    fn secondary_action(&self) -> &'static str {
        // TODO choose a reasonable secondary action
        "focus"
    }

    fn invoke(&self, action_name: &ActionName) -> ActionResult {
        // Unwrap action name or substitute default action
        let action = match action_name {
            ActionName::Default => self.default_action(),
            ActionName::Action(name) => name,
        };
        if let Some(func) = DISPATCH_TABLE.get(action) {
            func(self);
            ActionResult::Success
        } else {
            let error_message = format!(
                "Unknown action '{}' for DesktopItem '{}'",
                action,
                self.name().unwrap_or_default()
            );
            error!(error_message);
            ActionResult::Error(error_message)
        }
    }

    fn invoke_default(&self) -> ActionResult {
        self.invoke(&ActionName::Default)
    }
}

impl Launchable for Window {}

// request_sender: Sender<Box<Request>>,
pub(crate) fn load_niri_windows() -> (Option<Socket>, HashMap<String, Shared<LaunchEntry>>) {
    // we want to log any errors, otherwise we could just call `Socket::connect().ok()`
    let (niri_socket, windows) = match Socket::connect() {
        Ok(mut socket) => {
            // TODO move this into a separate thread so it doesn't block
            let windows = match socket.send(NiriRequest::Windows) {
                Ok(response) => match response {
                    Ok(response) => match response {
                        niri_ipc::Response::Windows(windows) => {
                            // we need to convert the NiriWindows to LaunchItems
                            windows
                                .iter()
                                .map(|w| {
                                    let window = LaunchEntry::from(w.clone());
                                    let window_id = window.id.clone();
                                    let window_lock = Shared::new(window);
                                    (window_id, window_lock)
                                })
                                .collect()
                        }
                        _ => Default::default(),
                        // niri_ipc::Response::Handled => todo!(),
                        // niri_ipc::Response::Version(_) => todo!(),
                        // niri_ipc::Response::Outputs(hash_map) => todo!(),
                        // niri_ipc::Response::Workspaces(workspaces) => todo!(),
                        // niri_ipc::Response::Layers(layer_surfaces) => todo!(),
                        // niri_ipc::Response::KeyboardLayouts(keyboard_layouts) => todo!(),
                        // niri_ipc::Response::FocusedOutput(output) => todo!(),
                        // niri_ipc::Response::FocusedWindow(window) => todo!(),
                        // niri_ipc::Response::PickedWindow(window) => todo!(),
                        // niri_ipc::Response::PickedColor(picked_color) => todo!(),
                        // niri_ipc::Response::OutputConfigChanged(output_config_changed) => todo!(),
                        // niri_ipc::Response::OverviewState(overview) => todo!(),
                    },
                    Err(e) => {
                        warn!("Error from Niri: {}", e);
                        Default::default()
                    }
                },
                Err(e) => {
                    warn!("Error communicating with Niri socket: {}", e);
                    Default::default()
                }
            };
            (Some(socket), windows)
        }
        Err(e) => {
            // log the error for the niri socket
            warn!("Could not connect to Niri's IPC socket: {}", e);
            (None, Default::default())
        }
    };

    // TODO do we need to send these to the main list of items
    // // send windows to the matcher
    // for (id, window) in windows {
    //     request_sender
    //         .send(Box::new(Request::IndexItem {
    //             item: ItemUpdate::LaunchEntry(window),
    //         }))
    //         .expect("should be able to send item update");
    // }

    (niri_socket, windows)
}