#![doc = include_str!("../docs/api/trees.md")]
use std::rc::Rc;
use dioxus::html::MountedData;
use dioxus::prelude::*;
use crate::core::{
use_dnd, use_joined_window, use_parent_zone, use_zone_id, use_zone_registry, DragMode,
DropOutcome, Rect, ZoneRecord,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct NodeId(pub u64);
impl From<u64> for NodeId {
fn from(v: u64) -> Self {
Self(v)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DropIntent {
Before,
After,
Into,
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub struct TreeDropEvent<T> {
pub payload: T,
pub target: NodeId,
pub intent: DropIntent,
}
impl<T> TreeDropEvent<T> {
pub fn new(payload: T, target: NodeId, intent: DropIntent) -> Self {
Self {
payload,
target,
intent,
}
}
}
pub fn intent_from_offset(y: f64, row_height: f64) -> DropIntent {
let h = row_height.max(1.0);
let ratio = (y / h).clamp(0.0, 1.0);
if ratio < 0.25 {
DropIntent::Before
} else if ratio > 0.75 {
DropIntent::After
} else {
DropIntent::Into
}
}
pub fn would_create_cycle(
parent_of: impl Fn(NodeId) -> Option<NodeId>,
dragged: NodeId,
target: NodeId,
) -> bool {
if dragged == target {
return true;
}
let mut cursor = Some(target);
for _ in 0..10_000 {
match cursor {
Some(n) if n == dragged => return true,
Some(n) => cursor = parent_of(n),
None => return false,
}
}
true
}
#[component]
pub fn TreeNodeTarget<T: Clone + PartialEq + 'static>(
node: NodeId,
#[props(default = 28.0)]
row_height: f64,
#[props(default)]
accepts: Option<Callback<(T, DropIntent), bool>>,
on_drop: EventHandler<TreeDropEvent<T>>,
#[props(default)]
label: Option<String>,
#[props(extends = div, extends = GlobalAttributes)] attributes: Vec<Attribute>,
children: Element,
) -> Element {
let dnd = use_dnd::<T>();
let joined = use_joined_window::<T>();
let mut registry = use_zone_registry::<T>();
let zone_id = use_zone_id();
let parent = use_parent_zone();
let registered_accepts = use_callback(move |p: T| match accepts {
Some(cb) => {
cb.call((p.clone(), DropIntent::Before))
|| cb.call((p.clone(), DropIntent::After))
|| cb.call((p, DropIntent::Into))
}
None => true,
});
let registered_drop = use_callback(move |o: DropOutcome<T>| {
let it = intent_from_offset(o.element.y, row_height);
let ok = match accepts {
Some(cb) => cb.call((o.payload.clone(), it)),
None => true,
};
if ok {
on_drop.call(TreeDropEvent {
payload: o.payload,
target: node,
intent: it,
});
}
});
let registered_label = label.clone();
let registration = use_hook(move || {
registry.register(ZoneRecord {
id: zone_id,
parent,
label: registered_label,
on_drop: registered_drop,
accepts: Some(registered_accepts),
mounted: None,
rect: None,
})
});
use_drop(move || {
registry.unregister_registration(registration);
});
let label_for_sync = label.clone();
use_effect(use_reactive!(|(label_for_sync)| {
registry.sync_label(zone_id, label_for_sync);
}));
use_effect(use_reactive!(|(parent)| {
registry.sync_parent(registration, parent);
}));
let display_intent = move || -> Option<DropIntent> {
let over = match joined {
Some(joined) => joined.is_over(zone_id),
None => dnd.over() == Some(zone_id),
};
if dnd.dragging()
&& dnd.proposed_effect() != crate::core::DropEffect::None
&& dnd.mode() == DragMode::Pointer
&& over
{
let r = registry.cached_rect(zone_id)?;
let pointer = joined
.and_then(|joined| joined.local_pointer())
.unwrap_or_else(|| dnd.pointer());
return Some(intent_from_offset(pointer.y - r.y, row_height));
}
None
};
let intent_str = move || -> Option<&'static str> {
match display_intent() {
Some(DropIntent::Before) => Some("before"),
Some(DropIntent::After) => Some("after"),
Some(DropIntent::Into) => Some("into"),
None => None,
}
};
let mut attributes = attributes;
crate::core::components::protect_attributes(&mut attributes, &["data-intent", "onmounted"]);
rsx! {
div {
"data-intent": intent_str(),
onmounted: move |evt: Event<MountedData>| {
let m: Rc<MountedData> = evt.data();
let mut registry = registry;
registry.set_mounted(registration, m.clone());
spawn(async move {
if let Ok(r) = m.get_client_rect().await {
registry.set_rect_if_present(registration, Rect::new(
r.origin.x,
r.origin.y,
r.size.width,
r.size.height,
));
}
});
},
..attributes,
{children}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn node_id_from_u64() {
assert_eq!(NodeId::from(42), NodeId(42));
}
#[test]
fn intent_bands() {
assert_eq!(intent_from_offset(2.0, 28.0), DropIntent::Before);
assert_eq!(intent_from_offset(14.0, 28.0), DropIntent::Into);
assert_eq!(intent_from_offset(26.0, 28.0), DropIntent::After);
assert_eq!(intent_from_offset(0.0, 0.0), DropIntent::Before);
}
#[test]
fn intent_bands_use_quarter_boundaries() {
assert_eq!(intent_from_offset(24.9, 100.0), DropIntent::Before);
assert_eq!(intent_from_offset(25.0, 100.0), DropIntent::Into);
assert_eq!(intent_from_offset(75.0, 100.0), DropIntent::Into);
assert_eq!(intent_from_offset(75.1, 100.0), DropIntent::After);
}
#[test]
fn intent_bands_clamp_out_of_range_offsets() {
assert_eq!(intent_from_offset(-20.0, 100.0), DropIntent::Before);
assert_eq!(intent_from_offset(120.0, 100.0), DropIntent::After);
assert_eq!(intent_from_offset(50.0, -10.0), DropIntent::After);
}
#[test]
fn cycle_detection() {
let parent = |n: NodeId| match n.0 {
3 => Some(NodeId(2)),
2 => Some(NodeId(1)),
_ => None,
};
assert!(would_create_cycle(parent, NodeId(1), NodeId(3)));
assert!(would_create_cycle(parent, NodeId(2), NodeId(2)));
assert!(!would_create_cycle(parent, NodeId(3), NodeId(1)));
}
#[test]
fn cycle_detection_handles_missing_parents() {
let parent = |n: NodeId| match n.0 {
9 => Some(NodeId(8)),
_ => None,
};
assert!(!would_create_cycle(parent, NodeId(1), NodeId(9)));
assert!(!would_create_cycle(parent, NodeId(9), NodeId(1)));
}
#[test]
fn cycle_detection_treats_parent_map_cycles_as_unsafe() {
let parent = |n: NodeId| match n.0 {
2 => Some(NodeId(3)),
3 => Some(NodeId(2)),
_ => None,
};
assert!(would_create_cycle(parent, NodeId(1), NodeId(2)));
}
}