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