viewer.rs (9908B)
1 use bitflags::bitflags; 2 use egui::{emath::TSTransform, pos2, Color32, Rangef, Rect}; 3 use notedeck::media::{MediaInfo, ViewMediaInfo}; 4 use notedeck::{ImageType, Images}; 5 6 bitflags! { 7 #[repr(transparent)] 8 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] 9 pub struct MediaViewerFlags: u64 { 10 /// Open the media viewer fullscreen 11 const Fullscreen = 1 << 0; 12 13 /// Enable a transition animation 14 const Transition = 1 << 1; 15 16 /// Are we open or closed? 17 const Open = 1 << 2; 18 } 19 } 20 21 /// State used in the MediaViewer ui widget. 22 pub struct MediaViewerState { 23 /// When 24 pub media_info: ViewMediaInfo, 25 pub scene_rect: Option<Rect>, 26 pub flags: MediaViewerFlags, 27 pub anim_id: egui::Id, 28 } 29 30 impl Default for MediaViewerState { 31 fn default() -> Self { 32 Self { 33 anim_id: egui::Id::new("notedeck-fullscreen-media-viewer"), 34 media_info: Default::default(), 35 scene_rect: None, 36 flags: MediaViewerFlags::Transition | MediaViewerFlags::Fullscreen, 37 } 38 } 39 } 40 41 impl MediaViewerState { 42 pub fn new(anim_id: egui::Id) -> Self { 43 Self { 44 anim_id, 45 ..Default::default() 46 } 47 } 48 49 /// How much is our media viewer open 50 pub fn open_amount(&self, ui: &mut egui::Ui) -> f32 { 51 ui.ctx().animate_bool_with_time_and_easing( 52 self.anim_id, 53 self.flags.contains(MediaViewerFlags::Open), 54 0.3, 55 egui::emath::easing::cubic_out, 56 ) 57 } 58 59 /// Should we show the control even if we're closed? 60 /// Needed for transition animation 61 pub fn should_show(&self, ui: &mut egui::Ui) -> bool { 62 if self.flags.contains(MediaViewerFlags::Open) { 63 return true; 64 } 65 66 // we are closing 67 self.open_amount(ui) > 0.0 68 } 69 } 70 71 /// A panning, scrolling, optionally fullscreen, and tiling media viewer 72 pub struct MediaViewer<'a> { 73 state: &'a mut MediaViewerState, 74 } 75 76 impl<'a> MediaViewer<'a> { 77 pub fn new(state: &'a mut MediaViewerState) -> Self { 78 Self { state } 79 } 80 81 /// Is this 82 pub fn fullscreen(self, enable: bool) -> Self { 83 self.state.flags.set(MediaViewerFlags::Fullscreen, enable); 84 self 85 } 86 87 /// Enable open transition animation 88 pub fn transition(self, enable: bool) -> Self { 89 self.state.flags.set(MediaViewerFlags::Transition, enable); 90 self 91 } 92 93 pub fn ui(&mut self, images: &mut Images, ui: &mut egui::Ui) -> egui::Response { 94 if self.state.flags.contains(MediaViewerFlags::Fullscreen) { 95 egui::Window::new("Media Viewer") 96 .title_bar(false) 97 .fixed_size(ui.ctx().screen_rect().size()) 98 .fixed_pos(ui.ctx().screen_rect().min) 99 .frame(egui::Frame::NONE) 100 .show(ui.ctx(), |ui| self.ui_content(images, ui)) 101 .unwrap() // SAFETY: we are always open 102 .inner 103 .unwrap() 104 } else { 105 self.ui_content(images, ui) 106 } 107 } 108 109 fn ui_content(&mut self, images: &mut Images, ui: &mut egui::Ui) -> egui::Response { 110 let avail_rect = ui.available_rect_before_wrap(); 111 112 let scene_rect = if let Some(scene_rect) = self.state.scene_rect { 113 scene_rect 114 } else { 115 self.state.scene_rect = Some(avail_rect); 116 avail_rect 117 }; 118 119 let zoom_range: egui::Rangef = (0.0..=10.0).into(); 120 121 let is_open = self.state.flags.contains(MediaViewerFlags::Open); 122 let can_transition = self.state.flags.contains(MediaViewerFlags::Transition); 123 let open_amount = self.state.open_amount(ui); 124 let transitioning = if !can_transition { 125 false 126 } else if is_open { 127 open_amount < 1.0 128 } else { 129 open_amount > 0.0 130 }; 131 132 let mut trans_rect = if transitioning { 133 let clicked_img = &self.state.media_info.clicked_media(); 134 let src_pos = &clicked_img.original_position; 135 let in_scene_pos = Self::first_image_rect(ui, clicked_img, images); 136 transition_scene_rect( 137 &avail_rect, 138 &zoom_range, 139 &in_scene_pos, 140 src_pos, 141 open_amount, 142 ) 143 } else { 144 scene_rect 145 }; 146 147 // Draw background 148 ui.painter().rect_filled( 149 avail_rect, 150 0.0, 151 egui::Color32::from_black_alpha((200.0 * open_amount) as u8), 152 ); 153 154 let scene = egui::Scene::new().zoom_range(zoom_range); 155 156 // We are opening, so lock controls 157 /* TODO(jb55): 0.32 158 if transitioning { 159 scene = scene.sense(egui::Sense::hover()); 160 } 161 */ 162 163 let resp = scene.show(ui, &mut trans_rect, |ui| { 164 Self::render_image_tiles(&self.state.media_info.medias, images, ui, open_amount); 165 }); 166 167 self.state.scene_rect = Some(trans_rect); 168 169 resp.response 170 } 171 172 /// The rect of the first image to be placed. 173 /// This is mainly used for the transition animation 174 /// 175 /// TODO(jb55): replace this with a "placed" variant once 176 /// we have image layouts 177 fn first_image_rect(ui: &mut egui::Ui, media: &MediaInfo, images: &mut Images) -> Rect { 178 // fetch image texture 179 let Some(texture) = images.latest_texture(ui, &media.url, ImageType::Content(None)) else { 180 tracing::error!("could not get latest texture in first_image_rect"); 181 return Rect::ZERO; 182 }; 183 184 // the area the next image will be put in. 185 let mut img_rect = ui.available_rect_before_wrap(); 186 187 let size = texture.size_vec2(); 188 img_rect.set_height(size.y); 189 img_rect.set_width(size.x); 190 img_rect 191 } 192 193 /// 194 /// Tile a scene with images. 195 /// 196 /// TODO(jb55): Let's improve image tiling over time, spiraling outward. We 197 /// should have a way to click "next" and have the scene smoothly transition and 198 /// focus on the next image 199 fn render_image_tiles( 200 infos: &[MediaInfo], 201 images: &mut Images, 202 ui: &mut egui::Ui, 203 open_amount: f32, 204 ) { 205 for info in infos { 206 let url = &info.url; 207 208 // fetch image texture 209 let Some(texture) = images.latest_texture(ui, url, ImageType::Content(None)) else { 210 continue; 211 }; 212 213 // the area the next image will be put in. 214 let mut img_rect = ui.available_rect_before_wrap(); 215 /* 216 if !ui.is_rect_visible(img_rect) { 217 // just stop rendering images if we're going out of the scene 218 // basic culling when we have lots of images 219 break; 220 } 221 */ 222 223 { 224 let size = texture.size_vec2(); 225 img_rect.set_height(size.y); 226 img_rect.set_width(size.x); 227 let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); 228 229 // image actions 230 //let response = ui.interact(render_rect, carousel_id.with("img"), Sense::click()); 231 232 /* 233 if response.clicked() { 234 } else if background_response.clicked() { 235 } 236 */ 237 238 // Paint image 239 ui.painter().image( 240 texture.id(), 241 img_rect, 242 uv, 243 Color32::from_white_alpha((open_amount * 255.0) as u8), 244 ); 245 246 ui.advance_cursor_after_rect(img_rect); 247 } 248 } 249 } 250 } 251 252 /// Helper: lerp a TSTransform (uniform scale + translation) 253 fn lerp_ts(a: TSTransform, b: TSTransform, t: f32) -> TSTransform { 254 let s = egui::lerp(a.scaling..=b.scaling, t); 255 let p = a.translation + (b.translation - a.translation) * t; 256 TSTransform { 257 scaling: s, 258 translation: p, 259 } 260 } 261 262 /// Calculate the open/close amount and transition rect 263 pub fn transition_scene_rect( 264 outer_rect: &Rect, 265 zoom_range: &Rangef, 266 image_rect_in_scene: &Rect, // e.g. Rect::from_min_size(Pos2::ZERO, image_size) 267 timeline_global_rect: &Rect, // saved from timeline Response.rect 268 open_amt: f32, // stable ID per media item 269 ) -> Rect { 270 // Compute the two endpoints: 271 let from = fit_to_rect_in_scene(timeline_global_rect, image_rect_in_scene, zoom_range); 272 let to = fit_to_rect_in_scene(outer_rect, image_rect_in_scene, zoom_range); 273 274 // Interpolate transform and convert to scene_rect expected by Scene::show: 275 let lerped = lerp_ts(from, to, open_amt); 276 277 lerped.inverse() * (*outer_rect) 278 } 279 280 /// Creates a transformation that fits a given scene rectangle into the available screen size. 281 /// 282 /// The resulting visual scene bounds can be larger, due to letterboxing. 283 /// 284 /// Returns the transformation from `scene` to `global` coordinates. 285 fn fit_to_rect_in_scene( 286 rect_in_global: &Rect, 287 rect_in_scene: &Rect, 288 zoom_range: &Rangef, 289 ) -> TSTransform { 290 // Compute the scale factor to fit the bounding rectangle into the available screen size: 291 let scale = rect_in_global.size() / rect_in_scene.size(); 292 293 // Use the smaller of the two scales to ensure the whole rectangle fits on the screen: 294 let scale = scale.min_elem(); 295 296 // Clamp scale to what is allowed 297 let scale = zoom_range.clamp(scale); 298 299 // Compute the translation to center the bounding rect in the screen: 300 let center_in_global = rect_in_global.center().to_vec2(); 301 let center_scene = rect_in_scene.center().to_vec2(); 302 303 // Set the transformation to scale and then translate to center. 304 TSTransform::from_translation(center_in_global - scale * center_scene) 305 * TSTransform::from_scaling(scale) 306 }