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:
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)
+}