From 7f321cb0a3b5ec53f6a6d33320e4f5d0f737959c Mon Sep 17 00:00:00 2001 From: Stephan Buys Date: Wed, 19 Nov 2025 15:44:31 +0200 Subject: [PATCH] segmented button: support tab drag + drop --- Cargo.toml | 2 +- src/widget/dnd_destination.rs | 144 ++++- src/widget/segmented_button/mod.rs | 13 + src/widget/segmented_button/model/mod.rs | 71 +++ src/widget/segmented_button/widget.rs | 753 ++++++++++++++++++++++- 5 files changed, 950 insertions(+), 33 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 94b53a64..430af23d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -122,6 +122,7 @@ image = { version = "0.25.8", default-features = false, features = [ "png", ] } libc = { version = "0.2.175", optional = true } +log = "0.4" mime = { version = "0.3.17", optional = true } palette = "0.7.6" raw-window-handle = "0.6" @@ -222,4 +223,3 @@ dirs = "6.0.0" [dev-dependencies] tempfile = "3.13.0" - diff --git a/src/widget/dnd_destination.rs b/src/widget/dnd_destination.rs index ccc0fb18..c943d2c7 100644 --- a/src/widget/dnd_destination.rs +++ b/src/widget/dnd_destination.rs @@ -39,6 +39,7 @@ pub fn dnd_destination_for_data<'a, T: AllowedMimeTypes, Message: 'static>( } static DRAG_ID_COUNTER: AtomicU64 = AtomicU64::new(0); +const DND_DEST_LOG_TARGET: &str = "libcosmic::widget::dnd_destination"; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct DragId(pub u128); @@ -75,6 +76,12 @@ pub struct DndDestination<'a, 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>, mimes: Vec>) -> Self { Self { id: Id::unique(), @@ -324,6 +331,12 @@ impl Widget 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, @@ -331,6 +344,18 @@ impl Widget 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 event::Status::Ignored; + } + 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, @@ -360,6 +385,11 @@ impl Widget return event::Status::Captured; } 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)) { @@ -383,6 +413,10 @@ impl Widget return event::Status::Ignored; } 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, @@ -413,6 +447,11 @@ impl Widget return event::Status::Captured; } 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)) { @@ -421,6 +460,10 @@ impl Widget return event::Status::Ignored; } 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)) { @@ -431,6 +474,10 @@ impl Widget 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 @@ -444,6 +491,11 @@ impl Widget 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() + ); if let (Some(msg), ret) = state.on_data_received( mime_type, data, @@ -521,6 +573,16 @@ impl Widget ) { 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 { @@ -535,12 +597,14 @@ impl Widget }; dnd_rectangles.push(my_dest); - self.container.as_widget().drag_destinations( - &state.children[0], - layout, - renderer, - dnd_rectangles, - ); + if let Some(child_layout) = layout.children().next() { + self.container.as_widget().drag_destinations( + &state.children[0], + child_layout.with_virtual_offset(layout.virtual_offset()), + renderer, + dnd_rectangles, + ); + } } fn id(&self) -> Option { @@ -696,3 +760,71 @@ impl<'a, Message: 'static> From> for Element<'a, Mes 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::( + 4.0, + 2.0, + vec!["text/plain".into()], + Option:: 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:: 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::( + 5.0, + -1.0, + vec![], + Option:: TestMsg>::None, + (), + ); + state.on_action_selected::(DndAction::Move, Option:: TestMsg>::None); + state.on_drop::(Option:: TestMsg>::None); + + let (message, status) = state.on_data_received( + "application/x-test".into(), + vec![7], + Option:: 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()); + } +} diff --git a/src/widget/segmented_button/mod.rs b/src/widget/segmented_button/mod.rs index e609d70b..81c71be8 100644 --- a/src/widget/segmented_button/mod.rs +++ b/src/widget/segmented_button/mod.rs @@ -88,6 +88,19 @@ pub use self::style::{Appearance, ItemAppearance, ItemStatusAppearance, StyleShe pub use self::vertical::{VerticalSegmentedButton, vertical}; pub use self::widget::{Id, SegmentedButton, SegmentedVariant, focus}; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum InsertPosition { + Before, + After, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ReorderEvent { + pub dragged: Entity, + pub target: Entity, + pub position: InsertPosition, +} + /// Associates extra data with an external secondary map. /// /// The secondary map internally uses a `Vec`, so should only be used for data that diff --git a/src/widget/segmented_button/model/mod.rs b/src/widget/segmented_button/model/mod.rs index 6b5a8a64..e0dd8c54 100644 --- a/src/widget/segmented_button/model/mod.rs +++ b/src/widget/segmented_button/model/mod.rs @@ -11,6 +11,7 @@ mod selection; pub use self::selection::{MultiSelect, Selectable, SingleSelect}; use crate::widget::Icon; +use crate::widget::segmented_button::InsertPosition; use slotmap::{SecondaryMap, SlotMap}; use std::any::{Any, TypeId}; use std::borrow::Cow; @@ -410,6 +411,36 @@ where true } + /// Reorder `dragged` relative to `target` based on the provided position. + /// + /// Returns `true` if the model changed, or `false` if the move was invalid. + pub fn reorder(&mut self, dragged: Entity, target: Entity, position: InsertPosition) -> bool { + if !self.contains_item(dragged) || !self.contains_item(target) || dragged == target { + return false; + } + + let len = self.iter().count(); + let target_pos = self.position(target).map(|pos| pos as usize).unwrap_or(len); + let from_pos = self + .position(dragged) + .map(|pos| pos as usize) + .unwrap_or(target_pos); + let mut insert_pos = match position { + InsertPosition::Before => target_pos, + InsertPosition::After => target_pos.saturating_add(1), + }; + if from_pos < insert_pos { + insert_pos = insert_pos.saturating_sub(1); + } + if len > 0 { + insert_pos = insert_pos.min(len.saturating_sub(1)); + } + + self.position_set(dragged, insert_pos as u16); + self.activate(dragged); + true + } + /// Removes an item from the model. /// /// The generation of the slot for the ID will be incremented, so this ID will no @@ -469,3 +500,43 @@ where self.text.remove(id) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_model() -> (Model, Vec) { + let mut ids = Vec::new(); + let model = Model::builder() + .insert(|b| b.text("Tab1").with_id(|id| ids.push(id))) + .insert(|b| b.text("Tab2").with_id(|id| ids.push(id))) + .insert(|b| b.text("Tab3").with_id(|id| ids.push(id))) + .insert(|b| b.text("Tab4").with_id(|id| ids.push(id))) + .build(); + (model, ids) + } + + fn order_of(model: &Model) -> Vec { + model.iter().collect() + } + + #[test] + fn reorder_inserts_before_target() { + let (mut model, ids) = sample_model(); + assert!(model.reorder(ids[3], ids[1], InsertPosition::Before)); + assert_eq!(order_of(&model), vec![ids[0], ids[3], ids[1], ids[2]]); + } + + #[test] + fn reorder_inserts_after_target() { + let (mut model, ids) = sample_model(); + assert!(model.reorder(ids[0], ids[2], InsertPosition::After)); + assert_eq!(order_of(&model), vec![ids[1], ids[2], ids[0], ids[3]]); + } + + #[test] + fn reorder_rejects_invalid_entities() { + let (mut model, ids) = sample_model(); + assert!(!model.reorder(ids[0], ids[0], InsertPosition::After)); + } +} diff --git a/src/widget/segmented_button/widget.rs b/src/widget/segmented_button/widget.rs index bb05aa9d..e852a2eb 100644 --- a/src/widget/segmented_button/widget.rs +++ b/src/widget/segmented_button/widget.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: MPL-2.0 use super::model::{Entity, Model, Selectable}; +use super::{InsertPosition, ReorderEvent}; use crate::iced_core::id::Internal; use crate::theme::{SegmentedButton as Style, THEME}; use crate::widget::dnd_destination::DragId; @@ -12,7 +13,9 @@ use crate::widget::menu::{ 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::dnd::{ + self, DndAction, DndDestinationRectangle, DndEvent, OfferEvent, SourceEvent, +}; use iced::clipboard::mime::AllowedMimeTypes; use iced::touch::Finger; use iced::{ @@ -41,6 +44,8 @@ thread_local! { static LAST_FOCUS_UPDATE: LazyCell> = LazyCell::new(|| Cell::new(Instant::now())); } +const TAB_REORDER_LOG_TARGET: &str = "libcosmic::widget::tab_reorder"; + /// A command that focuses a segmented item stored in a widget. pub fn focus(id: Id) -> Task { task::effect(Action::Widget(Box::new(operation::focusable::focus(id.0)))) @@ -51,6 +56,27 @@ pub enum ItemBounds { Divider(Rectangle, bool), } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DropSide { + Before, + After, +} + +impl From for InsertPosition { + fn from(side: DropSide) -> Self { + match side { + DropSide::Before => InsertPosition::Before, + DropSide::After => InsertPosition::After, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct DropHint { + entity: Entity, + side: DropSide, +} + /// Isolates variant-specific behaviors from [`SegmentedButton`]. pub trait SegmentedVariant { const VERTICAL: bool; @@ -157,6 +183,12 @@ where #[setters(strip_option)] pub(super) drag_id: Option, #[setters(skip)] + pub(super) tab_drag: Option>, + #[setters(skip)] + pub(super) on_drop_hint: Option) -> Message + 'static>>, + #[setters(skip)] + pub(super) on_reorder: Option Message + 'static>>, + #[setters(skip)] /// Defines the implementation of this struct variant: PhantomData, } @@ -204,6 +236,9 @@ where mimes: Vec::new(), variant: PhantomData, drag_id: None, + tab_drag: None, + on_drop_hint: None, + on_reorder: None, } } @@ -261,6 +296,77 @@ where self } + /// Enable drag-and-drop support for tabs using the provided payload builder. + pub fn enable_tab_drag( + mut self, + payload: impl Fn(Entity) -> Option<(String, Vec)> + 'static, + ) -> Self { + self.tab_drag = Some(TabDragSource::new(payload)); + self + } + + /// Receive drop hint updates during drag-and-drop. + pub fn on_drop_hint( + mut self, + callback: impl Fn(Option<(Entity, bool)>) -> Message + 'static, + ) -> Self { + self.on_drop_hint = Some(Box::new(callback)); + self + } + + /// Emit a message when a tab drag is dropped inside this widget. + pub fn on_reorder(mut self, callback: impl Fn(ReorderEvent) -> Message + 'static) -> Self { + self.on_reorder = Some(Box::new(callback)); + self + } + + /// Set the pointer distance threshold before a drag is started. + pub fn tab_drag_threshold(mut self, threshold: f32) -> Self { + if let Some(tab_drag) = self.tab_drag.as_mut() { + tab_drag.threshold = threshold.max(1.0); + } + self + } + + fn reorder_event_for_drop(&self, state: &LocalState, target: Entity) -> Option { + let dragged = state.dragging_tab?; + if dragged == target + || !self.model.contains_item(dragged) + || !self.model.contains_item(target) + { + return None; + } + let position = state + .drop_hint + .filter(|hint| hint.entity == target) + .map(|hint| InsertPosition::from(hint.side)) + .unwrap_or_else(|| self.default_insert_position(dragged, target)); + Some(ReorderEvent { + dragged, + target, + position, + }) + } + + fn default_insert_position(&self, dragged: Entity, target: Entity) -> InsertPosition { + let len = self.model.len(); + let target_pos = self + .model + .position(target) + .map(|pos| pos as usize) + .unwrap_or(len); + let from_pos = self + .model + .position(dragged) + .map(|pos| pos as usize) + .unwrap_or(target_pos); + if from_pos < target_pos { + InsertPosition::After + } else { + InsertPosition::Before + } + } + /// Check if an item is enabled. fn is_enabled(&self, key: Entity) -> bool { self.model.items.get(key).is_some_and(|item| item.enabled) @@ -545,6 +651,101 @@ where state.pressed_item == Some(Item::Tab(key)) } + fn emit_drop_hint(&self, shell: &mut Shell<'_, Message>, hint: Option) { + if let Some(on_hint) = self.on_drop_hint.as_ref() { + let mapped = hint.map(|hint| (hint.entity, matches!(hint.side, DropSide::After))); + shell.publish(on_hint(mapped)); + } + } + + fn drop_hint_for_position( + &self, + state: &LocalState, + bounds: Rectangle, + cursor: Point, + ) -> Option { + let dragging = state.dragging_tab?; + + self.variant_bounds(state, bounds) + .filter_map(|item| match item { + ItemBounds::Button(entity, rect) if rect.contains(cursor) => Some((entity, rect)), + _ => None, + }) + .find_map(|(entity, rect)| { + let before = if Self::VERTICAL { + cursor.y < rect.center_y() + } else { + cursor.x < rect.center_x() + }; + Some(DropHint { + entity, + side: if before { + DropSide::Before + } else { + DropSide::After + }, + }) + }) + } + + fn start_tab_drag( + &self, + state: &mut LocalState, + entity: Entity, + bounds: Rectangle, + cursor: Point, + clipboard: &mut dyn Clipboard, + ) -> bool { + let Some(tab_drag) = self.tab_drag.as_ref() else { + return false; + }; + + log::trace!( + target: TAB_REORDER_LOG_TARGET, + "start_tab_drag requested entity={:?} cursor=({:.2},{:.2}) bounds=({:.2},{:.2},{:.2},{:.2}) threshold={}", + entity, + cursor.x, + cursor.y, + bounds.x, + bounds.y, + bounds.width, + bounds.height, + tab_drag.threshold + ); + + let Some((mime, data)) = (tab_drag.payload)(entity) else { + log::trace!( + target: TAB_REORDER_LOG_TARGET, + "start_tab_drag aborted entity={:?}: payload builder returned None", + entity + ); + return false; + }; + + let data_len = data.len(); + let mime_label = mime.clone(); + + iced_core::clipboard::start_dnd::( + clipboard, + false, + Some(iced_core::clipboard::DndSource::Widget(self.id.0.clone())), + None, + Box::new(SimpleDragData::new(mime, data)), + DndAction::Move, + ); + log::trace!( + target: TAB_REORDER_LOG_TARGET, + "tab drag started entity={:?} mime={} bytes={}", + entity, + mime_label, + data_len + ); + state.dragging_tab = Some(entity); + state.tab_drag_candidate = None; + state.pressed_item = None; + true + } + /// Returns the drag id of the destination. /// /// # Panics @@ -611,6 +812,9 @@ where dnd_state: Default::default(), fingers_pressed: Default::default(), pressed_item: None, + tab_drag_candidate: None, + dragging_tab: None, + drop_hint: None, }) } @@ -701,7 +905,7 @@ where layout: Layout<'_>, cursor_position: mouse::Cursor, _renderer: &Renderer, - _clipboard: &mut dyn Clipboard, + clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, _viewport: &iced::Rectangle, ) -> event::Status { @@ -717,7 +921,26 @@ where .drag_offer .as_ref() .map(|dnd_state| dnd_state.data); + log::trace!( + target: TAB_REORDER_LOG_TARGET, + "segmented button {:?} received DnD event: {:?} entity={entity:?}", + my_id, + e + ); match e { + DndEvent::Source(SourceEvent::Cancelled | SourceEvent::Finished) => { + if state.dragging_tab.take().is_some() { + state.tab_drag_candidate = None; + state.drop_hint = None; + self.emit_drop_hint(shell, state.drop_hint); + log::trace!( + target: TAB_REORDER_LOG_TARGET, + "tab drag source finished id={:?}", + my_id + ); + return event::Status::Captured; + } + } DndEvent::Offer( id, OfferEvent::Enter { @@ -732,6 +955,16 @@ where }) .find(|(_key, bounds)| bounds.contains(Point::new(*x as f32, *y as f32))) .map(|(key, _)| key); + state.drop_hint = self.drop_hint_for_position( + state, + bounds, + Point::new(*x as f32, *y as f32), + ); + self.emit_drop_hint(shell, state.drop_hint); + log::trace!( + target: TAB_REORDER_LOG_TARGET, + "offer enter id={my_id:?} entity={entity:?} @ ({x},{y}) mimes={mime_types:?}" + ); let on_dnd_enter = self.on_dnd_enter @@ -750,15 +983,28 @@ where ); } DndEvent::Offer(id, OfferEvent::LeaveDestination) if Some(my_id) != *id => {} - DndEvent::Offer(id, OfferEvent::Leave | OfferEvent::LeaveDestination) => { + DndEvent::Offer(id, OfferEvent::Leave | OfferEvent::LeaveDestination) + if Some(my_id) == *id => + { + state.drop_hint = None; + self.emit_drop_hint(shell, state.drop_hint); if let Some(Some(entity)) = entity { if let Some(on_dnd_leave) = self.on_dnd_leave.as_ref() { shell.publish(on_dnd_leave(entity)); } } + log::trace!( + target: TAB_REORDER_LOG_TARGET, + "offer leave id={my_id:?} entity={entity:?}" + ); _ = state.dnd_state.on_leave::(None); } + DndEvent::Offer(_, OfferEvent::Leave | OfferEvent::LeaveDestination) => {} DndEvent::Offer(id, OfferEvent::Motion { x, y }) if Some(my_id) == *id => { + log::trace!( + target: TAB_REORDER_LOG_TARGET, + "offer motion id={my_id:?} cursor=({x},{y}) current_entity={entity:?}" + ); let new = self .variant_bounds(state, bounds) .filter_map(|item| match item { @@ -775,6 +1021,12 @@ where None:: Message>, Some(new_entity), ); + state.drop_hint = self.drop_hint_for_position( + state, + bounds, + Point::new(*x as f32, *y as f32), + ); + self.emit_drop_hint(shell, state.drop_hint); if Some(Some(new_entity)) != entity { let prev_action = state .dnd_state @@ -792,6 +1044,12 @@ where } } } else if entity.is_some() { + log::trace!( + target: TAB_REORDER_LOG_TARGET, + "offer motion leaving id={my_id:?}" + ); + state.drop_hint = None; + self.emit_drop_hint(shell, state.drop_hint); state.dnd_state.on_motion::( *x, *y, @@ -807,32 +1065,81 @@ where } } DndEvent::Offer(id, OfferEvent::Drop) if Some(my_id) == *id => { + log::trace!( + target: TAB_REORDER_LOG_TARGET, + "offer drop id={my_id:?} entity={entity:?}" + ); _ = state .dnd_state .on_drop::(None:: Message>); } DndEvent::Offer(id, OfferEvent::SelectedAction(action)) if Some(my_id) == *id => { if state.dnd_state.drag_offer.is_some() { + log::trace!( + target: TAB_REORDER_LOG_TARGET, + "offer selected action id={my_id:?} action={action:?} 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(Some(entity)) = entity { + log::trace!( + target: TAB_REORDER_LOG_TARGET, + "offer data id={my_id:?} entity={entity:?} mime={mime_type:?}" + ); + let drop_entity = entity + .flatten() + .or_else(|| state.drop_hint.map(|hint| hint.entity)); + let allow_reorder = state + .dnd_state + .drag_offer + .as_ref() + .is_some_and(|offer| offer.selected_action.contains(DndAction::Move)); + let pending_reorder = if allow_reorder && self.on_reorder.is_some() { + drop_entity.and_then(|target| self.reorder_event_for_drop(state, target)) + } else { + None + }; + if let Some(entity) = drop_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( + let (maybe_msg, ret) = state.dnd_state.on_data_received( mem::take(mime_type), mem::take(data), None:: Message>, on_drop, - ) { + ); + if let Some(msg) = maybe_msg { + log::trace!( + target: TAB_REORDER_LOG_TARGET, + "publishing drop message entity={entity:?}" + ); shell.publish(msg); - return ret; + } + state.drop_hint = None; + self.emit_drop_hint(shell, state.drop_hint); + if let Some(event) = pending_reorder { + if let Some(on_reorder) = self.on_reorder.as_ref() { + shell.publish(on_reorder(event)); + } + } + return ret; + } else { + log::trace!( + target: TAB_REORDER_LOG_TARGET, + "data received without entity id={my_id:?}" + ); + state.drop_hint = None; + self.emit_drop_hint(shell, state.drop_hint); + if let Some(event) = pending_reorder { + if let Some(on_reorder) = self.on_reorder.as_ref() { + shell.publish(on_reorder(event)); + } } } } @@ -897,12 +1204,16 @@ where // Record that the mouse is hovering over this button. state.hovered = Item::Tab(key); + let close_button_bounds = + close_bounds(bounds, f32::from(self.close_icon.size)); + let over_close_button = self.model.items[key].closable + && cursor_position.is_over(close_button_bounds); + // If marked as closable, show a close icon. if self.model.items[key].closable { // Emit close message if the close button is pressed. if let Some(on_close) = self.on_close.as_ref() { - if cursor_position - .is_over(close_bounds(bounds, f32::from(self.close_icon.size))) + if over_close_button && (left_button_released(&event) || (touch_lifted(&event) && fingers_pressed == 1)) { @@ -927,6 +1238,36 @@ where } } + if self.tab_drag.is_some() + && matches!( + event, + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) + ) + && !over_close_button + { + if let Some(position) = cursor_position.position() { + state.tab_drag_candidate = Some(TabDragCandidate { + entity: key, + bounds, + origin: position, + }); + if let Some(tab_drag) = self.tab_drag.as_ref() { + log::trace!( + target: TAB_REORDER_LOG_TARGET, + "tab drag candidate entity={:?} origin=({:.2},{:.2}) bounds=({:.2},{:.2},{:.2},{:.2}) threshold={}", + key, + position.x, + position.y, + bounds.x, + bounds.y, + bounds.width, + bounds.height, + tab_drag.threshold + ); + } + } + } + if is_lifted(&event) { state.unfocus(); } @@ -1046,6 +1387,42 @@ where state.pressed_item = None; } + if let (Some(tab_drag), Some(candidate)) = + (self.tab_drag.as_ref(), state.tab_drag_candidate) + { + if let Event::Mouse(mouse::Event::CursorMoved { .. }) = event { + if let Some(position) = cursor_position.position() { + if position.distance(candidate.origin) >= tab_drag.threshold { + if let Some(candidate) = state.tab_drag_candidate.take() { + log::trace!( + target: TAB_REORDER_LOG_TARGET, + "tab drag threshold met entity={:?} distance={:.2} threshold={}", + candidate.entity, + position.distance(candidate.origin), + tab_drag.threshold + ); + if self.start_tab_drag( + state, + candidate.entity, + candidate.bounds, + position, + clipboard, + ) { + return event::Status::Captured; + } + } + } + } + } + } + + if matches!( + event, + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) + ) { + state.tab_drag_candidate = None; + } + if state.is_focused() { if let Event::Keyboard(keyboard::Event::KeyPressed { key: keyboard::Key::Named(keyboard::key::Named::Tab), @@ -1120,6 +1497,7 @@ where ) { let state = tree.state.downcast_mut::(); operation.focusable(state, Some(&self.id.0)); + operation.custom(state, Some(&self.id.0)); if let Item::Set = state.focused_item { if self.prev_tab_sensitive(state) { @@ -1180,6 +1558,12 @@ where let appearance = Self::variant_appearance(theme, &self.style); let bounds: Rectangle = layout.bounds(); let button_amount = self.model.items.len(); + let show_drop_hint = state.dragging_tab.is_some(); + let drop_hint = if show_drop_hint { + state.drop_hint + } else { + None + }; // Draw the background, if a background was defined. if let Some(background) = appearance.background { @@ -1305,6 +1689,8 @@ where // Draw each of the items in the widget. let mut nth = 0; + let drop_hint_marker = drop_hint; + let show_drop_hint_marker = show_drop_hint; self.variant_bounds(state, bounds).for_each(move |item| { let (key, mut bounds) = match item { // Draw a button @@ -1332,8 +1718,27 @@ where } }; + let original_bounds = bounds; let center_y = bounds.center_y(); + if show_drop_hint_marker { + if matches!( + drop_hint_marker, + Some(DropHint { + entity, + side: DropSide::Before + }) if entity == key + ) { + draw_drop_indicator( + renderer, + original_bounds, + DropSide::Before, + Self::VERTICAL, + appearance.active.text_color, + ); + } + } + let menu_open = || { state.show_context == Some(key) && !tree.children.is_empty() @@ -1398,7 +1803,6 @@ where ); } - let original_bounds = bounds; bounds.x += f32::from(self.button_padding[0]); bounds.width -= f32::from(self.button_padding[0]) - f32::from(self.button_padding[2]); let mut indent_padding = 0.0; @@ -1595,6 +1999,24 @@ where ); } + if show_drop_hint_marker { + if matches!( + drop_hint_marker, + Some(DropHint { + entity, + side: DropSide::After + }) if entity == key + ) { + draw_drop_indicator( + renderer, + original_bounds, + DropSide::After, + Self::VERTICAL, + appearance.active.text_color, + ); + } + } + nth += 1; }); } @@ -1658,27 +2080,68 @@ where fn drag_destinations( &self, - _state: &Tree, + tree: &Tree, layout: Layout<'_>, _renderer: &Renderer, dnd_rectangles: &mut iced_core::clipboard::DndDestinationRectangles, ) { - let bounds = layout.bounds(); - + let local_state = tree.state.downcast_ref::(); 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); + let mut pushed = false; + + for item in self.variant_bounds(local_state, layout.bounds()) { + if let ItemBounds::Button(_entity, rect) = item { + pushed = true; + log::trace!( + target: TAB_REORDER_LOG_TARGET, + "register drag destination id={:?} bounds=({:.2},{:.2},{:.2},{:.2}) mimes={:?}", + my_id, + rect.x, + rect.y, + rect.width, + rect.height, + self.mimes + ); + dnd_rectangles.push(DndDestinationRectangle { + id: my_id, + rectangle: dnd::Rectangle { + x: f64::from(rect.x), + y: f64::from(rect.y), + width: f64::from(rect.width), + height: f64::from(rect.height), + }, + mime_types: self.mimes.clone().into_iter().map(Cow::Owned).collect(), + actions: DndAction::Copy | DndAction::Move, + preferred: DndAction::Move, + }); + } + } + + if !pushed { + let bounds = layout.bounds(); + log::trace!( + target: TAB_REORDER_LOG_TARGET, + "register drag destination id={:?} bounds=({:.2},{:.2},{:.2},{:.2}) mimes={:?}", + my_id, + bounds.x, + bounds.y, + bounds.width, + bounds.height, + self.mimes + ); + dnd_rectangles.push(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, + }); + } } } @@ -1700,6 +2163,54 @@ where } } +struct TabDragSource { + payload: Box Option<(String, Vec)>>, + threshold: f32, + _marker: PhantomData, +} + +impl TabDragSource { + fn new(payload: impl Fn(Entity) -> Option<(String, Vec)> + 'static) -> Self { + Self { + payload: Box::new(payload), + threshold: 8.0, + _marker: PhantomData, + } + } +} + +struct SimpleDragData { + mime: String, + bytes: Vec, +} + +impl SimpleDragData { + fn new(mime: String, bytes: Vec) -> Self { + Self { mime, bytes } + } +} + +impl iced::clipboard::mime::AsMimeTypes for SimpleDragData { + fn available(&self) -> Cow<'static, [String]> { + Cow::Owned(vec![self.mime.clone()]) + } + + fn as_bytes(&self, mime_type: &str) -> Option> { + if mime_type == self.mime { + Some(Cow::Owned(self.bytes.clone())) + } else { + None + } + } +} + +#[derive(Clone, Copy)] +struct TabDragCandidate { + entity: Entity, + bounds: Rectangle, + origin: Point, +} + #[derive(Debug, Clone, Copy)] struct Focus { updated_at: Instant, @@ -1746,6 +2257,12 @@ pub struct LocalState { fingers_pressed: HashSet, /// The currently pressed item pressed_item: Option, + /// Pending tab drag candidate data + tab_drag_candidate: Option, + /// Currently dragging tab entity + dragging_tab: Option, + /// Current drop hint for drag-and-drop indicator + drop_hint: Option, } #[derive(Debug, Default, PartialEq)] @@ -1770,6 +2287,143 @@ impl LocalState { } } +#[cfg(test)] +mod tests { + use super::*; + use crate::widget::segmented_button::{self, Appearance as SegAppearance}; + use iced::Size; + use slotmap::SecondaryMap; + use std::collections::HashSet; + + #[derive(Clone, Debug)] + enum TestMessage {} + + struct TestVariant; + + impl SegmentedVariant + for SegmentedButton<'_, TestVariant, SelectionMode, Message> + where + Model: Selectable, + SelectionMode: Default, + { + const VERTICAL: bool = false; + + fn variant_appearance( + _theme: &crate::Theme, + _style: &crate::theme::SegmentedButton, + ) -> SegAppearance { + SegAppearance::default() + } + + fn variant_bounds<'b>( + &'b self, + _state: &'b LocalState, + bounds: Rectangle, + ) -> Box + 'b> { + let len = self.model.order.len(); + if len == 0 { + return Box::new(std::iter::empty()); + } + let width = bounds.width / len as f32; + Box::new( + self.model + .order + .iter() + .copied() + .enumerate() + .map(move |(idx, entity)| { + let rect = Rectangle { + x: bounds.x + (idx as f32) * width, + y: bounds.y, + width, + height: bounds.height, + }; + ItemBounds::Button(entity, rect) + }), + ) + } + + fn variant_layout( + &self, + _state: &mut LocalState, + _renderer: &crate::Renderer, + _limits: &layout::Limits, + ) -> Size { + Size::ZERO + } + } + + fn sample_model() -> ( + segmented_button::SingleSelectModel, + Vec, + ) { + let mut entities = Vec::new(); + let model = segmented_button::Model::builder() + .insert(|b| b.text("One").with_id(|id| entities.push(id))) + .insert(|b| b.text("Two").with_id(|id| entities.push(id))) + .insert(|b| b.text("Three").with_id(|id| entities.push(id))) + .build(); + (model, entities) + } + + fn test_state(dragging: segmented_button::Entity, len: usize) -> LocalState { + let mut state = LocalState { + menu_state: MenuBarState::default(), + paragraphs: SecondaryMap::new(), + text_hashes: SecondaryMap::new(), + buttons_visible: 0, + buttons_offset: 0, + collapsed: false, + focused: None, + focused_item: Item::default(), + focused_visible: false, + hovered: Item::default(), + known_length: 0, + middle_clicked: None, + internal_layout: Vec::new(), + context_cursor: Point::ORIGIN, + show_context: None, + wheel_timestamp: None, + dnd_state: crate::widget::dnd_destination::State::>::new(), + fingers_pressed: HashSet::new(), + pressed_item: None, + tab_drag_candidate: None, + dragging_tab: Some(dragging), + drop_hint: None, + }; + state.buttons_visible = len; + state.known_length = len; + state + } + + #[test] + fn drop_hint_reports_before_and_after() { + let (model, ids) = sample_model(); + let button = + SegmentedButton::::new( + &model, + ); + let state = test_state(ids[0], model.order.len()); + let bounds = Rectangle { + x: 0.0, + y: 0.0, + width: 300.0, + height: 30.0, + }; + let before = button + .drop_hint_for_position(&state, bounds, Point::new(10.0, 15.0)) + .expect("hint"); + assert_eq!(before.entity, ids[0]); + assert!(matches!(before.side, DropSide::Before)); + + let after = button + .drop_hint_for_position(&state, bounds, Point::new(290.0, 15.0)) + .expect("hint"); + assert_eq!(after.entity, ids[2]); + assert!(matches!(after.side, DropSide::After)); + } +} + impl operation::Focusable for LocalState { fn is_focused(&self) -> bool { self.focused @@ -1882,6 +2536,53 @@ fn draw_icon( ); } +fn draw_drop_indicator( + renderer: &mut Renderer, + bounds: Rectangle, + side: DropSide, + vertical: bool, + color: Color, +) { + let thickness = 4.0; + let quad_bounds = if vertical { + let y = match side { + DropSide::Before => bounds.y - thickness / 2.0, + DropSide::After => bounds.y + bounds.height - thickness / 2.0, + }; + + Rectangle { + x: bounds.x, + y, + width: bounds.width, + height: thickness, + } + } else { + let x = match side { + DropSide::Before => bounds.x - thickness / 2.0, + DropSide::After => bounds.x + bounds.width - thickness / 2.0, + }; + + Rectangle { + x, + y: bounds.y, + width: thickness, + height: bounds.height, + } + }; + + renderer.fill_quad( + renderer::Quad { + bounds: quad_bounds, + border: Border { + radius: 2.0.into(), + ..Default::default() + }, + shadow: Shadow::default(), + }, + Background::Color(color), + ); +} + fn left_button_released(event: &Event) -> bool { matches!( event,