StratoSDK is a framework with a declarative approach similar to Flutter/React, written and designed entirely for Rust.
| 1 | use pathfinder_geometry::vector::vec2f; |
| 2 | use std::borrow::Cow; |
| 3 | |
| 4 | use crate::elements::{ |
| 5 | Align, ChildAnchor, CrossAxisAlignment, Flex, MainAxisAlignment, MainAxisSize, |
| 6 | OffsetPositioning, ParentAnchor, ParentElement, ParentOffsetBounds, Shrinkable, Stack, |
| 7 | }; |
| 8 | use crate::geometry::vector::Vector2F; |
| 9 | |
| 10 | use crate::platform::Cursor; |
| 11 | use crate::{ |
| 12 | elements::{ |
| 13 | Border, ConstrainedBox, Container, Element, Empty, Hoverable, Icon, MouseState, |
| 14 | MouseStateHandle, |
| 15 | }, |
| 16 | ui_components::{ |
| 17 | components::{UiComponent, UiComponentStyles}, |
| 18 | text::Span, |
| 19 | }, |
| 20 | }; |
| 21 | |
| 22 | /// Enum specifying relative alignment of the text and icon within |
| 23 | /// a button. "First" is used instead of left/right to make this |
| 24 | /// robust to RTL languages. |
| 25 | #[derive(Clone, Copy, PartialEq, Eq)] |
| 26 | pub enum TextAndIconAlignment { |
| 27 | /// Render the icon before the text. |
| 28 | IconFirst, |
| 29 | /// Render the text before the icon. |
| 30 | TextFirst, |
| 31 | } |
| 32 | |
| 33 | /// Configuration data for a button containing both a text and an icon. |
| 34 | #[derive(Clone)] |
| 35 | pub struct TextAndIcon { |
| 36 | alignment: TextAndIconAlignment, |
| 37 | /// The amount of space the `Flex` row should consume along the main axis. |
| 38 | flex_size: MainAxisSize, |
| 39 | /// The alignment strategy for rendering the `Flex`. |
| 40 | flex_spacing: MainAxisAlignment, |
| 41 | text: Cow<'static, str>, |
| 42 | icon: Icon, |
| 43 | /// Padding between the text and the icon. |
| 44 | padding: f32, |
| 45 | icon_size: Vector2F, |
| 46 | } |
| 47 | |
| 48 | impl TextAndIcon { |
| 49 | pub fn new( |
| 50 | alignment: TextAndIconAlignment, |
| 51 | text: impl Into<Cow<'static, str>>, |
| 52 | icon: Icon, |
| 53 | flex_size: MainAxisSize, |
| 54 | flex_spacing: MainAxisAlignment, |
| 55 | icon_size: Vector2F, |
| 56 | ) -> Self { |
| 57 | Self { |
| 58 | alignment, |
| 59 | flex_size, |
| 60 | flex_spacing, |
| 61 | text: text.into(), |
| 62 | icon, |
| 63 | padding: 0., |
| 64 | icon_size, |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | pub fn with_inner_padding(mut self, padding: f32) -> Self { |
| 69 | self.padding = padding; |
| 70 | self |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | enum ButtonLabel { |
| 75 | None, |
| 76 | /// A start-aligned text label. |
| 77 | Text(String), |
| 78 | /// A center-aligned text label. |
| 79 | CenteredText(String), |
| 80 | Icon(Icon), |
| 81 | TextAndIcon(TextAndIcon), |
| 82 | Custom(Box<dyn Element>), |
| 83 | } |
| 84 | |
| 85 | pub struct Button { |
| 86 | label: ButtonLabel, |
| 87 | /// Should the button be clickable? |
| 88 | disabled: bool, |
| 89 | /// Was the button clicked and its state is active? |
| 90 | active: bool, |
| 91 | styles: UiComponentStyles, |
| 92 | /// Used when the button is hovered, if None - falls back to `styles` |
| 93 | hovered_styles: Option<UiComponentStyles>, |
| 94 | /// Used when the button is clicked, if None - falls back to `styles` |
| 95 | clicked_styles: Option<UiComponentStyles>, |
| 96 | /// Used when the button is disabled, if None - falls back to `styles` |
| 97 | disabled_styles: Option<UiComponentStyles>, |
| 98 | /// Used when the button is active, if None - falls back to `clicked_styles` when available, |
| 99 | /// or `styles` otherwise |
| 100 | active_styles: Option<UiComponentStyles>, |
| 101 | render_tooltip_fn: Option<Box<dyn FnOnce() -> Box<dyn Element>>>, |
| 102 | tooltip_position: ButtonTooltipPosition, |
| 103 | hover_state: MouseStateHandle, |
| 104 | cursor: Option<Cursor>, |
| 105 | } |
| 106 | |
| 107 | #[derive(Copy, Clone, Debug, PartialEq, Eq)] |
| 108 | pub enum ButtonVariant { |
| 109 | Basic, |
| 110 | Secondary, |
| 111 | Accent, |
| 112 | Outlined, |
| 113 | Warn, |
| 114 | Error, |
| 115 | Text, |
| 116 | Link, |
| 117 | } |
| 118 | |
| 119 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] |
| 120 | pub enum ButtonTooltipPosition { |
| 121 | /// Position the tooltip above the button (center-aligned). |
| 122 | Above, |
| 123 | /// Position the tooltip below the button (center-aligned). |
| 124 | #[default] |
| 125 | Below, |
| 126 | /// Position the tooltip above the button (left-aligned). |
| 127 | AboveLeft, |
| 128 | /// Position the tooltip above the button (right-aligned). |
| 129 | AboveRight, |
| 130 | /// Position the tooltip below the button (left-aligned). |
| 131 | BelowLeft, |
| 132 | /// Position the tooltip below the button (right-aligned). |
| 133 | BelowRight, |
| 134 | } |
| 135 | |
| 136 | impl UiComponent for Button { |
| 137 | type ElementType = Hoverable; |
| 138 | fn build(self) -> Hoverable { |
| 139 | let disabled = self.disabled; |
| 140 | let cursor = self.cursor; |
| 141 | let mut hoverable = Hoverable::new(self.hover_state.clone(), |state| { |
| 142 | self.render_inner_button(state) |
| 143 | }); |
| 144 | if let Some(cursor) = cursor { |
| 145 | hoverable = hoverable.with_cursor(cursor); |
| 146 | } |
| 147 | if disabled { |
| 148 | return hoverable.disable(); |
| 149 | } |
| 150 | hoverable |
| 151 | } |
| 152 | |
| 153 | /// Overwrites _some_ styles passed in `style` parameter |
| 154 | fn with_style(self, styles: UiComponentStyles) -> Self { |
| 155 | Button { |
| 156 | styles: self.styles.merge(styles), |
| 157 | hovered_styles: Some(self.hovered_styles.unwrap_or(self.styles).merge(styles)), |
| 158 | clicked_styles: Some(self.clicked_styles.unwrap_or(self.styles).merge(styles)), |
| 159 | disabled_styles: Some(self.disabled_styles.unwrap_or(self.styles).merge(styles)), |
| 160 | active_styles: Some(self.active_styles.unwrap_or(self.styles).merge(styles)), |
| 161 | ..self |
| 162 | } |
| 163 | } |
| 164 | } |
| 165 | |
| 166 | impl Button { |
| 167 | pub fn new( |
| 168 | mouse_state: MouseStateHandle, |
| 169 | default_styles: UiComponentStyles, |
| 170 | hovered_styles: Option<UiComponentStyles>, |
| 171 | clicked_styles: Option<UiComponentStyles>, |
| 172 | disabled_styles: Option<UiComponentStyles>, |
| 173 | ) -> Self { |
| 174 | Button { |
| 175 | label: ButtonLabel::None, |
| 176 | disabled: false, |
| 177 | styles: default_styles, |
| 178 | hovered_styles, |
| 179 | clicked_styles, |
| 180 | disabled_styles, |
| 181 | active_styles: None, |
| 182 | active: false, |
| 183 | hover_state: mouse_state, |
| 184 | render_tooltip_fn: None, |
| 185 | tooltip_position: Default::default(), |
| 186 | cursor: Some(Cursor::PointingHand), |
| 187 | } |
| 188 | } |
| 189 | |
| 190 | pub fn disabled(mut self) -> Self { |
| 191 | self.disabled = true; |
| 192 | self |
| 193 | } |
| 194 | |
| 195 | pub fn active(mut self) -> Self { |
| 196 | self.active = true; |
| 197 | self |
| 198 | } |
| 199 | |
| 200 | pub fn with_text_label(mut self, label: String) -> Self { |
| 201 | self.label = ButtonLabel::Text(label); |
| 202 | self |
| 203 | } |
| 204 | |
| 205 | pub fn with_centered_text_label(mut self, label: String) -> Self { |
| 206 | self.label = ButtonLabel::CenteredText(label); |
| 207 | self |
| 208 | } |
| 209 | |
| 210 | pub fn with_icon_label(mut self, icon: Icon) -> Self { |
| 211 | self.label = ButtonLabel::Icon(icon); |
| 212 | self |
| 213 | } |
| 214 | |
| 215 | pub fn with_cursor(mut self, cursor: Option<Cursor>) -> Self { |
| 216 | self.cursor = cursor; |
| 217 | self |
| 218 | } |
| 219 | |
| 220 | pub fn with_active_styles(mut self, styles: UiComponentStyles) -> Self { |
| 221 | self.active_styles = Some(self.styles.merge(styles)); |
| 222 | self |
| 223 | } |
| 224 | |
| 225 | pub fn with_hovered_styles(mut self, styles: UiComponentStyles) -> Self { |
| 226 | self.hovered_styles = Some(self.styles.merge(styles)); |
| 227 | self |
| 228 | } |
| 229 | |
| 230 | pub fn hovered_styles(&self) -> &UiComponentStyles { |
| 231 | self.hovered_styles.as_ref().unwrap_or(&self.styles) |
| 232 | } |
| 233 | |
| 234 | pub fn with_disabled_styles(mut self, styles: UiComponentStyles) -> Self { |
| 235 | self.disabled_styles = Some(self.styles.merge(styles)); |
| 236 | self |
| 237 | } |
| 238 | |
| 239 | pub fn with_clicked_styles(mut self, styles: UiComponentStyles) -> Self { |
| 240 | self.clicked_styles = Some(self.styles.merge(styles)); |
| 241 | self |
| 242 | } |
| 243 | |
| 244 | /// Renders text followed by an icon within the Button. |
| 245 | pub fn with_text_and_icon_label(mut self, text_and_icon: TextAndIcon) -> Self { |
| 246 | self.label = ButtonLabel::TextAndIcon(text_and_icon); |
| 247 | self |
| 248 | } |
| 249 | |
| 250 | pub fn with_custom_label(mut self, label: Box<dyn Element>) -> Self { |
| 251 | self.label = ButtonLabel::Custom(label); |
| 252 | self |
| 253 | } |
| 254 | |
| 255 | pub fn with_tooltip<F>(mut self, render_tooltip_fn: F) -> Self |
| 256 | where |
| 257 | F: 'static + FnOnce() -> Box<dyn Element>, |
| 258 | { |
| 259 | self.render_tooltip_fn = Some(Box::new(render_tooltip_fn)); |
| 260 | self |
| 261 | } |
| 262 | |
| 263 | /// Sets how the tooltip is positioned relative to the button itself. This only has an effect |
| 264 | /// if a tooltip is set with [`Self::with_tooltip`]. |
| 265 | pub fn with_tooltip_position(mut self, position: ButtonTooltipPosition) -> Self { |
| 266 | self.tooltip_position = position; |
| 267 | self |
| 268 | } |
| 269 | |
| 270 | fn styles(&self, state: &MouseState) -> UiComponentStyles { |
| 271 | // disabled button ignores click/hover events |
| 272 | if self.disabled { |
| 273 | return self.disabled_styles.unwrap_or(self.styles); |
| 274 | } |
| 275 | |
| 276 | if self.active { |
| 277 | return self |
| 278 | .active_styles |
| 279 | .unwrap_or_else(|| self.clicked_styles.unwrap_or(self.styles)); |
| 280 | } |
| 281 | |
| 282 | // For hover styles, we want to show the correct style based on |
| 283 | // where the mouse _currently_ is, rather than whether the element |
| 284 | // is considered hovered, because the latter takes into account delays. |
| 285 | if state.is_mouse_over_element() { |
| 286 | if state.is_clicked() { |
| 287 | return self.clicked_styles.unwrap_or(self.styles); |
| 288 | } |
| 289 | return self.hovered_styles.unwrap_or(self.styles); |
| 290 | } |
| 291 | self.styles |
| 292 | } |
| 293 | |
| 294 | fn render_inner_button(mut self, state: &MouseState) -> Box<dyn Element> { |
| 295 | let styles = self.styles(state); |
| 296 | // Text & font / Icon |
| 297 | let label = match self.label { |
| 298 | ButtonLabel::Text(text) => Span::new(text, styles).build().finish(), |
| 299 | ButtonLabel::CenteredText(text) => { |
| 300 | Align::new(Span::new(text, styles).build().finish()).finish() |
| 301 | } |
| 302 | ButtonLabel::Icon(icon) => { |
| 303 | if let Some(color) = styles.font_color { |
| 304 | icon.with_color(color).finish() |
| 305 | } else { |
| 306 | icon.finish() |
| 307 | } |
| 308 | } |
| 309 | ButtonLabel::TextAndIcon(text_and_icon) => { |
| 310 | let text = Shrinkable::new( |
| 311 | 1., |
| 312 | Container::new(Span::new(text_and_icon.text, styles).build().finish()).finish(), |
| 313 | ) |
| 314 | .finish(); |
| 315 | let icon = if let Some(color) = styles.font_color { |
| 316 | text_and_icon.icon.with_color(color).finish() |
| 317 | } else { |
| 318 | text_and_icon.icon.finish() |
| 319 | }; |
| 320 | let icon = ConstrainedBox::new(icon) |
| 321 | .with_width(text_and_icon.icon_size.x()) |
| 322 | .with_height(text_and_icon.icon_size.y()) |
| 323 | .finish(); |
| 324 | |
| 325 | let (first, second) = if text_and_icon.alignment == TextAndIconAlignment::TextFirst |
| 326 | { |
| 327 | (text, icon) |
| 328 | } else { |
| 329 | (icon, text) |
| 330 | }; |
| 331 | |
| 332 | Flex::row() |
| 333 | .with_children([ |
| 334 | first, |
| 335 | Container::new(second) |
| 336 | .with_padding_left(text_and_icon.padding) |
| 337 | .finish(), |
| 338 | ]) |
| 339 | .with_cross_axis_alignment(CrossAxisAlignment::Center) |
| 340 | .with_main_axis_alignment(text_and_icon.flex_spacing) |
| 341 | .with_main_axis_size(text_and_icon.flex_size) |
| 342 | .finish() |
| 343 | } |
| 344 | ButtonLabel::Custom(element) => element, |
| 345 | ButtonLabel::None => Empty::new().finish(), |
| 346 | }; |
| 347 | |
| 348 | let mut container = Container::new(label); |
| 349 | // Setting up the border |
| 350 | if let Some(corner) = styles.border_radius { |
| 351 | container = container.with_corner_radius(corner); |
| 352 | } |
| 353 | // TODO border width separate for top/left/right/bottom |
| 354 | let mut border = Border::all(styles.border_width.unwrap_or_default()); |
| 355 | if let Some(border_color) = styles.border_color { |
| 356 | border = border.with_border_fill(border_color); |
| 357 | } |
| 358 | container = container.with_border(border); |
| 359 | |
| 360 | // Position-related settings |
| 361 | if let Some(padding) = styles.padding { |
| 362 | container = container |
| 363 | .with_padding_left(padding.left) |
| 364 | .with_padding_top(padding.top) |
| 365 | .with_padding_right(padding.right) |
| 366 | .with_padding_bottom(padding.bottom); |
| 367 | } |
| 368 | if let Some(margin) = styles.margin { |
| 369 | container = container |
| 370 | .with_margin_left(margin.left) |
| 371 | .with_margin_top(margin.top) |
| 372 | .with_margin_right(margin.right) |
| 373 | .with_margin_bottom(margin.bottom); |
| 374 | } |
| 375 | |
| 376 | if let Some(background) = styles.background { |
| 377 | container = container.with_background(background); |
| 378 | } |
| 379 | |
| 380 | let container = match (styles.height, styles.width) { |
| 381 | (None, None) => container.finish(), |
| 382 | (_, _) => { |
| 383 | let mut constrained_box = ConstrainedBox::new(container.finish()); |
| 384 | if let Some(height) = styles.height { |
| 385 | constrained_box = constrained_box.with_height(height); |
| 386 | } |
| 387 | if let Some(width) = styles.width { |
| 388 | constrained_box = constrained_box.with_width(width); |
| 389 | } |
| 390 | constrained_box.finish() |
| 391 | } |
| 392 | }; |
| 393 | |
| 394 | // The tooltip should only be shown if the element |
| 395 | // is considered hovered (accounting for delays). |
| 396 | if state.is_hovered() { |
| 397 | if let Some(render_tooltip_fn) = self.render_tooltip_fn.take() { |
| 398 | // Keep stack within this rather than using a stack for all cases to allow multiple stack overlays to work |
| 399 | let mut stack = Stack::new(); |
| 400 | stack.add_child(container); |
| 401 | let tooltip = render_tooltip_fn(); |
| 402 | let tooltip_offset = match self.tooltip_position { |
| 403 | ButtonTooltipPosition::Above => OffsetPositioning::offset_from_parent( |
| 404 | vec2f(0., -8.), |
| 405 | ParentOffsetBounds::WindowByPosition, |
| 406 | ParentAnchor::TopMiddle, |
| 407 | ChildAnchor::BottomMiddle, |
| 408 | ), |
| 409 | ButtonTooltipPosition::Below => OffsetPositioning::offset_from_parent( |
| 410 | vec2f(0., 8.), |
| 411 | ParentOffsetBounds::WindowByPosition, |
| 412 | ParentAnchor::BottomMiddle, |
| 413 | ChildAnchor::TopMiddle, |
| 414 | ), |
| 415 | ButtonTooltipPosition::AboveLeft => OffsetPositioning::offset_from_parent( |
| 416 | vec2f(0., -8.), |
| 417 | ParentOffsetBounds::WindowByPosition, |
| 418 | ParentAnchor::TopLeft, |
| 419 | ChildAnchor::BottomLeft, |
| 420 | ), |
| 421 | ButtonTooltipPosition::BelowLeft => OffsetPositioning::offset_from_parent( |
| 422 | vec2f(0., 8.), |
| 423 | ParentOffsetBounds::WindowByPosition, |
| 424 | ParentAnchor::BottomLeft, |
| 425 | ChildAnchor::TopLeft, |
| 426 | ), |
| 427 | ButtonTooltipPosition::AboveRight => OffsetPositioning::offset_from_parent( |
| 428 | vec2f(0., -8.), |
| 429 | ParentOffsetBounds::WindowByPosition, |
| 430 | ParentAnchor::TopRight, |
| 431 | ChildAnchor::BottomRight, |
| 432 | ), |
| 433 | ButtonTooltipPosition::BelowRight => OffsetPositioning::offset_from_parent( |
| 434 | vec2f(0., 8.), |
| 435 | ParentOffsetBounds::WindowByPosition, |
| 436 | ParentAnchor::BottomRight, |
| 437 | ChildAnchor::TopRight, |
| 438 | ), |
| 439 | }; |
| 440 | stack.add_positioned_overlay_child(tooltip, tooltip_offset); |
| 441 | return stack.finish(); |
| 442 | } |
| 443 | } |
| 444 | |
| 445 | container |
| 446 | } |
| 447 | |
| 448 | pub fn set_clicked_styles(mut self, styles: Option<UiComponentStyles>) -> Self { |
| 449 | self.clicked_styles = styles; |
| 450 | self |
| 451 | } |
| 452 | } |
| 453 |