viewer.rs (10536B)
1 use bitflags::bitflags; 2 use egui::{emath::TSTransform, pos2, Color32, Rangef, Rect}; 3 use notedeck::media::{AnimationMode, MediaInfo, ViewMediaInfo}; 4 use notedeck::{ImageType, Images, MediaJobSender}; 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( 94 &mut self, 95 images: &mut Images, 96 jobs: &MediaJobSender, 97 ui: &mut egui::Ui, 98 ) -> egui::Response { 99 if self.state.flags.contains(MediaViewerFlags::Fullscreen) { 100 egui::Window::new("Media Viewer") 101 .title_bar(false) 102 .fixed_size(ui.ctx().screen_rect().size()) 103 .fixed_pos(ui.ctx().screen_rect().min) 104 .frame(egui::Frame::NONE) 105 .show(ui.ctx(), |ui| self.ui_content(images, jobs, ui)) 106 .unwrap() // SAFETY: we are always open 107 .inner 108 .unwrap() 109 } else { 110 self.ui_content(images, jobs, ui) 111 } 112 } 113 114 fn ui_content( 115 &mut self, 116 images: &mut Images, 117 jobs: &MediaJobSender, 118 ui: &mut egui::Ui, 119 ) -> egui::Response { 120 let avail_rect = ui.available_rect_before_wrap(); 121 122 let scene_rect = if let Some(scene_rect) = self.state.scene_rect { 123 scene_rect 124 } else { 125 self.state.scene_rect = Some(avail_rect); 126 avail_rect 127 }; 128 129 let zoom_range: egui::Rangef = (0.0..=10.0).into(); 130 131 let is_open = self.state.flags.contains(MediaViewerFlags::Open); 132 let can_transition = self.state.flags.contains(MediaViewerFlags::Transition); 133 let open_amount = self.state.open_amount(ui); 134 let transitioning = if !can_transition { 135 false 136 } else if is_open { 137 open_amount < 1.0 138 } else { 139 open_amount > 0.0 140 }; 141 142 let mut trans_rect = if transitioning { 143 let clicked_img = &self.state.media_info.clicked_media(); 144 let src_pos = &clicked_img.original_position; 145 let in_scene_pos = Self::first_image_rect(ui, clicked_img, images, jobs); 146 transition_scene_rect( 147 &avail_rect, 148 &zoom_range, 149 &in_scene_pos, 150 src_pos, 151 open_amount, 152 ) 153 } else { 154 scene_rect 155 }; 156 157 // Draw background 158 ui.painter().rect_filled( 159 avail_rect, 160 0.0, 161 egui::Color32::from_black_alpha((200.0 * open_amount) as u8), 162 ); 163 164 let scene = egui::Scene::new().zoom_range(zoom_range); 165 166 // We are opening, so lock controls 167 /* TODO(jb55): 0.32 168 if transitioning { 169 scene = scene.sense(egui::Sense::hover()); 170 } 171 */ 172 173 let resp = scene.show(ui, &mut trans_rect, |ui| { 174 Self::render_image_tiles(&self.state.media_info.medias, images, jobs, ui, open_amount); 175 }); 176 177 self.state.scene_rect = Some(trans_rect); 178 179 resp.response 180 } 181 182 /// The rect of the first image to be placed. 183 /// This is mainly used for the transition animation 184 /// 185 /// TODO(jb55): replace this with a "placed" variant once 186 /// we have image layouts 187 fn first_image_rect( 188 ui: &mut egui::Ui, 189 media: &MediaInfo, 190 images: &mut Images, 191 jobs: &MediaJobSender, 192 ) -> Rect { 193 // fetch image texture 194 let Some(texture) = images.latest_texture( 195 jobs, 196 ui, 197 &media.url, 198 ImageType::Content(None), 199 AnimationMode::NoAnimation, 200 ) else { 201 tracing::error!("could not get latest texture in first_image_rect"); 202 return Rect::ZERO; 203 }; 204 205 // the area the next image will be put in. 206 let mut img_rect = ui.available_rect_before_wrap(); 207 208 let size = texture.size_vec2(); 209 img_rect.set_height(size.y); 210 img_rect.set_width(size.x); 211 img_rect 212 } 213 214 /// 215 /// Tile a scene with images. 216 /// 217 /// TODO(jb55): Let's improve image tiling over time, spiraling outward. We 218 /// should have a way to click "next" and have the scene smoothly transition and 219 /// focus on the next image 220 fn render_image_tiles( 221 infos: &[MediaInfo], 222 images: &mut Images, 223 jobs: &MediaJobSender, 224 ui: &mut egui::Ui, 225 open_amount: f32, 226 ) { 227 for info in infos { 228 let url = &info.url; 229 230 // fetch image texture 231 232 // we want to continually redraw things in the gallery 233 let Some(texture) = images.latest_texture( 234 jobs, 235 ui, 236 url, 237 ImageType::Content(None), 238 AnimationMode::Continuous { fps: None }, // media viewer has continuous rendering 239 ) else { 240 continue; 241 }; 242 243 // the area the next image will be put in. 244 let mut img_rect = ui.available_rect_before_wrap(); 245 /* 246 if !ui.is_rect_visible(img_rect) { 247 // just stop rendering images if we're going out of the scene 248 // basic culling when we have lots of images 249 break; 250 } 251 */ 252 253 { 254 let size = texture.size_vec2(); 255 img_rect.set_height(size.y); 256 img_rect.set_width(size.x); 257 let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); 258 259 // image actions 260 //let response = ui.interact(render_rect, carousel_id.with("img"), Sense::click()); 261 262 /* 263 if response.clicked() { 264 } else if background_response.clicked() { 265 } 266 */ 267 268 // Paint image 269 ui.painter().image( 270 texture.id(), 271 img_rect, 272 uv, 273 Color32::from_white_alpha((open_amount * 255.0) as u8), 274 ); 275 276 ui.advance_cursor_after_rect(img_rect); 277 } 278 } 279 } 280 } 281 282 /// Helper: lerp a TSTransform (uniform scale + translation) 283 fn lerp_ts(a: TSTransform, b: TSTransform, t: f32) -> TSTransform { 284 let s = egui::lerp(a.scaling..=b.scaling, t); 285 let p = a.translation + (b.translation - a.translation) * t; 286 TSTransform { 287 scaling: s, 288 translation: p, 289 } 290 } 291 292 /// Calculate the open/close amount and transition rect 293 pub fn transition_scene_rect( 294 outer_rect: &Rect, 295 zoom_range: &Rangef, 296 image_rect_in_scene: &Rect, // e.g. Rect::from_min_size(Pos2::ZERO, image_size) 297 timeline_global_rect: &Rect, // saved from timeline Response.rect 298 open_amt: f32, // stable ID per media item 299 ) -> Rect { 300 // Compute the two endpoints: 301 let from = fit_to_rect_in_scene(timeline_global_rect, image_rect_in_scene, zoom_range); 302 let to = fit_to_rect_in_scene(outer_rect, image_rect_in_scene, zoom_range); 303 304 // Interpolate transform and convert to scene_rect expected by Scene::show: 305 let lerped = lerp_ts(from, to, open_amt); 306 307 lerped.inverse() * (*outer_rect) 308 } 309 310 /// Creates a transformation that fits a given scene rectangle into the available screen size. 311 /// 312 /// The resulting visual scene bounds can be larger, due to letterboxing. 313 /// 314 /// Returns the transformation from `scene` to `global` coordinates. 315 fn fit_to_rect_in_scene( 316 rect_in_global: &Rect, 317 rect_in_scene: &Rect, 318 zoom_range: &Rangef, 319 ) -> TSTransform { 320 // Compute the scale factor to fit the bounding rectangle into the available screen size: 321 let scale = rect_in_global.size() / rect_in_scene.size(); 322 323 // Use the smaller of the two scales to ensure the whole rectangle fits on the screen: 324 let scale = scale.min_elem(); 325 326 // Clamp scale to what is allowed 327 let scale = zoom_range.clamp(scale); 328 329 // Compute the translation to center the bounding rect in the screen: 330 let center_in_global = rect_in_global.center().to_vec2(); 331 let center_scene = rect_in_scene.center().to_vec2(); 332 333 // Set the transformation to scale and then translate to center. 334 TSTransform::from_translation(center_in_global - scale * center_scene) 335 * TSTransform::from_scaling(scale) 336 }