1use geometry_core::{Rect, Transform};
2use smallvec::{SmallVec, smallvec};
3
4use crate::{DrawCommand, culling, culling::FontMetrics, draw_state::DrawState};
5
6fn advance_matrix(state: &mut DrawState, cmd: &DrawCommand) {
8 match cmd {
9 DrawCommand::PushMatrix { matrix } => state.push_matrix(*matrix),
10 DrawCommand::PopMatrix => state.pop_matrix(),
11 _ => {}
12 }
13}
14
15pub type DirtyRects = SmallVec<[Rect; 8]>;
17
18const MAX_DIRTY_RECTS: usize = 4;
20
21fn rects_adjacent_or_overlapping(a: Rect, b: Rect, slop: f32) -> bool {
23 a.x <= b.x + b.width + slop
24 && b.x <= a.x + a.width + slop
25 && a.y <= b.y + b.height + slop
26 && b.y <= a.y + a.height + slop
27}
28
29fn push_dirty_rect(rects: &mut DirtyRects, r: Rect) {
31 const SLOP: f32 = 1.0;
33 if let Some(idx) = rects
34 .iter()
35 .position(|e| rects_adjacent_or_overlapping(*e, r, SLOP))
36 {
37 let mut merged = rects[idx].union(r);
38 rects.swap_remove(idx);
39 let mut i = 0;
41 while i < rects.len() {
42 if rects_adjacent_or_overlapping(rects[i], merged, SLOP) {
43 merged = rects[i].union(merged);
44 rects.swap_remove(i);
45 } else {
46 i += 1;
47 }
48 }
49 rects.push(merged);
50 } else {
51 rects.push(r);
52 }
53
54 if rects.len() > MAX_DIRTY_RECTS {
55 let union = rects
56 .iter()
57 .copied()
58 .reduce(Rect::union)
59 .expect("non-empty");
60 *rects = smallvec![union];
61 }
62}
63
64pub struct ScrollBlit {
66 pub scroll_clip: Rect,
68 pub delta_x: i32,
70 pub delta_y: i32,
72 pub exposed_band: Rect,
74 pub extra_dirty: SmallVec<[Rect; 8]>,
76}
77
78const MAX_SCROLL_EXTRA_DIRTY: usize = 8;
80
81fn matrix_as_translation(m: &[f32; 6]) -> Option<(f32, f32)> {
82 if m[0] == 1.0 && m[1] == 0.0 && m[2] == 0.0 && m[3] == 1.0 {
83 Some((m[4], m[5]))
84 } else {
85 None
86 }
87}
88
89fn displaced_region(new_r: Option<Rect>, old_r: Option<Rect>, dx: f32, dy: f32) -> Option<Rect> {
91 let ghost = old_r.map(|r| Rect::new(r.x + dx, r.y + dy, r.width, r.height));
92 match (new_r, ghost) {
93 (Some(a), Some(b)) => Some(a.union(b)),
94 (Some(a), None) => Some(a),
95 (None, Some(b)) => Some(b),
96 (None, None) => None,
97 }
98}
99
100pub fn compute_dirty_rect(
102 new_cmds: &[DrawCommand],
103 old_cmds: &[DrawCommand],
104 visual_rect: impl Fn(&DrawCommand, [f32; 6]) -> Option<Rect>,
105) -> Option<DirtyRects> {
106 if new_cmds.len() != old_cmds.len() {
107 return None;
108 }
109
110 let mut dirty: DirtyRects = SmallVec::new();
111 let mut new_state = DrawState::new();
113 let mut old_state = DrawState::new();
114
115 for (new_cmd, old_cmd) in new_cmds.iter().zip(old_cmds.iter()) {
116 advance_matrix(&mut new_state, new_cmd);
117 advance_matrix(&mut old_state, old_cmd);
118 let new_matrix = new_state.cumulative_matrix;
119 let old_matrix = old_state.cumulative_matrix;
120
121 if new_cmd != old_cmd {
122 if matches!(
124 new_cmd,
125 DrawCommand::PushClip { .. } | DrawCommand::PushLayer { .. }
126 ) {
127 return None;
128 }
129 if let Some(r) = visual_rect(new_cmd, new_matrix) {
130 push_dirty_rect(&mut dirty, r);
131 }
132 if let Some(r) = visual_rect(old_cmd, old_matrix) {
133 push_dirty_rect(&mut dirty, r);
134 }
135 } else {
136 let new_r = visual_rect(new_cmd, new_matrix);
138 let old_r = visual_rect(old_cmd, old_matrix);
139 if new_r != old_r {
140 if let Some(r) = new_r {
141 push_dirty_rect(&mut dirty, r);
142 }
143 if let Some(r) = old_r {
144 push_dirty_rect(&mut dirty, r);
145 }
146 }
147 }
148 }
149
150 if dirty.is_empty() { None } else { Some(dirty) }
152}
153
154pub fn detect_scroll_blit(
156 new_cmds: &[DrawCommand],
157 old_cmds: &[DrawCommand],
158) -> Option<ScrollBlit> {
159 if new_cmds.len() != old_cmds.len() {
160 return None;
161 }
162
163 let n = new_cmds.len();
164
165 let scroll_idx = new_cmds
167 .iter()
168 .zip(old_cmds.iter())
169 .position(|(nc, oc)| nc != oc)?;
170
171 let (delta_x_f, delta_y_f) = match (&new_cmds[scroll_idx], &old_cmds[scroll_idx]) {
172 (DrawCommand::PushMatrix { matrix: nm }, DrawCommand::PushMatrix { matrix: om }) => {
173 match (matrix_as_translation(nm), matrix_as_translation(om)) {
174 (Some((ntx, nty)), Some((otx, oty))) if ntx == otx => (0.0f32, nty - oty),
175 (Some((ntx, nty)), Some((otx, oty))) if nty == oty => (ntx - otx, 0.0f32),
176 _ => return None,
177 }
178 }
179 _ => return None,
180 };
181
182 let mut clip_stack: Vec<Rect> = Vec::new();
184 for cmd in &new_cmds[..scroll_idx] {
185 match cmd {
186 DrawCommand::PushClip { rect, .. } => {
187 let effective = clip_stack
188 .last()
189 .and_then(|&c| c.intersect(*rect))
190 .unwrap_or(*rect);
191 clip_stack.push(effective);
192 }
193 DrawCommand::PopClip => {
194 clip_stack.pop();
195 }
196 _ => {}
197 }
198 }
199
200 let scroll_clip = *clip_stack.last()?;
201
202 let delta_x = delta_x_f as i32;
203 let delta_y = delta_y_f as i32;
204
205 if delta_x != 0 && (delta_x.abs() as f32) >= scroll_clip.width {
207 return None;
208 }
209 if delta_y != 0 && (delta_y.abs() as f32) >= scroll_clip.height {
210 return None;
211 }
212
213 let (dx_f, dy_f) = (delta_x as f32, delta_y as f32);
214 let mut extra_dirty: SmallVec<[Rect; 8]> = SmallVec::new();
216
217 for c in &new_cmds[..scroll_idx] {
219 let r = culling::command_visual_rect(
220 c,
221 Transform::IDENTITY.to_array(),
222 &FontMetrics::default(),
223 );
224 if let Some(region) = displaced_region(r, r, dx_f, dy_f) {
225 extra_dirty.push(region);
226 if extra_dirty.len() > MAX_SCROLL_EXTRA_DIRTY {
227 return None;
228 }
229 }
230 }
231
232 let mut depth = 1i32;
234 let mut pop_idx = None;
235 let mut i = scroll_idx + 1;
236 while i < n {
237 match &new_cmds[i] {
238 DrawCommand::PushMatrix { .. } => depth += 1,
239 DrawCommand::PopMatrix => {
240 depth -= 1;
241 if depth == 0 {
242 pop_idx = Some(i);
243 break;
244 }
245 }
246 _ => {}
247 }
248 i += 1;
249 }
250
251 let pop_idx = pop_idx?;
252
253 for j in (scroll_idx + 1)..pop_idx {
255 if new_cmds[j] != old_cmds[j] {
256 return None;
257 }
258 }
259
260 let exposed_band = if delta_x != 0 {
261 if delta_x < 0 {
262 let band_w = (-delta_x) as f32;
264 Rect::new(
265 scroll_clip.x + scroll_clip.width - band_w,
266 scroll_clip.y,
267 band_w,
268 scroll_clip.height,
269 )
270 } else {
271 let band_w = delta_x as f32;
273 Rect::new(scroll_clip.x, scroll_clip.y, band_w, scroll_clip.height)
274 }
275 } else if delta_y < 0 {
276 let band_h = (-delta_y) as f32;
278 Rect::new(
279 scroll_clip.x,
280 scroll_clip.y + scroll_clip.height - band_h,
281 scroll_clip.width,
282 band_h,
283 )
284 } else {
285 let band_h = delta_y as f32;
287 Rect::new(scroll_clip.x, scroll_clip.y, scroll_clip.width, band_h)
288 };
289
290 let mut state = DrawState::new();
292 for cmd in &new_cmds[..=pop_idx] {
293 advance_matrix(&mut state, cmd);
294 }
295
296 for j in (pop_idx + 1)..n {
298 advance_matrix(&mut state, &new_cmds[j]);
299 let cmd_matrix = state.cumulative_matrix;
300 let new_r = culling::command_visual_rect(&new_cmds[j], cmd_matrix, &FontMetrics::default());
301 let old_r = culling::command_visual_rect(&old_cmds[j], cmd_matrix, &FontMetrics::default());
302 if let Some(region) = displaced_region(new_r, old_r, dx_f, dy_f) {
303 extra_dirty.push(region);
304 if extra_dirty.len() > MAX_SCROLL_EXTRA_DIRTY {
305 return None;
306 }
307 }
308 }
309
310 Some(ScrollBlit {
311 scroll_clip,
312 delta_x,
313 delta_y,
314 exposed_band,
315 extra_dirty,
316 })
317}
318
319#[cfg(test)]
320mod tests {
321 use super::*;
322 use crate::{BorderRadius, DrawCommand, style::RectStyle};
323 use geometry_core::Rect;
324 use std::sync::Arc;
325
326 fn rect_cmd(x: f32, y: f32, w: f32, h: f32) -> DrawCommand {
327 DrawCommand::Rect {
328 rect: Rect::new(x, y, w, h),
329 style: Arc::new(RectStyle::default()),
330 }
331 }
332
333 #[test]
334 fn compute_dirty_rect_len_mismatch_returns_none() {
335 let a = vec![rect_cmd(0.0, 0.0, 10.0, 10.0)];
336 let b = vec![];
337 assert!(
338 compute_dirty_rect(&a, &b, |cmd, m| culling::command_visual_rect(
339 cmd,
340 m,
341 &FontMetrics::default()
342 ))
343 .is_none()
344 );
345 }
346
347 #[test]
349 fn changed_push_layer_opacity_forces_full_render() {
350 let inner = rect_cmd(10.0, 10.0, 50.0, 50.0);
351 let old = vec![
352 DrawCommand::PushLayer {
353 opacity: 0.9,
354 backdrop_blur: 0.0,
355 },
356 inner.clone(),
357 DrawCommand::PopLayer,
358 ];
359 let new = vec![
360 DrawCommand::PushLayer {
361 opacity: 0.8,
362 backdrop_blur: 0.0,
363 },
364 inner,
365 DrawCommand::PopLayer,
366 ];
367 assert!(
368 compute_dirty_rect(&new, &old, |cmd, m| culling::command_visual_rect(
369 cmd,
370 m,
371 &FontMetrics::default()
372 ))
373 .is_none(),
374 "changed layer must not be expressible as a bounded dirty region"
375 );
376 }
377
378 #[test]
379 fn compute_dirty_rect_no_change_returns_none() {
380 let a = vec![rect_cmd(0.0, 0.0, 10.0, 10.0)];
381 assert!(
382 compute_dirty_rect(&a, &a, |cmd, m| culling::command_visual_rect(
383 cmd,
384 m,
385 &FontMetrics::default()
386 ))
387 .is_none()
388 );
389 }
390
391 #[test]
392 fn compute_dirty_rect_single_change() {
393 let old = vec![rect_cmd(0.0, 0.0, 10.0, 10.0)];
394 let new = vec![rect_cmd(5.0, 0.0, 10.0, 10.0)];
395 let rects = compute_dirty_rect(&new, &old, |cmd, m| {
396 culling::command_visual_rect(cmd, m, &FontMetrics::default())
397 })
398 .unwrap();
399 let dirty = rects.iter().copied().reduce(Rect::union).unwrap();
401 assert!(dirty.x <= 0.0);
402 assert!(dirty.x + dirty.width >= 15.0);
403 }
404
405 #[test]
406 fn compute_dirty_rect_disjoint_changes_stay_separate() {
407 let old = vec![
409 rect_cmd(0.0, 0.0, 10.0, 10.0),
410 rect_cmd(500.0, 500.0, 10.0, 10.0),
411 ];
412 let new = vec![
413 rect_cmd(0.0, 0.0, 20.0, 20.0),
414 rect_cmd(500.0, 500.0, 20.0, 20.0),
415 ];
416 let rects = compute_dirty_rect(&new, &old, |cmd, m| {
417 culling::command_visual_rect(cmd, m, &FontMetrics::default())
418 })
419 .unwrap();
420 assert_eq!(rects.len(), 2);
421 for r in &rects {
423 assert!(r.width < 100.0 && r.height < 100.0);
424 }
425 }
426
427 #[test]
428 fn compute_dirty_rect_translate_shift() {
429 let old = vec![
430 DrawCommand::PushMatrix {
431 matrix: [1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
432 },
433 rect_cmd(0.0, 0.0, 10.0, 10.0),
434 DrawCommand::PopMatrix,
435 ];
436 let new = vec![
437 DrawCommand::PushMatrix {
438 matrix: [1.0, 0.0, 0.0, 1.0, 5.0, 5.0],
439 },
440 rect_cmd(0.0, 0.0, 10.0, 10.0),
441 DrawCommand::PopMatrix,
442 ];
443 let rects = compute_dirty_rect(&new, &old, |cmd, m| {
444 culling::command_visual_rect(cmd, m, &FontMetrics::default())
445 })
446 .unwrap();
447 let dirty = rects.iter().copied().reduce(Rect::union).unwrap();
448 assert!(dirty.x <= 0.0);
450 assert!(dirty.y <= 0.0);
451 assert!(dirty.x + dirty.width >= 15.0);
452 assert!(dirty.y + dirty.height >= 15.0);
453 }
454
455 #[test]
456 fn compute_dirty_rect_clip_change_returns_none() {
457 let old = vec![
459 DrawCommand::PushClip {
460 rect: Rect::new(0.0, 0.0, 100.0, 600.0),
461 radius: BorderRadius::zero(),
462 },
463 rect_cmd(0.0, 100.0, 100.0, 20.0),
464 DrawCommand::PopClip,
465 ];
466 let new = vec![
467 DrawCommand::PushClip {
468 rect: Rect::new(0.0, 0.0, 100.0, 400.0),
469 radius: BorderRadius::zero(),
470 },
471 rect_cmd(0.0, 100.0, 100.0, 20.0),
472 DrawCommand::PopClip,
473 ];
474 assert!(
475 compute_dirty_rect(&new, &old, |cmd, m| culling::command_visual_rect(
476 cmd,
477 m,
478 &FontMetrics::default()
479 ))
480 .is_none()
481 );
482 }
483
484 #[test]
485 fn detect_scroll_blit_no_change_returns_none() {
486 let cmds = vec![
487 DrawCommand::PushClip {
488 rect: Rect::new(0.0, 0.0, 100.0, 200.0),
489 radius: BorderRadius::zero(),
490 },
491 DrawCommand::PushMatrix {
492 matrix: [1.0, 0.0, 0.0, 1.0, 0.0, -50.0],
493 },
494 rect_cmd(0.0, 0.0, 100.0, 400.0),
495 DrawCommand::PopMatrix,
496 DrawCommand::PopClip,
497 ];
498 assert!(detect_scroll_blit(&cmds, &cmds).is_none());
499 }
500
501 #[test]
502 fn detect_scroll_blit_repaints_static_visual_before_scroll() {
503 let old = vec![
505 DrawCommand::PushClip {
506 rect: Rect::new(0.0, 0.0, 100.0, 200.0),
507 radius: BorderRadius::zero(),
508 },
509 rect_cmd(0.0, 0.0, 100.0, 30.0), DrawCommand::PushMatrix {
511 matrix: [1.0, 0.0, 0.0, 1.0, 0.0, -50.0],
512 },
513 rect_cmd(0.0, 0.0, 100.0, 400.0),
514 DrawCommand::PopMatrix,
515 DrawCommand::PopClip,
516 ];
517 let new = vec![
518 DrawCommand::PushClip {
519 rect: Rect::new(0.0, 0.0, 100.0, 200.0),
520 radius: BorderRadius::zero(),
521 },
522 rect_cmd(0.0, 0.0, 100.0, 30.0), DrawCommand::PushMatrix {
524 matrix: [1.0, 0.0, 0.0, 1.0, 0.0, -60.0],
525 }, rect_cmd(0.0, 0.0, 100.0, 400.0),
527 DrawCommand::PopMatrix,
528 DrawCommand::PopClip,
529 ];
530 let sb = detect_scroll_blit(&new, &old).expect("blit should apply with a static header");
531 let covers_header = sb
533 .extra_dirty
534 .iter()
535 .any(|r| r.x <= 50.0 && r.x + r.width >= 50.0 && r.y <= 15.0 && r.y + r.height >= 15.0);
536 assert!(covers_header, "header not repainted: {:?}", sb.extra_dirty);
537 }
538
539 #[test]
540 fn detect_scroll_blit_repaints_static_visual_after_scroll() {
541 let old = vec![
543 DrawCommand::PushClip {
544 rect: Rect::new(0.0, 0.0, 100.0, 200.0),
545 radius: BorderRadius::zero(),
546 },
547 DrawCommand::PushMatrix {
548 matrix: [1.0, 0.0, 0.0, 1.0, 0.0, -50.0],
549 },
550 rect_cmd(0.0, 0.0, 100.0, 400.0),
551 DrawCommand::PopMatrix,
552 rect_cmd(0.0, 170.0, 100.0, 30.0), DrawCommand::PopClip,
554 ];
555 let new = vec![
556 DrawCommand::PushClip {
557 rect: Rect::new(0.0, 0.0, 100.0, 200.0),
558 radius: BorderRadius::zero(),
559 },
560 DrawCommand::PushMatrix {
561 matrix: [1.0, 0.0, 0.0, 1.0, 0.0, -60.0],
562 }, rect_cmd(0.0, 0.0, 100.0, 400.0),
564 DrawCommand::PopMatrix,
565 rect_cmd(0.0, 170.0, 100.0, 30.0), DrawCommand::PopClip,
567 ];
568 let sb = detect_scroll_blit(&new, &old).expect("blit should apply with a static footer");
569 let covers_footer = sb.extra_dirty.iter().any(|r| {
571 r.x <= 50.0 && r.x + r.width >= 50.0 && r.y <= 185.0 && r.y + r.height >= 185.0
572 });
573 assert!(covers_footer, "footer not repainted: {:?}", sb.extra_dirty);
574 }
575
576 #[test]
577 fn compute_dirty_rect_nested_matrix_position_change() {
578 let make = |outer_ty: f32| {
580 vec![
581 DrawCommand::PushMatrix {
582 matrix: [1.0, 0.0, 0.0, 1.0, 0.0, outer_ty],
583 },
584 DrawCommand::PushMatrix {
585 matrix: [1.0, 0.0, 0.0, 1.0, 10.0, 10.0],
586 },
587 rect_cmd(0.0, 0.0, 10.0, 10.0),
588 DrawCommand::PopMatrix,
589 DrawCommand::PopMatrix,
590 ]
591 };
592 let old = make(0.0);
593 let new = make(50.0);
594 let rects = compute_dirty_rect(&new, &old, |cmd, m| {
595 culling::command_visual_rect(cmd, m, &FontMetrics::default())
596 })
597 .unwrap();
598 let dirty = rects.iter().copied().reduce(Rect::union).unwrap();
599 assert!(dirty.y <= 10.0);
601 assert!(dirty.y + dirty.height >= 70.0);
602 }
603
604 #[test]
605 fn detect_scroll_blit_pure_y_scroll() {
606 let old = vec![
607 DrawCommand::PushClip {
608 rect: Rect::new(0.0, 0.0, 100.0, 200.0),
609 radius: BorderRadius::zero(),
610 },
611 DrawCommand::PushMatrix {
612 matrix: [1.0, 0.0, 0.0, 1.0, 0.0, -50.0],
613 },
614 rect_cmd(0.0, 0.0, 100.0, 400.0),
615 DrawCommand::PopMatrix,
616 DrawCommand::PopClip,
617 ];
618 let new = vec![
619 DrawCommand::PushClip {
620 rect: Rect::new(0.0, 0.0, 100.0, 200.0),
621 radius: BorderRadius::zero(),
622 },
623 DrawCommand::PushMatrix {
624 matrix: [1.0, 0.0, 0.0, 1.0, 0.0, -60.0],
625 },
626 rect_cmd(0.0, 0.0, 100.0, 400.0),
627 DrawCommand::PopMatrix,
628 DrawCommand::PopClip,
629 ];
630 let blit = detect_scroll_blit(&new, &old).unwrap();
631 assert_eq!(blit.delta_y, -10);
632 assert_eq!(blit.exposed_band.y, 190.0);
634 assert_eq!(blit.exposed_band.height, 10.0);
635 }
636}