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/segmented_control.rs
StratoSDK / crates / strato-ui-core / src / ui_components / segmented_control.rs
1use itertools::Itertools;
2 
3use 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 
19use core::fmt;
20use std::{borrow::Cow, boxed::Box};
21 
22use super::button::ButtonTooltipPosition;
23const MAX_WIDTH: f32 = 300.0;
24 
25/// A segmented control component with multiple selectable options
26pub 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)]
38pub enum SegmentedControlAction<T: SegmentedControlOption> {
39 SelectOption(T),
40}
41 
42pub enum SegmentedControlEvent<T: SegmentedControlOption> {
43 OptionSelected(T),
44}
45 
46pub struct LabelConfig {
47 pub label: Cow<'static, str>,
48 pub width_override: Option<f32>,
49 pub color: ColorU,
50}
51 
52pub 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.
61pub 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.
72pub trait SegmentedControlOption:
73 fmt::Debug + Copy + Clone + PartialEq + Eq + Send + Sync + 'static
74{
75}
76 
77impl<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.
89pub type BuildRenderableOptionConfig<T> =
90 Box<dyn Fn(T, bool, &AppContext) -> Option<RenderableOptionConfig>>;
91 
92impl<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 
186impl<T: SegmentedControlOption> Entity for SegmentedControl<T> {
187 type Event = SegmentedControlEvent<T>;
188}
189 
190impl<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 
386impl<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