Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 173 additions & 25 deletions plugins/src/cosmic_toplevel/mod.rs
Original file line number Diff line number Diff line change
@@ -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};

Expand All @@ -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};
Expand Down Expand Up @@ -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,
Expand All @@ -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;
}
}
}
}
}
Expand All @@ -107,11 +128,18 @@ pub async fn main() {
}
}

struct ToplevelEntry {
info: ToplevelInfo,
workspace_coordinates: HashSet<Vec<u32>>,
}

struct App<W> {
locales: Vec<String>,
desktop_entries: Vec<DesktopEntry>,
ids_to_ignore: Vec<u32>,
toplevels: Vec<Box<ToplevelInfo>>,
toplevels: Vec<ToplevelEntry>,
active_workspace_coordinates: HashSet<Vec<u32>>,
pending_workspace_search: Option<String>,
calloop_tx: calloop::channel::Sender<ToplevelAction>,
tx: W,
}
Expand All @@ -135,6 +163,8 @@ impl<W: AsyncWrite + Unpin> App<W> {
desktop_entries,
ids_to_ignore: Vec::new(),
toplevels: Vec::new(),
active_workspace_coordinates: HashSet::new(),
pending_workspace_search: None,
calloop_tx,
tx,
},
Expand All @@ -148,8 +178,8 @@ impl<W: AsyncWrite + Unpin> App<W> {
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
}
Expand All @@ -164,8 +194,8 @@ impl<W: AsyncWrite + Unpin> App<W> {
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
}
Expand All @@ -174,7 +204,45 @@ impl<W: AsyncWrite + Unpin> App<W> {
}
}

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))
Expand All @@ -183,7 +251,12 @@ impl<W: AsyncWrite + Unpin> App<W> {
let query = query.to_ascii_lowercase();
let haystack = query.split_ascii_whitespace().collect::<Vec<&str>>();

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);
Expand All @@ -194,22 +267,21 @@ impl<W: AsyncWrite + Unpin> App<W> {

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()
});
Expand All @@ -218,6 +290,82 @@ impl<W: AsyncWrite + Unpin> App<W> {
}

send(&mut self.tx, PluginResponse::Finished).await;
let _ = self.tx.flush();
let _ = self.tx.flush().await;
}
}

fn matches_workspace_filter(
active_workspace_coordinates: &HashSet<Vec<u32>>,
entry_workspace_coordinates: &HashSet<Vec<u32>>,
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<Vec<u32>> {
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
));
}
}
Loading