Skip to main content

egui_elements/widgets/particles/snow/
settings.rs

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