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 {
267 state.is_pressed = true;
268 state.press_origin = None;
269 shell.capture_event();
270 shell.request_redraw();
271 }
272 Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
273 | Event::Touch(touch::Event::FingerLifted { .. })
274 if state.is_pressed =>
275 {
276 let is_released_over = release_is_over(event, hit_bounds, cursor);
277
278 state.is_pressed = false;
279 state.press_origin = None;
280
281 if is_released_over && let Some(on_click) = &self.on_click {
282 shell.publish(on_click.clone());
283 }
284
285 shell.capture_event();
286 shell.request_redraw();
287 }
288 Event::Touch(touch::Event::FingerLost { .. }) if state.is_pressed => {
289 state.is_pressed = false;
290 state.press_origin = None;
291 shell.request_redraw();
292 }
293 _ => {}
294 }
295
296 let now = match event {
297 Event::Window(window::Event::RedrawRequested(now)) => Some(*now),
298 _ => None,
299 };
300
301 if state.target != self.is_selected {
302 let now = now.unwrap_or_else(Instant::now);
303
304 state.target = self.is_selected;
305 state.color.set_target(
306 bool_value(self.is_selected),
307 now,
308 duration_ms(tokens::component::radio::ICON_COLOR_TRANSITION_DURATION_MS),
309 tokens::motion::EASING_LINEAR,
310 );
311
312 if self.is_selected {
313 state.size = AnimatedScalar::new(0.0);
314 state.size.set_target(
315 1.0,
316 now,
317 duration_ms(tokens::component::radio::SELECT_TRANSITION_DURATION_MS),
318 tokens::component::radio::SELECT_TRANSITION_EASING,
319 );
320 } else {
321 state.size = AnimatedScalar::new(1.0);
322 }
323
324 shell.request_redraw();
325 }
326
327 let current_status = self.current_status(hit_bounds, cursor);
328
329 if let Some(now) = now {
330 if state.advance(now) {
331 shell.request_redraw();
332 }
333
334 state.last_status = Some(current_status);
335 } else if state
336 .last_status
337 .is_some_and(|status| status != current_status)
338 || state.is_animating()
339 {
340 shell.request_redraw();
341 }
342 }
343
344 fn mouse_interaction(
345 &self,
346 _tree: &Tree,
347 layout: Layout<'_>,
348 cursor: mouse::Cursor,
349 _viewport: &Rectangle,
350 _renderer: &Renderer,
351 ) -> mouse::Interaction {
352 let hit_bounds =
353 selection_control_hit_bounds(layout, tokens::component::radio::TARGET_SIZE);
354
355 if cursor.is_over(hit_bounds) {
356 if self.on_click.is_some() {
357 mouse::Interaction::Pointer
358 } else {
359 mouse::Interaction::NotAllowed
360 }
361 } else {
362 mouse::Interaction::default()
363 }
364 }
365
366 fn draw(
367 &self,
368 tree: &Tree,
369 renderer: &mut Renderer,
370 theme: &Theme,
371 defaults: &renderer::Style,
372 layout: Layout<'_>,
373 _cursor: mouse::Cursor,
374 viewport: &Rectangle,
375 ) {
376 let state = tree
377 .state
378 .downcast_ref::<SelectionState<Renderer::Paragraph, iced_radio::Status>>();
379
380 let mut children = layout.children();
381 let control_layout = children.next().unwrap();
382 let bounds = control_layout.bounds();
383 let status = state.last_status.unwrap_or(iced_radio::Status::Active {
384 is_selected: self.is_selected,
385 });
386 let unchecked_status = match status {
387 iced_radio::Status::Active { .. } => iced_radio::Status::Active { is_selected: false },
388 iced_radio::Status::Hovered { .. } => {
389 iced_radio::Status::Hovered { is_selected: false }
390 }
391 };
392 let checked_status = match status {
393 iced_radio::Status::Active { .. } => iced_radio::Status::Active { is_selected: true },
394 iced_radio::Status::Hovered { .. } => iced_radio::Status::Hovered { is_selected: true },
395 };
396 let current_style = (self.style)(theme, status);
397 let unchecked_style = (self.style)(theme, unchecked_status);
398 let checked_style = (self.style)(theme, checked_status);
399
400 if let Some(layer_color) = self.state_layer_color(theme, state, status) {
401 renderer.fill_quad(
402 renderer::Quad {
403 bounds: scaled_rect(
404 bounds,
405 tokens::component::radio::STATE_LAYER_SIZE,
406 tokens::component::radio::STATE_LAYER_SIZE,
407 ),
408 border: border::rounded(tokens::component::radio::STATE_LAYER_SIZE / 2.0),
409 ..renderer::Quad::default()
410 },
411 Background::Color(layer_color),
412 );
413 }
414
415 let color_progress = state.color.value.clamp(0.0, 1.0);
416 let icon_color = mix(
417 unchecked_style.border_color,
418 checked_style.border_color,
419 color_progress,
420 );
421 let radius = bounds.width.min(bounds.height) / 2.0;
422
423 renderer.fill_quad(
424 renderer::Quad {
425 bounds,
426 border: Border {
427 radius: radius.into(),
428 width: tokens::component::radio::OUTER_RING_WIDTH,
429 color: icon_color,
430 },
431 ..renderer::Quad::default()
432 },
433 Color::TRANSPARENT,
434 );
435
436 if color_progress > 0.0 {
437 let dot_size =
438 tokens::component::radio::INNER_DOT_SIZE * state.size.value.clamp(0.0, 1.0);
439
440 if dot_size > 0.0 {
441 renderer.fill_quad(
442 renderer::Quad {
443 bounds: scaled_rect(bounds, dot_size, dot_size),
444 border: border::rounded(dot_size / 2.0),
445 ..renderer::Quad::default()
446 },
447 Background::Color(alpha_color(checked_style.dot_color, color_progress)),
448 );
449 }
450 }
451
452 let label_layout = children.next().unwrap();
453
454 core_widget::text::draw(
455 renderer,
456 defaults,
457 label_layout.bounds(),
458 state.text.raw(),
459 core_widget::text::Style {
460 color: current_style.text_color,
461 },
462 viewport,
463 );
464 }
465
466 fn operate(
467 &mut self,
468 _tree: &mut Tree,
469 layout: Layout<'_>,
470 _renderer: &Renderer,
471 operation: &mut dyn core_widget::Operation,
472 ) {
473 operation.text(None, layout.bounds(), &self.label);
474 }
475}
476
477impl<'a, Message, Renderer> From<Radio<'a, Message, Renderer>>
478 for Element<'a, Message, Theme, Renderer>
479where
480 Message: Clone + 'a,
481 Renderer: core_text::Renderer + 'a,
482{
483 fn from(radio: Radio<'a, Message, Renderer>) -> Self {
484 Element::new(radio)
485 }
486}
487
488pub fn control<'a, Message, Renderer, V>(
489 label: impl Into<String>,
490 value: V,
491 selected: Option<V>,
492 on_select: impl FnOnce(V) -> Message,
493) -> Radio<'a, Message, Renderer>
494where
495 Message: Clone + 'a,
496 Renderer: core_text::Renderer + 'a,
497 V: Eq + Copy,
498{
499 Radio::new(label, value, selected, on_select)
500 .size(tokens::component::radio::ICON_SIZE)
501 .spacing(f32::from(
502 tokens::component::divider::LIST_ITEM_LEADING_SPACE,
503 ))
504 .text_size(tokens::component::radio::LABEL_TEXT_SIZE)
505 .text_line_height(absolute_line_height(
506 tokens::component::radio::LABEL_TEXT_LINE_HEIGHT,
507 ))
508 .style(crate::style::radio::default)
509}
510
511pub fn standard<'a, Message, Renderer, V>(
512 label: impl Into<String>,
513 value: V,
514 selected: Option<V>,
515 on_select: impl FnOnce(V) -> Message,
516) -> Element<'a, Message, Theme, Renderer>
517where
518 Message: Clone + 'a,
519 Renderer: iced_widget::core::Renderer + core_text::Renderer + 'a,
520 V: Eq + Copy,
521{
522 Container::new(control(label, value, selected, on_select))
523 .center_y(Length::Fixed(tokens::component::radio::TARGET_SIZE))
524 .into()
525}