use button::StyleSheet as ButtonStyleSheet; use cosmic::iced_style::container::StyleSheet; use cosmic::widget::{ button, column, container, header_bar, icon, list_column, row, scrollable, text, text_input, Column, }; use cosmic::{ cctk::sctk::reexports::client::protocol::wl_data_device_manager::DndAction, cosmic_config::{Config, CosmicConfigEntry}, iced::{ alignment::{Horizontal, Vertical}, event::{ self, wayland::{self}, PlatformSpecific, }, mouse, overlay, touch, wayland::actions::{ data_device::{ActionInner, DataFromMimeType, DndIcon}, window::SctkWindowSettings, }, wayland::data_device::action as data_device_action, window, Alignment, Color, Length, Point, Rectangle, Size, }, iced_runtime::{command::platform_specific, core::id::Id, Command}, iced_sctk::commands, iced_widget::{ core::{ layout, renderer, widget::{tree, Operation, OperationOutputWrapper, Tree}, Clipboard, Shell, Widget, }, graphics::image::image_rs::EncodableLayout, }, theme, Apply, Element, }; use once_cell::sync::Lazy; use std::{ borrow::{Borrow, Cow}, fmt::Debug, mem, path::{Path, PathBuf}, str::FromStr, sync::Arc, }; use crate::{app, pages}; use cosmic_panel_config::CosmicPanelConfig; use cosmic_settings_page::{self as page, section, Section}; use freedesktop_desktop_entry::DesktopEntry; use slotmap::SlotMap; use tracing::error; const MIME_TYPE: &str = "text/uri-list"; pub type OnDndCommand<'a, Message> = Box< dyn Fn( Box platform_specific::wayland::data_device::ActionInner>, ) -> Message + 'a, >; const SPACING: f32 = 8.0; // radius is 8.0 const DRAG_START_DISTANCE_SQUARED: f32 = 64.0; pub static APPLET_DND_ICON_ID: Lazy = Lazy::new(|| window::Id::unique()); pub static ADD_PANEL_APPLET_DIALOGUE_ID: Lazy = Lazy::new(|| window::Id::unique()); pub struct Page { pub(crate) available_entries: Vec>, pub(crate) config_helper: Option, pub(crate) current_config: Option, pub(crate) reorder_widget_state: ReorderWidgetState, pub(crate) search: String, pub(crate) has_dialogue: bool, } impl Default for Page { fn default() -> Self { let config_helper = CosmicPanelConfig::cosmic_config("Panel").ok(); let current_config = config_helper.as_ref().and_then(|config_helper| { let panel_config = CosmicPanelConfig::get_entry(config_helper).ok()?; // If the config is not present, it will be created with the default values and the name will not match (panel_config.name == "Panel").then_some(panel_config) }); Self { available_entries: freedesktop_desktop_entry::Iter::new( freedesktop_desktop_entry::default_paths(), ) .filter_map(|p| Applet::try_from(Cow::from(p)).ok()) .collect(), config_helper, current_config, reorder_widget_state: ReorderWidgetState::default(), search: String::new(), has_dialogue: false, } } } pub trait AppletsPage { fn inner(&self) -> &Page; fn inner_mut(&mut self) -> &mut Page; } impl AppletsPage for Page { fn inner(&self) -> &Page { self } fn inner_mut(&mut self) -> &mut Page { self } } impl page::Page for Page { #[allow(clippy::too_many_lines)] fn content( &self, sections: &mut SlotMap>, ) -> Option { Some(vec![ sections.insert(lists::(pages::Message::PanelApplet)) ]) } fn info(&self) -> page::Info { page::Info::new("panel_applets", "preferences-pop-desktop-dock-symbolic") // .title(fl!("applets")) } } impl page::AutoBind for Page {} #[derive(Clone)] pub enum Message { RemoveStart(String), RemoveCenter(String), RemoveEnd(String), DetailStart(String), DetailCenter(String), DetailEnd(String), ReorderStart(Vec>), ReorderCenter(Vec>), ReorderEnd(Vec>), Applets(Vec>), PanelConfig(CosmicPanelConfig), StartDnd(ReorderWidgetState), DnDCommand(Arc ActionInner>>), Search(String), AddApplet(Applet<'static>), AddAppletDialogue, CloseAppletDialogue, ClosedAppletDialogue, DragAppletDialogue, Save, Cancel, } impl Debug for Message { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Message::ReorderStart(_) => write!(f, "ReorderStart"), Message::ReorderCenter(_) => write!(f, "ReorderCenter"), Message::ReorderEnd(_) => write!(f, "ReorderEnd"), Message::Applets(_) => write!(f, "Applets"), Message::PanelConfig(_) => write!(f, "PanelConfig"), Message::StartDnd(_) => write!(f, "StartDnd"), Message::DnDCommand(_) => write!(f, "DnDCommand"), Message::Save => write!(f, "ApplyReorder"), Message::RemoveStart(_) => write!(f, "RemoveStart"), Message::RemoveCenter(_) => write!(f, "RemoveCenter"), Message::RemoveEnd(_) => write!(f, "RemoveEnd"), Message::DetailStart(_) => write!(f, "DetailStart"), Message::DetailCenter(_) => write!(f, "DetailCenter"), Message::DetailEnd(_) => write!(f, "DetailEnd"), Message::Cancel => write!(f, "Cancel"), Message::Search(_) => write!(f, "Search"), Message::AddApplet(_) => write!(f, "AddApplet"), Message::AddAppletDialogue => write!(f, "AddAppletDialogue"), Message::CloseAppletDialogue => write!(f, "CloseAppletDialogue"), Message::DragAppletDialogue => write!(f, "DragAppletDialogue"), Message::ClosedAppletDialogue => write!(f, "ClosedAppletDialogue"), } } } impl Page { pub fn save(&self) { let Some(config) = self.current_config.as_ref() else { error!("No panel config. Failed to save applets."); return; }; let Some(helper) = self.config_helper.as_ref() else { error!("No panel config helper. Failed to save applets."); return; }; if let Err(e) = config.write_entry(helper) { error!("Failed to save applets: {:?}", e); } } #[must_use] pub fn dnd_icon(&self) -> Element { Element::from(AppletReorderList::dnd_icon(&self.reorder_widget_state)) } #[must_use] #[allow(clippy::too_many_lines)] pub fn add_applet_view crate::pages::Message + Copy + 'static>( &self, msg_map: T, ) -> Element { let mut list_column = list_column(); let mut has_some = false; for info in self .available_entries .iter() .filter(|a| a.matches(&self.search)) { if let Some(config) = self.current_config.as_ref() { if let Some(center) = config.plugins_center.as_ref() { if center.iter().any(|a| a.as_str() == info.id.as_ref()) { continue; } } if let Some(wings) = config.plugins_wings.as_ref() { if wings .0 .iter() .chain(wings.1.iter()) .any(|a| a.as_str() == info.id.as_ref()) { continue; } } } has_some = true; list_column = list_column.add( row::with_children(vec![ icon::from_name(&*info.icon) .size(32) .symbolic(true) .icon() .into(), column::with_capacity(2) .push(text(info.name.clone())) .push(text(info.description.clone()).size(10)) .spacing(4.0) .width(Length::Fill) .into(), button(text(fl!("add"))) .style(button::Style::Custom { active: Box::new(|focused, theme| { let mut style = theme.active(focused, &button::Style::Text); style.text_color = Some(theme.cosmic().accent_color().into()); style }), disabled: Box::new(|theme| { let mut style = theme.disabled(&button::Style::Text); let mut text_color: Color = theme.cosmic().accent_color().into(); text_color.a *= 0.5; style.text_color = Some(text_color); style }), hovered: Box::new(|focused, theme| { let mut style = theme.hovered(focused, &theme::Button::Text); style.text_color = Some(theme.cosmic().accent_color().into()); style }), pressed: Box::new(|focused, theme| { let mut style = theme.pressed(focused, &theme::Button::Text); style.text_color = Some(theme.cosmic().accent_color().into()); style }), }) .padding(8.0) .on_press(app::Message::PageMessage(msg_map(Message::AddApplet( info.clone(), )))) .into(), ]) .padding([0, 32, 0, 32]) .spacing(12) .align_items(Alignment::Center), ); } if !has_some { list_column = list_column.add( text(fl!("no-applets-found")) .width(Length::Fill) .horizontal_alignment(Horizontal::Center), ); } column::with_children(vec![ header_bar() .title(fl!("add-applet")) .on_close(app::Message::PageMessage(msg_map( Message::CloseAppletDialogue, ))) .on_drag(app::Message::PageMessage(msg_map( Message::DragAppletDialogue, ))) .into(), container( scrollable( column::with_children(vec![ text(fl!("add-applet")).size(24).width(Length::Fill).into(), text_input::search_input(fl!("search-applets"), &self.search) .on_input(move |s| { app::Message::PageMessage(msg_map(Message::Search(s))) }) .on_paste(move |s| { app::Message::PageMessage(msg_map(Message::Search(s))) }) .width(Length::Fixed(312.0)) .into(), list_column.into(), ]) .padding([0, 64, 32, 64]) .align_items(Alignment::Center) .spacing(8.0), ) .width(Length::Fill) .height(Length::Fill), ) .style(theme::Container::Background) .width(Length::Fill) .height(Length::Fill) .into(), ]) .into() } #[allow(clippy::too_many_lines)] pub fn update(&mut self, message: Message, window_id: window::Id) -> Command { match message { Message::PanelConfig(c) => { self.current_config = Some(c); } Message::ReorderStart(start_list) => { let Some(config) = self.current_config.as_mut() else { return Command::none(); }; let Some((list, _)) = config.plugins_wings.as_mut() else { config.plugins_wings = Some((start_list.into_iter().map(|a: Applet| a.id.into()).collect(), Vec::new())); return Command::none(); }; *list = start_list.into_iter().map(|a| a.id.into()).collect(); } Message::ReorderCenter(center_list) => { let Some(config) = self.current_config.as_mut() else { return Command::none(); }; let Some(list) = config.plugins_center.as_mut() else { config.plugins_center = Some(center_list.into_iter().map(|a: Applet| a.id.into()).collect()); return Command::none(); }; *list = center_list.into_iter().map(|a| a.id.into()).collect(); } Message::ReorderEnd(end_list) => { let Some(config) = self.current_config.as_mut() else { return Command::none(); }; let Some((_, list)) = config.plugins_wings.as_mut() else { config.plugins_wings = Some((Vec::new(), end_list.into_iter().map(|a: Applet| a.id.into()).collect())); return Command::none(); }; *list = end_list.into_iter().map(|a| a.id.into()).collect(); } Message::Applets(applets) => { self.available_entries = applets; } Message::StartDnd(state) => { self.reorder_widget_state = state; return Command::none(); } Message::DnDCommand(action) => { return data_device_action(action()); } Message::Save => { self.reorder_widget_state = ReorderWidgetState::default(); self.save(); } Message::RemoveStart(to_remove) => { let Some(config) = self.current_config.as_mut() else { return Command::none(); }; let Some((list, _)) = config.plugins_wings.as_mut() else { return Command::none(); }; list.retain(|id| id != &to_remove); self.save(); } Message::RemoveCenter(to_remove) => { let Some(config) = self.current_config.as_mut() else { return Command::none(); }; let Some(list) = config.plugins_center.as_mut() else { return Command::none(); }; list.retain(|id| id != &to_remove); self.save(); } Message::RemoveEnd(to_remove) => { let Some(config) = self.current_config.as_mut() else { return Command::none(); }; let Some((_, list)) = config.plugins_wings.as_mut() else { return Command::none(); }; list.retain(|id| id != &to_remove); self.save(); } Message::DetailStart(_) => { // TODO ask design team } Message::DetailCenter(_) => { // TODO ask design team } Message::DetailEnd(_) => { // TODO ask design team } Message::Cancel => { self.reorder_widget_state = ReorderWidgetState::default(); let current_config = self.config_helper.as_ref().and_then(|config_helper| { // TODO error handling... let panel_config = CosmicPanelConfig::get_entry(config_helper).ok()?; // If the config is not present, it will be created with the default values and the name will not match (panel_config.name == "Panel").then_some(panel_config) }); self.current_config = current_config; } Message::Search(text) => { self.search = text; } Message::AddApplet(applet) => { // TODO ask design team let Some(config) = self.current_config.as_mut() else { return Command::none(); }; let list = if let Some((list, _)) = config.plugins_wings.as_mut() { list } else { config.plugins_wings = Some((Vec::new(), Vec::new())); &mut config.plugins_wings.as_mut().unwrap().0 }; list.push(applet.id.to_string()); self.save(); return commands::window::close_window(window_id); } Message::AddAppletDialogue => { self.has_dialogue = true; let window_settings = SctkWindowSettings { window_id, app_id: Some("com.system76.CosmicSettings".to_string()), title: Some(fl!("add-applet")), parent: Some(window::Id::MAIN), autosize: false, size_limits: layout::Limits::NONE .min_width(300.0) .max_width(800.0) .min_height(200.0) .max_height(1080.0), size: (512, 420), resizable: None, client_decorations: true, transparent: true, ..Default::default() }; return commands::window::get_window(window_settings); } Message::ClosedAppletDialogue => { self.has_dialogue = false; } Message::CloseAppletDialogue => { self.has_dialogue = false; return commands::window::close_window(window_id); } Message::DragAppletDialogue => { return commands::window::start_drag_window(window_id); } }; Command::none() } } #[allow(clippy::too_many_lines)] pub fn lists< P: page::Page + AppletsPage, T: Fn(Message) -> crate::pages::Message + Copy + 'static, >( msg_map: T, ) -> Section { Section::default().view::

