ltk/gles_render/helpers.rs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Backend-neutral free helpers for the GLES renderer: MVP matrix
//! construction, shader compilation, FBO / texture allocation,
//! system-font lookup, small typed-handle extractors. Visible only
//! within `crate::gles_render` — callers always go through
//! `GlesCanvas`'s public methods.
use glow::HasContext;
use crate::types::Rect;
use super::GlesVersion;
pub( super ) fn ortho_rect( vp_w: u32, vp_h: u32, rect: Rect ) -> [f32; 16]
{
let w = vp_w as f32;
let h = vp_h as f32;
let sx = rect.width * 2.0 / w;
let sy = rect.height * 2.0 / h;
let tx = rect.x * 2.0 / w - 1.0;
let ty = 1.0 - rect.y * 2.0 / h - sy;
[
sx, 0.0, 0.0, 0.0,
0.0, sy, 0.0, 0.0,
0.0, 0.0, 1.0, 0.0,
tx, ty, 0.0, 1.0,
]
}
/// Upload `data` as a 2D RGBA texture, premultiplying alpha into the
/// upload buffer.
///
/// `data` is straight-alpha (the format CPU PNG / JPG decoders
/// produce). GL_LINEAR sampling interpolates RGB and A independently
/// across texels: at the antialiased edge of an icon — say a fully
/// opaque black texel next to a fully transparent white texel —
/// straight-alpha interpolation midway gives `( 0.5, 0.5, 0.5, 0.5 )`
/// which composes onto the destination as a 50 % gray halo, while
/// premultiplied interpolation gives `( 0, 0, 0, 0.5 )` and composes
/// as transparent black. Premultiplying once at upload eliminates
/// the halo on every later draw.
///
/// Defensive: when the declared `w × h × 4` size does not match
/// `data.len()`, the function refuses the upload, logs once via
/// stderr, and substitutes a 1×1 transparent placeholder. The GL
/// driver would otherwise read past the slice end inside
/// `tex_image_2d`, since it trusts the dimensions over the slice
/// length.
pub( super ) fn upload_rgba_texture( gl: &glow::Context, version: GlesVersion, data: &[u8], w: i32, h: i32 ) -> glow::Texture
{
let expected = ( w as i64 ).saturating_mul( h as i64 ).saturating_mul( 4 );
let valid = w > 0 && h > 0 && expected >= 0 && expected as usize == data.len();
let placeholder: [u8; 4] = [ 0, 0, 0, 0 ];
let ( safe_w, safe_h, safe_data ): ( i32, i32, &[u8] ) = if valid
{
( w, h, data )
}
else
{
eprintln!(
"[ltk] upload_rgba_texture: refusing malformed upload — \
{w}×{h} declared, {} bytes provided, expected {}",
data.len(),
expected.max( 0 ),
);
( 1, 1, &placeholder[..] )
};
let mut premul = Vec::with_capacity( safe_data.len() );
for px in safe_data.chunks_exact( 4 )
{
let a = px[3] as u32;
// `(c * a + 127) / 255` — round-to-nearest integer scale.
// Plain `c * a / 255` truncates, leaving fully-opaque pixels
// with rgb < their straight value (visibly darker icons).
premul.push( ( ( px[0] as u32 * a + 127 ) / 255 ) as u8 );
premul.push( ( ( px[1] as u32 * a + 127 ) / 255 ) as u8 );
premul.push( ( ( px[2] as u32 * a + 127 ) / 255 ) as u8 );
premul.push( px[3] );
}
// `RGBA8` (sized) on GLES 3 forces 8-bit-per-channel storage; the
// unsized `RGBA` token leaves the format up to the driver and some
// mobile GPUs pick a 4-bits-per-channel or 565+A4 layout for it,
// which shows up as banded / colour-quantised icons. ES 2 has no
// `RGBA8` constant, so we fall back to the unsized form there
// (matching `alloc_fbo_tex`).
let internal_format = match version
{
GlesVersion::V3 => glow::RGBA8 as i32,
GlesVersion::V2 => glow::RGBA as i32,
};
// SAFETY: caller's GL context is current. The most important
// invariant is on the upload size: the validity check above
// guarantees `safe_w * safe_h * 4 == safe_data.len() == premul.len()`,
// or replaces the upload with a 1×1 transparent placeholder when the
// caller provided malformed dimensions. Without this guard the GLES
// driver trusts the dimensions and reads past the slice end inside
// `tex_image_2d` (the original UB this defensive code prevents).
// `RGBA + UNSIGNED_BYTE` is universally supported. We unbind on
// exit to avoid stranding TEXTURE_2D bound to the new texture.
unsafe
{
let tex = gl.create_texture().unwrap();
gl.bind_texture( glow::TEXTURE_2D, Some( tex ) );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MIN_FILTER, glow::LINEAR as i32 );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::LINEAR as i32 );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::CLAMP_TO_EDGE as i32 );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::CLAMP_TO_EDGE as i32 );
gl.tex_image_2d(
glow::TEXTURE_2D, 0, internal_format,
safe_w, safe_h, 0, glow::RGBA, glow::UNSIGNED_BYTE,
glow::PixelUnpackData::Slice( Some( &premul ) ),
);
gl.bind_texture( glow::TEXTURE_2D, None );
tex
}
}
/// Allocate a fresh color texture sized for the FBO. Internal format is sized
/// (`GL_RGBA8`) on ES3 and unsized (`GL_RGBA`) on ES2 — the unsized form is
/// required for color-renderable textures on ES2 drivers.
pub( super ) unsafe fn alloc_fbo_tex( gl: &glow::Context, version: GlesVersion, w: u32, h: u32 ) -> glow::Texture
{
// SAFETY: caller guarantees the GL context bound to `gl` is current
// on this thread. `tex_image_2d` with a `None` data slice allocates
// uninitialised storage of size `w*h*4` bytes (RGBA8 / unsized RGBA);
// the size and format combination is valid on every GLES2 / GLES3
// driver. The bind / unbind pair leaves TEXTURE_2D unbound on exit
// so we don't strand a binding the caller might rely on.
unsafe
{
let tex = gl.create_texture().expect( "create_texture" );
gl.bind_texture( glow::TEXTURE_2D, Some( tex ) );
let internal_format = match version
{
GlesVersion::V3 => glow::RGBA8 as i32,
GlesVersion::V2 => glow::RGBA as i32,
};
gl.tex_image_2d(
glow::TEXTURE_2D, 0, internal_format,
w as i32, h as i32, 0, glow::RGBA, glow::UNSIGNED_BYTE,
glow::PixelUnpackData::Slice( None ),
);
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MIN_FILTER, glow::NEAREST as i32 );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::NEAREST as i32 );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::CLAMP_TO_EDGE as i32 );
gl.tex_parameter_i32( glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::CLAMP_TO_EDGE as i32 );
gl.bind_texture( glow::TEXTURE_2D, None );
tex
}
}
pub( super ) fn compile_program( gl: &glow::Context, vert_src: &str, frag_src: &str ) -> glow::Program
{
compile_program_with_attribs( gl, vert_src, frag_src, &[ ( 0, "a_pos" ) ] )
}
pub( super ) fn compile_program_with_attribs( gl: &glow::Context, vert_src: &str, frag_src: &str, attribs: &[ ( u32, &str ) ] ) -> glow::Program
{
unsafe
{
let program = gl.create_program().unwrap();
let vs = gl.create_shader( glow::VERTEX_SHADER ).unwrap();
gl.shader_source( vs, vert_src );
gl.compile_shader( vs );
assert!( gl.get_shader_compile_status( vs ), "VS: {}", gl.get_shader_info_log( vs ) );
let fs = gl.create_shader( glow::FRAGMENT_SHADER ).unwrap();
gl.shader_source( fs, frag_src );
gl.compile_shader( fs );
assert!( gl.get_shader_compile_status( fs ), "FS: {}", gl.get_shader_info_log( fs ) );
gl.attach_shader( program, vs );
gl.attach_shader( program, fs );
for ( loc, name ) in attribs
{
gl.bind_attrib_location( program, *loc, name );
}
gl.link_program( program );
assert!( gl.get_program_link_status( program ), "Link: {}", gl.get_program_info_log( program ) );
gl.delete_shader( vs );
gl.delete_shader( fs );
program
}
}
pub( super ) fn bytemuck_cast_slice( floats: &[f32] ) -> &[u8]
{
// SAFETY: `f32` has the same allocation provenance / validity as
// `[u8; 4]` for any bit pattern (every bit pattern is a valid byte;
// `f32` admits NaN payloads but those are valid `u8` reads). The
// returned slice's lifetime is tied to the input slice's lifetime
// through the function signature so the read window cannot outlive
// the underlying storage. `len * 4` cannot overflow `usize` because
// it would require an `f32` slice exceeding `usize::MAX / 4` bytes
// — physically impossible on any addressable target.
unsafe
{
std::slice::from_raw_parts(
floats.as_ptr() as *const u8,
floats.len() * 4,
)
}
}
pub( super ) fn native_texture_id( texture: glow::Texture ) -> u32
{
texture.0.get()
}
pub( super ) fn native_framebuffer_id( framebuffer: glow::Framebuffer ) -> u32
{
framebuffer.0.get()
}
const SYSTEM_FONT_CANDIDATES: &[&str] =
&[
// Debian `fonts-sora` — the canonical path the `ltk-theme-default`
// package depends on. Listed first so Sora wins as the default
// font whenever that package is installed.
"/usr/share/fonts/opentype/sora/Sora-Regular.otf",
"/usr/share/fonts/truetype/sora/Sora-Regular.ttf",
"/usr/share/fonts/sora/Sora-Regular.ttf",
"/usr/share/fonts/TTF/Sora-Regular.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
"/usr/share/fonts/liberation/LiberationSans-Regular.ttf",
"/usr/share/fonts/truetype/freefont/FreeSans.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/TTF/DejaVuSans.ttf",
];
/// Load the bytes of a default system font. Tries
/// [`SYSTEM_FONT_CANDIDATES`] in order; falls back to the embedded
/// [`crate::theme::fallback::FALLBACK_FONT`] (Sora Regular, ~50 KB,
/// OFL 1.1) when nothing matches or the file cannot be read. Always
/// returns usable bytes so canvas construction never panics on a
/// system without the expected fonts.
pub( super ) fn load_default_font_bytes() -> Vec<u8>
{
for path in SYSTEM_FONT_CANDIDATES.iter()
{
if std::path::Path::new( path ).exists()
{
if let Ok( bytes ) = std::fs::read( path )
{
return bytes;
}
}
}
crate::theme::fallback::FALLBACK_FONT.to_vec()
}