rlvgl-widgets 0.2.5

Built-in widgets for rlvgl.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
//! Tests for the analog clock widget: angle math, tick outcomes, dirty
//! union math, and end-to-end Widget integration with a recording renderer.

use rlvgl_core::event::Event;
use rlvgl_core::raster::Obb;
use rlvgl_core::renderer::Renderer;
use rlvgl_core::widget::{Color, Rect, Widget};
use rlvgl_widgets::clock::{
    AnalogHand, CenterCap, Clock, ClockFace, ClockLayer, ClockState, ClockTime, SubsecondDot,
    TickMark, TickOutcome,
};

const FACE: Rect = Rect {
    x: 50,
    y: 50,
    width: 200,
    height: 200,
};
const RED: Color = Color(255, 0, 0, 255);

/// Recording renderer that captures every call. Used to assert the clock
/// emits the right primitives in the right order.
#[derive(Default)]
struct Recorder {
    obbs: Vec<(Obb, Color)>,
    rects: Vec<(Rect, Color)>,
    rows: Vec<(i32, i32, Color, usize)>,
}

impl Renderer for Recorder {
    fn fill_rect(&mut self, rect: Rect, color: Color) {
        self.rects.push((rect, color));
    }
    fn draw_text(&mut self, _position: (i32, i32), _text: &str, _color: Color) {}
    fn fill_obb_aa(&mut self, obb: Obb, color: Color) {
        self.obbs.push((obb, color));
    }
    fn blend_row(&mut self, x: i32, y: i32, color: Color, coverage: &[u8]) {
        self.rows.push((x, y, color, coverage.len()));
    }
}

fn approx_eq(a: f32, b: f32, eps: f32) -> bool {
    (a - b).abs() < eps
}

#[test]
fn time_to_angles_at_canonical_times() {
    use core::f32::consts::{FRAC_PI_2, PI, TAU};

    // 12:00:00 → all hands at 0.
    let a = ClockTime {
        seconds_of_day: 0.0,
    }
    .to_angles();
    assert!(approx_eq(a.hour, 0.0, 1e-3));
    assert!(approx_eq(a.minute, 0.0, 1e-3));
    assert!(approx_eq(a.second, 0.0, 1e-3));

    // 03:00:00 → hour at π/2, minute at 0, second at 0.
    let a = ClockTime {
        seconds_of_day: 3.0 * 3600.0,
    }
    .to_angles();
    assert!(approx_eq(a.hour, FRAC_PI_2, 1e-3), "hour {}", a.hour);
    assert!(approx_eq(a.minute, 0.0, 1e-3));
    assert!(approx_eq(a.second, 0.0, 1e-3));

    // 06:00:00 → hour at π.
    let a = ClockTime {
        seconds_of_day: 6.0 * 3600.0,
    }
    .to_angles();
    assert!(approx_eq(a.hour, PI, 1e-3), "hour {}", a.hour);

    // 00:00:30 → second at π (half a minute).
    let a = ClockTime {
        seconds_of_day: 30.0,
    }
    .to_angles();
    assert!(approx_eq(a.second, PI, 1e-3), "second {}", a.second);

    // 00:30:00 → minute at π.
    let a = ClockTime {
        seconds_of_day: 30.0 * 60.0,
    }
    .to_angles();
    assert!(approx_eq(a.minute, PI, 1e-3));

    // 11:00:00 → hour at 11/12 of TAU. Tests modular reduction near the
    // top of the period without hitting f32 precision limits at very
    // sub-second deltas (43200 - 0.001 quantizes to 43200 in f32).
    let a = ClockTime {
        seconds_of_day: 11.0 * 3600.0,
    }
    .to_angles();
    let expected = TAU * 11.0 / 12.0;
    assert!(
        approx_eq(a.hour, expected, 1e-3),
        "hour {} vs {}",
        a.hour,
        expected
    );
}

