Skip to main content

smudge_core/
paint.rs

1//! Paint operations and the display list that renderers replay.
2//!
3//! Widgets emit [`PaintOp`]s in paint coordinates: logical window
4//! coordinates with the scroll offsets of the enclosing scroll views left
5//! out, so that scrolling moves existing operations instead of rebuilding
6//! them (see [`crate::widget::Widget::paint`]). Outside a scrolled subtree
7//! the two coincide. Y grows downwards and the origin is the top left of the
8//! window. The frame collects the operations into one [`DisplayList`] in
9//! paint order, and both renderers replay that list.
10//!
11//! A display list may carry [`DisplayItem::PushOffset`] scopes. Items inside a
12//! scope are drawn translated by the offset, so a scrolled subtree can keep
13//! its operations in its own coordinates while the window scrolls. A renderer
14//! that replays a list must honor them.
15//!
16//! A renderer skips items whose bounds miss the damaged area, so
17//! [`PaintOp::bounds`] must cover every pixel an operation can change.
18
19use std::rc::Rc;
20use std::sync::Arc;
21
22use crate::geometry::{Color, Corners, Point, Rect};
23
24/// The bytes of one font file, shared by every glyph run that uses it.
25///
26/// Glyph runs carry the font as plain bytes so that paint operations never
27/// depend on a particular shaping library. `id` identifies the data uniquely
28/// for the lifetime of the process, which renderers use as a glyph cache key
29/// without hashing the bytes. `index` selects one face inside a font file that
30/// holds several.
31#[derive(Clone)]
32pub struct FontBlob {
33    /// The unique identifier of the font data within this process.
34    pub id: u64,
35    /// The bytes of the font file.
36    pub data: Arc<dyn AsRef<[u8]> + Send + Sync>,
37    /// The face to use when the file holds a collection of fonts.
38    pub index: u32,
39}
40
41impl PartialEq for FontBlob {
42    fn eq(&self, other: &Self) -> bool {
43        self.id == other.id && self.index == other.index
44    }
45}
46
47impl std::fmt::Debug for FontBlob {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        f.debug_struct("FontBlob")
50            .field("id", &self.id)
51            .field("index", &self.index)
52            .field("len", &self.data.as_ref().as_ref().len())
53            .finish()
54    }
55}
56
57/// One glyph of a [`GlyphRun`], positioned in the run's paint coordinates.
58#[derive(Copy, Clone, Debug, PartialEq)]
59pub struct PositionedGlyph {
60    /// The glyph identifier inside the run's font, as shaping produced it.
61    pub id: u32,
62    /// Horizontal position of the glyph's baseline origin.
63    pub x: f32,
64    /// Vertical position of the glyph's baseline origin.
65    pub y: f32,
66}
67
68/// A shaped run of glyphs that all use one font, size and color.
69///
70/// Glyph positions are in the run's paint coordinates, which leave out the
71/// scroll offsets of enclosing views (see [`crate::widget::Widget::paint`]),
72/// with each glyph's `x` and `y` at its baseline origin. A renderer
73/// rasterizes each glyph separately and blits it in its run's color, so
74/// nothing else about the text, such as clusters or line breaking, travels
75/// with the run.
76#[derive(Clone, Debug, PartialEq)]
77pub struct GlyphRun {
78    /// The font the glyph identifiers refer to.
79    pub font: FontBlob,
80    /// The font size in logical pixels.
81    pub size: f32,
82    /// The straight-alpha sRGB color the glyphs paint with.
83    pub color: Color,
84    /// The glyphs of the run, in paint order.
85    pub glyphs: Vec<PositionedGlyph>,
86    /// The rectangle in the run's paint coordinates that covers the ink of
87    /// every glyph, plus one logical pixel on every side for anti-aliasing.
88    ///
89    /// A renderer may rely on it as the area the run can change: ink outside
90    /// it is not guaranteed to be drawn.
91    pub ink_bounds: Rect,
92}
93
94/// A single drawing operation in paint coordinates, which leave out the
95/// scroll offsets of enclosing views (see [`crate::widget::Widget::paint`]).
96#[derive(Clone, Debug, PartialEq)]
97#[non_exhaustive]
98pub enum PaintOp {
99    /// A filled rectangle with optional rounded corners.
100    FillRect {
101        /// Area the fill covers.
102        rect: Rect,
103        /// Corner radii of the filled shape.
104        radius: Corners,
105        /// Straight-alpha sRGB fill color.
106        color: Color,
107    },
108    /// A border drawn inside `rect`, so it never extends past the rectangle.
109    StrokeRect {
110        /// Outer edge of the border.
111        rect: Rect,
112        /// Corner radii of the border.
113        radius: Corners,
114        /// Border thickness in logical pixels, measured inwards from the edge
115        /// of `rect`.
116        width: f32,
117        /// Straight-alpha sRGB border color.
118        color: Color,
119    },
120    /// A shaped run of glyphs, painted in one font, size and color.
121    ///
122    /// The run is shared in an `Rc` so that rebuilding the display list for a
123    /// frame in which text did not change copies no glyph vectors.
124    Glyphs(Rc<GlyphRun>),
125    /// A premultiplied RGBA image stretched over a rounded rectangle.
126    Image {
127        /// Area the image stretches over.
128        rect: Rect,
129        /// Corner radii of the rounded rectangle the image clips to.
130        radius: Corners,
131        /// The pixels to draw, shared with the element that shows them.
132        image: crate::image::Image,
133    },
134}
135
136impl PaintOp {
137    /// Returns the rectangle in the operation's paint coordinates that this
138    /// operation can change.
139    ///
140    /// The bounds cover the whole operation, including a stroke, which is drawn
141    /// inside its rectangle. Anti-aliasing can shade pixels the edge passes
142    /// through, which is why damage converts these bounds to physical pixels by
143    /// rounding outwards rather than to the nearest pixel.
144    pub fn bounds(&self) -> Rect {
145        match self {
146            PaintOp::FillRect { rect, .. } | PaintOp::StrokeRect { rect, .. } => *rect,
147            PaintOp::Glyphs(run) => run.ink_bounds,
148            PaintOp::Image { rect, .. } => *rect,
149        }
150    }
151}
152
153/// One entry in a [`DisplayList`].
154#[derive(Clone, Debug, PartialEq)]
155pub enum DisplayItem {
156    /// A drawing operation.
157    Op(PaintOp),
158    /// Clips every following item to this rectangle until the matching
159    /// [`DisplayItem::PopClip`].
160    PushClip(Rect),
161    /// Restores the clip that was active before the matching
162    /// [`DisplayItem::PushClip`].
163    PopClip,
164    /// Draws every following item translated by this offset until the
165    /// matching [`DisplayItem::PopOffset`].
166    ///
167    /// Offsets nest with clips and with each other: an item's window position
168    /// is its own position plus the sum of the open offsets. Clips pushed
169    /// inside an offset scope hold their rectangle in the scope's coordinates,
170    /// so translating the scope moves the clip with the items it cuts.
171    PushOffset(Point),
172    /// Restores the offset that was active before the matching
173    /// [`DisplayItem::PushOffset`].
174    PopOffset,
175}
176
177/// A flat list of display items in paint order, ready for a renderer to replay.
178///
179/// The list is rebuilt from the widget tree whenever a node paints. Clips are
180/// nested, so every [`DisplayItem::PushClip`] needs a later
181/// [`DisplayItem::PopClip`], which [`DisplayList::finish`] checks in debug
182/// builds. Offsets nest the same way with [`DisplayItem::PushOffset`] and
183/// [`DisplayItem::PopOffset`].
184#[derive(Clone, Debug, Default, PartialEq)]
185pub struct DisplayList {
186    items: Vec<DisplayItem>,
187}
188
189impl DisplayList {
190    /// Creates an empty list.
191    ///
192    /// ```
193    /// use smudge_core::geometry::{Color, Corners, Rect};
194    /// use smudge_core::paint::{DisplayList, PaintOp};
195    ///
196    /// let mut list = DisplayList::new();
197    /// list.push_op(PaintOp::FillRect {
198    ///     rect: Rect::new(0.0, 0.0, 8.0, 8.0),
199    ///     radius: Corners::ZERO,
200    ///     color: Color::BLACK,
201    /// });
202    /// list.finish();
203    /// assert_eq!(list.len(), 1);
204    /// ```
205    pub fn new() -> DisplayList {
206        DisplayList::default()
207    }
208
209    /// Removes every item, leaving the list empty and ready for a new build.
210    pub fn clear(&mut self) {
211        self.items.clear();
212    }
213
214    /// Appends one drawing operation.
215    pub fn push_op(&mut self, op: PaintOp) {
216        self.items.push(DisplayItem::Op(op));
217    }
218
219    /// Appends several drawing operations in order.
220    pub fn extend_ops(&mut self, ops: &[PaintOp]) {
221        self.items.extend(ops.iter().cloned().map(DisplayItem::Op));
222    }
223
224    /// Starts a clip to `rect`, which applies to every following item until the
225    /// matching [`DisplayList::pop_clip`].
226    pub fn push_clip(&mut self, rect: Rect) {
227        self.items.push(DisplayItem::PushClip(rect));
228    }
229
230    /// Ends the most recent clip.
231    ///
232    /// Callers must pair every call with an earlier
233    /// [`DisplayList::push_clip`]. [`DisplayList::finish`] reports an imbalance
234    /// in debug builds.
235    pub fn pop_clip(&mut self) {
236        self.items.push(DisplayItem::PopClip);
237    }
238
239    /// Starts an offset scope, which translates every following item by
240    /// `offset` until the matching [`DisplayList::pop_offset`].
241    ///
242    /// Scopes nest with clips and with each other. A clip pushed inside a
243    /// scope holds its rectangle in the scope's coordinates, so it moves with
244    /// the scope.
245    pub fn push_offset(&mut self, offset: Point) {
246        self.items.push(DisplayItem::PushOffset(offset));
247    }
248
249    /// Ends the most recent offset scope.
250    ///
251    /// Callers must pair every call with an earlier
252    /// [`DisplayList::push_offset`]. [`DisplayList::finish`] reports an
253    /// imbalance in debug builds.
254    pub fn pop_offset(&mut self) {
255        self.items.push(DisplayItem::PopOffset);
256    }
257
258    /// Returns the items in paint order.
259    pub fn items(&self) -> &[DisplayItem] {
260        &self.items
261    }
262
263    /// Returns the number of items, counting clips.
264    pub fn len(&self) -> usize {
265        self.items.len()
266    }
267
268    /// Returns `true` when the list holds no item.
269    pub fn is_empty(&self) -> bool {
270        self.items.is_empty()
271    }
272
273    /// Completes a build of the list.
274    ///
275    /// The frame calls this once after rebuilding the list and before handing
276    /// it to a renderer. In release builds it does nothing, because an
277    /// unbalanced list is an internal invariant violation rather than input
278    /// from outside the library.
279    ///
280    /// # Panics
281    ///
282    /// Panics in debug builds when a [`DisplayItem::PushClip`] has no matching
283    /// [`DisplayItem::PopClip`], when a `PopClip` appears with no clip open,
284    /// or when an offset scope is unbalanced the same way.
285    pub fn finish(&mut self) {
286        #[cfg(debug_assertions)]
287        self.debug_assert_balanced();
288    }
289
290    /// Panics unless every clip and offset scope was pushed before it was
291    /// popped.
292    #[cfg(debug_assertions)]
293    fn debug_assert_balanced(&self) {
294        let mut open = 0usize;
295        let mut open_offsets = 0usize;
296        for item in &self.items {
297            match item {
298                DisplayItem::PushClip(_) => open += 1,
299                DisplayItem::PopClip => {
300                    debug_assert!(open > 0, "PopClip with no open clip");
301                    open = open.saturating_sub(1);
302                }
303                DisplayItem::PushOffset(_) => open_offsets += 1,
304                DisplayItem::PopOffset => {
305                    debug_assert!(open_offsets > 0, "PopOffset with no open offset");
306                    open_offsets = open_offsets.saturating_sub(1);
307                }
308                DisplayItem::Op(_) => {}
309            }
310        }
311        debug_assert_eq!(open, 0, "PushClip with no matching PopClip");
312        debug_assert_eq!(open_offsets, 0, "PushOffset with no matching PopOffset");
313    }
314}
315
316#[cfg(test)]
317#[path = "paint_tests.rs"]
318mod tests;