photon_ui/components/
button.rs1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
25pub enum ButtonVariant {
26 #[default]
28 Dark,
29 Cream,
31 Ghost,
33 Text,
35 Primary,
37}
38
39pub struct Button {
44 label: String,
45 variant: ButtonVariant,
46 pad: usize,
47}
48
49impl Button {
50 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 pub fn primary(label: impl Into<String>) -> Self {
61 Self::new(label, ButtonVariant::Primary)
62 }
63
64 pub fn dark(label: impl Into<String>) -> Self {
66 Self::new(label, ButtonVariant::Dark)
67 }
68
69 pub fn cream(label: impl Into<String>) -> Self {
71 Self::new(label, ButtonVariant::Cream)
72 }
73
74 pub fn ghost(label: impl Into<String>) -> Self {
76 Self::new(label, ButtonVariant::Ghost)
77 }
78
79 pub fn text(label: impl Into<String>) -> Self {
81 Self::new(label, ButtonVariant::Text)
82 }
83
84 pub fn pad(mut self, pad: usize) -> Self {
86 self.pad = pad;
87 self
88 }
89
90 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 | 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 | 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 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 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 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 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 assert!(rendered.lines[0].contains(" OK "));
217 });
218 }
219
220 #[test]
221 fn button_respects_theme() {
222 let light_line = Theme::with(Theme::Light, || {
224 Button::primary("Test").render(80).unwrap().lines[0].clone()
225 });
226
227 let dark_line = Theme::with(Theme::Dark, || {
229 Button::primary("Test").render(80).unwrap().lines[0].clone()
230 });
231
232 assert!(light_line.contains("Test"));
234 assert!(dark_line.contains("Test"));
235 }
236
237 #[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 assert!(
248 !line.contains("\x1b[48;2;255;255;255m"),
249 "Dark button must not have white bg in dark mode"
250 );
251 assert!(
253 line.contains("\x1b[48;2;42;42;42m"),
254 "Dark button should use CARD_DARK (#2a2a2a) bg in dark mode"
255 );
256 assert!(
258 line.contains("\x1b[38;2;255;255;255m"),
259 "Dark button should have white text"
260 );
261 }
262
263 #[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 #[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 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 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}