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}