Skip to main content

agpu/
paint.rs

1//! Backend-agnostic 2D painting interface.
2//!
3//! The [`Painter`] trait is the core rendering abstraction in agpu. Widgets
4//! render exclusively through this trait, making the rendering backend fully
5//! pluggable.
6//!
7//! # Implementations
8//!
9//! | Backend            | Purpose                                       |
10//! |--------------------|-----------------------------------------------|
11//! | `AgpuPainter`      | GPU-accelerated rendering via wgpu             |
12//! | `NullPainter`      | Discards all output (headless / agent mode)    |
13
14use crate::core::{Color, Position, Rect, Size, TextStyle};
15
16/// Gradient definition for fill operations.
17#[derive(Debug, Clone)]
18pub enum Gradient {
19    /// Linear gradient from `start` to `end` position.
20    Linear {
21        start: Position,
22        end: Position,
23        stops: Vec<GradientStop>,
24    },
25    /// Radial gradient from `center` outward.
26    Radial {
27        center: Position,
28        radius: f32,
29        stops: Vec<GradientStop>,
30    },
31}
32
33/// A color stop in a gradient.
34#[derive(Debug, Clone, Copy)]
35pub struct GradientStop {
36    pub offset: f32,
37    pub color: Color,
38}
39
40impl GradientStop {
41    pub fn new(offset: f32, color: Color) -> Self {
42        Self { offset, color }
43    }
44}
45
46/// Shadow parameters for shape rendering.
47#[derive(Debug, Clone, Copy)]
48pub struct Shadow {
49    pub offset_x: f32,
50    pub offset_y: f32,
51    pub blur_radius: f32,
52    pub color: Color,
53}
54
55impl Shadow {
56    pub fn new(offset_x: f32, offset_y: f32, blur_radius: f32, color: Color) -> Self {
57        Self {
58            offset_x,
59            offset_y,
60            blur_radius,
61            color,
62        }
63    }
64}
65
66impl Default for Shadow {
67    fn default() -> Self {
68        Self {
69            offset_x: 2.0,
70            offset_y: 2.0,
71            blur_radius: 4.0,
72            color: Color::rgba(0.0, 0.0, 0.0, 0.3),
73        }
74    }
75}
76
77/// Image handle for texture-backed drawing.
78#[derive(Debug, Clone)]
79pub struct ImageHandle {
80    /// Unique image identifier.
81    pub id: u64,
82    /// Image dimensions.
83    pub width: u32,
84    pub height: u32,
85}
86
87impl ImageHandle {
88    pub fn new(id: u64, width: u32, height: u32) -> Self {
89        Self { id, width, height }
90    }
91}
92
93/// Backend-agnostic 2D painter.
94///
95/// Every widget renders through this trait. Backends implement it to
96/// produce actual pixels (GPU), record operations (testing), or discard
97/// output (headless).
98pub trait Painter {
99    /// Fill a rectangle with a solid color and optional corner rounding.
100    fn fill_rect(&mut self, rect: Rect, color: Color, corner_radius: f32);
101
102    /// Stroke a rectangle outline.
103    fn stroke_rect(&mut self, rect: Rect, color: Color, width: f32, corner_radius: f32);
104
105    /// Stroke a rounded rectangle with a known background color for proper inner cutout.
106    fn stroke_rounded_rect(
107        &mut self,
108        rect: Rect,
109        color: Color,
110        width: f32,
111        corner_radius: f32,
112        bg_color: Color,
113    ) {
114        // Default: delegate to stroke_rect
115        self.stroke_rect(rect, color, width, corner_radius);
116        let _ = bg_color;
117    }
118
119    /// Fill a circle with a solid color.
120    fn fill_circle(&mut self, center: Position, radius: f32, color: Color);
121
122    /// Stroke a circle outline.
123    fn stroke_circle(&mut self, center: Position, radius: f32, color: Color, width: f32);
124
125    /// Draw a line segment between two points.
126    fn line(&mut self, from: Position, to: Position, color: Color, width: f32);
127
128    /// Draw text at a position using the given style.
129    fn text(&mut self, pos: Position, text: &str, style: &TextStyle);
130
131    /// Measure how much space a text string would occupy without drawing it.
132    fn measure_text(&self, text: &str, style: &TextStyle) -> Size;
133
134    /// Measure text precisely (requires mutable access for font shaping).
135    fn measure_text_mut(&mut self, text: &str, style: &TextStyle) -> Size {
136        self.measure_text(text, style)
137    }
138
139    /// Push a clipping rectangle. Drawing outside this rect is discarded.
140    fn push_clip(&mut self, rect: Rect);
141
142    /// Pop the most recent clipping rectangle.
143    fn pop_clip(&mut self);
144
145    /// Fill a rectangle with a gradient.
146    fn fill_rect_gradient(&mut self, rect: Rect, gradient: &Gradient, corner_radius: f32) {
147        // Default: use the first stop color as solid fill
148        let color = match gradient {
149            Gradient::Linear { stops, .. } | Gradient::Radial { stops, .. } => {
150                stops.first().map(|s| s.color).unwrap_or(Color::WHITE)
151            }
152        };
153        self.fill_rect(rect, color, corner_radius);
154    }
155
156    /// Draw a shadow behind a rectangle.
157    fn shadow_rect(&mut self, rect: Rect, shadow: &Shadow, corner_radius: f32) {
158        // Default: approximate shadow as an offset, expanded semi-transparent rect
159        let expand = shadow.blur_radius * 0.5;
160        let shadow_rect = Rect::new(
161            rect.x + shadow.offset_x - expand,
162            rect.y + shadow.offset_y - expand,
163            rect.width + expand * 2.0,
164            rect.height + expand * 2.0,
165        );
166        self.fill_rect(shadow_rect, shadow.color, corner_radius + expand);
167    }
168
169    /// Draw an image within the given rectangle.
170    fn draw_image(&mut self, _handle: &ImageHandle, _rect: Rect) {
171        // Default: no-op (not all backends support images)
172    }
173    /// Draw an image with optional tint color.
174    fn draw_image_tinted(&mut self, handle: &ImageHandle, rect: Rect, _tint: Color) {
175        self.draw_image(handle, rect);
176    }
177}
178
179/// A no-op painter that discards all operations.
180///
181/// Used in headless mode (agent protocol, tests) where no visual output
182/// is needed but widgets still run their logic and register ontology.
183pub struct NullPainter;
184
185impl Painter for NullPainter {
186    fn fill_rect(&mut self, _rect: Rect, _color: Color, _corner_radius: f32) {}
187    fn stroke_rect(&mut self, _rect: Rect, _color: Color, _width: f32, _corner_radius: f32) {}
188    fn fill_circle(&mut self, _center: Position, _radius: f32, _color: Color) {}
189    fn stroke_circle(&mut self, _center: Position, _radius: f32, _color: Color, _width: f32) {}
190    fn line(&mut self, _from: Position, _to: Position, _color: Color, _width: f32) {}
191    fn text(&mut self, _pos: Position, _text: &str, _style: &TextStyle) {}
192    fn measure_text(&self, text: &str, style: &TextStyle) -> Size {
193        // Rough estimate: average character width ~ 0.6 * font_size
194        let w = style.font_size * 0.6 * text.len() as f32;
195        Size::new(w, style.font_size * 1.2)
196    }
197    fn push_clip(&mut self, _rect: Rect) {}
198    fn pop_clip(&mut self) {}
199    fn fill_rect_gradient(&mut self, _rect: Rect, _gradient: &Gradient, _corner_radius: f32) {}
200    fn shadow_rect(&mut self, _rect: Rect, _shadow: &Shadow, _corner_radius: f32) {}
201    fn draw_image(&mut self, _handle: &ImageHandle, _rect: Rect) {}
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn null_painter_measure_text() {
210        let np = NullPainter;
211        let style = TextStyle::default();
212        let size = np.measure_text("hello", &style);
213        assert!(size.width > 0.0);
214        assert!(size.height > 0.0);
215    }
216
217    #[test]
218    fn null_painter_measure_empty() {
219        let np = NullPainter;
220        let style = TextStyle::default();
221        let size = np.measure_text("", &style);
222        assert_eq!(size.width, 0.0);
223        assert!(size.height > 0.0);
224    }
225
226    #[test]
227    fn null_painter_operations_do_not_panic() {
228        let mut np = NullPainter;
229        np.fill_rect(Rect::ZERO, Color::RED, 0.0);
230        np.stroke_rect(Rect::ZERO, Color::RED, 1.0, 0.0);
231        np.fill_circle(Position::ZERO, 10.0, Color::GREEN);
232        np.stroke_circle(Position::ZERO, 10.0, Color::GREEN, 1.0);
233        np.line(
234            Position::ZERO,
235            Position::new(100.0, 100.0),
236            Color::BLUE,
237            2.0,
238        );
239        np.text(Position::ZERO, "test", &TextStyle::default());
240        np.push_clip(Rect::ZERO);
241        np.pop_clip();
242    }
243}