StratoSDK is a framework with a declarative approach similar to Flutter/React, written and designed entirely for Rust.
| 1 | use std::{borrow::Cow, rc::Rc, sync::Arc}; |
| 2 | |
| 3 | use 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 | |
| 13 | use parking_lot::Mutex; |
| 14 | use pathfinder_color::ColorU; |
| 15 | use pathfinder_geometry::vector::Vector2F; |
| 16 | |
| 17 | use super::{ |
| 18 | components::{UiComponent, UiComponentStyles}, |
| 19 | text::Span, |
| 20 | }; |
| 21 | use lazy_static::lazy_static; |
| 22 | |
| 23 | const BORDER_RADIUS: f32 = 4.; |
| 24 | const BUTTON_VERTICAL_PADDING: f32 = 2.; |
| 25 | const BUTTON_MARGIN: f32 = 4.; |
| 26 | |
| 27 | lazy_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 | |
| 32 | pub struct ToggleMenuItem { |
| 33 | label: Cow<'static, str>, |
| 34 | } |
| 35 | |
| 36 | impl 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)] |
| 45 | struct ToggleMenuState { |
| 46 | selected_item: Option<usize>, |
| 47 | default_selected_item: Option<usize>, |
| 48 | } |
| 49 | |
| 50 | #[derive(Clone, Default)] |
| 51 | pub struct ToggleMenuStateHandle { |
| 52 | inner: Arc<Mutex<ToggleMenuState>>, |
| 53 | } |
| 54 | |
| 55 | impl 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 | |
| 85 | struct 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 | |
| 94 | impl 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 | |
| 208 | pub type ToggleMenuCallback = dyn Fn(&mut EventContext, &AppContext, Vector2F) + 'static; |
| 209 | |
| 210 | pub 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 | |
| 217 | impl 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 | |
| 259 | impl 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 |