From 090bb9653f433c76d2473eaff6762fca86ce356f Mon Sep 17 00:00:00 2001 From: Ashley Wulber <48420062+wash2@users.noreply.github.com> Date: Mon, 4 Mar 2024 13:08:52 -0500 Subject: [PATCH] minimize applet (#217) * chore: add minimize applet skeleton * fix(minimize): desktop typo * wip: minimize applet * feat: include window images, and overlay their icon * cleanup * fix: add minimize applet to workspace * chore: add host wayland display to desktop file for minimize applet * chore: Cargo.lock * cleanup: fix typos * fix: don't hide minimized apps in app-list --- Cargo.lock | 22 + Cargo.toml | 2 + cosmic-applet-minimize/Cargo.toml | 25 + .../com.system76.CosmicAppletMinimize.desktop | 15 + .../com.system76.CosmicAppletMinimize.svg | 29 + cosmic-applet-minimize/i18n.toml | 4 + .../i18n/en/cosmic_applet_minimize.ftl | 0 cosmic-applet-minimize/src/localize.rs | 47 ++ cosmic-applet-minimize/src/main.rs | 187 ++++++ cosmic-applet-minimize/src/wayland_handler.rs | 555 ++++++++++++++++++ .../src/wayland_subscription.rs | 121 ++++ cosmic-applet-minimize/src/window_image.rs | 289 +++++++++ cosmic-applet-network/Cargo.toml | 2 +- cosmic-applet-notifications/Cargo.toml | 2 +- cosmic-applet-workspaces/Cargo.toml | 2 +- justfile | 3 +- 16 files changed, 1301 insertions(+), 4 deletions(-) create mode 100644 cosmic-applet-minimize/Cargo.toml create mode 100644 cosmic-applet-minimize/data/com.system76.CosmicAppletMinimize.desktop create mode 100644 cosmic-applet-minimize/data/icons/scalable/apps/com.system76.CosmicAppletMinimize.svg create mode 100644 cosmic-applet-minimize/i18n.toml create mode 100644 cosmic-applet-minimize/i18n/en/cosmic_applet_minimize.ftl create mode 100644 cosmic-applet-minimize/src/localize.rs create mode 100644 cosmic-applet-minimize/src/main.rs create mode 100644 cosmic-applet-minimize/src/wayland_handler.rs create mode 100644 cosmic-applet-minimize/src/wayland_subscription.rs create mode 100644 cosmic-applet-minimize/src/window_image.rs diff --git a/Cargo.lock b/Cargo.lock index 6f97815c..47fe8b6e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -927,6 +927,28 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "cosmic-applet-minimize" +version = "0.1.1" +dependencies = [ + "anyhow", + "i18n-embed 0.13.9", + "i18n-embed-fl 0.6.7", + "image", + "libcosmic", + "memmap2 0.9.4", + "once_cell", + "png", + "rust-embed 6.8.1", + "rust-embed-utils 7.8.1", + "rustix 0.38.31", + "tempfile", + "tokio", + "tracing", + "tracing-log", + "tracing-subscriber", +] + [[package]] name = "cosmic-applet-network" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 006caf4c..41a6b3bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "cosmic-applet-audio", "cosmic-applet-battery", "cosmic-applet-bluetooth", + "cosmic-applet-minimize", "cosmic-applet-network", "cosmic-applet-notifications", "cosmic-applet-power", @@ -18,6 +19,7 @@ members = [ resolver = "2" [workspace.dependencies] +anyhow = "1.0.79" cctk = { git = "https://github.com/pop-os/cosmic-protocols", package = "cosmic-client-toolkit", rev = "e65fa5e" } cosmic-protocols = { git = "https://github.com/pop-os/cosmic-protocols", default-features = false, features = [ "client", diff --git a/cosmic-applet-minimize/Cargo.toml b/cosmic-applet-minimize/Cargo.toml new file mode 100644 index 00000000..1dd1b9ac --- /dev/null +++ b/cosmic-applet-minimize/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "cosmic-applet-minimize" +version = "0.1.1" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +anyhow.workspace = true +image = "0.24" +libcosmic.workspace = true +memmap2 = "0.9.0" +rustix = { version = "0.38.0", features = ["fs"] } +png = "0.17.5" +tokio = { version = "1.17.0", features = ["sync", "macros"] } +tracing = "0.1.40" +tracing-subscriber.workspace = true +tracing-log.workspace = true +tempfile = "3.5.0" +# Application i18n +i18n-embed = { version = "0.13", features = ["fluent-system", "desktop-requester"] } +i18n-embed-fl = "0.6" +rust-embed = "6.6" +rust-embed-utils = "7.5.0" +once_cell = "1" diff --git a/cosmic-applet-minimize/data/com.system76.CosmicAppletMinimize.desktop b/cosmic-applet-minimize/data/com.system76.CosmicAppletMinimize.desktop new file mode 100644 index 00000000..076c90b9 --- /dev/null +++ b/cosmic-applet-minimize/data/com.system76.CosmicAppletMinimize.desktop @@ -0,0 +1,15 @@ +[Desktop Entry] +Name=Cosmic Applet Minimize Windows +Comment=Applet for Cosmic Panel +Type=Application +Exec=cosmic-applet-minimize +Terminal=false +Categories=Cosmic;Iced; +Keywords=Cosmic;Iced; +# Translators: Do NOT translate or transliterate this text (this is an icon file name)! +Icon=com.system76.CosmicAppletMinimize +StartupNotify=true +NoDisplay=true +X-CosmicApplet=true +X-MinimizeApplet=true +X-HostWaylandDisplay=true diff --git a/cosmic-applet-minimize/data/icons/scalable/apps/com.system76.CosmicAppletMinimize.svg b/cosmic-applet-minimize/data/icons/scalable/apps/com.system76.CosmicAppletMinimize.svg new file mode 100644 index 00000000..7d1b1a58 --- /dev/null +++ b/cosmic-applet-minimize/data/icons/scalable/apps/com.system76.CosmicAppletMinimize.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/cosmic-applet-minimize/i18n.toml b/cosmic-applet-minimize/i18n.toml new file mode 100644 index 00000000..05c50ba2 --- /dev/null +++ b/cosmic-applet-minimize/i18n.toml @@ -0,0 +1,4 @@ +fallback_language = "en" + +[fluent] +assets_dir = "i18n" \ No newline at end of file diff --git a/cosmic-applet-minimize/i18n/en/cosmic_applet_minimize.ftl b/cosmic-applet-minimize/i18n/en/cosmic_applet_minimize.ftl new file mode 100644 index 00000000..e69de29b diff --git a/cosmic-applet-minimize/src/localize.rs b/cosmic-applet-minimize/src/localize.rs new file mode 100644 index 00000000..44521f68 --- /dev/null +++ b/cosmic-applet-minimize/src/localize.rs @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: MPL-2.0-only + +use i18n_embed::{ + fluent::{fluent_language_loader, FluentLanguageLoader}, + DefaultLocalizer, LanguageLoader, Localizer, +}; +use once_cell::sync::Lazy; +use rust_embed::RustEmbed; + +#[derive(RustEmbed)] +#[folder = "i18n/"] +struct Localizations; + +pub static LANGUAGE_LOADER: Lazy = Lazy::new(|| { + let loader: FluentLanguageLoader = fluent_language_loader!(); + + loader + .load_fallback_language(&Localizations) + .expect("Error while loading fallback language"); + + loader +}); + +#[macro_export] +macro_rules! fl { + ($message_id:literal) => {{ + i18n_embed_fl::fl!($crate::localize::LANGUAGE_LOADER, $message_id) + }}; + + ($message_id:literal, $($args:expr),*) => {{ + i18n_embed_fl::fl!($crate::localize::LANGUAGE_LOADER, $message_id, $($args), *) + }}; +} + +// Get the `Localizer` to be used for localizing this library. +pub fn localizer() -> Box { + Box::from(DefaultLocalizer::new(&*LANGUAGE_LOADER, &Localizations)) +} + +pub fn localize() { + let localizer = localizer(); + let requested_languages = i18n_embed::DesktopLanguageRequester::requested_languages(); + + if let Err(error) = localizer.select(&requested_languages) { + eprintln!("Error while loading language for Minimize {}", error); + } +} diff --git a/cosmic-applet-minimize/src/main.rs b/cosmic-applet-minimize/src/main.rs new file mode 100644 index 00000000..5b6abaac --- /dev/null +++ b/cosmic-applet-minimize/src/main.rs @@ -0,0 +1,187 @@ +mod localize; +pub(crate) mod wayland_handler; +pub(crate) mod wayland_subscription; +pub(crate) mod window_image; + +use crate::localize::localize; +use cosmic::app::Command; +use cosmic::applet::cosmic_panel_config::PanelAnchor; +use cosmic::cctk::cosmic_protocols::toplevel_info::v1::client::zcosmic_toplevel_handle_v1::ZcosmicToplevelHandleV1; +use cosmic::cctk::sctk::reexports::calloop; +use cosmic::cctk::toplevel_info::ToplevelInfo; +use cosmic::desktop::DesktopEntryData; +use cosmic::iced::{widget::text, Length, Subscription}; + +use cosmic::iced_style::application; +use cosmic::iced_widget::{Column, Row}; + +use cosmic::widget::tooltip; +use cosmic::{Element, Theme}; +use wayland_subscription::{ + ToplevelRequest, ToplevelUpdate, WaylandImage, WaylandRequest, WaylandUpdate, +}; + +const VERSION: &str = env!("CARGO_PKG_VERSION"); + +pub fn main() -> cosmic::iced::Result { + tracing_subscriber::fmt::init(); + let _ = tracing_log::LogTracer::init(); + + // Prepare i18n + localize(); + + tracing::info!("Starting minimize applet with version {VERSION}"); + + cosmic::applet::run::(true, ()) +} + +#[derive(Default)] +struct Minimize { + core: cosmic::app::Core, + apps: Vec<( + ZcosmicToplevelHandleV1, + ToplevelInfo, + DesktopEntryData, + Option, + )>, + tx: Option>, +} + +#[derive(Debug, Clone)] +enum Message { + Wayland(WaylandUpdate), + Activate(ZcosmicToplevelHandleV1), +} + +impl cosmic::Application for Minimize { + type Message = Message; + type Executor = cosmic::SingleThreadExecutor; + type Flags = (); + const APP_ID: &'static str = "com.system76.CosmicAppletMinimize"; + + fn init(core: cosmic::app::Core, _flags: ()) -> (Self, Command) { + ( + Self { + core, + ..Default::default() + }, + Command::none(), + ) + } + + fn core(&self) -> &cosmic::app::Core { + &self.core + } + + fn core_mut(&mut self) -> &mut cosmic::app::Core { + &mut self.core + } + + fn style(&self) -> Option<::Style> { + Some(cosmic::applet::style()) + } + + fn update(&mut self, message: Message) -> Command { + match message { + Message::Wayland(update) => match update { + WaylandUpdate::Init(tx) => { + self.tx = Some(tx); + } + WaylandUpdate::Finished => { + panic!("Wayland Subscription ended...") + } + WaylandUpdate::Toplevel(t) => match t { + ToplevelUpdate::Add(handle, info) | ToplevelUpdate::Update(handle, info) => { + let data = |id| { + cosmic::desktop::load_applications_for_app_ids( + None, + std::iter::once(id), + true, + ) + .remove(0) + }; + if let Some(pos) = self.apps.iter_mut().position(|a| a.0 == handle) { + if self.apps[pos].1.app_id != info.app_id { + self.apps[pos].2 = data(&info.app_id) + } + self.apps[pos].1 = info; + } else { + let data = data(&info.app_id); + self.apps.push((handle, info, data, None)); + } + } + ToplevelUpdate::Remove(handle) => self.apps.retain(|a| a.0 != handle), + }, + WaylandUpdate::Image(handle, img) => { + if let Some(pos) = self.apps.iter().position(|a| a.0 == handle) { + self.apps[pos].3 = Some(img); + } + } + }, + Message::Activate(handle) => { + if let Some(tx) = self.tx.as_ref() { + let _ = tx.send(WaylandRequest::Toplevel(ToplevelRequest::Activate(handle))); + } + } + }; + Command::none() + } + + fn subscription(&self) -> Subscription { + wayland_subscription::wayland_subscription().map(Message::Wayland) + } + + fn view(&self) -> Element { + let (width, _) = self.core.applet.suggested_size(); + let theme = self.core.system_theme().cosmic(); + let space_xxs = theme.space_xxs(); + let icon_buttons = self.apps.iter().map(|(handle, _, data, img)| { + tooltip( + Element::from(crate::window_image::WindowImage::new( + img.clone(), + &data.icon, + width as f32, + Message::Activate(handle.clone()), + space_xxs, + )), + data.name.clone(), + // tooltip::Position::FollowCursor, + // FIXME tooltip fails to appear when created as indicated in design + // maybe it should be a subsurface + match self.core.applet.anchor { + PanelAnchor::Left => tooltip::Position::Right, + PanelAnchor::Right => tooltip::Position::Left, + PanelAnchor::Top => tooltip::Position::Bottom, + PanelAnchor::Bottom => tooltip::Position::Top, + }, + ) + .snap_within_viewport(false) + .text_shaping(text::Shaping::Advanced) + .into() + }); + + // TODO optional dividers on ends if detects app list neighbor + // not sure the best way to tell if there is an adjacent app-list + + if matches!( + self.core.applet.anchor, + PanelAnchor::Top | PanelAnchor::Bottom + ) { + Row::with_children(icon_buttons) + .align_items(cosmic::iced_core::Alignment::Center) + .height(Length::Shrink) + .width(Length::Shrink) + .spacing(space_xxs) + .padding([0, space_xxs]) + .into() + } else { + Column::with_children(icon_buttons) + .align_items(cosmic::iced_core::Alignment::Center) + .height(Length::Shrink) + .width(Length::Shrink) + .spacing(space_xxs) + .padding([space_xxs, 0]) + .into() + } + } +} diff --git a/cosmic-applet-minimize/src/wayland_handler.rs b/cosmic-applet-minimize/src/wayland_handler.rs new file mode 100644 index 00000000..d17a6a3b --- /dev/null +++ b/cosmic-applet-minimize/src/wayland_handler.rs @@ -0,0 +1,555 @@ +use crate::wayland_subscription::{ + ToplevelRequest, ToplevelUpdate, WaylandImage, WaylandRequest, WaylandUpdate, +}; +use std::{ + os::{ + fd::{AsFd, FromRawFd, RawFd}, + unix::net::UnixStream, + }, + sync::{Arc, Condvar, Mutex, MutexGuard}, +}; + +use cctk::{ + sctk::{ + self, + reexports::{calloop, calloop_wayland_source::WaylandSource}, + seat::{SeatHandler, SeatState}, + }, + toplevel_info::{ToplevelInfoHandler, ToplevelInfoState}, + toplevel_management::{ToplevelManagerHandler, ToplevelManagerState}, + wayland_client::{self, protocol::wl_seat::WlSeat, WEnum}, +}; +use cosmic::{ + cctk::{ + self, + cosmic_protocols::{ + self, + screencopy::v1::client::{ + zcosmic_screencopy_manager_v1, zcosmic_screencopy_session_v1, + }, + toplevel_info::v1::client::zcosmic_toplevel_handle_v1::ZcosmicToplevelHandleV1, + }, + screencopy::{ + BufferInfo, ScreencopyHandler, ScreencopySessionData, ScreencopySessionDataExt, + ScreencopyState, + }, + sctk::shm::{Shm, ShmHandler}, + wayland_client::{ + protocol::{ + wl_buffer, + wl_shm::{self, WlShm}, + wl_shm_pool, + }, + Dispatch, Proxy, + }, + }, + iced_futures::futures, +}; +use cosmic_protocols::{ + toplevel_info::v1::client::zcosmic_toplevel_handle_v1, + toplevel_management::v1::client::zcosmic_toplevel_manager_v1, +}; +use futures::channel::mpsc::UnboundedSender; +use sctk::registry::{ProvidesRegistryState, RegistryState}; +use wayland_client::{globals::registry_queue_init, Connection, QueueHandle}; + +#[derive(Default)] +struct SessionInner { + buffer_infos: 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, +} + +impl Session { + pub fn for_session( + session: &zcosmic_screencopy_session_v1::ZcosmicScreencopySessionV1, + ) -> 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 + } +} +struct AppData { + exit: bool, + tx: UnboundedSender, + queue_handle: QueueHandle, + conn: Connection, + screencopy_state: ScreencopyState, + shm_state: Shm, + registry_state: RegistryState, + toplevel_info_state: ToplevelInfoState, + toplevel_manager_state: ToplevelManagerState, + seat_state: SeatState, +} + +struct CaptureData { + qh: QueueHandle, + conn: Connection, + wl_shm: WlShm, + screencopy_manager: zcosmic_screencopy_manager_v1::ZcosmicScreencopyManagerV1, +} + +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 screencopy_session = self.screencopy_manager.capture_toplevel( + &source, + zcosmic_screencopy_manager_v1::CursorMode::Hidden, // XXX take into account adventised capabilities + &self.qh, + SessionData { + session: session.clone(), + session_data: Default::default(), + }, + ); + self.conn.flush().unwrap(); + + let buffer_infos = session + .wait_while(|data| data.buffer_infos.is_none()) + .buffer_infos + .take() + .unwrap(); + + // XXX + let Some(buffer_info) = buffer_infos.iter().find(|x| { + x.type_ == WEnum::Value(zcosmic_screencopy_session_v1::BufferType::WlShm) + && x.format == wl_shm::Format::Abgr8888.into() + }) else { + tracing::error!("No suitable buffer format found"); + tracing::warn!("Available formats: {:#?}", buffer_infos); + return None; + }; + + let buf_len = buffer_info.stride * buffer_info.height; + 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, + buffer_info.width as i32, + buffer_info.height as i32, + buffer_info.stride as i32, + wl_shm::Format::Abgr8888, + &self.qh, + (), + ); + + screencopy_session.attach_buffer(&buffer, None, 0); // XXX age? + screencopy_session.commit(zcosmic_screencopy_session_v1::Options::empty()); + 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: buffer_info.width, + height: buffer_info.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 ProvidesRegistryState for AppData { + fn registry(&mut self) -> &mut RegistryState { + &mut self.registry_state + } + + sctk::registry_handlers!(); +} + +impl SeatHandler for AppData { + fn seat_state(&mut self) -> &mut sctk::seat::SeatState { + &mut self.seat_state + } + + fn new_seat(&mut self, _: &Connection, _: &QueueHandle, _: WlSeat) {} + + fn new_capability( + &mut self, + _: &Connection, + _: &QueueHandle, + _: WlSeat, + _: sctk::seat::Capability, + ) { + } + + fn remove_capability( + &mut self, + _: &Connection, + _: &QueueHandle, + _: WlSeat, + _: sctk::seat::Capability, + ) { + } + + fn remove_seat(&mut self, _: &Connection, _: &QueueHandle, _: WlSeat) {} +} + +impl ToplevelManagerHandler for AppData { + fn toplevel_manager_state(&mut self) -> &mut cctk::toplevel_management::ToplevelManagerState { + &mut self.toplevel_manager_state + } + + fn capabilities( + &mut self, + _: &Connection, + _: &QueueHandle, + _: Vec>, + ) { + // TODO capabilities could affect the options in the applet + } +} +impl AppData { + fn send_image(&self, handle: ZcosmicToplevelHandleV1) { + let tx = self.tx.clone(); + let capure_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(), + }; + std::thread::spawn(move || { + use std::ffi::CStr; + let name = + unsafe { CStr::from_bytes_with_nul_unchecked(b"minimize-applet-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 = capure_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 ToplevelInfoHandler for AppData { + fn toplevel_info_state(&mut self) -> &mut ToplevelInfoState { + &mut self.toplevel_info_state + } + + fn new_toplevel( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + toplevel: &zcosmic_toplevel_handle_v1::ZcosmicToplevelHandleV1, + ) { + if let Some(info) = self.toplevel_info_state.info(toplevel) { + if info + .state + .contains(&zcosmic_toplevel_handle_v1::State::Minimized) + { + // spawn thread for sending the image + self.send_image(toplevel.clone()); + let _ = self + .tx + .unbounded_send(WaylandUpdate::Toplevel(ToplevelUpdate::Add( + toplevel.clone(), + info.clone(), + ))); + } else { + let _ = self + .tx + .unbounded_send(WaylandUpdate::Toplevel(ToplevelUpdate::Remove( + toplevel.clone(), + ))); + } + } + } + + fn update_toplevel( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + toplevel: &zcosmic_toplevel_handle_v1::ZcosmicToplevelHandleV1, + ) { + if let Some(info) = self.toplevel_info_state.info(toplevel) { + if info + .state + .contains(&zcosmic_toplevel_handle_v1::State::Minimized) + { + self.send_image(toplevel.clone()); + let _ = self + .tx + .unbounded_send(WaylandUpdate::Toplevel(ToplevelUpdate::Update( + toplevel.clone(), + info.clone(), + ))); + } else { + let _ = self + .tx + .unbounded_send(WaylandUpdate::Toplevel(ToplevelUpdate::Remove( + toplevel.clone(), + ))); + } + } + } + + fn toplevel_closed( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + toplevel: &zcosmic_toplevel_handle_v1::ZcosmicToplevelHandleV1, + ) { + let _ = self + .tx + .unbounded_send(WaylandUpdate::Toplevel(ToplevelUpdate::Remove( + toplevel.clone(), + ))); + } +} + +pub(crate) fn wayland_handler( + tx: UnboundedSender, + rx: calloop::channel::Channel, +) { + let socket = std::env::var("X_PRIVILEGED_WAYLAND_SOCKET") + .ok() + .and_then(|fd| { + fd.parse::() + .ok() + .map(|fd| unsafe { UnixStream::from_raw_fd(fd) }) + }); + + let conn = if let Some(socket) = socket { + Connection::from_socket(socket).unwrap() + } else { + Connection::connect_to_env().unwrap() + }; + let (globals, event_queue) = registry_queue_init(&conn).unwrap(); + + let mut event_loop = calloop::EventLoop::::try_new().unwrap(); + let qh = event_queue.handle(); + let wayland_source = WaylandSource::new(conn.clone(), event_queue); + let handle = event_loop.handle(); + wayland_source + .insert(handle.clone()) + .expect("Failed to insert wayland source."); + + if handle + .insert_source(rx, |event, _, state| match event { + calloop::channel::Event::Msg(req) => match req { + WaylandRequest::Toplevel(req) => match req { + ToplevelRequest::Activate(handle) => { + if let Some(seat) = state.seat_state.seats().next() { + let manager = &state.toplevel_manager_state.manager; + manager.activate(&handle, &seat); + } + } + }, + }, + calloop::channel::Event::Closed => { + state.exit = true; + } + }) + .is_err() + { + 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(), + shm_state, + screencopy_state, + seat_state: SeatState::new(&globals, &qh), + toplevel_info_state: ToplevelInfoState::new(®istry_state, &qh), + toplevel_manager_state: ToplevelManagerState::new(®istry_state, &qh), + registry_state, + }; + + loop { + if app_data.exit { + break; + } + event_loop.dispatch(None, &mut app_data).unwrap(); + } +} + +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_v1::ZcosmicScreencopySessionV1, + buffer_infos: &[BufferInfo], + ) { + Session::for_session(session).unwrap().update(|data| { + data.buffer_infos = Some(buffer_infos.to_vec()); + }); + } + + fn ready( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + session: &zcosmic_screencopy_session_v1::ZcosmicScreencopySessionV1, + ) { + Session::for_session(session).unwrap().update(|data| { + data.res = Some(Ok(())); + }); + session.destroy(); + } + + fn failed( + &mut self, + _conn: &Connection, + _qh: &QueueHandle, + session: &zcosmic_screencopy_session_v1::ZcosmicScreencopySessionV1, + reason: WEnum, + ) { + // TODO send message to thread + Session::for_session(session).unwrap().update(|data| { + data.res = Some(Err(reason)); + }); + session.destroy(); + } +} + +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, + ) { + } +} + +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]); diff --git a/cosmic-applet-minimize/src/wayland_subscription.rs b/cosmic-applet-minimize/src/wayland_subscription.rs new file mode 100644 index 00000000..2372a6ee --- /dev/null +++ b/cosmic-applet-minimize/src/wayland_subscription.rs @@ -0,0 +1,121 @@ +//! # DBus interface proxy for: `org.freedesktop.UPower.KbdBacklight` +//! +//! This code was generated by `zbus-xmlgen` `2.0.1` from DBus introspection data. +//! Source: `Interface '/org/freedesktop/UPower/KbdBacklight' from service 'org.freedesktop.UPower' on system bus`. +use cctk::sctk::reexports::calloop; +use cctk::toplevel_info::ToplevelInfo; +use cosmic::cctk::cosmic_protocols; +use cosmic::iced::subscription; +use cosmic::iced_futures::futures; +use cosmic::{cctk, iced}; +use cosmic_protocols::toplevel_info::v1::client::zcosmic_toplevel_handle_v1::ZcosmicToplevelHandleV1; +use futures::{ + channel::mpsc::{unbounded, UnboundedReceiver}, + SinkExt, StreamExt, +}; +use image::EncodableLayout; +use once_cell::sync::Lazy; +use std::fmt::Debug; +use std::sync::Arc; +use tokio::sync::Mutex; + +use crate::wayland_handler::wayland_handler; + +pub static WAYLAND_RX: Lazy>>> = + Lazy::new(|| Mutex::new(None)); + +pub fn wayland_subscription() -> iced::Subscription { + subscription::channel( + std::any::TypeId::of::(), + 50, + move |mut output| async move { + let mut state = State::Waiting; + + loop { + state = start_listening(state, &mut output).await; + } + }, + ) +} + +pub enum State { + Waiting, + Finished, +} + +async fn start_listening( + state: State, + output: &mut futures::channel::mpsc::Sender, +) -> State { + match state { + State::Waiting => { + let mut guard = WAYLAND_RX.lock().await; + let rx = { + if guard.is_none() { + let (calloop_tx, calloop_rx) = calloop::channel::channel(); + let (toplevel_tx, toplevel_rx) = unbounded(); + let _ = std::thread::spawn(move || { + wayland_handler(toplevel_tx, calloop_rx); + }); + *guard = Some(toplevel_rx); + _ = output.send(WaylandUpdate::Init(calloop_tx)).await; + } + guard.as_mut().unwrap() + }; + match rx.next().await { + Some(u) => { + _ = output.send(u).await; + State::Waiting + } + None => { + _ = output.send(WaylandUpdate::Finished).await; + tracing::error!("Wayland handler thread died"); + State::Finished + } + } + } + State::Finished => iced::futures::future::pending().await, + } +} + +#[derive(Clone, Debug)] +pub enum WaylandUpdate { + Init(calloop::channel::Sender), + Finished, + Toplevel(ToplevelUpdate), + Image(ZcosmicToplevelHandleV1, WaylandImage), +} + +#[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() + } +} + +#[derive(Clone, Debug)] +pub enum ToplevelUpdate { + Add(ZcosmicToplevelHandleV1, ToplevelInfo), + Update(ZcosmicToplevelHandleV1, ToplevelInfo), + Remove(ZcosmicToplevelHandleV1), +} + +#[derive(Clone, Debug)] +pub enum WaylandRequest { + Toplevel(ToplevelRequest), +} + +#[derive(Debug, Clone)] +pub enum ToplevelRequest { + Activate(ZcosmicToplevelHandleV1), +} diff --git a/cosmic-applet-minimize/src/window_image.rs b/cosmic-applet-minimize/src/window_image.rs new file mode 100644 index 00000000..d9d9f652 --- /dev/null +++ b/cosmic-applet-minimize/src/window_image.rs @@ -0,0 +1,289 @@ +use cosmic::{ + desktop::IconSource, + iced::Limits, + iced_core::{layout, overlay, widget::Tree, Border, Layout, Length, Size, Vector}, + theme::{Button, Container}, + widget::{button, container, image::Handle, Image, Widget}, + Element, +}; + +use crate::wayland_subscription::WaylandImage; + +pub struct WindowImage<'a, Msg> { + image_button: Element<'a, Msg>, + icon: Element<'a, Msg>, +} + +impl<'a, Msg> WindowImage<'a, Msg> +where + Msg: 'static + Clone, +{ + pub fn new( + img: Option, + icon: &IconSource, + size: f32, + on_press: Msg, + padding: u16, + ) -> Self { + let border = 1.0; + Self { + image_button: button( + container( + container(if let Some(img) = img { + let max_dim = img.img.width().max(img.img.height()).max(1); + let ratio = max_dim as f32 / (size - border * 2.0).max(1.0); + let adjusted_width = img.img.width() as f32 / ratio; + let adjusted_height = img.img.height() as f32 / ratio; + + Element::from( + Image::new(Handle::from_pixels( + img.img.width(), + img.img.height(), + img.clone(), + )) + .width(Length::Fixed(adjusted_width)) + .height(Length::Fixed(adjusted_height)) + .content_fit(cosmic::iced_core::ContentFit::Contain), + ) + } else { + Element::from( + icon.as_cosmic_icon() + .width(Length::Fixed(size)) + .height(Length::Fixed(size)), + ) + }) + .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::Shrink) + .width(Length::Shrink), + ) + .align_x(cosmic::iced_core::alignment::Horizontal::Center) + .align_y(cosmic::iced_core::alignment::Vertical::Center) + .height(Length::Fixed(size)) + .width(Length::Fixed(size)), + ) + .on_press(on_press) + .width(Length::Shrink) + .height(Length::Shrink) + .style(Button::AppletIcon) + .padding(padding) + .into(), + icon: icon + .as_cosmic_icon() + .width(Length::Fixed(size / 3.0)) + .height(Length::Fixed(size / 3.0)) + .into(), + } + } +} + +impl<'a, Msg> Widget for WindowImage<'a, Msg> { + fn children(&self) -> Vec { + vec![Tree::new(&self.image_button), Tree::new(&self.icon)] + } + + fn diff(&mut self, tree: &mut cosmic::iced_core::widget::Tree) { + tree.diff_children(&mut [&mut self.image_button, &mut self.icon]) + } + + fn overlay<'b>( + &'b mut self, + state: &'b mut Tree, + layout: Layout<'_>, + renderer: &cosmic::Renderer, + ) -> Option> { + let children = [&mut self.image_button, &mut self.icon] + .into_iter() + .zip(&mut state.children) + .zip(layout.children()) + .filter_map(|((child, state), layout)| { + child.as_widget_mut().overlay(state, layout, renderer) + }) + .collect::>(); + + (!children.is_empty()).then(|| overlay::Group::with_children(children).overlay()) + } + + fn size(&self) -> Size { + Size::new(Length::Shrink, Length::Shrink) + } + + fn layout( + &self, + tree: &mut cosmic::iced_core::widget::Tree, + renderer: &cosmic::Renderer, + limits: &cosmic::iced_core::layout::Limits, + ) -> cosmic::iced_core::layout::Node { + let children = &mut tree.children; + let button = &mut children[0]; + let button_node = self + .image_button + .as_widget() + .layout(button, renderer, limits); + + let button_bounds = button_node.size(); + let icon_width = button_bounds.width / 3.0; + let icon_height = button_bounds.height / 3.0; + let icon = &mut children[1]; + let icon_node = self + .icon + .as_widget() + .layout( + icon, + renderer, + &Limits::NONE.width(icon_width).height(icon_height), + ) + .translate(Vector::new(2. * icon_width, 2. * icon_height)); + + layout::Node::with_children( + limits.resolve(Length::Shrink, Length::Shrink, button_node.size()), + vec![button_node, icon_node], + ) + } + + fn draw( + &self, + tree: &cosmic::iced_core::widget::Tree, + renderer: &mut cosmic::Renderer, + theme: &cosmic::Theme, + style: &cosmic::iced_core::renderer::Style, + layout: cosmic::iced_core::Layout<'_>, + cursor: cosmic::iced_core::mouse::Cursor, + viewport: &cosmic::iced_core::Rectangle, + ) { + let children = &[&self.image_button, &self.icon]; + // draw children in order + for (i, (layout, child)) in layout.children().zip(children).enumerate() { + let tree = &tree.children[i]; + child + .as_widget() + .draw(tree, renderer, theme, style, layout, cursor, viewport); + } + } + + fn size_hint(&self) -> Size { + self.size() + } + + fn tag(&self) -> cosmic::iced_core::widget::tree::Tag { + cosmic::iced_core::widget::tree::Tag::stateless() + } + + fn state(&self) -> cosmic::iced_core::widget::tree::State { + cosmic::iced_core::widget::tree::State::None + } + + fn operate( + &self, + tree: &mut cosmic::iced_core::widget::Tree, + layout: cosmic::iced_core::Layout<'_>, + renderer: &cosmic::Renderer, + operation: &mut dyn cosmic::widget::Operation< + cosmic::iced_core::widget::OperationOutputWrapper, + >, + ) { + let layout = layout.children().collect::>(); + let children = [&self.image_button, &self.icon]; + for (i, (layout, child)) in layout + .into_iter() + .zip(children.into_iter()) + .enumerate() + .rev() + { + let tree = &mut tree.children[i]; + child.as_widget().operate(tree, layout, renderer, operation); + } + } + + fn on_event( + &mut self, + state: &mut cosmic::iced_core::widget::Tree, + event: cosmic::iced_core::Event, + layout: cosmic::iced_core::Layout<'_>, + cursor: cosmic::iced_core::mouse::Cursor, + renderer: &cosmic::Renderer, + clipboard: &mut dyn cosmic::iced_core::Clipboard, + shell: &mut cosmic::iced_core::Shell<'_, Msg>, + viewport: &cosmic::iced_core::Rectangle, + ) -> cosmic::iced_core::event::Status { + let children = [&mut self.image_button, &mut self.icon]; + + let layout = layout.children().collect::>(); + // draw children in order + let mut status = cosmic::iced_core::event::Status::Ignored; + for (i, (layout, child)) in layout + .into_iter() + .zip(children.into_iter()) + .enumerate() + .rev() + { + let tree = &mut state.children[i]; + + status = child.as_widget_mut().on_event( + tree, + event.clone(), + layout, + cursor, + renderer, + clipboard, + shell, + viewport, + ); + if matches!(status, cosmic::iced_core::event::Status::Captured) { + return status; + } + } + status + } + + fn mouse_interaction( + &self, + state: &cosmic::iced_core::widget::Tree, + layout: cosmic::iced_core::Layout<'_>, + cursor: cosmic::iced_core::mouse::Cursor, + viewport: &cosmic::iced_core::Rectangle, + renderer: &cosmic::Renderer, + ) -> cosmic::iced_core::mouse::Interaction { + let children = [&self.image_button, &self.icon]; + let layout = layout.children().collect::>(); + for (i, (layout, child)) in layout + .into_iter() + .zip(children.into_iter()) + .enumerate() + .rev() + { + let tree = &state.children[i]; + let interaction = child + .as_widget() + .mouse_interaction(tree, layout, cursor, viewport, renderer); + if cursor.is_over(layout.bounds()) { + return interaction; + } + } + cosmic::iced_core::mouse::Interaction::Idle + } + + fn id(&self) -> Option { + None + } + + fn set_id(&mut self, _id: cosmic::widget::Id) {} +} + +impl<'a, Message> From> for cosmic::Element<'a, Message> +where + Message: 'static + Clone, +{ + fn from(w: WindowImage<'a, Message>) -> cosmic::Element<'a, Message> { + Element::new(w) + } +} diff --git a/cosmic-applet-network/Cargo.toml b/cosmic-applet-network/Cargo.toml index 3378556f..3cd47e01 100644 --- a/cosmic-applet-network/Cargo.toml +++ b/cosmic-applet-network/Cargo.toml @@ -18,7 +18,7 @@ tracing-log.workspace = true itertools = "0.10.3" slotmap = "1.0.6" tokio = { version = "1.15.0", features = ["full"] } -anyhow = "1.0" +anyhow.workspace = true # Application i18n i18n-embed = { version = "0.13.4", features = ["fluent-system", "desktop-requester"] } i18n-embed-fl = "0.6.4" diff --git a/cosmic-applet-notifications/Cargo.toml b/cosmic-applet-notifications/Cargo.toml index 3c629b3e..27b3f83f 100644 --- a/cosmic-applet-notifications/Cargo.toml +++ b/cosmic-applet-notifications/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" license = "GPL-3.0-or-later" [dependencies] -anyhow = "1" +anyhow.workspace = true libcosmic.workspace = true cosmic-time.workspace = true nix = "0.26" diff --git a/cosmic-applet-workspaces/Cargo.toml b/cosmic-applet-workspaces/Cargo.toml index 1de199af..18c3d72f 100644 --- a/cosmic-applet-workspaces/Cargo.toml +++ b/cosmic-applet-workspaces/Cargo.toml @@ -15,7 +15,7 @@ tracing-log.workspace = true once_cell = "1.9" futures = "0.3.21" xdg = "2.4.0" -anyhow = "1.0" +anyhow.workspace = true tokio = "1.35" # Application i18n i18n-embed = { version = "0.13.4", features = ["fluent-system", "desktop-requester"] } diff --git a/justfile b/justfile index 80c0e82c..04a4ac3c 100644 --- a/justfile +++ b/justfile @@ -39,6 +39,7 @@ _install_app_list: (_install 'com.system76.CosmicAppList' 'cosmic-app-list') _install_audio: (_install 'com.system76.CosmicAppletAudio' 'cosmic-applet-audio') _install_battery: (_install 'com.system76.CosmicAppletBattery' 'cosmic-applet-battery') _install_bluetooth: (_install 'com.system76.CosmicAppletBluetooth' 'cosmic-applet-bluetooth') +_install_minimize: (_install 'com.system76.CosmicAppletMinimize' 'cosmic-applet-minimize') _install_network: (_install 'com.system76.CosmicAppletNetwork' 'cosmic-applet-network') _install_notifications: (_install 'com.system76.CosmicAppletNotifications' 'cosmic-applet-notifications') _install_power: (_install 'com.system76.CosmicAppletPower' 'cosmic-applet-power') @@ -54,7 +55,7 @@ _install_app_button: (_install_button 'com.system76.CosmicPanelAppButton' 'cosmi _install_workspaces_button: (_install_button 'com.system76.CosmicPanelWorkspacesButton' 'cosmic-panel-workspaces-button') # Installs files into the system -install: _install_app_list _install_audio _install_battery _install_bluetooth _install_network _install_notifications _install_power _install_workspace _install_time _install_tiling _install_panel_button _install_app_button _install_workspaces_button _install_status_area +install: _install_app_list _install_audio _install_battery _install_bluetooth _install_minimize _install_network _install_notifications _install_power _install_workspace _install_time _install_tiling _install_panel_button _install_app_button _install_workspaces_button _install_status_area # Extracts vendored dependencies if vendor=1 _extract_vendor: