First commit. Version 0.1.0

This commit is contained in:
2026-05-10 09:58:23 +02:00
parent af105b7f7d
commit bbab5e238d
635 changed files with 53627 additions and 175 deletions

485
src/core.rs Normal file
View File

@@ -0,0 +1,485 @@
// 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();
}
}