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/button.rs
1//! Button widget implementation
2//!
3//! Provides interactive button components with various styles, states, and event handling.
4 
5use crate::control::{ControlRole, ControlState};
6use crate::widget::{generate_id, Widget, WidgetContext, WidgetId, WidgetState};
7use std::{any::Any, sync::Arc};
8use strato_core::{
9 event::{Event, EventResult},
10 layout::{Constraints, Layout, Size},
11 state::Signal,
12 taffy::{
13 prelude::*,
14 style::{Dimension, LengthPercentage},
15 },
16 taffy_layout::{TaffyLayoutError, TaffyLayoutResult, TaffyWidget},
17 theme::{Color, Theme},
18 types::Rect,
19 types::{Point, Transform},
20};
21use strato_renderer::{batch::RenderBatch, vertex::VertexBuilder};
22 
23/// Button state is kept in sync with the shared widget state enum.
24pub type ButtonState = WidgetState;
25 
26/// Button style configuration
27#[derive(Debug, Clone)]
28pub struct ButtonStyle {
29 pub background_color: Color,
30 pub hover_color: Color,
31 pub pressed_color: Color,
32 pub disabled_color: Color,
33 pub text_color: Color,
34 pub border_radius: f32,
35 pub border_width: f32,
36 pub border_color: Color,
37 pub padding: f32,
38 pub font_size: f32,
39 pub min_width: f32,
40 pub min_height: f32,
41}
42 
43impl Default for ButtonStyle {
44 fn default() -> Self {
45 Self {
46 background_color: Color::rgba(0.2, 0.4, 0.8, 1.0),
47 hover_color: Color::rgba(0.3, 0.5, 0.9, 1.0),
48 pressed_color: Color::rgba(0.1, 0.3, 0.7, 1.0),
49 disabled_color: Color::rgba(0.5, 0.5, 0.5, 1.0),
50 text_color: Color::rgba(1.0, 1.0, 1.0, 1.0),
51 border_radius: 4.0,
52 border_width: 0.0,
53 border_color: Color::rgba(0.0, 0.0, 0.0, 0.0),
54 padding: 12.0,
55 font_size: 14.0,
56 min_width: 80.0,
57 min_height: 32.0,
58 }
59 }
60}
61 
62impl ButtonStyle {
63 /// Create a primary button style
64 pub fn primary() -> Self {
65 Self {
66 background_color: Color::rgba(0.0, 0.4, 0.8, 1.0),
67 hover_color: Color::rgba(0.1, 0.5, 0.9, 1.0),
68 pressed_color: Color::rgba(0.0, 0.3, 0.7, 1.0),
69 ..Default::default()
70 }
71 }
72 
73 /// Create a secondary button style
74 pub fn secondary() -> Self {
75 Self {
76 background_color: Color::rgba(0.6, 0.6, 0.6, 1.0),
77 hover_color: Color::rgba(0.7, 0.7, 0.7, 1.0),
78 pressed_color: Color::rgba(0.5, 0.5, 0.5, 1.0),
79 text_color: Color::rgba(0.0, 0.0, 0.0, 1.0),
80 ..Default::default()
81 }
82 }
83 
84 /// Create a danger button style
85 pub fn danger() -> Self {
86 Self {
87 background_color: Color::rgba(0.8, 0.2, 0.2, 1.0),
88 hover_color: Color::rgba(0.9, 0.3, 0.3, 1.0),
89 pressed_color: Color::rgba(0.7, 0.1, 0.1, 1.0),
90 ..Default::default()
91 }
92 }
93 
94 /// Create an outline button style
95 pub fn outline() -> Self {
96 Self {
97 background_color: Color::rgba(0.0, 0.0, 0.0, 0.0),
98 hover_color: Color::rgba(0.0, 0.4, 0.8, 0.1),
99 pressed_color: Color::rgba(0.0, 0.4, 0.8, 0.2),
100 text_color: Color::rgba(0.0, 0.4, 0.8, 1.0),
101 border_width: 1.0,
102 border_color: Color::rgba(0.0, 0.4, 0.8, 1.0),
103 ..Default::default()
104 }
105 }
106 
107 /// Create a ghost button style
108 pub fn ghost() -> Self {
109 Self {
110 background_color: Color::rgba(0.0, 0.0, 0.0, 0.0),
111 hover_color: Color::rgba(0.0, 0.0, 0.0, 0.05),
112 pressed_color: Color::rgba(0.0, 0.0, 0.0, 0.1),
113 text_color: Color::rgba(0.3, 0.3, 0.3, 1.0),
114 border_width: 0.0,
115 ..Default::default()
116 }
117 }
118 
119 /// Create a text button style (transparent background)
120 pub fn text() -> Self {
121 Self {
122 background_color: Color::rgba(0.0, 0.0, 0.0, 0.0),
123 hover_color: Color::rgba(0.0, 0.0, 0.0, 0.05),
124 pressed_color: Color::rgba(0.0, 0.0, 0.0, 0.1),
125 text_color: Color::rgba(0.0, 0.4, 0.8, 1.0),
126 border_width: 0.0,
127 ..Default::default()
128 }
129 }
130}
131 
132fn blend_colors(from: Color, to: Color, t: f32) -> Color {
133 let mix = |a: f32, b: f32| a + (b - a) * t;
134 Color::rgba(
135 mix(from.r, to.r),
136 mix(from.g, to.g),
137 mix(from.b, to.b),
138 mix(from.a, to.a),
139 )
140}
141 
142/// Button widget
143pub struct Button {
144 id: WidgetId,
145 text: String,
146 style: ButtonStyle,
147 control: ControlState,
148 bounds: Signal<Rect>,
149 enabled: Signal<bool>,
150 visible: Signal<bool>,
151 on_click: Option<Box<dyn Fn() + Send + Sync>>,
152 on_hover: Option<Box<dyn Fn(bool) + Send + Sync>>,
153 theme: Option<Arc<Theme>>,
154}
155 
156impl std::fmt::Debug for Button {
157 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158 f.debug_struct("Button")
159 .field("id", &self.id)
160 .field("text", &self.text)
161 .field("style", &self.style)
162 .field("state", &self.control)
163 .field("bounds", &self.bounds)
164 .field("enabled", &self.enabled)
165 .field("visible", &self.visible)
166 .field(
167 "on_click",
168 &self.on_click.as_ref().map(|_| "Fn() + Send + Sync"),
169 )
170 .field(
171 "on_hover",
172 &self.on_hover.as_ref().map(|_| "Fn(bool) + Send + Sync"),
173 )
174 .field("theme", &self.theme)
175 .finish()
176 }
177}
178 
179impl Button {
180 /// Create a new button with text
181 pub fn new(text: impl Into<String>) -> Self {
182 let text_value = text.into();
183 let mut control = ControlState::new(ControlRole::Button);
184 control.set_label(text_value.clone());
185 Self {
186 id: generate_id(),
187 text: text_value,
188 style: ButtonStyle::default(),
189 control,
190 bounds: Signal::new(Rect::new(0.0, 0.0, 0.0, 0.0)),
191 enabled: Signal::new(true),
192 visible: Signal::new(true),
193 on_click: None,
194 on_hover: None,
195 theme: None,
196 }
197 }
198 
199 /// Set button style
200 pub fn style(mut self, style: ButtonStyle) -> Self {
201 self.style = style;
202 self
203 }
204 
205 /// Set primary style
206 pub fn primary(mut self) -> Self {
207 self.style = ButtonStyle::primary();
208 self
209 }
210 
211 /// Set secondary style
212 pub fn secondary(mut self) -> Self {
213 self.style = ButtonStyle::secondary();
214 self
215 }
216 
217 /// Set danger style
218 pub fn danger(mut self) -> Self {
219 self.style = ButtonStyle::danger();
220 self
221 }
222 
223 /// Set outline style
224 pub fn outline(mut self) -> Self {
225 self.style = ButtonStyle::outline();
226 self
227 }
228 
229 /// Set ghost style
230 pub fn ghost(mut self) -> Self {
231 self.style = ButtonStyle::ghost();
232 self
233 }
234 
235 /// Set click handler
236 pub fn on_click<F>(mut self, handler: F) -> Self
237 where
238 F: Fn() + Send + Sync + 'static,
239 {
240 self.on_click = Some(Box::new(handler));
241 self
242 }
243 
244 /// Set hover handler
245 pub fn on_hover<F>(mut self, handler: F) -> Self
246 where
247 F: Fn(bool) + Send + Sync + 'static,
248 {
249 self.on_hover = Some(Box::new(handler));
250 self
251 }
252 
253 /// Set enabled state
254 pub fn enabled(self, enabled: bool) -> Self {
255 self.enabled.set(enabled);
256 self.control.set_disabled(!enabled);
257 self
258 }
259 
260 /// Set visible state
261 pub fn visible(self, visible: bool) -> Self {
262 self.visible.set(visible);
263 self
264 }
265 
266 /// Set theme
267 pub fn theme(mut self, theme: Arc<Theme>) -> Self {
268 self.theme = Some(theme);
269 self
270 }
271 
272 /// Set button size (width, height)
273 pub fn size(mut self, width: f32, height: f32) -> Self {
274 self.style.min_width = width;
275 self.style.min_height = height;
276 self
277 }
278 
279 /// Get button ID
280 pub fn id(&self) -> WidgetId {
281 self.id
282 }
283 
284 /// Get button text
285 pub fn text(&self) -> &str {
286 &self.text
287 }
288 
289 /// Set button text
290 pub fn set_text(&mut self, text: impl Into<String>) {
291 let text = text.into();
292 self.text = text.clone();
293 self.control.set_label(text);
294 }
295 
296 /// Override the accessibility label without changing the visible text.
297 pub fn accessibility_label(mut self, label: impl Into<String>) -> Self {
298 self.control.set_label(label);
299 self
300 }
301 
302 /// Provide an accessibility hint/description for assistive technologies.
303 pub fn accessibility_hint(mut self, hint: impl Into<String>) -> Self {
304 self.control.set_hint(hint);
305 self
306 }
307 
308 /// Get current state
309 pub fn get_state(&self) -> ButtonState {
310 self.control.state()
311 }
312 
313 /// Set button state
314 pub fn set_state(&self, state: ButtonState) {
315 self.control.set_state(state);
316 }
317 
318 /// Check if button is enabled
319 pub fn is_enabled(&self) -> bool {
320 self.enabled.get() && self.control.state() != ButtonState::Disabled
321 }
322 
323 /// Check if button is visible
324 pub fn is_visible(&self) -> bool {
325 self.visible.get()
326 }
327 
328 /// Handle mouse enter event
329 pub fn on_mouse_enter(&self) {
330 if self.is_enabled() && self.get_state() != ButtonState::Pressed {
331 self.control.hover(true);
332 if let Some(ref handler) = self.on_hover {
333 handler(true);
334 }
335 }
336 }
337 
338 /// Handle mouse leave event
339 pub fn on_mouse_leave(&self) {
340 if self.is_enabled() {
341 self.control.hover(false);
342 if let Some(ref handler) = self.on_hover {
343 handler(false);
344 }
345 }
346 }
347 
348 /// Handle mouse press event
349 pub fn on_mouse_press(&self, point: Point) -> bool {
350 if !self.is_enabled() || !self.is_visible() {
351 return false;
352 }
353 
354 let bounds = self.bounds.get();
355 self.control.press(point, bounds)
356 }
357 
358 /// Handle mouse release event
359 pub fn on_mouse_release(&self, point: Point) -> bool {
360 if !self.is_enabled() || !self.is_visible() {
361 return false;
362 }
363 
364 let bounds = self.bounds.get();
365 if self.control.release(point, bounds) {
366 if let Some(ref handler) = self.on_click {
367 handler();
368 }
369 return true;
370 }
371 false
372 }
373 
374 /// Calculate button size
375 pub fn calculate_size(&self, available_size: Size) -> Size {
376 // Use accurate text measurement
377 let text_width = crate::text::measure_text_width(&self.text, self.style.font_size, 0.0);
378 let text_height = self.style.font_size;
379 
380 let width = (text_width + self.style.padding * 2.0).max(self.style.min_width);
381 let height = (text_height + self.style.padding * 2.0).max(self.style.min_height);
382 
383 Size::new(
384 width.min(available_size.width),
385 height.min(available_size.height),
386 )
387 }
388 
389 /// Layout the button
390 pub fn layout(&self, bounds: Rect) {
391 self.bounds.set(bounds);
392 }
393 
394 /// Apply theme to button
395 pub fn apply_theme(&mut self, theme: &Theme) {
396 // Update style based on theme
397 self.style.background_color = theme.colors.primary;
398 self.style.text_color = theme.colors.on_primary;
399 self.style.border_radius = theme.spacing.md;
400 self.style.font_size = theme.typography.base_size;
401 }
402}
403 
404/// Button builder for fluent API
405pub struct ButtonBuilder {
406 button: Button,
407}
408 
409impl ButtonBuilder {
410 /// Create a new button builder
411 pub fn new(text: impl Into<String>) -> Self {
412 Self {
413 button: Button::new(text),
414 }
415 }
416 
417 /// Set style
418 pub fn style(mut self, style: ButtonStyle) -> Self {
419 self.button = self.button.style(style);
420 self
421 }
422 
423 /// Set as primary button
424 pub fn primary(mut self) -> Self {
425 self.button = self.button.primary();
426 self
427 }
428 
429 /// Set as secondary button
430 pub fn secondary(mut self) -> Self {
431 self.button = self.button.secondary();
432 self
433 }
434 
435 /// Set as danger button
436 pub fn danger(mut self) -> Self {
437 self.button = self.button.danger();
438 self
439 }
440 
441 /// Set as outline button
442 pub fn outline(mut self) -> Self {
443 self.button = self.button.outline();
444 self
445 }
446 
447 /// Set as ghost button
448 pub fn ghost(mut self) -> Self {
449 self.button = self.button.ghost();
450 self
451 }
452 
453 /// Set click handler
454 pub fn on_click<F>(mut self, handler: F) -> Self
455 where
456 F: Fn() + Send + Sync + 'static,
457 {
458 self.button = self.button.on_click(handler);
459 self
460 }
461 
462 /// Set hover handler
463 pub fn on_hover<F>(mut self, handler: F) -> Self
464 where
465 F: Fn(bool) + Send + Sync + 'static,
466 {
467 self.button = self.button.on_hover(handler);
468 self
469 }
470 
471 /// Set enabled state
472 pub fn enabled(mut self, enabled: bool) -> Self {
473 self.button = self.button.enabled(enabled);
474 self
475 }
476 
477 /// Set visible state
478 pub fn visible(mut self, visible: bool) -> Self {
479 self.button = self.button.visible(visible);
480 self
481 }
482 
483 /// Build the button
484 pub fn build(self) -> Button {
485 self.button
486 }
487}
488 
489#[cfg(test)]
490mod tests {
491 use super::*;
492 
493 #[test]
494 fn test_button_creation() {
495 let button = Button::new("Test Button");
496 assert_eq!(button.text(), "Test Button");
497 assert_eq!(button.get_state(), ButtonState::Normal);
498 assert!(button.is_enabled());
499 assert!(button.is_visible());
500 }
501 
502 #[test]
503 fn test_button_styles() {
504 let primary = Button::new("Primary").primary();
505 let secondary = Button::new("Secondary").secondary();
506 let danger = Button::new("Danger").danger();
507 
508 // Styles should be different
509 assert_ne!(
510 primary.style.background_color,
511 secondary.style.background_color
512 );
513 assert_ne!(
514 secondary.style.background_color,
515 danger.style.background_color
516 );
517 }
518 
519 #[test]
520 fn test_button_state_changes() {
521 let button = Button::new("Test");
522 
523 assert_eq!(button.get_state(), ButtonState::Normal);
524 
525 button.on_mouse_enter();
526 assert_eq!(button.get_state(), ButtonState::Hovered);
527 
528 button.on_mouse_leave();
529 assert_eq!(button.get_state(), ButtonState::Normal);
530 }
531 
532 #[test]
533 fn test_button_builder() {
534 let button = ButtonBuilder::new("Builder Test")
535 .primary()
536 .enabled(true)
537 .build();
538 
539 assert_eq!(button.text(), "Builder Test");
540 assert!(button.is_enabled());
541 }
542 
543 #[test]
544 fn test_button_size_calculation() {
545 let button = Button::new("Test");
546 let available = Size::new(200.0, 100.0);
547 let size = button.calculate_size(available);
548 
549 assert!(size.width >= button.style.min_width);
550 assert!(size.height >= button.style.min_height);
551 assert!(size.width <= available.width);
552 assert!(size.height <= available.height);
553 }
554}
555 
556// Implement Widget trait for Button
557impl Widget for Button {
558 fn id(&self) -> WidgetId {
559 self.id
560 }
561 
562 fn layout(&mut self, constraints: Constraints) -> Size {
563 let text_width = crate::text::measure_text_width(&self.text, self.style.font_size, 0.0);
564 let text_height = self.style.font_size;
565 
566 let content_width = text_width + self.style.padding * 2.0;
567 let content_height = text_height + self.style.padding * 2.0;
568 
569 let width = content_width.max(self.style.min_width);
570 let height = content_height.max(self.style.min_height);
571 
572 // Respect constraints
573 let width = width.min(constraints.max_width).max(constraints.min_width);
574 let height = height
575 .min(constraints.max_height)
576 .max(constraints.min_height);
577 
578 Size::new(width, height)
579 }
580 
581 fn render(&self, batch: &mut RenderBatch, layout: Layout) {
582 let bounds = Rect::new(
583 layout.position.x,
584 layout.position.y,
585 layout.size.width,
586 layout.size.height,
587 );
588 self.bounds.set(bounds);
589 
590 if !self.is_visible() {
591 return;
592 }
593 
594 let state = self.get_state();
595 let target_color = match state {
596 ButtonState::Normal => self.style.background_color,
597 ButtonState::Hovered => self.style.hover_color,
598 ButtonState::Pressed => self.style.pressed_color,
599 ButtonState::Disabled => self.style.disabled_color,
600 ButtonState::Focused => {
601 blend_colors(self.style.background_color, self.style.hover_color, 0.35)
602 }
603 };
604 let background_color = if matches!(state, ButtonState::Disabled) {
605 self.style.disabled_color
606 } else {
607 blend_colors(
608 self.style.background_color,
609 target_color,
610 self.control.interaction_factor(),
611 )
612 };
613 
614 // Apply a subtle offset when pressed to give physical feedback
615 let mut draw_bounds = bounds;
616 if state == ButtonState::Pressed {
617 draw_bounds.x += 1.0;
618 draw_bounds.y += 1.0;
619 }
620 
621 // Draw background
622 batch.add_rect(
623 draw_bounds,
624 background_color.to_types_color(),
625 Transform::identity(),
626 );
627 
628 // Render border if needed
629 if self.style.border_width > 0.0 {
630 let border_bounds = Rect::new(
631 draw_bounds.x - self.style.border_width / 2.0,
632 draw_bounds.y - self.style.border_width / 2.0,
633 draw_bounds.width + self.style.border_width,
634 draw_bounds.height + self.style.border_width,
635 );
636 
637 if self.style.border_radius > 0.0 {
638 // Render rounded border (simplified - would need proper border rendering)
639 let (vertices, indices) = VertexBuilder::rounded_rectangle(
640 border_bounds.x,
641 border_bounds.y,
642 border_bounds.width,
643 border_bounds.height,
644 self.style.border_radius + self.style.border_width / 2.0,
645 self.style.border_color.to_array(),
646 8, // corner segments
647 );
648 batch.add_vertices(&vertices, &indices);
649 }
650 }
651 
652 // Render text
653 let text_x = draw_bounds.x + draw_bounds.width / 2.0;
654 let text_y = draw_bounds.y + draw_bounds.height / 2.0 - self.style.font_size / 2.0;
655 let mut text_color = self.style.text_color;
656 if matches!(state, ButtonState::Disabled) {
657 text_color.a *= 0.35;
658 }
659 
660 batch.add_text_aligned(
661 self.text.clone(),
662 (text_x, text_y),
663 text_color.to_types_color(),
664 self.style.font_size,
665 0.0, // Default letter spacing
666 strato_core::text::TextAlign::Center,
667 );
668 }
669 
670 fn update(&mut self, ctx: &WidgetContext) {
671 self.control.update(ctx.delta_time);
672 }
673 
674 fn handle_event(&mut self, event: &Event) -> EventResult {
675 let previous_state = self.get_state();
676 let bounds = self.bounds.get();
677 
678 // Pointer interactions and hover callbacks
679 if let EventResult::Handled = self.control.handle_pointer_event(event, bounds) {
680 if matches!(event, Event::MouseUp(_)) && matches!(previous_state, ButtonState::Pressed)
681 {
682 if let Some(handler) = &self.on_click {
683 handler();
684 }
685 }
686 if let Event::MouseMove(mouse_event) = event {
687 let is_hovered =
688 bounds.contains(Point::new(mouse_event.position.x, mouse_event.position.y));
689 if let Some(handler) = &self.on_hover {
690 handler(is_hovered);
691 }
692 }
693 return EventResult::Handled;
694 }
695 
696 // Keyboard accessibility
697 if let EventResult::Handled = self.control.handle_keyboard_activation(event) {
698 if matches!(event, Event::KeyUp(_)) {
699 if let Some(handler) = &self.on_click {
700 handler();
701 }
702 }
703 return EventResult::Handled;
704 }
705 
706 EventResult::Ignored
707 }
708 
709 fn as_any(&self) -> &dyn Any {
710 self
711 }
712 
713 fn as_any_mut(&mut self) -> &mut dyn Any {
714 self
715 }
716 
717 fn clone_widget(&self) -> Box<dyn Widget> {
718 Box::new(Button {
719 id: generate_id(),
720 text: self.text.clone(),
721 style: self.style.clone(),
722 control: self.control.clone(),
723 bounds: Signal::new(self.bounds.get()),
724 enabled: Signal::new(self.enabled.get()),
725 visible: Signal::new(self.visible.get()),
726 on_click: None,
727 on_hover: None,
728 theme: self.theme.clone(),
729 })
730 }
731 
732 fn as_taffy(&self) -> Option<&dyn TaffyWidget> {
733 Some(self)
734 }
735}
736 
737impl TaffyWidget for Button {
738 fn build_layout(&self, tree: &mut TaffyTree<()>) -> TaffyLayoutResult<NodeId> {
739 let text_width = crate::text::measure_text_width(&self.text, self.style.font_size, 0.0);
740 let text_height = self.style.font_size;
741 
742 let width = (text_width + self.style.padding * 2.0).max(self.style.min_width);
743 let height = (text_height + self.style.padding * 2.0).max(self.style.min_height);
744 
745 let style = Style {
746 size: strato_core::taffy::geometry::Size {
747 width: length(width),
748 height: length(height),
749 },
750 min_size: strato_core::taffy::geometry::Size {
751 width: length(self.style.min_width),
752 height: length(self.style.min_height),
753 },
754 padding: strato_core::taffy::geometry::Rect {
755 left: length(self.style.padding),
756 right: length(self.style.padding),
757 top: length(self.style.padding),
758 bottom: length(self.style.padding),
759 },
760 ..Default::default()
761 };
762 
763 tree.new_leaf(style).map_err(|e| TaffyLayoutError::from(e))
764 }
765}
766