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