1
//! The content overlay: the ONE home for quickly-mutable content state.
2
//!
3
//! The DOM (`StyledDom`) is immutable by design — `NodeId`s stay stable, and
4
//! every fast-changing piece of content (camera frames, canvas repaints,
5
//! swapped images, in-progress text edits) lives in an overlay that readers
6
//! consult FIRST, falling back to the DOM. Before this module, that overlay
7
//! was scattered: a side map only the CPU rasterizer knew about
8
//! (`cpu_image_callback_results`), an in-place `set_node_type` DOM mutation
9
//! only some paths performed, a css-id image cache mirrored between shell and
10
//! layout, and text in `dirty_text_nodes`. Every combination that missed one
11
//! of them was a shipped bug — per BACKEND, because each of the 8 event loops
12
//! assembled the pipeline by hand.
13
//!
14
//! The rules this module enforces:
15
//!
16
//! 1. **One write chokepoint**: [`crate::window::LayoutWindow::apply_content_change`]
17
//!    is the only way content state changes. It validates, writes the overlay
18
//!    arm, journals the change, and returns the dirty tier the frame loop must
19
//!    honor. Backends never see content — they receive a tier.
20
//! 2. **One read order**: overlay first, immutable DOM second, via
21
//!    [`ResolvedContent`]. Every consumer (display-list build, IFC build,
22
//!    raster, hit-test, a11y, exports) resolves through it.
23
//! 3. **One retention clock**: [`ContentJournal`] entries are retired by frame
24
//!    age (swapchain depth), never by document size or session length.
25
//!    Journal = what the RENDERER may still need; the `UndoRedoManager`
26
//!    (user intent) is fed separately by the same chokepoint.
27

            
28
use std::collections::{BTreeMap, VecDeque};
29

            
30
use azul_core::{
31
    dom::{DomId, NodeId, NodeType},
32
    resources::{ImageRef, ImageRefHash},
33
    selection::TextCursor,
34
    styled_dom::StyledDom,
35
};
36
use azul_css::AzString;
37

            
38
use crate::managers::{NodeIdMap, NodeIdRemap};
39
use crate::text3::cache::InlineContent;
40

            
41
/// The text overlay entry: an IFC root's edited inline content, layered over
42
/// the immutable DOM's (now stale) text until the app's next generation
43
/// catches up ("optimistic state").
44
#[derive(Debug, Clone)]
45
pub struct DirtyTextNode {
46
    /// The new inline content (text + images) after editing
47
    pub content: Vec<InlineContent>,
48
    /// The new cursor position after editing
49
    pub cursor: Option<TextCursor>,
50
    /// Whether this edit requires ancestor relayout (e.g., text grew taller)
51
    pub needs_ancestor_relayout: bool,
52
}
53

            
54
/// Flatten inline content to the plain string it displays.
55
///
56
/// This is the SAME flattening every consumer (a11y, convergence GC,
57
/// exports) must share, or two of them will disagree about whether an edit
58
/// "is" committed.
59
#[must_use]
60
4184
pub fn flatten_inline_content(content: &[InlineContent]) -> String {
61
4184
    let mut result = String::new();
62
8369
    for item in content {
63
4185
        match item {
64
4185
            InlineContent::Text(text_run) => result.push_str(&text_run.text),
65
            InlineContent::Space(_) => result.push(' '),
66
            InlineContent::LineBreak(_) => result.push('\n'),
67
            InlineContent::Tab { .. } => result.push('\t'),
68
            InlineContent::Ruby { base, .. } => {
69
                result.push_str(&flatten_inline_content(base));
70
            }
71
            InlineContent::Marker { run, .. } => result.push_str(&run.text),
72
            InlineContent::Image(_) | InlineContent::Shape(_) => {}
73
        }
74
    }
75
4184
    result
76
4184
}
77

            
78
/// How many PRESENTED frames of history the journal keeps.
79
///
80
/// A backend re-presenting a not-fully-redrawn buffer composed `k` frames
81
/// ago may still sample the previous image of a node via
82
/// [`ContentJournal::image_as_of`]; `3` covers the deepest swapchain in the
83
/// tree (triple buffering — `wl_shm` double-buffer needs 2).
84
pub const JOURNAL_RETENTION_FRAMES: u64 = 3;
85

            
86
/// A content mutation, as accepted by the chokepoint.
87
///
88
/// Constructors on `LayoutWindow` (e.g. `apply_content_change`) decide
89
/// per-variant whether the change is user-undoable; per-frame producer writes
90
/// (camera/callback frames) never are.
91
#[derive(Debug, Clone)]
92
pub enum ContentChange {
93
    /// Swap the displayed image of a node (camera / video / screenshare /
94
    /// explicit `ChangeNodeImage`). Participates in intrinsic-size tier
95
    /// detection: a different-sized image relayouts, a same-sized one repaints.
96
    Image {
97
        dom_id: DomId,
98
        node_id: NodeId,
99
        image: ImageRef,
100
    },
101
    /// A `RenderImageCallback` produced a frame for a callback-image node.
102
    /// Always paint-tier: callback frames are PAINT content — the box is
103
    /// CSS-determined, and the callback's declared image stays the layout
104
    /// authority (otherwise a producer could resize the document per frame).
105
    ImageCallbackResult {
106
        dom_id: DomId,
107
        node_id: NodeId,
108
        image: ImageRef,
109
    },
110
    /// Register (`Some`) or remove (`None`) an image under a css id
111
    /// (`background-image: url("id")`). Takes effect on the NEXT display-list
112
    /// build — the chokepoint returns the rebuild tier instead of the old
113
    /// `DoNothing`.
114
    ImageById {
115
        id: AzString,
116
        image: Option<ImageRef>,
117
    },
118
    /// Restyle a node at runtime (animation frames, `:hover`-driven writes,
119
    /// the css-override e2e op). Writes go through the retained cascade
120
    /// (`restyle_user_property` — the property cache's single write site);
121
    /// with `override_only` the node's inline vec is left alone (the
122
    /// fast animation channel). Tier: paint-only properties rebuild the DL,
123
    /// layout-affecting ones relayout — decided by
124
    /// `callbacks::css_properties_need_relayout`, so hosts cannot drift.
125
    NodeCss {
126
        dom_id: DomId,
127
        node_id: NodeId,
128
        props: Vec<azul_css::props::property::CssProperty>,
129
        override_only: bool,
130
    },
131
    /// Change a node's image mask (an attribute-slot write like css props —
132
    /// fingerprinted by reconcile, not a content-identity mutation).
133
    ImageMask {
134
        dom_id: DomId,
135
        node_id: NodeId,
136
        mask: azul_core::resources::ImageMask,
137
    },
138
}
139

            
140
/// What the frame loop must do after a content change — the ONLY thing
141
/// backends learn about content. Ordered weakest → strongest so results merge
142
/// with `max`.
143
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
144
pub enum ContentDirtyTier {
145
    /// The change was a no-op (same image re-set, unknown node).
146
    Unchanged,
147
    /// Display-list items were patched in place; repaint. Damage discovery is
148
    /// the backend diff's job — `ImageRef` identity makes patched items
149
    /// unequal to the previous frame's.
150
    Paint,
151
    /// The display list must be rebuilt (css-id images resolve at build time).
152
    RebuildDisplayList,
153
    /// Intrinsic content size changed: relayout (which also rebuilds the DL).
154
    Relayout,
155
}
156

            
157
impl ContentDirtyTier {
158
    /// The ONE mapping from content dirty tier to the event-loop result every
159
    /// host consumes. Defined here — next to the tier — so a backend cannot
160
    /// invent its own interpretation:
161
    /// - `Paint`: the DL was already patched in place; a re-render picks it up
162
    ///   (CPU: the DL diff sees the `ImageRef` identity change and damages those
163
    ///   bounds; GPU: the translator re-reads the patched DL).
164
    /// - `RebuildDisplayList`: DL regeneration + re-render.
165
    /// - `Relayout`: incremental relayout (which rebuilds the DL).
166
11
    pub const fn to_process_event_result(self) -> azul_core::events::ProcessEventResult {
167
        use azul_core::events::ProcessEventResult;
168
11
        match self {
169
            Self::Unchanged => ProcessEventResult::DoNothing,
170
1
            Self::Paint => ProcessEventResult::ShouldReRenderCurrentWindow,
171
8
            Self::RebuildDisplayList => ProcessEventResult::ShouldUpdateDisplayListCurrentWindow,
172
2
            Self::Relayout => ProcessEventResult::ShouldIncrementalRelayout,
173
        }
174
11
    }
175
}
176

            
177
/// Result of one `apply_content_change`.
178
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
179
pub struct ContentChangeResult {
180
    pub tier: ContentDirtyTier,
181
}
182

            
183
/// Identity of one overlay part — "NodeIdGen2": minted from a monotonic
184
/// per-process counter, NEVER a real `NodeId` (a previewed part has no DOM
185
/// node until the app's re-render creates one).
186
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
187
pub struct OverlayPartId(pub u64);
188

            
189
impl OverlayPartId {
190
126
    fn mint() -> Self {
191
        use std::sync::atomic::{AtomicU64, Ordering};
192
        static NEXT: AtomicU64 = AtomicU64::new(1);
193
126
        Self(NEXT.fetch_add(1, Ordering::Relaxed))
194
126
    }
195
}
196

            
197
/// A pending STRUCTURAL delta over the immutable DOM.
198
///
199
/// What a mutable DOM would have done with `insertChild` / `removeChild` /
200
/// `replaceChild` / split / merge, recorded here as a PREVIEW until the
201
/// app's re-render lands. The preview never copies content: it references
202
/// DOM nodes (by id) and pending subtrees (the changeset's own `Dom`
203
/// payloads).
204
#[derive(Debug, Clone)]
205
pub enum StructuralPreview {
206
    /// `node` renders as TWO parts split at the structural position.
207
    Split {
208
        node: NodeId,
209
        at: crate::managers::changeset::NodePosition,
210
        part_ids: [OverlayPartId; 2],
211
    },
212
    /// `second` renders MERGED into `first` (its children appended; `second`
213
    /// itself hidden).
214
    Merge { first: NodeId, second: NodeId },
215
    /// A pending subtree renders under `parent` at child `index`.
216
    Insert {
217
        parent: NodeId,
218
        index: u32,
219
        content: azul_core::dom::Dom,
220
    },
221
    /// `parent`'s children `[start, end)` render REMOVED.
222
    Remove { parent: NodeId, start: u32, end: u32 },
223
    /// `parent`'s children `[start, end)` render REPLACED by a pending
224
    /// subtree.
225
    Replace {
226
        parent: NodeId,
227
        start: u32,
228
        end: u32,
229
        content: azul_core::dom::Dom,
230
    },
231
}
232

            
233
/// One entry in the pending-structure list: the changeset it anticipates
234
/// (the commit handshake ties the two lifecycles) + the delta.
235
#[derive(Debug, Clone)]
236
pub struct PendingStructure {
237
    pub changeset_id: u64,
238
    pub preview: StructuralPreview,
239
}
240

            
241
/// One RESOLVED child in a node's adjusted child list — what a consumer
242
/// iterating "the children of X as the user should see them" receives.
243
#[derive(Debug, Clone)]
244
pub enum ResolvedChild<'a> {
245
    /// An existing DOM child, unchanged.
