1use presentar_core::draw::DrawCommand;
12use presentar_core::{Constraints, Event, RecordingCanvas, Rect, Size, Widget};
13use std::any::Any;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum FrameAction {
18 Repaint,
20 Stop,
22}
23
24#[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#[derive(Debug, Clone, PartialEq)]
37pub enum Frame {
38 Idle,
40 Painted(Vec<DrawCommand>),
42 Blocked(String),
44}
45
46impl Frame {
47 #[must_use]
50 pub const fn wants_next(&self) -> bool {
51 matches!(self, Self::Painted(_))
52 }
53}
54
55pub struct Mounted {
57 root: Box<dyn Widget>,
58 size: Size,
59 dirty: bool,
60 animating: bool,
61 halted: bool,
64 messages: Vec<Box<dyn Any + Send>>,
65 painted: u64,
66}
67
68fn 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 #[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 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 const fn ticking(&self) -> bool {
126 self.animating && !self.halted
127 }
128
129 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 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 pub fn set_animating(&mut self, animating: bool) {
153 self.animating = animating;
154 }
155
156 pub fn request_repaint(&mut self) {
158 self.dirty = true;
159 }
160
161 #[must_use]
163 pub const fn needs_frame(&self) -> bool {
164 matches!(schedule(self.dirty, self.ticking()), FrameAction::Repaint)
165 }
166
167 pub fn take_messages(&mut self) -> Vec<Box<dyn Any + Send>> {
169 std::mem::take(&mut self.messages)
170 }
171
172 #[must_use]
174 pub const fn frames_painted(&self) -> u64 {
175 self.painted
176 }
177
178 pub fn root_mut(&mut self) -> &mut dyn Widget {
180 self.root.as_mut()
181 }
182
183 pub fn set_root(&mut self, root: Box<dyn Widget>) {
185 self.root = root;
186 self.relayout();
187 self.dirty = true;
188 }
189
190 #[must_use]
192 pub const fn size(&self) -> Size {
193 self.size
194 }
195}
196
197#[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 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 mounted.request_repaint();
414 assert!(matches!(mounted.frame(), Frame::Blocked(_)));
415 assert_eq!(mounted.frame(), Frame::Idle);
416 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 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}