#[test]
fn first_tick_is_full_repaint() {
    let mut clock = Clock::new(FACE);
    clock.push_layer(AnalogHand::second(RED));

    let outcome = clock.set_target_time(ClockTime {
        seconds_of_day: 0.0,
    });
    match outcome {
        TickOutcome::FullRepaint {
            dirty_px,
            layers_painted,
        } => {
            assert!(dirty_px > 0, "first tick should have non-zero dirty pixels");
            assert_eq!(layers_painted, 1);
        }
        other => panic!("expected FullRepaint, got {other:?}"),
    }
    assert_eq!(clock.last_outcome(), outcome);
}

#[test]
fn second_tick_with_motion_is_painted() {
    let mut clock = Clock::new(FACE);
    clock.push_layer(AnalogHand::second(RED));

    let _ = clock.set_target_time(ClockTime {
        seconds_of_day: 0.0,
    });
    let outcome = clock.set_target_time(ClockTime {
        seconds_of_day: 0.5,
    });
    match outcome {
        TickOutcome::Painted {
            dirty_px,
            layers_painted,
        } => {
            assert!(dirty_px > 0);
            assert_eq!(layers_painted, 1);
        }
        other => panic!("expected Painted, got {other:?}"),
    }
}

#[test]
fn identical_time_after_first_paint_is_skipped() {
    let mut clock = Clock::new(FACE);
    clock.push_layer(AnalogHand::second(RED));

    let _ = clock.set_target_time(ClockTime {
        seconds_of_day: 5.0,
    });
    let outcome = clock.set_target_time(ClockTime {
        seconds_of_day: 5.0,
    });
    assert_eq!(outcome, TickOutcome::Skipped);
}

#[test]
fn invalidate_forces_full_repaint() {
    let mut clock = Clock::new(FACE);
    clock.push_layer(AnalogHand::second(RED));

    let _ = clock.set_target_time(ClockTime {
        seconds_of_day: 5.0,
    });
    clock.invalidate();
    let outcome = clock.set_target_time(ClockTime {
        seconds_of_day: 5.0,
    });
    assert!(
        matches!(outcome, TickOutcome::FullRepaint { .. }),
        "got {outcome:?}"
    );
}

#[test]
fn clear_region_returns_dirty_union_then_clears() {
    let mut clock = Clock::new(FACE);
    clock.push_layer(AnalogHand::second(RED));

    let _ = clock.set_target_time(ClockTime {
        seconds_of_day: 0.0,
    });
    let r = clock.clear_region().expect("should have dirty rect");
    // Second-hand AABB sits inside the face bounds (with AA padding).
    assert!(r.x >= FACE.x - 1 && r.y >= FACE.y - 1);
    assert!(r.x + r.width <= FACE.x + FACE.width + 1);
    assert!(r.y + r.height <= FACE.y + FACE.height + 1);
    // Once consumed, gone until the next set_target_time.
    assert!(clock.clear_region().is_none());
}

#[test]
fn dirty_union_grows_with_hand_sweep() {
    let mut clock = Clock::new(FACE);
    clock.push_layer(AnalogHand::second(RED));

    let _ = clock.set_target_time(ClockTime {
        seconds_of_day: 0.0,
    });
    let _ = clock.clear_region();
    let _ = clock.set_target_time(ClockTime {
        seconds_of_day: 7.5,
    });

    let r = clock.clear_region().expect("dirty after sweep");
    // After a 45° sweep the union of prev+current OBB AABBs should be
    // larger than a single hand's AABB. Lower bound: must span more than
    // half the face along at least one axis.
    let half_face = FACE.width.min(FACE.height) / 2;
    assert!(
        r.width > half_face || r.height > half_face,
        "swept union should span >half face; got {r:?}"
    );
}

#[test]
fn draw_emits_obb_per_layer_within_dirty() {
    let mut clock = Clock::new(FACE);
    clock.push_layer(AnalogHand::hour(RED));
    clock.push_layer(AnalogHand::minute(RED));
    clock.push_layer(AnalogHand::second(RED));

    let _ = clock.set_target_time(ClockTime {
        seconds_of_day: 4.0 * 3600.0 + 30.0 * 60.0 + 15.0,
    });
    let _ = clock.clear_region();

    let mut recorder = Recorder::default();
    clock.draw(&mut recorder);
    assert_eq!(
        recorder.obbs.len(),
        3,
        "every layer should have emitted exactly one fill_obb_aa"
    );
    for (_obb, color) in &recorder.obbs {
        assert_eq!(*color, RED);
    }
}

