// SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. //! 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, /// 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 { canvas: Canvas, egl_context: Option, focused_idx: Option, hovered_idx: Option, pressed_idx: Option, prev_focused: Option, prev_hovered: Option, prev_pressed: Option, widget_rects: Vec>, cursor_state: HashMap, selection_anchor: HashMap, scroll_offsets: HashMap, scroll_canvases: HashMap, scroll_navigable_items: HashMap>, content_dirty: bool, } impl UiSurface { /// 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 { 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, 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( 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, ) -> 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 ] { &self.widget_rects } /// Hit-test a physical point against the last rendered widget rects. pub fn hit_test( &self, pos: Point ) -> Option { 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> { 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> { 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 ) { self.focused_idx = idx; } /// Update hover state. This is interaction-only. pub fn set_hovered( &mut self, idx: Option ) { self.hovered_idx = idx; } /// Update pressed state. This is interaction-only. pub fn set_pressed( &mut self, idx: Option ) { self.pressed_idx = idx; } pub fn focused( &self ) -> Option { self.focused_idx } pub fn hovered( &self ) -> Option { self.hovered_idx } pub fn pressed( &self ) -> Option { 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, 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 clone per frame. let old_rects: Vec> = 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 = 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 Drop for UiSurface { fn drop( &mut self ) { self.make_owned_gles_current(); } }