bevy_ui_builders/button/
builder.rs

1//! ButtonBuilder implementation
2
3use bevy::prelude::*;
4use bevy::picking::Pickable;
5use crate::styles::{colors, dimensions, ButtonStyle, ButtonSize};
6use super::types::{StyledButton, ButtonStateColors, SelectableButton, Selected, Active, ButtonSelectionColors, StateColorSet};
7use crate::systems::hover::{HoverScale, HoverBrightness, OriginalColors};
8use crate::relationships::InButtonGroup;
9
10/// Builder for creating buttons with consistent styling
11pub struct ButtonBuilder {
12    text: String,
13    style: ButtonStyle,
14    size: ButtonSize,
15    width: Option<Val>,
16    custom_height: Option<Val>,
17    margin: Option<UiRect>,
18    hover_scale: Option<f32>,
19    hover_brightness: Option<f32>,
20    disabled: bool,
21    icon: Option<String>,
22    // Selection state fields
23    selectable: bool,
24    auto_toggle: bool,
25    is_selected: bool,
26    is_active: bool,
27    button_group: Option<Entity>,
28    custom_selection_colors: Option<(StateColorSet, StateColorSet)>, // (selected, active)
29}
30
31impl ButtonBuilder {
32    /// Create a new button builder with text
33    pub fn new(text: impl Into<String>) -> Self {
34        Self {
35            text: text.into(),
36            style: ButtonStyle::Primary,
37            size: ButtonSize::Medium,
38            width: None,
39            custom_height: None,
40            margin: None,
41            hover_scale: None,
42            hover_brightness: None,
43            disabled: false,
44            icon: None,
45            selectable: false,
46            auto_toggle: true,
47            is_selected: false,
48            is_active: false,
49            button_group: None,
50            custom_selection_colors: None,
51        }
52    }
53
54    /// Set the button style
55    pub fn style(mut self, style: ButtonStyle) -> Self {
56        self.style = style;
57        self
58    }
59
60    /// Set the button size
61    pub fn size(mut self, size: ButtonSize) -> Self {
62        self.size = size;
63        self
64    }
65
66    /// Set a custom width
67    pub fn width(mut self, width: Val) -> Self {
68        self.width = Some(width);
69        self
70    }
71
72    /// Set a custom height
73    pub fn height(mut self, height: Val) -> Self {
74        self.custom_height = Some(height);
75        self
76    }
77
78    /// Set the margin
79    pub fn margin(mut self, margin: UiRect) -> Self {
80        self.margin = Some(margin);
81        self
82    }
83
84    /// Enable hover scale effect
85    pub fn hover_scale(mut self, scale: f32) -> Self {
86        self.hover_scale = Some(scale);
87        self
88    }
89
90    /// Enable hover brightness effect
91    pub fn hover_brightness(mut self, brightness: f32) -> Self {
92        self.hover_brightness = Some(brightness);
93        self
94    }
95
96    /// Set the button as disabled
97    pub fn disabled(mut self) -> Self {
98        self.disabled = true;
99        self
100    }
101
102    /// Set whether the button is enabled
103    pub fn enabled(mut self, enabled: bool) -> Self {
104        self.disabled = !enabled;
105        self
106    }
107
108    /// Add an icon (emoji or symbol)
109    pub fn icon(mut self, icon: impl Into<String>) -> Self {
110        self.icon = Some(icon.into());
111        self
112    }
113
114    /// Make the button selectable (supports toggle/selection behavior)
115    pub fn selectable(mut self) -> Self {
116        self.selectable = true;
117        self
118    }
119
120    /// Set the initial selected state (implies selectable)
121    pub fn selected(mut self, selected: bool) -> Self {
122        self.selectable = true;
123        self.is_selected = selected;
124        self
125    }
126
127    /// Set the button as active (implies selectable)
128    /// Active state is used for current tab/page indicators
129    pub fn active(mut self, active: bool) -> Self {
130        self.selectable = true;
131        self.is_active = active;
132        self
133    }
134
135    /// Disable auto-toggle behavior (selection must be managed manually)
136    pub fn manual_toggle(mut self) -> Self {
137        self.auto_toggle = false;
138        self
139    }
140
141    /// Add this button to a button group for exclusive selection (radio button behavior)
142    /// Automatically makes the button selectable
143    pub fn in_group(mut self, group_entity: Entity) -> Self {
144        self.selectable = true;
145        self.button_group = Some(group_entity);
146        self
147    }
148
149    /// Set custom colors for selected and active states
150    /// If not set, colors are auto-generated from the button style
151    pub fn selection_colors(mut self, selected: StateColorSet, active: StateColorSet) -> Self {
152        self.custom_selection_colors = Some((selected, active));
153        self
154    }
155
156    /// Attach a marker component to the button
157    pub fn with_marker<M: Component>(self, marker: M) -> ButtonBuilderWithMarker<M> {
158        ButtonBuilderWithMarker {
159            builder: self,
160            marker,
161        }
162    }
163
164    /// Build the button entity (alias for build)
165    pub fn build_in(self, parent: &mut ChildSpawnerCommands) -> Entity {
166        self.build(parent)
167    }
168
169    /// Build the button entity
170    pub fn build(self, parent: &mut ChildSpawnerCommands) -> Entity {
171        let (bg_color, border_color, text_color) = get_style_colors(&self.style, self.disabled);
172        let (width, height) = get_size_dimensions(&self.size);
173        let font_size = get_font_size(&self.size);
174
175        let button_width = self.width.unwrap_or(Val::Px(width));
176        let button_height = self.custom_height.unwrap_or(Val::Px(height));
177        let button_margin = self.margin.unwrap_or_default();
178
179        let mut button = parent.spawn((
180            Button,
181            Node {
182                width: button_width,
183                height: button_height,
184                margin: button_margin,
185                justify_content: JustifyContent::Center,
186                align_items: AlignItems::Center,
187                border: UiRect::all(Val::Px(dimensions::BORDER_WIDTH_MEDIUM)),
188                padding: UiRect::horizontal(Val::Px(dimensions::PADDING_MEDIUM)),
189                ..default()
190            },
191            BackgroundColor(bg_color),
192            BorderColor::all(border_color),
193            BorderRadius::all(Val::Px(dimensions::BORDER_RADIUS_MEDIUM)),
194            StyledButton,
195            Transform::default(), // Required for scale animations
196        ));
197
198        // Store state colors for automatic hover effects
199        button.insert(ButtonStateColors {
200            normal_bg: bg_color,
201            hover_bg: self.style.hover_color(),
202            pressed_bg: self.style.pressed_color(),
203            normal_border: border_color,
204            hover_border: self.style.border_color(),
205            pressed_border: self.style.border_color(),
206        });
207
208        // Store original colors for custom hover effects
209        button.insert(OriginalColors {
210            background: bg_color,
211            border: border_color,
212        });
213
214        // Add hover scale - use default if not specified
215        let scale = self.hover_scale.unwrap_or(1.015); // Even more subtle default scale
216        button.insert(HoverScale(scale));
217
218        // Add animation state for smooth transitions
219        button.insert(super::types::ButtonAnimationState {
220            current_scale: 1.0,
221            target_scale: 1.0,
222            current_color_blend: 0.0,
223            target_color_blend: 0.0,
224            animation_speed: 12.0, // Smooth but responsive
225        });
226
227        // Add hover brightness if specified (optional)
228        if let Some(brightness) = self.hover_brightness {
229            button.insert(HoverBrightness(brightness));
230        }
231
232        if self.disabled {
233            button.insert(Interaction::None);
234        }
235
236        // Add selection components if selectable
237        if self.selectable {
238            button.insert(SelectableButton {
239                auto_toggle: self.auto_toggle,
240            });
241
242            // Generate or use custom selection colors
243            let selection_colors = if let Some((selected, active)) = self.custom_selection_colors {
244                let mut base_colors = generate_selection_colors(&self.style);
245                base_colors.selected = selected;
246                base_colors.active = active;
247                base_colors
248            } else {
249                generate_selection_colors(&self.style)
250            };
251
252            button.insert(selection_colors);
253
254            // Add initial state markers
255            if self.is_selected {
256                button.insert(Selected);
257            }
258            if self.is_active {
259                button.insert(Active);
260            }
261
262            // Add to button group if specified
263            if let Some(group_entity) = self.button_group {
264                button.insert(InButtonGroup(group_entity));
265            }
266        }
267
268        let button_entity = button.id();
269
270        // Add text content
271        button.with_children(|button| {
272            if let Some(icon) = self.icon {
273                // Icon + Text layout
274                button.spawn((
275                    Node {
276                        flex_direction: FlexDirection::Row,
277                        align_items: AlignItems::Center,
278                        column_gap: Val::Px(dimensions::SPACING_SMALL),
279                        ..default()
280                    },
281                    BackgroundColor(Color::NONE),
282                    Pickable::IGNORE, // Don't block button interaction
283                )).with_children(|container| {
284                    // Icon
285                    container.spawn((
286                        Text::new(icon),
287                        TextFont {
288                            font_size,
289                            ..default()
290                        },
291                        TextColor(text_color),
292                        Pickable::IGNORE, // Don't block button interaction
293                    ));
294
295                    // Text
296                    container.spawn((
297                        Text::new(&self.text),
298                        TextFont {
299                            font_size,
300                            ..default()
301                        },
302                        TextColor(text_color),
303                        Pickable::IGNORE, // Don't block button interaction
304                    ));
305                });
306            } else {
307                // Just text
308                button.spawn((
309                    Text::new(&self.text),
310                    TextFont {
311                        font_size,
312                        ..default()
313                    },
314                    TextColor(text_color),
315                    Pickable::IGNORE, // Don't block button interaction
316                ));
317            }
318        });
319
320        button_entity
321    }
322}
323
324/// Get colors for a button style
325fn get_style_colors(style: &ButtonStyle, disabled: bool) -> (Color, Color, Color) {
326    if disabled {
327        return (
328            colors::BACKGROUND_TERTIARY,
329            colors::BORDER_DEFAULT,
330            colors::TEXT_DISABLED,
331        );
332    }
333
334    match style {
335        ButtonStyle::Primary => (colors::PRIMARY, colors::PRIMARY_DARK, colors::TEXT_ON_PRIMARY),
336        ButtonStyle::Secondary => (colors::SECONDARY, colors::SECONDARY_DARK, colors::TEXT_ON_SECONDARY),
337        ButtonStyle::Success => (colors::SUCCESS, colors::SUCCESS_DARK, colors::TEXT_ON_SUCCESS),
338        ButtonStyle::Danger => (colors::DANGER, colors::DANGER_DARK, colors::TEXT_ON_DANGER),
339        ButtonStyle::Warning => (colors::WARNING, colors::WARNING_PRESSED, Color::BLACK),
340        ButtonStyle::Ghost => (Color::NONE, colors::BORDER_DEFAULT, colors::TEXT_PRIMARY),
341    }
342}
343
344/// Get dimensions for a button size
345fn get_size_dimensions(size: &ButtonSize) -> (f32, f32) {
346    match size {
347        ButtonSize::Small => (dimensions::BUTTON_WIDTH_SMALL, dimensions::BUTTON_HEIGHT_SMALL),
348        ButtonSize::Medium => (dimensions::BUTTON_WIDTH_MEDIUM, dimensions::BUTTON_HEIGHT_MEDIUM),
349        ButtonSize::Large => (dimensions::BUTTON_WIDTH_LARGE, dimensions::BUTTON_HEIGHT_LARGE),
350        ButtonSize::XLarge => (dimensions::BUTTON_WIDTH_XLARGE, dimensions::BUTTON_HEIGHT_XLARGE),
351    }
352}
353
354/// Get font size for a button size
355fn get_font_size(size: &ButtonSize) -> f32 {
356    match size {
357        ButtonSize::Small => dimensions::FONT_SIZE_SMALL,
358        ButtonSize::Medium => dimensions::FONT_SIZE_MEDIUM,
359        ButtonSize::Large => dimensions::FONT_SIZE_LARGE,
360        ButtonSize::XLarge => dimensions::FONT_SIZE_XLARGE,
361    }
362}
363
364/// Generate selection colors for a button style
365/// Returns (normal, selected, active) StateColorSets
366/// For selectable buttons: normal=gray, selected=style color, active=emphasized
367fn generate_selection_colors(style: &ButtonStyle) -> ButtonSelectionColors {
368    let (style_bg, style_border, _text_color) = get_style_colors(style, false);
369
370    // Normal state (unselected) - use neutral gray like Secondary style
371    // This makes unselected radio buttons/toggles clearly distinct
372    let normal = StateColorSet::new(
373        colors::SECONDARY,           // Gray background when not selected
374        colors::SECONDARY_HOVER,     // Slightly lighter on hover
375        colors::SECONDARY_PRESSED,   // Slightly darker when pressed
376        colors::BORDER_DEFAULT,
377        colors::BORDER_DEFAULT,
378        colors::BORDER_DEFAULT,
379    );
380
381    // Selected state - use the button's actual style color (Primary blue, Success green, etc.)
382    let selected = StateColorSet::new(
383        style_bg,                    // Use the style's color (e.g., PRIMARY blue)
384        style.hover_color(),         // Hover variant
385        style.pressed_color(),       // Pressed variant
386        style_border,
387        style_border,
388        style_border,
389    );
390
391    // Active state - darker/more saturated version of selected for emphasis
392    let active_bg = adjust_color_for_selection(style_bg, 0.85, 1.15);
393    let active_border = adjust_color_for_selection(style_border, 0.85, 1.1);
394    let active = StateColorSet::new(
395        active_bg,
396        adjust_color_for_selection(active_bg, 1.1, 1.0), // Hover is lighter
397        adjust_color_for_selection(active_bg, 0.9, 1.0), // Pressed is darker
398        active_border,
399        active_border,
400        active_border,
401    );
402
403    ButtonSelectionColors {
404        normal,
405        selected,
406        active,
407    }
408}
409
410/// Adjust a color's brightness and saturation for selection states
411fn adjust_color_for_selection(color: Color, brightness: f32, saturation: f32) -> Color {
412    let linear = color.to_linear();
413
414    // Convert to HSV for saturation adjustment
415    let max = linear.red.max(linear.green).max(linear.blue);
416    let min = linear.red.min(linear.green).min(linear.blue);
417    let delta = max - min;
418
419    if delta < 0.00001 {
420        // Grayscale - just adjust brightness
421        return Color::LinearRgba(LinearRgba {
422            red: (linear.red * brightness).min(1.0),
423            green: (linear.green * brightness).min(1.0),
424            blue: (linear.blue * brightness).min(1.0),
425            alpha: linear.alpha,
426        });
427    }
428
429    // Apply brightness and saturation
430    let avg = (linear.red + linear.green + linear.blue) / 3.0;
431
432    Color::LinearRgba(LinearRgba {
433        red: ((linear.red - avg) * saturation + avg) * brightness,
434        green: ((linear.green - avg) * saturation + avg) * brightness,
435        blue: ((linear.blue - avg) * saturation + avg) * brightness,
436        alpha: linear.alpha,
437    })
438}
439
440/// Convenience function for creating a primary button
441pub fn primary_button(text: impl Into<String>) -> ButtonBuilder {
442    ButtonBuilder::new(text).style(ButtonStyle::Primary)
443}
444
445/// Convenience function for creating a secondary button
446pub fn secondary_button(text: impl Into<String>) -> ButtonBuilder {
447    ButtonBuilder::new(text).style(ButtonStyle::Secondary)
448}
449
450/// Convenience function for creating a success button
451pub fn success_button(text: impl Into<String>) -> ButtonBuilder {
452    ButtonBuilder::new(text).style(ButtonStyle::Success)
453}
454
455/// Convenience function for creating a danger button
456pub fn danger_button(text: impl Into<String>) -> ButtonBuilder {
457    ButtonBuilder::new(text).style(ButtonStyle::Danger)
458}
459
460/// Convenience function for creating a ghost button
461pub fn ghost_button(text: impl Into<String>) -> ButtonBuilder {
462    ButtonBuilder::new(text).style(ButtonStyle::Ghost)
463}
464
465/// A ButtonBuilder with an attached marker component
466pub struct ButtonBuilderWithMarker<M: Component> {
467    builder: ButtonBuilder,
468    marker: M,
469}
470
471impl<M: Component> ButtonBuilderWithMarker<M> {
472    /// Build the button with the marker component
473    pub fn build(self, parent: &mut ChildSpawnerCommands) -> Entity {
474        let entity = self.builder.build(parent);
475        parent.commands().entity(entity).insert(self.marker);
476        entity
477    }
478
479    /// Build the button with the marker component (alias for build)
480    pub fn build_in(self, parent: &mut ChildSpawnerCommands) -> Entity {
481        self.build(parent)
482    }
483}