1use std::path::PathBuf;
2
3use crate::{input, FitMode, ZoomHandler};
4use eframe::egui::{self, ColorImage, Pos2, Rect, TextureOptions, Ui};
5use egui::{
6 Area,
7 Color32,
8 Context,
9 FontFamily,
10 FontId,
11 Label,
12 Order,
13 RichText,
14 Sense,
15 Vec2,
16};
17use ferrite_config::{
18 ControlsConfig,
19 FerriteConfig,
20 IndicatorConfig,
21 Position,
22};
23use image::GenericImageView;
24
25pub struct ImageRenderer;
26
27#[derive(Debug, Default)]
28pub struct RenderResult {
29 pub delete_requested: bool,
30}
31
32impl ImageRenderer {
33 pub fn render(
34 ui: &mut Ui,
35 ctx: &Context,
36 image_manager: &mut ferrite_image::ImageManager,
37 zoom_handler: &mut ZoomHandler,
38 config: &FerriteConfig,
39 controls: &ControlsConfig,
40 ) -> RenderResult {
41 let mut result = RenderResult::default();
42 let panel_rect = ui.available_rect_before_wrap();
43 input::handle_input(ctx, ui, zoom_handler, controls, panel_rect);
44
45 let current_image_size =
46 image_manager
47 .current_image
48 .as_mut()
49 .map(|image_data| {
50 let (width, height) = image_data.dimensions();
51 Vec2::new(width as f32, height as f32)
52 });
53
54 if let Some(image_size) = current_image_size {
55 zoom_handler
56 .update_for_window_resize(image_size, panel_rect.size());
57 }
58
59 let texture_handle = if let Some(image_data) =
60 image_manager.current_image.as_mut()
61 {
62 if image_manager.texture.is_none() {
63 let size =
64 [image_data.width() as usize, image_data.height() as usize];
65 let image = image_data.to_rgba8();
66
67 let texture = ctx.load_texture(
68 "current-image",
69 ColorImage::from_rgba_unmultiplied(
70 size,
71 image.as_flat_samples().as_slice(),
72 ),
73 TextureOptions::LINEAR,
74 );
75
76 let image_size = Vec2::new(size[0] as f32, size[1] as f32);
77 zoom_handler
78 .update_for_new_image(image_size, panel_rect.size());
79 image_manager.texture = Some(texture);
80 }
81 image_manager.texture.as_ref()
82 } else {
83 None
84 };
85
86 if let Some(texture) = texture_handle {
87 let original_size = texture.size_vec2();
88 let scaled_size = original_size * zoom_handler.zoom_level() as f32;
89
90 let (image_rect, response) = Self::handle_image_positioning(
91 ui,
92 panel_rect,
93 scaled_size,
94 zoom_handler,
95 );
96
97 if response.dragged() {
98 zoom_handler.add_offset(response.drag_delta());
99 }
100
101 let scroll_delta = ctx.input(|i| i.raw_scroll_delta.y);
102 if scroll_delta != 0.0 {
103 Self::handle_zoom(
104 ui,
105 zoom_handler,
106 scroll_delta.into(),
107 panel_rect,
108 );
109 }
110
111 render_resolution_indicator(
112 ui,
113 image_manager.get_current_dimensions(),
114 &config.indicator,
115 );
116 Self::render_zoom_indicator(ui, zoom_handler, &config.indicator);
117 Self::render_filename_indicator(
118 ui,
119 image_manager.current_path.as_ref(),
120 &config.indicator,
121 );
122
123 if Self::render_delete_button(
125 ui,
126 image_manager.current_path.as_ref(),
127 ) {
128 result.delete_requested = true;
129 }
130
131 ui.painter().image(
132 texture.id(),
133 image_rect,
134 Rect::from_min_max(Pos2::ZERO, Pos2::new(1.0, 1.0)),
135 Color32::WHITE,
136 );
137 }
138
139 result
140 }
141
142 fn handle_zoom(
143 ui: &Ui,
144 zoom_handler: &mut ZoomHandler,
145 scroll_delta: f64,
146 panel_rect: Rect,
147 ) {
148 let zoom_step = if scroll_delta > 0.0 { 1.1 } else { 0.9 };
149 let new_zoom = (zoom_handler.zoom_level() * zoom_step).clamp(0.1, 10.0);
150 let current_center = panel_rect.center() + zoom_handler.offset();
151
152 if let Some(cursor_pos) = ui.input(|i| i.pointer.hover_pos()) {
153 let cursor_to_center = cursor_pos - current_center;
154 let scale_factor = new_zoom / zoom_handler.zoom_level();
155 let new_cursor_to_center = cursor_to_center * scale_factor as f32;
156 let offset_correction = cursor_to_center - new_cursor_to_center;
157 zoom_handler.add_offset(offset_correction);
158 zoom_handler.set_zoom(new_zoom);
159 } else {
160 zoom_handler.set_zoom(new_zoom);
161 }
162
163 ui.ctx().request_repaint();
164 }
165
166 fn handle_image_positioning(
167 ui: &mut Ui,
168 panel_rect: Rect,
169 scaled_size: Vec2,
170 zoom_handler: &ZoomHandler,
171 ) -> (Rect, egui::Response) {
172 let panel_center = panel_rect.center();
173 let image_center = panel_center + zoom_handler.offset();
174 let image_rect = Rect::from_center_size(image_center, scaled_size);
175 let constrain_dragging = zoom_handler.get_fit_mode() != FitMode::Custom;
176 let response = ui.allocate_rect(image_rect, Sense::drag());
177
178 let final_rect = if constrain_dragging && response.dragged() {
179 let drag_delta = response.drag_delta();
180 let mut new_rect = image_rect.translate(drag_delta);
181 let min_visible = 50.0;
182
183 if new_rect.max.x < panel_rect.min.x + min_visible {
184 new_rect = new_rect.translate(Vec2::new(
185 panel_rect.min.x + min_visible - new_rect.max.x,
186 0.0,
187 ));
188 }
189 if new_rect.min.x > panel_rect.max.x - min_visible {
190 new_rect = new_rect.translate(Vec2::new(
191 panel_rect.max.x - min_visible - new_rect.min.x,
192 0.0,
193 ));
194 }
195 if new_rect.max.y < panel_rect.min.y + min_visible {
196 new_rect = new_rect.translate(Vec2::new(
197 0.0,
198 panel_rect.min.y + min_visible - new_rect.max.y,
199 ));
200 }
201 if new_rect.min.y > panel_rect.max.y - min_visible {
202 new_rect = new_rect.translate(Vec2::new(
203 0.0,
204 panel_rect.max.y - min_visible - new_rect.min.y,
205 ));
206 }
207 new_rect
208 } else {
209 image_rect
210 };
211
212 (final_rect, response)
213 }
214
215 fn render_zoom_indicator(
216 ui: &mut Ui,
217 zoom_handler: &ZoomHandler,
218 config: &IndicatorConfig,
219 ) {
220 if !config.show_percentage {
221 return;
222 }
223
224 let percentage_text = format!("{:.0}%", zoom_handler.zoom_percentage());
225 let screen_rect = ui.ctx().screen_rect();
226 let padding =
227 Vec2::new(config.padding.x() as f32, config.padding.y() as f32);
228 let font_size = config.font_size as f32;
229 let box_size = measure_text(ui.ctx(), &percentage_text, font_size);
230
231 let pos = match config.position {
232 Position::TopLeft => Pos2::new(
233 screen_rect.min.x + padding.x,
234 screen_rect.min.y + padding.y,
235 ),
236 Position::TopRight => Pos2::new(
237 screen_rect.max.x - box_size.x - padding.x,
238 screen_rect.min.y + padding.y,
239 ),
240 Position::BottomLeft => Pos2::new(
241 screen_rect.min.x + padding.x,
242 screen_rect.max.y - box_size.y - padding.y,
243 ),
244 Position::BottomRight => Pos2::new(
245 screen_rect.max.x - box_size.x - padding.x,
246 screen_rect.max.y - box_size.y - padding.y,
247 ),
248 Position::Top => Pos2::new(
249 screen_rect.center().x - box_size.x / 2.0,
250 screen_rect.min.y + padding.y,
251 ),
252 Position::Bottom => Pos2::new(
253 screen_rect.center().x - box_size.x / 2.0,
254 screen_rect.max.y - box_size.y - padding.y,
255 ),
256 Position::Left => Pos2::new(
257 screen_rect.min.x + padding.x,
258 screen_rect.center().y - box_size.y / 2.0,
259 ),
260 Position::Right => Pos2::new(
261 screen_rect.max.x - box_size.x - padding.x,
262 screen_rect.center().y - box_size.y / 2.0,
263 ),
264 Position::Center => Pos2::new(
265 screen_rect.center().x - box_size.x / 2.0,
266 screen_rect.center().y - box_size.y / 2.0,
267 ),
268 };
269
270 Area::new("zoom_indicator".into())
271 .order(Order::Foreground)
272 .fixed_pos(pos)
273 .show(ui.ctx(), |ui| {
274 egui::Frame::new()
275 .fill(Color32::from_rgba_unmultiplied(
276 config.background_color.r,
277 config.background_color.g,
278 config.background_color.b,
279 config.background_color.a,
280 ))
281 .corner_radius(4.0)
282 .inner_margin(4.0)
283 .show(ui, |ui| {
284 let rich_text = RichText::new(percentage_text)
285 .color(Color32::from_rgba_unmultiplied(
286 config.text_color.r,
287 config.text_color.g,
288 config.text_color.b,
289 config.text_color.a,
290 ))
291 .size(font_size)
292 .family(FontFamily::Proportional);
293
294 let new_lab = Label::new(rich_text).extend();
295 ui.add(new_lab);
296 });
297 });
298 }
299
300 fn render_filename_indicator(
301 ui: &mut Ui,
302 path: Option<&PathBuf>,
303 config: &IndicatorConfig,
304 ) {
305 if let Some(path) = path {
306 let filename = path
307 .file_name()
308 .and_then(|n| n.to_str())
309 .unwrap_or("Unknown");
310
311 let screen_rect = ui.ctx().screen_rect();
312 let padding =
313 Vec2::new(config.padding.x() as f32, config.padding.y() as f32);
314 let font_size = config.font_size as f32;
315
316 let position_pos = Pos2::new(
318 screen_rect.min.x + padding.x,
319 screen_rect.min.y + padding.y,
320 );
321
322 Area::new("filename_indicator".into())
323 .order(Order::Foreground)
324 .fixed_pos(position_pos)
325 .show(ui.ctx(), |ui| {
326 egui::Frame::new()
327 .fill(Color32::from_rgba_unmultiplied(
328 config.background_color.r,
329 config.background_color.g,
330 config.background_color.b,
331 config.background_color.a,
332 ))
333 .corner_radius(4.0)
334 .inner_margin(4.0)
335 .show(ui, |ui| {
336 let rich_text = RichText::new(filename)
337 .color(Color32::from_rgba_unmultiplied(
338 config.text_color.r,
339 config.text_color.g,
340 config.text_color.b,
341 config.text_color.a,
342 ))
343 .size(font_size)
344 .family(FontFamily::Proportional);
345
346 let new_lab = Label::new(rich_text).extend();
348 ui.add(new_lab);
349 });
350 });
351 }
352 }
353}
354
355fn render_resolution_indicator(
356 ui: &mut Ui,
357 dimensions: Option<(u32, u32)>,
358 config: &IndicatorConfig,
359) {
360 if let Some((width, height)) = dimensions {
361 let resolution_text = format!("{}x{}", width, height);
362 let screen_rect = ui.ctx().screen_rect();
363 let padding =
364 Vec2::new(config.padding.x() as f32, config.padding.y() as f32);
365 let font_size = config.font_size as f32;
366 let char_width = font_size * 0.6;
367 let text_width = char_width * resolution_text.len() as f32;
368 let frame_margin = 8.0;
369 let box_size = Vec2::new(
370 text_width + frame_margin * 2.0,
371 font_size + frame_margin * 2.0,
372 );
373
374 let pos = Pos2::new(
375 screen_rect.center().x - box_size.x / 2.0,
376 screen_rect.min.y + padding.y,
377 );
378
379 Area::new("resolution_indicator".into())
380 .order(Order::Foreground)
381 .fixed_pos(pos)
382 .show(ui.ctx(), |ui| {
383 egui::Frame::new()
384 .fill(Color32::from_rgba_unmultiplied(
385 config.background_color.r,
386 config.background_color.g,
387 config.background_color.b,
388 config.background_color.a,
389 ))
390 .corner_radius(4.0)
391 .inner_margin(4.0)
392 .show(ui, |ui| {
393 let rich_text = RichText::new(resolution_text)
394 .color(Color32::from_rgba_unmultiplied(
395 config.text_color.r,
396 config.text_color.g,
397 config.text_color.b,
398 config.text_color.a,
399 ))
400 .size(font_size)
401 .family(FontFamily::Proportional);
402
403 let new_lab = Label::new(rich_text).extend();
404 ui.add(new_lab);
405 });
406 });
407 }
408}
409
410fn measure_text(ctx: &Context, text: &str, font_size: f32) -> Vec2 {
412 let font_id = FontId::new(font_size, FontFamily::Proportional);
413 ctx.fonts(|f| {
414 let galley = f.layout_no_wrap(
415 text.to_string(),
416 font_id,
417 Color32::WHITE, );
419 galley.size()
420 })
421}
422
423impl ImageRenderer {
424 fn render_delete_button(
425 ui: &mut Ui,
426 current_path: Option<&PathBuf>,
427 ) -> bool {
428 if current_path.is_none() {
429 return false;
430 }
431
432 let screen_rect = ui.ctx().screen_rect();
433 let padding = Vec2::new(10.0, 10.0);
434 let button_size = Vec2::new(80.0, 30.0);
435
436 let pos = Pos2::new(
438 screen_rect.min.x + padding.x,
439 screen_rect.max.y - button_size.y - padding.y,
440 );
441
442 Area::new("delete_button".into())
443 .order(Order::Foreground)
444 .fixed_pos(pos)
445 .show(ui.ctx(), |ui| {
446 let button = egui::Button::new("🗑 Delete")
447 .fill(Color32::from_rgba_unmultiplied(180, 60, 60, 200))
448 .stroke(egui::Stroke::new(
449 1.0,
450 Color32::from_rgba_unmultiplied(200, 80, 80, 255),
451 ))
452 .min_size(button_size);
453
454 if ui.add(button).clicked() {
455 return true;
456 }
457 false
458 })
459 .inner
460 }
461}