1use egui::{
2 Align, Align2, Atom, AtomExt as _, AtomKind, AtomLayout, AtomLayoutResponse, Color32, Frame, Id,
3 Image, IntoAtoms, NumExt as _, Pos2, Rect, Response, Sense, Shadow, Shape, Stroke, TextStyle,
4 TextWrapMode, Ui, Vec2, Widget, WidgetInfo, WidgetText, WidgetType, response::Flags,
5};
6
7use crate::glow::{GlowProfile, glow_mesh_with, size_attenuation};
8use crate::theme::Theme;
9use crate::visuals::ButtonVisuals;
10use crate::widgets::badge::Badge;
11use crate::widgets::hold::{HoldBurst, HoldSettings};
12use crate::widgets::particles::Lightning;
13
14const HOLD_MAX_DT: f32 = 0.1;
16const HOLD_EDGE_WIDTH: f32 = 1.5;
18const HOLD_BURST_FLASH: f32 = 0.22;
21pub(crate) const HOLD_GLOW_START: f32 = 1.0;
24pub(crate) const HOLD_GLOW_END: f32 = 2.2;
27
28#[derive(Clone, Copy, Default)]
30struct HoldState {
31 pressing: bool,
37 progress: f32,
39 fired: bool,
41 burst_age: Option<f32>,
43}
44
45fn burst_lightning(id: Id, area: Rect, settings: &HoldSettings, color: Color32) -> Lightning {
48 let intensity = settings.burst_intensity.clamp(0.0, 1.0);
49 Lightning::new(("hold_burst", id))
50 .color(color.gamma_multiply(intensity))
51 .bolt_count(settings.burst_bolts)
52 .branch_depth(settings.burst_branch_depth)
53 .thickness(settings.burst_thickness)
54 .jitter(settings.burst_jitter)
55 .duration_ms(settings.burst_ms)
56 .flash_intensity(HOLD_BURST_FLASH * intensity)
57 .area(area)
58}
59
60fn paint_burst(
62 ui: &Ui,
63 settings: &HoldSettings,
64 id: Id,
65 rect: Rect,
66 expansion: f32,
67 age: f32,
68 color: Color32,
69) {
70 if !settings.burst_visible() {
71 return;
72 }
73
74 let t = (age / settings.burst_secs()).clamp(0.0, 1.0);
75 let intensity = settings.burst_intensity.clamp(0.0, 1.0);
76 match settings.burst {
77 HoldBurst::Off => {}
78 HoldBurst::Glow => {
79 let short = rect.width().min(rect.height()).max(1.0);
82 let (from, to) = settings.glow_radius_range();
83 let radius = short * (from + (to - from) * t);
84 let attenuation = size_attenuation(radius, short * from, short * to);
85 let tint = color.gamma_multiply((1.0 - t) * attenuation * intensity);
86 if tint.a() > 0 {
87 ui.painter().add(Shape::mesh(glow_mesh_with(
88 rect.center(),
89 radius,
90 tint,
91 GlowProfile::softness(settings.burst_glow_softness),
92 )));
93 }
94 }
95 HoldBurst::Lightning => {
96 burst_lightning(id, rect.expand(expansion), settings, color).show(ui.ctx());
97 }
98 }
99}
100
101#[must_use = "You should put this widget in a ui with `ui.add(widget);`"]
118pub struct Button<'a> {
119 layout: AtomLayout<'a>,
120 visuals: Option<ButtonVisuals>,
121 bg_color: Option<Color32>,
122 badge: Option<Badge>,
123 small: bool,
124 frame_when_inactive: bool,
125 min_size: Vec2,
126 label_align: Align,
127 selected: bool,
128 image_tint_follows_text_color: bool,
129 limit_image_size: bool,
130 hold: Option<HoldSettings>,
131}
132
133impl<'a> Button<'a> {
134 pub fn new(atoms: impl IntoAtoms<'a>) -> Self {
136 Self {
137 layout: AtomLayout::new(atoms.into_atoms())
138 .sense(Sense::click())
139 .fallback_font(TextStyle::Button),
140 visuals: None,
141 bg_color: None,
142 badge: None,
143 small: false,
144 frame_when_inactive: true,
145 min_size: Vec2::ZERO,
146 label_align: Align::Min,
147 selected: false,
148 image_tint_follows_text_color: false,
149 limit_image_size: false,
150 hold: None,
151 }
152 }
153
154 pub fn selectable(selected: bool, atoms: impl IntoAtoms<'a>) -> Self {
169 Self::new(atoms).selected(selected).frame_when_inactive(selected)
170 }
171
172 pub fn image(image: impl Into<Image<'a>>) -> Self {
177 Self::opt_image_and_text(Some(image.into()), None)
178 }
179
180 pub fn image_and_text(image: impl Into<Image<'a>>, text: impl Into<WidgetText>) -> Self {
185 Self::opt_image_and_text(Some(image.into()), Some(text.into()))
186 }
187
188 pub fn opt_image_and_text(image: Option<Image<'a>>, text: Option<WidgetText>) -> Self {
193 let mut button = Self::new(());
194 if let Some(image) = image {
195 button.layout.push_right(image);
196 }
197 if let Some(text) = text {
198 button.layout.push_right(text);
199 }
200 button.limit_image_size = true;
201 button
202 }
203
204 #[inline]
210 pub fn wrap_mode(mut self, wrap_mode: TextWrapMode) -> Self {
211 self.layout = self.layout.wrap_mode(wrap_mode);
212 self
213 }
214
215 #[inline]
217 pub fn wrap(self) -> Self {
218 self.wrap_mode(TextWrapMode::Wrap)
219 }
220
221 #[inline]
223 pub fn truncate(self) -> Self {
224 self.wrap_mode(TextWrapMode::Truncate)
225 }
226
227 #[inline]
229 pub fn small(mut self) -> Self {
230 self.small = true;
231 self
232 }
233
234 #[inline]
241 pub fn frame_when_inactive(mut self, frame_when_inactive: bool) -> Self {
242 self.frame_when_inactive = frame_when_inactive;
243 self
244 }
245
246 #[inline]
249 pub fn sense(mut self, sense: Sense) -> Self {
250 self.layout = self.layout.sense(sense);
251 self
252 }
253
254 #[inline]
256 pub fn min_size(mut self, min_size: Vec2) -> Self {
257 self.min_size = min_size;
258 self
259 }
260
261 #[inline]
276 pub fn label_align(mut self, align: Align) -> Self {
277 self.label_align = align;
278 self
279 }
280
281 #[inline]
288 pub fn image_tint_follows_text_color(mut self, image_tint_follows_text_color: bool) -> Self {
289 self.image_tint_follows_text_color = image_tint_follows_text_color;
290 self
291 }
292
293 #[inline]
301 pub fn shortcut_text(mut self, shortcut_text: impl Into<Atom<'a>>) -> Self {
302 let mut atom = shortcut_text.into();
303 atom.kind = match atom.kind {
304 AtomKind::Text(text) => AtomKind::Text(text.weak()),
305 other => other,
306 };
307 self.layout.push_right(Atom::grow());
308 self.layout.push_right(atom);
309 self
310 }
311
312 #[inline]
314 pub fn right_text(mut self, right_text: impl Into<Atom<'a>>) -> Self {
315 self.layout.push_right(Atom::grow());
316 self.layout.push_right(right_text.into());
317 self
318 }
319
320 #[inline]
322 pub fn selected(mut self, selected: bool) -> Self {
323 self.selected = selected;
324 self
325 }
326
327 #[inline]
329 pub fn visuals(mut self, visuals: ButtonVisuals) -> Self {
330 self.visuals = Some(visuals);
331 self
332 }
333
334 #[inline]
336 pub fn bg_color(mut self, color: Color32) -> Self {
337 self.bg_color = Some(color);
338 self
339 }
340
341 #[inline]
362 pub fn badge(mut self, badge: Badge) -> Self {
363 self.badge = Some(badge);
364 self
365 }
366
367 pub fn hold(mut self, seconds: f32) -> Self {
393 let mut settings = self.hold.unwrap_or_else(HoldSettings::default);
394 settings.seconds = seconds;
395 self.hold = Some(settings);
396 self
397 }
398
399 pub fn hold_fill(mut self, color: Color32) -> Self {
411 let settings = self.hold.get_or_insert_with(|| HoldSettings {
412 seconds: 0.0,
413 ..Default::default()
414 });
415 settings.fill_from = Some(color);
416 settings.fill_to = None;
417 self
418 }
419
420 pub fn hold_burst(mut self, burst: HoldBurst) -> Self {
426 let settings = self.hold.get_or_insert_with(|| HoldSettings {
427 seconds: 0.0,
428 ..Default::default()
429 });
430 settings.burst = burst;
431 self
432 }
433
434 pub fn hold_settings(mut self, settings: &HoldSettings) -> Self {
441 self.hold = Some(*settings);
442 self
443 }
444
445 pub fn atom_ui(self, ui: &mut Ui) -> AtomLayoutResponse {
447 let Button {
448 mut layout,
449 bg_color,
450 badge,
451 small,
452 visuals,
453 frame_when_inactive,
454 mut min_size,
455 label_align,
456 selected,
457 image_tint_follows_text_color,
458 limit_image_size,
459 hold,
460 } = self;
461
462 let hold_settings = hold.filter(|settings| settings.seconds > 0.0);
463
464 let visuals = visuals.or_else(|| Theme::button_visuals_from_ctx(ui.ctx()));
465
466 if !small {
467 min_size.y = min_size.y.at_least(ui.spacing().interact_size.y);
468 }
469 if limit_image_size {
470 layout.map_atoms(|atom| {
471 if matches!(&atom.kind, AtomKind::Image(_)) {
472 atom.atom_max_height_font_size(ui)
473 } else {
474 atom
475 }
476 });
477 }
478
479 let text = layout.text().map(String::from);
480
481 if label_align != Align::Min {
486 let vertical = ui.layout().vertical_align();
487 layout = layout.align2(Align2([label_align, vertical]));
488 }
489
490 let has_frame_margin = visuals.is_some() || ui.visuals().button_frame;
491 let mut button_padding = if has_frame_margin {
492 ui.spacing().button_padding
493 } else {
494 Vec2::ZERO
495 };
496
497 if small {
498 button_padding.y = 0.0;
499 }
500
501 let frame = Frame::new().inner_margin(button_padding);
502 let mut prepared = layout.frame(frame).min_size(min_size).allocate(ui);
503
504 let response = if ui.is_rect_visible(prepared.response.rect) {
505 let interact_visuals = ui.style().interact_selectable(&prepared.response, selected);
506
507 let mut hold = HoldState::default();
513 let mut fired_now = false;
514 let mut holding = false;
515 if let Some(settings) = hold_settings {
516 let id = prepared.response.id;
517 let seconds = settings.seconds;
518 let dt = ui.ctx().input(|i| i.stable_dt).min(HOLD_MAX_DT);
519 let (pointer_pos, primary_down) =
520 ui.ctx().input(|i| (i.pointer.interact_pos(), i.pointer.primary_down()));
521 let max_click_dist = ui.ctx().options(|o| o.input_options.max_click_dist);
522 let rect = prepared.response.rect;
523 hold = ui.ctx().data_mut(|data| {
524 let state = data.get_temp_mut_or_insert_with(id, HoldState::default);
525 if prepared.response.is_pointer_button_down_on() {
526 state.pressing = true;
527 }
528 let held = state.pressing
529 && primary_down
530 && pointer_pos.is_some_and(|p| rect.expand(max_click_dist).contains(p));
531 holding = held;
532 if held {
533 state.progress = (state.progress + dt / seconds).clamp(0.0, 1.0);
534 } else {
535 state.progress = 0.0;
537 state.fired = false;
538 if !primary_down {
539 state.pressing = false;
541 }
542 }
543 if state.progress >= 1.0 && !state.fired {
544 state.fired = true;
545 if settings.burst_visible() {
546 state.burst_age = Some(0.0);
547 }
548 fired_now = true;
549 }
550 let snapshot = *state;
551 if let Some(age) = state.burst_age.as_mut() {
554 *age += dt;
555 if *age >= settings.burst_secs() {
556 state.burst_age = None;
557 }
558 }
559 snapshot
560 });
561
562 prepared.response.flags.remove(Flags::CLICKED);
565
566 if fired_now {
568 prepared.response.flags.insert(Flags::FAKE_PRIMARY_CLICKED);
569 }
570
571 if fired_now && settings.burst == HoldBurst::Lightning && settings.burst_visible() {
573 let accent = Theme::current(ui.ctx()).colors.accent;
574 let fill = settings.fill_color(accent, 1.0);
575 let color = settings.burst_color.unwrap_or(fill);
576 burst_lightning(
577 id,
578 prepared.response.rect.expand(interact_visuals.expansion),
579 &settings,
580 color,
581 )
582 .strike(ui.ctx());
583 }
584 }
585
586 let is_active = if hold_settings.is_some() {
591 holding && !hold.fired
592 } else {
593 prepared.response.is_pointer_button_down_on()
594 };
595 let is_hovered = prepared.response.hovered();
596
597 let (fill, stroke, corner_radius) = if let Some(v) = &visuals {
599 if is_active {
600 (v.bg_click, v.border_click, v.corner_radius)
601 } else if is_hovered {
602 (v.bg_hover, v.border_hover, v.corner_radius)
603 } else {
604 let bg = bg_color.unwrap_or(v.bg);
605 (bg, v.border, v.corner_radius)
606 }
607 } else {
608 (
609 interact_visuals.weak_bg_fill,
610 interact_visuals.bg_stroke,
611 interact_visuals.corner_radius,
612 )
613 };
614
615 let fill = match selected {
616 false => fill,
617 true => {
618 visuals.as_ref().map(|v| v.bg_selected).unwrap_or(interact_visuals.weak_bg_fill)
619 }
620 };
621
622 let text_color = visuals.as_ref().map(|v| v.text).unwrap_or(interact_visuals.text_color());
623 let shadow = visuals.as_ref().map(|v| v.shadow).unwrap_or(Shadow::NONE);
624
625 let visible_frame = if frame_when_inactive {
626 has_frame_margin
627 } else {
628 has_frame_margin && (is_hovered || is_active || prepared.response.has_focus())
629 };
630
631 if image_tint_follows_text_color {
632 prepared.map_images(|image| image.tint(text_color));
633 }
634 prepared.fallback_text_color = text_color;
635
636 if visible_frame {
637 prepared.frame = prepared
638 .frame
639 .inner_margin(
640 button_padding + Vec2::splat(interact_visuals.expansion)
641 - Vec2::splat(stroke.width),
642 )
643 .outer_margin(-Vec2::splat(interact_visuals.expansion))
644 .fill(fill)
645 .stroke(stroke)
646 .corner_radius(corner_radius)
647 .shadow(shadow);
648 }
649
650 let sweep_active = hold.progress > 0.0 && !hold.fired;
653 let burst_active = hold.burst_age.is_some();
654 let hold_color = hold_settings.filter(|_| sweep_active || burst_active).map(|settings| {
655 let accent = Theme::current(ui.ctx()).colors.accent;
656 settings.fill_color(accent, hold.progress)
657 });
658
659 if let Some(hold_color) = hold_color {
660 if visible_frame {
661 prepared.frame = prepared.frame.fill(Color32::TRANSPARENT).shadow(Shadow::NONE);
668
669 let rect = prepared.response.rect;
670 let expanded = rect.expand(interact_visuals.expansion);
671 if shadow != Shadow::NONE {
672 ui.painter().add(shadow.as_shape(expanded, corner_radius));
673 }
674 ui.painter().add(Shape::rect_filled(expanded, corner_radius, fill));
675
676 if sweep_active {
677 let sweep_clip = Rect::from_min_max(
682 Pos2::new(rect.left(), expanded.top()),
683 Pos2::new(
684 rect.left() + hold.progress * rect.width(),
685 expanded.bottom(),
686 ),
687 );
688 let clip = ui.painter().clip_rect().intersect(sweep_clip);
689 ui.painter().with_clip_rect(clip).add(Shape::rect_filled(
690 expanded,
691 corner_radius,
692 hold_color,
693 ));
694
695 let edge_x = rect.left() + hold.progress * rect.width();
696 let edge_clip = ui.painter().clip_rect().intersect(expanded);
697 ui.painter().with_clip_rect(edge_clip).add(Shape::line_segment(
698 [
699 Pos2::new(edge_x, expanded.top()),
700 Pos2::new(edge_x, expanded.bottom()),
701 ],
702 Stroke::new(HOLD_EDGE_WIDTH, hold_color),
703 ));
704 }
705 }
706
707 if let (Some(settings), Some(age)) = (hold_settings, hold.burst_age) {
708 let burst_color = settings.burst_color.unwrap_or(hold_color);
709 paint_burst(
710 ui,
711 &settings,
712 prepared.response.id,
713 prepared.response.rect,
714 interact_visuals.expansion,
715 age,
716 burst_color,
717 );
718 }
719 }
720
721 if (sweep_active && hold.progress < 1.0) || burst_active {
724 ui.ctx().request_repaint();
725 }
726
727 let painted = prepared.paint(ui);
728 if let Some(badge) = &badge {
729 badge.paint_at(ui, painted.response.rect);
730 }
731 painted
732 } else {
733 AtomLayoutResponse::empty(prepared.response)
734 };
735
736 response.response.widget_info(|| {
737 if let Some(text) = &text {
738 WidgetInfo::labeled(WidgetType::Button, ui.is_enabled(), text)
739 } else {
740 WidgetInfo::new(WidgetType::Button)
741 }
742 });
743
744 response
745 }
746}
747
748impl Widget for Button<'_> {
749 fn ui(self, ui: &mut Ui) -> Response {
750 self.atom_ui(ui).response
751 }
752}
753
754#[cfg(test)]
755mod tests {
756 use super::*;
757 use egui::{
758 Context, Event, FullOutput, Key, Modifiers, PointerButton, RawInput, ViewportId,
759 epaint::Mesh, vec2,
760 };
761 use std::time::Duration;
762
763 fn screen() -> Rect {
765 Rect::from_min_size(Pos2::ZERO, vec2(320.0, 200.0))
766 }
767
768 const STEP: f32 = 0.125;
770
771 const SWEEP: Color32 = Color32::from_rgb(1, 2, 3);
773
774 fn press(pos: Pos2) -> Event {
775 Event::PointerButton {
776 pos,
777 button: PointerButton::Primary,
778 pressed: true,
779 modifiers: Modifiers::NONE,
780 }
781 }
782
783 fn release(pos: Pos2) -> Event {
784 Event::PointerButton {
785 pos,
786 button: PointerButton::Primary,
787 pressed: false,
788 modifiers: Modifiers::NONE,
789 }
790 }
791
792 fn enter() -> Event {
793 Event::Key {
794 key: Key::Enter,
795 physical_key: None,
796 pressed: true,
797 repeat: false,
798 modifiers: Modifiers::NONE,
799 }
800 }
801
802 fn hold_button() -> Button<'static> {
803 Button::new("Hold").hold(0.5)
804 }
805
806 fn run_pass(
809 ctx: &Context,
810 events: Vec<Event>,
811 time: f64,
812 build: impl FnOnce() -> Button<'static>,
813 ) -> (FullOutput, Response) {
814 let mut build = Some(build);
815 let mut response = None;
816 let mut output = ctx.run_ui(
817 RawInput {
818 events,
819 screen_rect: Some(screen()),
820 time: Some(time),
821 predicted_dt: STEP,
822 ..Default::default()
823 },
824 |ui| {
825 let build = build.take().expect("the builder runs once");
826 response = Some(ui.add(build()));
827 },
828 );
829 output.textures_delta.clear();
831 (
832 output,
833 response.expect("the button must be added"),
834 )
835 }
836
837 fn hold_state(ctx: &Context, id: Id) -> Option<HoldState> {
838 ctx.data(|d| d.get_temp::<HoldState>(id))
839 }
840
841 fn max_click_duration(ctx: &Context) -> f64 {
844 ctx.options(|o| o.input_options.max_click_duration)
845 }
846
847 fn sweep_shape(output: &FullOutput, color: Color32) -> Option<(f32, f32)> {
849 output.shapes.iter().find_map(|clipped| match &clipped.shape {
850 Shape::Rect(rect) if rect.fill == color => Some((
851 clipped.clip_rect.width(),
852 clipped.clip_rect.left(),
853 )),
854 _ => None,
855 })
856 }
857
858 fn rect_fills(output: &FullOutput) -> Vec<Color32> {
861 fn collect(shape: &Shape, fills: &mut Vec<Color32>) {
862 match shape {
863 Shape::Rect(rect) => fills.push(rect.fill),
864 Shape::Vec(shapes) => {
865 for shape in shapes {
866 collect(shape, fills);
867 }
868 }
869 _ => {}
870 }
871 }
872 let mut fills = Vec::new();
873 for clipped in &output.shapes {
874 collect(&clipped.shape, &mut fills);
875 }
876 fills
877 }
878
879 fn has_mesh(output: &FullOutput) -> bool {
880 output.shapes.iter().any(|clipped| matches!(clipped.shape, Shape::Mesh(_)))
881 }
882
883 fn mesh_alpha(output: &FullOutput) -> u8 {
885 output
886 .shapes
887 .iter()
888 .filter_map(|clipped| match &clipped.shape {
889 Shape::Mesh(mesh) => Some(mesh.vertices.iter().map(|vertex| vertex.color.a()).max()),
890 _ => None,
891 })
892 .flatten()
893 .max()
894 .unwrap_or(0)
895 }
896
897 fn path_count(output: &FullOutput) -> usize {
899 output
900 .shapes
901 .iter()
902 .filter(|clipped| matches!(clipped.shape, Shape::Path(_)))
903 .count()
904 }
905
906 fn has_burst_shape(output: &FullOutput) -> bool {
908 has_mesh(output) || path_count(output) > 0
909 }
910
911 fn painted_sweep(output: &FullOutput, rect: Rect, progress: f32) -> Option<Color32> {
914 output.shapes.iter().find_map(|clipped| match &clipped.shape {
915 Shape::Rect(shape)
916 if (shape.rect.width() - rect.width()).abs() < 4.0
917 && (shape.rect.height() - rect.height()).abs() < 4.0
918 && (clipped.clip_rect.width() - progress * rect.width()).abs() < 0.5
919 && (clipped.clip_rect.left() - rect.left()).abs() < 0.5 =>
920 {
921 Some(shape.fill)
922 }
923 _ => None,
924 })
925 }
926
927 fn advance_until_fired(
929 ctx: &Context,
930 time: &mut f64,
931 build: &impl Fn() -> Button<'static>,
932 ) -> (FullOutput, f64) {
933 for _ in 0..12 {
934 *time += f64::from(STEP);
935 let (output, frame) = run_pass(ctx, vec![], *time, || build());
936 if frame.clicked() {
937 return (output, *time);
938 }
939 }
940 panic!("the hold must complete");
941 }
942
943 fn complete_hold(settings: HoldSettings) -> (Context, FullOutput, Response, f64) {
945 let ctx = Context::default();
946 let build = move || Button::new("Hold").hold_settings(&settings);
947 let (_, warm) = run_pass(&ctx, vec![], 0.0, build);
948 let center = warm.rect.center();
949 let mut time = f64::from(STEP);
950 run_pass(&ctx, vec![press(center)], time, build);
951 let (completed, completion_time) = advance_until_fired(&ctx, &mut time, &build);
952 (ctx, completed, warm, completion_time)
953 }
954
955 fn burst_mesh(output: &FullOutput) -> Option<Mesh> {
959 output.shapes.iter().find_map(|clipped| match &clipped.shape {
960 Shape::Mesh(mesh) if mesh.vertices.iter().any(|vertex| vertex.color.a() == 0) => {
961 Some(mesh.as_ref().clone())
962 }
963 _ => None,
964 })
965 }
966
967 fn mesh_radius(mesh: &Mesh, center: Pos2) -> f32 {
969 mesh
970 .vertices
971 .iter()
972 .map(|vertex| vertex.pos.distance(center))
973 .fold(0.0_f32, f32::max)
974 }
975
976 fn polylines(output: &FullOutput) -> Vec<(Vec<Pos2>, f32)> {
978 output
979 .shapes
980 .iter()
981 .filter_map(|clipped| match &clipped.shape {
982 Shape::Path(path) => Some((path.points.clone(), path.stroke.width)),
983 _ => None,
984 })
985 .collect()
986 }
987
988 fn max_deviation(points: &[Pos2]) -> f32 {
991 let (Some(&first), Some(&last)) = (points.first(), points.last()) else {
992 return 0.0;
993 };
994 let direction = last - first;
995 let length = direction.length();
996 if length <= f32::MIN_POSITIVE {
997 return 0.0;
998 }
999 points
1000 .iter()
1001 .map(|point| {
1002 let offset = *point - first;
1003 (offset.x * direction.y - offset.y * direction.x).abs() / length
1004 })
1005 .fold(0.0_f32, f32::max)
1006 }
1007
1008 fn glow_burst_after(settings: HoldSettings, extra: usize) -> (Mesh, Rect) {
1011 let ctx = Context::default();
1012 let build = move || Button::new("Hold").hold_settings(&settings);
1013 let (_, warm) = run_pass(&ctx, vec![], 0.0, build);
1014 let center = warm.rect.center();
1015 let mut time = f64::from(STEP);
1016 run_pass(&ctx, vec![press(center)], time, build);
1017 let (mut output, mut time) = advance_until_fired(&ctx, &mut time, &build);
1018 for _ in 0..extra {
1019 time += f64::from(STEP);
1020 let (next, _) = run_pass(&ctx, vec![], time, build);
1021 output = next;
1022 }
1023 let mesh = burst_mesh(&output).expect("the Glow burst must paint its mesh");
1024 (mesh, warm.rect)
1025 }
1026
1027 fn complete_hold_sized(settings: HoldSettings) -> (Context, FullOutput, Response, f64) {
1030 let ctx = Context::default();
1031 let build = move || Button::new("Hold").hold_settings(&settings).min_size(vec2(250.0, 45.0));
1032 let (_, warm) = run_pass(&ctx, vec![], 0.0, build);
1033 let center = warm.rect.center();
1034 let mut time = f64::from(STEP);
1035 run_pass(&ctx, vec![press(center)], time, build);
1036 let (completed, completion_time) = advance_until_fired(&ctx, &mut time, &build);
1037 (ctx, completed, warm, completion_time)
1038 }
1039
1040 fn press_outcome(build: impl Fn() -> Button<'static> + Copy) -> (f32, usize) {
1043 let ctx = Context::default();
1044 let (_, warm) = run_pass(&ctx, vec![], 0.0, build);
1045 let center = warm.rect.center();
1046 let mut time = f64::from(STEP);
1047 run_pass(&ctx, vec![press(center)], time, build);
1048 let first = hold_state(&ctx, warm.id).expect("hold state").progress;
1049 let mut passes = 0;
1050 loop {
1051 time += f64::from(STEP);
1052 passes += 1;
1053 let (_, frame) = run_pass(&ctx, vec![], time, build);
1054 if frame.clicked() {
1055 break;
1056 }
1057 assert!(
1058 passes < 12,
1059 "the hold did not complete after {passes} passes"
1060 );
1061 }
1062 (first, passes)
1063 }
1064
1065 fn base_fill(output: &FullOutput, rect: Rect) -> Option<Color32> {
1068 output.shapes.iter().find_map(|clipped| match &clipped.shape {
1069 Shape::Rect(shape)
1070 if shape.fill.a() == 255
1071 && (shape.rect.width() - rect.width()).abs() < 4.0
1072 && (shape.rect.height() - rect.height()).abs() < 4.0
1073 && shape.rect.center().distance(rect.center()) < 2.0 =>
1074 {
1075 Some(shape.fill)
1076 }
1077 _ => None,
1078 })
1079 }
1080
1081 fn text_rect(output: &FullOutput, text: &str) -> Option<Rect> {
1083 output.shapes.iter().find_map(|clipped| match &clipped.shape {
1084 Shape::Text(shape) if shape.galley.text() == text => Some(Rect::from_min_size(
1085 shape.pos,
1086 shape.galley.size(),
1087 )),
1088 _ => None,
1089 })
1090 }
1091
1092 #[test]
1093 fn click_fires_only_at_the_end_of_the_hold() {
1094 let ctx = Context::default();
1095 let (_, warm) = run_pass(&ctx, vec![], 0.0, hold_button);
1096 let id = warm.id;
1097 let center = warm.rect.center();
1098
1099 let mut time = f64::from(STEP);
1100 let (_, pressed) = run_pass(&ctx, vec![press(center)], time, hold_button);
1101 assert!(
1102 !pressed.clicked(),
1103 "a fresh press must not report a click"
1104 );
1105 assert_eq!(
1106 hold_state(&ctx, id).expect("hold state").progress,
1107 0.2,
1108 "the press frame must accumulate one capped step"
1109 );
1110
1111 for _ in 0..3 {
1113 time += f64::from(STEP);
1114 let (_, frame) = run_pass(&ctx, vec![], time, hold_button);
1115 assert!(
1116 !frame.clicked(),
1117 "the hold reported a click before its duration elapsed"
1118 );
1119 assert!(hold_state(&ctx, id).expect("hold state").progress < 1.0);
1120 }
1121 assert_eq!(
1122 hold_state(&ctx, id).expect("hold state").progress,
1123 0.8
1124 );
1125
1126 time += f64::from(STEP);
1128 let (_, completed) = run_pass(&ctx, vec![], time, hold_button);
1129 assert!(
1130 completed.clicked(),
1131 "the completed hold must report a click"
1132 );
1133 assert_eq!(
1134 hold_state(&ctx, id).expect("hold state").progress,
1135 1.0
1136 );
1137
1138 time += f64::from(STEP);
1140 let (_, after) = run_pass(&ctx, vec![], time, hold_button);
1141 assert!(
1142 !after.clicked(),
1143 "the click must not repeat after completion"
1144 );
1145 }
1146
1147 #[test]
1148 fn releasing_midway_resets_and_never_fires() {
1149 let ctx = Context::default();
1150 let (_, warm) = run_pass(&ctx, vec![], 0.0, hold_button);
1151 let id = warm.id;
1152 let center = warm.rect.center();
1153
1154 let mut time = f64::from(STEP);
1155 let (_, pressed) = run_pass(&ctx, vec![press(center)], time, hold_button);
1156 assert!(!pressed.clicked());
1157 assert!(hold_state(&ctx, id).expect("hold state").progress > 0.0);
1158
1159 time += f64::from(STEP);
1160 let (_, released) = run_pass(&ctx, vec![release(center)], time, hold_button);
1161 assert!(
1162 !released.clicked(),
1163 "a release under the duration must not click"
1164 );
1165 assert_eq!(
1166 hold_state(&ctx, id).expect("hold state").progress,
1167 0.0,
1168 "a release must reset the hold"
1169 );
1170
1171 for _ in 0..3 {
1172 time += f64::from(STEP);
1173 let (_, frame) = run_pass(&ctx, vec![], time, hold_button);
1174 assert!(
1175 !frame.clicked(),
1176 "a released hold must never fire"
1177 );
1178 }
1179 assert_eq!(
1180 hold_state(&ctx, id).expect("hold state").progress,
1181 0.0
1182 );
1183 }
1184
1185 #[test]
1186 fn dragging_off_resets_the_hold() {
1187 let ctx = Context::default();
1188 let (_, warm) = run_pass(&ctx, vec![], 0.0, hold_button);
1189 let id = warm.id;
1190 let center = warm.rect.center();
1191
1192 let mut time = f64::from(STEP);
1193 run_pass(&ctx, vec![press(center)], time, hold_button);
1194 assert!(hold_state(&ctx, id).expect("hold state").progress > 0.0);
1195
1196 time += f64::from(STEP);
1197 let (_, dragged) = run_pass(
1198 &ctx,
1199 vec![Event::PointerMoved(Pos2::new(310.0, 190.0))],
1200 time,
1201 hold_button,
1202 );
1203 assert!(
1204 !dragged.is_pointer_button_down_on(),
1205 "dragging off the button must end the press"
1206 );
1207 assert_eq!(
1208 hold_state(&ctx, id).expect("hold state").progress,
1209 0.0,
1210 "dragging off must reset the hold"
1211 );
1212
1213 time += f64::from(STEP);
1214 run_pass(&ctx, vec![], time, hold_button);
1215 assert_eq!(
1216 hold_state(&ctx, id).expect("hold state").progress,
1217 0.0
1218 );
1219 }
1220
1221 #[test]
1222 fn a_plain_click_never_fires_a_hold() {
1223 let ctx = Context::default();
1224 let (_, warm) = run_pass(&ctx, vec![], 0.0, hold_button);
1225 let center = warm.rect.center();
1226
1227 let mut time = f64::from(STEP);
1228 let (_, pressed) = run_pass(&ctx, vec![press(center)], time, hold_button);
1229 assert!(!pressed.clicked());
1230
1231 time += f64::from(STEP);
1232 let (_, released) = run_pass(&ctx, vec![release(center)], time, hold_button);
1233 assert!(
1234 !released.clicked(),
1235 "a plain click must not confirm a hold-to-confirm button"
1236 );
1237 }
1238
1239 #[test]
1240 fn keyboard_activation_confirms_immediately() {
1241 let ctx = Context::default();
1242 let (_, warm) = run_pass(&ctx, vec![], 0.0, hold_button);
1243 let id = warm.id;
1244
1245 ctx.memory_mut(|mem| mem.request_focus(id));
1247 let (_, activated) = run_pass(&ctx, vec![enter()], f64::from(STEP), hold_button);
1248 assert!(
1249 activated.clicked(),
1250 "Enter on a focused hold button must confirm"
1251 );
1252 assert!(
1253 hold_state(&ctx, id).expect("hold state").progress < 1.0,
1254 "keyboard confirmation must not wait for the hold duration"
1255 );
1256 }
1257
1258 #[test]
1259 fn sweep_width_tracks_progress() {
1260 let build = || Button::new("Hold").hold(0.5).hold_fill(SWEEP);
1261 let ctx = Context::default();
1262 let (_, warm) = run_pass(&ctx, vec![], 0.0, build);
1263 let center = warm.rect.center();
1264
1265 let (output, pressed) = run_pass(&ctx, vec![press(center)], f64::from(STEP), build);
1266 let progress = hold_state(&ctx, pressed.id).expect("hold state").progress;
1267 assert!(
1268 (progress - 0.2).abs() < 1e-6,
1269 "unexpected progress {progress}"
1270 );
1271
1272 let (clip_width, clip_left) = sweep_shape(&output, SWEEP).expect("a sweep shape");
1273 assert!(
1274 (clip_width - progress * pressed.rect.width()).abs() < 0.01,
1275 "sweep width {clip_width} does not track progress {progress} of {}",
1276 pressed.rect.width()
1277 );
1278 assert!(
1279 (clip_left - pressed.rect.left()).abs() < 0.01,
1280 "the sweep must start at the button's left edge"
1281 );
1282 }
1283
1284 #[test]
1285 fn two_hold_buttons_keep_separate_progress() {
1286 let ctx = Context::default();
1287 let mut first = None;
1288 let mut second = None;
1289 let warm = ctx.run_ui(
1290 RawInput {
1291 screen_rect: Some(screen()),
1292 time: Some(0.0),
1293 predicted_dt: STEP,
1294 ..Default::default()
1295 },
1296 |ui| {
1297 ui.horizontal(|ui| {
1298 first = Some(ui.add(Button::new("First").hold(0.5)));
1299 second = Some(ui.add(Button::new("Second").hold(0.5)));
1300 });
1301 },
1302 );
1303 warm.drop_without_applying_deltas();
1304 let first = first.expect("first button");
1305 let second = second.expect("second button");
1306 let center = first.rect.center();
1307
1308 let (_, pressed_first, pressed_second) = {
1309 let mut first = None;
1310 let mut second = None;
1311 let mut output = ctx.run_ui(
1312 RawInput {
1313 events: vec![press(center)],
1314 screen_rect: Some(screen()),
1315 time: Some(f64::from(STEP)),
1316 predicted_dt: STEP,
1317 ..Default::default()
1318 },
1319 |ui| {
1320 ui.horizontal(|ui| {
1321 first = Some(ui.add(Button::new("First").hold(0.5)));
1322 second = Some(ui.add(Button::new("Second").hold(0.5)));
1323 });
1324 },
1325 );
1326 output.textures_delta.clear();
1327 (
1328 output,
1329 first.expect("first button"),
1330 second.expect("second button"),
1331 )
1332 };
1333 assert_eq!(
1334 pressed_first.id, first.id,
1335 "the first button's id must be stable"
1336 );
1337 assert_eq!(
1338 pressed_second.id, second.id,
1339 "the second button's id must be stable"
1340 );
1341
1342 let first_progress = hold_state(&ctx, pressed_first.id).expect("first hold state").progress;
1343 let second_progress =
1344 hold_state(&ctx, pressed_second.id).expect("second hold state").progress;
1345 assert!(
1346 first_progress > 0.0,
1347 "the pressed button must progress"
1348 );
1349 assert_eq!(
1350 second_progress, 0.0,
1351 "the other button must not progress"
1352 );
1353 }
1354
1355 #[test]
1356 fn idle_hold_button_requests_no_repaint() {
1357 let ctx = Context::default();
1358 for _ in 0..2 {
1360 run_pass(&ctx, vec![], 0.0, hold_button).0.drop_without_applying_deltas();
1361 }
1362
1363 let (output, _) = run_pass(&ctx, vec![], 0.0, hold_button);
1364 let delay = output.viewport_output[&ViewportId::ROOT].repaint_delay;
1365 assert_eq!(
1366 delay,
1367 Duration::MAX,
1368 "an idle hold button requested a repaint after {delay:?}"
1369 );
1370 }
1371
1372 #[test]
1373 fn hold_unset_changes_nothing() {
1374 let build = || Button::new("Hold").hold_fill(SWEEP);
1375 let ctx = Context::default();
1376 for _ in 0..2 {
1377 run_pass(&ctx, vec![], 0.0, build).0.drop_without_applying_deltas();
1378 }
1379
1380 let (output, response) = run_pass(&ctx, vec![], 0.0, build);
1381 let delay = output.viewport_output[&ViewportId::ROOT].repaint_delay;
1382 assert_eq!(
1383 delay,
1384 Duration::MAX,
1385 "a button without `hold` requested a repaint after {delay:?}"
1386 );
1387 assert!(
1388 sweep_shape(&output, SWEEP).is_none(),
1389 "a button without `hold` must not paint a sweep"
1390 );
1391 assert!(
1392 hold_state(&ctx, response.id).is_none(),
1393 "a button without `hold` must not create hold state"
1394 );
1395 }
1396
1397 #[test]
1398 fn the_burst_ends() {
1399 let build = || Button::new(()).hold(0.5);
1400 let ctx = Context::default();
1401 let (_, warm) = run_pass(&ctx, vec![], 0.0, build);
1402 let id = warm.id;
1403 let center = warm.rect.center();
1404
1405 let mut time = f64::from(STEP);
1406 run_pass(&ctx, vec![press(center)], time, build);
1407
1408 let mut completed = None;
1410 let mut completion_time = time;
1411 for _ in 0..6 {
1412 time += f64::from(STEP);
1413 let (output, frame) = run_pass(&ctx, vec![], time, build);
1414 if frame.clicked() {
1415 completed = Some(output);
1416 completion_time = time;
1417 break;
1418 }
1419 }
1420 let completed = completed.expect("the hold must complete");
1421 assert!(
1422 hold_state(&ctx, id).expect("hold state").burst_age.is_some(),
1423 "a burst must start when the hold completes"
1424 );
1425 assert!(
1426 has_mesh(&completed),
1427 "the glow burst must paint a mesh"
1428 );
1429
1430 time = completion_time + f64::from(STEP);
1432 let (_, released) = run_pass(&ctx, vec![release(center)], time, build);
1433 assert!(!released.clicked());
1434
1435 for _ in 0..4 {
1437 time += f64::from(STEP);
1438 run_pass(&ctx, vec![], time, build);
1439 }
1440 assert!(
1441 hold_state(&ctx, id).expect("hold state").burst_age.is_none(),
1442 "the burst must end"
1443 );
1444
1445 time += f64::from(STEP);
1446 let (idle, _) = run_pass(&ctx, vec![], time, build);
1447 assert!(
1448 !has_mesh(&idle),
1449 "the ended burst must paint no mesh"
1450 );
1451 let delay = idle.viewport_output[&ViewportId::ROOT].repaint_delay;
1452 assert_eq!(
1453 delay,
1454 Duration::MAX,
1455 "the ended burst requested a repaint after {delay:?}"
1456 );
1457 }
1458
1459 #[test]
1460 fn hold_outlives_egui_max_click_duration() {
1461 const SECONDS: f32 = 1.5;
1462 let ctx = Context::default();
1463 let ceiling = max_click_duration(&ctx);
1464 assert!(
1465 f64::from(SECONDS) > ceiling,
1466 "this test only bites while its {SECONDS}s hold outlives egui's {ceiling}s click ceiling"
1467 );
1468 let build = || Button::new("Hold").hold(SECONDS);
1469 let (_, warm) = run_pass(&ctx, vec![], 0.0, build);
1470 let id = warm.id;
1471 let center = warm.rect.center();
1472
1473 let mut time = f64::from(STEP);
1474 let (_, pressed) = run_pass(&ctx, vec![press(center)], time, build);
1475 assert!(!pressed.clicked(), "a fresh press must not click");
1476 let press_time = time;
1477
1478 let max_frames = (SECONDS / HOLD_MAX_DT).ceil() as usize + 2;
1479 let mut completion_time = time;
1480 let mut fired = false;
1481 for _ in 0..max_frames {
1482 time += f64::from(STEP);
1483 let (_, frame) = run_pass(&ctx, vec![], time, build);
1484 let progress = hold_state(&ctx, id).expect("hold state").progress;
1485 if progress < 1.0 {
1486 assert!(
1487 !frame.clicked(),
1488 "the hold clicked while the sweep was only {progress} full"
1489 );
1490 } else {
1491 assert!(
1492 frame.clicked(),
1493 "the hold must click on the frame its sweep completes"
1494 );
1495 fired = true;
1496 completion_time = time;
1497 break;
1498 }
1499 }
1500 assert!(
1501 fired,
1502 "a {SECONDS}s hold must complete past egui's {ceiling}s click ceiling"
1503 );
1504 assert!(
1505 completion_time - press_time > ceiling,
1506 "the hold finished in {}s, inside egui's {ceiling}s click ceiling",
1507 completion_time - press_time
1508 );
1509
1510 time = completion_time + f64::from(STEP);
1512 let (_, released) = run_pass(&ctx, vec![release(center)], time, build);
1513 assert!(
1514 !released.clicked(),
1515 "a completed hold must not click again on release"
1516 );
1517 assert_eq!(
1518 hold_state(&ctx, id).expect("hold state").progress,
1519 0.0,
1520 "a release must reset the completed hold"
1521 );
1522 }
1523
1524 #[test]
1525 fn the_sweep_keeps_filling_past_egui_max_click_duration() {
1526 const SECONDS: f32 = 1.5;
1527 let ctx = Context::default();
1528 let ceiling = max_click_duration(&ctx);
1529 assert!(
1530 f64::from(SECONDS) > ceiling,
1531 "this test only bites while its {SECONDS}s hold outlives egui's {ceiling}s click ceiling"
1532 );
1533 let build = || Button::new("Hold").hold(SECONDS).hold_fill(SWEEP);
1534 let (_, warm) = run_pass(&ctx, vec![], 0.0, build);
1535 let center = warm.rect.center();
1536 let width = warm.rect.width();
1537
1538 let mut time = f64::from(STEP);
1539 run_pass(&ctx, vec![press(center)], time, build);
1540 let press_time = time;
1541
1542 let max_frames = (SECONDS / HOLD_MAX_DT).ceil() as usize + 2;
1543 let mut at_ceiling = None;
1544 let mut past_ceiling = None;
1545 for _ in 0..max_frames {
1546 time += f64::from(STEP);
1547 let (output, frame) = run_pass(&ctx, vec![], time, build);
1548 let elapsed = time - press_time;
1549 let sweep = sweep_shape(&output, SWEEP).map(|(clip_width, _)| clip_width);
1550 if at_ceiling.is_none() && elapsed >= ceiling {
1551 at_ceiling = sweep;
1552 } else if past_ceiling.is_none() && elapsed >= ceiling + f64::from(STEP) {
1553 past_ceiling = sweep;
1554 break;
1555 }
1556 if frame.clicked() {
1557 break;
1558 }
1559 }
1560
1561 let at_ceiling = at_ceiling.expect("the sweep must still be painted at egui's click ceiling");
1562 let past_ceiling =
1563 past_ceiling.expect("the sweep must still be painted past the click ceiling");
1564 assert!(
1565 past_ceiling > at_ceiling,
1566 "the sweep stopped growing past the click ceiling: {at_ceiling} px then {past_ceiling} px"
1567 );
1568 assert!(
1569 past_ceiling < width,
1570 "the sweep completed early, before the hold finished: {past_ceiling} of {width} px"
1571 );
1572 }
1573
1574 #[test]
1575 fn the_shadow_does_not_paint_over_the_sweep() {
1576 const SHADOW: Color32 = Color32::from_rgb(200, 30, 40);
1578 const SHADOW_SPREAD: u8 = 4;
1580 const IDLE: Color32 = Color32::from_rgb(10, 20, 30);
1583 const HOVER: Color32 = Color32::from_rgb(40, 50, 60);
1584 const PRESSED: Color32 = Color32::from_rgb(70, 80, 90);
1585 let ctx = Context::default();
1586 let visuals = ButtonVisuals {
1587 bg: IDLE,
1588 bg_hover: HOVER,
1589 bg_click: PRESSED,
1590 shadow: Shadow {
1591 offset: [0, 0],
1592 blur: 0,
1593 spread: SHADOW_SPREAD,
1594 color: SHADOW,
1595 },
1596 ..Default::default()
1597 };
1598 let build = || Button::new("Hold").hold(0.5).hold_fill(SWEEP).visuals(visuals);
1599 let (_, warm) = run_pass(&ctx, vec![], 0.0, build);
1600 let rect = warm.rect;
1601 let center = rect.center();
1602
1603 let (output, pressed) = run_pass(&ctx, vec![press(center)], f64::from(STEP), build);
1604 let progress = hold_state(&ctx, pressed.id).expect("hold state").progress;
1605 assert!(
1606 progress > 0.0 && progress < 1.0,
1607 "the sweep must be partial mid-hold, got progress {progress}"
1608 );
1609
1610 let fills = rect_fills(&output);
1611 let sweep_index = fills
1612 .iter()
1613 .position(|fill| *fill == SWEEP)
1614 .expect("the sweep must be painted mid-hold");
1615 let shadow_indices: Vec<usize> = fills
1616 .iter()
1617 .enumerate()
1618 .filter_map(|(index, fill)| (*fill == SHADOW).then_some(index))
1619 .collect();
1620 assert!(
1621 !shadow_indices.is_empty(),
1622 "the frame's shadow must still be painted while the sweep is drawn"
1623 );
1624 assert!(
1625 shadow_indices.iter().all(|&index| index < sweep_index),
1626 "the shadow (shapes {shadow_indices:?}) paints at or after the sweep (shape {sweep_index}) and would cover it"
1627 );
1628
1629 let (clip_width, clip_left) = sweep_shape(&output, SWEEP).expect("a sweep shape");
1630 assert!(
1631 (clip_width - progress * rect.width()).abs() < 0.01,
1632 "sweep width {clip_width} does not track progress {progress} of {}",
1633 rect.width()
1634 );
1635 assert!(
1636 (clip_left - rect.left()).abs() < 0.01,
1637 "the sweep must start at the button's left edge"
1638 );
1639 }
1640
1641 #[test]
1642 fn the_chrome_stays_pressed_for_the_whole_hold() {
1643 const SECONDS: f32 = 1.5;
1644 const EARLY: f64 = 0.4;
1646 const PAST_CEILING: f64 = 0.2;
1648 const IDLE: Color32 = Color32::from_rgb(10, 20, 30);
1651 const HOVER: Color32 = Color32::from_rgb(40, 50, 60);
1652 const PRESSED: Color32 = Color32::from_rgb(70, 80, 90);
1653 let ctx = Context::default();
1654 let ceiling = max_click_duration(&ctx);
1655 assert!(
1656 f64::from(SECONDS) > ceiling + PAST_CEILING,
1657 "this test only bites while its {SECONDS}s hold outlives egui's {ceiling}s click ceiling"
1658 );
1659 let visuals = ButtonVisuals {
1660 bg: IDLE,
1661 bg_hover: HOVER,
1662 bg_click: PRESSED,
1663 ..Default::default()
1664 };
1665 let build = || Button::new("Hold").hold(SECONDS).visuals(visuals);
1666 let (_, warm) = run_pass(&ctx, vec![], 0.0, build);
1667 let rect = warm.rect;
1668 let center = rect.center();
1669
1670 let mut time = f64::from(STEP);
1671 run_pass(&ctx, vec![press(center)], time, build);
1672 let press_time = time;
1673
1674 let max_frames = (SECONDS / HOLD_MAX_DT).ceil() as usize + 2;
1675 let mut early = None;
1676 let mut late = None;
1677 for _ in 0..max_frames {
1678 time += f64::from(STEP);
1679 let (output, frame) = run_pass(&ctx, vec![], time, build);
1680 let elapsed = time - press_time;
1681 if early.is_none() && elapsed >= EARLY {
1682 early = base_fill(&output, rect);
1683 } else if late.is_none() && elapsed >= ceiling + PAST_CEILING {
1684 late = base_fill(&output, rect);
1685 break;
1686 }
1687 if frame.clicked() {
1688 break;
1689 }
1690 }
1691
1692 let early = early.expect("the pressed chrome must be painted early in the hold");
1693 let late = late.expect("the pressed chrome must still be painted past the click ceiling");
1694 assert_eq!(
1695 early, late,
1696 "the chrome un-pressed at egui's click ceiling: {early:?} then {late:?} (worst case {IDLE:?}/{HOVER:?})"
1697 );
1698 assert_eq!(
1699 early, PRESSED,
1700 "the hold must paint its pressed chrome while the pointer is down, got {early:?}"
1701 );
1702 }
1703
1704 #[test]
1705 fn the_chrome_releases_when_the_hold_completes() {
1706 const SECONDS: f32 = 0.5;
1707 const IDLE: Color32 = Color32::from_rgb(10, 20, 30);
1710 const HOVER: Color32 = Color32::from_rgb(40, 50, 60);
1711 const PRESSED: Color32 = Color32::from_rgb(70, 80, 90);
1712 let ctx = Context::default();
1713 let visuals = ButtonVisuals {
1714 bg: IDLE,
1715 bg_hover: HOVER,
1716 bg_click: PRESSED,
1717 ..Default::default()
1718 };
1719 let build = || Button::new("Hold").hold(SECONDS).visuals(visuals);
1720 let (_, warm) = run_pass(&ctx, vec![], 0.0, build);
1721 let rect = warm.rect;
1722 let id = warm.id;
1723 let center = rect.center();
1724
1725 let mut time = f64::from(STEP);
1726 run_pass(&ctx, vec![press(center)], time, build);
1727
1728 time += f64::from(STEP);
1730 let (held_output, _) = run_pass(&ctx, vec![], time, build);
1731 let held_progress = hold_state(&ctx, id).expect("hold state").progress;
1732 assert!(
1733 held_progress > 0.0 && held_progress < 1.0,
1734 "the hold must still be running, got progress {held_progress}"
1735 );
1736 assert_eq!(
1737 base_fill(&held_output, rect),
1738 Some(PRESSED),
1739 "the pressed chrome must be painted mid-hold"
1740 );
1741
1742 let max_frames = (SECONDS / HOLD_MAX_DT).ceil() as usize + 2;
1744 let mut completed_output = None;
1745 for _ in 0..max_frames {
1746 time += f64::from(STEP);
1747 let (output, frame) = run_pass(&ctx, vec![], time, build);
1748 if frame.clicked() {
1749 assert_eq!(
1750 hold_state(&ctx, id).expect("hold state").progress,
1751 1.0,
1752 "the completed hold must be full"
1753 );
1754 completed_output = Some(output);
1755 break;
1756 }
1757 assert!(
1758 hold_state(&ctx, id).expect("hold state").progress < 1.0,
1759 "the hold must not be full before it clicks"
1760 );
1761 }
1762 let completed_output = completed_output.expect("the hold must complete without a release");
1763 assert_ne!(
1764 base_fill(&completed_output, rect),
1765 Some(PRESSED),
1766 "the pressed chrome must be released once the hold fires"
1767 );
1768
1769 time += f64::from(STEP);
1771 let (after_output, _) = run_pass(&ctx, vec![], time, build);
1772 assert_eq!(
1773 hold_state(&ctx, id).expect("hold state").progress,
1774 1.0,
1775 "the pointer is still down, so the completed sweep must stay full"
1776 );
1777 assert_ne!(
1778 base_fill(&after_output, rect),
1779 Some(PRESSED),
1780 "the pressed chrome must stay released while the completed hold is still held"
1781 );
1782 }
1783
1784 #[test]
1785 fn a_second_press_starts_a_fresh_hold() {
1786 const SECONDS: f32 = 1.0;
1787 let build = || Button::new("Hold").hold(SECONDS);
1788 let ctx = Context::default();
1789 let (_, warm) = run_pass(&ctx, vec![], 0.0, build);
1790 let id = warm.id;
1791 let center = warm.rect.center();
1792 let max_frames = (SECONDS / HOLD_MAX_DT).ceil() as usize + 2;
1793
1794 let mut time = f64::from(STEP);
1795 run_pass(&ctx, vec![press(center)], time, build);
1796
1797 let mut completion_time = time;
1799 let mut first_fired = false;
1800 for _ in 0..max_frames {
1801 time += f64::from(STEP);
1802 let (_, frame) = run_pass(&ctx, vec![], time, build);
1803 if frame.clicked() {
1804 first_fired = true;
1805 completion_time = time;
1806 break;
1807 }
1808 }
1809 assert!(first_fired, "the first hold must fire");
1810 assert_eq!(
1811 hold_state(&ctx, id).expect("hold state").progress,
1812 1.0
1813 );
1814
1815 time = completion_time + f64::from(STEP);
1817 let (_, released) = run_pass(&ctx, vec![release(center)], time, build);
1818 assert!(!released.clicked(), "a release must not click");
1819 assert_eq!(
1820 hold_state(&ctx, id).expect("hold state").progress,
1821 0.0,
1822 "a release must reset the first hold"
1823 );
1824
1825 time += f64::from(STEP);
1827 let (_, second) = run_pass(&ctx, vec![press(center)], time, build);
1828 assert!(
1829 !second.clicked(),
1830 "the second press must not click immediately"
1831 );
1832 let second_progress = hold_state(&ctx, id).expect("hold state").progress;
1833 assert!(
1834 second_progress > 0.0 && second_progress < 1.0,
1835 "the second gesture must start from zero, got {second_progress}"
1836 );
1837
1838 let mut second_fired = false;
1839 for _ in 0..max_frames {
1840 time += f64::from(STEP);
1841 let (_, frame) = run_pass(&ctx, vec![], time, build);
1842 if frame.clicked() {
1843 second_fired = true;
1844 break;
1845 }
1846 }
1847 assert!(
1848 second_fired,
1849 "the second hold must fire on its own"
1850 );
1851 }
1852 #[test]
1853 fn a_solid_fill_ignores_progress() {
1854 let settings = HoldSettings {
1855 seconds: 1.0,
1856 fill_from: Some(SWEEP),
1857 fill_to: None,
1858 ..Default::default()
1859 };
1860 let build = move || Button::new("Hold").hold_settings(&settings);
1861 let ctx = Context::default();
1862 let (_, warm) = run_pass(&ctx, vec![], 0.0, build);
1863 let rect = warm.rect;
1864
1865 let mut time = f64::from(STEP);
1866 run_pass(&ctx, vec![press(rect.center())], time, build);
1867
1868 time += f64::from(STEP);
1869 let (early, _) = run_pass(&ctx, vec![], time, build);
1870 let early_progress = hold_state(&ctx, warm.id).expect("hold state").progress;
1871 assert!(
1872 (early_progress - 0.2).abs() < 1e-6,
1873 "unexpected early progress {early_progress}"
1874 );
1875 assert_eq!(
1876 painted_sweep(&early, rect, early_progress),
1877 Some(SWEEP),
1878 "a solid fill must paint its colour at {early_progress} progress"
1879 );
1880
1881 let mut late = None;
1882 for _ in 0..7 {
1883 time += f64::from(STEP);
1884 let (output, _) = run_pass(&ctx, vec![], time, build);
1885 late = Some(output);
1886 }
1887 let late = late.expect("a late frame");
1888 let late_progress = hold_state(&ctx, warm.id).expect("hold state").progress;
1889 assert!(
1890 (late_progress - 0.9).abs() < 1e-6,
1891 "unexpected late progress {late_progress}"
1892 );
1893 assert_eq!(
1894 painted_sweep(&late, rect, late_progress),
1895 Some(SWEEP),
1896 "a solid fill must paint the same colour at {late_progress} progress"
1897 );
1898 }
1899
1900 #[test]
1901 fn the_fill_shifts_towards_the_second_colour() {
1902 const FROM: Color32 = Color32::from_rgb(16, 200, 32);
1904 const TO: Color32 = Color32::from_rgb(224, 48, 176);
1907 let settings = HoldSettings {
1908 seconds: 1.0,
1909 fill_from: Some(FROM),
1910 fill_to: Some(TO),
1911 ..Default::default()
1912 };
1913 let build = move || Button::new("Hold").hold_settings(&settings);
1914 let ctx = Context::default();
1915 let (_, warm) = run_pass(&ctx, vec![], 0.0, build);
1916 let rect = warm.rect;
1917
1918 let mut time = f64::from(STEP);
1919 run_pass(&ctx, vec![press(rect.center())], time, build);
1920
1921 time += f64::from(STEP);
1922 let (early, _) = run_pass(&ctx, vec![], time, build);
1923 let early_progress = hold_state(&ctx, warm.id).expect("hold state").progress;
1924 assert!(
1925 (early_progress - 0.2).abs() < 1e-6,
1926 "unexpected early progress {early_progress}"
1927 );
1928 let early = painted_sweep(&early, rect, early_progress).expect("the early sweep");
1929
1930 let mut late = None;
1931 for _ in 0..7 {
1932 time += f64::from(STEP);
1933 let (output, _) = run_pass(&ctx, vec![], time, build);
1934 late = Some(output);
1935 }
1936 let late = late.expect("a late frame");
1937 let late_progress = hold_state(&ctx, warm.id).expect("hold state").progress;
1938 assert!(
1939 (late_progress - 0.9).abs() < 1e-6,
1940 "unexpected late progress {late_progress}"
1941 );
1942 let late = painted_sweep(&late, rect, late_progress).expect("the late sweep");
1943
1944 assert_ne!(
1945 early, late,
1946 "the fill painted the same colour at {early_progress} and {late_progress}"
1947 );
1948 for (from, to, actual, label) in [
1949 (FROM.r(), TO.r(), early.r(), "red early"),
1950 (FROM.g(), TO.g(), early.g(), "green early"),
1951 (FROM.b(), TO.b(), early.b(), "blue early"),
1952 (FROM.r(), TO.r(), late.r(), "red late"),
1953 (FROM.g(), TO.g(), late.g(), "green late"),
1954 (FROM.b(), TO.b(), late.b(), "blue late"),
1955 ] {
1956 assert!(
1957 actual >= from.min(to).saturating_sub(1) && actual <= from.max(to).saturating_add(1),
1958 "{label} channel {actual} is not between {from} and {to}"
1959 );
1960 }
1961 }
1962
1963 #[test]
1964 fn the_fill_clears_when_the_hold_fires() {
1965 let settings = HoldSettings {
1966 seconds: 0.5,
1967 fill_from: Some(SWEEP),
1968 fill_to: None,
1969 ..Default::default()
1970 };
1971 let build = move || Button::new("Hold").hold_settings(&settings);
1972 let ctx = Context::default();
1973 let (_, warm) = run_pass(&ctx, vec![], 0.0, build);
1974 let center = warm.rect.center();
1975
1976 let mut time = f64::from(STEP);
1977 let (held, pressed) = run_pass(&ctx, vec![press(center)], time, build);
1978 let progress = hold_state(&ctx, pressed.id).expect("hold state").progress;
1979 assert!(
1980 progress > 0.0 && progress < 1.0,
1981 "the sweep must be partial mid-hold, got progress {progress}"
1982 );
1983 assert!(
1984 sweep_shape(&held, SWEEP).is_some(),
1985 "the sweep must be painted mid-hold"
1986 );
1987
1988 let (completed, completion_time) = advance_until_fired(&ctx, &mut time, &build);
1989 assert!(
1990 sweep_shape(&completed, SWEEP).is_none(),
1991 "the sweep must clear on the frame the hold fires"
1992 );
1993
1994 time = completion_time + f64::from(STEP);
1995 let (after, _) = run_pass(&ctx, vec![], time, build);
1996 assert_eq!(
1997 hold_state(&ctx, warm.id).expect("hold state").progress,
1998 1.0,
1999 "the pointer is still down, so the completed hold must stay full"
2000 );
2001 assert!(
2002 sweep_shape(&after, SWEEP).is_none(),
2003 "the sweep must stay cleared while the completed hold is still held"
2004 );
2005 }
2006
2007 #[test]
2008 fn burst_off_paints_nothing_and_costs_no_frames() {
2009 let settings = HoldSettings {
2010 seconds: 0.5,
2011 burst: HoldBurst::Off,
2012 ..Default::default()
2013 };
2014 let (ctx, completed, warm, mut time) = complete_hold(settings);
2015 assert!(
2016 !has_burst_shape(&completed),
2017 "a burst set to Off must not paint a completion shape"
2018 );
2019
2020 let build = move || Button::new("Hold").hold_settings(&settings);
2022 time += f64::from(STEP);
2023 let (after, _) = run_pass(&ctx, vec![], time, build);
2024 assert_eq!(
2025 hold_state(&ctx, warm.id).expect("hold state").progress,
2026 1.0,
2027 "the pointer is still down after completion"
2028 );
2029 assert!(
2030 !has_burst_shape(&after),
2031 "an Off burst must stay unpainted while the completed hold is held"
2032 );
2033 let delay = after.viewport_output[&ViewportId::ROOT].repaint_delay;
2034 assert_eq!(
2035 delay,
2036 Duration::MAX,
2037 "an Off burst requested a repaint after {delay:?}"
2038 );
2039 }
2040
2041 #[test]
2042 fn the_burst_honours_its_duration() {
2043 const BURST_MS: f32 = 600.0;
2045 let settings = HoldSettings {
2046 seconds: 0.5,
2047 burst: HoldBurst::Glow,
2048 burst_ms: BURST_MS,
2049 ..Default::default()
2050 };
2051 let (ctx, _, _, completion_time) = complete_hold(settings);
2052 let build = move || Button::new("Hold").hold_settings(&settings);
2053 let mut time = completion_time;
2054
2055 for _ in 0..3 {
2057 time += f64::from(STEP);
2058 run_pass(&ctx, vec![], time, build);
2059 }
2060 let (still, _) = run_pass(&ctx, vec![], time, build);
2061 assert!(
2062 mesh_alpha(&still) > 0,
2063 "the {BURST_MS} ms burst was already invisible ~400 ms after completion"
2064 );
2065
2066 for _ in 0..2 {
2068 time += f64::from(STEP);
2069 run_pass(&ctx, vec![], time, build);
2070 }
2071 let (gone, _) = run_pass(&ctx, vec![], time, build);
2072 assert_eq!(
2073 mesh_alpha(&gone),
2074 0,
2075 "the {BURST_MS} ms burst still painted ~700 ms after completion"
2076 );
2077 }
2078
2079 #[test]
2080 fn burst_intensity_zero_hides_the_burst() {
2081 let visible = HoldSettings {
2082 seconds: 0.5,
2083 burst: HoldBurst::Glow,
2084 burst_intensity: 1.0,
2085 ..Default::default()
2086 };
2087 let hidden = HoldSettings {
2088 burst_intensity: 0.0,
2089 ..visible
2090 };
2091
2092 let (_, hidden_output, _, _) = complete_hold(hidden);
2093 assert_eq!(
2094 mesh_alpha(&hidden_output),
2095 0,
2096 "intensity 0 must not paint a visible glow"
2097 );
2098
2099 let (_, visible_output, _, _) = complete_hold(visible);
2100 assert!(
2101 mesh_alpha(&visible_output) > 0,
2102 "intensity 1 must paint the glow"
2103 );
2104 }
2105
2106 #[test]
2107 fn lightning_knobs_reach_the_effect() {
2108 let few = HoldSettings {
2109 seconds: 0.5,
2110 burst: HoldBurst::Lightning,
2111 burst_bolts: 1,
2112 burst_branch_depth: 1,
2113 ..Default::default()
2114 };
2115 let many = HoldSettings {
2116 burst_bolts: 4,
2117 burst_branch_depth: 3,
2118 ..few
2119 };
2120
2121 let (_, few_output, _, _) = complete_hold(few);
2122 let (_, many_output, _, _) = complete_hold(many);
2123 let few_paths = path_count(&few_output);
2124 let many_paths = path_count(&many_output);
2125 assert!(
2126 few_paths > 0,
2127 "the lightning burst must paint at least one bolt stroke"
2128 );
2129 assert!(
2130 many_paths > few_paths,
2131 "4 bolts / depth 3 painted {many_paths} bolt polylines, 1 bolt / depth 1 painted {few_paths}"
2132 );
2133 }
2134
2135 #[test]
2136 fn the_glow_size_range_is_honoured() {
2137 const BURST_MS: f32 = 2000.0;
2139 const BURST_DT: f32 = HOLD_MAX_DT;
2141 const LATE_FRAMES: usize = ((BURST_MS / 1000.0 - BURST_DT) / BURST_DT) as usize;
2144 const FROM: f32 = 1.0;
2146 const SMALL_TO: f32 = 1.4;
2148 const LARGE_TO: f32 = 2.6;
2150
2151 let base = HoldSettings {
2152 seconds: 0.5,
2153 burst: HoldBurst::Glow,
2154 burst_ms: BURST_MS,
2155 burst_glow_from: FROM,
2156 ..Default::default()
2157 };
2158 let small = HoldSettings {
2159 burst_glow_to: SMALL_TO,
2160 ..base
2161 };
2162 let large = HoldSettings {
2163 burst_glow_to: LARGE_TO,
2164 ..base
2165 };
2166
2167 let (start_mesh, rect) = glow_burst_after(small, 0);
2170 let (small_mesh, _) = glow_burst_after(small, LATE_FRAMES);
2171 let (large_mesh, _) = glow_burst_after(large, LATE_FRAMES);
2172 let center = rect.center();
2173 let short = rect.width().min(rect.height()).max(1.0);
2174
2175 let start_radius = mesh_radius(&start_mesh, center);
2176 assert!(
2177 (start_radius - short * FROM).abs() < 1e-3,
2178 "the glow started at {start_radius} px, expected {short} px (short x {FROM})"
2179 );
2180
2181 let t = 1.0 - BURST_DT / (BURST_MS / 1000.0);
2182 let small_radius = mesh_radius(&small_mesh, center);
2183 let large_radius = mesh_radius(&large_mesh, center);
2184 let expected_small = short * (FROM + (SMALL_TO - FROM) * t);
2185 let expected_large = short * (FROM + (LARGE_TO - FROM) * t);
2186 assert!(
2187 (small_radius - expected_small).abs() < 1e-3,
2188 "the small glow's bounding radius was {small_radius} px, expected \
2189 {expected_small} px (short {short} x {FROM} + ({SMALL_TO} - {FROM}) x {t})"
2190 );
2191 assert!(
2192 (large_radius - expected_large).abs() < 1e-3,
2193 "the large glow's bounding radius was {large_radius} px, expected \
2194 {expected_large} px (short {short} x {FROM} + ({LARGE_TO} - {FROM}) x {t})"
2195 );
2196 assert!(
2197 large_radius > small_radius,
2198 "a larger end radius must paint a larger glow: {large_radius} px vs {small_radius} px"
2199 );
2200 assert!(
2201 (large_radius - short * LARGE_TO).abs() < 0.1 * short * LARGE_TO,
2202 "the late large glow reached {large_radius} px, not ~ short x {LARGE_TO} = {} px",
2203 short * LARGE_TO
2204 );
2205 }
2206
2207 #[test]
2208 fn softness_one_matches_the_shared_profile() {
2209 const BURST: Color32 = Color32::from_rgb(140, 90, 40);
2211
2212 let settings = HoldSettings {
2213 seconds: 0.5,
2214 burst: HoldBurst::Glow,
2215 burst_color: Some(BURST),
2216 burst_glow_softness: 1.0,
2217 ..Default::default()
2218 };
2219
2220 let (mesh, rect) = glow_burst_after(settings, 0);
2223 let short = rect.width().min(rect.height()).max(1.0);
2224 let expected = crate::glow::glow_mesh(rect.center(), short, BURST);
2225 assert_eq!(
2226 mesh, expected,
2227 "softness 1.0 must reproduce the shared glow fan vertex for vertex"
2228 );
2229
2230 let softer = HoldSettings {
2231 burst_glow_softness: 2.0,
2232 ..settings
2233 };
2234 let (softer_mesh, _) = glow_burst_after(softer, 0);
2235 assert_ne!(
2236 softer_mesh, expected,
2237 "softness 2.0 must reshape the shared glow fan"
2238 );
2239 }
2240
2241 #[test]
2242 fn the_strike_thickness_and_jitter_reach_the_effect() {
2243 const THIN: f32 = 1.0;
2245 const THICK: f32 = 8.0;
2246 const STRAIGHT_TOLERANCE: f32 = 1e-3;
2249
2250 let base = HoldSettings {
2251 seconds: 0.5,
2252 burst: HoldBurst::Lightning,
2253 burst_thickness: THIN,
2254 burst_jitter: 0.0,
2255 ..Default::default()
2256 };
2257
2258 let (_, thin_output, _, _) = complete_hold_sized(base);
2259 let thin = polylines(&thin_output);
2260 assert!(
2261 !thin.is_empty(),
2262 "the strike must paint its polylines"
2263 );
2264 let thin_core = thin.iter().map(|(_, width)| *width).fold(f32::MAX, f32::min);
2265 assert!(
2266 (thin_core - THIN).abs() < 1e-6,
2267 "the thinnest stroke was {thin_core} px, expected the core's {THIN} px"
2268 );
2269
2270 let (_, thick_output, _, _) = complete_hold_sized(HoldSettings {
2271 burst_thickness: THICK,
2272 ..base
2273 });
2274 let thick = polylines(&thick_output);
2275 let thick_core = thick.iter().map(|(_, width)| *width).fold(f32::MAX, f32::min);
2276 assert!(
2277 (thick_core - THICK).abs() < 1e-6,
2278 "the thinnest stroke was {thick_core} px, expected the core's {THICK} px"
2279 );
2280 assert!(
2281 thick_core > thin_core,
2282 "a thicker burst must paint a wider core: {thick_core} px vs {thin_core} px"
2283 );
2284
2285 let trunk = thin
2289 .first()
2290 .map(|(points, _)| points.as_slice())
2291 .expect("the strike must paint a trunk");
2292 let straight = max_deviation(trunk);
2293 assert!(
2294 straight <= STRAIGHT_TOLERANCE,
2295 "jitter 0 still deviated by {straight} px"
2296 );
2297
2298 let (_, jittered_output, _, _) = complete_hold_sized(HoldSettings {
2299 burst_jitter: 0.6,
2300 ..base
2301 });
2302 let jittered = polylines(&jittered_output);
2303 let jittered_trunk = jittered
2304 .first()
2305 .map(|(points, _)| points.as_slice())
2306 .expect("the jittered strike must paint a trunk");
2307 let deviated = max_deviation(jittered_trunk);
2308 assert!(
2309 deviated > STRAIGHT_TOLERANCE,
2310 "jitter 0.6 must bend the bolt, but the trunk's largest deviation was {deviated} px"
2311 );
2312 }
2313
2314 #[test]
2315 fn default_settings_paint_the_previous_burst() {
2316 let settings = HoldSettings {
2320 seconds: 0.5,
2321 burst: HoldBurst::Lightning,
2322 ..Default::default()
2323 };
2324 let (ctx, output, warm, _) = complete_hold(settings);
2325 let actual = polylines(&output);
2326 assert!(
2327 !actual.is_empty(),
2328 "the default Lightning burst must paint its polylines"
2329 );
2330
2331 let accent = Theme::current(&ctx).colors.accent;
2332 let color = settings.burst_color.unwrap_or(accent);
2333 let area = warm.rect;
2334 let effect = || {
2335 Lightning::new(("hold_burst", warm.id))
2336 .color(color)
2337 .bolt_count(settings.burst_bolts)
2338 .branch_depth(settings.burst_branch_depth)
2339 .duration_ms(settings.burst_ms)
2340 .flash_intensity(HOLD_BURST_FLASH)
2341 .area(area)
2342 };
2343 let reference_ctx = Context::default();
2344 let mut reference = reference_ctx.run_ui(
2345 RawInput {
2346 screen_rect: Some(screen()),
2347 time: Some(f64::from(STEP)),
2348 predicted_dt: STEP,
2349 ..Default::default()
2350 },
2351 |ui| {
2352 effect().strike(ui.ctx());
2353 effect().show(ui.ctx());
2354 },
2355 );
2356 reference.textures_delta.clear();
2357 assert_eq!(
2358 actual,
2359 polylines(&reference),
2360 "the default Lightning burst must paint the effect's own default polylines and strokes"
2361 );
2362
2363 let (glow_ctx, glow_output, glow_warm, _) = complete_hold(HoldSettings {
2366 seconds: 0.5,
2367 ..Default::default()
2368 });
2369 let mesh = burst_mesh(&glow_output).expect("the default Glow burst must paint a mesh");
2370 let short = glow_warm.rect.width().min(glow_warm.rect.height()).max(1.0);
2371 let accent = Theme::current(&glow_ctx).colors.accent;
2372 assert_eq!(
2373 mesh,
2374 crate::glow::glow_mesh(glow_warm.rect.center(), short, accent),
2375 "the default Glow burst must emit the pre-change fan at short x 1.0"
2376 );
2377 }
2378
2379 #[test]
2380 fn hold_settings_sets_the_duration() {
2381 let settings = HoldSettings {
2382 seconds: 0.5,
2383 ..Default::default()
2384 };
2385 let from_settings = press_outcome(move || Button::new("Hold").hold_settings(&settings));
2386 let from_shortcut = press_outcome(|| Button::new("Hold").hold(0.5));
2387 assert_eq!(
2388 from_settings, from_shortcut,
2389 "hold_settings(seconds = 0.5) must fire exactly like hold(0.5)"
2390 );
2391 assert_eq!(
2392 from_settings.0, 0.2,
2393 "the first press frame must accumulate one capped step"
2394 );
2395 }
2396
2397 #[test]
2398 fn shortcuts_and_settings_compose_in_call_order() {
2399 let later_shortcut = press_outcome(|| Button::new("Hold").hold(2.0).hold(0.5));
2401 let plain = press_outcome(|| Button::new("Hold").hold(0.5));
2402 assert_eq!(
2403 later_shortcut, plain,
2404 "`.hold(2.0).hold(0.5)` must behave like `.hold(0.5)`"
2405 );
2406
2407 const SHORTCUT_FILL: Color32 = Color32::from_rgb(220, 20, 20);
2409 const SETTINGS_FILL: Color32 = Color32::from_rgb(20, 220, 20);
2411 let settings = HoldSettings {
2412 seconds: 0.5,
2413 fill_from: Some(SETTINGS_FILL),
2414 fill_to: None,
2415 ..Default::default()
2416 };
2417 let build = move || Button::new("Hold").hold_fill(SHORTCUT_FILL).hold_settings(&settings);
2418 let ctx = Context::default();
2419 let (_, warm) = run_pass(&ctx, vec![], 0.0, build);
2420 let (pressed, _) = run_pass(
2421 &ctx,
2422 vec![press(warm.rect.center())],
2423 f64::from(STEP),
2424 build,
2425 );
2426 assert!(
2427 sweep_shape(&pressed, SETTINGS_FILL).is_some(),
2428 "`.hold_fill(a).hold_settings(&s)` must use the settings' fill"
2429 );
2430 assert!(
2431 sweep_shape(&pressed, SHORTCUT_FILL).is_none(),
2432 "the earlier shortcut fill must have been replaced"
2433 );
2434
2435 let before_hold = || Button::new("Hold").hold_fill(SHORTCUT_FILL).hold(0.5);
2437 let ctx = Context::default();
2438 let (_, warm) = run_pass(&ctx, vec![], 0.0, before_hold);
2439 let (pressed, _) = run_pass(
2440 &ctx,
2441 vec![press(warm.rect.center())],
2442 f64::from(STEP),
2443 before_hold,
2444 );
2445 assert!(
2446 sweep_shape(&pressed, SHORTCUT_FILL).is_some(),
2447 "`.hold_fill(a).hold(seconds)` must keep the earlier fill"
2448 );
2449 }
2450
2451 #[test]
2452 fn the_settings_body_renders_and_the_widget_uses_its_fields() {
2453 let ctx = Context::default();
2454 let mut settings = HoldSettings {
2455 seconds: 0.5,
2456 fill_from: Some(Color32::from_rgb(1, 1, 1)),
2457 ..Default::default()
2458 };
2459
2460 let output = ctx.run_ui(
2461 RawInput {
2462 screen_rect: Some(screen()),
2463 ..Default::default()
2464 },
2465 |ui| {
2466 settings.body_ui(ui);
2467 },
2468 );
2469 output.drop_without_applying_deltas();
2470 assert!(
2471 !settings.open,
2472 "body_ui must not touch the window flag"
2473 );
2474
2475 settings.fill_from = Some(SWEEP);
2477 let build = move || Button::new("Hold").hold_settings(&settings);
2478 let (_, warm) = run_pass(&ctx, vec![], 0.0, build);
2479 let (pressed, response) = run_pass(
2480 &ctx,
2481 vec![press(warm.rect.center())],
2482 f64::from(STEP),
2483 build,
2484 );
2485 assert_eq!(
2486 hold_state(&ctx, response.id).expect("hold state").progress,
2487 0.2,
2488 "the widget must use the settings' duration"
2489 );
2490 assert!(
2491 sweep_shape(&pressed, SWEEP).is_some(),
2492 "the widget must use the fill edited in the struct"
2493 );
2494 }
2495
2496 const WIDE: Vec2 = vec2(240.0, 44.0);
2498
2499 #[test]
2500 fn the_default_label_alignment_is_left() {
2501 let ctx = Context::default();
2502 let (output, response) = run_pass(&ctx, vec![], 0.0, || {
2503 Button::new("Reset").min_size(WIDE)
2504 });
2505 let text = text_rect(&output, "Reset").expect("the label must be painted");
2506 let padding = ctx.style_of(ctx.theme()).spacing.button_padding.x;
2507 assert!(
2508 (text.left() - (response.rect.left() + padding)).abs() < 1.0,
2509 "the default label must start at the button's left edge plus padding: \
2510 label left {} vs button left {} plus padding {padding}",
2511 text.left(),
2512 response.rect.left()
2513 );
2514 assert!(
2515 text.left() < response.rect.center().x,
2516 "the default label must sit left of the button's centre"
2517 );
2518 }
2519
2520 #[test]
2521 fn a_centred_label_sits_in_the_middle() {
2522 let ctx = Context::default();
2523 let (output, response) = run_pass(&ctx, vec![], 0.0, || {
2524 Button::new("Reset").min_size(WIDE).label_align(Align::Center)
2525 });
2526 let text = text_rect(&output, "Reset").expect("the label must be painted");
2527 assert!(
2528 (text.center().x - response.rect.center().x).abs() <= 2.0,
2529 "the centred label's centre {} must match the button's centre {}",
2530 text.center().x,
2531 response.rect.center().x
2532 );
2533 }
2534
2535 #[test]
2536 fn a_right_aligned_label_moves_to_the_end() {
2537 let ctx = Context::default();
2538 let (output, response) = run_pass(&ctx, vec![], 0.0, || {
2539 Button::new("Reset").min_size(WIDE).label_align(Align::Max)
2540 });
2541 let text = text_rect(&output, "Reset").expect("the label must be painted");
2542 let padding = ctx.style_of(ctx.theme()).spacing.button_padding.x;
2543 assert!(
2544 (text.right() - (response.rect.right() - padding)).abs() <= 2.0,
2545 "the right-aligned label's right edge {} must sit near the button's \
2546 right edge {} minus padding {padding}",
2547 text.right(),
2548 response.rect.right()
2549 );
2550 }
2551
2552 #[test]
2553 fn a_right_text_atom_stays_at_the_right_edge() {
2554 let ctx = Context::default();
2555 let padding = ctx.style_of(ctx.theme()).spacing.button_padding.x;
2556 for align in [Align::Min, Align::Center, Align::Max] {
2557 let (output, response) = run_pass(&ctx, vec![], 0.0, || {
2558 Button::new("Reset").min_size(WIDE).right_text("\u{2318}K").label_align(align)
2559 });
2560 let right = text_rect(&output, "\u{2318}K").expect("the right text must be painted");
2561 assert!(
2562 (right.right() - (response.rect.right() - padding)).abs() <= 2.0,
2563 "the right text must stay at the button's right edge under {align:?}: \
2564 its right edge {} vs the button's right edge {}",
2565 right.right(),
2566 response.rect.right()
2567 );
2568 let label = text_rect(&output, "Reset").expect("the label must be painted");
2572 assert!(
2573 (label.left() - (response.rect.left() + padding)).abs() <= 2.0,
2574 "with a trailing grow atom the label must stay left under {align:?}: \
2575 its left edge {} vs the button's left edge {} plus padding {padding}",
2576 label.left(),
2577 response.rect.left()
2578 );
2579 }
2580 }
2581}