Mature ltk to host an externally-laid-out Android view tree
Add the primitives rustdroid needs to project an Android view hierarchy onto an ltk surface, all kept general rather than Android-specific. Canvas gains arbitrary vector path fill and stroke: `Canvas::fill_path` / `stroke_path` over a new `PathCmd` command list (MoveTo/LineTo/QuadTo/CubicTo/Close in surface coordinates). The software backend rasterises directly with tiny-skia; the GLES backend rasterises into a tiny-skia pixmap and blits it (CPU fallback, no GPU path shader). This is what renders an Android `Path`, a `VectorDrawable`, or a Lottie frame. `ExternalSource::Cpu` (and the `External::cpu` constructor) adds an immediate-mode CPU drawing closure, invoked once per frame with the canvas and the widget's laid-out rect, working on both the GLES and software backends. It hosts a custom `View.onDraw` straight onto the ltk canvas without a GL texture round-trip, unlike the existing `Texture` source which only renders on GLES. `Stack::push_placed` appends a child at an exact rect, bypassing alignment and intrinsic sizing. This lets a view tree whose geometry is computed elsewhere — Android's measure/layout pass, which yields an absolute rect per view — be projected onto a Stack in paint order. New `RichText` widget: wrapped paragraph text carrying a message per clickable link range, with the layout pass emitting one hit rect per link line so taps land on the link rather than the whole paragraph. It is the ltk side of an Android `Spanned` carrying `URLSpan` / `ClickableSpan`. gesture: only drive the horizontal pager once the axis locks horizontal `on_move` emitted horizontal swipe progress whenever `swipe_axis != Vertical`, which includes the pre-lock window where `swipe_axis` is still `None` (the first ~8 px of travel). But `horizontal_drag_started` only flips once `dx.abs() > 8`. A vertical gesture that opened with a few pixels of lateral drift — a swipe-up to the launcher, a scroll — therefore emitted a tiny horizontal progress sample, which armed the consumer's pager, then locked vertical without `dx` ever passing 8, so `horizontal_drag_started` stayed false. On release the horizontal branch was skipped, no `on_swipe_horizontal_progress(0.0)` fired, and the pager stayed stuck active — on the crustace homescreen that froze the surface (the main stays motion-only behind stale page subsurfaces) until an unrelated gesture reset it. Emit horizontal progress only once `swipe_axis == Some(Horizontal)`. Locking onto the horizontal axis already implies `dx.abs() > 8`, so `horizontal_drag_started` is set in the same step, restoring the invariant that a frame which drives the pager always has a matching release event to settle it. The cost is that the first ~8 px of a horizontal drag no longer move the page, which is the same deadband the axis lock already imposes on the vertical panels. Adds `pre_lock_lateral_drift_does_not_drive_horizontal_pager`.
This commit is contained in:
16
src/app.rs
16
src/app.rs
@@ -473,6 +473,22 @@ pub trait App: 'static
|
|||||||
/// edge, matching the usual system-panel pull-down UX.
|
/// edge, matching the usual system-panel pull-down UX.
|
||||||
fn swipe_down_edge( &self ) -> f32 { 1.0 }
|
fn swipe_down_edge( &self ) -> f32 { 1.0 }
|
||||||
|
|
||||||
|
/// Called while a vertical [`scroll`](crate::widget::scroll) is pinned at
|
||||||
|
/// its top and the finger keeps pulling down. `progress` is the
|
||||||
|
/// accumulated overscroll as a fraction of the surface height (≥ 0.0,
|
||||||
|
/// unbounded). Use it to drive a pull-from-top-to-dismiss panel that
|
||||||
|
/// tracks the finger.
|
||||||
|
fn on_overscroll_down_progress( &mut self, _progress: f32 ) {}
|
||||||
|
|
||||||
|
/// Called when the finger releases after a top overscroll pull-down.
|
||||||
|
/// `committed` is `true` when the pull passed
|
||||||
|
/// [`overscroll_dismiss_threshold`](Self::overscroll_dismiss_threshold).
|
||||||
|
fn on_overscroll_down( &mut self, _committed: bool ) -> Option<Self::Message> { None }
|
||||||
|
|
||||||
|
/// Fraction of surface height a top overscroll pull-down must exceed to
|
||||||
|
/// commit a dismiss on release. Default: `0.25`.
|
||||||
|
fn overscroll_dismiss_threshold( &self ) -> f32 { 0.25 }
|
||||||
|
|
||||||
/// Called when a sufficient leftward swipe gesture is detected.
|
/// Called when a sufficient leftward swipe gesture is detected.
|
||||||
///
|
///
|
||||||
/// Return a message to handle the swipe (e.g., paging to the next
|
/// Return a message to handle the swipe (e.g., paging to the next
|
||||||
|
|||||||
@@ -380,6 +380,38 @@ pub( crate ) fn layout_and_draw<Msg: Clone>(
|
|||||||
canvas.blit_fade_bottom( &sub, rect.x as i32, rect.y as i32, v.fade_bottom );
|
canvas.blit_fade_bottom( &sub, rect.x as i32, rect.y as i32, v.fade_bottom );
|
||||||
next_idx
|
next_idx
|
||||||
}
|
}
|
||||||
|
Element::RichText( r ) =>
|
||||||
|
{
|
||||||
|
r.draw( canvas, rect );
|
||||||
|
// One hit rect per link line, each its own flat_idx so hover/press
|
||||||
|
// state does not bleed between links sharing a paragraph.
|
||||||
|
let link_rects = r.link_rects( rect, canvas );
|
||||||
|
let consumed = link_rects.len().max( 1 );
|
||||||
|
for ( k, ( lr, msg ) ) in link_rects.into_iter().enumerate()
|
||||||
|
{
|
||||||
|
ctx.widget_rects.push( LaidOutWidget
|
||||||
|
{
|
||||||
|
rect: lr,
|
||||||
|
flat_idx: flat_idx + k,
|
||||||
|
id: None,
|
||||||
|
paint_rect: lr,
|
||||||
|
handlers: WidgetHandlers::Button
|
||||||
|
{
|
||||||
|
on_press: Some( msg ),
|
||||||
|
on_long_press: None,
|
||||||
|
on_drag_start: None,
|
||||||
|
on_escape: None,
|
||||||
|
repeating: false,
|
||||||
|
},
|
||||||
|
keyboard_focusable: false,
|
||||||
|
cursor: crate::types::CursorShape::Pointer,
|
||||||
|
tooltip: None,
|
||||||
|
accessible_label: None,
|
||||||
|
is_live_region: ctx.live_depth > 0,
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
flat_idx + consumed
|
||||||
|
}
|
||||||
other =>
|
other =>
|
||||||
{
|
{
|
||||||
// Gate the focus ring by `is_focusable`: a widget that opts out
|
// Gate the focus ring by `is_focusable`: a widget that opts out
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
use glow::HasContext;
|
use glow::HasContext;
|
||||||
|
|
||||||
use crate::theme::{ gradient_lut, BlendMode, InsetShadow, LinearGradient, RadialGradient, Shadow };
|
use crate::theme::{ gradient_lut, BlendMode, InsetShadow, LinearGradient, RadialGradient, Shadow };
|
||||||
use crate::types::{ Color, Corners, Rect };
|
use crate::types::{ Color, Corners, PathCmd, Rect };
|
||||||
|
|
||||||
use super::helpers::ortho_rect;
|
use super::helpers::ortho_rect;
|
||||||
use super::GlesCanvas;
|
use super::GlesCanvas;
|
||||||
@@ -51,6 +51,69 @@ impl GlesCanvas
|
|||||||
r_x1 <= clip.x || c_x1 <= r_x0 || r_y1 <= clip.y || c_y1 <= r_y0
|
r_x1 <= clip.x || c_x1 <= r_x0 || r_y1 <= clip.y || c_y1 <= r_y0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn fill_path( &mut self, cmds: &[ PathCmd ], color: Color )
|
||||||
|
{
|
||||||
|
self.rasterise_path( cmds, color, None );
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stroke_path( &mut self, cmds: &[ PathCmd ], color: Color, width: f32 )
|
||||||
|
{
|
||||||
|
self.rasterise_path( cmds, color, Some( width ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CPU fallback for vector paths on the GPU backend: rasterise into a
|
||||||
|
/// tiny-skia pixmap sized to the path's bounding box, then blit it as a
|
||||||
|
/// texture via `draw_image_data`. No GPU path shader.
|
||||||
|
fn rasterise_path( &mut self, cmds: &[ PathCmd ], color: Color, stroke: Option<f32> )
|
||||||
|
{
|
||||||
|
let Some( path ) = crate::render::helpers::build_ts_path( cmds ) else { return };
|
||||||
|
let bounds = path.bounds();
|
||||||
|
let pad = stroke.map_or( 1.0, |w| w * 0.5 + 1.0 );
|
||||||
|
let x0 = ( bounds.left() - pad ).floor();
|
||||||
|
let y0 = ( bounds.top() - pad ).floor();
|
||||||
|
let x1 = ( bounds.right() + pad ).ceil();
|
||||||
|
let y1 = ( bounds.bottom() + pad ).ceil();
|
||||||
|
let pw = ( x1 - x0 ).max( 1.0 ) as u32;
|
||||||
|
let ph = ( y1 - y0 ).max( 1.0 ) as u32;
|
||||||
|
if pw > 8192 || ph > 8192 { return; }
|
||||||
|
let Some( mut pixmap ) = tiny_skia::Pixmap::new( pw, ph ) else { return };
|
||||||
|
|
||||||
|
let mut paint = tiny_skia::Paint::default();
|
||||||
|
paint.set_color( Color::rgba( color.r, color.g, color.b, color.a * self.global_alpha ).to_tiny_skia() );
|
||||||
|
paint.anti_alias = true;
|
||||||
|
let tf = tiny_skia::Transform::from_translate( -x0, -y0 );
|
||||||
|
match stroke
|
||||||
|
{
|
||||||
|
Some( w ) =>
|
||||||
|
{
|
||||||
|
let mut s = tiny_skia::Stroke::default();
|
||||||
|
s.width = w;
|
||||||
|
pixmap.stroke_path( &path, &paint, &s, tf, None );
|
||||||
|
}
|
||||||
|
None => { pixmap.fill_path( &path, &paint, tiny_skia::FillRule::Winding, tf, None ); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// tiny-skia stores premultiplied RGBA; the texture shader expects straight.
|
||||||
|
let mut rgba = pixmap.data().to_vec();
|
||||||
|
for px in rgba.chunks_exact_mut( 4 )
|
||||||
|
{
|
||||||
|
let a = px[ 3 ] as u32;
|
||||||
|
if a > 0 && a < 255
|
||||||
|
{
|
||||||
|
px[ 0 ] = ( px[ 0 ] as u32 * 255 / a ).min( 255 ) as u8;
|
||||||
|
px[ 1 ] = ( px[ 1 ] as u32 * 255 / a ).min( 255 ) as u8;
|
||||||
|
px[ 2 ] = ( px[ 2 ] as u32 * 255 / a ).min( 255 ) as u8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Transient texture (upload → draw → delete): a path's pixels change
|
||||||
|
// every frame for animated vectors, which would make the content-keyed
|
||||||
|
// image cache in `draw_image_data` grow without bound and exhaust GL.
|
||||||
|
let dest = Rect { x: x0, y: y0, width: pw as f32, height: ph as f32 };
|
||||||
|
let tex = super::helpers::upload_rgba_texture( &self.gl, self.version, &rgba, pw as i32, ph as i32 );
|
||||||
|
self.draw_external_texture( tex, dest, 1.0 );
|
||||||
|
unsafe { self.gl.delete_texture( tex ); }
|
||||||
|
}
|
||||||
|
|
||||||
pub fn fill_rect( &mut self, rect: Rect, color: Color, corners: Corners )
|
pub fn fill_rect( &mut self, rect: Rect, color: Color, corners: Corners )
|
||||||
{
|
{
|
||||||
if self.rect_culled( rect, 1.0 ) { return; }
|
if self.rect_culled( rect, 1.0 ) { return; }
|
||||||
|
|||||||
@@ -92,7 +92,9 @@ impl GlesCanvas
|
|||||||
if !canvas_handle.bytes.is_empty() { Some( canvas_handle.clone() ) } else { None }
|
if !canvas_handle.bytes.is_empty() { Some( canvas_handle.clone() ) } else { None }
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
let shaped = crate::text_shaping::shape_line( text, scaled, resolve );
|
let shaped = crate::text_shaping::shape_line_cached(
|
||||||
|
text, scaled, Arc::as_ptr( &prefer_handle.font ) as usize, resolve,
|
||||||
|
);
|
||||||
if shaped.is_empty() { return; }
|
if shaped.is_empty() { return; }
|
||||||
|
|
||||||
// Resolve each glyph's `font_id` to an actual `Arc<Font>` for
|
// Resolve each glyph's `font_id` to an actual `Arc<Font>` for
|
||||||
@@ -105,7 +107,7 @@ impl GlesCanvas
|
|||||||
{
|
{
|
||||||
fonts.push( ( font_id( &canvas_handle.font ), Arc::clone( &canvas_handle.font ) ) );
|
fonts.push( ( font_id( &canvas_handle.font ), Arc::clone( &canvas_handle.font ) ) );
|
||||||
}
|
}
|
||||||
for g in &shaped
|
for g in shaped.iter()
|
||||||
{
|
{
|
||||||
if fonts.iter().any( |( id, _ )| *id == g.font_id ) { continue; }
|
if fonts.iter().any( |( id, _ )| *id == g.font_id ) { continue; }
|
||||||
let mut found = None;
|
let mut found = None;
|
||||||
@@ -222,7 +224,7 @@ impl GlesCanvas
|
|||||||
let surface_h = self.height as f32;
|
let surface_h = self.height as f32;
|
||||||
let mut verts: Vec<f32> = Vec::with_capacity( shaped.len() * 24 );
|
let mut verts: Vec<f32> = Vec::with_capacity( shaped.len() * 24 );
|
||||||
let mut cx = x;
|
let mut cx = x;
|
||||||
for g in &shaped
|
for g in shaped.iter()
|
||||||
{
|
{
|
||||||
let glyph_id = g.glyph_id as u16;
|
let glyph_id = g.glyph_id as u16;
|
||||||
let key = ( glyph_id, size_key, g.font_id );
|
let key = ( glyph_id, size_key, g.font_id );
|
||||||
@@ -325,7 +327,9 @@ impl GlesCanvas
|
|||||||
if !canvas_handle.bytes.is_empty() { Some( canvas_handle.clone() ) } else { None }
|
if !canvas_handle.bytes.is_empty() { Some( canvas_handle.clone() ) } else { None }
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
let shaped = crate::text_shaping::shape_line( text, scaled, resolve );
|
let shaped = crate::text_shaping::shape_line_cached(
|
||||||
|
text, scaled, Arc::as_ptr( &prefer_handle.font ) as usize, resolve,
|
||||||
|
);
|
||||||
if shaped.is_empty()
|
if shaped.is_empty()
|
||||||
{
|
{
|
||||||
return text.chars().map( |ch|
|
return text.chars().map( |ch|
|
||||||
|
|||||||
@@ -44,6 +44,18 @@ impl<A: App> AppData<A>
|
|||||||
{
|
{
|
||||||
self.surface_mut( focus ).request_redraw();
|
self.surface_mut( focus ).request_redraw();
|
||||||
}
|
}
|
||||||
|
MoveOutcome::OverscrollDown { progress } =>
|
||||||
|
{
|
||||||
|
self.app.on_overscroll_down_progress( progress );
|
||||||
|
// Like a vertical swipe, the dismiss rides an overlay panel
|
||||||
|
// (subsurface) over a static main, so refresh the overlays but
|
||||||
|
// leave the main alone — re-rastering it every motion is waste.
|
||||||
|
self.overlays_dirty = true;
|
||||||
|
for ss in self.overlays.values_mut()
|
||||||
|
{
|
||||||
|
ss.request_redraw();
|
||||||
|
}
|
||||||
|
}
|
||||||
MoveOutcome::Swipe { up, down, horizontal } =>
|
MoveOutcome::Swipe { up, down, horizontal } =>
|
||||||
{
|
{
|
||||||
if let Some( v ) = up { self.app.on_swipe_progress( v ); }
|
if let Some( v ) = up { self.app.on_swipe_progress( v ); }
|
||||||
@@ -58,7 +70,12 @@ impl<A: App> AppData<A>
|
|||||||
// GPU, stalls the event loop so the gesture itself feels laggy
|
// GPU, stalls the event loop so the gesture itself feels laggy
|
||||||
// to start. So refresh the overlays always, but the main only
|
// to start. So refresh the overlays always, but the main only
|
||||||
// for a horizontal swipe.
|
// for a horizontal swipe.
|
||||||
if horizontal.is_some()
|
// ...unless the app is sliding the pager on subsurfaces
|
||||||
|
// (`subsurface_motion_only`): then the main is unchanged and the
|
||||||
|
// slide is a per-iteration subsurface reposition, so re-rastering
|
||||||
|
// the full main every motion event is the same waste the vertical
|
||||||
|
// panels already avoid — and, with child subsurfaces, it flickers.
|
||||||
|
if horizontal.is_some() && !self.app.subsurface_motion_only()
|
||||||
{
|
{
|
||||||
self.view_dirty = true;
|
self.view_dirty = true;
|
||||||
if let SurfaceFocus::Main = focus
|
if let SurfaceFocus::Main = focus
|
||||||
@@ -172,6 +189,21 @@ impl<A: App> AppData<A>
|
|||||||
ss.frame_pending = false;
|
ss.frame_pending = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ReleaseEvent::OverscrollDown { committed } =>
|
||||||
|
{
|
||||||
|
if let Some( msg ) = self.app.on_overscroll_down( committed )
|
||||||
|
{
|
||||||
|
self.pending_msgs.push( msg );
|
||||||
|
}
|
||||||
|
self.dirty_caches();
|
||||||
|
self.main.request_redraw();
|
||||||
|
self.main.frame_pending = false;
|
||||||
|
for ss in self.overlays.values_mut()
|
||||||
|
{
|
||||||
|
ss.request_redraw();
|
||||||
|
ss.frame_pending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
ReleaseEvent::HorizontalFellThrough =>
|
ReleaseEvent::HorizontalFellThrough =>
|
||||||
{
|
{
|
||||||
// Below threshold: pulse horizontal 0 so a
|
// Below threshold: pulse horizontal 0 so a
|
||||||
|
|||||||
@@ -128,6 +128,12 @@ pub struct GestureState<Msg: Clone>
|
|||||||
/// `app.on_drop`. Set by the touch deadline when `drag_start_msg`
|
/// `app.on_drop`. Set by the touch deadline when `drag_start_msg`
|
||||||
/// is present, or by the mouse-motion promotion path.
|
/// is present, or by the mouse-motion promotion path.
|
||||||
pub long_press_fired: bool,
|
pub long_press_fired: bool,
|
||||||
|
/// Accumulated downward overscroll (physical px) while a vertical
|
||||||
|
/// scroll is pinned at its top — built up by [`Self::on_move`] when a
|
||||||
|
/// pull-down can no longer scroll content, unwound by upward drag, and
|
||||||
|
/// drained on release into an `OverscrollDown` event. Drives
|
||||||
|
/// pull-from-top-to-dismiss.
|
||||||
|
pub overscroll_y: f32,
|
||||||
/// `true` when the press came from a mouse (`wl_pointer`) rather
|
/// `true` when the press came from a mouse (`wl_pointer`) rather
|
||||||
/// than touch. The 6 px stray-cancel in [`Self::on_move`] is gated
|
/// than touch. The 6 px stray-cancel in [`Self::on_move`] is gated
|
||||||
/// on this: touch needs the cancel so a hold that turns into a
|
/// on this: touch needs the cancel so a hold that turns into a
|
||||||
@@ -168,6 +174,7 @@ impl<Msg: Clone> GestureState<Msg>
|
|||||||
drag_start_msg: None,
|
drag_start_msg: None,
|
||||||
long_press_text_idx: None,
|
long_press_text_idx: None,
|
||||||
long_press_fired: false,
|
long_press_fired: false,
|
||||||
|
overscroll_y: 0.0,
|
||||||
mouse_press: false,
|
mouse_press: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -204,6 +211,7 @@ impl<Msg: Clone> GestureState<Msg>
|
|||||||
self.scrolling_widget = self.scroll_candidates.first().copied();
|
self.scrolling_widget = self.scroll_candidates.first().copied();
|
||||||
self.scroll_locked = false;
|
self.scroll_locked = false;
|
||||||
self.scroll_drag_started = false;
|
self.scroll_drag_started = false;
|
||||||
|
self.overscroll_y = 0.0;
|
||||||
self.horizontal_drag_started = false;
|
self.horizontal_drag_started = false;
|
||||||
self.vertical_drag_started = false;
|
self.vertical_drag_started = false;
|
||||||
self.swipe_axis = None;
|
self.swipe_axis = None;
|
||||||
@@ -341,12 +349,43 @@ impl<Msg: Clone> GestureState<Msg>
|
|||||||
let ( scroll_idx, axis ) = self.scrolling_widget.unwrap();
|
let ( scroll_idx, axis ) = self.scrolling_widget.unwrap();
|
||||||
let entry = scroll_offsets.entry( scroll_idx ).or_insert( ( 0.0, 0.0 ) );
|
let entry = scroll_offsets.entry( scroll_idx ).or_insert( ( 0.0, 0.0 ) );
|
||||||
if axis.allows_x() { entry.0 = ( entry.0 - dx ).max( 0.0 ); }
|
if axis.allows_x() { entry.0 = ( entry.0 - dx ).max( 0.0 ); }
|
||||||
if axis.allows_y() { entry.1 = ( entry.1 - dy ).max( 0.0 ); }
|
if axis.allows_y()
|
||||||
|
{
|
||||||
|
// Split the vertical delta between content scroll and top
|
||||||
|
// overscroll: a downward pull first scrolls toward the top,
|
||||||
|
// then any excess (the offset can't go below 0) feeds
|
||||||
|
// `overscroll_y`; an upward pull unwinds that overscroll
|
||||||
|
// before it resumes scrolling content.
|
||||||
|
if dy >= 0.0
|
||||||
|
{
|
||||||
|
let consume = dy.min( entry.1 );
|
||||||
|
entry.1 -= consume;
|
||||||
|
self.overscroll_y += dy - consume;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
let up = -dy;
|
||||||
|
let consume = up.min( self.overscroll_y );
|
||||||
|
self.overscroll_y -= consume;
|
||||||
|
entry.1 += up - consume;
|
||||||
|
}
|
||||||
|
}
|
||||||
let moved = if axis.allows_x() && axis.allows_y() { dx.hypot( dy ) }
|
let moved = if axis.allows_x() && axis.allows_y() { dx.hypot( dy ) }
|
||||||
else if axis.allows_x() { dx.abs() }
|
else if axis.allows_x() { dx.abs() }
|
||||||
else { dy.abs() };
|
else { dy.abs() };
|
||||||
if moved > 8.0 { self.scroll_drag_started = true; }
|
if moved > 8.0 { self.scroll_drag_started = true; }
|
||||||
self.start = Some( pos );
|
self.start = Some( pos );
|
||||||
|
// Gate the dismiss on the same 8 px arm as a real scroll drag so
|
||||||
|
// tap jitter at the top can't engage a follow-the-finger close.
|
||||||
|
if self.overscroll_y > 0.0 && axis.allows_y() && self.scroll_drag_started
|
||||||
|
{
|
||||||
|
let progress = if swipe.surface_height > 0
|
||||||
|
{
|
||||||
|
self.overscroll_y / swipe.surface_height as f32
|
||||||
|
}
|
||||||
|
else { 0.0 };
|
||||||
|
return MoveOutcome::OverscrollDown { progress };
|
||||||
|
}
|
||||||
return MoveOutcome::Scroll;
|
return MoveOutcome::Scroll;
|
||||||
}
|
}
|
||||||
return MoveOutcome::Idle;
|
return MoveOutcome::Idle;
|
||||||
@@ -367,7 +406,6 @@ impl<Msg: Clone> GestureState<Msg>
|
|||||||
self.swipe_axis = Some( if dy.abs() >= dx.abs() { SwipeAxis::Vertical } else { SwipeAxis::Horizontal } );
|
self.swipe_axis = Some( if dy.abs() >= dx.abs() { SwipeAxis::Vertical } else { SwipeAxis::Horizontal } );
|
||||||
}
|
}
|
||||||
let vertical_allowed = self.swipe_axis != Some( SwipeAxis::Horizontal );
|
let vertical_allowed = self.swipe_axis != Some( SwipeAxis::Horizontal );
|
||||||
let horizontal_allowed = self.swipe_axis != Some( SwipeAxis::Vertical );
|
|
||||||
|
|
||||||
let ( up, down ) = if vertical_allowed && swipe.surface_height > 0
|
let ( up, down ) = if vertical_allowed && swipe.surface_height > 0
|
||||||
{
|
{
|
||||||
@@ -392,11 +430,18 @@ impl<Msg: Clone> GestureState<Msg>
|
|||||||
}
|
}
|
||||||
else { ( None, None ) };
|
else { ( None, None ) };
|
||||||
|
|
||||||
let horizontal = if horizontal_allowed && swipe.surface_width > 0
|
// Only drive the horizontal pager once the gesture has LOCKED onto the
|
||||||
|
// horizontal axis. Emitting progress while `swipe_axis` is still None
|
||||||
|
// would activate the pager from the sub-8 px lateral jitter at the
|
||||||
|
// start of a vertical swipe; `horizontal_drag_started` would then never
|
||||||
|
// trip, so the release emits no horizontal event and the pager stays
|
||||||
|
// stuck active (frozen homescreen). Locking to Horizontal implies
|
||||||
|
// `dx.abs() > 8.0`, so the drag is unambiguously started here.
|
||||||
|
let horizontal = if self.swipe_axis == Some( SwipeAxis::Horizontal ) && swipe.surface_width > 0
|
||||||
{
|
{
|
||||||
let h_thr = swipe.surface_width as f32 * swipe.horizontal_thresh;
|
let h_thr = swipe.surface_width as f32 * swipe.horizontal_thresh;
|
||||||
let h_progress = if h_thr > 0.0 { dx / h_thr } else { 0.0 };
|
let h_progress = if h_thr > 0.0 { dx / h_thr } else { 0.0 };
|
||||||
if dx.abs() > 8.0 { self.horizontal_drag_started = true; }
|
self.horizontal_drag_started = true;
|
||||||
Some( h_progress )
|
Some( h_progress )
|
||||||
}
|
}
|
||||||
else { None };
|
else { None };
|
||||||
@@ -474,6 +519,15 @@ impl<Msg: Clone> GestureState<Msg>
|
|||||||
if scroll_consumed
|
if scroll_consumed
|
||||||
{
|
{
|
||||||
self.start = None;
|
self.start = None;
|
||||||
|
// A pull past the top releases into an overscroll-dismiss event;
|
||||||
|
// the app decides whether the accumulated pull committed.
|
||||||
|
let over = self.overscroll_y;
|
||||||
|
self.overscroll_y = 0.0;
|
||||||
|
if over > 0.0
|
||||||
|
{
|
||||||
|
let threshold = swipe.surface_height as f32 * swipe.overscroll_thresh;
|
||||||
|
events.push( ReleaseEvent::OverscrollDown { committed: over >= threshold } );
|
||||||
|
}
|
||||||
return events;
|
return events;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ pub enum MoveOutcome<Msg>
|
|||||||
Slider { msg: Option<Msg> },
|
Slider { msg: Option<Msg> },
|
||||||
/// Scroll viewport offset updated. Handler just requests a redraw.
|
/// Scroll viewport offset updated. Handler just requests a redraw.
|
||||||
Scroll,
|
Scroll,
|
||||||
|
/// Vertical scroll pinned at the top while the finger keeps pulling
|
||||||
|
/// down: `progress` is the accumulated overscroll as a fraction of
|
||||||
|
/// surface height. Handler fires `app.on_overscroll_down_progress`.
|
||||||
|
OverscrollDown { progress: f32 },
|
||||||
/// Swipe progress. Each axis is `Some(value)` when it has a valid
|
/// Swipe progress. Each axis is `Some(value)` when it has a valid
|
||||||
/// progress reading for this move (not every motion updates all
|
/// progress reading for this move (not every motion updates all
|
||||||
/// axes). Handler fires the matching app callback.
|
/// axes). Handler fires the matching app callback.
|
||||||
@@ -50,6 +54,10 @@ pub enum ReleaseEvent<Msg>
|
|||||||
SwipeUp,
|
SwipeUp,
|
||||||
/// Vertical swipe committed down.
|
/// Vertical swipe committed down.
|
||||||
SwipeDown,
|
SwipeDown,
|
||||||
|
/// Released after a top overscroll pull-down — fire
|
||||||
|
/// `app.on_overscroll_down(committed)`. `committed` is set when the
|
||||||
|
/// pull passed `overscroll_thresh` of surface height.
|
||||||
|
OverscrollDown { committed: bool },
|
||||||
/// Horizontal swipe did not commit — fire
|
/// Horizontal swipe did not commit — fire
|
||||||
/// `app.on_swipe_horizontal_progress(0.0)` + main redraw.
|
/// `app.on_swipe_horizontal_progress(0.0)` + main redraw.
|
||||||
HorizontalFellThrough,
|
HorizontalFellThrough,
|
||||||
@@ -79,6 +87,9 @@ pub struct SwipeConfig
|
|||||||
pub down_edge: f32,
|
pub down_edge: f32,
|
||||||
/// Horizontal commit fraction of surface width.
|
/// Horizontal commit fraction of surface width.
|
||||||
pub horizontal_thresh: f32,
|
pub horizontal_thresh: f32,
|
||||||
|
/// Top-overscroll dismiss commit fraction of surface height — the
|
||||||
|
/// pull-down past a scroll's top must exceed this to commit.
|
||||||
|
pub overscroll_thresh: f32,
|
||||||
pub surface_width: u32,
|
pub surface_width: u32,
|
||||||
pub surface_height: u32,
|
pub surface_height: u32,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ fn cfg_full( w: u32, h: u32 ) -> SwipeConfig
|
|||||||
down_thresh: 0.30,
|
down_thresh: 0.30,
|
||||||
down_edge: 0.10,
|
down_edge: 0.10,
|
||||||
horizontal_thresh: 0.30,
|
horizontal_thresh: 0.30,
|
||||||
|
overscroll_thresh: 0.25,
|
||||||
surface_width: w,
|
surface_width: w,
|
||||||
surface_height: h,
|
surface_height: h,
|
||||||
}
|
}
|
||||||
@@ -269,6 +270,76 @@ fn move_below_eight_pixels_does_not_arm_scroll_drag()
|
|||||||
assert!( !g.scroll_drag_started );
|
assert!( !g.scroll_drag_started );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── on_move / on_release: top overscroll dismiss ──────────────────────────
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn pull_down_at_top_emits_overscroll_progress()
|
||||||
|
{
|
||||||
|
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
|
||||||
|
let scrolls: [( Rect, usize, ScrollAxis ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 3, ScrollAxis::Vertical ) ];
|
||||||
|
let mut g = GestureState::<Msg>::new();
|
||||||
|
let _ = g.on_press( pt( 100.0, 100.0 ), &widgets, &scrolls );
|
||||||
|
let mut offsets = HashMap::new();
|
||||||
|
// Already at the top; drag down 120 px → all of it is overscroll and the
|
||||||
|
// content offset stays pinned at 0.
|
||||||
|
let out = g.on_move( pt( 100.0, 220.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
|
||||||
|
assert_eq!( offsets.get( &3 ).copied(), Some( ( 0.0, 0.0 ) ) );
|
||||||
|
match out
|
||||||
|
{
|
||||||
|
MoveOutcome::OverscrollDown { progress } =>
|
||||||
|
assert!( ( progress - 120.0 / 1200.0 ).abs() < 1e-4 ),
|
||||||
|
_ => panic!( "expected OverscrollDown" ),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn upward_drag_unwinds_overscroll_before_scrolling_content()
|
||||||
|
{
|
||||||
|
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
|
||||||
|
let scrolls: [( Rect, usize, ScrollAxis ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 4, ScrollAxis::Vertical ) ];
|
||||||
|
let mut g = GestureState::<Msg>::new();
|
||||||
|
let _ = g.on_press( pt( 100.0, 100.0 ), &widgets, &scrolls );
|
||||||
|
let mut offsets = HashMap::new();
|
||||||
|
let _ = g.on_move( pt( 100.0, 200.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
|
||||||
|
assert!( ( g.overscroll_y - 100.0 ).abs() < 1e-4 );
|
||||||
|
// Drag back up 60 px → unwinds 60 px of overscroll, content stays at top.
|
||||||
|
let _ = g.on_move( pt( 100.0, 140.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
|
||||||
|
assert!( ( g.overscroll_y - 40.0 ).abs() < 1e-4 );
|
||||||
|
assert_eq!( offsets.get( &4 ).map( |o| o.1 ), Some( 0.0 ) );
|
||||||
|
// A further 90 px up unwinds the remaining 40 and scrolls the 50 px excess.
|
||||||
|
let _ = g.on_move( pt( 100.0, 50.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
|
||||||
|
assert_eq!( g.overscroll_y, 0.0 );
|
||||||
|
assert_eq!( offsets.get( &4 ).map( |o| o.1 ), Some( 50.0 ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn release_after_overscroll_past_threshold_commits_dismiss()
|
||||||
|
{
|
||||||
|
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
|
||||||
|
let scrolls: [( Rect, usize, ScrollAxis ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 5, ScrollAxis::Vertical ) ];
|
||||||
|
let mut g = GestureState::<Msg>::new();
|
||||||
|
let _ = g.on_press( pt( 100.0, 100.0 ), &widgets, &scrolls );
|
||||||
|
let mut offsets = HashMap::new();
|
||||||
|
// cfg_full overscroll_thresh is 0.25 → 300 px of 1200 commits; drag 400 px.
|
||||||
|
let _ = g.on_move( pt( 100.0, 500.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
|
||||||
|
let events = g.on_release( pt( 100.0, 500.0 ), &widgets, &cfg_full( 800, 1200 ), false );
|
||||||
|
assert!( events.iter().any( |e| matches!( e, ReleaseEvent::OverscrollDown { committed: true } ) ) );
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn release_after_short_overscroll_does_not_commit()
|
||||||
|
{
|
||||||
|
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
|
||||||
|
let scrolls: [( Rect, usize, ScrollAxis ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 6, ScrollAxis::Vertical ) ];
|
||||||
|
let mut g = GestureState::<Msg>::new();
|
||||||
|
let _ = g.on_press( pt( 100.0, 100.0 ), &widgets, &scrolls );
|
||||||
|
let mut offsets = HashMap::new();
|
||||||
|
// 100 px is below the 300 px commit threshold.
|
||||||
|
let _ = g.on_move( pt( 100.0, 200.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
|
||||||
|
let events = g.on_release( pt( 100.0, 200.0 ), &widgets, &cfg_full( 800, 1200 ), false );
|
||||||
|
assert!( events.iter().any( |e| matches!( e, ReleaseEvent::OverscrollDown { committed: false } ) ) );
|
||||||
|
}
|
||||||
|
|
||||||
// ── on_move: swipe progress ───────────────────────────────────────────────
|
// ── on_move: swipe progress ───────────────────────────────────────────────
|
||||||
|
|
||||||
#[ test ]
|
#[ test ]
|
||||||
@@ -338,6 +409,23 @@ fn horizontal_lock_suppresses_later_vertical_drift()
|
|||||||
assert!( matches!( out, MoveOutcome::Swipe { up: None, down: None, .. } ) );
|
assert!( matches!( out, MoveOutcome::Swipe { up: None, down: None, .. } ) );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[ test ]
|
||||||
|
fn pre_lock_lateral_drift_does_not_drive_horizontal_pager()
|
||||||
|
{
|
||||||
|
// Regression: a vertical swipe that opens with a few px of lateral drift
|
||||||
|
// (axis not yet locked) must not emit horizontal progress. It used to, which
|
||||||
|
// armed the pager without ever tripping `horizontal_drag_started` — so the
|
||||||
|
// release sent no horizontal event and the pager stayed stuck active,
|
||||||
|
// freezing the homescreen.
|
||||||
|
let widgets: Vec<LaidOutWidget<Msg>> = vec![];
|
||||||
|
let mut g = GestureState::<Msg>::new();
|
||||||
|
g.start = Some( pt( 100.0, 600.0 ) );
|
||||||
|
let mut offsets = HashMap::new();
|
||||||
|
let out = g.on_move( pt( 103.0, 595.0 ), &widgets, &mut offsets, &cfg_full( 800, 1200 ), false );
|
||||||
|
assert_eq!( g.swipe_axis, None );
|
||||||
|
assert!( matches!( out, MoveOutcome::Swipe { horizontal: None, .. } ) );
|
||||||
|
}
|
||||||
|
|
||||||
#[ test ]
|
#[ test ]
|
||||||
fn move_up_progress_unclamped_past_unit_interval()
|
fn move_up_progress_unclamped_past_unit_interval()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ impl<A: App> AppData<A>
|
|||||||
down_thresh: self.app.swipe_down_threshold(),
|
down_thresh: self.app.swipe_down_threshold(),
|
||||||
down_edge: self.app.swipe_down_edge(),
|
down_edge: self.app.swipe_down_edge(),
|
||||||
horizontal_thresh: self.app.swipe_horizontal_threshold(),
|
horizontal_thresh: self.app.swipe_horizontal_threshold(),
|
||||||
|
overscroll_thresh: self.app.overscroll_dismiss_threshold(),
|
||||||
surface_width: ss.physical_width(),
|
surface_width: ss.physical_width(),
|
||||||
surface_height: ss.physical_height(),
|
surface_height: ss.physical_height(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,8 +52,10 @@ pub enum VAlign
|
|||||||
pub struct Stack<Msg: Clone>
|
pub struct Stack<Msg: Clone>
|
||||||
{
|
{
|
||||||
/// Children with their alignment, margin, and extra `(x, y)` translation
|
/// Children with their alignment, margin, and extra `(x, y)` translation
|
||||||
/// applied after alignment. Drawn in order — last child is on top.
|
/// applied after alignment. Drawn in order — last child is on top. The final
|
||||||
pub children: Vec<( Element<Msg>, HAlign, VAlign, f32, f32, f32 )>,
|
/// `Option<Rect>` overrides alignment/sizing with an exact rect (see
|
||||||
|
/// [`push_placed`](Self::push_placed)).
|
||||||
|
pub children: Vec<( Element<Msg>, HAlign, VAlign, f32, f32, f32, Option<Rect> )>,
|
||||||
/// When `true`, [`preferred_size`](Self::preferred_size) reports the max of
|
/// When `true`, [`preferred_size`](Self::preferred_size) reports the max of
|
||||||
/// children's intrinsic widths and heights instead of `(max_width, tallest)`.
|
/// children's intrinsic widths and heights instead of `(max_width, tallest)`.
|
||||||
pub fit_content: bool,
|
pub fit_content: bool,
|
||||||
@@ -100,7 +102,7 @@ impl<Msg: Clone> Stack<Msg>
|
|||||||
margin: f32,
|
margin: f32,
|
||||||
) -> Self
|
) -> Self
|
||||||
{
|
{
|
||||||
self.children.push( ( e.into(), h_align, v_align, margin, 0.0, 0.0 ) );
|
self.children.push( ( e.into(), h_align, v_align, margin, 0.0, 0.0, None ) );
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +119,21 @@ impl<Msg: Clone> Stack<Msg>
|
|||||||
offset_y: f32,
|
offset_y: f32,
|
||||||
) -> Self
|
) -> Self
|
||||||
{
|
{
|
||||||
self.children.push( ( e.into(), h_align, v_align, 0.0, offset_x, offset_y ) );
|
self.children.push( ( e.into(), h_align, v_align, 0.0, offset_x, offset_y, None ) );
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append a child placed at an exact `rect` (in the Stack's coordinate space),
|
||||||
|
/// bypassing alignment and intrinsic sizing. This is what hosts a view tree
|
||||||
|
/// whose geometry is computed elsewhere — e.g. Android's measure/layout pass,
|
||||||
|
/// which yields absolute rects per view.
|
||||||
|
pub fn push_placed(
|
||||||
|
mut self,
|
||||||
|
e: impl Into<Element<Msg>>,
|
||||||
|
rect: Rect,
|
||||||
|
) -> Self
|
||||||
|
{
|
||||||
|
self.children.push( ( e.into(), HAlign::Start, VAlign::Top, 0.0, 0.0, 0.0, Some( rect ) ) );
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +142,7 @@ impl<Msg: Clone> Stack<Msg>
|
|||||||
if self.fit_content
|
if self.fit_content
|
||||||
{
|
{
|
||||||
let content_w = self.children.iter()
|
let content_w = self.children.iter()
|
||||||
.map( |( c, _, _, _, _, _ )| match c
|
.map( |( c, _, _, _, _, _, _ )| match c
|
||||||
{
|
{
|
||||||
Element::Spacer( s ) => s.resolved_width( canvas ).unwrap_or( 0.0 ),
|
Element::Spacer( s ) => s.resolved_width( canvas ).unwrap_or( 0.0 ),
|
||||||
Element::Separator( _ ) => 0.0,
|
Element::Separator( _ ) => 0.0,
|
||||||
@@ -141,13 +157,13 @@ impl<Msg: Clone> Stack<Msg>
|
|||||||
} )
|
} )
|
||||||
.fold( 0.0_f32, f32::max );
|
.fold( 0.0_f32, f32::max );
|
||||||
let max_h = self.children.iter()
|
let max_h = self.children.iter()
|
||||||
.map( |( c, _, _, _, _, _ )| c.preferred_size( max_width, canvas ).1 )
|
.map( |( c, _, _, _, _, _, _ )| c.preferred_size( max_width, canvas ).1 )
|
||||||
.fold( 0.0_f32, f32::max );
|
.fold( 0.0_f32, f32::max );
|
||||||
return ( content_w.min( max_width ), max_h );
|
return ( content_w.min( max_width ), max_h );
|
||||||
}
|
}
|
||||||
|
|
||||||
let max_h = self.children.iter()
|
let max_h = self.children.iter()
|
||||||
.map( |( c, _, _, _, _, _ )| c.preferred_size( max_width, canvas ).1 )
|
.map( |( c, _, _, _, _, _, _ )| c.preferred_size( max_width, canvas ).1 )
|
||||||
.fold( 0.0_f32, f32::max );
|
.fold( 0.0_f32, f32::max );
|
||||||
( max_width, max_h )
|
( max_width, max_h )
|
||||||
}
|
}
|
||||||
@@ -155,8 +171,13 @@ impl<Msg: Clone> Stack<Msg>
|
|||||||
/// Return `(rect, child_index)` pairs, computing each child's rect from its alignment.
|
/// Return `(rect, child_index)` pairs, computing each child's rect from its alignment.
|
||||||
pub fn layout( &self, rect: Rect, canvas: &Canvas ) -> Vec<(Rect, usize)>
|
pub fn layout( &self, rect: Rect, canvas: &Canvas ) -> Vec<(Rect, usize)>
|
||||||
{
|
{
|
||||||
self.children.iter().enumerate().map( |( i, ( child, h_align, v_align, margin, ox, oy ) )|
|
self.children.iter().enumerate().map( |( i, ( child, h_align, v_align, margin, ox, oy, placed ) )|
|
||||||
{
|
{
|
||||||
|
if let Some( p ) = placed
|
||||||
|
{
|
||||||
|
return ( Rect { x: rect.x + p.x, y: rect.y + p.y, width: p.width, height: p.height }, i );
|
||||||
|
}
|
||||||
|
|
||||||
let inner_w = ( rect.width - margin * 2.0 ).max( 0.0 );
|
let inner_w = ( rect.width - margin * 2.0 ).max( 0.0 );
|
||||||
let inner_h = ( rect.height - margin * 2.0 ).max( 0.0 );
|
let inner_h = ( rect.height - margin * 2.0 ).max( 0.0 );
|
||||||
let ( pref_w, pref_h ) = child.preferred_size( inner_w, canvas );
|
let ( pref_w, pref_h ) = child.preferred_size( inner_w, canvas );
|
||||||
@@ -192,8 +213,8 @@ impl<Msg: Clone> Stack<Msg>
|
|||||||
Stack
|
Stack
|
||||||
{
|
{
|
||||||
children: self.children.into_iter()
|
children: self.children.into_iter()
|
||||||
.map( |( child, ha, va, margin, ox, oy )|
|
.map( |( child, ha, va, margin, ox, oy, placed )|
|
||||||
( child.map_arc( f ), ha, va, margin, ox, oy ) )
|
( child.map_arc( f ), ha, va, margin, ox, oy, placed ) )
|
||||||
.collect(),
|
.collect(),
|
||||||
fit_content: self.fit_content,
|
fit_content: self.fit_content,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -240,11 +240,13 @@ pub use theme::branding_image as theme_branding_image;
|
|||||||
pub use theme::{ decode_svg_bytes, icon_path as theme_icon_path, icon_rgba as theme_icon_rgba };
|
pub use theme::{ decode_svg_bytes, icon_path as theme_icon_path, icon_rgba as theme_icon_rgba };
|
||||||
pub use gles_render::{ BorrowedGlesTexture, GlesVersion };
|
pub use gles_render::{ BorrowedGlesTexture, GlesVersion };
|
||||||
pub use render::is_software_render;
|
pub use render::is_software_render;
|
||||||
|
pub use render::Canvas;
|
||||||
pub use wallpaper::{ WallpaperBundle, ImageData };
|
pub use wallpaper::{ WallpaperBundle, ImageData };
|
||||||
pub use chassis::{ set_default_theme, theme_logo_rgba, theme_icon_tinted, branding_bundle_or_solid, wallpaper_bundle_or_solid, backdrop };
|
pub use chassis::{ set_default_theme, theme_logo_rgba, theme_icon_tinted, branding_bundle_or_solid, wallpaper_bundle_or_solid, backdrop };
|
||||||
pub use types::{ Color, Corners, CursorShape, Length, LengthBase, Point, Rect, Size, WidgetId };
|
pub use types::{ Color, Corners, CursorShape, Length, LengthBase, PathCmd, Point, Rect, Size, WidgetId };
|
||||||
pub use types::{ design_reference, set_design_reference };
|
pub use types::{ design_reference, set_design_reference };
|
||||||
pub use widget::{ Element, button, icon_button, text_edit, image as img_widget, text, container };
|
pub use widget::{ Element, button, icon_button, text_edit, image as img_widget, text, container };
|
||||||
|
pub use widget::rich_text::{ rich_text, RichText, LinkSpan };
|
||||||
pub use widget::button::ButtonVariant;
|
pub use widget::button::ButtonVariant;
|
||||||
pub use widget::slider::{ Slider, slider, SliderAxis };
|
pub use widget::slider::{ Slider, slider, SliderAxis };
|
||||||
pub use widget::vslider::{ VSlider, vslider };
|
pub use widget::vslider::{ VSlider, vslider };
|
||||||
|
|||||||
@@ -6,12 +6,31 @@
|
|||||||
|
|
||||||
use tiny_skia::{ Path, PathBuilder };
|
use tiny_skia::{ Path, PathBuilder };
|
||||||
|
|
||||||
use crate::types::Corners;
|
use crate::types::{ Corners, PathCmd };
|
||||||
|
|
||||||
/// Cubic bezier control-point factor for a quarter-circle approximation
|
/// Cubic bezier control-point factor for a quarter-circle approximation
|
||||||
/// (`(4/3) * (sqrt(2) - 1) ≈ 0.5523`).
|
/// (`(4/3) * (sqrt(2) - 1) ≈ 0.5523`).
|
||||||
const KAPPA: f32 = 0.5523_f32;
|
const KAPPA: f32 = 0.5523_f32;
|
||||||
|
|
||||||
|
/// Build a tiny-skia path from a list of [`PathCmd`]s. Shared by both backends'
|
||||||
|
/// `fill_path` / `stroke_path`. Returns None for an empty or degenerate path.
|
||||||
|
pub ( crate ) fn build_ts_path( cmds: &[ PathCmd ] ) -> Option<Path>
|
||||||
|
{
|
||||||
|
let mut pb = PathBuilder::new();
|
||||||
|
for cmd in cmds
|
||||||
|
{
|
||||||
|
match *cmd
|
||||||
|
{
|
||||||
|
PathCmd::MoveTo( x, y ) => pb.move_to( x, y ),
|
||||||
|
PathCmd::LineTo( x, y ) => pb.line_to( x, y ),
|
||||||
|
PathCmd::QuadTo( x1, y1, x, y ) => pb.quad_to( x1, y1, x, y ),
|
||||||
|
PathCmd::CubicTo( x1, y1, x2, y2, x, y ) => pb.cubic_to( x1, y1, x2, y2, x, y ),
|
||||||
|
PathCmd::Close => pb.close(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pb.finish()
|
||||||
|
}
|
||||||
|
|
||||||
/// Build a rounded rectangle path with independent per-corner radii
|
/// Build a rounded rectangle path with independent per-corner radii
|
||||||
/// using cubic bezier curves. Each corner is clamped against the
|
/// using cubic bezier curves. Each corner is clamped against the
|
||||||
/// inscribed-circle limit `min(width, height) / 2` before drawing,
|
/// inscribed-circle limit `min(width, height) / 2` before drawing,
|
||||||
|
|||||||
@@ -326,6 +326,9 @@ impl Canvas
|
|||||||
/// Install a theme font registry on the active backend.
|
/// Install a theme font registry on the active backend.
|
||||||
pub fn set_font_registry( &mut self, registry: Arc<FontRegistry> )
|
pub fn set_font_registry( &mut self, registry: Arc<FontRegistry> )
|
||||||
{
|
{
|
||||||
|
// Drop the shaped-line cache: a new registry can swap the faces the
|
||||||
|
// resolver leads with, so cached glyph runs may no longer match.
|
||||||
|
crate::text_shaping::clear_shape_cache();
|
||||||
match self
|
match self
|
||||||
{
|
{
|
||||||
Canvas::Software( c ) => c.set_font_registry( registry ),
|
Canvas::Software( c ) => c.set_font_registry( registry ),
|
||||||
@@ -518,6 +521,29 @@ impl Canvas
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fill an arbitrary vector path (commands in surface coordinates) with a
|
||||||
|
/// solid colour. The software backend rasterises with tiny-skia directly;
|
||||||
|
/// the GLES backend rasterises into a tiny-skia pixmap and blits it (the
|
||||||
|
/// CPU fallback — no path shader on the GPU path).
|
||||||
|
pub fn fill_path( &mut self, cmds: &[ crate::types::PathCmd ], color: Color )
|
||||||
|
{
|
||||||
|
match self
|
||||||
|
{
|
||||||
|
Canvas::Software( c ) => c.fill_path( cmds, color ),
|
||||||
|
Canvas::Gles( c ) => c.fill_path( cmds, color ),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stroke an arbitrary vector path (commands in surface coordinates).
|
||||||
|
pub fn stroke_path( &mut self, cmds: &[ crate::types::PathCmd ], color: Color, width: f32 )
|
||||||
|
{
|
||||||
|
match self
|
||||||
|
{
|
||||||
|
Canvas::Software( c ) => c.stroke_path( cmds, color, width ),
|
||||||
|
Canvas::Gles( c ) => c.stroke_path( cmds, color, width ),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Paint an outer drop shadow behind the rounded rect `target`.
|
/// Paint an outer drop shadow behind the rounded rect `target`.
|
||||||
///
|
///
|
||||||
/// On the GPU backend this runs an analytic soft-shadow shader
|
/// On the GPU backend this runs an analytic soft-shadow shader
|
||||||
|
|||||||
@@ -9,9 +9,9 @@
|
|||||||
|
|
||||||
use tiny_skia::{ Paint, PathBuilder, Stroke, Transform };
|
use tiny_skia::{ Paint, PathBuilder, Stroke, Transform };
|
||||||
|
|
||||||
use crate::types::{ Color, Corners, Rect };
|
use crate::types::{ Color, Corners, PathCmd, Rect };
|
||||||
|
|
||||||
use super::helpers::build_rounded_rect;
|
use super::helpers::{ build_rounded_rect, build_ts_path };
|
||||||
use super::SoftwareCanvas;
|
use super::SoftwareCanvas;
|
||||||
|
|
||||||
impl SoftwareCanvas
|
impl SoftwareCanvas
|
||||||
@@ -100,4 +100,24 @@ impl SoftwareCanvas
|
|||||||
stroke.width = width;
|
stroke.width = width;
|
||||||
self.pixmap.stroke_path( &path, &paint, &stroke, Transform::identity(), self.clip_mask.as_ref() );
|
self.pixmap.stroke_path( &path, &paint, &stroke, Transform::identity(), self.clip_mask.as_ref() );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn fill_path( &mut self, cmds: &[ PathCmd ], color: Color )
|
||||||
|
{
|
||||||
|
let Some( path ) = build_ts_path( cmds ) else { return };
|
||||||
|
let mut paint = Paint::default();
|
||||||
|
paint.set_color( Color::rgba( color.r, color.g, color.b, color.a * self.global_alpha ).to_tiny_skia() );
|
||||||
|
paint.anti_alias = true;
|
||||||
|
self.pixmap.fill_path( &path, &paint, tiny_skia::FillRule::Winding, Transform::identity(), self.clip_mask.as_ref() );
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stroke_path( &mut self, cmds: &[ PathCmd ], color: Color, width: f32 )
|
||||||
|
{
|
||||||
|
let Some( path ) = build_ts_path( cmds ) else { return };
|
||||||
|
let mut paint = Paint::default();
|
||||||
|
paint.set_color( Color::rgba( color.r, color.g, color.b, color.a * self.global_alpha ).to_tiny_skia() );
|
||||||
|
paint.anti_alias = true;
|
||||||
|
let mut stroke = Stroke::default();
|
||||||
|
stroke.width = width;
|
||||||
|
self.pixmap.stroke_path( &path, &paint, &stroke, Transform::identity(), self.clip_mask.as_ref() );
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,7 +115,9 @@ impl SoftwareCanvas
|
|||||||
// refused the font, missing bytes for the preferred face)
|
// refused the font, missing bytes for the preferred face)
|
||||||
// falls through to no-op — we'd rather not paint than risk
|
// falls through to no-op — we'd rather not paint than risk
|
||||||
// a corrupted line.
|
// a corrupted line.
|
||||||
let shaped = crate::text_shaping::shape_line( text, scaled, resolve );
|
let shaped = crate::text_shaping::shape_line_cached(
|
||||||
|
text, scaled, Arc::as_ptr( &canvas_handle.font ) as usize, resolve,
|
||||||
|
);
|
||||||
if shaped.is_empty() { return; }
|
if shaped.is_empty() { return; }
|
||||||
|
|
||||||
// Resolve every glyph's font into an `Arc<Font>` for
|
// Resolve every glyph's font into an `Arc<Font>` for
|
||||||
@@ -128,7 +130,7 @@ impl SoftwareCanvas
|
|||||||
{
|
{
|
||||||
fonts.push( ( primary_id, Arc::clone( &canvas_handle.font ) ) );
|
fonts.push( ( primary_id, Arc::clone( &canvas_handle.font ) ) );
|
||||||
}
|
}
|
||||||
for g in &shaped
|
for g in shaped.iter()
|
||||||
{
|
{
|
||||||
if fonts.iter().any( |( id, _ )| *id == g.font_id ) { continue; }
|
if fonts.iter().any( |( id, _ )| *id == g.font_id ) { continue; }
|
||||||
// Walk the fallback chain to find an Arc<Font> with this
|
// Walk the fallback chain to find an Arc<Font> with this
|
||||||
@@ -155,7 +157,7 @@ impl SoftwareCanvas
|
|||||||
let mut layout: Vec<( GlyphKey, f32, f32 )> = Vec::with_capacity( shaped.len() );
|
let mut layout: Vec<( GlyphKey, f32, f32 )> = Vec::with_capacity( shaped.len() );
|
||||||
{
|
{
|
||||||
let mut cursor_x = x;
|
let mut cursor_x = x;
|
||||||
for g in &shaped
|
for g in shaped.iter()
|
||||||
{
|
{
|
||||||
let Some( ( _, font_arc ) ) = fonts.iter().find( |( id, _ )| *id == g.font_id ) else
|
let Some( ( _, font_arc ) ) = fonts.iter().find( |( id, _ )| *id == g.font_id ) else
|
||||||
{
|
{
|
||||||
@@ -251,7 +253,9 @@ impl SoftwareCanvas
|
|||||||
if !canvas_handle.bytes.is_empty() { Some( canvas_handle.clone() ) } else { None }
|
if !canvas_handle.bytes.is_empty() { Some( canvas_handle.clone() ) } else { None }
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
let shaped = crate::text_shaping::shape_line( text, scaled, resolve );
|
let shaped = crate::text_shaping::shape_line_cached(
|
||||||
|
text, scaled, Arc::as_ptr( &canvas_handle.font ) as usize, resolve,
|
||||||
|
);
|
||||||
if shaped.is_empty()
|
if shaped.is_empty()
|
||||||
{
|
{
|
||||||
// Fallback: rustybuzz could not shape (no bytes for the
|
// Fallback: rustybuzz could not shape (no bytes for the
|
||||||
|
|||||||
@@ -126,6 +126,56 @@ where
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
thread_local!
|
||||||
|
{
|
||||||
|
/// Shaped-line cache. [`shape_line`] re-parses the font face
|
||||||
|
/// (`rustybuzz::Face::from_slice`) and re-runs HarfBuzz on every call;
|
||||||
|
/// during an animation the same labels are shaped every frame, at both
|
||||||
|
/// measure and draw time. Caching the visual-order glyph run by
|
||||||
|
/// `(font context, pixel size, text)` makes those repeats free. Keyed by
|
||||||
|
/// the resolver's leading font (its `Arc` pointer) since the output
|
||||||
|
/// depends on which face leads; the system fallback chain is
|
||||||
|
/// process-global and stable, so it need not be in the key.
|
||||||
|
static SHAPE_CACHE: std::cell::RefCell<
|
||||||
|
std::collections::HashMap<( usize, u32, String ), std::sync::Arc<Vec<PositionedGlyph>>>
|
||||||
|
> = std::cell::RefCell::new( std::collections::HashMap::new() );
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Soft cap on cached shaped lines; cleared wholesale on overflow (the live
|
||||||
|
/// set of on-screen labels is far smaller than this).
|
||||||
|
const SHAPE_CACHE_CAP: usize = 8192;
|
||||||
|
|
||||||
|
/// Cached wrapper over [`shape_line`]. `font_ctx` identifies the resolver's
|
||||||
|
/// leading font so two fonts shaping the same text don't collide — pass
|
||||||
|
/// `std::sync::Arc::as_ptr( &handle.font ) as usize`. `resolve_font` runs only
|
||||||
|
/// on a cache miss.
|
||||||
|
pub fn shape_line_cached<F>( text: &str, px: f32, font_ctx: usize, resolve_font: F ) -> std::sync::Arc<Vec<PositionedGlyph>>
|
||||||
|
where
|
||||||
|
F: FnMut( char ) -> Option<crate::system_fonts::FontHandle>,
|
||||||
|
{
|
||||||
|
if text.is_empty() { return std::sync::Arc::new( Vec::new() ); }
|
||||||
|
let key = ( font_ctx, px.to_bits(), text.to_owned() );
|
||||||
|
if let Some( hit ) = SHAPE_CACHE.with( |c| c.borrow().get( &key ).cloned() )
|
||||||
|
{
|
||||||
|
return hit;
|
||||||
|
}
|
||||||
|
let shaped = std::sync::Arc::new( shape_line( text, px, resolve_font ) );
|
||||||
|
SHAPE_CACHE.with( |c|
|
||||||
|
{
|
||||||
|
let mut c = c.borrow_mut();
|
||||||
|
if c.len() >= SHAPE_CACHE_CAP { c.clear(); }
|
||||||
|
c.insert( key, std::sync::Arc::clone( &shaped ) );
|
||||||
|
} );
|
||||||
|
shaped
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop every cached shaped line. Called when the font registry changes so a
|
||||||
|
/// new face set can't be served stale glyph runs keyed by a reused pointer.
|
||||||
|
pub fn clear_shape_cache()
|
||||||
|
{
|
||||||
|
SHAPE_CACHE.with( |c| c.borrow_mut().clear() );
|
||||||
|
}
|
||||||
|
|
||||||
/// Shape a text run using rustybuzz (HarfBuzz-compatible shaping) and
|
/// Shape a text run using rustybuzz (HarfBuzz-compatible shaping) and
|
||||||
/// return the glyph sequence with ink positions.
|
/// return the glyph sequence with ink positions.
|
||||||
///
|
///
|
||||||
|
|||||||
13
src/types.rs
13
src/types.rs
@@ -273,6 +273,19 @@ impl From<( f32, f32, f32, f32 )> for Corners
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One command of a vector path, in canvas (surface) coordinates. Fed to
|
||||||
|
/// [`Canvas::fill_path`](crate::Canvas::fill_path) / `stroke_path` to render
|
||||||
|
/// arbitrary shapes (e.g. an Android `Path` / a Lottie frame).
|
||||||
|
#[ derive( Clone, Copy, Debug, PartialEq ) ]
|
||||||
|
pub enum PathCmd
|
||||||
|
{
|
||||||
|
MoveTo( f32, f32 ),
|
||||||
|
LineTo( f32, f32 ),
|
||||||
|
QuadTo( f32, f32, f32, f32 ),
|
||||||
|
CubicTo( f32, f32, f32, f32, f32, f32 ),
|
||||||
|
Close,
|
||||||
|
}
|
||||||
|
|
||||||
/// A stable widget identifier used for focus management.
|
/// A stable widget identifier used for focus management.
|
||||||
///
|
///
|
||||||
/// Assign an id to a widget with `.id( WidgetId("my_widget") )`, then request
|
/// Assign an id to a widget with `.id( WidgetId("my_widget") )`, then request
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use crate::types::{ Point, Rect };
|
|||||||
use crate::render::Canvas;
|
use crate::render::Canvas;
|
||||||
use super::{
|
use super::{
|
||||||
anchored_overlay, button, carousel, checkbox, container, external, flex,
|
anchored_overlay, button, carousel, checkbox, container, external, flex,
|
||||||
image, list_item, pressable, progress_bar, radio, scroll, separator,
|
image, list_item, pressable, progress_bar, radio, rich_text, scroll, separator,
|
||||||
slider, spinner, text, text_edit, toggle, viewport, vslider, window_button,
|
slider, spinner, text, text_edit, toggle, viewport, vslider, window_button,
|
||||||
};
|
};
|
||||||
use super::handlers::WidgetHandlers;
|
use super::handlers::WidgetHandlers;
|
||||||
@@ -22,6 +22,7 @@ pub enum Element<Msg: Clone>
|
|||||||
Row( crate::layout::row::Row<Msg> ),
|
Row( crate::layout::row::Row<Msg> ),
|
||||||
Stack( crate::layout::stack::Stack<Msg> ),
|
Stack( crate::layout::stack::Stack<Msg> ),
|
||||||
Text( text::Text ),
|
Text( text::Text ),
|
||||||
|
RichText( rich_text::RichText<Msg> ),
|
||||||
Spacer( crate::layout::spacer::Spacer ),
|
Spacer( crate::layout::spacer::Spacer ),
|
||||||
Scroll( scroll::Scroll<Msg> ),
|
Scroll( scroll::Scroll<Msg> ),
|
||||||
Viewport( viewport::Viewport<Msg> ),
|
Viewport( viewport::Viewport<Msg> ),
|
||||||
@@ -56,6 +57,7 @@ impl<Msg: Clone> Element<Msg>
|
|||||||
Element::Row( r ) => r.preferred_size( max_width, canvas ),
|
Element::Row( r ) => r.preferred_size( max_width, canvas ),
|
||||||
Element::Stack( s ) => s.preferred_size( max_width, canvas ),
|
Element::Stack( s ) => s.preferred_size( max_width, canvas ),
|
||||||
Element::Text( t ) => t.preferred_size( max_width, canvas ),
|
Element::Text( t ) => t.preferred_size( max_width, canvas ),
|
||||||
|
Element::RichText( r ) => r.preferred_size( max_width, canvas ),
|
||||||
Element::Spacer( s ) => s.preferred_size( canvas ),
|
Element::Spacer( s ) => s.preferred_size( canvas ),
|
||||||
Element::Scroll( s ) => s.preferred_size( max_width, canvas ),
|
Element::Scroll( s ) => s.preferred_size( max_width, canvas ),
|
||||||
Element::Viewport( v ) => v.preferred_size( max_width, canvas ),
|
Element::Viewport( v ) => v.preferred_size( max_width, canvas ),
|
||||||
@@ -99,6 +101,7 @@ impl<Msg: Clone> Element<Msg>
|
|||||||
Element::Row( r ) => r.draw( canvas, rect, focused ),
|
Element::Row( r ) => r.draw( canvas, rect, focused ),
|
||||||
Element::Stack( s ) => s.draw( canvas, rect, focused ),
|
Element::Stack( s ) => s.draw( canvas, rect, focused ),
|
||||||
Element::Text( t ) => t.draw( canvas, rect, focused ),
|
Element::Text( t ) => t.draw( canvas, rect, focused ),
|
||||||
|
Element::RichText( r ) => r.draw( canvas, rect ),
|
||||||
Element::Spacer( _ ) => {}
|
Element::Spacer( _ ) => {}
|
||||||
Element::Scroll( _ ) => {}
|
Element::Scroll( _ ) => {}
|
||||||
Element::Viewport( _ ) => {}
|
Element::Viewport( _ ) => {}
|
||||||
@@ -270,6 +273,7 @@ impl<Msg: Clone> Element<Msg>
|
|||||||
Element::ListItem( l ) => Some( l.label.clone() ).filter( |s| !s.is_empty() ),
|
Element::ListItem( l ) => Some( l.label.clone() ).filter( |s| !s.is_empty() ),
|
||||||
Element::WindowButton( _ ) => None,
|
Element::WindowButton( _ ) => None,
|
||||||
Element::Text( t ) => Some( t.content.clone() ).filter( |s| !s.is_empty() ),
|
Element::Text( t ) => Some( t.content.clone() ).filter( |s| !s.is_empty() ),
|
||||||
|
Element::RichText( r ) => Some( r.content.clone() ).filter( |s| !s.is_empty() ),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -394,6 +398,7 @@ impl<Msg: Clone + 'static> Element<Msg>
|
|||||||
Element::Row( r ) => Element::Row( r.map_msg( f ) ),
|
Element::Row( r ) => Element::Row( r.map_msg( f ) ),
|
||||||
Element::Stack( s ) => Element::Stack( s.map_msg( f ) ),
|
Element::Stack( s ) => Element::Stack( s.map_msg( f ) ),
|
||||||
Element::Text( t ) => Element::Text( t ),
|
Element::Text( t ) => Element::Text( t ),
|
||||||
|
Element::RichText( r ) => Element::RichText( r.map_msg( f ) ),
|
||||||
Element::Spacer( s ) => Element::Spacer( s ),
|
Element::Spacer( s ) => Element::Spacer( s ),
|
||||||
Element::Scroll( s ) => Element::Scroll( s.map_msg( f ) ),
|
Element::Scroll( s ) => Element::Scroll( s.map_msg( f ) ),
|
||||||
Element::Viewport( v ) => Element::Viewport( v.map_msg( f ) ),
|
Element::Viewport( v ) => Element::Viewport( v.map_msg( f ) ),
|
||||||
|
|||||||
35
src/widget/external/mod.rs
vendored
35
src/widget/external/mod.rs
vendored
@@ -93,6 +93,12 @@ pub enum ExternalSource
|
|||||||
/// allocation (e.g. resize a WPEToplevel on layout change) and
|
/// allocation (e.g. resize a WPEToplevel on layout change) and
|
||||||
/// translate input coordinates by `rect.origin`.
|
/// translate input coordinates by `rect.origin`.
|
||||||
Texture( Arc<dyn Fn( &glow::Context, Rect ) -> Option<glow::Texture> + Send + Sync> ),
|
Texture( Arc<dyn Fn( &glow::Context, Rect ) -> Option<glow::Texture> + 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.
|
||||||
|
/// Works on both the GLES and software backends.
|
||||||
|
Cpu( Arc<dyn Fn( &mut Canvas, Rect ) + Send + Sync> ),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl External
|
impl External
|
||||||
@@ -104,6 +110,13 @@ impl External
|
|||||||
Self { width, height, source, opacity: 1.0 }
|
Self { width, height, source, opacity: 1.0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build an external widget that paints via an immediate-mode CPU closure
|
||||||
|
/// reserving `width × height` logical pixels.
|
||||||
|
pub fn cpu( width: f32, height: f32, draw: impl Fn( &mut Canvas, Rect ) + Send + Sync + 'static ) -> Self
|
||||||
|
{
|
||||||
|
Self::new( width, height, ExternalSource::Cpu( Arc::new( draw ) ) )
|
||||||
|
}
|
||||||
|
|
||||||
/// Override the opacity multiplier. Default: `1.0`.
|
/// Override the opacity multiplier. Default: `1.0`.
|
||||||
pub fn opacity( mut self, opacity: f32 ) -> Self
|
pub fn opacity( mut self, opacity: f32 ) -> Self
|
||||||
{
|
{
|
||||||
@@ -118,23 +131,23 @@ impl External
|
|||||||
|
|
||||||
pub fn draw( &self, canvas: &mut Canvas, rect: Rect )
|
pub fn draw( &self, canvas: &mut Canvas, rect: Rect )
|
||||||
{
|
{
|
||||||
// SAFETY-ish: this is the only call site of the source closure;
|
match &self.source
|
||||||
// LTK's draw pass guarantees the GLES context is current and
|
{
|
||||||
// the canvas is bound to its FBO. The rect we pass is the
|
// Immediate CPU drawing works against any backend; the producer
|
||||||
// widget's laid-out rect in physical pixels — the same
|
// paints straight into the canvas at `rect`.
|
||||||
// coordinate space pointer events arrive in, so the producer
|
ExternalSource::Cpu( draw ) =>
|
||||||
// can use `rect.origin` to translate input.
|
{
|
||||||
// External content only renders against the GLES backend — the
|
draw( canvas, rect );
|
||||||
|
}
|
||||||
|
// External GL content only renders against the GLES backend — the
|
||||||
// software backend has no GL texture to sample. Skip silently.
|
// software backend has no GL texture to sample. Skip silently.
|
||||||
|
ExternalSource::Texture( get ) =>
|
||||||
|
{
|
||||||
let gl = match canvas
|
let gl = match canvas
|
||||||
{
|
{
|
||||||
Canvas::Software( _ ) => return,
|
Canvas::Software( _ ) => return,
|
||||||
Canvas::Gles( c ) => c.gl.clone(),
|
Canvas::Gles( c ) => c.gl.clone(),
|
||||||
};
|
};
|
||||||
match &self.source
|
|
||||||
{
|
|
||||||
ExternalSource::Texture( get ) =>
|
|
||||||
{
|
|
||||||
if let Some( tex ) = get( &gl, rect )
|
if let Some( tex ) = get( &gl, rect )
|
||||||
{
|
{
|
||||||
canvas.draw_external_texture( tex, rect, self.opacity );
|
canvas.draw_external_texture( tex, rect, self.opacity );
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ pub mod container;
|
|||||||
pub mod text_edit;
|
pub mod text_edit;
|
||||||
pub mod image;
|
pub mod image;
|
||||||
pub mod text;
|
pub mod text;
|
||||||
|
pub mod rich_text;
|
||||||
pub mod scroll;
|
pub mod scroll;
|
||||||
pub mod viewport;
|
pub mod viewport;
|
||||||
pub mod slider;
|
pub mod slider;
|
||||||
|
|||||||
322
src/widget/rich_text/mod.rs
Normal file
322
src/widget/rich_text/mod.rs
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
// SPDX-License-Identifier: LGPL-2.1-only
|
||||||
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
||||||
|
|
||||||
|
//! Wrapped paragraph text with clickable link ranges — the ltk side of an
|
||||||
|
//! Android `Spanned` carrying `URLSpan` / `ClickableSpan`. Unlike [`Text`] it
|
||||||
|
//! carries a `Msg` per link and the layout pass emits one hit rect per link
|
||||||
|
//! line so taps land on the link, not the whole paragraph.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use fontdue::Font;
|
||||||
|
|
||||||
|
use crate::theme::FontStyle;
|
||||||
|
use crate::types::{ Color, Length, Rect };
|
||||||
|
use crate::render::Canvas;
|
||||||
|
use super::{ Element, MapFn };
|
||||||
|
|
||||||
|
/// A clickable range `[start, end)` (byte offsets into the content) and the
|
||||||
|
/// message to emit when it is tapped.
|
||||||
|
pub struct LinkSpan<Msg>
|
||||||
|
{
|
||||||
|
pub start: usize,
|
||||||
|
pub end: usize,
|
||||||
|
pub msg: Msg,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RichText<Msg: Clone>
|
||||||
|
{
|
||||||
|
pub content: String,
|
||||||
|
pub size: Length,
|
||||||
|
pub color: Color,
|
||||||
|
pub link_color: Color,
|
||||||
|
pub font: Option<( String, u16, FontStyle )>,
|
||||||
|
pub links: Vec<LinkSpan<Msg>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Msg: Clone> RichText<Msg>
|
||||||
|
{
|
||||||
|
pub fn new( content: impl Into<String> ) -> Self
|
||||||
|
{
|
||||||
|
Self
|
||||||
|
{
|
||||||
|
content: content.into(),
|
||||||
|
size: Length::px( 16.0 ),
|
||||||
|
color: Color::WHITE,
|
||||||
|
link_color: Color::rgb( 0.20, 0.50, 0.95 ),
|
||||||
|
font: None,
|
||||||
|
links: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn size( mut self, s: impl Into<Length> ) -> Self
|
||||||
|
{
|
||||||
|
self.size = s.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn color( mut self, c: Color ) -> Self
|
||||||
|
{
|
||||||
|
self.color = c;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn link_color( mut self, c: Color ) -> Self
|
||||||
|
{
|
||||||
|
self.link_color = c;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn font( mut self, family: impl Into<String>, weight: u16, style: FontStyle ) -> Self
|
||||||
|
{
|
||||||
|
self.font = Some( ( family.into(), weight, style ) );
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a clickable range `[start, end)` (byte offsets) emitting `msg`.
|
||||||
|
pub fn link( mut self, start: usize, end: usize, msg: Msg ) -> Self
|
||||||
|
{
|
||||||
|
self.links.push( LinkSpan { start, end, msg } );
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
#[ inline ]
|
||||||
|
fn resolved_size( &self, canvas: &Canvas ) -> f32
|
||||||
|
{
|
||||||
|
self.size.resolve( canvas.viewport_logical(), Length::EM_BASE_DEFAULT )
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_font( &self, canvas: &Canvas ) -> Option<Arc<Font>>
|
||||||
|
{
|
||||||
|
self.font.as_ref().map( |( family, weight, style )| canvas.font_for( family, *weight, *style ) )
|
||||||
|
}
|
||||||
|
|
||||||
|
fn measure( &self, text: &str, canvas: &Canvas, font: Option<&Arc<Font>> ) -> f32
|
||||||
|
{
|
||||||
|
let size = self.resolved_size( canvas );
|
||||||
|
match font
|
||||||
|
{
|
||||||
|
Some( f ) => canvas.measure_text_with_font( text, size, f ),
|
||||||
|
None => canvas.measure_text( text, size ),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> ( f32, f32 )
|
||||||
|
{
|
||||||
|
let size = self.resolved_size( canvas );
|
||||||
|
let line_h = canvas.font_line_metrics( size ).map( |m| m.new_line_size ).unwrap_or( size );
|
||||||
|
let font = self.resolve_font( canvas );
|
||||||
|
let lines = wrap_tracked( &self.content, size, max_width, canvas, font.as_ref() );
|
||||||
|
( max_width, line_h * lines.len().max( 1 ) as f32 )
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn draw( &self, canvas: &mut Canvas, rect: Rect )
|
||||||
|
{
|
||||||
|
let size = self.resolved_size( canvas );
|
||||||
|
let ascent = canvas.font_line_metrics( size ).map( |m| m.ascent ).unwrap_or( size * 0.8 );
|
||||||
|
let line_h = canvas.font_line_metrics( size ).map( |m| m.new_line_size ).unwrap_or( size );
|
||||||
|
let font = self.resolve_font( canvas );
|
||||||
|
let lines = wrap_tracked( &self.content, size, rect.width, canvas, font.as_ref() );
|
||||||
|
|
||||||
|
for ( i, line ) in lines.iter().enumerate()
|
||||||
|
{
|
||||||
|
let ty = rect.y + ascent + line_h * i as f32;
|
||||||
|
match font.as_ref()
|
||||||
|
{
|
||||||
|
Some( f ) => canvas.draw_text_with_font( &line.text, rect.x, ty, size, self.color, f ),
|
||||||
|
None => canvas.draw_text( &line.text, rect.x, ty, size, self.color ),
|
||||||
|
}
|
||||||
|
|
||||||
|
for link in &self.links
|
||||||
|
{
|
||||||
|
let Some( ( cx, _cw, sub ) ) = line.segment( link.start, link.end ) else { continue; };
|
||||||
|
let dx = self.measure( &line.text[..cx], canvas, font.as_ref() );
|
||||||
|
let tx = rect.x + dx;
|
||||||
|
match font.as_ref()
|
||||||
|
{
|
||||||
|
Some( f ) => canvas.draw_text_with_font( &sub, tx, ty, size, self.link_color, f ),
|
||||||
|
None => canvas.draw_text( &sub, tx, ty, size, self.link_color ),
|
||||||
|
}
|
||||||
|
let sw = self.measure( &sub, canvas, font.as_ref() );
|
||||||
|
canvas.draw_line( tx, ty + 2.0, tx + sw, ty + 2.0, self.link_color, 1.0 );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-link hit rects for the layout pass: one rect per visual line a link
|
||||||
|
/// covers, paired with the link's message.
|
||||||
|
pub fn link_rects( &self, rect: Rect, canvas: &Canvas ) -> Vec<( Rect, Msg )>
|
||||||
|
{
|
||||||
|
let size = self.resolved_size( canvas );
|
||||||
|
let ascent = canvas.font_line_metrics( size ).map( |m| m.ascent ).unwrap_or( size * 0.8 );
|
||||||
|
let line_h = canvas.font_line_metrics( size ).map( |m| m.new_line_size ).unwrap_or( size );
|
||||||
|
let font = self.resolve_font( canvas );
|
||||||
|
let lines = wrap_tracked( &self.content, size, rect.width, canvas, font.as_ref() );
|
||||||
|
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for ( i, line ) in lines.iter().enumerate()
|
||||||
|
{
|
||||||
|
for link in &self.links
|
||||||
|
{
|
||||||
|
let Some( ( cx, _cw, sub ) ) = line.segment( link.start, link.end ) else { continue; };
|
||||||
|
let dx = self.measure( &line.text[..cx], canvas, font.as_ref() );
|
||||||
|
let sw = self.measure( &sub, canvas, font.as_ref() );
|
||||||
|
let y = rect.y + line_h * i as f32;
|
||||||
|
out.push( ( Rect { x: rect.x + dx, y, width: sw, height: ascent.max( line_h ) }, link.msg.clone() ) );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub( crate ) fn map_msg<U: Clone>( self, f: &MapFn<Msg, U> ) -> RichText<U>
|
||||||
|
{
|
||||||
|
RichText
|
||||||
|
{
|
||||||
|
content: self.content,
|
||||||
|
size: self.size,
|
||||||
|
color: self.color,
|
||||||
|
link_color: self.link_color,
|
||||||
|
font: self.font,
|
||||||
|
links: self.links.into_iter().map( |l| LinkSpan { start: l.start, end: l.end, msg: f( l.msg ) } ).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One visual row of the wrapped paragraph. `text` is the rendered line (words
|
||||||
|
/// joined by single spaces); `offsets` maps each rendered char to its source
|
||||||
|
/// byte offset in the original content, with a trailing sentinel = line end.
|
||||||
|
struct VisualLine
|
||||||
|
{
|
||||||
|
text: String,
|
||||||
|
offsets: Vec<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VisualLine
|
||||||
|
{
|
||||||
|
/// The rendered substring covered by source range `[start, end)`, as
|
||||||
|
/// `( char_prefix_len, substring_byte_len, substring )` — or None when the
|
||||||
|
/// link does not touch this line. `char_prefix_len` is a byte index into
|
||||||
|
/// `self.text` (the rendered chars before the link on this line).
|
||||||
|
fn segment( &self, start: usize, end: usize ) -> Option<( usize, usize, String )>
|
||||||
|
{
|
||||||
|
let mut byte = 0;
|
||||||
|
let mut first: Option<usize> = None;
|
||||||
|
let mut last_byte = 0;
|
||||||
|
for ( ci, ch ) in self.text.chars().enumerate()
|
||||||
|
{
|
||||||
|
let src = self.offsets.get( ci ).copied().unwrap_or( usize::MAX );
|
||||||
|
if src >= start && src < end
|
||||||
|
{
|
||||||
|
if first.is_none() { first = Some( byte ); }
|
||||||
|
last_byte = byte + ch.len_utf8();
|
||||||
|
}
|
||||||
|
byte += ch.len_utf8();
|
||||||
|
}
|
||||||
|
let f = first?;
|
||||||
|
Some( ( f, last_byte - f, self.text[f..last_byte].to_string() ) )
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wrap_tracked( content: &str, size: f32, max_width: f32, canvas: &Canvas, font: Option<&Arc<Font>> ) -> Vec<VisualLine>
|
||||||
|
{
|
||||||
|
let measure = |s: &str| -> f32
|
||||||
|
{
|
||||||
|
match font
|
||||||
|
{
|
||||||
|
Some( f ) => canvas.measure_text_with_font( s, size, f ),
|
||||||
|
None => canvas.measure_text( s, size ),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tokenise into words (byte ranges) and hard breaks (from '\n').
|
||||||
|
let mut words: Vec<( usize, usize )> = Vec::new();
|
||||||
|
let mut breaks: Vec<usize> = Vec::new(); // word-index after which a hard break sits
|
||||||
|
let mut start: Option<usize> = None;
|
||||||
|
for ( b, ch ) in content.char_indices()
|
||||||
|
{
|
||||||
|
if ch == '\n'
|
||||||
|
{
|
||||||
|
if let Some( s ) = start.take() { words.push( ( s, b ) ); }
|
||||||
|
breaks.push( words.len() );
|
||||||
|
}
|
||||||
|
else if ch.is_whitespace()
|
||||||
|
{
|
||||||
|
if let Some( s ) = start.take() { words.push( ( s, b ) ); }
|
||||||
|
}
|
||||||
|
else if start.is_none()
|
||||||
|
{
|
||||||
|
start = Some( b );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some( s ) = start { words.push( ( s, content.len() ) ); }
|
||||||
|
|
||||||
|
let space_w = measure( " " );
|
||||||
|
let mut lines: Vec<VisualLine> = Vec::new();
|
||||||
|
let mut cur: Vec<( usize, usize )> = Vec::new();
|
||||||
|
let mut cur_w = 0.0_f32;
|
||||||
|
|
||||||
|
for ( wi, &( ws, we ) ) in words.iter().enumerate()
|
||||||
|
{
|
||||||
|
let word_w = measure( &content[ws..we] );
|
||||||
|
if cur.is_empty()
|
||||||
|
{
|
||||||
|
cur.push( ( ws, we ) );
|
||||||
|
cur_w = word_w;
|
||||||
|
}
|
||||||
|
else if max_width > 0.0 && cur_w + space_w + word_w > max_width
|
||||||
|
{
|
||||||
|
flush_line( content, &mut cur, &mut lines );
|
||||||
|
cur.push( ( ws, we ) );
|
||||||
|
cur_w = word_w;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
cur.push( ( ws, we ) );
|
||||||
|
cur_w += space_w + word_w;
|
||||||
|
}
|
||||||
|
|
||||||
|
if breaks.contains( &( wi + 1 ) )
|
||||||
|
{
|
||||||
|
flush_line( content, &mut cur, &mut lines );
|
||||||
|
cur_w = 0.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !cur.is_empty() { flush_line( content, &mut cur, &mut lines ); }
|
||||||
|
if lines.is_empty() { lines.push( VisualLine { text: String::new(), offsets: vec![ 0 ] } ); }
|
||||||
|
lines
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flush_line( content: &str, cur: &mut Vec<( usize, usize )>, lines: &mut Vec<VisualLine> )
|
||||||
|
{
|
||||||
|
let mut text = String::new();
|
||||||
|
let mut offsets = Vec::new();
|
||||||
|
for ( wi, &( ws, we ) ) in cur.iter().enumerate()
|
||||||
|
{
|
||||||
|
if wi > 0
|
||||||
|
{
|
||||||
|
offsets.push( ws );
|
||||||
|
text.push( ' ' );
|
||||||
|
}
|
||||||
|
for ( b, ch ) in content[ws..we].char_indices()
|
||||||
|
{
|
||||||
|
offsets.push( ws + b );
|
||||||
|
text.push( ch );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
offsets.push( cur.last().map( |&( _, we )| we ).unwrap_or( 0 ) );
|
||||||
|
lines.push( VisualLine { text, offsets } );
|
||||||
|
cur.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Msg: Clone + 'static> From<RichText<Msg>> for Element<Msg>
|
||||||
|
{
|
||||||
|
fn from( t: RichText<Msg> ) -> Self
|
||||||
|
{
|
||||||
|
Element::RichText( t )
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rich_text<Msg: Clone>( content: impl Into<String> ) -> RichText<Msg>
|
||||||
|
{
|
||||||
|
RichText::new( content )
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user