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 = match self.render(rect.width) {
142            | Ok(r) => r,
143            | Err(e) => return Err(e),
144        };
145        let height = rendered.lines.len();
146        let pad_top = (rect.height as usize).saturating_sub(height) / 2;
147
148        let mut lines = Vec::new();
149        for _ in 0..pad_top {
150            lines.push(String::new());
151        }
152        lines.extend(rendered.lines);
153        while lines.len() < rect.height as usize {
154            lines.push(String::new());
155        }
156        rendered.lines = lines;
157        Ok(rendered)
158    }
159
160    fn handle_input(&mut self, _event: &Event) -> InputResult {
161        InputResult::Ignored
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use crate::theme::Theme;
169
170    #[test]
171    fn primary_button_renders() {
172        Theme::with(Theme::Light, || {
173            let btn = Button::primary("Click me");
174            let rendered = btn.render(80).unwrap();
175            assert_eq!(rendered.lines.len(), 1);
176            assert!(rendered.lines[0].contains("Click me"));
177            // Should have ANSI codes
178            assert!(rendered.lines[0].starts_with('\x1b'));
179        });
180    }
181
182    #[test]
183    fn dark_button_renders() {
184        Theme::with(Theme::Light, || {
185            let btn = Button::dark("Submit");
186            let rendered = btn.render(80).unwrap();
187            assert!(rendered.lines[0].contains("Submit"));
188        });
189    }
190
191    #[test]
192    fn ghost_button_has_brackets() {
193        Theme::with(Theme::Light, || {
194            let btn = Button::ghost("Cancel");
195            let rendered = btn.render(80).unwrap();
196            let line = &rendered.lines[0];
197            assert!(line.contains('['));
198            assert!(line.contains(']'));
199            assert!(line.contains("Cancel"));
200        });
201    }
202
203    #[test]
204    fn text_button_is_underlined() {
205        Theme::with(Theme::Light, || {
206            let btn = Button::text("Link");
207            let rendered = btn.render(80).unwrap();
208            // Underline ANSI code is \x1b[4m
209            assert!(rendered.lines[0].contains("\x1b[4m"));
210        });
211    }
212
213    #[test]
214    fn button_padding() {
215        Theme::with(Theme::Light, || {
216            let btn = Button::primary("OK").pad(2);
217            let rendered = btn.render(80).unwrap();
218            // Should have 2 spaces on each side
219            assert!(rendered.lines[0].contains("  OK  "));
220        });
221    }
222
223    #[test]
224    fn button_respects_theme() {
225        // Light theme: primary bg is orange
226        let light_line = Theme::with(Theme::Light, || {
227            Button::primary("Test").render(80).unwrap().lines[0].clone()
228        });
229
230        // Dark theme: primary bg is still orange, but text colors differ
231        let dark_line = Theme::with(Theme::Dark, || {
232            Button::primary("Test").render(80).unwrap().lines[0].clone()
233        });
234
235        // Both should contain the label
236        assert!(light_line.contains("Test"));
237        assert!(dark_line.contains("Test"));
238    }
239
240    // ── Regression: dark-mode visibility ─────────────────────────────
241
242    /// Regression: Dark button must not use white background in dark mode.
243    /// Previously `bg(text_primary())` produced white-on-white.
244    #[test]
245    fn dark_button_not_white_on_white_in_dark_mode() {
246        let line = Theme::with(Theme::Dark, || {
247            Button::dark("Dark").render(80).unwrap().lines[0].clone()
248        });
249        // White bg ANSI: \x1b[48;2;255;255;255m
250        assert!(
251            !line.contains("\x1b[48;2;255;255;255m"),
252            "Dark button must not have white bg in dark mode"
253        );
254        // Should have CARD_DARK bg (#2a2a2a)
255        assert!(
256            line.contains("\x1b[48;2;42;42;42m"),
257            "Dark button should use CARD_DARK (#2a2a2a) bg in dark mode"
258        );
259        // Text should be visible (white fg)
260        assert!(
261            line.contains("\x1b[38;2;255;255;255m"),
262            "Dark button should have white text"
263        );
264    }
265
266    /// Regression: Dark button must use black background in light mode.
267    #[test]
268    fn dark_button_uses_black_bg_in_light_mode() {
269        let line = Theme::with(Theme::Light, || {
270            Button::dark("Dark").render(80).unwrap().lines[0].clone()
271        });
272        assert!(
273            line.contains("\x1b[48;2;31;31;31m"),
274            "Dark button should use SUNBEAM_BLACK (#1f1f1f) bg in light mode"
275        );
276    }
277
278    /// Regression: Cream button must always use cream bg + dark text.
279    /// Previously in dark mode it used bg_card() which was nearly invisible.
280    #[test]
281    fn cream_button_always_cream_colored() {
282        let light_line = Theme::with(Theme::Light, || {
283            Button::cream("Cream").render(80).unwrap().lines[0].clone()
284        });
285        let dark_line = Theme::with(Theme::Dark, || {
286            Button::cream("Cream").render(80).unwrap().lines[0].clone()
287        });
288
289        // Cream bg: #fff0c2 = (255, 240, 194)
290        let cream_bg = "\x1b[48;2;255;240;194m";
291        assert!(
292            light_line.contains(cream_bg),
293            "Cream button bg in light mode"
294        );
295        assert!(dark_line.contains(cream_bg), "Cream button bg in dark mode");
296
297        // Dark text: SUNBEAM_BLACK #1f1f1f = (31, 31, 31)
298        let dark_fg = "\x1b[38;2;31;31;31m";
299        assert!(
300            light_line.contains(dark_fg),
301            "Cream button fg in light mode"
302        );
303        assert!(dark_line.contains(dark_fg), "Cream button fg in dark mode");
304    }
305}