libcosmic/src/widget/dnd_destination.rs
2026-03-04 12:04:33 -05:00

879 lines
27 KiB
Rust

use std::{
borrow::Cow,
sync::atomic::{AtomicU64, Ordering},
};
use iced::Vector;
use crate::{
Element,
iced::{
Event, Length, Rectangle,
clipboard::{
dnd::{self, DndAction, DndDestinationRectangle, DndEvent, OfferEvent},
mime::AllowedMimeTypes,
},
event,
id::Internal,
mouse, overlay,
},
iced_core::{
self, Clipboard, Shell, layout,
widget::{Tree, tree},
},
widget::{Id, Widget},
};
pub fn dnd_destination<'a, Message: 'static>(
child: impl Into<Element<'a, Message>>,
mimes: Vec<Cow<'static, str>>,
) -> DndDestination<'a, Message> {
DndDestination::new(child, mimes)
}
pub fn dnd_destination_for_data<'a, T: AllowedMimeTypes, Message: 'static>(
child: impl Into<Element<'a, Message>>,
on_finish: impl Fn(Option<T>, DndAction) -> Message + 'static,
) -> DndDestination<'a, Message> {
DndDestination::for_data(child, on_finish)
}
static DRAG_ID_COUNTER: AtomicU64 = AtomicU64::new(0);
const DND_DEST_LOG_TARGET: &str = "libcosmic::widget::dnd_destination";
#[cfg(feature = "xdg-portal")]
pub const FILE_TRANSFER_MIME: &str = "application/vnd.portal.filetransfer";
#[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>,
preferred_action: DndAction,
action: DndAction,
container: Element<'a, Message>,
mime_types: Vec<Cow<'static, str>>,
forward_drag_as_cursor: bool,
on_hold: Option<Box<dyn Fn(f64, f64) -> Message>>,
on_drop: Option<Box<dyn Fn(f64, f64) -> Message>>,
on_enter: Option<Box<dyn Fn(f64, f64, Vec<String>) -> Message>>,
on_leave: Option<Box<dyn Fn() -> Message>>,
on_motion: Option<Box<dyn Fn(f64, f64) -> Message>>,
on_action_selected: Option<Box<dyn Fn(DndAction) -> Message>>,
on_data_received: Option<Box<dyn Fn(String, Vec<u8>) -> Message>>,
on_finish: Option<Box<dyn Fn(String, Vec<u8>, DndAction, f64, f64) -> Message>>,
#[cfg(feature = "xdg-portal")]
on_file_transfer: Option<Box<dyn Fn(String) -> Message>>,
}
impl<'a, Message: 'static> DndDestination<'a, Message> {
fn mime_matches(&self, offered: &[String]) -> bool {
self.mime_types.is_empty()
|| offered
.iter()
.any(|mime| self.mime_types.iter().any(|allowed| allowed == mime))
}
pub fn new(child: impl Into<Element<'a, Message>>, mimes: Vec<Cow<'static, str>>) -> Self {
Self {
id: Id::unique(),
drag_id: None,
mime_types: mimes,
preferred_action: DndAction::Move,
action: DndAction::Copy | DndAction::Move,
container: child.into(),
forward_drag_as_cursor: false,
on_hold: None,
on_drop: None,
on_enter: None,
on_leave: None,
on_motion: None,
on_action_selected: None,
on_data_received: None,
on_finish: None,
#[cfg(feature = "xdg-portal")]
on_file_transfer: None,
}
}
pub fn for_data<T: AllowedMimeTypes>(
child: impl Into<Element<'a, Message>>,
on_finish: impl Fn(Option<T>, DndAction) -> Message + 'static,
) -> Self {
Self {
id: Id::unique(),
drag_id: None,
mime_types: T::allowed().iter().cloned().map(Cow::Owned).collect(),
preferred_action: DndAction::Move,
action: DndAction::Copy | DndAction::Move,
container: child.into(),
forward_drag_as_cursor: false,
on_hold: None,
on_drop: None,
on_enter: None,
on_leave: None,
on_motion: None,
on_action_selected: None,
on_data_received: None,
on_finish: Some(Box::new(move |mime, data, action, _, _| {
on_finish(T::try_from((data, mime)).ok(), action)
})),
#[cfg(feature = "xdg-portal")]
on_file_transfer: None,
}
}
#[must_use]
pub fn data_received_for<T: AllowedMimeTypes>(
mut self,
f: impl Fn(Option<T>) -> Message + 'static,
) -> Self {
self.on_data_received = Some(Box::new(
move |mime, data| f(T::try_from((data, mime)).ok()),
));
self
}
pub fn with_id(
child: impl Into<Element<'a, Message>>,
id: Id,
mimes: Vec<Cow<'static, str>>,
) -> Self {
Self {
id,
drag_id: None,
mime_types: mimes,
preferred_action: DndAction::Move,
action: DndAction::Copy | DndAction::Move,
container: child.into(),
forward_drag_as_cursor: false,
on_hold: None,
on_drop: None,
on_enter: None,
on_leave: None,
on_motion: None,
on_action_selected: None,
on_data_received: None,
on_finish: None,
#[cfg(feature = "xdg-portal")]
on_file_transfer: None,
}
}
#[must_use]
pub fn drag_id(mut self, id: u64) -> Self {
self.drag_id = Some(id);
self
}
#[must_use]
pub fn action(mut self, action: DndAction) -> Self {
self.action = action;
self
}
#[must_use]
pub fn preferred_action(mut self, action: DndAction) -> Self {
self.preferred_action = action;
self
}
#[must_use]
pub fn forward_drag_as_cursor(mut self, forward: bool) -> Self {
self.forward_drag_as_cursor = forward;
self
}
#[must_use]
pub fn on_hold(mut self, f: impl Fn(f64, f64) -> Message + 'static) -> Self {
self.on_hold = Some(Box::new(f));
self
}
#[must_use]
pub fn on_drop(mut self, f: impl Fn(f64, f64) -> Message + 'static) -> Self {
self.on_drop = Some(Box::new(f));
self
}
#[must_use]
pub fn on_enter(mut self, f: impl Fn(f64, f64, Vec<String>) -> Message + 'static) -> Self {
self.on_enter = Some(Box::new(f));
self
}
#[must_use]
pub fn on_leave(mut self, m: impl Fn() -> Message + 'static) -> Self {
self.on_leave = Some(Box::new(m));
self
}
#[must_use]
pub fn on_finish(
mut self,
f: impl Fn(String, Vec<u8>, DndAction, f64, f64) -> Message + 'static,
) -> Self {
self.on_finish = Some(Box::new(f));
self
}
#[must_use]
pub fn on_motion(mut self, f: impl Fn(f64, f64) -> Message + 'static) -> Self {
self.on_motion = Some(Box::new(f));
self
}
#[must_use]
pub fn on_action_selected(mut self, f: impl Fn(DndAction) -> Message + 'static) -> Self {
self.on_action_selected = Some(Box::new(f));
self
}
#[must_use]
pub fn on_data_received(mut self, f: impl Fn(String, Vec<u8>) -> Message + 'static) -> Self {
self.on_data_received = Some(Box::new(f));
self
}
/// Add a message that will be emitted instead of [`on_data_received`](Self::on_data_received) if the dropped files
/// are offered through the xdg share portal. You can then use [`crate::command::file_transfer_receive`]
/// with the key to receive the files.
#[cfg(feature = "xdg-portal")]
#[must_use]
pub fn on_file_transfer(mut self, f: impl Fn(String) -> Message + 'static) -> Self {
match self.mime_types.iter().position(|v| v == "text/uri-list") {
Some(i) => self.mime_types.insert(i, Cow::Borrowed(FILE_TRANSFER_MIME)),
None => self.mime_types.push(Cow::Borrowed(FILE_TRANSFER_MIME)),
}
self.on_file_transfer = Some(Box::new(f));
self
}
/// 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 {
u128::from(self.drag_id.unwrap_or_else(|| match &self.id.0 {
Internal::Unique(id) | Internal::Custom(id, _) => *id,
Internal::Set(_) => panic!("Invalid Id assigned to dnd destination."),
}))
}
pub fn id(mut self, id: Id) -> Self {
self.id = id;
self
}
}
impl<Message: 'static> Widget<Message, crate::Theme, crate::Renderer>
for DndDestination<'_, Message>
{
fn children(&self) -> Vec<Tree> {
vec![Tree::new(&self.container)]
}
fn tag(&self) -> iced_core::widget::tree::Tag {
tree::Tag::of::<State<()>>()
}
fn diff(&mut self, tree: &mut Tree) {
tree.diff_children(std::slice::from_mut(&mut self.container));
}
fn state(&self) -> iced_core::widget::tree::State {
tree::State::new(State::<()>::new())
}
fn size(&self) -> iced_core::Size<Length> {
self.container.as_widget().size()
}
fn layout(
&mut self,
tree: &mut Tree,
renderer: &crate::Renderer,
limits: &layout::Limits,
) -> layout::Node {
self.container
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn operate(
&mut self,
tree: &mut Tree,
layout: layout::Layout<'_>,
renderer: &crate::Renderer,
operation: &mut dyn iced_core::widget::Operation<()>,
) {
self.container
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, operation);
}
#[allow(clippy::too_many_lines)]
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: layout::Layout<'_>,
cursor: mouse::Cursor,
renderer: &crate::Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
self.container.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
if shell.is_event_captured() {
return;
}
let state = tree.state.downcast_mut::<State<()>>();
let my_id = self.get_drag_id();
log::trace!(
target: DND_DEST_LOG_TARGET,
"dnd_destination id={:?}: event {:?}",
self.drag_id.unwrap_or_default(),
event
);
match event {
Event::Dnd(DndEvent::Offer(
id,
OfferEvent::Enter {
x, y, mime_types, ..
},
)) if *id == Some(my_id) => {
if !self.mime_matches(&mime_types) {
log::trace!(
target: DND_DEST_LOG_TARGET,
"offer enter id={my_id:?} ignored (mimes={mime_types:?} not in {:?})",
self.mime_types
);
return;
}
log::trace!(
target: DND_DEST_LOG_TARGET,
"offer enter id={my_id:?} coords=({x},{y}) mimes={mime_types:?}"
);
if let Some(msg) = state.on_enter(
*x,
*y,
mime_types.clone(),
self.on_enter.as_ref().map(std::convert::AsRef::as_ref),
(),
) {
shell.publish(msg);
}
if self.forward_drag_as_cursor {
#[allow(clippy::cast_possible_truncation)]
let drag_cursor = mouse::Cursor::Available((*x as f32, *y as f32).into());
let event = Event::Mouse(mouse::Event::CursorMoved {
position: drag_cursor.position().unwrap(),
});
self.container.as_widget_mut().update(
&mut tree.children[0],
&event,
layout,
drag_cursor,
renderer,
clipboard,
shell,
viewport,
);
}
shell.capture_event();
return;
}
Event::Dnd(DndEvent::Offer(_, OfferEvent::Leave)) => {
log::trace!(
target: DND_DEST_LOG_TARGET,
"offer leave id={:?}",
my_id
);
if let Some(msg) =
state.on_leave(self.on_leave.as_ref().map(std::convert::AsRef::as_ref))
{
shell.publish(msg);
}
if self.forward_drag_as_cursor {
let drag_cursor = mouse::Cursor::Unavailable;
let event = Event::Mouse(mouse::Event::CursorLeft);
self.container.as_widget_mut().update(
&mut tree.children[0],
&event,
layout,
drag_cursor,
renderer,
clipboard,
shell,
viewport,
);
}
return;
}
Event::Dnd(DndEvent::Offer(id, OfferEvent::Motion { x, y })) if *id == Some(my_id) => {
log::trace!(
target: DND_DEST_LOG_TARGET,
"offer motion id={my_id:?} coords=({x},{y})"
);
if let Some(msg) = state.on_motion(
*x,
*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);
}
if self.forward_drag_as_cursor {
#[allow(clippy::cast_possible_truncation)]
let drag_cursor = mouse::Cursor::Available((*x as f32, *y as f32).into());
let event = Event::Mouse(mouse::Event::CursorMoved {
position: drag_cursor.position().unwrap(),
});
self.container.as_widget_mut().update(
&mut tree.children[0],
&event,
layout,
drag_cursor,
renderer,
clipboard,
shell,
viewport,
);
}
shell.capture_event();
return;
}
Event::Dnd(DndEvent::Offer(_, OfferEvent::LeaveDestination)) => {
log::trace!(
target: DND_DEST_LOG_TARGET,
"offer leave-destination id={:?}",
my_id
);
if let Some(msg) =
state.on_leave(self.on_leave.as_ref().map(std::convert::AsRef::as_ref))
{
shell.publish(msg);
}
return;
}
Event::Dnd(DndEvent::Offer(id, OfferEvent::Drop)) if *id == Some(my_id) => {
log::trace!(
target: DND_DEST_LOG_TARGET,
"offer drop id={my_id:?}"
);
if let Some(msg) =
state.on_drop(self.on_drop.as_ref().map(std::convert::AsRef::as_ref))
{
shell.publish(msg);
}
shell.capture_event();
return;
}
Event::Dnd(DndEvent::Offer(id, OfferEvent::SelectedAction(action)))
if *id == Some(my_id) =>
{
log::trace!(
target: DND_DEST_LOG_TARGET,
"offer selected-action id={my_id:?} action={action:?}"
);
if let Some(msg) = state.on_action_selected(
*action,
self.on_action_selected
.as_ref()
.map(std::convert::AsRef::as_ref),
) {
shell.publish(msg);
}
shell.capture_event();
return;
}
Event::Dnd(DndEvent::Offer(id, OfferEvent::Data { data, mime_type }))
if *id == Some(my_id) =>
{
log::trace!(
target: DND_DEST_LOG_TARGET,
"offer data id={my_id:?} mime={mime_type:?} bytes={}",
data.len()
);
#[cfg(feature = "xdg-portal")]
if mime_type == FILE_TRANSFER_MIME
&& let Some(f) = self.on_file_transfer.as_ref()
&& let Ok(s) = String::from_utf8(data[..data.len() - 1].to_vec())
{
shell.publish(f(s));
shell.capture_event();
return;
}
if let (Some(msg), ret) = state.on_data_received(
mime_type.clone(),
data.clone(),
self.on_data_received
.as_ref()
.map(std::convert::AsRef::as_ref),
self.on_finish.as_ref().map(std::convert::AsRef::as_ref),
) {
shell.publish(msg);
if ret == event::Status::Captured {
log::trace!(
target: DND_DEST_LOG_TARGET,
"offer data id={my_id:?} captured"
);
shell.capture_event();
}
return;
}
shell.capture_event();
return;
}
_ => {}
}
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: layout::Layout<'_>,
cursor_position: mouse::Cursor,
viewport: &Rectangle,
renderer: &crate::Renderer,
) -> mouse::Interaction {
self.container.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor_position,
viewport,
renderer,
)
}
fn draw(
&self,
tree: &Tree,
renderer: &mut crate::Renderer,
theme: &crate::Theme,
renderer_style: &iced_core::renderer::Style,
layout: layout::Layout<'_>,
cursor_position: mouse::Cursor,
viewport: &Rectangle,
) {
self.container.as_widget().draw(
&tree.children[0],
renderer,
theme,
renderer_style,
layout,
cursor_position,
viewport,
);
}
fn overlay<'b>(
&'b mut self,
tree: &'b mut Tree,
layout: layout::Layout<'b>,
renderer: &crate::Renderer,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'b, Message, crate::Theme, crate::Renderer>> {
self.container.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
fn drag_destinations(
&self,
state: &Tree,
layout: layout::Layout<'_>,
renderer: &crate::Renderer,
dnd_rectangles: &mut iced_core::clipboard::DndDestinationRectangles,
) {
let bounds = layout.bounds();
let my_id = self.get_drag_id();
log::trace!(
target: DND_DEST_LOG_TARGET,
"register destination id={:?} bounds=({:.2},{:.2},{:.2},{:.2}) mimes={:?}",
my_id,
bounds.x,
bounds.y,
bounds.width,
bounds.height,
self.mime_types
);
let my_dest = 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.mime_types.clone(),
actions: self.action,
preferred: self.preferred_action,
};
dnd_rectangles.push(my_dest);
self.container.as_widget().drag_destinations(
&state.children[0],
layout,
renderer,
dnd_rectangles,
);
}
fn id(&self) -> Option<Id> {
Some(self.id.clone())
}
fn set_id(&mut self, id: Id) {
self.id = id;
}
#[cfg(feature = "a11y")]
/// get the a11y nodes for the widget
fn a11y_nodes(
&self,
layout: iced_core::Layout<'_>,
state: &Tree,
p: mouse::Cursor,
) -> iced_accessibility::A11yTree {
let c_state = &state.children[0];
self.container.as_widget().a11y_nodes(layout, c_state, p)
}
}
#[derive(Default)]
pub struct State<T> {
pub drag_offer: Option<DragOffer<T>>,
}
pub struct DragOffer<T> {
pub x: f64,
pub y: f64,
pub dropped: bool,
pub selected_action: DndAction,
pub data: T,
}
impl<T> State<T> {
#[must_use]
pub fn new() -> Self {
Self { drag_offer: None }
}
pub fn on_enter<Message>(
&mut self,
x: f64,
y: f64,
mime_types: Vec<String>,
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))
}
pub fn on_leave<Message>(&mut self, on_leave: Option<&dyn Fn() -> Message>) -> Option<Message> {
if self.drag_offer.as_ref().is_some_and(|d| !d.dropped) {
self.drag_offer = None;
on_leave.map(|f| f())
} else {
None
}
}
pub fn on_motion<Message>(
&mut self,
x: f64,
y: f64,
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;
s.y = y;
} else {
self.drag_offer = Some(DragOffer {
x,
y,
dropped: false,
selected_action: DndAction::empty(),
data,
});
if let Some(f) = on_enter {
return Some(f(x, y, vec![]));
}
}
on_motion.map(|f| f(x, y))
}
pub fn on_drop<Message>(
&mut self,
on_drop: Option<impl Fn(f64, f64) -> Message>,
) -> Option<Message> {
if let Some(offer) = self.drag_offer.as_mut() {
offer.dropped = true;
if let Some(f) = on_drop {
return Some(f(offer.x, offer.y));
}
}
None
}
pub fn on_action_selected<Message>(
&mut self,
action: DndAction,
on_action_selected: Option<impl Fn(DndAction) -> Message>,
) -> Option<Message> {
if let Some(s) = self.drag_offer.as_mut() {
s.selected_action = action;
}
if let Some(f) = on_action_selected {
f(action).into()
} else {
None
}
}
pub fn on_data_received<Message>(
&mut self,
mime: String,
data: Vec<u8>,
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;
return (None, event::Status::Ignored);
};
if dnd.dropped {
let ret = (
on_finish.map(|f| f(mime, data, dnd.selected_action, dnd.x, dnd.y)),
event::Status::Captured,
);
self.drag_offer = None;
ret
} else if let Some(f) = on_data_received {
(Some(f(mime, data)), event::Status::Captured)
} else {
(None, event::Status::Ignored)
}
}
}
impl<'a, Message: 'static> From<DndDestination<'a, Message>> for Element<'a, Message> {
fn from(wrapper: DndDestination<'a, Message>) -> Self {
Element::new(wrapper)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Clone, Copy, Debug, PartialEq)]
enum TestMsg {
Data,
Finished,
}
#[test]
fn data_before_drop_invokes_data_handler_only() {
let mut state: State<()> = State::new();
assert!(state.drag_offer.is_none());
state.on_enter::<TestMsg>(
4.0,
2.0,
vec!["text/plain".into()],
Option::<fn(_, _, _) -> TestMsg>::None,
(),
);
let (message, status) = state.on_data_received(
"text/plain".into(),
vec![1],
Some(|mime, data| {
assert_eq!(mime, "text/plain");
assert_eq!(data, vec![1]);
TestMsg::Data
}),
Option::<fn(_, _, _, _, _) -> TestMsg>::None,
);
assert!(matches!(message, Some(TestMsg::Data)));
assert_eq!(status, event::Status::Captured);
assert!(state.drag_offer.is_some());
}
#[test]
fn finish_only_emits_after_drop() {
let mut state: State<()> = State::new();
state.on_enter::<TestMsg>(
5.0,
-1.0,
vec![],
Option::<fn(_, _, _) -> TestMsg>::None,
(),
);
state.on_action_selected::<TestMsg>(DndAction::Move, Option::<fn(_) -> TestMsg>::None);
state.on_drop::<TestMsg>(Option::<fn(_, _) -> TestMsg>::None);
let (message, status) = state.on_data_received(
"application/x-test".into(),
vec![7],
Option::<fn(_, _) -> TestMsg>::None,
Some(|mime, data, action, x, y| {
assert_eq!(mime, "application/x-test");
assert_eq!(data, vec![7]);
assert_eq!(action, DndAction::Move);
assert_eq!(x, 5.0);
assert_eq!(y, -1.0);
TestMsg::Finished
}),
);
assert!(matches!(message, Some(TestMsg::Finished)));
assert_eq!(status, event::Status::Captured);
assert!(state.drag_offer.is_none());
}
}