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_texture: Option<crate::renderer::VideoTexture>,
97    video_size: (u32, u32),
98    per_output_uniforms: Option<crate::renderer::PerOutputUniforms>,
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_texture: None,
124            video_size: (1, 1),
125            per_output_uniforms: None,
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                crate::config::VideoConfig::default().preload_frames,
209                0,
210            ) {
211                Ok(meta) => {
212                    let first = playback.wait_first_frame(std::time::Duration::from_millis(2000));
213                    let (w, h) = (meta.width, meta.height);
214                    let texture = renderer.create_video_texture(w, h);
215                    if let Some(frame) = first {
216                        if let Err(e) = renderer.update_video_texture(&texture, &frame.data) {
217                            eprintln!("failed to upload first video frame: {e}");
218                            event_loop.exit();
219                            return;
220                        }
221                    }
222                    self.video = Some(playback);
223                    self.video_texture = Some(texture);
224                    self.video_size = (w, h);
225                    self.window = Some(window);
226                    self.per_output_uniforms = Some(renderer.create_per_output_uniforms());
227                    self.renderer = Some(renderer);
228                    self.surface = Some(surface);
229                    self.surface_format = Some(format);
230                    self.start = Some(Instant::now());
231                    return;
232                }
233                Err(e) => {
234                    eprintln!("failed to start video preview: {e}");
235                    event_loop.exit();
236                    return;
237                }
238            }
239        }
240
241        let img = match image::ImageReader::open(&self.target_path) {
242            Ok(reader) => match reader.decode() {
243                Ok(img) => img,
244                Err(e) => {
245                    eprintln!("failed to decode image: {e}");
246                    event_loop.exit();
247                    return;
248                }
249            },
250            Err(e) => {
251                eprintln!("failed to open image: {e}");
252                event_loop.exit();
253                return;
254            }
255        };
256
257        let (new_tex, new_bind) = match renderer.load_texture(&img) {
258            Ok(t) => t,
259            Err(e) => {
260                eprintln!("failed to upload image: {e}");
261                event_loop.exit();
262                return;
263            }
264        };
265
266        let (w, h) = img.dimensions();
267
268        // Fade in over the last applied wallpaper (persisted by the daemon),
269        // so the preview shows a real transition like on the desktop. Fall
270        // back to a solid black texture when nothing was ever applied, when
271        // the outgoing image cannot be loaded, or when it is the same file
272        // as the incoming one (a same-to-same loop would show nothing).
273        let (bg_tex, bg_bind, old_size) =
274            match load_last_wallpaper(&self.target_path).and_then(|bg| {
275                renderer
276                    .load_texture(&bg)
277                    .ok()
278                    .map(|(tex, bind)| (tex, bind, bg.dimensions()))
279            }) {
280                Some((tex, bind, size)) => (tex, bind, size),
281                None => {
282                    let black = image::DynamicImage::ImageRgba8(image::ImageBuffer::from_pixel(
283                        w.max(1),
284                        h.max(1),
285                        image::Rgba([0, 0, 0, 255]),
286                    ));
287                    match renderer.load_texture(&black) {
288                        Ok((tex, bind)) => (tex, bind, (w, h)),
289                        Err(e) => {
290                            eprintln!("failed to upload background: {e}");
291                            event_loop.exit();
292                            return;
293                        }
294                    }
295                }
296            };
297
298        // When the target is an animated GIF, the transition runs once over
299        // the first frame and then the preview switches to live playback,
300        // just like the daemon does on the desktop.
301        self.animated = crate::animated::AnimatedImage::decode(&self.target_path)
302            .ok()
303            .flatten();
304        if let Some(anim) = self.animated.as_mut() {
305            let (w, h) = (anim.width, anim.height);
306            let (tex, bind) = renderer.create_texture(w, h);
307            let first = anim.first_frame();
308            if !first.is_empty() {
309                renderer.update_texture(&tex, first, w, h);
310            }
311            self.play_tex = Some(tex);
312            self.play_bind = Some(bind);
313        }
314
315        self.window = Some(window);
316        self.per_output_uniforms = Some(renderer.create_per_output_uniforms());
317        self.renderer = Some(renderer);
318        self.surface = Some(surface);
319        self.surface_format = Some(format);
320        self.bg_bind = Some(bg_bind);
321        self.new_bind = Some(new_bind);
322        self._bg_tex = Some(bg_tex);
323        self._new_tex = Some(new_tex);
324        self.img_size = img.dimensions();
325        self.old_img_size = old_size;
326        self.start = Some(Instant::now());
327    }
328
329    fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
330        match event {
331            WindowEvent::CloseRequested => event_loop.exit(),
332            WindowEvent::Resized(size) => {
333                if let (Some(renderer), Some(surface), Some(format)) =
334                    (&self.renderer, &self.surface, self.surface_format)
335                {
336                    self.configure_surface(renderer, surface, format, size.width, size.height);
337                }
338            }
339            WindowEvent::RedrawRequested => self.render_frame(event_loop),
340            _ => {}
341        }
342    }
343
344    fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) {
345        if let Some(window) = &self.window {
346            window.request_redraw();
347        }
348    }
349}
350
351impl PreviewApp {
352    fn render_frame(&mut self, event_loop: &ActiveEventLoop) {
353        // Video targets skip the transition and stream frames live.
354        if self.video.is_some() {
355            self.render_video_frame(event_loop);
356            return;
357        }
358
359        let Some(start) = self.start else { return };
360
361        let elapsed = start.elapsed().as_millis() as f32;
362        let total = self.duration.as_millis() as f32;
363        let progress = if self.animated.is_some() {
364            // Animated targets: the transition runs exactly once, then the
365            // preview switches to live playback of the GIF frames.
366            (elapsed / total).min(1.0)
367        } else {
368            // Loop the transition forever so the effect can be judged
369            // repeatedly. The final frame is held for 25% of the duration so
370            // the loop restart reads as a deliberate pause, not a hard snap
371            // back to the outgoing wallpaper.
372            let hold = total * 0.25;
373            let cycle = elapsed % (total + hold);
374            (cycle / total).min(1.0)
375        };
376
377        let size = self
378            .window
379            .as_ref()
380            .map(|w| w.inner_size())
381            .unwrap_or(winit::dpi::PhysicalSize::new(1, 1));
382
383        if self.animated.is_some() && progress >= 1.0 {
384            self.render_live_frame(event_loop, size, elapsed, total);
385            return;
386        }
387
388        let Some(renderer) = &self.renderer else {
389            return;
390        };
391        let Some(surface) = &self.surface else { return };
392        let Some(format) = self.surface_format else {
393            return;
394        };
395        let Some(bg) = &self.bg_bind else { return };
396        let Some(new_bind) = &self.new_bind else {
397            return;
398        };
399
400        let (img_w, img_h) = self.img_size;
401        let (old_w, old_h) = self.old_img_size;
402
403        let uniforms = compute_effect_uniforms(&self.effect, progress);
404
405        match renderer.render_frame(
406            crate::renderer::FrameRequest {
407                surface,
408                format,
409                bg_bind: bg,
410                new_bind,
411                effect: &uniforms,
412                width: size.width,
413                height: size.height,
414                img_width: img_w,
415                img_height: img_h,
416                old_img_width: old_w,
417                old_img_height: old_h,
418                scaling_mode: 0,
419            },
420            self.per_output_uniforms.as_ref().unwrap(),
421        ) {
422            Ok(crate::renderer::FrameStatus::TimedOut) => {
423                // The surface is not presenting right now (e.g. the window is
424                // hidden or the monitor is off); keep polling so the preview
425                // resumes cleanly once frames flow again.
426            }
427            Err(e) => {
428                eprintln!("render error: {e}");
429                event_loop.exit();
430            }
431            _ => {}
432        }
433    }
434
435    /// Mirrors the daemon's `play_video` loop: pull the next displayable frame
436    /// per vsync and present it, never blocking on the bounded decoder queue.
437    fn render_video_frame(&mut self, event_loop: &ActiveEventLoop) {
438        let Some(renderer) = &self.renderer else {
439            return;
440        };
441        let Some(surface) = &self.surface else { return };
442        let Some(format) = self.surface_format else {
443            return;
444        };
445        let Some(playback) = &self.video else { return };
446        let Some(texture) = &self.video_texture else {
447            return;
448        };
449
450        let size = self
451            .window
452            .as_ref()
453            .map(|w| w.inner_size())
454            .unwrap_or(winit::dpi::PhysicalSize::new(1, 1));
455
456        let (w, h) = self.video_size;
457        if let Some(frame) = playback.next_frame()
458            && frame.width == w
459            && frame.height == h
460        {
461            if let Err(e) = renderer.update_video_texture(texture, &frame.data) {
462                eprintln!("video upload error: {e}");
463                event_loop.exit();
464                return;
465            }
466        }
467
468        let uniforms = compute_effect_uniforms(&self.effect, 1.0);
469        match renderer.render_frame(
470            crate::renderer::FrameRequest {
471                surface,
472                format,
473                bg_bind: texture.bind_group(),
474                new_bind: texture.bind_group(),
475                effect: &uniforms,
476                width: size.width.max(1),
477                height: size.height.max(1),
478                img_width: w,
479                img_height: h,
480                old_img_width: w,
481                old_img_height: h,
482                scaling_mode: 0,
483            },
484            self.per_output_uniforms.as_ref().unwrap(),
485        ) {
486            Ok(crate::renderer::FrameStatus::TimedOut) => {}
487            Err(e) => {
488                eprintln!("render error: {e}");
489                event_loop.exit();
490            }
491            _ => {}
492        }
493    }
494
495    /// Present GIF frames live, one per vsync, after the initial transition
496    /// has completed. Texture uploads only happen when the playhead crosses
497    /// into a new frame, so playback is smooth without re-uploading frames
498    /// that are already showing.
499    fn render_live_frame(
500        &mut self,
501        event_loop: &ActiveEventLoop,
502        size: winit::dpi::PhysicalSize<u32>,
503        elapsed: f32,
504        total: f32,
505    ) {
506        let Some(renderer) = &self.renderer else {
507            return;
508        };
509        let Some(surface) = &self.surface else { return };
510        let Some(format) = self.surface_format else {
511            return;
512        };
513        let Some(anim) = self.animated.as_mut() else {
514            return;
515        };
516        let Some(tex) = &self.play_tex else { return };
517        let Some(bind) = &self.play_bind else { return };
518
519        let (anim_w, anim_h) = (anim.width, anim.height);
520        let live_ms = (elapsed - total).max(0.0) as u64;
521        let index = anim.frame_index_at(std::time::Duration::from_millis(live_ms));
522        if index != self.shown_frame {
523            let frame = anim.frame_at(index);
524            if !frame.is_empty() {
525                renderer.update_texture(tex, frame, anim_w, anim_h);
526                self.shown_frame = index;
527            }
528        }
529
530        let uniforms = compute_effect_uniforms(&self.effect, 1.0);
531        match renderer.render_frame(
532            crate::renderer::FrameRequest {
533                surface,
534                format,
535                bg_bind: bind,
536                new_bind: bind,
537                effect: &uniforms,
538                width: size.width,
539                height: size.height,
540                img_width: anim.width,
541                img_height: anim.height,
542                old_img_width: anim.width,
543                old_img_height: anim.height,
544                scaling_mode: 0,
545            },
546            self.per_output_uniforms.as_ref().unwrap(),
547        ) {
548            Ok(crate::renderer::FrameStatus::TimedOut) => {}
549            Err(e) => {
550                eprintln!("render error: {e}");
551                event_loop.exit();
552            }
553            _ => {}
554        }
555    }
556}