notedeck

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

commit 51f774414968c2f3945ccef19bcc8b2b85056cb4
parent 6d393c9c3743059adb2ccde1592e1b350992db8f
Author: William Casarin <jb55@jb55.com>
Date:   Mon, 28 Jul 2025 16:12:29 -0700

media/viewer: fullscreen transition animations

Signed-off-by: William Casarin <jb55@jb55.com>

Diffstat:
Mcrates/notedeck/src/media/action.rs | 6++++++
Mcrates/notedeck_chrome/src/chrome.rs | 2--
Mcrates/notedeck_columns/src/actionbar.rs | 10+++++-----
Mcrates/notedeck_columns/src/app.rs | 33+++++++++++++++------------------
Mcrates/notedeck_columns/src/nav.rs | 1-
Mcrates/notedeck_columns/src/options.rs | 3---
Mcrates/notedeck_ui/src/media/mod.rs | 2+-
Mcrates/notedeck_ui/src/media/viewer.rs | 224+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
8 files changed, 230 insertions(+), 51 deletions(-)

diff --git a/crates/notedeck/src/media/action.rs b/crates/notedeck/src/media/action.rs @@ -25,6 +25,12 @@ pub struct ViewMediaInfo { pub medias: Vec<MediaInfo>, } +impl ViewMediaInfo { + pub fn clicked_media(&self) -> &MediaInfo { + &self.medias[self.clicked_index] + } +} + /// Actions generated by media ui interactions pub enum MediaAction { /// An image was clicked on in a carousel, we have diff --git a/crates/notedeck_chrome/src/chrome.rs b/crates/notedeck_chrome/src/chrome.rs @@ -696,7 +696,6 @@ fn chrome_handle_app_action( ctx.zaps, ctx.img_cache, &mut columns.view_state, - &mut columns.options, ui, ); @@ -753,7 +752,6 @@ fn columns_route_to_profile( ctx.zaps, ctx.img_cache, &mut columns.view_state, - &mut columns.options, ui, ); diff --git a/crates/notedeck_columns/src/actionbar.rs b/crates/notedeck_columns/src/actionbar.rs @@ -1,7 +1,6 @@ use crate::{ column::Columns, nav::{RouterAction, RouterType}, - options::AppOptions, route::Route, timeline::{ thread::{ @@ -18,6 +17,7 @@ use notedeck::{ get_wallet_for, note::ZapTargetAmount, Accounts, GlobalWallet, Images, NoteAction, NoteCache, NoteZapTargetOwned, UnknownIds, ZapAction, ZapTarget, ZappingError, Zaps, }; +use notedeck_ui::media::MediaViewerFlags; use tracing::error; pub struct NewNotes { @@ -54,7 +54,6 @@ fn execute_note_action( zaps: &mut Zaps, images: &mut Images, view_state: &mut ViewState, - app_options: &mut AppOptions, router_type: RouterType, ui: &mut egui::Ui, col: usize, @@ -160,7 +159,10 @@ fn execute_note_action( media_action.on_view_media(|medias| { view_state.media_viewer.media_info = medias.clone(); tracing::debug!("on_view_media {:?}", &medias); - app_options.set(AppOptions::FullscreenMedia, true); + view_state + .media_viewer + .flags + .set(MediaViewerFlags::Open, true); }); media_action.process_default_media_actions(images) @@ -191,7 +193,6 @@ pub fn execute_and_process_note_action( zaps: &mut Zaps, images: &mut Images, view_state: &mut ViewState, - app_options: &mut AppOptions, ui: &mut egui::Ui, ) -> Option<RouterAction> { let router_type = { @@ -217,7 +218,6 @@ pub fn execute_and_process_note_action( zaps, images, view_state, - app_options, router_type, ui, col, diff --git a/crates/notedeck_columns/src/app.rs b/crates/notedeck_columns/src/app.rs @@ -23,7 +23,7 @@ use notedeck::{ Images, JobsCache, Localization, UnknownIds, }; use notedeck_ui::{ - media::{MediaViewer, MediaViewerState}, + media::{MediaViewer, MediaViewerFlags, MediaViewerState}, NoteOptions, }; use std::collections::{BTreeSet, HashMap}; @@ -368,12 +368,7 @@ fn render_damus( render_damus_desktop(damus, app_ctx, ui) }; - fullscreen_media_viewer_ui( - ui, - &mut damus.options, - &mut damus.view_state.media_viewer, - app_ctx.img_cache, - ); + fullscreen_media_viewer_ui(ui, &mut damus.view_state.media_viewer, app_ctx.img_cache); // We use this for keeping timestamps and things up to date ui.ctx().request_repaint_after(Duration::from_secs(5)); @@ -386,33 +381,35 @@ fn render_damus( /// an image is clicked fn fullscreen_media_viewer_ui( ui: &mut egui::Ui, - options: &mut AppOptions, - viewer_state: &mut MediaViewerState, + state: &mut MediaViewerState, img_cache: &mut Images, ) { - if !options.contains(AppOptions::FullscreenMedia) || viewer_state.media_info.medias.is_empty() { + if !state.should_show(ui) { + if state.scene_rect.is_some() { + // if we shouldn't show yet we will have a scene + // rect, then we should clear it for next time + tracing::debug!("fullscreen_media_viewer_ui: resetting scene rect"); + state.scene_rect = None; + } return; } // Close it? if ui.input(|i| i.key_pressed(egui::Key::Escape)) { - fullscreen_media_close(options, viewer_state); + fullscreen_media_close(state); return; } - let resp = MediaViewer::new(viewer_state) - .fullscreen(true) - .ui(img_cache, ui); + let resp = MediaViewer::new(state).fullscreen(true).ui(img_cache, ui); if resp.clicked() { - fullscreen_media_close(options, viewer_state); + fullscreen_media_close(state); } } /// Close the fullscreen media player. This also resets the scene_rect state -fn fullscreen_media_close(options: &mut AppOptions, state: &mut MediaViewerState) { - options.set(AppOptions::FullscreenMedia, false); - state.scene_rect = None; +fn fullscreen_media_close(state: &mut MediaViewerState) { + state.flags.set(MediaViewerFlags::Open, false); } /* diff --git a/crates/notedeck_columns/src/nav.rs b/crates/notedeck_columns/src/nav.rs @@ -460,7 +460,6 @@ fn process_render_nav_action( ctx.zaps, ctx.img_cache, &mut app.view_state, - &mut app.options, ui, ) } diff --git a/crates/notedeck_columns/src/options.rs b/crates/notedeck_columns/src/options.rs @@ -16,9 +16,6 @@ bitflags! { /// Should we scroll to top on the active column? const ScrollToTop = 1 << 3; - - /// Are we showing fullscreen media? - const FullscreenMedia = 1 << 4; } } diff --git a/crates/notedeck_ui/src/media/mod.rs b/crates/notedeck_ui/src/media/mod.rs @@ -1,3 +1,3 @@ mod viewer; -pub use viewer::{MediaViewer, MediaViewerState}; +pub use viewer::{MediaViewer, MediaViewerFlags, MediaViewerState}; diff --git a/crates/notedeck_ui/src/media/viewer.rs b/crates/notedeck_ui/src/media/viewer.rs @@ -1,34 +1,93 @@ -use egui::{pos2, Color32, Rect}; +use bitflags::bitflags; +use egui::{emath::TSTransform, pos2, Color32, Rangef, Rect}; use notedeck::media::{MediaInfo, ViewMediaInfo}; use notedeck::{ImageType, Images}; +bitflags! { + #[repr(transparent)] + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] + pub struct MediaViewerFlags: u64 { + /// Open the media viewer fullscreen + const Fullscreen = 1 << 0; + + /// Enable a transition animation + const Transition = 1 << 1; + + /// Are we open or closed? + const Open = 1 << 2; + } +} + /// State used in the MediaViewer ui widget. -#[derive(Default)] pub struct MediaViewerState { /// When pub media_info: ViewMediaInfo, pub scene_rect: Option<Rect>, + pub flags: MediaViewerFlags, + pub anim_id: egui::Id, +} + +impl Default for MediaViewerState { + fn default() -> Self { + Self { + anim_id: egui::Id::new("notedeck-fullscreen-media-viewer"), + media_info: Default::default(), + scene_rect: None, + flags: MediaViewerFlags::Transition | MediaViewerFlags::Fullscreen, + } + } +} + +impl MediaViewerState { + pub fn new(anim_id: egui::Id) -> Self { + Self { + anim_id, + ..Default::default() + } + } + + /// How much is our media viewer open + pub fn open_amount(&self, ui: &mut egui::Ui) -> f32 { + ui.ctx() + .animate_bool_responsive(self.anim_id, self.flags.contains(MediaViewerFlags::Open)) + } + + /// Should we show the control even if we're closed? + /// Needed for transition animation + pub fn should_show(&self, ui: &mut egui::Ui) -> bool { + if self.flags.contains(MediaViewerFlags::Open) { + return true; + } + + // we are closing + self.open_amount(ui) > 0.0 + } } /// A panning, scrolling, optionally fullscreen, and tiling media viewer pub struct MediaViewer<'a> { state: &'a mut MediaViewerState, - fullscreen: bool, } impl<'a> MediaViewer<'a> { pub fn new(state: &'a mut MediaViewerState) -> Self { - let fullscreen = false; - Self { state, fullscreen } + Self { state } } - pub fn fullscreen(mut self, enable: bool) -> Self { - self.fullscreen = enable; + /// Is this + pub fn fullscreen(self, enable: bool) -> Self { + self.state.flags.set(MediaViewerFlags::Fullscreen, enable); + self + } + + /// Enable open transition animation + pub fn transition(self, enable: bool) -> Self { + self.state.flags.set(MediaViewerFlags::Transition, enable); self } pub fn ui(&mut self, images: &mut Images, ui: &mut egui::Ui) -> egui::Response { - if self.fullscreen { + if self.state.flags.contains(MediaViewerFlags::Fullscreen) { egui::Window::new("Media Viewer") .title_bar(false) .fixed_size(ui.ctx().screen_rect().size()) @@ -45,37 +104,100 @@ impl<'a> MediaViewer<'a> { fn ui_content(&mut self, images: &mut Images, ui: &mut egui::Ui) -> egui::Response { let avail_rect = ui.available_rect_before_wrap(); - //let id = ui.id().with("media_viewer"); - let mut scene_rect = if let Some(scene_rect) = self.state.scene_rect { + let scene_rect = if let Some(scene_rect) = self.state.scene_rect { scene_rect } else { self.state.scene_rect = Some(avail_rect); avail_rect }; + let zoom_range: egui::Rangef = (0.0..=10.0).into(); + + let is_open = self.state.flags.contains(MediaViewerFlags::Open); + let can_transition = self.state.flags.contains(MediaViewerFlags::Transition); + let open_amount = self.state.open_amount(ui); + let transitioning = if !can_transition { + false + } else if is_open { + open_amount < 1.0 + } else { + open_amount > 0.0 + }; + + let mut trans_rect = if transitioning { + let clicked_img = &self.state.media_info.clicked_media(); + let src_pos = &clicked_img.original_position; + let in_scene_pos = Self::first_image_rect(ui, clicked_img, images); + transition_scene_rect( + &avail_rect, + &zoom_range, + &in_scene_pos, + src_pos, + open_amount, + ) + } else { + scene_rect + }; + // Draw background - ui.painter() - .rect_filled(avail_rect, 0.0, egui::Color32::from_black_alpha(128)); + ui.painter().rect_filled( + avail_rect, + 0.0, + egui::Color32::from_black_alpha((128.0 * open_amount) as u8), + ); + + let scene = egui::Scene::new().zoom_range(zoom_range); + + // We are opening, so lock controls + /* TODO(jb55): 0.32 + if transitioning { + scene = scene.sense(egui::Sense::hover()); + } + */ - let resp = egui::Scene::new() - .zoom_range(0.0..=10.0) // enhance 🔬 - .show(ui, &mut scene_rect, |ui| { - Self::render_image_tiles(&self.state.media_info.medias, images, ui); - }); + let resp = scene.show(ui, &mut trans_rect, |ui| { + Self::render_image_tiles(&self.state.media_info.medias, images, ui, open_amount); + }); - self.state.scene_rect = Some(scene_rect); + self.state.scene_rect = Some(trans_rect); resp.response } + /// The rect of the first image to be placed. + /// This is mainly used for the transition animation + /// + /// TODO(jb55): replace this with a "placed" variant once + /// we have image layouts + fn first_image_rect(ui: &mut egui::Ui, media: &MediaInfo, images: &mut Images) -> Rect { + // fetch image texture + let Some(texture) = images.latest_texture(ui, &media.url, ImageType::Content(None)) else { + tracing::error!("could not get latest texture in first_image_rect"); + return Rect::ZERO; + }; + + // the area the next image will be put in. + let mut img_rect = ui.available_rect_before_wrap(); + + let size = texture.size_vec2(); + img_rect.set_height(size.y); + img_rect.set_width(size.x); + img_rect + } + /// /// Tile a scene with images. /// /// TODO(jb55): Let's improve image tiling over time, spiraling outward. We /// should have a way to click "next" and have the scene smoothly transition and /// focus on the next image - fn render_image_tiles(infos: &[MediaInfo], images: &mut Images, ui: &mut egui::Ui) { + fn render_image_tiles( + infos: &[MediaInfo], + images: &mut Images, + ui: &mut egui::Ui, + open_amount: f32, + ) { for info in infos { let url = &info.url; @@ -108,11 +230,71 @@ impl<'a> MediaViewer<'a> { */ // Paint image - ui.painter() - .image(texture.id(), img_rect, uv, Color32::WHITE); + ui.painter().image( + texture.id(), + img_rect, + uv, + Color32::from_white_alpha((open_amount * 255.0) as u8), + ); ui.advance_cursor_after_rect(img_rect); } } } } + +/// Helper: lerp a TSTransform (uniform scale + translation) +fn lerp_ts(a: TSTransform, b: TSTransform, t: f32) -> TSTransform { + let s = egui::lerp(a.scaling..=b.scaling, t); + let p = a.translation + (b.translation - a.translation) * t; + TSTransform { + scaling: s, + translation: p, + } +} + +/// Calculate the open/close amount and transition rect +pub fn transition_scene_rect( + outer_rect: &Rect, + zoom_range: &Rangef, + image_rect_in_scene: &Rect, // e.g. Rect::from_min_size(Pos2::ZERO, image_size) + timeline_global_rect: &Rect, // saved from timeline Response.rect + open_amt: f32, // stable ID per media item +) -> Rect { + // Compute the two endpoints: + let from = fit_to_rect_in_scene(timeline_global_rect, image_rect_in_scene, zoom_range); + let to = fit_to_rect_in_scene(outer_rect, image_rect_in_scene, zoom_range); + + // Interpolate transform and convert to scene_rect expected by Scene::show: + let lerped = lerp_ts(from, to, open_amt); + + lerped.inverse() * (*outer_rect) +} + +/// Creates a transformation that fits a given scene rectangle into the available screen size. +/// +/// The resulting visual scene bounds can be larger, due to letterboxing. +/// +/// Returns the transformation from `scene` to `global` coordinates. +fn fit_to_rect_in_scene( + rect_in_global: &Rect, + rect_in_scene: &Rect, + zoom_range: &Rangef, +) -> TSTransform { + // Compute the scale factor to fit the bounding rectangle into the available screen size: + let scale = rect_in_global.size() / rect_in_scene.size(); + + // Use the smaller of the two scales to ensure the whole rectangle fits on the screen: + let scale = scale.min_elem(); + + // Clamp scale to what is allowed + let scale = zoom_range.clamp(scale); + + // Compute the translation to center the bounding rect in the screen: + let center_in_global = rect_in_global.center().to_vec2(); + let center_scene = rect_in_scene.center().to_vec2(); + + // Set the transformation to scale and then translate to center. + TSTransform::from_translation(center_in_global - scale * center_scene) + * TSTransform::from_scaling(scale) +}