Skip to main content

egui_elements/widgets/
hold.rs

1//! Hold-to-confirm settings: the completion burst and the public knobs for a
2//! [`Button::hold`](crate::widgets::Button::hold) action.
3
4use egui::{Color32, Context, Slider, Ui};
5
6use crate::widgets::button::{HOLD_GLOW_END, HOLD_GLOW_START};
7use crate::widgets::particles::LightningSettings;
8use crate::widgets::particles::shared::{begin_body, settings_window};
9
10/// Softness that reproduces the shared glow fan exactly.
11const DEFAULT_GLOW_SOFTNESS: f32 = 1.0;
12
13/// Completion burst drawn when a hold-to-confirm button reaches 100 %.
14#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
15#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
16pub enum HoldBurst {
17   /// No completion effect; a completed hold paints nothing extra.
18   Off,
19   /// An expanding radial glow at the button's centre, fading out.
20   #[default]
21   Glow,
22   /// A lightning bolt and flash struck across the button.
23   Lightning,
24}
25
26impl HoldBurst {
27   /// Human-readable name, used by the settings window's kind selector.
28   pub const fn to_str(self) -> &'static str {
29      match self {
30         Self::Off => "Off",
31         Self::Glow => "Glow",
32         Self::Lightning => "Lightning",
33      }
34   }
35}
36
37/// Knobs for a hold-to-confirm button: the hold duration, the progressive fill
38/// and the completion burst.
39///
40/// [`Button::hold_settings`](crate::widgets::Button::hold_settings) replaces the
41/// whole configuration at once; the per-field shortcuts
42/// [`Button::hold`](crate::widgets::Button::hold),
43/// [`Button::hold_fill`](crate::widgets::Button::hold_fill) and
44/// [`Button::hold_burst`](crate::widgets::Button::hold_burst) compose field by
45/// field instead.
46///
47/// The defaults reproduce the look of a button that configures nothing: a 1 s
48/// hold, the theme's accent colour, and a 300 ms [`HoldBurst::Glow`] burst.
49///
50/// With the `serde` feature enabled the whole struct is serializable, so a host
51/// can persist it as part of its own settings instead of copying each knob.
52/// Deserialization ignores unknown fields (serde's default) and fills missing
53/// ones from [`HoldSettings::default`], so a file written before a knob existed
54/// still loads and that knob takes its default — which is what lets a host
55/// persist this type across crate upgrades. [`Self::open`] is serialized like
56/// every other field; a host that owns the settings window sets it to `false`
57/// after loading.
58#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
59#[cfg_attr(feature = "serde", serde(default))]
60#[derive(Clone, Copy, Debug, PartialEq)]
61pub struct HoldSettings {
62   /// Settings-window visibility. Default `false` (the example opens it).
63   ///
64   /// [`Button`](crate::widgets::Button) ignores this field; it exists so a host
65   /// can keep one settings value for both the window and the buttons.
66   pub open: bool,
67   /// Hold duration in seconds; `0.0` or less means "not a hold button".
68   ///
69   /// Default `1.0`.
70   pub seconds: f32,
71   /// Sweep colour at the start of the hold, or `None` for the installed
72   /// theme's [`colors.accent`](crate::theme::ThemeColors::accent).
73   ///
74   /// Default `None`.
75   pub fill_from: Option<Color32>,
76   /// Sweep colour at completion. When `Some`, the sweep shifts from
77   /// [`Self::fill_from`] towards this colour as the hold fills; `None` keeps it
78   /// solid.
79   ///
80   /// Default `None`.
81   pub fill_to: Option<Color32>,
82   /// Completion effect. Default [`HoldBurst::Glow`].
83   pub burst: HoldBurst,
84   /// Burst colour, or `None` for the resolved fill colour at completion.
85   ///
86   /// Default `None`.
87   pub burst_color: Option<Color32>,
88   /// Multiplier over the effect's own profile, in `0.0..=1.0`. `0.0` disables
89   /// the burst entirely.
90   ///
91   /// Default `1.0`.
92   pub burst_intensity: f32,
93   /// Burst duration in milliseconds. Default `300.0`.
94   pub burst_ms: f32,
95   /// Number of bolts for a [`HoldBurst::Lightning`] burst; ignored by the other
96   /// kinds.
97   ///
98   /// Default `1`.
99   pub burst_bolts: usize,
100   /// Branch recursion depth for a [`HoldBurst::Lightning`] burst (`0` draws the
101   /// trunk only); ignored by the other kinds.
102   ///
103   /// Default `1`.
104   pub burst_branch_depth: u32,
105   /// Core stroke thickness of a [`HoldBurst::Lightning`] burst, in pixels;
106   /// ignored by the other kinds.
107   ///
108   /// Default `2.0`, the [`LightningSettings::default`] value (see
109   /// `DEFAULT_THICKNESS` in `widgets/particles/lightning/effect.rs`).
110   pub burst_thickness: f32,
111   /// Midpoint-displacement jitter of a [`HoldBurst::Lightning`] burst, as a
112   /// fraction of each segment's length; ignored by the other kinds.
113   ///
114   /// Default `0.18`, the [`LightningSettings::default`] value (see
115   /// `DEFAULT_JITTER` in `widgets/particles/lightning/effect.rs`).
116   pub burst_jitter: f32,
117   /// Radius of a [`HoldBurst::Glow`] at the start of the burst, as a multiple
118   /// of the button's shorter side; ignored by the other kinds.
119   ///
120   /// Default `1.0` (`HOLD_GLOW_START`).
121   pub burst_glow_from: f32,
122   /// Radius of a [`HoldBurst::Glow`] at the end of the burst, as a multiple of
123   /// the button's shorter side; ignored by the other kinds.
124   ///
125   /// At paint time this is clamped to at least [`Self::burst_glow_from`], so a
126   /// pair that would shrink (or a negative one) paints a constant radius
127   /// instead of a shrinking band.
128   ///
129   /// Default `2.2` (`HOLD_GLOW_END`).
130   pub burst_glow_to: f32,
131   /// Falloff softness of a [`HoldBurst::Glow`]; ignored by the other kinds.
132   ///
133   /// The shared fan keeps its hot-core ring at `0.15` of the radius and its
134   /// inner ring at `0.45`, carrying `0.55` and `0.18` of the colour. This knob
135   /// maps each interior ring fraction `r` to `r.powf(1.0 / softness)`; the rim
136   /// and every alpha stay put. Values above `1.0` push the two interior rings
137   /// outward, spreading the colour over a longer run to the transparent rim (a
138   /// gentler falloff); values below `1.0` pull them in for a tighter, harder
139   /// core. Exactly `1.0` reproduces the shared profile vertex for vertex.
140   ///
141   /// Default `1.0`.
142   pub burst_glow_softness: f32,
143}
144
145impl Default for HoldSettings {
146   fn default() -> Self {
147      // The burst's strike knobs default to the effect's own, so a hold burst
148      // paints exactly like the same `Lightning` built without those calls.
149      let lightning = LightningSettings::default();
150      Self {
151         open: false,
152         seconds: 1.0,
153         fill_from: None,
154         fill_to: None,
155         burst: HoldBurst::Glow,
156         burst_color: None,
157         burst_intensity: 1.0,
158         burst_ms: 300.0,
159         burst_bolts: 1,
160         burst_branch_depth: 1,
161         burst_thickness: lightning.thickness,
162         burst_jitter: lightning.jitter,
163         burst_glow_from: HOLD_GLOW_START,
164         burst_glow_to: HOLD_GLOW_END,
165         burst_glow_softness: DEFAULT_GLOW_SOFTNESS,
166      }
167   }
168}
169
170impl HoldSettings {
171   /// Knobs back to defaults; `open` is preserved (a reset must not close the window).
172   pub fn reset(&mut self) {
173      let open = self.open;
174      *self = Self::default();
175      self.open = open;
176   }
177
178   /// The sweep colour at `progress`: [`Self::fill_from`] alone when there is no
179   /// [`Self::fill_to`], otherwise a gamma-space shift towards `fill_to`.
180   pub(crate) fn fill_color(&self, accent: Color32, progress: f32) -> Color32 {
181      let from = self.fill_from.unwrap_or(accent);
182      match self.fill_to {
183         Some(to) => from.lerp_to_gamma(to, progress.clamp(0.0, 1.0)),
184         None => from,
185      }
186   }
187
188   /// Burst length in seconds; `0.0` for a zero-length burst.
189   pub(crate) fn burst_secs(&self) -> f32 {
190      (self.burst_ms / 1000.0).max(0.0)
191   }
192
193   /// The [`Self::burst_glow_from`]..[`Self::burst_glow_to`] radius band, with
194   /// both ends non-negative and `to` never below `from`.
195   pub(crate) fn glow_radius_range(&self) -> (f32, f32) {
196      let from = self.burst_glow_from.max(0.0);
197      (from, self.burst_glow_to.max(from))
198   }
199
200   /// Whether a completed hold should start a burst at all.
201   ///
202   /// [`HoldBurst::Off`], a zero-length burst and zero intensity all paint
203   /// nothing, so the widget must not keep the frame loop awake for them.
204   pub(crate) fn burst_visible(&self) -> bool {
205      self.burst != HoldBurst::Off && self.burst_secs() > 0.0 && self.burst_intensity > 0.0
206   }
207
208   /// Themed floating settings window; returns the window's outer `Response` when open.
209   ///
210   /// Window chrome plus a vertical scroll area around [`Self::body_ui`], so a
211   /// viewport shorter than the controls scrolls instead of clipping them.
212   pub fn show_window(&mut self, ctx: &Context) -> Option<egui::Response> {
213      // Local copy — `settings_window` borrows `open` while the body borrows `self`.
214      let mut open = self.open;
215      let resp = settings_window(ctx, "Hold Settings", &mut open, |ui| {
216         self.body_ui(ui)
217      });
218      self.open = open;
219      resp
220   }
221
222   /// Draw the settings controls into `ui` — no window chrome.
223   ///
224   /// Render them in your own container (`egui::Window`, `SidePanel`, `ScrollArea`, …)
225   /// instead of using [`Self::show_window`]. If that container can be shorter than the
226   /// controls, wrap it in an `egui::ScrollArea`. This method never touches
227   /// [`Self::open`].
228   pub fn body_ui(&mut self, ui: &mut Ui) {
229      let theme = begin_body(ui);
230
231      ui.heading("Hold");
232      ui.horizontal(|ui| {
233         ui.label("Duration (s):");
234         ui.add(Slider::new(&mut self.seconds, 0.2..=5.0));
235      });
236
237      ui.separator();
238      ui.heading("Fill");
239      ui.horizontal(|ui| {
240         let mut theme_accent = self.fill_from.is_none();
241         if ui.checkbox(&mut theme_accent, "Theme accent").changed() {
242            self.fill_from = if theme_accent {
243               None
244            } else {
245               Some(theme.colors.accent)
246            };
247         }
248         if let Some(color) = self.fill_from.as_mut() {
249            ui.color_edit_button_srgba(color);
250         }
251      });
252      ui.horizontal(|ui| {
253         let mut shifts = self.fill_to.is_some();
254         if ui.checkbox(&mut shifts, "Shift to a second colour").changed() {
255            self.fill_to = if shifts {
256               Some(self.fill_from.unwrap_or(theme.colors.accent))
257            } else {
258               None
259            };
260         }
261         if let Some(color) = self.fill_to.as_mut() {
262            ui.color_edit_button_srgba(color);
263         }
264      });
265
266      ui.separator();
267      ui.heading("Burst");
268      ui.horizontal(|ui| {
269         ui.label("Kind:");
270         for kind in [HoldBurst::Off, HoldBurst::Glow, HoldBurst::Lightning] {
271            ui.selectable_value(&mut self.burst, kind, kind.to_str());
272         }
273      });
274      if self.burst != HoldBurst::Off {
275         ui.horizontal(|ui| {
276            let mut same_as_fill = self.burst_color.is_none();
277            if ui.checkbox(&mut same_as_fill, "Same as the fill").changed() {
278               self.burst_color = if same_as_fill {
279                  None
280               } else {
281                  Some(self.fill_from.unwrap_or(theme.colors.accent))
282               };
283            }
284            if let Some(color) = self.burst_color.as_mut() {
285               ui.color_edit_button_srgba(color);
286            }
287         });
288         ui.horizontal(|ui| {
289            ui.label("Intensity:");
290            ui.add(Slider::new(&mut self.burst_intensity, 0.0..=1.0));
291         });
292         ui.horizontal(|ui| {
293            ui.label("Duration (ms):");
294            ui.add(Slider::new(&mut self.burst_ms, 80.0..=2000.0));
295         });
296         if self.burst == HoldBurst::Glow {
297            ui.horizontal(|ui| {
298               ui.label("Size from:");
299               ui.add(Slider::new(&mut self.burst_glow_from, 0.2..=4.0))
300                  .on_hover_text("Start radius as a multiple of the button's shorter side");
301            });
302            ui.horizontal(|ui| {
303               ui.label("Size to:");
304               ui.add(Slider::new(&mut self.burst_glow_to, 0.2..=6.0))
305                  .on_hover_text("End radius as a multiple of the button's shorter side");
306            });
307            ui.horizontal(|ui| {
308               ui.label("Softness:");
309               ui.add(Slider::new(
310                  &mut self.burst_glow_softness,
311                  0.2..=3.0,
312               ))
313               .on_hover_text("1.0 reproduces the shared glow profile exactly");
314            });
315         }
316         if self.burst == HoldBurst::Lightning {
317            ui.horizontal(|ui| {
318               ui.label("Bolts:");
319               ui.add(Slider::new(&mut self.burst_bolts, 1..=5));
320            });
321            ui.horizontal(|ui| {
322               ui.label("Branch depth:");
323               ui.add(Slider::new(&mut self.burst_branch_depth, 0..=3));
324            });
325            ui.horizontal(|ui| {
326               ui.label("Thickness (px):");
327               ui.add(Slider::new(&mut self.burst_thickness, 0.5..=12.0));
328            });
329            ui.horizontal(|ui| {
330               ui.label("Jitter:");
331               ui.add(Slider::new(&mut self.burst_jitter, 0.0..=0.6))
332                  .on_hover_text("Midpoint displacement as a fraction of segment length");
333            });
334         }
335      }
336
337      ui.add_space(theme.spacing.sm);
338      if ui.button("Reset").clicked() {
339         self.reset();
340      }
341   }
342}
343
344#[cfg(test)]
345mod tests {
346   use super::*;
347
348   #[test]
349   fn defaults_match_the_original_look() {
350      let settings = HoldSettings::default();
351      assert!(!settings.open);
352      assert_eq!(settings.seconds, 1.0);
353      assert_eq!(settings.fill_from, None);
354      assert_eq!(settings.fill_to, None);
355      assert_eq!(settings.burst, HoldBurst::Glow);
356      assert_eq!(settings.burst_color, None);
357      assert_eq!(settings.burst_intensity, 1.0);
358      assert_eq!(settings.burst_ms, 300.0);
359      assert_eq!(settings.burst_bolts, 1);
360      assert_eq!(settings.burst_branch_depth, 1);
361      let lightning = LightningSettings::default();
362      assert_eq!(
363         settings.burst_thickness, lightning.thickness,
364         "the burst's thickness default must be the effect's own"
365      );
366      assert_eq!(
367         settings.burst_jitter, lightning.jitter,
368         "the burst's jitter default must be the effect's own"
369      );
370      assert_eq!(settings.burst_glow_from, 1.0);
371      assert_eq!(settings.burst_glow_to, 2.2);
372      assert_eq!(settings.burst_glow_softness, 1.0);
373   }
374
375   #[test]
376   fn reset_keeps_open() {
377      let mut settings = HoldSettings {
378         open: true,
379         seconds: 3.0,
380         fill_from: Some(Color32::from_rgb(1, 2, 3)),
381         fill_to: Some(Color32::from_rgb(4, 5, 6)),
382         burst: HoldBurst::Lightning,
383         burst_color: Some(Color32::from_rgb(7, 8, 9)),
384         burst_intensity: 0.2,
385         burst_ms: 900.0,
386         burst_bolts: 4,
387         burst_branch_depth: 3,
388         burst_thickness: 7.0,
389         burst_jitter: 0.5,
390         burst_glow_from: 0.4,
391         burst_glow_to: 3.0,
392         burst_glow_softness: 2.0,
393      };
394
395      settings.reset();
396
397      assert!(settings.open, "reset must not close the window");
398      assert_eq!(
399         settings,
400         HoldSettings {
401            open: true,
402            ..Default::default()
403         }
404      );
405   }
406
407   #[test]
408   fn the_glow_band_never_shrinks() {
409      let inverted = HoldSettings {
410         burst_glow_from: 2.5,
411         burst_glow_to: 0.8,
412         ..Default::default()
413      };
414      assert_eq!(
415         inverted.glow_radius_range(),
416         (2.5, 2.5),
417         "an end radius below the start must clamp up to it"
418      );
419
420      let negative = HoldSettings {
421         burst_glow_from: -1.0,
422         burst_glow_to: -3.0,
423         ..Default::default()
424      };
425      assert_eq!(
426         negative.glow_radius_range(),
427         (0.0, 0.0),
428         "a negative band must clamp to zero"
429      );
430   }
431
432   #[test]
433   fn the_fill_is_solid_without_a_second_colour() {
434      let start = Color32::from_rgb(10, 20, 30);
435      let settings = HoldSettings {
436         fill_from: Some(start),
437         ..Default::default()
438      };
439      assert_eq!(
440         settings.fill_color(Color32::RED, 0.2),
441         start,
442         "a fill without `fill_to` must not depend on progress"
443      );
444      assert_eq!(settings.fill_color(Color32::RED, 0.9), start);
445   }
446
447   #[test]
448   fn body_ui_renders_without_a_window() {
449      let mut settings = HoldSettings::default();
450      assert!(!settings.open);
451      egui::__run_test_ui(|ui| {
452         settings.body_ui(ui);
453      });
454      assert!(
455         !settings.open,
456         "body_ui must not touch the window flag"
457      );
458   }
459
460   #[test]
461   fn show_window_returns_response_only_when_open() {
462      egui::__run_test_ctx(|ctx| {
463         let mut closed = HoldSettings::default();
464         assert!(closed.show_window(ctx).is_none());
465      });
466
467      egui::__run_test_ctx(|ctx| {
468         let mut open = HoldSettings {
469            open: true,
470            ..Default::default()
471         };
472         assert!(open.show_window(ctx).is_some());
473      });
474   }
475
476   #[test]
477   fn off_and_zero_intensity_are_not_visible_bursts() {
478      assert!(
479         !HoldSettings {
480            burst: HoldBurst::Off,
481            ..Default::default()
482         }
483         .burst_visible()
484      );
485      assert!(
486         !HoldSettings {
487            burst_intensity: 0.0,
488            ..Default::default()
489         }
490         .burst_visible()
491      );
492      assert!(HoldSettings::default().burst_visible());
493   }
494
495   #[cfg(feature = "serde")]
496   #[test]
497   fn hold_settings_round_trip() -> Result<(), serde_json::Error> {
498      let settings = HoldSettings {
499         open: true,
500         seconds: 2.5,
501         fill_from: Some(Color32::from_rgb(10, 20, 30)),
502         fill_to: Some(Color32::from_rgb(40, 50, 60)),
503         burst: HoldBurst::Lightning,
504         burst_color: Some(Color32::from_rgb(70, 80, 90)),
505         burst_intensity: 0.35,
506         burst_ms: 750.0,
507         burst_bolts: 3,
508         burst_branch_depth: 2,
509         burst_thickness: 5.5,
510         burst_jitter: 0.42,
511         burst_glow_from: 0.6,
512         burst_glow_to: 3.4,
513         burst_glow_softness: 2.25,
514      };
515
516      let json = serde_json::to_string(&settings)?;
517      let restored: HoldSettings = serde_json::from_str(&json)?;
518
519      assert_eq!(restored, settings);
520      Ok(())
521   }
522
523   #[cfg(feature = "serde")]
524   #[test]
525   fn missing_fields_take_their_defaults() -> Result<(), serde_json::Error> {
526      let restored: HoldSettings = serde_json::from_str(r#"{"seconds": 0.6}"#)?;
527
528      assert_eq!(
529         restored,
530         HoldSettings {
531            seconds: 0.6,
532            ..HoldSettings::default()
533         }
534      );
535      Ok(())
536   }
537
538   #[cfg(feature = "serde")]
539   #[test]
540   fn unknown_fields_are_ignored() -> Result<(), serde_json::Error> {
541      let restored: HoldSettings =
542         serde_json::from_str(r#"{"seconds": 0.6, "some_knob_from_the_future": 3}"#)?;
543
544      assert_eq!(
545         restored,
546         HoldSettings {
547            seconds: 0.6,
548            ..HoldSettings::default()
549         }
550      );
551      Ok(())
552   }
553
554   #[cfg(feature = "serde")]
555   #[test]
556   fn every_burst_kind_round_trips() -> Result<(), serde_json::Error> {
557      for burst in [HoldBurst::Off, HoldBurst::Glow, HoldBurst::Lightning] {
558         let settings = HoldSettings {
559            burst,
560            ..HoldSettings::default()
561         };
562
563         let json = serde_json::to_string(&settings)?;
564         let restored: HoldSettings = serde_json::from_str(&json)?;
565
566         assert_eq!(
567            restored, settings,
568            "burst kind {burst:?} must survive a round trip"
569         );
570      }
571      Ok(())
572   }
573}