retroglyph_widgets/widget/
button.rs1use retroglyph_core::{Backend, Color, Rect, Style, Terminal};
3
4use super::Widget;
5use crate::Response;
6use crate::Theme;
7use crate::draw::fill_rect;
8use crate::text::truncate as truncate_to_cols;
9
10#[derive(Clone, Copy, Debug)]
49pub struct Button<'a> {
50 label: &'a str,
51 response: Response,
52 style: Style,
53 hovered_style: Style,
54 pressed_style: Style,
55 focused_style: Style,
56}
57
58impl<'a> Button<'a> {
59 #[must_use]
61 pub fn new(label: &'a str, response: Response) -> Self {
62 Self {
63 label,
64 response,
65 style: Style::new()
66 .fg(Color::Rgb {
67 r: 170,
68 g: 175,
69 b: 190,
70 })
71 .bg(Color::Rgb {
72 r: 45,
73 g: 48,
74 b: 58,
75 }),
76 hovered_style: Style::new().fg(Color::BRIGHT_WHITE).bg(Color::Rgb {
77 r: 60,
78 g: 65,
79 b: 80,
80 }),
81 pressed_style: Style::new().fg(Color::BRIGHT_WHITE).bg(Color::Rgb {
82 r: 40,
83 g: 60,
84 b: 90,
85 }),
86 focused_style: Style::new().fg(Color::BRIGHT_WHITE).bg(Color::Rgb {
87 r: 55,
88 g: 55,
89 b: 70,
90 }),
91 }
92 }
93
94 #[must_use]
96 pub const fn style(mut self, style: Style) -> Self {
97 self.style = style;
98 self
99 }
100
101 #[must_use]
103 pub const fn hovered_style(mut self, style: Style) -> Self {
104 self.hovered_style = style;
105 self
106 }
107
108 #[must_use]
110 pub const fn pressed_style(mut self, style: Style) -> Self {
111 self.pressed_style = style;
112 self
113 }
114
115 #[must_use]
118 pub const fn focused_style(mut self, style: Style) -> Self {
119 self.focused_style = style;
120 self
121 }
122
123 #[must_use]
130 pub fn theme(mut self, theme: Theme) -> Self {
131 self.style = Style::new().fg(theme.fg).bg(theme.panel_bg);
132 self.hovered_style = Style::new().fg(theme.fg).bg(theme.hover_bg);
133 self.pressed_style = Style::new().fg(theme.fg).bg(theme.press_bg);
134 self.focused_style = Style::new().fg(theme.accent).bg(theme.panel_bg);
135 self
136 }
137
138 const fn resolved_style(&self) -> Style {
141 if self.response.pressed() {
142 self.pressed_style
143 } else if self.response.hovered() {
144 self.hovered_style
145 } else if self.response.focused() {
146 self.focused_style
147 } else {
148 self.style
149 }
150 }
151}
152
153impl<B: Backend> Widget<B> for Button<'_> {
154 fn render(self, area: Rect, term: &mut Terminal<B>) {
155 if area.width() == 0 || area.height() == 0 {
156 return;
157 }
158
159 let style = self.resolved_style();
160 fill_rect(term, area, ' ', style);
161
162 let text = truncate_to_cols(self.label, area.width_usize());
163 let text_width = text.chars().count() as u16;
164 let x = area.left() + (area.width().saturating_sub(text_width)) / 2;
165 let y = area.top() + area.height() / 2;
166
167 term.reset_style()
168 .fg(style.foreground())
169 .bg(style.background());
170 term.print(x, y, &text);
171 term.reset_style();
172 }
173}
174
175#[cfg(test)]
176mod tests {
177 use retroglyph_core::{
178 Event, Headless, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, Pos,
179 };
180
181 use super::*;
182 use crate::{Interaction, Sense};
183
184 #[derive(Clone, Copy, PartialEq, Eq)]
185 enum Id {
186 Save,
187 }
188
189 #[test]
190 fn draws_the_label_centered_in_the_idle_style() {
191 let area = Rect::new(0, 0, 7, 1);
192 let mut term = Terminal::new(Headless::new(7, 1));
193 Button::new("Go", Response::default()).render(area, &mut term);
194
195 assert_eq!(term.grid().get(2, 0).glyph(), 'G');
197 assert_eq!(term.grid().get(3, 0).glyph(), 'o');
198 }
199
200 #[test]
201 fn fills_the_whole_area_with_the_background() {
202 let area = Rect::new(0, 0, 7, 1);
203 let mut term = Terminal::new(Headless::new(7, 1));
204 Button::new("Go", Response::default()).render(area, &mut term);
205
206 let idle_bg = Style::new()
207 .fg(Color::Rgb {
208 r: 170,
209 g: 175,
210 b: 190,
211 })
212 .bg(Color::Rgb {
213 r: 45,
214 g: 48,
215 b: 58,
216 })
217 .background();
218 assert_eq!(term.grid().get(0, 0).style().background(), idle_bg);
219 assert_eq!(term.grid().get(6, 0).style().background(), idle_bg);
220 }
221
222 #[test]
223 fn pressed_takes_precedence_over_hovered() {
224 let response = Response {
225 hovered: true,
226 pressed: true,
227 ..Response::default()
228 };
229 let button = Button::new("Go", response);
230 assert_eq!(
231 button.resolved_style().background(),
232 button.pressed_style.background()
233 );
234 }
235
236 #[test]
237 fn hovered_takes_precedence_over_focused() {
238 let response = Response {
239 hovered: true,
240 focused: true,
241 ..Response::default()
242 };
243 let button = Button::new("Go", response);
244 assert_eq!(
245 button.resolved_style().background(),
246 button.hovered_style.background()
247 );
248 }
249
250 #[test]
251 fn focused_only_shows_when_not_pressed_or_hovered() {
252 let response = Response {
253 focused: true,
254 ..Response::default()
255 };
256 let button = Button::new("Go", response);
257 assert_eq!(
258 button.resolved_style().background(),
259 button.focused_style.background()
260 );
261 }
262
263 #[test]
264 fn idle_by_default() {
265 let button = Button::new("Go", Response::default());
266 assert_eq!(
267 button.resolved_style().background(),
268 button.style.background()
269 );
270 }
271
272 #[test]
273 fn style_knobs_can_be_overridden() {
274 let custom = Style::new().fg(Color::RED).bg(Color::GREEN);
275 let response = Response {
276 pressed: true,
277 ..Response::default()
278 };
279 let button = Button::new("Go", response).pressed_style(custom);
280 assert_eq!(button.resolved_style().background(), Color::GREEN);
281 }
282
283 #[test]
284 fn integrates_with_interaction_and_reflects_a_real_click() {
285 let mut interaction = Interaction::<Id>::new();
286 let area = Rect::new(0, 0, 7, 1);
287
288 interaction.begin_frame();
289 let _ = interaction.interact(area, Id::Save, Sense::click());
290 interaction.end_frame();
291
292 interaction.handle_event(&Event::Mouse(MouseEvent {
293 kind: MouseEventKind::Down(MouseButton::Left),
294 position: Pos::new(2, 0),
295 pixel_position: None,
296 modifiers: KeyModifiers::NONE,
297 }));
298 interaction.handle_event(&Event::Mouse(MouseEvent {
299 kind: MouseEventKind::Up(MouseButton::Left),
300 position: Pos::new(2, 0),
301 pixel_position: None,
302 modifiers: KeyModifiers::NONE,
303 }));
304
305 interaction.begin_frame();
306 let response = interaction.interact(area, Id::Save, Sense::click());
307 interaction.end_frame();
308 assert!(response.clicked());
309
310 let button = Button::new("Go", response);
316 assert_eq!(
317 button.resolved_style().background(),
318 button.pressed_style.background()
319 );
320
321 let mut term = Terminal::new(Headless::new(7, 1));
322 button.render(area, &mut term);
323 }
324
325 #[test]
326 fn zero_size_is_a_no_op() {
327 let area = Rect::new(0, 0, 0, 1);
328 let mut term = Terminal::new(Headless::new(1, 1));
329 Button::new("Go", Response::default()).render(area, &mut term);
330 assert_eq!(term.grid().get(0, 0).glyph(), ' ');
331 }
332
333 #[test]
334 fn theme_maps_named_roles_onto_every_state() {
335 use crate::Theme;
336
337 let response = Response {
338 hovered: true,
339 ..Response::default()
340 };
341 let button = Button::new("Go", response).theme(Theme::DARK);
342
343 assert_eq!(button.style.foreground(), Theme::DARK.fg);
344 assert_eq!(button.style.background(), Theme::DARK.panel_bg);
345 assert_eq!(button.hovered_style.background(), Theme::DARK.hover_bg);
346 assert_eq!(button.pressed_style.background(), Theme::DARK.press_bg);
347 assert_eq!(button.focused_style.foreground(), Theme::DARK.accent);
348 assert_eq!(button.resolved_style().background(), Theme::DARK.hover_bg);
349 }
350}