// SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. //! Primitive draw ops for [`GlesCanvas`]: solid and gradient rect //! fills, inner and outer shadows, stroke, line. All go through the //! shared quad VAO + one of the pre-compiled shader programs from //! [`super::shaders`], with uniforms set per-call. //! //! # Shared `unsafe` invariants //! //! Every `unsafe` block below relies on the same canvas-wide contract //! and only adds one note per block when something specific applies: //! //! * The GL context behind `self.gl` is current on this thread — the //! `GlesCanvas` constructors only return a value when this is true, //! and every `&mut self` method runs on the construction thread. //! * Every `program` / `uniform_*` / `vertex_array` / `texture` handle //! stored on `self` was produced by the same context in `setup.rs` //! and outlives the draw call. //! * Each draw method calls `activate_target` first, which re-binds the //! canvas FBO and re-applies viewport / scissor — so the unsafe block //! never inherits a stranded binding from a sibling canvas. //! * `bind_vertex_array(None)` and `bind_texture(_, None)` at the end //! of the unsafe block leaves the global GL state in the same shape //! the next draw method assumes (no stranded VAO / texture binding). use glow::HasContext; use crate::theme::{ gradient_lut, BlendMode, InsetShadow, LinearGradient, RadialGradient, Shadow }; use crate::types::{ Color, Corners, PathCmd, Rect }; use super::helpers::ortho_rect; use super::GlesCanvas; impl GlesCanvas { /// Returns `true` when `rect` (expanded by `margin` on every side) /// is entirely outside the active scissor — the GPU would cull /// every fragment pre-shader anyway, so skipping the draw saves /// the `activate_target` / `use_program` / uniform / VAO / draw /// sequence. No scissor = no cull (the whole canvas is fair game). fn rect_culled( &self, rect: Rect, margin: f32 ) -> bool { let Some( clip ) = self.clip_scissor else { return false }; let r_x0 = rect.x - margin; let r_y0 = rect.y - margin; let r_x1 = rect.x + rect.width + margin; let r_y1 = rect.y + rect.height + margin; let c_x1 = clip.x + clip.width; let c_y1 = clip.y + clip.height; r_x1 <= clip.x || c_x1 <= r_x0 || r_y1 <= clip.y || c_y1 <= r_y0 } /// Fill an arbitrary vector path (commands in surface coordinates) with a /// solid colour. CPU fallback: rasterised with tiny-skia into a bbox-sized /// pixmap and blitted as a transient texture — there is no GPU path shader. pub fn fill_path( &mut self, cmds: &[ PathCmd ], color: Color ) { self.rasterise_path( cmds, color, None ); } /// Stroke an arbitrary vector path (commands in surface coordinates) with a /// centered stroke of `width` px. Same tiny-skia-into-texture CPU fallback as /// [`Self::fill_path`]. pub fn stroke_path( &mut self, cmds: &[ PathCmd ], color: Color, width: f32 ) { self.rasterise_path( cmds, color, Some( width ) ); } /// CPU fallback for vector paths on the GPU backend: rasterise into a /// tiny-skia pixmap sized to the path's bounding box, then blit it as a /// texture via `draw_image_data`. No GPU path shader. fn rasterise_path( &mut self, cmds: &[ PathCmd ], color: Color, stroke: Option ) { let Some( path ) = crate::render::helpers::build_ts_path( cmds ) else { return }; let bounds = path.bounds(); let pad = stroke.map_or( 1.0, |w| w * 0.5 + 1.0 ); let x0 = ( bounds.left() - pad ).floor(); let y0 = ( bounds.top() - pad ).floor(); let x1 = ( bounds.right() + pad ).ceil(); let y1 = ( bounds.bottom() + pad ).ceil(); let pw = ( x1 - x0 ).max( 1.0 ) as u32; let ph = ( y1 - y0 ).max( 1.0 ) as u32; if pw > 8192 || ph > 8192 { return; } let Some( mut pixmap ) = tiny_skia::Pixmap::new( pw, ph ) else { return }; let mut paint = tiny_skia::Paint::default(); paint.set_color( Color::rgba( color.r, color.g, color.b, color.a * self.global_alpha ).to_tiny_skia() ); paint.anti_alias = true; let tf = tiny_skia::Transform::from_translate( -x0, -y0 ); match stroke { Some( w ) => { let mut s = tiny_skia::Stroke::default(); s.width = w; pixmap.stroke_path( &path, &paint, &s, tf, None ); } None => { pixmap.fill_path( &path, &paint, tiny_skia::FillRule::Winding, tf, None ); } } // tiny-skia stores premultiplied RGBA; the texture shader expects straight. let mut rgba = pixmap.data().to_vec(); for px in rgba.chunks_exact_mut( 4 ) { let a = px[ 3 ] as u32; if a > 0 && a < 255 { px[ 0 ] = ( px[ 0 ] as u32 * 255 / a ).min( 255 ) as u8; px[ 1 ] = ( px[ 1 ] as u32 * 255 / a ).min( 255 ) as u8; px[ 2 ] = ( px[ 2 ] as u32 * 255 / a ).min( 255 ) as u8; } } // Transient texture (upload → draw → delete): a path's pixels change // every frame for animated vectors, which would make the content-keyed // image cache in `draw_image_data` grow without bound and exhaust GL. let dest = Rect { x: x0, y: y0, width: pw as f32, height: ph as f32 }; let tex = super::helpers::upload_rgba_texture( &self.gl, self.version, &rgba, pw as i32, ph as i32 ); self.draw_external_texture( tex, dest, 1.0 ); unsafe { self.gl.delete_texture( tex ); } } /// Fill `rect` with a solid colour, with per-corner rounding from `corners`. /// Coverage (including the rounded corners) comes from an SDF in the rect /// shader; `color.a` is multiplied by `global_alpha`. Culled early when the /// rect falls entirely outside the active scissor. pub fn fill_rect( &mut self, rect: Rect, color: Color, corners: Corners ) { if self.rect_culled( rect, 1.0 ) { return; } self.activate_target(); // Expand the quad 1 px on each side so the outer half of the SDF // antialiasing band (d ∈ [0, 0.5]) has fragments to cover along the // straight edges of pills / rounded rects. `u_size` and `u_radii` // stay anchored to the original rect — `u_pad` lets the shader // remap `v_uv` from the larger quad back into rect-local space. let pad = 1.0_f32; let expanded = rect.expand( pad ); let mvp = ortho_rect( self.width, self.height, expanded ); let alpha = color.a * self.global_alpha; // SAFETY: see module doc. `u_rect_stroke = 0.0` triggers the fill // branch of `RECT_FRAG_SRC`; the SDF reads `u_size` / `u_radii` of // the original rect while `u_pad` remaps `v_uv` from the expanded // quad — so the rasteriser sees the padded geometry but the shader // computes coverage in original-rect coordinates. unsafe { self.gl.use_program( Some( self.rect_program ) ); self.gl.uniform_matrix_4_f32_slice( Some( &self.u_rect_mvp ), false, &mvp ); self.gl.uniform_4_f32( Some( &self.u_rect_color ), color.r, color.g, color.b, alpha ); self.gl.uniform_2_f32( Some( &self.u_rect_size ), rect.width, rect.height ); self.gl.uniform_4_f32_slice( Some( &self.u_rect_radii ), &corners.to_uniform() ); self.gl.uniform_1_f32( Some( &self.u_rect_stroke ), 0.0 ); self.gl.uniform_1_f32( Some( &self.u_rect_pad ), pad ); self.gl.bind_vertex_array( Some( self.quad_vao ) ); self.gl.draw_arrays( glow::TRIANGLES, 0, 6 ); self.gl.bind_vertex_array( None ); } } /// Return the cached gradient LUT texture for `lut_bytes`, uploading /// it on the first call for each unique byte sequence. Subsequent calls /// with the same bytes skip `glTexImage2D` entirely. The texture lives /// until `clear_gradient_cache` is called (e.g. on theme change) or /// the canvas is dropped. fn ensure_lut_texture( &mut self, lut_bytes: &[u8] ) -> glow::Texture { use std::hash::{ Hash, Hasher }; let mut h = std::collections::hash_map::DefaultHasher::new(); lut_bytes.hash( &mut h ); let key = h.finish(); if let Some( &tex ) = self.gradient_lut_cache.get( &key ) { return tex; } // SAFETY: see module doc. `lut_bytes` is the contiguous // `LUT_SAMPLES * 4` byte LUT produced by `gradient_lut::build_lut_bytes` // (RGBA8, one row); the texture allocation matches that exact shape. // We unbind TEXTURE_2D on exit to keep the unit-0 binding shape the // rest of the canvas assumes. unsafe { let tex = self.gl.create_texture().expect( "gradient LUT texture" ); self.gl.active_texture( glow::TEXTURE0 ); self.gl.bind_texture( glow::TEXTURE_2D, Some( tex ) ); self.gl.tex_image_2d( glow::TEXTURE_2D, 0, glow::RGBA as i32, gradient_lut::LUT_SAMPLES as i32, 1, 0, glow::RGBA, glow::UNSIGNED_BYTE, glow::PixelUnpackData::Slice( Some( lut_bytes ) ), ); self.gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MIN_FILTER, glow::LINEAR as i32 ); self.gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::LINEAR as i32 ); self.gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::CLAMP_TO_EDGE as i32 ); self.gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::CLAMP_TO_EDGE as i32 ); self.gl.bind_texture( glow::TEXTURE_2D, None ); self.gradient_lut_cache.insert( key, tex ); tex } } /// Fill a rectangle with a linear gradient. /// /// Bakes a CPU-side LUT from `g.stops` and fetches (or creates) the /// corresponding cached GPU texture via `ensure_lut_texture`, /// then draws the quad with the gradient shader. pub fn fill_linear_gradient_rect( &mut self, rect: Rect, g: &LinearGradient, corners: Corners ) { if self.rect_culled( rect, 1.0 ) { return; } let lut_bytes = gradient_lut::build_lut_bytes( &g.stops, g.space ); let tex = self.ensure_lut_texture( &lut_bytes ); let theta = g.angle_deg.to_radians(); // CSS convention: 0° points up. dir.y is negative-up in screen space. let dir_x = theta.sin(); let dir_y = -theta.cos(); let line_length = ( rect.width * dir_x ).abs() + ( rect.height * dir_y ).abs(); let line_length = if line_length.abs() < 1e-3 { 1e-3 } else { line_length }; self.activate_target(); // See fill_rect for the rationale on the 1 px quad pad. let pad = 1.0_f32; let expanded = rect.expand( pad ); let mvp = ortho_rect( self.width, self.height, expanded ); // SAFETY: see module doc. `tex` is the cached LUT for `g.stops` // produced by `ensure_lut_texture` above (RGBA8, sampler unit 0); // `dir_x`, `dir_y`, `line_length` are derived from finite inputs // (`line_length` is clamped above 1e-3 so the shader's divide is // well-defined). unsafe { self.gl.active_texture( glow::TEXTURE0 ); self.gl.bind_texture( glow::TEXTURE_2D, Some( tex ) ); self.gl.use_program( Some( self.linear_gradient_program ) ); self.gl.uniform_matrix_4_f32_slice( Some( &self.u_lingrad_mvp ), false, &mvp ); self.gl.uniform_1_i32( Some( &self.u_lingrad_lut ), 0 ); self.gl.uniform_2_f32( Some( &self.u_lingrad_dir ), dir_x, dir_y ); self.gl.uniform_2_f32( Some( &self.u_lingrad_size ), rect.width, rect.height ); self.gl.uniform_1_f32( Some( &self.u_lingrad_line_length ), line_length ); self.gl.uniform_4_f32_slice( Some( &self.u_lingrad_radii ), &corners.to_uniform() ); self.gl.uniform_1_f32( Some( &self.u_lingrad_pad ), pad ); self.gl.uniform_1_f32( Some( &self.u_lingrad_lut_domain_min ), gradient_lut::LUT_DOMAIN.0 ); self.gl.uniform_1_f32( Some( &self.u_lingrad_lut_domain_span ), gradient_lut::LUT_DOMAIN.1 - gradient_lut::LUT_DOMAIN.0 ); self.gl.bind_vertex_array( Some( self.quad_vao ) ); self.gl.draw_arrays( glow::TRIANGLES, 0, 6 ); self.gl.bind_vertex_array( None ); self.gl.bind_texture( glow::TEXTURE_2D, None ); } } /// Fill a rectangle with a radial gradient. /// /// `g.center` is interpreted in box-relative fractions (as declared by /// the theme), `g.radius` is the fractional radial extent. Same cached /// LUT strategy as [`Self::fill_linear_gradient_rect`]. pub fn fill_radial_gradient_rect( &mut self, rect: Rect, g: &RadialGradient, corners: Corners ) { if self.rect_culled( rect, 1.0 ) { return; } let lut_bytes = gradient_lut::build_lut_bytes( &g.stops, g.space ); let tex = self.ensure_lut_texture( &lut_bytes ); self.activate_target(); // See fill_rect for the rationale on the 1 px quad pad. let pad = 1.0_f32; let expanded = rect.expand( pad ); let mvp = ortho_rect( self.width, self.height, expanded ); // SAFETY: see module doc. Same LUT contract as the linear path // above. `g.center` and `g.radius` are finite fractional values // from the theme parser (validated at load time). unsafe { self.gl.active_texture( glow::TEXTURE0 ); self.gl.bind_texture( glow::TEXTURE_2D, Some( tex ) ); self.gl.use_program( Some( self.radial_gradient_program ) ); self.gl.uniform_matrix_4_f32_slice( Some( &self.u_radgrad_mvp ), false, &mvp ); self.gl.uniform_1_i32( Some( &self.u_radgrad_lut ), 0 ); self.gl.uniform_2_f32( Some( &self.u_radgrad_center ), g.center[0], g.center[1] ); self.gl.uniform_1_f32( Some( &self.u_radgrad_radius_frac ), g.radius ); self.gl.uniform_2_f32( Some( &self.u_radgrad_size ), rect.width, rect.height ); self.gl.uniform_4_f32_slice( Some( &self.u_radgrad_radii ), &corners.to_uniform() ); self.gl.uniform_1_f32( Some( &self.u_radgrad_pad ), pad ); self.gl.uniform_1_f32( Some( &self.u_radgrad_lut_domain_min ), gradient_lut::LUT_DOMAIN.0 ); self.gl.uniform_1_f32( Some( &self.u_radgrad_lut_domain_span ), gradient_lut::LUT_DOMAIN.1 - gradient_lut::LUT_DOMAIN.0 ); self.gl.bind_vertex_array( Some( self.quad_vao ) ); self.gl.draw_arrays( glow::TRIANGLES, 0, 6 ); self.gl.bind_vertex_array( None ); self.gl.bind_texture( glow::TEXTURE_2D, None ); } } /// Paint an outer drop shadow behind a rounded rect. /// /// Analytic Gaussian approximation over the shape SDF — see the note /// above `SHADOW_OUTER_FRAG_SRC`. The drawing quad is expanded on each /// side by `max(blur, 0) + max(spread, 0) + 1` (the `+ 1` leaves a /// single antialias pixel of slack) and offset by `shadow.offset` so /// the fragment shader sees the full falloff region. /// /// Only `BlendMode::Normal` is honoured today; other modes silently /// fall through to `Normal` because the analytic shader only outputs /// `over`. pub fn fill_shadow_outer( &mut self, target: Rect, shadow: &Shadow, corners: Corners ) { let blur_margin = shadow.blur.max( 0.0 ); let spread_margin = shadow.spread.max( 0.0 ); let margin = blur_margin + spread_margin + 1.0; // Outer shadows draw a quad expanded by `margin` on each side // (to capture the Gaussian falloff outside the shape); offset // the target by `shadow.offset` for the cull test so a shadow // that sits off-centre is not skipped prematurely. let culled_rect = Rect { x: target.x + shadow.offset[0], y: target.y + shadow.offset[1], width: target.width, height: target.height, }; if self.rect_culled( culled_rect, margin ) { return; } let quad = Rect { x: target.x + shadow.offset[0] - margin, y: target.y + shadow.offset[1] - margin, width: target.width + 2.0 * margin, height: target.height + 2.0 * margin, }; let sigma = shadow.sigma().max( 0.5 ); let alpha = shadow.color.a * self.global_alpha; self.activate_target(); let mvp = ortho_rect( self.width, self.height, quad ); // SAFETY: see module doc. `sigma` is clamped above 0.5 so the // shader's divide is well-defined; `margin` covers the full // Gaussian falloff so the rasteriser sees every fragment the // SDF wants to shade. unsafe { self.gl.use_program( Some( self.shadow_outer_program ) ); self.gl.uniform_matrix_4_f32_slice( Some( &self.u_shadow_mvp ), false, &mvp ); self.gl.uniform_2_f32( Some( &self.u_shadow_size ), target.width, target.height ); self.gl.uniform_2_f32( Some( &self.u_shadow_padding ), margin, margin ); self.gl.uniform_4_f32_slice( Some( &self.u_shadow_radii ), &corners.to_uniform() ); self.gl.uniform_1_f32( Some( &self.u_shadow_spread ), shadow.spread ); self.gl.uniform_1_f32( Some( &self.u_shadow_sigma ), sigma ); self.gl.uniform_4_f32( Some( &self.u_shadow_color ), shadow.color.r, shadow.color.g, shadow.color.b, alpha ); self.gl.bind_vertex_array( Some( self.quad_vao ) ); self.gl.draw_arrays( glow::TRIANGLES, 0, 6 ); self.gl.bind_vertex_array( None ); } } /// Paint an inner (inset) shadow inside a rounded rect. /// /// Differences versus [`Self::fill_shadow_outer`]: /// /// * The drawing quad matches the target rect exactly — the inset is /// clipped to the outer SDF by the shader, so no external padding /// is needed and there is no spatial offset of the geometry. /// * The shader carries the per-shadow `offset` as a uniform rather /// than translating the quad, because the inset is biased *inside* /// the shape rather than cast outside it. /// * The pipeline blend state is switched for the duration of the /// draw to honour `InsetShadow::blend` and restored afterwards. /// /// Blend modes: `Normal` stays on the pipeline default /// `(ONE, ONE_MINUS_SRC_ALPHA)`. `PlusLighter` uses `(ONE, ONE)` — /// pure additive on premultiplied inputs, naturally clamped by the /// framebuffer to `[0, 1]`, which is exactly the CSS definition. /// `Multiply` uses `(DST_COLOR, ZERO)` on RGB and `(DST_ALPHA, ZERO)` /// on alpha — a straight multiplicative blend. `Screen` uses /// `(ONE_MINUS_DST_COLOR, ONE)`, the canonical `a + b − a·b` form. /// `Overlay` cannot be expressed with GL's fixed-function blend /// state alone — it needs to read the destination pixel. This /// branch snapshots the current FBO into `aux_a` via /// `glCopyTexSubImage2D`, then draws through /// `shadow_inset_overlay_program` which samples that snapshot at /// `gl_FragCoord.xy / canvas_size`, computes the per-channel CSS /// Overlay formula in-shader, and emits premultiplied /// `(overlay * mask, mask)` — so the usual premul over blend /// composes the blended colour on top of the base. One FBO /// snapshot per Overlay shadow. pub fn fill_shadow_inset( &mut self, target: Rect, shadow: &InsetShadow, corners: Corners ) { // Inset shadows draw a quad at `target` ± 1 px AA pad; the // shape lives entirely inside. If that quad is outside the // scissor, every fragment is culled — skip the whole path // (including the Overlay snapshot, which is the expensive // bit). if self.rect_culled( target, 1.0 ) { return; } let sigma = shadow.sigma().max( 0.5 ); let alpha = shadow.color.a * self.global_alpha; // Overlay goes through the framebuffer-fetch path. Everything // else uses the original SDF inset shader with a blend-state // swap. if matches!( shadow.blend, BlendMode::Overlay ) { // Snapshot the inset's draw rect plus the 1 px AA pad so the // quad's expanded edge still samples valid snapshot data. // The shader samples `aux_a` at `gl_FragCoord.xy / // canvas_size`, so reads outside the snapshotted region // would pull stale content from a previous frame. // // Use the scissor-tight variant: Overlay samples at exactly // one point per fragment, and any fragment outside the // active scissor is culled before the shader runs, so the // snapshot only needs to cover the intersection. let pad = 1.0_f32; let snap_rect = target.expand( pad ); self.snapshot_fbo_region_tight( snap_rect ); self.activate_target(); let expanded = target.expand( pad ); let mvp = ortho_rect( self.width, self.height, expanded ); let aux_tex = self.aux_a.expect( "snapshotted" ).1; // SAFETY: see module doc. `aux_tex` was just populated by // `snapshot_fbo_region_tight` so it carries a valid copy of // the live FBO at full canvas resolution; the shader samples // it through `gl_FragCoord.xy / canvas_size`. We unbind unit-0 // after the draw to avoid stranding the snapshot binding. unsafe { self.gl.use_program( Some( self.shadow_inset_overlay_program ) ); self.gl.uniform_matrix_4_f32_slice( Some( &self.u_inset_ov_mvp ), false, &mvp ); self.gl.uniform_2_f32( Some( &self.u_inset_ov_size ), target.width, target.height ); self.gl.uniform_2_f32( Some( &self.u_inset_ov_padding ), pad, pad ); self.gl.uniform_4_f32_slice( Some( &self.u_inset_ov_radii ), &corners.to_uniform() ); self.gl.uniform_1_f32( Some( &self.u_inset_ov_spread ), shadow.spread ); self.gl.uniform_1_f32( Some( &self.u_inset_ov_sigma ), sigma ); self.gl.uniform_2_f32( Some( &self.u_inset_ov_offset ), shadow.offset[0], shadow.offset[1] ); self.gl.uniform_4_f32( Some( &self.u_inset_ov_color ), shadow.color.r, shadow.color.g, shadow.color.b, alpha ); self.gl.uniform_2_f32( Some( &self.u_inset_ov_canvas_size ), self.width as f32, self.height as f32 ); self.gl.active_texture( glow::TEXTURE0 ); self.gl.bind_texture( glow::TEXTURE_2D, Some( aux_tex ) ); self.gl.uniform_1_i32( Some( &self.u_inset_ov_snapshot ), 0 ); self.gl.bind_vertex_array( Some( self.quad_vao ) ); self.gl.draw_arrays( glow::TRIANGLES, 0, 6 ); self.gl.bind_vertex_array( None ); self.gl.bind_texture( glow::TEXTURE_2D, None ); } return; } self.activate_target(); // 1 px AA pad on the quad so the outer-silhouette clip // (`outer_coverage`) renders its full smoothstep band instead // of terminating at the surface rect. Same rationale as // fill_rect. `u_size` / `u_radii` stay anchored to `target`. let pad = 1.0_f32; let expanded = target.expand( pad ); let mvp = ortho_rect( self.width, self.height, expanded ); // SAFETY: see module doc. We swap the global blend state for the // duration of one draw and restore the canvas-wide default // `(ONE, ONE_MINUS_SRC_ALPHA)` at the end of the block so the // next draw inherits the expected pipeline blend. unsafe { // Switch the blend state for this one draw. match shadow.blend { BlendMode::Normal => { /* already the pipeline default */ } BlendMode::PlusLighter => self.gl.blend_func( glow::ONE, glow::ONE ), BlendMode::Multiply => self.gl.blend_func_separate ( glow::DST_COLOR, glow::ZERO, glow::DST_ALPHA, glow::ZERO, ), BlendMode::Screen => self.gl.blend_func( glow::ONE_MINUS_DST_COLOR, glow::ONE ), BlendMode::Overlay => unreachable!( "Overlay handled above via snapshot" ), } self.gl.use_program( Some( self.shadow_inset_program ) ); self.gl.uniform_matrix_4_f32_slice( Some( &self.u_inset_mvp ), false, &mvp ); self.gl.uniform_2_f32( Some( &self.u_inset_size ), target.width, target.height ); self.gl.uniform_2_f32( Some( &self.u_inset_padding ), pad, pad ); self.gl.uniform_4_f32_slice( Some( &self.u_inset_radii ), &corners.to_uniform() ); self.gl.uniform_1_f32( Some( &self.u_inset_spread ), shadow.spread ); self.gl.uniform_1_f32( Some( &self.u_inset_sigma ), sigma ); self.gl.uniform_2_f32( Some( &self.u_inset_offset ), shadow.offset[0], shadow.offset[1] ); self.gl.uniform_4_f32( Some( &self.u_inset_color ), shadow.color.r, shadow.color.g, shadow.color.b, alpha ); self.gl.bind_vertex_array( Some( self.quad_vao ) ); self.gl.draw_arrays( glow::TRIANGLES, 0, 6 ); self.gl.bind_vertex_array( None ); // Restore the pipeline default. if !matches!( shadow.blend, BlendMode::Normal ) { self.gl.blend_func( glow::ONE, glow::ONE_MINUS_SRC_ALPHA ); } } } /// Stroke a rectangle outline. The stroke is centered on the (rounded) /// boundary, matching tiny-skia's stroke_path so software and GPU paths /// produce the same shape (e.g. a circular focus ring around an icon /// button stays circular). /// /// The drawing quad is expanded by `width / 2` so the outer half of the /// stroke — which lies *outside* the original rect — has fragments to /// cover; the SDF in the rect shader then clamps to the ring. pub fn stroke_rect( &mut self, rect: Rect, color: Color, width: f32, corners: Corners ) { let half = width * 0.5; if self.rect_culled( rect, half + 1.0 ) { return; } self.activate_target(); // Expand the *quad* outward so the outer half of the stroke has // fragments to cover, plus 1 px extra so the 2 px AA band on the // outer side of the stroke (half_w + 1 in the shader) has // fragments too. `u_size` and `u_radii` keep their ORIGINAL // values — they define the SDF, and the stroke's centerline must // sit on the SDF zero-line (the original rect boundary). `u_pad` // tells the fragment shader to remap `v_uv` from the larger quad // back into original-rect space, so the SDF stays anchored to // the original geometry. Growing `u_size`/`u_radii` instead // would shift the zero-line outward and, in the circle case // (radius = size/2), turn the result into a rounded square. let pad = half + 1.0; let expanded = rect.expand( pad ); let mvp = ortho_rect( self.width, self.height, expanded ); let alpha = color.a * self.global_alpha; // SAFETY: see module doc. `u_rect_stroke = width > 0.0` triggers // the stroke branch of `RECT_FRAG_SRC`. Same SDF-anchored-to-original // remap as `fill_rect`; here the quad is padded by `half + 1.0` so // the outer half of the stroke plus its 1 px AA band have fragments. unsafe { self.gl.use_program( Some( self.rect_program ) ); self.gl.uniform_matrix_4_f32_slice( Some( &self.u_rect_mvp ), false, &mvp ); self.gl.uniform_4_f32( Some( &self.u_rect_color ), color.r, color.g, color.b, alpha ); self.gl.uniform_2_f32( Some( &self.u_rect_size ), rect.width, rect.height ); self.gl.uniform_4_f32_slice( Some( &self.u_rect_radii ), &corners.to_uniform() ); self.gl.uniform_1_f32( Some( &self.u_rect_stroke ), width ); self.gl.uniform_1_f32( Some( &self.u_rect_pad ), pad ); self.gl.bind_vertex_array( Some( self.quad_vao ) ); self.gl.draw_arrays( glow::TRIANGLES, 0, 6 ); self.gl.bind_vertex_array( None ); } } /// Draw a line as a thin axis-aligned rect (diagonal lines fall back to /// stamping small squares). pub fn draw_line( &mut self, x0: f32, y0: f32, x1: f32, y1: f32, color: Color, width: f32 ) { let dx = x1 - x0; let dy = y1 - y0; let len = ( dx * dx + dy * dy ).sqrt(); if len < 0.1 { return; } let min_x = x0.min( x1 ); let min_y = y0.min( y1 ); if dy.abs() < 0.1 { self.fill_rect( Rect { x: min_x, y: min_y - width / 2.0, width: dx.abs(), height: width }, color, Corners::ZERO ); } else if dx.abs() < 0.1 { self.fill_rect( Rect { x: min_x - width / 2.0, y: min_y, width, height: dy.abs() }, color, Corners::ZERO ); } else { let steps = len.ceil() as usize; for i in 0..steps { let t = i as f32 / len; let px = x0 + dx * t; let py = y0 + dy * t; self.fill_rect( Rect { x: px - width / 2.0, y: py - width / 2.0, width, height: width }, color, Corners::ZERO ); } } } }