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}