diff --git a/src/widget/dropdown/mod.rs b/src/widget/dropdown/mod.rs index bcb37af..fa4184c 100644 --- a/src/widget/dropdown/mod.rs +++ b/src/widget/dropdown/mod.rs @@ -7,15 +7,17 @@ use std::borrow::Cow; pub mod menu; -use iced_core::window; pub use menu::Menu; pub mod multi; +pub mod operation; mod widget; pub use widget::*; use crate::surface; +pub use iced_core::widget::Id; +use iced_core::window; /// Displays a list of options in a popover menu on select. pub fn dropdown< @@ -53,3 +55,13 @@ pub fn popup_dropdown< dropdown } + +/// Produces a [`Task`] that closes the [`Dropdown`]. +pub fn close(id: Id) -> iced_runtime::Task { + iced_runtime::task::effect(iced_runtime::Action::Widget(Box::new(operation::close(id)))) +} + +/// Produces a [`Task`] that opens the [`Dropdown`]. +pub fn open(id: Id) -> iced_runtime::Task { + iced_runtime::task::effect(iced_runtime::Action::Widget(Box::new(operation::open(id)))) +} diff --git a/src/widget/dropdown/operation.rs b/src/widget/dropdown/operation.rs new file mode 100644 index 0000000..8cea456 --- /dev/null +++ b/src/widget/dropdown/operation.rs @@ -0,0 +1,72 @@ +// Copyright 2025 System76 +// SPDX-License-Identifier: MPL-2.0 AND MIT +//! Operate on dropdown widgets. + +use super::State; +use iced::Rectangle; +use iced_core::widget::{Id, Operation}; + +pub trait Dropdown { + fn close(&mut self); + fn open(&mut self); +} + +/// Produces a [`Task`] that closes a [`Dropdown`] popup. +pub fn close(id: Id) -> impl Operation { + struct Close(Id); + + impl Operation for Close { + fn custom(&mut self, state: &mut dyn std::any::Any, id: Option<&Id>) { + if id.map_or(true, |id| id != &self.0) { + return; + } + + let Some(state) = state.downcast_mut::() else { + return; + }; + + state.close(); + } + + fn container( + &mut self, + _id: Option<&Id>, + _bounds: Rectangle, + operate_on_children: &mut dyn FnMut(&mut dyn Operation), + ) { + operate_on_children(self) + } + } + + Close(id) +} + +/// Produces a [`Task`] that opens a [`Dropdown`] popup. +pub fn open(id: Id) -> impl Operation { + struct Open(Id); + + impl Operation for Open { + fn custom(&mut self, state: &mut dyn std::any::Any, id: Option<&Id>) { + if id.map_or(true, |id| id != &self.0) { + return; + } + + let Some(state) = state.downcast_mut::() else { + return; + }; + + state.open(); + } + + fn container( + &mut self, + _id: Option<&Id>, + _bounds: Rectangle, + operate_on_children: &mut dyn FnMut(&mut dyn Operation), + ) { + operate_on_children(self) + } + } + + Open(id) +} diff --git a/src/widget/dropdown/widget.rs b/src/widget/dropdown/widget.rs index d196215..47df9b8 100644 --- a/src/widget/dropdown/widget.rs +++ b/src/widget/dropdown/widget.rs @@ -2,6 +2,7 @@ // Copyright 2019 Héctor Ramón, Iced contributors // SPDX-License-Identifier: MPL-2.0 AND MIT +use super::Id; use super::menu::{self, Menu}; use crate::widget::icon::{self, Handle}; use crate::{Element, surface}; @@ -18,19 +19,21 @@ use iced_widget::pick_list::{self, Catalog}; use std::borrow::Cow; use std::ffi::OsStr; use std::hash::{DefaultHasher, Hash, Hasher}; -use std::marker::PhantomData; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, LazyLock, Mutex}; pub type DropdownView = Arc Element<'static, Message> + Send + Sync>; static AUTOSIZE_ID: LazyLock = LazyLock::new(|| crate::widget::Id::new("cosmic-applet-autosize")); + /// A widget for selecting a single value from a list of selections. #[derive(Setters)] pub struct Dropdown<'a, S: AsRef + Send + Sync + Clone + 'static, Message, AppMessage> where [S]: std::borrow::ToOwned, { + #[setters(skip)] + id: Option, #[setters(skip)] on_selected: Arc Message + Send + Sync>, #[setters(skip)] @@ -78,6 +81,7 @@ where on_selected: impl Fn(usize) -> Message + 'static + Send + Sync, ) -> Self { Self { + id: None, on_selected: Arc::new(on_selected), selections, icons: Cow::Borrowed(&[]), @@ -100,12 +104,13 @@ where /// Handle dropdown requests for popup creation. /// Intended to be used with [`crate::app::message::get_popup`] pub fn with_popup( - mut self, + self, parent_id: window::Id, on_surface_action: impl Fn(surface::Action) -> Message + Send + Sync + 'static, action_map: impl Fn(Message) -> NewAppMessage + Send + Sync + 'static, ) -> Dropdown<'a, S, Message, NewAppMessage> { let Self { + id, on_selected, selections, icons, @@ -121,6 +126,7 @@ where } = self; Dropdown::<'a, S, Message, NewAppMessage> { + id, on_selected, selections, icons, @@ -138,6 +144,11 @@ where } } + pub fn id(mut self, id: Id) -> Self { + self.id = Some(id); + self + } + #[cfg(all(feature = "winit", feature = "wayland"))] pub fn with_positioner( mut self, @@ -299,6 +310,17 @@ where ); } + fn operate( + &self, + tree: &mut Tree, + _layout: Layout<'_>, + _renderer: &crate::Renderer, + operation: &mut dyn iced_core::widget::Operation, + ) { + let state = tree.state.downcast_mut::(); + operation.custom(state, self.id.as_ref()); + } + fn overlay<'b>( &'b mut self, tree: &'b mut Tree, @@ -364,6 +386,8 @@ pub struct State { menu: menu::State, keyboard_modifiers: keyboard::Modifiers, is_open: Arc, + close_operation: bool, + open_operation: bool, hovered_option: Arc>>, hashes: Vec, selections: Vec, @@ -389,6 +413,8 @@ impl State { selections: Vec::new(), hashes: Vec::new(), popup_id: window::Id::unique(), + close_operation: false, + open_operation: false, } } } @@ -399,6 +425,16 @@ impl Default for State { } } +impl super::operation::Dropdown for State { + fn close(&mut self) { + self.close_operation = true; + } + + fn open(&mut self) { + self.open_operation = true; + } +} + /// Computes the layout of a [`Dropdown`]. #[allow(clippy::too_many_arguments)] pub fn layout( @@ -484,10 +520,121 @@ pub fn update< font: Option, selected_option: Option, ) -> event::Status { + let state = state(); + + let open = |shell: &mut Shell<'_, Message>, + state: &mut State, + on_selected: Arc Message + Send + Sync + 'static>| { + state.is_open.store(true, Ordering::Relaxed); + let mut hovered_guard = state.hovered_option.lock().unwrap(); + *hovered_guard = selected; + let id = window::Id::unique(); + state.popup_id = id; + #[cfg(all(feature = "winit", feature = "wayland"))] + if let Some(((on_surface_action, parent), action_map)) = on_surface_action + .as_ref() + .zip(_window_id) + .zip(action_map.clone()) + { + use iced_runtime::platform_specific::wayland::popup::{ + SctkPopupSettings, SctkPositioner, + }; + let bounds = layout.bounds(); + let anchor_rect = Rectangle { + x: bounds.x as i32, + y: bounds.y as i32, + width: bounds.width as i32, + height: bounds.height as i32, + }; + let icon_width = if icons.is_empty() { 0.0 } else { 24.0 }; + let measure = |_label: &str, selection_paragraph: &crate::Paragraph| -> f32 { + selection_paragraph.min_width().round() + }; + let pad_width = padding.horizontal().mul_add(2.0, 16.0); + + let selections_width = selections + .iter() + .zip(state.selections.iter_mut()) + .map(|(label, selection)| measure(label.as_ref(), selection.raw())) + .fold(0.0, |next, current| current.max(next)); + + let icons: Cow<'static, [Handle]> = Cow::Owned(icons.to_vec()); + let selections: Cow<'static, [S]> = Cow::Owned(selections.to_vec()); + let state = state.clone(); + let on_close = surface::action::destroy_popup(id); + let on_surface_action_clone = on_surface_action.clone(); + let translation = layout.virtual_offset(); + let get_popup_action = surface::action::simple_popup::( + move || { + SctkPopupSettings { + parent, + id, + input_zone: None, + positioner: SctkPositioner { + size: Some((selections_width as u32 + gap as u32 + pad_width as u32 + icon_width as u32, 10)), + anchor_rect, + // TODO: left or right alignment based on direction? + anchor: cctk::wayland_protocols::xdg::shell::client::xdg_positioner::Anchor::BottomLeft, + gravity: cctk::wayland_protocols::xdg::shell::client::xdg_positioner::Gravity::BottomRight, + reactive: true, + offset: ((-padding.left - translation.x) as i32, -translation.y as i32), + constraint_adjustment: 9, + ..Default::default() + }, + parent_size: None, + grab: true, + close_with_children: true, + } + }, + Some(Box::new(move || { + let action_map = action_map.clone(); + let on_selected = on_selected.clone(); + let e: Element<'static, crate::Action> = + Element::from(menu_widget( + bounds, + &state, + gap, + padding, + text_size.unwrap_or(14.0), + selections.clone(), + icons.clone(), + selected_option, + Arc::new(move |i| on_selected.clone()(i)), + Some(on_surface_action_clone(on_close.clone())), + )) + .map(move |m| crate::Action::App(action_map.clone()(m))); + e + })), + ); + shell.publish(on_surface_action(get_popup_action)); + } + }; + + let is_open = state.is_open.load(Ordering::Relaxed); + let refresh = state.close_operation && state.open_operation; + + if state.close_operation { + state.close_operation = false; + state.is_open.store(false, Ordering::SeqCst); + if is_open { + #[cfg(all(feature = "winit", feature = "wayland"))] + if let Some(ref on_close) = on_surface_action { + shell.publish(on_close(surface::action::destroy_popup(state.popup_id))); + } + } + } + + if state.open_operation { + state.open_operation = false; + state.is_open.store(true, Ordering::SeqCst); + if (refresh && is_open) || (!refresh && !is_open) { + open(shell, state, on_selected.clone()); + } + } + match event { Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) | Event::Touch(touch::Event::FingerPressed { .. }) => { - let state = state(); let is_open = state.is_open.load(Ordering::Relaxed); if is_open { // Event wasn't processed by overlay, so cursor was clicked either outside it's @@ -499,87 +646,7 @@ pub fn update< } event::Status::Captured } else if cursor.is_over(layout.bounds()) { - state.is_open.store(true, Ordering::Relaxed); - let mut hovered_guard = state.hovered_option.lock().unwrap(); - *hovered_guard = selected; - let id = window::Id::unique(); - state.popup_id = id; - #[cfg(all(feature = "winit", feature = "wayland"))] - if let Some(((on_surface_action, parent), action_map)) = - on_surface_action.zip(_window_id).zip(action_map) - { - use iced_runtime::platform_specific::wayland::popup::{ - SctkPopupSettings, SctkPositioner, - }; - let bounds = layout.bounds(); - let anchor_rect = Rectangle { - x: bounds.x as i32, - y: bounds.y as i32, - width: bounds.width as i32, - height: bounds.height as i32, - }; - let icon_width = if icons.is_empty() { 0.0 } else { 24.0 }; - let measure = |_label: &str, selection_paragraph: &crate::Paragraph| -> f32 { - selection_paragraph.min_width().round() - }; - let pad_width = padding.horizontal().mul_add(2.0, 16.0); - - let selections_width = selections - .iter() - .zip(state.selections.iter_mut()) - .map(|(label, selection)| measure(label.as_ref(), selection.raw())) - .fold(0.0, |next, current| current.max(next)); - - let icons: Cow<'static, [Handle]> = Cow::Owned(icons.to_vec()); - let selections: Cow<'static, [S]> = Cow::Owned(selections.to_vec()); - let state = state.clone(); - let on_close = surface::action::destroy_popup(id); - let on_surface_action_clone = on_surface_action.clone(); - let translation = layout.virtual_offset(); - let get_popup_action = surface::action::simple_popup::( - move || { - SctkPopupSettings { - parent, - id, - input_zone: None, - positioner: SctkPositioner { - size: Some((selections_width as u32 + gap as u32 + pad_width as u32 + icon_width as u32, 10)), - anchor_rect, - // TODO: left or right alignment based on direction? - anchor: cctk::wayland_protocols::xdg::shell::client::xdg_positioner::Anchor::BottomLeft, - gravity: cctk::wayland_protocols::xdg::shell::client::xdg_positioner::Gravity::BottomRight, - reactive: true, - offset: ((-padding.left - translation.x) as i32, -translation.y as i32), - constraint_adjustment: 9, - ..Default::default() - }, - parent_size: None, - grab: true, - close_with_children: true, - } - }, - Some(Box::new(move || { - let action_map = action_map.clone(); - let on_selected = on_selected.clone(); - let e: Element<'static, crate::Action> = - Element::from(menu_widget( - bounds, - &state, - gap, - padding, - text_size.unwrap_or(14.0), - selections.clone(), - icons.clone(), - selected_option, - Arc::new(move |i| on_selected.clone()(i)), - Some(on_surface_action_clone(on_close.clone())), - )) - .map(move |m| crate::Action::App(action_map.clone()(m))); - e - })), - ); - shell.publish(on_surface_action(get_popup_action)); - } + open(shell, state, on_selected); event::Status::Captured } else { event::Status::Ignored @@ -588,7 +655,6 @@ pub fn update< Event::Mouse(mouse::Event::WheelScrolled { delta: mouse::ScrollDelta::Lines { .. }, }) => { - let state = state(); let is_open = state.is_open.load(Ordering::Relaxed); if state.keyboard_modifiers.command() && cursor.is_over(layout.bounds()) && !is_open { @@ -604,8 +670,6 @@ pub fn update< } } Event::Keyboard(keyboard::Event::ModifiersChanged(modifiers)) => { - let state = state(); - state.keyboard_modifiers = *modifiers; event::Status::Ignored