246
    Existing(NodeId),
247
    /// An existing DOM child that is part of a SPLIT: render only the given
248
    /// byte range of its text (`None` end = to the end).
249
    ExistingTextSlice {
250
        node: NodeId,
251
        start_byte: u32,
252
        end_byte: Option<u32>,
253
    },
254
    /// A pending (not-yet-real) subtree from a recorded insert/replace.
255
    Pending(&'a azul_core::dom::Dom),
256
}
257

            
258
/// The overlay proper. Fields are private on purpose: reads go through
259
/// [`ResolvedContent`] / the accessors below, writes only through the
260
/// chokepoint (`pub(crate)` mutators).
261
#[derive(Debug, Default)]
262
pub struct ContentOverlay {
263
    /// Node-image arm: the currently-displayed image for a node, overriding
264
    /// the immutable DOM's `NodeType::Image` content.
265
    images: BTreeMap<(DomId, NodeId), ImageRef>,
266
    /// Text arm: edited inline content per IFC root ("optimistic state"),
267
    /// overriding the immutable DOM's text. Written only through
268
    /// `LayoutWindow::update_text_cache_after_edit` (the documented single
269
    /// text mutation point, itself fed by `apply_text_changeset`); retired by
270
    /// [`Self::gc_converged_text`] when the app's regenerated DOM catches up.
271
    text: BTreeMap<(DomId, NodeId), DirtyTextNode>,
272
    /// Structural arm: pending tree deltas (split/merge/insert/remove/
273
    /// replace) previewed ahead of the app's re-render. Consumers resolve
274
    /// via [`ResolvedContent::children_for_node`] /
275
    /// [`Self::pending_structure`]; empty = the DOM is the whole truth.
276
    pending_structure: BTreeMap<DomId, Vec<PendingStructure>>,
277
}
278

            
279
impl ContentOverlay {
280
    /// The overlay's image for a node, if any. Callers wanting the full
281
    /// overlay→DOM read order use [`ResolvedContent`] instead.
282
    #[must_use]
283
47
    pub fn image_for_node(&self, dom_id: DomId, node_id: NodeId) -> Option<&ImageRef> {
284
47
        self.images.get(&(dom_id, node_id))
285
47
    }
286

            
287
    /// Iterate all image-overlay entries (renderer registration: the WR/GL
288
    /// backend walks produced callback frames to register external textures).
289
    pub fn iter_images(&self) -> impl Iterator<Item = (&(DomId, NodeId), &ImageRef)> {
290
        self.images.iter()
291
    }
292

            
293
    /// The overlay's edited text entry for an IFC root, if any.
294
    #[must_use]
295
8091
    pub fn text_for_node(&self, dom_id: DomId, node_id: NodeId) -> Option<&DirtyTextNode> {
296
8091
        self.text.get(&(dom_id, node_id))
297
8091
    }
298

            
299
    /// Iterate all text-overlay entries (a11y snapshot, diagnostics).
300
4340
    pub fn iter_text(&self) -> impl Iterator<Item = (&(DomId, NodeId), &DirtyTextNode)> {
301
4340
        self.text.iter()
302
4340
    }
303

            
304
    /// Number of text-overlay entries (manager fingerprints, tests).
305
    #[must_use]
306
    pub fn text_len(&self) -> usize {
307
        self.text.len()
308
    }
309

            
310
    /// Whether ANY text entry needs an ancestor relayout.
311
    #[must_use]
312
1881
    pub fn any_text_needs_ancestor_relayout(&self) -> bool {
313
1881
        self.text.values().any(|d| d.needs_ancestor_relayout)
314
1881
    }
315

            
316
    #[must_use]
317
    pub fn is_empty(&self) -> bool {
318
        self.images.is_empty() && self.text.is_empty() && self.pending_structure.is_empty()
319
    }
320

            
321
6
    pub(crate) fn set_image(
322
6
        &mut self,
323
6
        dom_id: DomId,
324
6
        node_id: NodeId,
325
6
        image: ImageRef,
326
6
    ) -> Option<ImageRef> {
327
6
        self.images.insert((dom_id, node_id), image)
328
6
    }
329

            
330
1902
    pub(crate) fn set_text(
331
1902
        &mut self,
332
1902
        dom_id: DomId,
333
1902
        node_id: NodeId,
334
1902
        entry: DirtyTextNode,
335
1902
    ) -> Option<DirtyTextNode> {
336
1902
        self.text.insert((dom_id, node_id), entry)
337
1902
    }
338

            
339
1971
    pub(crate) fn text_for_node_mut(
340
1971
        &mut self,
341
1971
        dom_id: DomId,
342
1971
        node_id: NodeId,
343
1971
    ) -> Option<&mut DirtyTextNode> {
344
1971
        self.text.get_mut(&(dom_id, node_id))
345
1971
    }
346

            
347
    /// The pending structural deltas of `dom` (empty slice = none).
348
    #[must_use]
349
4560
    pub fn pending_structure(&self, dom_id: DomId) -> &[PendingStructure] {
350
4560
        self.pending_structure
351
4560
            .get(&dom_id)
352
4560
            .map_or(&[], |v| v.as_slice())
353
4560
    }
354

            
355
    /// Number of pending structural deltas across all DOMs (tests).
356
    #[must_use]
357
28
    pub fn pending_structure_len(&self) -> usize {
358
28
        self.pending_structure.values().map(Vec::len).sum()
359
28
    }
360

            
361
    /// Materialize a recorded structural changeset as a PREVIEW — the
362
    /// generic entry for EVERY operation kind (the recorded delta IS the
363
    /// preview; nothing is copied except the changeset's own subtree
364
    /// payloads, which are refcounted).
365
218
    pub(crate) fn preview_structural_change(
366
218
        &mut self,
367
218
        dom_id: DomId,
368
218
        changeset: &crate::managers::changeset::DocumentChangeset,
369
218
    ) {
370
        use crate::managers::changeset::DocumentOperation as Op;
371
218
        let preview = match &changeset.operation {
372
63
            Op::SplitNode(sp) => sp.node.node.into_crate_internal().map(|node| {
373
63
                StructuralPreview::Split {
374
63
                    node,
375
63
                    at: sp.at,
376
63
                    part_ids: [OverlayPartId::mint(), OverlayPartId::mint()],
377
63
                }
378
63
            }),
379
72
            Op::MergeNodes(m) => match (
380
72
                m.first.node.into_crate_internal(),
381
72
                m.second.node.into_crate_internal(),
382
            ) {
383
72
                (Some(first), Some(second)) => {
384
72
                    Some(StructuralPreview::Merge { first, second })
385
                }
386
                _ => None,
387
            },
388
1
            Op::InsertChildren(i) => i.parent.node.into_crate_internal().map(|parent| {
389
1
                StructuralPreview::Insert {
390
1
                    parent,
391
1
                    index: i.index,
392
1
                    content: i.content.clone(),
393
1
                }
394
1
            }),
395
10
            Op::RemoveChildren(r) => r.parent.node.into_crate_internal().map(|parent| {
396
10
                StructuralPreview::Remove {
397
10
                    parent,
398
10
                    start: r.start,
399
10
                    end: r.end,
400
10
                }
401
10
            }),
402
72
            Op::ReplaceChildren(r) => r.parent.node.into_crate_internal().map(|parent| {
403
72
                StructuralPreview::Replace {
404
72
                    parent,
405
72
                    start: r.start,
406
72
                    end: r.end,
407
72
                    content: r.content.clone(),
408
72
                }
409
72
            }),
410
            // Wrap/unwrap previews are staged with the render consumption
411
            // (they restructure WITHIN a node — the child-list adjustment
412
            // needs the part-aware renderer to matter visually).
413
            Op::WrapRange(_) | Op::UnwrapRange(_) => None,
414
        };
415
218
        if let Some(preview) = preview {
416
218
            self.pending_structure
417
218
                .entry(dom_id)
418
218
                .or_default()
419
218
                .push(PendingStructure {
420
218
                    changeset_id: changeset.id,
421
218
                    preview,
422
218
                });
423
218
        }
424
218
    }
425

            
426
    /// Drop every pending structural delta of `dom` — called when a new
427
    /// generation lands (the app either applied the changeset, so REAL nodes
428
    /// exist, or rejected it, so the content reverts; both end the preview).
429
316
    pub(crate) fn gc_splits(&mut self, dom_id: DomId) {
430
316
        self.pending_structure.remove(&dom_id);
431
316
    }
432

            
433
    /// Convergence GC — the rule that closes the edit commit loop: after a
434
    /// generation swap (keys already remapped), an overlay text entry whose
435
    /// flattened text EQUALS the new DOM's text at that node has been
436
    /// committed by the app → drop it. Not equal → the app hasn't caught up;
437
    /// the overlay stays authoritative. Before this rule, entries were
438
    /// remapped forward FOREVER and DOM-reading exports silently saw pre-edit
439
    /// text.
440
4550
    pub(crate) fn gc_converged_text(&mut self, dom_id: DomId, styled_dom: &StyledDom) {
441
4550
        let node_data = styled_dom.node_data.as_container();
442
4550
        self.text.retain(|&(d, node_id), dirty| {
443
3
            if d != dom_id {
444
                return true;
445
3
            }
446
3
            let Some(node) = node_data.get(node_id) else {
447
                // Node gone in the new generation: nothing to converge to.
448
                return false;
449
            };
450
3
            let dom_text = if let NodeType::Text(s) = node.get_node_type() {
451
2
                s.as_str().to_string()
452
            } else {
453
                // Non-text IFC roots (contenteditable hosts): compare against
454
                // the concatenated text of DIRECT text children.
455
1
                let hierarchy = styled_dom.node_hierarchy.as_container();
456
1
                let mut s = String::new();
457
1
                if let Some(n) = hierarchy.get(node_id) {
458
1
                    let mut child = n.first_child_id(node_id);
459
2
                    while let Some(c) = child {
460
1
                        if let Some(cd) = node_data.get(c) {
461
1
                            if let NodeType::Text(t) = cd.get_node_type() {
462
1
                                s.push_str(t.as_str());
463
1
                            }
464
                        }
465
1
                        child = hierarchy.get(c).and_then(azul_core::styled_dom::NodeHierarchyItem::next_sibling_id);
466
                    }
467
                }
468
1
                s
469
            };
470
3
            flatten_inline_content(&dirty.content) != dom_text
471
3
        });
472
4550
    }
473

            
474
    /// Drop every overlay entry of `dom` (full DOM regeneration without a
475
    /// remap — the new generation's DOM is the authority again).
476
1
    pub(crate) fn clear_dom(&mut self, dom_id: DomId) {
477
1
        self.images.retain(|(d, _), _| *d != dom_id);
478
1
        self.text.retain(|(d, _), _| *d != dom_id);
479
1
        self.pending_structure.remove(&dom_id);
480
1
    }
481
}
482

            
483
impl NodeIdRemap for ContentOverlay {
484
25
    fn remap_node_ids(&mut self, dom: DomId, map: &NodeIdMap) {
485
25
        crate::managers::remap_dom_keys(&mut self.images, dom, map);
486
25
        crate::managers::remap_dom_keys(&mut self.text, dom, map);
487
        // Previews do NOT remap: a remap means a new generation landed,
488
        // which ends every preview's life (gc at the layout tail); remapping
489
        // would keep a preview alive over content that superseded it.
490
25
        self.pending_structure.remove(&dom);
491
25
    }
492
}
493

            
494
/// The one overlay→DOM read order, borrowed by every consumer.
495
///
496
/// Constructed at the few pipeline entries that own both halves (display-list
497
/// build / IFC build via `LayoutContext`, exports); everything downstream
498
/// takes this instead of reaching into `StyledDom` for content.
499
#[derive(Debug, Clone, Copy)]
500
pub struct ResolvedContent<'a> {
501
    pub overlay: Option<&'a ContentOverlay>,
