Skip to main content

jag_surface/
canvas.rs

1use std::sync::Arc;
2
3use jag_draw::{
4    Brush, ColorLinPremul, FontStyle, Painter, Path, RasterizedGlyph, Rect, RoundedRadii,
5    RoundedRect, Stroke, TextProvider, TextRun, Transform2D, Viewport, snap_to_device,
6};
7
8/// How an image should fit within its bounds.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum ImageFitMode {
11    /// Stretch to fill (may distort aspect ratio)
12    Fill,
13    /// Fit inside maintaining aspect ratio (letterbox/pillarbox)
14    Contain,
15    /// Fill maintaining aspect ratio (may crop edges)
16    Cover,
17}
18
19impl Default for ImageFitMode {
20    fn default() -> Self {
21        Self::Contain
22    }
23}
24
25/// Builder for a single frame’s draw commands. Wraps `Painter` and adds canvas helpers.
26pub struct Canvas {
27    pub(crate) viewport: Viewport,
28    pub(crate) painter: Painter,
29    pub(crate) clear_color: Option<ColorLinPremul>,
30    pub(crate) text_provider: Option<Arc<dyn TextProvider + Send + Sync>>, // optional high-level text shaper
31    pub(crate) glyph_draws: Vec<([f32; 2], RasterizedGlyph, ColorLinPremul, i32)>, // low-level glyph masks with z-index
32    pub(crate) svg_draws: Vec<(
33        std::path::PathBuf,
34        [f32; 2],
35        [f32; 2],
36        Option<jag_draw::SvgStyle>,
37        i32,
38        Transform2D,
39        Option<Rect>,
40    )>, // (path, origin, max_size, style, z, transform, device_clip)
41    pub(crate) image_draws: Vec<(
42        std::path::PathBuf,
43        [f32; 2],
44        [f32; 2],
45        ImageFitMode,
46        i32,
47        Transform2D,
48        Option<Rect>,
49    )>, // (path, origin, size, fit, z, transform, device_clip)
50    /// Raw pixel data draws: (pixels_rgba, src_width, src_height, origin, dst_size, z, transform)
51    pub(crate) raw_image_draws: Vec<RawImageDraw>,
52    pub(crate) dpi_scale: f32, // DPI scale factor for text rendering
53    // Effective clip stack in device coordinates for direct text rendering.
54    // Each entry is the intersection of all active clips at that depth.
55    pub(crate) clip_stack: Vec<Option<Rect>>,
56    // Overlay rectangles that render without depth testing (for modal scrims).
57    // These are rendered in a separate pass after the main scene.
58    pub(crate) overlay_draws: Vec<(Rect, ColorLinPremul)>,
59    // Scrim draws that blend over content but allow z-ordered content to render on top.
60    // Supports either a full-rect scrim or a scrim with a rounded-rect cutout via stencil.
61    pub(crate) scrim_draws: Vec<ScrimDraw>,
62}
63
64/// Scrim drawing modes.
65#[derive(Clone, Copy)]
66pub enum ScrimDraw {
67    Rect(Rect, ColorLinPremul),
68    Cutout {
69        hole: RoundedRect,
70        color: ColorLinPremul,
71    },
72}
73
74/// Raw image draw request for rendering pixel data directly.
75#[derive(Clone)]
76pub struct RawImageDraw {
77    /// BGRA pixel data (4 bytes per pixel) - matches CEF native format
78    pub pixels: Vec<u8>,
79    /// Source image width
80    pub src_width: u32,
81    /// Source image height
82    pub src_height: u32,
83    /// Destination origin in scene coordinates
84    pub origin: [f32; 2],
85    /// Destination size in scene coordinates
86    pub dst_size: [f32; 2],
87    /// Z-index for depth ordering
88    pub z: i32,
89    /// Transform at draw time
90    pub transform: Transform2D,
91    /// Dirty rectangles for partial update (x, y, w, h) - empty = full frame
92    pub dirty_rects: Vec<(u32, u32, u32, u32)>,
93    /// Device-space clip rect for GPU scissor clipping (None = no clip).
94    pub clip: Option<Rect>,
95}
96
97impl Canvas {
98    pub fn viewport(&self) -> Viewport {
99        self.viewport
100    }
101
102    /// Get the current transform from the painter's transform stack.
103    pub fn current_transform(&self) -> Transform2D {
104        self.painter.current_transform()
105    }
106
107    /// Set the frame clear/background color (premultiplied linear RGBA).
108    pub fn clear(&mut self, color: ColorLinPremul) {
109        self.clear_color = Some(color);
110    }
111
112    /// Fill a rectangle with a brush.
113    pub fn fill_rect(&mut self, x: f32, y: f32, w: f32, h: f32, brush: Brush, z: i32) {
114        let rect = Rect { x, y, w, h };
115        if let Some(clip) = self.clip_rect_local() {
116            if let Some(clipped) = intersect_rect(rect, clip) {
117                self.painter.rect(clipped, brush, z);
118            }
119        } else {
120            self.painter.rect(rect, brush, z);
121        }
122    }
123
124    /// Composite an externally-rendered texture at the given rectangle.
125    ///
126    /// The `texture_id` must be registered with the `PassManager` before the
127    /// frame is submitted via `register_external_texture`.
128    pub fn external_texture(
129        &mut self,
130        rect: Rect,
131        texture_id: jag_draw::ExternalTextureId,
132        z: i32,
133    ) {
134        // Skip draws entirely outside the active clip rect.
135        if let Some(clip) = self.clip_rect_local() {
136            if intersect_rect(rect, clip).is_none() {
137                return;
138            }
139        }
140        self.painter.external_texture(rect, texture_id, z);
141    }
142
143    /// Fill a rectangle as an overlay (no depth testing).
144    /// Use this for modal scrims and other overlays that should blend over
145    /// existing content without blocking text rendered at lower z-indices.
146    ///
147    /// The rectangle coordinates are transformed by the current canvas transform,
148    /// so they should be in local (viewport) coordinates.
149    pub fn fill_overlay_rect(&mut self, x: f32, y: f32, w: f32, h: f32, color: ColorLinPremul) {
150        // Apply current transform to get screen coordinates.
151        // Transform all four corners and compute axis-aligned bounding box.
152        let t = self.painter.current_transform();
153        let [a, b, c, d, e, f] = t.m;
154
155        // Transform corner points
156        let p0 = [a * x + c * y + e, b * x + d * y + f];
157        let p1 = [a * (x + w) + c * y + e, b * (x + w) + d * y + f];
158        let p2 = [a * (x + w) + c * (y + h) + e, b * (x + w) + d * (y + h) + f];
159        let p3 = [a * x + c * (y + h) + e, b * x + d * (y + h) + f];
160
161        // For axis-aligned transforms (translation/scale only), the AABB works.
162        // For rotation, this is an approximation but should be fine for scrims.
163        let min_x = p0[0].min(p1[0]).min(p2[0]).min(p3[0]);
164        let max_x = p0[0].max(p1[0]).max(p2[0]).max(p3[0]);
165        let min_y = p0[1].min(p1[1]).min(p2[1]).min(p3[1]);
166        let max_y = p0[1].max(p1[1]).max(p2[1]).max(p3[1]);
167
168        self.overlay_draws.push((
169            Rect {
170                x: min_x,
171                y: min_y,
172                w: max_x - min_x,
173                h: max_y - min_y,
174            },
175            color,
176        ));
177    }
178
179    /// Fill a rectangle as a scrim (blends over all existing content but allows
180    /// subsequent z-ordered draws to render on top).
181    ///
182    /// Unlike `fill_overlay_rect`, this uses a depth buffer attachment with:
183    /// - depth_compare = Always (always passes depth test)
184    /// - depth_write_enabled = false (doesn't affect depth buffer)
185    ///
186    /// This allows the scrim to dim background content while the modal panel
187    /// (rendered at a higher z-index afterward) renders cleanly on top.
188    pub fn fill_scrim_rect(&mut self, x: f32, y: f32, w: f32, h: f32, color: ColorLinPremul) {
189        // Apply current transform to get screen coordinates.
190        let t = self.painter.current_transform();
191        let [a, b, c, d, e, f] = t.m;
192
193        // Transform corner points
194        let p0 = [a * x + c * y + e, b * x + d * y + f];
195        let p1 = [a * (x + w) + c * y + e, b * (x + w) + d * y + f];
196        let p2 = [a * (x + w) + c * (y + h) + e, b * (x + w) + d * (y + h) + f];
197        let p3 = [a * x + c * (y + h) + e, b * x + d * (y + h) + f];
198
199        // Compute axis-aligned bounding box
200        let min_x = p0[0].min(p1[0]).min(p2[0]).min(p3[0]);
201        let max_x = p0[0].max(p1[0]).max(p2[0]).max(p3[0]);
202        let min_y = p0[1].min(p1[1]).min(p2[1]).min(p3[1]);
203        let max_y = p0[1].max(p1[1]).max(p2[1]).max(p3[1]);
204
205        self.scrim_draws.push(ScrimDraw::Rect(
206            Rect {
207                x: min_x,
208                y: min_y,
209                w: max_x - min_x,
210                h: max_y - min_y,
211            },
212            color,
213        ));
214    }
215
216    /// Fill a fullscreen scrim that leaves a rounded-rect hole using stencil.
217    pub fn fill_scrim_with_cutout(&mut self, hole: RoundedRect, color: ColorLinPremul) {
218        // Transform the hole into screen space using the current canvas transform.
219        // Assumes transform is affine (translation/scale/skew); uses AABB to keep it simple.
220        let t = self.painter.current_transform();
221        let [a, b, c, d, e, f] = t.m;
222
223        let rect = hole.rect;
224        let corners = [
225            [rect.x, rect.y],
226            [rect.x + rect.w, rect.y],
227            [rect.x + rect.w, rect.y + rect.h],
228            [rect.x, rect.y + rect.h],
229        ];
230
231        let mut min_x = f32::INFINITY;
232        let mut max_x = f32::NEG_INFINITY;
233        let mut min_y = f32::INFINITY;
234        let mut max_y = f32::NEG_INFINITY;
235
236        for p in corners {
237            let tx = a * p[0] + c * p[1] + e;
238            let ty = b * p[0] + d * p[1] + f;
239            min_x = min_x.min(tx);
240            max_x = max_x.max(tx);
241            min_y = min_y.min(ty);
242            max_y = max_y.max(ty);
243        }
244
245        // Approximate radius scaling by average scale of the transform axes.
246        let sx = (a * a + b * b).sqrt();
247        let sy = (c * c + d * d).sqrt();
248        let scale = if sx.is_finite() && sy.is_finite() && sx > 0.0 && sy > 0.0 {
249            (sx + sy) * 0.5
250        } else {
251            1.0
252        };
253
254        let transformed = RoundedRect {
255            rect: Rect {
256                x: min_x,
257                y: min_y,
258                w: (max_x - min_x).max(0.0),
259                h: (max_y - min_y).max(0.0),
260            },
261            radii: RoundedRadii {
262                tl: hole.radii.tl * scale,
263                tr: hole.radii.tr * scale,
264                br: hole.radii.br * scale,
265                bl: hole.radii.bl * scale,
266            },
267        };
268
269        self.scrim_draws.push(ScrimDraw::Cutout {
270            hole: transformed,
271            color,
272        });
273    }
274
275    /// Stroke a path with uniform width and solid color.
276    ///
277    /// Paths that are not fully contained within the active clip are rejected
278    /// because arbitrary path geometry cannot be CPU-clipped to a rectangle.
279    pub fn stroke_path(&mut self, path: Path, width: f32, color: ColorLinPremul, z: i32) {
280        if let Some(clip) = self.clip_rect_local() {
281            if let Some(bounds) = path_bounds(&path) {
282                let expanded = Rect {
283                    x: bounds.x - width,
284                    y: bounds.y - width,
285                    w: bounds.w + width * 2.0,
286                    h: bounds.h + width * 2.0,
287                };
288                // Skip only when the path is fully outside the clip.
289                // Paths can't be CPU-clipped to a rect, so partially-visible
290                // paths are drawn in full; the push_clip_rect zero-area fix
291                // handles the viewport-overflow case.
292                if intersect_rect(expanded, clip).is_none() {
293                    return;
294                }
295            }
296        }
297        self.painter.stroke_path(path, Stroke { width }, color, z);
298    }
299
300    /// Fill a path with a solid color.
301    ///
302    /// Paths that are fully outside the active clip are rejected.
303    pub fn fill_path(&mut self, path: Path, color: ColorLinPremul, z: i32) {
304        if let Some(clip) = self.clip_rect_local() {
305            if let Some(bounds) = path_bounds(&path) {
306                if intersect_rect(bounds, clip).is_none() {
307                    return;
308                }
309            }
310        }
311        self.painter.fill_path(path, color, z);
312    }
313
314    /// Draw an ellipse (y-down coordinates).
315    pub fn ellipse(&mut self, center: [f32; 2], radii: [f32; 2], brush: Brush, z: i32) {
316        if let Some(clip) = self.clip_rect_local() {
317            let bounds = Rect {
318                x: center[0] - radii[0],
319                y: center[1] - radii[1],
320                w: radii[0] * 2.0,
321                h: radii[1] * 2.0,
322            };
323            // Skip if any part overflows the clip (ellipse geometry can't be
324            // CPU-clipped, so we reject unless fully contained).
325            if let Some(clipped) = intersect_rect(bounds, clip) {
326                let fully_inside = (clipped.x - bounds.x).abs() < 0.5
327                    && (clipped.y - bounds.y).abs() < 0.5
328                    && (clipped.w - bounds.w).abs() < 0.5
329                    && (clipped.h - bounds.h).abs() < 0.5;
330                if !fully_inside {
331                    return;
332                }
333            } else {
334                return;
335            }
336        }
337        self.painter.ellipse(center, radii, brush, z);
338    }
339
340    /// Draw a circle (y-down coordinates).
341    pub fn circle(&mut self, center: [f32; 2], radius: f32, brush: Brush, z: i32) {
342        if let Some(clip) = self.clip_rect_local() {
343            let bounds = Rect {
344                x: center[0] - radius,
345                y: center[1] - radius,
346                w: radius * 2.0,
347                h: radius * 2.0,
348            };
349            // Skip if any part overflows the clip (circle geometry can't be
350            // CPU-clipped, so we reject unless fully contained).
351            if let Some(clipped) = intersect_rect(bounds, clip) {
352                let fully_inside = (clipped.x - bounds.x).abs() < 0.5
353                    && (clipped.y - bounds.y).abs() < 0.5
354                    && (clipped.w - bounds.w).abs() < 0.5
355                    && (clipped.h - bounds.h).abs() < 0.5;
356                if !fully_inside {
357                    return;
358                }
359            } else {
360                return;
361            }
362        }
363        self.painter.circle(center, radius, brush, z);
364    }
365
366    /// Draw a rounded rectangle fill.
367    pub fn rounded_rect(&mut self, rrect: RoundedRect, brush: Brush, z: i32) {
368        if let Some(clip) = self.clip_rect_local() {
369            if let Some(clipped) = intersect_rect(rrect.rect, clip) {
370                let fully_inside = (clipped.x - rrect.rect.x).abs() < 0.5
371                    && (clipped.y - rrect.rect.y).abs() < 0.5
372                    && (clipped.w - rrect.rect.w).abs() < 0.5
373                    && (clipped.h - rrect.rect.h).abs() < 0.5;
374                if fully_inside {
375                    self.painter.rounded_rect(rrect, brush, z);
376                } else {
377                    // Zero radii on clipped edges for clean clip boundaries.
378                    let mut radii = rrect.radii;
379                    if clipped.x > rrect.rect.x + 0.5 {
380                        radii.tl = 0.0;
381                        radii.bl = 0.0;
382                    }
383                    if clipped.x + clipped.w < rrect.rect.x + rrect.rect.w - 0.5 {
384                        radii.tr = 0.0;
385                        radii.br = 0.0;
386                    }
387                    if clipped.y > rrect.rect.y + 0.5 {
388                        radii.tl = 0.0;
389                        radii.tr = 0.0;
390                    }
391                    if clipped.y + clipped.h < rrect.rect.y + rrect.rect.h - 0.5 {
392                        radii.bl = 0.0;
393                        radii.br = 0.0;
394                    }
395                    self.painter.rounded_rect(
396                        RoundedRect {
397                            rect: clipped,
398                            radii,
399                        },
400                        brush,
401                        z,
402                    );
403                }
404            }
405        } else {
406            self.painter.rounded_rect(rrect, brush, z);
407        }
408    }
409
410    /// Stroke a rounded rectangle.
411    pub fn stroke_rounded_rect(&mut self, rrect: RoundedRect, width: f32, brush: Brush, z: i32) {
412        if let Some(clip) = self.clip_rect_local() {
413            // Expand bounds by stroke width for rejection test.
414            let expanded = Rect {
415                x: rrect.rect.x - width,
416                y: rrect.rect.y - width,
417                w: rrect.rect.w + width * 2.0,
418                h: rrect.rect.h + width * 2.0,
419            };
420            if let Some(clipped_expanded) = intersect_rect(expanded, clip) {
421                let fully_inside = (clipped_expanded.x - expanded.x).abs() < 0.5
422                    && (clipped_expanded.y - expanded.y).abs() < 0.5
423                    && (clipped_expanded.w - expanded.w).abs() < 0.5
424                    && (clipped_expanded.h - expanded.h).abs() < 0.5;
425                if fully_inside {
426                    self.painter
427                        .stroke_rounded_rect(rrect, Stroke { width }, brush, z);
428                } else {
429                    // Clip the inner rect and zero radii on clipped edges.
430                    if let Some(clipped_inner) = intersect_rect(rrect.rect, clip) {
431                        let mut radii = rrect.radii;
432                        if clipped_inner.x > rrect.rect.x + 0.5 {
433                            radii.tl = 0.0;
434                            radii.bl = 0.0;
435                        }
436                        if clipped_inner.x + clipped_inner.w < rrect.rect.x + rrect.rect.w - 0.5 {
437                            radii.tr = 0.0;
438                            radii.br = 0.0;
439                        }
440                        if clipped_inner.y > rrect.rect.y + 0.5 {
441                            radii.tl = 0.0;
442                            radii.tr = 0.0;
443                        }
444                        if clipped_inner.y + clipped_inner.h < rrect.rect.y + rrect.rect.h - 0.5 {
445                            radii.bl = 0.0;
446                            radii.br = 0.0;
447                        }
448                        self.painter.stroke_rounded_rect(
449                            RoundedRect {
450                                rect: clipped_inner,
451                                radii,
452                            },
453                            Stroke { width },
454                            brush,
455                            z,
456                        );
457                    }
458                }
459            }
460        } else {
461            self.painter
462                .stroke_rounded_rect(rrect, Stroke { width }, brush, z);
463        }
464    }
465
466    /// Draw text using direct rasterization (recommended).
467    ///
468    /// This method rasterizes glyphs immediately using the text provider,
469    /// bypassing complex display list paths. This is simpler and more
470    /// reliable than deferred rendering.
471    ///
472    /// # Performance
473    /// - Glyphs are shaped and rasterized on each call
474    /// - Use [`TextLayoutCache`](jag_draw::TextLayoutCache) to cache wrapping computations
475    /// - Debounce resize events to avoid excessive rasterization
476    ///
477    /// # Transform Stack
478    /// The current transform is applied to position text correctly
479    /// within zones (viewport, toolbar, etc.).
480    ///
481    /// # DPI Scaling
482    /// Both position and size are automatically scaled by `self.dpi_scale`.
483    ///
484    /// # Example
485    /// ```no_run
486    /// # use jag_surface::Canvas;
487    /// # use jag_draw::ColorLinPremul;
488    /// # let mut canvas: Canvas = todo!();
489    /// canvas.draw_text_run(
490    ///     [10.0, 20.0],
491    ///     "Hello, world!".to_string(),
492    ///     16.0,
493    ///     ColorLinPremul::rgba(255, 255, 255, 255),
494    ///     10,  // z-index
495    /// );
496    /// ```
497    pub fn draw_text_run(
498        &mut self,
499        origin: [f32; 2],
500        text: String,
501        size_px: f32,
502        color: ColorLinPremul,
503        z: i32,
504    ) {
505        // Backwards-compatible wrapper: treat as normal weight text.
506        self.draw_text_run_weighted(origin, text, size_px, 400.0, color, z);
507    }
508
509    /// Draw a text run with an explicit font weight.
510    ///
511    /// `weight` should follow CSS semantics (100–900; 400 = normal, 700 = bold).
512    pub fn draw_text_run_weighted(
513        &mut self,
514        origin: [f32; 2],
515        text: String,
516        size_px: f32,
517        weight: f32,
518        color: ColorLinPremul,
519        z: i32,
520    ) {
521        self.draw_text_run_styled(
522            origin,
523            text,
524            size_px,
525            weight,
526            FontStyle::Normal,
527            None,
528            color,
529            z,
530        );
531    }
532
533    /// Draw a text run with full styling options.
534    ///
535    /// `weight` should follow CSS semantics (100–900; 400 = normal, 700 = bold).
536    /// `style` specifies normal, italic, or oblique rendering.
537    /// `family` optionally overrides the font family.
538    pub fn draw_text_run_styled(
539        &mut self,
540        origin: [f32; 2],
541        text: String,
542        size_px: f32,
543        weight: f32,
544        style: FontStyle,
545        family: Option<String>,
546        color: ColorLinPremul,
547        z: i32,
548    ) {
549        // If we have a provider and we're not inside an opacity group,
550        // rasterize immediately (simple, reliable).
551        // Inside opacity groups we route through display-list text so the
552        // whole subtree can be composited once with group alpha.
553        if let Some(ref provider) = self.text_provider
554            && !self.painter.has_active_opacity()
555        {
556            // Apply current transform to origin (handles zone positioning)
557            let transform = self.painter.current_transform();
558            let [a, b, c, d, e, f] = transform.m;
559            let transformed_origin = [
560                a * origin[0] + c * origin[1] + e,
561                b * origin[0] + d * origin[1] + f,
562            ];
563
564            // DPI scale for converting logical to device coordinates
565            let sf = if self.dpi_scale.is_finite() && self.dpi_scale > 0.0 {
566                self.dpi_scale
567            } else {
568                1.0
569            };
570
571            // Rasterize at *physical* pixel size so glyph bitmaps map 1:1 to the
572            // backbuffer. We still keep layout in logical pixels and convert
573            // offsets back into logical units below.
574            // Carry font weight through so providers (e.g., JagTextProvider) can approximate
575            // bold/semibold rendering when available.
576            let run = TextRun {
577                text,
578                pos: [0.0, 0.0],
579                size: (size_px * sf).max(1.0),
580                color,
581                weight,
582                style,
583                family,
584            };
585
586            // Rasterize glyphs, using a shared cache to avoid
587            // re-rasterizing identical text every frame.
588            let glyphs = jag_draw::rasterize_run_cached(provider.as_ref(), &run);
589            // Current effective clip rect in device coordinates, if any.
590            let current_clip = self.clip_stack.last().cloned().unwrap_or(None);
591
592            for g in glyphs.iter() {
593                // Provider offsets are in *physical* pixels (due to size scaling above).
594                // Convert back into logical coordinates so PassManager's logical DPI
595                // scale keeps geometry/text aligned.
596                let mut glyph_origin_logical = [
597                    transformed_origin[0] + g.offset[0] / sf,
598                    transformed_origin[1] + g.offset[1] / sf,
599                ];
600
601                // Snap small text so the resulting *physical* origin lands on whole pixels.
602                if size_px <= 15.0 {
603                    glyph_origin_logical[0] = (glyph_origin_logical[0] * sf).round() / sf;
604                    glyph_origin_logical[1] = (glyph_origin_logical[1] * sf).round() / sf;
605                }
606
607                // For clipping, convert to device pixels using the scaled logical origin.
608                let glyph_origin_device =
609                    [glyph_origin_logical[0] * sf, glyph_origin_logical[1] * sf];
610
611                if let Some(clip) = current_clip {
612                    // Clip glyph to the current rect in device coordinates.
613                    if let Some((clipped_mask, clipped_origin_device)) =
614                        clip_glyph_to_rect(&g.mask, glyph_origin_device, clip)
615                    {
616                        let clipped = RasterizedGlyph {
617                            offset: [0.0, 0.0],
618                            mask: clipped_mask,
619                        };
620                        // Convert clipped origin back to logical coordinates
621                        let mut clipped_origin_logical =
622                            [clipped_origin_device[0] / sf, clipped_origin_device[1] / sf];
623                        if size_px <= 15.0 {
624                            clipped_origin_logical[0] =
625                                (clipped_origin_logical[0] * sf).round() / sf;
626                            clipped_origin_logical[1] =
627                                (clipped_origin_logical[1] * sf).round() / sf;
628                        }
629                        self.glyph_draws
630                            .push((clipped_origin_logical, clipped, color, z));
631                    }
632                } else {
633                    self.glyph_draws
634                        .push((glyph_origin_logical, g.clone(), color, z));
635                }
636            }
637        } else {
638            // Fallback: use display list path (complex, but kept for compatibility)
639            self.painter.text(
640                TextRun {
641                    text,
642                    pos: origin,
643                    size: size_px,
644                    color,
645                    weight,
646                    style,
647                    family,
648                },
649                z,
650            );
651        }
652    }
653
654    /// Draw text with per-glyph gradient color sampling.
655    ///
656    /// Works like `draw_text_run_styled` but instead of a single flat color,
657    /// each glyph is tinted by sampling the provided `Brush` at the glyph's
658    /// normalised horizontal position (`t = glyph_x / text_width`).
659    /// This implements CSS `background-clip: text` with gradient backgrounds.
660    #[allow(clippy::too_many_arguments)]
661    pub fn draw_text_run_gradient(
662        &mut self,
663        origin: [f32; 2],
664        text: String,
665        size_px: f32,
666        weight: f32,
667        style: FontStyle,
668        family: Option<String>,
669        brush: &Brush,
670        text_width: f32,
671        z: i32,
672    ) {
673        if let Some(ref provider) = self.text_provider
674            && !self.painter.has_active_opacity()
675        {
676            let transform = self.painter.current_transform();
677            let [a, b, c, d, e, f] = transform.m;
678            let transformed_origin = [
679                a * origin[0] + c * origin[1] + e,
680                b * origin[0] + d * origin[1] + f,
681            ];
682
683            let sf = if self.dpi_scale.is_finite() && self.dpi_scale > 0.0 {
684                self.dpi_scale
685            } else {
686                1.0
687            };
688
689            // Solid fallback colour (first gradient stop or white).
690            let solid_fallback = match brush {
691                Brush::Solid(c) => *c,
692                Brush::LinearGradient { stops, .. } => stops.first().map_or(
693                    ColorLinPremul {
694                        r: 1.0,
695                        g: 1.0,
696                        b: 1.0,
697                        a: 1.0,
698                    },
699                    |s| s.1,
700                ),
701                Brush::RadialGradient { stops, .. } => stops.first().map_or(
702                    ColorLinPremul {
703                        r: 1.0,
704                        g: 1.0,
705                        b: 1.0,
706                        a: 1.0,
707                    },
708                    |s| s.1,
709                ),
710            };
711
712            let run = TextRun {
713                text,
714                pos: [0.0, 0.0],
715                size: (size_px * sf).max(1.0),
716                color: solid_fallback,
717                weight,
718                style,
719                family,
720            };
721
722            let glyphs = jag_draw::rasterize_run_cached(provider.as_ref(), &run);
723            let current_clip = self.clip_stack.last().cloned().unwrap_or(None);
724            let tw = text_width.max(1.0);
725
726            // Pre-convert gradient stops for the sampling function.
727            let grad_stops: Vec<(f32, [f32; 4])> = match brush {
728                Brush::LinearGradient { stops, .. } | Brush::RadialGradient { stops, .. } => stops
729                    .iter()
730                    .map(|(t, c)| (*t, [c.r, c.g, c.b, c.a]))
731                    .collect(),
732                _ => Vec::new(),
733            };
734
735            for g in glyphs.iter() {
736                let mut glyph_origin_logical = [
737                    transformed_origin[0] + g.offset[0] / sf,
738                    transformed_origin[1] + g.offset[1] / sf,
739                ];
740                if size_px <= 15.0 {
741                    glyph_origin_logical[0] = (glyph_origin_logical[0] * sf).round() / sf;
742                    glyph_origin_logical[1] = (glyph_origin_logical[1] * sf).round() / sf;
743                }
744
745                // Sample gradient at the glyph's horizontal position.
746                let glyph_color = if grad_stops.is_empty() {
747                    solid_fallback
748                } else {
749                    let glyph_x = g.offset[0] / sf;
750                    let t = (glyph_x / tw).clamp(0.0, 1.0);
751                    let [r, g, b, a] = jag_draw::sample_gradient_stops(&grad_stops, t);
752                    ColorLinPremul { r, g, b, a }
753                };
754
755                let glyph_origin_device =
756                    [glyph_origin_logical[0] * sf, glyph_origin_logical[1] * sf];
757
758                if let Some(clip) = current_clip {
759                    if let Some((clipped_mask, clipped_origin_device)) =
760                        clip_glyph_to_rect(&g.mask, glyph_origin_device, clip)
761                    {
762                        let clipped = RasterizedGlyph {
763                            offset: [0.0, 0.0],
764                            mask: clipped_mask,
765                        };
766                        let mut clipped_origin_logical =
767                            [clipped_origin_device[0] / sf, clipped_origin_device[1] / sf];
768                        if size_px <= 15.0 {
769                            clipped_origin_logical[0] =
770                                (clipped_origin_logical[0] * sf).round() / sf;
771                            clipped_origin_logical[1] =
772                                (clipped_origin_logical[1] * sf).round() / sf;
773                        }
774                        self.glyph_draws
775                            .push((clipped_origin_logical, clipped, glyph_color, z));
776                    }
777                } else {
778                    self.glyph_draws
779                        .push((glyph_origin_logical, g.clone(), glyph_color, z));
780                }
781            }
782        } else {
783            // Fallback: extract solid color and use display list path.
784            let fallback_color = match brush {
785                Brush::Solid(c) => *c,
786                Brush::LinearGradient { stops, .. } | Brush::RadialGradient { stops, .. } => {
787                    stops.first().map_or(
788                        ColorLinPremul {
789                            r: 1.0,
790                            g: 1.0,
791                            b: 1.0,
792                            a: 1.0,
793                        },
794                        |s| s.1,
795                    )
796                }
797            };
798            self.painter.text(
799                TextRun {
800                    text,
801                    pos: origin,
802                    size: size_px,
803                    color: fallback_color,
804                    weight,
805                    style,
806                    family,
807                },
808                z,
809            );
810        }
811    }
812
813    /// Draw text directly by rasterizing immediately (simpler, bypasses display list).
814    /// This is the recommended approach - it's simpler and more reliable than draw_text_run.
815    pub fn draw_text_direct(
816        &mut self,
817        origin: [f32; 2],
818        text: &str,
819        size_px: f32,
820        color: ColorLinPremul,
821        provider: &dyn TextProvider,
822        z: i32,
823    ) {
824        // Apply current transform to origin (handles zone positioning)
825        let transform = self.painter.current_transform();
826        let [a, b, c, d, e, f] = transform.m;
827        let transformed_origin = [
828            a * origin[0] + c * origin[1] + e,
829            b * origin[0] + d * origin[1] + f,
830        ];
831
832        // Current effective clip rect in device coordinates, if any.
833        let current_clip = self.clip_stack.last().cloned().unwrap_or(None);
834
835        // DPI scale for converting logical to device coordinates
836        let sf = if self.dpi_scale.is_finite() && self.dpi_scale > 0.0 {
837            self.dpi_scale
838        } else {
839            1.0
840        };
841
842        // Rasterize at *physical* pixel size; treat this as normal-weight text.
843        let run = TextRun {
844            text: text.to_string(),
845            pos: [0.0, 0.0],
846            size: (size_px * sf).max(1.0),
847            color,
848            weight: 400.0,
849            style: FontStyle::Normal,
850            family: None,
851        };
852
853        // Rasterize glyphs, using the shared cache to avoid
854        // re-rasterizing identical text every frame.
855        let glyphs = jag_draw::rasterize_run_cached(provider, &run);
856
857        for g in glyphs.iter() {
858            // Provider offsets are in physical pixels; convert back into logical
859            // coordinates so PassManager's DPI scaling remains the single source
860            // of truth for mapping to device pixels.
861            let mut glyph_origin_logical = [
862                transformed_origin[0] + g.offset[0] / sf,
863                transformed_origin[1] + g.offset[1] / sf,
864            ];
865
866            if size_px <= 15.0 {
867                glyph_origin_logical[0] = (glyph_origin_logical[0] * sf).round() / sf;
868                glyph_origin_logical[1] = (glyph_origin_logical[1] * sf).round() / sf;
869            }
870
871            // For clipping, convert to device pixels
872            let glyph_origin_device = [glyph_origin_logical[0] * sf, glyph_origin_logical[1] * sf];
873
874            if let Some(clip) = current_clip {
875                if let Some((clipped_mask, clipped_origin_device)) =
876                    clip_glyph_to_rect(&g.mask, glyph_origin_device, clip)
877                {
878                    let clipped = RasterizedGlyph {
879                        offset: [0.0, 0.0],
880                        mask: clipped_mask,
881                    };
882                    // Convert clipped origin back to logical coordinates
883                    let mut clipped_origin_logical =
884                        [clipped_origin_device[0] / sf, clipped_origin_device[1] / sf];
885                    if size_px <= 15.0 {
886                        clipped_origin_logical[0] = (clipped_origin_logical[0] * sf).round() / sf;
887                        clipped_origin_logical[1] = (clipped_origin_logical[1] * sf).round() / sf;
888                    }
889                    self.glyph_draws
890                        .push((clipped_origin_logical, clipped, color, z));
891                }
892            } else {
893                self.glyph_draws
894                    .push((glyph_origin_logical, g.clone(), color, z));
895            }
896        }
897    }
898
899    /// Provide a text provider used for high-level text runs in this frame.
900    pub fn set_text_provider(&mut self, provider: Arc<dyn TextProvider + Send + Sync>) {
901        self.text_provider = Some(provider);
902    }
903
904    pub fn text_provider(&self) -> Option<&Arc<dyn TextProvider + Send + Sync>> {
905        self.text_provider.as_ref()
906    }
907
908    /// Measure the width of a text run in logical pixels using the active text provider.
909    ///
910    /// This is intended for layout/centering code that needs a more accurate width than
911    /// simple character-count heuristics. When no provider is set, falls back to
912    /// `font_size * 0.55 * text.len()` to match legacy behavior.
913    pub fn measure_text_width(&self, text: &str, size_px: f32) -> f32 {
914        if let Some(provider) = self.text_provider() {
915            if let Some(shaped) = provider.shape_paragraph(text, size_px) {
916                let total: f32 = shaped.glyphs.iter().map(|g| g.x_advance).sum();
917                // Clamp to non-negative to avoid surprising negatives in rare cases.
918                return total.max(0.0);
919            }
920        }
921        // Fallback heuristic consistent with text_measure.rs and legacy elements.
922        text.chars().count() as f32 * size_px * 0.55
923    }
924
925    /// Measure the width of a styled text run in logical pixels.
926    ///
927    /// Unlike `measure_text_width`, this accounts for font weight, style, and
928    /// family so that measurements match `draw_text_run_styled` rendering.
929    pub fn measure_text_width_styled(
930        &self,
931        text: &str,
932        size_px: f32,
933        weight: f32,
934        style: FontStyle,
935        family: Option<&str>,
936    ) -> f32 {
937        if let Some(provider) = self.text_provider() {
938            let run = TextRun {
939                text: text.to_string(),
940                pos: [0.0, 0.0],
941                size: size_px,
942                color: ColorLinPremul::from_srgba_u8([0, 0, 0, 0]),
943                weight,
944                style,
945                family: family.map(String::from),
946            };
947            provider.measure_run(&run)
948        } else {
949            text.chars().count() as f32 * size_px * 0.55
950        }
951    }
952
953    /// Draw pre-rasterized glyph masks at the given origin tinted with the color.
954    pub fn draw_text_glyphs(
955        &mut self,
956        origin: [f32; 2],
957        glyphs: &[RasterizedGlyph],
958        color: ColorLinPremul,
959        z: i32,
960    ) {
961        for g in glyphs.iter().cloned() {
962            self.glyph_draws.push((origin, g, color, z));
963        }
964    }
965
966    /// Draw a hyperlink with text, optional underline, and URL target.
967    ///
968    /// # Example
969    /// ```no_run
970    /// # use jag_surface::Canvas;
971    /// # use jag_draw::{ColorLinPremul, Hyperlink};
972    /// # let mut canvas: Canvas = todo!();
973    /// let link = Hyperlink {
974    ///     text: "Click me".to_string(),
975    ///     pos: [10.0, 20.0],
976    ///     size: 16.0,
977    ///     color: ColorLinPremul::from_srgba_u8([0, 122, 255, 255]),
978    ///     url: "https://example.com".to_string(),
979    ///     weight: 400.0,
980    ///     measured_width: None,
981    ///     underline: true,
982    ///     underline_color: None,
983    ///     family: None,
984    ///     style: jag_draw::FontStyle::Normal,
985    /// };
986    /// canvas.draw_hyperlink(link, 10);
987    /// ```
988    pub fn draw_hyperlink(&mut self, hyperlink: jag_draw::Hyperlink, z: i32) {
989        // When a text provider is available and we're not inside an opacity
990        // group, render the text and underline through canvas methods so they
991        // respect the clip_stack (per-glyph clipping for text, rect clipping
992        // for the underline).  A stripped-down DrawHyperlink is still emitted
993        // into the display list so hit testing continues to work.
994        if self.text_provider.is_some() && !self.painter.has_active_opacity() {
995            // --- visual: text via per-glyph clipped path ---
996            self.draw_text_run_styled(
997                hyperlink.pos,
998                hyperlink.text.clone(),
999                hyperlink.size,
1000                hyperlink.weight,
1001                hyperlink.style,
1002                hyperlink.family.clone(),
1003                hyperlink.color,
1004                z,
1005            );
1006
1007            // --- visual: underline via clip-aware fill_rect ---
1008            if hyperlink.underline {
1009                let underline_color = hyperlink.underline_color.unwrap_or(hyperlink.color);
1010                let (underline_x, text_width) =
1011                    if let Some(w) = hyperlink.measured_width.map(|v| v.max(0.0)) {
1012                        (hyperlink.pos[0], w)
1013                    } else {
1014                        let trimmed = hyperlink.text.trim_end();
1015                        let char_count = trimmed.chars().count() as f32;
1016                        let weight_boost = ((hyperlink.weight - 400.0).max(0.0) / 500.0) * 0.08;
1017                        let char_width = hyperlink.size * (0.50 + weight_boost);
1018                        let mut width = char_count * char_width;
1019                        let inset = hyperlink.size * 0.10;
1020                        if width > inset * 2.0 {
1021                            width -= inset * 2.0;
1022                        }
1023                        (hyperlink.pos[0] + inset, width)
1024                    };
1025
1026                let underline_thickness = (hyperlink.size * 0.08).max(1.0);
1027                let underline_offset = hyperlink.size * 0.10;
1028                self.fill_rect(
1029                    underline_x,
1030                    hyperlink.pos[1] + underline_offset,
1031                    text_width,
1032                    underline_thickness,
1033                    Brush::Solid(underline_color),
1034                    z,
1035                );
1036            }
1037
1038            // --- hit testing only: emit DrawHyperlink with no visual payload ---
1039            let mut hit_only = hyperlink;
1040            // Ensure measured_width is set so hit testing doesn't rely on
1041            // text length (which we're about to clear).
1042            if hit_only.measured_width.is_none() {
1043                hit_only.measured_width = Some(self.measure_text_width_styled(
1044                    &hit_only.text,
1045                    hit_only.size,
1046                    hit_only.weight,
1047                    hit_only.style,
1048                    hit_only.family.as_deref(),
1049                ));
1050            }
1051            hit_only.text = String::new();
1052            hit_only.underline = false;
1053            self.painter.hyperlink(hit_only, z);
1054        } else {
1055            // Fallback: no text provider or inside opacity group — use
1056            // display-list path (no clip, but correct compositing).
1057            self.painter.hyperlink(hyperlink, z);
1058        }
1059    }
1060
1061    /// Queue an SVG to be rasterized and drawn at origin, scaled to fit within max_size.
1062    /// Captures the current transform from the painter's transform stack.
1063    /// Optional style parameter allows overriding fill, stroke, and stroke-width.
1064    pub fn draw_svg<P: Into<std::path::PathBuf>>(
1065        &mut self,
1066        path: P,
1067        origin: [f32; 2],
1068        max_size: [f32; 2],
1069        z: i32,
1070    ) {
1071        // Skip draws entirely outside the active clip rect.
1072        if let Some(clip) = self.clip_rect_local() {
1073            let bounds = Rect {
1074                x: origin[0],
1075                y: origin[1],
1076                w: max_size[0],
1077                h: max_size[1],
1078            };
1079            if intersect_rect(bounds, clip).is_none() {
1080                return;
1081            }
1082        }
1083        let device_clip = self.clip_stack.last().copied().flatten();
1084        let transform = self.painter.current_transform();
1085        self.svg_draws.push((
1086            path.into(),
1087            origin,
1088            max_size,
1089            None,
1090            z,
1091            transform,
1092            device_clip,
1093        ));
1094    }
1095
1096    /// Queue an SVG with style overrides to be rasterized and drawn.
1097    pub fn draw_svg_styled<P: Into<std::path::PathBuf>>(
1098        &mut self,
1099        path: P,
1100        origin: [f32; 2],
1101        max_size: [f32; 2],
1102        style: jag_draw::SvgStyle,
1103        z: i32,
1104    ) {
1105        // Skip draws entirely outside the active clip rect.
1106        if let Some(clip) = self.clip_rect_local() {
1107            let bounds = Rect {
1108                x: origin[0],
1109                y: origin[1],
1110                w: max_size[0],
1111                h: max_size[1],
1112            };
1113            if intersect_rect(bounds, clip).is_none() {
1114                return;
1115            }
1116        }
1117        let device_clip = self.clip_stack.last().copied().flatten();
1118        let path_buf = path.into();
1119        let transform = self.painter.current_transform();
1120        self.svg_draws.push((
1121            path_buf,
1122            origin,
1123            max_size,
1124            Some(style),
1125            z,
1126            transform,
1127            device_clip,
1128        ));
1129    }
1130
1131    /// Queue a raster image (PNG/JPEG/GIF/WebP) to be drawn at origin with the given size.
1132    /// The fit parameter controls how the image is scaled within the size bounds.
1133    /// Captures the current transform from the painter's transform stack.
1134    pub fn draw_image<P: Into<std::path::PathBuf>>(
1135        &mut self,
1136        path: P,
1137        origin: [f32; 2],
1138        size: [f32; 2],
1139        fit: ImageFitMode,
1140        z: i32,
1141    ) {
1142        // Skip draws entirely outside the active clip rect.
1143        if let Some(clip) = self.clip_rect_local() {
1144            let bounds = Rect {
1145                x: origin[0],
1146                y: origin[1],
1147                w: size[0],
1148                h: size[1],
1149            };
1150            if intersect_rect(bounds, clip).is_none() {
1151                return;
1152            }
1153        }
1154        let device_clip = self.clip_stack.last().copied().flatten();
1155        let transform = self.painter.current_transform();
1156        self.image_draws
1157            .push((path.into(), origin, size, fit, z, transform, device_clip));
1158    }
1159
1160    /// Queue raw pixel data to be drawn at origin with the given size.
1161    /// Pixels should be in BGRA format (4 bytes per pixel) to match CEF native output.
1162    /// Captures the current transform from the painter's transform stack.
1163    pub fn draw_raw_image(
1164        &mut self,
1165        pixels: Vec<u8>,
1166        src_width: u32,
1167        src_height: u32,
1168        origin: [f32; 2],
1169        dst_size: [f32; 2],
1170        z: i32,
1171    ) {
1172        // Skip draws entirely outside the active clip rect.
1173        if let Some(clip) = self.clip_rect_local() {
1174            let bounds = Rect {
1175                x: origin[0],
1176                y: origin[1],
1177                w: dst_size[0],
1178                h: dst_size[1],
1179            };
1180            if intersect_rect(bounds, clip).is_none() {
1181                return;
1182            }
1183        }
1184        let device_clip = self.clip_stack.last().copied().flatten();
1185        let transform = self.painter.current_transform();
1186        self.raw_image_draws.push(RawImageDraw {
1187            pixels,
1188            src_width,
1189            src_height,
1190            origin,
1191            dst_size,
1192            z,
1193            transform,
1194            dirty_rects: Vec::new(), // Full frame update
1195            clip: device_clip,
1196        });
1197    }
1198
1199    /// Queue raw pixel data with dirty rects for partial update.
1200    /// Pixels should be in BGRA format (4 bytes per pixel) to match CEF native output.
1201    /// Only the dirty rectangles will be uploaded to the GPU texture.
1202    pub fn draw_raw_image_with_dirty_rects(
1203        &mut self,
1204        pixels: Vec<u8>,
1205        src_width: u32,
1206        src_height: u32,
1207        origin: [f32; 2],
1208        dst_size: [f32; 2],
1209        z: i32,
1210        dirty_rects: Vec<(u32, u32, u32, u32)>,
1211    ) {
1212        // Skip draws entirely outside the active clip rect.
1213        if let Some(clip) = self.clip_rect_local() {
1214            let bounds = Rect {
1215                x: origin[0],
1216                y: origin[1],
1217                w: dst_size[0],
1218                h: dst_size[1],
1219            };
1220            if intersect_rect(bounds, clip).is_none() {
1221                return;
1222            }
1223        }
1224        let device_clip = self.clip_stack.last().copied().flatten();
1225        let transform = self.painter.current_transform();
1226        self.raw_image_draws.push(RawImageDraw {
1227            pixels,
1228            src_width,
1229            src_height,
1230            origin,
1231            dst_size,
1232            z,
1233            transform,
1234            dirty_rects,
1235            clip: device_clip,
1236        });
1237    }
1238
1239    // Expose some painter helpers for advanced users
1240    pub fn push_clip_rect(&mut self, rect: Rect) {
1241        // Forward to Painter to keep display list behavior.
1242        self.painter.push_clip_rect(rect);
1243
1244        // Compute device-space clip rect based on current transform and dpi.
1245        let t = self.painter.current_transform();
1246        let [a, b, c, d, e, f] = t.m;
1247
1248        let x0 = rect.x;
1249        let y0 = rect.y;
1250        let x1 = rect.x + rect.w;
1251        let y1 = rect.y + rect.h;
1252
1253        let p0 = [a * x0 + c * y0 + e, b * x0 + d * y0 + f];
1254        let p1 = [a * x1 + c * y0 + e, b * x1 + d * y0 + f];
1255        let p2 = [a * x0 + c * y1 + e, b * x0 + d * y1 + f];
1256        let p3 = [a * x1 + c * y1 + e, b * x1 + d * y1 + f];
1257
1258        let min_x = p0[0].min(p1[0]).min(p2[0]).min(p3[0]) * self.dpi_scale;
1259        let max_x = p0[0].max(p1[0]).max(p2[0]).max(p3[0]) * self.dpi_scale;
1260        let min_y = p0[1].min(p1[1]).min(p2[1]).min(p3[1]) * self.dpi_scale;
1261        let max_y = p0[1].max(p1[1]).max(p2[1]).max(p3[1]) * self.dpi_scale;
1262
1263        let new_clip = Rect {
1264            x: min_x,
1265            y: min_y,
1266            w: (max_x - min_x).max(0.0),
1267            h: (max_y - min_y).max(0.0),
1268        };
1269
1270        let merged = match self.clip_stack.last().cloned().unwrap_or(None) {
1271            None => Some(new_clip),
1272            Some(prev) => {
1273                // If the new clip doesn't intersect the parent clip, push a
1274                // zero-area rect instead of None.  None is interpreted as
1275                // "no clip active" which would let draws through; a zero-area
1276                // rect correctly blocks all draws inside this clip scope.
1277                Some(intersect_rect(prev, new_clip).unwrap_or(Rect {
1278                    x: prev.x,
1279                    y: prev.y,
1280                    w: 0.0,
1281                    h: 0.0,
1282                }))
1283            }
1284        };
1285        self.clip_stack.push(merged);
1286    }
1287
1288    pub fn pop_clip(&mut self) {
1289        self.painter.pop_clip();
1290        if self.clip_stack.len() > 1 {
1291            self.clip_stack.pop();
1292        }
1293    }
1294    pub fn push_transform(&mut self, t: Transform2D) {
1295        self.painter.push_transform(t);
1296    }
1297    pub fn pop_transform(&mut self) {
1298        self.painter.pop_transform();
1299    }
1300
1301    pub fn push_opacity(&mut self, opacity: f32) {
1302        self.painter.push_opacity(opacity);
1303    }
1304
1305    pub fn pop_opacity(&mut self) {
1306        self.painter.pop_opacity();
1307    }
1308
1309    /// Add a hit-only region (invisible, used for interaction detection)
1310    pub fn hit_region_rect(&mut self, id: u32, rect: Rect, z: i32) {
1311        self.painter.hit_region_rect(id, rect, z);
1312    }
1313
1314    /// Return the current number of commands in the display list.
1315    pub fn command_count(&self) -> usize {
1316        self.painter.command_count()
1317    }
1318
1319    /// Get a reference to the display list for hit testing
1320    pub fn display_list(&self) -> &jag_draw::DisplayList {
1321        self.painter.display_list()
1322    }
1323
1324    /// Snap a rectangle defined in logical coordinates so that, after applying
1325    /// the current transform and DPI scale, its edges land on physical pixel
1326    /// boundaries. This assumes the current transform is an axis-aligned
1327    /// translate/scale (no rotation/skew); for more complex transforms the
1328    /// original rect is returned unchanged.
1329    pub fn snap_rect_logical_to_device(&self, rect: Rect) -> Rect {
1330        let sf = if self.dpi_scale.is_finite() && self.dpi_scale > 0.0 {
1331            self.dpi_scale
1332        } else {
1333            1.0
1334        };
1335        let t = self.painter.current_transform();
1336        let [a, b, c, d, e, f] = t.m;
1337
1338        // Only handle simple translate/scale transforms. If there is rotation
1339        // or skew, fall back to the original rect to avoid warping.
1340        let is_simple = (b.abs() < 1e-4)
1341            && (c.abs() < 1e-4)
1342            && ((a - 1.0).abs() < 1e-4)
1343            && ((d - 1.0).abs() < 1e-4);
1344        if !is_simple {
1345            return rect;
1346        }
1347
1348        let tx = e;
1349        let ty = f;
1350
1351        // Snap both corners in device space, then bring them back to logical
1352        // by subtracting the translation and dividing by scale factor.
1353        let x0_device = snap_to_device(rect.x + tx, sf);
1354        let y0_device = snap_to_device(rect.y + ty, sf);
1355        let x1_device = snap_to_device(rect.x + rect.w + tx, sf);
1356        let y1_device = snap_to_device(rect.y + rect.h + ty, sf);
1357
1358        let x0 = x0_device - tx;
1359        let y0 = y0_device - ty;
1360        let x1 = x1_device - tx;
1361        let y1 = y1_device - ty;
1362
1363        Rect {
1364            x: x0,
1365            y: y0,
1366            w: (x1 - x0).max(0.0),
1367            h: (y1 - y0).max(0.0),
1368        }
1369    }
1370
1371    /// Get the current effective clip rect in local (pre-transform) coordinates.
1372    ///
1373    /// Returns `None` when no clip is active or when the transform contains
1374    /// rotation/skew (where axis-aligned clipping would be incorrect).
1375    /// For axis-aligned transforms (translation + scale), the device-space clip
1376    /// is inverse-transformed back to the local coordinate space.
1377    fn clip_rect_local(&self) -> Option<Rect> {
1378        let clip_device = match self.clip_stack.last() {
1379            Some(Some(r)) => *r,
1380            _ => return None,
1381        };
1382        let t = self.painter.current_transform();
1383        let [a, b, c, d, e, f] = t.m;
1384
1385        // Only handle axis-aligned transforms (no rotation/skew).
1386        if b.abs() > 1e-4 || c.abs() > 1e-4 {
1387            return None;
1388        }
1389
1390        let sf = if self.dpi_scale.is_finite() && self.dpi_scale > 0.0 {
1391            self.dpi_scale
1392        } else {
1393            1.0
1394        };
1395
1396        let sx = a * sf;
1397        let sy = d * sf;
1398        if sx.abs() < 1e-6 || sy.abs() < 1e-6 {
1399            return None;
1400        }
1401
1402        // Inverse-transform: device = (a * local + e) * sf
1403        //                  → local = device / (a * sf) - e / a
1404        let local_x0 = clip_device.x / sx - e / a;
1405        let local_y0 = clip_device.y / sy - f / d;
1406        let local_x1 = (clip_device.x + clip_device.w) / sx - e / a;
1407        let local_y1 = (clip_device.y + clip_device.h) / sy - f / d;
1408
1409        // Handle negative scales (flips).
1410        let (lx0, lx1) = if local_x0 < local_x1 {
1411            (local_x0, local_x1)
1412        } else {
1413            (local_x1, local_x0)
1414        };
1415        let (ly0, ly1) = if local_y0 < local_y1 {
1416            (local_y0, local_y1)
1417        } else {
1418            (local_y1, local_y0)
1419        };
1420
1421        Some(Rect {
1422            x: lx0,
1423            y: ly0,
1424            w: lx1 - lx0,
1425            h: ly1 - ly0,
1426        })
1427    }
1428}
1429
1430/// Intersect two rectangles (device-space); returns None if they do not overlap.
1431/// Compute the axis-aligned bounding box of a path's control points.
1432fn path_bounds(path: &jag_draw::Path) -> Option<Rect> {
1433    use jag_draw::PathCmd;
1434    let mut min_x = f32::INFINITY;
1435    let mut min_y = f32::INFINITY;
1436    let mut max_x = f32::NEG_INFINITY;
1437    let mut max_y = f32::NEG_INFINITY;
1438    let mut has_points = false;
1439
1440    let mut extend = |p: &[f32; 2]| {
1441        min_x = min_x.min(p[0]);
1442        min_y = min_y.min(p[1]);
1443        max_x = max_x.max(p[0]);
1444        max_y = max_y.max(p[1]);
1445        has_points = true;
1446    };
1447
1448    for cmd in &path.cmds {
1449        match cmd {
1450            PathCmd::MoveTo(p) | PathCmd::LineTo(p) => extend(p),
1451            PathCmd::QuadTo(a, b) => {
1452                extend(a);
1453                extend(b);
1454            }
1455            PathCmd::CubicTo(a, b, c) => {
1456                extend(a);
1457                extend(b);
1458                extend(c);
1459            }
1460            PathCmd::Close => {}
1461        }
1462    }
1463
1464    if has_points {
1465        Some(Rect {
1466            x: min_x,
1467            y: min_y,
1468            w: max_x - min_x,
1469            h: max_y - min_y,
1470        })
1471    } else {
1472        None
1473    }
1474}
1475
1476fn intersect_rect(a: Rect, b: Rect) -> Option<Rect> {
1477    let ax1 = a.x + a.w;
1478    let ay1 = a.y + a.h;
1479    let bx1 = b.x + b.w;
1480    let by1 = b.y + b.h;
1481
1482    let x0 = a.x.max(b.x);
1483    let y0 = a.y.max(b.y);
1484    let x1 = ax1.min(bx1);
1485    let y1 = ay1.min(by1);
1486
1487    if x1 <= x0 || y1 <= y0 {
1488        None
1489    } else {
1490        Some(Rect {
1491            x: x0,
1492            y: y0,
1493            w: x1 - x0,
1494            h: y1 - y0,
1495        })
1496    }
1497}
1498
1499/// Clip a glyph mask to a device-space rectangle, returning a new mask and origin.
1500fn clip_glyph_to_rect(
1501    mask: &jag_draw::GlyphMask,
1502    origin: [f32; 2],
1503    clip: Rect,
1504) -> Option<(jag_draw::GlyphMask, [f32; 2])> {
1505    use jag_draw::{ColorMask, GlyphMask, SubpixelMask};
1506
1507    let glyph_x0 = origin[0];
1508    let glyph_y0 = origin[1];
1509    let (width, height, data, bpp) = match mask {
1510        GlyphMask::Subpixel(m) => (m.width, m.height, &m.data, m.bytes_per_pixel()),
1511        GlyphMask::Color(m) => (m.width, m.height, &m.data, m.bytes_per_pixel()),
1512    };
1513
1514    let glyph_x1 = glyph_x0 + width as f32;
1515    let glyph_y1 = glyph_y0 + height as f32;
1516
1517    let clip_x0 = clip.x;
1518    let clip_y0 = clip.y;
1519    let clip_x1 = clip.x + clip.w;
1520    let clip_y1 = clip.y + clip.h;
1521
1522    let ix0 = glyph_x0.max(clip_x0);
1523    let iy0 = glyph_y0.max(clip_y0);
1524    let ix1 = glyph_x1.min(clip_x1);
1525    let iy1 = glyph_y1.min(clip_y1);
1526
1527    if ix0 >= ix1 || iy0 >= iy1 {
1528        return None;
1529    }
1530
1531    // Convert intersection to pixel indices within the glyph mask.
1532    let start_x = ((ix0 - glyph_x0).floor().max(0.0)) as u32;
1533    let start_y = ((iy0 - glyph_y0).floor().max(0.0)) as u32;
1534    let end_x = ((ix1 - glyph_x0).ceil().min(width as f32)) as u32;
1535    let end_y = ((iy1 - glyph_y0).ceil().min(height as f32)) as u32;
1536
1537    if end_x <= start_x || end_y <= start_y {
1538        return None;
1539    }
1540
1541    let new_w = end_x - start_x;
1542    let new_h = end_y - start_y;
1543
1544    let src_stride = width * bpp as u32;
1545    let dst_stride = new_w * bpp as u32;
1546    let mut clipped_data = vec![0u8; (new_w * new_h * bpp as u32) as usize];
1547
1548    for row in 0..new_h {
1549        let src_y = start_y + row;
1550        let src_offset = (src_y * src_stride + start_x * bpp as u32) as usize;
1551        let dst_offset = (row * dst_stride) as usize;
1552        clipped_data[dst_offset..dst_offset + dst_stride as usize]
1553            .copy_from_slice(&data[src_offset..src_offset + dst_stride as usize]);
1554    }
1555
1556    let clipped = match mask {
1557        GlyphMask::Subpixel(m) => GlyphMask::Subpixel(SubpixelMask {
1558            width: new_w,
1559            height: new_h,
1560            format: m.format,
1561            data: clipped_data,
1562        }),
1563        GlyphMask::Color(_) => GlyphMask::Color(ColorMask {
1564            width: new_w,
1565            height: new_h,
1566            data: clipped_data,
1567        }),
1568    };
1569
1570    let new_origin = [glyph_x0 + start_x as f32, glyph_y0 + start_y as f32];
1571    Some((clipped, new_origin))
1572}