#[test]
fn second_layer_alone_repaints_when_only_second_changed() {
    let mut clock = Clock::new(FACE);
    clock.push_layer(AnalogHand::hour(RED));
    clock.push_layer(AnalogHand::minute(RED));
    clock.push_layer(AnalogHand::second(RED));

    let t0 = ClockTime {
        seconds_of_day: 4.0 * 3600.0 + 30.0 * 60.0 + 15.0,
    };
    let _ = clock.set_target_time(t0);
    let _ = clock.clear_region();

    // Advance by one second — at this scale the hour hand AABB barely
    // shifts and the minute hand's bbox doesn't intersect the second-hand
    // sweep arc near its tip. The hour and minute layers may or may not be
    // pulled into the repaint via cross-layer touch, but the second hand
    // must always be painted.
    let _ = clock.set_target_time(ClockTime {
        seconds_of_day: t0.seconds_of_day + 1.0,
    });

    if let TickOutcome::Painted { layers_painted, .. } = clock.last_outcome() {
        assert!(
            (1..=3).contains(&layers_painted),
            "expected 1-3 layers painted, got {layers_painted}"
        );
    } else {
        panic!("expected Painted, got {:?}", clock.last_outcome());
    }
}

#[test]
fn handle_event_does_not_consume() {
    let mut clock = Clock::new(FACE);
    clock.push_layer(AnalogHand::second(RED));
    assert!(!clock.handle_event(&Event::Tick));
}

#[test]
fn analog_hand_obb_points_at_tip_at_three_oclock() {
    // 03:00:00 → hour hand should point at (cx + len, cy) — i.e.
    // OBB axis ≈ (1, 0) at 3 o'clock.
    let hand = AnalogHand::hour(RED);
    let state = ClockState {
        time: ClockTime {
            seconds_of_day: 3.0 * 3600.0,
        },
        angles: ClockTime {
            seconds_of_day: 3.0 * 3600.0,
        }
        .to_angles(),
    };
    let bbox = hand.bbox(&state, FACE);
    let cx = FACE.x + FACE.width / 2;
    let cy = FACE.y + FACE.height / 2;
    // Bbox center must be to the right of face center at 3 o'clock.
    let bbox_cx = bbox.x + bbox.width / 2;
    let bbox_cy = bbox.y + bbox.height / 2;
    assert!(
        bbox_cx > cx,
        "3 o'clock hour-hand bbox should be right of face center"
    );
    // At 3 o'clock, the hand is horizontal — bbox center y near face cy.
    assert!(
        (bbox_cy - cy).abs() <= 2,
        "3 o'clock hand should be roughly horizontal; got dy={}",
        bbox_cy - cy
    );
}

#[test]
fn clock_face_pushes_twelve_hour_marks_when_hours_only() {
    let mut clock = Clock::new(FACE);
    let face = ClockFace::hours_only(Color(80, 80, 80, 255));
    face.push_layers(&mut clock);
    // Face contributes 12 layers when hours-only.
    let _ = clock.set_target_time(ClockTime {
        seconds_of_day: 0.0,
    });
    if let TickOutcome::FullRepaint { layers_painted, .. } = clock.last_outcome() {
        assert_eq!(layers_painted, 12, "hours_only should add exactly 12 ticks");
    } else {
        panic!("expected FullRepaint, got {:?}", clock.last_outcome());
    }
}

#[test]
fn clock_face_standard_pushes_60_marks() {
    // 12 hour marks + 48 non-hour-position minute marks = 60 layers.
    let mut clock = Clock::new(FACE);
    let face = ClockFace::standard(Color(80, 80, 80, 255), Color(160, 160, 160, 255));
    face.push_layers(&mut clock);
    let _ = clock.set_target_time(ClockTime {
        seconds_of_day: 0.0,
    });
    if let TickOutcome::FullRepaint { layers_painted, .. } = clock.last_outcome() {
        assert_eq!(layers_painted, 60);
    } else {
        panic!("expected FullRepaint, got {:?}", clock.last_outcome());
    }
}