502
    pub styled_dom: &'a StyledDom,
503
    pub dom_id: DomId,
504
}
505

            
506
impl ResolvedContent<'_> {
507
    /// The image to PAINT for `node_id`: overlay first (produced callback
508
    /// frames, swapped images), then the DOM's `NodeType::Image`.
509
    #[must_use]
510
51
    pub fn image_for_paint(&self, node_id: NodeId) -> Option<ImageRef> {
511
51
        if let Some(overlay) = self.overlay {
512
23
            if let Some(img) = overlay.image_for_node(self.dom_id, node_id) {
513
3
                return Some(img.clone());
514
20
            }
515
28
        }
516
48
        self.dom_image(node_id)
517
51
    }
518

            
519
    /// The image whose intrinsic size LAYOUT uses for `node_id`. Overlay
520
    /// first — EXCEPT when the DOM declares a callback image: produced frames
521
    /// are paint content and must not resize the box per frame.
522
    #[must_use]
523
47
    pub fn image_for_layout(&self, node_id: NodeId) -> Option<ImageRef> {
524
47
        let dom_image = self.dom_image(node_id);
525
47
        if let Some(dom_ref) = &dom_image {
526
47
            if dom_ref.is_callback() {
527
                return dom_image;
528
47
            }
529
        }
530
47
        if let Some(overlay) = self.overlay {
531
20
            if let Some(img) = overlay.image_for_node(self.dom_id, node_id) {
532
1
                return Some(img.clone());
533
19
            }
534
27
        }
535
46
        dom_image
536
47
    }
