// SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. 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 { /// // 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( 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, /// Optional label drawn to the left of the track. pub( crate ) label: Option, /// Optional stable identifier for focus management. pub( crate ) id: Option, /// Optional override of the theme row height. pub( crate ) height: 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, 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 ) -> 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 ) -> 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( 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, 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 { /// 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 ) } }