Skip to main content

egui_screensaver_mystify/
lib.rs

1//! Mystify screensaver for [egui](https://github.com/emilk/egui).
2//!
3//! Renders two bouncing quadrilaterals with colour-cycling trails onto the
4//! egui background layer, recreating the classic Windows 3.x Mystify screen
5//! saver.  The simulation runs at a fixed 30 fps time-step regardless of the
6//! actual display refresh rate so the animation looks identical on any monitor.
7//! Repaints are capped at 30 FPS; if the hardware cannot sustain that rate
8//! the screensaver animates as fast as possible without any artificial delay.
9//!
10//! # Usage
11//!
12//! ```rust,no_run
13//! use egui_screensaver_mystify::MystifyBackground;
14//!
15//! struct MyApp {
16//!     mystify: MystifyBackground,
17//! }
18//!
19//! impl eframe::App for MyApp {
20//!     fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
21//!         let ctx = ui.ctx().clone();
22//!         // Call paint once per frame before drawing any UI windows so the
23//!         // screensaver sits on the background layer behind everything else.
24//!         self.mystify.paint(&ctx);
25//!     }
26//! }
27//! ```
28
29use std::time::Duration;
30
31use egui::{Context, LayerId, Painter, Pos2, Shape, Stroke, Vec2, ecolor::Hsva, pos2, vec2};
32
33// ── Simulation constants ─────────────────────────────────────────────────────
34
35/// Maximum number of historic polygon positions kept for trail rendering.
36const TRAIL_LENGTH: usize = 18;
37
38/// Pixels of inset on every edge so vertices never touch the window border.
39const EDGE_PADDING: f32 = 12.0;
40
41/// Target simulation rate.  Physics steps always advance by `1/TARGET_FPS`
42/// seconds of virtual time, decoupled from the actual rendering rate.
43const TARGET_FPS: f64 = 30.0;
44
45/// Virtual time (seconds) consumed by one simulation step.
46const TARGET_FRAME_TIME: f64 = 1.0 / TARGET_FPS;
47
48// ── Internal helpers ─────────────────────────────────────────────────────────
49
50/// One bouncing quadrilateral and its motion trail.
51#[derive(Debug)]
52struct MystifyPolygon {
53    /// Current vertex positions in normalised [0, 1] × [0, 1] space.
54    points: Vec<Pos2>,
55    /// Per-vertex velocity in normalised units per second.
56    velocities: Vec<Vec2>,
57    /// Ring buffer of past vertex snapshots used to draw the fading trail.
58    trail: Vec<Vec<Pos2>>,
59    /// Per-polygon hue phase offset so the two polygons cycle different colours.
60    hue_offset: f32,
61}
62
63// ── Public API ───────────────────────────────────────────────────────────────
64
65/// Mystify screensaver state.
66///
67/// Create one instance (e.g. as a field of your `eframe::App` struct) and call
68/// [`MystifyBackground::paint`] every frame from your `update` method.
69#[derive(Debug)]
70pub struct MystifyBackground {
71    /// The two animated polygons.
72    polygons: Vec<MystifyPolygon>,
73    /// Multiplier applied to `TARGET_FRAME_TIME` each simulation step.
74    /// `0.5` means half speed; `1.0` is full speed.
75    speed_factor: f32,
76    /// Global hue value that cycles through [0, 1) over time.
77    hue: f32,
78    /// Wall-clock time (seconds) at the previous call to [`paint`].
79    last_time: Option<f64>,
80    /// Accumulated wall-clock time not yet consumed by simulation steps.
81    time_accumulator: f64,
82}
83
84impl Default for MystifyBackground {
85    fn default() -> Self {
86        Self {
87            polygons: vec![
88                // First polygon — starts near the top-left quadrant.
89                MystifyPolygon {
90                    points: vec![
91                        pos2(0.15, 0.20),
92                        pos2(0.80, 0.14),
93                        pos2(0.86, 0.78),
94                        pos2(0.20, 0.84),
95                    ],
96                    velocities: vec![
97                        vec2(0.21, 0.28),
98                        vec2(-0.24, 0.19),
99                        vec2(-0.18, -0.23),
100                        vec2(0.26, -0.20),
101                    ],
102                    trail: Vec::new(),
103                    hue_offset: 0.0,
104                },
105                // Second polygon — offset in both position and hue so the two
106                // polygons are visually distinct from the start.
107                MystifyPolygon {
108                    points: vec![
109                        pos2(0.28, 0.12),
110                        pos2(0.90, 0.30),
111                        pos2(0.74, 0.88),
112                        pos2(0.12, 0.62),
113                    ],
114                    velocities: vec![
115                        vec2(-0.17, 0.24),
116                        vec2(-0.23, -0.18),
117                        vec2(0.20, -0.22),
118                        vec2(0.25, 0.16),
119                    ],
120                    trail: Vec::new(),
121                    // 180° phase shift puts this polygon on the complementary
122                    // colour relative to the first one.
123                    hue_offset: 0.45,
124                },
125            ],
126            speed_factor: 0.5,
127            hue: 0.0,
128            last_time: None,
129            time_accumulator: 0.0,
130        }
131    }
132}
133
134impl MystifyBackground {
135    /// Paint the screensaver onto the egui background layer for this frame.
136    ///
137    /// Call this once per frame **before** drawing any UI panels or windows so
138    /// the animation appears behind all other content.
139    ///
140    /// Repaints are capped at 30 FPS; if the hardware cannot sustain that rate
141    /// the screensaver animates as fast as possible without any artificial delay.
142    pub fn paint(&mut self, ctx: &Context) {
143        ctx.request_repaint_after(Duration::from_secs_f64(1.0 / 30.0));
144
145        let time = ctx.input(|input| input.time);
146
147        // Accumulate wall-clock time elapsed since the last frame, clamped to
148        // 250 ms so a tab switch or debugger pause doesn't produce a huge jump.
149        if let Some(last_time) = self.last_time {
150            let elapsed = (time - last_time).clamp(0.0, 0.25);
151            self.time_accumulator += elapsed;
152        }
153        self.last_time = Some(time);
154
155        let step_dt = TARGET_FRAME_TIME as f32 * self.speed_factor;
156
157        // Consume accumulated time in fixed-size simulation steps.
158        while self.time_accumulator >= TARGET_FRAME_TIME {
159            for polygon in &mut self.polygons {
160                Self::step_polygon(polygon, step_dt);
161
162                // Snapshot the current vertex positions into the trail buffer.
163                polygon.trail.push(polygon.points.clone());
164
165                // Drop the oldest snapshot once we reach capacity.
166                if polygon.trail.len() > TRAIL_LENGTH {
167                    polygon.trail.remove(0);
168                }
169            }
170
171            // Advance the global hue slowly so colours drift over time.
172            self.hue = (self.hue + step_dt * 0.06).fract();
173            self.time_accumulator -= TARGET_FRAME_TIME;
174        }
175
176        let rect = ctx.content_rect();
177        let painter = Painter::new(ctx.clone(), LayerId::background(), rect);
178
179        // Draw each polygon's trail as a series of polylines.  Older snapshots
180        // are drawn with lower alpha so the trail fades toward the tail.
181        for polygon in &self.polygons {
182            for (i, points) in polygon.trail.iter().enumerate() {
183                // `progress` goes from near 0 (oldest) to 1.0 (newest).
184                let progress = (i + 1) as f32 / polygon.trail.len() as f32;
185
186                // Quadratic alpha ramp so the trail fades smoothly.
187                let alpha = (progress.powf(2.0) * 220.0).round() as u8;
188
189                // Shift hue slightly along the trail for a rainbow sweep effect.
190                let hue = (self.hue + polygon.hue_offset + progress * 0.12).fract();
191                let color = Hsva::new(hue, 0.75, 1.0, alpha as f32 / 255.0);
192
193                // Map normalised coordinates to screen pixels, then close the
194                // polygon by repeating the first vertex at the end.
195                let mut screen_points: Vec<Pos2> = points
196                    .iter()
197                    .map(|point| {
198                        pos2(
199                            rect.left()
200                                + EDGE_PADDING
201                                + point.x * (rect.width() - EDGE_PADDING * 2.0),
202                            rect.top()
203                                + EDGE_PADDING
204                                + point.y * (rect.height() - EDGE_PADDING * 2.0),
205                        )
206                    })
207                    .collect();
208                if let Some(first) = screen_points.first().copied() {
209                    screen_points.push(first);
210                }
211
212                painter.add(Shape::line(
213                    screen_points,
214                    Stroke::new(1.6, egui::Color32::from(color)),
215                ));
216            }
217        }
218    }
219
220    /// Advance all vertices of `polygon` by one simulation step of `dt` seconds,
221    /// bouncing off the [0, 1] boundary on each axis with a perfect reflection.
222    fn step_polygon(polygon: &mut MystifyPolygon, dt: f32) {
223        for (point, velocity) in polygon.points.iter_mut().zip(&mut polygon.velocities) {
224            point.x += velocity.x * dt;
225            point.y += velocity.y * dt;
226
227            // Reflect off left / right walls.
228            if point.x <= 0.0 {
229                point.x = 0.0;
230                velocity.x = velocity.x.abs(); // ensure positive (rightward)
231            } else if point.x >= 1.0 {
232                point.x = 1.0;
233                velocity.x = -velocity.x.abs(); // ensure negative (leftward)
234            }
235
236            // Reflect off top / bottom walls.
237            if point.y <= 0.0 {
238                point.y = 0.0;
239                velocity.y = velocity.y.abs(); // ensure positive (downward)
240            } else if point.y >= 1.0 {
241                point.y = 1.0;
242                velocity.y = -velocity.y.abs(); // ensure negative (upward)
243            }
244        }
245    }
246}