1use std::rc::Rc;
2
3use sequoia_core::{Color, Edges, Length};
4use sequoia_layout::LayoutStyle;
5use sequoia_reactive::Environment;
6use sequoia_style::ButtonStyle;
7use sequoia_text::FontWeight;
8use sequoia_view::{AnimSpec, AnyView, Column, Icon, Text, View};
9
10use crate::container::{ClickTrigger, Container};
11
12const PAD_X: f32 = 10.0;
13const PAD_Y: f32 = 5.0;
14const ICON_DEFAULT_PX: f32 = 16.0;
15const RIBBON_ICON_PX: f32 = 28.0;
16const RIBBON_GAP: f32 = 2.0;
17
18type EnvAction = Rc<dyn Fn(&Environment)>;
19
20struct IconSpec {
21 name: String,
22 px: f32,
23 color: Option<Color>,
24 label: Option<String>,
25}
26
27#[derive(Clone)]
28pub struct Button {
29 label: String,
30 icon: Option<IconSpec>,
31 style: Option<ButtonStyle>,
32 background: Option<Color>,
33 hover: Option<Color>,
34 pressed: Option<Color>,
35 disabled_background: Option<Color>,
36 text_color: Option<Color>,
37 text_disabled: Option<Color>,
38 font_size: Option<f32>,
39 font_weight: Option<u16>,
40 radius: Option<f32>,
41 padding: Option<(f32, f32)>,
42 border_width: Option<f32>,
43 border_color: Option<Color>,
44 disabled: bool,
45 hover_anim: Option<AnimSpec>,
46 press_anim: Option<AnimSpec>,
47
48 instant: bool,
49 on_click: Option<Rc<dyn Fn()>>,
50 on_click_env: Option<EnvAction>,
51 margin: Edges,
52 min_width: Option<f32>,
53 max_width: Option<f32>,
54 min_height: Option<f32>,
55 max_height: Option<f32>,
56
57 width: Option<f32>,
58 height: Option<f32>,
59
60 trigger: ClickTrigger,
61}
62
63pub const BUTTON_BLEND_RATE: f32 = 12.0;
64
65impl Clone for IconSpec {
66 fn clone(&self) -> Self {
67 Self {
68 name: self.name.clone(),
69 px: self.px,
70 color: self.color,
71 label: self.label.clone(),
72 }
73 }
74}
75
76impl Button {
77 pub fn labeled(label: impl Into<String>) -> Self {
78 Self {
79 label: label.into(),
80 icon: None,
81 style: None,
82 background: None,
83 hover: None,
84 pressed: None,
85 disabled_background: None,
86 text_color: None,
87 text_disabled: None,
88 font_size: None,
89 font_weight: None,
90 radius: None,
91 padding: None,
92 border_width: None,
93 border_color: None,
94 disabled: false,
95 hover_anim: None,
96 press_anim: None,
97 instant: false,
98 on_click: None,
99 on_click_env: None,
100 margin: Edges::ZERO,
101 min_width: None,
102 max_width: None,
103 min_height: None,
104 max_height: None,
105 width: None,
106 height: None,
107 trigger: ClickTrigger::default(),
108 }
109 }
110
111 pub fn new(label: impl Into<String>) -> Self {
112 Self::labeled(label)
113 }
114
115 pub fn icon(name: impl Into<String>) -> Self {
116 let mut b = Self::labeled("");
117 b.icon = Some(IconSpec {
118 name: name.into(),
119 px: ICON_DEFAULT_PX,
120 color: None,
121 label: None,
122 });
123 b.padding = Some((4.0, 4.0));
124 b
125 }
126
127 pub fn ribbon(icon: impl Into<String>, label: impl Into<String>) -> Self {
128 let mut b = Self::labeled("");
129 b.icon = Some(IconSpec {
130 name: icon.into(),
131 px: RIBBON_ICON_PX,
132 color: None,
133 label: Some(label.into()),
134 });
135 b.padding = Some((7.0, 7.0));
136 b
137 }
138}
139
140impl Button {
141 pub fn on_click(mut self, on_click: impl Fn() + 'static) -> Self {
142 self.on_click = Some(Rc::new(on_click));
143 self
144 }
145
146 pub fn on_press(mut self, on_press: impl Fn() + 'static) -> Self {
147 self.on_click = Some(Rc::new(on_press));
148 self.trigger = ClickTrigger::Press;
149 self
150 }
151
152 pub fn trigger(mut self, trigger: ClickTrigger) -> Self {
153 self.trigger = trigger;
154 self
155 }
156
157 pub fn on_click_env(mut self, on_click: impl Fn(&Environment) + 'static) -> Self {
158 self.on_click_env = Some(Rc::new(on_click));
159 self
160 }
161
162 pub fn style(mut self, style: ButtonStyle) -> Self {
163 self.style = Some(style);
164 self
165 }
166
167 pub fn style_with(mut self, build: impl FnOnce(ButtonStyle) -> ButtonStyle) -> Self {
168 self.style = Some(build(self.style.unwrap_or_default()));
169 self
170 }
171
172 pub fn hover_anim(mut self, spec: impl Into<AnimSpec>) -> Self {
173 self.hover_anim = Some(spec.into());
174 self
175 }
176
177 pub fn press_anim(mut self, spec: impl Into<AnimSpec>) -> Self {
178 self.press_anim = Some(spec.into());
179 self
180 }
181
182 pub fn no_animation(mut self) -> Self {
183 self.instant = true;
184 self
185 }
186
187 pub fn padding(mut self, pad_x: f32, pad_y: f32) -> Self {
188 self.padding = Some((pad_x, pad_y));
189 self
190 }
191
192 pub fn color(mut self, color: Color) -> Self {
193 self.text_color = Some(color);
194 self
195 }
196
197 pub fn background(mut self, color: Color) -> Self {
198 self.background = Some(color);
199 self
200 }
201
202 pub fn hover(mut self, color: Color) -> Self {
203 self.hover = Some(color);
204 self
205 }
206
207 pub fn pressed(mut self, color: Color) -> Self {
208 self.pressed = Some(color);
209 self
210 }
211
212 pub fn disabled_background(mut self, color: Color) -> Self {
213 self.disabled_background = Some(color);
214 self
215 }
216
217 pub fn text_color(mut self, color: Color) -> Self {
218 self.text_color = Some(color);
219 self
220 }
221
222 pub fn text_disabled(mut self, color: Color) -> Self {
223 self.text_disabled = Some(color);
224 self
225 }
226
227 pub fn font_size(mut self, size: f32) -> Self {
228 self.font_size = Some(size);
229 self
230 }
231
232 pub fn font_weight(mut self, weight: u16) -> Self {
233 self.font_weight = Some(weight);
234 self
235 }
236
237 pub fn radius(mut self, radius: f32) -> Self {
238 self.radius = Some(radius);
239 self
240 }
241
242 pub fn padding_xy(mut self, pad_x: f32, pad_y: f32) -> Self {
243 self.padding = Some((pad_x, pad_y));
244 self
245 }
246
247 pub fn icon_size(mut self, px: f32) -> Self {
248 if let Some(i) = &mut self.icon {
249 i.px = px.max(0.0);
250 }
251 self
252 }
253
254 pub fn icon_color(mut self, color: Color) -> Self {
255 if let Some(i) = &mut self.icon {
256 i.color = Some(color);
257 }
258 self
259 }
260
261 pub fn label(mut self, label: impl Into<String>) -> Self {
262 match &mut self.icon {
263 Some(i) => i.label = Some(label.into()),
264 None => self.label = label.into(),
265 }
266 self
267 }
268
269 pub fn border_width(mut self, width: f32) -> Self {
270 self.border_width = Some(width);
271 self
272 }
273
274 pub fn border_color(mut self, color: Color) -> Self {
275 self.border_color = Some(color);
276 self
277 }
278
279 pub fn border(mut self, width: f32, color: Color) -> Self {
280 self.border_width = Some(width);
281 self.border_color = Some(color);
282 self
283 }
284
285 pub fn disabled(mut self, disabled: bool) -> Self {
286 self.disabled = disabled;
287 self
288 }
289
290 pub fn min_width(mut self, v: f32) -> Self {
291 self.min_width = Some(v.max(0.0));
292 self
293 }
294
295 pub fn max_width(mut self, v: f32) -> Self {
296 self.max_width = Some(v.max(0.0));
297 self
298 }
299
300 pub fn min_height(mut self, v: f32) -> Self {
301 self.min_height = Some(v.max(0.0));
302 self
303 }
304
305 pub fn max_height(mut self, v: f32) -> Self {
306 self.max_height = Some(v.max(0.0));
307 self
308 }
309
310 pub fn width(mut self, px: f32) -> Self {
311 self.width = Some(px.max(0.0));
312 self
313 }
314
315 pub fn height(mut self, px: f32) -> Self {
316 self.height = Some(px.max(0.0));
317 self
318 }
319
320 pub fn size(mut self, px: f32) -> Self {
321 let px = px.max(0.0);
322 self.width = Some(px);
323 self.height = Some(px);
324 self
325 }
326
327 pub fn margin(mut self, margin: impl Into<Edges>) -> Self {
328 let m = margin.into();
329 self.margin = Edges::only(
330 sanitize_margin(m.left),
331 sanitize_margin(m.top),
332 sanitize_margin(m.right),
333 sanitize_margin(m.bottom),
334 );
335 self
336 }
337
338 pub fn margin_xy(self, x: f32, y: f32) -> Self {
339 self.margin(Edges::xy(x, y))
340 }
341
342 pub fn margin_x(mut self, x: f32) -> Self {
343 let x = sanitize_margin(x);
344 self.margin.left = x;
345 self.margin.right = x;
346 self
347 }
348
349 pub fn margin_y(mut self, y: f32) -> Self {
350 let y = sanitize_margin(y);
351 self.margin.top = y;
352 self.margin.bottom = y;
353 self
354 }
355
356 pub fn margin_left(mut self, v: f32) -> Self {
357 self.margin.left = sanitize_margin(v);
358 self
359 }
360
361 pub fn margin_right(mut self, v: f32) -> Self {
362 self.margin.right = sanitize_margin(v);
363 self
364 }
365
366 pub fn margin_top(mut self, v: f32) -> Self {
367 self.margin.top = sanitize_margin(v);
368 self
369 }
370
371 pub fn margin_bottom(mut self, v: f32) -> Self {
372 self.margin.bottom = sanitize_margin(v);
373 self
374 }
375}
376
377fn sanitize_margin(v: f32) -> f32 {
378 if v.is_finite() { v.max(0.0) } else { 0.0 }
379}
380
381impl Button {
382 fn merged_padding(&self) -> (f32, f32) {
383 self.padding
384 .or_else(|| self.style.and_then(|s| s.padding))
385 .unwrap_or((PAD_X, PAD_Y))
386 }
387
388 fn text_tint(&self) -> Option<Color> {
389 let style = self.style.unwrap_or_default();
390 if self.disabled {
391 self.text_disabled.or(style.text_disabled)
392 } else {
393 self.text_color.or(style.text)
394 }
395 }
396
397 fn label_view(&self, text: &str) -> Text {
398 let style = self.style.unwrap_or_default();
399
400 let mut t = Text::new(text.to_owned()).hit_testable(false);
401 if let Some(size) = self.font_size.or(style.font_size) {
402 t = t.size(size);
403 }
404 if let Some(color) = self.text_tint() {
405 t = t.color(color);
406 }
407 if let Some(w) = self.font_weight.or(style.font_weight) {
408 t = t.weight(FontWeight::new(w));
409 }
410 t
411 }
412
413 fn icon_view(&self, icon: &IconSpec, tint: Option<Color>) -> Icon {
414 let mut v = Icon::new(icon.name.clone())
415 .size(icon.px.max(1.0))
416 .hit_testable(false);
417 if let Some(c) = icon.color.or(tint) {
418 v = v.color(c);
419 }
420 v
421 }
422
423 fn content_view(&self) -> AnyView {
424 let tint = self.text_tint();
425 match &self.icon {
426 None => AnyView::new(self.label_view(&self.label)),
427 Some(icon) if icon.label.is_none() => AnyView::new(self.icon_view(icon, tint)),
428 Some(icon) => {
429 let label = icon.label.clone().unwrap_or_default();
430 AnyView::new(
431 Column::new()
432 .center()
433 .main_gap(RIBBON_GAP)
434 .hit_testable(false)
435 .child(self.icon_view(icon, tint))
436 .child(self.label_view(&label)),
437 )
438 }
439 }
440 }
441
442 fn composed(&self) -> Container {
443 let style = self.style.unwrap_or_default();
444 let (pad_x, pad_y) = self.merged_padding();
445
446 let rate = if self.instant {
447 0.0
448 } else {
449 self.hover_anim
450 .or(self.press_anim)
451 .map(|s| (4.6 / s.duration_secs.max(0.001)).clamp(4.0, 60.0))
452 .unwrap_or(0.0)
453 };
454
455 let mut p = Container::new()
456 .child(self.content_view())
457 .padding_xy(pad_x, pad_y)
458 .disabled(self.disabled)
459 .trigger(self.trigger)
460 .blend_rate(rate)
461 .background(
462 self.background
463 .or(style.background)
464 .unwrap_or(Color::hex(0x2A2A2A)),
465 )
466 .hover(self.hover.or(style.hover).unwrap_or(Color::hex(0x323232)))
467 .pressed(
468 self.pressed
469 .or(style.pressed)
470 .unwrap_or(Color::hex(0x3A3A3A)),
471 );
472 if let Some(v) = self.width {
473 p = p.width(v);
474 }
475 if let Some(v) = self.height {
476 p = p.height(v);
477 }
478 if let Some(v) = self.min_width {
479 p = p.min_width(v);
480 }
481 if let Some(v) = self.max_width {
482 p = p.max_width(v);
483 }
484 if let Some(v) = self.min_height {
485 p = p.min_height(v);
486 }
487 if let Some(v) = self.max_height {
488 p = p.max_height(v);
489 }
490 if let Some(c) = self.disabled_background.or(style.disabled) {
491 p = p.disabled_background(c);
492 }
493 if let Some(r) = self.radius.or(style.radius) {
494 p = p.radius(r);
495 }
496 let bw = self
497 .border_width
498 .or(style.border_width)
499 .unwrap_or(0.0)
500 .max(0.0);
501 let bc = self
502 .border_color
503 .or(style.border_color)
504 .unwrap_or(Color::TRANSPARENT);
505 if bw > 0.0 && bc.a > 0.0 {
506 p = p.border(bw, bc);
507 }
508 if let Some(f) = &self.on_click {
509 let f = Rc::clone(f);
510 p = p.on_click(move || f());
511 }
512 if let Some(f) = &self.on_click_env {
513 let f = Rc::clone(f);
514 p = p.on_click_env(move |env| f(env));
515 }
516 if self.on_click.is_none() && self.on_click_env.is_none() {
517 p = p.on_click(|| {});
518 }
519 p
520 }
521}
522
523impl View for Button {
524 fn body(&self) -> impl View + 'static {
525 self.composed()
526 }
527
528 fn accessibility(&self) -> Option<sequoia_view::Accessible> {
529 Some(sequoia_view::Accessible::new(
530 sequoia_view::AccessibleRole::Button,
531 self.label.clone(),
532 ))
533 }
534
535 fn layout_style(&self) -> LayoutStyle {
536 let mut style = LayoutStyle {
537 width: Length::Fit,
538 height: Length::Fit,
539 margin: self.margin,
540 ..LayoutStyle::default()
541 };
542 if let Some(v) = self.min_width {
543 style = style.min_width(v);
544 }
545 if let Some(v) = self.max_width {
546 style = style.max_width(v);
547 }
548 if let Some(v) = self.min_height {
549 style = style.min_height(v);
550 }
551 if let Some(v) = self.max_height {
552 style = style.max_height(v);
553 }
554 if let Some(v) = self.width {
555 style = style.width(Length::Px(v));
556 }
557 if let Some(v) = self.height {
558 style = style.height(Length::Px(v));
559 }
560 style
561 }
562
563 fn hit_testable(&self) -> bool {
564 false
565 }
566}
567
568#[cfg(test)]
569mod tests {
570 use super::*;
571 use sequoia_view::live::{exp_blend, exp_settled};
572
573 #[test]
574 fn background_rate_gives_snappy_but_smooth_blend() {
575 let first = exp_blend(Color::BLACK, Color::WHITE, 1.0 / 60.0, BUTTON_BLEND_RATE);
576 assert!(first.r > 0.1, "first frame starts clearly: {}", first.r);
577 assert!(first.r < 1.0, "nie dobiega od razu");
578 let mut cur = Color::BLACK;
579 for _ in 0..120 {
580 cur = exp_blend(cur, Color::WHITE, 1.0 / 60.0, BUTTON_BLEND_RATE);
581 }
582 assert!(
583 exp_settled(cur, Color::WHITE),
584 "120 klatek dobiega: {cur:?}"
585 );
586 }
587
588 #[test]
589 fn background_blend_handles_degenerate_input() {
590 let a = Color::RED;
591
592 assert_eq!(exp_blend(a, Color::BLUE, -1.0, BUTTON_BLEND_RATE), a);
593 assert_eq!(exp_blend(a, Color::BLUE, 0.016, 0.0), a);
594 assert!(!exp_settled(a, Color::BLUE));
595 assert!(exp_settled(a, a));
596 }
597
598 #[test]
599 fn app_api_builders_compose() {
600 let b = Button::new("Save")
601 .on_click(|| {})
602 .style_with(|s| s.background(Color::BLUE).radius(6.0))
603 .background(Color::BLUE)
604 .hover(Color::WHITE)
605 .hover_anim(AnimSpec::SNAPPY)
606 .press_anim(AnimSpec::GENTLE)
607 .padding(8.0, 14.0)
608 .color(Color::WHITE)
609 .radius(6.0);
610 assert!(b.hover_anim.is_some());
611 assert!(b.press_anim.is_some());
612 assert_eq!(b.style.unwrap().radius, Some(6.0));
613
614 let _ = View::body(&b);
615 }
616
617 #[test]
618 fn anim_spec_maps_to_faster_blend_rate() {
619 let fast = Button::new("x").hover_anim(AnimSpec::SNAPPY).composed();
620 let slow = Button::new("x").hover_anim(AnimSpec::GENTLE).composed();
621 let plain = Button::new("x").composed();
622
623 let _ = (fast, slow, plain);
624 let rate = |s: AnimSpec| (4.6 / s.duration_secs.max(0.001)).clamp(4.0, 60.0);
625 assert!(rate(AnimSpec::SNAPPY) > rate(AnimSpec::GENTLE));
626 assert!(rate(AnimSpec::SNAPPY) > BUTTON_BLEND_RATE);
627 }
628
629 #[test]
630 fn default_button_has_no_blend_but_preset_animates() {
631 let plain = Button::new("x").composed();
632 assert!(plain.effective_blend_rate() <= 0.0);
633
634 let instant = Button::new("x").no_animation().composed();
635 assert!(instant.effective_blend_rate() <= 0.0);
636
637 let snappy = Button::new("x").hover_anim(AnimSpec::SNAPPY).composed();
638 assert!(snappy.effective_blend_rate() > 0.0);
639
640 let over = Button::new("x")
641 .hover_anim(AnimSpec::SNAPPY)
642 .no_animation()
643 .composed();
644 assert!(over.effective_blend_rate() <= 0.0);
645 }
646
647 #[test]
648 fn size_sets_both_dimensions_width_height_singly() {
649 use sequoia_core::Length;
650
651 let fit = Button::new("x").layout_style();
652 assert_eq!(fit.width, Length::Fit);
653 assert_eq!(fit.height, Length::Fit);
654
655 let sq = Button::new("x").size(4.0).layout_style();
656 assert_eq!(sq.width, Length::Px(4.0));
657 assert_eq!(sq.height, Length::Px(4.0));
658
659 let w = Button::new("x").width(20.0).layout_style();
660 assert_eq!(w.width, Length::Px(20.0));
661 assert_eq!(w.height, Length::Fit);
662 let h = Button::new("x").height(12.0).layout_style();
663 assert_eq!(h.height, Length::Px(12.0));
664 assert_eq!(h.width, Length::Fit);
665
666 let z = Button::new("x").size(-5.0).layout_style();
667 assert_eq!(z.width, Length::Px(0.0));
668 assert_eq!(z.height, Length::Px(0.0));
669 }
670}