ltk/widget/radio/mod.rs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>
use crate::types::{ Rect, WidgetId };
use crate::render::Canvas;
use super::Element;
mod theme;
#[cfg(test)]
mod tests;
/// One option inside a mutually-exclusive group.
///
/// Renders a circle with a centred dot when selected. Unlike
/// [`Checkbox`](super::checkbox::Checkbox), a radio is meaningful only as
/// part of a group: the group is the application's responsibility — define
/// an enum for the choices, store the current value, and build one `Radio`
/// per variant with `selected = current == this_variant`.
///
/// ```rust,no_run
/// # use ltk::{ column, radio, Element };
/// #[ derive( Clone, PartialEq ) ]
/// enum Priority { Low, Medium, High }
/// # #[ derive( Clone ) ] enum Msg { SetPriority( Priority ) }
/// # struct App { priority: Priority }
/// # impl App { fn _ex( &self ) -> Element<Msg> {
/// // In view():
/// column()
/// .push( radio( self.priority == Priority::Low ).label( "Low" ).on_select( Msg::SetPriority( Priority::Low ) ) )
/// .push( radio( self.priority == Priority::Medium ).label( "Medium" ).on_select( Msg::SetPriority( Priority::Medium ) ) )
/// .push( radio( self.priority == Priority::High ).label( "High" ).on_select( Msg::SetPriority( Priority::High ) ) )
/// .into()
/// # }}
/// ```
///
/// `ltk` does not enforce mutual exclusion automatically; the application's
/// `update` decides which variant becomes active when a radio is selected.
pub struct Radio<Msg: Clone>
{
/// Whether this option is currently selected. Drawn from this field
/// every frame.
pub selected: bool,
/// Message emitted when the user picks this option. `None` leaves the
/// radio inert.
pub on_select: Option<Msg>,
/// Optional label drawn to the right of the circle.
pub label: Option<String>,
/// Optional stable identifier for focus management.
pub id: Option<WidgetId>,
}
impl<Msg: Clone> Radio<Msg>
{
/// Create a radio in the given state, with no label and no callback.
pub fn new( selected: bool ) -> Self
{
Self { selected, on_select: None, label: None, id: None }
}
/// Set the message emitted when this option is picked. The
/// application's `update` is responsible for flipping the group's
/// current value.
pub fn on_select( mut self, msg: Msg ) -> Self
{
self.on_select = Some( msg );
self
}
/// Set a text label rendered to the right of the circle.
pub fn label( mut self, label: impl Into<String> ) -> Self
{
self.label = Some( label.into() );
self
}
/// Assign a stable identifier for focus management.
pub fn id( mut self, id: WidgetId ) -> Self
{
self.id = Some( id );
self
}
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 );
( theme::OUTER_SIZE + theme::GAP + text_w ).min( max_width )
} else {
theme::OUTER_SIZE.min( max_width )
};
( w, theme::HEIGHT )
}
/// Focus ring on the outer circle extends `FOCUS_W + 2 + FOCUS_W/2 ≈ 6.5 px`
/// beyond the circle (which sits flush with the widget's left edge).
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 circle_y = rect.y + ( rect.height - theme::OUTER_SIZE ) / 2.0;
let outer_r = theme::OUTER_SIZE / 2.0;
let outer_rect = Rect
{
x: rect.x,
y: circle_y,
width: theme::OUTER_SIZE,
height: theme::OUTER_SIZE,
};
if self.selected
{
canvas.fill_rect( outer_rect, theme::selected(), outer_r );
let dot_r = theme::DOT_SIZE / 2.0;
let cx = rect.x + outer_r;
let cy = circle_y + outer_r;
let dot_rect = Rect
{
x: cx - dot_r,
y: cy - dot_r,
width: theme::DOT_SIZE,
height: theme::DOT_SIZE,
};
canvas.fill_rect( dot_rect, theme::dot_color(), dot_r );
} else {
canvas.stroke_rect( outer_rect, theme::ring_color(), theme::BORDER_W, outer_r );
}
if focused
{
let ring = outer_rect.expand( theme::FOCUS_W + 2.0 );
canvas.stroke_rect( ring, theme::focus_color(), theme::FOCUS_W, outer_r + theme::FOCUS_W + 2.0 );
}
if let Some( ref label ) = self.label
{
let text_x = rect.x + theme::OUTER_SIZE + theme::GAP;
let text_y = rect.y + ( rect.height + theme::FONT_SIZE ) / 2.0 - 2.0;
canvas.draw_text( label, text_x, text_y, theme::FONT_SIZE, theme::label_color() );
}
}
pub( crate ) fn map_msg<U>( self, f: &super::MapFn<Msg, U> ) -> Radio<U>
where
U: Clone + 'static,
Msg: 'static,
{
Radio
{
selected: self.selected,
on_select: self.on_select.map( |m| ( *f )( m ) ),
label: self.label,
id: self.id,
}
}
}
/// Create a [`Radio`] option in the given state.
///
/// Shorthand for [`Radio::new`]. See the [`Radio`] type-level docs for
/// the full mutual-exclusion pattern across multiple options.
pub fn radio<Msg: Clone>( selected: bool ) -> Radio<Msg>
{
Radio::new( selected )
}
impl<Msg: Clone + 'static> From<Radio<Msg>> for Element<Msg>
{
fn from( r: Radio<Msg> ) -> Self
{
Element::Radio( r )
}
}