dioxus_dnd/core/world/state.rs
1//! World construction and the joined-window table: process-lived state,
2//! join/leave lifecycle, and window lookup.
3
4use std::cell::RefCell;
5
6use dioxus::prelude::*;
7use dioxus::signals::{AnyStorage, Owner, SyncStorage, UnsyncStorage};
8
9use crate::core::hooks::SettleFlag;
10use crate::core::registry::ZoneRegistry;
11use crate::core::state::{DndContext, DragState};
12use crate::core::types::{Point, ZoneId};
13
14use super::drag::ActiveDrag;
15use super::geometry::{WindowGeometry, WindowKey};
16use super::settle::SettleClaim;
17
18thread_local! {
19 /// Owners of every world's state, held for the life of the process
20 /// (all of an app's windows share one thread). Worlds are deliberately
21 /// immortal: scope-owned state would die with its creating window and
22 /// panic every surviving window that still renders from it, and no
23 /// close order should be able to do that. Bounded: a few signals per
24 /// world, one world per payload type per app, window records pruned on
25 /// close. Both storage flavors: signals live in unsync storage, but a
26 /// store's subscription tree allocates in SYNC storage.
27 static WORLD_OWNERS: RefCell<Vec<(Owner<UnsyncStorage>, Owner<SyncStorage>)>> =
28 const { RefCell::new(Vec::new()) };
29}
30
31/// The world's initial bridging policy from `DIOXUS_DND_NO_BRIDGE`,
32/// read once at creation so end users can disable host-side bridging
33/// without a rebuild. Opt-out semantics: only an explicit non-`0`,
34/// non-empty value disables - an unset or neutered variable must never
35/// strand the flagship feature by accident.
36fn default_bridging(no_bridge: Option<&str>) -> bool {
37 match no_bridge {
38 None | Some("") | Some("0") => true,
39 Some(_) => false,
40 }
41}
42
43/// A drop-zone identity qualified by the joined window that owns it.
44///
45/// Legacy single-window APIs continue to expose [`ZoneId`]; worlds use this
46/// richer identity so separate windows may safely reuse the same explicit id.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
48pub struct ZoneLocation {
49 pub window: WindowKey,
50 pub zone: ZoneId,
51}
52
53/// One window joined to a [`DndWorld`]: its geometry, its zone registry,
54/// and the per-window handles drop delivery needs.
55pub struct WindowRecord<T: Clone + 'static> {
56 pub key: WindowKey,
57 pub geometry: WindowGeometry,
58 pub registry: ZoneRegistry<T>,
59 /// The window's own settle flag: a drop landing here settles iff *this*
60 /// window has a settle-enabled overlay mounted.
61 pub(crate) settle: SettleFlag<T>,
62 /// Re-measures the window's zones. Created by the window's provider, so
63 /// calling it runs `refresh_rects` inside that window's own runtime
64 /// (`Callback::call` re-enters its origin runtime) - the spawned
65 /// measurements land on the right scheduler.
66 pub(crate) refresh: Callback<()>,
67}
68
69impl<T: Clone + 'static> Copy for WindowRecord<T> {}
70impl<T: Clone + 'static> Clone for WindowRecord<T> {
71 fn clone(&self) -> Self {
72 *self
73 }
74}
75
76/// A drag world shared by several windows: one [`DndContext`] every joined
77/// provider re-provides, plus the window table cross-window hit-testing
78/// walks. Cheap to copy; pass it to a sibling window via
79/// `VirtualDom::with_root_context`.
80pub struct DndWorld<T: Clone + 'static> {
81 pub(super) ctx: DndContext<T>,
82 windows: Signal<Vec<WindowRecord<T>>>,
83 /// The window the in-flight drag started in - the coordinate anchor:
84 /// `ctx.pointer()` is always in *this* window's client px.
85 pub(super) active: Signal<Option<ActiveDrag>>,
86 /// Exact host- or world-resolved pointer position in global physical px.
87 /// Kept separate from `active` so pointer ticks do not invalidate
88 /// session-metadata subscribers.
89 pub(super) global_pointer: Signal<Option<Point>>,
90 /// Window-qualified hover identity. The legacy id remains in
91 /// `DragState` for single-window and custom-source compatibility.
92 pub(super) over_location: Signal<Option<ZoneLocation>>,
93 /// The elected settle presenter and its freshness generation. Kept as
94 /// one value so owner and generation can never disagree.
95 pub(super) settle_claim: Signal<Option<SettleClaim>>,
96 /// Host-side bridging kill switch (see [`DndWorld::set_bridging`]).
97 /// Owned by the world, not the desktop adapter, so a custom host
98 /// cannot keep driving a world whose app disabled bridging.
99 bridging: Signal<bool>,
100}
101
102impl<T: Clone + 'static> Copy for DndWorld<T> {}
103impl<T: Clone + 'static> Clone for DndWorld<T> {
104 fn clone(&self) -> Self {
105 *self
106 }
107}
108impl<T: Clone + 'static> PartialEq for DndWorld<T> {
109 fn eq(&self, other: &Self) -> bool {
110 self.windows == other.windows
111 }
112}
113
114impl<T: Clone + 'static> DndWorld<T> {
115 /// Create a world. Its state is **process-lived** (see the module docs
116 /// on lifetimes), so windows may close in any order afterwards. Must
117 /// run inside a Dioxus app; prefer [`use_dnd_world`](super::use_dnd_world), which also
118 /// provides the world in context.
119 pub fn new() -> Self {
120 let owner = UnsyncStorage::owner();
121 let sync_owner = SyncStorage::owner();
122 let world = dioxus::core::with_owner(owner.clone(), || {
123 dioxus::core::with_owner(sync_owner.clone(), || Self {
124 ctx: DndContext::from_parts(
125 Store::new(DragState::default()),
126 Signal::new(String::new()),
127 ),
128 windows: Signal::new(Vec::new()),
129 active: Signal::new(None),
130 global_pointer: Signal::new(None),
131 over_location: Signal::new(None),
132 settle_claim: Signal::new(None),
133 bridging: Signal::new(default_bridging(
134 std::env::var("DIOXUS_DND_NO_BRIDGE").ok().as_deref(),
135 )),
136 })
137 });
138 WORLD_OWNERS.with_borrow_mut(|owners| owners.push((owner, sync_owner)));
139 world
140 }
141
142 /// The shared drag context every joined provider re-provides.
143 pub fn context(&self) -> DndContext<T> {
144 self.ctx
145 }
146
147 /// Enable or disable host-side bridging at runtime - the lever for the
148 /// day a webview or OS update ships a cross-window regression that a
149 /// rebuild cannot wait for. While disabled, every host-drive entry
150 /// point ([`Self::track_global`], [`Self::drop_at_global`]) is inert
151 /// and the `desktop` feature's bridge legs stand down, so drags
152 /// degrade to per-window - exactly the already-modeled Wayland
153 /// behavior. Local drags, geometry, settle and delivery are untouched.
154 /// [`Self::cancel_drag`] deliberately stays live: it is an escape
155 /// hatch, not a bridge leg.
156 ///
157 /// End users can flip the same switch without a rebuild by setting
158 /// `DIOXUS_DND_NO_BRIDGE=1` before launch (read once at world
159 /// creation; `0` or an empty value leaves bridging on).
160 pub fn set_bridging(&self, enabled: bool) {
161 let mut bridging = self.bridging;
162 if *bridging.peek() != enabled {
163 bridging.set(enabled);
164 }
165 }
166
167 /// Is host-side bridging currently enabled? (See [`Self::set_bridging`].)
168 pub fn bridging_enabled(&self) -> bool {
169 // try_peek: callable from destructors and foreign runtimes, like
170 // every other world read on the leg paths.
171 self.bridging.try_peek().map(|b| *b).unwrap_or(true)
172 }
173
174 /// Join a window. Called by `use_dnd_provider` when it finds a world in
175 /// context; call directly only from custom provider integrations.
176 pub(crate) fn join(
177 &self,
178 geometry: WindowGeometry,
179 registry: ZoneRegistry<T>,
180 settle: SettleFlag<T>,
181 refresh: Callback<()>,
182 ) -> WindowKey {
183 let key = WindowKey::auto();
184 let mut windows = self.windows;
185 windows.write().push(WindowRecord {
186 key,
187 geometry,
188 registry,
189 settle,
190 refresh,
191 });
192 key
193 }
194
195 /// Remove a window (its provider unmounted, usually because the window
196 /// closed). An active drag that originated there aborts because its
197 /// coordinate anchor is gone; a receiver-owned settle survives from its
198 /// snapshotted release point. A drag merely hovering one of the leaving
199 /// window's zones just loses the hover. Pruning keeps the world from ever
200 /// calling into a closed window's runtime.
201 pub(crate) fn leave(&self, key: WindowKey) {
202 // Worlds are process-lived, so this should be unreachable - but
203 // leave runs inside a destructor, where a panic aborts the whole
204 // process, so degrade to a no-op rather than trusting that.
205 if self.windows.try_peek().is_err() {
206 return;
207 }
208 let mut ctx = self.ctx;
209 {
210 let mut windows = self.windows;
211 windows.write().retain(|w| w.key != key);
212 }
213 // Qualified hover tells us exactly which window disappeared, even
214 // when another survivor reuses the same explicit ZoneId. Retain the
215 // legacy reachability fallback for keyboard/custom paths that have
216 // not supplied world-qualified metadata.
217 let qualified_over = *self.over_location.peek();
218 if qualified_over.is_some_and(|over| over.window == key) {
219 if let Some(over) = ctx.over() {
220 ctx.leave(over);
221 }
222 let mut over_location = self.over_location;
223 over_location.set(None);
224 } else if qualified_over.is_none() {
225 // Checked against the REMAINING windows, not the leaving one:
226 // scopes drop children first, so the leaving window's zones have
227 // usually unregistered before its provider leaves.
228 if let Some(over) = ctx.over() {
229 let reachable = self
230 .windows
231 .peek()
232 .iter()
233 .any(|w| w.registry.contains(over));
234 if !reachable {
235 ctx.leave(over);
236 }
237 }
238 }
239 let active_drag = *self.active.peek();
240 if active_drag.is_some_and(|active| active.origin == key) {
241 if ctx.dragging() {
242 match active_drag.and_then(|active| active.session) {
243 Some(session) if ctx.is_session(session) => {
244 // A built-in source normally finishes from its own
245 // cleanup before its provider leaves. Never call an
246 // unknown custom source after child teardown.
247 ctx.abandon_session(session);
248 }
249 // An untracked replacement can coexist briefly with the
250 // old source's committed completion. Cancel the closing
251 // window's drag without consuming that unrelated slot.
252 _ => ctx.cancel(),
253 }
254 self.clear_world_state();
255 return;
256 }
257 if ctx.settling().is_some() {
258 // A settle elected in another window no longer needs the
259 // origin runtime: the release point and origin scale were
260 // snapshotted into world state. Keep them until it lands.
261 match self.settle_presenter() {
262 Some(presenter) if presenter != key => {}
263 Some(_) => {
264 self.finish_settle_from(key);
265 }
266 None => {
267 // Compatibility for a custom source that entered the
268 // context's public settle state without a world claim:
269 // the origin is its only possible presenter.
270 ctx.finish_settle();
271 self.clear_world_state();
272 }
273 }
274 return;
275 }
276 self.clear_world_state();
277 return;
278 }
279 if self.settle_presenter_is(key) {
280 // The elected overlay is gone, so no transition listener remains
281 // to finish the glide. Non-presenter closure is intentionally
282 // inert because this equality fails for every other window.
283 self.finish_settle_from(key);
284 }
285 }
286
287 /// Look up a joined window. `None` for unknown keys.
288 pub fn record(&self, key: WindowKey) -> Option<WindowRecord<T>> {
289 self.windows
290 .try_peek()
291 .ok()?
292 .iter()
293 .find(|w| w.key == key)
294 .copied()
295 }
296
297 /// Every joined window, in join order. Subscribing read, like
298 /// [`ZoneRegistry::records`] - its consumers are renderers and tests.
299 pub fn windows(&self) -> Vec<WindowRecord<T>> {
300 self.windows
301 .try_read()
302 .map(|w| w.to_vec())
303 .unwrap_or_default()
304 }
305
306 /// The window containing `global` (physical px), most recently focused
307 /// first when several overlap. `None` while no live geometry contains
308 /// the point.
309 pub fn window_under(&self, global: Point) -> Option<WindowRecord<T>> {
310 self.windows
311 .try_peek()
312 .ok()?
313 .iter()
314 .filter(|w| w.geometry.contains_global(global))
315 .max_by_key(|w| w.geometry.focus_stamp())
316 .copied()
317 }
318
319 /// Resolve a global point to (window, client-local point). `None` when
320 /// no live window contains it.
321 pub fn resolve_global(&self, global: Point) -> Option<(WindowRecord<T>, Point)> {
322 let rec = self.window_under(global)?;
323 let local = rec.geometry.to_client(global)?;
324 Some((rec, local))
325 }
326
327 /// Ask every joined window to re-measure its zones, each inside its own
328 /// runtime through the window's internal refresh callback.
329 pub fn refresh_all_rects(&self) {
330 let Ok(windows) = self.windows.try_peek() else {
331 return;
332 };
333 for rec in windows.iter() {
334 rec.refresh.call(());
335 }
336 }
337}
338
339impl<T: Clone + 'static> Default for DndWorld<T> {
340 fn default() -> Self {
341 Self::new()
342 }
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348
349 #[test]
350 fn bridging_defaults_on_unless_no_bridge_is_meaningfully_set() {
351 assert!(default_bridging(None));
352 assert!(default_bridging(Some("")));
353 assert!(default_bridging(Some("0")));
354 assert!(!default_bridging(Some("1")));
355 assert!(!default_bridging(Some("true")));
356 }
357}