Skip to main content

photon_ui/components/
button.rs

1//! Beam Design Language button component.
2//!
3//! Supports five visual variants: Primary, Dark, Cream, Ghost, and Text.
4//! Each variant maps to semantic palette colors and renders as a single
5//! line of styled text with appropriate foreground/background colors.
6
7use crate::{
8    Component,
9    InputResult,
10    RenderError,
11    Rendered,
12    events::Event,
13    layout::Rect,
14    theme::{
15        Color,
16        Style,
17        Theme,
18        stylize_padded,
19    },
20};
21
22/// Visual variant of a button.
23#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
24pub enum ButtonVariant {
25    /// Dark background, white text. Default.
26    #[default]
27    Dark,
28    /// Cream background, dark text.
29    Cream,
30    /// Transparent with a border.
31    Ghost,
32    /// Plain text, accent color, underlined.
33    Text,
34    /// Accent (orange) background, white text.
35    Primary,
36}
37
38/// A styled button component.
39///
40/// Renders as a single line of text with padding and ANSI colors.
41/// The width is determined by the label length plus padding.
42pub struct Button {
43    label: String,
44    variant: ButtonVariant,
45    pad: usize,
46}
47
48impl Button {
49    /// Create a new button with the given label and variant.
50    pub fn new(label: impl Into<String>, variant: ButtonVariant) -> Self {
51        Self {
52            label: label.into(),
53            variant,
54            pad: 1,
55        }
56    }
57
58    /// Create a primary button.
59    pub fn primary(label: impl Into<String>) -> Self {
60        Self::new(label, ButtonVariant::Primary)
61    }
62
63    /// Create a dark button.
64    pub fn dark(label: impl Into<String>) -> Self {
65        Self::new(label, ButtonVariant::Dark)
66    }
67
68    /// Create a cream button.
69    pub fn cream(label: impl Into<String>) -> Self {
70        Self::new(label, ButtonVariant::Cream)
71    }
72
73    /// Create a ghost button.
74    pub fn ghost(label: impl Into<String>) -> Self {
75        Self::new(label, ButtonVariant::Ghost)
76    }
77
78    /// Create a text button.
79    pub fn text(label: impl Into<String>) -> Self {
80        Self::new(label, ButtonVariant::Text)
81    }
82
83    /// Set horizontal padding (spaces on each side).
84    pub fn pad(mut self, pad: usize) -> Self {
85        self.pad = pad;
86        self
87    }
88
89    /// Build the ANSI style for this button given the active theme.
90    fn build_style(&self) -> Style {
91        let theme = Theme::palette();
92        match self.variant {
93            | ButtonVariant::Primary => Style::new().fg(Color::WHITE).bg(theme.accent()).bold(),
94            // Dark is a fixed visual style: near-black bg, white text.
95            // In dark mode use CARD_DARK so it's visible against the black page.
96            // Under a custom palette, use the elevated surface color.
97            | ButtonVariant::Dark => {
98                if Theme::has_palette() {
99                    Style::new().fg(Color::WHITE).bg(theme.surface()).bold()
100                } else {
101                    match Theme::current() {
102                        | Theme::Light => Style::new()
103                            .fg(Color::WHITE)
104                            .bg(Color::SUNBEAM_BLACK)
105                            .bold(),
106                        | Theme::Dark => Style::new().fg(Color::WHITE).bg(Color::CARD_DARK).bold(),
107                    }
108                }
109            },
110            // Cream is always cream bg + dark text, regardless of theme.
111            | ButtonVariant::Cream => Style::new()
112                .fg(Color::SUNBEAM_BLACK)
113                .bg(Color::CREAM)
114                .bold(),
115            | ButtonVariant::Ghost => Style::new().fg(theme.accent()).bold(),
116            | ButtonVariant::Text => Style::new().fg(theme.accent()).underline(),
117        }
118    }
119}
120
121impl Component for Button {
122    fn render(&self, _width: u16) -> Result<Rendered, RenderError> {
123        let style = self.build_style();
124
125        let line = match self.variant {
126            | ButtonVariant::Ghost => {
127                // Ghost: [ label ] with brackets in muted color
128                let theme = Theme::palette();
129                let bracket_style = Style::new().fg(theme.border());
130                let bracket_open = crate::theme::stylize("[", &bracket_style);
131                let bracket_close = crate::theme::stylize("]", &bracket_style);
132                let inner = stylize_padded(&self.label, &style, self.pad);
133                format!("{}{}{}", bracket_open, inner, bracket_close)
134            },
135            | _ => stylize_padded(&self.label, &style, self.pad),
136        };
137
138        Ok(Rendered {
139            lines: vec![line],
140            cursor: None,
141            images: Vec::new(),
142        })
143    }
144
145    fn render_rect(&self, rect: Rect) -> Result<Rendered, RenderError> {
146        // Center the button vertically within the rect
147        let mut rendered = match self.render(rect.width) {
148            | Ok(r) => r,
149            | Err(e) => return Err(e),
150        };
151        let height = rendered.lines.len();
152        let pad_top = (rect.height as usize).saturating_sub(height) / 2;
153
154        let mut lines = Vec::new();
155        for _ in 0..pad_top {
156            lines.push(String::new());
157        }
158        lines.extend(rendered.lines);
159        while lines.len() < rect.height as usize {
160            lines.push(String::new());
161        }
162        rendered.lines = lines;
163        Ok(rendered)
164    }
165
166    fn handle_input(&mut self, _event: &Event) -> InputResult {
167        InputResult::Ignored
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use crate::theme::Theme;
175
176    #[test]
177    fn primary_button_renders() {
178        Theme::with(Theme::Light, || {
179            let btn = Button::primary("Click me");
180            let rendered = btn.render(80).unwrap();
181            assert_eq!(rendered.lines.len(), 1);
182            assert!(rendered.lines[0].contains("Click me"));
183            // Should have ANSI codes
184            assert!(rendered.lines[0].starts_with('\x1b'));
185        });
186    }
187
188    #[test]
189    fn dark_button_renders() {
190        Theme::with(Theme::Light, || {
191            let btn = Button::dark("Submit");
192            let rendered = btn.render(80).unwrap();
193            assert!(rendered.lines[0].contains("Submit"));
194        });
195    }
196
197    #[test]
198    fn ghost_button_has_brackets() {
199        Theme::with(Theme::Light, || {
200            let btn = Button::ghost("Cancel");
201            let rendered = btn.render(80).unwrap();
202            let line = &rendered.lines[0];
203            assert!(line.contains('['));
204            assert!(line.contains(']'));
205            assert!(line.contains("Cancel"));
206        });
207    }
208
209    #[test]
210    fn text_button_is_underlined() {
211        Theme::with(Theme::Light, || {
212            let btn = Button::text("Link");
213            let rendered = btn.render(80).unwrap();
214            // Underline ANSI code is \x1b[4m
215            assert!(rendered.lines[0].contains("\x1b[4m"));
216        });
217    }
218
219    #[test]
220    fn button_padding() {
221        Theme::with(Theme::Light, || {
222            let btn = Button::primary("OK").pad(2);
223            let rendered = btn.render(80).unwrap();
224            // Should have 2 spaces on each side
225            assert!(rendered.lines[0].contains("  OK  "));
226        });
227    }
228
229    #[test]
230    fn button_respects_theme() {
231        // Light theme: primary bg is orange
232        let light_line = Theme::with(Theme::Light, || {
233            Button::primary("Test").render(80).unwrap().lines[0].clone()
234        });
235
236        // Dark theme: primary bg is still orange, but text colors differ
237        let dark_line = Theme::with(Theme::Dark, || {
238            Button::primary("Test").render(80).unwrap().lines[0].clone()
239        });
240
241        // Both should contain the label
242        assert!(light_line.contains("Test"));
243        assert!(dark_line.contains("Test"));
244    }
245
246    // ── Regression: dark-mode visibility ─────────────────────────────
247
248    /// Regression: Dark button must not use white background in dark mode.
249    /// Previously `bg(text())` produced white-on-white.
250    #[test]
251    fn dark_button_not_white_on_white_in_dark_mode() {
252        let line = Theme::with(Theme::Dark, || {
253            Button::dark("Dark").render(80).unwrap().lines[0].clone()
254        });
255        // White bg ANSI: \x1b[48;2;255;255;255m
256        assert!(
257            !line.contains("\x1b[48;2;255;255;255m"),
258            "Dark button must not have white bg in dark mode"
259        );
260        // Should have CARD_DARK bg (#2a2a2a)
261        assert!(
262            line.contains("\x1b[48;2;42;42;42m"),
263            "Dark button should use CARD_DARK (#2a2a2a) bg in dark mode"
264        );
265        // Text should be visible (white fg)
266        assert!(
267            line.contains("\x1b[38;2;255;255;255m"),
268            "Dark button should have white text"
269        );
270    }
271
272    /// Regression: Dark button must use black background in light mode.
273    #[test]
274    fn dark_button_uses_black_bg_in_light_mode() {
275        let line = Theme::with(Theme::Light, || {
276            Button::dark("Dark").render(80).unwrap().lines[0].clone()
277        });
278        assert!(
279            line.contains("\x1b[48;2;31;31;31m"),
280            "Dark button should use SUNBEAM_BLACK (#1f1f1f) bg in light mode"
281        );
282    }
283
284    /// Regression: Cream button must always use cream bg + dark text.
285    /// Previously in dark mode it used surface() which was nearly invisible.
286    #[test]
287    fn cream_button_always_cream_colored() {
288        let light_line = Theme::with(Theme::Light, || {
289            Button::cream("Cream").render(80).unwrap().lines[0].clone()
290        });
291        let dark_line = Theme::with(Theme::Dark, || {
292            Button::cream("Cream").render(80).unwrap().lines[0].clone()
293        });
294
295        // Cream bg: #fff0c2 = (255, 240, 194)
296        let cream_bg = "\x1b[48;2;255;240;194m";
297        assert!(
298            light_line.contains(cream_bg),
299            "Cream button bg in light mode"
300        );
301        assert!(dark_line.contains(cream_bg), "Cream button bg in dark mode");
302
303        // Dark text: SUNBEAM_BLACK #1f1f1f = (31, 31, 31)
304        let dark_fg = "\x1b[38;2;31;31;31m";
305        assert!(
306            light_line.contains(dark_fg),
307            "Cream button fg in light mode"
308        );
309        assert!(dark_line.contains(dark_fg), "Cream button fg in dark mode");
310    }
311}