Skip to main content

egui_elements/widgets/
button.rs

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
14/// Longest `stable_dt` the hold accumulation accepts, in seconds (spike guard).
15const HOLD_MAX_DT: f32 = 0.1;
16/// Width of the sweep's leading-edge line, in pixels.
17const HOLD_EDGE_WIDTH: f32 = 1.5;
18/// Peak flash alpha of a [`HoldBurst::Lightning`] completion burst at full
19/// intensity.
20const HOLD_BURST_FLASH: f32 = 0.22;
21/// [`HoldBurst::Glow`] radius at the start of the burst, as a multiple of the
22/// button's shorter side; the default of [`HoldSettings::burst_glow_from`].
23pub(crate) const HOLD_GLOW_START: f32 = 1.0;
24/// [`HoldBurst::Glow`] radius at the end of the burst, as a multiple of the
25/// button's shorter side; the default of [`HoldSettings::burst_glow_to`].
26pub(crate) const HOLD_GLOW_END: f32 = 2.2;
27
28/// Per-button hold-to-confirm state, keyed by the button's response [`Id`].
29#[derive(Clone, Copy, Default)]
30struct HoldState {
31   /// Whether this widget owns the current press.
32   ///
33   /// Latched from egui's hit test at press time, while that test is still
34   /// valid, and kept while the primary button is down so the gesture outlives
35   /// egui's `max_click_duration`.
36   pressing: bool,
37   /// Fraction of the hold completed, `0.0..=1.0`.
38   progress: f32,
39   /// Whether the current hold has already fired (so it fires once).
40   fired: bool,
41   /// Seconds since the burst started, while one is animating.
42   burst_age: Option<f32>,
43}
44
45/// A [`HoldBurst::Lightning`] strike, addressed by a derived id so it never
46/// collides with a host's own [`Lightning`].
47fn 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
60/// Paints one frame of a completion burst on `ui`'s painter.
61fn 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         // Grow and fade the shared glow fan; `size_attenuation` keeps the peak
80         // alpha in family with the Light effect instead of a second falloff.
81         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/// Clickable themed button, built on egui [`Atom`]s.
102///
103/// Reads [`ButtonVisuals`] from the installed [`Theme`] unless you pass
104/// [`.visuals(...)`](Self::visuals). Use [`Button::bg_color`] for a one-off
105/// idle fill (semantic actions). Chromatic fills should use `theme.colors.bg`
106/// as the text color so glyphs stay readable.
107///
108/// ```
109/// # use egui::__run_test_ui;
110/// # use egui_elements::widgets::Button;
111/// # __run_test_ui(|ui| {
112/// if ui.add(Button::new("Save")).clicked() {
113///     // …
114/// }
115/// # });
116/// ```
117#[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   /// Create a button from atoms (text, images, or a mix).
135   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   /// Show a selectable button.
155   ///
156   /// Equivalent to:
157   /// ```rust
158   /// # use egui::{Button, IntoAtoms, __run_test_ui};
159   /// # __run_test_ui(|ui| {
160   /// let selected = true;
161   /// ui.add(Button::new("toggle me").selected(selected).frame_when_inactive(!selected).frame(true));
162   /// # });
163   /// ```
164   ///
165   /// See also:
166   ///   - [`Ui::selectable_value`]
167   ///   - [`Ui::selectable_label`]
168   pub fn selectable(selected: bool, atoms: impl IntoAtoms<'a>) -> Self {
169      Self::new(atoms).selected(selected).frame_when_inactive(selected)
170   }
171
172   /// Creates a button with an image. The size of the image as displayed is defined by the provided size.
173   ///
174   /// Note: In contrast to [`Button::new`], this limits the image size to the default font height
175   /// (using [`egui::AtomExt::atom_max_height_font_size`]).
176   pub fn image(image: impl Into<Image<'a>>) -> Self {
177      Self::opt_image_and_text(Some(image.into()), None)
178   }
179
180   /// Creates a button with an image to the left of the text.
181   ///
182   /// Note: In contrast to [`Button::new`], this limits the image size to the default font height
183   /// (using [`egui::AtomExt::atom_max_height_font_size`]).
184   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   /// Create a button with an optional image and optional text.
189   ///
190   /// Note: In contrast to [`Button::new`], this limits the image size to the default font height
191   /// (using [`egui::AtomExt::atom_max_height_font_size`]).
192   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   /// Set the wrap mode for the text.
205   ///
206   /// By default, [`egui::Ui::wrap_mode`] will be used, which can be overridden with [`egui::Style::wrap_mode`].
207   ///
208   /// Note that any `\n` in the text will always produce a new line.
209   #[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   /// Set [`Self::wrap_mode`] to [`TextWrapMode::Wrap`].
216   #[inline]
217   pub fn wrap(self) -> Self {
218      self.wrap_mode(TextWrapMode::Wrap)
219   }
220
221   /// Set [`Self::wrap_mode`] to [`TextWrapMode::Truncate`].
222   #[inline]
223   pub fn truncate(self) -> Self {
224      self.wrap_mode(TextWrapMode::Truncate)
225   }
226
227   /// Make this a small button, suitable for embedding into text.
228   #[inline]
229   pub fn small(mut self) -> Self {
230      self.small = true;
231      self
232   }
233
234   /// If `false`, the button will not have a frame when inactive.
235   ///
236   /// Default: `true`.
237   ///
238   /// Note: When `ui.visuals().button_frame` is `false`, this setting
239   /// has no effect.
240   #[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   /// By default, buttons senses clicks.
247   /// Change this to a drag-button with `Sense::drag()`.
248   #[inline]
249   pub fn sense(mut self, sense: Sense) -> Self {
250      self.layout = self.layout.sense(sense);
251      self
252   }
253
254   /// Set the minimum size of the button.
255   #[inline]
256   pub fn min_size(mut self, min_size: Vec2) -> Self {
257      self.min_size = min_size;
258      self
259   }
260
261   /// Align the button's atoms within its [`Self::min_size`] rect.
262   ///
263   /// Defaults to [`Align::Min`], which leaves the atoms where the surrounding
264   /// [`Ui`] layout puts them — the rendering from before this builder existed.
265   ///
266   /// The implementation sets the horizontal component of the atom layout's
267   /// [`AtomLayout::align2`]: it moves the measured block of atoms inside the
268   /// allocated rect. It does **not** use the layout's main-axis justification,
269   /// which would stretch the gap *between* atoms instead of moving the block.
270   ///
271   /// A trailing grow atom — added by [`Self::right_text`] or
272   /// [`Self::shortcut_text`] — makes the atom block fill the rect, so there is
273   /// no leftover space for this alignment to move and the trailing text stays
274   /// pinned to the right edge under every alignment.
275   #[inline]
276   pub fn label_align(mut self, align: Align) -> Self {
277      self.label_align = align;
278      self
279   }
280
281   /// If true, the tint of the image is multiplied by the widget text color.
282   ///
283   /// This makes sense for images that are white, that should have the same color as the text color.
284   /// This will also make the icon color depend on hover state.
285   ///
286   /// Default: `false`.
287   #[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   /// Show some text on the right side of the button, in weak color.
294   ///
295   /// Designed for menu buttons, for setting a keyboard shortcut text (e.g. `Ctrl+S`).
296   ///
297   /// The text can be created with [`egui::Context::format_shortcut`].
298   ///
299   /// See also [`Self::right_text`].
300   #[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   /// Show some text on the right side of the button.
313   #[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   /// If `true`, mark this button as "selected".
321   #[inline]
322   pub fn selected(mut self, selected: bool) -> Self {
323      self.selected = selected;
324      self
325   }
326
327   /// Set the visuals of the button
328   #[inline]
329   pub fn visuals(mut self, visuals: ButtonVisuals) -> Self {
330      self.visuals = Some(visuals);
331      self
332   }
333
334   /// Set the background color of the button
335   #[inline]
336   pub fn bg_color(mut self, color: Color32) -> Self {
337      self.bg_color = Some(color);
338      self
339   }
340
341   /// Attach a notification [`Badge`] to a corner of this button.
342   ///
343   /// The badge is painted on top of the button and does not change its size or
344   /// layout. Use it to signal unseen content behind the button — e.g. a red
345   /// circle with the number of notifications.
346   ///
347   /// ```
348   /// # use egui::__run_test_ui;
349   /// # use egui::Color32;
350   /// # use egui_elements::widgets::{Badge, BadgeCorner, Button};
351   /// # __run_test_ui(|ui| {
352   /// ui.add(
353   ///    Button::new("Notifications").badge(
354   ///       Badge::new("3")
355   ///          .color(Color32::RED)
356   ///          .corner(BadgeCorner::TopRight),
357   ///    ),
358   /// );
359   /// # });
360   /// ```
361   #[inline]
362   pub fn badge(mut self, badge: Badge) -> Self {
363      self.badge = Some(badge);
364      self
365   }
366
367   /// Arm the button as hold-to-confirm.
368   ///
369   /// [`clicked`](Response::clicked) then reports `true` only once the pointer
370   /// has been held down on the button for `seconds` — never for a plain click.
371   /// A non-positive `seconds` leaves the button as an ordinary click button.
372   ///
373   /// The widget tracks the press itself: it latches egui's hit test at press
374   /// time and then follows the raw pointer, so **the hold works for durations
375   /// longer than egui's `max_click_duration`** (0.8 s by default), past the
376   /// point where egui stops treating the press as a potential click. Releasing
377   /// the button, or dragging the pointer further than `max_click_dist` away
378   /// from it, cancels the hold.
379   ///
380   /// Note for touch screens: egui may additionally report a long touch as a
381   /// secondary-click (context menu) once the press passes `max_click_duration`;
382   /// this widget does not consume that.
383   ///
384   /// While the hold progresses the frame's fill is replaced by a left-to-right
385   /// sweep that grows as the hold completes; when it fills, the button fires
386   /// once and plays its [`HoldSettings::burst`]. Pressing Enter or Space on a
387   /// focused button confirms immediately, like egui's keyboard activation for
388   /// buttons.
389   ///
390   /// This shortcut only sets [`HoldSettings::seconds`]; every other field keeps
391   /// its current value, so shortcuts compose in call order.
392   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   /// Set a solid hold-sweep colour, and the default tint of a
400   /// [`HoldBurst::Glow`] burst.
401   ///
402   /// This sets [`HoldSettings::fill_from`] and clears
403   /// [`HoldSettings::fill_to`], so the sweep stays one colour; use
404   /// [`Self::hold_settings`] for a colour that shifts as the hold fills.
405   ///
406   /// Defaults to the installed theme's
407   /// [`colors.accent`](crate::theme::ThemeColors::accent). This shortcut does
408   /// **not** arm the hold on its own — call [`Self::hold`] or
409   /// [`Self::hold_settings`] too.
410   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   /// Set the completion burst played when the hold fires.
421   ///
422   /// This sets [`HoldSettings::burst`]. Default: [`HoldBurst::Glow`]. This
423   /// shortcut does **not** arm the hold on its own — call [`Self::hold`] or
424   /// [`Self::hold_settings`] too.
425   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   /// Replace the whole hold configuration with `settings`.
435   ///
436   /// Copies every field, including [`HoldSettings::seconds`]; a value of
437   /// `0.0` or less leaves the button as an ordinary click button. The most
438   /// recent of this call and the per-field shortcuts wins for the fields it
439   /// sets.
440   pub fn hold_settings(mut self, settings: &HoldSettings) -> Self {
441      self.hold = Some(*settings);
442      self
443   }
444
445   /// Show the button and return a [`AtomLayoutResponse`] for painting custom contents.
446   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      // Only the horizontal component is overridden: `Align::Min` keeps the
482      // surrounding layout's own alignment, so the default emits the same shapes
483      // as before this knob existed. The vertical component always follows the
484      // layout, exactly as it did when `align2` was left unset.
485      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         // ---- hold-to-confirm gesture ------------------------------------
508         // egui abandons `is_pointer_button_down_on` once the press outlives
509         // `max_click_duration` (it backs the long-touch context menu there), so
510         // use it only to latch press ownership at press time and track the hold
511         // from the raw pointer afterwards.
512         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                  // Release and drag-off both land here.
536                  state.progress = 0.0;
537                  state.fired = false;
538                  if !primary_down {
539                     // The latch must not leak into the next press.
540                     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               // Advance the burst clock for the next frame; the snapshot above
552               // is this frame's age, so the burst paints before it advances.
553               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            // A press+release must never report a plain click: only a completed
563            // hold confirms.
564            prepared.response.flags.remove(Flags::CLICKED);
565
566            // A completed hold reports `clicked()` for exactly this frame.
567            if fired_now {
568               prepared.response.flags.insert(Flags::FAKE_PRIMARY_CLICKED);
569            }
570
571            // Queue the lightning once, at completion.
572            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         // The button keeps its pressed chrome for the whole hold; egui's own
587         // active flag stops mid-press at `max_click_duration`. Once the hold
588         // fires the pressed look is released, while the sweep and burst keep
589         // following `holding`.
590         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         // Select custom visuals based on state, or fall back to defaults
598         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         // ---- sweep + burst painting -------------------------------------
651         // The fill clears the moment the hold fires; the burst takes over.
652         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               // Give up the frame's own fill *and* shadow so the base fill,
662               // sweep and edge can be painted under the frame; the frame's
663               // stroke still lands on top. The shadow is taken over here and
664               // painted before the base fill, exactly as `Frame::paint` would
665               // have ordered it — otherwise its opaque spread would cover the
666               // alpha-filled button and the sweep.
667               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                  // Clip the same full rounded rect to the swept span rather than
678                  // build a partial one, so the button keeps its shape and only
679                  // the leading edge is straight. Intersect the painter's own
680                  // clip so a scroll area's clip is never widened.
681                  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         // Repaint only while the hold progresses or a burst animates; a full,
722         // held sweep is static, so it does not pin the frame loop.
723         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   /// Screen the test buttons live in; small enough to keep clicks deterministic.
764   fn screen() -> Rect {
765      Rect::from_min_size(Pos2::ZERO, vec2(320.0, 200.0))
766   }
767
768   /// Time step used by every pass; a binary fraction so progress sums exactly.
769   const STEP: f32 = 0.125;
770
771   /// The sweep colour used by the shape-inspection tests.
772   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   /// Runs one pass, adding exactly one button and returning the frame's output
807   /// and the button's [`Response`].
808   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      // The tests inspect `shapes`; the font-atlas delta is never applied.
830      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   /// egui's click ceiling, read from the live context so the tests stay
842   /// meaningful if the default ever changes.
843   fn max_click_duration(ctx: &Context) -> f64 {
844      ctx.options(|o| o.input_options.max_click_duration)
845   }
846
847   /// The clipped fill of the sweep shape, as `(clip_width, clip_left)`.
848   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   /// Every `RectShape` fill in paint order, flattening nested [`Shape::Vec`]s
859   /// so a frame-painted shadow stays visible to the ordering assertions.
860   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   /// Highest vertex alpha of any glow mesh in the frame (`0` when none).
884   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   /// Number of polylines painted this frame (a lightning strike's bolts).
898   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   /// Whether the frame paints any completion-burst shape (glow mesh or bolt).
907   fn has_burst_shape(output: &FullOutput) -> bool {
908      has_mesh(output) || path_count(output) > 0
909   }
910
911   /// Fill of the partial sweep rect at `progress`, found by geometry rather
912   /// than by colour so a shifting fill can be read too.
913   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   /// Advance passes until the hold fires, returning that frame's output and time.
928   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   /// Press a button built from `settings` and hold it to completion.
944   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   /// The glow mesh the frame painted, identified by its fully transparent rim
956   /// (text stays a `Shape::Text`, so the glow is the only `Shape::Mesh` with a
957   /// zero-alpha vertex).
958   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   /// Largest distance from `center` to any vertex of `mesh`.
968   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   /// Every painted polyline as `(points, stroke width)`, in paint order.
977   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   /// Largest perpendicular distance of `points` from the line joining their
989   /// first and last point (`0.0` for a straight polyline).
990   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   /// Hold a button from `settings` to completion and advance `extra` frames,
1009   /// returning the last frame's glow mesh and the button rect.
1010   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   /// Like [`complete_hold`], but at the demo's `(250, 45)` button size so a
1028   /// strike's geometry has a stable area to bend in.
1029   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   /// The press frame's progress and the number of further passes until the
1041   /// hold reports `clicked()`.
1042   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   /// The fill of the button's own base (chrome) rect, picked by matching the
1066   /// button's rect so panel backgrounds and the partial sweep are skipped.
1067   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   /// Bounding rect of the painted text whose galley source is `text`.
1082   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      // Advance 0.4 s in steps: still below the 0.5 s duration.
1112      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      // The next step crosses 0.5 s: fire exactly here.
1127      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      // Held but already fired: the click must not repeat.
1139      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      // Focus the button, then press Enter.
1246      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      // Two warm-up passes absorb egui's first-frame `SetTheme` command.
1359      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      // Hold until it fires.
1409      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      // Release; the burst keeps animating on its own.
1431      time = completion_time + f64::from(STEP);
1432      let (_, released) = run_pass(&ctx, vec![release(center)], time, build);
1433      assert!(!released.clicked());
1434
1435      // Let it run its course, then it must be gone and cost no frames.
1436      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      // Releasing after completion resets the gesture and must not re-fire.
1511      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      /// An opaque test shadow, distinct from every chrome fill.
1577      const SHADOW: Color32 = Color32::from_rgb(200, 30, 40);
1578      /// Shadow spread, in points — enough to ring the button's rect.
1579      const SHADOW_SPREAD: u8 = 4;
1580      /// Distinct fills so the chrome selection is unambiguous; the shipped
1581      /// themes alias idle and pressed, which is why sentinels are needed.
1582      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      /// A sample well inside egui's click window.
1645      const EARLY: f64 = 0.4;
1646      /// How far past the click ceiling the late sample is taken.
1647      const PAST_CEILING: f64 = 0.2;
1648      /// Distinct test-only chrome fills; the shipped themes alias idle and
1649      /// pressed, so pick explicit ones to isolate the pressed selection.
1650      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      /// Distinct test-only chrome fills; the shipped themes alias idle and
1708      /// pressed, so pick explicit ones to isolate the release.
1709      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      // Mid-hold, before completion: the pressed chrome must be painted.
1729      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      // Hold to completion without releasing the pointer.
1743      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      // Still held: the next frame must neither re-press nor reset.
1770      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      // First gesture: hold to completion.
1798      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      // Release: the latch must clear so the next press starts from zero.
1816      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      // A second press must accumulate from zero and fire on its own.
1826      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      /// The shift starts here.
1903      const FROM: Color32 = Color32::from_rgb(16, 200, 32);
1904      /// …and ends here; every channel moves a long way, so between-ness is
1905      /// unambiguous.
1906      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      // Still held: nothing animates, so the widget must not pin the frame loop.
2021      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      /// Long enough that the old 300 ms constant would be long gone.
2044      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      // ~375 ms after completion: the burst must still be visible.
2056      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      // ~750 ms after completion: the burst must be over.
2067      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      /// Burst long enough to sample it late.
2138      const BURST_MS: f32 = 2000.0;
2139      /// Seconds a burst advances per pass (`stable_dt`, capped by `HOLD_MAX_DT`).
2140      const BURST_DT: f32 = HOLD_MAX_DT;
2141      /// Passes from completion to the last painted frame: the age reaches
2142      /// `secs - BURST_DT`, i.e. `t = 1 - BURST_DT / secs`.
2143      const LATE_FRAMES: usize = ((BURST_MS / 1000.0 - BURST_DT) / BURST_DT) as usize;
2144      /// The start radius, in short sides; shared by both pairs.
2145      const FROM: f32 = 1.0;
2146      /// A small end radius.
2147      const SMALL_TO: f32 = 1.4;
2148      /// A larger end radius.
2149      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      // At completion the radius is exactly `short x from`; at the last painted
2168      // frame it is `short x (from + (to - from) x t)`.
2169      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      /// A known burst colour, so the expected fan's tint is unambiguous.
2210      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      // At completion `t = 0`, so the radius is `short x from == short`, the
2221      // attenuation is exactly `1.0` and the tint is the burst colour.
2222      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      /// Thin and thick cores, so the painted stroke widths are unambiguous.
2244      const THIN: f32 = 1.0;
2245      const THICK: f32 = 8.0;
2246      /// A straight trunk's rounding is far below this; a jittered bolt is far
2247      /// above it.
2248      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      // `jitter = 0` lays the trunk on a straight line. Only the trunk (the
2286      // first polyline) is checked: a branch endpoint clamped to the effect
2287      // area can bend even a zero-jitter branch.
2288      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      // Lightning: the default settings only switch the kind; the new thickness
2317      // and jitter fields keep the effect's own defaults, so the painted
2318      // polylines must match a `Lightning` built without those two calls.
2319      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      // Glow: the default kind, whose default fan is the pre-change fan at
2364      // `short x from == short`.
2365      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      // The later `hold(0.5)` wins over `hold(2.0)` for `seconds`.
2400      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      /// The shortcut's fill, replaced by `hold_settings`.
2408      const SHORTCUT_FILL: Color32 = Color32::from_rgb(220, 20, 20);
2409      /// The settings' fill, which must win.
2410      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      // Fill before `hold` still composes: `.hold_fill(a).hold(0.5)` keeps `a`.
2436      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      // Edit the struct after the body pass: the widget must read the live value.
2476      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   /// Wide enough that a left, centred and right label land far apart.
2497   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         // `right_text` inserts a trailing grow atom, so the atom block already
2569         // fills the rect and the label keeps its left position under every
2570         // alignment; only the alignment of grow-free buttons can move.
2571         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}