1use super::*;
4
5type StyleFn<'a> = Box<dyn Fn(&Theme, iced_radio::Status) -> iced_radio::Style + 'a>;
6
7pub struct Radio<'a, Message, Renderer = iced_widget::Renderer>
9where
10 Renderer: core_text::Renderer,
11{
12 is_selected: bool,
13 on_click: Option<Message>,
14 label: String,
15 width: Length,
16 size: f32,
17 spacing: f32,
18 text_size: Option<Pixels>,
19 text_line_height: LineHeight,
20 text_shaping: text::Shaping,
21 text_wrapping: text::Wrapping,
22 font: Option<Renderer::Font>,
23 style: StyleFn<'a>,
24}
25
26impl<Message, Renderer> std::fmt::Debug for Radio<'_, Message, Renderer>
27where
28 Message: std::fmt::Debug,
29 Renderer: core_text::Renderer,
30{
31 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32 f.debug_struct("Radio")
33 .field("is_selected", &self.is_selected)
34 .field("on_click", &self.on_click)
35 .field("label", &self.label)
36 .field("width", &self.width)
37 .field("size", &self.size)
38 .field("spacing", &self.spacing)
39 .field("text_size", &self.text_size)
40 .field("text_line_height", &self.text_line_height)
41 .finish_non_exhaustive()
42 }
43}
44
45impl<'a, Message, Renderer> Radio<'a, Message, Renderer>
46where
47 Message: Clone,
48 Renderer: core_text::Renderer,
49{
50 pub fn new<F, V>(label: impl Into<String>, value: V, selected: Option<V>, on_select: F) -> Self
51 where
52 V: Eq + Copy,
53 F: FnOnce(V) -> Message,
54 {
55 Self {
56 is_selected: Some(value) == selected,
57 on_click: Some(on_select(value)),
58 label: label.into(),
59 width: Length::Shrink,
60 size: tokens::component::radio::ICON_SIZE,
61 spacing: f32::from(tokens::component::divider::LIST_ITEM_LEADING_SPACE),
62 text_size: Some(Pixels(tokens::component::radio::LABEL_TEXT_SIZE)),
63 text_line_height: absolute_line_height(
64 tokens::component::radio::LABEL_TEXT_LINE_HEIGHT,
65 ),
66 text_shaping: text::Shaping::default(),
67 text_wrapping: text::Wrapping::default(),
68 font: None,
69 style: Box::new(crate::style::radio::default),
70 }
71 }
72
73 pub fn on_select_maybe(mut self, on_click: Option<Message>) -> Self {
74 self.on_click = on_click;
75 self
76 }
77
78 pub fn size(mut self, size: impl Into<Pixels>) -> Self {
79 self.size = size.into().0;
80 self
81 }
82
83 pub fn width(mut self, width: impl Into<Length>) -> Self {
84 self.width = width.into();
85 self
86 }
87
88 pub fn spacing(mut self, spacing: impl Into<Pixels>) -> Self {
89 self.spacing = spacing.into().0;
90 self
91 }
92
93 pub fn text_size(mut self, text_size: impl Into<Pixels>) -> Self {
94 self.text_size = Some(text_size.into());
95 self
96 }
97
98 pub fn text_line_height(mut self, line_height: impl Into<LineHeight>) -> Self {
99 self.text_line_height = line_height.into();
100 self
101 }
102
103 pub fn text_shaping(mut self, shaping: text::Shaping) -> Self {
104 self.text_shaping = shaping;
105 self
106 }
107
108 pub fn text_wrapping(mut self, wrapping: text::Wrapping) -> Self {
109 self.text_wrapping = wrapping;
110 self
111 }
112
113 pub fn font(mut self, font: impl Into<Renderer::Font>) -> Self {
114 self.font = Some(font.into());
115 self
116 }
117
118 pub fn style(
119 mut self,
120 style: impl Fn(&Theme, iced_radio::Status) -> iced_radio::Style + 'a,
121 ) -> Self {
122 self.style = Box::new(style);
123 self
124 }
125}
126
127impl<Message, Renderer> Radio<'_, Message, Renderer>
128where
129 Renderer: core_text::Renderer,
130{
131 fn current_status(&self, bounds: Rectangle, cursor: mouse::Cursor) -> iced_radio::Status {
132 if cursor.is_over(bounds) && self.on_click.is_some() {
133 iced_radio::Status::Hovered {
134 is_selected: self.is_selected,
135 }
136 } else {
137 iced_radio::Status::Active {
138 is_selected: self.is_selected,
139 }
140 }
141 }
142
143 fn state_layer_color(
144 &self,
145 theme: &Theme,
146 state: &SelectionState<Renderer::Paragraph, iced_radio::Status>,
147 status: iced_radio::Status,
148 ) -> Option<Color> {
149 let is_hovered = matches!(status, iced_radio::Status::Hovered { .. });
150
151 if !state.is_pressed && !is_hovered {
152 return None;
153 }
154
155 let colors = theme.colors();
156 let (color, opacity) = if self.is_selected {
157 if state.is_pressed {
158 (
159 colors.surface.text,
160 tokens::state::PRESSED_STATE_LAYER_OPACITY,
161 )
162 } else {
163 (
164 colors.primary.color,
165 tokens::state::HOVER_STATE_LAYER_OPACITY,
166 )
167 }
168 } else if state.is_pressed {
169 (
170 colors.primary.color,
171 tokens::state::PRESSED_STATE_LAYER_OPACITY,
172 )
173 } else {
174 (
175 colors.surface.text,
176 tokens::state::HOVER_STATE_LAYER_OPACITY,
177 )
178 };
179
180 Some(alpha_color(color, opacity))
181 }
182}
183
184impl<Message, Renderer> Widget<Message, Theme, Renderer> for Radio<'_, Message, Renderer>
185where
186 Message: Clone,
187 Renderer: core_text::Renderer,
188{
189 fn tag(&self) -> tree::Tag {
190 tree::Tag::of::<SelectionState<Renderer::Paragraph, iced_radio::Status>>()
191 }
192
193 fn state(&self) -> tree::State {
194 let mut state =
195 SelectionState::<Renderer::Paragraph, iced_radio::Status>::new(self.is_selected);
196
197 state.size = AnimatedScalar::new(bool_value(self.is_selected));
198
199 tree::State::new(state)
200 }
201
202 fn size(&self) -> Size<Length> {
203 Size {
204 width: self.width,
205 height: Length::Shrink,
206 }
207 }
208
209 fn layout(
210 &mut self,
211 tree: &mut Tree,
212 renderer: &Renderer,
213 limits: &layout::Limits,
214 ) -> layout::Node {
215 layout::next_to_each_other(
216 &limits.width(self.width),
217 self.spacing,
218 |_| layout::Node::new(Size::new(self.size, self.size)),
219 |limits| {
220 let state = tree
221 .state
222 .downcast_mut::<SelectionState<Renderer::Paragraph, iced_radio::Status>>();
223
224 core_widget::text::layout(
225 &mut state.text,
226 renderer,
227 limits,
228 &self.label,
229 core_widget::text::Format {
230 width: self.width,
231 height: Length::Shrink,
232 line_height: self.text_line_height,
233 size: self.text_size,
234 font: self.font,
235 align_x: text::Alignment::Default,
236 align_y: alignment::Vertical::Top,
237 shaping: self.text_shaping,
238 wrapping: self.text_wrapping,
239 },
240 )
241 },
242 )
243 }
244
245 fn update(
246 &mut self,
247 tree: &mut Tree,
248 event: &Event,
249 layout: Layout<'_>,
250 cursor: mouse::Cursor,
251 _renderer: &Renderer,
252 _clipboard: &mut dyn Clipboard,
253 shell: &mut Shell<'_, Message>,
254 _viewport: &Rectangle,
255 ) {
256 let state = tree
257 .state
258 .downcast_mut::<SelectionState<Renderer::Paragraph, iced_radio::Status>>();
259 let hit_bounds =
260 selection_control_hit_bounds(layout, tokens::component::radio::TARGET_SIZE);
261
262 match event {
263 Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
264 | Event::Touch(touch::Event::FingerPressed { .. }) => {
265 if self.on_click.is_some() && press_is_over(event, hit_bounds, cursor) {
266 state.is_pressed = true;
267 state.press_origin = None;
268 shell.capture_event();
269 shell.request_redraw();
270 }
271 }
272 Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
273 | Event::Touch(touch::Event::FingerLifted { .. }) => {
274 if state.is_pressed {
275 let is_released_over = release_is_over(event, hit_bounds, cursor);
276
277 state.is_pressed = false;
278 state.press_origin = None;
279
280 if is_released_over && let Some(on_click) = &self.on_click {
281 shell.publish(on_click.clone());
282 }
283
284 shell.capture_event();
285 shell.request_redraw();
286 }
287 }
288 Event::Touch(touch::Event::FingerLost { .. }) => {
289 if state.is_pressed {
290 state.is_pressed = false;
291 state.press_origin = None;
292 shell.request_redraw();
293 }
294 }
295 _ => {}
296 }
297
298 let now = match event {
299 Event::Window(window::Event::RedrawRequested(now)) => Some(*now),
300 _ => None,
301 };
302
303 if state.target != self.is_selected {
304 let now = now.unwrap_or_else(Instant::now);
305
306 state.target = self.is_selected;
307 state.color.set_target(
308 bool_value(self.is_selected),
309 now,
310 duration_ms(tokens::component::radio::ICON_COLOR_TRANSITION_DURATION_MS),
311 tokens::motion::EASING_LINEAR,
312 );
313
314 if self.is_selected {
315 state.size = AnimatedScalar::new(0.0);
316 state.size.set_target(
317 1.0,
318 now,
319 duration_ms(tokens::component::radio::SELECT_TRANSITION_DURATION_MS),
320 tokens::component::radio::SELECT_TRANSITION_EASING,
321 );
322 } else {
323 state.size = AnimatedScalar::new(1.0);
324 }
325
326 shell.request_redraw();
327 }
328
329 let current_status = self.current_status(hit_bounds, cursor);
330
331 if let Some(now) = now {
332 if state.advance(now) {
333 shell.request_redraw();
334 }
335
336 state.last_status = Some(current_status);
337 } else if state
338 .last_status
339 .is_some_and(|status| status != current_status)
340 || state.is_animating()
341 {
342 shell.request_redraw();
343 }
344 }
345
346 fn mouse_interaction(
347 &self,
348 _tree: &Tree,
349 layout: Layout<'_>,
350 cursor: mouse::Cursor,
351 _viewport: &Rectangle,
352 _renderer: &Renderer,
353 ) -> mouse::Interaction {
354 let hit_bounds =
355 selection_control_hit_bounds(layout, tokens::component::radio::TARGET_SIZE);
356
357 if cursor.is_over(hit_bounds) {
358 if self.on_click.is_some() {
359 mouse::Interaction::Pointer
360 } else {
361 mouse::Interaction::NotAllowed
362 }
363 } else {
364 mouse::Interaction::default()
365 }
366 }
367
368 fn draw(
369 &self,
370 tree: &Tree,
371 renderer: &mut Renderer,
372 theme: &Theme,
373 defaults: &renderer::Style,
374 layout: Layout<'_>,
375 _cursor: mouse::Cursor,
376 viewport: &Rectangle,
377 ) {
378 let state = tree
379 .state
380 .downcast_ref::<SelectionState<Renderer::Paragraph, iced_radio::Status>>();
381
382 let mut children = layout.children();
383 let control_layout = children.next().unwrap();
384 let bounds = control_layout.bounds();
385 let status = state.last_status.unwrap_or(iced_radio::Status::Active {
386 is_selected: self.is_selected,
387 });
388 let unchecked_status = match status {
389 iced_radio::Status::Active { .. } => iced_radio::Status::Active { is_selected: false },
390 iced_radio::Status::Hovered { .. } => {
391 iced_radio::Status::Hovered { is_selected: false }
392 }
393 };
394 let checked_status = match status {
395 iced_radio::Status::Active { .. } => iced_radio::Status::Active { is_selected: true },
396 iced_radio::Status::Hovered { .. } => iced_radio::Status::Hovered { is_selected: true },
397 };
398 let current_style = (self.style)(theme, status);
399 let unchecked_style = (self.style)(theme, unchecked_status);
400 let checked_style = (self.style)(theme, checked_status);
401
402 if let Some(layer_color) = self.state_layer_color(theme, state, status) {
403 renderer.fill_quad(
404 renderer::Quad {
405 bounds: scaled_rect(
406 bounds,
407 tokens::component::radio::STATE_LAYER_SIZE,
408 tokens::component::radio::STATE_LAYER_SIZE,
409 ),
410 border: border::rounded(tokens::component::radio::STATE_LAYER_SIZE / 2.0),
411 ..renderer::Quad::default()
412 },
413 Background::Color(layer_color),
414 );
415 }
416
417 let color_progress = state.color.value.clamp(0.0, 1.0);
418 let icon_color = mix(
419 unchecked_style.border_color,
420 checked_style.border_color,
421 color_progress,
422 );
423 let radius = bounds.width.min(bounds.height) / 2.0;
424
425 renderer.fill_quad(
426 renderer::Quad {
427 bounds,
428 border: Border {
429 radius: radius.into(),
430 width: tokens::component::radio::OUTER_RING_WIDTH,
431 color: icon_color,
432 },
433 ..renderer::Quad::default()
434 },
435 Color::TRANSPARENT,
436 );
437
438 if color_progress > 0.0 {
439 let dot_size =
440 tokens::component::radio::INNER_DOT_SIZE * state.size.value.clamp(0.0, 1.0);
441
442 if dot_size > 0.0 {
443 renderer.fill_quad(
444 renderer::Quad {
445 bounds: scaled_rect(bounds, dot_size, dot_size),
446 border: border::rounded(dot_size / 2.0),
447 ..renderer::Quad::default()
448 },
449 Background::Color(alpha_color(checked_style.dot_color, color_progress)),
450 );
451 }
452 }
453
454 let label_layout = children.next().unwrap();
455
456 core_widget::text::draw(
457 renderer,
458 defaults,
459 label_layout.bounds(),
460 state.text.raw(),
461 core_widget::text::Style {
462 color: current_style.text_color,
463 },
464 viewport,
465 );
466 }
467
468 fn operate(
469 &mut self,
470 _tree: &mut Tree,
471 layout: Layout<'_>,
472 _renderer: &Renderer,
473 operation: &mut dyn core_widget::Operation,
474 ) {
475 operation.text(None, layout.bounds(), &self.label);
476 }
477}
478
479impl<'a, Message, Renderer> From<Radio<'a, Message, Renderer>>
480 for Element<'a, Message, Theme, Renderer>
481where
482 Message: Clone + 'a,
483 Renderer: core_text::Renderer + 'a,
484{
485 fn from(radio: Radio<'a, Message, Renderer>) -> Self {
486 Element::new(radio)
487 }
488}
489
490pub fn control<'a, Message, Renderer, V>(
491 label: impl Into<String>,
492 value: V,
493 selected: Option<V>,
494 on_select: impl FnOnce(V) -> Message,
495) -> Radio<'a, Message, Renderer>
496where
497 Message: Clone + 'a,
498 Renderer: core_text::Renderer + 'a,
499 V: Eq + Copy,
500{
501 Radio::new(label, value, selected, on_select)
502 .size(tokens::component::radio::ICON_SIZE)
503 .spacing(f32::from(
504 tokens::component::divider::LIST_ITEM_LEADING_SPACE,
505 ))
506 .text_size(tokens::component::radio::LABEL_TEXT_SIZE)
507 .text_line_height(absolute_line_height(
508 tokens::component::radio::LABEL_TEXT_LINE_HEIGHT,
509 ))
510 .style(crate::style::radio::default)
511}
512
513pub fn standard<'a, Message, Renderer, V>(
514 label: impl Into<String>,
515 value: V,
516 selected: Option<V>,
517 on_select: impl FnOnce(V) -> Message,
518) -> Element<'a, Message, Theme, Renderer>
519where
520 Message: Clone + 'a,
521 Renderer: iced_widget::core::Renderer + core_text::Renderer + 'a,
522 V: Eq + Copy,
523{
524 Container::new(control(label, value, selected, on_select))
525 .center_y(Length::Fixed(tokens::component::radio::TARGET_SIZE))
526 .into()
527}