537

            
538
95
    fn dom_image(&self, node_id: NodeId) -> Option<ImageRef> {
539
95
        let node_data = self.styled_dom.node_data.as_container();
540
95
        match node_data.get(node_id)?.get_node_type() {
541
94
            NodeType::Image(image_ref) => Some(image_ref.as_ref().clone()),
542
1
            _ => None,
543
        }
544
95
    }
545

            
546
    /// The children of `node_id` AS THE USER SHOULD SEE THEM: the immutable
547
    /// DOM's child list with every pending structural delta applied on top —
548
    /// the read side of the `.insertChild`-through-the-overlay design.
549
    /// With no pending structure this is exactly the DOM's children.
550
    ///
551
    /// Split previews surface on the SPLIT NODE itself via
552
    /// [`Self::split_positions_for_node`]; here a split node still occupies
553
    /// one slot (its parts are an internal regrouping).
554
    #[must_use]
555
3
    pub fn children_for_node(&self, node_id: NodeId) -> Vec<ResolvedChild<'_>> {
556
3
        let hierarchy = self.styled_dom.node_hierarchy.as_container();
557
3
        let mut out: Vec<ResolvedChild<'_>> = Vec::new();
558
3
        let mut child = hierarchy.get(node_id).and_then(|n| n.first_child_id(node_id));
