ferrite_ui/
render.rs

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            // Render delete button
124            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            // Position in top left
317            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                            // This is the current fix for text wrap
347                            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
410/// Helper function to precisely measure text dimensions
411fn 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, // Color doesn't affect measurement
418        );
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        // Position in bottom left corner
437        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}