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 Style,
17 Theme,
18 stylize_padded,
19 },
20};
21
22#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
24pub enum ButtonVariant {
25 #[default]
27 Dark,
28 Cream,
30 Ghost,
32 Text,
34 Primary,
36}
37
38pub struct Button {
43 label: String,
44 variant: ButtonVariant,
45 pad: usize,
46}
47
48impl Button {
49 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 pub fn primary(label: impl Into<String>) -> Self {
60 Self::new(label, ButtonVariant::Primary)
61 }
62
63 pub fn dark(label: impl Into<String>) -> Self {
65 Self::new(label, ButtonVariant::Dark)
66 }
67
68 pub fn cream(label: impl Into<String>) -> Self {
70 Self::new(label, ButtonVariant::Cream)
71 }
72
73 pub fn ghost(label: impl Into<String>) -> Self {
75 Self::new(label, ButtonVariant::Ghost)
76 }
77
78 pub fn text(label: impl Into<String>) -> Self {
80 Self::new(label, ButtonVariant::Text)
81 }
82
83 pub fn pad(mut self, pad: usize) -> Self {
85 self.pad = pad;
86 self
87 }
88
89 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 | 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 | 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 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 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 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 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 assert!(rendered.lines[0].contains(" OK "));
226 });
227 }
228
229 #[test]
230 fn button_respects_theme() {
231 let light_line = Theme::with(Theme::Light, || {
233 Button::primary("Test").render(80).unwrap().lines[0].clone()
234 });
235
236 let dark_line = Theme::with(Theme::Dark, || {
238 Button::primary("Test").render(80).unwrap().lines[0].clone()
239 });
240
241 assert!(light_line.contains("Test"));
243 assert!(dark_line.contains("Test"));
244 }
245
246 #[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 assert!(
257 !line.contains("\x1b[48;2;255;255;255m"),
258 "Dark button must not have white bg in dark mode"
259 );
260 assert!(
262 line.contains("\x1b[48;2;42;42;42m"),
263 "Dark button should use CARD_DARK (#2a2a2a) bg in dark mode"
264 );
265 assert!(
267 line.contains("\x1b[38;2;255;255;255m"),
268 "Dark button should have white text"
269 );
270 }
271
272 #[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 #[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 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 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}