// SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. use crate::types::{ Rect, WidgetId }; use crate::render::Canvas; use super::Element; mod theme { use crate::types::Color; pub fn track_off() -> Color { crate::theme::palette().divider } pub fn track_on() -> Color { crate::theme::palette().accent } /// Thumb — uses the page-background colour so it reads as the /// inverse of the track fill regardless of mode. pub fn thumb() -> Color { crate::theme::palette().bg } pub fn thumb_border() -> Color { crate::theme::palette().text_primary } pub fn focus_color() -> Color { crate::theme::palette().accent } pub fn label_color() -> Color { crate::theme::palette().text_primary } pub const TRACK_W: f32 = 52.0; pub const TRACK_H: f32 = 30.0; pub const THUMB_SIZE: f32 = 24.0; pub const HEIGHT: f32 = 48.0; pub const GAP: f32 = 12.0; pub const FOCUS_W: f32 = 3.0; pub const THUMB_BORDER_W: f32 = 1.0; pub const FONT_SIZE: f32 = 16.0; } /// 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 { /// // 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 { /// Current on / off state. Drawn from this field every frame; the /// runtime never mutates it. pub value: bool, /// Message emitted on activation. `None` leaves the toggle inert (it /// still renders and takes focus, but does nothing on press). pub on_toggle: Option, /// Optional label drawn to the left of the track. pub label: Option, /// Optional stable identifier for focus management. pub id: Option, } impl Toggle { /// 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 } } /// 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 ) -> 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 } /// 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 w = if let Some( ref label ) = self.label { let text_w = canvas.measure_text( label, theme::FONT_SIZE ); ( text_w + theme::GAP + theme::TRACK_W ).min( max_width ) } else { theme::TRACK_W.min( max_width ) }; ( w, theme::HEIGHT ) } /// 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 track_x = if let Some( ref label ) = self.label { let text_y = rect.y + ( rect.height + theme::FONT_SIZE ) / 2.0 - 2.0; canvas.draw_text( label, rect.x, text_y, theme::FONT_SIZE, theme::label_color() ); rect.x + rect.width - theme::TRACK_W } else { rect.x + ( rect.width - theme::TRACK_W ) / 2.0 }; let track_y = rect.y + ( rect.height - theme::TRACK_H ) / 2.0; let track_r = theme::TRACK_H / 2.0; let track_rect = Rect { x: track_x, y: track_y, width: theme::TRACK_W, height: theme::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 = ( theme::TRACK_H - theme::THUMB_SIZE ) / 2.0; let thumb_cx = if self.value { track_x + theme::TRACK_W - thumb_pad - theme::THUMB_SIZE / 2.0 } else { track_x + thumb_pad + theme::THUMB_SIZE / 2.0 }; let thumb_cy = track_y + theme::TRACK_H / 2.0; let thumb_r = theme::THUMB_SIZE / 2.0; let thumb_rect = Rect { x: thumb_cx - thumb_r, y: thumb_cy - thumb_r, width: theme::THUMB_SIZE, height: theme::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( self, f: &super::MapFn ) -> Toggle 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, } } } /// 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 { /// toggle( self.wifi_enabled ) /// .label( "Wi-Fi" ) /// .on_toggle( Msg::ToggleWifi ) /// # }} /// ``` pub fn toggle( value: bool ) -> Toggle { Toggle::new( value ) } impl From> for Element { fn from( t: Toggle ) -> Self { Element::Toggle( t ) } } #[cfg(test)] mod tests { use super::*; #[test] fn toggle_default_state() { let t = toggle::<()>( true ); assert!( t.value ); assert!( t.on_toggle.is_none() ); assert!( t.label.is_none() ); } #[test] fn toggle_off_state() { let t = toggle::<()>( false ); assert!( !t.value ); } }