Skip to main content

wallr_core/preview/
mod.rs

1use std::path::PathBuf;
2use std::sync::Arc;
3use std::time::Instant;
4
5use image::GenericImageView;
6use tracing::info;
7use winit::application::ApplicationHandler;
8use winit::dpi::LogicalSize;
9use winit::event::WindowEvent;
10use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
11use winit::window::{Window, WindowId};
12
13use crate::animation::{Effect, compute_effect_uniforms};
14use crate::renderer::Renderer;
15
16#[derive(Debug, thiserror::Error)]
17pub enum PreviewError {
18    #[error("failed to open preview window: {0}")]
19    Window(String),
20}
21
22pub struct PreviewWindow {
23    pub target_path: PathBuf,
24    pub duration: std::time::Duration,
25    pub effect: Effect,
26}
27
28impl PreviewWindow {
29    pub fn new(target_path: PathBuf) -> Self {
30        Self {
31            target_path,
32            duration: std::time::Duration::from_millis(2000),
33            effect: Effect::Grow(crate::animation::GrowParams::default()),
34        }
35    }
36
37    pub async fn run(&self) -> Result<(), PreviewError> {
38        info!("Initializing preview mode for: {:?}", self.target_path);
39
40        let event_loop = EventLoop::new().map_err(|e| PreviewError::Window(e.to_string()))?;
41        event_loop.set_control_flow(ControlFlow::Poll);
42
43        let mut app = PreviewApp::new(self.target_path.clone(), self.duration, self.effect.clone());
44        event_loop
45            .run_app(&mut app)
46            .map_err(|e| PreviewError::Window(e.to_string()))?;
47        Ok(())
48    }
49}
50
51/// The daemon persists the path of the last applied wallpaper here.
52fn last_wallpaper_state() -> Option<PathBuf> {
53    let state_path = dirs::cache_dir()
54        .unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
55        .join("wallr/last_wallpaper");
56    std::fs::read_to_string(state_path)
57        .ok()
58        .map(|s| PathBuf::from(s.trim()))
59}
60
61/// Load the last applied wallpaper for use as the outgoing frame. Returns
62/// `None` when nothing was ever applied or when it is the same file as the
63/// incoming image (a same-to-same transition would show nothing).
64fn load_last_wallpaper(target: &std::path::Path) -> Option<image::DynamicImage> {
65    let last = last_wallpaper_state().filter(|p| p.exists())?;
66    let same = last.canonicalize().unwrap_or_else(|_| last.clone())
67        == target
68            .canonicalize()
69            .unwrap_or_else(|_| target.to_path_buf());
70    if same {
71        return None;
72    }
73    image::ImageReader::open(&last).ok()?.decode().ok()
74}
75
76struct PreviewApp {
77    target_path: PathBuf,
78    duration: std::time::Duration,
79    effect: Effect,
80    window: Option<Arc<Window>>,
81    renderer: Option<Renderer>,
82    surface: Option<wgpu::Surface<'static>>,
83    surface_format: Option<wgpu::TextureFormat>,
84    bg_bind: Option<wgpu::BindGroup>,
85    new_bind: Option<wgpu::BindGroup>,
86    _bg_tex: Option<wgpu::Texture>,
87    _new_tex: Option<wgpu::Texture>,
88    start: Option<Instant>,
89    img_size: (u32, u32),
90    old_img_size: (u32, u32),
91    animated: Option<crate::animated::AnimatedImage>,
92    play_tex: Option<wgpu::Texture>,
93    play_bind: Option<wgpu::BindGroup>,
94    shown_frame: usize,
95}
96
97impl PreviewApp {
98    fn new(target_path: PathBuf, duration: std::time::Duration, effect: Effect) -> Self {
99        Self {
100            target_path,
101            duration,
102            effect,
103            window: None,
104            renderer: None,
105            surface: None,
106            surface_format: None,
107            bg_bind: None,
108            new_bind: None,
109            _bg_tex: None,
110            _new_tex: None,
111            start: None,
112            img_size: (1, 1),
113            old_img_size: (1, 1),
114            animated: None,
115            play_tex: None,
116            play_bind: None,
117            shown_frame: usize::MAX,
118        }
119    }
120
121    fn configure_surface(
122        &self,
123        renderer: &Renderer,
124        surface: &wgpu::Surface<'static>,
125        format: wgpu::TextureFormat,
126        width: u32,
127        height: u32,
128    ) {
129        surface.configure(
130            &renderer.device,
131            &wgpu::SurfaceConfiguration {
132                usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
133                format,
134                width: width.max(1),
135                height: height.max(1),
136                present_mode: wgpu::PresentMode::AutoVsync,
137                alpha_mode: wgpu::CompositeAlphaMode::Auto,
138                view_formats: vec![],
139                desired_maximum_frame_latency: 2,
140            },
141        );
142    }
143}
144
145impl ApplicationHandler for PreviewApp {
146    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
147        if self.window.is_some() {
148            return;
149        }
150
151        let window = match event_loop.create_window(
152            Window::default_attributes()
153                .with_title("wallr preview")
154                .with_inner_size(LogicalSize::new(1280.0, 720.0)),
155        ) {
156            Ok(w) => Arc::new(w),
157            Err(e) => {
158                eprintln!("failed to create preview window: {e}");
159                event_loop.exit();
160                return;
161            }
162        };
163
164        let renderer = match pollster::block_on(Renderer::new()) {
165            Ok(r) => r,
166            Err(e) => {
167                eprintln!("renderer init failed: {e}");
168                event_loop.exit();
169                return;
170            }
171        };
172
173        let surface = match renderer.instance.create_surface(window.clone()) {
174            Ok(s) => s,
175            Err(e) => {
176                eprintln!("surface creation failed: {e}");
177                event_loop.exit();
178                return;
179            }
180        };
181
182        let caps = surface.get_capabilities(&renderer.adapter);
183        let format = caps
184            .formats
185            .iter()
186            .find(|f| f.is_srgb())
187            .copied()
188            .unwrap_or(caps.formats[0]);
189
190        let size = window.inner_size();
191        self.configure_surface(&renderer, &surface, format, size.width, size.height);
192
193        let img = match image::ImageReader::open(&self.target_path) {
194            Ok(reader) => match reader.decode() {
195                Ok(img) => img,
196                Err(e) => {
197                    eprintln!("failed to decode image: {e}");
198                    event_loop.exit();
199                    return;
200                }
201            },
202            Err(e) => {
203                eprintln!("failed to open image: {e}");
204                event_loop.exit();
205                return;
206            }
207        };
208
209        let (new_tex, new_bind) = match renderer.load_texture(&img) {
210            Ok(t) => t,
211            Err(e) => {
212                eprintln!("failed to upload image: {e}");
213                event_loop.exit();
214                return;
215            }
216        };
217
218        let (w, h) = img.dimensions();
219
220        // Fade in over the last applied wallpaper (persisted by the daemon),
221        // so the preview shows a real transition like on the desktop. Fall
222        // back to a solid black texture when nothing was ever applied, when
223        // the outgoing image cannot be loaded, or when it is the same file
224        // as the incoming one (a same-to-same loop would show nothing).
225        let (bg_tex, bg_bind, old_size) =
226            match load_last_wallpaper(&self.target_path).and_then(|bg| {
227                renderer
228                    .load_texture(&bg)
229                    .ok()
230                    .map(|(tex, bind)| (tex, bind, bg.dimensions()))
231            }) {
232                Some((tex, bind, size)) => (tex, bind, size),
233                None => {
234                    let black = image::DynamicImage::ImageRgba8(image::ImageBuffer::from_pixel(
235                        w.max(1),
236                        h.max(1),
237                        image::Rgba([0, 0, 0, 255]),
238                    ));
239                    match renderer.load_texture(&black) {
240                        Ok((tex, bind)) => (tex, bind, (w, h)),
241                        Err(e) => {
242                            eprintln!("failed to upload background: {e}");
243                            event_loop.exit();
244                            return;
245                        }
246                    }
247                }
248            };
249
250        // When the target is an animated GIF, the transition runs once over
251        // the first frame and then the preview switches to live playback,
252        // just like the daemon does on the desktop.
253        self.animated = crate::animated::AnimatedImage::decode(&self.target_path)
254            .ok()
255            .flatten();
256        if let Some(anim) = &self.animated {
257            let (tex, bind) = renderer.create_texture(anim.width, anim.height);
258            renderer.update_texture(&tex, anim.first_frame(), anim.width, anim.height);
259            self.play_tex = Some(tex);
260            self.play_bind = Some(bind);
261        }
262
263        self.window = Some(window);
264        self.renderer = Some(renderer);
265        self.surface = Some(surface);
266        self.surface_format = Some(format);
267        self.bg_bind = Some(bg_bind);
268        self.new_bind = Some(new_bind);
269        self._bg_tex = Some(bg_tex);
270        self._new_tex = Some(new_tex);
271        self.img_size = img.dimensions();
272        self.old_img_size = old_size;
273        self.start = Some(Instant::now());
274    }
275
276    fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
277        match event {
278            WindowEvent::CloseRequested => event_loop.exit(),
279            WindowEvent::Resized(size) => {
280                if let (Some(renderer), Some(surface), Some(format)) =
281                    (&self.renderer, &self.surface, self.surface_format)
282                {
283                    self.configure_surface(renderer, surface, format, size.width, size.height);
284                }
285            }
286            WindowEvent::RedrawRequested => self.render_frame(event_loop),
287            _ => {}
288        }
289    }
290
291    fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) {
292        if let Some(window) = &self.window {
293            window.request_redraw();
294        }
295    }
296}
297
298impl PreviewApp {
299    fn render_frame(&mut self, event_loop: &ActiveEventLoop) {
300        let Some(start) = self.start else { return };
301
302        let elapsed = start.elapsed().as_millis() as f32;
303        let total = self.duration.as_millis() as f32;
304        let progress = if self.animated.is_some() {
305            // Animated targets: the transition runs exactly once, then the
306            // preview switches to live playback of the GIF frames.
307            (elapsed / total).min(1.0)
308        } else {
309            // Loop the transition forever so the effect can be judged
310            // repeatedly. The final frame is held for 25% of the duration so
311            // the loop restart reads as a deliberate pause, not a hard snap
312            // back to the outgoing wallpaper.
313            let hold = total * 0.25;
314            let cycle = elapsed % (total + hold);
315            (cycle / total).min(1.0)
316        };
317
318        let size = self
319            .window
320            .as_ref()
321            .map(|w| w.inner_size())
322            .unwrap_or(winit::dpi::PhysicalSize::new(1, 1));
323
324        if self.animated.is_some() && progress >= 1.0 {
325            self.render_live_frame(event_loop, size, elapsed, total);
326            return;
327        }
328
329        let Some(renderer) = &self.renderer else {
330            return;
331        };
332        let Some(surface) = &self.surface else { return };
333        let Some(format) = self.surface_format else {
334            return;
335        };
336        let Some(bg) = &self.bg_bind else { return };
337        let Some(new_bind) = &self.new_bind else {
338            return;
339        };
340
341        let (img_w, img_h) = self.img_size;
342        let (old_w, old_h) = self.old_img_size;
343
344        let uniforms = compute_effect_uniforms(&self.effect, progress);
345
346        match renderer.render_frame(crate::renderer::FrameRequest {
347            surface,
348            format,
349            bg_bind: bg,
350            new_bind,
351            effect: &uniforms,
352            width: size.width,
353            height: size.height,
354            img_width: img_w,
355            img_height: img_h,
356            old_img_width: old_w,
357            old_img_height: old_h,
358        }) {
359            Ok(crate::renderer::FrameStatus::TimedOut) => {
360                // The surface is not presenting right now (e.g. the window is
361                // hidden or the monitor is off); keep polling so the preview
362                // resumes cleanly once frames flow again.
363            }
364            Err(e) => {
365                eprintln!("render error: {e}");
366                event_loop.exit();
367            }
368            _ => {}
369        }
370    }
371
372    /// Present GIF frames live, one per vsync, after the initial transition
373    /// has completed. Texture uploads only happen when the playhead crosses
374    /// into a new frame, so playback is smooth without re-uploading frames
375    /// that are already showing.
376    fn render_live_frame(
377        &mut self,
378        event_loop: &ActiveEventLoop,
379        size: winit::dpi::PhysicalSize<u32>,
380        elapsed: f32,
381        total: f32,
382    ) {
383        let Some(renderer) = &self.renderer else {
384            return;
385        };
386        let Some(surface) = &self.surface else { return };
387        let Some(format) = self.surface_format else {
388            return;
389        };
390        let Some(anim) = &self.animated else { return };
391        let Some(tex) = &self.play_tex else { return };
392        let Some(bind) = &self.play_bind else { return };
393
394        let live_ms = (elapsed - total).max(0.0) as u64;
395        let index = anim.frame_index_at(std::time::Duration::from_millis(live_ms));
396        if index != self.shown_frame {
397            renderer.update_texture(tex, anim.frame_at(index), anim.width, anim.height);
398            self.shown_frame = index;
399        }
400
401        let uniforms = compute_effect_uniforms(&self.effect, 1.0);
402        match renderer.render_frame(crate::renderer::FrameRequest {
403            surface,
404            format,
405            bg_bind: bind,
406            new_bind: bind,
407            effect: &uniforms,
408            width: size.width,
409            height: size.height,
410            img_width: anim.width,
411            img_height: anim.height,
412            old_img_width: anim.width,
413            old_img_height: anim.height,
414        }) {
415            Ok(crate::renderer::FrameStatus::TimedOut) => {}
416            Err(e) => {
417                eprintln!("render error: {e}");
418                event_loop.exit();
419            }
420            _ => {}
421        }
422    }
423}