StratoSDK is a framework with a declarative approach similar to Flutter/React, written and designed entirely for Rust.
| 1 | use itertools::Itertools; |
| 2 | |
| 3 | use crate::{ |
| 4 | color::ColorU, |
| 5 | elements::{ |
| 6 | Align, Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Fill, Flex, |
| 7 | Icon, MainAxisAlignment, MouseStateHandle, ParentElement, Radius, Text, |
| 8 | }, |
| 9 | fonts::FamilyId, |
| 10 | platform::Cursor, |
| 11 | ui_components::{ |
| 12 | button::Button, |
| 13 | components::{Coords, UiComponent, UiComponentStyles}, |
| 14 | tool_tip::{Tooltip, TooltipWithSublabel}, |
| 15 | }, |
| 16 | AppContext, Element, Entity, TypedActionView, View, ViewContext, |
| 17 | }; |
| 18 | |
| 19 | use core::fmt; |
| 20 | use std::{borrow::Cow, boxed::Box}; |
| 21 | |
| 22 | use super::button::ButtonTooltipPosition; |
| 23 | const MAX_WIDTH: f32 = 300.0; |
| 24 | |
| 25 | /// A segmented control component with multiple selectable options |
| 26 | pub struct SegmentedControl<T> { |
| 27 | options: Vec<T>, |
| 28 | selected_option: T, |
| 29 | build_option_config: BuildRenderableOptionConfig<T>, |
| 30 | mouse_states: Vec<MouseStateHandle>, |
| 31 | styles: UiComponentStyles, |
| 32 | |
| 33 | /// If Some, we will set the control to disabled and use the tooltip text provided |
| 34 | disabled_tooltip: Option<Cow<'static, str>>, |
| 35 | } |
| 36 | |
| 37 | #[derive(Debug)] |
| 38 | pub enum SegmentedControlAction<T: SegmentedControlOption> { |
| 39 | SelectOption(T), |
| 40 | } |
| 41 | |
| 42 | pub enum SegmentedControlEvent<T: SegmentedControlOption> { |
| 43 | OptionSelected(T), |
| 44 | } |
| 45 | |
| 46 | pub struct LabelConfig { |
| 47 | pub label: Cow<'static, str>, |
| 48 | pub width_override: Option<f32>, |
| 49 | pub color: ColorU, |
| 50 | } |
| 51 | |
| 52 | pub struct TooltipConfig { |
| 53 | pub text: Cow<'static, str>, |
| 54 | pub sub_text: Option<Cow<'static, str>>, |
| 55 | pub text_color: ColorU, |
| 56 | pub background_color: ColorU, |
| 57 | pub border_color: ColorU, |
| 58 | } |
| 59 | |
| 60 | /// Config for rendering an option within the control. |
| 61 | pub struct RenderableOptionConfig { |
| 62 | pub icon_path: &'static str, |
| 63 | pub icon_color: ColorU, |
| 64 | pub label: Option<LabelConfig>, |
| 65 | pub tooltip: Option<TooltipConfig>, |
| 66 | pub background: Fill, |
| 67 | } |
| 68 | |
| 69 | /// Trait for data types that may be used as options within a segmented control. |
| 70 | /// |
| 71 | /// This basically exists to ensure options are `Copy` and support checking for value equality. |
| 72 | pub trait SegmentedControlOption: |
| 73 | fmt::Debug + Copy + Clone + PartialEq + Eq + Send + Sync + 'static |
| 74 | { |
| 75 | } |
| 76 | |
| 77 | impl<T> SegmentedControlOption for T where |
| 78 | T: fmt::Debug + Copy + Clone + PartialEq + Eq + Send + Sync + 'static |
| 79 | { |
| 80 | } |
| 81 | |
| 82 | /// Type alias for function used to construct a [`RenderableOptionConfig`] to do determine how to |
| 83 | /// render an option within the segmented control, called at render time. |
| 84 | /// |
| 85 | /// The first param is the option `T` being rendered, the second param is a boolean indicating |
| 86 | /// whether the option is currently selected. |
| 87 | /// |
| 88 | /// If the returned value is [`None`], the option will not be rendered. |
| 89 | pub type BuildRenderableOptionConfig<T> = |
| 90 | Box<dyn Fn(T, bool, &AppContext) -> Option<RenderableOptionConfig>>; |
| 91 | |
| 92 | impl<T: SegmentedControlOption> SegmentedControl<T> { |
| 93 | pub fn new<F>( |
| 94 | options: Vec<T>, |
| 95 | build_option_config_fn: F, |
| 96 | mut default_option: T, |
| 97 | styles: UiComponentStyles, |
| 98 | ) -> Self |
| 99 | where |
| 100 | F: Fn(T, bool, &AppContext) -> Option<RenderableOptionConfig> + 'static, |
| 101 | { |
| 102 | debug_assert!( |
| 103 | options.contains(&default_option), |
| 104 | "Default option must be one of the provided options" |
| 105 | ); |
| 106 | |
| 107 | if !options.contains(&default_option) { |
| 108 | default_option = options[0]; |
| 109 | } |
| 110 | |
| 111 | let mouse_states = options |
| 112 | .iter() |
| 113 | .map(|_| MouseStateHandle::default()) |
| 114 | .collect(); |
| 115 | |
| 116 | Self { |
| 117 | options, |
| 118 | build_option_config: Box::new(build_option_config_fn), |
| 119 | selected_option: default_option, |
| 120 | mouse_states, |
| 121 | styles, |
| 122 | disabled_tooltip: None, |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | /// Get the value of the currently selected option |
| 127 | pub fn selected_option(&self) -> T { |
| 128 | self.selected_option |
| 129 | } |
| 130 | |
| 131 | /// Set the selected option. |
| 132 | /// |
| 133 | /// If `option` is not present in `self.options`, does nothing. |
| 134 | pub fn set_selected_option(&mut self, option: T, ctx: &mut ViewContext<Self>) { |
| 135 | if !self.options.iter().contains(&option) { |
| 136 | return; |
| 137 | } |
| 138 | self.selected_option = option; |
| 139 | ctx.notify(); |
| 140 | } |
| 141 | |
| 142 | pub fn set_styles(&mut self, styles: UiComponentStyles, ctx: &mut ViewContext<Self>) { |
| 143 | self.styles = styles; |
| 144 | ctx.notify(); |
| 145 | } |
| 146 | |
| 147 | /// Enable/disable the segmented control (disables click selection but retains hover/tooltip) |
| 148 | pub fn set_disabled_tooltip( |
| 149 | &mut self, |
| 150 | disabled_tooltip: Option<Cow<'static, str>>, |
| 151 | ctx: &mut ViewContext<Self>, |
| 152 | ) { |
| 153 | self.disabled_tooltip = disabled_tooltip; |
| 154 | ctx.notify(); |
| 155 | } |
| 156 | |
| 157 | /// Update the available options in the control. |
| 158 | /// |
| 159 | /// If the currently selected option is not present in the new list, selects the first option by default. |
| 160 | pub fn update_options(&mut self, updated_options: Vec<T>, ctx: &mut ViewContext<Self>) { |
| 161 | debug_assert!( |
| 162 | !updated_options.is_empty(), |
| 163 | "Cannot pass empty options to SegmentedControl" |
| 164 | ); |
| 165 | if updated_options.is_empty() { |
| 166 | log::error!("Attempted to update SegmentedControl with empty options"); |
| 167 | return; |
| 168 | } |
| 169 | |
| 170 | let should_update_selected = !updated_options.contains(&self.selected_option); |
| 171 | self.options = updated_options; |
| 172 | |
| 173 | self.mouse_states = self |
| 174 | .options |
| 175 | .iter() |
| 176 | .map(|_| MouseStateHandle::default()) |
| 177 | .collect(); |
| 178 | |
| 179 | if should_update_selected { |
| 180 | self.set_selected_option(self.options[0], ctx); |
| 181 | } |
| 182 | ctx.notify(); |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | impl<T: SegmentedControlOption> Entity for SegmentedControl<T> { |
| 187 | type Event = SegmentedControlEvent<T>; |
| 188 | } |
| 189 | |
| 190 | impl<T: SegmentedControlOption> View for SegmentedControl<T> { |
| 191 | fn ui_name() -> &'static str { |
| 192 | "SegmentedControl" |
| 193 | } |
| 194 | |
| 195 | fn render(&self, app: &AppContext) -> Box<dyn Element> { |
| 196 | let is_disabled = self.disabled_tooltip.is_some(); |
| 197 | let mut options_container = Flex::row() |
| 198 | .with_cross_axis_alignment(CrossAxisAlignment::Center) |
| 199 | .with_main_axis_alignment(MainAxisAlignment::Start); |
| 200 | |
| 201 | for (index, option) in self.options.iter().enumerate() { |
| 202 | let is_selected = *option == self.selected_option; |
| 203 | let Some(mut option_config) = (self.build_option_config)(*option, is_selected, app) |
| 204 | else { |
| 205 | continue; |
| 206 | }; |
| 207 | |
| 208 | // If globally disabled and an override tooltip is set, replace the tooltip text |
| 209 | if let Some(disabled_text) = self.disabled_tooltip.clone() { |
| 210 | // Override tooltip text with disabled text |
| 211 | if let Some(tooltip_config) = option_config.tooltip.as_mut() { |
| 212 | tooltip_config.text = disabled_text; |
| 213 | // Clear keybinding/subtext when disabled |
| 214 | tooltip_config.sub_text = None; |
| 215 | } |
| 216 | } |
| 217 | |
| 218 | let mouse_state = self.mouse_states[index].clone(); |
| 219 | |
| 220 | let button_styles = UiComponentStyles { |
| 221 | background: Some(option_config.background), |
| 222 | // Slightly tighter padding to keep controls compact in narrow headers. |
| 223 | padding: Some(Coords::uniform(2.0)), |
| 224 | border_width: None, |
| 225 | border_radius: Some(CornerRadius::with_all(Radius::Pixels(3.0))), |
| 226 | margin: None, |
| 227 | ..self.styles |
| 228 | }; |
| 229 | |
| 230 | let mut button = Button::new( |
| 231 | mouse_state, |
| 232 | button_styles, |
| 233 | None, // hover styles |
| 234 | None, // clicked styles |
| 235 | None, // disabled styles |
| 236 | ); |
| 237 | |
| 238 | if let Some(label_config) = option_config.label.take() { |
| 239 | let font_size = if cfg!(any(windows, target_os = "linux")) { |
| 240 | // Reduce the font size by one to avoid text being cut off on Windows and Linux. |
| 241 | self.styles.font_size.unwrap_or(12.0) - 1.0 |
| 242 | } else { |
| 243 | self.styles.font_size.unwrap_or(12.0) |
| 244 | }; |
| 245 | let icon_size = font_size * 1.4; |
| 246 | let font_family_id = self.styles.font_family_id.unwrap_or(FamilyId(0)); |
| 247 | |
| 248 | let mut text = ConstrainedBox::new( |
| 249 | Container::new( |
| 250 | Align::new( |
| 251 | Text::new(label_config.label, font_family_id, font_size) |
| 252 | .with_color(option_config.icon_color) |
| 253 | .finish(), |
| 254 | ) |
| 255 | .finish(), |
| 256 | ) |
| 257 | // Account for icon margins due to viewbox difference in the SVG |
| 258 | .with_padding_right(icon_size * 0.2) |
| 259 | .finish(), |
| 260 | ); |
| 261 | |
| 262 | if let Some(width_override) = label_config.width_override { |
| 263 | // Scale label width by the same ratio as font size for proper zoom behavior |
| 264 | let font_size = self.styles.font_size.unwrap_or(12.0); |
| 265 | let base_font_size = 10.0; // Match the base font size used in universal_developer_input.rs |
| 266 | let ui_scalar = font_size / base_font_size; |
| 267 | text = text.with_width(width_override * ui_scalar); |
| 268 | } |
| 269 | |
| 270 | let text = text.finish(); |
| 271 | |
| 272 | if option_config.icon_path.is_empty() { |
| 273 | button = button.with_custom_label(text); |
| 274 | } else { |
| 275 | let icon = ConstrainedBox::new( |
| 276 | Container::new( |
| 277 | Icon::new(option_config.icon_path, option_config.icon_color).finish(), |
| 278 | ) |
| 279 | .finish(), |
| 280 | ) |
| 281 | .with_width(icon_size) |
| 282 | .with_height(icon_size) |
| 283 | .finish(); |
| 284 | |
| 285 | let button_label = Flex::row() |
| 286 | .with_cross_axis_alignment(CrossAxisAlignment::Center) |
| 287 | .with_child(icon) |
| 288 | .with_child(text) |
| 289 | .finish(); |
| 290 | |
| 291 | button = button.with_custom_label(button_label); |
| 292 | } |
| 293 | } else { |
| 294 | button = button |
| 295 | .with_icon_label(Icon::new(option_config.icon_path, option_config.icon_color)); |
| 296 | } |
| 297 | |
| 298 | if let Some(tooltip_config) = option_config.tooltip.as_ref() { |
| 299 | let styles = self.styles; |
| 300 | let tooltip = tooltip_config.text.clone(); |
| 301 | let subtext = tooltip_config.sub_text.clone(); |
| 302 | let text_color = tooltip_config.text_color; |
| 303 | let background_color = tooltip_config.background_color; |
| 304 | let border_color = tooltip_config.border_color; |
| 305 | button = button.with_tooltip(move || { |
| 306 | let styles = UiComponentStyles { |
| 307 | font_color: Some(text_color), |
| 308 | background: Some(Fill::Solid(background_color)), |
| 309 | border_radius: Some(CornerRadius::with_all(Radius::Pixels(4.0))), |
| 310 | border_width: Some(1.0), |
| 311 | border_color: Some(Fill::Solid(border_color)), |
| 312 | font_family_id: styles.font_family_id, |
| 313 | font_size: styles.font_size.map(|size| size - 2.0), |
| 314 | padding: Some(Coords { |
| 315 | top: 4., |
| 316 | bottom: 4., |
| 317 | left: 8., |
| 318 | right: 8., |
| 319 | }), |
| 320 | ..Default::default() |
| 321 | }; |
| 322 | if let Some(subtext) = subtext { |
| 323 | TooltipWithSublabel::new(tooltip.into(), subtext.into(), styles) |
| 324 | .build() |
| 325 | .finish() |
| 326 | } else { |
| 327 | Tooltip::new(tooltip.into(), styles).build().finish() |
| 328 | } |
| 329 | }); |
| 330 | } |
| 331 | |
| 332 | button = button.with_tooltip_position(ButtonTooltipPosition::AboveLeft); |
| 333 | |
| 334 | let option_copy = *option; |
| 335 | let mut hoverable = button.build().with_cursor(if is_disabled { |
| 336 | Cursor::Arrow |
| 337 | } else { |
| 338 | Cursor::PointingHand |
| 339 | }); |
| 340 | |
| 341 | // Buttons should not be clickable if they are disabled |
| 342 | if !is_disabled { |
| 343 | hoverable = hoverable.on_click({ |
| 344 | move |ctx, _, _| { |
| 345 | ctx.dispatch_typed_action(SegmentedControlAction::SelectOption( |
| 346 | option_copy, |
| 347 | )); |
| 348 | } |
| 349 | }); |
| 350 | } |
| 351 | |
| 352 | options_container = options_container.with_child(hoverable.finish()); |
| 353 | } |
| 354 | |
| 355 | let mut container = Container::new( |
| 356 | ConstrainedBox::new(options_container.finish()) |
| 357 | .with_max_width(MAX_WIDTH) |
| 358 | .finish(), |
| 359 | ); |
| 360 | |
| 361 | // Apply styles from UiComponentStyles |
| 362 | if let Some(background) = self.styles.background { |
| 363 | container = container.with_background(background); |
| 364 | } |
| 365 | if let Some(border_width) = self.styles.border_width { |
| 366 | if let Some(border_color) = self.styles.border_color { |
| 367 | container = |
| 368 | container.with_border(Border::all(border_width).with_border_fill(border_color)); |
| 369 | } |
| 370 | } |
| 371 | if let Some(border_radius) = self.styles.border_radius { |
| 372 | container = container.with_corner_radius(border_radius); |
| 373 | } |
| 374 | if let Some(margin) = self.styles.margin { |
| 375 | container = container |
| 376 | .with_margin_left(margin.left) |
| 377 | .with_margin_right(margin.right) |
| 378 | .with_margin_top(margin.top) |
| 379 | .with_margin_bottom(margin.bottom); |
| 380 | } |
| 381 | |
| 382 | container.finish() |
| 383 | } |
| 384 | } |
| 385 | |
| 386 | impl<T: SegmentedControlOption> TypedActionView for SegmentedControl<T> { |
| 387 | type Action = SegmentedControlAction<T>; |
| 388 | |
| 389 | fn handle_action(&mut self, action: &SegmentedControlAction<T>, ctx: &mut ViewContext<Self>) { |
| 390 | match action { |
| 391 | SegmentedControlAction::SelectOption(option) => { |
| 392 | self.set_selected_option(*option, ctx); |
| 393 | ctx.emit(SegmentedControlEvent::OptionSelected(*option)); |
| 394 | } |
| 395 | } |
| 396 | } |
| 397 | } |
| 398 |