StratoSDK is a framework with a declarative approach similar to Flutter/React, written and designed entirely for Rust.
| 1 | use crate::color::ColorU; |
| 2 | use crate::elements::{ |
| 3 | AnchorPair, ChildAnchor, Empty, Fill, OffsetPositioning, OffsetType, ParentAnchor, |
| 4 | ParentOffsetBounds, PositioningAxis, Stack, XAxisAnchor, YAxisAnchor, |
| 5 | }; |
| 6 | use crate::geometry::vector::vec2f; |
| 7 | use crate::platform::Cursor; |
| 8 | use crate::scene::{DropShadow, Radius}; |
| 9 | use crate::{ |
| 10 | elements::{ |
| 11 | ConstrainedBox, Container, CornerRadius, Element, Flex, Hoverable, MouseState, |
| 12 | MouseStateHandle, ParentElement, Rect, |
| 13 | }, |
| 14 | ui_components::components::{UiComponent, UiComponentStyles}, |
| 15 | ui_components::text::Span, |
| 16 | ui_components::tool_tip::Tooltip, |
| 17 | }; |
| 18 | use lazy_static::lazy_static; |
| 19 | |
| 20 | const DEFAULT_THUMB_HEIGHT: f32 = 18.; |
| 21 | |
| 22 | lazy_static! { |
| 23 | // Hardcode for now, but can be made configurable if necessary. |
| 24 | pub static ref TRACK_COLOR: ColorU = ColorU::new(170, 170, 170, 255); |
| 25 | |
| 26 | static ref DROP_SHADOW: DropShadow = DropShadow { |
| 27 | color: ColorU::black(), |
| 28 | offset: vec2f(-0.5, 2.), |
| 29 | blur_radius: 20., |
| 30 | spread_radius: 0., |
| 31 | }; |
| 32 | } |
| 33 | |
| 34 | /// A config to provide both the text and the styles for a tooltip. |
| 35 | /// Bundling these together prevents any callers from passing in just one |
| 36 | /// without the other (and this ui element is not capable of coming up with sensible, themed defaults for the tooltip styles). |
| 37 | #[derive(Clone)] |
| 38 | pub struct TooltipConfig { |
| 39 | pub text: String, |
| 40 | pub styles: UiComponentStyles, |
| 41 | } |
| 42 | |
| 43 | /// A switch element used to toggle the on/off state of a single value. A switch consists of two |
| 44 | /// distinct pieces: the "thumb" which is the piece that is clickable and is rendered on the left if |
| 45 | /// unchecked and on the right if checked, and the "track", the background that the thumb moves |
| 46 | /// along. The switch optionally includes a label that can also be clicked to active the element. |
| 47 | /// Note the switch does not contain any state, it's up to the caller to rebuild the switch with the |
| 48 | /// correct value for "checked" when the switch is clicked. |
| 49 | pub struct Switch { |
| 50 | checked: bool, |
| 51 | disabled: bool, |
| 52 | label: Option<Span>, // optional label for the Switch, also clickable |
| 53 | styles: UiComponentStyles, |
| 54 | hovered_styles: Option<UiComponentStyles>, |
| 55 | checked_styles: Option<UiComponentStyles>, |
| 56 | disabled_styles: Option<UiComponentStyles>, |
| 57 | hover_border_size: Option<f32>, |
| 58 | mouse_state: SwitchStateHandle, |
| 59 | tooltip: Option<TooltipConfig>, |
| 60 | } |
| 61 | |
| 62 | /// State handles necessary for the Switch component. Two mouse state handles are needed to handle |
| 63 | /// clicks on the entire component while having a hover on only the thumb. |
| 64 | #[derive(Default, Clone)] |
| 65 | pub struct SwitchStateHandle { |
| 66 | component_mouse_state: MouseStateHandle, |
| 67 | thumb_mouse_state: MouseStateHandle, |
| 68 | } |
| 69 | |
| 70 | impl UiComponent for Switch { |
| 71 | type ElementType = Hoverable; |
| 72 | fn build(self) -> Hoverable { |
| 73 | let tooltip = self.tooltip.clone(); |
| 74 | |
| 75 | let hoverable = Hoverable::new(self.mouse_state.component_mouse_state.clone(), |state| { |
| 76 | let styles = self.styles(state); |
| 77 | let thumb_height = styles.height.unwrap_or(DEFAULT_THUMB_HEIGHT); |
| 78 | |
| 79 | let switch_element = self.render_switch(styles); |
| 80 | let switch_element = if let Some(label) = self.label.clone() { |
| 81 | let label = label.with_style(self.styles).build(); |
| 82 | let font_size = self.styles.font_size.unwrap_or_default(); |
| 83 | |
| 84 | // If the thumb is larger than the label font, apply padding so the switch is |
| 85 | // centered with the label. |
| 86 | let padding_top = if thumb_height > font_size { |
| 87 | (thumb_height - font_size) / 2. |
| 88 | } else { |
| 89 | 0. |
| 90 | }; |
| 91 | |
| 92 | Flex::row() |
| 93 | .with_child(label.finish()) |
| 94 | .with_child( |
| 95 | Container::new(switch_element) |
| 96 | .with_padding_top(padding_top) |
| 97 | .finish(), |
| 98 | ) |
| 99 | .finish() |
| 100 | } else { |
| 101 | switch_element |
| 102 | }; |
| 103 | |
| 104 | // If a tooltip is configured and we're hovered, show it above the switch |
| 105 | if let Some(TooltipConfig { text, styles }) = &tooltip { |
| 106 | if state.is_hovered() { |
| 107 | let tooltip_element = Tooltip::new(text.clone(), *styles).build().finish(); |
| 108 | return Stack::new() |
| 109 | .with_child(switch_element) |
| 110 | .with_positioned_child( |
| 111 | tooltip_element, |
| 112 | OffsetPositioning::offset_from_parent( |
| 113 | vec2f(0., -3.), |
| 114 | ParentOffsetBounds::Unbounded, |
| 115 | ParentAnchor::TopRight, |
| 116 | ChildAnchor::BottomRight, |
| 117 | ), |
| 118 | ) |
| 119 | .finish(); |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | switch_element |
| 124 | }); |
| 125 | |
| 126 | if !self.disabled { |
| 127 | hoverable.with_cursor(Cursor::PointingHand) |
| 128 | } else { |
| 129 | hoverable |
| 130 | } |
| 131 | } |
| 132 | |
| 133 | /// Overwrites _some_ styles passed in `style` parameter |
| 134 | fn with_style(self, styles: UiComponentStyles) -> Self { |
| 135 | Self { |
| 136 | checked: self.checked, |
| 137 | disabled: self.disabled, |
| 138 | label: self.label, |
| 139 | styles: self.styles.merge(styles), |
| 140 | hovered_styles: Some(self.hovered_styles.unwrap_or(self.styles).merge(styles)), |
| 141 | checked_styles: Some(self.checked_styles.unwrap_or(self.styles).merge(styles)), |
| 142 | disabled_styles: Some(self.disabled_styles.unwrap_or(self.styles).merge(styles)), |
| 143 | mouse_state: self.mouse_state, |
| 144 | hover_border_size: self.hover_border_size, |
| 145 | tooltip: self.tooltip, |
| 146 | } |
| 147 | } |
| 148 | } |
| 149 | |
| 150 | impl Switch { |
| 151 | pub fn new( |
| 152 | mouse_state: SwitchStateHandle, |
| 153 | default_styles: UiComponentStyles, |
| 154 | hovered_styles: Option<UiComponentStyles>, |
| 155 | checked_styles: Option<UiComponentStyles>, |
| 156 | disabled_styles: Option<UiComponentStyles>, |
| 157 | ) -> Self { |
| 158 | Self { |
| 159 | checked: false, |
| 160 | disabled: false, |
| 161 | label: None, |
| 162 | styles: default_styles, |
| 163 | hovered_styles, |
| 164 | checked_styles, |
| 165 | disabled_styles, |
| 166 | mouse_state, |
| 167 | hover_border_size: None, |
| 168 | tooltip: None, |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | /// Sets the a circular hover border on the thumb of size `border_size`. |
| 173 | pub fn with_thumb_hover_border(mut self, border_size: f32) -> Self { |
| 174 | self.hover_border_size = Some(border_size); |
| 175 | self |
| 176 | } |
| 177 | |
| 178 | pub fn with_disabled_styles(mut self, styles: UiComponentStyles) -> Self { |
| 179 | self.disabled_styles = Some(self.disabled_styles.unwrap_or_default().merge(styles)); |
| 180 | self |
| 181 | } |
| 182 | |
| 183 | pub fn check(mut self, check: bool) -> Self { |
| 184 | self.checked = check; |
| 185 | self |
| 186 | } |
| 187 | |
| 188 | pub fn disable(mut self) -> Self { |
| 189 | self.disabled = true; |
| 190 | self |
| 191 | } |
| 192 | |
| 193 | pub fn with_disabled(mut self, is_disabled: bool) -> Self { |
| 194 | self.disabled = is_disabled; |
| 195 | self |
| 196 | } |
| 197 | |
| 198 | pub fn label(mut self, label: Span) -> Self { |
| 199 | self.label = Some(label); |
| 200 | self |
| 201 | } |
| 202 | |
| 203 | /// Adds a tooltip that appears above the switch on hover. |
| 204 | pub fn with_tooltip(mut self, config: TooltipConfig) -> Self { |
| 205 | self.tooltip = Some(config); |
| 206 | self |
| 207 | } |
| 208 | |
| 209 | fn styles(&self, state: &MouseState) -> UiComponentStyles { |
| 210 | if self.disabled { |
| 211 | return self.disabled_styles.unwrap_or(self.styles); |
| 212 | } |
| 213 | |
| 214 | if self.checked { |
| 215 | return self.checked_styles.unwrap_or(self.styles); |
| 216 | } |
| 217 | |
| 218 | if state.is_mouse_over_element() { |
| 219 | return self.hovered_styles.unwrap_or(self.styles); |
| 220 | } |
| 221 | self.styles |
| 222 | } |
| 223 | |
| 224 | // Renders the thumb. The thumb needs its own hoverable to render a border around itself when |
| 225 | // hovered. |
| 226 | fn render_thumb(&self, styles: UiComponentStyles, thumb_height: f32) -> Box<dyn Element> { |
| 227 | let is_disabled = self.disabled; |
| 228 | let thumb_color = styles.foreground.unwrap_or(Fill::Solid(ColorU::white())); |
| 229 | Hoverable::new(self.mouse_state.thumb_mouse_state.clone(), |state| { |
| 230 | let thumb = Container::new( |
| 231 | ConstrainedBox::new( |
| 232 | Rect::new() |
| 233 | .with_background(thumb_color) |
| 234 | .with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.))) |
| 235 | .with_drop_shadow(*DROP_SHADOW) |
| 236 | .finish(), |
| 237 | ) |
| 238 | .with_width(thumb_height) |
| 239 | .with_height(thumb_height) |
| 240 | .finish(), |
| 241 | ) |
| 242 | .finish(); |
| 243 | let mut stack = Stack::new(); |
| 244 | |
| 245 | // If a border is specified and the mouse is over the element, |
| 246 | // render a circle behind the thumb with the border color. |
| 247 | if let Some(border_size) = self.hover_border_size { |
| 248 | if !is_disabled && state.is_mouse_over_element() { |
| 249 | let mut hover_background = *TRACK_COLOR; |
| 250 | hover_background.a = 100; |
| 251 | |
| 252 | let hover_size = thumb_height + border_size; |
| 253 | |
| 254 | let thumb_hover = Container::new( |
| 255 | ConstrainedBox::new( |
| 256 | Rect::new() |
| 257 | .with_background_color(hover_background) |
| 258 | .with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.))) |
| 259 | .finish(), |
| 260 | ) |
| 261 | .with_width(hover_size) |
| 262 | .with_height(hover_size) |
| 263 | .finish(), |
| 264 | ) |
| 265 | .finish(); |
| 266 | |
| 267 | // Position the hover so that it's centered around the thumb. Since the hover |
| 268 | // is guaranteed to be larger than the thumb, we position the hover at the top |
| 269 | // left corner of the thumb and then translate it to the left and up so that it |
| 270 | // is centered. |
| 271 | stack.add_positioned_child( |
| 272 | thumb_hover, |
| 273 | OffsetPositioning::from_axes( |
| 274 | PositioningAxis::relative_to_parent( |
| 275 | ParentOffsetBounds::Unbounded, |
| 276 | OffsetType::Pixel(-((hover_size - thumb_height) / 2.)), |
| 277 | AnchorPair::new(XAxisAnchor::Left, XAxisAnchor::Left), |
| 278 | ), |
| 279 | PositioningAxis::relative_to_parent( |
| 280 | ParentOffsetBounds::Unbounded, |
| 281 | OffsetType::Pixel(-((hover_size - thumb_height) / 2.)), |
| 282 | AnchorPair::new(YAxisAnchor::Top, YAxisAnchor::Top), |
| 283 | ), |
| 284 | ), |
| 285 | ); |
| 286 | } |
| 287 | } |
| 288 | |
| 289 | stack.add_child(thumb); |
| 290 | stack.finish() |
| 291 | }) |
| 292 | .finish() |
| 293 | } |
| 294 | |
| 295 | fn render_switch(&self, styles: UiComponentStyles) -> Box<dyn Element> { |
| 296 | let thumb_height = styles.height.unwrap_or(DEFAULT_THUMB_HEIGHT); |
| 297 | |
| 298 | let track = Container::new( |
| 299 | ConstrainedBox::new(Empty::new().finish()) |
| 300 | .with_width(thumb_height * 2.) |
| 301 | .with_height(thumb_height) |
| 302 | .finish(), |
| 303 | ) |
| 304 | .with_corner_radius(CornerRadius::with_all(Radius::Percentage(50.))); |
| 305 | |
| 306 | let background_color = styles.background.unwrap_or(Fill::Solid(*TRACK_COLOR)); |
| 307 | |
| 308 | let mut stack = Stack::new(); |
| 309 | stack.add_child(track.with_background(background_color).finish()); |
| 310 | |
| 311 | let thumb = self.render_thumb(styles, thumb_height); |
| 312 | |
| 313 | // If checked, render the thumb's right corner on the right corner of the track. If |
| 314 | // unchecked, render the thumb's left corner on the left corner of the track. |
| 315 | let positioning = if self.checked { |
| 316 | OffsetPositioning::from_axes( |
| 317 | PositioningAxis::relative_to_parent( |
| 318 | ParentOffsetBounds::Unbounded, |
| 319 | OffsetType::Pixel(0.), |
| 320 | AnchorPair::new(XAxisAnchor::Right, XAxisAnchor::Right), |
| 321 | ), |
| 322 | PositioningAxis::relative_to_parent( |
| 323 | ParentOffsetBounds::Unbounded, |
| 324 | OffsetType::Pixel(0.), |
| 325 | AnchorPair::new(YAxisAnchor::Top, YAxisAnchor::Top), |
| 326 | ), |
| 327 | ) |
| 328 | } else { |
| 329 | OffsetPositioning::offset_from_parent( |
| 330 | vec2f(0., 0.), |
| 331 | ParentOffsetBounds::Unbounded, |
| 332 | ParentAnchor::TopLeft, |
| 333 | ChildAnchor::TopLeft, |
| 334 | ) |
| 335 | }; |
| 336 | |
| 337 | stack.add_positioned_child(thumb, positioning); |
| 338 | stack.finish() |
| 339 | } |
| 340 | } |
| 341 |