From b00cf460bb29192aa605d76318b235538f8fb1d0 Mon Sep 17 00:00:00 2001 From: "Pedro M. de Echanove Pasquin" Date: Fri, 12 Jun 2026 22:14:02 +0200 Subject: [PATCH] =?UTF-8?q?Mature=20ltk=20to=20host=20an=20externally-laid?= =?UTF-8?q?-out=20Android=20view=20tree=20Add=20the=20primitives=20rustdro?= =?UTF-8?q?id=20needs=20to=20project=20an=20Android=20view=20hierarchy=20o?= =?UTF-8?q?nto=20an=20ltk=20surface,=20all=20kept=20general=20rather=20tha?= =?UTF-8?q?n=20Android-specific.=20Canvas=20gains=20arbitrary=20vector=20p?= =?UTF-8?q?ath=20fill=20and=20stroke:=20`Canvas::fill=5Fpath`=20/=20`strok?= =?UTF-8?q?e=5Fpath`=20over=20a=20new=20`PathCmd`=20command=20list=20(Move?= =?UTF-8?q?To/LineTo/QuadTo/CubicTo/Close=20in=20surface=20coordinates).?= =?UTF-8?q?=20The=20software=20backend=20rasterises=20directly=20with=20ti?= =?UTF-8?q?ny-skia;=20the=20GLES=20backend=20rasterises=20into=20a=20tiny-?= =?UTF-8?q?skia=20pixmap=20and=20blits=20it=20(CPU=20fallback,=20no=20GPU?= =?UTF-8?q?=20path=20shader).=20This=20is=20what=20renders=20an=20Android?= =?UTF-8?q?=20`Path`,=20a=20`VectorDrawable`,=20or=20a=20Lottie=20frame.?= =?UTF-8?q?=20`ExternalSource::Cpu`=20(and=20the=20`External::cpu`=20const?= =?UTF-8?q?ructor)=20adds=20an=20immediate-mode=20CPU=20drawing=20closure,?= =?UTF-8?q?=20invoked=20once=20per=20frame=20with=20the=20canvas=20and=20t?= =?UTF-8?q?he=20widget's=20laid-out=20rect,=20working=20on=20both=20the=20?= =?UTF-8?q?GLES=20and=20software=20backends.=20It=20hosts=20a=20custom=20`?= =?UTF-8?q?View.onDraw`=20straight=20onto=20the=20ltk=20canvas=20without?= =?UTF-8?q?=20a=20GL=20texture=20round-trip,=20unlike=20the=20existing=20`?= =?UTF-8?q?Texture`=20source=20which=20only=20renders=20on=20GLES.=20`Stac?= =?UTF-8?q?k::push=5Fplaced`=20appends=20a=20child=20at=20an=20exact=20rec?= =?UTF-8?q?t,=20bypassing=20alignment=20and=20intrinsic=20sizing.=20This?= =?UTF-8?q?=20lets=20a=20view=20tree=20whose=20geometry=20is=20computed=20?= =?UTF-8?q?elsewhere=20=E2=80=94=20Android's=20measure/layout=20pass,=20wh?= =?UTF-8?q?ich=20yields=20an=20absolute=20rect=20per=20view=20=E2=80=94=20?= =?UTF-8?q?be=20projected=20onto=20a=20Stack=20in=20paint=20order.=20New?= =?UTF-8?q?=20`RichText`=20widget:=20wrapped=20paragraph=20text=20carrying?= =?UTF-8?q?=20a=20message=20per=20clickable=20link=20range,=20with=20the?= =?UTF-8?q?=20layout=20pass=20emitting=20one=20hit=20rect=20per=20link=20l?= =?UTF-8?q?ine=20so=20taps=20land=20on=20the=20link=20rather=20than=20the?= =?UTF-8?q?=20whole=20paragraph.=20It=20is=20the=20ltk=20side=20of=20an=20?= =?UTF-8?q?Android=20`Spanned`=20carrying=20`URLSpan`=20/=20`ClickableSpan?= =?UTF-8?q?`.=20gesture:=20only=20drive=20the=20horizontal=20pager=20once?= =?UTF-8?q?=20the=20axis=20locks=20horizontal=20`on=5Fmove`=20emitted=20ho?= =?UTF-8?q?rizontal=20swipe=20progress=20whenever=20`swipe=5Faxis=20!=3D?= =?UTF-8?q?=20Vertical`,=20which=20includes=20the=20pre-lock=20window=20wh?= =?UTF-8?q?ere=20`swipe=5Faxis`=20is=20still=20`None`=20(the=20first=20~8?= =?UTF-8?q?=20px=20of=20travel).=20But=20`horizontal=5Fdrag=5Fstarted`=20o?= =?UTF-8?q?nly=20flips=20once=20`dx.abs()=20>=208`.=20A=20vertical=20gestu?= =?UTF-8?q?re=20that=20opened=20with=20a=20few=20pixels=20of=20lateral=20d?= =?UTF-8?q?rift=20=E2=80=94=20a=20swipe-up=20to=20the=20launcher,=20a=20sc?= =?UTF-8?q?roll=20=E2=80=94=20therefore=20emitted=20a=20tiny=20horizontal?= =?UTF-8?q?=20progress=20sample,=20which=20armed=20the=20consumer's=20page?= =?UTF-8?q?r,=20then=20locked=20vertical=20without=20`dx`=20ever=20passing?= =?UTF-8?q?=208,=20so=20`horizontal=5Fdrag=5Fstarted`=20stayed=20false.=20?= =?UTF-8?q?On=20release=20the=20horizontal=20branch=20was=20skipped,=20no?= =?UTF-8?q?=20`on=5Fswipe=5Fhorizontal=5Fprogress(0.0)`=20fired,=20and=20t?= =?UTF-8?q?he=20pager=20stayed=20stuck=20active=20=E2=80=94=20on=20the=20c?= =?UTF-8?q?rustace=20homescreen=20that=20froze=20the=20surface=20(the=20ma?= =?UTF-8?q?in=20stays=20motion-only=20behind=20stale=20page=20subsurfaces)?= =?UTF-8?q?=20until=20an=20unrelated=20gesture=20reset=20it.=20Emit=20hori?= =?UTF-8?q?zontal=20progress=20only=20once=20`swipe=5Faxis=20=3D=3D=20Some?= =?UTF-8?q?(Horizontal)`.=20Locking=20onto=20the=20horizontal=20axis=20alr?= =?UTF-8?q?eady=20implies=20`dx.abs()=20>=208`,=20so=20`horizontal=5Fdrag?= =?UTF-8?q?=5Fstarted`=20is=20set=20in=20the=20same=20step,=20restoring=20?= =?UTF-8?q?the=20invariant=20that=20a=20frame=20which=20drives=20the=20pag?= =?UTF-8?q?er=20always=20has=20a=20matching=20release=20event=20to=20settl?= =?UTF-8?q?e=20it.=20The=20cost=20is=20that=20the=20first=20~8=20px=20of?= =?UTF-8?q?=20a=20horizontal=20drag=20no=20longer=20move=20the=20page,=20w?= =?UTF-8?q?hich=20is=20the=20same=20deadband=20the=20axis=20lock=20already?= =?UTF-8?q?=20imposes=20on=20the=20vertical=20panels.=20Adds=20`pre=5Flock?= =?UTF-8?q?=5Flateral=5Fdrift=5Fdoes=5Fnot=5Fdrive=5Fhorizontal=5Fpager`.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app.rs | 16 ++ src/draw/layout.rs | 32 ++++ src/gles_render/primitives.rs | 65 ++++++- src/gles_render/text.rs | 12 +- src/input/dispatch/outcomes.rs | 34 +++- src/input/gesture/mod.rs | 64 ++++++- src/input/gesture/outcomes.rs | 11 ++ src/input/gesture/tests.rs | 88 +++++++++ src/input/pointer/helpers.rs | 1 + src/layout/stack.rs | 41 ++++- src/lib.rs | 4 +- src/render/helpers.rs | 21 ++- src/render/mod.rs | 26 +++ src/render/primitives.rs | 24 ++- src/render/text.rs | 12 +- src/text_shaping.rs | 50 +++++ src/types.rs | 13 ++ src/widget/element.rs | 7 +- src/widget/external/mod.rs | 39 ++-- src/widget/mod.rs | 1 + src/widget/rich_text/mod.rs | 322 +++++++++++++++++++++++++++++++++ 21 files changed, 840 insertions(+), 43 deletions(-) create mode 100644 src/widget/rich_text/mod.rs diff --git a/src/app.rs b/src/app.rs index 20c0874..3aac06c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -473,6 +473,22 @@ pub trait App: 'static /// edge, matching the usual system-panel pull-down UX. 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 { 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. /// /// Return a message to handle the swipe (e.g., paging to the next diff --git a/src/draw/layout.rs b/src/draw/layout.rs index 6b51db6..2f96a0d 100644 --- a/src/draw/layout.rs +++ b/src/draw/layout.rs @@ -380,6 +380,38 @@ pub( crate ) fn layout_and_draw( canvas.blit_fade_bottom( &sub, rect.x as i32, rect.y as i32, v.fade_bottom ); 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 => { // Gate the focus ring by `is_focusable`: a widget that opts out diff --git a/src/gles_render/primitives.rs b/src/gles_render/primitives.rs index 4f5a660..cbadcf6 100644 --- a/src/gles_render/primitives.rs +++ b/src/gles_render/primitives.rs @@ -27,7 +27,7 @@ use glow::HasContext; 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::GlesCanvas; @@ -51,6 +51,69 @@ impl GlesCanvas 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 ) + { + 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 ) { if self.rect_culled( rect, 1.0 ) { return; } diff --git a/src/gles_render/text.rs b/src/gles_render/text.rs index ebb7394..99de9b1 100644 --- a/src/gles_render/text.rs +++ b/src/gles_render/text.rs @@ -92,7 +92,9 @@ impl GlesCanvas 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; } // Resolve each glyph's `font_id` to an actual `Arc` for @@ -105,7 +107,7 @@ impl GlesCanvas { 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; } let mut found = None; @@ -222,7 +224,7 @@ impl GlesCanvas let surface_h = self.height as f32; let mut verts: Vec = Vec::with_capacity( shaped.len() * 24 ); let mut cx = x; - for g in &shaped + for g in shaped.iter() { let glyph_id = g.glyph_id as u16; 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 } ) }; - 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 text.chars().map( |ch| diff --git a/src/input/dispatch/outcomes.rs b/src/input/dispatch/outcomes.rs index c618e65..2ecb903 100644 --- a/src/input/dispatch/outcomes.rs +++ b/src/input/dispatch/outcomes.rs @@ -44,6 +44,18 @@ impl AppData { 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 } => { if let Some( v ) = up { self.app.on_swipe_progress( v ); } @@ -58,7 +70,12 @@ impl AppData // GPU, stalls the event loop so the gesture itself feels laggy // to start. So refresh the overlays always, but the main only // 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; if let SurfaceFocus::Main = focus @@ -172,6 +189,21 @@ impl AppData 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 => { // Below threshold: pulse horizontal 0 so a diff --git a/src/input/gesture/mod.rs b/src/input/gesture/mod.rs index 9024d9c..934c301 100644 --- a/src/input/gesture/mod.rs +++ b/src/input/gesture/mod.rs @@ -128,6 +128,12 @@ pub struct GestureState /// `app.on_drop`. Set by the touch deadline when `drag_start_msg` /// is present, or by the mouse-motion promotion path. 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 /// 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 @@ -168,6 +174,7 @@ impl GestureState drag_start_msg: None, long_press_text_idx: None, long_press_fired: false, + overscroll_y: 0.0, mouse_press: false, } } @@ -204,6 +211,7 @@ impl GestureState self.scrolling_widget = self.scroll_candidates.first().copied(); self.scroll_locked = false; self.scroll_drag_started = false; + self.overscroll_y = 0.0; self.horizontal_drag_started = false; self.vertical_drag_started = false; self.swipe_axis = None; @@ -341,12 +349,43 @@ impl GestureState let ( scroll_idx, axis ) = self.scrolling_widget.unwrap(); 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_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 ) } else if axis.allows_x() { dx.abs() } else { dy.abs() }; if moved > 8.0 { self.scroll_drag_started = true; } 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::Idle; @@ -366,8 +405,7 @@ impl GestureState { self.swipe_axis = Some( if dy.abs() >= dx.abs() { SwipeAxis::Vertical } else { SwipeAxis::Horizontal } ); } - let vertical_allowed = self.swipe_axis != Some( SwipeAxis::Horizontal ); - let horizontal_allowed = self.swipe_axis != Some( SwipeAxis::Vertical ); + let vertical_allowed = self.swipe_axis != Some( SwipeAxis::Horizontal ); let ( up, down ) = if vertical_allowed && swipe.surface_height > 0 { @@ -392,11 +430,18 @@ impl GestureState } 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_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 ) } else { None }; @@ -474,6 +519,15 @@ impl GestureState if scroll_consumed { 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; } diff --git a/src/input/gesture/outcomes.rs b/src/input/gesture/outcomes.rs index 652872e..60e61ad 100644 --- a/src/input/gesture/outcomes.rs +++ b/src/input/gesture/outcomes.rs @@ -28,6 +28,10 @@ pub enum MoveOutcome Slider { msg: Option }, /// Scroll viewport offset updated. Handler just requests a redraw. 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 /// progress reading for this move (not every motion updates all /// axes). Handler fires the matching app callback. @@ -50,6 +54,10 @@ pub enum ReleaseEvent SwipeUp, /// Vertical swipe committed down. 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 /// `app.on_swipe_horizontal_progress(0.0)` + main redraw. HorizontalFellThrough, @@ -79,6 +87,9 @@ pub struct SwipeConfig pub down_edge: f32, /// Horizontal commit fraction of surface width. 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_height: u32, } diff --git a/src/input/gesture/tests.rs b/src/input/gesture/tests.rs index 9fd26a6..bca597c 100644 --- a/src/input/gesture/tests.rs +++ b/src/input/gesture/tests.rs @@ -56,6 +56,7 @@ fn cfg_full( w: u32, h: u32 ) -> SwipeConfig down_thresh: 0.30, down_edge: 0.10, horizontal_thresh: 0.30, + overscroll_thresh: 0.25, surface_width: w, surface_height: h, } @@ -269,6 +270,76 @@ fn move_below_eight_pixels_does_not_arm_scroll_drag() assert!( !g.scroll_drag_started ); } +// ── on_move / on_release: top overscroll dismiss ────────────────────────── + +#[ test ] +fn pull_down_at_top_emits_overscroll_progress() +{ + let widgets: Vec> = vec![]; + let scrolls: [( Rect, usize, ScrollAxis ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 3, ScrollAxis::Vertical ) ]; + let mut g = GestureState::::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> = vec![]; + let scrolls: [( Rect, usize, ScrollAxis ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 4, ScrollAxis::Vertical ) ]; + let mut g = GestureState::::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> = vec![]; + let scrolls: [( Rect, usize, ScrollAxis ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 5, ScrollAxis::Vertical ) ]; + let mut g = GestureState::::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> = vec![]; + let scrolls: [( Rect, usize, ScrollAxis ); 1] = [ ( rect( 0.0, 0.0, 400.0, 800.0 ), 6, ScrollAxis::Vertical ) ]; + let mut g = GestureState::::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 ─────────────────────────────────────────────── #[ test ] @@ -338,6 +409,23 @@ fn horizontal_lock_suppresses_later_vertical_drift() 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> = vec![]; + let mut g = GestureState::::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 ] fn move_up_progress_unclamped_past_unit_interval() { diff --git a/src/input/pointer/helpers.rs b/src/input/pointer/helpers.rs index 7c919f1..401a207 100644 --- a/src/input/pointer/helpers.rs +++ b/src/input/pointer/helpers.rs @@ -24,6 +24,7 @@ impl AppData down_thresh: self.app.swipe_down_threshold(), down_edge: self.app.swipe_down_edge(), horizontal_thresh: self.app.swipe_horizontal_threshold(), + overscroll_thresh: self.app.overscroll_dismiss_threshold(), surface_width: ss.physical_width(), surface_height: ss.physical_height(), } diff --git a/src/layout/stack.rs b/src/layout/stack.rs index b7eb404..b57fc12 100644 --- a/src/layout/stack.rs +++ b/src/layout/stack.rs @@ -52,8 +52,10 @@ pub enum VAlign pub struct Stack { /// Children with their alignment, margin, and extra `(x, y)` translation - /// applied after alignment. Drawn in order — last child is on top. - pub children: Vec<( Element, HAlign, VAlign, f32, f32, f32 )>, + /// applied after alignment. Drawn in order — last child is on top. The final + /// `Option` overrides alignment/sizing with an exact rect (see + /// [`push_placed`](Self::push_placed)). + pub children: Vec<( Element, HAlign, VAlign, f32, f32, f32, Option )>, /// When `true`, [`preferred_size`](Self::preferred_size) reports the max of /// children's intrinsic widths and heights instead of `(max_width, tallest)`. pub fit_content: bool, @@ -100,7 +102,7 @@ impl Stack margin: f32, ) -> 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 } @@ -117,7 +119,21 @@ impl Stack offset_y: f32, ) -> 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>, + rect: Rect, + ) -> Self + { + self.children.push( ( e.into(), HAlign::Start, VAlign::Top, 0.0, 0.0, 0.0, Some( rect ) ) ); self } @@ -126,7 +142,7 @@ impl Stack if self.fit_content { 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::Separator( _ ) => 0.0, @@ -141,13 +157,13 @@ impl Stack } ) .fold( 0.0_f32, f32::max ); 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 ); return ( content_w.min( max_width ), max_h ); } 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 ); ( max_width, max_h ) } @@ -155,8 +171,13 @@ impl Stack /// Return `(rect, child_index)` pairs, computing each child's rect from its alignment. 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_h = ( rect.height - margin * 2.0 ).max( 0.0 ); let ( pref_w, pref_h ) = child.preferred_size( inner_w, canvas ); @@ -192,8 +213,8 @@ impl Stack Stack { children: self.children.into_iter() - .map( |( child, ha, va, margin, ox, oy )| - ( child.map_arc( f ), ha, va, margin, ox, oy ) ) + .map( |( child, ha, va, margin, ox, oy, placed )| + ( child.map_arc( f ), ha, va, margin, ox, oy, placed ) ) .collect(), fit_content: self.fit_content, } diff --git a/src/lib.rs b/src/lib.rs index c8f136f..611e475 100755 --- a/src/lib.rs +++ b/src/lib.rs @@ -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 gles_render::{ BorrowedGlesTexture, GlesVersion }; pub use render::is_software_render; +pub use render::Canvas; 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 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 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::slider::{ Slider, slider, SliderAxis }; pub use widget::vslider::{ VSlider, vslider }; diff --git a/src/render/helpers.rs b/src/render/helpers.rs index 28af782..557c722 100644 --- a/src/render/helpers.rs +++ b/src/render/helpers.rs @@ -6,12 +6,31 @@ use tiny_skia::{ Path, PathBuilder }; -use crate::types::Corners; +use crate::types::{ Corners, PathCmd }; /// Cubic bezier control-point factor for a quarter-circle approximation /// (`(4/3) * (sqrt(2) - 1) ≈ 0.5523`). 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 +{ + 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 /// using cubic bezier curves. Each corner is clamped against the /// inscribed-circle limit `min(width, height) / 2` before drawing, diff --git a/src/render/mod.rs b/src/render/mod.rs index aa1c62d..6419551 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -326,6 +326,9 @@ impl Canvas /// Install a theme font registry on the active backend. pub fn set_font_registry( &mut self, registry: Arc ) { + // 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 { 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`. /// /// On the GPU backend this runs an analytic soft-shadow shader diff --git a/src/render/primitives.rs b/src/render/primitives.rs index 344b3f6..07d1d7a 100644 --- a/src/render/primitives.rs +++ b/src/render/primitives.rs @@ -9,9 +9,9 @@ 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; impl SoftwareCanvas @@ -100,4 +100,24 @@ impl SoftwareCanvas stroke.width = width; 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() ); + } } diff --git a/src/render/text.rs b/src/render/text.rs index 3cb794d..6754aa4 100644 --- a/src/render/text.rs +++ b/src/render/text.rs @@ -115,7 +115,9 @@ impl SoftwareCanvas // refused the font, missing bytes for the preferred face) // falls through to no-op — we'd rather not paint than risk // 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; } // Resolve every glyph's font into an `Arc` for @@ -128,7 +130,7 @@ impl SoftwareCanvas { 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; } // Walk the fallback chain to find an Arc with this @@ -155,7 +157,7 @@ impl SoftwareCanvas let mut layout: Vec<( GlyphKey, f32, f32 )> = Vec::with_capacity( shaped.len() ); { 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 { @@ -251,7 +253,9 @@ impl SoftwareCanvas 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() { // Fallback: rustybuzz could not shape (no bytes for the diff --git a/src/text_shaping.rs b/src/text_shaping.rs index c771799..a92c81e 100644 --- a/src/text_shaping.rs +++ b/src/text_shaping.rs @@ -126,6 +126,56 @@ where 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>> + > = 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( text: &str, px: f32, font_ctx: usize, resolve_font: F ) -> std::sync::Arc> +where + F: FnMut( char ) -> Option, +{ + 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 /// return the glyph sequence with ink positions. /// diff --git a/src/types.rs b/src/types.rs index bd5a09b..8662086 100644 --- a/src/types.rs +++ b/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. /// /// Assign an id to a widget with `.id( WidgetId("my_widget") )`, then request diff --git a/src/widget/element.rs b/src/widget/element.rs index d55a20f..ec50710 100644 --- a/src/widget/element.rs +++ b/src/widget/element.rs @@ -6,7 +6,7 @@ use crate::types::{ Point, Rect }; use crate::render::Canvas; use super::{ 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, }; use super::handlers::WidgetHandlers; @@ -22,6 +22,7 @@ pub enum Element Row( crate::layout::row::Row ), Stack( crate::layout::stack::Stack ), Text( text::Text ), + RichText( rich_text::RichText ), Spacer( crate::layout::spacer::Spacer ), Scroll( scroll::Scroll ), Viewport( viewport::Viewport ), @@ -56,6 +57,7 @@ impl Element Element::Row( r ) => r.preferred_size( max_width, canvas ), Element::Stack( s ) => s.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::Scroll( s ) => s.preferred_size( max_width, canvas ), Element::Viewport( v ) => v.preferred_size( max_width, canvas ), @@ -99,6 +101,7 @@ impl Element Element::Row( r ) => r.draw( canvas, rect, focused ), Element::Stack( s ) => s.draw( canvas, rect, focused ), Element::Text( t ) => t.draw( canvas, rect, focused ), + Element::RichText( r ) => r.draw( canvas, rect ), Element::Spacer( _ ) => {} Element::Scroll( _ ) => {} Element::Viewport( _ ) => {} @@ -270,6 +273,7 @@ impl Element Element::ListItem( l ) => Some( l.label.clone() ).filter( |s| !s.is_empty() ), Element::WindowButton( _ ) => None, 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, } } @@ -394,6 +398,7 @@ impl Element Element::Row( r ) => Element::Row( r.map_msg( f ) ), Element::Stack( s ) => Element::Stack( s.map_msg( f ) ), Element::Text( t ) => Element::Text( t ), + Element::RichText( r ) => Element::RichText( r.map_msg( f ) ), Element::Spacer( s ) => Element::Spacer( s ), Element::Scroll( s ) => Element::Scroll( s.map_msg( f ) ), Element::Viewport( v ) => Element::Viewport( v.map_msg( f ) ), diff --git a/src/widget/external/mod.rs b/src/widget/external/mod.rs index 01c03da..4a477a9 100644 --- a/src/widget/external/mod.rs +++ b/src/widget/external/mod.rs @@ -93,6 +93,12 @@ pub enum ExternalSource /// allocation (e.g. resize a WPEToplevel on layout change) and /// translate input coordinates by `rect.origin`. Texture( Arc Option + 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 ), } impl External @@ -104,6 +110,13 @@ impl External 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`. pub fn opacity( mut self, opacity: f32 ) -> Self { @@ -118,23 +131,23 @@ impl External pub fn draw( &self, canvas: &mut Canvas, rect: Rect ) { - // SAFETY-ish: this is the only call site of the source closure; - // LTK's draw pass guarantees the GLES context is current and - // the canvas is bound to its FBO. The rect we pass is the - // widget's laid-out rect in physical pixels — the same - // coordinate space pointer events arrive in, so the producer - // can use `rect.origin` to translate input. - // External content only renders against the GLES backend — the - // software backend has no GL texture to sample. Skip silently. - let gl = match canvas - { - Canvas::Software( _ ) => return, - Canvas::Gles( c ) => c.gl.clone(), - }; match &self.source { + // Immediate CPU drawing works against any backend; the producer + // paints straight into the canvas at `rect`. + ExternalSource::Cpu( draw ) => + { + draw( canvas, rect ); + } + // External GL content only renders against the GLES backend — the + // software backend has no GL texture to sample. Skip silently. ExternalSource::Texture( get ) => { + let gl = match canvas + { + Canvas::Software( _ ) => return, + Canvas::Gles( c ) => c.gl.clone(), + }; if let Some( tex ) = get( &gl, rect ) { canvas.draw_external_texture( tex, rect, self.opacity ); diff --git a/src/widget/mod.rs b/src/widget/mod.rs index b3ef7c5..cee01d7 100755 --- a/src/widget/mod.rs +++ b/src/widget/mod.rs @@ -64,6 +64,7 @@ pub mod container; pub mod text_edit; pub mod image; pub mod text; +pub mod rich_text; pub mod scroll; pub mod viewport; pub mod slider; diff --git a/src/widget/rich_text/mod.rs b/src/widget/rich_text/mod.rs new file mode 100644 index 0000000..447b781 --- /dev/null +++ b/src/widget/rich_text/mod.rs @@ -0,0 +1,322 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! 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 +{ + pub start: usize, + pub end: usize, + pub msg: Msg, +} + +pub struct RichText +{ + pub content: String, + pub size: Length, + pub color: Color, + pub link_color: Color, + pub font: Option<( String, u16, FontStyle )>, + pub links: Vec>, +} + +impl RichText +{ + pub fn new( content: impl Into ) -> 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 ) -> 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, 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> + { + self.font.as_ref().map( |( family, weight, style )| canvas.font_for( family, *weight, *style ) ) + } + + fn measure( &self, text: &str, canvas: &Canvas, font: Option<&Arc> ) -> 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( self, f: &MapFn ) -> RichText + { + 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, +} + +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 = 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> ) -> Vec +{ + 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 = Vec::new(); // word-index after which a hard break sits + let mut start: Option = 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 = 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 ) +{ + 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 From> for Element +{ + fn from( t: RichText ) -> Self + { + Element::RichText( t ) + } +} + +pub fn rich_text( content: impl Into ) -> RichText +{ + RichText::new( content ) +}