ailloli_ui_runtime/app/
runtime.rs

1//! Application runtime orchestration for retained layout, paint, and input state.
2
3use std::collections::{HashMap, HashSet};
4use std::rc::Rc;
5
6use ailloli_ui_core::geometry::Constraints;
7use ailloli_ui_core::math::Scale;
8use ailloli_ui_text::TextSystem;
9
10use crate::app::{ElementTreeDiagnosticsSnapshot, FrameWorkPlan, Invalidation, RuntimeHandle};
11use crate::component::{IntoView, View};
12use crate::element::reconcile::{reconcile_existing_component, reconcile_root};
13use crate::element::{ElementKind, ElementTree};
14use crate::input::InputSnapshot;
15use crate::layout::{commit_layout_element_observed, LayoutEngine};
16use crate::scene::{paint_element_observed, PaintCtx, Scene};
17
18/// Per-window or per-root retained pipeline for reconciliation, layout, and paint.
19///
20/// A runtime owns one [`ElementTree`] namespace allocated from a clonable
21/// [`RuntimeHandle`]. It is UI-thread-oriented: component/widget callbacks run
22/// synchronously, while the handle supplies thread-safe queues and invalidation
23/// ingress. Dropping this value releases the tree scope and its scoped runtime
24/// state.
25///
26/// # Examples
27///
28/// ```
29/// use ailloli_ui_runtime::app::{Runtime, RuntimeHandle};
30/// let runtime = Runtime::new(RuntimeHandle::<()>::new());
31/// assert!(runtime.root.is_none());
32/// assert!(runtime.tree.root().is_none());
33/// ```
34pub struct Runtime<A> {
35    /// Handle bound to this runtime's retained-tree namespace.
36    pub runtime: RuntimeHandle<A>,
37    /// Mutable retained elements, geometry, dirty flags, and work diagnostics.
38    pub tree: ElementTree<A>,
39    /// Current reconciled root, or `None` before the first reconciliation.
40    pub root: Option<ailloli_ui_core::ids::ElementId>,
41}
42
43/// Releases this runtime's allocated element-tree scope.
44impl<A> Drop for Runtime<A> {
45    /// Releases the retained-tree scope and all tree-owned runtime records.
46    fn drop(&mut self) {
47        self.runtime.release_element_tree_scope();
48    }
49}
50
51/// Retained frame-stage operations for application payload type `A`.
52impl<A: 'static> Runtime<A> {
53    /// Allocates a retained-tree scope from `runtime` and starts empty.
54    ///
55    /// The stored handle shares application state and service registrations
56    /// with the supplied handle, but receives the next allocated tree identity.
57    /// Allocation starts at `0` and increases by one. No view is built and no
58    /// invalidation is consumed.
59    ///
60    /// # Panics
61    ///
62    /// Panics if the shared `u64` tree-identity counter is exhausted.
63    ///
64    /// # Examples
65    ///
66    /// ```
67    /// use ailloli_ui_runtime::app::{Runtime, RuntimeHandle};
68    /// let base = RuntimeHandle::<()>::new();
69    /// let runtime = Runtime::new(base);
70    /// assert!(runtime.root.is_none());
71    /// assert_eq!(runtime.runtime.element_tree_id().get(), 0);
72    /// ```
73    pub fn new(runtime: RuntimeHandle<A>) -> Self {
74        Self {
75            runtime: runtime.allocate_element_tree_scope(),
76            tree: ElementTree::new(),
77            root: None,
78        }
79    }
80
81    /// Snapshots saturating work counters for this retained tree.
82    ///
83    /// The returned value is owned and does not update as later build, layout,
84    /// paint, or hit-test work occurs. An untouched runtime has zero totals and
85    /// no per-element entries.
86    ///
87    /// # Examples
88    ///
89    /// ```
90    /// use ailloli_ui_runtime::app::{Runtime, RuntimeHandle};
91    /// let runtime = Runtime::new(RuntimeHandle::<()>::new());
92    /// let snapshot = runtime.work_diagnostics();
93    /// assert_eq!(snapshot.totals.builds, 0);
94    /// assert!(snapshot.elements.is_empty());
95    /// ```
96    pub fn work_diagnostics(&self) -> ElementTreeDiagnosticsSnapshot {
97        self.tree.diagnostics()
98    }
99
100    /// Converts and reconciles a declarative root, returning its retained ID.
101    ///
102    /// This is the generic convenience form of [`Self::reconcile_view`]. It
103    /// stores the root ID and removes popup owners whose retained elements no
104    /// longer exist. Layout and paint are not run.
105    ///
106    /// # Examples
107    ///
108    /// ```
109    /// use ailloli_ui_runtime::{app::{Runtime, RuntimeHandle}, component::View};
110    /// let mut runtime = Runtime::new(RuntimeHandle::<()>::new());
111    /// let id = runtime.reconcile(View::empty());
112    /// assert_eq!(runtime.root, Some(id));
113    /// ```
114    pub fn reconcile<V: IntoView<A>>(&mut self, root: V) -> ailloli_ui_core::ids::ElementId {
115        self.reconcile_view(root.into_view())
116    }
117
118    /// Reconciles a concrete [`View`] against the current retained root.
119    ///
120    /// Compatible keyed/positional nodes are reused and changed nodes are marked
121    /// dirty by the reconciler. The resulting ID becomes [`Self::root`]. Stale
122    /// popup owners are pruned after reconciliation; layout and paint remain
123    /// separate stages.
124    ///
125    /// # Examples
126    ///
127    /// ```
128    /// use ailloli_ui_runtime::{app::{Runtime, RuntimeHandle}, component::View};
129    /// let mut runtime = Runtime::new(RuntimeHandle::<()>::new());
130    /// let first = runtime.reconcile_view(View::empty().key("root"));
131    /// let second = runtime.reconcile_view(View::empty().key("root"));
132    /// assert_eq!(first, second);
133    /// ```
134    pub fn reconcile_view(&mut self, root_view: View<A>) -> ailloli_ui_core::ids::ElementId {
135        let root_id = reconcile_root(&mut self.tree, &self.runtime, root_view);
136        self.root = Some(root_id);
137        self.prune_stale_popup_owners();
138        root_id
139    }
140
141    /// Prepares pending work, lays out the root, and commits absolute geometry.
142    ///
143    /// Constraint dimensions and all output geometry are logical pixels;
144    /// `scale` is physical pixels per logical pixel. Pending component
145    /// invalidations are reconciled first. With no reconciled root, this still
146    /// consumes/prepares pending work and then returns. Layout artifacts may
147    /// borrow caches in `text_system` only for the duration of this call.
148    ///
149    /// # Panics
150    ///
151    /// Propagates panics from component/widget layout callbacks and from invalid
152    /// scale or constraint operations those callbacks perform.
153    ///
154    /// # Examples
155    ///
156    /// ```
157    /// use ailloli_ui_core::{Constraints, Scale};
158    /// use ailloli_ui_runtime::{app::{Runtime, RuntimeHandle}, component::View};
159    /// use ailloli_ui_text::TextSystem;
160    /// let mut runtime = Runtime::new(RuntimeHandle::<()>::new());
161    /// let root = runtime.reconcile_view(View::empty());
162    /// runtime.layout(Constraints::tight(80.0, 24.0), Scale::new(1.0), &mut TextSystem::new());
163    /// assert!(runtime.tree.get(root).unwrap().layout.is_some());
164    /// ```
165    pub fn layout(&mut self, constraints: Constraints, scale: Scale, text_system: &mut TextSystem) {
166        let _ = self.prepare_frame();
167        let Some(root_id) = self.root else {
168            return;
169        };
170        let layout_published = {
171            let mut ctx = self.reactive_layout_context(scale, text_system);
172            let mut engine = LayoutEngine::new(&mut self.tree);
173            let (_, published) =
174                engine.layout_element_with_publication(&mut ctx, root_id, constraints);
175            published
176        };
177        if !layout_published {
178            return;
179        }
180        let mut ctx = self.reactive_layout_context(scale, text_system);
181        commit_layout_element_observed(
182            &mut self.tree,
183            &self.runtime,
184            &mut ctx,
185            root_id,
186            ailloli_ui_core::Offset::default(),
187        );
188    }
189
190    /// Creates a layout context wired to this runtime's exact dependency graph.
191    fn reactive_layout_context<'a>(
192        &self,
193        scale: Scale,
194        text_system: &'a mut TextSystem,
195    ) -> crate::layout::LayoutCtx<'a> {
196        let publisher = self.runtime.clone();
197        let retry = self.runtime.clone();
198        let diagnostics = self.runtime.clone();
199        let mut ctx = crate::layout::LayoutCtx::with_text_system(scale, text_system);
200        ctx.set_reactive_layout_callbacks(
201            Rc::new(move |updates| publisher.replace_reactive_dependencies_batch(updates)),
202            Rc::new(move |element_id| retry.invalidate(element_id, Invalidation::Layout)),
203        );
204        ctx.set_reactive_layout_abandon_callback(Rc::new(move || {
205            diagnostics.record_abandoned_layout_transaction();
206        }));
207        ctx
208    }
209
210    /// Applies pending element-scoped invalidations and returns only the
211    /// aggregate work visible to a presentation host.
212    ///
213    /// Exact dirty roots remain encapsulated by the runtime. A host may skip
214    /// layout when this plan only requests paint, while resize/DPI changes can
215    /// still force layout independently.
216    /// Calling this method drains the pending invalidation set and reconciles
217    /// dirty components before returning the plan captured at entry.
218    ///
219    /// # Examples
220    ///
221    /// ```
222    /// use ailloli_ui_core::ElementId;
223    /// use ailloli_ui_runtime::app::{Invalidation, Runtime, RuntimeHandle};
224    /// let mut runtime = Runtime::new(RuntimeHandle::<()>::new());
225    /// runtime.runtime.invalidate(ElementId(9), Invalidation::Paint);
226    /// let plan = runtime.prepare_frame();
227    /// assert!(plan.needs_paint());
228    /// assert!(runtime.runtime.frame_work_plan().is_empty());
229    /// ```
230    pub fn prepare_frame(&mut self) -> FrameWorkPlan {
231        let plan = self.runtime.frame_work_plan();
232        self.reconcile_dirty_components();
233        plan
234    }
235
236    /// Paints committed retained state with default input and frame time zero.
237    ///
238    /// This is shorthand for `paint_with_input(text_system, Default::default(),
239    /// 0)`. With no root it returns an empty scene. It does not reconcile or run
240    /// layout, so hosts must order stages appropriately.
241    ///
242    /// # Examples
243    ///
244    /// ```
245    /// use ailloli_ui_runtime::app::{Runtime, RuntimeHandle};
246    /// use ailloli_ui_text::TextSystem;
247    /// let runtime = Runtime::new(RuntimeHandle::<()>::new());
248    /// assert!(runtime.paint(&mut TextSystem::new()).layers.is_empty());
249    /// ```
250    pub fn paint(&self, text_system: &mut TextSystem) -> Scene {
251        self.paint_with_input(text_system, InputSnapshot::default(), 0)
252    }
253
254    /// Paints committed state with an input snapshot and frame timestamp.
255    ///
256    /// `frame_time_ms` is a host-defined millisecond timestamp exposed to paint
257    /// callbacks; it is stored verbatim and need not be wall-clock time. Input
258    /// state is copied into the paint context. No root produces an empty scene.
259    /// This method does not reconcile or lay out dirty state.
260    ///
261    /// # Examples
262    ///
263    /// ```
264    /// use ailloli_ui_runtime::{app::{Runtime, RuntimeHandle}, input::InputSnapshot};
265    /// use ailloli_ui_text::TextSystem;
266    /// let runtime = Runtime::new(RuntimeHandle::<()>::new());
267    /// let scene = runtime.paint_with_input(&mut TextSystem::new(), InputSnapshot::default(), 16);
268    /// assert!(scene.layers.is_empty());
269    /// ```
270    pub fn paint_with_input(
271        &self,
272        text_system: &mut TextSystem,
273        input: InputSnapshot,
274        frame_time_ms: u128,
275    ) -> Scene {
276        let Some(root_id) = self.root else {
277            return Scene::default();
278        };
279        let mut ctx = PaintCtx::with_text_system_and_input(text_system, input, frame_time_ms);
280        paint_element_observed(
281            &self.tree,
282            &self.runtime,
283            &mut ctx,
284            root_id,
285            ailloli_ui_core::Offset::default(),
286        );
287        ctx.into_scene()
288    }
289
290    /// Runs the canonical frame order: reconcile, layout, then paint.
291    ///
292    /// Pending invalidations are prepared during the layout stage. Input state
293    /// and frame time use their defaults, so interactive hosts needing current
294    /// values should call the individual stages and [`Self::paint_with_input`].
295    ///
296    /// # Panics
297    ///
298    /// Propagates panics from component and widget callbacks.
299    ///
300    /// # Examples
301    ///
302    /// ```
303    /// use ailloli_ui_core::{Constraints, Scale};
304    /// use ailloli_ui_runtime::{app::{Runtime, RuntimeHandle}, component::View};
305    /// use ailloli_ui_text::TextSystem;
306    /// let mut runtime = Runtime::new(RuntimeHandle::<()>::new());
307    /// let scene = runtime.render_root(View::empty(), Constraints::tight(40.0, 20.0), Scale::new(1.0), &mut TextSystem::new());
308    /// assert!(runtime.root.is_some());
309    /// assert!(scene.layers.is_empty());
310    /// ```
311    pub fn render_root(
312        &mut self,
313        root_view: View<A>,
314        constraints: Constraints,
315        scale: Scale,
316        text_system: &mut TextSystem,
317    ) -> Scene {
318        self.reconcile_view(root_view);
319        self.layout(constraints, scale, text_system);
320        self.paint(text_system)
321    }
322
323    /// Applies and drains all pending invalidations for this tree scope.
324    ///
325    /// Paint requests mark only their element. Layout requests dirty the path
326    /// and, for component targets, their direct children. Build requests rebuild
327    /// the nearest owning component; ancestor component rebuilds subsume selected
328    /// descendants. Unknown build targets are conservatively layout-dirtied.
329    /// Selected components are processed shallowest-first, then stale popup
330    /// owners are pruned. Newly enqueued invalidations are left for a later call.
331    ///
332    /// # Examples
333    ///
334    /// ```
335    /// use ailloli_ui_runtime::{app::{Invalidation, Runtime, RuntimeHandle}, component::View};
336    /// let mut runtime = Runtime::new(RuntimeHandle::<()>::new());
337    /// let root = runtime.reconcile_view(View::empty());
338    /// runtime.runtime.invalidate(root, Invalidation::Paint);
339    /// runtime.reconcile_dirty_components();
340    /// assert!(runtime.tree.get(root).unwrap().dirty.paint);
341    /// ```
342    pub fn reconcile_dirty_components(&mut self) {
343        let invalidations = self.runtime.take_invalidations();
344        if invalidations.is_empty() {
345            return;
346        }
347
348        let mut components = HashSet::new();
349        let mut layout_roots = Vec::new();
350        let mut direct_layout_nodes = HashSet::new();
351        for (element_id, invalidation) in invalidations {
352            match invalidation {
353                Invalidation::Paint => self.tree.mark_paint_dirty(element_id),
354                Invalidation::Layout => {
355                    layout_roots.push(element_id);
356                    if self
357                        .tree
358                        .get(element_id)
359                        .is_some_and(|element| matches!(element.kind, ElementKind::Component(_)))
360                    {
361                        direct_layout_nodes
362                            .extend(self.tree.children_of(element_id).iter().copied());
363                    }
364                }
365                Invalidation::Build => {
366                    if let Some(component_id) = self.owner_component(element_id) {
367                        components.insert(component_id);
368                    } else {
369                        layout_roots.push(element_id);
370                    }
371                }
372            }
373        }
374        let component_paths = self.component_path_metadata(&components);
375        let mut selected = components
376            .into_iter()
377            .filter(|component_id| {
378                self.tree
379                    .parent_of(*component_id)
380                    .and_then(|parent| component_paths.get(&parent))
381                    .and_then(|(_, nearest_component)| *nearest_component)
382                    .is_none()
383            })
384            .collect::<Vec<_>>();
385        selected.sort_by_key(|id| (component_paths.get(id).map_or(0, |(depth, _)| *depth), id.0));
386
387        for component_id in selected {
388            if reconcile_existing_component(&mut self.tree, &self.runtime, component_id) {
389                layout_roots.push(component_id);
390            }
391        }
392        layout_roots.sort_by_key(|id| id.0);
393        layout_roots.dedup();
394        let visited = self.tree.mark_layout_paths_dirty(layout_roots);
395        let mut direct_layout_nodes = direct_layout_nodes.into_iter().collect::<Vec<_>>();
396        direct_layout_nodes.sort_by_key(|id| id.0);
397        for child in direct_layout_nodes {
398            if !visited.contains(&child) {
399                self.tree.mark_element_layout_dirty(child);
400            }
401        }
402        self.prune_stale_popup_owners();
403    }
404
405    /// Removes popup entries whose owner no longer exists in this retained tree.
406    fn prune_stale_popup_owners(&self) {
407        self.runtime
408            .prune_stale_popup_owners(|element_id| self.tree.get(element_id).is_some());
409    }
410
411    /// Finds the inclusive nearest component ancestor through parent links.
412    fn owner_component(
413        &self,
414        element_id: ailloli_ui_core::ids::ElementId,
415    ) -> Option<ailloli_ui_core::ids::ElementId> {
416        let mut current = Some(element_id);
417        while let Some(id) = current {
418            let Some(element) = self.tree.get(id) else {
419                break;
420            };
421            if matches!(element.kind, ElementKind::Component(_)) {
422                return Some(id);
423            }
424            current = element.parent;
425        }
426        None
427    }
428
429    /// Resolves depths and nearest Build candidates over the union of paths.
430    ///
431    /// Each retained node shared by several candidates is expanded once. The
432    /// nearest-candidate entry is inclusive, so callers inspect a component's
433    /// parent to determine whether a shallower Build subsumes it.
434    fn component_path_metadata(
435        &self,
436        components: &HashSet<ailloli_ui_core::ids::ElementId>,
437    ) -> HashMap<ailloli_ui_core::ids::ElementId, (usize, Option<ailloli_ui_core::ids::ElementId>)>
438    {
439        let mut metadata = HashMap::<
440            ailloli_ui_core::ids::ElementId,
441            (usize, Option<ailloli_ui_core::ids::ElementId>),
442        >::new();
443        for &component_id in components {
444            if metadata.contains_key(&component_id) {
445                continue;
446            }
447            let mut path = Vec::new();
448            let mut path_seen = HashSet::new();
449            let mut current = Some(component_id);
450            while let Some(id) = current {
451                if metadata.contains_key(&id) || !path_seen.insert(id) {
452                    break;
453                }
454                path.push(id);
455                current = self.tree.parent_of(id);
456            }
457            while let Some(id) = path.pop() {
458                let parent_metadata = self
459                    .tree
460                    .parent_of(id)
461                    .and_then(|parent| metadata.get(&parent).copied());
462                let depth = parent_metadata.map_or(0, |(depth, _)| depth.saturating_add(1));
463                let nearest_component = if components.contains(&id) {
464                    Some(id)
465                } else {
466                    parent_metadata.and_then(|(_, nearest_component)| nearest_component)
467                };
468                metadata.insert(id, (depth, nearest_component));
469            }
470        }
471        metadata
472    }
473}