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    video: Option<crate::video::VideoPlayback>,
96    video_tex: Option<wgpu::Texture>,
97    video_bind: Option<wgpu::BindGroup>,
98    video_size: (u32, u32),
99}
100
101impl PreviewApp {
102    fn new(target_path: PathBuf, duration: std::time::Duration, effect: Effect) -> Self {
103        Self {
104            target_path,
105            duration,
106            effect,
107            window: None,
108            renderer: None,
109            surface: None,
110            surface_format: None,
111            bg_bind: None,
112            new_bind: None,
113            _bg_tex: None,
114            _new_tex: None,
115            start: None,
116            img_size: (1, 1),
117            old_img_size: (1, 1),
118            animated: None,
119            play_tex: None,
120            play_bind: None,
121            shown_frame: usize::MAX,
122            video: None,
123            video_tex: None,
124            video_bind: None,
125            video_size: (1, 1),
126        }
127    }
128
129    fn configure_surface(
130        &self,
131        renderer: &Renderer,
132        surface: &wgpu::Surface<'static>,
133        format: wgpu::TextureFormat,
134        width: u32,
135        height: u32,
136    ) {
137        surface.configure(
138            &renderer.device,
139            &wgpu::SurfaceConfiguration {
140                usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
141                format,
142                width: width.max(1),
143                height: height.max(1),
144                present_mode: wgpu::PresentMode::AutoVsync,
145                alpha_mode: wgpu::CompositeAlphaMode::Auto,
146                view_formats: vec![],
147                desired_maximum_frame_latency: 2,
148            },
149        );
150    }
151}
152
153impl ApplicationHandler for PreviewApp {
154    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
155        if self.window.is_some() {
156            return;
157        }
158
159        let window = match event_loop.create_window(
160            Window::default_attributes()
161                .with_title("wallr preview")
162                .with_inner_size(LogicalSize::new(1280.0, 720.0)),
163        ) {
164            Ok(w) => Arc::new(w),
165            Err(e) => {
166                eprintln!("failed to create preview window: {e}");
167                event_loop.exit();
168                return;
169            }
170        };
171
172        let renderer = match pollster::block_on(Renderer::new()) {
173            Ok(r) => r,
174            Err(e) => {
175                eprintln!("renderer init failed: {e}");
176                event_loop.exit();
177                return;
178            }
179        };
180
181        let surface = match renderer.instance.create_surface(window.clone()) {
182            Ok(s) => s,
183            Err(e) => {
184                eprintln!("surface creation failed: {e}");
185                event_loop.exit();
186                return;
187            }
188        };
189
190        let caps = surface.get_capabilities(&renderer.adapter);
191        let format = caps
192            .formats
193            .iter()
194            .find(|f| f.is_srgb())
195            .copied()
196            .unwrap_or(caps.formats[0]);
197
198        let size = window.inner_size();
199        self.configure_surface(&renderer, &surface, format, size.width, size.height);
200
201        // Video preview: stream decoded frames directly into the preview
202        // window. Images and GIFs keep the transition-based preview path.
203        if crate::video::VideoDecoder::is_video_file(&self.target_path) {
204            let playback = crate::video::VideoPlayback::new();
205            match playback.start(
206                &self.target_path,
207                crate::video::HwAccel::from_config("auto"),
208            ) {
209                Ok(meta) => {
210                    let first = playback.wait_first_frame(std::time::Duration::from_millis(2000));
211                    let (w, h) = (meta.width, meta.height);
212                    let (tex, bind) = renderer.create_texture(w, h);
213                    if let Some(frame) = first {
214                        renderer.update_texture(&tex, &frame.data, frame.width, frame.height);
215                    }
216                    self.video = Some(playback);
217                    self.video_tex = Some(tex);
218                    self.video_bind = Some(bind);
219                    self.video_size = (w, h);
220                    self.window = Some(window);
221                    self.renderer = Some(renderer);
222                    self.surface = Some(surface);
223                    self.surface_format = Some(format);
224                    self.start = Some(Instant::now());
225                    return;
226                }
227                Err(e) => {
228                    eprintln!("failed to start video preview: {e}");
229                    event_loop.exit();
230                    return;
231                }
232            }
233        }
234
235        let img = match image::ImageReader::open(&self.target_path) {
236            Ok(reader) => match reader.decode() {
237                Ok(img) => img,
238                Err(e) => {
239                    eprintln!("failed to decode image: {e}");
240                    event_loop.exit();
241                    return;
242                }
243            },
244            Err(e) => {
245                eprintln!("failed to open image: {e}");
246                event_loop.exit();
247                return;
248            }
249        };
250
251        let (new_tex, new_bind) = match renderer.load_texture(&img) {
252            Ok(t) => t,
253            Err(e) => {
254                eprintln!("failed to upload image: {e}");
255                event_loop.exit();
256                return;
257            }
258        };
259
260        let (w, h) = img.dimensions();
261
262        // Fade in over the last applied wallpaper (persisted by the daemon),
263        // so the preview shows a real transition like on the desktop. Fall
264        // back to a solid black texture when nothing was ever applied, when
265        // the outgoing image cannot be loaded, or when it is the same file
266        // as the incoming one (a same-to-same loop would show nothing).
267        let (bg_tex, bg_bind, old_size) =
268            match load_last_wallpaper(&self.target_path).and_then(|bg| {
269                renderer
270                    .load_texture(&bg)
271                    .ok()
272                    .map(|(tex, bind)| (tex, bind, bg.dimensions()))
273            }) {
274                Some((tex, bind, size)) => (tex, bind, size),
275                None => {
276                    let black = image::DynamicImage::ImageRgba8(image::ImageBuffer::from_pixel(
277                        w.max(1),
278                        h.max(1),
279                        image::Rgba([0, 0, 0, 255]),
280                    ));
281                    match renderer.load_texture(&black) {
282                        Ok((tex, bind)) => (tex, bind, (w, h)),
283                        Err(e) => {
284                            eprintln!("failed to upload background: {e}");
285                            event_loop.exit();
286                            return;
287                        }
288                    }
289                }
290            };
291
292        // When the target is an animated GIF, the transition runs once over
293        // the first frame and then the preview switches to live playback,
294        // just like the daemon does on the desktop.
295        self.animated = crate::animated::AnimatedImage::decode(&self.target_path)
296            .ok()
297            .flatten();
298        if let Some(anim) = self.animated.as_mut() {
299            let (w, h) = (anim.width, anim.height);
300            let (tex, bind) = renderer.create_texture(w, h);
301            let first = anim.first_frame();
302            if !first.is_empty() {
303                renderer.update_texture(&tex, first, w, h);
304            }
305            self.play_tex = Some(tex);
306            self.play_bind = Some(bind);
307        }
308
309        self.window = Some(window);
310        self.renderer = Some(renderer);
311        self.surface = Some(surface);
312        self.surface_format = Some(format);
313        self.bg_bind = Some(bg_bind);
314        self.new_bind = Some(new_bind);
315        self._bg_tex = Some(bg_tex);
316        self._new_tex = Some(new_tex);
317        self.img_size = img.dimensions();
318        self.old_img_size = old_size;
319        self.start = Some(Instant::now());
320    }
321
322    fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
323        match event {
324            WindowEvent::CloseRequested => event_loop.exit(),
325            WindowEvent::Resized(size) => {
326                if let (Some(renderer), Some(surface), Some(format)) =
327                    (&self.renderer, &self.surface, self.surface_format)
328                {
329                    self.configure_surface(renderer, surface, format, size.width, size.height);
330                }
331            }
332            WindowEvent::RedrawRequested => self.render_frame(event_loop),
333            _ => {}
334        }
335    }
336
337    fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) {
338        if let Some(window) = &self.window {
339            window.request_redraw();
340        }
341    }
342}
343
344impl PreviewApp {
345    fn render_frame(&mut self, event_loop: &ActiveEventLoop) {
346        // Video targets skip the transition and stream frames live.
347        if self.video.is_some() {
348            self.render_video_frame(event_loop);
349            return;
350        }
351
352        let Some(start) = self.start else { return };
353
354        let elapsed = start.elapsed().as_millis() as f32;
355        let total = self.duration.as_millis() as f32;
356        let progress = if self.animated.is_some() {
357            // Animated targets: the transition runs exactly once, then the
358            // preview switches to live playback of the GIF frames.
359            (elapsed / total).min(1.0)
360        } else {
361            // Loop the transition forever so the effect can be judged
362            // repeatedly. The final frame is held for 25% of the duration so
363            // the loop restart reads as a deliberate pause, not a hard snap
364            // back to the outgoing wallpaper.
365            let hold = total * 0.25;
366            let cycle = elapsed % (total + hold);
367            (cycle / total).min(1.0)
368        };
369
370        let size = self
371            .window
372            .as_ref()
373            .map(|w| w.inner_size())
374            .unwrap_or(winit::dpi::PhysicalSize::new(1, 1));
375
376        if self.animated.is_some() && progress >= 1.0 {
377            self.render_live_frame(event_loop, size, elapsed, total);
378            return;
379        }
380
381        let Some(renderer) = &self.renderer else {
382            return;
383        };
384        let Some(surface) = &self.surface else { return };
385        let Some(format) = self.surface_format else {
386            return;
387        };
388        let Some(bg) = &self.bg_bind else { return };
389        let Some(new_bind) = &self.new_bind else {
390            return;
391        };
392
393        let (img_w, img_h) = self.img_size;
394        let (old_w, old_h) = self.old_img_size;
395
396        let uniforms = compute_effect_uniforms(&self.effect, progress);
397
398        match renderer.render_frame(crate::renderer::FrameRequest {
399            surface,
400            format,
401            bg_bind: bg,
402            new_bind,
403            effect: &uniforms,
404            width: size.width,
405            height: size.height,
406            img_width: img_w,
407            img_height: img_h,
408            old_img_width: old_w,
409            old_img_height: old_h,
410            scaling_mode: 0,
411        }) {
412            Ok(crate::renderer::FrameStatus::TimedOut) => {
413                // The surface is not presenting right now (e.g. the window is
414                // hidden or the monitor is off); keep polling so the preview
415                // resumes cleanly once frames flow again.
416            }
417            Err(e) => {
418                eprintln!("render error: {e}");
419                event_loop.exit();
420            }
421            _ => {}
422        }
423    }
424
425    /// Mirrors the daemon's `play_video` loop: pull the next displayable frame
426    /// per vsync and present it, never blocking on the bounded decoder queue.
427    fn render_video_frame(&mut self, event_loop: &ActiveEventLoop) {
428        let Some(renderer) = &self.renderer else {
429            return;
430        };
431        let Some(surface) = &self.surface else { return };
432        let Some(format) = self.surface_format else {
433            return;
434        };
435        let Some(playback) = &self.video else { return };
436        let Some(tex) = &self.video_tex else { return };
437        let Some(bind) = &self.video_bind else { return };
438
439        let size = self
440            .window
441            .as_ref()
442            .map(|w| w.inner_size())
443            .unwrap_or(winit::dpi::PhysicalSize::new(1, 1));
444
445        let (w, h) = self.video_size;
446        if let Some(frame) = playback.next_frame()
447            && frame.width == w
448            && frame.height == h
449        {
450            renderer.update_texture(tex, &frame.data, frame.width, frame.height);
451        }
452
453        let uniforms = compute_effect_uniforms(&self.effect, 1.0);
454        match renderer.render_frame(crate::renderer::FrameRequest {
455            surface,
456            format,
457            bg_bind: bind,
458            new_bind: bind,
459            effect: &uniforms,
460            width: size.width.max(1),
461            height: size.height.max(1),
462            img_width: w,
463            img_height: h,
464            old_img_width: w,
465            old_img_height: h,
466            scaling_mode: 0,
467        }) {
468            Ok(crate::renderer::FrameStatus::TimedOut) => {}
469            Err(e) => {
470                eprintln!("render error: {e}");
471                event_loop.exit();
472            }
473            _ => {}
474        }
475    }
476
477    /// Present GIF frames live, one per vsync, after the initial transition
478    /// has completed. Texture uploads only happen when the playhead crosses
479    /// into a new frame, so playback is smooth without re-uploading frames
480    /// that are already showing.
481    fn render_live_frame(
482        &mut self,
483        event_loop: &ActiveEventLoop,
484        size: winit::dpi::PhysicalSize<u32>,
485        elapsed: f32,
486        total: f32,
487    ) {
488        let Some(renderer) = &self.renderer else {
489            return;
490        };
491        let Some(surface) = &self.surface else { return };
492        let Some(format) = self.surface_format else {
493            return;
494        };
495        let Some(anim) = self.animated.as_mut() else {
496            return;
497        };
498        let Some(tex) = &self.play_tex else { return };
499        let Some(bind) = &self.play_bind else { return };
500
501        let (anim_w, anim_h) = (anim.width, anim.height);
502        let live_ms = (elapsed - total).max(0.0) as u64;
503        let index = anim.frame_index_at(std::time::Duration::from_millis(live_ms));
504        if index != self.shown_frame {
505            let frame = anim.frame_at(index);
506            if !frame.is_empty() {
507                renderer.update_texture(tex, frame, anim_w, anim_h);
508                self.shown_frame = index;
509            }
510        }
511
512        let uniforms = compute_effect_uniforms(&self.effect, 1.0);
513        match renderer.render_frame(crate::renderer::FrameRequest {
514            surface,
515            format,
516            bg_bind: bind,
517            new_bind: bind,
518            effect: &uniforms,
519            width: size.width,
520            height: size.height,
521            img_width: anim.width,
522            img_height: anim.height,
523            old_img_width: anim.width,
524            old_img_height: anim.height,
525            scaling_mode: 0,
526        }) {
527            Ok(crate::renderer::FrameStatus::TimedOut) => {}
528            Err(e) => {
529                eprintln!("render error: {e}");
530                event_loop.exit();
531            }
532            _ => {}
533        }
534    }
535}