Skip to main content

free_launch/model/
window.rs

1use niri_ipc::{
2    Action::FocusWindow, Request as NiriRequest, Timestamp, Window as NiriWindow, socket::Socket,
3};
4use readlock::Shared;
5use std::{collections::HashMap, path::Path, sync::LazyLock};
6use tracing::{error, warn};
7
8use crate::launch_entries::{
9    action_name::ActionName,
10    action_result::ActionResult,
11    launch_entry::LaunchEntry,
12    launch_entry_trait::{LaunchAction, LaunchId, Launchable},
13};
14
15pub(crate) struct Window {
16    window: NiriWindow,
17}
18
19impl Window {
20    pub(crate) fn name(&self) -> Option<String> {
21        self.window.title.clone()
22    }
23
24    pub(crate) fn id(&self) -> String {
25        format!("window-{}", self.window.id)
26    }
27
28    /// Returns the focus history ID, which indicates activation order.
29    /// Higher values mean more recently focused.
30    pub(crate) fn focus_timestamp(&self) -> Option<Timestamp> {
31        self.window.focus_timestamp
32    }
33
34    fn focus(&self) {
35        // Focus the window using niri IPC
36        match Socket::connect() {
37            Ok(mut socket) => {
38                let request = NiriRequest::Action(FocusWindow { id: self.window.id });
39                if let Err(e) = socket.send(request) {
40                    warn!("Failed to focus window {}: {}", self.window.id, e);
41                }
42            }
43            Err(e) => {
44                warn!("Failed to connect to niri socket: {}", e);
45            }
46        }
47    }
48}
49
50impl From<NiriWindow> for Window {
51    fn from(window: NiriWindow) -> Self {
52        Window { window }
53    }
54}
55
56impl LaunchId for Window {
57    fn id(&self) -> String {
58        format!("window-{}", self.window.id)
59    }
60
61    fn launchable(&self) -> bool {
62        // We'll just return true for now as windows don't exist on disk
63        // TODO We may want to check if the window is still open in the future
64        true
65    }
66
67    fn file_path(&self) -> &Path {
68        // Windows don't have file paths, return a dummy path
69        Path::new("")
70    }
71
72    fn icon_name(&self) -> Option<&str> {
73        // Windows don't have icon names in this context
74        None
75    }
76
77    fn comment(&self) -> Option<&str> {
78        // Windows don't have comments
79        None
80    }
81
82    fn debug_ui(&self, ui: &mut egui::Ui, count: usize) {
83        ui.label(format!("  [{}] ID: {}", count, self.id()));
84        if let Some(title) = &self.window.title {
85            ui.label(format!("      Title: {}", title));
86        }
87        if let Some(app_id) = &self.window.app_id {
88            ui.label(format!("      App ID: {}", app_id));
89        }
90        ui.label(format!("      Window ID: {}", self.window.id));
91        if let Some(focus_id) = self.window.focus_timestamp {
92            ui.label(format!("      Focus History ID: {:?}", focus_id));
93        }
94    }
95}
96
97static DISPATCH_TABLE: LazyLock<HashMap<&'static str, fn(&Window)>> = LazyLock::new(|| {
98    let mut m: HashMap<&'static str, fn(&Window)> = HashMap::new();
99    m.insert("focus", Window::focus);
100    m
101});
102
103impl LaunchAction for Window {
104    fn actions(&self) -> Vec<&'static str> {
105        DISPATCH_TABLE.keys().copied().collect()
106    }
107
108    fn default_action(&self) -> &'static str {
109        "focus"
110    }
111
112    fn secondary_action(&self) -> &'static str {
113        // TODO choose a reasonable secondary action
114        "focus"
115    }
116
117    fn invoke(&self, action_name: &ActionName) -> ActionResult {
118        // Unwrap action name or substitute default action
119        let action = match action_name {
120            ActionName::Default => self.default_action(),
121            ActionName::Action(name) => name,
122        };
123        if let Some(func) = DISPATCH_TABLE.get(action) {
124            func(self);
125            ActionResult::Success
126        } else {
127            let error_message = format!(
128                "Unknown action '{}' for DesktopItem '{}'",
129                action,
130                self.name().unwrap_or_default()
131            );
132            error!(error_message);
133            ActionResult::Error(error_message)
134        }
135    }
136
137    fn invoke_default(&self) -> ActionResult {
138        self.invoke(&ActionName::Default)
139    }
140}
141
142impl Launchable for Window {}
143
144// request_sender: Sender<Box<Request>>,
145pub(crate) fn load_niri_windows() -> (Option<Socket>, HashMap<String, Shared<LaunchEntry>>) {
146    // we want to log any errors, otherwise we could just call `Socket::connect().ok()`
147    let (niri_socket, windows) = match Socket::connect() {
148        Ok(mut socket) => {
149            // TODO move this into a separate thread so it doesn't block
150            let windows = match socket.send(NiriRequest::Windows) {
151                Ok(response) => match response {
152                    Ok(response) => match response {
153                        niri_ipc::Response::Windows(windows) => {
154                            // we need to convert the NiriWindows to LaunchItems
155                            windows
156                                .iter()
157                                .map(|w| {
158                                    let window = LaunchEntry::from(w.clone());
159                                    let window_id = window.id.clone();
160                                    let window_lock = Shared::new(window);
161                                    (window_id, window_lock)
162                                })
163                                .collect()
164                        }
165                        _ => Default::default(),
166                        // niri_ipc::Response::Handled => todo!(),
167                        // niri_ipc::Response::Version(_) => todo!(),
168                        // niri_ipc::Response::Outputs(hash_map) => todo!(),
169                        // niri_ipc::Response::Workspaces(workspaces) => todo!(),
170                        // niri_ipc::Response::Layers(layer_surfaces) => todo!(),
171                        // niri_ipc::Response::KeyboardLayouts(keyboard_layouts) => todo!(),
172                        // niri_ipc::Response::FocusedOutput(output) => todo!(),
173                        // niri_ipc::Response::FocusedWindow(window) => todo!(),
174                        // niri_ipc::Response::PickedWindow(window) => todo!(),
175                        // niri_ipc::Response::PickedColor(picked_color) => todo!(),
176                        // niri_ipc::Response::OutputConfigChanged(output_config_changed) => todo!(),
177                        // niri_ipc::Response::OverviewState(overview) => todo!(),
178                    },
179                    Err(e) => {
180                        warn!("Error from Niri: {}", e);
181                        Default::default()
182                    }
183                },
184                Err(e) => {
185                    warn!("Error communicating with Niri socket: {}", e);
186                    Default::default()
187                }
188            };
189            (Some(socket), windows)
190        }
191        Err(e) => {
192            // log the error for the niri socket
193            warn!("Could not connect to Niri's IPC socket: {}", e);
194            (None, Default::default())
195        }
196    };
197
198    // TODO do we need to send these to the main list of items
199    // // send windows to the matcher
200    // for (id, window) in windows {
201    //     request_sender
202    //         .send(Box::new(Request::IndexItem {
203    //             item: ItemUpdate::LaunchEntry(window),
204    //         }))
205    //         .expect("should be able to send item update");
206    // }
207
208    (niri_socket, windows)
209}