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}