First commit. Version 0.1.0
This commit is contained in:
485
src/core.rs
Normal file
485
src/core.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user