1#![warn(missing_docs)]
2
3use 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
18struct ImageViewer {
20 texture: egui::TextureHandle,
22 image_size: [usize; 2],
24 zoom: f32,
26 base_zoom: f32,
28 screenshot: Option<ScreenshotState>,
30 title: String,
32}
33
34const PADDING_PX: f32 = 24.0;
36const MIN_WINDOW: Vec2 = Vec2::new(320.0, 240.0);
38const MAX_WINDOW: Vec2 = Vec2::new(1200.0, 900.0);
40const UI_OVERHEAD_PX: f32 = 120.0;
42const UI_OVERHEAD_X_PX: f32 = 24.0;
44
45#[derive(Clone)]
47struct ScreenshotState {
48 requested: bool,
50 output_path: PathBuf,
52}
53
54fn 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 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 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 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 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
232fn initial_window_size(image_size: [usize; 2]) -> Vec2 {
234 let (_, window) = initial_view(image_size);
235 window
236}
237
238pub 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#[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
326fn 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}