1use 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
10const DEFAULT_GLOW_SOFTNESS: f32 = 1.0;
12
13#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
15#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
16pub enum HoldBurst {
17 Off,
19 #[default]
21 Glow,
22 Lightning,
24}
25
26impl HoldBurst {
27 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#[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 pub open: bool,
67 pub seconds: f32,
71 pub fill_from: Option<Color32>,
76 pub fill_to: Option<Color32>,
82 pub burst: HoldBurst,
84 pub burst_color: Option<Color32>,
88 pub burst_intensity: f32,
93 pub burst_ms: f32,
95 pub burst_bolts: usize,
100 pub burst_branch_depth: u32,
105 pub burst_thickness: f32,
111 pub burst_jitter: f32,
117 pub burst_glow_from: f32,
122 pub burst_glow_to: f32,
131 pub burst_glow_softness: f32,
143}
144
145impl Default for HoldSettings {
146 fn default() -> Self {
147 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 pub fn reset(&mut self) {
173 let open = self.open;
174 *self = Self::default();
175 self.open = open;
176 }
177
178 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 pub(crate) fn burst_secs(&self) -> f32 {
190 (self.burst_ms / 1000.0).max(0.0)
191 }
192
193 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 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 pub fn show_window(&mut self, ctx: &Context) -> Option<egui::Response> {
213 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 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}