From d625291266116f9841158c55b705ae9fdd0d4cb4 Mon Sep 17 00:00:00 2001 From: Ashley Wulber Date: Tue, 9 Apr 2024 14:28:06 -0400 Subject: [PATCH] feat: dnd for segmented buttons and nav --- src/widget/dnd_destination.rs | 58 +++++-- src/widget/mod.rs | 8 +- src/widget/nav_bar.rs | 41 +++++ src/widget/segmented_button/widget.rs | 218 +++++++++++++++++++++++++- 4 files changed, 305 insertions(+), 20 deletions(-) diff --git a/src/widget/dnd_destination.rs b/src/widget/dnd_destination.rs index 3325c64b..57a52768 100644 --- a/src/widget/dnd_destination.rs +++ b/src/widget/dnd_destination.rs @@ -1,4 +1,7 @@ -use std::borrow::Cow; +use std::{ + borrow::Cow, + sync::atomic::{AtomicU64, Ordering}, +}; use crate::{ iced::{ @@ -33,6 +36,24 @@ pub fn dnd_destination_for_data( DndDestination::for_data(child, on_finish) } +static DRAG_ID_COUNTER: AtomicU64 = AtomicU64::new(0); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct DragId(pub u128); + +impl DragId { + pub fn new() -> Self { + DragId(u128::from(u64::MAX) + u128::from(DRAG_ID_COUNTER.fetch_add(1, Ordering::Relaxed))) + } +} + +#[allow(clippy::new_without_default)] +impl Default for DragId { + fn default() -> Self { + DragId::new() + } +} + pub struct DndDestination<'a, Message> { id: Id, drag_id: Option, @@ -228,7 +249,7 @@ impl<'a, Message: 'static> Widget } fn tag(&self) -> iced_core::widget::tree::Tag { - tree::Tag::of::() + tree::Tag::of::>() } fn diff(&mut self, tree: &mut Tree) { @@ -236,7 +257,7 @@ impl<'a, Message: 'static> Widget } fn state(&self) -> iced_core::widget::tree::State { - tree::State::new(State::new()) + tree::State::new(State::<()>::new()) } fn size(&self) -> iced_core::Size { @@ -294,7 +315,7 @@ impl<'a, Message: 'static> Widget return event::Status::Captured; } - let state = tree.state.downcast_mut::(); + let state = tree.state.downcast_mut::>(); let my_id = self.get_drag_id(); @@ -310,6 +331,7 @@ impl<'a, Message: 'static> Widget y, mime_types, self.on_enter.as_ref().map(std::convert::AsRef::as_ref), + (), ) { shell.publish(msg); } @@ -357,6 +379,7 @@ impl<'a, Message: 'static> Widget y, self.on_motion.as_ref().map(std::convert::AsRef::as_ref), self.on_enter.as_ref().map(std::convert::AsRef::as_ref), + (), ) { shell.publish(msg); } @@ -516,18 +539,19 @@ impl<'a, Message: 'static> Widget } #[derive(Default)] -pub struct State { - pub drag_offer: Option, +pub struct State { + pub drag_offer: Option>, } -pub struct DragOffer { +pub struct DragOffer { pub x: f64, pub y: f64, pub dropped: bool, pub selected_action: DndAction, + pub data: T, } -impl State { +impl State { #[must_use] pub fn new() -> Self { Self { drag_offer: None } @@ -538,13 +562,15 @@ impl State { x: f64, y: f64, mime_types: Vec, - on_enter: Option<&dyn Fn(f64, f64, Vec) -> Message>, + on_enter: Option) -> Message>, + data: T, ) -> Option { self.drag_offer = Some(DragOffer { x, y, dropped: false, selected_action: DndAction::empty(), + data, }); on_enter.map(|f| f(x, y, mime_types)) } @@ -562,8 +588,9 @@ impl State { &mut self, x: f64, y: f64, - on_motion: Option<&dyn Fn(f64, f64) -> Message>, - on_enter: Option<&dyn Fn(f64, f64, Vec) -> Message>, + on_motion: Option Message>, + on_enter: Option) -> Message>, + data: T, ) -> Option { if let Some(s) = self.drag_offer.as_mut() { s.x = x; @@ -574,6 +601,7 @@ impl State { y, dropped: false, selected_action: DndAction::empty(), + data, }); if let Some(f) = on_enter { return Some(f(x, y, vec![])); @@ -584,7 +612,7 @@ impl State { pub fn on_drop( &mut self, - on_drop: Option<&dyn Fn(f64, f64) -> Message>, + on_drop: Option Message>, ) -> Option { if let Some(offer) = self.drag_offer.as_mut() { offer.dropped = true; @@ -598,7 +626,7 @@ impl State { pub fn on_action_selected( &mut self, action: DndAction, - on_action_selected: Option<&dyn Fn(DndAction) -> Message>, + on_action_selected: Option Message>, ) -> Option { if let Some(s) = self.drag_offer.as_mut() { s.selected_action = action; @@ -614,8 +642,8 @@ impl State { &mut self, mime: String, data: Vec, - on_data_received: Option<&dyn Fn(String, Vec) -> Message>, - on_finish: Option<&dyn Fn(String, Vec, DndAction, f64, f64) -> Message>, + on_data_received: Option) -> Message>, + on_finish: Option, DndAction, f64, f64) -> Message>, ) -> (Option, event::Status) { let Some(dnd) = self.drag_offer.as_ref() else { self.drag_offer = None; diff --git a/src/widget/mod.rs b/src/widget/mod.rs index ccf7f542..ed0e430c 100644 --- a/src/widget/mod.rs +++ b/src/widget/mod.rs @@ -113,6 +113,12 @@ pub mod divider { } } +pub mod dnd_destination; +pub use dnd_destination::{dnd_destination, DndDestination}; + +pub mod dnd_source; +pub use dnd_source::{dnd_source, DndSource}; + pub mod dropdown; pub use dropdown::{dropdown, Dropdown}; @@ -137,7 +143,7 @@ pub use list::*; pub mod menu; pub mod nav_bar; -pub use nav_bar::nav_bar; +pub use nav_bar::{nav_bar, nav_bar_dnd}; pub mod nav_bar_toggle; pub use nav_bar_toggle::{nav_bar_toggle, NavBarToggle}; diff --git a/src/widget/nav_bar.rs b/src/widget/nav_bar.rs index aedde7ee..7924e32a 100644 --- a/src/widget/nav_bar.rs +++ b/src/widget/nav_bar.rs @@ -7,6 +7,7 @@ use apply::Apply; use iced::{ + clipboard::{dnd::DndAction, mime::AllowedMimeTypes}, widget::{container, scrollable}, Background, Length, }; @@ -14,6 +15,8 @@ use iced_core::{Border, Color, Shadow}; use crate::{theme, widget::segmented_button, Theme}; +use super::dnd_destination::DragId; + pub type Id = segmented_button::Entity; pub type Model = segmented_button::SingleSelectModel; @@ -46,6 +49,44 @@ where .style(theme::Container::custom(nav_bar_style)) } +/// Navigation side panel for switching between views. +/// Can receive drag and drop events. +pub fn nav_bar_dnd( + model: &segmented_button::SingleSelectModel, + on_activate: fn(segmented_button::Entity) -> Message, + on_dnd_enter: impl Fn(segmented_button::Entity, Vec) -> Message + 'static, + on_dnd_leave: impl Fn(segmented_button::Entity) -> Message + 'static, + on_dnd_drop: impl Fn(segmented_button::Entity, Option, DndAction) -> Message + 'static, + id: DragId, +) -> iced::widget::Container +where + Message: Clone + 'static, +{ + let theme = crate::theme::active(); + let space_s = theme.cosmic().space_s(); + let space_xxs = theme.cosmic().space_xxs(); + + let nav_buttons = segmented_button::vertical(model) + .button_height(32) + .button_padding([space_s, space_xxs, space_s, space_xxs]) + .button_spacing(space_xxs) + .spacing(space_xxs) + .on_activate(on_activate) + .style(crate::theme::SegmentedButton::TabBar) + .on_dnd_enter(on_dnd_enter) + .on_dnd_leave(on_dnd_leave) + .on_dnd_drop(on_dnd_drop) + .drag_id(id); + + nav_buttons + .apply(scrollable) + .height(Length::Fill) + .apply(container) + .padding(space_xxs) + .height(Length::Fill) + .style(theme::Container::custom(nav_bar_style)) +} + #[must_use] pub fn nav_bar_style(theme: &Theme) -> iced_style::container::Appearance { let cosmic = &theme.cosmic(); diff --git a/src/widget/segmented_button/widget.rs b/src/widget/segmented_button/widget.rs index 81476689..86eff9de 100644 --- a/src/widget/segmented_button/widget.rs +++ b/src/widget/segmented_button/widget.rs @@ -2,10 +2,14 @@ // SPDX-License-Identifier: MPL-2.0 use super::model::{Entity, Model, Selectable}; +use crate::iced_core::id::Internal; use crate::theme::{SegmentedButton as Style, THEME}; +use crate::widget::dnd_destination::DragId; use crate::widget::{icon, Icon}; use crate::{Element, Renderer}; use derive_setters::Setters; +use iced::clipboard::dnd::{self, DndAction, DndDestinationRectangle, DndEvent, OfferEvent}; +use iced::clipboard::mime::AllowedMimeTypes; use iced::{ alignment, event, keyboard, mouse, touch, Alignment, Background, Color, Command, Event, Length, Padding, Rectangle, Size, @@ -16,9 +20,11 @@ use iced_core::widget::{self, operation, tree}; use iced_core::{layout, renderer, widget::Tree, Clipboard, Layout, Shell, Widget}; use iced_core::{Border, Gradient, Point, Renderer as IcedRenderer, Shadow, Text}; use slotmap::{Key, SecondaryMap}; +use std::borrow::Cow; use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; use std::marker::PhantomData; +use std::mem; use std::time::{Duration, Instant}; /// A command that focuses a segmented item stored in a widget. @@ -67,7 +73,7 @@ where #[setters(skip)] pub(super) model: &'a Model, /// iced widget ID - pub(super) id: Option, + pub(super) id: Id, /// The icon used for the close button. pub(super) close_icon: Icon, /// Scrolling switches focus between tabs. @@ -118,6 +124,16 @@ where #[setters(skip)] pub(super) on_close: Option Message + 'static>>, #[setters(skip)] + pub(super) on_dnd_drop: + Option, String, DndAction) -> Message + 'static>>, + pub(super) mimes: Vec, + #[setters(skip)] + pub(super) on_dnd_enter: Option) -> Message + 'static>>, + #[setters(skip)] + pub(super) on_dnd_leave: Option Message + 'static>>, + #[setters(strip_option)] + pub(super) drag_id: Option, + #[setters(skip)] /// Defines the implementation of this struct variant: PhantomData, } @@ -131,7 +147,7 @@ where pub fn new(model: &'a Model) -> Self { Self { model, - id: None, + id: Id::unique(), close_icon: icon::from_name("window-close-symbolic").size(16).icon(), scrollable_focus: false, show_close_icon_on_hover: false, @@ -155,7 +171,12 @@ where style: Style::default(), on_activate: None, on_close: None, + on_dnd_drop: None, + on_dnd_enter: None, + on_dnd_leave: None, + mimes: Vec::new(), variant: PhantomData, + drag_id: None, } } @@ -182,6 +203,33 @@ where self.model.items.get(key).map_or(false, |item| item.enabled) } + /// Handle the dnd drop event. + pub fn on_dnd_drop( + mut self, + dnd_drop_handler: impl Fn(Entity, Option, DndAction) -> Message + 'static, + ) -> Self { + self.on_dnd_drop = Some(Box::new(move |entity, data, mime, action| { + dnd_drop_handler(entity, D::try_from((data, mime)).ok(), action) + })); + self.mimes = D::allowed().iter().map(|mime| mime.to_string()).collect(); + self + } + + /// Handle the dnd enter event. + pub fn on_dnd_enter( + mut self, + dnd_enter_handler: impl Fn(Entity, Vec) -> Message + 'static, + ) -> Self { + self.on_dnd_enter = Some(Box::new(dnd_enter_handler)); + self + } + + /// Handle the dnd leave event. + pub fn on_dnd_leave(mut self, dnd_leave_handler: impl Fn(Entity) -> Message + 'static) -> Self { + self.on_dnd_leave = Some(Box::new(dnd_leave_handler)); + self + } + /// Item the previous item in the widget. fn focus_previous(&mut self, state: &mut LocalState) -> event::Status { match state.focused_item { @@ -416,6 +464,28 @@ where fn button_is_hovered(&self, state: &LocalState, key: Entity) -> bool { self.on_activate.is_some() && state.hovered == Item::Tab(key) + || state + .dnd_state + .drag_offer + .as_ref() + .is_some_and(|id| id.data == key) + } + + /// Returns the drag id of the destination. + /// + /// # Panics + /// Panics if the destination has been assigned a Set id, which is invalid. + #[must_use] + pub fn get_drag_id(&self) -> u128 { + self.drag_id.map_or_else( + || { + u128::from(match &self.id.0 .0 { + Internal::Unique(id) | Internal::Custom(id, _) => *id, + Internal::Set(_) => panic!("Invalid Id assigned to dnd destination."), + }) + }, + |id| id.0, + ) } } @@ -507,7 +577,7 @@ where fn on_event( &mut self, tree: &mut Tree, - event: Event, + mut event: Event, layout: Layout<'_>, cursor_position: mouse::Cursor, _renderer: &Renderer, @@ -519,6 +589,120 @@ where let state = tree.state.downcast_mut::(); state.hovered = Item::None; + let my_id = self.get_drag_id(); + + if let Event::Dnd(e) = &mut event { + let entity = state + .dnd_state + .drag_offer + .as_ref() + .map(|dnd_state| dnd_state.data); + match e { + DndEvent::Offer( + id, + OfferEvent::Enter { + x, y, mime_types, .. + }, + ) if Some(my_id) == *id => { + let entity = self + .variant_bounds(state, bounds) + .filter_map(|item| match item { + ItemBounds::Button(entity, bounds) => Some((entity, bounds)), + _ => None, + }) + .find(|(_key, bounds)| bounds.contains(Point::new(*x as f32, *y as f32))) + .map(|(key, _)| key); + if let Some(entity) = entity { + let on_dnd_enter = self + .on_dnd_enter + .as_ref() + .map(|on_enter| |_, _, mime_types| on_enter(entity, mime_types)); + + _ = state.dnd_state.on_enter::( + *x, + *y, + mime_types.clone(), + on_dnd_enter, + entity, + ); + } + } + DndEvent::Offer(id, OfferEvent::Leave | OfferEvent::LeaveDestination) + if Some(my_id) == *id => + { + if let Some(entity) = entity { + if let Some(on_dnd_leave) = self.on_dnd_leave.as_ref() { + shell.publish(on_dnd_leave(entity)); + } + } + _ = state.dnd_state.on_leave::(None); + } + DndEvent::Offer(id, OfferEvent::Motion { x, y }) if Some(my_id) == *id => { + let new = self + .variant_bounds(state, bounds) + .filter_map(|item| match item { + ItemBounds::Button(entity, bounds) => Some((entity, bounds)), + _ => None, + }) + .find(|(_key, bounds)| bounds.contains(Point::new(*x as f32, *y as f32))) + .map(|(key, _)| key); + if let Some(new_entity) = new { + state.dnd_state.on_motion::( + *x, + *y, + None:: Message>, + None:: Message>, + new_entity, + ); + if Some(new_entity) != entity { + if let Some(on_dnd_enter) = self.on_dnd_enter.as_ref() { + shell.publish(on_dnd_enter(new_entity, Vec::new())); + } + if let Some(dnd) = state.dnd_state.drag_offer.as_mut() { + dnd.data = new_entity; + } + } + } else if entity.is_some() { + state.dnd_state.drag_offer = None; + if let Some(on_dnd_leave) = self.on_dnd_leave.as_ref() { + shell.publish(on_dnd_leave(entity.unwrap())); + } + } + } + DndEvent::Offer(id, OfferEvent::Drop) if Some(my_id) == *id => { + _ = state + .dnd_state + .on_drop::(None:: Message>); + } + DndEvent::Offer(id, OfferEvent::SelectedAction(action)) if Some(my_id) == *id => { + if let Some(entity) = entity { + _ = state + .dnd_state + .on_action_selected::(*action, None:: Message>); + } + } + DndEvent::Offer(id, OfferEvent::Data { data, mime_type }) if Some(my_id) == *id => { + if let Some(entity) = entity { + let on_drop = self.on_dnd_drop.as_ref(); + let on_drop = on_drop.map(|on_drop| { + |mime, data, action, _, _| on_drop(entity, data, mime, action) + }); + + if let (Some(msg), ret) = state.dnd_state.on_data_received( + mem::take(mime_type), + mem::take(data), + None:: Message>, + on_drop, + ) { + shell.publish(msg); + return ret; + } + } + } + _ => {} + } + } + if cursor_position.is_over(bounds) { // Check for clicks on the previous and next tab buttons, when tabs are collapsed. if state.collapsed { @@ -730,7 +914,7 @@ where >, ) { let state = tree.state.downcast_mut::(); - operation.focusable(state, self.id.as_ref().map(|id| &id.0)); + operation.focusable(state, Some(&self.id.0)); if let Item::Set = state.focused_item { if self.prev_tab_sensitive(state) { @@ -1173,6 +1357,30 @@ where ) -> Option> { None } + + fn drag_destinations( + &self, + _state: &Tree, + layout: Layout<'_>, + dnd_rectangles: &mut iced_core::clipboard::DndDestinationRectangles, + ) { + let bounds = layout.bounds(); + + let my_id = self.get_drag_id(); + let dnd_rect = DndDestinationRectangle { + id: my_id, + rectangle: dnd::Rectangle { + x: f64::from(bounds.x), + y: f64::from(bounds.y), + width: f64::from(bounds.width), + height: f64::from(bounds.height), + }, + mime_types: self.mimes.clone().into_iter().map(Cow::Owned).collect(), + actions: DndAction::Copy | DndAction::Move, + preferred: DndAction::Move, + }; + dnd_rectangles.push(dnd_rect); + } } impl<'a, Variant, SelectionMode, Message> From> @@ -1218,6 +1426,8 @@ pub struct LocalState { text_hashes: SecondaryMap, /// Time since last tab activation from wheel movements. wheel_timestamp: Option, + /// Dnd state + pub dnd_state: crate::widget::dnd_destination::State, } #[derive(Debug, Default, PartialEq)]