notedeck

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

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 }