feat: dnd for segmented buttons and nav

This commit is contained in:
Ashley Wulber 2024-04-09 14:28:06 -04:00 committed by Michael Murphy
parent f15aeb4247
commit d625291266
4 changed files with 305 additions and 20 deletions

View file

@ -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<T: AllowedMimeTypes, Message: 'static>(
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<u64>,
@ -228,7 +249,7 @@ impl<'a, Message: 'static> Widget<Message, crate::Theme, crate::Renderer>
}
fn tag(&self) -> iced_core::widget::tree::Tag {
tree::Tag::of::<State>()
tree::Tag::of::<State<()>>()
}
fn diff(&mut self, tree: &mut Tree) {
@ -236,7 +257,7 @@ impl<'a, Message: 'static> Widget<Message, crate::Theme, crate::Renderer>
}
fn state(&self) -> iced_core::widget::tree::State {
tree::State::new(State::new())
tree::State::new(State::<()>::new())
}
fn size(&self) -> iced_core::Size<Length> {
@ -294,7 +315,7 @@ impl<'a, Message: 'static> Widget<Message, crate::Theme, crate::Renderer>
return event::Status::Captured;
}
let state = tree.state.downcast_mut::<State>();
let state = tree.state.downcast_mut::<State<()>>();
let my_id = self.get_drag_id();
@ -310,6 +331,7 @@ impl<'a, Message: 'static> Widget<Message, crate::Theme, crate::Renderer>
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<Message, crate::Theme, crate::Renderer>
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<Message, crate::Theme, crate::Renderer>
}
#[derive(Default)]
pub struct State {
pub drag_offer: Option<DragOffer>,
pub struct State<T> {
pub drag_offer: Option<DragOffer<T>>,
}
pub struct DragOffer {
pub struct DragOffer<T> {
pub x: f64,
pub y: f64,
pub dropped: bool,
pub selected_action: DndAction,
pub data: T,
}
impl State {
impl<T> State<T> {
#[must_use]
pub fn new() -> Self {
Self { drag_offer: None }
@ -538,13 +562,15 @@ impl State {
x: f64,
y: f64,
mime_types: Vec<String>,
on_enter: Option<&dyn Fn(f64, f64, Vec<String>) -> Message>,
on_enter: Option<impl Fn(f64, f64, Vec<String>) -> Message>,
data: T,
) -> Option<Message> {
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<String>) -> Message>,
on_motion: Option<impl Fn(f64, f64) -> Message>,
on_enter: Option<impl Fn(f64, f64, Vec<String>) -> Message>,
data: T,
) -> Option<Message> {
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<Message>(
&mut self,
on_drop: Option<&dyn Fn(f64, f64) -> Message>,
on_drop: Option<impl Fn(f64, f64) -> Message>,
) -> Option<Message> {
if let Some(offer) = self.drag_offer.as_mut() {
offer.dropped = true;
@ -598,7 +626,7 @@ impl State {
pub fn on_action_selected<Message>(
&mut self,
action: DndAction,
on_action_selected: Option<&dyn Fn(DndAction) -> Message>,
on_action_selected: Option<impl Fn(DndAction) -> Message>,
) -> Option<Message> {
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<u8>,
on_data_received: Option<&dyn Fn(String, Vec<u8>) -> Message>,
on_finish: Option<&dyn Fn(String, Vec<u8>, DndAction, f64, f64) -> Message>,
on_data_received: Option<impl Fn(String, Vec<u8>) -> Message>,
on_finish: Option<impl Fn(String, Vec<u8>, DndAction, f64, f64) -> Message>,
) -> (Option<Message>, event::Status) {
let Some(dnd) = self.drag_offer.as_ref() else {
self.drag_offer = None;

View file

@ -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};

View file

@ -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<Message, D: AllowedMimeTypes>(
model: &segmented_button::SingleSelectModel,
on_activate: fn(segmented_button::Entity) -> Message,
on_dnd_enter: impl Fn(segmented_button::Entity, Vec<String>) -> Message + 'static,
on_dnd_leave: impl Fn(segmented_button::Entity) -> Message + 'static,
on_dnd_drop: impl Fn(segmented_button::Entity, Option<D>, DndAction) -> Message + 'static,
id: DragId,
) -> iced::widget::Container<Message, crate::Theme, crate::Renderer>
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();

View file

@ -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<SelectionMode>,
/// iced widget ID
pub(super) id: Option<Id>,
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<Box<dyn Fn(Entity) -> Message + 'static>>,
#[setters(skip)]
pub(super) on_dnd_drop:
Option<Box<dyn Fn(Entity, Vec<u8>, String, DndAction) -> Message + 'static>>,
pub(super) mimes: Vec<String>,
#[setters(skip)]
pub(super) on_dnd_enter: Option<Box<dyn Fn(Entity, Vec<String>) -> Message + 'static>>,
#[setters(skip)]
pub(super) on_dnd_leave: Option<Box<dyn Fn(Entity) -> Message + 'static>>,
#[setters(strip_option)]
pub(super) drag_id: Option<DragId>,
#[setters(skip)]
/// Defines the implementation of this struct
variant: PhantomData<Variant>,
}
@ -131,7 +147,7 @@ where
pub fn new(model: &'a Model<SelectionMode>) -> 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<D: AllowedMimeTypes>(
mut self,
dnd_drop_handler: impl Fn(Entity, Option<D>, 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<String>) -> 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::<LocalState>();
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::<Message>(
*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::<Message>(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::<Message>(
*x,
*y,
None::<fn(_, _) -> Message>,
None::<fn(_, _, _) -> 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::<Message>(None::<fn(_, _) -> Message>);
}
DndEvent::Offer(id, OfferEvent::SelectedAction(action)) if Some(my_id) == *id => {
if let Some(entity) = entity {
_ = state
.dnd_state
.on_action_selected::<Message>(*action, None::<fn(_) -> 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::<fn(_, _) -> 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::<LocalState>();
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<iced_core::overlay::Element<'b, Message, crate::Theme, Renderer>> {
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<SegmentedButton<'a, Variant, SelectionMode, Message>>
@ -1218,6 +1426,8 @@ pub struct LocalState {
text_hashes: SecondaryMap<Entity, u64>,
/// Time since last tab activation from wheel movements.
wheel_timestamp: Option<Instant>,
/// Dnd state
pub dnd_state: crate::widget::dnd_destination::State<Entity>,
}
#[derive(Debug, Default, PartialEq)]