Skip to main content

egui_elements/widgets/particles/lightning/
settings.rs

1//! [`LightningSettings`] — public knobs for the lightning effect plus a themed settings window.
2
3use egui::{Color32, Context, DragValue, Slider, Ui};
4
5use super::super::shared::{ParticleLayer, begin_body, settings_window};
6use super::Lightning;
7use super::effect::{
8   DEFAULT_AMBIENT_INTERVAL_SECS, DEFAULT_BOLT_COUNT, DEFAULT_BRANCH_DEPTH, DEFAULT_COLOR,
9   DEFAULT_DURATION_MS, DEFAULT_FLASH_INTENSITY, DEFAULT_JITTER, DEFAULT_THICKNESS,
10};
11
12/// User-facing knobs for [`Lightning`](super::Lightning).
13///
14/// The defaults are a cold white-blue bolt with one trunk, a shallow branch,
15/// moderate jitter, a moderate full-area flash and a ~420 ms strike. The ambient
16/// scheduler is off, so the effect is idle until the host strikes it.
17#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
18#[derive(Debug, Clone, PartialEq)]
19pub struct LightningSettings {
20   /// Settings-window visibility. Default `false` (the example opens it).
21   pub open: bool,
22   /// Whether the effect is drawn at all.
23   pub active: bool,
24   /// Bolt and flash color.
25   pub color: Color32,
26   /// Number of bolts drawn per strike.
27   pub bolt_count: usize,
28   /// Branch recursion depth (`0` draws the trunk only).
29   pub branch_depth: u32,
30   /// Bright core stroke thickness in pixels.
31   pub thickness: f32,
32   /// Midpoint-displacement jitter as a fraction of segment length.
33   pub jitter: f32,
34   /// Peak alpha of the full-area flash in `0.0..=1.0`.
35   pub flash_intensity: f32,
36   /// Strike duration in milliseconds.
37   pub duration_ms: f32,
38   /// Ambient interval in seconds (`0.0` disables the scheduler).
39   pub ambient_interval_secs: f32,
40   /// Layer the effect is painted on.
41   pub layer: ParticleLayer,
42}
43
44impl Default for LightningSettings {
45   fn default() -> Self {
46      Self {
47         open: false,
48         active: true,
49         color: DEFAULT_COLOR,
50         bolt_count: DEFAULT_BOLT_COUNT,
51         branch_depth: DEFAULT_BRANCH_DEPTH,
52         thickness: DEFAULT_THICKNESS,
53         jitter: DEFAULT_JITTER,
54         flash_intensity: DEFAULT_FLASH_INTENSITY,
55         duration_ms: DEFAULT_DURATION_MS,
56         ambient_interval_secs: DEFAULT_AMBIENT_INTERVAL_SECS,
57         layer: ParticleLayer::Foreground,
58      }
59   }
60}
61
62impl LightningSettings {
63   /// `id_source` the settings window's "Strike now" button queues a strike on.
64   ///
65   /// Construct the effect with this id
66   /// (`Lightning::from_settings(LightningSettings::STRIKE_ID, &settings)`) so
67   /// the button drives it instead of a second, invisible state.
68   pub const STRIKE_ID: &'static str = "lightning";
69
70   /// Knobs back to defaults; `open` is preserved (a reset must not close the window).
71   pub fn reset(&mut self) {
72      let open = self.open;
73      *self = Self::default();
74      self.open = open;
75   }
76
77   /// Themed floating settings window; returns the window's outer `Response` when open.
78   ///
79   /// Window chrome plus a vertical scroll area around [`Self::body_ui`], so a
80   /// viewport shorter than the controls scrolls instead of clipping them.
81   pub fn show_window(&mut self, ctx: &Context) -> Option<egui::Response> {
82      // Local copy — `settings_window` borrows `open` while the body borrows `self`.
83      let mut open = self.open;
84      let resp = settings_window(ctx, "Lightning Settings", &mut open, |ui| {
85         self.body_ui(ui)
86      });
87      self.open = open;
88      resp
89   }
90
91   /// Draw the settings controls into `ui` — no window chrome.
92   ///
93   /// Render them in your own container (`egui::Window`, `SidePanel`, `ScrollArea`, …)
94   /// instead of using [`Self::show_window`]. If that container can be shorter than the
95   /// controls, wrap it in an `egui::ScrollArea`.
96   pub fn body_ui(&mut self, ui: &mut Ui) {
97      let theme = begin_body(ui);
98
99      ui.heading("General");
100      ui.checkbox(&mut self.active, "Effect Active");
101      ui.horizontal(|ui| {
102         ui.label("Color:");
103         ui.color_edit_button_srgba(&mut self.color);
104      });
105
106      ui.separator();
107      ui.heading("Bolt");
108
109      ui.horizontal(|ui| {
110         ui.label("Bolts:");
111         ui.add(Slider::new(&mut self.bolt_count, 1..=8));
112      });
113
114      ui.horizontal(|ui| {
115         ui.label("Branch Depth:");
116         ui.add(Slider::new(&mut self.branch_depth, 0..=8))
117            .on_hover_text("0 draws the trunk only; each step forks a shorter side branch");
118      });
119
120      ui.horizontal(|ui| {
121         ui.label("Thickness (px):");
122         ui.add(Slider::new(&mut self.thickness, 0.5..=12.0));
123      });
124
125      ui.horizontal(|ui| {
126         ui.label("Jitter:");
127         ui.add(Slider::new(&mut self.jitter, 0.0..=0.6))
128            .on_hover_text("Midpoint displacement as a fraction of segment length");
129      });
130
131      ui.separator();
132      ui.heading("Flash");
133
134      ui.horizontal(|ui| {
135         ui.label("Intensity:");
136         ui.add(Slider::new(&mut self.flash_intensity, 0.0..=1.0));
137      });
138
139      ui.horizontal(|ui| {
140         ui.label("Duration (ms):");
141         ui.add(Slider::new(&mut self.duration_ms, 80.0..=2000.0));
142      });
143
144      if ui.button("Strike now").clicked() {
145         Lightning::from_settings(Self::STRIKE_ID, self).strike(ui.ctx());
146      }
147
148      ui.separator();
149      ui.heading("Ambient");
150
151      ui.horizontal(|ui| {
152         ui.label("Interval (s):");
153         ui.add(
154            DragValue::new(&mut self.ambient_interval_secs)
155               .speed(0.1)
156               .range(0.0..=120.0)
157               .suffix(" s"),
158         )
159         .on_hover_text("0 disables the ambient scheduler");
160      });
161
162      ui.separator();
163      ui.heading("Rendering");
164      ui.horizontal(|ui| {
165         ui.label("Layer:");
166         ui.selectable_value(
167            &mut self.layer,
168            ParticleLayer::Background,
169            ParticleLayer::Background.to_str(),
170         )
171         .on_hover_text("Behind everything (Order::Background)");
172         ui.selectable_value(
173            &mut self.layer,
174            ParticleLayer::Foreground,
175            ParticleLayer::Foreground.to_str(),
176         )
177         .on_hover_text("In front of windows (Order::Foreground)");
178         ui.selectable_value(
179            &mut self.layer,
180            ParticleLayer::Top,
181            ParticleLayer::Top.to_str(),
182         )
183         .on_hover_text("On top of everything including debug/tooltips (Order::Debug)");
184      });
185
186      ui.add_space(theme.spacing.sm);
187      if ui.button("Reset Defaults").clicked() {
188         self.reset();
189      }
190   }
191}
192
193#[cfg(test)]
194mod tests {
195   use super::*;
196   use egui::__run_test_ctx;
197   use egui::Vec2;
198
199   #[test]
200   fn defaults_are_sane() {
201      let settings = LightningSettings::default();
202      assert!(!settings.open);
203      assert!(settings.active);
204      assert_eq!(settings.bolt_count, 1);
205      assert!(settings.branch_depth >= 1);
206      assert!(settings.thickness > 0.0);
207      assert!((0.0..=1.0).contains(&settings.jitter));
208      assert!((0.0..=1.0).contains(&settings.flash_intensity));
209      assert!(settings.duration_ms > 100.0);
210      assert_eq!(settings.ambient_interval_secs, 0.0);
211      assert_eq!(settings.layer, ParticleLayer::Foreground);
212   }
213
214   #[test]
215   fn reset_keeps_open() {
216      let mut settings = LightningSettings {
217         open: true,
218         active: false,
219         color: Color32::from_rgb(9, 9, 9),
220         bolt_count: 7,
221         branch_depth: 5,
222         thickness: 9.0,
223         jitter: 0.6,
224         flash_intensity: 0.1,
225         duration_ms: 900.0,
226         ambient_interval_secs: 12.0,
227         layer: ParticleLayer::Top,
228      };
229
230      settings.reset();
231
232      assert!(settings.open, "reset must not close the window");
233      assert_eq!(
234         settings,
235         LightningSettings {
236            open: true,
237            ..Default::default()
238         }
239      );
240   }
241
242   /// Lay the open settings window out on `screen` twice and return the second
243   /// pass's outer window height (window rects settle on the second pass).
244   ///
245   /// Unlike [`egui::__run_test_ctx`] this keeps the real fonts: empty fonts
246   /// make every galley 0 tall and the measured height meaningless.
247   fn window_height_on(screen: Vec2) -> f32 {
248      let ctx = Context::default();
249      // Mirror the demo: install the theme once so spacing/`item_spacing`
250      // match the real app instead of egui's tighter default style.
251      let mut theme = crate::theme::Theme::new(crate::theme::ThemeKind::TokyoNight);
252      theme.install(&ctx);
253      let mut settings = LightningSettings {
254         open: true,
255         ..Default::default()
256      };
257      let mut size = Vec2::ZERO;
258      for _ in 0..2 {
259         let output = ctx.run_ui(
260            egui::RawInput {
261               screen_rect: Some(egui::Rect::from_min_size(
262                  egui::Pos2::ZERO,
263                  screen,
264               )),
265               ..Default::default()
266            },
267            |ui| {
268               if let Some(resp) = settings.show_window(ui.ctx()) {
269                  size = resp.rect.size();
270               }
271            },
272         );
273         output.drop_without_applying_deltas();
274      }
275      size.y
276   }
277
278   #[test]
279   fn settings_body_fits_the_demo_root() {
280      let height = window_height_on(Vec2::new(900.0, 2000.0));
281      assert!(
282         height <= 650.0,
283         "settings window is {height} tall, root is 650"
284      );
285   }
286
287   #[test]
288   fn body_ui_renders_without_a_window() {
289      let mut settings = LightningSettings::default();
290      assert!(!settings.open);
291      egui::__run_test_ui(|ui| {
292         settings.body_ui(ui);
293      });
294      assert!(
295         !settings.open,
296         "body_ui must not touch the window flag"
297      );
298   }
299
300   #[test]
301   fn show_window_returns_response_only_when_open() {
302      __run_test_ctx(|ctx| {
303         let mut closed = LightningSettings::default();
304         assert!(closed.show_window(ctx).is_none());
305      });
306
307      __run_test_ctx(|ctx| {
308         let mut open = LightningSettings {
309            open: true,
310            ..Default::default()
311         };
312         assert!(open.show_window(ctx).is_some());
313      });
314   }
315}