Skip to main content

pax_runtime/engine/
layer_tiling.rs

1use serde::Serialize;
2
3const DEFAULT_SCROLLER_TARGET_TILE_BACKING_DIMENSION: f64 = 2496.0;
4const DEFAULT_MIN_LOGICAL_TILE_SIZE: f64 = 256.0;
5const DEFAULT_TILE_OVERSCAN_COLUMNS: i32 = 0;
6const DEFAULT_TILE_OVERSCAN_ROWS: i32 = 0;
7const DEFAULT_MIN_UNTILED_RENDER_DPR: f64 = 1.0;
8const DEFAULT_PREWARM_VIEWPORT_PAD_X_MULTIPLIER: f64 = 1.0;
9const DEFAULT_PREWARM_VIEWPORT_PAD_Y_MULTIPLIER: f64 = 1.5;
10const DEFAULT_PREWARM_VIEWPORT_PAD_MIN_X: f64 = 512.0;
11const DEFAULT_PREWARM_VIEWPORT_PAD_MIN_Y: f64 = 512.0;
12
13#[derive(Clone, Copy, Debug)]
14pub struct ScrollerTilingPolicy {
15    pub target_tile_backing_dimension: f64,
16    pub max_tile_backing_width: Option<f64>,
17    pub max_tile_backing_height: Option<f64>,
18    pub max_tile_backing_area: Option<f64>,
19    pub min_logical_tile_size: f64,
20    pub tile_overscan_columns: i32,
21    pub tile_overscan_rows: i32,
22    pub min_untiled_render_dpr: f64,
23    pub prewarm_viewport_pad_x_multiplier: f64,
24    pub prewarm_viewport_pad_y_multiplier: f64,
25    pub prewarm_viewport_pad_min_x: f64,
26    pub prewarm_viewport_pad_min_y: f64,
27    pub max_surfaces_per_layer: Option<usize>,
28}
29
30impl Default for ScrollerTilingPolicy {
31    fn default() -> Self {
32        Self {
33            target_tile_backing_dimension: DEFAULT_SCROLLER_TARGET_TILE_BACKING_DIMENSION,
34            max_tile_backing_width: None,
35            max_tile_backing_height: None,
36            max_tile_backing_area: None,
37            min_logical_tile_size: DEFAULT_MIN_LOGICAL_TILE_SIZE,
38            tile_overscan_columns: DEFAULT_TILE_OVERSCAN_COLUMNS,
39            tile_overscan_rows: DEFAULT_TILE_OVERSCAN_ROWS,
40            min_untiled_render_dpr: DEFAULT_MIN_UNTILED_RENDER_DPR,
41            prewarm_viewport_pad_x_multiplier: DEFAULT_PREWARM_VIEWPORT_PAD_X_MULTIPLIER,
42            prewarm_viewport_pad_y_multiplier: DEFAULT_PREWARM_VIEWPORT_PAD_Y_MULTIPLIER,
43            prewarm_viewport_pad_min_x: DEFAULT_PREWARM_VIEWPORT_PAD_MIN_X,
44            prewarm_viewport_pad_min_y: DEFAULT_PREWARM_VIEWPORT_PAD_MIN_Y,
45            max_surfaces_per_layer: None,
46        }
47    }
48}
49
50#[derive(Clone, Debug, Serialize)]
51#[serde(rename_all = "camelCase")]
52/// One physical canvas surface used to render a logical Pax layer tile.
53pub struct SurfaceCanvasDescriptor {
54    pub id: String,
55    pub key: String,
56    pub left: f64,
57    pub top: f64,
58    pub width: f64,
59    pub height: f64,
60    pub replay_priority: i32,
61    pub surface_signature: String,
62    pub transform_signature: String,
63    pub host_signature: String,
64}
65
66#[derive(Clone, Debug, Serialize)]
67#[serde(rename_all = "camelCase")]
68/// Canvas tiling plan for one logical render layer.
69pub struct LayerCanvasPlan {
70    pub layer_id: usize,
71    pub active: bool,
72    pub surfaces: Vec<SurfaceCanvasDescriptor>,
73}
74
75#[derive(Clone, Copy, Debug)]
76struct LogicalTileDimensions {
77    width: f64,
78    height: f64,
79}
80
81/// Build a one-surface plan for layers that do not need tiling.
82pub fn single_surface_plan(
83    layer_id: usize,
84    host_signature: String,
85    width: f64,
86    height: f64,
87) -> LayerCanvasPlan {
88    let width = width.max(0.0);
89    let height = height.max(0.0);
90    LayerCanvasPlan {
91        layer_id,
92        active: true,
93        surfaces: vec![SurfaceCanvasDescriptor {
94            id: layer_id.to_string(),
95            key: "single".to_string(),
96            left: 0.0,
97            top: 0.0,
98            width,
99            height,
100            replay_priority: 0,
101            surface_signature: format!("single:{width}x{height}@{host_signature}"),
102            transform_signature: "0,0".to_string(),
103            host_signature,
104        }],
105    }
106}
107
108/// Build a tile window for a scrollable vector layer.
109pub fn scroller_canvas_plan(
110    layer_id: usize,
111    host_signature: String,
112    content_width: f64,
113    content_height: f64,
114    viewport_width: f64,
115    viewport_height: f64,
116    scroll_x: f64,
117    scroll_y: f64,
118    device_pixel_ratio: f64,
119) -> LayerCanvasPlan {
120    scroller_canvas_plan_with_policy(
121        layer_id,
122        host_signature,
123        content_width,
124        content_height,
125        viewport_width,
126        viewport_height,
127        scroll_x,
128        scroll_y,
129        device_pixel_ratio,
130        ScrollerTilingPolicy::default(),
131    )
132}
133
134pub fn scroller_canvas_plan_with_policy(
135    layer_id: usize,
136    host_signature: String,
137    content_width: f64,
138    content_height: f64,
139    viewport_width: f64,
140    viewport_height: f64,
141    scroll_x: f64,
142    scroll_y: f64,
143    device_pixel_ratio: f64,
144    policy: ScrollerTilingPolicy,
145) -> LayerCanvasPlan {
146    let content_width = content_width.max(0.0);
147    let content_height = content_height.max(0.0);
148    let viewport_width = clamp_dimension(viewport_width, content_width);
149    let viewport_height = clamp_dimension(viewport_height, content_height);
150    let scroll_x = clamp_offset(scroll_x, content_width, viewport_width);
151    let scroll_y = clamp_offset(scroll_y, content_height, viewport_height);
152    let dpr = device_pixel_ratio.max(1.0);
153
154    if can_render_as_single_surface(content_width, content_height, dpr, policy) {
155        return single_surface_plan(layer_id, host_signature, content_width, content_height);
156    }
157
158    let horizontal_scrollable = content_width > viewport_width + 0.5;
159    let vertical_scrollable = content_height > viewport_height + 0.5;
160    let tile_dimensions = compute_logical_tile_dimensions(
161        content_width,
162        content_height,
163        horizontal_scrollable,
164        vertical_scrollable,
165        dpr,
166        policy,
167    );
168    if content_width <= tile_dimensions.width && content_height <= tile_dimensions.height {
169        return single_surface_plan(layer_id, host_signature, content_width, content_height);
170    }
171
172    let max_column = ((content_width / tile_dimensions.width).ceil() as i32 - 1).max(0);
173    let max_row = ((content_height / tile_dimensions.height).ceil() as i32 - 1).max(0);
174    let pad_x = if horizontal_scrollable {
175        (viewport_width * policy.prewarm_viewport_pad_x_multiplier)
176            .max(policy.prewarm_viewport_pad_min_x)
177    } else {
178        0.0
179    };
180    let pad_y = if vertical_scrollable {
181        (viewport_height * policy.prewarm_viewport_pad_y_multiplier)
182            .max(policy.prewarm_viewport_pad_min_y)
183    } else {
184        0.0
185    };
186    let padded_viewport_width = viewport_width + pad_x * 2.0;
187    let padded_viewport_height = viewport_height + pad_y * 2.0;
188    let padded_scroll_x = clamp_offset(scroll_x - pad_x, content_width, padded_viewport_width);
189    let padded_scroll_y = clamp_offset(scroll_y - pad_y, content_height, padded_viewport_height);
190    let overscan_columns = if horizontal_scrollable {
191        policy.tile_overscan_columns.max(0)
192    } else {
193        0
194    };
195    let overscan_rows = if vertical_scrollable {
196        policy.tile_overscan_rows.max(0)
197    } else {
198        0
199    };
200    let active_columns = (max_column + 1).min(
201        visible_tile_span(padded_viewport_width, tile_dimensions.width, true)
202            + overscan_columns * 2,
203    );
204    let active_rows = (max_row + 1).min(
205        visible_tile_span(padded_viewport_height, tile_dimensions.height, true) + overscan_rows * 2,
206    );
207    let min_active_columns = (max_column + 1).min(visible_tile_span(
208        viewport_width,
209        tile_dimensions.width,
210        true,
211    ));
212    let min_active_rows = (max_row + 1).min(visible_tile_span(
213        viewport_height,
214        tile_dimensions.height,
215        true,
216    ));
217    let (active_columns, active_rows) = clamp_active_tile_window(
218        active_columns,
219        active_rows,
220        min_active_columns,
221        min_active_rows,
222        horizontal_scrollable,
223        vertical_scrollable,
224        policy.max_surfaces_per_layer,
225    );
226    let start_column = clamp_window_start(
227        (padded_scroll_x / tile_dimensions.width).floor() as i32 - overscan_columns,
228        max_column,
229        active_columns,
230    );
231    let start_row = clamp_window_start(
232        (padded_scroll_y / tile_dimensions.height).floor() as i32 - overscan_rows,
233        max_row,
234        active_rows,
235    );
236    let end_column = max_column.min(start_column + active_columns - 1);
237    let end_row = max_row.min(start_row + active_rows - 1);
238
239    let mut surfaces = Vec::new();
240    for row in start_row..=end_row {
241        for column in start_column..=end_column {
242            let slot_column = column.rem_euclid(active_columns);
243            let slot_row = row.rem_euclid(active_rows);
244            let left = column as f64 * tile_dimensions.width;
245            let top = row as f64 * tile_dimensions.height;
246            let width = (content_width - left).min(tile_dimensions.width).max(1.0);
247            let height = (content_height - top).min(tile_dimensions.height).max(1.0);
248            let replay_priority = tile_replay_priority(
249                left,
250                top,
251                width,
252                height,
253                scroll_x,
254                scroll_y,
255                viewport_width,
256                viewport_height,
257                tile_dimensions.width,
258                tile_dimensions.height,
259            );
260            let key = format!("{slot_column}:{slot_row}");
261            let id = format!("layer-{layer_id}-tile-{slot_column}-{slot_row}");
262            let surface_signature = format!(
263                "tile:{slot_column},{slot_row}:{width}x{height}:{viewport_width}x{viewport_height}:{host_signature}"
264            );
265            let transform_signature = format!("{left},{top}");
266            surfaces.push(SurfaceCanvasDescriptor {
267                id,
268                key,
269                left,
270                top,
271                width,
272                height,
273                replay_priority,
274                surface_signature,
275                transform_signature,
276                host_signature: host_signature.clone(),
277            });
278        }
279    }
280    surfaces.sort_by(|left, right| {
281        left.key
282            .cmp(&right.key)
283            .then_with(|| left.transform_signature.cmp(&right.transform_signature))
284    });
285
286    LayerCanvasPlan {
287        layer_id,
288        active: true,
289        surfaces,
290    }
291}
292
293fn compute_logical_tile_size(device_pixel_ratio: f64, policy: ScrollerTilingPolicy) -> f64 {
294    let tile_size = (policy.target_tile_backing_dimension.max(1.0) / device_pixel_ratio).floor();
295    tile_size.max(policy.min_logical_tile_size.max(1.0))
296}
297
298fn compute_logical_tile_dimensions(
299    content_width: f64,
300    content_height: f64,
301    horizontal_scrollable: bool,
302    vertical_scrollable: bool,
303    device_pixel_ratio: f64,
304    policy: ScrollerTilingPolicy,
305) -> LogicalTileDimensions {
306    let target_tile_size = compute_logical_tile_size(device_pixel_ratio, policy);
307    let max_width = max_logical_tile_width(device_pixel_ratio, policy);
308    let max_height = max_logical_tile_height(device_pixel_ratio, policy);
309    let mut width = target_tile_size.min(max_width).max(1.0);
310    let mut height = target_tile_size.min(max_height).max(1.0);
311
312    if vertical_scrollable && !horizontal_scrollable {
313        width = content_width.max(1.0).min(max_width).max(1.0);
314        height = constrain_axis_by_area(height, width, device_pixel_ratio, policy);
315    } else if horizontal_scrollable && !vertical_scrollable {
316        height = content_height.max(1.0).min(max_height).max(1.0);
317        width = constrain_axis_by_area(width, height, device_pixel_ratio, policy);
318    } else {
319        (width, height) = constrain_tile_area(width, height, device_pixel_ratio, policy);
320    }
321
322    LogicalTileDimensions { width, height }
323}
324
325fn can_render_as_single_surface(
326    content_width: f64,
327    content_height: f64,
328    desired_dpr: f64,
329    policy: ScrollerTilingPolicy,
330) -> bool {
331    let safe_width = content_width.max(1.0);
332    let safe_height = content_height.max(1.0);
333    let backing_width = max_tile_backing_width(policy);
334    let backing_height = max_tile_backing_height(policy);
335    let backing_area = max_tile_backing_area(policy);
336    let single_surface_dpr = desired_dpr
337        .min(backing_width / safe_width)
338        .min(backing_height / safe_height)
339        .min((backing_area / (safe_width * safe_height)).sqrt());
340    single_surface_dpr >= policy.min_untiled_render_dpr.max(0.0)
341}
342
343fn max_tile_backing_width(policy: ScrollerTilingPolicy) -> f64 {
344    finite_positive_or(
345        policy.max_tile_backing_width,
346        policy.target_tile_backing_dimension,
347    )
348}
349
350fn max_tile_backing_height(policy: ScrollerTilingPolicy) -> f64 {
351    finite_positive_or(
352        policy.max_tile_backing_height,
353        policy.target_tile_backing_dimension,
354    )
355}
356
357fn max_tile_backing_area(policy: ScrollerTilingPolicy) -> f64 {
358    finite_positive_or(
359        policy.max_tile_backing_area,
360        max_tile_backing_width(policy) * max_tile_backing_height(policy),
361    )
362}
363
364fn finite_positive_or(value: Option<f64>, fallback: f64) -> f64 {
365    value
366        .filter(|value| value.is_finite() && *value > 0.0)
367        .unwrap_or(fallback)
368        .max(1.0)
369}
370
371fn max_logical_tile_width(device_pixel_ratio: f64, policy: ScrollerTilingPolicy) -> f64 {
372    (max_tile_backing_width(policy) / device_pixel_ratio.max(1.0))
373        .floor()
374        .max(1.0)
375}
376
377fn max_logical_tile_height(device_pixel_ratio: f64, policy: ScrollerTilingPolicy) -> f64 {
378    (max_tile_backing_height(policy) / device_pixel_ratio.max(1.0))
379        .floor()
380        .max(1.0)
381}
382
383fn backing_extent(logical_extent: f64, device_pixel_ratio: f64) -> f64 {
384    (logical_extent.max(1.0) * device_pixel_ratio.max(1.0))
385        .ceil()
386        .max(1.0)
387}
388
389fn backing_area(width: f64, height: f64, device_pixel_ratio: f64) -> f64 {
390    backing_extent(width, device_pixel_ratio) * backing_extent(height, device_pixel_ratio)
391}
392
393fn constrain_axis_by_area(
394    axis: f64,
395    cross_axis: f64,
396    device_pixel_ratio: f64,
397    policy: ScrollerTilingPolicy,
398) -> f64 {
399    let max_area = max_tile_backing_area(policy);
400    let cross_backing = backing_extent(cross_axis, device_pixel_ratio);
401    let max_axis = (max_area / cross_backing / device_pixel_ratio.max(1.0))
402        .floor()
403        .max(1.0);
404    axis.min(max_axis).max(1.0)
405}
406
407fn constrain_tile_area(
408    mut width: f64,
409    mut height: f64,
410    device_pixel_ratio: f64,
411    policy: ScrollerTilingPolicy,
412) -> (f64, f64) {
413    let max_area = max_tile_backing_area(policy);
414    let area = backing_area(width, height, device_pixel_ratio);
415    if area <= max_area {
416        return (width, height);
417    }
418
419    let scale = (max_area / area).sqrt().min(1.0);
420    width = (width * scale).floor().max(1.0);
421    height = (height * scale).floor().max(1.0);
422    if backing_area(width, height, device_pixel_ratio) > max_area {
423        height = constrain_axis_by_area(height, width, device_pixel_ratio, policy);
424    }
425    if backing_area(width, height, device_pixel_ratio) > max_area {
426        width = constrain_axis_by_area(width, height, device_pixel_ratio, policy);
427    }
428    (width.max(1.0), height.max(1.0))
429}
430
431fn tile_replay_priority(
432    tile_left: f64,
433    tile_top: f64,
434    tile_width: f64,
435    tile_height: f64,
436    viewport_left: f64,
437    viewport_top: f64,
438    viewport_width: f64,
439    viewport_height: f64,
440    tile_step_width: f64,
441    tile_step_height: f64,
442) -> i32 {
443    let tile_right = tile_left + tile_width;
444    let tile_bottom = tile_top + tile_height;
445    let viewport_right = viewport_left + viewport_width;
446    let viewport_bottom = viewport_top + viewport_height;
447
448    let intersects = tile_right >= viewport_left
449        && tile_left <= viewport_right
450        && tile_bottom >= viewport_top
451        && tile_top <= viewport_bottom;
452    if intersects {
453        return 0;
454    }
455
456    let dx = if tile_right < viewport_left {
457        viewport_left - tile_right
458    } else if tile_left > viewport_right {
459        tile_left - viewport_right
460    } else {
461        0.0
462    };
463    let dy = if tile_bottom < viewport_top {
464        viewport_top - tile_bottom
465    } else if tile_top > viewport_bottom {
466        tile_top - viewport_bottom
467    } else {
468        0.0
469    };
470
471    let x_priority = if dx > 0.0 {
472        (dx / tile_step_width.max(1.0)).ceil() as i32
473    } else {
474        0
475    };
476    let y_priority = if dy > 0.0 {
477        (dy / tile_step_height.max(1.0)).ceil() as i32
478    } else {
479        0
480    };
481    (x_priority.max(y_priority) + 1).max(1)
482}
483
484fn clamp_active_tile_window(
485    mut active_columns: i32,
486    mut active_rows: i32,
487    min_active_columns: i32,
488    min_active_rows: i32,
489    horizontal_scrollable: bool,
490    vertical_scrollable: bool,
491    max_surfaces: Option<usize>,
492) -> (i32, i32) {
493    let Some(max_surfaces) = max_surfaces else {
494        return (active_columns.max(1), active_rows.max(1));
495    };
496    let max_surfaces = max_surfaces.max(1) as i32;
497
498    // First trim only the axis that can actually scroll. This preserves visible-axis coverage
499    // whenever the platform cap is large enough to fit the viewport tile span.
500    while active_columns * active_rows > max_surfaces {
501        let can_trim_rows = vertical_scrollable && active_rows > min_active_rows.max(1);
502        let can_trim_columns = horizontal_scrollable && active_columns > min_active_columns.max(1);
503        if can_trim_rows && (!can_trim_columns || active_rows >= active_columns) {
504            active_rows -= 1;
505            continue;
506        }
507        if can_trim_columns {
508            active_columns -= 1;
509            continue;
510        }
511        if can_trim_rows {
512            active_rows -= 1;
513            continue;
514        }
515        break;
516    }
517
518    // A too-small platform cap is a hard constraint. Fall back to shrinking whichever dimension
519    // is larger rather than allowing a plan the chassis cannot materialize.
520    while active_columns * active_rows > max_surfaces && (active_columns > 1 || active_rows > 1) {
521        if active_rows >= active_columns && active_rows > 1 {
522            active_rows -= 1;
523        } else if active_columns > 1 {
524            active_columns -= 1;
525        } else {
526            active_rows -= 1;
527        }
528    }
529
530    (active_columns.max(1), active_rows.max(1))
531}
532
533fn clamp_dimension(value: f64, fallback: f64) -> f64 {
534    if value.is_finite() {
535        value.max(0.0).min(fallback.max(0.0))
536    } else {
537        fallback.max(0.0)
538    }
539}
540
541fn clamp_offset(value: f64, content: f64, viewport: f64) -> f64 {
542    if content <= viewport {
543        return 0.0;
544    }
545    if !value.is_finite() {
546        return 0.0;
547    }
548    value.max(0.0).min((content - viewport).max(0.0))
549}
550
551fn visible_tile_span(viewport_size: f64, tile_size: f64, include_extra_slot: bool) -> i32 {
552    let base = (viewport_size.max(0.0) / tile_size).ceil() as i32;
553    let extra = if include_extra_slot { 1 } else { 0 };
554    (base + extra).max(1)
555}
556
557fn clamp_window_start(value: i32, max_index: i32, window_size: i32) -> i32 {
558    clamp_index(value, (max_index - window_size + 1).max(0))
559}
560
561fn clamp_index(value: i32, max: i32) -> i32 {
562    value.max(0).min(max)
563}
564
565#[cfg(test)]
566mod tests {
567    use super::*;
568
569    #[test]
570    fn vertical_only_scroller_does_not_add_horizontal_overscan() {
571        let mut policy = ScrollerTilingPolicy::default();
572        policy.tile_overscan_columns = 3;
573        policy.tile_overscan_rows = 0;
574        let plan = scroller_canvas_plan_with_policy(
575            1,
576            "test".to_string(),
577            600.0,
578            10_000.0,
579            600.0,
580            600.0,
581            0.0,
582            0.0,
583            1.0,
584            policy,
585        );
586
587        assert!(plan.surfaces.iter().all(|surface| surface.left == 0.0));
588    }
589
590    #[test]
591    fn vertical_only_scroller_uses_full_width_tiles_when_cap_allows() {
592        let mut policy = ScrollerTilingPolicy::default();
593        policy.target_tile_backing_dimension = 4096.0;
594        policy.max_tile_backing_width = Some(4096.0);
595        policy.max_tile_backing_height = Some(4096.0);
596        policy.max_tile_backing_area = Some(4096.0 * 4096.0);
597        policy.prewarm_viewport_pad_y_multiplier = 0.5;
598        policy.prewarm_viewport_pad_min_y = 384.0;
599        let plan = scroller_canvas_plan_with_policy(
600            1,
601            "test".to_string(),
602            1376.0,
603            120_000.0,
604            1376.0,
605            904.0,
606            0.0,
607            0.0,
608            2.0,
609            policy,
610        );
611
612        assert!(plan.surfaces.iter().all(|surface| surface.left == 0.0));
613        assert!(plan.surfaces.iter().all(|surface| surface.width == 1376.0));
614        assert!(plan.surfaces.len() <= 3);
615    }
616
617    #[test]
618    fn non_scrolling_axis_still_splits_when_backing_cap_requires_it() {
619        let mut policy = ScrollerTilingPolicy::default();
620        policy.target_tile_backing_dimension = 2048.0;
621        policy.max_tile_backing_width = Some(2048.0);
622        policy.max_tile_backing_height = Some(2048.0);
623        policy.max_tile_backing_area = Some(2048.0 * 2048.0);
624        let plan = scroller_canvas_plan_with_policy(
625            1,
626            "test".to_string(),
627            3000.0,
628            20_000.0,
629            3000.0,
630            600.0,
631            0.0,
632            0.0,
633            1.0,
634            policy,
635        );
636
637        assert!(plan.surfaces.iter().any(|surface| surface.left > 0.0));
638        assert!(plan.surfaces.iter().all(|surface| surface.width <= 2048.0));
639    }
640
641    #[test]
642    fn tile_area_cap_reduces_scroll_axis_extent() {
643        let mut policy = ScrollerTilingPolicy::default();
644        policy.target_tile_backing_dimension = 4096.0;
645        policy.max_tile_backing_width = Some(4096.0);
646        policy.max_tile_backing_height = Some(4096.0);
647        policy.max_tile_backing_area = Some(4_000_000.0);
648        let plan = scroller_canvas_plan_with_policy(
649            1,
650            "test".to_string(),
651            3000.0,
652            20_000.0,
653            3000.0,
654            600.0,
655            0.0,
656            0.0,
657            1.0,
658            policy,
659        );
660
661        assert!(plan
662            .surfaces
663            .iter()
664            .all(|surface| { (surface.width.ceil() * surface.height.ceil()) <= 4_000_000.0 }));
665        assert!(plan.surfaces.iter().all(|surface| surface.height <= 1333.0));
666    }
667
668    #[test]
669    fn vertical_overscan_is_axis_gated() {
670        let mut policy = ScrollerTilingPolicy::default();
671        policy.tile_overscan_rows = 1;
672        let vertical_plan = scroller_canvas_plan_with_policy(
673            1,
674            "test".to_string(),
675            600.0,
676            10_000.0,
677            600.0,
678            600.0,
679            0.0,
680            0.0,
681            1.0,
682            policy,
683        );
684        let horizontal_plan = scroller_canvas_plan_with_policy(
685            1,
686            "test".to_string(),
687            10_000.0,
688            600.0,
689            600.0,
690            600.0,
691            0.0,
692            0.0,
693            1.0,
694            policy,
695        );
696
697        assert!(vertical_plan.surfaces.len() > horizontal_plan.surfaces.len());
698    }
699
700    #[test]
701    fn max_surfaces_clamps_requested_tile_window() {
702        let mut policy = ScrollerTilingPolicy::default();
703        policy.tile_overscan_rows = 2;
704        policy.max_surfaces_per_layer = Some(3);
705        let plan = scroller_canvas_plan_with_policy(
706            1,
707            "test".to_string(),
708            600.0,
709            20_000.0,
710            600.0,
711            600.0,
712            0.0,
713            5_000.0,
714            1.0,
715            policy,
716        );
717
718        assert!(plan.surfaces.len() <= 3);
719    }
720
721    #[test]
722    fn overlapping_vertical_tile_keeps_physical_slot_key() {
723        let policy = ScrollerTilingPolicy::default();
724        let first = scroller_canvas_plan_with_policy(
725            1,
726            "test".to_string(),
727            600.0,
728            20_000.0,
729            600.0,
730            600.0,
731            0.0,
732            0.0,
733            1.0,
734            policy,
735        );
736        let second = scroller_canvas_plan_with_policy(
737            1,
738            "test".to_string(),
739            600.0,
740            20_000.0,
741            600.0,
742            600.0,
743            0.0,
744            3_200.0,
745            1.0,
746            policy,
747        );
748
749        let overlapping_surface = first
750            .surfaces
751            .iter()
752            .find(|surface| surface.top > 0.0)
753            .expect("first plan should have a second row");
754        let matching_surface = second
755            .surfaces
756            .iter()
757            .find(|surface| surface.top == overlapping_surface.top)
758            .expect("second plan should keep the overlapping row warm");
759
760        assert_eq!(matching_surface.key, overlapping_surface.key);
761        assert_eq!(matching_surface.id, overlapping_surface.id);
762    }
763
764    #[test]
765    fn replay_priority_prefers_visible_tiles() {
766        let policy = ScrollerTilingPolicy::default();
767        let plan = scroller_canvas_plan_with_policy(
768            1,
769            "test".to_string(),
770            600.0,
771            20_000.0,
772            600.0,
773            600.0,
774            0.0,
775            5_000.0,
776            1.0,
777            policy,
778        );
779
780        let visible_tiles = plan
781            .surfaces
782            .iter()
783            .filter(|surface| surface.top + surface.height >= 5_000.0 && surface.top <= 5_600.0)
784            .count();
785        assert!(visible_tiles > 0);
786        assert!(plan
787            .surfaces
788            .iter()
789            .filter(|surface| surface.top + surface.height >= 5_000.0 && surface.top <= 5_600.0)
790            .all(|surface| surface.replay_priority == 0));
791        assert!(plan
792            .surfaces
793            .iter()
794            .any(|surface| surface.replay_priority > 0));
795    }
796}