Skip to main content

egui_img/
lib.rs

1#![warn(missing_docs)]
2
3//! Tiny helper to show an RGBA image inside an egui window.
4
5use std::{
6    fs::File,
7    io::BufWriter,
8    path::{Path, PathBuf},
9    sync::Arc,
10};
11
12use anyhow::{Result, anyhow};
13use eframe::{NativeOptions, egui};
14use egui::{Vec2, load::SizedTexture};
15use image::RgbaImage;
16use png::{BitDepth, ColorType, Encoder};
17
18/// Simple egui app that shows a single texture with a zoom slider.
19struct ImageViewer {
20    /// Texture containing the displayed image.
21    texture: egui::TextureHandle,
22    /// Pixel dimensions of the image.
23    image_size: [usize; 2],
24    /// Current zoom multiplier.
25    zoom: f32,
26    /// Default zoom used for reset.
27    base_zoom: f32,
28    /// Optional screenshot capture state.
29    screenshot: Option<ScreenshotState>,
30    /// Window title shown in the header.
31    title: String,
32}
33
34/// Layout constants for the viewer window.
35const PADDING_PX: f32 = 24.0;
36/// Minimum window size used for the viewer.
37const MIN_WINDOW: Vec2 = Vec2::new(320.0, 240.0);
38/// Maximum window size used for the viewer.
39const MAX_WINDOW: Vec2 = Vec2::new(1200.0, 900.0);
40/// Estimated vertical chrome (heading + controls) reserved in the window.
41const UI_OVERHEAD_PX: f32 = 120.0;
42/// Horizontal chrome allowance (panel padding/scrollbar reserve).
43const UI_OVERHEAD_X_PX: f32 = 24.0;
44
45/// Tracks pending screenshot capture for the debug helper.
46#[derive(Clone)]
47struct ScreenshotState {
48    /// Whether a screenshot request has been issued.
49    requested: bool,
50    /// Destination path for the captured PNG.
51    output_path: PathBuf,
52}
53
54/// Compute initial zoom and window size that keep a gap around the image while respecting caps.
55fn initial_view(image_size: [usize; 2]) -> (f32, Vec2) {
56    let img = Vec2::new(image_size[0] as f32, image_size[1] as f32);
57    let usable_max = Vec2::new(
58        (MAX_WINDOW.x - PADDING_PX * 2.0 - UI_OVERHEAD_X_PX).max(1.0),
59        (MAX_WINDOW.y - PADDING_PX * 2.0 - UI_OVERHEAD_PX).max(1.0),
60    );
61
62    let fit_zoom = (usable_max.x / img.x)
63        .min(usable_max.y / img.y)
64        .clamp(0.1, 1.0);
65
66    let window = Vec2::new(
67        (img.x * fit_zoom + PADDING_PX * 2.0 + UI_OVERHEAD_X_PX).clamp(MIN_WINDOW.x, MAX_WINDOW.x),
68        (img.y * fit_zoom + PADDING_PX * 2.0 + UI_OVERHEAD_PX).clamp(MIN_WINDOW.y, MAX_WINDOW.y),
69    );
70
71    (fit_zoom, window)
72}
73
74impl ImageViewer {
75    /// Create an `ImageViewer` by uploading the provided `ColorImage` to a texture.
76    fn new(
77        cc: &eframe::CreationContext<'_>,
78        title: String,
79        color_image: egui::ColorImage,
80        screenshot: Option<PathBuf>,
81    ) -> Self {
82        let image_size = color_image.size;
83        let (base_zoom, _) = initial_view(image_size);
84        let texture =
85            cc.egui_ctx
86                .load_texture(title.clone(), color_image, egui::TextureOptions::NEAREST);
87
88        Self {
89            texture,
90            image_size,
91            zoom: base_zoom,
92            base_zoom,
93            screenshot: screenshot.map(|output_path| ScreenshotState {
94                requested: false,
95                output_path,
96            }),
97            title,
98        }
99    }
100
101    /// Pixel size of the image at the current zoom level.
102    fn display_size(&self) -> Vec2 {
103        Vec2::new(
104            self.image_size[0] as f32 * self.zoom,
105            self.image_size[1] as f32 * self.zoom,
106        )
107    }
108
109    /// Render the texture into the given `ui` at `display_size`.
110    fn paint_image(&self, ui: &mut egui::Ui, display_size: Vec2) {
111        let sized_texture = SizedTexture::from_handle(&self.texture);
112
113        ui.add(
114            egui::Image::from_texture(sized_texture)
115                .texture_options(egui::TextureOptions::NEAREST)
116                .fit_to_exact_size(display_size),
117        );
118    }
119
120    /// Kick off and save a screenshot if configured. Returns true when capture completes.
121    fn handle_screenshot(&mut self, ctx: &egui::Context) -> bool {
122        let Some(state) = self.screenshot.as_mut() else {
123            return false;
124        };
125
126        if !state.requested {
127            state.requested = true;
128            ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(Default::default()));
129            ctx.request_repaint();
130            return false;
131        }
132
133        let mut captured: Option<Arc<egui::ColorImage>> = None;
134        ctx.input(|input| {
135            for event in &input.events {
136                if let egui::Event::Screenshot { image, .. } = event {
137                    captured = Some(image.clone());
138                    break;
139                }
140            }
141        });
142
143        if let Some(image) = captured {
144            if let Err(err) = save_color_image(&state.output_path, &image) {
145                eprintln!("Failed to save screenshot: {err}");
146            }
147            ctx.send_viewport_cmd(egui::ViewportCommand::Close);
148            return true;
149        }
150
151        ctx.request_repaint();
152        false
153    }
154}
155
156impl eframe::App for ImageViewer {
157    fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
158        let title = self.title.clone();
159        egui::CentralPanel::default().show(ctx, |ui| {
160            if self.screenshot.is_none() {
161                ui.heading(&title);
162                ui.separator();
163
164                ui.horizontal(|ui| {
165                    ui.label(format!("{} Ă— {}", self.image_size[0], self.image_size[1]));
166                    ui.add(
167                        egui::Slider::new(&mut self.zoom, 0.1..=8.0)
168                            .logarithmic(true)
169                            .text("Zoom"),
170                    );
171                    if ui.button("Reset").clicked() {
172                        self.zoom = self.base_zoom;
173                    }
174                });
175
176                ui.separator();
177            }
178
179            let display_size = self.display_size();
180            let padded_size = Vec2::new(
181                display_size.x + PADDING_PX * 2.0,
182                display_size.y + PADDING_PX * 2.0,
183            );
184            let available = ui.available_size();
185            let fits_without_scroll = padded_size.x <= available.x && padded_size.y <= available.y;
186
187            if fits_without_scroll {
188                if let Some(state) = &self.screenshot && !state.requested {
189                    println!(
190                        "[egui-img debug] available={:?} padded={:?} display={:?} (fits)",
191                        available, padded_size, display_size
192                    );
193                }
194                ui.allocate_ui_with_layout(
195                    available,
196                    egui::Layout::centered_and_justified(egui::Direction::TopDown),
197                    |ui| {
198                        ui.allocate_ui_with_layout(
199                            padded_size,
200                            egui::Layout::centered_and_justified(egui::Direction::TopDown),
201                            |ui| self.paint_image(ui, display_size),
202                        );
203                    },
204                );
205            } else {
206                egui::ScrollArea::both()
207                    .auto_shrink([false, false])
208                    .show(ui, |ui| {
209                        let container = Vec2::new(
210                            padded_size.x.max(ui.available_width()),
211                            padded_size.y.max(ui.available_height()),
212                        );
213                        if let Some(state) = &self.screenshot && !state.requested {
214                            println!(
215                                "[egui-img debug] available={:?} padded={:?} display={:?} (scroll, container={:?})",
216                                available, padded_size, display_size, container
217                            );
218                        }
219                        ui.allocate_ui_with_layout(
220                            container,
221                            egui::Layout::centered_and_justified(egui::Direction::TopDown),
222                            |ui| self.paint_image(ui, display_size),
223                        );
224                    });
225            }
226        });
227
228        let _ = self.handle_screenshot(ctx);
229    }
230}
231
232/// Suggest a window size that stays within a comfortable range for most screens.
233fn initial_window_size(image_size: [usize; 2]) -> Vec2 {
234    let (_, window) = initial_view(image_size);
235    window
236}
237
238/// Show an RGBA image in a lightweight egui window.
239///
240/// This function blocks until the window is closed by the user.
241/// The image is uploaded with nearest‑neighbour sampling to keep pixels crisp.
242pub fn view_image(title: &str, image: RgbaImage) -> Result<()> {
243    let size = [image.width() as usize, image.height() as usize];
244    let mut color_image = Some(egui::ColorImage::from_rgba_unmultiplied(
245        size,
246        image.as_raw(),
247    ));
248    drop(image);
249    let window_title = title.to_string();
250    let app_title = window_title.clone();
251
252    let native_options = NativeOptions {
253        viewport: egui::ViewportBuilder::default()
254            .with_inner_size(initial_window_size(size))
255            .with_title(window_title.clone()),
256        ..Default::default()
257    };
258
259    eframe::run_native(
260        &app_title,
261        native_options,
262        Box::new(move |cc| {
263            let color_image = color_image
264                .take()
265                .expect("image should only be consumed once");
266
267            Ok(Box::new(ImageViewer::new(
268                cc,
269                window_title.clone(),
270                color_image,
271                None,
272            )))
273        }),
274    )
275    .map_err(|err| anyhow!(err.to_string()))
276}
277
278/// View an image and emit a screenshot to `output` once the first frame is rendered.
279///
280/// This is intended for debugging layout/centering issues. The window closes after capture.
281#[cfg(not(target_arch = "wasm32"))]
282pub fn view_image_with_screenshot(title: &str, image: RgbaImage, output: &Path) -> Result<()> {
283    let size = [image.width() as usize, image.height() as usize];
284    let mut color_image = Some(egui::ColorImage::from_rgba_unmultiplied(
285        size,
286        image.as_raw(),
287    ));
288    let output_path = output.to_path_buf();
289    drop(image);
290    let window_title = title.to_string();
291    let app_title = window_title.clone();
292
293    let native_options = NativeOptions {
294        viewport: egui::ViewportBuilder::default()
295            .with_inner_size(initial_window_size(size))
296            .with_title(window_title.clone()),
297        ..Default::default()
298    };
299
300    println!(
301        "[egui-img debug] screenshot to {:?}, window {:?}, base_zoom {:.3}",
302        output_path,
303        initial_window_size(size),
304        initial_view(size).0
305    );
306
307    eframe::run_native(
308        &app_title,
309        native_options,
310        Box::new(move |cc| {
311            let color_image = color_image
312                .take()
313                .expect("image should only be consumed once");
314
315            Ok(Box::new(ImageViewer::new(
316                cc,
317                window_title.clone(),
318                color_image,
319                Some(output_path.clone()),
320            )))
321        }),
322    )
323    .map_err(|err| anyhow!(err.to_string()))
324}
325
326/// Persist an egui `ColorImage` to disk as a PNG file.
327fn save_color_image(path: &Path, image: &egui::ColorImage) -> Result<()> {
328    let file = File::create(path)?;
329    let buffered_file = BufWriter::new(file);
330    let mut encoder = Encoder::new(buffered_file, image.size[0] as u32, image.size[1] as u32);
331    encoder.set_color(ColorType::Rgba);
332    encoder.set_depth(BitDepth::Eight);
333    let mut writer = encoder.write_header()?;
334
335    let mut data = Vec::with_capacity(image.pixels.len() * 4);
336    for color in &image.pixels {
337        let [red, green, blue, alpha] = color.to_srgba_unmultiplied();
338        data.extend_from_slice(&[red, green, blue, alpha]);
339    }
340
341    writer.write_image_data(&data)?;
342    Ok(())
343}