From da34eee9e6ad716e33965e2280b567cec480f905 Mon Sep 17 00:00:00 2001 From: "Pedro M. de Echanove Pasquin" Date: Tue, 27 Oct 2026 12:46:20 +0100 Subject: [PATCH] External textures drawn at their own size, a repaint without rebuilding the view, an icon size for list rows, and lighter tabs on the desktop strip External textures. `ExternalSource::Texture` stretches its texture over the widget's rect, so a producer that is a few frames behind a new rect shows its old picture stretched or squeezed. A web page while its window is resized is the clearest case: the page keeps sending frames of the old size until it has laid itself out again, and the content wobbles as the window moves. The new `ExternalSource::Frame` closure returns the texture with its size in physical pixels, and the texture is drawn at that size from the rect's top left corner, cut to the rect. A late frame then leaves an empty edge or loses one for a moment, as other browsers do, instead of being distorted. The cut uses `interior_clip`, which moves from the text field's drawing code to `render/clip.rs` so both widgets can use it, and the clip that was active before is restored afterwards. Repaint without a view. An application whose only change is a new frame in an external texture had to report `InvalidationScope::All`, which rebuilds the whole view just to draw the same widgets again. `App::needs_repaint` is asked once per iteration, after the messages are processed. When it returns `true`, the main surface and the secondary surfaces are redrawn from the cached view, without calling `view`. The default is `false`, so existing applications behave as before. List rows. `ListItem::icon_size` overrides the size of the leading icon, which was always the theme's 24 design px, for dense lists such as a menu, where an icon of that size dominates the row. Tab strip. On the desktop, a tab's title is drawn at the regular weight whether it is selected or not; the selected tab already stands out by its fill and edge, and the bold title made the strip heavy. The desktop tab's close icon is 10 widget pixels instead of the 16 that the cards of the tab overview keep, so `parts::close` now takes the icon size from its caller. --- docs/onboarding.md | 1 + docs/widgets.md | 6 ++++- src/app.rs | 3 +++ src/event_loop/step.rs | 8 +++++++ src/render/clip.rs | 33 +++++++++++++++++++++++++++ src/widget/external/mod.rs | 30 +++++++++++++++++++++---- src/widget/list_item/mod.rs | 17 +++++++++++++- src/widget/tab_strip/desktop.rs | 7 +++--- src/widget/tab_strip/overview.rs | 4 ++-- src/widget/tab_strip/parts.rs | 6 ++--- src/widget/tab_strip/theme.rs | 1 + src/widget/text_edit/draw.rs | 38 +------------------------------- src/widget/text_edit/tests.rs | 2 +- 13 files changed, 103 insertions(+), 53 deletions(-) diff --git a/docs/onboarding.md b/docs/onboarding.md index 8af95a7..f113253 100644 --- a/docs/onboarding.md +++ b/docs/onboarding.md @@ -276,6 +276,7 @@ Use it when you need: - external wakeups via `set_channel_sender()` - timer-driven or async state via `poll_external()` / `poll_interval()` - redraw narrowing via `invalidate_after()` +- repainting an external texture's new frame without rebuilding the view via `needs_repaint()` - runtime theme state access - runtime-free embedding through `core::UiSurface` diff --git a/docs/widgets.md b/docs/widgets.md index 9284f2a..b031628 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -203,7 +203,7 @@ its content (theme default 16 px) — lower it when the enclosing view's own padding already provides the margin. `ink( label, secondary )` draws the label in `label`, the subtitle and trailing text in `secondary`, and tints hover and press with `label`, instead of the theme's colours, for a row laid on a surface the theme does not know about, such as a menu drawn in an app's own colours. -`height( len )` overrides the theme row height (56 design px, 68 with a subtitle) and `font_size( len )` the primary-label size (16 design px; subtitle and trailing keep their theme sizes), both `impl Into`. Use them together where a dense list — a context menu, a compact picker — should trade the touch-target generosity of a settings list for row density; the resolved height is floored at the label's rendered height so the text never clips however aggressive the override. +`height( len )` overrides the theme row height (56 design px, 68 with a subtitle) and `font_size( len )` the primary-label size (16 design px; subtitle and trailing keep their theme sizes), both `impl Into`. Use them together where a dense list — a context menu, a compact picker — should trade the touch-target generosity of a settings list for row density; the resolved height is floored at the label's rendered height so the text never clips however aggressive the override. `icon_size( len )` overrides the leading icon's size (24 design px). `ink( label, secondary )` draws the label in `label` and the subtitle and trailing text in `secondary`, and tints hover and press with `label`, in place of the theme's colours: for rows on a surface of the app's own, such as a menu in a fixed colour. @@ -583,6 +583,10 @@ Two sources: - `External::new( w, h, ExternalSource::Texture( … ) )` — samples a caller-owned GL texture each frame (a web engine, a video decoder). **GLES only**; the producer keeps the texture and ltk only composites. +- `External::new( w, h, ExternalSource::Frame( … ) )` — the same, but the + closure also returns the texture's size and ltk draws it at that size + from the top left corner, cut to the rect, instead of stretching it: a + web page whose frame lags behind a window resize is not distorted. **When**: a `VectorDrawable` / Lottie frame, a custom-painted gauge, or embedding another renderer's output. diff --git a/src/app.rs b/src/app.rs index 5a110b2..55649d2 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1218,6 +1218,9 @@ pub trait App: 'static /// The event loop will keep requesting redraws at ~60 fps until this returns `false`. fn is_animating( &self ) -> bool { false } + /// Return `true` when the main surface's pixels changed but its widgets did not, such as an [`crate::widget::external::External`] with a new frame: the surface is drawn again from the cached view, without calling [`Self::view`]. Asked once per iteration, after the messages are processed. + fn needs_repaint( &mut self ) -> bool { false } + /// Cap [`Self::is_animating`] redraws to ~30 Hz when the **software** /// renderer is active. Default `true`: 60 fps software rasterization is a /// common battery sink on mobile (no GPU offload), and halving it is diff --git a/src/event_loop/step.rs b/src/event_loop/step.rs index 7c0bb5e..9e1eb61 100644 --- a/src/event_loop/step.rs +++ b/src/event_loop/step.rs @@ -40,6 +40,14 @@ impl AppData self.pending_msgs.extend( ext ); self.process_messages(); + if self.app.needs_repaint() + { + self.main.request_redraw(); + for ( _, ss ) in self.secondaries.iter_mut() + { + ss.request_redraw(); + } + } if self.app.requested_exit() { self.exit_requested = true; } self.sync_fullscreen(); self.sync_external_text_input(); diff --git a/src/render/clip.rs b/src/render/clip.rs index 78b8bc9..687656f 100644 --- a/src/render/clip.rs +++ b/src/render/clip.rs @@ -6,6 +6,7 @@ //! inside the dirty rects are touched. `set_clip_path` installs an //! arbitrary anti-aliased vector path as the clip (an exact tiny-skia //! coverage [`Mask`]) for shaped clipping such as a circular avatar. +//! [`interior_clip`] narrows whatever clip is active to a widget's rect, on either backend. use tiny_skia::{ FillRule, Mask, PathBuilder, Transform }; @@ -13,6 +14,38 @@ use crate::types::Rect; use super::SoftwareCanvas; +/// The active clip `outer` narrowed to `inner`. An empty intersection must still clip everything: an empty rect list would lift the clip. +pub( crate ) fn interior_clip( outer: &[ Rect ], inner: Rect ) -> Vec +{ + if outer.is_empty() + { + return vec![ inner ]; + } + let clip: Vec = outer.iter() + .filter_map( |o| rect_intersect( *o, inner ) ) + .collect(); + if clip.is_empty() + { + vec![ Rect { x: 0.0, y: 0.0, width: 0.0, height: 0.0 } ] + } else { + clip + } +} + +fn rect_intersect( a: Rect, b: Rect ) -> Option +{ + let x0 = a.x.max( b.x ); + let y0 = a.y.max( b.y ); + let x1 = ( a.x + a.width ).min( b.x + b.width ); + let y1 = ( a.y + a.height ).min( b.y + b.height ); + if x1 > x0 && y1 > y0 + { + Some( Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 } ) + } else { + None + } +} + impl SoftwareCanvas { /// Set the active clip region to the union of `rects` (physical pixels). diff --git a/src/widget/external/mod.rs b/src/widget/external/mod.rs index 3ff6570..7aef68c 100644 --- a/src/widget/external/mod.rs +++ b/src/widget/external/mod.rs @@ -55,6 +55,7 @@ use std::sync::Arc; use crate::render::Canvas; +use crate::render::clip::interior_clip; use crate::types::Rect; /// A widget that defers its pixels to an external GL texture producer. @@ -76,10 +77,9 @@ pub struct External /// Backends an [`External`] widget can pull pixels from. /// -/// The only variant today is [`ExternalSource::Texture`], a closure -/// returning the current GL texture name. Future variants (DMA-BUF -/// imports, software pixmaps for the CPU backend, …) can be added -/// without changing call sites. +/// A GL texture stretched over the rect ([`ExternalSource::Texture`]) or +/// drawn at its own size ([`ExternalSource::Frame`]), or immediate CPU +/// drawing ([`ExternalSource::Cpu`]). #[ derive( Clone ) ] pub enum ExternalSource { @@ -94,6 +94,9 @@ pub enum ExternalSource /// translate input coordinates by `rect.origin`. Texture( Arc Option + Send + Sync> ), + /// Like [`Self::Texture`], but the closure also returns the texture's size in physical pixels, and the texture is drawn at that size from the rect's top left corner, cut to the rect, rather than stretched over it. For a producer that catches up with a new rect a few frames late, such as a web page while its window is resized. + Frame( Arc Option<( glow::Texture, u32, u32 )> + Send + Sync> ), + /// Closure invoked once per frame with LTK's [`Canvas`] and the widget's /// laid-out rect, for immediate-mode CPU drawing (no GL texture). Used to /// host an Android `View`'s custom `onDraw` straight onto the LTK canvas. @@ -153,6 +156,25 @@ impl External canvas.draw_external_texture( tex, rect, self.opacity ); } } + ExternalSource::Frame( get ) => + { + let gl = match canvas + { + Canvas::Software( _ ) => return, + Canvas::Gles( c ) => c.gl.clone(), + }; + let Some( ( tex, width, height ) ) = get( &gl, rect ) else { return }; + let frame = Rect { x: rect.x, y: rect.y, width: width as f32, height: height as f32 }; + let outer = canvas.clip_bounds(); + canvas.set_clip_rects( &interior_clip( &outer, rect ) ); + canvas.draw_external_texture( tex, frame, self.opacity ); + if outer.is_empty() + { + canvas.clear_clip(); + } else { + canvas.set_clip_rects( &outer ); + } + } } } } diff --git a/src/widget/list_item/mod.rs b/src/widget/list_item/mod.rs index ba1f0b5..99fb896 100644 --- a/src/widget/list_item/mod.rs +++ b/src/widget/list_item/mod.rs @@ -64,6 +64,8 @@ pub struct ListItem /// when this is set, and offsets the label / subtitle by the /// same amount. Pass `None` to keep the icon-less layout. pub( crate ) icon: Option<( Arc>, u32, u32 )>, + /// Optional override of `theme::ICON_SIZE` for the leading icon. + pub( crate ) icon_size: Option, /// Optional override of the horizontal content inset. `None` /// falls back to `theme::PAD_H`. pub( crate ) pad_h: Option, @@ -91,6 +93,7 @@ impl ListItem id: None, selected: false, icon: None, + icon_size: None, pad_h: None, height: None, font_size: None, @@ -117,6 +120,13 @@ impl ListItem self } + /// Override the leading icon's size (default: `theme::ICON_SIZE` design px). + pub fn icon_size( mut self, s: impl Into ) -> Self + { + self.icon_size = Some( s.into() ); + self + } + /// Mark this row as the currently-selected option in its list. /// Selected rows paint with a dark surface and white text and /// override hover / press visuals. @@ -288,7 +298,11 @@ impl ListItem // is in `theme::icon_rgba` rather than baked here. let text_x = if let Some( ( rgba, w, h ) ) = &self.icon { - let icon_size = canvas.geom_px( theme::ICON_SIZE ); + let icon_size = match self.icon_size + { + Some( l ) => canvas.resolve_geom( l ), + None => canvas.geom_px( theme::ICON_SIZE ), + }; let icon_gap = canvas.geom_px( theme::ICON_GAP ); let icon_y = rect.y + ( rect.height - icon_size ) / 2.0; let icon_rect = Rect { x: rect.x + pad_h, y: icon_y, width: icon_size, height: icon_size }; @@ -370,6 +384,7 @@ impl ListItem id: self.id, selected: self.selected, icon: self.icon, + icon_size: self.icon_size, pad_h: self.pad_h, height: self.height, font_size: self.font_size, diff --git a/src/widget/tab_strip/desktop.rs b/src/widget/tab_strip/desktop.rs index e104a68..57e7b11 100644 --- a/src/widget/tab_strip/desktop.rs +++ b/src/widget/tab_strip/desktop.rs @@ -10,7 +10,7 @@ use crate::widget::viewport::viewport; use crate::widget::{ container, text, Element }; use super::row::{ TabCells, TabRow }; -use super::theme::{ EDGE_WIDTH, GAP, ICON_SIZE, LABEL_SIZE, TAB_RADIUS }; +use super::theme::{ EDGE_WIDTH, GAP, ICON_SIZE, LABEL_SIZE, TAB_CLOSE_ICON, TAB_RADIUS }; use super::{ parts, IndexMsg, TabItem, TabStrip, TabStripColors }; /// `[leading] [tabs…][after tabs] clear [trailing]`, the tabs scrolling sideways when they do not fit. @@ -54,16 +54,15 @@ fn handle( ) -> Element { let ( fill, edge, ink ) = colors.of( active ); - let weight = if active { 600 } else { 400 }; let mut body = row().spacing( Length::widget( 8.0 ) ); if let Some( icon ) = parts::icon( &tab, Length::widget( ICON_SIZE ), ink ) { body = body.push( icon ); } - body = body.push( flex( text( tab.label ).size( LABEL_SIZE ).weight( weight ).color( ink ) ) ); + body = body.push( flex( text( tab.label ).size( LABEL_SIZE ).weight( 400 ).color( ink ) ) ); if let Some( close ) = on_close.filter( |_| tab.closable ) { - body = body.push( parts::close( close( idx ), ink ) ); + body = body.push( parts::close( close( idx ), ink, TAB_CLOSE_ICON ) ); } let face = container( body ) .background( fill ) diff --git a/src/widget/tab_strip/overview.rs b/src/widget/tab_strip/overview.rs index 480bd45..b1ade16 100644 --- a/src/widget/tab_strip/overview.rs +++ b/src/widget/tab_strip/overview.rs @@ -15,7 +15,7 @@ use crate::widget::scroll::scroll; use crate::widget::separator::separator; use crate::widget::{ container, text, Element }; -use super::theme::{ BACK_ICON, BODY_WIDTH, BUTTON_ICON, BUTTON_SIZE, CARD_RADIUS, COLUMNS, EDGE_WIDTH }; +use super::theme::{ BACK_ICON, BODY_WIDTH, BUTTON_ICON, BUTTON_SIZE, CARD_RADIUS, CLOSE_ICON_SIZE, COLUMNS, EDGE_WIDTH }; use super::cards::TabCards; use super::{ parts, IndexMsg, TabItem, TabMotion, TabStripColors }; @@ -207,7 +207,7 @@ fn card( let mut card = stack().push( press ); if let Some( close ) = on_close.filter( |_| tab.closable ) { - card = card.push_aligned_margin( parts::close( close( idx ), ink ), HAlign::End, VAlign::Top, 4.0 ); + card = card.push_aligned_margin( parts::close( close( idx ), ink, CLOSE_ICON_SIZE ), HAlign::End, VAlign::Top, 4.0 ); } card.into() } diff --git a/src/widget/tab_strip/parts.rs b/src/widget/tab_strip/parts.rs index 3d8b1b8..f7a2485 100644 --- a/src/widget/tab_strip/parts.rs +++ b/src/widget/tab_strip/parts.rs @@ -6,7 +6,7 @@ use crate::widget::button::ButtonVariant; use crate::widget::{ button, icon_button, image, Element }; use super::icons; -use super::theme::{ CLOSE_ICON, CLOSE_ICON_SIZE, CLOSE_SIZE }; +use super::theme::{ CLOSE_ICON, CLOSE_SIZE }; use super::{ TabIcon, TabItem }; /// The tab's icon at `size`, a symbolic one in `ink`. @@ -20,9 +20,9 @@ pub( super ) fn icon( tab: &TabItem, size: Length, ink: Color ) -> O Some( image( rgba, w, h ).size( size, size ).into() ) } -pub( super ) fn close( msg: Msg, ink: Color ) -> Element +pub( super ) fn close( msg: Msg, ink: Color, icon_size: f32 ) -> Element { - symbol_button( CLOSE_ICON, "x", msg, ink, CLOSE_SIZE, CLOSE_ICON_SIZE ) + symbol_button( CLOSE_ICON, "x", msg, ink, CLOSE_SIZE, icon_size ) } /// A borderless button with a symbolic icon in `ink`, or `fallback` as text when the theme lacks the icon. diff --git a/src/widget/tab_strip/theme.rs b/src/widget/tab_strip/theme.rs index 7699fa9..22ada1a 100644 --- a/src/widget/tab_strip/theme.rs +++ b/src/widget/tab_strip/theme.rs @@ -20,6 +20,7 @@ pub( crate ) const LABEL_SIZE: f32 = 13.0; pub( crate ) const ICON_SIZE: f32 = 16.0; pub( crate ) const CLOSE_SIZE: f32 = 28.0; pub( crate ) const CLOSE_ICON_SIZE: f32 = 16.0; +pub( crate ) const TAB_CLOSE_ICON: f32 = 10.0; pub( crate ) const BUTTON_SIZE: f32 = 40.0; pub( crate ) const BUTTON_ICON: f32 = 22.0; pub( crate ) const TAB_RADIUS: f32 = 8.0; diff --git a/src/widget/text_edit/draw.rs b/src/widget/text_edit/draw.rs index 98866d9..781aaf9 100644 --- a/src/widget/text_edit/draw.rs +++ b/src/widget/text_edit/draw.rs @@ -2,6 +2,7 @@ // Copyright (C) 2026 Liberux Labs, S. L. use crate::render::Canvas; +use crate::render::clip::interior_clip; use crate::types::{ Color, Rect }; use super::TextEdit; @@ -13,43 +14,6 @@ use super::tabs::{ line_x, next_stop, tab_width }; use super::TextFont; use super::theme; -/// Axis-aligned intersection of two rects. Returns `None` when the rects -/// do not overlap (zero-area / negative-extent results count as -/// "no overlap" so the caller can `filter_map` straight into a clip -/// list). -/// Clip for the field interior. An empty intersection with the parent clip -/// must still clip everything: an empty rect list would lift the clip. -pub( super ) fn interior_clip( outer: &[ Rect ], inner: Rect ) -> Vec -{ - if outer.is_empty() - { - return vec![ inner ]; - } - let clip: Vec = outer.iter() - .filter_map( |o| rect_intersect( *o, inner ) ) - .collect(); - if clip.is_empty() - { - vec![ Rect { x: 0.0, y: 0.0, width: 0.0, height: 0.0 } ] - } else { - clip - } -} - -fn rect_intersect( a: Rect, b: Rect ) -> Option -{ - let x0 = a.x.max( b.x ); - let y0 = a.y.max( b.y ); - let x1 = ( a.x + a.width ).min( b.x + b.width ); - let y1 = ( a.y + a.height ).min( b.y + b.height ); - if x1 > x0 && y1 > y0 - { - Some( Rect { x: x0, y: y0, width: x1 - x0, height: y1 - y0 } ) - } else { - None - } -} - fn password_toggle_reserve( rect: Rect ) -> f32 { ( theme::PASSWORD_TOGGLE_SIZE + theme::PASSWORD_TOGGLE_SLOP * 2.0 ) * single_line_scale( rect ) diff --git a/src/widget/text_edit/tests.rs b/src/widget/text_edit/tests.rs index 4b79058..cc950c7 100644 --- a/src/widget/text_edit/tests.rs +++ b/src/widget/text_edit/tests.rs @@ -741,7 +741,7 @@ fn password_toggle_drop_zeros_underlying_string_buffer() #[ test ] fn interior_clip_outside_the_parent_clip_clips_everything() { - use super::draw::interior_clip; + use crate::render::clip::interior_clip; use crate::types::Rect; let inner = Rect { x: 10.0, y: 10.0, width: 100.0, height: 20.0 };