1use retroglyph_core::color::{Color, Style};
3
4use super::{InteractiveWidget, Widget};
5use crate::Align;
6use crate::Response;
7use crate::Sense;
8use crate::Surface;
9use crate::Theme;
10use crate::draw::fill_rect;
11use crate::text::draw_clipped;
12
13#[derive(Clone, Copy, Debug)]
56pub struct Button<'a> {
57 label: &'a str,
58 style: Style,
59 hovered_style: Style,
60 pressed_style: Style,
61 focused_style: Style,
62 disabled_style: Style,
63}
64
65impl<'a> Button<'a> {
66 #[must_use]
70 pub fn new(label: &'a str) -> Self {
71 Self {
72 label,
73 style: Style::new(),
74 hovered_style: Style::new(),
75 pressed_style: Style::new(),
76 focused_style: Style::new(),
77 disabled_style: Style::new(),
78 }
79 .theme(Theme::DARK)
80 }
81
82 #[must_use]
84 pub const fn style(mut self, style: Style) -> Self {
85 self.style = style;
86 self
87 }
88
89 #[must_use]
91 pub const fn hovered_style(mut self, style: Style) -> Self {
92 self.hovered_style = style;
93 self
94 }
95
96 #[must_use]
98 pub const fn pressed_style(mut self, style: Style) -> Self {
99 self.pressed_style = style;
100 self
101 }
102
103 #[must_use]
106 pub const fn focused_style(mut self, style: Style) -> Self {
107 self.focused_style = style;
108 self
109 }
110
111 #[must_use]
114 pub const fn disabled_style(mut self, style: Style) -> Self {
115 self.disabled_style = style;
116 self
117 }
118
119 #[must_use]
126 pub fn theme(self, theme: Theme) -> Self {
127 self.theme_on(theme, theme.panel_bg)
128 }
129
130 #[must_use]
142 pub fn theme_on(mut self, theme: Theme, bg: Color) -> Self {
143 self.style = Style::new().fg(theme.fg).bg(bg);
144 self.hovered_style = Style::new().fg(theme.fg).bg(theme.hover_bg);
145 self.pressed_style = Style::new().fg(theme.fg).bg(theme.press_bg);
146 self.focused_style = Style::new().fg(theme.accent).bg(bg);
147 self.disabled_style = Style::new().fg(theme.dim).bg(bg);
148 self
149 }
150
151 const fn resolved_style<Id>(&self, response: &Response<Id>) -> Style {
154 if response.disabled() {
155 self.disabled_style
156 } else if response.pressed() {
157 self.pressed_style
158 } else if response.hovered() {
159 self.hovered_style
160 } else if response.focused() {
161 self.focused_style
162 } else {
163 self.style
164 }
165 }
166}
167
168impl<Id> InteractiveWidget<Id> for Button<'_> {
169 type State = ();
170
171 fn sense(&self) -> Sense {
172 Sense::click()
173 }
174
175 fn render(&self, surface: &mut Surface<'_>, (): &mut Self::State, response: Response<Id>) {
176 let (width, height) = (surface.width(), surface.height());
177 if width == 0 || height == 0 {
178 return;
179 }
180
181 let style = self.resolved_style(&response);
182 let local_area = surface.area().at_origin();
183 fill_rect(surface, local_area, ' ', style);
184
185 let y = height / 2;
187 let _ = draw_clipped(surface, (0, y), width, self.label, Align::Center, style);
188 }
189}
190
191impl Widget for Button<'_> {
192 fn render(&self, surface: &mut Surface<'_>) {
196 InteractiveWidget::<()>::render(self, surface, &mut (), Response::default());
197 }
198}
199
200#[cfg(test)]
201mod tests {
202 use retroglyph_core::event::{Event, KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
203 use retroglyph_core::grid::{Grid, Pos, Rect};
204
205 use super::*;
206 use crate::Interaction;
207
208 #[derive(Clone, Copy, PartialEq, Eq)]
209 enum Id {
210 Save,
211 }
212
213 #[test]
214 fn draws_the_label_centered_in_the_idle_style() {
215 let area = Rect::new(0, 0, 7, 1);
216 let mut grid = Grid::new(7, 1);
217 Widget::render(&Button::new("Go"), &mut Surface::new(&mut grid, area, 0));
218
219 assert_eq!(grid[Pos::new(2, 0)].glyph(), 'G');
221 assert_eq!(grid[Pos::new(3, 0)].glyph(), 'o');
222 }
223
224 #[test]
225 fn fills_the_whole_area_with_the_background() {
226 let area = Rect::new(0, 0, 7, 1);
227 let mut grid = Grid::new(7, 1);
228 Widget::render(&Button::new("Go"), &mut Surface::new(&mut grid, area, 0));
229
230 let idle_bg = Theme::DARK.panel_bg;
231 assert_eq!(grid[Pos::new(0, 0)].style().background(), idle_bg);
232 assert_eq!(grid[Pos::new(6, 0)].style().background(), idle_bg);
233 }
234
235 #[test]
236 fn pressed_takes_precedence_over_hovered() {
237 let response: Response<()> = Response {
238 hovered: true,
239 pressed: true,
240 ..Response::default()
241 };
242 let button = Button::new("Go");
243 assert_eq!(
244 button.resolved_style(&response).background(),
245 button.pressed_style.background()
246 );
247 }
248
249 #[test]
250 fn hovered_takes_precedence_over_focused() {
251 let response: Response<()> = Response {
252 hovered: true,
253 focused: true,
254 ..Response::default()
255 };
256 let button = Button::new("Go");
257 assert_eq!(
258 button.resolved_style(&response).background(),
259 button.hovered_style.background()
260 );
261 }
262
263 #[test]
264 fn focused_only_shows_when_not_pressed_or_hovered() {
265 let response: Response<()> = Response {
266 focused: true,
267 ..Response::default()
268 };
269 let button = Button::new("Go");
270 assert_eq!(
271 button.resolved_style(&response).background(),
272 button.focused_style.background()
273 );
274 }
275
276 #[test]
277 fn idle_by_default() {
278 let button = Button::new("Go");
279 assert_eq!(
280 button
281 .resolved_style(&Response::<()>::default())
282 .background(),
283 button.style.background()
284 );
285 }
286
287 #[test]
288 fn style_knobs_can_be_overridden() {
289 let custom = Style::new().fg(Color::RED).bg(Color::GREEN);
290 let response: Response<()> = Response {
291 pressed: true,
292 ..Response::default()
293 };
294 let button = Button::new("Go").pressed_style(custom);
295 assert_eq!(button.resolved_style(&response).background(), Color::GREEN);
296 }
297
298 #[test]
299 fn integrates_with_interaction_and_reflects_a_real_click() {
300 let mut interaction = Interaction::<Id>::new();
301 let area = Rect::new(0, 0, 7, 1);
302 let button = Button::new("Go");
303
304 interaction.begin_frame();
305 let _ = interaction.interact(area, Id::Save, InteractiveWidget::<Id>::sense(&button));
306 interaction.end_frame();
307
308 let _ = interaction.handle_event(&Event::Mouse(MouseEvent::new(
309 MouseEventKind::Down(MouseButton::Left),
310 Pos::new(2, 0),
311 KeyModifiers::NONE,
312 )));
313 let _ = interaction.handle_event(&Event::Mouse(MouseEvent::new(
314 MouseEventKind::Up(MouseButton::Left),
315 Pos::new(2, 0),
316 KeyModifiers::NONE,
317 )));
318
319 interaction.begin_frame();
320 let response =
321 interaction.interact(area, Id::Save, InteractiveWidget::<Id>::sense(&button));
322 interaction.end_frame();
323 assert!(response.clicked());
324
325 assert_eq!(
331 button.resolved_style(&response).background(),
332 button.pressed_style.background()
333 );
334
335 let mut grid = Grid::new(7, 1);
336 InteractiveWidget::render(
337 &button,
338 &mut Surface::new(&mut grid, area, 0),
339 &mut (),
340 response,
341 );
342 }
343
344 #[test]
345 fn scoped_into_a_narrower_clip_still_centers_against_the_full_area() {
346 let mut grid = Grid::new(10, 1);
347 let full = Rect::new(0, 0, 10, 1);
348 let mut surface = Surface::new(&mut grid, full, 0);
349 let mut clipped = surface.clip(Rect::new(6, 0, 4, 1));
353 Widget::render(&Button::new("Save"), &mut clipped.scope(full));
354
355 assert_eq!(grid[Pos::new(6, 0)].glyph(), 'e');
360 assert_eq!(grid[Pos::new(7, 0)].glyph(), ' ');
361 }
362
363 #[test]
364 fn disabled_style_takes_precedence_over_pressed_and_hovered() {
365 let response: Response<()> = Response {
366 hovered: true,
367 pressed: true,
368 disabled: true,
369 ..Response::default()
370 };
371 let button = Button::new("Go");
372 assert_eq!(button.resolved_style(&response), button.disabled_style);
373 }
374
375 #[test]
376 fn theme_on_maps_dim_onto_disabled_style() {
377 use crate::Theme;
378
379 let button = Button::new("Go").theme_on(Theme::DARK, Color::Default);
380 assert_eq!(button.disabled_style.foreground(), Theme::DARK.dim);
381 assert_eq!(button.disabled_style.background(), Color::Default);
382 }
383
384 #[test]
385 fn zero_size_is_a_no_op() {
386 let area = Rect::new(0, 0, 0, 1);
387 let mut grid = Grid::new(1, 1);
388 Widget::render(&Button::new("Go"), &mut Surface::new(&mut grid, area, 0));
389 assert_eq!(grid[Pos::new(0, 0)].glyph(), ' ');
390 }
391
392 #[test]
393 fn theme_maps_named_roles_onto_every_state() {
394 use crate::Theme;
395
396 let response: Response<()> = Response {
397 hovered: true,
398 ..Response::default()
399 };
400 let button = Button::new("Go").theme(Theme::DARK);
401
402 assert_eq!(button.style.foreground(), Theme::DARK.fg);
403 assert_eq!(button.style.background(), Theme::DARK.panel_bg);
404 assert_eq!(button.hovered_style.background(), Theme::DARK.hover_bg);
405 assert_eq!(button.pressed_style.background(), Theme::DARK.press_bg);
406 assert_eq!(button.focused_style.foreground(), Theme::DARK.accent);
407 assert_eq!(
408 button.resolved_style(&response).background(),
409 Theme::DARK.hover_bg
410 );
411 }
412
413 #[test]
414 fn theme_on_uses_the_given_backdrop_instead_of_panel_bg() {
415 use crate::Theme;
416
417 let button = Button::new("Go").theme_on(Theme::DARK, Color::Default);
418
419 assert_eq!(button.style.foreground(), Theme::DARK.fg);
420 assert_eq!(button.style.background(), Color::Default);
421 assert_eq!(button.focused_style.foreground(), Theme::DARK.accent);
422 assert_eq!(button.focused_style.background(), Color::Default);
423 assert_eq!(button.hovered_style.background(), Theme::DARK.hover_bg);
425 assert_eq!(button.pressed_style.background(), Theme::DARK.press_bg);
426 }
427
428 #[test]
429 fn button_wide_label_draws_outside_its_own_area() {
430 let area = Rect::new(0, 0, 4, 1);
431 let mut grid = Grid::new(5, 1);
432 Widget::render(&Button::new("保存"), &mut Surface::new(&mut grid, area, 0));
433
434 assert_eq!(grid[Pos::new(0, 0)].glyph(), '保');
438 assert_eq!(grid[Pos::new(2, 0)].glyph(), '存');
439 assert_eq!(grid[Pos::new(4, 0)].glyph(), ' ');
440 }
441
442 #[test]
443 fn button_centers_a_wide_label_by_display_width() {
444 let area = Rect::new(0, 0, 8, 1);
445 let mut grid = Grid::new(8, 1);
446 Widget::render(&Button::new("保存"), &mut Surface::new(&mut grid, area, 0));
447
448 assert_eq!(grid[Pos::new(2, 0)].glyph(), '保');
450 assert_eq!(grid[Pos::new(4, 0)].glyph(), '存');
451 }
452}