notedeck

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

commit 71f6d3014a7e5b2d35ebee5e80cbe9f71fe1fcb9
parent c93c2242b13dd4790a2c143db5d301ed2c8ad625
Author: William Casarin <jb55@jb55.com>
Date:   Thu, 13 Mar 2025 10:28:21 -0700

Merge fullscreen images from jglad

jglad (3):
      #716 add full screen images
      #716 move goto button one level down
      #716 store full size img, add zoom & pan

Diffstat:
Mcrates/notedeck_columns/src/images.rs | 11+++++------
Mcrates/notedeck_columns/src/ui/note/contents.rs | 232++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mcrates/notedeck_columns/src/ui/note/post.rs | 2+-
Mcrates/notedeck_columns/src/ui/timeline.rs | 2+-
4 files changed, 226 insertions(+), 21 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 @@ -5,7 +5,7 @@ use crate::ui::{ note::{NoteOptions, NoteResponse}, }; use crate::{actionbar::NoteAction, images::ImageType, timeline::TimelineKind}; -use egui::{Color32, Hyperlink, Image, RichText}; +use egui::{Button, Color32, Hyperlink, Image, Response, RichText, Sense, Window}; use nostrdb::{BlockType, Mention, Ndb, Note, NoteKey, Transaction}; use tracing::warn; @@ -297,6 +297,20 @@ fn image_carousel( let width = ui.available_size().x; let spinsz = if height > width { width } else { height }; + let show_popup = ui.ctx().memory(|mem| { + mem.data + .get_temp(carousel_id.with("show_popup")) + .unwrap_or(false) + }); + + let current_image = show_popup.then(|| { + ui.ctx().memory(|mem| { + mem.data + .get_temp::<(String, MediaCacheType)>(carousel_id.with("current_image")) + .unwrap_or_else(|| (images[0].0.clone(), images[0].1.clone())) + }) + }); + ui.add_sized([width, height], |ui: &mut egui::Ui| { egui::ScrollArea::horizontal() .id_salt(carousel_id) @@ -307,8 +321,8 @@ fn image_carousel( ui, img_cache, &image, - ImageType::Content(width.round() as u32, height.round() as u32), - cache_type, + ImageType::Content, + cache_type.clone(), |ui| { ui.allocate_space(egui::vec2(spinsz, spinsz)); }, @@ -321,18 +335,26 @@ fn image_carousel( retrieve_latest_texture(&image, gifs, renderable_media), ); let img_resp = ui.add( - Image::new(texture) - .max_height(height) - .rounding(5.0) - .fit_to_original_size(1.0), + Button::image( + Image::new(texture) + .max_height(height) + .rounding(5.0) + .fit_to_original_size(1.0), + ) + .frame(false), ); - img_resp.context_menu(|ui| { - if ui.button("Copy Link").clicked() { - ui.ctx().copy_text(url.to_owned()); - ui.close_menu(); - } - }); + if img_resp.clicked() { + ui.ctx().memory_mut(|mem| { + mem.data.insert_temp(carousel_id.with("show_popup"), true); + mem.data.insert_temp( + carousel_id.with("current_image"), + (image.clone(), cache_type.clone()), + ); + }); + } + + copy_link(url, img_resp); }, ); } @@ -341,4 +363,188 @@ fn image_carousel( }) .inner }); + + if show_popup { + let current_image = current_image + .as_ref() + .expect("the image was actually clicked"); + let image = current_image.clone().0; + let cache_type = current_image.clone().1; + + Window::new("image_popup") + .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.ctx().memory_mut(|mem| { + mem.data.insert_temp(carousel_id.with("show_popup"), false); + }); + } + + // background + ui.painter() + .rect_filled(screen_rect, 0.0, Color32::from_black_alpha(230)); + + // 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, + cache_type.clone(), + |ui| { + ui.allocate_space(egui::vec2(spinsz, spinsz)); + }, + |ui, _| { + ui.allocate_space(egui::vec2(spinsz, spinsz)); + }, + |ui, url, renderable_media, gifs| { + let texture = handle_repaint( + ui, + retrieve_latest_texture(&image, gifs, renderable_media), + ); + + 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 uv_max = egui::pos2( + uv_min.x + visible_width / scaled_size.x, + uv_min.y + visible_height / scaled_size.y, + ); + + 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, response); + }, + ); + }); + }); + } +} + +fn copy_link(url: &str, img_resp: Response) { + img_resp.context_menu(|ui| { + if ui.button("Copy Link").clicked() { + ui.ctx().copy_text(url.to_owned()); + ui.close_menu(); + } + }); } diff --git a/crates/notedeck_columns/src/ui/note/post.rs b/crates/notedeck_columns/src/ui/note/post.rs @@ -427,7 +427,7 @@ impl<'a, 'd> PostView<'a, 'd> { ui, self.note_context.img_cache, &media.url, - crate::images::ImageType::Content(width, height), + crate::images::ImageType::Content, cache_type, |ui| { ui.spinner(); diff --git a/crates/notedeck_columns/src/ui/timeline.rs b/crates/notedeck_columns/src/ui/timeline.rs @@ -110,7 +110,7 @@ fn timeline_ui( let goto_top_resp = if show_top_button { let top_button_pos = ui.available_rect_before_wrap().right_top() - vec2(48.0, -24.0); egui::Area::new(ui.id().with("foreground_area")) - .order(egui::Order::Foreground) + .order(egui::Order::Middle) .fixed_pos(top_button_pos) .show(ui.ctx(), |ui| Some(ui.add(goto_top_button(top_button_pos)))) .inner