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()
}