Rendering parity (software ↔ GLES). The software backend now rounds glyph pen positions and image destinations to the nearest integer pixel, matching what the GLES backend already did; previously it truncated, so text and 1:1 images could land up to half a pixel off between the two backends and the bilinear sample read ~1 px softer than the source. Gradients and shadows are deliberately left unimplemented on the software backend, and the GLES multi-rect `glScissor` clip is left coarse on purpose: making it exact would need stencil bits the EGL config does not carry, or routing the partial-redraw path through the offscreen clip layer, which would break its `fill` / `clear_rects_transparent` scissor semantics. Adds software-backend pixel tests covering the snapping. Font resolution unification. The system-font candidate chain, `find_font_opt` and `load_default_font_bytes` lived in two copies (`render/helpers` and `gles_render/helpers`) that had already diverged — one resolved through `find_font_opt`, the other inlined the candidate loop — and now live once in `system_fonts`. The two per-backend `OnceLock` default-font caches and `primary_handle` collapse into a single `system_fonts::default_handle`. Module docs and the `font_registry` caller are updated accordingly. Shared image validation and rect inflation. `draw_image_data`'s dimension check and its one-line warning were byte-duplicated across both backends and are now `render::helpers::validate_rgba_dims`. The six manual symmetric `Rect`-inflate literals in the GLES primitives reuse the existing `Rect::expand`. FrameState and DrawCtx de-duplication. The eleven `SurfaceState` fields the draw pass owns and threads through `DrawCtx` — `widget_rects`, the cursor / selection maps, the scroll state, `accessible_extras`, `prev_focused` / `prev_hovered` / `prev_pressed` — move into a `FrameState` sub-struct. The per-frame `build_draw_ctx` / `commit_draw_ctx` helpers can then borrow `&mut ss.frame`, disjoint from `ss.canvas` and `ss.pool`, so the four frame paths (software / GLES × full / partial) replace their duplicated `DrawCtx` construction and write-back with a single helper call each. A whole-`SurfaceState` borrow could not express this (partial borrows do not cross function boundaries), which is why the helpers take the sub-struct. `content_dirty` stays on `SurfaceState` — it is an invalidation flag, not frame state — and is reset at the call site. rich_text tests. Adds the previously-missing `tests.rs` for the `RichText` widget: one hit rect per visual line a link spans (the widget's core invariant), the single-line and no-link cases, preferred-size growth with hard line breaks, and `map_msg` range preservation — all headless against a software `Canvas`, with line counts forced by `\n` so they do not depend on any system font's measured width. Documentation. Fills the rustdoc gaps on the embedder-facing surface: `core::UiSurface` accessors, the `egl_context` public API, the `GlesCanvas` methods, `theme::typography` and `theme::error`, and the `RichText` / `Text` builders; adds a crate-level "Rendering backends" overview. `CHANGELOG.md` is added (0.2.0 / 0.1.0), and `docs/widgets.md` / `docs/cookbook.md` gain `rich_text`, `external`, and the CPU-draw / path-clip / externally-laid-out-tree recipes. `debian/changelog` gets the 0.2.0-1 entry. Private intra-doc links to `system_fonts` are demoted to code spans so `cargo doc` is warning-free. Packaging. The `libltk-dev` registry crate shipped a `Cargo.toml` declaring the `lookup` bench while the `Makefile` install copied only `src/`, so Cargo refused to parse the manifest over a missing `benches/lookup.rs`; the install now ships `benches/` as well (the file alone satisfies the parse — criterion is a dev-dependency and is not resolved when the crate is consumed as a library). `Cargo.toml` is bumped to 0.2.0 to match the package version and the `ltk-0.2.0` registry directory.
577 lines
26 KiB
Rust
577 lines
26 KiB
Rust
// SPDX-License-Identifier: LGPL-2.1-only
|
||
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||
|
||
//! 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<f32> )
|
||
{
|
||
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 );
|
||
}
|
||
}
|
||
}
|
||
|
||
}
|