1296 lines
39 KiB
Rust
1296 lines
39 KiB
Rust
// Copyright 2026 System76 <info@system76.com>
|
|
// SPDX-License-Identifier: MPL-2.0
|
|
|
|
use crate::{Element, Renderer};
|
|
use iced::{Alignment, Pixels, alignment};
|
|
use iced_core::event::Event;
|
|
use iced_core::layout::{self, Layout};
|
|
use iced_core::mouse::{self, Cursor};
|
|
use iced_core::widget::Operation;
|
|
use iced_core::widget::tree::{self, Tree};
|
|
#[cfg(feature = "wgpu")]
|
|
use iced_core::{Background, Border, Shadow};
|
|
use iced_core::{
|
|
Clipboard, Length, Padding, Point, Rectangle, Renderer as _, Shell, Size, Vector, Widget,
|
|
overlay, renderer, window,
|
|
};
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::hash::Hash;
|
|
use std::time::{Duration, Instant};
|
|
|
|
const DEFAULT_ANIMATION_DURATION: Duration = Duration::from_millis(180);
|
|
const DEFAULT_DRAG_LIFT: f32 = 10.0;
|
|
const DEFAULT_DRAG_THRESHOLD: f32 = 6.0;
|
|
const SHADOW_BLUR_RADIUS: f32 = 20.0;
|
|
const POSITION_EPSILON: f32 = 0.5;
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct SlotAnimation {
|
|
from: Point,
|
|
to: Point,
|
|
started_at: Option<Instant>,
|
|
}
|
|
|
|
impl SlotAnimation {
|
|
fn new(position: Point) -> Self {
|
|
Self {
|
|
from: position,
|
|
to: position,
|
|
started_at: None,
|
|
}
|
|
}
|
|
|
|
fn current_position(&self, duration: Duration) -> Point {
|
|
let Some(started_at) = self.started_at else {
|
|
return self.to;
|
|
};
|
|
|
|
let duration_secs = duration.as_secs_f32();
|
|
if duration_secs <= f32::EPSILON {
|
|
return self.to;
|
|
}
|
|
|
|
let progress = (started_at.elapsed().as_secs_f32() / duration_secs).clamp(0.0, 1.0);
|
|
let eased = 1.0 - (1.0 - progress).powi(3);
|
|
|
|
Point::new(
|
|
self.from.x + (self.to.x - self.from.x) * eased,
|
|
self.from.y + (self.to.y - self.from.y) * eased,
|
|
)
|
|
}
|
|
|
|
fn retarget(&mut self, new_target: Point, duration: Duration) {
|
|
if approx_eq_point(self.to, new_target) {
|
|
if !self.is_animating(duration) {
|
|
self.from = new_target;
|
|
self.to = new_target;
|
|
self.started_at = None;
|
|
}
|
|
return;
|
|
}
|
|
|
|
self.from = self.current_position(duration);
|
|
self.to = new_target;
|
|
self.started_at = Some(Instant::now());
|
|
}
|
|
|
|
fn is_animating(&self, duration: Duration) -> bool {
|
|
self.started_at
|
|
.is_some_and(|started_at| started_at.elapsed() < duration)
|
|
}
|
|
|
|
fn finish_if_done(&mut self, duration: Duration) {
|
|
if self
|
|
.started_at
|
|
.is_some_and(|started_at| started_at.elapsed() >= duration)
|
|
{
|
|
self.from = self.to;
|
|
self.started_at = None;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct PendingDragState<Key>
|
|
where
|
|
Key: Clone + Eq + Hash + 'static,
|
|
{
|
|
key: Key,
|
|
item_index: usize,
|
|
original_index: usize,
|
|
press_local: Point,
|
|
pointer_offset: Vector,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct DragState<Key>
|
|
where
|
|
Key: Clone + Eq + Hash + 'static,
|
|
{
|
|
key: Key,
|
|
item_index: usize,
|
|
original_index: usize,
|
|
current_index: usize,
|
|
cursor_local: Point,
|
|
pointer_offset: Vector,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct State<Key>
|
|
where
|
|
Key: Clone + Eq + Hash + 'static,
|
|
{
|
|
keys: Vec<Key>,
|
|
slot_positions: HashMap<Key, SlotAnimation>,
|
|
pending_drag: Option<PendingDragState<Key>>,
|
|
drag: Option<DragState<Key>>,
|
|
wrap_width: f32,
|
|
initialized: bool,
|
|
}
|
|
|
|
impl<Key> Default for State<Key>
|
|
where
|
|
Key: Clone + Eq + Hash + 'static,
|
|
{
|
|
fn default() -> Self {
|
|
Self {
|
|
keys: Vec::new(),
|
|
slot_positions: HashMap::new(),
|
|
pending_drag: None,
|
|
drag: None,
|
|
wrap_width: f32::INFINITY,
|
|
initialized: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<Key> State<Key>
|
|
where
|
|
Key: Clone + Eq + Hash + 'static,
|
|
{
|
|
fn retain_keys(&mut self, keys: &[Key]) {
|
|
let keep: HashSet<_> = keys.iter().cloned().collect();
|
|
self.slot_positions.retain(|key, _| keep.contains(key));
|
|
|
|
if self
|
|
.pending_drag
|
|
.as_ref()
|
|
.is_some_and(|drag| !keep.contains(&drag.key))
|
|
{
|
|
self.pending_drag = None;
|
|
}
|
|
|
|
if self
|
|
.drag
|
|
.as_ref()
|
|
.is_some_and(|drag| !keep.contains(&drag.key))
|
|
{
|
|
self.drag = None;
|
|
}
|
|
}
|
|
|
|
fn finish_animations(&mut self, duration: Duration) {
|
|
self.slot_positions
|
|
.values_mut()
|
|
.for_each(|slot| slot.finish_if_done(duration));
|
|
}
|
|
|
|
fn is_animating(&self, duration: Duration) -> bool {
|
|
self.slot_positions
|
|
.values()
|
|
.any(|slot| slot.is_animating(duration))
|
|
}
|
|
|
|
fn current_slot_position(&self, key: &Key, fallback: Point, duration: Duration) -> Point {
|
|
self.slot_positions
|
|
.get(key)
|
|
.map(|slot| slot.current_position(duration))
|
|
.unwrap_or(fallback)
|
|
}
|
|
|
|
fn retarget_slot(&mut self, key: &Key, target: Point, duration: Duration) {
|
|
self.slot_positions
|
|
.entry(key.clone())
|
|
.or_insert_with(|| SlotAnimation::new(target))
|
|
.retarget(target, duration);
|
|
}
|
|
|
|
fn snap_slot(&mut self, key: &Key, target: Point) {
|
|
self.slot_positions
|
|
.insert(key.clone(), SlotAnimation::new(target));
|
|
}
|
|
|
|
fn apply_layout_position(&mut self, key: &Key, target: Point, duration: Duration) {
|
|
if self.initialized {
|
|
self.retarget_slot(key, target, duration);
|
|
} else {
|
|
self.snap_slot(key, target);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
struct LocalSlot<Key>
|
|
where
|
|
Key: Clone + Eq + Hash + 'static,
|
|
{
|
|
key: Key,
|
|
locked: bool,
|
|
bounds: Rectangle,
|
|
}
|
|
|
|
/// A horizontal flex row with drag-to-reorder behavior.
|
|
#[must_use]
|
|
pub struct ReorderableFlexRow<'a, Key, Message>
|
|
where
|
|
Key: Clone + Eq + Hash + 'static,
|
|
{
|
|
spacing: f32,
|
|
padding: Padding,
|
|
width: Length,
|
|
height: Length,
|
|
align: Alignment,
|
|
clip: bool,
|
|
drag_lift: f32,
|
|
animation_duration: Duration,
|
|
on_reorder: Box<dyn Fn(Vec<Key>) -> Message + 'a>,
|
|
keys: Vec<Key>,
|
|
locked: Vec<bool>,
|
|
children: Vec<Element<'a, Message>>,
|
|
}
|
|
|
|
impl<'a, Key, Message> ReorderableFlexRow<'a, Key, Message>
|
|
where
|
|
Key: Clone + Eq + Hash + 'static,
|
|
{
|
|
pub fn new(on_reorder: impl Fn(Vec<Key>) -> Message + 'a) -> Self {
|
|
Self {
|
|
spacing: 0.0,
|
|
padding: Padding::ZERO,
|
|
width: Length::Shrink,
|
|
height: Length::Shrink,
|
|
align: Alignment::Start,
|
|
clip: false,
|
|
drag_lift: DEFAULT_DRAG_LIFT,
|
|
animation_duration: DEFAULT_ANIMATION_DURATION,
|
|
on_reorder: Box::new(on_reorder),
|
|
keys: Vec::new(),
|
|
locked: Vec::new(),
|
|
children: Vec::new(),
|
|
}
|
|
}
|
|
|
|
pub fn with_capacity(capacity: usize, on_reorder: impl Fn(Vec<Key>) -> Message + 'a) -> Self {
|
|
let mut row = Self::new(on_reorder);
|
|
row.keys = Vec::with_capacity(capacity);
|
|
row.locked = Vec::with_capacity(capacity);
|
|
row.children = Vec::with_capacity(capacity);
|
|
row
|
|
}
|
|
|
|
pub fn spacing(mut self, amount: impl Into<Pixels>) -> Self {
|
|
self.spacing = amount.into().0;
|
|
self
|
|
}
|
|
|
|
pub fn padding<P: Into<Padding>>(mut self, padding: P) -> Self {
|
|
self.padding = padding.into();
|
|
self
|
|
}
|
|
|
|
pub fn width(mut self, width: impl Into<Length>) -> Self {
|
|
self.width = width.into();
|
|
self
|
|
}
|
|
|
|
pub fn height(mut self, height: impl Into<Length>) -> Self {
|
|
self.height = height.into();
|
|
self
|
|
}
|
|
|
|
pub fn align_y(mut self, align: impl Into<alignment::Vertical>) -> Self {
|
|
self.align = Alignment::from(align.into());
|
|
self
|
|
}
|
|
|
|
/// Leave disabled for dragged item to visibly lift above the row.
|
|
pub fn clip(mut self, clip: bool) -> Self {
|
|
self.clip = clip;
|
|
self
|
|
}
|
|
|
|
pub fn drag_lift(mut self, lift: f32) -> Self {
|
|
self.drag_lift = lift.max(0.0);
|
|
self
|
|
}
|
|
|
|
pub fn animation_duration(mut self, duration: Duration) -> Self {
|
|
self.animation_duration = duration;
|
|
self
|
|
}
|
|
|
|
pub fn push(self, key: Key, child: impl Into<Element<'a, Message>>) -> Self {
|
|
self.push_with_lock(key, false, child)
|
|
}
|
|
|
|
/// Item stays fixed, never participates in reordering.
|
|
pub fn push_locked(self, key: Key, child: impl Into<Element<'a, Message>>) -> Self {
|
|
self.push_with_lock(key, true, child)
|
|
}
|
|
|
|
fn push_with_lock(
|
|
mut self,
|
|
key: Key,
|
|
locked: bool,
|
|
child: impl Into<Element<'a, Message>>,
|
|
) -> Self {
|
|
let child = child.into();
|
|
let child_size = child.as_widget().size_hint();
|
|
|
|
if !child_size.is_void() {
|
|
self.width = self.width.enclose(child_size.width);
|
|
self.height = self.height.enclose(child_size.height);
|
|
self.keys.push(key);
|
|
self.locked.push(locked);
|
|
self.children.push(child);
|
|
}
|
|
|
|
self
|
|
}
|
|
|
|
pub fn extend(self, items: impl IntoIterator<Item = (Key, Element<'a, Message>)>) -> Self {
|
|
items
|
|
.into_iter()
|
|
.fold(self, |row, (key, child)| row.push(key, child))
|
|
}
|
|
|
|
pub fn extend_locked(
|
|
self,
|
|
items: impl IntoIterator<Item = (Key, Element<'a, Message>)>,
|
|
) -> Self {
|
|
items
|
|
.into_iter()
|
|
.fold(self, |row, (key, child)| row.push_locked(key, child))
|
|
}
|
|
|
|
fn item_local_slots_from_layout(
|
|
&self,
|
|
bounds: Rectangle,
|
|
child_layouts: &[Layout<'_>],
|
|
) -> Vec<LocalSlot<Key>> {
|
|
self.keys
|
|
.iter()
|
|
.zip(self.locked.iter())
|
|
.zip(child_layouts.iter())
|
|
.map(|((key, locked), child_layout)| {
|
|
let child_bounds = child_layout.bounds();
|
|
LocalSlot {
|
|
key: key.clone(),
|
|
locked: *locked,
|
|
bounds: Rectangle {
|
|
x: child_bounds.x - bounds.x,
|
|
y: child_bounds.y - bounds.y,
|
|
width: child_bounds.width,
|
|
height: child_bounds.height,
|
|
},
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn sync_slot_positions(&self, state: &mut State<Key>, slots: &[LocalSlot<Key>]) {
|
|
let ordered_keys: Vec<Key> = slots.iter().map(|slot| slot.key.clone()).collect();
|
|
state.retain_keys(&ordered_keys);
|
|
|
|
let size_by_key: HashMap<Key, Size> = slots
|
|
.iter()
|
|
.map(|slot| {
|
|
(
|
|
slot.key.clone(),
|
|
Size::new(slot.bounds.width, slot.bounds.height),
|
|
)
|
|
})
|
|
.collect();
|
|
let locked_by_key: HashMap<Key, bool> = slots
|
|
.iter()
|
|
.map(|slot| (slot.key.clone(), slot.locked))
|
|
.collect();
|
|
|
|
let Some(drag_snapshot) = state.drag.as_ref().map(|drag| {
|
|
(
|
|
drag.key.clone(),
|
|
drag.cursor_local,
|
|
drag.pointer_offset,
|
|
drag.item_index,
|
|
)
|
|
}) else {
|
|
for slot in slots {
|
|
state.apply_layout_position(
|
|
&slot.key,
|
|
Point::new(slot.bounds.x, slot.bounds.y),
|
|
self.animation_duration,
|
|
);
|
|
}
|
|
return;
|
|
};
|
|
|
|
let (drag_key, cursor_local, pointer_offset, drag_item_index) = drag_snapshot;
|
|
|
|
let Some(dragged_slot) = slots.iter().find(|slot| slot.key == drag_key) else {
|
|
state.drag = None;
|
|
for slot in slots {
|
|
state.apply_layout_position(
|
|
&slot.key,
|
|
Point::new(slot.bounds.x, slot.bounds.y),
|
|
self.animation_duration,
|
|
);
|
|
}
|
|
return;
|
|
};
|
|
|
|
if dragged_slot.locked {
|
|
state.drag = None;
|
|
return;
|
|
}
|
|
|
|
let dragged_center = Point::new(
|
|
cursor_local.x - pointer_offset.x + dragged_slot.bounds.width / 2.0,
|
|
cursor_local.y - pointer_offset.y + dragged_slot.bounds.height / 2.0,
|
|
);
|
|
let target_index = target_index_for_drag(slots, &drag_key, dragged_center);
|
|
let prior_index = state.drag.as_ref().map(|drag| drag.current_index);
|
|
|
|
if let Some(drag) = state.drag.as_mut() {
|
|
drag.current_index = target_index;
|
|
drag.item_index = drag_item_index;
|
|
}
|
|
|
|
if prior_index == Some(target_index) {
|
|
return;
|
|
}
|
|
|
|
let reordered_keys =
|
|
reordered_keys_for_drag(&ordered_keys, &locked_by_key, &drag_key, target_index);
|
|
let (target_slots, _) = compute_wrapped_slots(
|
|
&reordered_keys,
|
|
&locked_by_key,
|
|
&size_by_key,
|
|
state.wrap_width,
|
|
self.padding,
|
|
self.spacing,
|
|
self.align,
|
|
);
|
|
let target_positions: HashMap<Key, Point> = target_slots
|
|
.iter()
|
|
.map(|slot| (slot.key.clone(), Point::new(slot.bounds.x, slot.bounds.y)))
|
|
.collect();
|
|
|
|
for slot in slots {
|
|
let target = target_positions
|
|
.get(&slot.key)
|
|
.copied()
|
|
.unwrap_or(Point::new(slot.bounds.x, slot.bounds.y));
|
|
state.retarget_slot(&slot.key, target, self.animation_duration);
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<'a, Key, Message> Widget<Message, crate::Theme, Renderer>
|
|
for ReorderableFlexRow<'a, Key, Message>
|
|
where
|
|
Key: Clone + Eq + Hash + 'static,
|
|
Message: 'a,
|
|
{
|
|
fn tag(&self) -> tree::Tag {
|
|
tree::Tag::of::<State<Key>>()
|
|
}
|
|
|
|
fn state(&self) -> tree::State {
|
|
tree::State::new(State {
|
|
keys: self.keys.clone(),
|
|
..State::default()
|
|
})
|
|
}
|
|
|
|
fn children(&self) -> Vec<Tree> {
|
|
self.children.iter().map(Tree::new).collect()
|
|
}
|
|
|
|
fn diff(&mut self, tree: &mut Tree) {
|
|
let Tree {
|
|
state, children, ..
|
|
} = tree;
|
|
let state = state.downcast_mut::<State<Key>>();
|
|
let previous_keys = state.keys.clone();
|
|
let previous_children = std::mem::take(children);
|
|
let mut previous_by_key = HashMap::with_capacity(previous_keys.len());
|
|
|
|
for (key, child_tree) in previous_keys.into_iter().zip(previous_children) {
|
|
previous_by_key.insert(key, child_tree);
|
|
}
|
|
|
|
children.reserve(self.children.len());
|
|
|
|
for (key, child) in self.keys.iter().cloned().zip(self.children.iter_mut()) {
|
|
let mut child_tree = previous_by_key
|
|
.remove(&key)
|
|
.unwrap_or_else(|| Tree::new(child.as_widget()));
|
|
child.as_widget_mut().diff(&mut child_tree);
|
|
children.push(child_tree);
|
|
}
|
|
|
|
if state.keys != self.keys {
|
|
state.keys.clone_from(&self.keys);
|
|
}
|
|
state.retain_keys(&self.keys);
|
|
}
|
|
|
|
fn size(&self) -> Size<Length> {
|
|
Size {
|
|
width: self.width,
|
|
height: self.height,
|
|
}
|
|
}
|
|
|
|
fn layout(
|
|
&mut self,
|
|
tree: &mut Tree,
|
|
renderer: &Renderer,
|
|
limits: &layout::Limits,
|
|
) -> layout::Node {
|
|
let limits = limits
|
|
.width(self.width)
|
|
.height(self.height)
|
|
.shrink(self.padding);
|
|
let child_limits = limits.loose();
|
|
let wrap_width = limits.max().width;
|
|
|
|
let mut nodes = Vec::with_capacity(self.children.len());
|
|
let mut size_by_key = HashMap::with_capacity(self.children.len());
|
|
let locked_by_key: HashMap<Key, bool> = self
|
|
.keys
|
|
.iter()
|
|
.cloned()
|
|
.zip(self.locked.iter().copied())
|
|
.collect();
|
|
|
|
for ((key, child), child_tree) in self
|
|
.keys
|
|
.iter()
|
|
.zip(self.children.iter_mut())
|
|
.zip(tree.children.iter_mut())
|
|
{
|
|
let node = child
|
|
.as_widget_mut()
|
|
.layout(child_tree, renderer, &child_limits);
|
|
size_by_key.insert(key.clone(), node.size());
|
|
nodes.push(node);
|
|
}
|
|
|
|
let (slots, intrinsic_size) = compute_wrapped_slots(
|
|
&self.keys,
|
|
&locked_by_key,
|
|
&size_by_key,
|
|
wrap_width,
|
|
self.padding,
|
|
self.spacing,
|
|
self.align,
|
|
);
|
|
|
|
for (node, slot) in nodes.iter_mut().zip(&slots) {
|
|
node.move_to_mut(Point::new(slot.bounds.x, slot.bounds.y));
|
|
}
|
|
|
|
let size = limits.resolve(self.width, self.height, intrinsic_size);
|
|
let node = layout::Node::with_children(size.expand(self.padding), nodes);
|
|
let state = tree.state.downcast_mut::<State<Key>>();
|
|
state.wrap_width = wrap_width;
|
|
self.sync_slot_positions(state, &slots);
|
|
|
|
node
|
|
}
|
|
|
|
fn operate(
|
|
&mut self,
|
|
tree: &mut Tree,
|
|
layout: Layout<'_>,
|
|
renderer: &Renderer,
|
|
operation: &mut dyn Operation,
|
|
) {
|
|
operation.container(None, layout.bounds());
|
|
operation.traverse(&mut |operation| {
|
|
self.children
|
|
.iter_mut()
|
|
.zip(&mut tree.children)
|
|
.zip(layout.children())
|
|
.for_each(|((child, state), child_layout)| {
|
|
child.as_widget_mut().operate(
|
|
state,
|
|
child_layout.with_virtual_offset(layout.virtual_offset()),
|
|
renderer,
|
|
operation,
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
fn update(
|
|
&mut self,
|
|
tree: &mut Tree,
|
|
event: &Event,
|
|
layout: Layout<'_>,
|
|
cursor: Cursor,
|
|
renderer: &Renderer,
|
|
clipboard: &mut dyn Clipboard,
|
|
shell: &mut Shell<'_, Message>,
|
|
viewport: &Rectangle,
|
|
) {
|
|
let bounds = layout.bounds();
|
|
let state = tree.state.downcast_mut::<State<Key>>();
|
|
let child_layouts: Vec<_> = layout.children().collect();
|
|
|
|
if let Event::Window(window::Event::RedrawRequested(_)) = event {
|
|
state.initialized = true;
|
|
state.finish_animations(self.animation_duration);
|
|
if state.drag.is_some() || state.is_animating(self.animation_duration) {
|
|
shell.request_redraw();
|
|
}
|
|
}
|
|
|
|
match event {
|
|
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
|
|
if state.drag.is_none()
|
|
&& state.pending_drag.is_none()
|
|
&& let Some(cursor_pos) = cursor.position()
|
|
&& let Some((index, child_layout)) = child_layouts
|
|
.iter()
|
|
.enumerate()
|
|
.find(|(_, child_layout)| child_layout.bounds().contains(cursor_pos))
|
|
&& !self.locked.get(index).copied().unwrap_or(false)
|
|
&& let Some(reorder_index) = reorderable_index_for_child(&self.locked, index)
|
|
{
|
|
let child_bounds = child_layout.bounds();
|
|
state.pending_drag = Some(PendingDragState {
|
|
key: self.keys[index].clone(),
|
|
item_index: index,
|
|
original_index: reorder_index,
|
|
press_local: Point::new(cursor_pos.x - bounds.x, cursor_pos.y - bounds.y),
|
|
pointer_offset: Vector::new(
|
|
cursor_pos.x - child_bounds.x,
|
|
cursor_pos.y - child_bounds.y,
|
|
),
|
|
});
|
|
}
|
|
}
|
|
Event::Mouse(mouse::Event::CursorMoved { .. }) => {
|
|
if let Some(pending) = state.pending_drag.clone()
|
|
&& let Some(cursor_pos) = cursor.position()
|
|
{
|
|
let cursor_local = Point::new(cursor_pos.x - bounds.x, cursor_pos.y - bounds.y);
|
|
let delta = Vector::new(
|
|
cursor_local.x - pending.press_local.x,
|
|
cursor_local.y - pending.press_local.y,
|
|
);
|
|
let distance = (delta.x.powi(2) + delta.y.powi(2)).sqrt();
|
|
|
|
if distance >= DEFAULT_DRAG_THRESHOLD {
|
|
if let (Some(child), Some(child_tree), Some(child_layout)) = (
|
|
self.children.get_mut(pending.item_index),
|
|
tree.children.get_mut(pending.item_index),
|
|
child_layouts.get(pending.item_index),
|
|
) {
|
|
let cursor_left = Event::Mouse(mouse::Event::CursorLeft);
|
|
child.as_widget_mut().update(
|
|
child_tree,
|
|
&cursor_left,
|
|
child_layout.with_virtual_offset(layout.virtual_offset()),
|
|
cursor,
|
|
renderer,
|
|
clipboard,
|
|
shell,
|
|
viewport,
|
|
);
|
|
}
|
|
|
|
state.pending_drag = None;
|
|
state.drag = Some(DragState {
|
|
key: pending.key,
|
|
item_index: pending.item_index,
|
|
original_index: pending.original_index,
|
|
current_index: pending.original_index,
|
|
cursor_local,
|
|
pointer_offset: pending.pointer_offset,
|
|
});
|
|
let slots = self.item_local_slots_from_layout(bounds, &child_layouts);
|
|
self.sync_slot_positions(state, &slots);
|
|
shell.capture_event();
|
|
shell.request_redraw();
|
|
return;
|
|
}
|
|
}
|
|
|
|
if let Some(drag) = state.drag.as_mut()
|
|
&& let Some(cursor_pos) = cursor.position()
|
|
{
|
|
drag.cursor_local =
|
|
Point::new(cursor_pos.x - bounds.x, cursor_pos.y - bounds.y);
|
|
let slots = self.item_local_slots_from_layout(bounds, &child_layouts);
|
|
self.sync_slot_positions(state, &slots);
|
|
shell.capture_event();
|
|
shell.request_redraw();
|
|
return;
|
|
}
|
|
}
|
|
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
|
|
state.pending_drag = None;
|
|
|
|
if state.drag.is_some() {
|
|
let slots = self.item_local_slots_from_layout(bounds, &child_layouts);
|
|
self.sync_slot_positions(state, &slots);
|
|
}
|
|
if let Some(drag) = state.drag.take() {
|
|
if drag.current_index != drag.original_index {
|
|
let locked_by_key: HashMap<Key, bool> = self
|
|
.keys
|
|
.iter()
|
|
.cloned()
|
|
.zip(self.locked.iter().copied())
|
|
.collect();
|
|
let new_order = reordered_keys_for_drag(
|
|
&self.keys,
|
|
&locked_by_key,
|
|
&drag.key,
|
|
drag.current_index,
|
|
);
|
|
shell.publish((self.on_reorder)(new_order));
|
|
}
|
|
shell.capture_event();
|
|
shell.request_redraw();
|
|
return;
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
|
|
if state.drag.is_some() {
|
|
return;
|
|
}
|
|
|
|
for ((item, tree), child_layout) in self
|
|
.children
|
|
.iter_mut()
|
|
.zip(&mut tree.children)
|
|
.zip(child_layouts.into_iter())
|
|
{
|
|
item.as_widget_mut().update(
|
|
tree,
|
|
event,
|
|
child_layout.with_virtual_offset(layout.virtual_offset()),
|
|
cursor,
|
|
renderer,
|
|
clipboard,
|
|
shell,
|
|
viewport,
|
|
);
|
|
}
|
|
}
|
|
|
|
fn mouse_interaction(
|
|
&self,
|
|
tree: &Tree,
|
|
layout: Layout<'_>,
|
|
cursor: Cursor,
|
|
viewport: &Rectangle,
|
|
renderer: &Renderer,
|
|
) -> mouse::Interaction {
|
|
let state = tree.state.downcast_ref::<State<Key>>();
|
|
|
|
if state.drag.is_some() {
|
|
return mouse::Interaction::Grabbing;
|
|
}
|
|
|
|
if let Some(cursor_pos) = cursor.position()
|
|
&& self
|
|
.locked
|
|
.iter()
|
|
.zip(layout.children())
|
|
.any(|(locked, child_layout)| {
|
|
!*locked && child_layout.bounds().contains(cursor_pos)
|
|
})
|
|
{
|
|
return mouse::Interaction::Grab;
|
|
}
|
|
|
|
self.children
|
|
.iter()
|
|
.zip(&tree.children)
|
|
.zip(layout.children())
|
|
.map(|((child, tree), child_layout)| {
|
|
child.as_widget().mouse_interaction(
|
|
tree,
|
|
child_layout.with_virtual_offset(layout.virtual_offset()),
|
|
cursor,
|
|
viewport,
|
|
renderer,
|
|
)
|
|
})
|
|
.max()
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn draw(
|
|
&self,
|
|
tree: &Tree,
|
|
renderer: &mut Renderer,
|
|
theme: &crate::Theme,
|
|
style: &renderer::Style,
|
|
layout: Layout<'_>,
|
|
cursor: Cursor,
|
|
viewport: &Rectangle,
|
|
) {
|
|
let state = tree.state.downcast_ref::<State<Key>>();
|
|
let bounds = layout.bounds();
|
|
|
|
let Some(clipped_viewport) = bounds.intersection(viewport) else {
|
|
return;
|
|
};
|
|
|
|
let viewport = if self.clip {
|
|
&clipped_viewport
|
|
} else {
|
|
viewport
|
|
};
|
|
let drag_key = state.drag.as_ref().map(|drag| &drag.key);
|
|
let drag_item = state.drag.as_ref().and_then(|drag| {
|
|
self.keys
|
|
.iter()
|
|
.zip(&self.children)
|
|
.zip(&tree.children)
|
|
.zip(layout.children())
|
|
.find_map(|(((key, child), state), child_layout)| {
|
|
(key == &drag.key).then_some((key, child, state, child_layout, drag))
|
|
})
|
|
});
|
|
|
|
for (((key, child), child_tree), child_layout) in self
|
|
.keys
|
|
.iter()
|
|
.zip(&self.children)
|
|
.zip(&tree.children)
|
|
.zip(layout.children())
|
|
{
|
|
if drag_key.is_some_and(|drag_key| drag_key == key) {
|
|
continue;
|
|
}
|
|
|
|
let child_layout = child_layout.with_virtual_offset(layout.virtual_offset());
|
|
let child_bounds = child_layout.bounds();
|
|
let base_local = Point::new(child_bounds.x - bounds.x, child_bounds.y - bounds.y);
|
|
let target_local =
|
|
state.current_slot_position(key, base_local, self.animation_duration);
|
|
let translation =
|
|
Vector::new(target_local.x - base_local.x, target_local.y - base_local.y);
|
|
let translated_bounds = translate_rect(child_bounds, translation);
|
|
|
|
if translated_bounds.intersects(viewport) {
|
|
renderer.with_translation(translation, |renderer| {
|
|
child.as_widget().draw(
|
|
child_tree,
|
|
renderer,
|
|
theme,
|
|
style,
|
|
child_layout,
|
|
cursor,
|
|
viewport,
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
if let Some((_key, child, child_tree, child_layout, drag)) = drag_item {
|
|
let child_layout = child_layout.with_virtual_offset(layout.virtual_offset());
|
|
let child_bounds = child_layout.bounds();
|
|
let base_local = Point::new(child_bounds.x - bounds.x, child_bounds.y - bounds.y);
|
|
let drag_local = Point::new(
|
|
drag.cursor_local.x - drag.pointer_offset.x,
|
|
drag.cursor_local.y - drag.pointer_offset.y - self.drag_lift,
|
|
);
|
|
let translation = Vector::new(drag_local.x - base_local.x, drag_local.y - base_local.y);
|
|
|
|
#[cfg(feature = "wgpu")]
|
|
{
|
|
let translated_bounds = translate_rect(child_bounds, translation);
|
|
draw_drag_backdrop(renderer, theme, translated_bounds);
|
|
}
|
|
|
|
renderer.with_translation(translation, |renderer| {
|
|
child.as_widget().draw(
|
|
child_tree,
|
|
renderer,
|
|
theme,
|
|
style,
|
|
child_layout,
|
|
cursor,
|
|
viewport,
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
fn overlay<'b>(
|
|
&'b mut self,
|
|
tree: &'b mut Tree,
|
|
layout: Layout<'b>,
|
|
renderer: &Renderer,
|
|
viewport: &Rectangle,
|
|
translation: Vector,
|
|
) -> Option<overlay::Element<'b, Message, crate::Theme, Renderer>> {
|
|
overlay::from_children(
|
|
&mut self.children,
|
|
tree,
|
|
layout,
|
|
renderer,
|
|
viewport,
|
|
translation,
|
|
)
|
|
}
|
|
|
|
fn drag_destinations(
|
|
&self,
|
|
state: &Tree,
|
|
layout: Layout<'_>,
|
|
renderer: &Renderer,
|
|
dnd_rectangles: &mut iced_core::clipboard::DndDestinationRectangles,
|
|
) {
|
|
for ((item, child_layout), child_state) in self
|
|
.children
|
|
.iter()
|
|
.zip(layout.children())
|
|
.zip(state.children.iter())
|
|
{
|
|
item.as_widget().drag_destinations(
|
|
child_state,
|
|
child_layout.with_virtual_offset(layout.virtual_offset()),
|
|
renderer,
|
|
dnd_rectangles,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<'a, Key, Message> From<ReorderableFlexRow<'a, Key, Message>> for Element<'a, Message>
|
|
where
|
|
Key: Clone + Eq + Hash + 'static,
|
|
Message: 'a,
|
|
{
|
|
fn from(row: ReorderableFlexRow<'a, Key, Message>) -> Self {
|
|
Element::new(row)
|
|
}
|
|
}
|
|
|
|
/// Create a horizontal flex row with drag-to-reorder behavior.
|
|
pub fn reorderable_flex_row<'a, Key, Message>(
|
|
on_reorder: impl Fn(Vec<Key>) -> Message + 'a,
|
|
) -> ReorderableFlexRow<'a, Key, Message>
|
|
where
|
|
Key: Clone + Eq + Hash + 'static,
|
|
{
|
|
ReorderableFlexRow::new(on_reorder)
|
|
}
|
|
|
|
fn compute_wrapped_slots<Key>(
|
|
ordered_keys: &[Key],
|
|
locked_by_key: &HashMap<Key, bool>,
|
|
size_by_key: &HashMap<Key, Size>,
|
|
wrap_width: f32,
|
|
padding: Padding,
|
|
spacing: f32,
|
|
align: Alignment,
|
|
) -> (Vec<LocalSlot<Key>>, Size)
|
|
where
|
|
Key: Clone + Eq + Hash + 'static,
|
|
{
|
|
let wrap_width = if wrap_width.is_finite() {
|
|
wrap_width.max(0.0)
|
|
} else {
|
|
f32::INFINITY
|
|
};
|
|
|
|
let mut slots = Vec::with_capacity(ordered_keys.len());
|
|
let mut intrinsic_size = Size::ZERO;
|
|
let mut row_start = 0;
|
|
let mut row_height = 0.0;
|
|
let mut x = 0.0;
|
|
let mut y = 0.0;
|
|
|
|
let align_factor = match align {
|
|
Alignment::Start => 0.0,
|
|
Alignment::Center => 2.0,
|
|
Alignment::End => 1.0,
|
|
};
|
|
|
|
let align_row =
|
|
|range: std::ops::Range<usize>, row_height: f32, slots: &mut [LocalSlot<Key>]| {
|
|
if align_factor == 0.0 {
|
|
return;
|
|
}
|
|
|
|
for slot in &mut slots[range] {
|
|
slot.bounds.y += (row_height - slot.bounds.height) / align_factor;
|
|
}
|
|
};
|
|
|
|
for (index, key) in ordered_keys.iter().enumerate() {
|
|
let size = size_by_key.get(key).copied().unwrap_or(Size::ZERO);
|
|
|
|
if x != 0.0 && x + size.width > wrap_width {
|
|
intrinsic_size.width = intrinsic_size.width.max(x - spacing);
|
|
align_row(row_start..index, row_height, &mut slots);
|
|
y += row_height + spacing;
|
|
x = 0.0;
|
|
row_start = index;
|
|
row_height = 0.0;
|
|
}
|
|
|
|
row_height = row_height.max(size.height);
|
|
|
|
slots.push(LocalSlot {
|
|
key: key.clone(),
|
|
locked: locked_by_key.get(key).copied().unwrap_or(false),
|
|
bounds: Rectangle {
|
|
x: x + padding.left,
|
|
y: y + padding.top,
|
|
width: size.width,
|
|
height: size.height,
|
|
},
|
|
});
|
|
|
|
x += size.width + spacing;
|
|
}
|
|
|
|
if x != 0.0 {
|
|
intrinsic_size.width = intrinsic_size.width.max(x - spacing);
|
|
}
|
|
|
|
intrinsic_size.height = y + row_height;
|
|
align_row(row_start..slots.len(), row_height, &mut slots);
|
|
|
|
(slots, intrinsic_size)
|
|
}
|
|
|
|
fn reordered_keys_for_drag<Key>(
|
|
ordered_keys: &[Key],
|
|
locked_by_key: &HashMap<Key, bool>,
|
|
dragged_key: &Key,
|
|
target_index: usize,
|
|
) -> Vec<Key>
|
|
where
|
|
Key: Clone + Eq + Hash + 'static,
|
|
{
|
|
let movable_keys: Vec<Key> = ordered_keys
|
|
.iter()
|
|
.filter(|key| !locked_by_key.get(*key).copied().unwrap_or(false))
|
|
.cloned()
|
|
.collect();
|
|
let mut remaining: Vec<Key> = movable_keys
|
|
.iter()
|
|
.filter(|key| *key != dragged_key)
|
|
.cloned()
|
|
.collect();
|
|
|
|
remaining.insert(target_index.min(remaining.len()), dragged_key.clone());
|
|
merge_locked_and_reordered_keys(ordered_keys, locked_by_key, &remaining)
|
|
}
|
|
|
|
/// Picks insertion index among movable items using row-aware midpoint rule.
|
|
///
|
|
/// Walks laid-out slots in reading order, counting how many non-dragged movable
|
|
/// items the cursor has moved past. Skips locked slots. O(n) single pass, no
|
|
/// allocations.
|
|
fn target_index_for_drag<Key>(
|
|
slots: &[LocalSlot<Key>],
|
|
dragged_key: &Key,
|
|
dragged_center: Point,
|
|
) -> usize
|
|
where
|
|
Key: Clone + Eq + Hash + 'static,
|
|
{
|
|
let mut target = 0;
|
|
let mut passed_movable: usize = 0;
|
|
let mut found_target = false;
|
|
|
|
let mut i = 0;
|
|
while i < slots.len() {
|
|
let slot = &slots[i];
|
|
|
|
if slot.locked || &slot.key == dragged_key {
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
let row_top = slot.bounds.y;
|
|
let row_bottom = row_top + slot.bounds.height;
|
|
|
|
if !found_target && dragged_center.y < row_top {
|
|
target = passed_movable;
|
|
found_target = true;
|
|
break;
|
|
}
|
|
|
|
if dragged_center.y > row_bottom {
|
|
passed_movable += 1;
|
|
i += 1;
|
|
continue;
|
|
}
|
|
|
|
let center_x = slot.bounds.x + slot.bounds.width / 2.0;
|
|
if dragged_center.x < center_x {
|
|
target = passed_movable;
|
|
found_target = true;
|
|
break;
|
|
}
|
|
|
|
passed_movable += 1;
|
|
i += 1;
|
|
}
|
|
|
|
if !found_target {
|
|
target = passed_movable;
|
|
}
|
|
|
|
target
|
|
}
|
|
|
|
fn reorderable_index_for_child(locked: &[bool], item_index: usize) -> Option<usize> {
|
|
(!locked.get(item_index).copied().unwrap_or(false)).then(|| {
|
|
locked[..item_index]
|
|
.iter()
|
|
.filter(|is_locked| !**is_locked)
|
|
.count()
|
|
})
|
|
}
|
|
|
|
fn merge_locked_and_reordered_keys<Key>(
|
|
ordered_keys: &[Key],
|
|
locked_by_key: &HashMap<Key, bool>,
|
|
reordered_movable_keys: &[Key],
|
|
) -> Vec<Key>
|
|
where
|
|
Key: Clone + Eq + Hash + 'static,
|
|
{
|
|
let mut movable = reordered_movable_keys.iter();
|
|
|
|
ordered_keys
|
|
.iter()
|
|
.map(|key| {
|
|
if locked_by_key.get(key).copied().unwrap_or(false) {
|
|
key.clone()
|
|
} else {
|
|
movable.next().cloned().unwrap_or_else(|| key.clone())
|
|
}
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn approx_eq_point(a: Point, b: Point) -> bool {
|
|
(a.x - b.x).abs() <= POSITION_EPSILON && (a.y - b.y).abs() <= POSITION_EPSILON
|
|
}
|
|
|
|
fn translate_rect(bounds: Rectangle, translation: Vector) -> Rectangle {
|
|
Rectangle {
|
|
x: bounds.x + translation.x,
|
|
y: bounds.y + translation.y,
|
|
width: bounds.width,
|
|
height: bounds.height,
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "wgpu")]
|
|
fn draw_drag_backdrop(renderer: &mut Renderer, theme: &crate::Theme, bounds: Rectangle) {
|
|
let cosmic = theme.cosmic();
|
|
let backdrop = iced::Color {
|
|
a: 0.08,
|
|
..iced::Color::from(cosmic.bg_component_color())
|
|
};
|
|
|
|
renderer.fill_quad(
|
|
renderer::Quad {
|
|
bounds,
|
|
border: Border {
|
|
radius: cosmic.corner_radii.radius_m.into(),
|
|
..Border::default()
|
|
},
|
|
shadow: Shadow {
|
|
color: cosmic.shade.into(),
|
|
offset: Vector::new(0.0, 8.0),
|
|
blur_radius: SHADOW_BLUR_RADIUS,
|
|
},
|
|
snap: true,
|
|
},
|
|
Background::Color(backdrop),
|
|
);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{compute_wrapped_slots, reordered_keys_for_drag, target_index_for_drag};
|
|
use iced::{Alignment, Padding, Point, Size};
|
|
use std::collections::HashMap;
|
|
|
|
fn size_map(keys: &[usize], width: f32, height: f32) -> HashMap<usize, Size> {
|
|
keys.iter()
|
|
.copied()
|
|
.map(|key| (key, Size::new(width, height)))
|
|
.collect()
|
|
}
|
|
|
|
fn locked_map(keys: &[usize], locked_keys: &[usize]) -> HashMap<usize, bool> {
|
|
keys.iter()
|
|
.copied()
|
|
.map(|key| (key, locked_keys.contains(&key)))
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn compute_wrapped_slots_creates_new_rows() {
|
|
let ordered_keys = vec![0, 1, 2];
|
|
let locked_by_key = locked_map(&ordered_keys, &[]);
|
|
let size_by_key = size_map(&ordered_keys, 100.0, 40.0);
|
|
let (slots, intrinsic_size) = compute_wrapped_slots(
|
|
&ordered_keys,
|
|
&locked_by_key,
|
|
&size_by_key,
|
|
220.0,
|
|
Padding::ZERO,
|
|
10.0,
|
|
Alignment::Start,
|
|
);
|
|
|
|
assert_eq!(slots[0].bounds.x, 0.0);
|
|
assert_eq!(slots[0].bounds.y, 0.0);
|
|
assert_eq!(slots[1].bounds.x, 110.0);
|
|
assert_eq!(slots[1].bounds.y, 0.0);
|
|
assert_eq!(slots[2].bounds.x, 0.0);
|
|
assert_eq!(slots[2].bounds.y, 50.0);
|
|
assert_eq!(intrinsic_size.width, 210.0);
|
|
assert_eq!(intrinsic_size.height, 90.0);
|
|
}
|
|
|
|
#[test]
|
|
fn reordered_keys_for_drag_inserts_key_at_target_index() {
|
|
let keys = [0, 1, 2, 3];
|
|
let locked_by_key = locked_map(&keys, &[]);
|
|
let reordered = reordered_keys_for_drag(&keys, &locked_by_key, &0, 3);
|
|
assert_eq!(reordered, vec![1, 2, 3, 0]);
|
|
}
|
|
|
|
#[test]
|
|
fn target_index_tracks_wrapped_drop_positions() {
|
|
let ordered_keys = vec![0, 1, 2, 3];
|
|
let locked_by_key = locked_map(&ordered_keys, &[]);
|
|
let size_by_key = size_map(&ordered_keys, 100.0, 40.0);
|
|
|
|
let (slots, _) = compute_wrapped_slots(
|
|
&ordered_keys,
|
|
&locked_by_key,
|
|
&size_by_key,
|
|
220.0,
|
|
Padding::ZERO,
|
|
10.0,
|
|
Alignment::Start,
|
|
);
|
|
|
|
let target_index = target_index_for_drag(&slots, &0, Point::new(160.0, 70.0));
|
|
|
|
assert_eq!(target_index, 3);
|
|
}
|
|
|
|
#[test]
|
|
fn reordered_keys_for_drag_preserves_locked_positions() {
|
|
let keys = [10, 11, 12, 13];
|
|
let locked_by_key = locked_map(&keys, &[10, 13]);
|
|
let reordered = reordered_keys_for_drag(&keys, &locked_by_key, &11, 1);
|
|
|
|
assert_eq!(reordered, vec![10, 12, 11, 13]);
|
|
}
|
|
}
|