Skip to main content

aprender_present_lib/browser/
mount.rs

1//! Rust-side mount: a widget tree, its input events and its frame schedule, with no script
2//! on the page (aprender#6893, aprender#6892).
3//!
4//! `Mounted` is the part that decides: it takes events, tracks whether a repaint is owed
5//! and produces the draw commands of one frame. It touches no browser API, so it is tested
6//! natively. The wasm32 shell that feeds it from DOM listeners and
7//! `requestAnimationFrame` is `browser::entry`.
8//!
9//! Contract: `contracts/present-mount-frame-v1.yaml`.
10
11use presentar_core::draw::DrawCommand;
12use presentar_core::{Constraints, Event, RecordingCanvas, Rect, Size, Widget};
13use std::any::Any;
14
15/// What the frame loop does at one tick.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum FrameAction {
18    /// Paint this tick and ask for another one.
19    Repaint,
20    /// Paint nothing and stop asking for frames until an event arrives.
21    Stop,
22}
23
24/// The whole scheduling decision: repaint while something is dirty or an animation runs,
25/// otherwise stop.
26#[must_use]
27pub const fn schedule(dirty: bool, animating: bool) -> FrameAction {
28    if dirty || animating {
29        FrameAction::Repaint
30    } else {
31        FrameAction::Stop
32    }
33}
34
35/// The outcome of one frame tick.
36#[derive(Debug, Clone, PartialEq)]
37pub enum Frame {
38    /// Nothing was owed; the loop stops.
39    Idle,
40    /// The tree was laid out and painted; these are its draw commands.
41    Painted(Vec<DrawCommand>),
42    /// The root brick failed its assertions, so nothing was painted (PROBAR-SPEC-009).
43    Blocked(String),
44}
45
46impl Frame {
47    /// Whether the loop asks for another tick after this one. Only a painted frame does: an
48    /// idle loop stops, and a blocked tree stays blocked until something changes it.
49    #[must_use]
50    pub const fn wants_next(&self) -> bool {
51        matches!(self, Self::Painted(_))
52    }
53}
54
55/// A widget tree mounted on a surface of a known size.
56pub struct Mounted {
57    root: Box<dyn Widget>,
58    size: Size,
59    dirty: bool,
60    animating: bool,
61    /// The last frame was blocked. An animation does not tick a blocked tree; only a change
62    /// (an event, `set_root`, `request_repaint`) has it verified again.
63    halted: bool,
64    messages: Vec<Box<dyn Any + Send>>,
65    painted: u64,
66}
67
68/// Every failed assertion in `widget` and its descendants, depth first. Containers such as
69/// `Column` verify clean without looking at their children, so the walk is done here.
70fn failed_assertions(widget: &dyn Widget, found: &mut Vec<String>) {
71    for (assertion, reason) in &widget.verify().failed {
72        found.push(format!(
73            "brick '{}' {assertion:?}: {reason}",
74            widget.brick_name()
75        ));
76    }
77    for child in widget.children() {
78        failed_assertions(child.as_ref(), found);
79    }
80}
81
82impl Mounted {
83    /// Mount `root` on a `width` x `height` surface. The tree is laid out at once, so the
84    /// first event already hits the right bounds, and the first frame paints.
85    #[must_use]
86    pub fn new(root: Box<dyn Widget>, width: f32, height: f32) -> Self {
87        let mut mounted = Self {
88            root,
89            size: Size::new(width, height),
90            dirty: true,
91            animating: false,
92            halted: false,
93            messages: Vec::new(),
94            painted: 0,
95        };
96        mounted.relayout();
97        mounted
98    }
99
100    fn relayout(&mut self) {
101        let wanted = self.root.measure(Constraints::loose(self.size));
102        self.root
103            .layout(Rect::new(0.0, 0.0, wanted.width, wanted.height));
104    }
105
106    /// Give one input event to the tree. Any message a widget emits is queued for
107    /// `take_messages`. Every event owes a repaint, because widgets change hover and
108    /// pressed state without emitting a message. Returns whether a message was emitted.
109    pub fn dispatch(&mut self, event: &Event) -> bool {
110        if let Event::Resize { width, height } = event {
111            self.size = Size::new(*width, *height);
112            self.relayout();
113        }
114        self.dirty = true;
115        match self.root.event(event) {
116            Some(message) => {
117                self.messages.push(message);
118                true
119            }
120            None => false,
121        }
122    }
123
124    /// Whether an animation tick is owed: an animation is on and the tree is not blocked.
125    const fn ticking(&self) -> bool {
126        self.animating && !self.halted
127    }
128
129    /// Run one frame tick. Every brick in the tree is verified before anything is painted;
130    /// one failure anywhere blocks the whole frame (PROBAR-SPEC-009).
131    pub fn frame(&mut self) -> Frame {
132        if schedule(self.dirty, self.ticking()) == FrameAction::Stop {
133            return Frame::Idle;
134        }
135        self.dirty = false;
136        // Lay out first: containers (List) materialize children in layout, so verification
137        // must walk the exact tree about to be painted.
138        self.relayout();
139        let mut failed = Vec::new();
140        failed_assertions(self.root.as_ref(), &mut failed);
141        self.halted = !failed.is_empty();
142        if self.halted {
143            return Frame::Blocked(format!("failed verification: {}", failed.join("; ")));
144        }
145        let mut canvas = RecordingCanvas::new();
146        self.root.paint(&mut canvas);
147        self.painted += 1;
148        Frame::Painted(canvas.take_commands())
149    }
150
151    /// Repaint on every tick (a waveform, a video overlay) or only when dirty.
152    pub fn set_animating(&mut self, animating: bool) {
153        self.animating = animating;
154    }
155
156    /// Owe a repaint without an event (the app changed the tree).
157    pub fn request_repaint(&mut self) {
158        self.dirty = true;
159    }
160
161    /// Whether the next tick would paint.
162    #[must_use]
163    pub const fn needs_frame(&self) -> bool {
164        matches!(schedule(self.dirty, self.ticking()), FrameAction::Repaint)
165    }
166
167    /// Messages emitted by widgets since the last call, oldest first.
168    pub fn take_messages(&mut self) -> Vec<Box<dyn Any + Send>> {
169        std::mem::take(&mut self.messages)
170    }
171
172    /// Frames painted so far.
173    #[must_use]
174    pub const fn frames_painted(&self) -> u64 {
175        self.painted
176    }
177
178    /// The mounted tree, for the app to change between frames.
179    pub fn root_mut(&mut self) -> &mut dyn Widget {
180        self.root.as_mut()
181    }
182
183    /// Replace the tree (the app rebuilt it from new state); lays it out and owes a repaint.
184    pub fn set_root(&mut self, root: Box<dyn Widget>) {
185        self.root = root;
186        self.relayout();
187        self.dirty = true;
188    }
189
190    /// The surface size the tree is laid out for.
191    #[must_use]
192    pub const fn size(&self) -> Size {
193        self.size
194    }
195}
196
197/// The tree of the built-in counter page: a title, the count and one "+" button.
198#[must_use]
199pub fn counter_tree(count: i32) -> Box<dyn Widget> {
200    use presentar_widgets::{Button, Column, Text};
201    Box::new(
202        Column::new()
203            .gap(20.0)
204            .child(Text::new("Counter").font_size(28.0))
205            .child(Text::new(format!("Count: {count}")).font_size(48.0))
206            .child(Button::new("+").padding(12.0)),
207    )
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use presentar_core::{MouseButton, Point};
214    use presentar_widgets::button::ButtonClicked;
215    use presentar_widgets::{Button, Column, Text};
216
217    fn tree() -> Box<dyn Widget> {
218        Box::new(
219            Column::new()
220                .child(Text::new("Counter"))
221                .child(Button::new("+")),
222        )
223    }
224
225    /// Find the button by clicking a grid. `Widget::bounds` is not overridden by `Button`,
226    /// so the tree cannot be asked. The grid starts at (2, 2): a tree that was never laid
227    /// out has a zero rect at the origin, which this scan must not hit.
228    fn find_button(mounted: &mut Mounted) -> Option<Point> {
229        for y in (2..600).step_by(4) {
230            for x in (2..800).step_by(4) {
231                let at = Point::new(x as f32, y as f32);
232                if click(mounted, at) {
233                    let _ = mounted.take_messages();
234                    return Some(at);
235                }
236            }
237        }
238        None
239    }
240
241    fn click(mounted: &mut Mounted, at: Point) -> bool {
242        let down = mounted.dispatch(&Event::MouseDown {
243            position: at,
244            button: MouseButton::Left,
245        });
246        let up = mounted.dispatch(&Event::MouseUp {
247            position: at,
248            button: MouseButton::Left,
249        });
250        down || up
251    }
252
253    #[test]
254    fn the_button_probe_finds_nothing_on_a_tree_without_a_button() {
255        let mut mounted = Mounted::new(Box::new(Text::new("no button here")), 800.0, 600.0);
256        assert!(find_button(&mut mounted).is_none());
257    }
258
259    #[test]
260    fn schedule_truth_table() {
261        assert_eq!(schedule(false, false), FrameAction::Stop);
262        assert_eq!(schedule(true, false), FrameAction::Repaint);
263        assert_eq!(schedule(false, true), FrameAction::Repaint);
264        assert_eq!(schedule(true, true), FrameAction::Repaint);
265    }
266
267    #[test]
268    fn first_frame_paints_then_the_loop_stops() {
269        let mut mounted = Mounted::new(tree(), 800.0, 600.0);
270        assert!(mounted.needs_frame());
271        let first = mounted.frame();
272        assert!(matches!(&first, Frame::Painted(commands) if !commands.is_empty()));
273        assert!(first.wants_next());
274        assert!(!Frame::Blocked(String::new()).wants_next());
275        assert_eq!(mounted.frame(), Frame::Idle);
276        assert!(!Frame::Idle.wants_next());
277        assert!(!mounted.needs_frame());
278        assert_eq!(mounted.frames_painted(), 1);
279    }
280
281    #[test]
282    fn a_click_on_the_button_emits_its_message_and_owes_a_repaint() {
283        let mut mounted = Mounted::new(tree(), 800.0, 600.0);
284        let _ = mounted.frame();
285        let at = find_button(&mut mounted).expect("the button is on the surface");
286        assert!(click(&mut mounted, at));
287        let messages = mounted.take_messages();
288        assert_eq!(messages.len(), 1);
289        assert!(messages[0].downcast_ref::<ButtonClicked>().is_some());
290        assert!(mounted.take_messages().is_empty());
291        assert!(matches!(mounted.frame(), Frame::Painted(_)));
292        assert_eq!(mounted.frames_painted(), 2);
293    }
294
295    #[test]
296    fn a_click_beside_the_button_emits_nothing_but_still_repaints() {
297        let mut mounted = Mounted::new(tree(), 800.0, 600.0);
298        let _ = mounted.frame();
299        assert!(!click(&mut mounted, Point::new(790.0, 590.0)));
300        assert!(mounted.take_messages().is_empty());
301        assert!(mounted.needs_frame());
302    }
303
304    #[test]
305    fn the_tree_is_laid_out_before_the_first_frame() {
306        let mut mounted = Mounted::new(tree(), 800.0, 600.0);
307        assert!(find_button(&mut mounted).is_some());
308    }
309
310    #[test]
311    fn animating_repaints_every_tick_until_switched_off() {
312        let mut mounted = Mounted::new(tree(), 800.0, 600.0);
313        mounted.set_animating(true);
314        for _ in 0..3 {
315            assert!(matches!(mounted.frame(), Frame::Painted(_)));
316        }
317        assert_eq!(mounted.frames_painted(), 3);
318        mounted.set_animating(false);
319        assert_eq!(mounted.frame(), Frame::Idle);
320    }
321
322    #[test]
323    fn request_repaint_owes_exactly_one_frame() {
324        let mut mounted = Mounted::new(tree(), 800.0, 600.0);
325        let _ = mounted.frame();
326        mounted.request_repaint();
327        assert!(matches!(mounted.frame(), Frame::Painted(_)));
328        assert_eq!(mounted.frame(), Frame::Idle);
329    }
330
331    #[test]
332    fn resize_changes_the_surface_and_repaints() {
333        let mut mounted = Mounted::new(tree(), 800.0, 600.0);
334        let _ = mounted.frame();
335        assert!(!mounted.dispatch(&Event::Resize {
336            width: 320.0,
337            height: 240.0,
338        }));
339        assert_eq!(mounted.size(), Size::new(320.0, 240.0));
340        assert_eq!(mounted.root_mut().children().len(), 2);
341        assert!(matches!(mounted.frame(), Frame::Painted(_)));
342    }
343
344    #[test]
345    fn set_root_swaps_the_tree_and_owes_a_repaint() {
346        let mut mounted = Mounted::new(counter_tree(0), 800.0, 600.0);
347        let before = mounted.frame();
348        assert_eq!(mounted.frame(), Frame::Idle);
349        mounted.set_root(counter_tree(1));
350        let after = mounted.frame();
351        assert!(matches!(after, Frame::Painted(_)));
352        assert_ne!(
353            before, after,
354            "the count text changed, so the commands differ"
355        );
356    }
357
358    #[test]
359    fn a_swapped_tree_is_laid_out_before_its_first_event() {
360        let mut mounted = Mounted::new(counter_tree(0), 800.0, 600.0);
361        let _ = mounted.frame();
362        mounted.set_root(counter_tree(1));
363        assert!(
364            find_button(&mut mounted).is_some(),
365            "a click right after the swap, before any frame, reaches the new button"
366        );
367    }
368
369    #[test]
370    fn a_brick_that_fails_verification_is_not_painted_and_does_not_spin() {
371        let mut mounted = Mounted::new(Box::new(Button::new("")), 800.0, 600.0);
372        let blocked = mounted.frame();
373        assert!(
374            matches!(&blocked, Frame::Blocked(why) if why.contains("no label")),
375            "{blocked:?}"
376        );
377        assert!(!blocked.wants_next());
378        assert_eq!(mounted.frames_painted(), 0);
379        assert_eq!(mounted.frame(), Frame::Idle);
380    }
381
382    #[test]
383    fn a_bad_brick_inside_a_container_blocks_the_frame() {
384        let nested = Column::new()
385            .child(Text::new("fine"))
386            .child(Column::new().child(Button::new("")));
387        let mut mounted = Mounted::new(Box::new(nested), 800.0, 600.0);
388        let blocked = mounted.frame();
389        assert!(
390            matches!(&blocked, Frame::Blocked(why) if why.contains("no label")),
391            "{blocked:?}"
392        );
393        assert_eq!(mounted.frames_painted(), 0);
394    }
395
396    #[test]
397    fn an_animating_tree_that_is_blocked_does_not_spin() {
398        let mut mounted = Mounted::new(Box::new(Button::new("")), 800.0, 600.0);
399        mounted.set_animating(true);
400        assert!(matches!(mounted.frame(), Frame::Blocked(_)));
401        assert!(!mounted.needs_frame());
402        for _ in 0..3 {
403            assert_eq!(mounted.frame(), Frame::Idle);
404        }
405    }
406
407    #[test]
408    fn a_blocked_tree_is_verified_again_after_a_change_and_the_animation_resumes() {
409        let mut mounted = Mounted::new(Box::new(Button::new("")), 800.0, 600.0);
410        mounted.set_animating(true);
411        assert!(matches!(mounted.frame(), Frame::Blocked(_)));
412        // An event on a tree that is still bad: one more verification, then quiet again.
413        mounted.request_repaint();
414        assert!(matches!(mounted.frame(), Frame::Blocked(_)));
415        assert_eq!(mounted.frame(), Frame::Idle);
416        // A good tree: painted, and the animation ticks again.
417        mounted.set_root(tree());
418        for _ in 0..3 {
419            assert!(matches!(mounted.frame(), Frame::Painted(_)));
420        }
421        assert_eq!(mounted.frames_painted(), 3);
422    }
423
424    // aprender#6893 clause 4: a failing brick is never painted, wherever it sits. Every widget
425    // that owns a boxed child and paints it must expose it through `children()`.
426    fn bad() -> Button {
427        Button::new("")
428    }
429
430    fn assert_blocked(root: Box<dyn Widget>, what: &str) {
431        let mut mounted = Mounted::new(root, 800.0, 600.0);
432        assert!(
433            matches!(mounted.frame(), Frame::Blocked(_)),
434            "{what}: bad child was painted"
435        );
436        assert_eq!(mounted.frames_painted(), 0, "{what}");
437    }
438
439    #[test]
440    fn a_default_disabled_button_does_not_block_the_frame() {
441        let mut mounted = Mounted::new(Box::new(Button::new("+").disabled(true)), 800.0, 600.0);
442        assert!(matches!(mounted.frame(), Frame::Painted(_)));
443    }
444
445    #[test]
446    fn column_blocks_a_bad_child() {
447        assert_blocked(Box::new(Column::new().child(bad())), "Column");
448    }
449
450    #[test]
451    fn row_blocks_a_bad_child() {
452        assert_blocked(Box::new(presentar_widgets::Row::new().child(bad())), "Row");
453    }
454
455    #[test]
456    fn stack_blocks_a_bad_child() {
457        assert_blocked(
458            Box::new(presentar_widgets::Stack::new().child(bad())),
459            "Stack",
460        );
461    }
462
463    #[test]
464    fn container_blocks_a_bad_child() {
465        assert_blocked(
466            Box::new(presentar_widgets::Container::new().child(bad())),
467            "Container",
468        );
469    }
470
471    #[test]
472    fn list_blocks_a_bad_child() {
473        let list = presentar_widgets::List::new()
474            .items(vec![presentar_widgets::ListItem::new("a")])
475            .render_with(|_, _| Box::new(Button::new("")));
476        assert_blocked(Box::new(list), "List");
477    }
478
479    #[test]
480    fn list_blocks_a_bad_child_materialized_by_keyboard_scroll() {
481        let items: Vec<presentar_widgets::ListItem> = (0..20)
482            .map(|i| presentar_widgets::ListItem::new(format!("item-{i}")))
483            .collect();
484        let list = presentar_widgets::List::new()
485            .item_height(50.0)
486            .items(items)
487            .render_with(|i, _| {
488                if i == 19 {
489                    Box::new(Button::new(""))
490                } else {
491                    Box::new(Button::new("ok"))
492                }
493            });
494        let mut mounted = Mounted::new(Box::new(list), 200.0, 100.0);
495        assert!(matches!(mounted.frame(), Frame::Painted(_)));
496        let _ = mounted.dispatch(&Event::MouseDown {
497            position: presentar_core::Point::new(10.0, 10.0),
498            button: presentar_core::MouseButton::Left,
499        });
500        assert!(matches!(mounted.frame(), Frame::Painted(_)));
501        let _ = mounted.dispatch(&Event::key_down(presentar_core::Key::End));
502        assert!(
503            matches!(mounted.frame(), Frame::Blocked(_)),
504            "scrolled-in bad child was painted"
505        );
506    }
507
508    #[test]
509    fn menu_blocks_a_bad_trigger_widget() {
510        assert_blocked(
511            Box::new(presentar_widgets::Menu::new().trigger_widget(bad())),
512            "Menu",
513        );
514    }
515
516    #[test]
517    fn modal_blocks_a_bad_content() {
518        assert_blocked(
519            Box::new(presentar_widgets::Modal::new().open(true).content(bad())),
520            "Modal content",
521        );
522    }
523
524    #[test]
525    fn modal_blocks_a_bad_footer() {
526        assert_blocked(
527            Box::new(presentar_widgets::Modal::new().open(true).footer(bad())),
528            "Modal footer",
529        );
530    }
531
532    #[test]
533    fn modal_blocks_when_only_the_footer_is_bad_and_content_is_good() {
534        assert_blocked(
535            Box::new(
536                presentar_widgets::Modal::new()
537                    .open(true)
538                    .content(Button::new("ok"))
539                    .footer(bad()),
540            ),
541            "Modal content+footer",
542        );
543    }
544}