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-widgets/src/checkbox.rs
StratoSDK / crates / strato-widgets / src / checkbox.rs
1//! Checkbox widget implementation for StratoUI
2 
3use crate::control::{ControlRole, ControlState};
4use crate::widget::{generate_id, Widget, WidgetContext, WidgetId, WidgetState};
5use std::any::Any;
6use strato_core::{
7 event::{Event, EventResult, MouseButton},
8 layout::{Constraints, Layout, Size},
9 state::Signal,
10 theme::Theme,
11 types::{Color, Point, Rect, Transform},
12 vdom::VNode,
13};
14use strato_renderer::batch::RenderBatch;
15 
16/// Checkbox widget for boolean selection
17#[derive(Debug, Clone)]
18pub struct Checkbox {
19 id: WidgetId,
20 checked: Signal<bool>,
21 label: Option<String>,
22 enabled: bool,
23 size: f32,
24 style: CheckboxStyle,
25 bounds: Signal<Rect>,
26 control: ControlState,
27}
28 
29/// Styling options for checkbox
30#[derive(Debug, Clone)]
31pub struct CheckboxStyle {
32 pub size: f32,
33 pub border_width: f32,
34 pub border_radius: f32,
35 pub check_color: [f32; 4],
36 pub border_color: [f32; 4],
37 pub background_color: [f32; 4],
38 pub hover_color: [f32; 4],
39 pub disabled_color: [f32; 4],
40}
41 
42impl Default for CheckboxStyle {
43 fn default() -> Self {
44 Self {
45 size: 20.0,
46 border_width: 2.0,
47 border_radius: 4.0,
48 check_color: [1.0, 1.0, 1.0, 1.0], // White
49 border_color: [0.5, 0.5, 0.5, 1.0], // Gray
50 background_color: [0.2, 0.6, 1.0, 1.0], // Blue
51 hover_color: [0.3, 0.7, 1.0, 1.0], // Light blue
52 disabled_color: [0.7, 0.7, 0.7, 1.0], // Light gray
53 }
54 }
55}
56 
57fn color_from(values: [f32; 4]) -> Color {
58 Color::rgba(values[0], values[1], values[2], values[3])
59}
60 
61fn blend_color(a: Color, b: Color, t: f32) -> Color {
62 let mix = |from: f32, to: f32| from + (to - from) * t;
63 Color::rgba(mix(a.r, b.r), mix(a.g, b.g), mix(a.b, b.b), mix(a.a, b.a))
64}
65 
66impl Checkbox {
67 /// Create a new checkbox
68 pub fn new() -> Self {
69 let mut control = ControlState::new(ControlRole::Checkbox);
70 control.set_toggled(false);
71 Self {
72 id: generate_id(),
73 checked: Signal::new(false),
74 label: None,
75 enabled: true,
76 size: 20.0,
77 style: CheckboxStyle::default(),
78 bounds: Signal::new(Rect::new(0.0, 0.0, 0.0, 0.0)),
79 control,
80 }
81 }
82 
83 /// Set the checked state
84 pub fn checked(mut self, checked: bool) -> Self {
85 self.checked.set(checked);
86 self.control.set_toggled(checked);
87 self
88 }
89 
90 /// Set the label text
91 pub fn label<S: Into<String>>(mut self, label: S) -> Self {
92 let label = label.into();
93 self.control.set_label(label.clone());
94 self.label = Some(label);
95 self
96 }
97 
98 /// Set enabled state
99 pub fn enabled(mut self, enabled: bool) -> Self {
100 self.enabled = enabled;
101 self.control.set_disabled(!enabled);
102 self
103 }
104 
105 /// Set the checkbox size
106 pub fn size(mut self, size: f32) -> Self {
107 self.size = size;
108 self.style.size = size;
109 self
110 }
111 
112 /// Set custom style
113 pub fn style(mut self, style: CheckboxStyle) -> Self {
114 self.style = style;
115 self
116 }
117 
118 /// Get the checked state signal
119 pub fn checked_signal(&self) -> &Signal<bool> {
120 &self.checked
121 }
122 
123 /// Get current checked state
124 pub fn is_checked(&self) -> bool {
125 self.checked.get()
126 }
127 
128 /// Toggle the checkbox state
129 pub fn toggle(&mut self) {
130 let current = self.checked.get();
131 self.checked.set(!current);
132 self.control.set_toggled(!current);
133 }
134 
135 /// Handle click event
136 fn handle_click(&mut self) -> EventResult {
137 if self.enabled {
138 self.toggle();
139 EventResult::Handled
140 } else {
141 EventResult::Ignored
142 }
143 }
144 
145 /// Create the checkbox visual representation
146 fn create_checkbox_node(&self, theme: &Theme) -> VNode {
147 let checked = self.checked.get();
148 let size = self.style.size;
149 
150 let background_color = if !self.enabled {
151 self.style.disabled_color
152 } else if checked {
153 self.style.background_color
154 } else {
155 [1.0, 1.0, 1.0, 1.0] // White background when unchecked
156 };
157 
158 let mut checkbox = VNode::element("div")
159 .attr("class", "checkbox")
160 .attr("width", size.to_string())
161 .attr("height", size.to_string())
162 .attr(
163 "background-color",
164 format!(
165 "rgba({}, {}, {}, {})",
166 background_color[0],
167 background_color[1],
168 background_color[2],
169 background_color[3]
170 ),
171 )
172 .attr(
173 "border",
174 format!(
175 "{}px solid rgba({}, {}, {}, {})",
176 self.style.border_width,
177 self.style.border_color[0],
178 self.style.border_color[1],
179 self.style.border_color[2],
180 self.style.border_color[3]
181 ),
182 )
183 .attr("border-radius", format!("{}px", self.style.border_radius));
184 
185 // Add checkmark if checked
186 if checked {
187 let checkmark = VNode::element("div")
188 .attr("class", "checkmark")
189 .attr(
190 "color",
191 format!(
192 "rgba({}, {}, {}, {})",
193 self.style.check_color[0],
194 self.style.check_color[1],
195 self.style.check_color[2],
196 self.style.check_color[3]
197 ),
198 )
199 .children(vec![VNode::text("✓")]);
200 
201 checkbox = checkbox.children(vec![checkmark]);
202 }
203 
204 checkbox
205 }
206}
207 
208impl Default for Checkbox {
209 fn default() -> Self {
210 Self::new()
211 }
212}
213 
214impl Widget for Checkbox {
215 fn id(&self) -> WidgetId {
216 self.id
217 }
218 
219 fn layout(&mut self, constraints: Constraints) -> Size {
220 let checkbox_size = self.style.size;
221 let label_width = if let Some(ref label) = self.label {
222 // Estimate label width (this would be more accurate with actual text measurement)
223 label.len() as f32 * 8.0 + 8.0 // 8px per char + 8px spacing
224 } else {
225 0.0
226 };
227 
228 let total_width = checkbox_size + label_width;
229 let height = checkbox_size.max(20.0); // Minimum height for text
230 
231 constraints.constrain(Size::new(total_width, height))
232 }
233 
234 fn render(&self, batch: &mut RenderBatch, layout: Layout) {
235 let bounds = Rect::new(
236 layout.position.x,
237 layout.position.y,
238 layout.size.width,
239 layout.size.height,
240 );
241 self.bounds.set(bounds);
242 
243 // Draw checkbox background
244 // Center vertically
245 let box_y = bounds.y + (bounds.height - self.style.size) / 2.0;
246 let box_rect = Rect::new(bounds.x, box_y, self.style.size, self.style.size);
247 let state = self.control.state();
248 let base_color = if !self.enabled {
249 color_from(self.style.disabled_color)
250 } else if self.is_checked() {
251 color_from(self.style.background_color)
252 } else {
253 Color::WHITE
254 };
255 
256 let hover_color = if self.enabled {
257 color_from(self.style.hover_color)
258 } else {
259 base_color
260 };
261 
262 let target_color = match state {
263 WidgetState::Hovered | WidgetState::Pressed => hover_color,
264 WidgetState::Disabled => color_from(self.style.disabled_color),
265 _ => base_color,
266 };
267 let bg_color = blend_color(base_color, target_color, self.control.interaction_factor());
268 
269 batch.add_rect(box_rect, bg_color, Transform::identity());
270 
271 // Draw label
272 if let Some(label) = &self.label {
273 let text_x = bounds.x + self.style.size + 8.0;
274 let text_y = bounds.y + bounds.height / 2.0 - 7.0; // approx center
275 let mut label_color = Color::BLACK;
276 if !self.enabled {
277 label_color.a = 0.6;
278 }
279 batch.add_text(label.clone(), (text_x, text_y), label_color, 14.0, 0.0);
280 }
281 }
282 
283 fn update(&mut self, ctx: &WidgetContext) {
284 self.control.update(ctx.delta_time);
285 }
286 
287 fn handle_event(&mut self, event: &Event) -> EventResult {
288 if let EventResult::Handled = self.control.handle_pointer_event(event, self.bounds.get()) {
289 if matches!(event, Event::MouseUp(_)) {
290 if self.enabled {
291 self.handle_click();
292 }
293 }
294 return EventResult::Handled;
295 }
296 
297 if let EventResult::Handled = self.control.handle_keyboard_activation(event) {
298 if matches!(event, Event::KeyUp(_)) && self.enabled {
299 self.handle_click();
300 }
301 return EventResult::Handled;
302 }
303 
304 EventResult::Ignored
305 }
306 
307 fn as_any(&self) -> &dyn Any {
308 self
309 }
310 
311 fn as_any_mut(&mut self) -> &mut dyn Any {
312 self
313 }
314 
315 fn clone_widget(&self) -> Box<dyn Widget> {
316 Box::new(self.clone())
317 }
318 
319 // Removed state method as it's not part of Widget trait
320}
321 
322/// Radio button widget for single selection from a group
323#[derive(Debug, Clone)]
324pub struct RadioButton {
325 id: WidgetId,
326 selected: Signal<bool>,
327 group: String,
328 value: String,
329 label: Option<String>,
330 enabled: bool,
331 style: RadioStyle,
332 bounds: Signal<Rect>,
333 control: ControlState,
334}
335 
336/// Styling options for radio button
337#[derive(Debug, Clone)]
338pub struct RadioStyle {
339 pub size: f32,
340 pub border_width: f32,
341 pub dot_color: [f32; 4],
342 pub border_color: [f32; 4],
343 pub background_color: [f32; 4],
344 pub hover_color: [f32; 4],
345 pub disabled_color: [f32; 4],
346}
347 
348impl Default for RadioStyle {
349 fn default() -> Self {
350 Self {
351 size: 20.0,
352 border_width: 2.0,
353 dot_color: [1.0, 1.0, 1.0, 1.0], // White
354 border_color: [0.5, 0.5, 0.5, 1.0], // Gray
355 background_color: [0.2, 0.6, 1.0, 1.0], // Blue
356 hover_color: [0.3, 0.7, 1.0, 1.0], // Light blue
357 disabled_color: [0.7, 0.7, 0.7, 1.0], // Light gray
358 }
359 }
360}
361 
362impl RadioButton {
363 /// Create a new radio button
364 pub fn new<S: Into<String>>(group: S, value: S) -> Self {
365 let mut control = ControlState::new(ControlRole::Radio);
366 control.set_toggled(false);
367 Self {
368 id: generate_id(),
369 selected: Signal::new(false),
370 group: group.into(),
371 value: value.into(),
372 label: None,
373 enabled: true,
374 style: RadioStyle::default(),
375 bounds: Signal::new(Rect::new(0.0, 0.0, 0.0, 0.0)),
376 control,
377 }
378 }
379 
380 /// Set the selected state
381 pub fn selected(mut self, selected: bool) -> Self {
382 self.selected.set(selected);
383 self.control.set_toggled(selected);
384 self
385 }
386 
387 /// Set the label text
388 pub fn label<S: Into<String>>(mut self, label: S) -> Self {
389 let label = label.into();
390 self.control.set_label(label.clone());
391 self.label = Some(label);
392 self
393 }
394 
395 /// Set enabled state
396 pub fn enabled(mut self, enabled: bool) -> Self {
397 self.enabled = enabled;
398 self.control.set_disabled(!enabled);
399 self
400 }
401 
402 /// Set custom style
403 pub fn style(mut self, style: RadioStyle) -> Self {
404 self.style = style;
405 self
406 }
407 
408 /// Get the selected state signal
409 pub fn selected_signal(&self) -> &Signal<bool> {
410 &self.selected
411 }
412 
413 /// Get current selected state
414 pub fn is_selected(&self) -> bool {
415 self.selected.get()
416 }
417 
418 /// Get the group name
419 pub fn group(&self) -> &str {
420 &self.group
421 }
422 
423 /// Get the value
424 pub fn value(&self) -> &str {
425 &self.value
426 }
427 
428 /// Select this radio button
429 pub fn select(&mut self) {
430 self.selected.set(true);
431 self.control.set_toggled(true);
432 }
433 
434 /// Deselect this radio button
435 pub fn deselect(&mut self) {
436 self.selected.set(false);
437 self.control.set_toggled(false);
438 }
439}
440 
441impl Widget for RadioButton {
442 fn id(&self) -> WidgetId {
443 self.id
444 }
445 
446 fn layout(&mut self, constraints: Constraints) -> Size {
447 let radio_size = self.style.size;
448 let label_width = if let Some(ref label) = self.label {
449 label.len() as f32 * 8.0 + 8.0
450 } else {
451 0.0
452 };
453 
454 let total_width = radio_size + label_width;
455 let height = radio_size.max(20.0);
456 
457 constraints.constrain(Size::new(total_width, height))
458 }
459 
460 fn render(&self, batch: &mut RenderBatch, layout: Layout) {
461 let bounds = Rect::new(
462 layout.position.x,
463 layout.position.y,
464 layout.size.width,
465 layout.size.height,
466 );
467 self.bounds.set(bounds);
468 
469 // Draw radio background (circle)
470 let radio_y = bounds.y + (bounds.height - self.style.size) / 2.0;
471 let center = (
472 bounds.x + self.style.size / 2.0,
473 radio_y + self.style.size / 2.0,
474 );
475 let radius = self.style.size / 2.0;
476 
477 let state = self.control.state();
478 let base_color = if !self.enabled {
479 color_from(self.style.disabled_color)
480 } else if self.is_selected() {
481 color_from(self.style.background_color)
482 } else {
483 Color::WHITE
484 };
485 
486 let hover_color = if self.enabled {
487 color_from(self.style.hover_color)
488 } else {
489 base_color
490 };
491 
492 let target_color = match state {
493 WidgetState::Hovered | WidgetState::Pressed => hover_color,
494 WidgetState::Disabled => color_from(self.style.disabled_color),
495 _ => base_color,
496 };
497 let bg_color = blend_color(base_color, target_color, self.control.interaction_factor());
498 
499 batch.add_circle(
500 center,
501 radius,
502 bg_color,
503 16,
504 strato_core::types::Transform::default(),
505 );
506 
507 // Draw label
508 if let Some(label) = &self.label {
509 let text_x = bounds.x + self.style.size + 8.0;
510 let text_y = bounds.y + bounds.height / 2.0 - 7.0; // approx center
511 let mut label_color = Color::BLACK;
512 if !self.enabled {
513 label_color.a = 0.6;
514 }
515 batch.add_text(label.clone(), (text_x, text_y), label_color, 14.0, 0.0);
516 }
517 }
518 
519 fn update(&mut self, ctx: &WidgetContext) {
520 self.control.update(ctx.delta_time);
521 }
522 
523 fn handle_event(&mut self, event: &Event) -> EventResult {
524 if let EventResult::Handled = self.control.handle_pointer_event(event, self.bounds.get()) {
525 if matches!(event, Event::MouseUp(_)) {
526 if self.enabled {
527 self.select();
528 }
529 }
530 return EventResult::Handled;
531 }
532 
533 if let EventResult::Handled = self.control.handle_keyboard_activation(event) {
534 if matches!(event, Event::KeyUp(_)) && self.enabled {
535 self.select();
536 }
537 return EventResult::Handled;
538 }
539 
540 EventResult::Ignored
541 }
542 
543 fn as_any(&self) -> &dyn Any {
544 self
545 }
546 
547 fn as_any_mut(&mut self) -> &mut dyn Any {
548 self
549 }
550 
551 fn clone_widget(&self) -> Box<dyn Widget> {
552 Box::new(self.clone())
553 }
554 
555 // Removed state method as it's not part of Widget trait
556}
557 
558#[cfg(test)]
559mod tests {
560 use super::*;
561 
562 #[test]
563 fn test_checkbox_creation() {
564 let checkbox = Checkbox::new();
565 assert!(!checkbox.is_checked());
566 assert!(checkbox.enabled);
567 }
568 
569 #[test]
570 fn test_checkbox_toggle() {
571 let mut checkbox = Checkbox::new();
572 assert!(!checkbox.is_checked());
573 
574 checkbox.toggle();
575 assert!(checkbox.is_checked());
576 
577 checkbox.toggle();
578 assert!(!checkbox.is_checked());
579 }
580 
581 #[test]
582 fn test_radio_button_creation() {
583 let radio = RadioButton::new("group1", "value1");
584 assert!(!radio.is_selected());
585 assert_eq!(radio.group(), "group1");
586 assert_eq!(radio.value(), "value1");
587 }
588 
589 #[test]
590 fn test_radio_button_selection() {
591 let mut radio = RadioButton::new("group1", "value1");
592 assert!(!radio.is_selected());
593 
594 radio.select();
595 assert!(radio.is_selected());
596 
597 radio.deselect();
598 assert!(!radio.is_selected());
599 }
600}
601