#[test]
fn tick_marks_are_static_after_first_paint() {
    // After first paint, a tick alone should report Skipped on identical
    // ticks even with `prev` known — it's static.
    let mut clock = Clock::new(FACE);
    clock.push_layer(TickMark {
        angle: 0.0,
        outer_radius: 0.95,
        length: 0.1,
        width: 0.03,
        color: RED,
    });
    let _ = clock.set_target_time(ClockTime {
        seconds_of_day: 0.0,
    });
    let outcome = clock.set_target_time(ClockTime {
        seconds_of_day: 0.5,
    });
    assert_eq!(
        outcome,
        TickOutcome::Skipped,
        "static tick alone should yield Skipped on motion in other (absent) layers"
    );
}

#[test]
fn union_expands_to_cover_full_face_when_hand_crosses_tick() {
    // A second hand swept across one tick must expand the dirty union to
    // include the entire tick AABB, not just the sweep area, so pristine
    // restore covers everything before AA blend.
    let mut clock = Clock::new(FACE);
    // Place a tick at 3 o'clock — the second hand will sweep across it
    // when angles transition from ~just-before-3 to ~just-after-3.
    clock.push_layer(TickMark {
        angle: core::f32::consts::FRAC_PI_2, // 3 o'clock
        outer_radius: 0.95,
        length: 0.10,
        width: 0.03,
        color: Color(80, 80, 80, 255),
    });
    clock.push_layer(AnalogHand::second(RED));

    // First tick: full repaint.
    let _ = clock.set_target_time(ClockTime {
        seconds_of_day: 14.0,
    });
    let _ = clock.clear_region();

    // Tick across: second hand sweeps from ~14s position to ~16s
    // position, crossing 3 o'clock (15s = π/2).
    let _ = clock.set_target_time(ClockTime {
        seconds_of_day: 16.0,
    });
    let union = clock
        .clear_region()
        .expect("dirty union should be present after sweep");

    // The 3-o'clock tick has its outer end at ~ (face_cx + 0.95*r, face_cy).
    // Its bbox must be fully inside the reported union.
    let cx = FACE.x + FACE.width / 2;
    let cy = FACE.y + FACE.height / 2;
    let r = FACE.width.min(FACE.height) / 2;
    let tick_outer_x = cx + (r * 95) / 100;
    assert!(
        union.x + union.width > tick_outer_x,
        "union right edge {} should reach past tick outer x {}; union={:?}",
        union.x + union.width,
        tick_outer_x,
        union
    );
    assert!(
        union.y <= cy && union.y + union.height >= cy,
        "union should span face center y {cy}; union={union:?}"
    );
}

#[test]
fn center_cap_is_static_and_emits_disc() {
    // Cap added to a clock with a hand: first tick paints; second tick
    // (with hand motion) yields Painted, and the cap layer is among
    // those repainted because its bbox sits at the face center where the
    // hands rotate.
    let mut clock = Clock::new(FACE);
    clock.push_layer(AnalogHand::second(RED));
    clock.push_layer(CenterCap::standard(Color(20, 20, 20, 255)));

    let _ = clock.set_target_time(ClockTime {
        seconds_of_day: 0.0,
    });
    let outcome = clock.set_target_time(ClockTime {
        seconds_of_day: 0.5,
    });
    if let TickOutcome::Painted { layers_painted, .. } = outcome {
        assert_eq!(
            layers_painted, 2,
            "second-hand sweep over center cap should repaint both layers"
        );
    } else {
        panic!("expected Painted, got {outcome:?}");
    }
}

#[test]
fn center_cap_alone_yields_skipped_after_first_paint() {
    // A static layer alone should never produce dirty after first paint.
    let mut clock = Clock::new(FACE);
    clock.push_layer(CenterCap::standard(Color(20, 20, 20, 255)));
    let _ = clock.set_target_time(ClockTime {
        seconds_of_day: 0.0,
    });
    let outcome = clock.set_target_time(ClockTime {
        seconds_of_day: 30.0,
    });
    assert_eq!(outcome, TickOutcome::Skipped);
}