(move |_binder, page, _section| { let page = page.inner(); let Some(config) = page.current_config.as_ref() else { return Element::from( text(fl!("unknown")) ); }; let button = button::standard(fl!("add-applet")); column::with_children(vec![ column::with_children(vec![ row::with_children(vec![ text(fl!("applets")).width(Length::Fill).size(24).into(), (if page.has_dialogue { button } else { button.on_press(Message::AddAppletDialogue) }) .into(), ]) .into(), text(fl!("start-segment")).into(), AppletReorderList::new( config .plugins_wings .as_ref() .map(|list| { list.0 .iter() .filter_map(|id| { page.available_entries .iter() .find(|e| e.id.as_ref() == id.as_str()) .map(Applet::borrowed) }) .collect() }) .unwrap_or_default(), Some((window::Id::MAIN, *APPLET_DND_ICON_ID)), Message::StartDnd, |a| Message::DnDCommand(Arc::new(a)), Message::RemoveStart, Message::DetailStart, Message::ReorderStart, Message::Save, Message::Cancel, page.reorder_widget_state.dragged_applet().as_ref(), ) .into(), ]) .spacing(8.0) .into(), column::with_children(vec![ text(fl!("center-segment")).into(), AppletReorderList::new( config .plugins_center .as_ref() .map(|list| { list.iter() .filter_map(|id| { page.available_entries .iter() .find(|e| e.id.as_ref() == id.as_str()) .map(Applet::borrowed) }) .collect() }) .unwrap_or_default(), Some((window::Id::MAIN, *APPLET_DND_ICON_ID)), Message::StartDnd, |a| Message::DnDCommand(Arc::new(a)), Message::RemoveCenter, Message::DetailCenter, Message::ReorderCenter, Message::Save, Message::Cancel, page.reorder_widget_state.dragged_applet().as_ref(), ) .into(), ]) .spacing(8.0) .into(), column::with_children(vec![ text(fl!("end-segment")).into(), AppletReorderList::new( config .plugins_wings .as_ref() .map(|list| { list.1 .iter() .filter_map(|id| { page.available_entries .iter() .find(|e| e.id.as_ref() == id.as_str()) .map(Applet::borrowed) }) .collect() }) .unwrap_or_default(), Some((window::Id::MAIN, *APPLET_DND_ICON_ID)), Message::StartDnd, |a| Message::DnDCommand(Arc::new(a)), Message::RemoveEnd, Message::DetailEnd, Message::ReorderEnd, Message::Save, Message::Cancel, page.reorder_widget_state.dragged_applet().as_ref(), ) .into(), ]) .spacing(8.0) .into(), ]) .padding([0, 16, 0, 16]) .spacing(12.0) .apply(Element::from) .map(msg_map) }) } #[derive(Debug, Clone, PartialEq, Eq)] pub struct Applet<'a> { pub id: Cow<'a, str>, pub name: Cow<'a, str>, pub description: Cow<'a, str>, pub icon: Cow<'a, str>, pub path: Cow<'a, Path>, } impl Applet<'_> { #[must_use] pub fn matches(&self, query: &str) -> bool { self.name.contains(query) || self.description.contains(query) || self.id.contains(query) } } impl<'a> TryFrom> for Applet<'static> { type Error = anyhow::Error; fn try_from(path: Cow<'a, Path>) -> Result { let content = std::fs::read_to_string(path.as_ref())?; let entry = DesktopEntry::decode(path.as_ref(), &content)?; if entry.desktop_entry("X-CosmicApplet").is_none() { anyhow::bail!("Not an applet"); } Ok(Self { id: Cow::from(entry.id().to_string()), name: Cow::from(entry.name(None).unwrap_or_default().to_string()), description: Cow::from(entry.comment(None).unwrap_or_default().to_string()), icon: Cow::from(entry.icon().unwrap_or_default().to_string()), path: Cow::from(path.into_owned()), }) } } impl Applet<'static> { fn borrowed(&self) -> Applet<'_> { Applet { id: Cow::from(self.id.as_ref()), name: Cow::from(self.name.as_ref()), description: Cow::from(self.description.as_ref()), icon: Cow::from(self.icon.as_ref()), path: Cow::from(self.path.as_ref()), } } } impl<'a> Applet<'a> { fn into_owned(self) -> Applet<'static> { Applet { id: Cow::from(self.id.into_owned()), name: Cow::from(self.name.into_owned()), description: Cow::from(self.description.into_owned()), icon: Cow::from(self.icon.into_owned()), path: Cow::from(self.path.into_owned()), } } } // TODO A11y / keyboard support #[allow(dead_code)] pub struct AppletReorderList<'a, Message> { id: Id, info: Vec>, on_create_dnd_source: Box Message + 'a>, on_dnd_command_produced: OnDndCommand<'a, Message>, on_reorder: Box>) -> Message + 'a>, on_finish: Option, on_cancel: Option, surface_ids: Option<(window::Id, window::Id)>, inner: Element<'a, Message>, } impl<'a, Message: 'static + Clone> AppletReorderList<'a, Message> { #[allow(clippy::too_many_arguments)] #[must_use] /// new applet list which can be reordered and dragged pub fn new( info: Vec>, surface_ids: Option<(window::Id, window::Id)>, on_create_dnd_source: impl Fn(ReorderWidgetState) -> Message + 'a, on_dnd_command_produced: impl Fn( Box platform_specific::wayland::data_device::ActionInner>, ) -> Message + 'a, on_remove: impl Fn(String) -> Message + 'a, on_details: impl Fn(String) -> Message + 'a, on_reorder: impl Fn(Vec>) -> Message + 'a, on_apply_reorder: Message, on_cancel: Message, active_dnd: Option<&Applet<'a>>, ) -> Self { let applet_buttons = info .clone() .into_iter() .map(|info| { let id_clone = info.id.to_string(); let is_dragged = active_dnd.as_ref().map_or(false, |dnd| dnd.id == info.id); container( row::with_children(vec![ icon::from_name("open-menu-symbolic") .symbolic(true) .size(16) .into(), icon::from_name(info.icon).size(32).symbolic(true).into(), column::with_capacity(2) .spacing(4.0) .width(Length::Fill) .push(text(info.name)) .push(text::caption(info.description)) .into(), button::icon(icon::from_name("edit-delete-symbolic")) .extra_small() .on_press(on_remove(id_clone.clone())) .into(), button::icon(icon::from_name("open-menu-symbolic")) .extra_small() .on_press(on_details(id_clone)) .into(), ]) .spacing(12) .align_items(Alignment::Center), ) .width(Length::Fill) .padding(8) .style(theme::Container::Custom(Box::new(move |theme| { let mut style = theme.appearance(&theme::Container::Primary); style.border_radius = 8.0.into(); if is_dragged { style.border_color = theme.cosmic().accent_color().into(); style.border_width = 2.0; } style }))) .into() }) .collect::>(); Self { id: Id::unique(), info, on_create_dnd_source: Box::new(on_create_dnd_source), on_dnd_command_produced: Box::new(on_dnd_command_produced), on_reorder: Box::new(on_reorder), on_finish: Some(on_apply_reorder), on_cancel: Some(on_cancel), surface_ids, inner: if active_dnd.is_some() && applet_buttons.is_empty() { container( text(fl!("drop-here")) .width(Length::Fill) .height(Length::Fill) .vertical_alignment(Vertical::Center) .horizontal_alignment(Horizontal::Center), ) .width(Length::Fill) .height(Length::Fixed(48.0)) .padding(8) .style(theme::Container::Custom(Box::new(move |theme| { let mut style = theme.appearance(&theme::Container::Primary); style.border_radius = 8.0.into(); style.border_color = theme.cosmic().bg_divider().into(); style.border_width = 2.0; style.background = Some(Color::TRANSPARENT.into()); style }))) .into() } else { Column::with_children(applet_buttons) .spacing(SPACING) .into() }, } } #[must_use] /// mark this as a dnd icon pub fn dnd_icon(state: &'a ReorderWidgetState) -> Self { Self { id: Id::unique(), info: Vec::new(), on_create_dnd_source: Box::new(|_| unimplemented!()), on_dnd_command_produced: Box::new(|_| unimplemented!()), on_reorder: Box::new(|_| unimplemented!()), on_finish: None, surface_ids: None, inner: if let Some(info) = state.dragged_applet() { container( row::with_children(vec![ icon::from_name("open-menu-symbolic") .size(16) .symbolic(true) .into(), icon::from_name(info.icon.into_owned()) .size(32) .symbolic(true) .into(), column::with_capacity(2) .spacing(4.0) .width(Length::Fill) .push(text(info.name)) .push(text::caption(info.description)) .into(), button::icon(icon::from_name("edit-delete-symbolic")) .extra_small() .into(), button::icon(icon::from_name("open-menu-symbolic")) .extra_small() .into(), ]) .spacing(12) .align_items(Alignment::Center), ) .width(Length::Fixed(state.layout.map_or(400.0, |l| l.width))) .padding(8) .style(theme::Container::Custom(Box::new(move |theme| { let mut style = theme.appearance(&theme::Container::Primary); style.border_radius = 8.0.into(); style }))) .into() } else { text("unknown").into() }, on_cancel: None, } } #[must_use] /// reorders the list of applets given: /// - the bounds of the list /// - the current mouse position during a drag /// - the applet being offered to this list fn get_reordered( &self, layout: &layout::Layout, pos: Point, offered_applet: Applet<'a>, ) -> Vec> { let mut reordered: Vec<_> = self.info.clone(); if !layout.bounds().contains(pos) { // applets shouldn't be in two lists at once reordered.retain(|a| a != &offered_applet); return reordered; } // special case if reordered.is_empty() { reordered.push(offered_applet); return reordered; } // special case if reordered.len() == 1 && reordered[0] == offered_applet { return reordered; } let height = (layout.bounds().height - SPACING * (self.info.len() - 1) as f32) / self.info.len() as f32; let mut found = false; let mut y = layout.bounds().y; for i in 0..=reordered.len() { if i == 0 || i == reordered.len() { y += height / 2.0; } else { y += height + SPACING; } if pos.y <= y { reordered.insert(i, offered_applet.clone()); let mut index = 0; reordered.retain(|a| { let ret = a != &offered_applet || index == i; index += 1; ret }); found = true; break; } } if !found { reordered.retain(|a| a != &offered_applet); reordered.push(offered_applet); } reordered } } impl<'a, Message: 'static> Widget for AppletReorderList<'a, Message> where Message: Clone, { fn tag(&self) -> tree::Tag { tree::Tag::of::() } fn state(&self) -> tree::State { tree::State::new(ReorderWidgetState::new()) } fn children(&self) -> Vec { vec![Tree::new(&self.inner)] } fn diff(&mut self, tree: &mut Tree) { tree.diff_children(std::slice::from_mut(&mut self.inner)); } fn width(&self) -> Length { Length::Fill } fn height(&self) -> Length { Length::Shrink } fn layout( &self, tree: &mut Tree, renderer: &cosmic::Renderer, limits: &layout::Limits, ) -> layout::Node { let inner_layout = self.inner.as_widget().layout(tree, renderer, limits); layout::Node::with_children(inner_layout.size(), vec![inner_layout]) } fn operate( &self, tree: &mut Tree, layout: layout::Layout<'_>, renderer: &cosmic::Renderer, operation: &mut dyn Operation>, ) { self.inner.as_widget().operate( &mut tree.children[0], layout.children().next().unwrap(), renderer, operation, ); } #[allow(clippy::too_many_lines, clippy::needless_match)] fn on_event( &mut self, tree: &mut Tree, event: event::Event, layout: layout::Layout<'_>, cursor_position: mouse::Cursor, renderer: &cosmic::Renderer, clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, viewport: &Rectangle, ) -> event::Status { let mut ret = match self.inner.as_widget_mut().on_event( &mut tree.children[0], event.clone(), layout.children().next().unwrap(), cursor_position, renderer, clipboard, shell, viewport, ) { event::Status::Captured => return event::Status::Captured, event::Status::Ignored => event::Status::Ignored, }; let height = (layout.bounds().height - SPACING * (self.info.len() - 1) as f32) / self.info.len() as f32; let state = tree.state.downcast_mut::(); state.dragging_state = match mem::take(&mut state.dragging_state) { DraggingState::None => { // if no dragging state, listen for press events match &event { event::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) | event::Event::Touch(touch::Event::FingerPressed { .. }) if cursor_position.is_over(layout.bounds()) => { ret = event::Status::Captured; DraggingState::Pressed(cursor_position.position().unwrap_or_default()) } _ => DraggingState::None, } } DraggingState::Dragging(applet) => match &event { event::Event::PlatformSpecific(PlatformSpecific::Wayland( wayland::Event::DataSource(wayland::DataSourceEvent::DndFinished), )) => { ret = event::Status::Captured; DraggingState::None } event::Event::PlatformSpecific(PlatformSpecific::Wayland( wayland::Event::DataSource(wayland::DataSourceEvent::Cancelled), )) => { ret = event::Status::Captured; if let Some(on_cancel) = self.on_cancel.clone() { shell.publish(on_cancel); } DraggingState::None } event::Event::PlatformSpecific(PlatformSpecific::Wayland( wayland::Event::DataSource(wayland::DataSourceEvent::DndDropPerformed), )) => { ret = event::Status::Captured; DraggingState::None } _ => DraggingState::Dragging(applet), }, DraggingState::Pressed(start) => { // if dragging state is pressed, listen for motion events or release events match &event { event::Event::Mouse(mouse::Event::CursorMoved { .. }) | event::Event::Touch(touch::Event::FingerMoved { .. }) => { let pos = cursor_position.position().unwrap_or_default(); let d_y = pos.y - start.y; let d_x = pos.x - start.x; let distance_squared = d_y * d_y + d_x * d_x; if distance_squared > DRAG_START_DISTANCE_SQUARED { if let Some((_, applet)) = self.info.iter().enumerate().find(|(i, _)| { start.y < layout.bounds().y + (*i + 1) as f32 * (height + SPACING) }) { let (window_id, icon_id) = self.surface_ids.unwrap(); state.dragging_state = DraggingState::Dragging(applet.clone().into_owned()); // TODO emit a dnd command state.layout = Some(layout.bounds().size()); let state_clone = state.clone(); shell.publish((self.on_create_dnd_source.as_ref())( state_clone.clone(), )); let p = applet.path.to_path_buf(); shell.publish((self.on_dnd_command_produced.as_ref())(Box::new(move || { platform_specific::wayland::data_device::ActionInner::StartDnd { mime_types: vec![MIME_TYPE.to_string()], actions: DndAction::Move, origin_id: window_id, icon_id: Some(DndIcon::Widget( icon_id, Box::new(state_clone.clone()), )), data: Box::new(AppletString(p.clone())), } }))); ret = event::Status::Captured; DraggingState::Dragging(applet.clone().into_owned()) } else { DraggingState::Pressed(start) } } else { DraggingState::Pressed(start) } } event::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) | event::Event::Touch( touch::Event::FingerLifted { .. } | touch::Event::FingerLost { .. }, ) => { ret = event::Status::Captured; DraggingState::None } _ => DraggingState::Pressed(start), } } }; state.dnd_offer = match mem::take(&mut state.dnd_offer) { DndOfferState::None => match &event { event::Event::PlatformSpecific(PlatformSpecific::Wayland( wayland::Event::DndOffer(wayland::DndOfferEvent::SourceActions(actions)), )) => DndOfferState::OutsideWidget(Vec::new(), *actions, None), event::Event::PlatformSpecific(PlatformSpecific::Wayland( wayland::Event::DndOffer(wayland::DndOfferEvent::Enter { x, y, mime_types }), )) => { if mime_types.iter().any(|m| m.as_str() == MIME_TYPE) { let point = Point::new(*x as f32, *y as f32); if layout.bounds().contains(point) { shell.publish((self.on_dnd_command_produced.as_ref())(Box::new( move || { platform_specific::wayland::data_device::ActionInner::SetActions { preferred: DndAction::Move, accepted: DndAction::Move, } }, ))); shell.publish((self.on_dnd_command_produced.as_ref())(Box::new( move || { platform_specific::wayland::data_device::ActionInner::Accept( Some(MIME_TYPE.to_string()), ) }, ))); let data = if let DraggingState::Dragging(a) = &state.dragging_state { Some(a.clone()) } else { shell.publish((self.on_dnd_command_produced.as_ref())(Box::new( move || { platform_specific::wayland::data_device::ActionInner::RequestDndData( MIME_TYPE.to_string(), ) }, ))); None }; DndOfferState::HandlingOffer( mime_types.clone(), DndAction::empty(), data, ) } else { let data = match &state.dragging_state { DraggingState::Dragging(data) => { let filtered: Vec<_> = self .info .clone() .into_iter() .filter(|a| a != data) .collect(); if filtered != self.info { shell.publish((self.on_reorder.as_ref())( filtered .into_iter() .map(pages::desktop::panel::applets_inner::Applet::into_owned) .collect(), )); } Some(data.clone()) } _ => None, }; DndOfferState::OutsideWidget( mime_types.clone(), DndAction::empty(), data, ) } } else { DndOfferState::None } } _ => DndOfferState::None, }, DndOfferState::OutsideWidget(mime_types, action, data) => match &event { event::Event::PlatformSpecific(PlatformSpecific::Wayland( wayland::Event::DndOffer(wayland::DndOfferEvent::SourceActions(actions)), )) => DndOfferState::OutsideWidget(mime_types, *actions, data), event::Event::PlatformSpecific(PlatformSpecific::Wayland( wayland::Event::DndOffer(wayland::DndOfferEvent::Motion { x, y }), )) => { let point = Point::new(*x as f32, *y as f32); if layout.bounds().contains(point) { shell.publish((self.on_dnd_command_produced.as_ref())(Box::new( move || { platform_specific::wayland::data_device::ActionInner::SetActions { preferred: DndAction::Move, accepted: DndAction::Move, } }, ))); shell.publish((self.on_dnd_command_produced.as_ref())(Box::new( move || { platform_specific::wayland::data_device::ActionInner::Accept(Some( MIME_TYPE.to_string(), )) }, ))); shell.publish((self.on_dnd_command_produced.as_ref())(Box::new( move || { platform_specific::wayland::data_device::ActionInner::SetActions { preferred: action.intersection(DndAction::Move), accepted: action .intersection(DndAction::Move.union(DndAction::Copy)), } }, ))); // TODO maybe keep track of data and request here if we don't have it // also maybe just refactor DND Targets to allow easier handling... if data.is_none() { shell.publish((self.on_dnd_command_produced.as_ref())(Box::new( move || { platform_specific::wayland::data_device::ActionInner::RequestDndData( MIME_TYPE.to_string(), ) }, ))); } if let Some(applet) = data.clone() { let reordered_list: Vec<_> = self.get_reordered( &layout, Point { x: *x as f32, y: *y as f32, }, applet, ); if reordered_list != self.info { shell.publish((self.on_reorder.as_ref())( reordered_list.into_iter().map(Applet::into_owned).collect(), )); } } DndOfferState::HandlingOffer(mime_types, DndAction::empty(), data) } else { DndOfferState::OutsideWidget(mime_types, DndAction::empty(), data) } } event::Event::PlatformSpecific(PlatformSpecific::Wayland( wayland::Event::DndOffer(wayland::DndOfferEvent::DndData { data: new_data, mime_type, }), )) => { if mime_type.as_str() == MIME_TYPE { let data = std::str::from_utf8(new_data.as_bytes()) .ok() .and_then(|s| url::Url::from_str(s).ok()) .and_then(|url| url.to_file_path().ok()) .and_then(|p| Applet::try_from(Cow::from(p)).ok()); DndOfferState::OutsideWidget(mime_types, action, data) } else { DndOfferState::OutsideWidget(mime_types, action, data) } } event::Event::PlatformSpecific(PlatformSpecific::Wayland( wayland::Event::DndOffer( wayland::DndOfferEvent::DropPerformed | wayland::DndOfferEvent::Leave, ), )) => DndOfferState::None, _ => DndOfferState::OutsideWidget(mime_types, action, data), }, DndOfferState::HandlingOffer(mime_types, action, data) => match &event { event::Event::PlatformSpecific(PlatformSpecific::Wayland( wayland::Event::DndOffer(wayland::DndOfferEvent::Motion { x, y }), )) => { let point = Point::new(*x as f32, *y as f32); if layout.bounds().contains(point) { shell.publish((self.on_dnd_command_produced.as_ref())(Box::new( move || { platform_specific::wayland::data_device::ActionInner::SetActions { preferred: DndAction::Move, accepted: DndAction::Move, } }, ))); if let Some(data) = data.clone() { let reordered_list = self.get_reordered( &layout, Point { x: *x as f32, y: *y as f32, }, data, ); if reordered_list != self.info { shell.publish((self.on_reorder.as_ref())( reordered_list .into_iter() .map(pages::desktop::panel::applets_inner::Applet::into_owned) .collect(), )); } } DndOfferState::HandlingOffer(mime_types, DndAction::empty(), data) } else { if let Some(applet) = data.clone() { let reordered_list: Vec<_> = self.get_reordered( &layout, Point { x: *x as f32, y: *y as f32, }, applet, ); if reordered_list != self.info { shell.publish((self.on_reorder.as_ref())( reordered_list.into_iter().map(Applet::into_owned).collect(), )); } } shell.publish((self.on_dnd_command_produced.as_ref())(Box::new( move || { platform_specific::wayland::data_device::ActionInner::Accept(None) }, ))); DndOfferState::OutsideWidget(mime_types, DndAction::empty(), data) } } event::Event::PlatformSpecific(PlatformSpecific::Wayland( wayland::Event::DndOffer(wayland::DndOfferEvent::Leave), )) => DndOfferState::None, event::Event::PlatformSpecific(PlatformSpecific::Wayland( wayland::Event::DndOffer(wayland::DndOfferEvent::SourceActions(actions)), )) => { shell.publish((self.on_dnd_command_produced.as_ref())(Box::new( move || platform_specific::wayland::data_device::ActionInner::SetActions { preferred: DndAction::Move, accepted: DndAction::Move, }, ))); DndOfferState::HandlingOffer(mime_types, *actions, data) } event::Event::PlatformSpecific(PlatformSpecific::Wayland( wayland::Event::DndOffer(wayland::DndOfferEvent::DndData { data: new_data, mime_type, }), )) => { if mime_type.as_str() == MIME_TYPE { let data = std::str::from_utf8(new_data.as_bytes()) .ok() .and_then(|s| url::Url::from_str(s).ok()) .and_then(|url| url.to_file_path().ok()) .and_then(|p| Applet::try_from(Cow::from(p)).ok()); if let Some(data) = data.borrow() { let filtered: Vec<_> = self .info .clone() .into_iter() .filter(|a| a != data) .collect(); if filtered != self.info { shell.publish((self.on_reorder.as_ref())( filtered .into_iter() .map(pages::desktop::panel::applets_inner::Applet::into_owned) .collect(), )); } } DndOfferState::HandlingOffer(mime_types, action, data) } else { DndOfferState::HandlingOffer(mime_types, action, data) } } event::Event::PlatformSpecific(PlatformSpecific::Wayland( wayland::Event::DndOffer(wayland::DndOfferEvent::DropPerformed), )) => { shell.publish((self.on_dnd_command_produced.as_ref())(Box::new( move || platform_specific::wayland::data_device::ActionInner::SetActions { preferred: DndAction::Move, accepted: DndAction::Move, }, ))); shell.publish((self.on_dnd_command_produced.as_ref())(Box::new( move || { platform_specific::wayland::data_device::ActionInner::Accept(Some( MIME_TYPE.to_string(), )) }, ))); shell.publish((self.on_dnd_command_produced.as_ref())(Box::new( move || { platform_specific::wayland::data_device::ActionInner::RequestDndData( MIME_TYPE.to_string(), ) }, ))); DndOfferState::Dropped } _ => DndOfferState::HandlingOffer(mime_types, action, data), }, DndOfferState::Dropped => match &event { event::Event::PlatformSpecific(PlatformSpecific::Wayland( wayland::Event::DndOffer(wayland::DndOfferEvent::DndData { .. }), )) => { if let Some(on_finish) = self.on_finish.clone() { shell.publish(on_finish); } shell.publish((self.on_dnd_command_produced.as_ref())(Box::new( move || platform_specific::wayland::data_device::ActionInner::DndFinished, ))); DndOfferState::None } event::Event::PlatformSpecific(PlatformSpecific::Wayland( wayland::Event::DndOffer(wayland::DndOfferEvent::Leave), )) => { // already applied the offer, so we can just finish if let Some(on_cancel) = self.on_cancel.clone() { shell.publish(on_cancel); } DndOfferState::None } _ => DndOfferState::Dropped, }, }; ret } fn draw( &self, state: &Tree, renderer: &mut cosmic::Renderer, theme: &cosmic::Theme, style: &renderer::Style, layout: layout::Layout<'_>, cursor_position: mouse::Cursor, viewport: &Rectangle, ) { self.inner.as_widget().draw( &state.children[0], renderer, theme, style, layout.children().next().unwrap(), cursor_position, viewport, ); } fn overlay<'b>( &'b mut self, tree: &'b mut Tree, layout: layout::Layout<'_>, renderer: &cosmic::Renderer, ) -> Option> { self.inner.as_widget_mut().overlay( &mut tree.children[0], layout.children().next().unwrap(), renderer, ) } fn mouse_interaction( &self, state: &Tree, layout: layout::Layout<'_>, cursor_position: mouse::Cursor, viewport: &Rectangle, renderer: &cosmic::Renderer, ) -> mouse::Interaction { match self.inner.as_widget().mouse_interaction( &state.children[0], layout.children().next().unwrap(), cursor_position, viewport, renderer, ) { mouse::Interaction::Idle => { let state = state.state.downcast_ref::(); if matches!(state.dragging_state, DraggingState::Dragging(_)) { mouse::Interaction::Grabbing } else if cursor_position.is_over(layout.bounds()) { mouse::Interaction::Grab } else { mouse::Interaction::default() } } interaction => interaction, } } } /// A string which can be sent to the clipboard or drag-and-dropped. #[derive(Debug, Clone)] pub struct AppletString(PathBuf); impl DataFromMimeType for AppletString { fn from_mime_type(&self, mime_type: &str) -> Option> { if mime_type == MIME_TYPE { let data = Some( url::Url::from_file_path(self.0.clone()) .ok()? .to_string() .as_bytes() .to_vec(), ); data } else { None } } } #[derive(Debug, Default, Clone)] pub enum DraggingState { #[default] /// No ongoing drag or press None, /// A draggable item was being pressed at the recorded point Pressed(Point), /// An item is being dragged Dragging(Applet<'static>), } #[derive(Debug, Default, Clone)] pub(crate) enum DndOfferState { #[default] None, OutsideWidget(Vec, DndAction, Option>), HandlingOffer(Vec, DndAction, Option>), Dropped, } #[derive(Debug, Default, Clone)] pub struct ReorderWidgetState { dragging_state: DraggingState, dnd_offer: DndOfferState, layout: Option, } impl ReorderWidgetState { pub(crate) fn new() -> ReorderWidgetState { ReorderWidgetState::default() } pub(crate) fn dragged_applet(&self) -> Option> { match &self.dragging_state { DraggingState::Dragging(applet) => Some(applet.borrowed()), _ => None, } } } impl<'a, Message: 'static + Clone> From> for Element<'a, Message> { fn from(applet_reorder_list: AppletReorderList<'a, Message>) -> Self { Element::new(applet_reorder_list) } } #[derive(Debug, Clone)] pub enum State { DndIcon(ReorderWidgetState), }