notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit a124187db6b1964589edbdac73d0f5fdc2185708
parent 02ec0250966ebcd93019a04eb1f2e6cafc4c3af6
Author: jglad <jakub.gladysz1@gmail.com>
Date:   Tue, 11 Mar 2025 21:47:52 +0100

#716 store full size img, add zoom & pan

Diffstat:
Mcrates/notedeck_columns/src/images.rs | 11+++++------
Mcrates/notedeck_columns/src/ui/note/contents.rs | 139+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mcrates/notedeck_columns/src/ui/note/post.rs | 2+-
3 files changed, 128 insertions(+), 24 deletions(-)

diff --git a/crates/notedeck_columns/src/images.rs b/crates/notedeck_columns/src/images.rs @@ -119,9 +119,8 @@ fn process_pfp_bitmap(imgtyp: ImageType, mut image: image::DynamicImage) -> Colo puffin::profile_function!(); match imgtyp { - ImageType::Content(w, h) => { - let image = image.resize(w, h, FilterType::CatmullRom); // DynamicImage - let image_buffer = image.into_rgba8(); // RgbaImage (ImageBuffer) + ImageType::Content => { + let image_buffer = image.clone().into_rgba8(); let color_image = ColorImage::from_rgba_unmultiplied( [ image_buffer.width() as usize, @@ -164,7 +163,7 @@ fn parse_img_response(response: ehttp::Response, imgtyp: ImageType) -> Result<Co let content_type = response.content_type().unwrap_or_default(); let size_hint = match imgtyp { ImageType::Profile(size) => SizeHint::Size(size, size), - ImageType::Content(w, h) => SizeHint::Size(w, h), + ImageType::Content => SizeHint::default(), }; if content_type.starts_with("image/svg") { @@ -354,8 +353,8 @@ pub fn fetch_binary_from_disk(path: PathBuf) -> Result<Vec<u8>> { pub enum ImageType { /// Profile Image (size) Profile(u32), - /// Content Image (width, height) - Content(u32, u32), + /// Content Image + Content, } pub fn fetch_img( diff --git a/crates/notedeck_columns/src/ui/note/contents.rs b/crates/notedeck_columns/src/ui/note/contents.rs @@ -319,7 +319,7 @@ fn image_carousel( ui, img_cache, &image, - ImageType::Content(width.round() as u32, height.round() as u32), + ImageType::Content, cache_type.clone(), |ui| { ui.allocate_space(egui::vec2(spinsz, spinsz)); @@ -370,17 +370,15 @@ fn image_carousel( let cache_type = current_image.clone().1; Window::new("image_popup") - .order(egui::Order::Foreground) .title_bar(false) .fixed_size(ui.ctx().screen_rect().size()) + .fixed_pos(ui.ctx().screen_rect().min) .frame(egui::Frame::none()) .show(ui.ctx(), |ui| { let screen_rect = ui.ctx().screen_rect(); // escape - if ui.input(|i| i.key_pressed(egui::Key::Escape)) - || (ui.input(|i| i.pointer.any_click())) - { + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { ui.ctx().memory_mut(|mem| { mem.data.insert_temp(carousel_id.with("show_popup"), false); }); @@ -390,12 +388,43 @@ fn image_carousel( ui.painter() .rect_filled(screen_rect, 0.0, Color32::from_black_alpha(230)); - ui.vertical_centered(|ui| { + // zoom init + let zoom_id = carousel_id.with("zoom_level"); + let mut zoom = ui + .ctx() + .memory(|mem| mem.data.get_temp(zoom_id).unwrap_or(1.0_f32)); + + // pan init + let pan_id = carousel_id.with("pan_offset"); + let mut pan_offset = ui + .ctx() + .memory(|mem| mem.data.get_temp(pan_id).unwrap_or(egui::Vec2::ZERO)); + + // zoom & scroll + if ui.input(|i| i.pointer.hover_pos()).is_some() { + let scroll_delta = ui.input(|i| i.smooth_scroll_delta); + if scroll_delta.y != 0.0 { + let zoom_factor = if scroll_delta.y > 0.0 { 1.05 } else { 0.95 }; + zoom *= zoom_factor; + zoom = zoom.clamp(0.1, 5.0); + + if zoom <= 1.0 { + pan_offset = egui::Vec2::ZERO; + } + + ui.ctx().memory_mut(|mem| { + mem.data.insert_temp(zoom_id, zoom); + mem.data.insert_temp(pan_id, pan_offset); + }); + } + } + + ui.centered_and_justified(|ui| { render_images( ui, img_cache, &image, - ImageType::Content(width.round() as u32, height.round() as u32), + ImageType::Content, cache_type.clone(), |ui| { ui.allocate_space(egui::vec2(spinsz, spinsz)); @@ -409,23 +438,99 @@ fn image_carousel( retrieve_latest_texture(&image, gifs, renderable_media), ); - // top margin because ui.vertical_centered pushes the img to the top - // and ui.centered_and_justified takes up all the screen - ui.add_space((screen_rect.height() - texture.size_vec2().y) / 2.0); + let texture_size = texture.size_vec2(); + let screen_size = screen_rect.size(); + let scale = (screen_size.x / texture_size.x) + .min(screen_size.y / texture_size.y) + .min(1.0); + let scaled_size = texture_size * scale * zoom; + + let visible_width = scaled_size.x.min(screen_size.x); + let visible_height = scaled_size.y.min(screen_size.y); + + let max_pan_x = ((scaled_size.x - visible_width) / 2.0).max(0.0); + let max_pan_y = ((scaled_size.y - visible_height) / 2.0).max(0.0); + + if max_pan_x > 0.0 { + pan_offset.x = pan_offset.x.clamp(-max_pan_x, max_pan_x); + } else { + pan_offset.x = 0.0; + } + + if max_pan_y > 0.0 { + pan_offset.y = pan_offset.y.clamp(-max_pan_y, max_pan_y); + } else { + pan_offset.y = 0.0; + } + + let (rect, response) = ui.allocate_exact_size( + egui::vec2(visible_width, visible_height), + egui::Sense::click_and_drag(), + ); + + let uv_min = egui::pos2( + 0.5 - (visible_width / scaled_size.x) / 2.0 + + pan_offset.x / scaled_size.x, + 0.5 - (visible_height / scaled_size.y) / 2.0 + + pan_offset.y / scaled_size.y, + ); - let img_resp = ui.add( - Image::new(texture) - .fit_to_original_size(1.0) - .sense(Sense::click()), + let uv_max = egui::pos2( + uv_min.x + visible_width / scaled_size.x, + uv_min.y + visible_height / scaled_size.y, ); - if img_resp.clicked() || img_resp.secondary_clicked() { - ui.memory_mut(|mem| { + let uv = egui::Rect::from_min_max(uv_min, uv_max); + + ui.painter() + .image(texture.id(), rect, uv, egui::Color32::WHITE); + let img_rect = ui.allocate_rect(rect, Sense::click()); + + if img_rect.clicked() { + ui.ctx().memory_mut(|mem| { mem.data.insert_temp(carousel_id.with("show_popup"), true); }); + } else if img_rect.clicked_elsewhere() { + ui.ctx().memory_mut(|mem| { + mem.data.insert_temp(carousel_id.with("show_popup"), false); + }); + } + + // Handle dragging for pan + if response.dragged() { + let delta = response.drag_delta(); + + pan_offset.x -= delta.x; + pan_offset.y -= delta.y; + + if max_pan_x > 0.0 { + pan_offset.x = pan_offset.x.clamp(-max_pan_x, max_pan_x); + } else { + pan_offset.x = 0.0; + } + + if max_pan_y > 0.0 { + pan_offset.y = pan_offset.y.clamp(-max_pan_y, max_pan_y); + } else { + pan_offset.y = 0.0; + } + + ui.ctx().memory_mut(|mem| { + mem.data.insert_temp(pan_id, pan_offset); + }); + } + + // reset zoom on double-click + if response.double_clicked() { + pan_offset = egui::Vec2::ZERO; + zoom = 1.0; + ui.ctx().memory_mut(|mem| { + mem.data.insert_temp(pan_id, pan_offset); + mem.data.insert_temp(zoom_id, zoom); + }); } - copy_link(url, img_resp); + copy_link(url, response); }, ); }); diff --git a/crates/notedeck_columns/src/ui/note/post.rs b/crates/notedeck_columns/src/ui/note/post.rs @@ -410,7 +410,7 @@ impl<'a> PostView<'a> { ui, self.img_cache, &media.url, - crate::images::ImageType::Content(width, height), + crate::images::ImageType::Content, cache_type, |ui| { ui.spinner();