Files
ltk/src/core.rs
T
admin d4d7ee742e
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
Bump to 0.2.0: SW/GLES paint parity, shared font resolution, FrameState refactor, docs and packaging fixes
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.
2026-06-25 12:43:40 +02:00

491 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
//! Runtime-free UI surface primitives.
//!
//! This module exposes the part of ltk that is useful outside `ltk::run`:
//! widget tree layout, drawing into a `Canvas`, hit-test rect snapshots, and
//! damage tracking. It deliberately contains no Wayland client event loop,
//! layer-shell, xdg-shell, SHM pool, or frame-callback logic.
use std::collections::HashMap;
use std::ffi::c_void;
use std::sync::Arc;
pub use crate::gles_render::{ BorrowedGlesTexture, GlesVersion };
pub use crate::render::Canvas;
pub use crate::widget::{ LaidOutWidget, WidgetHandlers };
use crate::draw::{ self, DrawCtx };
use crate::egl_context::EglOffscreenContext;
use crate::types::{ Color, Point, Rect };
use crate::widget::Element;
/// Options for one runtime-free render pass.
#[ derive( Debug, Clone, Copy ) ]
pub struct RenderOptions
{
/// Physical-pixel bounds to lay out the tree into.
pub bounds: Rect,
/// Background fill. Use [`Color::TRANSPARENT`] for a transparent target.
pub background: Color,
/// Draw red debug rectangles around laid-out interactive widgets.
pub debug_layout: bool,
}
impl RenderOptions
{
/// Render into the full `width × height` canvas with a transparent
/// background and debug layout disabled.
pub fn full_canvas( width: u32, height: u32 ) -> Self
{
Self
{
bounds: Rect { x: 0.0, y: 0.0, width: width as f32, height: height as f32 },
background: Color::TRANSPARENT,
debug_layout: false,
}
}
/// Set the background fill for the render pass.
pub fn background( mut self, color: Color ) -> Self
{
self.background = color;
self
}
/// Enable or disable debug layout rectangles.
pub fn debug_layout( mut self, yes: bool ) -> Self
{
self.debug_layout = yes;
self
}
}
/// Result of rendering a tree into a [`UiSurface`].
#[ derive( Debug, Clone ) ]
pub struct RenderOutput
{
/// Damage rects in physical pixels. Empty means "damage the full bounds".
pub damage_rects: Vec<Rect>,
/// True when the caller should treat the whole render bounds as dirty.
pub full_redraw: bool,
}
/// A retained rendering target for code that wants ltk widgets without
/// `ltk::run`.
///
/// A compositor can keep one `UiSurface` per decoration or panel, mutate
/// focus/hover/pressed state from its own input routing, then call
/// [`Self::render`] whenever it decides a repaint is needed.
// Field declaration order is load-bearing: Rust drops fields top-to-bottom,
// so `canvas` is dropped before `egl_context`. That matters because
// `Canvas::Gles::Drop` releases textures / FBOs / shader programs through
// glow, which requires the matching GL context to be current — which is what
// `Drop for UiSurface` ensures by calling `make_owned_gles_current()` first.
// If `egl_context` were declared above `canvas`, the EGL context would be
// torn down first and `canvas`'s GL releases would silently leak / corrupt
// state. Do not reorder these two fields without preserving that property.
pub struct UiSurface<Msg: Clone>
{
canvas: Canvas,
egl_context: Option<EglOffscreenContext>,
focused_idx: Option<usize>,
hovered_idx: Option<usize>,
pressed_idx: Option<usize>,
prev_focused: Option<usize>,
prev_hovered: Option<usize>,
prev_pressed: Option<usize>,
widget_rects: Vec<LaidOutWidget<Msg>>,
cursor_state: HashMap<usize, usize>,
selection_anchor: HashMap<usize, usize>,
scroll_offsets: HashMap<usize, ( f32, f32 )>,
scroll_canvases: HashMap<usize, Canvas>,
scroll_navigable_items: HashMap<usize, Vec<( usize, f32, f32 )>>,
content_dirty: bool,
}
impl<Msg: Clone> UiSurface<Msg>
{
/// Create a software-backed surface of the given physical size.
///
/// This is intentionally conservative for compositor integrations: an
/// embedder typically already owns an EGL/GLES context, so `new` must
/// not allocate a hidden offscreen context per decoration. Use
/// [`Self::from_gles_context`] when the caller can provide an
/// already-current GL context.
pub fn new( width: u32, height: u32 ) -> Self
{
Self::new_software( width, height )
}
/// Create a software-backed surface explicitly.
pub fn new_software( width: u32, height: u32 ) -> Self
{
Self::from_canvas( Canvas::new( width, height ) )
}
/// Create a GLES-backed surface with an ltk-owned offscreen EGL context.
///
/// The context is made current before render/resize operations. This is
/// useful for runtime-free rendering; compositors that already own a GL
/// context can instead build a `Canvas::new_gles(...)` and pass it to
/// [`Self::from_canvas`].
pub fn try_new_gles( width: u32, height: u32 ) -> Result<Self, String>
{
let egl_context = EglOffscreenContext::new()?;
egl_context.make_current()?;
let canvas = Canvas::new_gles(
Arc::clone( egl_context.gl() ),
egl_context.version(),
width,
height,
);
Ok( Self::from_canvas_with_egl_context( canvas, Some( egl_context ) ) )
}
/// Create a GLES-backed surface from a caller-owned, already-current GL
/// context.
///
/// This is the path intended for compositor embedders: no EGL display,
/// EGL context, or pbuffer is allocated by ltk. The caller must keep
/// the underlying GL context alive and current whenever this surface
/// is created, rendered, resized, or dropped.
pub fn from_gles_context(
gl: Arc<glow::Context>,
version: GlesVersion,
width: u32,
height: u32,
) -> Self
{
Self::from_canvas( Canvas::new_gles( gl, version, width, height ) )
}
/// Create a GLES-backed surface from the GL function loader of the current
/// context.
///
/// This is useful for renderers such as Smithay's `GlesRenderer`, which
/// keep their own EGL context and expose custom GL access through a
/// callback. This constructor does not allocate an EGL context; it only
/// builds ltk's `glow` dispatch table and GPU canvas resources in the
/// context that is current while this function runs.
///
/// # Safety
///
/// `loader` must resolve symbols for the GL context that is current on this
/// thread. That same context must remain alive, and must be made current
/// before any call that touches the returned surface — **including the
/// implicit destructor**: dropping the `UiSurface` releases GPU resources
/// (textures, FBOs, programs) through the caller-owned context and will
/// leak / corrupt state if that context is not current at drop time.
pub unsafe fn from_current_gles_loader<F>(
mut loader: F,
version: GlesVersion,
width: u32,
height: u32,
) -> Self
where
F: FnMut( &str ) -> *const c_void,
{
// SAFETY: forwarded from `from_current_gles_loader`'s own `# Safety`
// contract — `loader` resolves symbols for a context current on this
// thread, so `glGetString( GL_VERSION )` (called inside
// `from_loader_function`) is well-defined.
let gl = Arc::new( unsafe { glow::Context::from_loader_function( move |name| loader( name ) ) } );
Self::from_gles_context( gl, version, width, height )
}
/// Wrap an existing canvas. This is the hook for compositor-owned GPU
/// targets once the caller provides an already-current GL context. If
/// `canvas` is `Canvas::Gles`, the caller remains responsible for making the
/// matching GL context current before calling methods that touch the
/// canvas — **including the implicit destructor**: dropping the
/// `UiSurface` releases GPU resources (textures, FBOs, programs) through
/// the caller-owned context and will leak / corrupt state if that context
/// is not current at drop time.
pub fn from_canvas( canvas: Canvas ) -> Self
{
Self::from_canvas_with_egl_context( canvas, None )
}
fn from_canvas_with_egl_context(
canvas: Canvas,
egl_context: Option<EglOffscreenContext>,
) -> Self
{
Self
{
canvas,
egl_context,
focused_idx: None,
hovered_idx: None,
pressed_idx: None,
prev_focused: None,
prev_hovered: None,
prev_pressed: None,
widget_rects: Vec::new(),
cursor_state: HashMap::new(),
selection_anchor: HashMap::new(),
scroll_offsets: HashMap::new(),
scroll_canvases: HashMap::new(),
scroll_navigable_items: HashMap::new(),
content_dirty: true,
}
}
/// Access the backing canvas after a render pass.
pub fn canvas( &self ) -> &Canvas
{
&self.canvas
}
/// Mutable access to the backing canvas for compositor-specific upload or
/// presentation code. Mark content dirty afterwards if external drawing
/// changes what ltk should preserve across partial redraws.
pub fn canvas_mut( &mut self ) -> &mut Canvas
{
self.make_owned_gles_current();
&mut self.canvas
}
/// Current canvas size in physical pixels.
pub fn size( &self ) -> ( u32, u32 )
{
self.canvas.size()
}
/// Resize the backing canvas and force the next render to redraw fully.
pub fn resize( &mut self, width: u32, height: u32 )
{
self.make_owned_gles_current();
self.canvas.resize( width, height );
self.mark_content_dirty();
}
/// Set the DPI scale used for text and font metrics.
pub fn set_dpi_scale( &mut self, scale: f32 )
{
self.make_owned_gles_current();
self.canvas.set_dpi_scale( scale );
self.mark_content_dirty();
}
/// Mark the next render as content-changing, forcing a full repaint.
pub fn mark_content_dirty( &mut self )
{
self.content_dirty = true;
}
/// Current laid-out interactive widgets from the last render.
pub fn widget_rects( &self ) -> &[ LaidOutWidget<Msg> ]
{
&self.widget_rects
}
/// Hit-test a physical point against the last rendered widget rects.
pub fn hit_test( &self, pos: Point ) -> Option<usize>
{
crate::tree::find_widget_at( &self.widget_rects, pos )
}
/// Lookup a laid-out widget by flat index.
pub fn widget( &self, flat_idx: usize ) -> Option<&LaidOutWidget<Msg>>
{
crate::tree::find_widget( &self.widget_rects, flat_idx )
}
/// Lookup the handler snapshot for a laid-out widget.
pub fn handlers( &self, flat_idx: usize ) -> Option<&WidgetHandlers<Msg>>
{
crate::tree::find_handlers( &self.widget_rects, flat_idx )
}
/// Update keyboard focus state. This is interaction-only, so the next
/// render can use partial damage when layout/content did not change.
pub fn set_focused( &mut self, idx: Option<usize> )
{
self.focused_idx = idx;
}
/// Update hover state. This is interaction-only.
pub fn set_hovered( &mut self, idx: Option<usize> )
{
self.hovered_idx = idx;
}
/// Update pressed state. This is interaction-only.
pub fn set_pressed( &mut self, idx: Option<usize> )
{
self.pressed_idx = idx;
}
/// Flat index of the keyboard-focused widget, or `None` when nothing holds focus.
pub fn focused( &self ) -> Option<usize> { self.focused_idx }
/// Flat index of the hovered widget, or `None` when the pointer is over no interactive widget.
pub fn hovered( &self ) -> Option<usize> { self.hovered_idx }
/// Flat index of the pressed widget, or `None` when no widget is held down.
pub fn pressed( &self ) -> Option<usize> { self.pressed_idx }
/// Render `element` into the backing canvas.
///
/// This does not commit, swap buffers, request frame callbacks, or talk to
/// Wayland. The caller owns presentation and frame pacing.
pub fn render( &mut self, element: &Element<Msg>, options: RenderOptions ) -> RenderOutput
{
self.make_owned_gles_current();
let ( width, height ) = self.canvas.size();
let damage_rects = if self.content_dirty || self.widget_rects.is_empty()
{
Vec::new()
}
else
{
draw::compute_interaction_dirty_rects(
&self.widget_rects,
self.prev_focused, self.prev_hovered, self.prev_pressed,
self.focused_idx, self.hovered_idx, self.pressed_idx,
width,
height,
)
};
let use_partial = !self.content_dirty && !damage_rects.is_empty();
// Only `compute_damage` (called below in the !use_partial branch)
// needs the previous frame's rects, and it consumes them by reference.
// The clone is therefore deferred to that branch — the partial-damage
// path (typical for hover / focus / press transitions) skips it
// entirely, saving one Vec<LaidOutWidget> clone per frame.
let old_rects: Vec<LaidOutWidget<Msg>> = if use_partial
{
Vec::new()
}
else
{
self.widget_rects.clone()
};
if use_partial
{
self.canvas.set_clip_rects( &damage_rects );
if options.background.a > 0.0
{
self.canvas.fill( options.background );
}
else
{
self.canvas.clear_rects_transparent( &damage_rects );
}
}
else
{
self.canvas.clear_clip();
if options.background.a > 0.0
{
self.canvas.fill( options.background );
}
else
{
self.canvas.clear();
}
}
let mut ctx: DrawCtx<Msg> = DrawCtx
{
focused_idx: self.focused_idx,
hovered_idx: self.hovered_idx,
pressed_idx: self.pressed_idx,
cursor_state: std::mem::take( &mut self.cursor_state ),
selection_anchor: std::mem::take( &mut self.selection_anchor ),
widget_rects: Vec::new(),
debug_layout: options.debug_layout && !use_partial,
scroll_offsets: std::mem::take( &mut self.scroll_offsets ),
scroll_rects: Vec::new(),
scroll_canvases: std::mem::take( &mut self.scroll_canvases ),
scroll_navigable_items: std::mem::take( &mut self.scroll_navigable_items ),
previous_widget_rects: self.widget_rects.clone(),
accessible_extras: Vec::new(),
live_depth: 0,
};
draw::layout_and_draw( element, &mut self.canvas, options.bounds, &mut ctx, 0 );
if ctx.debug_layout && !use_partial
{
for w in &ctx.widget_rects
{
self.canvas.stroke_rect( w.rect, Color::rgb( 1.0, 0.0, 0.0 ), 1.5, 0.0 );
}
}
self.canvas.clear_clip();
let output_damage = if use_partial
{
damage_rects
}
else
{
draw::compute_damage(
&old_rects,
&ctx.widget_rects,
self.prev_focused, self.prev_hovered, self.prev_pressed,
self.focused_idx, self.hovered_idx, self.pressed_idx,
width,
height,
)
};
self.prev_focused = self.focused_idx;
self.prev_hovered = self.hovered_idx;
self.prev_pressed = self.pressed_idx;
self.widget_rects = ctx.widget_rects;
self.cursor_state = ctx.cursor_state;
self.selection_anchor = ctx.selection_anchor;
self.scroll_offsets = ctx.scroll_offsets;
// `ctx.scroll_rects` is intentionally dropped here. The `Scroll` widget
// pushes its hit-test rects into the DrawCtx during the draw pass, but
// `UiSurface` exposes no wheel-event routing API, so persisting them
// across frames serves no consumer. If a future embedder needs to
// dispatch wheel events through `UiSurface`, add a `scroll_rects()`
// getter together with a `scroll_by( flat_idx, dy )` mutator and start
// keeping the field again.
self.scroll_canvases = ctx.scroll_canvases;
self.scroll_navigable_items = ctx.scroll_navigable_items;
self.content_dirty = false;
RenderOutput
{
full_redraw: output_damage.is_empty(),
damage_rects: output_damage,
}
}
fn make_owned_gles_current( &self )
{
if let Some( egl_context ) = &self.egl_context
{
if let Err( e ) = egl_context.make_current()
{
log_make_current_failure_once( &e );
}
}
}
}
fn log_make_current_failure_once( reason: &str )
{
use std::sync::Once;
static ONCE: Once = Once::new();
ONCE.call_once( ||
{
eprintln!( "[ltk] core: eglMakeCurrent failed: {reason} (subsequent GL ops will silently no-op or render garbage)" );
} );
}
impl<Msg: Clone> Drop for UiSurface<Msg>
{
fn drop( &mut self )
{
self.make_owned_gles_current();
}
}