diff --git a/plugins/src/cosmic_toplevel/mod.rs b/plugins/src/cosmic_toplevel/mod.rs index 6c250b1..42b934b 100644 --- a/plugins/src/cosmic_toplevel/mod.rs +++ b/plugins/src/cosmic_toplevel/mod.rs @@ -1,10 +1,12 @@ mod toplevel_handler; use cctk::cosmic_protocols::toplevel_info::v1::client::zcosmic_toplevel_handle_v1::State; +use cctk::toplevel_info::ToplevelInfo; use cctk::wayland_client::Proxy; -use cctk::{sctk::reexports::calloop, toplevel_info::ToplevelInfo}; +use cctk::sctk::reexports::calloop; use fde::DesktopEntry; use freedesktop_desktop_entry as fde; +use std::collections::HashSet; use toplevel_handler::ToplevelUpdate; use tracing::{debug, error, info, warn}; @@ -16,8 +18,8 @@ use futures::{ future::{Either, select}, }; use pop_launcher::{ - IconSource, PluginResponse, PluginSearchResult, Request, async_stdin, async_stdout, - json_input_stream, + IconSource, PluginResponse, PluginSearchResult, Request, WorkspaceFilter, async_stdin, + async_stdout, json_input_stream, }; use std::borrow::Cow; use tokio::io::{AsyncWrite, AsyncWriteExt}; @@ -52,8 +54,15 @@ pub async fn main() { Request::Quit(id) => app.quit(id), Request::Search(query) => { debug!("searching {query}"); - app.search(&query).await; - // clear the ids to ignore, as all just sent are valid + app.search(&query, WorkspaceFilter::All).await; + app.ids_to_ignore.clear(); + } + Request::SearchFiltered { + query, + workspace_filter, + } => { + debug!("searching {query} with workspace filter {workspace_filter:?}"); + app.search(&query, workspace_filter).await; app.ids_to_ignore.clear(); } Request::Exit => break, @@ -70,35 +79,47 @@ pub async fn main() { for update in updates { match update { - ToplevelUpdate::Info(info) => { + ToplevelUpdate::Info { + info, + workspace_coordinates, + } => { + let entry = ToplevelEntry { + info, + workspace_coordinates, + }; if let Some(pos) = app .toplevels .iter() - .position(|t| t.foreign_toplevel == info.foreign_toplevel) + .position(|t| t.info.foreign_toplevel == entry.info.foreign_toplevel) { - if info.state.contains(&State::Activated) { + if entry.info.state.contains(&State::Activated) { app.toplevels.remove(pos); - app.toplevels.push(Box::new(info)); + app.toplevels.push(entry); } else { - app.toplevels[pos] = Box::new(info); + app.toplevels[pos] = entry; } } else { - app.toplevels.push(Box::new(info)); + app.toplevels.push(entry); } } ToplevelUpdate::Remove(foreign_toplevel) => { if let Some(pos) = app .toplevels .iter() - .position(|t| t.foreign_toplevel == foreign_toplevel) + .position(|t| t.info.foreign_toplevel == foreign_toplevel) { app.toplevels.remove(pos); - // ignore requests for this id until after the next search app.ids_to_ignore.push(foreign_toplevel.id().protocol_id()); } else { warn!("no toplevel to remove"); } } + ToplevelUpdate::ActiveWorkspaces(active_workspace_coordinates) => { + app.active_workspace_coordinates = active_workspace_coordinates; + if let Some(query) = app.pending_workspace_search.take() { + app.search(&query, WorkspaceFilter::Current).await; + } + } } } } @@ -107,11 +128,18 @@ pub async fn main() { } } +struct ToplevelEntry { + info: ToplevelInfo, + workspace_coordinates: HashSet>, +} + struct App { locales: Vec, desktop_entries: Vec, ids_to_ignore: Vec, - toplevels: Vec>, + toplevels: Vec, + active_workspace_coordinates: HashSet>, + pending_workspace_search: Option, calloop_tx: calloop::channel::Sender, tx: W, } @@ -135,6 +163,8 @@ impl App { desktop_entries, ids_to_ignore: Vec::new(), toplevels: Vec::new(), + active_workspace_coordinates: HashSet::new(), + pending_workspace_search: None, calloop_tx, tx, }, @@ -148,8 +178,8 @@ impl App { return; } if let Some(handle) = self.toplevels.iter().find_map(|t| { - if t.foreign_toplevel.id().protocol_id() == id { - Some(t.foreign_toplevel.clone()) + if t.info.foreign_toplevel.id().protocol_id() == id { + Some(t.info.foreign_toplevel.clone()) } else { None } @@ -164,8 +194,8 @@ impl App { return; } if let Some(handle) = self.toplevels.iter().find_map(|t| { - if t.foreign_toplevel.id().protocol_id() == id { - Some(t.foreign_toplevel.clone()) + if t.info.foreign_toplevel.id().protocol_id() == id { + Some(t.info.foreign_toplevel.clone()) } else { None } @@ -174,7 +204,45 @@ impl App { } } - async fn search(&mut self, query: &str) { + fn matches_workspace_filter( + &self, + entry: &ToplevelEntry, + workspace_filter: WorkspaceFilter, + ) -> bool { + matches_workspace_filter( + &self.active_workspace_coordinates, + &entry.workspace_coordinates, + workspace_filter, + ) + } + + async fn search(&mut self, query: &str, workspace_filter: WorkspaceFilter) { + if workspace_filter == WorkspaceFilter::Current + && self.active_workspace_coordinates.is_empty() + { + debug!( + "deferring workspace-filtered search until active workspaces are known" + ); + self.pending_workspace_search = Some(query.to_owned()); + send(&mut self.tx, PluginResponse::Finished).await; + let _ = self.tx.flush().await; + return; + } + + self.pending_workspace_search = None; + + let matched = self + .toplevels + .iter() + .filter(|t| self.matches_workspace_filter(t, workspace_filter)) + .count(); + debug!( + "workspace search: filter={workspace_filter:?} active_coords={:?} toplevels={} matched={}", + self.active_workspace_coordinates, + self.toplevels.len(), + matched + ); + fn contains_pattern(needle: &str, haystack: &[&str]) -> bool { let needle = needle.to_ascii_lowercase(); haystack.iter().all(|h| needle.contains(h)) @@ -183,7 +251,12 @@ impl App { let query = query.to_ascii_lowercase(); let haystack = query.split_ascii_whitespace().collect::>(); - for info in &self.toplevels { + for toplevel in &self.toplevels { + if !self.matches_workspace_filter(toplevel, workspace_filter) { + continue; + } + + let info = &toplevel.info; let retain = query.is_empty() || contains_pattern(&info.app_id, &haystack) || contains_pattern(&info.title, &haystack); @@ -194,22 +267,21 @@ impl App { let appid = fde::unicase::Ascii::new(info.app_id.as_str()); - let entry = fde::find_app_by_id(&self.desktop_entries, appid) + let desktop_entry = fde::find_app_by_id(&self.desktop_entries, appid) .map(ToOwned::to_owned) .unwrap_or_else(|| fde::DesktopEntry::from_appid(appid.to_string()).to_owned()); - let icon_name = if let Some(icon) = entry.icon() { + let icon_name = if let Some(icon) = desktop_entry.icon() { Cow::Owned(icon.to_owned()) } else { Cow::Borrowed("application-x-executable") }; let response = PluginResponse::Append(PluginSearchResult { - // XXX protocol id may be re-used later id: info.foreign_toplevel.id().protocol_id(), window: Some((0, info.foreign_toplevel.id().protocol_id())), description: info.title.clone(), - name: get_description(&entry, &self.locales), + name: get_description(&desktop_entry, &self.locales), icon: Some(IconSource::Name(icon_name)), ..Default::default() }); @@ -218,6 +290,82 @@ impl App { } send(&mut self.tx, PluginResponse::Finished).await; - let _ = self.tx.flush(); + let _ = self.tx.flush().await; } } + +fn matches_workspace_filter( + active_workspace_coordinates: &HashSet>, + entry_workspace_coordinates: &HashSet>, + workspace_filter: WorkspaceFilter, +) -> bool { + if workspace_filter == WorkspaceFilter::All { + return true; + } + + if active_workspace_coordinates.is_empty() || entry_workspace_coordinates.is_empty() { + return false; + } + + entry_workspace_coordinates + .iter() + .any(|coords| active_workspace_coordinates.contains(coords)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn coords(values: &[&[u32]]) -> HashSet> { + values.iter().map(|coords| (*coords).to_vec()).collect() + } + + #[test] + fn all_filter_matches_everything() { + let active = coords(&[&[1]]); + let entry = coords(&[&[2]]); + assert!(matches_workspace_filter( + &active, + &entry, + WorkspaceFilter::All + )); + } + + #[test] + fn current_filter_matches_shared_coordinates() { + let active = coords(&[&[1, 2]]); + let entry = coords(&[&[1, 2], &[3]]); + assert!(matches_workspace_filter( + &active, + &entry, + WorkspaceFilter::Current + )); + } + + #[test] + fn current_filter_rejects_other_workspaces() { + let active = coords(&[&[1]]); + let entry = coords(&[&[2]]); + assert!(!matches_workspace_filter( + &active, + &entry, + WorkspaceFilter::Current + )); + } + + #[test] + fn current_filter_rejects_missing_metadata() { + let active = coords(&[&[1]]); + let entry = coords(&[]); + assert!(!matches_workspace_filter( + &active, + &entry, + WorkspaceFilter::Current + )); + assert!(!matches_workspace_filter( + &coords(&[]), + &coords(&[&[1]]), + WorkspaceFilter::Current + )); + } +} \ No newline at end of file diff --git a/plugins/src/cosmic_toplevel/toplevel_handler.rs b/plugins/src/cosmic_toplevel/toplevel_handler.rs index 1b1d6a5..4a29da7 100644 --- a/plugins/src/cosmic_toplevel/toplevel_handler.rs +++ b/plugins/src/cosmic_toplevel/toplevel_handler.rs @@ -1,11 +1,15 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use cctk::{ cosmic_protocols, toplevel_info::{ToplevelInfo, ToplevelInfoHandler, ToplevelInfoState}, toplevel_management::{ToplevelManagerHandler, ToplevelManagerState}, - wayland_client::{self, WEnum}, - wayland_protocols::ext::foreign_toplevel_list::v1::client::ext_foreign_toplevel_handle_v1::ExtForeignToplevelHandleV1, + wayland_client::{self, Proxy, WEnum}, + wayland_protocols::ext::{ + foreign_toplevel_list::v1::client::ext_foreign_toplevel_handle_v1::ExtForeignToplevelHandleV1, + workspace::v1::client::ext_workspace_handle_v1, + }, + workspace::{WorkspaceHandler, WorkspaceState}, }; use sctk::{ self, @@ -31,8 +35,13 @@ pub enum ToplevelAction { } pub enum ToplevelUpdate { - Info(ToplevelInfo), + Info { + info: ToplevelInfo, + workspace_coordinates: HashSet>, + }, Remove(ExtForeignToplevelHandleV1), + /// Coordinates of currently active workspace(s). + ActiveWorkspaces(HashSet>), } struct AppData { @@ -41,8 +50,14 @@ struct AppData { registry_state: RegistryState, toplevel_info_state: ToplevelInfoState, toplevel_manager_state: ToplevelManagerState, + workspace_state: WorkspaceState, seat_state: SeatState, pending_update: HashSet, + /// Workspace coordinates keyed by handle protocol id. + /// + /// Toplevel and workspace protocols may hand out different handles for the + /// same workspace, so we track coordinates per handle as events arrive. + workspace_coords_by_id: HashMap>, } impl AppData { @@ -55,6 +70,63 @@ impl AppData { .cosmic_toplevel .as_ref() } + + fn active_workspace_coordinates(&self) -> HashSet> { + self.workspace_state + .workspaces() + .filter(|workspace| workspace.state.contains(ext_workspace_handle_v1::State::Active)) + .map(|workspace| workspace.coordinates.clone()) + .collect() + } + + fn sync_workspace_coords(&mut self) { + for workspace in self.workspace_state.workspaces() { + self.workspace_coords_by_id.insert( + workspace.handle.id().protocol_id(), + workspace.coordinates.clone(), + ); + } + } + + fn workspace_coordinates(&self, info: &ToplevelInfo) -> HashSet> { + let mut coordinates = info + .workspace + .iter() + .filter_map(|handle| { + self.workspace_state + .workspace_info(handle) + .map(|workspace| workspace.coordinates.clone()) + .or_else(|| { + self.workspace_coords_by_id + .get(&handle.id().protocol_id()) + .cloned() + }) + }) + .collect::>(); + + if coordinates.is_empty() && !info.workspace.is_empty() { + for workspace in self.workspace_state.workspaces() { + if info.workspace.iter().any(|handle| { + workspace.handle.id().protocol_id() == handle.id().protocol_id() + }) { + coordinates.insert(workspace.coordinates.clone()); + } + } + } + + coordinates + } + + fn send_active_workspaces(&self) { + if let Err(err) = self + .tx + .unbounded_send(vec![ToplevelUpdate::ActiveWorkspaces( + self.active_workspace_coordinates(), + )]) + { + warn!("{err}"); + } + } } impl ProvidesRegistryState for AppData { @@ -140,14 +212,25 @@ impl ToplevelInfoHandler for AppData { } fn info_done(&mut self, _conn: &Connection, _qh: &QueueHandle) { - let res = self - .pending_update - .drain() - .map(|handle| match self.toplevel_info_state.info(&handle) { - Some(info) => ToplevelUpdate::Info(info.clone()), - None => ToplevelUpdate::Remove(handle), - }) - .collect(); + self.sync_workspace_coords(); + let pending = self.pending_update.drain().collect::>(); + let mut res = Vec::with_capacity(pending.len()); + for handle in pending { + match self.toplevel_info_state.info(&handle) { + Some(info) => { + let workspace_coordinates = self.workspace_coordinates(info); + res.push(ToplevelUpdate::Info { + info: info.clone(), + workspace_coordinates, + }); + } + None => res.push(ToplevelUpdate::Remove(handle)), + } + } + + res.push(ToplevelUpdate::ActiveWorkspaces( + self.active_workspace_coordinates(), + )); if let Err(err) = self.tx.unbounded_send(res) { warn!("{err}"); @@ -155,6 +238,17 @@ impl ToplevelInfoHandler for AppData { } } +impl WorkspaceHandler for AppData { + fn workspace_state(&mut self) -> &mut WorkspaceState { + &mut self.workspace_state + } + + fn done(&mut self) { + self.sync_workspace_coords(); + self.send_active_workspaces(); + } +} + pub(crate) fn toplevel_handler( tx: UnboundedSender>, rx: calloop::channel::Channel, @@ -198,8 +292,10 @@ pub(crate) fn toplevel_handler( seat_state: SeatState::new(&globals, &qh), toplevel_info_state: ToplevelInfoState::new(®istry_state, &qh), toplevel_manager_state: ToplevelManagerState::new(®istry_state, &qh), + workspace_state: WorkspaceState::new(®istry_state, &qh), registry_state, pending_update: HashSet::new(), + workspace_coords_by_id: HashMap::new(), }; loop { @@ -214,3 +310,4 @@ sctk::delegate_seat!(AppData); sctk::delegate_registry!(AppData); cctk::delegate_toplevel_info!(AppData); cctk::delegate_toplevel_manager!(AppData); +cctk::delegate_workspace!(AppData); \ No newline at end of file diff --git a/plugins/src/desktop_entries/mod.rs b/plugins/src/desktop_entries/mod.rs index 4d9ee24..dfe567a 100644 --- a/plugins/src/desktop_entries/mod.rs +++ b/plugins/src/desktop_entries/mod.rs @@ -25,7 +25,9 @@ pub async fn main() { Request::Activate(id) => app.activate(id).await, Request::ActivateContext { id, context } => app.activate_context(id, context).await, Request::Context(id) => app.context(id).await, - Request::Search(query) => app.search(&query).await, + Request::Search(query) | Request::SearchFiltered { query, .. } => { + app.search(&query).await + } Request::Exit => break, _ => (), }, diff --git a/plugins/src/pop_shell/mod.rs b/plugins/src/pop_shell/mod.rs index d3137c4..fb3a1d1 100644 --- a/plugins/src/pop_shell/mod.rs +++ b/plugins/src/pop_shell/mod.rs @@ -39,8 +39,12 @@ pub async fn main() { } }; - let mut app = App::new(connection, async_stdout()); - app.reload().await; + let out = async_stdout(); + let mut app = App::new(connection, out); + if !app.reload().await { + let _ = crate::send(&mut app.tx, PluginResponse::Deactivate).await; + return; + } let mut requests = json_input_stream(async_stdin()); while let Some(request) = requests.next().await { @@ -48,7 +52,9 @@ pub async fn main() { Ok(request) => match request { Request::Activate(id) => app.activate(id).await, Request::Quit(id) => app.quit(id).await, - Request::Search(query) => app.search(&query).await, + Request::Search(query) | Request::SearchFiltered { query, .. } => { + app.search(&query).await + } Request::Exit => break, _ => (), }, @@ -90,13 +96,16 @@ impl App { .await } - async fn reload(&mut self) { - if let Ok(message) = self.call_method("WindowList", &()).await { - self.entries = message - .body() - .deserialize() - .expect("pop-shell returned invalid WindowList response"); - } + async fn reload(&mut self) -> bool { + let Ok(message) = self.call_method("WindowList", &()).await else { + return false; + }; + + let Ok(entries) = message.body().deserialize() else { + return false; + }; + self.entries = entries; + true } async fn activate(&mut self, id: u32) { diff --git a/service/src/lib.rs b/service/src/lib.rs index ecb679d..7e375e7 100644 --- a/service/src/lib.rs +++ b/service/src/lib.rs @@ -146,6 +146,7 @@ pub struct Service { associated_list: HashMap, awaiting_results: HashSet, last_query: String, + last_workspace_filter: pop_launcher::WorkspaceFilter, no_sort: bool, output: O, plugins: Slab, @@ -161,6 +162,7 @@ impl + Unpin> Service { associated_list: HashMap::new(), awaiting_results: HashSet::new(), last_query: String::new(), + last_workspace_filter: pop_launcher::WorkspaceFilter::default(), output, no_sort: false, plugins: Slab::new(), @@ -220,7 +222,17 @@ impl + Unpin> Service { match event { Event::Request(request) => { match request { - Request::Search(query) => self.search(query).await, + Request::Search(query) => { + self.last_workspace_filter = pop_launcher::WorkspaceFilter::All; + self.search(query).await; + } + Request::SearchFiltered { + query, + workspace_filter, + } => { + self.last_workspace_filter = workspace_filter; + self.search(query).await; + } Request::Interrupt => self.interrupt().await, Request::Activate(id) => self.activate(id).await, Request::ActivateContext { id, context } => { @@ -424,6 +436,7 @@ impl + Unpin> Service { let search_list = self.sort(); self.respond(Response::Update(search_list)).await; + self.active_search.clear(); } async fn interrupt(&mut self) { @@ -450,11 +463,9 @@ impl + Unpin> Service { async fn search(&mut self, query: String) { if !self.awaiting_results.is_empty() { tracing::debug!("backing off from search until plugins are ready"); - if !self.search_scheduled { - self.interrupt().await; - self.search_scheduled = true; - self.last_query = query; - } + self.interrupt().await; + self.search_scheduled = true; + self.last_query = query; return; } @@ -500,11 +511,20 @@ impl + Unpin> Service { query_queue.push(key); } + let workspace_filter = self.last_workspace_filter; + let plugin_request = move |query: String| match workspace_filter { + pop_launcher::WorkspaceFilter::All => Request::Search(query), + workspace_filter => Request::SearchFiltered { + query, + workspace_filter, + }, + }; + if let Some(isolated) = isolated { if let Some(plugin) = self.plugins.get_mut(isolated) { if plugin .sender_exec() - .send_async(Request::Search(query.to_owned())) + .send_async(plugin_request(query.to_owned())) .await .is_ok() { @@ -519,7 +539,7 @@ impl + Unpin> Service { if let Some(plugin) = self.plugins.get_mut(plugin_id) { if plugin .sender_exec() - .send_async(Request::Search(query.to_owned())) + .send_async(plugin_request(query.to_owned())) .await .is_ok() { diff --git a/service/src/plugins/external/mod.rs b/service/src/plugins/external/mod.rs index f61b7ed..a23b655 100644 --- a/service/src/plugins/external/mod.rs +++ b/service/src/plugins/external/mod.rs @@ -207,8 +207,15 @@ impl Plugin for ExternalPlugin { &self.name } - async fn search(&mut self, query: &str) { - if self.query(&Request::Search(query.to_owned())).await.is_ok() { + async fn search(&mut self, query: &str, workspace_filter: pop_launcher::WorkspaceFilter) { + let request = match workspace_filter { + pop_launcher::WorkspaceFilter::All => Request::Search(query.to_owned()), + workspace_filter => Request::SearchFiltered { + query: query.to_owned(), + workspace_filter, + }, + }; + if self.query(&request).await.is_ok() { self.searching.store(true, Ordering::SeqCst); } else { let _ = self diff --git a/service/src/plugins/help.rs b/service/src/plugins/help.rs index 1dbf1bc..876b5b7 100644 --- a/service/src/plugins/help.rs +++ b/service/src/plugins/help.rs @@ -80,7 +80,7 @@ impl Plugin for HelpPlugin { "help" } - async fn search(&mut self, _query: &str) { + async fn search(&mut self, _query: &str, _workspace_filter: WorkspaceFilter) { if self.details.is_empty() { self.reload().await; } diff --git a/service/src/plugins/mod.rs b/service/src/plugins/mod.rs index 24c1078..c0d5fcb 100644 --- a/service/src/plugins/mod.rs +++ b/service/src/plugins/mod.rs @@ -34,7 +34,7 @@ where fn name(&self) -> &str; - async fn search(&mut self, query: &str); + async fn search(&mut self, query: &str, workspace_filter: pop_launcher::WorkspaceFilter); async fn quit(&mut self, id: Indice); @@ -47,7 +47,14 @@ where request ); match request { - Request::Search(query) => self.search(&query).await, + Request::Search(query) => { + self.search(&query, pop_launcher::WorkspaceFilter::All) + .await; + } + Request::SearchFiltered { + query, + workspace_filter, + } => self.search(&query, workspace_filter).await, Request::Interrupt => self.interrupt().await, Request::Activate(id) => self.activate(id).await, Request::ActivateContext { id, context } => { diff --git a/src/lib.rs b/src/lib.rs index c63fb70..7c4e904 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -124,6 +124,17 @@ impl PluginSearchResult { } } +/// Limits which workspaces are considered when listing open windows. +#[derive(Debug, Default, Deserialize, Serialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceFilter { + /// Include windows from every workspace. + #[default] + All, + /// Include only windows on the currently active workspace(s). + Current, +} + // Sent to the input pipe of the launcher service, and disseminated to its plugins. #[derive(Debug, Deserialize, Serialize, Clone)] pub enum Request { @@ -146,6 +157,12 @@ pub enum Request { Quit(Indice), /// Perform a search in our database. Search(String), + /// Perform a search with optional workspace filtering for window results. + SearchFiltered { + query: String, + #[serde(default)] + workspace_filter: WorkspaceFilter, + }, } /// Sent from the launcher service to a frontend. diff --git a/toolkit/src/plugin_trait.rs b/toolkit/src/plugin_trait.rs index b25c317..cef7962 100644 --- a/toolkit/src/plugin_trait.rs +++ b/toolkit/src/plugin_trait.rs @@ -77,6 +77,7 @@ where match request { Ok(request) => match request { Request::Search(query) => self.search(&query).await, + Request::SearchFiltered { query, .. } => self.search(&query).await, Request::Interrupt => self.interrupt().await, Request::Activate(id) => self.activate(id).await, Request::ActivateContext { id, context } => {