Files
ltk/src/widget/toggle/mod.rs
T
admin 477ef13ff4
CI / build + test (push) Has been cancelled
CI / cargo audit (push) Has been cancelled
toggle, widget: scale the pill with an explicit row height, and give elide a half-pixel tolerance
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.
2026-08-09 12:07:35 +02:00

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 )
}
}