From 530a5696b9c295ad67a68cc0b606d558a91e5dfc Mon Sep 17 00:00:00 2001 From: "Pedro M. de Echanove Pasquin" Date: Tue, 4 Aug 2026 18:44:21 +0200 Subject: [PATCH] =?UTF-8?q?event=5Floop,=20slider,=20list=5Fitem:=20overla?= =?UTF-8?q?y=20exclusive=20zones=20follow=20the=20surface,=20on=5Frelease?= =?UTF-8?q?=20for=20sliders,=20list=20labels=20elide=20against=20the=20tra?= =?UTF-8?q?iling=20slot=20Exclusive=20zones=20(event=5Floop/overlays=5Frec?= =?UTF-8?q?oncile.rs,=20event=5Floop/surface.rs).=20`OverlaySpec::size`=20?= =?UTF-8?q?is=20documented=20as=20physical=20pixels=20and=20converted=20to?= =?UTF-8?q?=20logical=20for=20`layer=5Fsurface.set=5Fsize`=20by=20dividing?= =?UTF-8?q?=20by=20the=20parent's=20integer=20scale;=20`exclusive=5Fzone`?= =?UTF-8?q?=20sat=20right=20beside=20it=20in=20the=20same=20`LayerConfig`?= =?UTF-8?q?=20and=20was=20passed=20through=20raw,=20so=20at=20scale=202=20?= =?UTF-8?q?the=20reserved=20band=20was=20expressed=20in=20a=20unit=20twice?= =?UTF-8?q?=20as=20coarse=20as=20the=20surface=20it=20is=20meant=20to=20ma?= =?UTF-8?q?tch.=20Worse,=20`set=5Fexclusive=5Fzone`=20appeared=20exactly?= =?UTF-8?q?=20once=20in=20the=20whole=20crate=20=E2=80=94=20at=20materiali?= =?UTF-8?q?ze=20time=20=E2=80=94=20while=20the=20reconcile=20loop=20propag?= =?UTF-8?q?ated=20later=20size=20changes=20through=20`last=5Frequested=5Fs?= =?UTF-8?q?ize`,=20so=20an=20overlay=20whose=20size=20kept=20being=20recom?= =?UTF-8?q?puted=20carried=20a=20reservation=20frozen=20at=20whatever=20th?= =?UTF-8?q?e=20first=20frame=20produced.=20Crustace's=20dock=20is=20the=20?= =?UTF-8?q?visible=20case:=20it=20derives=20both=20numbers=20from=20the=20?= =?UTF-8?q?same=20`desktop=5Fpill=5Fheight`,=20and=20with=20the=20zone=20s?= =?UTF-8?q?tuck=20at=20the=20density-1=20value=20(icon=20at=20its=2040=20p?= =?UTF-8?q?x=20floor,=2040=20=C3=97=201.70=20=3D=2068=20logical)=20while?= =?UTF-8?q?=20the=20surface=20grew=20to=2087,=20a=20maximized=20window=20o?= =?UTF-8?q?verlapped=20the=20top=20fifth=20of=20the=20dock=20=E2=80=94=20m?= =?UTF-8?q?easured=20on=20a=201.75=20output=20as=20151=20physical=20px=20o?= =?UTF-8?q?f=20painted=20dock=20against=20a=20120=20px=20reserved=20band.?= =?UTF-8?q?=20The=20zone=20now=20goes=20through=20the=20same=20divisor=20a?= =?UTF-8?q?s=20the=20size,=20with=20`-1`=20(ignore=20other=20zones)=20and?= =?UTF-8?q?=20`0`=20(reserve=20nothing)=20passing=20through=20untouched=20?= =?UTF-8?q?as=20the=20sentinels=20they=20are,=20and=20`SurfaceState`=20tra?= =?UTF-8?q?cks=20`last=5Frequested=5Fzone`=20so=20the=20reconcile=20loop?= =?UTF-8?q?=20re-sends=20it=20whenever=20the=20spec=20moves,=20committing?= =?UTF-8?q?=20once=20for=20both.=20Slider::on=5Frelease=20/=20VSlider::on?= =?UTF-8?q?=5Frelease=20(widget/slider,=20widget/vslider,=20widget/handler?= =?UTF-8?q?s.rs,=20widget/element.rs,=20input/gesture).=20Sliders=20only?= =?UTF-8?q?=20had=20`on=5Fchange`,=20which=20fires=20on=20every=20motion?= =?UTF-8?q?=20event,=20so=20an=20app=20whose=20commit=20is=20expensive=20?= =?UTF-8?q?=E2=80=94=20a=20subprocess,=20a=20D-Bus=20round=20trip,=20a=20c?= =?UTF-8?q?ompositor=20reconfigure=20=E2=80=94=20paid=20for=20it=20per=20p?= =?UTF-8?q?ixel=20of=20travel:=20dragging=20the=20text-scale=20slider=20in?= =?UTF-8?q?=20Eydos=20Settings=20wrote=20`gsettings`=20once=20per=20motion?= =?UTF-8?q?=20and=20swept=20the=20whole=20desktop=20through=20a=20repaint?= =?UTF-8?q?=20each=20time.=20The=20new=20builder=20fires=20once=20with=20t?= =?UTF-8?q?he=20final=20value=20when=20the=20drag=20ends,=20leaving=20`on?= =?UTF-8?q?=5Fchange`=20to=20move=20the=20thumb=20and=20nothing=20else;=20?= =?UTF-8?q?`on=5Fchange`=20alone=20behaves=20exactly=20as=20before,=20and?= =?UTF-8?q?=20the=20mapped=20variant=20propagates=20through=20`map=5Fmsg`?= =?UTF-8?q?=20like=20its=20sibling.=20The=20handler=20snapshot=20carries?= =?UTF-8?q?=20the=20callback=20next=20to=20`on=5Fchange`=20and=20exposes?= =?UTF-8?q?=20`slider=5Frelease=5Fmsg`;=20the=20gesture=20machine's=20slid?= =?UTF-8?q?er=20branch=20of=20`on=5Frelease`,=20which=20previously=20retur?= =?UTF-8?q?ned=20an=20empty=20event=20list,=20now=20resolves=20the=20widge?= =?UTF-8?q?t=20through=20`find=5Fwidget`,=20recomputes=20the=20value=20fro?= =?UTF-8?q?m=20the=20release=20position=20with=20the=20same=20`slider=5Fva?= =?UTF-8?q?lue=5Ffrom=5Fpos`=20the=20drag=20path=20uses,=20and=20pushes=20?= =?UTF-8?q?`ReleaseEvent::PushMsg`=20=E2=80=94=20the=20variant=20whose=20d?= =?UTF-8?q?ocumentation=20already=20described=20"button=20press=20or=20fin?= =?UTF-8?q?al=20slider=20value=20on=20release".=20A=20unit=20test=20covers?= =?UTF-8?q?=20the=20new=20emission;=20the=20existing=20one=20asserting=20a?= =?UTF-8?q?n=20empty=20release=20still=20holds,=20because=20it=20passes=20?= =?UTF-8?q?an=20empty=20widget=20list=20and=20the=20lookup=20finds=20nothi?= =?UTF-8?q?ng.=20ListItem=20elision=20(widget/list=5Fitem/mod.rs).=20The?= =?UTF-8?q?=20label=20and=20subtitle=20were=20painted=20with=20`draw=5Ftex?= =?UTF-8?q?t`=20and=20no=20width=20budget,=20so=20a=20row=20title=20longer?= =?UTF-8?q?=20than=20its=20width=20ran=20under=20the=20trailing=20text=20o?= =?UTF-8?q?r=20the=20disclosure=20icon=20instead=20of=20truncating.=20The?= =?UTF-8?q?=20trailing=20slots=20are=20now=20measured=20and=20positioned?= =?UTF-8?q?=20before=20the=20text=20is=20painted=20=E2=80=94=20their=20ext?= =?UTF-8?q?ent=20is=20what=20decides=20how=20much=20room=20the=20label=20h?= =?UTF-8?q?as=20=E2=80=94=20and=20both=20lines=20are=20elided=20with=20an?= =?UTF-8?q?=20ellipsis=20against=20the=20space=20left=20over,=20accumulati?= =?UTF-8?q?ng=20per-character=20widths=20the=20way=20`Text`=20already=20di?= =?UTF-8?q?d,=20with=20one=20icon=20gap=20kept=20between=20the=20text=20an?= =?UTF-8?q?d=20whatever=20follows=20it.=20`Text`=20keeps=20its=20own=20inl?= =?UTF-8?q?ine=20copy=20of=20that=20algorithm=20for=20now;=20folding=20the?= =?UTF-8?q?=20two=20into=20a=20single=20crate-internal=20helper=20is=20the?= =?UTF-8?q?=20natural=20follow-up,=20and=20a=20precondition=20for=20teachi?= =?UTF-8?q?ng=20`Button`=20to=20elide=20once=20`Row`=20learns=20to=20distr?= =?UTF-8?q?ibute=20a=20width=20deficit=20instead=20of=20only=20leftover=20?= =?UTF-8?q?space.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 + docs/widgets.md | 6 +++ src/event_loop/overlays_reconcile.rs | 27 ++++++++-- src/event_loop/surface.rs | 6 +++ src/input/gesture/mod.rs | 16 ++++-- src/input/gesture/tests.rs | 40 ++++++++++++++ src/widget/element.rs | 18 ++++--- src/widget/handlers.rs | 25 ++++++++- src/widget/list_item/mod.rs | 79 ++++++++++++++++++++++------ src/widget/slider/mod.rs | 19 +++++++ src/widget/vslider/mod.rs | 18 +++++++ 11 files changed, 223 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 393f8e5..6f9326f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to `ltk` are documented here. The format is based on [Keep a ### Added +- **`Slider::on_release` / `VSlider::on_release`** — fired once with the final value when the drag ends, so an app can keep an expensive commit (a subprocess, a D-Bus round trip, a compositor reconfigure) off the per-motion `on_change` path and still move the thumb live. The gesture machine emits it from the slider branch of `on_release`; `on_change` alone behaves exactly as before. - **`Viewport::local_viewport()`** — resolve the child's viewport-relative (`vw` / `vh` / `vmin`) and fluid `Length`s against the viewport's own rect instead of the root layout viewport the sub-canvas inherits. For fixed-size floating mini-UIs (a phone-shaped panel pinned to a corner of a desktop-wide surface) whose content is calibrated against the panel rect; scroll-like clips should keep the default inheritance. - **`ListItem::height( impl Into )` / `ListItem::font_size( impl Into )`** — override the theme row height (floored at the label's rendered height so text never clips) and the primary-label font size, mirroring the `Toggle` / `Radio` `height()` builders, so dense menus can trade the touch-target generosity for row density. - **Accessibility text scale** — `set_text_scale` / `text_scale` global multiplier (clamped `[0.5, 3.0]`) applied to every resolved font size (`Canvas::resolve_font` and the stock-widget `font_px` path); geometry is untouched. The run loop reads `org.gnome.desktop.interface text-scaling-factor` at startup and follows external changes via a `gsettings monitor` watcher thread, repainting on change — every ltk app tracks the desktop's "large text" setting live with no app-side wiring (silently fixed at 1.0 when `gsettings` is missing). Embedders driving `core::UiSurface` call `set_text_scale` themselves. @@ -41,6 +42,7 @@ All notable changes to `ltk` are documented here. The format is based on [Keep a ### Fixed +- **`ListItem` labels no longer run under the trailing slot.** The label and subtitle were painted with no width limit, so a title longer than the row overlapped the trailing text or the disclosure icon instead of truncating. The trailing slots are now laid out first and both lines are elided with an ellipsis against the space they leave, matching what `Text` already did. - **Text inputs no longer insert mid-string when the value grows after focus.** Focusing a text input pinned the cursor to a snapshot of `value.len()`; if the value kept growing without the widget seeing keystrokes (a field fed over IPC), the first normally-delivered key inserted at the stale position. The focus-time cursor is now an end-of-value sentinel that every consumer clamps to the current value length, collapsing to a concrete position on the first real keystroke or click. - **Single-line caret height now follows the text line.** The caret spanned `rect.height - 16`, so a field taller than its text line grew an oversized caret; it now measures `font_size + 4` and is vertically centered like the text, matching the multiline caret. - **Dialog action buttons no longer overflow the card on large surfaces.** The card's width cap was a fixed 480 px while the stock buttons inside grow fluidly with the surface, so on windows past the design size the right-aligned action row ran off the card's right edge. `Dialog::max_width` now takes `impl Into` (f32 call sites keep compiling as fixed px) and the default is `Length::fluid( 480.0 )`, matching the buttons' scaling curve. diff --git a/docs/widgets.md b/docs/widgets.md index 236a7eb..260f46f 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -269,6 +269,12 @@ slider( self.brightness ) # }} ``` +`on_release( f )` fires once with the final value when the drag ends. +Pair it with `on_change` — which keeps moving the thumb — when the commit +is expensive (a subprocess, a D-Bus round trip, a compositor +reconfigure): a sweep would otherwise pay for it on every motion event. +`vslider` carries the same builder. + `accent_thumb( true )` swaps the default thumb for the two-circle brand-coloured variant. `track_surface( id )` and `fill_surface( id )` override the default theme slots. diff --git a/src/event_loop/overlays_reconcile.rs b/src/event_loop/overlays_reconcile.rs index 187af80..1027561 100644 --- a/src/event_loop/overlays_reconcile.rs +++ b/src/event_loop/overlays_reconcile.rs @@ -129,6 +129,13 @@ pub( super ) fn reconcile_overlays( data: &mut AppData ) ( h as f32 / parent_scale ).round() as u32, ) }; + // The exclusive zone measures the same band the surface occupies, so it + // lives in the same space and needs the same conversion. `-1` (ignore + // other zones) and `0` (reserve nothing) are sentinels, not distances. + let to_logical_zone = move | z: i32 | -> i32 + { + if z > 0 { ( z as f32 / parent_scale ).round() as i32 } else { z } + }; // Snapshot the previous-frame anchor lookup table so we can resolve // `anchor_widget_id` → `Rect` without holding a borrow on `data.main` // across the overlay-mut loop below. @@ -148,14 +155,23 @@ pub( super ) fn reconcile_overlays( data: &mut AppData ) // sends a configure; the usual `on_configure` path picks up the // new dimensions and drives the redraw. Popups don't grow / // shrink mid-life — close and reopen instead. - if resolved_size != ss.last_requested_size + let resolved_zone = to_logical_zone( spec.exclusive_zone ); + if resolved_size != ss.last_requested_size || resolved_zone != ss.last_requested_zone { if let SurfaceKind::Layer( ref layer_surface ) = ss.surface { - let ( lw, lh ) = to_logical_size( resolved_size ); - layer_surface.set_size( lw, lh ); + if resolved_size != ss.last_requested_size + { + let ( lw, lh ) = to_logical_size( resolved_size ); + layer_surface.set_size( lw, lh ); + ss.last_requested_size = resolved_size; + } + if resolved_zone != ss.last_requested_zone + { + layer_surface.set_exclusive_zone( resolved_zone ); + ss.last_requested_zone = resolved_zone; + } layer_surface.commit(); - ss.last_requested_size = resolved_size; } } // Compare the anchor at integer logical-pixel resolution: @@ -301,7 +317,7 @@ pub( super ) fn reconcile_overlays( data: &mut AppData ) let cfg = LayerConfig { layer: spec.layer.to_wlr_layer(), - exclusive_zone: spec.exclusive_zone, + exclusive_zone: to_logical_zone( spec.exclusive_zone ), anchor: spec.anchor, size: to_logical_size( resolved_size ), keyboard_exclusive: spec.keyboard_exclusive, @@ -319,6 +335,7 @@ pub( super ) fn reconcile_overlays( data: &mut AppData ) // lands a frame or two later. ss.scale_factor = parent_scale_i; ss.last_requested_size = resolved_size; + ss.last_requested_zone = to_logical_zone( spec.exclusive_zone ); ss.layer_anchor = Some( spec.anchor ); overlays_m.insert( spec.id, ss ); } diff --git a/src/event_loop/surface.rs b/src/event_loop/surface.rs index b49bd6f..05a524a 100644 --- a/src/event_loop/surface.rs +++ b/src/event_loop/surface.rs @@ -276,6 +276,11 @@ pub( crate ) struct SurfaceState /// for slide-down / grow animations). `(0, 0)` for non-layer surfaces /// and for layer surfaces before their first configure. pub last_requested_size: ( u32, u32 ), + /// Logical exclusive zone last sent with `set_exclusive_zone`. Tracked + /// alongside the size because a zone that stays at its creation-time + /// value while the surface keeps resizing leaves the compositor + /// reserving a band that no longer matches what the overlay paints. + pub last_requested_zone: i32, pub layer_anchor: Option, /// Anchor rect the xdg-popup positioner was last configured with. /// `None` for non-popup surfaces. @@ -321,6 +326,7 @@ impl SurfaceState titlebar_close_rect: Rect::default(), scale_factor: 1, last_requested_size: ( 0, 0 ), + last_requested_zone: 0, layer_anchor: None, last_popup_anchor: None, popup_reposition_token: 0, diff --git a/src/input/gesture/mod.rs b/src/input/gesture/mod.rs index 934c301..805c4b5 100644 --- a/src/input/gesture/mod.rs +++ b/src/input/gesture/mod.rs @@ -475,7 +475,7 @@ impl GestureState ) -> Vec> { let pressed = self.pressed_idx.take(); - let was_dragging_slider = self.dragging_slider.is_some(); + let released_slider = self.dragging_slider; let long_press_fired = self.long_press_fired; let horizontal_drag_started = self.horizontal_drag_started; let vertical_drag_started = self.vertical_drag_started; @@ -504,11 +504,21 @@ impl GestureState return events; } - // Slider drag complete — not a swipe, not a tap. - if was_dragging_slider + // Slider drag complete — not a swipe, not a tap. The final value + // goes out once here so an app can keep an expensive commit off + // the per-motion `on_change` path. + if let Some( slider_idx ) = released_slider { self.scroll_drag_started = false; self.start = None; + if let Some( w ) = find_widget( widget_rects, slider_idx ) + { + let value = w.handlers.slider_value_from_pos( w.rect, pos ); + if let Some( msg ) = w.handlers.slider_release_msg( value ) + { + events.push( ReleaseEvent::PushMsg( msg ) ); + } + } return events; } diff --git a/src/input/gesture/tests.rs b/src/input/gesture/tests.rs index bca597c..290332b 100644 --- a/src/input/gesture/tests.rs +++ b/src/input/gesture/tests.rs @@ -10,6 +10,7 @@ enum Msg { Pressed, LongPressed, + SliderReleased, } fn rect( x: f32, y: f32, w: f32, h: f32 ) -> Rect @@ -48,6 +49,33 @@ fn button_full( } } +fn slider_widget( idx: usize, r: Rect, on_release: Option ) -> LaidOutWidget +{ + LaidOutWidget + { + rect: r, + flat_idx: idx, + id: None, + paint_rect: r, + handlers: WidgetHandlers::Slider + { + on_change: None, + on_release: on_release.map( |m| -> std::sync::Arc Msg> + { + std::sync::Arc::new( move |_| m.clone() ) + } ), + axis: crate::widget::slider::SliderAxis::Horizontal, + value: 0.0, + thumb_px: 0.0, + }, + keyboard_focusable: true, + cursor: crate::types::CursorShape::Default, + tooltip: None, + accessible_label: None, + is_live_region: false, + } +} + fn cfg_full( w: u32, h: u32 ) -> SwipeConfig { SwipeConfig @@ -546,6 +574,18 @@ fn release_after_slider_drag_emits_no_events() assert!( g.dragging_slider.is_none() ); } +#[ test ] +fn release_after_slider_drag_emits_release_msg() +{ + let widgets = vec![ slider_widget( 1, rect( 0.0, 0.0, 100.0, 20.0 ), Some( Msg::SliderReleased ) ) ]; + let mut g = GestureState::::new(); + g.start = Some( pt( 50.0, 10.0 ) ); + g.dragging_slider = Some( 1 ); + let events = g.on_release( pt( 50.0, 10.0 ), &widgets, &cfg_full( 800, 1200 ), false ); + assert!( matches!( events.as_slice(), [ ReleaseEvent::PushMsg( Msg::SliderReleased ) ] ) ); + assert!( g.dragging_slider.is_none() ); +} + #[ test ] fn release_after_consumed_scroll_emits_no_events() { diff --git a/src/widget/element.rs b/src/widget/element.rs index 2eae9e6..1b57a66 100644 --- a/src/widget/element.rs +++ b/src/widget/element.rs @@ -326,24 +326,26 @@ impl Element { WidgetHandlers::Slider { - on_change: s.on_change.clone(), - axis: slider::SliderAxis::Horizontal, - value: s.value, + on_change: s.on_change.clone(), + on_release: s.on_release.clone(), + axis: slider::SliderAxis::Horizontal, + value: s.value, // Design-px fallback; the layout pass overwrites this with // the widget-scaling-resolved size via `set_slider_thumb_px`. - thumb_px: slider::thumb_design_px(), + thumb_px: slider::thumb_design_px(), } } Element::VSlider( s ) => { WidgetHandlers::Slider { - on_change: s.on_change.clone(), - axis: slider::SliderAxis::Vertical, - value: s.value, + on_change: s.on_change.clone(), + on_release: s.on_release.clone(), + axis: slider::SliderAxis::Vertical, + value: s.value, // The vertical axis maps against the full rect height and // takes no thumb inset, so this is unused. - thumb_px: 0.0, + thumb_px: 0.0, } } _ => WidgetHandlers::None, diff --git a/src/widget/handlers.rs b/src/widget/handlers.rs index 31a9706..42c46f6 100644 --- a/src/widget/handlers.rs +++ b/src/widget/handlers.rs @@ -76,6 +76,10 @@ pub enum WidgetHandlers Slider { on_change: Option Msg>>, + /// Fired once when the drag ends, with the final value. Lets an app + /// keep an expensive commit (a subprocess, a D-Bus round trip, a + /// compositor reconfigure) off the per-motion path. + on_release: Option Msg>>, axis: slider::SliderAxis, value: f32, /// Thumb size resolved through the widget-scaling mode at layout @@ -142,9 +146,16 @@ impl Clone for WidgetHandlers password_toggle_msg: password_toggle_msg.clone(), } } - WidgetHandlers::Slider { on_change, axis, value, thumb_px } => + WidgetHandlers::Slider { on_change, on_release, axis, value, thumb_px } => { - WidgetHandlers::Slider { on_change: on_change.clone(), axis: *axis, value: *value, thumb_px: *thumb_px } + WidgetHandlers::Slider + { + on_change: on_change.clone(), + on_release: on_release.clone(), + axis: *axis, + value: *value, + thumb_px: *thumb_px, + } } } } @@ -247,6 +258,16 @@ impl WidgetHandlers } } + /// Build the `on_release` message for a Slider given its final value. + pub fn slider_release_msg( &self, value: f32 ) -> Option + { + match self + { + WidgetHandlers::Slider { on_release: Some( f ), .. } => Some( f( value ) ), + _ => None, + } + } + /// Compute the `[0.0, 1.0]` value for the slider this handler belongs to, /// given a pointer position inside its layout rect. Dispatches on the /// stored [`slider::SliderAxis`] so the same call site in `input.rs` drives diff --git a/src/widget/list_item/mod.rs b/src/widget/list_item/mod.rs index 134d875..8a78244 100644 --- a/src/widget/list_item/mod.rs +++ b/src/widget/list_item/mod.rs @@ -280,36 +280,56 @@ impl ListItem rect.x + pad_h }; - canvas.draw_text( &self.label, text_x, label_y, label_size, label_color ); - - if let Some( ref sub ) = self.subtitle - { - let sub_size = canvas.font_px( theme::SUBTITLE_SIZE ); - let sub_y = rect.y + rect.height * 0.62 + sub_size * 0.3; - canvas.draw_text( sub, text_x, sub_y, sub_size, subtitle_color ); - } - + // The trailing slots are laid out before the label is painted: the + // label has to be elided against the space they leave, or a long + // title runs under the disclosure arrow. let mut trail_right = rect.x + rect.width - pad_h; + let icon_gap = canvas.geom_px( theme::ICON_GAP ); - if let Some( ( rgba, w, h ) ) = &self.trailing_icon + let mut trail_icon_rect = None; + if self.trailing_icon.is_some() { let icon_size = canvas.geom_px( theme::TRAILING_ICON_SIZE ); - let icon_rect = Rect + let r = Rect { x: trail_right - icon_size, y: rect.y + ( rect.height - icon_size ) / 2.0, width: icon_size, height: icon_size, }; - canvas.draw_image_data( rgba, *w, *h, icon_rect, 1.0 ); - trail_right = icon_rect.x - canvas.geom_px( theme::ICON_GAP ); + trail_right = r.x - icon_gap; + trail_icon_rect = Some( r ); } + let mut trail_text = None; if let Some( ref trail ) = self.trailing { let trail_size = canvas.font_px( theme::TRAILING_SIZE ); - let tw = canvas.measure_text( trail, trail_size ); - let tx = trail_right - tw; + let tx = trail_right - canvas.measure_text( trail, trail_size ); + trail_right = tx - icon_gap; + trail_text = Some( ( tx, trail_size ) ); + } + + let text_max = ( trail_right - text_x ).max( 0.0 ); + + let label_text = elide( canvas, &self.label, label_size, text_max ); + canvas.draw_text( &label_text, text_x, label_y, label_size, label_color ); + + if let Some( ref sub ) = self.subtitle + { + let sub_size = canvas.font_px( theme::SUBTITLE_SIZE ); + let sub_y = rect.y + rect.height * 0.62 + sub_size * 0.3; + let sub_text = elide( canvas, sub, sub_size, text_max ); + canvas.draw_text( &sub_text, text_x, sub_y, sub_size, subtitle_color ); + } + + if let ( Some( r ), Some( ( rgba, w, h ) ) ) = ( trail_icon_rect, &self.trailing_icon ) + { + canvas.draw_image_data( rgba, *w, *h, r, 1.0 ); + } + + if let ( Some( ( tx, trail_size ) ), Some( trail ) ) = ( trail_text, &self.trailing ) + { let ty = rect.y + ( rect.height + trail_size ) / 2.0 - 2.0; canvas.draw_text( trail, tx, ty, trail_size, trailing_color ); } @@ -337,6 +357,35 @@ impl ListItem } } +/// Shorten `text` with a trailing ellipsis so it fits `max_w`, mirroring the +/// single-line truncation [`super::text::Text`] applies. +fn elide( canvas: &Canvas, text: &str, size: f32, max_w: f32 ) -> String +{ + if max_w <= 0.0 + { + return String::new(); + } + if canvas.measure_text( text, size ) <= max_w + { + return text.to_string(); + } + + let ellipsis = "..."; + let budget = max_w - canvas.measure_text( ellipsis, size ); + if budget <= 0.0 + { + return ellipsis.to_string(); + } + + let mut accum = 0.0_f32; + let kept: String = text.chars().take_while( |ch| + { + accum += canvas.measure_text( &ch.to_string(), size ); + accum <= budget + } ).collect(); + format!( "{kept}{ellipsis}" ) +} + /// Create a [`ListItem`] with the given primary label. /// /// Add detail and behaviour through the chained builders. For a diff --git a/src/widget/slider/mod.rs b/src/widget/slider/mod.rs index 457f492..a45be8e 100644 --- a/src/widget/slider/mod.rs +++ b/src/widget/slider/mod.rs @@ -134,6 +134,9 @@ pub struct Slider /// `Arc` (not `Box`) so the layout pass can clone it into the per-leaf /// handler snapshot for O(1) dispatch on input events. pub( crate ) on_change: Option Msg>>, + /// Callback invoked once with the final value when the drag ends, so an + /// app can keep an expensive commit off the per-motion path. + pub( crate ) on_release: Option Msg>>, /// Theme slot id for the track background pill. Defaults to the /// generic `surface-slider-track`; override per-instance to opt /// into the `-flat` (no per-surface backdrop) variant when the @@ -165,6 +168,7 @@ impl Slider { value: value.clamp( 0.0, 1.0 ), on_change: None, + on_release: None, track_surface: theme::SURFACE_TRACK, fill_surface: theme::SURFACE_FILL, accent_thumb: false, @@ -190,6 +194,15 @@ impl Slider self } + /// Set the callback invoked once when the drag ends, with the final + /// value. Pair it with [`Self::on_change`] to move the slider without + /// paying for the commit on every motion event. + pub fn on_release( mut self, f: impl Fn(f32) -> Msg + 'static ) -> Self + { + self.on_release = Some( Arc::new( f ) ); + self + } + /// Override the theme slot id used to paint the track background /// — pass `surface-slider-track-flat` (or any other surface) to /// drop the per-instance backdrop blur when the slider already @@ -407,10 +420,16 @@ impl Slider let mapper = Arc::clone( f ); Arc::new( move |v| ( *mapper )( ( *old )( v ) ) ) } ); + let on_release = self.on_release.map( |old| -> Arc U> + { + let mapper = Arc::clone( f ); + Arc::new( move |v| ( *mapper )( ( *old )( v ) ) ) + } ); Slider { value: self.value, on_change, + on_release, track_surface: self.track_surface, fill_surface: self.fill_surface, accent_thumb: self.accent_thumb, diff --git a/src/widget/vslider/mod.rs b/src/widget/vslider/mod.rs index 44e21b7..8493065 100644 --- a/src/widget/vslider/mod.rs +++ b/src/widget/vslider/mod.rs @@ -73,6 +73,9 @@ pub struct VSlider /// dragged. `Arc` (not `Box`) so the layout pass can clone it into the /// per-leaf handler snapshot for O(1) dispatch on input events. pub( crate ) on_change: Option Msg>>, + /// Callback invoked once with the final value when the drag ends, so an + /// app can keep an expensive commit off the per-motion path. + pub( crate ) on_release: Option Msg>>, /// Theme slot id for the unfilled track. Defaults to /// `surface-slider-track`. Override with [`VSlider::track_surface`] /// when the slider lives inside a panel that already provides its @@ -96,6 +99,7 @@ impl VSlider width: Length::widget( theme::WIDTH ), height: Length::widget( theme::HEIGHT ), on_change: None, + on_release: None, track_surface: theme::SURFACE_TRACK, fill_surface: theme::SURFACE_FILL, } @@ -135,6 +139,14 @@ impl VSlider self } + /// Set the callback invoked once when the drag ends, with the final + /// value. Counterpart of [`Self::on_change`] for expensive commits. + pub fn on_release( mut self, f: impl Fn(f32) -> Msg + 'static ) -> Self + { + self.on_release = Some( Arc::new( f ) ); + self + } + /// Return the preferred `(width, height)`. `max_width` is ignored — see /// the type-level docs on intrinsic sizing. pub fn preferred_size( &self, _max_width: f32, canvas: &Canvas ) -> (f32, f32) @@ -295,12 +307,18 @@ impl VSlider let mapper = Arc::clone( f ); Arc::new( move |v| ( *mapper )( ( *old )( v ) ) ) } ); + let on_release = self.on_release.map( |old| -> Arc U> + { + let mapper = Arc::clone( f ); + Arc::new( move |v| ( *mapper )( ( *old )( v ) ) ) + } ); VSlider { value: self.value, width: self.width, height: self.height, on_change, + on_release, track_surface: self.track_surface, fill_surface: self.fill_surface, }