Skip to main content

azul_layout/managers/
mod.rs

1//! Manager types responsible for stateful input and UI concerns.
2//!
3//! This module collects managers for accessibility, clipboard, drag-and-drop,
4//! focus/cursor, gestures, hover, scroll state, selection, text editing,
5//! text input, undo/redo, and virtual views. These managers are consumed
6//! primarily by `layout/src/window.rs` and `layout/src/event_determination.rs`.
7//!
8//! # `NodeId` staleness — read this before adding a manager
9//!
10//! A `NodeId` is an INDEX into the current DOM arena, not a stable identity.
11//! Every DOM rebuild (virtual-view re-invocation, window resize, route switch,
12//! any `regenerate_layout`) renumbers them. Reconciliation
13//! (`azul_core::diff::reconcile_dom`) tells us how: `node_moves` maps every
14//! MATCHED old `NodeId` to its new one. An old `NodeId` that is absent from
15//! that map was UNMOUNTED.
16//!
17//! Any manager that keys state by `NodeId` and does not participate in that
18//! remap ends up pointing at a *live but wrong* node — deleting a preceding
19//! sibling shifts every following index down by one, so the state silently
20//! re-attaches to a different element (no dangling id, no panic, no error).
21//! Unmapped keys also leak forever.
22//!
23//! The fix is structural: every node-keyed manager implements
24//! [`NodeIdRemap`], and [`crate::window::LayoutWindow::remap_node_ids`]
25//! (exhaustively destructured, so a NEW FIELD IS A COMPILE ERROR until it is
26//! classified) drives all of them from one place.
27
28pub mod a11y;
29pub mod biometric;
30pub mod changeset;
31pub mod clipboard;
32pub mod drag_drop;
33pub mod file_drop;
34pub mod focus_cursor;
35pub mod gamepad;
36pub mod geolocation;
37pub mod gesture;
38pub mod gpu_state;
39pub mod hover;
40pub mod keyring;
41pub mod permission;
42pub mod virtual_view;
43pub mod scroll_into_view;
44pub mod scroll_state;
45pub mod selection;
46pub mod sensors;
47pub mod text_edit;
48pub mod text_input;
49pub mod undo_redo;
50
51use alloc::collections::BTreeMap;
52
53use azul_core::dom::{DomId, DomNodeId, NodeId};
54use azul_core::styled_dom::NodeHierarchyItemId;
55
56/// The result of a DOM reconciliation, from the point of view of anyone holding
57/// `NodeId`-keyed state for a single DOM.
58///
59/// Built from `azul_core::diff::DiffResult::node_moves`, which contains an entry
60/// for EVERY matched node (including nodes that kept their index). The absence
61/// of an old `NodeId` from the map therefore has a precise meaning: that node was
62/// **unmounted**. This is what makes GC possible without a second "alive" set.
63///
64/// The contract for consumers is a single rule:
65///
66/// * [`NodeIdMap::resolve`] returns `Some(new_id)` — the node survived, rewrite the key.
67/// * [`NodeIdMap::resolve`] returns `None` — the node is GONE, **drop the state**.
68///
69/// Never "keep it, just in case": a kept key is a key that now denotes a
70/// different node.
71#[derive(Debug, Clone, Default, PartialEq, Eq)]
72pub struct NodeIdMap {
73    moves: BTreeMap<NodeId, NodeId>,
74}
75
76impl NodeIdMap {
77    /// Build from reconciliation output (`DiffResult::node_moves`).
78    #[must_use]
79    pub fn from_node_moves(node_moves: &[azul_core::diff::NodeMove]) -> Self {
80        Self {
81            moves: node_moves
82                .iter()
83                .map(|m| (m.old_node_id, m.new_node_id))
84                .collect(),
85        }
86    }
87
88    /// Build from raw `(old, new)` pairs — used by tests and by callers that
89    /// already computed a migration map.
90    #[must_use]
91    pub fn from_pairs<I: IntoIterator<Item = (NodeId, NodeId)>>(pairs: I) -> Self {
92        Self {
93            moves: pairs.into_iter().collect(),
94        }
95    }
96
97    /// `Some(new_id)` if the node survived the rebuild, `None` if it was unmounted.
98    #[must_use]
99    pub fn resolve(&self, old: NodeId) -> Option<NodeId> {
100        self.moves.get(&old).copied()
101    }
102
103    /// `true` if `old` no longer exists in the new DOM.
104    #[must_use]
105    pub fn is_unmounted(&self, old: NodeId) -> bool {
106        !self.moves.contains_key(&old)
107    }
108
109    /// Resolve a full `DomNodeId`. Ids belonging to a *different* DOM are passed
110    /// through untouched (this reconciliation says nothing about them).
111    #[must_use]
112    pub fn resolve_dom_node_id(&self, dom: DomId, id: DomNodeId) -> Option<DomNodeId> {
113        if id.dom != dom {
114            return Some(id);
115        }
116        let old = id.node.into_crate_internal()?;
117        let new = self.resolve(old)?;
118        Some(DomNodeId {
119            dom,
120            node: NodeHierarchyItemId::from_crate_internal(Some(new)),
121        })
122    }
123
124    /// The raw old→new map, for `azul_core` APIs that take a `BTreeMap`
125    /// (`DragContext::remap_node_ids`, `MultiCursorState::remap_node_ids`).
126    #[must_use]
127    pub const fn as_btree_map(&self) -> &BTreeMap<NodeId, NodeId> {
128        &self.moves
129    }
130
131    /// No matched nodes at all (everything was unmounted / the DOM is brand new).
132    #[must_use]
133    pub fn is_empty(&self) -> bool {
134        self.moves.is_empty()
135    }
136}
137
138/// Implemented by EVERY manager (or cache) that keys state by `NodeId`.
139///
140/// One method on purpose: remapping and GC are the same pass, so it is
141/// impossible to do one and forget the other. Implementors MUST, for state
142/// belonging to `dom`:
143///
144/// 1. rewrite each key/field `old` to `map.resolve(old)`, and
145/// 2. **drop** the state whenever `resolve` returns `None` (unmounted node).
146///
147/// State belonging to any *other* `DomId` must be left alone.
148pub trait NodeIdRemap {
149    /// Rewrite all `NodeId`s for `dom` and drop state for unmounted nodes.
150    fn remap_node_ids(&mut self, dom: DomId, map: &NodeIdMap);
151}
152
153/// Remap the keys of a `BTreeMap<NodeId, V>` in place, dropping unmounted nodes.
154pub(crate) fn remap_keys<V>(map: &mut BTreeMap<NodeId, V>, node_map: &NodeIdMap) {
155    let old = core::mem::take(map);
156    for (old_id, v) in old {
157        if let Some(new_id) = node_map.resolve(old_id) {
158            map.insert(new_id, v);
159        }
160    }
161}
162
163/// Remap the keys of a `BTreeMap<(DomId, NodeId), V>` in place: entries for
164/// `dom` are rewritten (or dropped if unmounted), entries for other DOMs are
165/// left untouched.
166pub(crate) fn remap_dom_keys<V>(
167    map: &mut BTreeMap<(DomId, NodeId), V>,
168    dom: DomId,
169    node_map: &NodeIdMap,
170) {
171    let old = core::mem::take(map);
172    for ((d, old_id), v) in old {
173        if d != dom {
174            map.insert((d, old_id), v);
175        } else if let Some(new_id) = node_map.resolve(old_id) {
176            map.insert((d, new_id), v);
177        }
178    }
179}
180
181// ============================================================================
182// THE PRECEDING-SIBLING TEST
183// ============================================================================
184//
185// These tests encode the failure mode that motivated `NodeIdRemap`. They do NOT
186// assert "no panic" — an unremapped manager never panics, that is exactly what
187// made this bug survive. They assert LOGICAL IDENTITY: after the rebuild, every
188// manager's state must still describe the SAME ELEMENT it described before.
189//
190// Scenario (one DOM, four nodes):
191//
192//     before:  0=root  1=A   2=B   3=C
193//     delete A
194//     after:   0=root        1=B   2=C          map = {0→0, 2→1, 3→2}
195//
196// State is seeded on B(2) and C(3) with DISTINGUISHABLE payloads, and on the
197// doomed A(1). A manager that skips the remap keeps C's state at key 3 and
198// leaves B's state at key 2 — but index 2 now denotes C. So the state is not
199// dangling, it is MISATTACHED: "give me C's state" silently answers with B's.
200// Asserting `state_at(2) == C_payload` is what catches that; a null/panic check
201// does not.
202#[cfg(all(test, feature = "std"))]
203mod preceding_sibling_remap_tests {
204    use alloc::collections::BTreeMap;
205
206    use azul_core::{
207        dom::{DomId, DomNodeId, NodeId},
208        drag::{DragContext, DragData},
209        geom::LogicalPosition,
210        hit_test::{FullHitTest, HitTest, HitTestItem},
211        selection::{CursorAffinity, GraphemeClusterId, MultiCursorState, TextCursor},
212        styled_dom::NodeHierarchyItemId,
213        task::{Instant, SystemTick},
214    };
215
216    use super::{
217        changeset::{TextChangeset, TextOpInsertText, TextOperation},
218        focus_cursor::FocusManager,
219        gesture::GestureAndDragManager,
220        gpu_state::GpuStateManager,
221        hover::{HoverManager, InputPointId},
222        scroll_state::ScrollManager,
223        text_edit::TextEditManager,
224        text_input::{TextInputManager, TextInputSource},
225        undo_redo::{NodeStateSnapshot, UndoRedoManager},
226        virtual_view::VirtualViewManager,
227        NodeIdMap, NodeIdRemap,
228    };
229
230    const ROOT: DomId = DomId { inner: 0 };
231    /// The node that gets deleted.
232    const A: NodeId = NodeId::new(1);
233    /// Surviving sibling, index 2 → 1.
234    const B_OLD: NodeId = NodeId::new(2);
235    const B_NEW: NodeId = NodeId::new(1);
236    /// Surviving sibling, index 3 → 2.
237    const C_OLD: NodeId = NodeId::new(3);
238    const C_NEW: NodeId = NodeId::new(2);
239
240    /// Exactly what `reconcile_dom` produces when the preceding sibling A is
241    /// deleted: every MATCHED node, with A absent (= unmounted).
242    fn delete_a() -> NodeIdMap {
243        NodeIdMap::from_pairs([
244            (NodeId::new(0), NodeId::new(0)),
245            (B_OLD, B_NEW),
246            (C_OLD, C_NEW),
247        ])
248    }
249
250    fn now() -> Instant {
251        Instant::Tick(SystemTick { tick_counter: 0 })
252    }
253
254    fn dom_node(node: NodeId) -> DomNodeId {
255        DomNodeId {
256            dom: ROOT,
257            node: NodeHierarchyItemId::from_crate_internal(Some(node)),
258        }
259    }
260
261    // ---------------------------------------------------------------- scroll
262
263    #[test]
264    fn scroll_offsets_follow_their_node_across_a_preceding_sibling_delete() {
265        let mut m = ScrollManager::new();
266        // Distinguishable payloads: y = 10 for A, 20 for B, 30 for C.
267        m.set_scroll_position_unclamped(ROOT, A, LogicalPosition::new(0.0, 10.0), now());
268        m.set_scroll_position_unclamped(ROOT, B_OLD, LogicalPosition::new(0.0, 20.0), now());
269        m.set_scroll_position_unclamped(ROOT, C_OLD, LogicalPosition::new(0.0, 30.0), now());
270
271        m.remap_node_ids(ROOT, &delete_a());
272
273        // C is now node 2 and MUST still have C's offset (30) — not B's (20),
274        // which is what an unremapped manager would answer here.
275        assert_eq!(
276            m.get_scroll_state(ROOT, C_NEW).map(|s| s.current_offset.y),
277            Some(30.0),
278            "C's scroll offset must follow C to its new NodeId"
279        );
280        assert_eq!(
281            m.get_scroll_state(ROOT, B_NEW).map(|s| s.current_offset.y),
282            Some(20.0),
283            "B's scroll offset must follow B to its new NodeId"
284        );
285        // GC: the deleted node's state must not linger. NodeId(3) no longer exists.
286        assert!(
287            m.get_scroll_state(ROOT, NodeId::new(3)).is_none(),
288            "no state may remain at a NodeId that no longer exists"
289        );
290        assert_eq!(m.get_scroll_states_for_dom(ROOT).len(), 2, "A's state must be GC'd");
291    }
292
293    // ------------------------------------------------------------- undo/redo
294
295    fn undo_op(changeset_id: usize, node: NodeId, text: &str) -> super::undo_redo::UndoableOperation {
296        super::undo_redo::UndoableOperation {
297            changeset: TextChangeset {
298                id: changeset_id,
299                target: dom_node(node),
300                operation: TextOperation::InsertText(TextOpInsertText {
301                    text: text.into(),
302                    position: azul_core::window::CursorPosition::Uninitialized,
303                    new_cursor: azul_core::window::CursorPosition::Uninitialized,
304                }),
305                timestamp: now(),
306            },
307            pre_state: NodeStateSnapshot {
308                node_id: node,
309                text_content: text.into(),
310                cursor_position: None.into(),
311                selection_range: None.into(),
312                timestamp: now(),
313            },
314        }
315    }
316
317    #[test]
318    fn undo_history_stays_attached_to_the_same_element() {
319        let mut m = UndoRedoManager::new();
320        let a = undo_op(1, A, "typed-into-A");
321        let b = undo_op(2, B_OLD, "typed-into-B");
322        let c = undo_op(3, C_OLD, "typed-into-C");
323        m.record_operation(a.changeset.clone(), a.pre_state.clone());
324        m.record_operation(b.changeset.clone(), b.pre_state.clone());
325        m.record_operation(c.changeset.clone(), c.pre_state.clone());
326
327        m.remap_node_ids(ROOT, &delete_a());
328
329        // THE bug: undoing "on C" must revert C's edit, not B's.
330        let undo_on_c = m.peek_undo(C_NEW).expect("C must still have undo history");
331        assert_eq!(
332            undo_on_c.pre_state.text_content.as_str(),
333            "typed-into-C",
334            "undo on C must revert C's edit — an unremapped Vec re-attaches B's history here"
335        );
336        let undo_on_b = m.peek_undo(B_NEW).expect("B must still have undo history");
337        assert_eq!(undo_on_b.pre_state.text_content.as_str(), "typed-into-B");
338
339        // The embedded NodeIds must be rewritten too, or the *replay* targets the wrong node.
340        assert_eq!(
341            undo_on_c.changeset.target.node.into_crate_internal(),
342            Some(C_NEW)
343        );
344        assert_eq!(undo_on_c.pre_state.node_id, C_NEW);
345
346        // GC: A is gone, its history must be gone.
347        assert_eq!(m.node_stacks.len(), 2, "the deleted node's undo stack must be GC'd");
348        assert!(!m.can_undo(NodeId::new(3)), "no history at a NodeId that no longer exists");
349    }
350
351    // ----------------------------------------------------------- virtual view
352
353    #[test]
354    fn virtual_view_nested_doms_stay_with_their_host_node() {
355        let mut m = VirtualViewManager::new();
356        let dom_a = m.get_or_create_nested_dom_id(ROOT, A);
357        let dom_b = m.get_or_create_nested_dom_id(ROOT, B_OLD);
358        let dom_c = m.get_or_create_nested_dom_id(ROOT, C_OLD);
359        assert_ne!(dom_b, dom_c);
360
361        m.remap_node_ids(ROOT, &delete_a());
362
363        assert_eq!(
364            m.get_nested_dom_id(ROOT, C_NEW),
365            Some(dom_c),
366            "C's nested DOM must follow C — otherwise C renders B's virtual view"
367        );
368        assert_eq!(m.get_nested_dom_id(ROOT, B_NEW), Some(dom_b));
369        assert_eq!(m.debug_counts(), 2, "the deleted view's state must be GC'd");
370        assert!(!m.all_view_keys().iter().any(|(_, n)| *n == C_OLD));
371        assert_ne!(m.get_nested_dom_id(ROOT, B_NEW), Some(dom_a));
372    }
373
374    // -------------------------------------------------------------- gpu state
375
376    #[test]
377    fn gpu_transform_keys_stay_with_their_node() {
378        use azul_core::resources::{OpacityKey, TransformKey};
379        let mut m = GpuStateManager::default();
380        {
381            let cache = m.get_or_create_cache(ROOT);
382            cache.opacity_keys.insert(A, OpacityKey::unique());
383            cache.current_opacity_values.insert(A, 0.1);
384            cache.current_opacity_values.insert(B_OLD, 0.2);
385            cache.current_opacity_values.insert(C_OLD, 0.3);
386            cache.css_transform_keys.insert(C_OLD, TransformKey::unique());
387        }
388        let c_key = m.get_cache(ROOT).unwrap().css_transform_keys[&C_OLD];
389
390        m.remap_node_ids(ROOT, &delete_a());
391
392        let cache = m.get_cache(ROOT).unwrap();
393        assert_eq!(
394            cache.current_opacity_values.get(&C_NEW).copied(),
395            Some(0.3),
396            "C's opacity must follow C, not be inherited from B"
397        );
398        assert_eq!(cache.current_opacity_values.get(&B_NEW).copied(), Some(0.2));
399        assert_eq!(
400            cache.css_transform_keys.get(&C_NEW).copied(),
401            Some(c_key),
402            "C's GPU transform key must follow C"
403        );
404        assert!(cache.opacity_keys.is_empty(), "the deleted node's GPU keys must be GC'd");
405        assert_eq!(cache.current_opacity_values.len(), 2);
406    }
407
408    // ------------------------------------------------------------------ focus
409
410    #[test]
411    fn focus_follows_its_node_and_is_cleared_when_the_node_dies() {
412        let mut m = FocusManager::new();
413        m.set_focused_node(Some(dom_node(C_OLD)));
414        m.remap_node_ids(ROOT, &delete_a());
415        assert_eq!(
416            m.get_focused_node().and_then(|f| f.node.into_crate_internal()),
417            Some(C_NEW),
418            "focus must follow the focused element, not stay on a recycled index"
419        );
420
421        let mut m = FocusManager::new();
422        m.set_focused_node(Some(dom_node(A)));
423        m.remap_node_ids(ROOT, &delete_a());
424        assert!(
425            m.get_focused_node().is_none(),
426            "focus on an unmounted node must be cleared, never retargeted"
427        );
428    }
429
430    // -------------------------------------------------------------- text edit
431
432    #[test]
433    fn a_live_selection_stays_on_the_edited_element() {
434        let cursor = TextCursor {
435            cluster_id: GraphemeClusterId {
436                source_run: 0,
437                start_byte_in_run: 0,
438            },
439            affinity: CursorAffinity::Leading,
440        };
441        let mut m = TextEditManager::new();
442        m.multi_cursor = Some(MultiCursorState::new_with_cursor(cursor, dom_node(C_OLD), 0));
443
444        m.remap_node_ids(ROOT, &delete_a());
445
446        let mc = m.multi_cursor.as_ref().expect("the editing session survives");
447        assert_eq!(
448            mc.node_id.node.into_crate_internal(),
449            Some(C_NEW),
450            "the caret must stay in the element the user is editing"
451        );
452        assert_eq!(mc.selections.len(), 1, "surviving node keeps its selections");
453
454        // Editing a node that gets deleted ends the session (no retarget).
455        let mut m = TextEditManager::new();
456        m.multi_cursor = Some(MultiCursorState::new_with_cursor(cursor, dom_node(A), 0));
457        m.remap_node_ids(ROOT, &delete_a());
458        assert!(m.multi_cursor.is_none(), "editing an unmounted node must end the session");
459    }
460
461    // ----------------------------------------------------------------- drag
462
463    fn node_drag(node: NodeId) -> DragContext {
464        DragContext::node_drag(ROOT, node, LogicalPosition::zero(), DragData::default(), 1)
465    }
466
467    #[test]
468    fn a_live_drag_keeps_dragging_the_same_element() {
469        let mut m = GestureAndDragManager::new();
470        m.active_drag = Some(node_drag(C_OLD));
471
472        m.remap_node_ids(ROOT, &delete_a());
473
474        assert!(
475            m.is_node_dragging(ROOT, C_NEW),
476            "the dragged element must still be the dragged element after the rebuild"
477        );
478        assert!(
479            !m.is_node_dragging(ROOT, B_NEW),
480            "the drag must NOT jump onto the sibling that inherited the old index"
481        );
482
483        // Dragging a node that gets deleted cancels the drag.
484        let mut m = GestureAndDragManager::new();
485        m.active_drag = Some(node_drag(A));
486        m.remap_node_ids(ROOT, &delete_a());
487        assert!(m.get_drag_context().is_none(), "a drag whose source vanished is cancelled");
488    }
489
490    // ----------------------------------------------------------- text input
491
492    #[test]
493    fn a_pending_text_edit_is_not_applied_to_the_wrong_node() {
494        let mut m = TextInputManager::new();
495        m.record_input(dom_node(C_OLD), "x".into(), String::new(), TextInputSource::Keyboard);
496        m.remap_node_ids(ROOT, &delete_a());
497        assert_eq!(
498            m.get_pending_changeset()
499                .and_then(|p| p.node.node.into_crate_internal()),
500            Some(C_NEW),
501            "the recorded edit must apply to the node it was recorded on"
502        );
503
504        let mut m = TextInputManager::new();
505        m.record_input(dom_node(A), "x".into(), String::new(), TextInputSource::Keyboard);
506        m.remap_node_ids(ROOT, &delete_a());
507        assert!(
508            m.get_pending_changeset().is_none(),
509            "an edit recorded on an unmounted node must be dropped, not applied elsewhere"
510        );
511    }
512
513    // ---------------------------------------------------------------- hover
514
515    #[test]
516    fn hover_history_hits_follow_their_nodes() {
517        fn hit(depth: u32) -> HitTestItem {
518            HitTestItem {
519                point_in_viewport: LogicalPosition::zero(),
520                point_relative_to_item: LogicalPosition::zero(),
521                is_focusable: false,
522                is_virtual_view_hit: None,
523                hit_depth: depth,
524            }
525        }
526        let mut ht = HitTest::empty();
527        ht.regular_hit_test_nodes.insert(A, hit(1));
528        ht.regular_hit_test_nodes.insert(B_OLD, hit(2));
529        ht.regular_hit_test_nodes.insert(C_OLD, hit(3));
530        let mut full = FullHitTest::empty(None);
531        full.hovered_nodes.insert(ROOT, ht);
532
533        let mut m = HoverManager::new();
534        m.push_hit_test(InputPointId::Mouse, full);
535
536        m.remap_node_ids(ROOT, &delete_a());
537
538        let nodes = &m
539            .get_current(&InputPointId::Mouse)
540            .unwrap()
541            .hovered_nodes[&ROOT]
542            .regular_hit_test_nodes;
543        assert_eq!(
544            nodes.get(&C_NEW).map(|h| h.hit_depth),
545            Some(3),
546            "C's hit must follow C (an unremapped history hands B's hit back for C)"
547        );
548        assert_eq!(nodes.get(&B_NEW).map(|h| h.hit_depth), Some(2));
549        assert_eq!(nodes.len(), 2, "the deleted node's hit must be GC'd");
550    }
551
552    // ------------------------------------------------------ cross-DOM safety
553
554    #[test]
555    fn state_belonging_to_another_dom_is_never_touched() {
556        let other = DomId { inner: 7 };
557        let mut m = ScrollManager::new();
558        m.set_scroll_position_unclamped(other, C_OLD, LogicalPosition::new(0.0, 99.0), now());
559        m.remap_node_ids(ROOT, &delete_a());
560        assert_eq!(
561            m.get_scroll_state(other, C_OLD).map(|s| s.current_offset.y),
562            Some(99.0),
563            "a reconciliation of DOM 0 says nothing about DOM 7"
564        );
565
566        let mut vv = VirtualViewManager::new();
567        let nested = vv.get_or_create_nested_dom_id(other, C_OLD);
568        vv.remap_node_ids(ROOT, &delete_a());
569        assert_eq!(vv.get_nested_dom_id(other, C_OLD), Some(nested));
570    }
571
572    /// The map itself is the GC oracle: `node_moves` lists EVERY matched node, so
573    /// an id missing from it is unmounted (not merely "unmoved").
574    #[test]
575    fn node_id_map_semantics() {
576        let map = delete_a();
577        assert_eq!(map.resolve(NodeId::new(0)), Some(NodeId::new(0)));
578        assert_eq!(map.resolve(C_OLD), Some(C_NEW));
579        assert!(map.is_unmounted(A));
580        assert!(map.resolve(A).is_none());
581        let _unused: &BTreeMap<NodeId, NodeId> = map.as_btree_map();
582    }
583}