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/switch.rs
1use crate::color::ColorU;
2use crate::elements::{
3 AnchorPair, ChildAnchor, Empty, Fill, OffsetPositioning, OffsetType, ParentAnchor,
4 ParentOffsetBounds, PositioningAxis, Stack, XAxisAnchor, YAxisAnchor,
5};
6use crate::geometry::vector::vec2f;
7use crate::platform::Cursor;
8use crate::scene::{DropShadow, Radius};
9use 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};
18use lazy_static::lazy_static;
19 
20const DEFAULT_THUMB_HEIGHT: f32 = 18.;
21 
22lazy_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)]
38pub 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.
49pub 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)]
65pub struct SwitchStateHandle {
66 component_mouse_state: MouseStateHandle,
67 thumb_mouse_state: MouseStateHandle,
68}
69 
70impl 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 
150impl 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