#[test]
fn subsecond_dot_orbits_at_subsecond_rate() {
    // Dot bbox at fractional seconds 0.0 (top, 12 o'clock) and 0.5
    // (bottom, 6 o'clock) must straddle the face center along y.
    let dot = SubsecondDot::standard(Color(200, 200, 30, 255));
    let cy = FACE.y + FACE.height / 2;

    let s_top = ClockState {
        time: ClockTime {
            seconds_of_day: 10.0,
        }, // frac = 0
        angles: ClockTime {
            seconds_of_day: 10.0,
        }
        .to_angles(),
    };
    let bbox_top = dot.bbox(&s_top, FACE);
    let bbox_top_cy = bbox_top.y + bbox_top.height / 2;
    assert!(
        bbox_top_cy < cy,
        "frac=0 dot should be above face center; got {bbox_top_cy} vs cy {cy}"
    );

    let s_bot = ClockState {
        time: ClockTime {
            seconds_of_day: 10.5,
        }, // frac = 0.5
        angles: ClockTime {
            seconds_of_day: 10.5,
        }
        .to_angles(),
    };
    let bbox_bot = dot.bbox(&s_bot, FACE);
    let bbox_bot_cy = bbox_bot.y + bbox_bot.height / 2;
    assert!(
        bbox_bot_cy > cy,
        "frac=0.5 dot should be below face center; got {bbox_bot_cy} vs cy {cy}"
    );
}

#[test]
fn subsecond_dot_dirty_empty_when_time_unchanged() {
    let mut clock = Clock::new(FACE);
    clock.push_layer(SubsecondDot::standard(Color(200, 200, 30, 255)));
    let _ = clock.set_target_time(ClockTime {
        seconds_of_day: 10.25,
    });
    let outcome = clock.set_target_time(ClockTime {
        seconds_of_day: 10.25,
    });
    assert_eq!(outcome, TickOutcome::Skipped);
}

#[test]
fn subsecond_dot_dirty_grows_with_motion() {
    let mut clock = Clock::new(FACE);
    clock.push_layer(SubsecondDot::standard(Color(200, 200, 30, 255)));
    let _ = clock.set_target_time(ClockTime {
        seconds_of_day: 10.0,
    });
    let _ = clock.clear_region();

    // Quarter-orbit advance: dot moves 90° around the orbit.
    let _ = clock.set_target_time(ClockTime {
        seconds_of_day: 10.25,
    });
    let dirty = clock.clear_region().expect("non-empty after motion");
    // Union must span at least the orbit chord (orbit_radius * face_radius).
    let face_r = FACE.width.min(FACE.height) / 2;
    let orbit_r = (face_r * 65) / 100; // matches SubsecondDot::standard.orbit_radius
    assert!(
        dirty.width >= orbit_r || dirty.height >= orbit_r,
        "quarter-orbit dirty union should reach orbit radius; got {dirty:?} (orbit_r={orbit_r})"
    );
}

#[test]
fn analog_hand_obb_points_up_at_twelve() {
    let hand = AnalogHand::minute(RED);
    let state = ClockState {
        time: ClockTime {
            seconds_of_day: 0.0,
        },
        angles: ClockTime {
            seconds_of_day: 0.0,
        }
        .to_angles(),
    };
    let bbox = hand.bbox(&state, FACE);
    let cx = FACE.x + FACE.width / 2;
    let cy = FACE.y + FACE.height / 2;
    let bbox_cx = bbox.x + bbox.width / 2;
    let bbox_cy = bbox.y + bbox.height / 2;
    assert!(
        (bbox_cx - cx).abs() <= 2,
        "12 o'clock hand should be roughly vertical; got dx={}",
        bbox_cx - cx
    );
    assert!(
        bbox_cy < cy,
        "12 o'clock hand bbox center should be above face center"
    );
}