Skip to main content

egui_elements/widgets/particles/light/
settings.rs

1//! [`LightSettings`] — public knobs for the light effect plus a themed settings window.
2
3use egui::{Color32, Context, DragValue, Slider, Ui, Vec2};
4
5use super::super::shared::{ParticleLayer, begin_body, settings_window};
6use super::LightMotion;
7
8/// User-facing knobs for [`Light`](super::Light).
9///
10/// The defaults are deliberately neutral — a warm white, low density, wide soft
11/// radii, gentle flicker and multi-second lifetimes — so the effect can be
12/// tuned to a particular screen instead of shipping one screen's look.
13#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
14#[derive(Debug, Clone, PartialEq)]
15pub struct LightSettings {
16   /// Settings-window visibility. Default `false` (the example opens it).
17   pub open: bool,
18   /// Whether the effect is drawn at all.
19   pub active: bool,
20   /// Mote color.
21   pub color: Color32,
22   /// Number of motes.
23   pub density: usize,
24   /// Minimum mote radius in pixels.
25   pub radius_min: f32,
26   /// Maximum mote radius in pixels.
27   pub radius_max: f32,
28   /// Global intensity multiplier in `0.0..=1.0`.
29   pub intensity: f32,
30   /// Motion model.
31   pub motion: LightMotion,
32   /// Global wind vector (x = horizontal, y = vertical).
33   pub wind: Vec2,
34   /// Flicker depth in `0.0..=1.0` (`0` disables flicker).
35   pub flicker: f32,
36   /// Minimum mote lifetime in seconds.
37   pub lifetime_min: f32,
38   /// Maximum mote lifetime in seconds.
39   pub lifetime_max: f32,
40   /// Layer the effect is painted on.
41   pub layer: ParticleLayer,
42}
43
44impl Default for LightSettings {
45   fn default() -> Self {
46      Self {
47         open: false,
48         active: true,
49         color: Color32::from_rgb(255, 224, 178),
50         density: 48,
51         radius_min: 8.0,
52         radius_max: 36.0,
53         intensity: 0.7,
54         motion: LightMotion::Drift,
55         wind: Vec2::ZERO,
56         flicker: 0.25,
57         lifetime_min: 4.0,
58         lifetime_max: 9.0,
59         layer: ParticleLayer::Foreground,
60      }
61   }
62}
63
64impl LightSettings {
65   /// Knobs back to defaults; `open` is preserved (a reset must not close the window).
66   pub fn reset(&mut self) {
67      let open = self.open;
68      *self = Self::default();
69      self.open = open;
70   }
71
72   /// Themed floating settings window; returns the window's outer `Response` when open.
73   ///
74   /// Window chrome plus a vertical scroll area around [`Self::body_ui`], so a
75   /// viewport shorter than the controls scrolls instead of clipping them.
76   pub fn show_window(&mut self, ctx: &Context) -> Option<egui::Response> {
77      // Local copy — `settings_window` borrows `open` while the body borrows `self`.
78      let mut open = self.open;
79      let resp = settings_window(ctx, "Light Settings", &mut open, |ui| {
80         self.body_ui(ui)
81      });
82      self.open = open;
83      resp
84   }
85
86   /// Draw the settings controls into `ui` — no window chrome.
87   ///
88   /// Render them in your own container (`egui::Window`, `SidePanel`, `ScrollArea`, …)
89   /// instead of using [`Self::show_window`]. If that container can be shorter than the
90   /// controls, wrap it in an `egui::ScrollArea`.
91   pub fn body_ui(&mut self, ui: &mut Ui) {
92      let theme = begin_body(ui);
93
94      ui.heading("General");
95      ui.checkbox(&mut self.active, "Effect Active");
96      ui.horizontal(|ui| {
97         ui.label("Color:");
98         ui.color_edit_button_srgba(&mut self.color);
99      });
100
101      ui.separator();
102      ui.heading("Motion");
103      ui.horizontal(|ui| {
104         ui.label("Motion:");
105         ui.selectable_value(
106            &mut self.motion,
107            LightMotion::Drift,
108            LightMotion::Drift.to_str(),
109         )
110         .on_hover_text("Slow wandering motes that bounce off the area edges");
111         ui.selectable_value(
112            &mut self.motion,
113            LightMotion::Rise,
114            LightMotion::Rise.to_str(),
115         )
116         .on_hover_text("Embers that rise from the bottom edge and sway as they climb");
117      });
118
119      ui.separator();
120      ui.heading("Particles");
121
122      ui.horizontal(|ui| {
123         ui.label("Density (Count):");
124         ui.add(Slider::new(&mut self.density, 0..=2000).logarithmic(true));
125      });
126
127      ui.horizontal(|ui| {
128         ui.label("Radius (px):");
129         ui.add(
130            DragValue::new(&mut self.radius_min)
131               .speed(0.5)
132               .range(1.0..=200.0)
133               .prefix("Min: "),
134         );
135         ui.add(
136            DragValue::new(&mut self.radius_max)
137               .speed(0.5)
138               .range(1.0..=200.0)
139               .prefix("Max: "),
140         );
141      });
142      // Ensure min does not exceed max
143      if self.radius_min > self.radius_max {
144         self.radius_min = self.radius_max;
145      }
146
147      ui.horizontal(|ui| {
148         ui.label("Intensity:");
149         ui.add(Slider::new(&mut self.intensity, 0.0..=1.0));
150      });
151
152      ui.horizontal(|ui| {
153         ui.label("Flicker:");
154         ui.add(Slider::new(&mut self.flicker, 0.0..=1.0));
155      });
156
157      ui.separator();
158      ui.heading("Physics");
159
160      ui.label("Wind Vector:");
161      ui.horizontal(|ui| {
162         ui.add(Slider::new(&mut self.wind.x, -200.0..=200.0).text("Horizontal"));
163      });
164      ui.horizontal(|ui| {
165         ui.add(Slider::new(&mut self.wind.y, -100.0..=100.0).text("Vertical"));
166      });
167
168      ui.horizontal(|ui| {
169         ui.label("Lifetime (s):");
170         ui.add(
171            DragValue::new(&mut self.lifetime_min)
172               .speed(0.1)
173               .range(0.5..=60.0)
174               .prefix("Min: "),
175         );
176         ui.add(
177            DragValue::new(&mut self.lifetime_max)
178               .speed(0.1)
179               .range(0.5..=60.0)
180               .prefix("Max: "),
181         );
182      });
183      if self.lifetime_min > self.lifetime_max {
184         self.lifetime_min = self.lifetime_max;
185      }
186
187      ui.separator();
188      ui.heading("Rendering");
189      ui.horizontal(|ui| {
190         ui.label("Layer:");
191         ui.selectable_value(
192            &mut self.layer,
193            ParticleLayer::Background,
194            ParticleLayer::Background.to_str(),
195         )
196         .on_hover_text("Behind everything (Order::Background)");
197         ui.selectable_value(
198            &mut self.layer,
199            ParticleLayer::Foreground,
200            ParticleLayer::Foreground.to_str(),
201         )
202         .on_hover_text("In front of windows (Order::Foreground)");
203         ui.selectable_value(
204            &mut self.layer,
205            ParticleLayer::Top,
206            ParticleLayer::Top.to_str(),
207         )
208         .on_hover_text("On top of everything including debug/tooltips (Order::Debug)");
209      });
210
211      ui.add_space(theme.spacing.sm);
212      if ui.button("Reset Defaults").clicked() {
213         self.reset();
214      }
215   }
216}
217
218#[cfg(test)]
219mod tests {
220   use super::*;
221   use egui::__run_test_ctx;
222
223   #[test]
224   fn defaults_are_sane() {
225      let settings = LightSettings::default();
226      assert!(!settings.open);
227      assert!(settings.active);
228      assert!(settings.density > 0);
229      assert!(settings.radius_min <= settings.radius_max);
230      assert!(settings.lifetime_min <= settings.lifetime_max);
231      assert!(
232         settings.lifetime_min > 1.0,
233         "lifetimes are multi-second"
234      );
235      assert_eq!(settings.motion, LightMotion::Drift);
236      assert_eq!(settings.layer, ParticleLayer::Foreground);
237   }
238
239   #[test]
240   fn reset_keeps_open() {
241      let mut settings = LightSettings {
242         open: true,
243         active: false,
244         density: 3,
245         radius_min: 90.0,
246         radius_max: 91.0,
247         intensity: 0.1,
248         motion: LightMotion::Rise,
249         wind: Vec2::new(7.0, 8.0),
250         flicker: 0.9,
251         lifetime_min: 1.0,
252         lifetime_max: 2.0,
253         layer: ParticleLayer::Top,
254         color: Color32::from_rgb(9, 9, 9),
255      };
256
257      settings.reset();
258
259      assert!(settings.open, "reset must not close the window");
260      assert_eq!(
261         settings,
262         LightSettings {
263            open: true,
264            ..Default::default()
265         }
266      );
267   }
268
269   /// Lay the open settings window out on `screen` twice and return the second
270   /// pass's outer window height (window rects settle on the second pass).
271   ///
272   /// Unlike [`egui::__run_test_ctx`] this keeps the real fonts: empty fonts
273   /// make every galley 0 tall and the measured height meaningless.
274   fn window_height_on(screen: Vec2) -> f32 {
275      let ctx = Context::default();
276      // Mirror the demo: install the theme once so spacing/`item_spacing`
277      // match the real app instead of egui's tighter default style.
278      let mut theme = crate::theme::Theme::new(crate::theme::ThemeKind::TokyoNight);
279      theme.install(&ctx);
280      let mut settings = LightSettings {
281         open: true,
282         ..Default::default()
283      };
284      let mut size = Vec2::ZERO;
285      for _ in 0..2 {
286         let output = ctx.run_ui(
287            egui::RawInput {
288               screen_rect: Some(egui::Rect::from_min_size(
289                  egui::Pos2::ZERO,
290                  screen,
291               )),
292               ..Default::default()
293            },
294            |ui| {
295               if let Some(resp) = settings.show_window(ui.ctx()) {
296                  size = resp.rect.size();
297               }
298            },
299         );
300         output.drop_without_applying_deltas();
301      }
302      size.y
303   }
304
305   #[test]
306   fn settings_body_fits_the_demo_root() {
307      let height = window_height_on(Vec2::new(900.0, 2000.0));
308      assert!(
309         height <= 650.0,
310         "settings window is {height} tall, root is 650"
311      );
312   }
313
314   #[test]
315   fn body_ui_renders_without_a_window() {
316      let mut settings = LightSettings::default();
317      assert!(!settings.open);
318      egui::__run_test_ui(|ui| {
319         settings.body_ui(ui);
320      });
321      assert!(
322         !settings.open,
323         "body_ui must not touch the window flag"
324      );
325   }
326
327   #[test]
328   fn show_window_returns_response_only_when_open() {
329      __run_test_ctx(|ctx| {
330         let mut closed = LightSettings::default();
331         assert!(closed.show_window(ctx).is_none());
332      });
333
334      __run_test_ctx(|ctx| {
335         let mut open = LightSettings {
336            open: true,
337            ..Default::default()
338         };
339         assert!(open.show_window(ctx).is_some());
340      });
341   }
342}