Skip to main content

telar_renderer_core/
draw_state.rs

1use geometry_core::{Point, Rect, Transform};
2
3use crate::DrawCommand;
4
5/// Maps clip rect `r` (in the currently-active transform's local space) to window space — the axis-aligned bounds of its four mapped corners. Widgets emit clip rects in their own local space, but the renderer clips in window space, so a clip must be mapped through the active cumulative matrix (scroll/layout translations) to compose correctly.
6pub fn transform_clip_rect(m: [f32; 6], r: Rect) -> Rect {
7    let [a, b, c, d, e, f] = m;
8    let map = |x: f32, y: f32| (a * x + c * y + e, b * x + d * y + f);
9    let corners = [
10        map(r.x, r.y),
11        map(r.x + r.width, r.y),
12        map(r.x, r.y + r.height),
13        map(r.x + r.width, r.y + r.height),
14    ];
15    let mut min_x = f32::INFINITY;
16    let mut min_y = f32::INFINITY;
17    let mut max_x = f32::NEG_INFINITY;
18    let mut max_y = f32::NEG_INFINITY;
19    for (x, y) in corners {
20        min_x = min_x.min(x);
21        min_y = min_y.min(y);
22        max_x = max_x.max(x);
23        max_y = max_y.max(y);
24    }
25    Rect {
26        x: min_x,
27        y: min_y,
28        width: max_x - min_x,
29        height: max_y - min_y,
30    }
31}
32
33/// Flat draw state tracking clips and transforms. Note: `PushLayer` and `PopLayer` commands are intentionally not tracked here; layers are managed outside this struct by the caller.
34pub struct DrawState {
35    clip_stack: Vec<Rect>,
36    transform_stack: Vec<[f32; 6]>,
37    pub cumulative_matrix: [f32; 6],
38}
39
40impl DrawState {
41    pub fn new() -> Self {
42        Self {
43            clip_stack: Vec::with_capacity(16),
44            transform_stack: Vec::with_capacity(16),
45            cumulative_matrix: Transform::IDENTITY.to_array(),
46        }
47    }
48
49    /// Pushes `rect` as a clip, intersected with whatever is already clipping, and returns the effective
50    /// scissor.
51    ///
52    /// A child clip that does *not* meet its parent clips away to nothing, and that case is worth spelling
53    /// out because getting it wrong is invisible in every test that does not scroll: `intersect` answers
54    /// `None` for two rects that do not overlap, and falling back to `rect` there hands the child its **own**
55    /// box as the scissor — outside everything that contains it. A widget that emits a clip of its own (an
56    /// image does, for a `Cover` overflow or a corner radius) therefore escaped the scroll area it lived in
57    /// the moment it scrolled out of view: it went on being drawn at its true position, outside the panel,
58    /// sliding as the content scrolled, and only looked right once its own box fell back inside the viewport.
59    /// Nothing *without* a clip of its own could show the bug, which is why the text and boxes beside it in
60    /// the same list were always clipped correctly.
61    #[inline]
62    pub fn push_clip(&mut self, rect: Rect) -> Rect {
63        // The empty rect is placed at the *parent's* origin rather than the child's. An empty clip has no
64        // position worth keeping, and a backend that cannot express one — wgpu rejects an empty scissor, so
65        // `physical_scissor` rounds it up to 1×1 — then draws that one pixel somewhere it was already allowed
66        // to draw, instead of leaving a stray dot outside the panel.
67        let effective = match self.clip_stack.last() {
68            Some(&current) => current
69                .intersect(rect)
70                .unwrap_or_else(|| Rect::new(current.x, current.y, 0.0, 0.0)),
71            None => rect,
72        };
73        self.clip_stack.push(effective);
74        effective
75    }
76
77    #[inline]
78    pub fn pop_clip(&mut self) -> Option<Rect> {
79        self.clip_stack.pop();
80        self.clip_stack.last().copied()
81    }
82
83    #[inline]
84    pub fn current_clip(&self) -> Option<Rect> {
85        self.clip_stack.last().copied()
86    }
87
88    #[inline]
89    pub fn push_matrix(&mut self, matrix: [f32; 6]) {
90        self.transform_stack.push(self.cumulative_matrix);
91        // Compose cumulative ∘ matrix: `a.then(b)` yields `b ∘ a`, so `matrix.then(cumulative)` maps a local point through `matrix` first, then the accumulated parent chain.
92        self.cumulative_matrix = Transform::from_array(matrix)
93            .then(Transform::from_array(self.cumulative_matrix))
94            .to_array();
95    }
96
97    #[inline]
98    pub fn pop_matrix(&mut self) {
99        if let Some(prev) = self.transform_stack.pop() {
100            self.cumulative_matrix = prev;
101        }
102    }
103
104    #[inline]
105    pub fn apply_point(&self, x: f32, y: f32) -> (f32, f32) {
106        let p = Transform::from_array(self.cumulative_matrix).apply(Point::new(x, y));
107        (p.x, p.y)
108    }
109
110    pub fn reset(&mut self) {
111        self.clip_stack.clear();
112        self.transform_stack.clear();
113        self.cumulative_matrix = Transform::IDENTITY.to_array();
114    }
115}
116
117impl Default for DrawState {
118    fn default() -> Self {
119        Self::new()
120    }
121}
122
123/// Iterates `cmds` calling `f(cmd, cumulative_matrix)` for every command. PushMatrix/PopMatrix update the matrix before the callback; all other commands see the matrix that was active when they were emitted.
124pub fn for_each_with_matrix<F>(cmds: &[DrawCommand], mut f: F)
125where
126    F: FnMut(&DrawCommand, [f32; 6]),
127{
128    let mut state = DrawState::new();
129    for cmd in cmds {
130        match cmd {
131            DrawCommand::PushMatrix { matrix } => state.push_matrix(*matrix),
132            DrawCommand::PopMatrix => state.pop_matrix(),
133            _ => {}
134        }
135        f(cmd, state.cumulative_matrix);
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    // Guards the push_matrix compose argument order (cumulative ∘ matrix) against the pre-refactor
144    // hand-rolled `compose_matrix(parent, child)`; a swapped order silently corrupts nested transforms.
145    #[test]
146    fn push_matrix_matches_legacy_compose_order() {
147        let cumulative = [2.0, 0.1, -0.2, 2.0, 10.0, 20.0];
148        let matrix = [1.0, 0.5, -0.5, 1.0, 5.0, 7.0];
149
150        // Old `compose_matrix(parent = cumulative, child = matrix)` computing parent(child(p)).
151        let [a1, b1, c1, d1, e1, f1] = matrix;
152        let [a2, b2, c2, d2, e2, f2] = cumulative;
153        let expected = [
154            a2 * a1 + c2 * b1,
155            b2 * a1 + d2 * b1,
156            a2 * c1 + c2 * d1,
157            b2 * c1 + d2 * d1,
158            a2 * e1 + c2 * f1 + e2,
159            b2 * e1 + d2 * f1 + f2,
160        ];
161
162        let mut state = DrawState::new();
163        state.push_matrix(cumulative);
164        state.push_matrix(matrix);
165        assert_eq!(state.cumulative_matrix, expected);
166    }
167
168    /// A clip nested inside one it does not touch must clip away to nothing. The failing case is a widget
169    /// that emits its own clip — an image with a `Cover` overflow or a corner radius — scrolled out of the
170    /// viewport containing it: falling back to the child's own rect scissors to a box outside the parent, and
171    /// the widget goes on being drawn there, over whatever the panel happens to be sitting on.
172    #[test]
173    fn a_clip_outside_its_parent_clips_away_to_nothing() {
174        let viewport = Rect::new(0.0, 0.0, 340.0, 300.0);
175        let mut state = DrawState::new();
176        assert_eq!(
177            state.push_clip(viewport),
178            viewport,
179            "the outermost clip is itself"
180        );
181
182        // An avatar 560px down a scrolling column: its own 56×56 clip is nowhere near the viewport.
183        let escaped = state.push_clip(Rect::new(0.0, 560.0, 56.0, 56.0));
184        assert_eq!(
185            (escaped.width, escaped.height),
186            (0.0, 0.0),
187            "an image scrolled out of its panel must not be drawn at all, got {escaped:?}"
188        );
189        assert!(
190            viewport
191                .intersect(Rect::new(escaped.x, escaped.y, 1.0, 1.0))
192                .is_some(),
193            "and the empty clip sits inside the parent, so a backend that rounds it up to 1×1 draws that \
194             pixel where it was already allowed to: {escaped:?}"
195        );
196
197        state.pop_clip();
198        // The same image scrolled back in is clipped to the part of it the viewport actually shows.
199        let visible = state.push_clip(Rect::new(0.0, 280.0, 56.0, 56.0));
200        assert_eq!(visible, Rect::new(0.0, 280.0, 56.0, 20.0));
201
202        // And a clip with nothing above it is still itself, which is what the scroll area's own one relies on.
203        let mut fresh = DrawState::new();
204        let alone = Rect::new(12.0, 34.0, 56.0, 78.0);
205        assert_eq!(fresh.push_clip(alone), alone);
206    }
207}