486 lines
15 KiB
Rust
486 lines
15 KiB
Rust
// 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();
|
||
}
|
||
}
|