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