559
9
        while let Some(c) = child {
560
6
            out.push(ResolvedChild::Existing(c));
561
6
            child = hierarchy.get(c).and_then(azul_core::styled_dom::NodeHierarchyItem::next_sibling_id);
562
6
        }
563

            
564
3
        let Some(overlay) = self.overlay else {
565
            return out;
566
        };
567
3
        for pending in overlay.pending_structure(self.dom_id) {
568
2
            match &pending.preview {
569
                StructuralPreview::Insert {
570
2
                    parent,
571
2
                    index,
572
2
                    content,
573
2
                } if *parent == node_id => {
574
2
                    let at = (*index as usize).min(out.len());
575
2
                    for (offset, pending_child) in content.children.as_ref().iter().enumerate() {
576
2
                        out.insert(at + offset, ResolvedChild::Pending(pending_child));
577
2
                    }
578
                }
579
1
                StructuralPreview::Remove { parent, start, end } if *parent == node_id => {
580
1
                    let s = (*start as usize).min(out.len());
581
1
                    let e = (*end as usize).min(out.len());
582
1
                    out.drain(s..e);
583
1
                }
584
                StructuralPreview::Replace {
585
                    parent,
586
                    start,
587
                    end,
588
                    content,
589
                } if *parent == node_id => {
590
                    let s = (*start as usize).min(out.len());
591
                    let e = (*end as usize).min(out.len());
592
                    let replacement: Vec<ResolvedChild<'_>> = content
593
                        .children
594
                        .as_ref()
595
                        .iter()
596
                        .map(ResolvedChild::Pending)
597
                        .collect();
598
                    out.splice(s..e, replacement);
599
                }
600
                StructuralPreview::Merge { first, second } => {
601
                    // The merged-away node disappears from ITS parent's list;
602
                    // its children surface under `first` (when iterating
603
                    // first's children).
604
                    if out
605
                        .iter()
606
                        .any(|c| matches!(c, ResolvedChild::Existing(n) if n == second))
607
                    {
608
                        out.retain(
609
                            |c| !matches!(c, ResolvedChild::Existing(n) if n == second),
610
                        );
611
                    }
612
                    if *first == node_id {
613
                        let mut sc = hierarchy
614
                            .get(*second)
615
                            .and_then(|n| n.first_child_id(*second));
616
                        while let Some(c) = sc {
617
                            out.push(ResolvedChild::Existing(c));
618
                            sc = hierarchy.get(c).and_then(azul_core::styled_dom::NodeHierarchyItem::next_sibling_id);
619
                        }
620
                    }
621
                }
622
                // A split regroups the node's OWN content; its parent's child
623
                // list is unchanged (the second part becomes real only when
624
                // the app applies).
625
                _ => {}
626
            }
627
        }
628
3
        out
629
3
    }
630

            
631
    /// The pending SPLIT positions of `node_id` (usually 0 or 1) — a
632
    /// part-aware consumer renders the node's children regrouped at these
633
    /// structural positions.
634
    #[must_use]
635
    pub fn split_positions_for_node(
636
        &self,
637
        node_id: NodeId,
638
    ) -> Vec<crate::managers::changeset::NodePosition> {
639
        let Some(overlay) = self.overlay else {
640
            return Vec::new();
641
        };
642
        overlay
643
            .pending_structure(self.dom_id)
644
            .iter()
645
            .filter_map(|p| match &p.preview {
646
                StructuralPreview::Split { node, at, .. } if *node == node_id => Some(*at),
647
                _ => None,
648
            })
649
            .collect()
650
    }
651

            
652
    /// The text to READ for `node_id` (a11y, exports): the overlay's edited
653
    /// content flattened, falling back to the DOM's `NodeType::Text`.
654
    /// `None` when the node has neither.
655
    #[must_use]
656
    pub fn text_for_node(&self, node_id: NodeId) -> Option<String> {
657
        if let Some(overlay) = self.overlay {
658
            if let Some(dirty) = overlay.text_for_node(self.dom_id, node_id) {
659
                return Some(flatten_inline_content(&dirty.content));
660
            }
661
        }
662
        let node_data = self.styled_dom.node_data.as_container();
663
        match node_data.get(node_id)?.get_node_type() {
664
            NodeType::Text(s) => Some(s.as_str().to_string()),
665
            _ => None,
666
        }
667
    }
