From 0629b18e008a5ad7817f7b0215d573e152b2dc74 Mon Sep 17 00:00:00 2001 From: Ryan Brue Date: Sat, 30 Mar 2024 23:55:14 -0500 Subject: [PATCH] feat: app list click: two or more toplevel behavior --- Cargo.lock | 4 + cosmic-app-list/Cargo.toml | 4 + cosmic-app-list/src/app.rs | 442 +++++++++++++------- cosmic-app-list/src/wayland_handler.rs | 389 ++++++++++++++++- cosmic-app-list/src/wayland_subscription.rs | 22 +- 5 files changed, 697 insertions(+), 164 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e358e3ca..fb3c5aee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -873,16 +873,20 @@ dependencies = [ name = "cosmic-app-list" version = "0.1.0" dependencies = [ + "anyhow", "cosmic-client-toolkit", "cosmic-protocols", "futures", "i18n-embed", "i18n-embed-fl", + "image 0.25.0", "itertools", "libcosmic", + "memmap2 0.9.4", "once_cell", "rand", "rust-embed", + "rustix 0.38.32", "serde", "switcheroo-control", "tokio", diff --git a/cosmic-app-list/Cargo.toml b/cosmic-app-list/Cargo.toml index 6e381d94..4234d2b1 100644 --- a/cosmic-app-list/Cargo.toml +++ b/cosmic-app-list/Cargo.toml @@ -5,16 +5,20 @@ edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +anyhow.workspace = true cctk.workspace = true cosmic-protocols.workspace = true futures.workspace = true i18n-embed.workspace = true i18n-embed-fl.workspace = true +image = { version = "0.25.0", default-features = false } itertools = "0.12.1" libcosmic.workspace = true +memmap2 = "0.9.4" once_cell = "1.19" rand = "0.8.5" rust-embed.workspace = true +rustix.workspace = true serde = { version = "1.0", features = ["derive"] } switcheroo-control = { git = "https://github.com/pop-os/dbus-settings-bindings" } tokio = { version = "1.36.0", features = ["sync", "rt", "rt-multi-thread", "macros", "process"] } diff --git a/cosmic-app-list/src/app.rs b/cosmic-app-list/src/app.rs index 3ef42fbb..73050b2d 100755 --- a/cosmic-app-list/src/app.rs +++ b/cosmic-app-list/src/app.rs @@ -5,6 +5,7 @@ use crate::fl; use crate::wayland_subscription::wayland_subscription; use crate::wayland_subscription::ToplevelRequest; use crate::wayland_subscription::ToplevelUpdate; +use crate::wayland_subscription::WaylandImage; use crate::wayland_subscription::WaylandRequest; use crate::wayland_subscription::WaylandUpdate; use crate::wayland_subscription::WorkspaceUpdate; @@ -13,6 +14,7 @@ use cctk::toplevel_info::ToplevelInfo; use cctk::wayland_client::protocol::wl_data_device_manager::DndAction; use cctk::wayland_client::protocol::wl_seat::WlSeat; use cosmic::cosmic_config::{Config, CosmicConfigEntry}; +use cosmic::desktop::IconSource; use cosmic::desktop::{ app_id_or_fallback_matches, load_applications_for_app_ids, DesktopEntryData, }; @@ -39,7 +41,9 @@ use cosmic::iced_sctk::commands::data_device::request_dnd_data; use cosmic::iced_sctk::commands::data_device::set_actions; use cosmic::iced_sctk::commands::data_device::start_drag; use cosmic::iced_style::application; +use cosmic::iced_widget::button; use cosmic::theme::Button; +use cosmic::theme::Container; use cosmic::widget::divider; use cosmic::widget::rectangle_tracker::rectangle_tracker_subscription; use cosmic::widget::rectangle_tracker::RectangleTracker; @@ -48,6 +52,12 @@ use cosmic::{ applet::{cosmic_panel_config::PanelAnchor, Context}, Command, }; +use cosmic::{ + iced::{alignment::Vertical, Limits}, + iced_core::{layout, overlay, widget::Tree, Layout, Size, Vector}, + iced_widget::text, + widget::{image::Handle, Image, Widget}, +}; use cosmic::{Element, Theme}; use cosmic_protocols::toplevel_info::v1::client::zcosmic_toplevel_handle_v1::State; use cosmic_protocols::toplevel_info::v1::client::zcosmic_toplevel_handle_v1::ZcosmicToplevelHandleV1; @@ -99,7 +109,7 @@ pub fn load_applications_for_app_ids_sorted<'a, 'b>( #[derive(Debug, Clone, Default)] struct DockItem { id: u32, - toplevels: Vec<(ZcosmicToplevelHandleV1, ToplevelInfo)>, + toplevels: Vec<(ZcosmicToplevelHandleV1, ToplevelInfo, Option)>, desktop_info: DesktopEntryData, } @@ -122,7 +132,7 @@ impl DataFromMimeType for DockItem { impl DockItem { fn new( id: u32, - toplevels: Vec<(ZcosmicToplevelHandleV1, ToplevelInfo)>, + toplevels: Vec<(ZcosmicToplevelHandleV1, ToplevelInfo, Option)>, desktop_info: DesktopEntryData, ) -> Self { Self { @@ -170,53 +180,28 @@ impl DockItem { }) .collect_vec() } else { - if focused { - (0..min(toplevels.len(), 3)) - .map(|_| { - container(vertical_space(Length::Fixed(0.0))) - .padding(dot_radius) - .style(::Style::Custom(Box::new( - |theme| container::Appearance { - text_color: Some(Color::TRANSPARENT), - background: Some(Background::Color( - theme.cosmic().accent_color().into(), - )), - border: Border { - radius: 4.0.into(), - width: 0.0, - color: Color::TRANSPARENT, - }, - shadow: Shadow::default(), - icon_color: Some(Color::TRANSPARENT), + (0..min(toplevels.len(), 3)) + .map(|_| { + container(vertical_space(Length::Fixed(0.0))) + .padding(dot_radius) + .style(::Style::Custom(Box::new( + |theme| container::Appearance { + text_color: Some(Color::TRANSPARENT), + background: Some(Background::Color( + theme.cosmic().on_bg_color().into(), + )), + border: Border { + radius: 4.0.into(), + width: 0.0, + color: Color::TRANSPARENT, }, - ))) - .into() - }) - .collect_vec() - } else { - (0..min(toplevels.len(), 3)) - .map(|_| { - container(vertical_space(Length::Fixed(0.0))) - .padding(dot_radius) - .style(::Style::Custom(Box::new( - |theme| container::Appearance { - text_color: Some(Color::TRANSPARENT), - background: Some(Background::Color( - theme.cosmic().on_bg_color().into(), - )), - border: Border { - radius: 4.0.into(), - width: 0.0, - color: Color::TRANSPARENT, - }, - shadow: Shadow::default(), - icon_color: Some(Color::TRANSPARENT), - }, - ))) - .into() - }) - .collect_vec() - } + shadow: Shadow::default(), + icon_color: Some(Color::TRANSPARENT), + }, + ))) + .into() + }) + .collect_vec() }; let icon_wrapper: Element<_> = match applet.anchor { @@ -289,20 +274,14 @@ impl DockItem { } else if toplevels.len() == 1 { toplevels.first().map(|t| { if focused { - Message::Minimize(t.0.clone()) + // FIXME: Change to Message::Minimize once focus tracking is fixed + Message::Activate(t.0.clone()) } else { Message::Activate(t.0.clone()) } }) } else { - // TODO: Change this - toplevels.first().map(|t| { - if focused { - Message::Minimize(t.0.clone()) - } else { - Message::Activate(t.0.clone()) - } - }) + Some(Message::TopLevelListPopup(desktop_info.id.clone())) }) .width(Length::Shrink) .height(Length::Shrink), @@ -333,7 +312,7 @@ struct DndOffer { #[derive(Clone, Default)] struct CosmicAppList { core: cosmic::app::Core, - popup: Option<(window::Id, u32)>, + popup: Option<(window::Id, u32, PopupType)>, subscription_ctr: u32, item_ctr: u32, active_list: Vec, @@ -350,6 +329,12 @@ struct CosmicAppList { active_workspace: Option, } +#[derive(Clone, PartialEq)] +pub enum PopupType { + RightClickMenu, + TopLevelList, +} + // TODO DnD after sctk merges DnD #[derive(Debug, Clone)] enum Message { @@ -357,6 +342,7 @@ enum Message { Favorite(String), UnFavorite(String), Popup(String), + TopLevelListPopup(String), GpuRequest(Option>), CloseRequested(window::Id), ClosePopup, @@ -447,6 +433,77 @@ pub fn menu_button<'a, Message>( .width(Length::Fill) } +const TOPLEVEL_BUTTON_WIDTH: f32 = 160.0; +const TOPLEVEL_BUTTON_HEIGHT: f32 = 130.0; + +pub fn toplevel_button<'a, Msg>( + img: Option, + icon: &IconSource, + on_press: Msg, + title: String, + text_size: f32, +) -> cosmic::widget::Button<'a, Msg, cosmic::Theme, cosmic::Renderer> +where + Msg: 'static + Clone, +{ + let border = 1.0; + cosmic::widget::Button::new( + container( + column![ + container(if let Some(img) = img { + Element::from( + Image::new(Handle::from_pixels( + img.img.width(), + img.img.height(), + img.clone(), + )) + .width(Length::Fill) + .height(Length::Fill) + .content_fit(cosmic::iced_core::ContentFit::Contain), + ) + } else { + Element::from( + icon.as_cosmic_icon() + .width(Length::Fill) + .height(Length::Fill), + ) + }) + .style(Container::Custom(Box::new(move |theme| { + container::Appearance { + border: Border { + color: theme.cosmic().bg_divider().into(), + width: border, + radius: 0.0.into(), + }, + ..Default::default() + } + }))) + .padding(border as u16) + .height(Length::Fill) + .width(Length::Fill), + container( + text(title) + .size(text_size) + .horizontal_alignment(Horizontal::Center), + ) + .height(Length::Fixed(14.0)) + .width(Length::Fill) + .center_x(), + ] + .spacing(4) + .align_items(Alignment::Center), + ) + .align_x(cosmic::iced_core::alignment::Horizontal::Center) + .align_y(cosmic::iced_core::alignment::Vertical::Center) + .height(Length::Fill) + .width(Length::Fill), + ) + .on_press(on_press) + .style(Button::AppletMenu) + .width(Length::Fixed(TOPLEVEL_BUTTON_WIDTH)) + .height(Length::Fixed(TOPLEVEL_BUTTON_HEIGHT)) +} + pub fn menu_control_padding() -> Padding { let theme = cosmic::theme::active(); let cosmic = theme.cosmic(); @@ -509,7 +566,7 @@ impl cosmic::Application for CosmicAppList { ) -> iced::Command> { match message { Message::Popup(id) => { - if let Some((popup_id, _toplevel)) = self.popup.take() { + if let Some((popup_id, _toplevel, _)) = self.popup.take() { return destroy_popup(popup_id); } if let Some(toplevel_group) = self @@ -524,7 +581,7 @@ impl cosmic::Application for CosmicAppList { }; let new_id = window::Id::unique(); - self.popup = Some((new_id, toplevel_group.id)); + self.popup = Some((new_id, toplevel_group.id, PopupType::RightClickMenu)); let mut popup_settings = self.core.applet.get_popup_settings( window::Id::MAIN, @@ -552,6 +609,51 @@ impl cosmic::Application for CosmicAppList { return Command::batch([gpu_update, get_popup(popup_settings)]); } } + Message::TopLevelListPopup(id) => { + if let Some((popup_id, _toplevel, _)) = self.popup.take() { + return destroy_popup(popup_id); + } + if let Some(toplevel_group) = self + .active_list + .iter() + .chain(self.favorite_list.iter()) + .find(|t| t.desktop_info.id == id) + { + let rectangle = match self.rectangles.get(&toplevel_group.id) { + Some(r) => r, + None => return Command::none(), + }; + + let new_id = window::Id::unique(); + self.popup = Some((new_id, toplevel_group.id, PopupType::TopLevelList)); + + let mut popup_settings = self.core.applet.get_popup_settings( + window::Id::MAIN, + new_id, + None, + None, + None, + ); + let iced::Rectangle { + x, + y, + width, + height, + } = *rectangle; + popup_settings.positioner.anchor_rect = iced::Rectangle:: { + x: x as i32, + y: y as i32, + width: width as i32, + height: height as i32, + }; + popup_settings.positioner.size_limits = Limits::NONE + .max_width(56.0 + (TOPLEVEL_BUTTON_WIDTH + 12.0) * 7.0) + .min_width(30.0) + .min_height(100.0) + .max_height(16.0 + TOPLEVEL_BUTTON_HEIGHT); + return get_popup(popup_settings); + } + } Message::Favorite(id) => { if let Some(i) = self .active_list @@ -564,7 +666,7 @@ impl cosmic::Application for CosmicAppList { self.config .add_favorite(id, &Config::new(APP_ID, AppListConfig::VERSION).unwrap()); - if let Some((popup_id, _toplevel)) = self.popup.take() { + if let Some((popup_id, _toplevel, _)) = self.popup.take() { return destroy_popup(popup_id); } } @@ -584,7 +686,7 @@ impl cosmic::Application for CosmicAppList { self.active_list.push(entry); } } - if let Some((popup_id, _toplevel)) = self.popup.take() { + if let Some((popup_id, _toplevel, _)) = self.popup.take() { return destroy_popup(popup_id); } } @@ -611,7 +713,7 @@ impl cosmic::Application for CosmicAppList { .chain(self.favorite_list.iter()) .find(|t| t.desktop_info.id == id) { - for (handle, _) in &toplevel_group.toplevels { + for (handle, _, _) in &toplevel_group.toplevels { if let Some(tx) = self.wayland_sender.as_ref() { let _ = tx.send(WaylandRequest::Toplevel(ToplevelRequest::Quit( handle.clone(), @@ -619,7 +721,7 @@ impl cosmic::Application for CosmicAppList { } } } - if let Some((popup_id, _toplevel)) = self.popup.take() { + if let Some((popup_id, _toplevel, _)) = self.popup.take() { return destroy_popup(popup_id); } } @@ -793,6 +895,22 @@ impl cosmic::Application for CosmicAppList { WaylandUpdate::Init(tx) => { self.wayland_sender.replace(tx); } + WaylandUpdate::Image(handle, img) => { + 'img_update: for x in self + .active_list + .iter_mut() + .chain(self.favorite_list.iter_mut()) + { + if let Some((_, _, ref mut handle_img)) = x + .toplevels + .iter_mut() + .find(|(toplevel_handle, _, _)| toplevel_handle.clone() == handle) + { + *handle_img = Some(img); + break 'img_update; + } + } + } WaylandUpdate::Finished => { for t in &mut self.favorite_list { t.toplevels.clear(); @@ -826,7 +944,7 @@ impl cosmic::Application for CosmicAppList { app_id_or_fallback_matches(&info.app_id, desktop_info) }) { - t.toplevels.push((handle, info)); + t.toplevels.push((handle, info, None)); } else { if info.app_id.is_empty() { info.app_id = format!("Unknown Application {}", self.item_ctr); @@ -842,7 +960,7 @@ impl cosmic::Application for CosmicAppList { .unwrap(); self.active_list.push(DockItem { id: self.item_ctr, - toplevels: vec![(handle, info)], + toplevels: vec![(handle, info, None)], desktop_info, }); } @@ -853,7 +971,7 @@ impl cosmic::Application for CosmicAppList { .iter_mut() .chain(self.favorite_list.iter_mut()) { - t.toplevels.retain(|(t_handle, _)| t_handle != &handle); + t.toplevels.retain(|(t_handle, _, _)| t_handle != &handle); } self.active_list.retain(|t| !t.toplevels.is_empty()); } @@ -867,7 +985,7 @@ impl cosmic::Application for CosmicAppList { .iter_mut() .chain(self.favorite_list.iter_mut()) { - for (t_handle, t_info) in &mut toplevel_list.toplevels { + for (t_handle, t_info, _) in &mut toplevel_list.toplevels { if &handle == t_handle { *t_info = info; break 'toplevel_loop; @@ -1162,7 +1280,8 @@ impl cosmic::Application for CosmicAppList { .as_cosmic_icon() .size(self.core.applet.suggested_size().0) .into() - } else if let Some((_popup_id, id)) = self.popup.as_ref().filter(|p| id == p.0) { + } else if let Some((_popup_id, id, popup_type)) = self.popup.as_ref().filter(|p| id == p.0) + { let Some(DockItem { toplevels, desktop_info, @@ -1175,91 +1294,116 @@ impl cosmic::Application for CosmicAppList { else { return iced::widget::text("").into(); }; + match popup_type { + PopupType::RightClickMenu => { + let is_favorite = self + .config + .favorites + .iter() + .any(|x| app_id_or_fallback_matches(&x, desktop_info)); - let is_favorite = self - .config - .favorites - .iter() - .any(|x| app_id_or_fallback_matches(&x, desktop_info)); + let mut content = column![container( + iced::widget::text(&desktop_info.name) + .horizontal_alignment(Horizontal::Center) + ) + .padding(menu_control_padding()),] + .padding([8, 0]) + .align_items(Alignment::Center); - let mut content = column![container( - iced::widget::text(&desktop_info.name).horizontal_alignment(Horizontal::Center) - ) - .padding(menu_control_padding()),] - .padding([8, 0]) - .align_items(Alignment::Center); - - if let Some(exec) = desktop_info.exec.clone() { - if !toplevels.is_empty() { - content = content.push( - menu_button(iced::widget::text(fl!("new-window"))) - .on_press(Message::Exec(exec, None)), - ); - } else if let Some(gpus) = self.gpus.as_ref() { - let default_idx = if desktop_info.prefers_dgpu { - gpus.iter().position(|gpu| !gpu.default).unwrap_or(0) - } else { - gpus.iter().position(|gpu| gpu.default).unwrap_or(0) - }; - for (i, gpu) in gpus.iter().enumerate() { - content = content.push( - menu_button(iced::widget::text(format!( - "{} {}", - fl!("run-on", gpu = gpu.name.clone()), - if i == default_idx { - fl!("run-on-default") - } else { - String::new() - } - ))) - .on_press(Message::Exec(exec.clone(), Some(i))), - ); + if let Some(exec) = desktop_info.exec.clone() { + if !toplevels.is_empty() { + content = content.push( + menu_button(iced::widget::text(fl!("new-window"))) + .on_press(Message::Exec(exec, None)), + ); + } else if let Some(gpus) = self.gpus.as_ref() { + let default_idx = if desktop_info.prefers_dgpu { + gpus.iter().position(|gpu| !gpu.default).unwrap_or(0) + } else { + gpus.iter().position(|gpu| gpu.default).unwrap_or(0) + }; + for (i, gpu) in gpus.iter().enumerate() { + content = content.push( + menu_button(iced::widget::text(format!( + "{} {}", + fl!("run-on", gpu = gpu.name.clone()), + if i == default_idx { + fl!("run-on-default") + } else { + String::new() + } + ))) + .on_press(Message::Exec(exec.clone(), Some(i))), + ); + } + } else { + content = content.push( + menu_button(iced::widget::text(fl!("run"))) + .on_press(Message::Exec(exec, None)), + ); + } + content = content.push(divider::horizontal::default()); } - } else { - content = content.push( - menu_button(iced::widget::text(fl!("run"))) - .on_press(Message::Exec(exec, None)), - ); - } - content = content.push(divider::horizontal::default()); - } - if !toplevels.is_empty() { - let mut list_col = column![]; - for (handle, info) in toplevels { - let title = if info.title.len() > 20 { - format!("{:.24}...", &info.title) + if !toplevels.is_empty() { + let mut list_col = column![]; + for (handle, info, _) in toplevels { + let title = if info.title.len() > 20 { + format!("{:.24}...", &info.title) + } else { + info.title.clone() + }; + list_col = list_col.push( + menu_button(iced::widget::text(title)) + .on_press(Message::Activate(handle.clone())), + ); + } + content = content.push(list_col); + content = content.push(divider::horizontal::default()); + } + content = content.push(if is_favorite { + menu_button(iced::widget::text(fl!("unfavorite"))) + .on_press(Message::UnFavorite(desktop_info.id.clone())) } else { - info.title.clone() - }; - list_col = list_col.push( - menu_button(iced::widget::text(title)) - .on_press(Message::Activate(handle.clone())), - ); - } - content = content.push(list_col); - content = content.push(divider::horizontal::default()); - } - content = content.push(if is_favorite { - menu_button(iced::widget::text(fl!("unfavorite"))) - .on_press(Message::UnFavorite(desktop_info.id.clone())) - } else { - menu_button(iced::widget::text(fl!("favorite"))) - .on_press(Message::Favorite(desktop_info.id.clone())) - }); + menu_button(iced::widget::text(fl!("favorite"))) + .on_press(Message::Favorite(desktop_info.id.clone())) + }); - content = match toplevels.len() { - 0 => content, - 1 => content.push( - menu_button(iced::widget::text(fl!("quit"))) - .on_press(Message::Quit(desktop_info.id.clone())), - ), - _ => content.push( - menu_button(iced::widget::text(&fl!("quit-all"))) - .on_press(Message::Quit(desktop_info.id.clone())), - ), - }; - self.core.applet.popup_container(content).into() + content = match toplevels.len() { + 0 => content, + 1 => content.push( + menu_button(iced::widget::text(fl!("quit"))) + .on_press(Message::Quit(desktop_info.id.clone())), + ), + _ => content.push( + menu_button(iced::widget::text(&fl!("quit-all"))) + .on_press(Message::Quit(desktop_info.id.clone())), + ), + }; + self.core.applet.popup_container(content).into() + } + PopupType::TopLevelList => { + let mut content = row![] + .padding([8, 12]) + .align_items(Alignment::Center) + .spacing(12); + for (handle, info, img) in toplevels { + let title = if info.title.len() > 18 { + format!("{:.15}...", &info.title) + } else { + info.title.clone() + }; + content = content.push(toplevel_button( + img.clone(), + &desktop_info.icon, + Message::Activate(handle.clone()), + title, + 10.0, + )); + } + self.core.applet.popup_container(content).into() + } + } } else { let suggested = self.core.applet.suggested_size(); iced::widget::row!() @@ -1332,7 +1476,7 @@ impl CosmicAppList { } let active_workspace = self.active_workspace.as_ref().unwrap().clone(); for toplevel_list in self.active_list.iter().chain(self.favorite_list.iter()) { - for (t_handle, t_info) in &toplevel_list.toplevels { + for (t_handle, t_info, _) in &toplevel_list.toplevels { if t_info.workspace.contains(&active_workspace) && t_info.state.contains(&State::Activated) { diff --git a/cosmic-app-list/src/wayland_handler.rs b/cosmic-app-list/src/wayland_handler.rs index 8d985a7d..2a2821eb 100644 --- a/cosmic-app-list/src/wayland_handler.rs +++ b/cosmic-app-list/src/wayland_handler.rs @@ -1,43 +1,62 @@ use crate::wayland_subscription::{ - ToplevelRequest, ToplevelUpdate, WaylandRequest, WaylandUpdate, WorkspaceUpdate, + ToplevelRequest, ToplevelUpdate, WaylandImage, WaylandRequest, WaylandUpdate, WorkspaceUpdate, }; -use std::os::{ - fd::{FromRawFd, RawFd}, - unix::net::UnixStream, +use std::{ + os::{ + fd::{AsFd, FromRawFd, RawFd}, + unix::net::UnixStream, + }, + sync::{Arc, Condvar, Mutex, MutexGuard}, }; use cctk::{ + screencopy::{ + capture, Formats, Frame, ScreencopyFrameData, ScreencopyFrameDataExt, ScreencopyHandler, + ScreencopySessionData, ScreencopySessionDataExt, ScreencopyState, + }, sctk::{ self, activation::{RequestData, RequestDataExt}, output::{OutputHandler, OutputState}, reexports::{calloop, calloop_wayland_source::WaylandSource}, seat::{SeatHandler, SeatState}, + shm::{Shm, ShmHandler}, }, toplevel_info::{ToplevelInfoHandler, ToplevelInfoState}, toplevel_management::{ToplevelManagerHandler, ToplevelManagerState}, wayland_client::{ - self, - protocol::{wl_output, wl_seat::WlSeat, wl_surface::WlSurface}, - WEnum, + globals::registry_queue_init, + protocol::{ + wl_buffer, wl_output, + wl_seat::WlSeat, + wl_shm::{self, WlShm}, + wl_shm_pool, + wl_surface::WlSurface, + }, + Connection, Dispatch, Proxy, QueueHandle, WEnum, }, workspace::{WorkspaceHandler, WorkspaceState}, }; use cosmic_protocols::{ - toplevel_info::v1::client::zcosmic_toplevel_handle_v1, + image_source::v1::client::zcosmic_toplevel_image_source_manager_v1::ZcosmicToplevelImageSourceManagerV1, + screencopy::v2::client::{ + zcosmic_screencopy_frame_v2, zcosmic_screencopy_manager_v2, zcosmic_screencopy_session_v2, + }, + toplevel_info::v1::client::zcosmic_toplevel_handle_v1::{ + self, State as ToplevelUpdateState, ZcosmicToplevelHandleV1, + }, toplevel_management::v1::client::zcosmic_toplevel_manager_v1, - workspace::v1::client::zcosmic_workspace_handle_v1::State, + workspace::v1::client::zcosmic_workspace_handle_v1::State as WorkspaceUpdateState, }; use futures::channel::mpsc::UnboundedSender; use sctk::{ activation::{ActivationHandler, ActivationState}, registry::{ProvidesRegistryState, RegistryState}, }; -use wayland_client::{globals::registry_queue_init, Connection, QueueHandle}; - struct AppData { exit: bool, tx: UnboundedSender, + conn: Connection, queue_handle: QueueHandle, registry_state: RegistryState, activation_state: Option, @@ -45,9 +64,13 @@ struct AppData { toplevel_manager_state: ToplevelManagerState, seat_state: SeatState, workspace_state: WorkspaceState, + shm_state: Shm, + screencopy_state: ScreencopyState, output_state: OutputState, } +// Workspace and toplevel handling + // Need to bind output globals just so workspace can get output events impl OutputHandler for AppData { fn output_state(&mut self) -> &mut OutputState { @@ -87,7 +110,10 @@ impl WorkspaceHandler for AppData { fn done(&mut self) { 'workspaces_loop: for group in self.workspace_state.workspace_groups() { for workspace in &group.workspaces { - if workspace.state.contains(&WEnum::Value(State::Active)) { + if workspace + .state + .contains(&WEnum::Value(WorkspaceUpdateState::Active)) + { let _ = self.tx .unbounded_send(WaylandUpdate::Workspace(WorkspaceUpdate::Enter( @@ -194,6 +220,9 @@ impl ToplevelInfoHandler for AppData { toplevel: &zcosmic_toplevel_handle_v1::ZcosmicToplevelHandleV1, ) { if let Some(info) = self.toplevel_info_state.info(toplevel) { + // spawn thread for sending the image + self.send_image(toplevel.clone()); + let _ = self .tx .unbounded_send(WaylandUpdate::Toplevel(ToplevelUpdate::Add( @@ -210,6 +239,8 @@ impl ToplevelInfoHandler for AppData { toplevel: &zcosmic_toplevel_handle_v1::ZcosmicToplevelHandleV1, ) { if let Some(info) = self.toplevel_info_state.info(toplevel) { + // spawn thread for sending the image + self.send_image(toplevel.clone()); let _ = self .tx .unbounded_send(WaylandUpdate::Toplevel(ToplevelUpdate::Update( @@ -233,6 +264,327 @@ impl ToplevelInfoHandler for AppData { } } +// Screencopy handling + +#[derive(Default)] +struct SessionInner { + formats: Option, + res: Option>>, +} + +// TODO: dmabuf? need to handle modifier negotation +#[derive(Default)] +struct Session { + condvar: Condvar, + inner: Mutex, +} + +#[derive(Default)] +struct SessionData { + session: Arc, + session_data: ScreencopySessionData, +} + +struct FrameData { + frame_data: ScreencopyFrameData, + session: zcosmic_screencopy_session_v2::ZcosmicScreencopySessionV2, +} + +impl Session { + pub fn for_session( + session: &zcosmic_screencopy_session_v2::ZcosmicScreencopySessionV2, + ) -> Option<&Self> { + Some(&session.data::()?.session) + } + + fn update(&self, f: F) { + f(&mut self.inner.lock().unwrap()); + self.condvar.notify_all(); + } + + fn wait_while bool>(&self, mut f: F) -> MutexGuard { + self.condvar + .wait_while(self.inner.lock().unwrap(), |data| f(data)) + .unwrap() + } +} + +impl ScreencopySessionDataExt for SessionData { + fn screencopy_session_data(&self) -> &ScreencopySessionData { + &self.session_data + } +} + +impl ScreencopyFrameDataExt for FrameData { + fn screencopy_frame_data(&self) -> &ScreencopyFrameData { + &self.frame_data + } +} + +impl Dispatch for AppData { + fn event( + _app_data: &mut Self, + _buffer: &wl_shm_pool::WlShmPool, + _event: wl_shm_pool::Event, + _: &(), + _: &Connection, + _qh: &QueueHandle, + ) { + } +} + +impl Dispatch for AppData { + fn event( + _app_data: &mut Self, + _buffer: &wl_buffer::WlBuffer, + _event: wl_buffer::Event, + _: &(), + _: &Connection, + _qh: &QueueHandle, + ) { + } +} + +struct CaptureData { + qh: QueueHandle, + conn: Connection, + wl_shm: WlShm, + screencopy_manager: zcosmic_screencopy_manager_v2::ZcosmicScreencopyManagerV2, + toplevel_source_manager: ZcosmicToplevelImageSourceManagerV1, +} + +impl CaptureData { + pub fn capture_source_shm_fd( + &self, + overlay_cursor: bool, + source: ZcosmicToplevelHandleV1, + fd: Fd, + len: Option, + ) -> Option> { + // XXX error type? + // TODO: way to get cursor metadata? + + #[allow(unused_variables)] // TODO + let overlay_cursor = if overlay_cursor { 1 } else { 0 }; + + let session = Arc::new(Session::default()); + let image_source = self + .toplevel_source_manager + .create_source(&source, &self.qh, ()); + let screencopy_session = self.screencopy_manager.create_session( + &image_source, + zcosmic_screencopy_manager_v2::Options::empty(), + &self.qh, + SessionData { + session: session.clone(), + session_data: Default::default(), + }, + ); + self.conn.flush().unwrap(); + + let formats = session + .wait_while(|data| data.formats.is_none()) + .formats + .take() + .unwrap(); + let (width, height) = formats.buffer_size; + + // XXX + if !formats + .shm_formats + .contains(&wl_shm::Format::Abgr8888.into()) + { + tracing::error!("No suitable buffer format found"); + tracing::warn!("Available formats: {:#?}", formats); + return None; + }; + + let buf_len = width * height * 4; + if let Some(len) = len { + if len != buf_len { + return None; + } + } else if let Err(_err) = rustix::fs::ftruncate(&fd, buf_len as _) { + }; + let pool = self + .wl_shm + .create_pool(fd.as_fd(), buf_len as i32, &self.qh, ()); + let buffer = pool.create_buffer( + 0, + width as i32, + height as i32, + width as i32 * 4, + wl_shm::Format::Abgr8888, + &self.qh, + (), + ); + + capture( + &screencopy_session, + &buffer, + &[], + &self.qh, + FrameData { + frame_data: Default::default(), + session: screencopy_session.clone(), + }, + ); + self.conn.flush().unwrap(); + + // TODO: wait for server to release buffer? + let res = session + .wait_while(|data| data.res.is_none()) + .res + .take() + .unwrap(); + pool.destroy(); + buffer.destroy(); + + //std::thread::sleep(std::time::Duration::from_millis(16)); + + if res.is_ok() { + Some(ShmImage { fd, width, height }) + } else { + None + } + } +} + +pub struct ShmImage { + fd: T, + pub width: u32, + pub height: u32, +} + +impl ShmImage { + pub fn image(&self) -> anyhow::Result { + let mmap = unsafe { memmap2::Mmap::map(&self.fd.as_fd())? }; + image::RgbaImage::from_raw(self.width, self.height, mmap.to_vec()) + .ok_or_else(|| anyhow::anyhow!("ShmImage had incorrect size")) + } +} + +impl AppData { + fn send_image(&self, handle: ZcosmicToplevelHandleV1) { + let tx = self.tx.clone(); + let capture_data = CaptureData { + qh: self.queue_handle.clone(), + conn: self.conn.clone(), + wl_shm: self.shm_state.wl_shm().clone(), + screencopy_manager: self.screencopy_state.screencopy_manager.clone(), + toplevel_source_manager: self + .screencopy_state + .toplevel_source_manager + .clone() + .unwrap(), + }; + std::thread::spawn(move || { + use std::ffi::CStr; + let name = unsafe { CStr::from_bytes_with_nul_unchecked(b"app-list-screencopy\0") }; + let Ok(fd) = rustix::fs::memfd_create(name, rustix::fs::MemfdFlags::CLOEXEC) else { + tracing::error!("Failed to get fd for capture"); + return; + }; + + // XXX is this going to use to much memory? + let img = capture_data.capture_source_shm_fd(false, handle.clone(), fd, None); + if let Some(img) = img { + let Ok(img) = img.image() else { + tracing::error!("Failed to get RgbaImage"); + return; + }; + + // resize to 128x128 + let max = img.width().max(img.height()); + let ratio = max as f32 / 128.0; + + let img = if ratio > 1.0 { + let new_width = (img.width() as f32 / ratio).round(); + let new_height = (img.height() as f32 / ratio).round(); + + image::imageops::resize( + &img, + new_width as u32, + new_height as u32, + image::imageops::FilterType::Lanczos3, + ) + } else { + img + }; + + if let Err(err) = + tx.unbounded_send(WaylandUpdate::Image(handle, WaylandImage::new(img))) + { + tracing::error!("Failed to send image event to subscription {err:?}"); + }; + } else { + tracing::error!("Failed to capture image"); + } + }); + } +} + +impl ShmHandler for AppData { + fn shm_state(&mut self) -> &mut Shm { + &mut self.shm_state + } +} + +impl ScreencopyHandler for AppData { + fn screencopy_state(&mut self) -> &mut ScreencopyState { + &mut self.screencopy_state + } + + fn init_done( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + session: &zcosmic_screencopy_session_v2::ZcosmicScreencopySessionV2, + formats: &Formats, + ) { + Session::for_session(session).unwrap().update(|data| { + data.formats = Some(formats.clone()); + }); + } + + fn ready( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + screencopy_frame: &zcosmic_screencopy_frame_v2::ZcosmicScreencopyFrameV2, + _frame: Frame, + ) { + let session = &screencopy_frame.data::().unwrap().session; + Session::for_session(session).unwrap().update(|data| { + data.res = Some(Ok(())); + }); + session.destroy(); + } + + fn failed( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + screencopy_frame: &zcosmic_screencopy_frame_v2::ZcosmicScreencopyFrameV2, + reason: WEnum, + ) { + // TODO send message to thread + let session = &screencopy_frame.data::().unwrap().session; + Session::for_session(session).unwrap().update(|data| { + data.res = Some(Err(reason)); + }); + session.destroy(); + } + + fn stopped( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + _session: &zcosmic_screencopy_session_v2::ZcosmicScreencopySessionV2, + ) { + } +} + pub(crate) fn wayland_handler( tx: UnboundedSender, rx: calloop::channel::Channel, @@ -254,7 +606,7 @@ pub(crate) fn wayland_handler( let mut event_loop = calloop::EventLoop::::try_new().unwrap(); let qh = event_queue.handle(); - let wayland_source = WaylandSource::new(conn, event_queue); + let wayland_source = WaylandSource::new(conn.clone(), event_queue); let handle = event_loop.handle(); wayland_source .insert(handle.clone()) @@ -319,9 +671,13 @@ pub(crate) fn wayland_handler( return; } let registry_state = RegistryState::new(&globals); + let screencopy_state = ScreencopyState::new(&globals, &qh); + let shm_state = Shm::bind(&globals, &qh).expect("Failed to get shm state"); + let mut app_data = AppData { exit: false, tx, + conn, queue_handle: qh.clone(), activation_state: ActivationState::bind::(&globals, &qh).ok(), seat_state: SeatState::new(&globals, &qh), @@ -329,6 +685,8 @@ pub(crate) fn wayland_handler( toplevel_manager_state: ToplevelManagerState::new(®istry_state, &qh), output_state: OutputState::new(&globals, &qh), workspace_state: WorkspaceState::new(®istry_state, &qh), + shm_state, + screencopy_state, registry_state, }; @@ -340,11 +698,14 @@ pub(crate) fn wayland_handler( } } -sctk::delegate_activation!(AppData, ExecRequestData); +sctk::delegate_shm!(AppData); sctk::delegate_seat!(AppData); sctk::delegate_registry!(AppData); cctk::delegate_toplevel_info!(AppData); cctk::delegate_toplevel_manager!(AppData); +cctk::delegate_screencopy!(AppData, session: [SessionData], frame: [FrameData]); + +sctk::delegate_activation!(AppData, ExecRequestData); sctk::delegate_output!(AppData); cctk::delegate_workspace!(AppData); diff --git a/cosmic-app-list/src/wayland_subscription.rs b/cosmic-app-list/src/wayland_subscription.rs index 20607299..e00e2dbe 100644 --- a/cosmic-app-list/src/wayland_subscription.rs +++ b/cosmic-app-list/src/wayland_subscription.rs @@ -8,12 +8,14 @@ use cosmic::iced; use cosmic::iced::subscription; use cosmic_protocols::toplevel_info::v1::client::zcosmic_toplevel_handle_v1::ZcosmicToplevelHandleV1; use cosmic_protocols::workspace::v1::client::zcosmic_workspace_handle_v1::ZcosmicWorkspaceHandleV1; +use image::EncodableLayout; + use futures::{ channel::mpsc::{unbounded, UnboundedReceiver}, SinkExt, StreamExt, }; use once_cell::sync::Lazy; -use std::fmt::Debug; +use std::{fmt::Debug, sync::Arc}; use tokio::sync::Mutex; use crate::wayland_handler::wayland_handler; @@ -40,6 +42,23 @@ pub enum State { Finished, } +#[derive(Debug, Clone)] +pub struct WaylandImage { + pub img: Arc, +} + +impl WaylandImage { + pub fn new(img: image::RgbaImage) -> Self { + Self { img: Arc::new(img) } + } +} + +impl AsRef<[u8]> for WaylandImage { + fn as_ref(&self) -> &[u8] { + self.img.as_bytes() + } +} + async fn start_listening( state: State, output: &mut futures::channel::mpsc::Sender, @@ -86,6 +105,7 @@ pub enum WaylandUpdate { exec: String, gpu_idx: Option, }, + Image(ZcosmicToplevelHandleV1, WaylandImage), } #[derive(Clone, Debug)]