Files
ltk/src/core.rs

486 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>,
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;
}
pub fn focused( &self ) -> Option<usize> { self.focused_idx }
pub fn hovered( &self ) -> Option<usize> { self.hovered_idx }
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(),
};
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();
}
}