668
}
669

            
670
/// One journaled content mutation.
671
#[derive(Debug, Clone)]
672
pub struct JournalEntry {
673
    /// The frame sequence number the change was applied in.
674
    pub frame_seq: u64,
675
    pub change: AppliedChange,
676
}
677

            
678
/// The mechanical record of an applied change — enough for a compositor to
679
/// reach content as of frame `N − k` and for damage to know old vs new.
680
#[derive(Debug, Clone)]
681
pub enum AppliedChange {
682
    Image {
683
        dom_id: DomId,
684
        node_id: NodeId,
685
        /// The image displayed BEFORE this change (holds the pixels alive for
686
        /// backends still compositing an old buffer). `None`: node had none.
687
        old: Option<ImageRef>,
688
        new_hash: ImageRefHash,
689
    },
690
    ImageById {
691
        id: AzString,
692
        old: Option<ImageRef>,
693
        removed: bool,
694
    },
695
    /// Manager-mutated window state moved this frame (focus / text selection
696
    /// / scroll positions). These live OUTSIDE the DOM by design — the
697
    /// journal records THAT they changed (fingerprint transitions), so every
698
    /// non-DOM mutation is at least auditable per frame, same clock as
699
    /// content.
700
    ManagerState {
701
        focus_changed: bool,
702
        selection_changed: bool,
703
        scroll_changed: bool,
704
    },
705
}
706

            
707
/// Frame-scoped record of applied content changes.
708
///
709
/// Retention is bounded by the PRESENT loop: `begin_frame` (called once per
710
/// frame from shared frame code — never from a backend) retires entries older
711
/// than [`JOURNAL_RETENTION_FRAMES`]. The journal never grows with document
712
/// size or session length.
713
#[derive(Debug, Default)]
714
pub struct ContentJournal {
715
    frame_seq: u64,
716
    entries: VecDeque<JournalEntry>,
717
    /// Fingerprints of (focus, selection, scroll) as of the last frame —
718
    /// the diff basis for [`Self::record_manager_state`].
719
    last_manager_fingerprint: Option<[u64; 3]>,
720
}
721

            
722
impl ContentJournal {
723
    /// The current frame sequence number. Bumped only by [`Self::begin_frame`].
724
    #[must_use]
725
2
    pub const fn frame_seq(&self) -> u64 {
726
2
        self.frame_seq
727
2
    }
728

            
729
    /// Advance the frame clock and retire entries older than the swapchain
730
    /// depth. Called from shared per-frame code (`LayoutWindow::prepare_frame_cpu`
731
    /// / the GPU frame orchestration) — backends never call this directly.
732
16680
    pub fn begin_frame(&mut self) {
733
16680
        self.frame_seq = self.frame_seq.wrapping_add(1);
734
16680
        let cutoff = self.frame_seq.saturating_sub(JOURNAL_RETENTION_FRAMES);
735
16796
        while self
736
16796
            .entries
737
16796
            .front()
738
16796
            .is_some_and(|e| e.frame_seq < cutoff)
739
116
        {
740
116
            self.entries.pop_front();
741
116
        }
742
16680
    }
743

            
744
    /// Diff the manager-state fingerprints against last frame's and record
745
    /// a [`AppliedChange::ManagerState`] entry when anything moved. Called
746
    /// from the shared frame preparation (the same clock content uses).
747
16674
    pub(crate) fn record_manager_state(&mut self, fingerprint: [u64; 3]) {
748
16674
        if let Some(last) = self.last_manager_fingerprint {
749
16617
            let focus_changed = last[0] != fingerprint[0];
750
16617
            let selection_changed = last[1] != fingerprint[1];
751
16617
            let scroll_changed = last[2] != fingerprint[2];
752
16617
            if focus_changed || selection_changed || scroll_changed {
753
40
                self.record(AppliedChange::ManagerState {
754
40
                    focus_changed,
755
40
                    selection_changed,
756
40
                    scroll_changed,
757
40
                });
758
16577
            }
759
57
        }
760
16674
        self.last_manager_fingerprint = Some(fingerprint);
761
16674
    }
762

            
763
145
    pub(crate) fn record(&mut self, change: AppliedChange) {
764
145
        self.entries.push_back(JournalEntry {
765
145
            frame_seq: self.frame_seq,
766
145
            change,
767
145
        });
768
145
    }
769

            
770
    /// The image `node` displayed as of `frame_seq` (≤ [`JOURNAL_RETENTION_FRAMES`]
771
    /// frames back): the `old` of the first change recorded AFTER that frame,
772
    /// or `None` if the node's image hasn't changed since (current is valid).
773
    #[must_use]
774
2
    pub fn image_as_of(
775
2
        &self,
776
2
        dom_id: DomId,
777
2
        node_id: NodeId,
778
2
        frame_seq: u64,
779
2
    ) -> Option<&ImageRef> {
780
2
        self.entries.iter().find_map(|e| match &e.change {
781
            AppliedChange::Image {
782
2
                dom_id: d,
783
2
                node_id: n,
784
2
                old,
785
                ..
786
2
            } if *d == dom_id && *n == node_id && e.frame_seq > frame_seq => old.as_ref(),
787
1
            _ => None,
788
2
        })
789
2
    }
790

            
791
    /// Number of retained entries (test/diagnostic use).
792
    #[must_use]
793
1
    pub fn len(&self) -> usize {
794
1
        self.entries.len()
795
1
    }
796

            
797
    #[must_use]
798
1
    pub fn is_empty(&self) -> bool {
799
1
        self.entries.is_empty()
800
1
    }
801

            
802
    /// Drop journal history for a DOM whose generation was swapped — the old
803
    /// generation's node ids no longer mean anything, and the swap itself
804
    /// repaints everything.
805
24
    pub(crate) fn clear_dom(&mut self, dom_id: DomId) {
806
24
        self.entries.retain(|e| match &e.change {
807
            AppliedChange::Image { dom_id: d, .. } => *d != dom_id,
808
1
            AppliedChange::ImageById { .. } | AppliedChange::ManagerState { .. } => true,
809
1
        });
810
24
    }
811
}
812

            
813
#[cfg(test)]
814
mod tests {
815
    use super::*;
816

            
817
206
    fn img(w: usize, h: usize) -> ImageRef {
818
206
        ImageRef::null_image(w, h, azul_core::resources::RawImageFormat::BGRA8, Vec::new())
819
206
    }
820

            
821
132
    fn dom0() -> DomId {
822
132
        DomId { inner: 0 }
823
132
    }
824

            
825
    #[test]
826
1
    fn journal_retires_by_frame_age_never_by_count() {
827
1
        let mut journal = ContentJournal::default();
828
        // 100 changes in ONE frame: all retained (retention is frames, not entries).
829
101
        for i in 0..100_usize {
830
100
            journal.record(AppliedChange::Image {
831
100
                dom_id: dom0(),
832
100
                node_id: NodeId::new(i),
833
100
                old: Some(img(1, 1)),
834
100
                new_hash: img(1, 1).get_hash(),
835
100
            });
836
100
        }
837
1
        assert_eq!(journal.len(), 100);
838

            
839
        // After JOURNAL_RETENTION_FRAMES + 1 empty frames, everything is retired.
840
5
        for _ in 0..=JOURNAL_RETENTION_FRAMES {
841
4
            journal.begin_frame();
842
4
        }
843
1
        assert!(journal.is_empty(), "entries older than the swapchain depth must retire");
844
1
    }
845

            
846
    #[test]
847
1
    fn image_as_of_returns_the_pre_change_image_within_retention() {
848
1
        let mut journal = ContentJournal::default();
849
1
        let node = NodeId::new(7);
850
1
        let old = img(10, 10);
851
1
        let old_hash = old.get_hash();
852

            
853
1
        journal.begin_frame(); // frame 1
854
1
        let composed_at = journal.frame_seq(); // a backend composited frame 1
855
1
        journal.begin_frame(); // frame 2
856
1
        journal.record(AppliedChange::Image {
857
1
            dom_id: dom0(),
858
1
            node_id: node,
859
1
            old: Some(old),
860
1
            new_hash: img(10, 10).get_hash(),
861
1
        });
862

            
863
        // The buffer composed at frame 1 may still sample the old image.
864
1
        let as_of = journal.image_as_of(dom0(), node, composed_at);
865
1
        assert_eq!(as_of.map(ImageRef::get_hash), Some(old_hash));
866

            
867
        // As of frame 2 (change applied in it), the current image is valid.
868
1
        assert!(journal.image_as_of(dom0(), node, journal.frame_seq()).is_none());
869
1
    }
870

            
871
    #[test]
872
1
    fn resolved_content_prefers_overlay_for_paint() {
873
1
        let styled_dom = StyledDom::default();
874
1
        let mut overlay = ContentOverlay::default();
875
1
        let node = NodeId::new(0);
876
1
        let overlay_img = img(4, 4);
877
1
        let overlay_hash = overlay_img.get_hash();
878
1
        overlay.set_image(dom0(), node, overlay_img);
879

            
880
1
        let resolved = ResolvedContent {
881
1
            overlay: Some(&overlay),
882
1
            styled_dom: &styled_dom,
883
1
            dom_id: dom0(),
884
1
        };
885
1
        assert_eq!(
886
1
            resolved.image_for_paint(node).map(|i| i.get_hash()),
887
1
            Some(overlay_hash),
888
            "overlay wins over the (empty) DOM"
889
        );
890

            
891
        // Without the overlay: falls back to the DOM (which has no image node).
892
1
        let resolved = ResolvedContent {
893
1
            overlay: None,
894
1
            styled_dom: &styled_dom,
895
1
            dom_id: dom0(),
896
1
        };
897
1
        assert!(resolved.image_for_paint(node).is_none());
898
1
    }
899

            
900
    #[test]
901
1
    fn structural_previews_adjust_the_resolved_child_list() {
902
        use crate::managers::changeset::{
903
            DocOpInsertChildren, DocOpRemoveChildren, DocumentChangeset, DocumentOperation,
904
            EditResumePoint, NodePosition,
905
        };
906
        use azul_core::dom::{Dom, DomNodeId};
907
        use azul_core::styled_dom::NodeHierarchyItemId;
908
        use azul_core::task::{Instant, SystemTick};
909

            
910
        // DOM: div > [p, p] (nodes 1, 2 with their text children 3, 4… the
911
        // exact ids come from creation order; resolve them dynamically).
912
1
        let mut dom = Dom::create_div();
913
1
        let mut p1 = Dom::create_p();
914
1
        p1.add_child(Dom::create_text_do_not_use_without_block_level_wrapper("one"));
915
1
        let mut p2 = Dom::create_p();
916
1
        p2.add_child(Dom::create_text_do_not_use_without_block_level_wrapper("two"));
917
1
        dom.add_child(p1);
918
1
        dom.add_child(p2);
919
1
        let styled = StyledDom::create_from_dom(dom);
920
1
        let root = NodeId::new(0);
921

            
922
1
        let dom_node = |n: NodeId| DomNodeId {
923
4
            dom: dom0(),
924
4
            node: NodeHierarchyItemId::from_crate_internal(Some(n)),
925
4
        };
926
1
        let resume = EditResumePoint {
927
1
            anchor_key: 1,
928
1
            node_path: vec![0].into(),
929
1
            position: NodePosition::before_child(0),
930
1
        };
931

            
932
1
        let mut overlay = ContentOverlay::default();
933

            
934
        // Baseline: with no pending structure the resolved children ARE the
935
        // DOM children (two <p> elements).
936
1
        let resolved = ResolvedContent {
937
1
            overlay: Some(&overlay),
938
1
            styled_dom: &styled,
939
1
            dom_id: dom0(),
940
1
        };
941
1
        let base = resolved.children_for_node(root);
942
1
        assert_eq!(base.len(), 2);
943
1
        assert!(base
944
1
            .iter()
945
2
            .all(|c| matches!(c, ResolvedChild::Existing(_))));
946

            
947
        // A recorded INSERT previews a PENDING subtree between them — the
948
        // .insertChild made visible without any DOM mutation.
949
1
        let mut ul = Dom::create_node(azul_core::xml::tag_to_node_type("ul"));
950
1
        ul.add_child(Dom::create_p());
951
1
        let insert_cs = DocumentChangeset::new(
952
1
            dom_node(root),
953
1
            DocumentOperation::InsertChildren(DocOpInsertChildren {
954
1
                parent: dom_node(root),
955
1
                index: 1,
956
1
                content: {
957
1
                    let mut frag = Dom::create_div();
958
1
                    frag.add_child(ul);
959
1
                    frag
960
1
                },
961
1
            }),
962
1
            resume.clone(),
963
1
            Instant::Tick(SystemTick::new(0)),
964
        );
965
1
        overlay.preview_structural_change(dom0(), &insert_cs);
966
1
        let resolved = ResolvedContent {
967
1
            overlay: Some(&overlay),
968
1
            styled_dom: &styled,
969
1
            dom_id: dom0(),
970
1
        };
971
1
        let with_insert = resolved.children_for_node(root);
972
1
        assert_eq!(with_insert.len(), 3);
973
1
        assert!(matches!(with_insert[1], ResolvedChild::Pending(_)));
974

            
975
        // A recorded REMOVE previews children [0..1) gone.
976
1
        let remove_cs = DocumentChangeset::new(
977
1
            dom_node(root),
978
1
            DocumentOperation::RemoveChildren(DocOpRemoveChildren {
979
1
                parent: dom_node(root),
980
1
                start: 0,
981
1
                end: 1,
982
1
            }),
983
1
            resume,
984
1
            Instant::Tick(SystemTick::new(0)),
985
        );
986
1
        overlay.preview_structural_change(dom0(), &remove_cs);
987
1
        let resolved = ResolvedContent {
988
1
            overlay: Some(&overlay),
989
1
            styled_dom: &styled,
990
1
            dom_id: dom0(),
991
1
        };
992
1
        let with_both = resolved.children_for_node(root);
993
1
        assert_eq!(with_both.len(), 2, "insert(+1) then remove(-1): {with_both:?}");
994

            
995
        // A new generation ends every preview.
996
1
        overlay.gc_splits(dom0());
997
1
        assert_eq!(overlay.pending_structure_len(), 0);
998
1
    }
999

            
    #[test]
1
    fn text_gc_drops_converged_entries_and_keeps_diverged_ones() {
        use crate::text3::cache::{InlineContent, StyledRun};
        use std::sync::Arc;
3
        fn dirty(text: &str) -> DirtyTextNode {
3
            DirtyTextNode {
3
                content: vec![InlineContent::Text(StyledRun {
3
                    text: Arc::from(text),
3
                    style: Arc::new(Default::default()),
3
                    logical_start_byte: 0,
3
                    source_node_id: None,
3
                })],
3
                cursor: None,
3
                needs_ancestor_relayout: false,
3
            }
3
        }
        // DOM: div > [text "committed"]
1
        let mut dom = azul_core::dom::Dom::create_div();
1
        dom.add_child(azul_core::dom::Dom::create_text_do_not_use_without_block_level_wrapper("committed"));
1
        let styled = StyledDom::create_from_dom(dom);
1
        let text_node = NodeId::new(1);
1
        let mut overlay = ContentOverlay::default();
        // Entry keyed on the TEXT node whose edit the app has committed:
1
        overlay.set_text(dom0(), text_node, dirty("committed"));
1
        overlay.gc_converged_text(dom0(), &styled);
1
        assert!(
1
            overlay.text_for_node(dom0(), text_node).is_none(),
            "app committed the edit → overlay entry retires (the commit loop closes)"
        );
        // Entry the app has NOT committed stays authoritative:
1
        overlay.set_text(dom0(), text_node, dirty("still-editing"));
1
        overlay.gc_converged_text(dom0(), &styled);
1
        assert!(
1
            overlay.text_for_node(dom0(), text_node).is_some(),
            "un-committed edit must survive the generation swap"
        );
        // Entry keyed on the HOST (div): compares against concatenated direct
        // text children.
1
        overlay.clear_dom(dom0());
1
        overlay.set_text(dom0(), NodeId::new(0), dirty("committed"));
1
        overlay.gc_converged_text(dom0(), &styled);
1
        assert!(
1
            overlay.text_for_node(dom0(), NodeId::new(0)).is_none(),
            "host-keyed entry converges against its direct text children"
        );
1
    }
    #[test]
1
    fn overlay_remap_moves_entries_and_drops_unmounted() {
        use std::collections::BTreeMap;
1
        let mut overlay = ContentOverlay::default();
1
        overlay.set_image(dom0(), NodeId::new(2), img(1, 1));
1
        overlay.set_image(dom0(), NodeId::new(3), img(2, 2));
1
        let other_dom = DomId { inner: 9 };
1
        let other_hash = {
1
            let i = img(5, 5);
1
            let h = i.get_hash();
1
            overlay.set_image(other_dom, NodeId::new(2), i);
1
            h
        };
        // Node 2 moved to 1; node 3 unmounted.
1
        let mut moves = BTreeMap::new();
1
        moves.insert(NodeId::new(2), NodeId::new(1));
1
        let map = NodeIdMap::from_pairs(moves);
1
        overlay.remap_node_ids(dom0(), &map);
1
        assert!(overlay.image_for_node(dom0(), NodeId::new(1)).is_some());
1
        assert!(overlay.image_for_node(dom0(), NodeId::new(2)).is_none());
1
        assert!(overlay.image_for_node(dom0(), NodeId::new(3)).is_none(), "unmounted dropped");
1
        assert_eq!(
1
            overlay.image_for_node(other_dom, NodeId::new(2)).map(ImageRef::get_hash),
1
            Some(other_hash),
            "other DOMs untouched"
        );
1
    }
}