1pub 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
72pub struct NodeIdMap {
73 moves: BTreeMap<NodeId, NodeId>,
74}
75
76impl NodeIdMap {
77 #[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 #[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 #[must_use]
99 pub fn resolve(&self, old: NodeId) -> Option<NodeId> {
100 self.moves.get(&old).copied()
101 }
102
103 #[must_use]
105 pub fn is_unmounted(&self, old: NodeId) -> bool {
106 !self.moves.contains_key(&old)
107 }
108
109 #[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 #[must_use]
127 pub const fn as_btree_map(&self) -> &BTreeMap<NodeId, NodeId> {
128 &self.moves
129 }
130
131 #[must_use]
133 pub fn is_empty(&self) -> bool {
134 self.moves.is_empty()
135 }
136}
137
138pub trait NodeIdRemap {
149 fn remap_node_ids(&mut self, dom: DomId, map: &NodeIdMap);
151}
152
153pub(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
163pub(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#[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 const A: NodeId = NodeId::new(1);
233 const B_OLD: NodeId = NodeId::new(2);
235 const B_NEW: NodeId = NodeId::new(1);
236 const C_OLD: NodeId = NodeId::new(3);
238 const C_NEW: NodeId = NodeId::new(2);
239
240 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 #[test]
264 fn scroll_offsets_follow_their_node_across_a_preceding_sibling_delete() {
265 let mut m = ScrollManager::new();
266 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 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 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 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 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 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 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 #[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 #[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 #[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 #[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 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 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 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 #[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 #[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 #[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 #[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}