Skip to main content

layout/display_list/
paint_timing_handler.rs

1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
5use std::collections::{HashMap, HashSet};
6
7use app_units::Au;
8use euclid::Rect;
9use layout_api::LCPCandidate;
10use paint_api::display_list::PaintTimingReport;
11use servo_arc::Arc as ServoArc;
12use servo_base::id::LCPCandidateID;
13use servo_geometry::FastLayoutTransform;
14use servo_url::ServoUrl;
15use style::dom::OpaqueNode;
16use style::properties::ComputedValues;
17use webrender_api::units::{LayoutRect, LayoutSize};
18
19use crate::fragment_tree::Tag;
20use crate::query::transform_f32_rectangle;
21
22/// <https://w3c.github.io/paint-timing/#pending-image-record>
23/// Different struct from spec, but fulfulling the same purpose.
24struct PendingImageRecord {
25    /// The image element this record belongs to.
26    /// for <https://w3c.github.io/paint-timing/#pending-image-record-element>
27    tag: Option<Tag>,
28    /// The image rect (adjusted for object-fit/object-position).
29    bounds: LayoutRect,
30    /// The element's content box.
31    clip_rect: LayoutRect,
32    /// Cumulative transform to root space, computed at collection time.
33    transform: FastLayoutTransform,
34    /// The image URL. `None` for background images.
35    url: Option<ServoUrl>,
36    /// Intrinsic width, used for upscaling normalization.
37    natural_width: Option<Au>,
38    /// Intrinsic height, used for upscaling normalization.
39    natural_height: Option<Au>,
40}
41
42/// <https://w3c.github.io/paint-timing/#sec-recording-paint-timing>
43/// > Each Element has a set of owned text nodes, which is an ordered set of
44/// > Text nodes, initially empty.
45///
46/// This struct corresponds to an Element for accumulating set of owned text
47/// nodes by nearest ancestor box fragment's tag during display list building.
48struct TextRecord {
49    /// The tag of containing box fragment these texts belongs to.
50    tag: Tag,
51    /// <https://w3c.github.io/paint-timing/#set-of-owned-text-nodes>
52    /// Collection of border_boxes of all Text nodes accumulated
53    border_boxes: Vec<LayoutRect>,
54    /// The containing element's computed style
55    style: ServoArc<ComputedValues>,
56}
57
58enum LCPCandidateType<'a> {
59    Image(&'a PendingImageRecord),
60    Text,
61}
62
63pub(crate) struct PaintTimingHandler {
64    /// The rect of viewport.
65    viewport_rect: LayoutRect,
66    /// Whether the current display list contains paintable items.
67    is_document_paintable: bool,
68    /// Whether the current display list contains contentful items.
69    is_document_contentful: bool,
70    /// <https://www.w3.org/TR/paint-timing/#set-of-previously-reported-paints>
71    previously_reported_paints: PaintTimingReport,
72    /// Counter for generating unique LCP candidate UUIDs.
73    lcp_next_uuid: u64,
74    /// The LCP candidate, it may be a image or text.
75    lcp_candidate: Option<LCPCandidate>,
76    /// The set of image nodes that have been reported as LCP candidates.
77    reported_image_nodes: HashSet<OpaqueNode>,
78    /// <https://www.w3.org/TR/paint-timing/#images-pending-rendering>
79    images_pending_rendering: Vec<PendingImageRecord>,
80    /// <https://www.w3.org/TR/paint-timing/#set-of-elements-with-rendered-text>
81    /// The set of text nodes that have been reported as LCP candidates.
82    elements_with_rendered_text: HashSet<OpaqueNode>,
83    /// The set of pending text nodes that will fight for LCP candidate.
84    elements_with_pending_rendered_text: HashMap<OpaqueNode, TextRecord>,
85}
86
87impl PaintTimingHandler {
88    pub(crate) fn new(viewport_size: LayoutSize) -> Self {
89        Self {
90            is_document_paintable: false,
91            is_document_contentful: false,
92            previously_reported_paints: PaintTimingReport::default(),
93            lcp_next_uuid: 0,
94            lcp_candidate: None,
95            viewport_rect: LayoutRect::from_size(viewport_size),
96            reported_image_nodes: HashSet::new(),
97            images_pending_rendering: Vec::new(),
98            elements_with_rendered_text: HashSet::new(),
99            elements_with_pending_rendered_text: HashMap::new(),
100        }
101    }
102
103    /// Marks the current display list as containing a paintable item.
104    pub(crate) fn mark_document_is_paintable(&mut self) {
105        self.is_document_paintable = true;
106    }
107
108    /// Marks the current display list as containing a contentful item.
109    pub(crate) fn mark_document_is_contentful(&mut self) {
110        self.is_document_contentful = true;
111    }
112
113    #[expect(clippy::too_many_arguments)]
114    pub(crate) fn append_image_record(
115        &mut self,
116        tag: Option<Tag>,
117        bounds: LayoutRect,
118        clip_rect: LayoutRect,
119        transform: FastLayoutTransform,
120        url: Option<ServoUrl>,
121        natural_width: Option<Au>,
122        natural_height: Option<Au>,
123    ) {
124        self.images_pending_rendering.push(PendingImageRecord {
125            tag,
126            bounds,
127            clip_rect,
128            transform,
129            url,
130            natural_width,
131            natural_height,
132        });
133    }
134
135    pub(crate) fn accumulate_text_rect(
136        &mut self,
137        tag: Tag,
138        rect: LayoutRect,
139        transform: FastLayoutTransform,
140        style: &ServoArc<ComputedValues>,
141    ) {
142        let border_box = transform_f32_rectangle(rect.to_rect(), transform)
143            .unwrap_or_default()
144            .to_box2d();
145        self.elements_with_pending_rendered_text
146            .entry(tag.node)
147            .and_modify(|record| {
148                record.border_boxes.push(border_box);
149            })
150            .or_insert(TextRecord {
151                tag,
152                border_boxes: vec![border_box],
153                style: ServoArc::clone(style),
154            });
155    }
156
157    /// <https://www.w3.org/TR/paint-timing/#paintable-bounding-rect>
158    fn paintable_bounding_rect(&self, bounds: LayoutRect) -> LayoutRect {
159        bounds.intersection(&self.viewport_rect).unwrap_or_default()
160    }
161
162    /// <https://www.w3.org/TR/paint-timing/#paintable>
163    pub(crate) fn paintable(&self, bounds: LayoutRect, opacity: f32) -> bool {
164        // An element el is paintable when all of the following apply:
165        // > el is being rendered.
166        // > el’s used visibility is visible.
167        // Note: Above conditions are met, as we selectively call this API.
168
169        // > el and all of its ancestors' used opacity is greater than zero.
170        if opacity <= 0.0 {
171            return false;
172        }
173
174        // > el’s paintable bounding rect intersects with the scrolling area of the document.
175        !self.paintable_bounding_rect(bounds).is_empty()
176    }
177
178    /// Marks wether the document is having paintable element
179    ///
180    /// <https://www.w3.org/TR/paint-timing/#paintable>
181    pub(crate) fn check_if_paintable(&mut self, bounds: LayoutRect, opacity: f32) {
182        if self.paintable(bounds, opacity) {
183            self.mark_document_is_paintable();
184        }
185    }
186
187    /// <https://www.w3.org/TR/largest-contentful-paint/#sec-effective-visual-size>
188    fn effective_visual_size(
189        &self,
190        intersection_rect: LayoutRect,
191        candidate_type: LCPCandidateType<'_>,
192    ) -> Option<f32> {
193        // Step 1. Let width be intersectionRect's width, rounded up to the
194        // nearest integer.
195        // Step 2. Let height be intersectionRect's height, rounded up to the
196        // nearest integer.
197        // Step 3. Let size be width * height.
198        let mut size = intersection_rect.area();
199
200        // Step 4. Let root be document's browsing context's top-level browsing
201        // context's active document.
202        // Note: This is not needed as we already have the viewport rect.
203
204        // Step 5. Let rootWidth be root's visual viewport's width,
205        // excluding any scrollbars.
206        // Step 6. Let rootHeight be root's visual viewport's height excluding
207        // any scrollbars.
208        // Step 7. If size is equal to rootWidth times rootHeight, return null.
209        if size >= self.viewport_rect.area() {
210            return None;
211        }
212
213        // Step 8: If imageRequest is not null, run the following steps to
214        // adjust for image position and upscaling.
215        // Note: This is skipped for Text aka the case of null request from specs
216        if let LCPCandidateType::Image(record) = candidate_type {
217            // TODO Step 8.1: If imageRequest's response's content length in bytes
218            // is less than size * 0.004, then return null. (Not Implemented)
219
220            // Step 8.2: Let concreteDimensions be imageRequest's concrete object
221            // size within element.
222            // Step 8.3: Let visibleDimensions be concreteDimensions, adjusted for
223            // positioning by object-position or background-position and element's
224            // content box.
225            // Note: bounds are already adjusted for positioning and content box
226            let visible_dimensions = record
227                .bounds
228                .intersection(&record.clip_rect)
229                .unwrap_or(LayoutRect::zero());
230
231            // Step 8.4: Let clientContentRect be the smallest DOMRectReadOnly
232            // containing visibleDimensions with element's transforms applied.
233            let client_content_rect =
234                transform_f32_rectangle(visible_dimensions.to_rect(), record.transform)
235                    .unwrap_or_default();
236
237            // Step 8.5: Let intersectingClientContentRect be the intersection of
238            // clientContentRect with intersectionRect.
239            let intersecting_client_content_rect = client_content_rect
240                .intersection(&intersection_rect.to_rect())
241                .unwrap_or(Rect::zero());
242
243            // Step 8.6: Set width to intersectingClientContentRect's width,
244            // rounded up to the nearest integer.
245            // Step 8.7: Set height to intersectingClientContentRect's height,
246            // rounded up to the nearest integer.
247            // Step 8.8: Set size to width * height.
248            size = intersecting_client_content_rect.area();
249
250            // Step 8.9: Let naturalArea be imageRequest's natural width * imageRequest's natural height.
251            if let (Some(natural_width), Some(natural_height)) =
252                (record.natural_width, record.natural_height)
253            {
254                let natural_area = natural_width.to_f32_px() * natural_height.to_f32_px();
255
256                // Step 8.10: If naturalArea is 0, then return null.
257                if natural_area == 0.0 {
258                    return None;
259                }
260                // Step 8.11: Let boundingClientArea be clientContentRect's width *
261                // clientContentRect's height.
262                let bounding_client_area =
263                    client_content_rect.width() * client_content_rect.height();
264
265                // Step 8.12: Let scaleFactor be boundingClientArea / naturalArea.
266                let scale_factor = bounding_client_area / natural_area;
267
268                // Step 8.13: If scaleFactor is greater than 1, then divide size by scaleFactor.
269                if scale_factor > 1.0 {
270                    size /= scale_factor;
271                }
272            }
273        }
274
275        // Step 9: Return an effective visual size result with size set to size,
276        // width set to width, and height set to height.
277        Some(size)
278    }
279
280    /// <https://www.w3.org/TR/largest-contentful-paint/#compute-a-new-largest-contentful-paint-candidate>
281    #[servo_tracing::instrument(
282        name = "Compute New LCP Candidate",
283        skip_all,
284        fields(
285            image_count = painted_images.len(),
286            text_count = painted_text_nodes.len(),
287        )
288    )]
289    fn compute_new_lcp_candidate(
290        &mut self,
291        painted_images: Vec<PendingImageRecord>,
292        painted_text_nodes: HashMap<OpaqueNode, TextRecord>,
293    ) -> Option<LCPCandidate> {
294        // Step 1. Let currentSize be currentCandidate’s size if
295        // currentCandidate is not null or 0 otherwise.
296        // Step 2. Let largestSize be currentSize.
297        let mut largest_size = self
298            .lcp_candidate
299            .as_ref()
300            .map_or(0.0, |candidate| candidate.area as f32);
301
302        // Step 3. Let newCandidate be null.
303        let mut new_candidate = None;
304
305        // Step 4. For each record of paintedImages:
306        for record in painted_images {
307            // Step 4.1. Let imageElement be record’s element.
308
309            // Step 4.2. If imageElement is not exposed for paint timing, given
310            // document, continue.
311            // Note: Satisfied, as the display-list builder only visits the
312            // connected DOM tree of the fully-active document being laid out.
313
314            // Step 4.3. Let intersectionRect be the value returned by the
315            // intersection rect algorithm using imageElement as the target
316            // and viewport as the root.
317            let intersection_rect =
318                transform_f32_rectangle(record.clip_rect.to_rect(), record.transform)
319                    .unwrap_or_default()
320                    .intersection(&self.viewport_rect.to_rect())
321                    .map(|rect| rect.to_box2d())
322                    .unwrap_or_default();
323
324            // Step 4.4. Let result be the effective visual size of imageElement
325            // given intersectionRect and record's request.
326            let result =
327                self.effective_visual_size(intersection_rect, LCPCandidateType::Image(&record));
328
329            // Step 4.5. If result is null, continue.
330            let Some(result) = result else {
331                continue;
332            };
333            // Step 4.6. If result's size is less than or equal to
334            // largestSize, continue.
335            if result <= largest_size {
336                continue;
337            }
338
339            // Step 4.7. Set largestSize to result’s size.
340            largest_size = result;
341
342            // Step 4.8. Set newCandidate to be a new largest contentful paint candidate ...
343            let uuid = self.lcp_next_uuid;
344            self.lcp_next_uuid += 1;
345            new_candidate = Some(LCPCandidate::new(
346                LCPCandidateID(uuid),
347                result as usize,
348                record.url,
349                record.tag.map(|tag| tag.node),
350            ));
351        }
352
353        // Step 5. For each textNode of paintedTextNodes,
354        for (_, record) in painted_text_nodes {
355            // Step 5.1. If textNode is not exposed for paint timing, given
356            // document, continue.
357            // Note: Satisfied, as the display-list builder only visits the
358            // connected DOM tree of the fully-active document being laid out.
359
360            // Step 5.2. If textNode has alpha channel value <=0 or opacity
361            // value <=0:
362            if record.style.get_color().alpha <= 0.0 || record.style.slow_clone_opacity() <= 0.0 {
363                // Step 5.2.1. If textNode's text-shadow value is none,
364                // textNode's stroke-color value is transparent and textNode's
365                // stroke-image value is none, continue.
366                // TODO: Update when we implement the `stroke-color`/`stroke-image`
367                // properties, as of now they are default (`transparent`/`none`)
368                if record.style.get_inherited_text().text_shadow.0.is_empty() {
369                    continue;
370                }
371            }
372            // Step 5.3. Let intersectionRect be the union of the border boxes of
373            // all Text nodes in textNode’s set of owned text nodes,
374            // intersected with the visual viewport.
375            let intersection_rect = record
376                .border_boxes
377                .into_iter()
378                .reduce(|a, b| a.union(&b))
379                .unwrap_or_default()
380                .intersection(&self.viewport_rect)
381                .unwrap_or_default();
382            // Step 5.4. Let result be the effective visual size of textNode
383            // given intersectionRect and null.
384            let result = self.effective_visual_size(intersection_rect, LCPCandidateType::Text);
385
386            // Step 5.5. If result is null, continue.
387            let Some(result) = result else {
388                continue;
389            };
390            // Step 5.6. If result's size is less than or equal to
391            // largestSize, continue.
392            if result <= largest_size {
393                continue;
394            }
395
396            // Step 5.7. Set largestSize to result’s size.
397            largest_size = result;
398
399            // Step 5.8. Set newCandidate to be a new largest contentful paint candidate ...
400            let uuid = self.lcp_next_uuid;
401            self.lcp_next_uuid += 1;
402            new_candidate = Some(LCPCandidate::new(
403                LCPCandidateID(uuid),
404                result as usize,
405                None,
406                Some(record.tag.node),
407            ));
408        }
409
410        // TODO Step 6. If newCandidate is not null and currentSize is greater than 0:
411        // TODO Step 6.1. If newCandidate’s width minus currentCandidate’s
412        // width is less than or equal to 3, and newCandidate’s height minus
413        // currentCandidate’s height is less than or equal to 3, return null.
414
415        // Step 7. Return newCandidate.
416        new_candidate
417    }
418
419    /// <https://www.w3.org/TR/largest-contentful-paint/#sec-report-largest-contentful-paint>
420    fn report_largest_contentful_paint(
421        &mut self,
422        halt_lcp: bool,
423        painted_images: Vec<PendingImageRecord>,
424        painted_text_nodes: HashMap<OpaqueNode, TextRecord>,
425    ) {
426        // Step 1. Let window be document’s relevant global object.
427        // Step 2. If either of window’s has dispatched scroll event or has
428        // dispatched input event is true, return.
429        if halt_lcp {
430            return;
431        }
432
433        // Step 3. Let newCandidate be the result of computing a new largest
434        // contentful paint candidate given document, paintedImages,
435        // paintedTextNodes, and document’s current largest contentful paint
436        // candidate.
437        self.lcp_candidate = self.compute_new_lcp_candidate(painted_images, painted_text_nodes);
438
439        // Step 4. If newCandidate is null, return.
440        // Step 5. Set document’s current largest contentful paint candidate to
441        // newCandidate.
442        // Note: Step 4-5 are fulfilled by the assignment above, as the new
443        // candidate wether `None or Some` is computed and assigned to the
444        // current candidate.
445
446        // Step 6. Let entry be the result of creating a LargestContentfulPaint
447        // entry with newCandidate, paintTimingInfo, and document.
448        // Step 7. Queue the PerformanceEntry entry.
449        // Note: Step 6-7 are handled in script.
450    }
451
452    /// <https://www.w3.org/TR/paint-timing/#first-paint>
453    fn should_report_first_paint(&self) -> bool {
454        // Step 1. If document's set of previously reported paints contains
455        // "first-paint", then return false.
456        if self
457            .previously_reported_paints
458            .contains(PaintTimingReport::FirstPaint)
459        {
460            return false;
461        }
462        // Step 2. If document contains at least one element that is
463        // paintable, then return true.
464        // Step 3. Otherwise, return false.
465        self.is_document_paintable
466    }
467
468    /// <https://www.w3.org/TR/paint-timing/#first-contentful-paint>
469    fn should_report_first_contentful_paint(&self) -> bool {
470        // Step 1. If document's set of previously reported paints contains
471        // "first-contentful-paint", then return false.
472        if self
473            .previously_reported_paints
474            .contains(PaintTimingReport::FirstContentfulPaint)
475        {
476            return false;
477        }
478        // Step 2. If document contains at least one element that is both
479        // paintable and contentful, then return true.
480        // Step 3. Otherwise, return false.
481        self.is_document_paintable && self.is_document_contentful
482    }
483
484    /// <https://www.w3.org/TR/paint-timing/#mark-paint-timing>
485    ///
486    /// Note: Step 10 is not in the current version of the specifications.
487    /// Refer <https://github.com/w3c/paint-timing/issues/122> for details on
488    /// the issue and for the modified steps yet to be merged.
489    #[servo_tracing::instrument(name = "Mark Paint Timing", skip_all, fields(halt_lcp = halt_lcp))]
490    pub(crate) fn mark_paint_timing(
491        &mut self,
492        paint_timing_eligible: bool,
493        halt_lcp: bool,
494    ) -> PaintTimingReport {
495        // Step 1. If the document's browsing context is not paint-timing
496        // eligible, return.
497        if !paint_timing_eligible {
498            return PaintTimingReport::default();
499        }
500
501        // Step 2. Let paintTimingInfo be a new paint timing info, whose
502        // rendering update end time is the current high resolution time given
503        // document's relevant global object.
504        // Note: This is satisfied in the script thread.
505
506        // Step 3. Let paintedImages be a new ordered set.
507        // Step 4. Let paintedTextNodes be a new ordered set.
508
509        // Step 5. For each record in doc's images pending rendering list:
510        // Step 5.1. If record's request is available and ready to be painted,
511        // then run the following steps:
512        // Note: Only available images are accumulated, hence it is fulfilled.
513        // Step 5.1.1. Append record to paintedImages.
514        // Step 5.1.2. Remove record from doc's images pending rendering list.
515        let painted_images: Vec<_> = std::mem::take(&mut self.images_pending_rendering)
516            .into_iter()
517            .filter(|record| {
518                record
519                    .tag
520                    .is_none_or(|tag| self.reported_image_nodes.insert(tag.node))
521            })
522            .collect();
523
524        // Step 6. For each Element element in doc's descendants:
525        // Step 6.1. If element is contained in doc's set of elements with
526        // rendered text, continue.
527        // Step 6.2. If element's set of owned text nodes is empty, continue.
528        // Step 6.3. Append element to doc's set of elements with rendered text.
529        // Step 6.4. Append element to paintedTextNodes.
530        let painted_text_nodes: HashMap<_, _> =
531            std::mem::take(&mut self.elements_with_pending_rendered_text)
532                .into_iter()
533                .filter(|(node, _record)| self.elements_with_rendered_text.insert(*node))
534                .collect();
535
536        // Step 7. Let reportedPaints be the document’s set of previously
537        // reported paints. (Directly accessing)
538
539        // TODO Step 8. Let frameTimingInfo be document’s current frame timing info.
540        // TODO Step 9. Set document’s current frame timing info to null.
541
542        // Step 10. Let flushPaintTimings be the following steps:
543
544        // Note: A new PaintTimingReport to accumulate the paints.
545        let mut paint_timing_report = PaintTimingReport::default();
546
547        // Step 10.1. If document should report first paint, then:
548        // Note: This step is not in the current version of the specifications,
549        // are waiting to be merged at w3c/paint-timing#123.
550        if self.should_report_first_paint() {
551            // Step 10.1.1. Report paint timing given document, "first-paint",
552            // and paintTimingInfo.
553            paint_timing_report.insert(PaintTimingReport::FirstPaint);
554        }
555
556        // Step 10.2. If document should report first contentful paint, then:
557        if self.should_report_first_contentful_paint() {
558            // Step 10.2.1. Report paint timing given document,
559            // "first-contentful-paint", and paintTimingInfo.
560            paint_timing_report.insert(PaintTimingReport::FirstContentfulPaint);
561        }
562
563        // Step 10.3. Report largest contentful paint given document,
564        // paintTimingInfo, paintedImages and paintedTextNodes.
565        self.report_largest_contentful_paint(halt_lcp, painted_images, painted_text_nodes);
566
567        // Note: Append the newly reported paints aka [`PaintTimingReport`] to
568        // the document's set of previously reported paints.
569        self.previously_reported_paints |= paint_timing_report;
570
571        paint_timing_report
572    }
573
574    pub(crate) fn largest_contentful_paint_candidate(&self) -> Option<LCPCandidate> {
575        self.lcp_candidate.clone()
576    }
577}