A scroll viewport keeps in `widget_rects` only the widgets at least partly inside its visible area (`draw/layout.rs`, the translation of the sub-canvas rects to surface space). A focus request whose target sits below the fold therefore never finds it: `App::take_focus_request` was honoured only for widgets already laid out on screen. The Wi-Fi page of settings is one such case by construction: tapping a secured network inserts the password form under the row and asks for focus on the field, so for a row in the lower part of the list the field is born outside the viewport and the request misses. Worse, the miss branch parked the id in `focus_retry`, marked the view dirty and requested a redraw on every iteration, so the application rebuilt and repainted at the compositor's frame rate for as long as the widget stayed hidden. The layout pass now records the widgets it drops for carrying a `WidgetId`: `DrawCtx::scroll_clipped` holds `(id, scroll flat_idx, rect)` with the rect in the scroll's content coordinates, the same frame the navigable-item list uses, and `draw_frame` hands it to `FrameState`. When a focus request misses, `AppData::reveal_clipped_widget` looks the id up in that list on the main surface and the overlays, finds the viewport in `scroll_rects`, and moves its offset on the axes the scroll allows so the rect lands inside; the next frame lays the widget out and the retry focuses it. Nested scrolls resolve one viewport per frame. The rule that computes the new offset is `scroll::offset_into_view`, extracted from the keyboard navigation in `input/keyboard/nav.rs`, which now calls it instead of carrying its own copy; unit tests cover the three cases. `focus_retry` carries a budget of drawn frames (`FOCUS_RETRY_FRAMES`, four), decremented only on iterations that actually drew, so a request made while a frame callback is pending does not lose its chance. A fresh request from the application replaces a pending retry with a full budget. When the budget runs out the request is dropped, which ends the redraw loop for a widget that never appears. Documented in the `take_focus_request` doc comment, the cookbook and the changelog; Debian revision 0.3.0-3.
492 lines
15 KiB
Rust
492 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, 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;
|
||
}
|
||
|
||
/// Flat index of the keyboard-focused widget, or `None` when nothing holds focus.
|
||
pub fn focused( &self ) -> Option<usize> { self.focused_idx }
|
||
/// Flat index of the hovered widget, or `None` when the pointer is over no interactive widget.
|
||
pub fn hovered( &self ) -> Option<usize> { self.hovered_idx }
|
||
/// Flat index of the pressed widget, or `None` when no widget is held down.
|
||
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 ),
|
||
scroll_clipped: Vec::new(),
|
||
previous_widget_rects: self.widget_rects.clone(),
|
||
accessible_extras: Vec::new(),
|
||
live_depth: 0,
|
||
};
|
||
|
||
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();
|
||
}
|
||
}
|