Skip to main content

oxiui_core/
paint.rs

1//! Draw-command buffer and render-backend abstraction for OxiUI.
2//!
3//! This module defines the canonical *paint* layer:
4//!
5//! - [`DrawCommand`] — a single GPU/CPU-agnostic draw operation.
6//! - [`DrawList`] — an ordered buffer of draw commands with clip-stack
7//!   tracking and accumulated bounds.
8//! - [`RenderBackend`] — a trait that backends implement to consume a
9//!   [`DrawList`].
10//! - Supporting types: [`PathData`], [`PathVerb`], [`StrokeStyle`],
11//!   [`GradientStop`], [`ImageData`], [`ImageFilter`], [`FillRule`],
12//!   [`LineJoin`], [`LineCap`].
13
14use crate::geometry::{Point, Rect, Size};
15use crate::UiError;
16use crate::{Color, FontSpec};
17
18// ── Enums: fill rule, join, cap ─────────────────────────────────────────────
19
20/// The rule used to determine which parts of a self-intersecting path are
21/// considered "inside" for filling purposes.
22#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
23pub enum FillRule {
24    /// The even-odd rule alternates inside/outside on each crossing.
25    EvenOdd,
26    /// The non-zero winding-number rule (the default for most renderers).
27    #[default]
28    NonZero,
29}
30
31/// The style used to join two path segments at a corner.
32#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
33pub enum LineJoin {
34    /// A sharp miter join (clipped at [`StrokeStyle::miter_limit`]).
35    #[default]
36    Miter,
37    /// A flat bevel cut across the outside corner.
38    Bevel,
39    /// A circular arc centered at the corner point.
40    Round,
41}
42
43/// The style applied to the start and end caps of an open path segment.
44#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
45pub enum LineCap {
46    /// No cap — the stroke ends exactly at the path endpoint.
47    #[default]
48    Butt,
49    /// A semicircular cap extending half the stroke width beyond the endpoint.
50    Round,
51    /// A rectangular cap extending half the stroke width beyond the endpoint.
52    Square,
53}
54
55// ── StrokeStyle ─────────────────────────────────────────────────────────────
56
57/// Parameters controlling how a path's outline is stroked.
58#[derive(Clone, Copy, Debug, PartialEq)]
59pub struct StrokeStyle {
60    /// Stroke width in logical pixels.
61    pub width: f32,
62    /// Corner join style.
63    pub join: LineJoin,
64    /// End-cap style for open sub-paths.
65    pub cap: LineCap,
66    /// Maximum ratio of miter length to stroke width before the join is
67    /// clipped to a bevel.
68    pub miter_limit: f32,
69}
70
71impl Default for StrokeStyle {
72    fn default() -> Self {
73        Self {
74            width: 1.0,
75            join: LineJoin::Miter,
76            cap: LineCap::Butt,
77            miter_limit: 4.0,
78        }
79    }
80}
81
82// ── GradientStop ────────────────────────────────────────────────────────────
83
84/// A single colour stop in a gradient ramp.
85#[derive(Clone, Copy, Debug, PartialEq)]
86pub struct GradientStop {
87    /// Position within the gradient, clamped to `[0.0, 1.0]`.
88    pub offset: f32,
89    /// The colour at this stop.
90    pub color: Color,
91}
92
93impl GradientStop {
94    /// Construct a gradient stop, clamping `offset` to `[0.0, 1.0]`.
95    pub fn new(offset: f32, color: Color) -> Self {
96        Self {
97            offset: offset.clamp(0.0, 1.0),
98            color,
99        }
100    }
101}
102
103// ── ImageFilter ─────────────────────────────────────────────────────────────
104
105/// The sampling filter applied when scaling an image.
106#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
107pub enum ImageFilter {
108    /// Nearest-neighbour sampling (blocky, no blending).
109    #[default]
110    Nearest,
111    /// Bilinear interpolation (smoother, slightly blurred).
112    Bilinear,
113}
114
115// ── ImageData ───────────────────────────────────────────────────────────────
116
117/// Owned raw RGBA image data.
118#[derive(Clone, Debug, PartialEq)]
119pub struct ImageData {
120    /// Raw pixel data in row-major RGBA order (`width * height * 4` bytes).
121    pub rgba: Vec<u8>,
122    /// Image width in pixels.
123    pub width: u32,
124    /// Image height in pixels.
125    pub height: u32,
126}
127
128impl ImageData {
129    /// Construct an [`ImageData`] from a raw RGBA byte vector and dimensions.
130    pub fn new(rgba: Vec<u8>, width: u32, height: u32) -> Self {
131        Self {
132            rgba,
133            width,
134            height,
135        }
136    }
137}
138
139// ── PathVerb / PathData ─────────────────────────────────────────────────────
140
141/// A single drawing verb in a [`PathData`] sequence.
142#[derive(Clone, Copy, Debug, PartialEq)]
143pub enum PathVerb {
144    /// Begin a new sub-path at the given point.
145    MoveTo(Point),
146    /// Draw a straight line to the given point.
147    LineTo(Point),
148    /// Draw a quadratic Bézier curve with one control point.
149    QuadTo {
150        /// The single control point.
151        ctrl: Point,
152        /// The end point of the curve.
153        end: Point,
154    },
155    /// Draw a cubic Bézier curve with two control points.
156    CubicTo {
157        /// The first control point.
158        c1: Point,
159        /// The second control point.
160        c2: Point,
161        /// The end point of the curve.
162        end: Point,
163    },
164    /// Close the current sub-path by drawing a line back to the last `MoveTo`.
165    Close,
166}
167
168/// A resolution-independent path built from [`PathVerb`] segments.
169///
170/// Paths are the primitive used for arbitrary filled and stroked shapes.
171/// Build a path with the chaining builder methods, then pass it to
172/// [`DrawList::push_path`] or [`DrawList::push_stroke_path`].
173#[derive(Clone, Debug, Default, PartialEq)]
174pub struct PathData {
175    /// The ordered sequence of drawing verbs that define this path.
176    pub verbs: Vec<PathVerb>,
177    /// The fill rule used when rasterising filled versions of this path.
178    pub fill_rule: FillRule,
179}
180
181impl PathData {
182    /// Construct an empty path with `FillRule::NonZero`.
183    pub fn new() -> Self {
184        Self::default()
185    }
186
187    /// Set the [`FillRule`] on this path (builder-style).
188    pub fn with_fill_rule(mut self, rule: FillRule) -> Self {
189        self.fill_rule = rule;
190        self
191    }
192
193    /// Append a `MoveTo` verb.
194    pub fn move_to(&mut self, p: Point) -> &mut Self {
195        self.verbs.push(PathVerb::MoveTo(p));
196        self
197    }
198
199    /// Append a `LineTo` verb.
200    pub fn line_to(&mut self, p: Point) -> &mut Self {
201        self.verbs.push(PathVerb::LineTo(p));
202        self
203    }
204
205    /// Append a quadratic Bézier `QuadTo` verb.
206    pub fn quad_to(&mut self, ctrl: Point, end: Point) -> &mut Self {
207        self.verbs.push(PathVerb::QuadTo { ctrl, end });
208        self
209    }
210
211    /// Append a cubic Bézier `CubicTo` verb.
212    pub fn cubic_to(&mut self, c1: Point, c2: Point, end: Point) -> &mut Self {
213        self.verbs.push(PathVerb::CubicTo { c1, c2, end });
214        self
215    }
216
217    /// Append a `Close` verb, closing the current sub-path.
218    pub fn close(&mut self) -> &mut Self {
219        self.verbs.push(PathVerb::Close);
220        self
221    }
222
223    /// Returns `true` if this path contains no verbs.
224    pub fn is_empty(&self) -> bool {
225        self.verbs.is_empty()
226    }
227
228    /// Conservative axis-aligned bounding box over all control and anchor
229    /// points in the path.
230    ///
231    /// Returns `None` if the path is empty.  Note this is a *control-point*
232    /// AABB, not a tight geometric bounds: Bézier curves can dip outside the
233    /// control-point hull.
234    pub fn bounds(&self) -> Option<Rect> {
235        let mut min_x = f32::MAX;
236        let mut min_y = f32::MAX;
237        let mut max_x = f32::MIN;
238        let mut max_y = f32::MIN;
239        let mut found = false;
240
241        let mut update = |p: Point| {
242            found = true;
243            if p.x < min_x {
244                min_x = p.x;
245            }
246            if p.y < min_y {
247                min_y = p.y;
248            }
249            if p.x > max_x {
250                max_x = p.x;
251            }
252            if p.y > max_y {
253                max_y = p.y;
254            }
255        };
256
257        for verb in &self.verbs {
258            match verb {
259                PathVerb::MoveTo(p) | PathVerb::LineTo(p) => update(*p),
260                PathVerb::QuadTo { ctrl, end } => {
261                    update(*ctrl);
262                    update(*end);
263                }
264                PathVerb::CubicTo { c1, c2, end } => {
265                    update(*c1);
266                    update(*c2);
267                    update(*end);
268                }
269                PathVerb::Close => {}
270            }
271        }
272
273        if found {
274            Some(Rect::new(min_x, min_y, max_x - min_x, max_y - min_y))
275        } else {
276            None
277        }
278    }
279}
280
281// ── DrawCommand ─────────────────────────────────────────────────────────────
282
283/// A single, backend-agnostic draw operation.
284///
285/// Commands are stored in a [`DrawList`] and later replayed by a
286/// [`RenderBackend`]. The enum is `#[non_exhaustive]` so that new variants
287/// can be added without breaking downstream code.
288#[derive(Clone, Debug, PartialEq)]
289#[non_exhaustive]
290pub enum DrawCommand {
291    // ── Clipping ──────────────────────────────────────────────────────────
292    /// Push a rectangular clip region onto the clip stack.
293    ///
294    /// All subsequent commands are clipped to the intersection of active clip
295    /// rectangles until the matching [`DrawCommand::PopClip`].
296    PushClip {
297        /// The clip rectangle in logical pixels.
298        rect: Rect,
299    },
300
301    /// Pop the most recently pushed clip rectangle from the clip stack.
302    PopClip,
303
304    // ── Rectangles ────────────────────────────────────────────────────────
305    /// Fill an axis-aligned rectangle with a solid colour.
306    FillRect {
307        /// The rectangle to fill.
308        rect: Rect,
309        /// Fill colour.
310        color: Color,
311    },
312
313    /// Stroke the outline of an axis-aligned rectangle.
314    StrokeRect {
315        /// The rectangle to stroke.
316        rect: Rect,
317        /// Stroke width in logical pixels.
318        thickness: f32,
319        /// Stroke colour.
320        color: Color,
321    },
322
323    /// Fill a rectangle with uniformly rounded corners.
324    FillRoundedRect {
325        /// The rectangle to fill.
326        rect: Rect,
327        /// Corner radius in logical pixels (applied to all four corners).
328        radius: f32,
329        /// Fill colour.
330        color: Color,
331    },
332
333    /// Fill a rectangle with per-corner radii.
334    ///
335    /// `radii` is `[top-left, top-right, bottom-right, bottom-left]`.
336    FillRoundedRectPerCorner {
337        /// The rectangle to fill.
338        rect: Rect,
339        /// Per-corner radii `[tl, tr, br, bl]` in logical pixels.
340        radii: [f32; 4],
341        /// Fill colour.
342        color: Color,
343    },
344
345    // ── Circles / Ellipses ────────────────────────────────────────────────
346    /// Fill a circle with a solid colour.
347    FillCircle {
348        /// Centre point of the circle.
349        center: Point,
350        /// Radius in logical pixels.
351        radius: f32,
352        /// Fill colour.
353        color: Color,
354    },
355
356    /// Fill an ellipse with a solid colour.
357    FillEllipse {
358        /// Centre point of the ellipse.
359        center: Point,
360        /// Horizontal (X-axis) radius in logical pixels.
361        rx: f32,
362        /// Vertical (Y-axis) radius in logical pixels.
363        ry: f32,
364        /// Fill colour.
365        color: Color,
366    },
367
368    // ── Lines ─────────────────────────────────────────────────────────────
369    /// Draw a 1-pixel aliased line segment.
370    Line {
371        /// Start point of the line.
372        from: Point,
373        /// End point of the line.
374        to: Point,
375        /// Line colour.
376        color: Color,
377    },
378
379    /// Draw a 1-pixel anti-aliased line segment.
380    LineAa {
381        /// Start point of the line.
382        from: Point,
383        /// End point of the line.
384        to: Point,
385        /// Line colour.
386        color: Color,
387    },
388
389    /// Draw a thick, filled line segment.
390    LineThick {
391        /// Start point of the line.
392        from: Point,
393        /// End point of the line.
394        to: Point,
395        /// Width of the line in logical pixels.
396        width: f32,
397        /// Line colour.
398        color: Color,
399    },
400
401    /// Draw a dashed line segment.
402    LineDashed {
403        /// Start point of the line.
404        from: Point,
405        /// End point of the line.
406        to: Point,
407        /// Length of each dash in logical pixels.
408        dash_len: f32,
409        /// Length of each gap in logical pixels.
410        gap_len: f32,
411        /// Line colour.
412        color: Color,
413    },
414
415    // ── Paths ─────────────────────────────────────────────────────────────
416    /// Fill a path with a solid colour.
417    FillPath {
418        /// The path to fill.
419        path: PathData,
420        /// Fill colour.
421        color: Color,
422    },
423
424    /// Stroke a path with a solid colour and style.
425    StrokePath {
426        /// The path to stroke.
427        path: PathData,
428        /// Stroke parameters (width, join, cap, miter limit).
429        style: StrokeStyle,
430        /// Stroke colour.
431        color: Color,
432    },
433
434    // ── Gradients ─────────────────────────────────────────────────────────
435    /// Fill a rectangular region with a linear gradient.
436    LinearGradient {
437        /// The destination rectangle (defines the fill area).
438        rect: Rect,
439        /// Start point of the gradient axis.
440        start: Point,
441        /// End point of the gradient axis.
442        end: Point,
443        /// Colour stops defining the ramp.
444        stops: Vec<GradientStop>,
445    },
446
447    /// Fill a rectangular region with a radial gradient.
448    RadialGradient {
449        /// The destination rectangle (defines the fill area).
450        rect: Rect,
451        /// Centre of the radial gradient.
452        center: Point,
453        /// Outer radius of the gradient in logical pixels.
454        radius: f32,
455        /// Colour stops defining the ramp.
456        stops: Vec<GradientStop>,
457    },
458
459    // ── Images ────────────────────────────────────────────────────────────
460    /// Blit a raw RGBA image into a destination rectangle.
461    Image {
462        /// The source image data.
463        image: ImageData,
464        /// Destination rectangle in logical pixels.
465        dest: Rect,
466        /// Resampling filter to use when scaling.
467        filter: ImageFilter,
468    },
469
470    /// Draw an image using 9-slice scaling.
471    ///
472    /// `insets` is `[top, right, bottom, left]` in pixels of the source image.
473    NineSlice {
474        /// The source image data.
475        image: ImageData,
476        /// Destination rectangle in logical pixels.
477        dest: Rect,
478        /// 9-slice insets `[top, right, bottom, left]` in source pixels.
479        insets: [u32; 4],
480    },
481
482    // ── Shadows ───────────────────────────────────────────────────────────
483    /// Draw a box shadow behind a rectangle.
484    BoxShadow {
485        /// The rectangle casting the shadow.
486        rect: Rect,
487        /// Shadow offset relative to `rect`.
488        offset: Point,
489        /// Blur radius in logical pixels (0 = hard edge).
490        blur_radius: f32,
491        /// Shadow colour (typically semi-transparent).
492        color: Color,
493    },
494
495    // ── Text ──────────────────────────────────────────────────────────────
496    /// Draw text into a rectangle.
497    ///
498    /// Full shaping is delegated to the backend; this command is a v1
499    /// placeholder. Backends that do not support text return `Err`.
500    DrawText {
501        /// Bounding rectangle for the text.
502        rect: Rect,
503        /// The string to render.
504        text: String,
505        /// Font specification (family, size, weight, style).
506        font: FontSpec,
507        /// Text colour.
508        color: Color,
509    },
510}
511
512// ── DrawList ─────────────────────────────────────────────────────────────────
513
514/// An ordered buffer of [`DrawCommand`]s, with integrated clip-stack and bounds
515/// tracking.
516///
517/// Build a list with the typed `push_*` helpers (or the low-level [`push`])
518/// then pass it to a [`RenderBackend::execute`] call.
519///
520/// [`push`]: DrawList::push
521#[derive(Clone, Debug, Default)]
522pub struct DrawList {
523    cmds: Vec<DrawCommand>,
524    clip_depth: usize,
525    bounds: Option<Rect>,
526}
527
528impl DrawList {
529    /// Construct an empty [`DrawList`].
530    pub fn new() -> Self {
531        Self::default()
532    }
533
534    /// Append an arbitrary [`DrawCommand`], automatically updating clip depth
535    /// and accumulated bounds.
536    pub fn push(&mut self, cmd: DrawCommand) {
537        match &cmd {
538            DrawCommand::PushClip { .. } => {
539                self.clip_depth = self.clip_depth.saturating_add(1);
540            }
541            DrawCommand::PopClip => {
542                self.clip_depth = self.clip_depth.saturating_sub(1);
543            }
544            _ => {
545                if let Some(b) = Self::cmd_bounds(&cmd) {
546                    self.bounds = Some(match self.bounds {
547                        None => b,
548                        Some(existing) => existing.union(&b),
549                    });
550                }
551            }
552        }
553        self.cmds.push(cmd);
554    }
555
556    /// Return the number of commands in the list.
557    pub fn len(&self) -> usize {
558        self.cmds.len()
559    }
560
561    /// Return `true` if the list contains no commands.
562    pub fn is_empty(&self) -> bool {
563        self.cmds.is_empty()
564    }
565
566    /// Iterate over all commands in submission order.
567    pub fn iter(&self) -> std::slice::Iter<'_, DrawCommand> {
568        self.cmds.iter()
569    }
570
571    /// Remove all commands and reset clip depth and bounds.
572    pub fn clear(&mut self) {
573        self.cmds.clear();
574        self.clip_depth = 0;
575        self.bounds = None;
576    }
577
578    /// Return the accumulated axis-aligned bounding box of all non-clip draw
579    /// commands, or `None` if no draw commands have been pushed.
580    pub fn bounds(&self) -> Option<Rect> {
581        self.bounds
582    }
583
584    /// Return the current clip-stack depth.  Zero means balanced.
585    pub fn clip_depth(&self) -> usize {
586        self.clip_depth
587    }
588
589    /// Return `true` if the clip stack is balanced (depth == 0).
590    pub fn is_clip_balanced(&self) -> bool {
591        self.clip_depth == 0
592    }
593
594    // ── Typed push helpers ───────────────────────────────────────────────
595
596    /// Push a solid-filled rectangle.
597    pub fn push_rect(&mut self, rect: Rect, color: Color) {
598        self.push(DrawCommand::FillRect { rect, color });
599    }
600
601    /// Push a stroked rectangle outline.
602    pub fn push_stroke_rect(&mut self, rect: Rect, thickness: f32, color: Color) {
603        self.push(DrawCommand::StrokeRect {
604            rect,
605            thickness,
606            color,
607        });
608    }
609
610    /// Push a filled rectangle with uniform corner radius.
611    pub fn push_rounded_rect(&mut self, rect: Rect, radius: f32, color: Color) {
612        self.push(DrawCommand::FillRoundedRect {
613            rect,
614            radius,
615            color,
616        });
617    }
618
619    /// Push a filled rectangle with per-corner radii `[tl, tr, br, bl]`.
620    pub fn push_rounded_rect_per_corner(&mut self, rect: Rect, radii: [f32; 4], color: Color) {
621        self.push(DrawCommand::FillRoundedRectPerCorner { rect, radii, color });
622    }
623
624    /// Push a filled circle.
625    pub fn push_circle(&mut self, center: Point, radius: f32, color: Color) {
626        self.push(DrawCommand::FillCircle {
627            center,
628            radius,
629            color,
630        });
631    }
632
633    /// Push a filled ellipse.
634    pub fn push_ellipse(&mut self, center: Point, rx: f32, ry: f32, color: Color) {
635        self.push(DrawCommand::FillEllipse {
636            center,
637            rx,
638            ry,
639            color,
640        });
641    }
642
643    /// Push a 1-pixel aliased line segment.
644    pub fn push_line(&mut self, from: Point, to: Point, color: Color) {
645        self.push(DrawCommand::Line { from, to, color });
646    }
647
648    /// Push a 1-pixel anti-aliased line segment.
649    pub fn push_line_aa(&mut self, from: Point, to: Point, color: Color) {
650        self.push(DrawCommand::LineAa { from, to, color });
651    }
652
653    /// Push a thick, filled line segment.
654    pub fn push_line_thick(&mut self, from: Point, to: Point, width: f32, color: Color) {
655        self.push(DrawCommand::LineThick {
656            from,
657            to,
658            width,
659            color,
660        });
661    }
662
663    /// Push a dashed line segment.
664    pub fn push_line_dashed(
665        &mut self,
666        from: Point,
667        to: Point,
668        dash_len: f32,
669        gap_len: f32,
670        color: Color,
671    ) {
672        self.push(DrawCommand::LineDashed {
673            from,
674            to,
675            dash_len,
676            gap_len,
677            color,
678        });
679    }
680
681    /// Push a clip rectangle onto the clip stack.
682    pub fn push_clip(&mut self, rect: Rect) {
683        self.push(DrawCommand::PushClip { rect });
684    }
685
686    /// Pop the top clip rectangle from the clip stack.
687    pub fn pop_clip(&mut self) {
688        self.push(DrawCommand::PopClip);
689    }
690
691    /// Push a solid-filled path.
692    pub fn push_path(&mut self, path: PathData, color: Color) {
693        self.push(DrawCommand::FillPath { path, color });
694    }
695
696    /// Push a stroked path.
697    pub fn push_stroke_path(&mut self, path: PathData, style: StrokeStyle, color: Color) {
698        self.push(DrawCommand::StrokePath { path, style, color });
699    }
700
701    /// Push a linear gradient fill over `rect`.
702    pub fn push_gradient_linear(
703        &mut self,
704        rect: Rect,
705        start: Point,
706        end: Point,
707        stops: Vec<GradientStop>,
708    ) {
709        self.push(DrawCommand::LinearGradient {
710            rect,
711            start,
712            end,
713            stops,
714        });
715    }
716
717    /// Push a radial gradient fill over `rect`.
718    pub fn push_gradient_radial(
719        &mut self,
720        rect: Rect,
721        center: Point,
722        radius: f32,
723        stops: Vec<GradientStop>,
724    ) {
725        self.push(DrawCommand::RadialGradient {
726            rect,
727            center,
728            radius,
729            stops,
730        });
731    }
732
733    /// Push a scaled image blit.
734    pub fn push_image(&mut self, image: ImageData, dest: Rect, filter: ImageFilter) {
735        self.push(DrawCommand::Image {
736            image,
737            dest,
738            filter,
739        });
740    }
741
742    /// Push a 9-slice scaled image.
743    pub fn push_nine_slice(&mut self, image: ImageData, dest: Rect, insets: [u32; 4]) {
744        self.push(DrawCommand::NineSlice {
745            image,
746            dest,
747            insets,
748        });
749    }
750
751    /// Push a box shadow.
752    pub fn push_shadow(&mut self, rect: Rect, offset: Point, blur_radius: f32, color: Color) {
753        self.push(DrawCommand::BoxShadow {
754            rect,
755            offset,
756            blur_radius,
757            color,
758        });
759    }
760
761    /// Push a text draw command.
762    pub fn push_text(&mut self, rect: Rect, text: impl Into<String>, font: FontSpec, color: Color) {
763        self.push(DrawCommand::DrawText {
764            rect,
765            text: text.into(),
766            font,
767            color,
768        });
769    }
770
771    // ── Private helpers ──────────────────────────────────────────────────
772
773    /// Compute a conservative bounding rect for `cmd`, or `None` for
774    /// clip-stack commands (which don't occupy draw-space geometry).
775    fn cmd_bounds(cmd: &DrawCommand) -> Option<Rect> {
776        match cmd {
777            DrawCommand::FillRect { rect, .. }
778            | DrawCommand::StrokeRect { rect, .. }
779            | DrawCommand::FillRoundedRect { rect, .. }
780            | DrawCommand::FillRoundedRectPerCorner { rect, .. }
781            | DrawCommand::LinearGradient { rect, .. }
782            | DrawCommand::RadialGradient { rect, .. }
783            | DrawCommand::Image { dest: rect, .. }
784            | DrawCommand::NineSlice { dest: rect, .. }
785            | DrawCommand::DrawText { rect, .. } => Some(*rect),
786
787            DrawCommand::BoxShadow {
788                rect,
789                offset,
790                blur_radius,
791                ..
792            } => {
793                let pad = *blur_radius;
794                Some(Rect::new(
795                    rect.left() + offset.x - pad,
796                    rect.top() + offset.y - pad,
797                    rect.width() + 2.0 * pad,
798                    rect.height() + 2.0 * pad,
799                ))
800            }
801
802            DrawCommand::FillCircle { center, radius, .. } => Some(Rect::new(
803                center.x - radius,
804                center.y - radius,
805                radius * 2.0,
806                radius * 2.0,
807            )),
808
809            DrawCommand::FillEllipse { center, rx, ry, .. } => {
810                Some(Rect::new(center.x - rx, center.y - ry, rx * 2.0, ry * 2.0))
811            }
812
813            DrawCommand::Line { from, to, .. } | DrawCommand::LineAa { from, to, .. } => {
814                let x = from.x.min(to.x);
815                let y = from.y.min(to.y);
816                Some(Rect::new(
817                    x,
818                    y,
819                    (from.x - to.x).abs(),
820                    (from.y - to.y).abs(),
821                ))
822            }
823
824            DrawCommand::LineThick {
825                from, to, width, ..
826            } => {
827                let pad = width / 2.0;
828                let x = from.x.min(to.x) - pad;
829                let y = from.y.min(to.y) - pad;
830                let w = (from.x - to.x).abs() + *width;
831                let h = (from.y - to.y).abs() + *width;
832                Some(Rect::new(x, y, w, h))
833            }
834
835            DrawCommand::LineDashed { from, to, .. } => {
836                let x = from.x.min(to.x);
837                let y = from.y.min(to.y);
838                Some(Rect::new(
839                    x,
840                    y,
841                    (from.x - to.x).abs(),
842                    (from.y - to.y).abs(),
843                ))
844            }
845
846            DrawCommand::FillPath { path, .. } => path.bounds(),
847
848            DrawCommand::StrokePath { path, style, .. } => path.bounds().map(|b| {
849                let pad = style.width / 2.0;
850                Rect::new(
851                    b.left() - pad,
852                    b.top() - pad,
853                    b.width() + style.width,
854                    b.height() + style.width,
855                )
856            }),
857
858            DrawCommand::PushClip { .. } | DrawCommand::PopClip => None,
859        }
860    }
861}
862
863// ── RenderBackend ─────────────────────────────────────────────────────────────
864
865/// A surface that can consume and render a [`DrawList`].
866///
867/// Implementors are the concrete rendering backends — software rasteriser,
868/// GPU pipeline, SVG emitter, etc.  The trait is intentionally minimal: a
869/// backend need only implement [`execute`] and [`RenderBackend::surface_size`].  The
870/// `supports_*` probes default to `false`; override them to advertise real
871/// capabilities so callers can avoid emitting unsupported commands.
872///
873/// [`execute`]: RenderBackend::execute
874pub trait RenderBackend {
875    /// Replay an entire [`DrawList`] onto the backend's surface.
876    ///
877    /// The whole list is submitted in one call (rather than command-by-command)
878    /// so the backend can guarantee clip-stack continuity across the sequence.
879    fn execute(&mut self, list: &DrawList) -> Result<(), UiError>;
880
881    /// Return the target surface dimensions in *physical* pixels.
882    fn surface_size(&self) -> Size;
883
884    /// Return `true` if this backend can render blur effects (e.g. box shadows).
885    fn supports_blur(&self) -> bool {
886        false
887    }
888
889    /// Return `true` if this backend can render gradient fills.
890    fn supports_gradients(&self) -> bool {
891        false
892    }
893
894    /// Return `true` if this backend can render arbitrary vector paths.
895    fn supports_paths(&self) -> bool {
896        false
897    }
898
899    /// Return `true` if this backend can blit [`ImageData`].
900    fn supports_images(&self) -> bool {
901        false
902    }
903
904    /// Return `true` if this backend can render text via [`DrawCommand::DrawText`].
905    fn supports_text(&self) -> bool {
906        false
907    }
908}
909
910// ── Tests ─────────────────────────────────────────────────────────────────────
911
912#[cfg(test)]
913mod tests {
914    use super::*;
915    use crate::geometry::{Point, Rect};
916    use crate::Color;
917
918    fn red() -> Color {
919        Color(255, 0, 0, 255)
920    }
921    fn blue() -> Color {
922        Color(0, 0, 255, 255)
923    }
924
925    #[test]
926    fn draw_list_builder_records_command_sequence() {
927        let mut dl = DrawList::new();
928        dl.push_rect(Rect::new(0.0, 0.0, 10.0, 10.0), red());
929        dl.push_clip(Rect::new(0.0, 0.0, 5.0, 5.0));
930        dl.push_rect(Rect::new(1.0, 1.0, 3.0, 3.0), blue());
931        dl.pop_clip();
932        assert_eq!(dl.len(), 4);
933        // verify order: FillRect, PushClip, FillRect, PopClip
934        let cmds: Vec<_> = dl.iter().collect();
935        assert!(matches!(cmds[0], DrawCommand::FillRect { .. }));
936        assert!(matches!(cmds[1], DrawCommand::PushClip { .. }));
937        assert!(matches!(cmds[2], DrawCommand::FillRect { .. }));
938        assert!(matches!(cmds[3], DrawCommand::PopClip));
939    }
940
941    #[test]
942    fn draw_list_len_and_is_empty() {
943        let mut dl = DrawList::new();
944        assert!(dl.is_empty());
945        assert_eq!(dl.len(), 0);
946        dl.push_rect(Rect::new(0.0, 0.0, 1.0, 1.0), red());
947        assert!(!dl.is_empty());
948        assert_eq!(dl.len(), 1);
949    }
950
951    #[test]
952    fn clip_push_pop_balance() {
953        let mut dl = DrawList::new();
954        assert!(dl.is_clip_balanced());
955        dl.push_clip(Rect::new(0.0, 0.0, 10.0, 10.0));
956        assert_eq!(dl.clip_depth(), 1);
957        assert!(!dl.is_clip_balanced());
958        dl.pop_clip();
959        assert_eq!(dl.clip_depth(), 0);
960        assert!(dl.is_clip_balanced());
961        // Extra pop saturates to 0 — no panic, no underflow
962        dl.pop_clip();
963        assert_eq!(dl.clip_depth(), 0);
964    }
965
966    #[test]
967    fn bounds_union_of_draw_commands() {
968        let mut dl = DrawList::new();
969        dl.push_rect(Rect::new(0.0, 0.0, 10.0, 10.0), red());
970        dl.push_rect(Rect::new(20.0, 20.0, 5.0, 5.0), blue());
971        let b = dl.bounds().expect("bounds should be Some");
972        // union of [0,0,10,10] and [20,20,5,5] = [0,0,25,25]
973        assert!((b.left() - 0.0).abs() < 0.001);
974        assert!((b.top() - 0.0).abs() < 0.001);
975        assert!((b.width() - 25.0).abs() < 0.001);
976        assert!((b.height() - 25.0).abs() < 0.001);
977    }
978
979    #[test]
980    fn bounds_excludes_clip_commands() {
981        let mut dl = DrawList::new();
982        dl.push_clip(Rect::new(0.0, 0.0, 100.0, 100.0));
983        dl.pop_clip();
984        assert!(
985            dl.bounds().is_none(),
986            "clip commands must not contribute to bounds"
987        );
988    }
989
990    #[test]
991    fn clear_resets_bounds_and_depth() {
992        let mut dl = DrawList::new();
993        dl.push_clip(Rect::new(0.0, 0.0, 10.0, 10.0));
994        dl.push_rect(Rect::new(0.0, 0.0, 10.0, 10.0), red());
995        dl.clear();
996        assert!(dl.is_empty());
997        assert!(dl.bounds().is_none());
998        assert_eq!(dl.clip_depth(), 0);
999    }
1000
1001    #[test]
1002    fn path_data_builder_and_bounds() {
1003        let mut p = PathData::new();
1004        p.move_to(Point::new(0.0, 0.0));
1005        p.line_to(Point::new(10.0, 0.0));
1006        p.line_to(Point::new(5.0, 8.0));
1007        p.close();
1008        let b = p.bounds().expect("triangle has bounds");
1009        assert!((b.left() - 0.0).abs() < 0.001);
1010        assert!((b.top() - 0.0).abs() < 0.001);
1011        assert!((b.width() - 10.0).abs() < 0.001);
1012        assert!((b.height() - 8.0).abs() < 0.001);
1013        assert_eq!(p.fill_rule, FillRule::NonZero);
1014        let p2 = PathData::new().with_fill_rule(FillRule::EvenOdd);
1015        assert_eq!(p2.fill_rule, FillRule::EvenOdd);
1016    }
1017
1018    #[test]
1019    fn empty_list_iter_is_empty() {
1020        let dl = DrawList::new();
1021        assert!(dl.iter().next().is_none());
1022    }
1023
1024    #[test]
1025    fn gradient_stop_clamps_offset() {
1026        let s = GradientStop::new(-0.5, red());
1027        assert!((s.offset - 0.0).abs() < 0.001);
1028        let s2 = GradientStop::new(1.5, blue());
1029        assert!((s2.offset - 1.0).abs() < 0.001);
1030    }
1031
1032    #[test]
1033    fn stroke_style_defaults() {
1034        let s = StrokeStyle::default();
1035        assert!((s.width - 1.0).abs() < 0.001);
1036        assert!(matches!(s.join, LineJoin::Miter));
1037        assert!(matches!(s.cap, LineCap::Butt));
1038    }
1039}