Skip to main content

kozan_core/widget/
frame_widget.rs

1//! Frame widget — the engine's entry point for a single rendering context.
2//!
3//! Chrome: `WebFrameWidgetImpl` + `LocalFrameView` — owns the
4//! frame's document, viewport, event handler, and runs the lifecycle pipeline.
5//!
6//! The platform NEVER creates `Document` directly or calls `recalc_styles()`.
7//! Everything goes through `FrameWidget`.
8
9use std::sync::Arc;
10
11use super::event_handler::{EventHandler, InputContext};
12use super::viewport::Viewport;
13use crate::compositor::layer_builder::LayerTreeBuilder;
14use crate::compositor::layer_tree::LayerTree;
15use crate::dirty_phases::DirtyPhases;
16use crate::dom::document::Document;
17use crate::events::ui_event::ScrollEvent;
18use crate::input::InputEvent;
19use crate::input::default_action::DefaultAction;
20use crate::layout::context::LayoutContext;
21use crate::layout::fragment::Fragment;
22use crate::layout::hit_test::HitTester;
23use crate::layout::inline::FontSystem;
24use crate::lifecycle::LifecycleState;
25use crate::paint::DisplayList;
26use crate::paint::Painter;
27use crate::scroll::{ScrollController, ScrollOffsets, ScrollTree};
28
29/// The engine's entry point for a single rendering context.
30///
31/// # Responsibilities
32/// - Owns the `Document` (DOM tree)
33/// - Owns the `Viewport` (dimensions, scale factor)
34/// - Owns the `EventHandler` (input → DOM events)
35/// - Owns the scroll subsystems (`ScrollTree`, `ScrollOffsets`)
36/// - Runs the lifecycle pipeline: style → layout → paint
37///
38/// # Chrome mapping
39///
40/// | Chrome | Kozan |
41/// |--------|-------|
42/// | `WebFrameWidgetImpl` | Platform bridge (input, resize) |
43/// | `LocalFrameView` | Lifecycle orchestration |
44/// | `DocumentLifecycle` | `LifecycleState` + `DirtyPhases` |
45/// | `LayoutView` | Document root (layout via `DocumentLayoutView`) |
46///
47/// # Usage (from platform)
48///
49/// ```ignore
50/// let mut widget = FrameWidget::new();
51/// widget.resize(1920, 1080);
52///
53/// // User builds DOM
54/// let doc = widget.document_mut();
55///
56/// // Event loop (called from Scheduler::tick render callback)
57/// widget.handle_input(input_event);
58/// widget.update_lifecycle();
59///
60/// // Read paint result
61/// if let Some(dl) = widget.last_display_list() {
62///     // send to renderer
63/// }
64/// ```
65pub struct FrameWidget {
66    doc: Document,
67    viewport: Viewport,
68    event_handler: EventHandler,
69
70    /// Current lifecycle phase — enforces phase ordering.
71    /// Chrome: `DocumentLifecycle`.
72    lifecycle: LifecycleState,
73
74    /// Per-phase dirty flags — scroll invalidates paint only, hover invalidates all.
75    dirty: DirtyPhases,
76
77    /// Immutable fragment tree from last layout pass.
78    last_fragment: Option<Arc<Fragment>>,
79
80    /// Font discovery, caching, and text measurement.
81    /// Chrome: `LocalFrameView::GetFontCache()`.
82    font_system: FontSystem,
83
84    /// Display list from last paint pass. Arc-wrapped for zero-copy
85    /// sharing with the render thread.
86    last_display_list: Option<Arc<DisplayList>>,
87
88    /// Fragment that was last painted — paint caching via Arc pointer equality.
89    painted_fragment: Option<Arc<Fragment>>,
90
91    /// Viewport changed — re-sync all styles, re-run Taffy.
92    needs_full_layout: bool,
93
94    /// Previous frame's pipeline timing.
95    last_timing: kozan_primitives::timing::FrameTiming,
96
97    /// Layer tree from last paint — committed to the compositor.
98    /// Chrome: `LayerTreeHost::pending_tree_`.
99    last_layer_tree: Option<LayerTree>,
100
101    /// Scroll chain topology — rebuilt after each layout pass.
102    /// Chrome: `cc/trees/scroll_tree.h`.
103    scroll_tree: ScrollTree,
104
105    /// Mutable scroll displacement per node — the only state that
106    /// changes during scroll (no relayout needed).
107    scroll_offsets: ScrollOffsets,
108}
109
110impl FrameWidget {
111    #[must_use]
112    pub fn new() -> Self {
113        let mut doc = Document::new();
114        doc.init_body();
115
116        Self {
117            doc,
118            viewport: Viewport::default(),
119            event_handler: EventHandler::new(),
120            lifecycle: LifecycleState::default(),
121            dirty: DirtyPhases::default(),
122            last_fragment: None,
123            font_system: FontSystem::new(),
124            last_display_list: None,
125            painted_fragment: None,
126            needs_full_layout: false,
127            last_timing: kozan_primitives::timing::FrameTiming::default(),
128            last_layer_tree: None,
129            scroll_tree: ScrollTree::new(),
130            scroll_offsets: ScrollOffsets::new(),
131        }
132    }
133
134    #[inline]
135    pub fn document(&self) -> &Document {
136        &self.doc
137    }
138
139    #[inline]
140    pub fn document_mut(&mut self) -> &mut Document {
141        &mut self.doc
142    }
143
144    #[inline]
145    pub fn font_system(&self) -> &FontSystem {
146        &self.font_system
147    }
148
149    #[inline]
150    pub fn viewport(&self) -> &Viewport {
151        &self.viewport
152    }
153
154    #[inline]
155    pub fn last_fragment(&self) -> Option<&Arc<Fragment>> {
156        self.last_fragment.as_ref()
157    }
158
159    #[inline]
160    pub fn lifecycle(&self) -> LifecycleState {
161        self.lifecycle
162    }
163
164    /// Handle an input event.
165    ///
166    /// Performs hit testing against the last fragment tree, dispatches
167    /// DOM events, and applies scroll actions via the scroll controller.
168    /// Returns `true` if visual state changed (DOM mutation or scroll).
169    ///
170    /// Chrome: `WebFrameWidgetImpl::HandleInputEvent()`.
171    pub fn handle_input(&mut self, event: InputEvent) -> bool {
172        let Some(fragment) = &self.last_fragment else {
173            return false;
174        };
175        let fragment = Arc::clone(fragment);
176
177        let hit_tester = HitTester::new(&self.scroll_offsets);
178        let viewport_height = self.viewport.logical_height() as f32;
179        let ctx = InputContext {
180            surface: &self.doc,
181            fragment: &fragment,
182            hit_tester: &hit_tester,
183            viewport_height,
184            scroll_tree: &self.scroll_tree,
185        };
186
187        let result = self.event_handler.handle_input(event, &ctx);
188
189        if result.state_changed {
190            self.dirty.invalidate_style();
191        }
192
193        match result.default_action {
194            DefaultAction::Scroll { target, delta } => {
195                self.apply_scroll(target, delta) || result.state_changed
196            }
197            DefaultAction::FocusNext | DefaultAction::FocusPrev | DefaultAction::Activate => {
198                // Stubs — wired when focus management lands.
199                result.state_changed
200            }
201            DefaultAction::ScrollPrevented | DefaultAction::None => result.state_changed,
202        }
203    }
204
205    /// Apply a scroll delta and dispatch `ScrollEvent` on affected nodes.
206    /// Returns `true` if any node actually scrolled.
207    fn apply_scroll(&mut self, target: u32, delta: kozan_primitives::geometry::Offset) -> bool {
208        let scrolled = ScrollController::new(&self.scroll_tree, &mut self.scroll_offsets)
209            .scroll(target, delta);
210        if scrolled.is_empty() {
211            return false;
212        }
213        self.dirty.invalidate_paint();
214        self.event_handler.invalidate_hit_cache();
215        self.event_handler.suppress_hover();
216
217        for node_id in scrolled.iter() {
218            let offset = self.scroll_offsets.offset(node_id);
219            if let Some(handle) = self.doc.handle_for_index(node_id) {
220                handle.dispatch_event(&ScrollEvent {
221                    scroll_x: offset.dx,
222                    scroll_y: offset.dy,
223                });
224            }
225        }
226        true
227    }
228
229    /// Apply scroll offsets received from the compositor.
230    ///
231    /// Called at the start of each frame on the view thread so the next
232    /// paint uses the compositor's authoritative scroll positions.
233    pub fn apply_compositor_scroll(&mut self, offsets: &ScrollOffsets) {
234        for (node_id, offset) in offsets.iter() {
235            let current = self.scroll_offsets.offset(node_id);
236            if current != *offset {
237                self.scroll_offsets.set_offset(node_id, *offset);
238                self.dirty.invalidate_paint();
239                self.event_handler.invalidate_hit_cache();
240            }
241        }
242    }
243
244    /// Run the rendering lifecycle pipeline.
245    ///
246    /// Chrome: `LocalFrameView::UpdateLifecyclePhases()`.
247    ///
248    /// Phases: style recalc → layout → paint.
249    /// `DirtyPhases` controls which phases actually run — scroll only
250    /// invalidates paint, so style+layout are skipped at 60fps during scroll.
251    pub fn update_lifecycle(&mut self) {
252        if self.doc.needs_visual_update() || self.needs_full_layout {
253            self.dirty.invalidate_all();
254        }
255
256        if !self.dirty.needs_update() && self.last_fragment.is_some() {
257            return;
258        }
259
260        let t0 = std::time::Instant::now();
261        let mut style_ms = 0.0;
262        let mut layout_ms = 0.0;
263
264        if self.dirty.needs_style() {
265            self.lifecycle = LifecycleState::InStyleRecalc;
266            let t = std::time::Instant::now();
267            self.doc.recalc_styles();
268            style_ms = t.elapsed().as_secs_f64() * 1000.0;
269            self.lifecycle = LifecycleState::StyleClean;
270            self.dirty.clear_style();
271        }
272
273        if self.dirty.needs_layout() || self.last_fragment.is_none() {
274            self.lifecycle = LifecycleState::InLayout;
275            let t = std::time::Instant::now();
276            self.layout_pass();
277            layout_ms = t.elapsed().as_secs_f64() * 1000.0;
278            self.lifecycle = LifecycleState::LayoutClean;
279            self.dirty.clear_layout();
280        }
281
282        if self.dirty.needs_paint() {
283            self.lifecycle = LifecycleState::InPaint;
284            let t = std::time::Instant::now();
285            self.paint_pass();
286            let paint_ms = t.elapsed().as_secs_f64() * 1000.0;
287            self.lifecycle = LifecycleState::PaintClean;
288            self.dirty.clear_paint();
289
290            self.last_timing = kozan_primitives::timing::FrameTiming {
291                style_ms,
292                layout_ms,
293                paint_ms,
294                total_ms: t0.elapsed().as_secs_f64() * 1000.0,
295            };
296        }
297    }
298
299    /// Layout pass — DOM IS the layout tree.
300    ///
301    /// Taffy's cache handles incrementality automatically.
302    /// After layout, rebuilds the scroll tree from the new fragment tree.
303    fn layout_pass(&mut self) {
304        if self.viewport.width() == 0 || self.viewport.height() == 0 {
305            return;
306        }
307
308        let vw = self.viewport.logical_width() as f32;
309        let vh = self.viewport.logical_height() as f32;
310
311        let layout_dirty = self.doc.take_layout_dirty() || self.needs_full_layout;
312        self.needs_full_layout = false;
313
314        let ctx = LayoutContext {
315            text_measurer: &self.font_system,
316        };
317        let root = self.doc.root_index();
318        let result = self
319            .doc
320            .resolve_layout_dirty(root, Some(vw), Some(vh), &ctx, layout_dirty);
321        self.last_fragment = Some(result.fragment);
322
323        // Rebuild scroll tree from the new fragment tree.
324        if let Some(frag) = &self.last_fragment {
325            self.scroll_tree.sync(frag);
326        }
327    }
328
329    /// Paint pass — generate display list from fragment tree.
330    ///
331    /// Chrome: `LocalFrameView::PaintTree()`.
332    fn paint_pass(&mut self) {
333        let Some(fragment) = &self.last_fragment else {
334            return;
335        };
336
337        // Paint caching: skip if fragment tree hasn't changed AND no
338        // scroll offset changed (dirty paint flag handles the latter).
339        if let Some(painted) = &self.painted_fragment {
340            if Arc::ptr_eq(painted, fragment) && !self.dirty.needs_paint() {
341                return;
342            }
343        }
344
345        let viewport_size = kozan_primitives::geometry::Size::new(
346            self.viewport.logical_width() as f32,
347            self.viewport.logical_height() as f32,
348        );
349
350        let display_list = Painter::new(&self.scroll_offsets).paint(fragment, viewport_size);
351        self.last_display_list = Some(Arc::new(display_list));
352        self.painted_fragment = Some(Arc::clone(fragment));
353
354        // Build layer tree for the compositor.
355        self.last_layer_tree = Some(LayerTreeBuilder::new(&self.scroll_offsets).build(fragment));
356    }
357
358    /// The last paint result. The `Arc` is cloned cheaply — no copy of the list.
359    #[inline]
360    pub fn last_display_list(&self) -> Option<Arc<DisplayList>> {
361        self.last_display_list.as_ref().map(Arc::clone)
362    }
363
364    /// Take the layer tree for commit to the compositor.
365    pub fn take_layer_tree(&mut self) -> Option<LayerTree> {
366        self.last_layer_tree.take()
367    }
368
369    /// Clone scroll state for the compositor.
370    /// Cheap: typically 1-5 nodes × 40 bytes.
371    pub fn scroll_state_snapshot(&self) -> (ScrollTree, ScrollOffsets) {
372        (self.scroll_tree.clone(), self.scroll_offsets.clone())
373    }
374
375    /// Update the viewport dimensions (physical pixels).
376    pub fn resize(&mut self, width: u32, height: u32) {
377        self.viewport.resize(width, height);
378        let lw = self.viewport.logical_width() as f32;
379        let lh = self.viewport.logical_height() as f32;
380        self.doc.set_viewport(lw, lh);
381        // Styles haven't changed — only the available space did.
382        // Taffy's cache handles this: nodes whose output depends on the
383        // available space will miss the cache automatically. No need to
384        // nuke all caches with needs_full_layout.
385        self.dirty.invalidate_layout();
386    }
387
388    pub fn set_scale_factor(&mut self, factor: f64) {
389        self.viewport.set_scale_factor(factor);
390        self.dirty.invalidate_all();
391        self.needs_full_layout = true;
392    }
393
394    /// Force the lifecycle to re-run on the next `update_lifecycle()` call.
395    ///
396    /// Called by the platform when the scheduler's frame callback fires,
397    /// because `set_needs_frame()` means something changed that requires
398    /// a new frame.
399    pub fn mark_needs_update(&mut self) {
400        self.dirty.invalidate_all();
401    }
402
403    #[inline]
404    pub fn last_timing(&self) -> kozan_primitives::timing::FrameTiming {
405        self.last_timing
406    }
407
408    pub fn set_focus(&mut self, _focused: bool) {
409        // Future: dispatch focus/blur DOM events.
410    }
411}
412
413impl Default for FrameWidget {
414    fn default() -> Self {
415        Self::new()
416    }
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422    use crate::input::{InputEvent, Modifiers, mouse::MouseMoveEvent as RawMouseMoveEvent};
423    use std::time::Instant;
424
425    #[test]
426    fn construction() {
427        let widget = FrameWidget::new();
428        assert_eq!(widget.viewport().width(), 0);
429        assert_eq!(widget.viewport().height(), 0);
430        assert_eq!(widget.viewport().scale_factor(), 1.0);
431        assert_eq!(widget.lifecycle(), LifecycleState::PaintClean);
432    }
433
434    #[test]
435    fn resize_updates_viewport() {
436        let mut widget = FrameWidget::new();
437        widget.resize(1920, 1080);
438        assert_eq!(widget.viewport().width(), 1920);
439        assert_eq!(widget.viewport().height(), 1080);
440    }
441
442    #[test]
443    fn resize_invalidates_dirty() {
444        let mut widget = FrameWidget::new();
445        widget.resize(800, 600);
446        assert!(widget.dirty.needs_update());
447    }
448
449    #[test]
450    fn scale_factor() {
451        let mut widget = FrameWidget::new();
452        widget.set_scale_factor(2.0);
453        assert_eq!(widget.viewport().scale_factor(), 2.0);
454    }
455
456    #[test]
457    fn handle_input_without_fragment() {
458        let mut widget = FrameWidget::new();
459        let changed = widget.handle_input(InputEvent::MouseMove(RawMouseMoveEvent {
460            x: 100.0,
461            y: 200.0,
462            modifiers: Modifiers::EMPTY,
463            timestamp: Instant::now(),
464        }));
465        assert!(!changed);
466    }
467
468    #[test]
469    fn update_lifecycle_runs_all_phases() {
470        let mut widget = FrameWidget::new();
471        widget.resize(800, 600);
472        widget.update_lifecycle();
473
474        assert_eq!(widget.lifecycle(), LifecycleState::PaintClean);
475        assert!(
476            widget.last_fragment().is_some(),
477            "layout should produce a fragment after lifecycle"
478        );
479    }
480
481    #[test]
482    fn update_lifecycle_skips_when_clean() {
483        let mut widget = FrameWidget::new();
484        widget.resize(800, 600);
485
486        widget.update_lifecycle();
487        let frag1 = widget.last_fragment().cloned();
488
489        widget.update_lifecycle();
490        let frag2 = widget.last_fragment().cloned();
491
492        assert!(
493            Arc::ptr_eq(
494                frag1.as_ref().expect("frag1"),
495                frag2.as_ref().expect("frag2")
496            ),
497            "clean lifecycle should not re-layout"
498        );
499    }
500
501    #[test]
502    fn resize_triggers_relayout() {
503        let mut widget = FrameWidget::new();
504        widget.resize(800, 600);
505        widget.update_lifecycle();
506        let frag1 = widget.last_fragment().cloned();
507
508        widget.resize(1024, 768);
509        widget.update_lifecycle();
510        let frag2 = widget.last_fragment().cloned();
511
512        assert!(
513            !Arc::ptr_eq(
514                frag1.as_ref().expect("frag1"),
515                frag2.as_ref().expect("frag2")
516            ),
517            "resize should trigger relayout"
518        );
519    }
520
521    #[test]
522    fn document_access() {
523        let widget = FrameWidget::new();
524        let _doc = widget.document();
525    }
526
527    #[test]
528    fn no_layout_without_resize() {
529        let mut widget = FrameWidget::new();
530        widget.update_lifecycle();
531        assert!(
532            widget.last_fragment().is_none(),
533            "should not layout with zero viewport"
534        );
535    }
536}