1use super::cells;
4use super::press::{self, Press};
5use crate::event::{Event, MouseButton, MouseKind};
6use crate::geometry::{Rect, Size};
7use crate::text;
8use crate::theme::State;
9use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
10
11pub struct Button<Msg> {
23 label: String,
24 icon: Option<String>,
25 shortcut: Option<String>,
26 variant: Option<String>,
27 disabled: bool,
28 loading: bool,
29 selected: Option<bool>,
30 faint: bool,
31 on_press: Option<Msg>,
32 on_middle_press: Option<Msg>,
33}
34
35impl<Msg> Button<Msg> {
36 #[must_use]
38 pub fn new(label: impl Into<String>) -> Self {
39 Self {
40 label: label.into(),
41 icon: None,
42 shortcut: None,
43 variant: None,
44 disabled: false,
45 loading: false,
46 selected: None,
47 faint: false,
48 on_press: None,
49 on_middle_press: None,
50 }
51 }
52
53 #[must_use]
55 pub fn on_press(mut self, message: Msg) -> Self {
56 self.on_press = Some(message);
57 self
58 }
59
60 #[must_use]
66 pub fn on_middle_press(mut self, message: Msg) -> Self {
67 self.on_middle_press = Some(message);
68 self
69 }
70
71 #[must_use]
73 pub fn variant(mut self, variant: impl Into<String>) -> Self {
74 self.variant = Some(variant.into());
75 self
76 }
77
78 #[must_use]
80 pub fn icon(mut self, key: impl Into<String>) -> Self {
81 self.icon = Some(key.into());
82 self
83 }
84
85 #[must_use]
88 pub fn shortcut(mut self, label: impl Into<String>) -> Self {
89 self.shortcut = Some(label.into());
90 self
91 }
92
93 #[must_use]
95 pub fn disabled(mut self, disabled: bool) -> Self {
96 self.disabled = disabled;
97 self
98 }
99
100 #[must_use]
102 pub fn loading(mut self, loading: bool) -> Self {
103 self.loading = loading;
104 self
105 }
106
107 #[must_use]
111 pub fn selected(mut self, selected: bool) -> Self {
112 self.selected = Some(selected);
113 self
114 }
115
116 #[must_use]
121 pub fn faint(mut self, faint: bool) -> Self {
122 self.faint = faint;
123 self
124 }
125
126 fn active(&self) -> bool {
127 !self.disabled && !self.loading && self.on_press.is_some()
128 }
129}
130
131const FAINT_GROUND: f32 = 0.6;
133
134const FAINT_TEXT: f32 = 0.35;
137
138fn key_width(key: &str, horizontal_padding: u16) -> u16 {
141 cells::sum([u16::from(horizontal_padding >= 1), text::width(key), 2])
142}
143
144impl<Msg: Clone + 'static> Button<Msg> {
145 fn press(&self, cx: &mut EventCx<'_, Msg>) {
146 self.send(cx, self.on_press.as_ref());
147 }
148
149 fn send(&self, cx: &mut EventCx<'_, Msg>, message: Option<&Msg>) {
150 if let Some(message) = message {
151 if self.selected.is_none() {
152 cx.flash();
153 }
154 cx.emit(message.clone());
155 }
156 }
157
158 fn middle_press(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> Option<bool> {
161 let message = self.on_middle_press.as_ref()?;
162 let Event::Mouse(mouse) = event else {
163 return None;
164 };
165 match mouse.kind {
166 MouseKind::Down(MouseButton::Middle) => {
167 cx.capture_pointer();
168 Some(true)
169 }
170 MouseKind::Up(MouseButton::Middle) => {
171 if cx.holds_pointer() && cx.area().contains(mouse.x, mouse.y) {
172 self.send(cx, Some(message));
173 }
174 Some(true)
175 }
176 _ => None,
177 }
178 }
179}
180
181impl<Msg: Clone + 'static> Widget<Msg> for Button<Msg> {
182 fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
183 let theme = cx.env().theme();
184 let style = theme.style("button", self.variant.as_deref(), &[]);
185 let (vertical, horizontal) = style.pair("padding").unwrap_or((0, 2));
186 let icon_width = self
187 .icon
188 .as_deref()
189 .map_or(0, |key| text::width(&cx.env().icons().glyph(key)) + 1)
190 .max(if self.loading { 2 } else { 0 });
191 let shortcut_width = self.shortcut.as_deref().map_or(0, |key| key_width(key, horizontal));
192 let width = cells::sum([shortcut_width, horizontal.saturating_mul(2), icon_width, text::width(&self.label)]);
193 Size::new(width, vertical.saturating_mul(2).saturating_add(1)).min(available)
194 }
195
196 fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
197 let interactive = !self.disabled && self.on_press.is_some();
200 let mut states = if interactive { cx.pressable_states() } else { Vec::new() };
203 if self.selected == Some(true) {
204 states.push(State::Selected);
205 }
206 if self.disabled {
207 states.push(State::Disabled);
208 }
209 let variant = self.variant.as_deref();
210 let style = cx.style("button", variant, &states);
211 let mut surface = style.text();
212 let mut background = surface.bg.unwrap_or_else(|| cx.color("raised"));
213 let quiet =
215 self.faint && !states.iter().any(|state| matches!(state, State::Hover | State::Focus | State::Pressed));
216 if quiet {
217 background = background.mix(cx.color("surface"), FAINT_GROUND);
218 surface.bg = Some(background);
219 surface.fg = surface.fg.map(|fg| fg.mix(background, FAINT_TEXT));
220 }
221 cx.clear(area, background);
222 if interactive {
223 cx.register_hit(area);
224 }
225
226 let padding = style.padding();
227 let y = area.y + i32::from(padding.top);
228 let mut x = area.x;
229 let shortcut_width = self.shortcut.as_deref().map_or(0, |key| key_width(key, padding.left));
230 if let Some(shortcut) = &self.shortcut {
231 let mut key_style = cx.style("button-key", variant, &states).text();
232 if quiet {
233 let ground = key_style.bg.unwrap_or(background).mix(cx.color("surface"), FAINT_GROUND);
234 key_style.bg = Some(ground);
235 key_style.fg = key_style.fg.map(|fg| fg.mix(ground, FAINT_TEXT));
236 }
237 cx.clear(Rect::new(x, area.y, shortcut_width, area.height), key_style.bg.unwrap_or(background));
238 let label = format!(" {shortcut} ");
239 let label_width = text::width(&label);
240 cx.text(x + i32::from(shortcut_width - label_width), y, &label, key_style, label_width);
241 }
242 let pillar = style.color("pillar").filter(|_| padding.left >= 1);
246 if let Some(color) = pillar {
247 cx.pillar(x, y, color);
248 }
249 x += i32::from(shortcut_width);
250 x += i32::from(padding.left);
251 let right = area.right() - i32::from(padding.right);
252 let budget = |x: i32| crate::geometry::clamp_u16(right - x);
253
254 if self.loading {
255 let spinner = super::SpinnerStyle::default().animation();
257 let cell = cx.animation(spinner, surface, Some(std::time::Duration::ZERO));
258 x += i32::from(cx.text(x, y, &cell.glyph, cell.style, budget(x).min(1)));
259 x += 1;
260 } else if let Some(icon) = &self.icon {
261 let glyph = cx.env().icons().glyph(icon).into_owned();
262 x += i32::from(cx.text(x, y, &glyph, surface, budget(x)));
263 x += 1;
264 }
265 let label = text::truncate(&self.label, budget(x)).into_owned();
266 cx.text(x, y, &label, surface, budget(x));
267 }
268
269 fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
270 if !self.active() {
271 return false;
272 }
273 if let Some(used) = self.middle_press(cx, event) {
274 return used;
275 }
276 match press::read(cx, event) {
277 Press::Ignored => false,
278 Press::Used => true,
279 Press::Key | Press::Click(..) => {
280 self.press(cx);
281 true
282 }
283 }
284 }
285
286 fn focusable(&self) -> bool {
287 self.active()
288 }
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294 use crate::event::{MouseButton, MouseKind};
295 use crate::runtime::{App, Command, Harness};
296 use crate::widget::View;
297 use std::time::Duration;
298
299 #[derive(Default)]
300 struct Demo {
301 presses: u32,
302 disabled: bool,
303 loading: bool,
304 }
305
306 impl App for Demo {
307 type Msg = ();
308 fn update(&mut self, _: ()) -> Command<()> {
309 self.presses += 1;
310 Command::none()
311 }
312 fn view(&self, ui: &mut View<'_, ()>) {
313 ui.row(|ui| {
314 ui.add(Button::new("Save").variant("primary").shortcut("⏎").on_press(()).disabled(self.disabled))
315 .id("save");
316 ui.add(Button::new("Wait").loading(self.loading).on_press(()));
317 })
318 .gap(1);
319 }
320 }
321
322 #[derive(Default)]
324 struct Quiet {
325 presses: u32,
326 }
327
328 impl App for Quiet {
329 type Msg = ();
330 fn update(&mut self, (): ()) -> Command<()> {
331 self.presses += 1;
332 Command::none()
333 }
334 fn view(&self, ui: &mut View<'_, ()>) {
335 ui.row(|ui| {
336 ui.add(Button::new("Stop").faint(true).on_press(())).id("stop");
337 ui.add(Button::new("Pause").on_press(()));
338 })
339 .gap(1);
340 }
341 }
342
343 #[test]
344 fn a_faint_button_is_quieter_still_presses_and_speaks_up_under_the_pointer() {
345 let mut h = Harness::new(Quiet::default(), 30, 1);
346 let (stop, pause) = (h.find("Stop").expect("stop"), h.find("Pause").expect("pause"));
347 let tone = |h: &Harness<Quiet>, (x, y): (i32, i32)| {
348 let (x, y) = (u16::try_from(x).unwrap_or(0), u16::try_from(y).unwrap_or(0));
349 (h.fg(x, y), h.bg(x, y))
350 };
351 assert_ne!(tone(&h, stop), tone(&h, pause), "the faint button steps back");
352 h.click(stop.0, stop.1);
353 assert_eq!(h.app().presses, 1, "and still presses");
354 let settle = Duration::from_millis(600);
355 h.hover(stop.0, stop.1).advance(settle);
356 let hovered = tone(&h, stop);
357 h.hover(pause.0, pause.1).advance(settle);
358 let pause_hovered = tone(&h, pause);
359 assert_eq!(hovered, pause_hovered, "under the pointer it has its full tone");
360 }
361
362 #[test]
363 fn a_faint_button_is_still_read_in_sixteen_colours() {
364 let mut h = Harness::new(Quiet::default(), 30, 1);
365 h.set_depth(crate::color::ColorDepth::Ansi16);
366 let (x, y) = h.find("Stop").expect("stop");
367 let cell = &h.buffer()[(u16::try_from(x).unwrap_or(0), u16::try_from(y).unwrap_or(0))];
368 let entry = |color| match color {
369 ratatui_core::style::Color::Indexed(index) => crate::color::Rgb::from_ansi256(index),
370 other => panic!("a palette frame sent {other:?}"),
371 };
372 let ratio = entry(cell.fg).contrast_ratio(entry(cell.bg));
373 assert!(ratio >= 1.6, "the label does not melt into its ground: {ratio:.2}");
374 }
375
376 #[test]
377 fn draws_without_brackets() {
378 let h = Harness::new(Demo::default(), 24, 1);
379 assert_eq!(h.screen(), " ⏎ Save Wait\n");
380 let theme = h.env().theme();
381 assert_ne!(h.bg(6, 0), theme.color("accent"), "a resting primary button is a tint, not the full fill");
382 assert_eq!(h.fg(6, 0), theme.color("accent"));
383 }
384
385 fn brightness(color: Option<crate::color::Rgb>) -> u32 {
387 color.map_or(0, |c| u32::from(c.r) + u32::from(c.g) + u32::from(c.b))
388 }
389
390 #[test]
391 fn tones_climb_from_rest_to_hover_to_press_and_never_invert() {
392 let mut h = Harness::new(Demo::default(), 24, 1);
393 let rest = h.bg(6, 0);
394 h.hover(6, 0);
395 let hover = h.bg(6, 0);
396 h.mouse(MouseKind::Down(MouseButton::Left), 6, 0).mouse(MouseKind::Up(MouseButton::Left), 6, 0);
397 let pressed = h.bg(6, 0);
398 assert!(brightness(rest) < brightness(hover) && brightness(hover) < brightness(pressed));
399 assert_eq!(h.fg(6, 0), h.env().theme().color("accent"), "the label keeps its colour while pressed");
400 h.advance(Duration::from_millis(200));
401 assert_eq!(h.bg(6, 0), hover, "a clicked button settles back to its hover tone, calm under the pointer");
402 }
403
404 #[test]
405 fn choice_buttons_show_selection_and_do_not_flash() {
406 struct Modes(usize);
407 impl App for Modes {
408 type Msg = usize;
409 fn update(&mut self, mode: usize) -> Command<usize> {
410 self.0 = mode;
411 Command::none()
412 }
413 fn view(&self, ui: &mut View<'_, usize>) {
414 ui.row(|ui| {
415 ui.add(Button::new("List").selected(self.0 == 0).on_press(0));
416 ui.add(Button::new("Grid").selected(self.0 == 1).on_press(1));
417 });
418 }
419 }
420 let mut h = Harness::new(Modes(0), 20, 1);
421 assert!(h.screen().starts_with("▌ List"), "the chosen one carries a steady pillar: {}", h.screen());
422 h.click_text("Grid");
423 assert_eq!(h.app().0, 1);
424 let chosen = h.bg(12, 0);
425 h.advance(Duration::from_millis(200));
426 assert_eq!(h.bg(12, 0), chosen, "no flash: the selection itself is the feedback");
427 }
428
429 #[test]
430 fn presses_by_keyboard_and_click_and_flashes() {
431 let mut h = Harness::new(Demo::default(), 24, 1);
432 h.press("tab");
433 assert!(h.is_focused("save"));
434 let focused = h.bg(6, 0);
435 h.press("enter");
436 assert_eq!(h.app().presses, 1);
437 let flash = h.bg(6, 0);
438 assert!(brightness(flash) > brightness(focused), "a press flashes one tone brighter");
439 h.advance(Duration::from_millis(200));
440 assert_eq!(h.bg(6, 0), focused);
441 h.click_text("Save");
442 assert_eq!(h.app().presses, 2);
443 }
444
445 #[test]
446 fn hover_and_focus_raise_the_pillar_without_sliding() {
447 let mut h = Harness::new(Demo::default(), 24, 1);
448 assert_eq!(h.screen(), " ⏎ Save Wait\n");
449 h.hover(15, 0);
450 assert_eq!(h.screen(), " ⏎ Save ▌ Wait\n", "the pillar rises; the label stays where it is");
451 h.hover(23, 0);
452 h.press("tab");
453 assert_eq!(h.screen(), "▌ ⏎ Save Wait\n", "the pillar comes before the shortcut segment");
454 let theme = h.env().theme();
455 assert_ne!(h.fg(0, 0), theme.color("ink"), "the pillar stays bright on the tint");
456 assert_eq!(h.bg(0, 0), h.bg(2, 0), "the pillar cell belongs to the shortcut segment");
457 h.set_reduced_motion(true);
458 assert_eq!(h.screen(), "▌ ⏎ Save Wait\n");
459 }
460
461 struct Marked;
463
464 impl App for Marked {
465 type Msg = ();
466 fn update(&mut self, _: ()) -> Command<()> {
467 Command::none()
468 }
469 fn view(&self, ui: &mut View<'_, ()>) {
470 ui.column(|ui| {
471 ui.add(Button::new("Open palette").shortcut("ctrl p").on_press(()));
472 ui.add(Button::new("Deploy").icon("check").on_press(()));
473 ui.add(Button::new("Save").icon("check").shortcut("⏎").variant("primary").on_press(()));
474 });
475 }
476 }
477
478 #[test]
479 fn the_pillar_is_the_leftmost_cell_with_a_shortcut_an_icon_or_both() {
480 let mut h = Harness::new(Marked, 30, 3);
481 h.set_glyph_mode(crate::icons::GlyphMode::Unicode);
482 let rest = h.screen();
483 assert_eq!(rest, " ctrl p Open palette\n ✓ Deploy\n ⏎ ✓ Save\n");
484 for (row, hovered) in ["▌ ctrl p Open palette", "▌ ✓ Deploy", "▌ ⏎ ✓ Save"].into_iter().enumerate()
485 {
486 let y = i32::try_from(row).unwrap_or(0);
487 h.hover(3, y);
488 let line = h.screen().lines().nth(row).unwrap_or_default().to_owned();
489 assert_eq!(line, hovered, "hover draws the pillar first");
490 for (x, (lit, calm)) in line.chars().zip(rest.lines().nth(row).unwrap_or_default().chars()).enumerate() {
491 assert!(x == 0 || lit == calm, "only the first cell changes: {line:?}");
492 }
493 }
494 h.hover(29, 2).press("tab");
495 assert!(h.screen().starts_with("▌ ctrl p Open palette"), "keyboard focus too: {}", h.screen());
496 }
497
498 #[test]
499 fn a_theme_without_padding_draws_no_pillar_and_no_pillar_cell() {
500 let dir = std::env::temp_dir().join(format!("quvyta-button-flat-{}", std::process::id()));
501 std::fs::create_dir_all(&dir).expect("temp dir");
502 let theme = "[meta]\nname = \"Flat\"\nextends = \"monochrome\"\n[style.button]\npadding = [0, 0]\n";
503 std::fs::write(dir.join("flat.toml"), theme).expect("theme file");
504 let dirs = crate::env::AssetDirs { themes: Some(dir.clone()), ..Default::default() };
505 let env = crate::env::Env::load(&dirs).expect("loads");
506 let mut h = Harness::with_env(Marked, env, 24, 3);
507 h.set_glyph_mode(crate::icons::GlyphMode::Unicode).set_theme("flat");
508 h.hover(1, 0);
509 assert_eq!(h.screen(), " ctrl p Open palette\n✓ Deploy\n ⏎ ✓ Save\n");
510 std::fs::remove_dir_all(dir).ok();
511 }
512
513 struct Job(bool);
515
516 impl App for Job {
517 type Msg = ();
518 fn update(&mut self, _: ()) -> Command<()> {
519 Command::none()
520 }
521 fn view(&self, ui: &mut View<'_, ()>) {
522 ui.add(Button::new("Count").icon("folder").loading(self.0).on_press(()));
523 }
524 }
525
526 #[test]
527 fn a_short_loading_state_keeps_the_raised_look() {
528 let mut idle = Harness::new(Job(false), 20, 1);
529 let mut busy = Harness::new(Job(true), 20, 1);
530 idle.hover(4, 0);
531 busy.hover(4, 0);
532 busy.set_glyph_mode(crate::icons::GlyphMode::Unicode);
533 assert!(busy.screen().contains("◜ Count"), "the default spinner turns in place of the icon: {}", busy.screen());
534 let label = |h: &Harness<Job>| h.find("Count");
535 assert_eq!(label(&idle), label(&busy), "the label does not jump while a quick job runs");
536 assert_eq!(idle.screen().starts_with('▌'), busy.screen().starts_with('▌'));
537 assert_eq!(idle.bg(10, 0), busy.bg(10, 0), "the surface keeps its hover tone");
538 busy.press("enter");
539 }
540
541 #[test]
542 fn held_enter_does_not_repeat() {
543 let mut h = Harness::new(Demo::default(), 24, 1);
544 h.press("tab").press("enter");
545 let event = crate::event::Event::Key(crate::event::KeyEvent::press("enter"));
546 let now = Duration::from_millis(330);
547 h.inject(event.clone(), now);
548 h.inject(event, now + Duration::from_millis(30));
549 assert_eq!(h.app().presses, 1);
550 }
551
552 #[test]
553 fn disabled_and_loading_ignore_presses() {
554 let mut h = Harness::new(Demo { disabled: true, loading: true, ..Demo::default() }, 24, 1);
555 h.press("tab").press("enter");
556 assert_eq!(h.app().presses, 0);
557 let theme = h.env().theme();
558 assert_eq!(h.fg(6, 0), theme.color("muted"));
559 }
560
561 #[derive(Default)]
563 struct TabStrip {
564 heard: Vec<&'static str>,
565 }
566
567 impl App for TabStrip {
568 type Msg = &'static str;
569 fn update(&mut self, heard: &'static str) -> Command<&'static str> {
570 self.heard.push(heard);
571 Command::none()
572 }
573 fn view(&self, ui: &mut View<'_, &'static str>) {
574 ui.row(|ui| {
575 ui.add(Button::new("docs.rs").on_press("open docs").on_middle_press("close docs"));
576 ui.add(Button::new("crates.io").on_press("open crates"));
577 })
578 .gap(1);
579 }
580 }
581
582 #[test]
583 fn a_middle_click_sends_the_middle_message_and_a_left_click_the_usual_one() {
584 let mut h = Harness::new(TabStrip::default(), 30, 1);
585 let (x, y) = h.find("docs.rs").expect("the tab");
586 let cell = (u16::try_from(x).unwrap_or(0), u16::try_from(y).unwrap_or(0));
587 let hovered = h.hover(x, y).bg(cell.0, cell.1);
588 h.mouse(MouseKind::Down(MouseButton::Middle), x, y);
589 assert!(h.app().heard.is_empty(), "nothing before the release");
590 h.mouse(MouseKind::Up(MouseButton::Middle), x, y);
591 assert_eq!(h.app().heard, ["close docs"]);
592 assert!(brightness(h.bg(cell.0, cell.1)) > brightness(hovered), "a middle press flashes like any press");
593 h.advance(Duration::from_millis(300)).click(x, y);
594 assert_eq!(h.app().heard, ["close docs", "open docs"], "the left button still opens");
595 h.mouse(MouseKind::Down(MouseButton::Middle), x, y).mouse(MouseKind::Up(MouseButton::Middle), 29, 0);
596 assert_eq!(h.app().heard.len(), 2, "releasing elsewhere cancels");
597 }
598
599 #[test]
600 fn a_button_without_a_middle_message_ignores_the_middle_button() {
601 let mut h = Harness::new(TabStrip::default(), 30, 1);
602 let (x, y) = h.find("crates.io").expect("the button");
603 let cell = (u16::try_from(x).unwrap_or(0), u16::try_from(y).unwrap_or(0));
604 let hovered = h.hover(x, y).bg(cell.0, cell.1);
605 h.mouse(MouseKind::Down(MouseButton::Middle), x, y).mouse(MouseKind::Up(MouseButton::Middle), x, y);
606 assert!(h.app().heard.is_empty(), "{:?}", h.app().heard);
607 assert_eq!(h.bg(cell.0, cell.1), hovered, "and does not flash");
608 h.click(x, y);
609 assert_eq!(h.app().heard, ["open crates"]);
610 }
611}