1use retroglyph_core::color::{Color, Style};
3use retroglyph_core::grid::Size;
4use retroglyph_core::text::width;
5
6use super::{InteractiveWidget, MinSize, Widget};
7use crate::Surface;
8use crate::align::Align;
9use crate::draw::fill_rect;
10use crate::interact::Density;
11use crate::interact::Response;
12use crate::interact::Sense;
13use crate::text::draw_clipped;
14use crate::theme::Theme;
15
16#[allow(clippy::too_long_first_doc_paragraph)]
20#[derive(Clone, Copy, Debug)]
65pub struct Button<'a> {
66 label: &'a str,
67 style: Style,
68 hovered_style: Style,
69 pressed_style: Style,
70 focused_style: Style,
71 disabled_style: Style,
72}
73
74impl<'a> Button<'a> {
75 #[must_use]
79 pub fn new(label: &'a str) -> Self {
80 Self {
81 label,
82 style: Style::new(),
83 hovered_style: Style::new(),
84 pressed_style: Style::new(),
85 focused_style: Style::new(),
86 disabled_style: Style::new(),
87 }
88 .theme(Theme::DARK)
89 }
90
91 #[must_use]
93 pub const fn style(mut self, style: Style) -> Self {
94 self.style = style;
95 self
96 }
97
98 #[must_use]
100 pub const fn hovered_style(mut self, style: Style) -> Self {
101 self.hovered_style = style;
102 self
103 }
104
105 #[must_use]
107 pub const fn pressed_style(mut self, style: Style) -> Self {
108 self.pressed_style = style;
109 self
110 }
111
112 #[must_use]
115 pub const fn focused_style(mut self, style: Style) -> Self {
116 self.focused_style = style;
117 self
118 }
119
120 #[must_use]
123 pub const fn disabled_style(mut self, style: Style) -> Self {
124 self.disabled_style = style;
125 self
126 }
127
128 #[must_use]
135 pub fn theme(self, theme: Theme) -> Self {
136 self.theme_on(theme, theme.panel_bg)
137 }
138
139 #[must_use]
151 pub fn theme_on(mut self, theme: Theme, bg: Color) -> Self {
152 self.style = Style::new().fg(theme.fg).bg(bg);
153 self.hovered_style = Style::new().fg(theme.fg).bg(theme.hover_bg);
154 self.pressed_style = Style::new().fg(theme.fg).bg(theme.press_bg);
155 self.focused_style = Style::new().fg(theme.accent).bg(bg);
156 self.disabled_style = Style::new().fg(theme.dim).bg(bg);
157 self
158 }
159
160 const fn resolved_style<Id>(&self, response: &Response<Id>) -> Style {
163 if response.disabled() {
164 self.disabled_style
165 } else if response.pressed() {
166 self.pressed_style
167 } else if response.hovered() {
168 self.hovered_style
169 } else if response.focused() {
170 self.focused_style
171 } else {
172 self.style
173 }
174 }
175}
176
177impl MinSize for Button<'_> {
178 fn min_size(&self, density: Density) -> Size {
182 let content = Size::new(width(self.label).saturating_add(4), 1);
183 content.max(density.min_target_size())
184 }
185}
186
187impl<Id> InteractiveWidget<Id> for Button<'_> {
188 type State = ();
189
190 fn sense(&self) -> Sense {
191 Sense::click()
192 }
193
194 fn render(&self, surface: &mut Surface<'_>, (): &mut Self::State, response: Response<Id>) {
195 let (width, height) = (surface.width(), surface.height());
196 if width == 0 || height == 0 {
197 return;
198 }
199
200 let style = self.resolved_style(&response);
201 let local_area = surface.area().at_origin();
202 fill_rect(surface, local_area, ' ', style);
203
204 let y = height / 2;
206 let _ = draw_clipped(surface, (0, y), width, self.label, Align::Center, style);
207 }
208}
209
210impl Widget for Button<'_> {
211 fn render(&self, surface: &mut Surface<'_>) {
215 InteractiveWidget::<()>::render(self, surface, &mut (), Response::default());
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use retroglyph_core::event::{Event, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
222 use retroglyph_core::grid::{Grid, HasSize as _, Pos, Rect};
223
224 use super::*;
225 use crate::interact::Interaction;
226
227 #[derive(Clone, Copy, PartialEq, Eq)]
228 enum Id {
229 Save,
230 }
231
232 #[test]
233 fn draws_the_label_centered_in_the_idle_style() {
234 let area = Rect::new(0, 0, 7, 1);
235 let mut grid = Grid::new(7, 1);
236 Widget::render(&Button::new("Go"), &mut Surface::new(&mut grid, area, 0));
237
238 assert_eq!(grid[Pos::new(2, 0)].glyph(), 'G');
240 assert_eq!(grid[Pos::new(3, 0)].glyph(), 'o');
241 }
242
243 #[test]
244 fn fills_the_whole_area_with_the_background() {
245 let area = Rect::new(0, 0, 7, 1);
246 let mut grid = Grid::new(7, 1);
247 Widget::render(&Button::new("Go"), &mut Surface::new(&mut grid, area, 0));
248
249 let idle_bg = Theme::DARK.panel_bg;
250 assert_eq!(grid[Pos::new(0, 0)].style().background(), idle_bg);
251 assert_eq!(grid[Pos::new(6, 0)].style().background(), idle_bg);
252 }
253
254 #[test]
255 fn pressed_takes_precedence_over_hovered() {
256 let response: Response<()> = Response {
257 hovered: true,
258 pressed: true,
259 ..Response::default()
260 };
261 let button = Button::new("Go");
262 assert_eq!(
263 button.resolved_style(&response).background(),
264 button.pressed_style.background()
265 );
266 }
267
268 #[test]
269 fn hovered_takes_precedence_over_focused() {
270 let response: Response<()> = Response {
271 hovered: true,
272 focused: true,
273 ..Response::default()
274 };
275 let button = Button::new("Go");
276 assert_eq!(
277 button.resolved_style(&response).background(),
278 button.hovered_style.background()
279 );
280 }
281
282 #[test]
283 fn focused_only_shows_when_not_pressed_or_hovered() {
284 let response: Response<()> = Response {
285 focused: true,
286 ..Response::default()
287 };
288 let button = Button::new("Go");
289 assert_eq!(
290 button.resolved_style(&response).background(),
291 button.focused_style.background()
292 );
293 }
294
295 #[test]
296 fn idle_by_default() {
297 let button = Button::new("Go");
298 assert_eq!(
299 button
300 .resolved_style(&Response::<()>::default())
301 .background(),
302 button.style.background()
303 );
304 }
305
306 #[test]
307 fn style_knobs_can_be_overridden() {
308 let custom = Style::new().fg(Color::RED).bg(Color::GREEN);
309 let response: Response<()> = Response {
310 pressed: true,
311 ..Response::default()
312 };
313 let button = Button::new("Go").pressed_style(custom);
314 assert_eq!(button.resolved_style(&response).background(), Color::GREEN);
315 }
316
317 #[test]
318 fn integrates_with_interaction_and_reflects_a_real_click() {
319 let mut interaction = Interaction::<Id>::new();
320 let area = Rect::new(0, 0, 7, 1);
321 let button = Button::new("Go");
322
323 interaction.begin_frame();
324 let _ = interaction.interact(area, Id::Save, InteractiveWidget::<Id>::sense(&button));
325 interaction.end_frame();
326
327 let _ = interaction.handle_event(&Event::Mouse(MouseEvent::new(
328 MouseEventKind::Down(MouseButton::Left),
329 Pos::new(2, 0),
330 KeyModifiers::NONE,
331 )));
332 let _ = interaction.handle_event(&Event::Mouse(MouseEvent::new(
333 MouseEventKind::Up(MouseButton::Left),
334 Pos::new(2, 0),
335 KeyModifiers::NONE,
336 )));
337
338 interaction.begin_frame();
339 let response =
340 interaction.interact(area, Id::Save, InteractiveWidget::<Id>::sense(&button));
341 interaction.end_frame();
342 assert!(response.clicked());
343
344 assert_eq!(
350 button.resolved_style(&response).background(),
351 button.pressed_style.background()
352 );
353
354 let mut grid = Grid::new(7, 1);
355 InteractiveWidget::render(
356 &button,
357 &mut Surface::new(&mut grid, area, 0),
358 &mut (),
359 response,
360 );
361 }
362
363 #[test]
364 fn scoped_into_a_narrower_clip_still_centers_against_the_full_area() {
365 let mut grid = Grid::new(10, 1);
366 let full = Rect::new(0, 0, 10, 1);
367 let mut surface = Surface::new(&mut grid, full, 0);
368 let mut clipped = surface.clip(Rect::new(6, 0, 4, 1));
372 Widget::render(&Button::new("Save"), &mut clipped.scope(full));
373
374 assert_eq!(grid[Pos::new(6, 0)].glyph(), 'e');
379 assert_eq!(grid[Pos::new(7, 0)].glyph(), ' ');
380 }
381
382 #[test]
383 fn disabled_style_takes_precedence_over_pressed_and_hovered() {
384 let response: Response<()> = Response {
385 hovered: true,
386 pressed: true,
387 disabled: true,
388 ..Response::default()
389 };
390 let button = Button::new("Go");
391 assert_eq!(button.resolved_style(&response), button.disabled_style);
392 }
393
394 #[test]
395 fn theme_on_maps_dim_onto_disabled_style() {
396 use crate::theme::Theme;
397
398 let button = Button::new("Go").theme_on(Theme::DARK, Color::Default);
399 assert_eq!(button.disabled_style.foreground(), Theme::DARK.dim);
400 assert_eq!(button.disabled_style.background(), Color::Default);
401 }
402
403 #[test]
404 fn zero_size_is_a_no_op() {
405 let area = Rect::new(0, 0, 0, 1);
406 let mut grid = Grid::new(1, 1);
407 Widget::render(&Button::new("Go"), &mut Surface::new(&mut grid, area, 0));
408 assert_eq!(grid[Pos::new(0, 0)].glyph(), ' ');
409 }
410
411 #[test]
412 fn theme_maps_named_roles_onto_every_state() {
413 use crate::theme::Theme;
414
415 let response: Response<()> = Response {
416 hovered: true,
417 ..Response::default()
418 };
419 let button = Button::new("Go").theme(Theme::DARK);
420
421 assert_eq!(button.style.foreground(), Theme::DARK.fg);
422 assert_eq!(button.style.background(), Theme::DARK.panel_bg);
423 assert_eq!(button.hovered_style.background(), Theme::DARK.hover_bg);
424 assert_eq!(button.pressed_style.background(), Theme::DARK.press_bg);
425 assert_eq!(button.focused_style.foreground(), Theme::DARK.accent);
426 assert_eq!(
427 button.resolved_style(&response).background(),
428 Theme::DARK.hover_bg
429 );
430 }
431
432 #[test]
433 fn theme_on_uses_the_given_backdrop_instead_of_panel_bg() {
434 use crate::theme::Theme;
435
436 let button = Button::new("Go").theme_on(Theme::DARK, Color::Default);
437
438 assert_eq!(button.style.foreground(), Theme::DARK.fg);
439 assert_eq!(button.style.background(), Color::Default);
440 assert_eq!(button.focused_style.foreground(), Theme::DARK.accent);
441 assert_eq!(button.focused_style.background(), Color::Default);
442 assert_eq!(button.hovered_style.background(), Theme::DARK.hover_bg);
444 assert_eq!(button.pressed_style.background(), Theme::DARK.press_bg);
445 }
446
447 #[test]
448 fn button_wide_label_draws_outside_its_own_area() {
449 let area = Rect::new(0, 0, 4, 1);
450 let mut grid = Grid::new(5, 1);
451 Widget::render(&Button::new("保存"), &mut Surface::new(&mut grid, area, 0));
452
453 assert_eq!(grid[Pos::new(0, 0)].glyph(), '保');
457 assert_eq!(grid[Pos::new(2, 0)].glyph(), '存');
458 assert_eq!(grid[Pos::new(4, 0)].glyph(), ' ');
459 }
460
461 #[test]
462 fn button_centers_a_wide_label_by_display_width() {
463 let area = Rect::new(0, 0, 8, 1);
464 let mut grid = Grid::new(8, 1);
465 Widget::render(&Button::new("保存"), &mut Surface::new(&mut grid, area, 0));
466
467 assert_eq!(grid[Pos::new(2, 0)].glyph(), '保');
469 assert_eq!(grid[Pos::new(4, 0)].glyph(), '存');
470 }
471
472 #[test]
473 fn min_size_pads_the_label_by_two_columns_each_side() {
474 let size = Button::new("Go").min_size(Density::Mouse);
477 assert_eq!(size.width(), 6);
478 assert_eq!(size.height(), 1);
479 }
480
481 #[test]
482 fn min_size_floors_a_short_label_at_the_density_minimum() {
483 let size = Button::new("OK").min_size(Density::Touch);
487 assert_eq!(size, Density::Touch.min_target_size());
488 }
489
490 #[test]
491 fn min_size_grows_past_the_density_floor_for_a_wide_label() {
492 let size = Button::new("Save Changes").min_size(Density::Mouse);
495 assert_eq!(size.width(), 16);
496 assert_eq!(size.height(), 1);
497 }
498}