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/toggle_menu.rs
1use std::{borrow::Cow, rc::Rc, sync::Arc};
2 
3use crate::{
4 elements::{
5 Container, CrossAxisAlignment, Empty, Flex, Hoverable, MainAxisSize, MouseStateHandle,
6 ParentElement, Shrinkable,
7 },
8 platform::Cursor,
9 scene::{CornerRadius, Radius},
10 AppContext, Element, EventContext,
11};
12 
13use parking_lot::Mutex;
14use pathfinder_color::ColorU;
15use pathfinder_geometry::vector::Vector2F;
16 
17use super::{
18 components::{UiComponent, UiComponentStyles},
19 text::Span,
20};
21use lazy_static::lazy_static;
22 
23const BORDER_RADIUS: f32 = 4.;
24const BUTTON_VERTICAL_PADDING: f32 = 2.;
25const BUTTON_MARGIN: f32 = 4.;
26 
27lazy_static! {
28 pub static ref FALLBACK_SELECTED_COLOR: ColorU = ColorU::new(64, 64, 64, 100);
29 pub static ref FALLBACK_BACKGROUND_COLOR: ColorU = ColorU::new(25, 25, 25, 100);
30}
31 
32pub struct ToggleMenuItem {
33 label: Cow<'static, str>,
34}
35 
36impl ToggleMenuItem {
37 pub fn new(label: impl Into<Cow<'static, str>>) -> Self {
38 Self {
39 label: label.into(),
40 }
41 }
42}
43 
44#[derive(Clone, Copy, Default)]
45struct ToggleMenuState {
46 selected_item: Option<usize>,
47 default_selected_item: Option<usize>,
48}
49 
50#[derive(Clone, Default)]
51pub struct ToggleMenuStateHandle {
52 inner: Arc<Mutex<ToggleMenuState>>,
53}
54 
55impl ToggleMenuStateHandle {
56 pub fn get_selected_idx(&self) -> Option<usize> {
57 let state = self.inner.lock();
58 match (state.selected_item, state.default_selected_item) {
59 (Some(selected_idx), _) => Some(selected_idx),
60 (None, Some(default_idx)) => Some(default_idx),
61 _ => None,
62 }
63 }
64 
65 fn get_default_idx(&self) -> Option<usize> {
66 let state = self.inner.lock();
67 state.default_selected_item
68 }
69 
70 fn set(&self, new_state: ToggleMenuState) {
71 let mut guard = self.inner.lock();
72 *guard = new_state;
73 }
74 
75 // Set the active index from outside of the toggle menu component
76 pub fn set_selected_idx(&self, new_idx: usize) {
77 let default_selected_item = self.get_default_idx();
78 self.set(ToggleMenuState {
79 selected_item: Some(new_idx),
80 default_selected_item,
81 });
82 }
83}
84 
85struct ToggleMenuRenderer {
86 default_styles: UiComponentStyles,
87 selected_styles: UiComponentStyles,
88 hovered_styles: UiComponentStyles,
89 state_handle: ToggleMenuStateHandle,
90 hover_states: Vec<MouseStateHandle>,
91 is_disabled: bool,
92}
93 
94impl ToggleMenuRenderer {
95 fn render_label(&self, label: Cow<'static, str>) -> Box<dyn Element> {
96 let font_styles = UiComponentStyles {
97 font_family_id: self.default_styles.font_family_id,
98 font_size: self.default_styles.font_size,
99 font_color: self
100 .default_styles
101 .font_color
102 .unwrap_or_else(ColorU::white)
103 .into(),
104 font_weight: self.default_styles.font_weight,
105 ..Default::default()
106 };
107 
108 Span::new(label, font_styles)
109 .with_soft_wrap()
110 .build()
111 .finish()
112 }
113 
114 fn render_item(
115 &self,
116 item_idx: usize,
117 item: ToggleMenuItem,
118 on_toggle_change: Rc<ToggleMenuCallback>,
119 ) -> Box<dyn Element> {
120 let selected = self
121 .state_handle
122 .get_selected_idx()
123 .map(|selected_idx| selected_idx == item_idx)
124 .unwrap_or(false);
125 
126 let mut hoverable = Hoverable::new(self.hover_states[item_idx].clone(), |state| {
127 let ToggleMenuItem { label } = item;
128 
129 let flex_row = Flex::row()
130 .with_cross_axis_alignment(CrossAxisAlignment::Center)
131 .with_child(Shrinkable::new(1., Empty::new().finish()).finish())
132 .with_child(self.render_label(label))
133 .with_child(Shrinkable::new(1., Empty::new().finish()).finish())
134 .finish();
135 
136 let mut container = Container::new(flex_row);
137 
138 if let Some(padding) = self.default_styles.padding {
139 container = container
140 .with_padding_bottom(padding.bottom)
141 .with_padding_left(padding.left)
142 .with_padding_right(padding.right)
143 .with_padding_top(padding.top);
144 } else {
145 container = container.with_vertical_padding(BUTTON_VERTICAL_PADDING)
146 }
147 
148 if let Some(margin) = self.default_styles.margin {
149 container = container
150 .with_margin_bottom(margin.bottom)
151 .with_margin_left(margin.left)
152 .with_margin_right(margin.right)
153 .with_margin_top(margin.top);
154 } else if item_idx == 0 {
155 container = container.with_uniform_margin(BUTTON_MARGIN);
156 } else {
157 container = container
158 .with_margin_right(BUTTON_MARGIN)
159 .with_vertical_margin(BUTTON_MARGIN);
160 }
161 
162 if let Some(radius) = self.default_styles.border_radius {
163 container = container.with_corner_radius(radius);
164 } else {
165 container = container
166 .with_corner_radius(CornerRadius::with_all(Radius::Pixels(BORDER_RADIUS)));
167 }
168 
169 if selected {
170 container = container.with_background(
171 self.selected_styles
172 .background
173 .unwrap_or((*FALLBACK_SELECTED_COLOR).into()),
174 );
175 } else if !self.is_disabled && state.is_hovered() {
176 container = container.with_background(
177 self.hovered_styles
178 .background
179 .unwrap_or((*FALLBACK_SELECTED_COLOR).into()),
180 );
181 }
182 
183 container.finish()
184 });
185 
186 let state_handle = self.state_handle.clone();
187 let old_default = state_handle.get_default_idx();
188 if !self.is_disabled {
189 hoverable = hoverable
190 .on_click(move |event_ctx, app, v2f| {
191 // Trigger the callback if a new item is selected
192 if state_handle.get_selected_idx() != Some(item_idx) {
193 on_toggle_change(event_ctx, app, v2f);
194 
195 state_handle.set(ToggleMenuState {
196 selected_item: Some(item_idx),
197 default_selected_item: old_default,
198 });
199 }
200 })
201 .with_cursor(Cursor::PointingHand);
202 }
203 
204 hoverable.finish()
205 }
206}
207 
208pub type ToggleMenuCallback = dyn Fn(&mut EventContext, &AppContext, Vector2F) + 'static;
209 
210pub struct ToggleMenu {
211 items: Vec<ToggleMenuItem>,
212 renderer: ToggleMenuRenderer,
213 /// Callback function to be run when the toggle state is changed.
214 on_toggle_change: Rc<ToggleMenuCallback>,
215}
216 
217impl UiComponent for ToggleMenu {
218 type ElementType = Container;
219 
220 fn build(self) -> Self::ElementType {
221 Container::new(
222 Flex::row()
223 .with_cross_axis_alignment(CrossAxisAlignment::Center)
224 .with_children(self.items.into_iter().enumerate().map(|(idx, item)| {
225 Shrinkable::new(
226 1.,
227 Container::new(self.renderer.render_item(
228 idx,
229 item,
230 self.on_toggle_change.clone(),
231 ))
232 .finish(),
233 )
234 .finish()
235 }))
236 .with_main_axis_size(MainAxisSize::Max)
237 .finish(),
238 )
239 .with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)))
240 .with_background(
241 self.renderer
242 .default_styles
243 .background
244 .unwrap_or((*FALLBACK_BACKGROUND_COLOR).into()),
245 )
246 }
247 
248 fn with_style(self, new_styles: UiComponentStyles) -> Self {
249 Self {
250 renderer: ToggleMenuRenderer {
251 default_styles: new_styles.merge(self.renderer.default_styles),
252 ..self.renderer
253 },
254 ..self
255 }
256 }
257}
258 
259impl ToggleMenu {
260 #[allow(clippy::too_many_arguments)]
261 pub fn new(
262 mouse_states: Vec<MouseStateHandle>,
263 items: Vec<ToggleMenuItem>,
264 toggle_menu_state_handle: ToggleMenuStateHandle,
265 default_option: Option<usize>,
266 default_styles: UiComponentStyles,
267 selected_styles: UiComponentStyles,
268 hovered_styles: UiComponentStyles,
269 on_toggle_change: Rc<ToggleMenuCallback>,
270 ) -> Self {
271 let mut selected_idx = toggle_menu_state_handle.get_selected_idx();
272 if let Some(id) = selected_idx {
273 if items.get(id).is_none() {
274 // Previously selected option is out of range, reset to default.
275 selected_idx = None
276 }
277 }
278 
279 toggle_menu_state_handle.set(ToggleMenuState {
280 selected_item: selected_idx,
281 default_selected_item: default_option,
282 });
283 Self {
284 items,
285 renderer: ToggleMenuRenderer {
286 default_styles,
287 selected_styles,
288 hovered_styles,
289 state_handle: toggle_menu_state_handle,
290 hover_states: mouse_states,
291 is_disabled: false,
292 },
293 on_toggle_change,
294 }
295 }
296 
297 pub fn with_disabled(mut self, is_disabled: bool) -> Self {
298 self.renderer.is_disabled = is_disabled;
299 self
300 }
301}
302