Seregon/StratoSDK

StratoSDK is a framework with a declarative approach similar to Flutter/React, written and designed entirely for Rust.

Rust/27.3 KB/No license
crates/strato-ui-core/src/ui_components/radio_buttons.rs
StratoSDK / crates / strato-ui-core / src / ui_components / radio_buttons.rs
1use std::{borrow::Cow, rc::Rc};
2 
3use crate::{elements::FormattedTextElement, platform::Cursor, AppContext, EventContext};
4 
5use parking_lot::Mutex;
6use pathfinder_color::ColorU;
7use pathfinder_geometry::vector::Vector2F;
8 
9use crate::{
10 elements::{
11 ChildAnchor, ConstrainedBox, Container, CrossAxisAlignment, Flex, Hoverable,
12 MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Rect,
13 Stack,
14 },
15 scene::{Border, CornerRadius, Radius},
16 Element,
17};
18 
19use super::components::{Coords, UiComponent, UiComponentStyles};
20use lazy_static::lazy_static;
21 
22const LABEL_LEFT_MARGIN: f32 = 8.;
23const BORDER_WIDTH: f32 = 1.5;
24const DEFAULT_FONT_SIZE: f32 = 14.;
25const HOVER_SIZE_MULTIPLE: f32 = 1.75;
26const RADIO_BUTTON_DIAMETER: f32 = 20.;
27 
28lazy_static! {
29 pub static ref HOVER_BACKGROUND_COLOR: ColorU = ColorU::new(170, 170, 170, 50);
30}
31 
32pub enum RadioButtonLayout {
33 Row,
34 Column,
35}
36 
37/// A function from (is_disabled, is_selected, hovered) to a rendered element.
38type RichLabelFn<'a> = dyn FnOnce(bool, bool, bool) -> Box<dyn Element> + 'a;
39/// A function from (is_disabled, is_selected, hovered) to a rendered element.
40type CustomItemFn<'a> = dyn FnOnce(bool, bool, bool) -> Box<dyn Element> + 'a;
41 
42pub enum Label<'a> {
43 Text(Cow<'static, str>),
44 Rich(Box<RichLabelFn<'a>>),
45 CustomItem(Box<CustomItemFn<'a>>),
46}
47 
48pub struct RadioButtonItem<'a> {
49 is_disabled: bool,
50 child: Label<'a>,
51}
52 
53impl<'a> RadioButtonItem<'a> {
54 fn new(child: Label<'a>) -> Self {
55 Self {
56 is_disabled: false,
57 child,
58 }
59 }
60 
61 pub fn text(label: impl Into<Cow<'static, str>>) -> Self {
62 Self::new(Label::Text(label.into()))
63 }
64 
65 pub fn rich_element(label: Box<RichLabelFn<'a>>) -> Self {
66 Self::new(Label::Rich(Box::new(label)))
67 }
68 
69 pub fn custom_item(label: Box<CustomItemFn<'a>>) -> Self {
70 Self::new(Label::CustomItem(Box::new(label)))
71 }
72 
73 pub fn with_disabled(mut self, is_disabled: bool) -> Self {
74 self.is_disabled = is_disabled;
75 self
76 }
77}
78 
79#[derive(Clone, Copy, Default)]
80struct RadioButtonState {
81 selected_item: Option<usize>,
82 default_selected_item: Option<usize>,
83}
84 
85impl RadioButtonState {
86 #[allow(dead_code)] // This is a temporary constructor that isn't used right now but will be used as soon as radio buttons are used.
87 pub fn new(default_selected_item: Option<usize>) -> Self {
88 RadioButtonState {
89 selected_item: None,
90 default_selected_item,
91 }
92 }
93}
94 
95#[derive(Clone, Default)]
96pub struct RadioButtonStateHandle {
97 inner: Rc<Mutex<RadioButtonState>>,
98}
99 
100// TODO(roland): Remembering the selected option can be unintuitive if the number of options
101// changes or options become disabled/enabled. The remembered index can be semantically different
102// if the number/content of options change, and we may not want to remember an option chosen only
103// because other options were disabled. Consider a refactor.
104impl RadioButtonStateHandle {
105 pub fn get_selected_idx(&self) -> Option<usize> {
106 let state = self.inner.lock();
107 match (state.selected_item, state.default_selected_item) {
108 (Some(selected_idx), _) => Some(selected_idx),
109 (None, Some(default_idx)) => Some(default_idx),
110 _ => None,
111 }
112 }
113 
114 fn get_default_idx(&self) -> Option<usize> {
115 let state = self.inner.lock();
116 state.default_selected_item
117 }
118 
119 fn set(&self, new_state: RadioButtonState) {
120 let mut guard = self.inner.lock();
121 *guard = new_state;
122 }
123 
124 // Set the active index from outside of the radio button component
125 pub fn set_selected_idx(&self, new_idx: usize) {
126 let default_selected_item = self.get_default_idx();
127 self.set(RadioButtonState {
128 selected_item: Some(new_idx),
129 default_selected_item,
130 });
131 }
132}
133 
134struct RadioButtonRenderer {
135 layout: RadioButtonLayout,
136 default_styles: UiComponentStyles,
137 selected_styles: UiComponentStyles,
138 disabled_styles: UiComponentStyles,
139 state_handle: RadioButtonStateHandle,
140 hover_states: Vec<MouseStateHandle>,
141 /// If None, then center the button relative to its child.
142 /// Otherwise, insert a margin on the top edge.
143 button_vertical_offset: Option<f32>,
144 change_handler: Option<Rc<OnChangeFn>>,
145 supports_unselected_state: bool,
146 button_diameter_override: Option<f32>,
147}
148 
149impl RadioButtonRenderer {
150 fn render_selection_circle(&self, selected: bool, is_disabled: bool) -> Box<dyn Element> {
151 let mut stack = Stack::new();
152 let diameter = self
153 .button_diameter_override
154 .unwrap_or(RADIO_BUTTON_DIAMETER);
155 
156 let mut outer_circle_rect =
157 Rect::new().with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)));
158 if let Some(background) = self.default_styles.background {
159 outer_circle_rect = outer_circle_rect.with_background(background);
160 }
161 let border_color = if selected {
162 self.selected_styles.border_color.unwrap_or_default()
163 } else if is_disabled {
164 self.disabled_styles.border_color.unwrap_or_default()
165 } else {
166 self.default_styles.border_color.unwrap_or_default()
167 };
168 outer_circle_rect =
169 outer_circle_rect.with_border(Border::all(BORDER_WIDTH).with_border_fill(border_color));
170 let outer_circle = ConstrainedBox::new(outer_circle_rect.finish())
171 .with_height(diameter)
172 .with_width(diameter);
173 
174 stack.add_child(outer_circle.finish());
175 
176 if selected {
177 let inner_circle_rect = Rect::new()
178 .with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
179 .with_background(self.selected_styles.background.unwrap_or_default());
180 let inner_circle_diameter = diameter / 2.;
181 let inner_circle = ConstrainedBox::new(inner_circle_rect.finish())
182 .with_height(inner_circle_diameter)
183 .with_width(inner_circle_diameter);
184 
185 // Position the inner circle so that it's centered in the outer circle.
186 stack.add_positioned_child(
187 inner_circle.finish(),
188 OffsetPositioning::offset_from_parent(
189 Vector2F::zero(),
190 ParentOffsetBounds::Unbounded,
191 ParentAnchor::Center,
192 ChildAnchor::Center,
193 ),
194 );
195 }
196 
197 stack.finish()
198 }
199 
200 fn render_label(&self, label: Cow<'static, str>, is_disabled: bool) -> Box<dyn Element> {
201 let color = if is_disabled {
202 self.disabled_styles.font_color
203 } else {
204 self.default_styles.font_color
205 }
206 .unwrap_or_else(ColorU::white);
207 
208 FormattedTextElement::from_str(
209 label,
210 self.default_styles.font_family_id.expect("No font family"),
211 self.default_styles.font_size.unwrap_or(DEFAULT_FONT_SIZE),
212 )
213 .with_color(color)
214 .with_weight(self.default_styles.font_weight.unwrap_or_default())
215 .finish()
216 }
217 
218 fn render_item(&self, item_idx: usize, item: RadioButtonItem) -> Box<dyn Element> {
219 let selected = self
220 .state_handle
221 .get_selected_idx()
222 .map(|selected_idx| selected_idx == item_idx)
223 .unwrap_or(false);
224 
225 let padding = self.default_styles.padding.unwrap_or(Coords::uniform(2.));
226 
227 let (left_padding, top_padding) = match (item_idx, &self.layout) {
228 (0, RadioButtonLayout::Column) => (padding.left, 0.),
229 (0, RadioButtonLayout::Row) => (0., padding.top),
230 _ => (padding.left, padding.top),
231 };
232 
233 let mut hoverable = Hoverable::new(self.hover_states[item_idx].clone(), |state| {
234 if let Label::CustomItem(build_child) = item.child {
235 return (build_child)(item.is_disabled, selected, state.is_hovered());
236 }
237 let mut stack = Stack::new();
238 
239 let button = self.render_selection_circle(selected, item.is_disabled);
240 
241 let circle_diameter = self.default_styles.font_size.unwrap_or_default();
242 let hover_size = circle_diameter * HOVER_SIZE_MULTIPLE;
243 if !item.is_disabled && state.is_hovered() {
244 let hover = Container::new(
245 ConstrainedBox::new(
246 Rect::new()
247 .with_background_color(*HOVER_BACKGROUND_COLOR)
248 .with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.)))
249 .finish(),
250 )
251 .with_width(hover_size)
252 .with_height(hover_size)
253 .finish(),
254 )
255 .finish();
256 
257 // Position the hover so that it's centered behind the circle.
258 stack.add_positioned_child(
259 hover,
260 OffsetPositioning::offset_from_parent(
261 Vector2F::zero(),
262 ParentOffsetBounds::Unbounded,
263 ParentAnchor::Center,
264 ChildAnchor::Center,
265 ),
266 );
267 }
268 
269 stack.add_child(button);
270 
271 let container = Container::new(
272 Flex::row()
273 .with_cross_axis_alignment(CrossAxisAlignment::Center)
274 .with_child(if let Some(offset) = self.button_vertical_offset {
275 Container::new(stack.finish())
276 .with_margin_top(offset)
277 .finish()
278 } else {
279 stack.finish()
280 })
281 .with_child(match item.child {
282 Label::Text(label) => {
283 Container::new(self.render_label(label, item.is_disabled))
284 .with_margin_left(LABEL_LEFT_MARGIN)
285 .finish()
286 }
287 Label::Rich(build_child) => {
288 (build_child)(item.is_disabled, selected, state.is_hovered())
289 }
290 _ => Flex::row().finish(),
291 })
292 .with_cross_axis_alignment(if self.button_vertical_offset.is_some() {
293 CrossAxisAlignment::Start
294 } else {
295 CrossAxisAlignment::Center
296 })
297 .finish(),
298 );
299 container
300 .with_padding_top(top_padding)
301 .with_padding_bottom(padding.bottom)
302 .with_padding_left(left_padding)
303 .with_padding_right(padding.right)
304 .finish()
305 });
306 
307 if !item.is_disabled {
308 let state_handle = self.state_handle.clone();
309 let old_default = state_handle.get_default_idx();
310 let change_handler = self.change_handler.clone();
311 let supports_unselected = self.supports_unselected_state;
312 hoverable = hoverable
313 .on_click(move |event_context, app_context, _| {
314 let selected_item = if supports_unselected && selected {
315 None
316 } else {
317 Some(item_idx)
318 };
319 state_handle.set(RadioButtonState {
320 selected_item,
321 default_selected_item: old_default,
322 });
323 if let Some(change_handler) = &change_handler {
324 change_handler(event_context, app_context, selected_item);
325 }
326 })
327 .with_cursor(Cursor::PointingHand);
328 }
329 
330 let margin = self.default_styles.margin.unwrap_or(Coords::uniform(2.));
331 
332 let (left_margin, top_margin) = match (item_idx, &self.layout) {
333 (0, RadioButtonLayout::Column) => (margin.left, 0.),
334 (0, RadioButtonLayout::Row) => (0., margin.top),
335 _ => (margin.left, margin.top),
336 };
337 
338 let container = Container::new(hoverable.finish());
339 container
340 .with_margin_top(top_margin)
341 .with_margin_bottom(margin.bottom)
342 .with_margin_left(left_margin)
343 .with_margin_right(margin.right)
344 .finish()
345 }
346}
347 
348type OnChangeFn = dyn Fn(&mut EventContext, &AppContext, Option<usize>) + 'static;
349 
350pub struct RadioButtons<'a> {
351 items: Vec<RadioButtonItem<'a>>,
352 renderer: RadioButtonRenderer,
353}
354 
355impl UiComponent for RadioButtons<'_> {
356 type ElementType = Flex;
357 
358 fn build(self) -> Self::ElementType {
359 let flex = match self.renderer.layout {
360 RadioButtonLayout::Row => Flex::row(),
361 RadioButtonLayout::Column => Flex::column(),
362 };
363 
364 flex.with_children(
365 self.items
366 .into_iter()
367 .enumerate()
368 .map(|(idx, item)| self.renderer.render_item(idx, item)),
369 )
370 }
371 
372 fn with_style(self, new_styles: UiComponentStyles) -> Self {
373 Self {
374 renderer: RadioButtonRenderer {
375 default_styles: self.renderer.default_styles.merge(new_styles),
376 ..self.renderer
377 },
378 ..self
379 }
380 }
381}
382 
383impl<'a> RadioButtons<'a> {
384 #[allow(clippy::too_many_arguments)]
385 pub fn new(
386 mouse_states: Vec<MouseStateHandle>,
387 items: Vec<RadioButtonItem<'a>>,
388 radio_button_state_handle: RadioButtonStateHandle,
389 default_option: Option<usize>,
390 default_styles: UiComponentStyles,
391 selected_styles: UiComponentStyles,
392 disabled_styles: UiComponentStyles,
393 layout: RadioButtonLayout,
394 ) -> Self {
395 let mut selected_idx = radio_button_state_handle.get_selected_idx();
396 if let Some(id) = selected_idx {
397 // If the previously selected option is disabled, reset the selected option to the default.
398 if let Some(item) = items.get(id) {
399 if item.is_disabled {
400 selected_idx = None
401 }
402 } else {
403 // Previously selected option is out of range, reset to default.
404 selected_idx = None
405 }
406 }
407 
408 radio_button_state_handle.set(RadioButtonState {
409 selected_item: selected_idx,
410 default_selected_item: default_option,
411 });
412 Self {
413 items,
414 renderer: RadioButtonRenderer {
415 layout,
416 default_styles,
417 selected_styles,
418 disabled_styles,
419 state_handle: radio_button_state_handle,
420 hover_states: mouse_states,
421 button_vertical_offset: None,
422 change_handler: None,
423 supports_unselected_state: false,
424 button_diameter_override: None,
425 },
426 }
427 }
428 
429 /// Set the vertical offset of the radio button relative to the top of the child element.
430 pub fn with_button_vertical_offset(mut self, offset: f32) -> Self {
431 self.renderer.button_vertical_offset = Some(offset);
432 self
433 }
434 
435 pub fn on_change(mut self, callback: Rc<OnChangeFn>) -> Self {
436 self.renderer.change_handler = Some(callback);
437 self
438 }
439 
440 pub fn supports_unselected_state(mut self) -> Self {
441 self.renderer.supports_unselected_state = true;
442 self
443 }
444 
445 pub fn with_button_diameter(mut self, diameter: f32) -> Self {
446 self.renderer.button_diameter_override = Some(diameter);
447 self
448 }
449}
450