Skip to main content

dioxus_flow/
state.rs

1//! Shared flow state: the non-generic core handed to every child component
2//! via context, plus the public [`FlowHandle`] for programmatic control from
3//! outside the flow.
4
5use std::collections::HashMap;
6use std::future::Future;
7
8use dioxus::prelude::*;
9
10use crate::anim::{bump_epoch, tween};
11use crate::layout::{compute_layout, LayoutNode, LayoutOptions};
12use crate::types::{
13    side_point, Edge, HandleGeom, HandleKey, HandleKind, Id, NodeGeom, Point, Rect, Side, Viewport,
14};
15
16/// Coarse interaction state. Per-frame details (drag offsets, last pointer
17/// position) live in the non-reactive [`DragState`] so pointer-move frames
18/// don't invalidate subscribers of this signal.
19#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
20pub enum Interaction {
21    #[default]
22    None,
23    /// Panning the canvas.
24    Pan,
25    /// Dragging one or more nodes.
26    DragNode,
27    /// Dragging a new connection out of a handle.
28    Connect,
29    /// The pane was pressed but panning is disabled; a release without
30    /// movement still counts as a pane click.
31    PanePressed,
32    /// A child claimed the pointer (e.g. edge click); no pan/drag behavior.
33    Pressed,
34}
35
36/// Per-gesture scratch state. Only ever accessed with `peek`/`write` from
37/// event handlers, so writes don't trigger renders.
38#[derive(Clone, Debug, Default)]
39pub struct DragState {
40    /// The pointer that owns the current gesture; other pointers' moves and
41    /// releases are ignored while it runs.
42    pub pointer_id: Option<i32>,
43    /// Where the press went down, in client coordinates: what a drag
44    /// threshold measures against.
45    pub origin_client: Point,
46    pub last_client: Point,
47    pub moved: bool,
48    /// A release without movement is normally a pane click; a gesture begun
49    /// with [`FlowCore::begin_pan`] can ask for it not to be.
50    pub suppress_click: bool,
51    /// Nodes being dragged: `(id, grab offset)` where
52    /// `position = cursor_flow - grab`.
53    pub grabs: Vec<(Id, Point)>,
54}
55
56/// A snap candidate for the in-progress connection.
57#[derive(Clone, PartialEq, Debug)]
58pub struct SnapTarget {
59    pub key: HandleKey,
60    pub point: Point,
61    pub side: Side,
62}
63
64/// The in-progress connection gesture.
65#[derive(Clone, PartialEq, Debug)]
66pub struct ConnectionState {
67    pub from: HandleKey,
68    pub cursor: Point,
69    pub snap: Option<SnapTarget>,
70}
71
72/// Static-ish configuration mirrored from `Flow` props.
73#[derive(Clone, Copy, PartialEq, Debug)]
74pub struct FlowConfig {
75    pub min_zoom: f64,
76    pub max_zoom: f64,
77    pub pan_on_drag: bool,
78    pub zoom_on_scroll: bool,
79    /// Scrolling pans instead of zooming; ctrl/meta (a trackpad pinch
80    /// included) zooms about the pointer. On by default, so a two-finger
81    /// trackpad drag pans. Takes precedence over `zoom_on_scroll`.
82    pub pan_on_scroll: bool,
83    pub nodes_draggable: bool,
84    /// How far (screen px) a press on a node must travel before it moves the
85    /// node. Zero moves on the first pixel; a few pixels keep sloppy clicks
86    /// from nudging nodes.
87    pub drag_threshold: f64,
88    /// Snap radius for completing connections, in screen pixels.
89    pub connection_radius: f64,
90    pub fit_view_padding: f64,
91}
92
93impl Default for FlowConfig {
94    fn default() -> Self {
95        Self {
96            // Low enough to overview a large graph, high enough that a
97            // stray zoom-out never strands the user on an unreadable speck.
98            min_zoom: 0.25,
99            max_zoom: 4.0,
100            pan_on_drag: true,
101            zoom_on_scroll: true,
102            pan_on_scroll: true,
103            nodes_draggable: true,
104            drag_threshold: 0.0,
105            connection_radius: 28.0,
106            fit_view_padding: 0.12,
107        }
108    }
109}
110
111/// The non-generic heart of a flow, shared through context with every child
112/// (layers, handles, `Background`, `Controls`, `MiniMap`, and user
113/// components). All fields are `Copy` handles to reactive state.
114#[derive(Clone, Copy)]
115pub struct FlowCore {
116    /// Unique per-flow-instance id, used to namespace SVG defs.
117    pub iid: usize,
118    pub viewport: Signal<Viewport>,
119    /// Container rect in client (page) coordinates.
120    pub container: Signal<Rect>,
121    pub interaction: Signal<Interaction>,
122    pub connection: Signal<Option<ConnectionState>>,
123    pub handles: Signal<HashMap<HandleKey, HandleGeom>>,
124    pub edges: Signal<Vec<Edge>>,
125    /// Geometry snapshot of all nodes, derived from the node list.
126    pub geoms: Memo<Vec<NodeGeom>>,
127    pub config: Signal<FlowConfig>,
128    pub(crate) drag: Signal<DragState>,
129    pub(crate) epoch: Signal<u64>,
130    /// Key of the current snap target — a narrow memo so handles don't
131    /// re-render on every connection cursor move.
132    pub(crate) snap_key: Memo<Option<HandleKey>>,
133    /// The handle a connection is being dragged from, if any.
134    pub(crate) connect_from: Memo<Option<HandleKey>>,
135    /// Type-erased "deselect all nodes", so non-generic components (edges,
136    /// pane) can clear node selection.
137    pub(crate) deselect_nodes: Callback<()>,
138    /// Screen-space bands reserved by overlay panels (minimap, controls…),
139    /// keyed per overlay instance: fit-view keeps the graph clear of them.
140    pub(crate) overlay_insets: Signal<HashMap<usize, (Side, f64)>>,
141    /// Measured node sizes awaiting a batched write into `nodes`. Only ever
142    /// peeked/written, never subscribed to: per-node resize events land here
143    /// so N nodes mounting costs one re-render wave instead of N (which made
144    /// mounting quadratic).
145    pub(crate) pending_sizes: Signal<Vec<(Id, crate::types::Size)>>,
146    /// Whether a size flush is already scheduled for this frame.
147    pub(crate) size_flush_queued: Signal<bool>,
148    /// Handle registrations/removals awaiting a batched write into `handles`
149    /// (`None` = remove). Same coalescing rationale as `pending_sizes`: every
150    /// handle registers in its own effect, and letting each registration
151    /// re-render the edge layer made mounting N connected nodes O(N²).
152    pub(crate) pending_handles: Signal<Vec<(HandleKey, Option<HandleGeom>)>>,
153    /// Whether a handle flush is already scheduled for this frame.
154    pub(crate) handle_flush_queued: Signal<bool>,
155    /// Fired when a connection drag leaves a handle (the gesture starting,
156    /// not completing). Stored here because the gesture starts inside
157    /// [`crate::Handle`], which only has the core.
158    pub(crate) on_connect_start: Option<EventHandler<HandleKey>>,
159    /// The application's say over which connections may complete: snap
160    /// targets that fail it are never offered, and a release on one adds
161    /// nothing.
162    pub(crate) valid_connection: Option<Callback<crate::types::Connection, bool>>,
163}
164
165impl PartialEq for FlowCore {
166    fn eq(&self, other: &Self) -> bool {
167        self.iid == other.iid
168    }
169}
170
171/// Access the surrounding flow's state from any component rendered inside a
172/// [`crate::Flow`] (custom nodes, edges, controls, overlays…).
173pub fn use_flow() -> FlowCore {
174    use_context::<FlowCore>()
175}
176
177static NEXT_OVERLAY_KEY: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
178
179/// Reserve a screen-space band of `thickness` pixels along `side` of the
180/// container, from inside an overlay component (the built-in [`crate::Controls`]
181/// and [`crate::MiniMap`] do this). Fit-view centers the graph in the
182/// remaining area so nodes don't land underneath overlay panels.
183pub fn use_overlay_inset(side: Side, thickness: f64) {
184    let core = use_context::<FlowCore>();
185    let key = use_hook(|| NEXT_OVERLAY_KEY.fetch_add(1, std::sync::atomic::Ordering::Relaxed));
186    let mut insets = core.overlay_insets;
187    if insets.peek().get(&key) != Some(&(side, thickness)) {
188        insets.write().insert(key, (side, thickness));
189    }
190    use_drop(move || {
191        core.overlay_insets.clone().write().remove(&key);
192    });
193}
194
195impl FlowCore {
196    /// Run work for this canvas and cancel it when the canvas unmounts.
197    pub(crate) fn spawn(&self, future: impl Future<Output = ()> + 'static) -> dioxus::core::Task {
198        dioxus::core::Runtime::current().in_scope(self.viewport.origin_scope(), || spawn(future))
199    }
200
201    /// Queue a handle registration (`Some(geom)`) or removal (`None`) for a
202    /// batched write into the `handles` registry at the end of the frame.
203    pub(crate) fn queue_handle_write(&self, key: HandleKey, geom: Option<HandleGeom>) {
204        self.pending_handles.clone().write().push((key, geom));
205        let mut queued = self.handle_flush_queued;
206        if *queued.peek() {
207            return;
208        }
209        queued.set(true);
210        let core = *self;
211        // Owned by the canvas, so removing the enqueuing handle cannot
212        // cancel the flush, and unmounting the canvas cancels it safely.
213        self.spawn(async move {
214            crate::anim::sleep_ms(0).await;
215            core.handle_flush_queued.clone().set(false);
216            let pending = std::mem::take(&mut *core.pending_handles.clone().write());
217            if pending.is_empty() {
218                return;
219            }
220            let mut handles = core.handles;
221            let changed = {
222                let current = handles.peek();
223                pending.iter().any(|(key, geom)| match geom {
224                    Some(geom) => current.get(key) != Some(geom),
225                    None => current.contains_key(key),
226                })
227            };
228            if !changed {
229                return;
230            }
231            let mut current = handles.write();
232            for (key, geom) in pending {
233                match geom {
234                    Some(geom) => {
235                        current.insert(key, geom);
236                    }
237                    None => {
238                        current.remove(&key);
239                    }
240                }
241            }
242        });
243    }
244
245    /// Claim the pointer for an application-level gesture that started on
246    /// content inside the canvas, so the pane neither pans nor reports a pane
247    /// click for this press. Call from a `pointerdown` handler (which runs
248    /// before the pane's, while the event bubbles); release the claim with
249    /// [`release_pointer`](Self::release_pointer) when the gesture ends —
250    /// though a `pointerup` reaching the pane releases it too.
251    ///
252    /// Returns `false` when some other gesture already owns the pointer.
253    pub fn claim_pointer(&self) -> bool {
254        let mut interaction = self.interaction;
255        if *interaction.peek() != Interaction::None {
256            return false;
257        }
258        interaction.set(Interaction::Pressed);
259        true
260    }
261
262    /// Release a claim taken with [`claim_pointer`](Self::claim_pointer).
263    pub fn release_pointer(&self) {
264        let mut interaction = self.interaction;
265        if *interaction.peek() == Interaction::Pressed {
266            interaction.set(Interaction::None);
267        }
268    }
269
270    /// Begin a canvas pan from an application handler — e.g. a press on an
271    /// edge that selects it and then lets the canvas pan underneath. The
272    /// press was on content, so the release does not count as a pane click.
273    ///
274    /// `client` is the press position in client (page) coordinates.
275    pub fn begin_pan(&self, pointer_id: i32, client: Point) -> bool {
276        let mut interaction = self.interaction;
277        if *interaction.peek() != Interaction::None {
278            return false;
279        }
280        self.cancel_animations();
281        {
282            let mut drag = self.drag;
283            let mut state = drag.write();
284            *state = DragState {
285                pointer_id: Some(pointer_id),
286                origin_client: client,
287                last_client: client,
288                moved: false,
289                suppress_click: true,
290                grabs: Vec::new(),
291            };
292        }
293        interaction.set(Interaction::Pan);
294        true
295    }
296
297    /// Convert client (page) coordinates to flow coordinates.
298    pub fn client_to_flow(&self, client: Point) -> Point {
299        let rect = *self.container.peek();
300        self.viewport.peek().screen_to_flow(client - rect.origin())
301    }
302
303    /// Convert flow coordinates to client (page) coordinates.
304    pub fn flow_to_client(&self, flow: Point) -> Point {
305        let rect = *self.container.peek();
306        self.viewport.peek().flow_to_screen(flow) + rect.origin()
307    }
308
309    /// Cancel any in-flight animation.
310    pub fn cancel_animations(&self) {
311        bump_epoch(self.epoch);
312    }
313
314    /// Bounding box of all nodes in flow coordinates, if any.
315    pub fn nodes_bounds(&self) -> Option<Rect> {
316        let geoms = self.geoms.peek();
317        let mut iter = geoms.iter();
318        let first = iter.next()?.rect;
319        Some(iter.fold(first, |acc, geom| acc.union(&geom.rect)))
320    }
321
322    /// Animate (or jump, with `duration_ms == 0`) to the given viewport.
323    pub fn set_viewport(&self, target: Viewport, duration_ms: u64) {
324        let mut viewport = self.viewport;
325        if duration_ms == 0 {
326            self.cancel_animations();
327            viewport.set(target);
328            return;
329        }
330        let from = *viewport.peek();
331        tween(self.epoch, duration_ms, move |t| {
332            viewport.set(from.lerp(&target, t));
333        });
334    }
335
336    /// Zoom by `factor` keeping `anchor_client` (client coordinates, defaults
337    /// to the container center) stationary.
338    pub fn zoom_by(&self, factor: f64, anchor_client: Option<Point>, duration_ms: u64) {
339        let config = *self.config.peek();
340        let rect = *self.container.peek();
341        let vp = *self.viewport.peek();
342        let anchor = anchor_client
343            .map(|c| c - rect.origin())
344            .unwrap_or_else(|| Point::new(rect.width / 2.0, rect.height / 2.0));
345        let target = vp.zoom_about(vp.zoom * factor, anchor, config.min_zoom, config.max_zoom);
346        self.set_viewport(target, duration_ms);
347    }
348
349    pub fn zoom_in(&self, duration_ms: u64) {
350        self.zoom_by(1.25, None, duration_ms);
351    }
352
353    pub fn zoom_out(&self, duration_ms: u64) {
354        self.zoom_by(0.8, None, duration_ms);
355    }
356
357    /// Fit the given flow-space bounds into the container.
358    pub fn fit_bounds(&self, bounds: Rect, padding: f64, duration_ms: u64) {
359        if let Some(target) = fit_viewport(self, bounds, padding) {
360            self.set_viewport(target, duration_ms);
361        }
362    }
363
364    /// Fit all nodes into view.
365    pub fn fit_view(&self, duration_ms: u64) {
366        let padding = self.config.peek().fit_view_padding;
367        if let Some(bounds) = self.nodes_bounds() {
368            self.fit_bounds(bounds, padding, duration_ms);
369        }
370    }
371
372    /// Center the given flow point in the container, keeping the zoom.
373    pub fn center_on(&self, flow: Point, duration_ms: u64) {
374        let rect = *self.container.peek();
375        let zoom = self.viewport.peek().zoom;
376        let target = Viewport::new(
377            rect.width / 2.0 - flow.x * zoom,
378            rect.height / 2.0 - flow.y * zoom,
379            zoom,
380        );
381        self.set_viewport(target, duration_ms);
382    }
383
384    /// Resolve the anchor point and side of an edge endpoint on `geom`,
385    /// preferring a registered handle and falling back to the node's default
386    /// side for that kind. The final `bool` says whether a real handle was
387    /// found (so callers can offset the path to the handle's rim).
388    pub(crate) fn resolve_anchor(
389        &self,
390        handles: &HashMap<HandleKey, HandleGeom>,
391        geom: &NodeGeom,
392        kind: HandleKind,
393        handle_id: &Option<Id>,
394    ) -> (Point, Side, bool) {
395        let key = HandleKey {
396            node: geom.id.clone(),
397            kind,
398            id: handle_id.clone().unwrap_or_default(),
399        };
400        anchor_from_geom(handles.get(&key), geom, kind)
401    }
402
403    /// Resolve a registered handle key to its anchor point and side.
404    pub(crate) fn anchor_of(&self, key: &HandleKey) -> Option<(Point, Side)> {
405        let handles = self.handles.peek();
406        let geoms = self.geoms.peek();
407        let geom = geoms.iter().find(|geom| geom.id == key.node)?;
408        let id = (!key.id.is_empty()).then(|| key.id.clone());
409        let (point, side, _) = self.resolve_anchor(&handles, geom, key.kind, &id);
410        Some((point, side))
411    }
412
413    /// Find the closest compatible handle within the snap radius of `cursor`
414    /// (flow coordinates).
415    pub(crate) fn find_snap(&self, from: &HandleKey, cursor: Point) -> Option<SnapTarget> {
416        let radius = self.config.peek().connection_radius / self.viewport.peek().zoom.max(1e-6);
417        let handles = self.handles.peek();
418        let geoms = self.geoms.peek();
419        let geom_by_id: HashMap<&str, &NodeGeom> =
420            geoms.iter().map(|geom| (geom.id.as_str(), geom)).collect();
421
422        let mut best: Option<(f64, SnapTarget)> = None;
423        for (key, hg) in handles.iter() {
424            if key.kind == from.kind || key.node == from.node {
425                continue;
426            }
427            let Some(geom) = geom_by_id.get(key.node.as_str()) else {
428                continue;
429            };
430            // A target the application would refuse is never offered: a snap
431            // that highlights and then does nothing on release is a lie.
432            if let Some(valid) = &self.valid_connection {
433                if !valid.call(orient_connection(from, key)) {
434                    continue;
435                }
436            }
437            let point = side_point(&geom.rect, hg.side, hg.offset);
438            let d2 = point.distance_sq(cursor);
439            if d2 <= radius * radius && best.as_ref().map(|(bd, _)| d2 < *bd).unwrap_or(true) {
440                best = Some((
441                    d2,
442                    SnapTarget {
443                        key: key.clone(),
444                        point,
445                        side: hg.side,
446                    },
447                ));
448            }
449        }
450        best.map(|(_, target)| target)
451    }
452}
453
454/// Anchor point/side for an edge endpoint: the handle's position when one is
455/// registered, otherwise the center of the node's default side for `kind`.
456/// The `bool` reports whether a real handle was found.
457pub(crate) fn anchor_from_geom(
458    handle: Option<&HandleGeom>,
459    geom: &NodeGeom,
460    kind: HandleKind,
461) -> (Point, Side, bool) {
462    if let Some(hg) = handle {
463        return (side_point(&geom.rect, hg.side, hg.offset), hg.side, true);
464    }
465    let side = match kind {
466        HandleKind::Source => geom.source_side,
467        HandleKind::Target => geom.target_side,
468    };
469    (side_point(&geom.rect, side, 0.5), side, false)
470}
471
472/// Orient a completed connection gesture into a `source -> target`
473/// [`crate::Connection`], regardless of which end the drag started from.
474pub(crate) fn orient_connection(from: &HandleKey, to: &HandleKey) -> crate::types::Connection {
475    let (source, target) = match from.kind {
476        HandleKind::Source => (from, to),
477        HandleKind::Target => (to, from),
478    };
479    crate::types::Connection {
480        source: source.node.clone(),
481        target: target.node.clone(),
482        source_handle: (!source.id.is_empty()).then(|| source.id.clone()),
483        target_handle: (!target.id.is_empty()).then(|| target.id.clone()),
484    }
485}
486
487/// The typed API attached to a [`FlowHandle`] once the flow mounts.
488pub struct FlowApi<T: 'static> {
489    pub core: FlowCore,
490    pub nodes: Signal<Vec<crate::types::Node<T>>>,
491}
492
493impl<T> Clone for FlowApi<T> {
494    fn clone(&self) -> Self {
495        *self
496    }
497}
498impl<T> Copy for FlowApi<T> {}
499
500/// A handle for controlling a [`crate::Flow`] from the component that owns it.
501///
502/// ```ignore
503/// let flow = use_flow_handle();
504/// rsx! {
505///     button { onclick: move |_| flow.auto_layout(&LayoutOptions::default()), "Layout" }
506///     Flow { nodes, edges, handle: flow }
507/// }
508/// ```
509pub struct FlowHandle<T: 'static = ()> {
510    pub(crate) inner: Signal<Option<FlowApi<T>>>,
511}
512
513impl<T> Clone for FlowHandle<T> {
514    fn clone(&self) -> Self {
515        *self
516    }
517}
518impl<T> Copy for FlowHandle<T> {}
519
520impl<T> PartialEq for FlowHandle<T> {
521    fn eq(&self, _other: &Self) -> bool {
522        true
523    }
524}
525
526/// Create a [`FlowHandle`] to pass to a [`crate::Flow`]'s `handle` prop.
527pub fn use_flow_handle<T: 'static>() -> FlowHandle<T> {
528    FlowHandle {
529        inner: use_signal(|| None),
530    }
531}
532
533impl<T: Clone + PartialEq + 'static> FlowHandle<T> {
534    fn api(&self) -> Option<FlowApi<T>> {
535        *self.inner.peek()
536    }
537
538    fn with_api<R>(&self, action: impl FnOnce(FlowApi<T>) -> R) -> Option<R> {
539        let api = self.api()?;
540        Some(
541            dioxus::core::Runtime::current()
542                .in_scope(api.core.viewport.origin_scope(), || action(api)),
543        )
544    }
545
546    /// The flow's shared core, once mounted.
547    pub fn core(&self) -> Option<FlowCore> {
548        self.api().map(|api| api.core)
549    }
550
551    /// Current viewport (non-reactive read).
552    pub fn viewport(&self) -> Option<Viewport> {
553        self.with_api(|api| *api.core.viewport.peek())
554    }
555
556    pub fn set_viewport(&self, viewport: Viewport, duration_ms: u64) {
557        self.with_api(|api| api.core.set_viewport(viewport, duration_ms));
558    }
559
560    pub fn fit_view(&self, duration_ms: u64) {
561        self.with_api(|api| api.core.fit_view(duration_ms));
562    }
563
564    pub fn zoom_in(&self, duration_ms: u64) {
565        self.with_api(|api| api.core.zoom_in(duration_ms));
566    }
567
568    pub fn zoom_out(&self, duration_ms: u64) {
569        self.with_api(|api| api.core.zoom_out(duration_ms));
570    }
571
572    /// Convert client (page) coordinates to flow coordinates, e.g. for
573    /// placing a node at a click position.
574    pub fn client_to_flow(&self, client: Point) -> Option<Point> {
575        self.with_api(|api| api.core.client_to_flow(client))
576    }
577
578    /// Delete the selected nodes (with their edges) and selected edges — the
579    /// same cascade the Delete key performs by default. Call this from an
580    /// `on_delete` handler after confirming or snapshotting for undo.
581    pub fn delete_selected(&self) {
582        self.with_api(|api| crate::flow::delete_selected(api.nodes, api.core.edges));
583    }
584
585    /// Re-layout the graph with animated node movement, then fit it into
586    /// view. Handle sides follow the layout direction when
587    /// `opts.update_handle_sides` is set.
588    pub fn auto_layout(&self, opts: &LayoutOptions) {
589        self.with_api(|api| api.auto_layout(opts));
590    }
591}
592
593impl<T: Clone + PartialEq + 'static> FlowApi<T> {
594    fn auto_layout(&self, opts: &LayoutOptions) {
595        let mut nodes = self.nodes;
596        let core = self.core;
597
598        let layout_nodes: Vec<LayoutNode> = nodes
599            .peek()
600            .iter()
601            .map(|node| LayoutNode {
602                id: node.id.clone(),
603                size: node.rect().size(),
604            })
605            .collect();
606        let edge_pairs: Vec<(Id, Id)> = core
607            .edges
608            .peek()
609            .iter()
610            .map(|edge| (edge.source.clone(), edge.target.clone()))
611            .collect();
612        let targets = compute_layout(&layout_nodes, &edge_pairs, opts);
613
614        if opts.update_handle_sides {
615            let (target_side, source_side) = opts.direction.handle_sides();
616            nodes.with_mut(|nodes| {
617                for node in nodes.iter_mut() {
618                    node.target_side = target_side;
619                    node.source_side = source_side;
620                }
621            });
622        }
623
624        let starts: HashMap<Id, Point> = nodes
625            .peek()
626            .iter()
627            .map(|node| (node.id.clone(), node.position))
628            .collect();
629
630        // Final bounds of the layout, for the parallel fit-view tween below.
631        let mut bounds: Option<Rect> = None;
632        for layout_node in &layout_nodes {
633            if let Some(pos) = targets.get(&layout_node.id) {
634                let rect = Rect::from_points(*pos, layout_node.size);
635                bounds = Some(bounds.map(|b| b.union(&rect)).unwrap_or(rect));
636            }
637        }
638
639        tween(core.epoch, 420, move |t| {
640            nodes.with_mut(|nodes| {
641                for node in nodes.iter_mut() {
642                    if let (Some(start), Some(end)) = (starts.get(&node.id), targets.get(&node.id))
643                    {
644                        node.position = start.lerp(*end, t);
645                    }
646                }
647            });
648        });
649
650        // Fit the final layout into view, in parallel with the node tween.
651        if let Some(bounds) = bounds {
652            let padding = core.config.peek().fit_view_padding;
653            fit_bounds_without_cancel(core, bounds, padding);
654        }
655    }
656}
657
658/// The viewport that fits `bounds` into the container, centered in the area
659/// left free by overlay insets (each side capped so overlays can never
660/// squeeze the fit area away entirely).
661fn fit_viewport(core: &FlowCore, bounds: Rect, padding: f64) -> Option<Viewport> {
662    let rect = *core.container.peek();
663    if rect.width <= 0.0 || rect.height <= 0.0 || (bounds.width <= 0.0 && bounds.height <= 0.0) {
664        return None;
665    }
666    let (mut left, mut right, mut top, mut bottom) = (0.0f64, 0.0f64, 0.0f64, 0.0f64);
667    for (side, thickness) in core.overlay_insets.peek().values() {
668        match side {
669            Side::Left => left = left.max(*thickness),
670            Side::Right => right = right.max(*thickness),
671            Side::Top => top = top.max(*thickness),
672            Side::Bottom => bottom = bottom.max(*thickness),
673        }
674    }
675    let cap_x = rect.width * 0.35;
676    let cap_y = rect.height * 0.35;
677    let (left, right) = (left.min(cap_x), right.min(cap_x));
678    let (top, bottom) = (top.min(cap_y), bottom.min(cap_y));
679    let free_w = rect.width - left - right;
680    let free_h = rect.height - top - bottom;
681
682    let config = *core.config.peek();
683    let zoom_x = free_w / bounds.width.max(1.0);
684    let zoom_y = free_h / bounds.height.max(1.0);
685    let zoom =
686        (zoom_x.min(zoom_y) * (1.0 - padding).max(0.05)).clamp(config.min_zoom, config.max_zoom);
687    let center = bounds.center();
688    Some(Viewport::new(
689        left + free_w / 2.0 - center.x * zoom,
690        top + free_h / 2.0 - center.y * zoom,
691        zoom,
692    ))
693}
694
695/// Like `FlowCore::fit_bounds`, but rides the same epoch as a concurrently
696/// running tween instead of cancelling it.
697fn fit_bounds_without_cancel(core: FlowCore, bounds: Rect, padding: f64) {
698    let Some(target) = fit_viewport(&core, bounds, padding) else {
699        return;
700    };
701    let mut viewport = core.viewport;
702    let from = *viewport.peek();
703    let epoch = core.epoch;
704    let my_epoch = *epoch.peek();
705    spawn(async move {
706        let start = web_time::Instant::now();
707        loop {
708            crate::anim::sleep_ms(16).await;
709            if *epoch.peek() != my_epoch {
710                return;
711            }
712            let t = (start.elapsed().as_secs_f64() * 1000.0 / 420.0).min(1.0);
713            viewport.set(from.lerp(&target, crate::anim::ease_in_out_cubic(t)));
714            if t >= 1.0 {
715                return;
716            }
717        }
718    });
719}