Toggle::height used to adjust only the row: the resolved value was floored at the theme track height and the pill kept its theme size, so a toggle capped below the fluid row height still rendered a full-size pill — on large surfaces, visibly out of scale next to controls that honour their cap. The floor is gone; when the resolved height falls below the theme row height, track and thumb now scale down proportionally (never up — the factor is capped at 1), and preferred_size reports the scaled track width so layout, focus ring and centring stay consistent. A toggle without an explicit height is untouched. elide compared measure( text ) <= max_w strictly, which breaks when the caller sized itself from the same measurement: a button reports text + 2×pad as its preferred width, the layout grants exactly that, and draw hands elide back rect.width − 2×pad. In f32 the add-then-subtract round-trip can land a few ULP under the original measurement, the strict comparison fails, and the truncation branch then costs the full width of the ellipsis — a sub-pixel deficit turned "Empezar" into "Empez...". The fit check now allows half a pixel of slack, which absorbs any mismatch of this class while leaving genuine overflows to truncate as before.
235 lines
7.4 KiB
Rust
235 lines
7.4 KiB
Rust
// SPDX-License-Identifier: LGPL-2.1-only
|
|
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
|
|
|
|
use crate::types::{ Length, Rect, WidgetId };
|
|
use crate::render::Canvas;
|
|
use super::Element;
|
|
|
|
mod theme;
|
|
|
|
#[ cfg( test ) ]
|
|
mod tests;
|
|
|
|
/// A two-state on / off switch.
|
|
///
|
|
/// Renders as a horizontal pill with a circular thumb that slides between the
|
|
/// off (left, divider colour) and on (right, accent colour) positions. The
|
|
/// widget is stateless: the application owns `value` and rebuilds the toggle
|
|
/// from its current state on every frame. Tapping or pressing Enter / Space
|
|
/// while focused emits the message configured with [`Self::on_toggle`] — the
|
|
/// app's `update` is then expected to flip the bool and re-render.
|
|
///
|
|
/// ```rust,no_run
|
|
/// # use ltk::{ toggle, Toggle };
|
|
/// # #[ derive( Clone ) ] enum Msg { ToggleWifi }
|
|
/// # struct App { wifi_enabled: bool }
|
|
/// # impl App { fn _ex( &self ) -> Toggle<Msg> {
|
|
/// // In view():
|
|
/// toggle( self.wifi_enabled )
|
|
/// .label( "Wi-Fi" )
|
|
/// .on_toggle( Msg::ToggleWifi )
|
|
/// # }}
|
|
/// ```
|
|
///
|
|
/// See also [`Checkbox`](super::checkbox::Checkbox) for binary opt-in
|
|
/// controls (terms acceptance, multi-select form fields) where the
|
|
/// pill-style affordance is too prominent.
|
|
pub struct Toggle<Msg: Clone>
|
|
{
|
|
/// Current on / off state. Drawn from this field every frame; the
|
|
/// runtime never mutates it.
|
|
pub( crate ) value: bool,
|
|
/// Message emitted on activation. `None` leaves the toggle inert (it
|
|
/// still renders and takes focus, but does nothing on press).
|
|
pub( crate ) on_toggle: Option<Msg>,
|
|
/// Optional label drawn to the left of the track.
|
|
pub( crate ) label: Option<String>,
|
|
/// Optional stable identifier for focus management.
|
|
pub( crate ) id: Option<WidgetId>,
|
|
/// Optional override of the theme row height.
|
|
pub( crate ) height: Option<Length>,
|
|
}
|
|
|
|
impl<Msg: Clone> Toggle<Msg>
|
|
{
|
|
/// Create a toggle in the given state, with no label and no callback.
|
|
///
|
|
/// Wire activation through [`Self::on_toggle`] before adding it to a
|
|
/// widget tree, otherwise the toggle is decorative.
|
|
pub fn new( value: bool ) -> Self
|
|
{
|
|
Self { value, on_toggle: None, label: None, id: None, height: None }
|
|
}
|
|
|
|
/// Set the message emitted when the toggle is activated (tap, Enter or
|
|
/// Space while focused). The application's `update` is responsible for
|
|
/// flipping `value` in response.
|
|
pub fn on_toggle( mut self, msg: Msg ) -> Self
|
|
{
|
|
self.on_toggle = Some( msg );
|
|
self
|
|
}
|
|
|
|
/// Set a text label rendered to the left of the track. The toggle's
|
|
/// preferred width grows to fit `label_width + gap + track_width`,
|
|
/// capped at the parent-supplied `max_width`.
|
|
pub fn label( mut self, label: impl Into<String> ) -> Self
|
|
{
|
|
self.label = Some( label.into() );
|
|
self
|
|
}
|
|
|
|
/// Assign a stable identifier so the application can target this
|
|
/// toggle through [`crate::App::take_focus_request`].
|
|
pub fn id( mut self, id: WidgetId ) -> Self
|
|
{
|
|
self.id = Some( id );
|
|
self
|
|
}
|
|
|
|
/// Override the preferred height (default: the theme row height,
|
|
/// `theme::HEIGHT` design px). Accepts any [`Length`] so dense
|
|
/// layouts can tie the row height to the viewport. When the resolved
|
|
/// value falls below the theme row height, the pill (track and thumb)
|
|
/// scales down proportionally so it never clips; it never grows
|
|
/// beyond its theme size.
|
|
pub fn height( mut self, h: impl Into<Length> ) -> Self
|
|
{
|
|
self.height = Some( h.into() );
|
|
self
|
|
}
|
|
|
|
/// Return the preferred `(width, height)` given available `max_width`.
|
|
///
|
|
/// Width is `track_width` for an unlabelled toggle, or
|
|
/// `label_width + gap + track_width` (clamped to `max_width`) when a
|
|
/// label is set. Height is the theme-defined row height.
|
|
pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> (f32, f32)
|
|
{
|
|
let h = match self.height
|
|
{
|
|
Some( l ) => canvas.resolve_geom( l ),
|
|
None => canvas.geom_px( theme::HEIGHT ),
|
|
};
|
|
let scale = ( h / canvas.geom_px( theme::HEIGHT ) ).min( 1.0 );
|
|
let track_w = canvas.geom_px( theme::TRACK_W ) * scale;
|
|
let w = if let Some( ref label ) = self.label
|
|
{
|
|
let text_w = canvas.measure_text( label, canvas.font_px( theme::FONT_SIZE ) );
|
|
( text_w + canvas.geom_px( theme::GAP ) + track_w ).min( max_width )
|
|
} else {
|
|
track_w.min( max_width )
|
|
};
|
|
( w, h )
|
|
}
|
|
|
|
/// Bounding box of everything painted at `rect` across all states. The
|
|
/// focus ring is drawn as `track_rect.expand( FOCUS_W + 2 )` with a stroke
|
|
/// of width `FOCUS_W`, so it extends `FOCUS_W + 2 + FOCUS_W/2 ≈ 6.5 px`
|
|
/// beyond the track. Since the track sits at the right edge of `rect`,
|
|
/// the ring can extend that far outside the widget's layout rect.
|
|
pub fn paint_bounds( &self, rect: Rect ) -> Rect
|
|
{
|
|
rect.expand( theme::FOCUS_W + 2.0 + theme::FOCUS_W * 0.5 + 1.0 )
|
|
}
|
|
|
|
pub fn draw( &self, canvas: &mut Canvas, rect: Rect, focused: bool )
|
|
{
|
|
let scale = ( rect.height / canvas.geom_px( theme::HEIGHT ) ).min( 1.0 );
|
|
let track_w = canvas.geom_px( theme::TRACK_W ) * scale;
|
|
let track_h = canvas.geom_px( theme::TRACK_H ) * scale;
|
|
let thumb_size = canvas.geom_px( theme::THUMB_SIZE ) * scale;
|
|
|
|
let track_x = if let Some( ref label ) = self.label
|
|
{
|
|
let fs = canvas.font_px( theme::FONT_SIZE );
|
|
let text_y = rect.y + ( rect.height + fs ) / 2.0 - 2.0;
|
|
canvas.draw_text( label, rect.x, text_y, fs, theme::label_color() );
|
|
rect.x + rect.width - track_w
|
|
} else {
|
|
rect.x + ( rect.width - track_w ) / 2.0
|
|
};
|
|
|
|
let track_y = rect.y + ( rect.height - track_h ) / 2.0;
|
|
let track_r = track_h / 2.0;
|
|
|
|
let track_rect = Rect
|
|
{
|
|
x: track_x,
|
|
y: track_y,
|
|
width: track_w,
|
|
height: track_h,
|
|
};
|
|
let track_color = if self.value { theme::track_on() } else { theme::track_off() };
|
|
canvas.fill_rect( track_rect, track_color, track_r );
|
|
|
|
let thumb_pad = ( track_h - thumb_size ) / 2.0;
|
|
let thumb_cx = if self.value
|
|
{
|
|
track_x + track_w - thumb_pad - thumb_size / 2.0
|
|
} else {
|
|
track_x + thumb_pad + thumb_size / 2.0
|
|
};
|
|
let thumb_cy = track_y + track_h / 2.0;
|
|
let thumb_r = thumb_size / 2.0;
|
|
let thumb_rect = Rect
|
|
{
|
|
x: thumb_cx - thumb_r,
|
|
y: thumb_cy - thumb_r,
|
|
width: thumb_size,
|
|
height: thumb_size,
|
|
};
|
|
canvas.fill_rect( thumb_rect, theme::thumb(), thumb_r );
|
|
canvas.stroke_rect( thumb_rect, theme::thumb_border(), theme::THUMB_BORDER_W, thumb_r );
|
|
|
|
if focused
|
|
{
|
|
let ring = track_rect.expand( theme::FOCUS_W + 2.0 );
|
|
canvas.stroke_rect( ring, theme::focus_color(), theme::FOCUS_W, track_r + theme::FOCUS_W + 2.0 );
|
|
}
|
|
}
|
|
|
|
pub( crate ) fn map_msg<U>( self, f: &super::MapFn<Msg, U> ) -> Toggle<U>
|
|
where
|
|
U: Clone + 'static,
|
|
Msg: 'static,
|
|
{
|
|
Toggle
|
|
{
|
|
value: self.value,
|
|
on_toggle: self.on_toggle.map( |m| ( *f )( m ) ),
|
|
label: self.label,
|
|
id: self.id,
|
|
height: self.height,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Create a [`Toggle`] in the given state.
|
|
///
|
|
/// Shorthand for [`Toggle::new`]. Wire activation with [`Toggle::on_toggle`]
|
|
/// and an optional label with [`Toggle::label`]:
|
|
///
|
|
/// ```rust,no_run
|
|
/// # use ltk::{ toggle, Toggle };
|
|
/// # #[ derive( Clone ) ] enum Msg { ToggleWifi }
|
|
/// # struct App { wifi_enabled: bool }
|
|
/// # impl App { fn _ex( &self ) -> Toggle<Msg> {
|
|
/// toggle( self.wifi_enabled )
|
|
/// .label( "Wi-Fi" )
|
|
/// .on_toggle( Msg::ToggleWifi )
|
|
/// # }}
|
|
/// ```
|
|
pub fn toggle<Msg: Clone>( value: bool ) -> Toggle<Msg>
|
|
{
|
|
Toggle::new( value )
|
|
}
|
|
|
|
impl<Msg: Clone + 'static> From<Toggle<Msg>> for Element<Msg>
|
|
{
|
|
fn from( t: Toggle<Msg> ) -> Self
|
|
{
|
|
Element::Toggle( t )
|
|
}
|
|
}
|