1use super::cells;
4use super::press::{self, Press};
5use crate::event::Event;
6use crate::geometry::{Rect, Size};
7use crate::text;
8use crate::theme::State;
9use crate::widget::{EventCx, MeasureCx, PaintCx, Widget};
10
11pub struct Button<Msg> {
21 label: String,
22 icon: Option<String>,
23 shortcut: Option<String>,
24 variant: Option<String>,
25 disabled: bool,
26 loading: bool,
27 selected: Option<bool>,
28 on_press: Option<Msg>,
29}
30
31impl<Msg> Button<Msg> {
32 #[must_use]
34 pub fn new(label: impl Into<String>) -> Self {
35 Self {
36 label: label.into(),
37 icon: None,
38 shortcut: None,
39 variant: None,
40 disabled: false,
41 loading: false,
42 selected: None,
43 on_press: None,
44 }
45 }
46
47 #[must_use]
49 pub fn on_press(mut self, message: Msg) -> Self {
50 self.on_press = Some(message);
51 self
52 }
53
54 #[must_use]
56 pub fn variant(mut self, variant: impl Into<String>) -> Self {
57 self.variant = Some(variant.into());
58 self
59 }
60
61 #[must_use]
63 pub fn icon(mut self, key: impl Into<String>) -> Self {
64 self.icon = Some(key.into());
65 self
66 }
67
68 #[must_use]
71 pub fn shortcut(mut self, label: impl Into<String>) -> Self {
72 self.shortcut = Some(label.into());
73 self
74 }
75
76 #[must_use]
78 pub fn disabled(mut self, disabled: bool) -> Self {
79 self.disabled = disabled;
80 self
81 }
82
83 #[must_use]
85 pub fn loading(mut self, loading: bool) -> Self {
86 self.loading = loading;
87 self
88 }
89
90 #[must_use]
94 pub fn selected(mut self, selected: bool) -> Self {
95 self.selected = Some(selected);
96 self
97 }
98
99 fn active(&self) -> bool {
100 !self.disabled && !self.loading && self.on_press.is_some()
101 }
102}
103
104fn key_width(key: &str, horizontal_padding: u16) -> u16 {
107 cells::sum([u16::from(horizontal_padding >= 1), text::width(key), 2])
108}
109
110impl<Msg: Clone + 'static> Button<Msg> {
111 fn press(&self, cx: &mut EventCx<'_, Msg>) {
112 if let Some(message) = &self.on_press {
113 if self.selected.is_none() {
114 cx.flash();
115 }
116 cx.emit(message.clone());
117 }
118 }
119}
120
121impl<Msg: Clone + 'static> Widget<Msg> for Button<Msg> {
122 fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
123 let theme = cx.env().theme();
124 let style = theme.style("button", self.variant.as_deref(), &[]);
125 let (vertical, horizontal) = style.pair("padding").unwrap_or((0, 2));
126 let icon_width = self
127 .icon
128 .as_deref()
129 .map_or(0, |key| text::width(&cx.env().icons().glyph(key)) + 1)
130 .max(if self.loading { 2 } else { 0 });
131 let shortcut_width = self.shortcut.as_deref().map_or(0, |key| key_width(key, horizontal));
132 let width = cells::sum([shortcut_width, horizontal.saturating_mul(2), icon_width, text::width(&self.label)]);
133 Size::new(width, vertical.saturating_mul(2).saturating_add(1)).min(available)
134 }
135
136 fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
137 let interactive = !self.disabled && self.on_press.is_some();
140 let mut states = if interactive { cx.pressable_states() } else { Vec::new() };
143 if self.selected == Some(true) {
144 states.push(State::Selected);
145 }
146 if self.disabled {
147 states.push(State::Disabled);
148 }
149 let variant = self.variant.as_deref();
150 let style = cx.style("button", variant, &states);
151 let surface = style.text();
152 let background = surface.bg.unwrap_or_else(|| cx.color("raised"));
153 cx.clear(area, background);
154 if interactive {
155 cx.register_hit(area);
156 }
157
158 let padding = style.padding();
159 let y = area.y + i32::from(padding.top);
160 let mut x = area.x;
161 let shortcut_width = self.shortcut.as_deref().map_or(0, |key| key_width(key, padding.left));
162 if let Some(shortcut) = &self.shortcut {
163 let key_style = cx.style("button-key", variant, &states).text();
164 cx.clear(Rect::new(x, area.y, shortcut_width, area.height), key_style.bg.unwrap_or(background));
165 let label = format!(" {shortcut} ");
166 let label_width = text::width(&label);
167 cx.text(x + i32::from(shortcut_width - label_width), y, &label, key_style, label_width);
168 }
169 let pillar = style.color("pillar").filter(|_| padding.left >= 1);
173 if let Some(color) = pillar {
174 cx.pillar(x, y, color);
175 }
176 x += i32::from(shortcut_width);
177 x += i32::from(padding.left);
178 let right = area.right() - i32::from(padding.right);
179 let budget = |x: i32| crate::geometry::clamp_u16(right - x);
180
181 if self.loading {
182 let spinner = super::SpinnerStyle::default().animation();
184 let cell = cx.animation(spinner, surface, Some(std::time::Duration::ZERO));
185 x += i32::from(cx.text(x, y, &cell.glyph, cell.style, budget(x).min(1)));
186 x += 1;
187 } else if let Some(icon) = &self.icon {
188 let glyph = cx.env().icons().glyph(icon).into_owned();
189 x += i32::from(cx.text(x, y, &glyph, surface, budget(x)));
190 x += 1;
191 }
192 let label = text::truncate(&self.label, budget(x)).into_owned();
193 cx.text(x, y, &label, surface, budget(x));
194 }
195
196 fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
197 if !self.active() {
198 return false;
199 }
200 match press::read(cx, event) {
201 Press::Ignored => false,
202 Press::Used => true,
203 Press::Key | Press::Click(..) => {
204 self.press(cx);
205 true
206 }
207 }
208 }
209
210 fn focusable(&self) -> bool {
211 self.active()
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218 use crate::event::{MouseButton, MouseKind};
219 use crate::runtime::{App, Command, Harness};
220 use crate::widget::View;
221 use std::time::Duration;
222
223 #[derive(Default)]
224 struct Demo {
225 presses: u32,
226 disabled: bool,
227 loading: bool,
228 }
229
230 impl App for Demo {
231 type Msg = ();
232 fn update(&mut self, _: ()) -> Command<()> {
233 self.presses += 1;
234 Command::none()
235 }
236 fn view(&self, ui: &mut View<'_, ()>) {
237 ui.row(|ui| {
238 ui.add(Button::new("Save").variant("primary").shortcut("⏎").on_press(()).disabled(self.disabled))
239 .id("save");
240 ui.add(Button::new("Wait").loading(self.loading).on_press(()));
241 })
242 .gap(1);
243 }
244 }
245
246 #[test]
247 fn draws_without_brackets() {
248 let h = Harness::new(Demo::default(), 24, 1);
249 assert_eq!(h.screen(), " ⏎ Save Wait\n");
250 let theme = h.env().theme();
251 assert_ne!(h.bg(6, 0), theme.color("accent"), "a resting primary button is a tint, not the full fill");
252 assert_eq!(h.fg(6, 0), theme.color("accent"));
253 }
254
255 fn brightness(color: Option<crate::color::Rgb>) -> u32 {
257 color.map_or(0, |c| u32::from(c.r) + u32::from(c.g) + u32::from(c.b))
258 }
259
260 #[test]
261 fn tones_climb_from_rest_to_hover_to_press_and_never_invert() {
262 let mut h = Harness::new(Demo::default(), 24, 1);
263 let rest = h.bg(6, 0);
264 h.hover(6, 0);
265 let hover = h.bg(6, 0);
266 h.mouse(MouseKind::Down(MouseButton::Left), 6, 0).mouse(MouseKind::Up(MouseButton::Left), 6, 0);
267 let pressed = h.bg(6, 0);
268 assert!(brightness(rest) < brightness(hover) && brightness(hover) < brightness(pressed));
269 assert_eq!(h.fg(6, 0), h.env().theme().color("accent"), "the label keeps its colour while pressed");
270 h.advance(Duration::from_millis(200));
271 assert_eq!(h.bg(6, 0), hover, "a clicked button settles back to its hover tone, calm under the pointer");
272 }
273
274 #[test]
275 fn choice_buttons_show_selection_and_do_not_flash() {
276 struct Modes(usize);
277 impl App for Modes {
278 type Msg = usize;
279 fn update(&mut self, mode: usize) -> Command<usize> {
280 self.0 = mode;
281 Command::none()
282 }
283 fn view(&self, ui: &mut View<'_, usize>) {
284 ui.row(|ui| {
285 ui.add(Button::new("List").selected(self.0 == 0).on_press(0));
286 ui.add(Button::new("Grid").selected(self.0 == 1).on_press(1));
287 });
288 }
289 }
290 let mut h = Harness::new(Modes(0), 20, 1);
291 assert!(h.screen().starts_with("▌ List"), "the chosen one carries a steady pillar: {}", h.screen());
292 h.click_text("Grid");
293 assert_eq!(h.app().0, 1);
294 let chosen = h.bg(12, 0);
295 h.advance(Duration::from_millis(200));
296 assert_eq!(h.bg(12, 0), chosen, "no flash: the selection itself is the feedback");
297 }
298
299 #[test]
300 fn presses_by_keyboard_and_click_and_flashes() {
301 let mut h = Harness::new(Demo::default(), 24, 1);
302 h.press("tab");
303 assert!(h.is_focused("save"));
304 let focused = h.bg(6, 0);
305 h.press("enter");
306 assert_eq!(h.app().presses, 1);
307 let flash = h.bg(6, 0);
308 assert!(brightness(flash) > brightness(focused), "a press flashes one tone brighter");
309 h.advance(Duration::from_millis(200));
310 assert_eq!(h.bg(6, 0), focused);
311 h.click_text("Save");
312 assert_eq!(h.app().presses, 2);
313 }
314
315 #[test]
316 fn hover_and_focus_raise_the_pillar_without_sliding() {
317 let mut h = Harness::new(Demo::default(), 24, 1);
318 assert_eq!(h.screen(), " ⏎ Save Wait\n");
319 h.hover(15, 0);
320 assert_eq!(h.screen(), " ⏎ Save ▌ Wait\n", "the pillar rises; the label stays where it is");
321 h.hover(23, 0);
322 h.press("tab");
323 assert_eq!(h.screen(), "▌ ⏎ Save Wait\n", "the pillar comes before the shortcut segment");
324 let theme = h.env().theme();
325 assert_ne!(h.fg(0, 0), theme.color("ink"), "the pillar stays bright on the tint");
326 assert_eq!(h.bg(0, 0), h.bg(2, 0), "the pillar cell belongs to the shortcut segment");
327 h.set_reduced_motion(true);
328 assert_eq!(h.screen(), "▌ ⏎ Save Wait\n");
329 }
330
331 struct Marked;
333
334 impl App for Marked {
335 type Msg = ();
336 fn update(&mut self, _: ()) -> Command<()> {
337 Command::none()
338 }
339 fn view(&self, ui: &mut View<'_, ()>) {
340 ui.column(|ui| {
341 ui.add(Button::new("Open palette").shortcut("ctrl p").on_press(()));
342 ui.add(Button::new("Deploy").icon("check").on_press(()));
343 ui.add(Button::new("Save").icon("check").shortcut("⏎").variant("primary").on_press(()));
344 });
345 }
346 }
347
348 #[test]
349 fn the_pillar_is_the_leftmost_cell_with_a_shortcut_an_icon_or_both() {
350 let mut h = Harness::new(Marked, 30, 3);
351 h.set_glyph_mode(crate::icons::GlyphMode::Unicode);
352 let rest = h.screen();
353 assert_eq!(rest, " ctrl p Open palette\n ✓ Deploy\n ⏎ ✓ Save\n");
354 for (row, hovered) in ["▌ ctrl p Open palette", "▌ ✓ Deploy", "▌ ⏎ ✓ Save"].into_iter().enumerate()
355 {
356 let y = i32::try_from(row).unwrap_or(0);
357 h.hover(3, y);
358 let line = h.screen().lines().nth(row).unwrap_or_default().to_owned();
359 assert_eq!(line, hovered, "hover draws the pillar first");
360 for (x, (lit, calm)) in line.chars().zip(rest.lines().nth(row).unwrap_or_default().chars()).enumerate() {
361 assert!(x == 0 || lit == calm, "only the first cell changes: {line:?}");
362 }
363 }
364 h.hover(29, 2).press("tab");
365 assert!(h.screen().starts_with("▌ ctrl p Open palette"), "keyboard focus too: {}", h.screen());
366 }
367
368 #[test]
369 fn a_theme_without_padding_draws_no_pillar_and_no_pillar_cell() {
370 let dir = std::env::temp_dir().join(format!("quvyta-button-flat-{}", std::process::id()));
371 std::fs::create_dir_all(&dir).expect("temp dir");
372 let theme = "[meta]\nname = \"Flat\"\nextends = \"monochrome\"\n[style.button]\npadding = [0, 0]\n";
373 std::fs::write(dir.join("flat.toml"), theme).expect("theme file");
374 let dirs = crate::env::AssetDirs { themes: Some(dir.clone()), ..Default::default() };
375 let env = crate::env::Env::load(&dirs).expect("loads");
376 let mut h = Harness::with_env(Marked, env, 24, 3);
377 h.set_glyph_mode(crate::icons::GlyphMode::Unicode).set_theme("flat");
378 h.hover(1, 0);
379 assert_eq!(h.screen(), " ctrl p Open palette\n✓ Deploy\n ⏎ ✓ Save\n");
380 std::fs::remove_dir_all(dir).ok();
381 }
382
383 struct Job(bool);
385
386 impl App for Job {
387 type Msg = ();
388 fn update(&mut self, _: ()) -> Command<()> {
389 Command::none()
390 }
391 fn view(&self, ui: &mut View<'_, ()>) {
392 ui.add(Button::new("Count").icon("folder").loading(self.0).on_press(()));
393 }
394 }
395
396 #[test]
397 fn a_short_loading_state_keeps_the_raised_look() {
398 let mut idle = Harness::new(Job(false), 20, 1);
399 let mut busy = Harness::new(Job(true), 20, 1);
400 idle.hover(4, 0);
401 busy.hover(4, 0);
402 busy.set_glyph_mode(crate::icons::GlyphMode::Unicode);
403 assert!(busy.screen().contains("◜ Count"), "the default spinner turns in place of the icon: {}", busy.screen());
404 let label = |h: &Harness<Job>| h.find("Count");
405 assert_eq!(label(&idle), label(&busy), "the label does not jump while a quick job runs");
406 assert_eq!(idle.screen().starts_with('▌'), busy.screen().starts_with('▌'));
407 assert_eq!(idle.bg(10, 0), busy.bg(10, 0), "the surface keeps its hover tone");
408 busy.press("enter");
409 }
410
411 #[test]
412 fn held_enter_does_not_repeat() {
413 let mut h = Harness::new(Demo::default(), 24, 1);
414 h.press("tab").press("enter");
415 let event = crate::event::Event::Key(crate::event::KeyEvent::press("enter"));
416 let now = Duration::from_millis(330);
417 h.inject(event.clone(), now);
418 h.inject(event, now + Duration::from_millis(30));
419 assert_eq!(h.app().presses, 1);
420 }
421
422 #[test]
423 fn disabled_and_loading_ignore_presses() {
424 let mut h = Harness::new(Demo { disabled: true, loading: true, ..Demo::default() }, 24, 1);
425 h.press("tab").press("enter");
426 assert_eq!(h.app().presses, 0);
427 let theme = h.env().theme();
428 assert_eq!(h.fg(6, 0), theme.color("muted"));
429 }
430}