Bump to 0.2.0: SW/GLES paint parity, shared font resolution, FrameState refactor, docs and packaging fixes
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled

Rendering parity (software ↔ GLES). The software backend now rounds glyph pen positions and image destinations to the nearest integer pixel, matching what the GLES backend already did; previously it truncated, so text and 1:1 images could land up to half a pixel off between the two backends and the bilinear sample read ~1 px softer than the source. Gradients and shadows are deliberately left unimplemented on the software backend, and the GLES multi-rect `glScissor` clip is left coarse on purpose: making it exact would need stencil bits the EGL config does not carry, or routing the partial-redraw path through the offscreen clip layer, which would break its `fill` / `clear_rects_transparent` scissor semantics. Adds software-backend pixel tests covering the snapping.
Font resolution unification. The system-font candidate chain, `find_font_opt` and `load_default_font_bytes` lived in two copies (`render/helpers` and `gles_render/helpers`) that had already diverged — one resolved through `find_font_opt`, the other inlined the candidate loop — and now live once in `system_fonts`. The two per-backend `OnceLock` default-font caches and `primary_handle` collapse into a single `system_fonts::default_handle`. Module docs and the `font_registry` caller are updated accordingly.
Shared image validation and rect inflation. `draw_image_data`'s dimension check and its one-line warning were byte-duplicated across both backends and are now `render::helpers::validate_rgba_dims`. The six manual symmetric `Rect`-inflate literals in the GLES primitives reuse the existing `Rect::expand`.
FrameState and DrawCtx de-duplication. The eleven `SurfaceState` fields the draw pass owns and threads through `DrawCtx` — `widget_rects`, the cursor / selection maps, the scroll state, `accessible_extras`, `prev_focused` / `prev_hovered` / `prev_pressed` — move into a `FrameState` sub-struct. The per-frame `build_draw_ctx` / `commit_draw_ctx` helpers can then borrow `&mut ss.frame`, disjoint from `ss.canvas` and `ss.pool`, so the four frame paths (software / GLES × full / partial) replace their duplicated `DrawCtx` construction and write-back with a single helper call each. A whole-`SurfaceState` borrow could not express this (partial borrows do not cross function boundaries), which is why the helpers take the sub-struct. `content_dirty` stays on `SurfaceState` — it is an invalidation flag, not frame state — and is reset at the call site.
rich_text tests. Adds the previously-missing `tests.rs` for the `RichText` widget: one hit rect per visual line a link spans (the widget's core invariant), the single-line and no-link cases, preferred-size growth with hard line breaks, and `map_msg` range preservation — all headless against a software `Canvas`, with line counts forced by `\n` so they do not depend on any system font's measured width.
Documentation. Fills the rustdoc gaps on the embedder-facing surface: `core::UiSurface` accessors, the `egl_context` public API, the `GlesCanvas` methods, `theme::typography` and `theme::error`, and the `RichText` / `Text` builders; adds a crate-level "Rendering backends" overview. `CHANGELOG.md` is added (0.2.0 / 0.1.0), and `docs/widgets.md` / `docs/cookbook.md` gain `rich_text`, `external`, and the CPU-draw / path-clip / externally-laid-out-tree recipes. `debian/changelog` gets the 0.2.0-1 entry. Private intra-doc links to `system_fonts` are demoted to code spans so `cargo doc` is warning-free.
Packaging. The `libltk-dev` registry crate shipped a `Cargo.toml` declaring the `lookup` bench while the `Makefile` install copied only `src/`, so Cargo refused to parse the manifest over a missing `benches/lookup.rs`; the install now ships `benches/` as well (the file alone satisfies the parse — criterion is a dev-dependency and is not resolved when the crate is consumed as a library). `Cargo.toml` is bumped to 0.2.0 to match the package version and the `ltk-0.2.0` registry directory.
This commit is contained in:
2026-06-25 12:43:40 +02:00
parent fb3552e9f7
commit d4d7ee742e
56 changed files with 936 additions and 549 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ impl<A: App> AppData<A>
pos: Point,
) -> bool
{
let toggle_msg = self.surface( focus ).widget_rects.iter()
let toggle_msg = self.surface( focus ).frame.widget_rects.iter()
.find( |w| w.flat_idx == idx )
.and_then( |w| match &w.handlers
{
+4 -4
View File
@@ -21,10 +21,10 @@ impl<A: App> AppData<A>
// Find the topmost scroll that has a navigable item list.
let scroll_meta = {
let ss = self.surface( focus );
ss.scroll_rects.iter().rev()
ss.frame.scroll_rects.iter().rev()
.find_map( |( rect, idx, _ax )|
{
ss.scroll_navigable_items.get( idx )
ss.frame.scroll_navigable_items.get( idx )
.filter( |list| !list.is_empty() )
.map( |list| ( *rect, *idx, list.clone() ) )
} )
@@ -50,7 +50,7 @@ impl<A: App> AppData<A>
// list-style); the X offset is preserved as-is.
let viewport_h = scroll_rect.height;
let ( current_x, current_y ) = self.surface( focus )
.scroll_offsets.get( &scroll_idx )
.frame.scroll_offsets.get( &scroll_idx )
.copied().unwrap_or( ( 0.0, 0.0 ) );
let new_y = if content_y < current_y
{
@@ -67,7 +67,7 @@ impl<A: App> AppData<A>
let ss = self.surface_mut( focus );
ss.hovered_idx = Some( new_idx );
ss.scroll_offsets.insert( scroll_idx, ( current_x, new_y ) );
ss.frame.scroll_offsets.insert( scroll_idx, ( current_x, new_y ) );
ss.request_redraw();
true
}
+6 -6
View File
@@ -14,7 +14,7 @@ impl<A: App> AppData<A>
{
let focused = self.surface( focus ).focused_idx;
let is_text = focused.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
find_handlers( &self.surface( focus ).frame.widget_rects, idx )
.map( |h| h.is_text_input() ) ).unwrap_or( false );
if is_text { self.handle_select_all( focus ); }
}
@@ -23,7 +23,7 @@ impl<A: App> AppData<A>
{
let focused = self.surface( focus ).focused_idx;
let is_text = focused.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
find_handlers( &self.surface( focus ).frame.widget_rects, idx )
.map( |h| h.is_text_input() ) ).unwrap_or( false );
if is_text { self.handle_copy( focus ); }
}
@@ -32,7 +32,7 @@ impl<A: App> AppData<A>
{
let focused = self.surface( focus ).focused_idx;
let is_text = focused.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
find_handlers( &self.surface( focus ).frame.widget_rects, idx )
.map( |h| h.is_text_input() ) ).unwrap_or( false );
if is_text { self.handle_cut( focus ); }
}
@@ -41,7 +41,7 @@ impl<A: App> AppData<A>
{
let focused = self.surface( focus ).focused_idx;
let is_text = focused.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
find_handlers( &self.surface( focus ).frame.widget_rects, idx )
.map( |h| h.is_text_input() ) ).unwrap_or( false );
if is_text { self.handle_paste( focus ); }
}
@@ -57,7 +57,7 @@ impl<A: App> AppData<A>
else
{
let ss = self.surface( focus );
let next_idx = next_focusable_index( &ss.widget_rects, ss.focused_idx, reverse );
let next_idx = next_focusable_index( &ss.frame.widget_rects, ss.focused_idx, reverse );
if let Some( next_idx ) = next_idx
{
self.set_focus( focus, Some( next_idx ), qh );
@@ -84,7 +84,7 @@ impl<A: App> AppData<A>
} else if self.collapse_selection_if_any( focus )
{
// Selection collapsed — keep focus, no app msg.
} else if let Some( msg ) = self.surface( focus ).widget_rects.iter()
} else if let Some( msg ) = self.surface( focus ).frame.widget_rects.iter()
.rev()
.find_map( |w| w.handlers.escape_msg() )
{
+7 -7
View File
@@ -47,7 +47,7 @@ impl<A: App> AppData<A>
// literal `\n` into the buffer instead of submitting, so
// the user can compose paragraphs.
let is_multi = focused.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
find_handlers( &self.surface( focus ).frame.widget_rects, idx )
.map( |h| h.is_multiline_text_input() ) ).unwrap_or( false );
if is_multi
{
@@ -57,7 +57,7 @@ impl<A: App> AppData<A>
let mut handled = false;
if let Some( idx ) = target
{
let msg = find_handlers( &self.surface( focus ).widget_rects, idx )
let msg = find_handlers( &self.surface( focus ).frame.widget_rects, idx )
.and_then( |h| h.submit_msg().or_else( || h.press_msg() ) );
if let Some( m ) = msg
{
@@ -79,7 +79,7 @@ impl<A: App> AppData<A>
{
let focused = self.surface( focus ).focused_idx;
let is_text = focused.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
find_handlers( &self.surface( focus ).frame.widget_rects, idx )
.map( |h| h.is_text_input() ) ).unwrap_or( false );
let reverse = event.keysym == Keysym::Up;
let extend = self.shift_pressed;
@@ -106,7 +106,7 @@ impl<A: App> AppData<A>
{
let focused = self.surface( focus ).focused_idx;
let is_text = focused.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
find_handlers( &self.surface( focus ).frame.widget_rects, idx )
.map( |h| h.is_text_input() ) ).unwrap_or( false );
let extend = self.shift_pressed;
let intercepted = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed );
@@ -129,7 +129,7 @@ impl<A: App> AppData<A>
{
let focused = self.surface( focus ).focused_idx;
let is_text = focused.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
find_handlers( &self.surface( focus ).frame.widget_rects, idx )
.map( |h| h.is_text_input() ) ).unwrap_or( false );
if is_text { self.handle_cursor_home( focus, self.shift_pressed ); }
}
@@ -138,7 +138,7 @@ impl<A: App> AppData<A>
{
let focused = self.surface( focus ).focused_idx;
let is_text = focused.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
find_handlers( &self.surface( focus ).frame.widget_rects, idx )
.map( |h| h.is_text_input() ) ).unwrap_or( false );
if is_text { self.handle_cursor_end( focus, self.shift_pressed ); }
}
@@ -148,7 +148,7 @@ impl<A: App> AppData<A>
let focused = self.surface( focus ).focused_idx;
if let Some( idx ) = focused
{
let press = find_handlers( &self.surface( focus ).widget_rects, idx )
let press = find_handlers( &self.surface( focus ).frame.widget_rects, idx )
.and_then( |h| h.press_msg() );
if let Some( msg ) = press
{
+5 -5
View File
@@ -33,12 +33,12 @@ impl<A: App> AppData<A>
// Hover tracking — pointer-only (touch has no hover).
// Runs before the gesture motion so the cache-dirty
// below picks up any hover-dependent redraw request.
let new_hover = find_widget_at( &self.surface( focus ).widget_rects, pp );
let new_hover = find_widget_at( &self.surface( focus ).frame.widget_rects, pp );
let old_hover = self.surface( focus ).hovered_idx;
if new_hover != old_hover
{
let redraw = hover_affects_paint( &self.surface( focus ).widget_rects, old_hover )
|| hover_affects_paint( &self.surface( focus ).widget_rects, new_hover );
let redraw = hover_affects_paint( &self.surface( focus ).frame.widget_rects, old_hover )
|| hover_affects_paint( &self.surface( focus ).frame.widget_rects, new_hover );
{
let ss = self.surface_mut( focus );
ss.hovered_idx = new_hover;
@@ -115,7 +115,7 @@ impl<A: App> AppData<A>
let outcome =
{
let ss = self.surface_mut( focus );
ss.gesture.on_move( pp, &ss.widget_rects, &mut ss.scroll_offsets, &swipe, global_drag )
ss.gesture.on_move( pp, &ss.frame.widget_rects, &mut ss.frame.scroll_offsets, &swipe, global_drag )
};
self.apply_move_outcome( focus, outcome );
@@ -129,7 +129,7 @@ impl<A: App> AppData<A>
let pressed_text = self.surface( focus ).gesture.pressed_idx
.and_then( |idx|
{
let is_text = find_handlers( &self.surface( focus ).widget_rects, idx )
let is_text = find_handlers( &self.surface( focus ).frame.widget_rects, idx )
.map( |h| h.is_text_input() ).unwrap_or( false );
if is_text { Some( idx ) } else { None }
} );
+6 -6
View File
@@ -43,9 +43,9 @@ impl<A: App> AppData<A>
let pos = self.surface( focus ).to_physical( event.position.0, event.position.1 );
self.pointer_pos = pos;
self.app.on_pointer_move( pos.x, pos.y );
let hit_idx = find_widget_at( &self.surface( focus ).widget_rects, pos );
let hit_idx = find_widget_at( &self.surface( focus ).frame.widget_rects, pos );
let lp_msg = hit_idx.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
find_handlers( &self.surface( focus ).frame.widget_rects, idx )
.and_then( |h| h.long_press_msg() ) );
if let Some( msg ) = lp_msg
{
@@ -53,7 +53,7 @@ impl<A: App> AppData<A>
self.surface_mut( focus ).request_redraw();
} else {
let is_text = hit_idx.and_then( |idx|
find_handlers( &self.surface( focus ).widget_rects, idx )
find_handlers( &self.surface( focus ).frame.widget_rects, idx )
.map( |h| h.is_text_input() ) ).unwrap_or( false );
if is_text
{
@@ -170,7 +170,7 @@ impl<A: App> AppData<A>
let outcome =
{
let ss = self.surface_mut( focus );
let result = ss.gesture.on_press( pos, &ss.widget_rects, &ss.scroll_rects );
let result = ss.gesture.on_press( pos, &ss.frame.widget_rects, &ss.frame.scroll_rects );
// Mark this gesture as mouse-driven so the
// gesture machine's 6 px stray-cancel skips
// the drag-start / long-press slots — mouse
@@ -199,7 +199,7 @@ impl<A: App> AppData<A>
if let Some( idx ) = outcome.hit_idx
{
let immediate = {
let handlers = find_handlers( &self.surface( focus ).widget_rects, idx );
let handlers = find_handlers( &self.surface( focus ).frame.widget_rects, idx );
if matches!( handlers, Some( WidgetHandlers::Button { repeating: true, .. } ) )
{
handlers.and_then( |h| h.press_msg() )
@@ -222,7 +222,7 @@ impl<A: App> AppData<A>
// under the cursor instead of just positioning.
if let Some( idx ) = outcome.hit_idx
{
let is_text = find_handlers( &self.surface( focus ).widget_rects, idx )
let is_text = find_handlers( &self.surface( focus ).frame.widget_rects, idx )
.map( |h| h.is_text_input() ).unwrap_or( false );
if is_text
{
+1 -1
View File
@@ -33,7 +33,7 @@ impl<A: App> AppData<A>
{
let ss = self.surface_mut( focus );
ss.needs_redraw = true;
ss.gesture.on_release( pos, &ss.widget_rects, &swipe, global_drag )
ss.gesture.on_release( pos, &ss.frame.widget_rects, &swipe, global_drag )
};
self.apply_release_events( focus, events_out );
// Cancel any held-button repeat — the press is
+2 -2
View File
@@ -33,7 +33,7 @@ impl<A: App> AppData<A>
let scroll_hit =
{
let ss = self.surface( focus );
ss.scroll_rects.iter()
ss.frame.scroll_rects.iter()
.find( |( r, _, _ )| r.contains( pos ) )
.map( |( _, idx, ax )| ( *idx, *ax ) )
};
@@ -47,7 +47,7 @@ impl<A: App> AppData<A>
let step_x = horizontal.absolute as f32 * multiplier;
let step_y = vertical.absolute as f32 * multiplier;
let ss = self.surface_mut( focus );
let entry = ss.scroll_offsets.entry( scroll_idx ).or_insert( ( 0.0, 0.0 ) );
let entry = ss.frame.scroll_offsets.entry( scroll_idx ).or_insert( ( 0.0, 0.0 ) );
// Wheels report on a single axis at a time; route to
// whichever axis the viewport allows. A pure horizontal
// viewport translates a vertical wheel into horizontal
+1 -1
View File
@@ -51,7 +51,7 @@ impl<A: App> AppData<A>
let token = self.loop_handle.insert_source( timer, move |_, _, data: &mut AppData<A>|
{
let live_msg = crate::tree::find_handlers(
&data.surface( focus ).widget_rects,
&data.surface( focus ).frame.widget_rects,
idx,
)
.and_then( |h| h.press_msg() );
+6 -6
View File
@@ -109,7 +109,7 @@ impl<A: App> TouchHandler for AppData<A>
let outcome =
{
let ss = self.surface_mut( focus );
let result = ss.gesture.on_press( pos, &ss.widget_rects, &ss.scroll_rects );
let result = ss.gesture.on_press( pos, &ss.frame.widget_rects, &ss.frame.scroll_rects );
ss.needs_redraw = true;
result
};
@@ -122,7 +122,7 @@ impl<A: App> TouchHandler for AppData<A>
if let Some( idx ) = outcome.hit_idx
{
let immediate = {
let handlers = find_handlers( &self.surface( focus ).widget_rects, idx );
let handlers = find_handlers( &self.surface( focus ).frame.widget_rects, idx );
if matches!( handlers, Some( crate::widget::WidgetHandlers::Button { repeating: true, .. } ) )
{
handlers.and_then( |h| h.press_msg() )
@@ -138,7 +138,7 @@ impl<A: App> TouchHandler for AppData<A>
// and double-tap selects the word under the press.
if let Some( idx ) = outcome.hit_idx
{
let is_text = find_handlers( &self.surface( focus ).widget_rects, idx )
let is_text = find_handlers( &self.surface( focus ).frame.widget_rects, idx )
.map( |h| h.is_text_input() ).unwrap_or( false );
if is_text
{
@@ -208,7 +208,7 @@ impl<A: App> TouchHandler for AppData<A>
let ss = self.surface_mut( focus );
ss.needs_redraw = true;
ss.primary_touch_id = None;
ss.gesture.on_release( pos, &ss.widget_rects, &swipe, global_drag )
ss.gesture.on_release( pos, &ss.frame.widget_rects, &swipe, global_drag )
};
self.apply_release_events( focus, events_out );
self.stop_button_repeat();
@@ -250,7 +250,7 @@ impl<A: App> TouchHandler for AppData<A>
let outcome =
{
let ss = self.surface_mut( focus );
ss.gesture.on_move( pp, &ss.widget_rects, &mut ss.scroll_offsets, &swipe, global_drag )
ss.gesture.on_move( pp, &ss.frame.widget_rects, &mut ss.frame.scroll_offsets, &swipe, global_drag )
};
self.apply_move_outcome( focus, outcome );
@@ -258,7 +258,7 @@ impl<A: App> TouchHandler for AppData<A>
let pressed_text = self.surface( focus ).gesture.pressed_idx
.and_then( |idx|
{
let is_text = find_handlers( &self.surface( focus ).widget_rects, idx )
let is_text = find_handlers( &self.surface( focus ).frame.widget_rects, idx )
.map( |h| h.is_text_input() ).unwrap_or( false );
if is_text { Some( idx ) } else { None }
} );