commit 6d393c9c3743059adb2ccde1592e1b350992db8f
parent 5c8ab0ce07e3a3b9baa18667ffe94b8591666bc6
Author: William Casarin <jb55@jb55.com>
Date: Mon, 28 Jul 2025 14:19:03 -0700
media/viewer: provide image-click provenance
We will be using this for transitions
Signed-off-by: William Casarin <jb55@jb55.com>
Diffstat:
6 files changed, 128 insertions(+), 69 deletions(-)
diff --git a/crates/notedeck/src/media/action.rs b/crates/notedeck/src/media/action.rs
@@ -1,12 +1,36 @@
use crate::{Images, MediaCacheType, TexturedImage};
use poll_promise::Promise;
+/// Tracks where media was on the screen so that
+/// we can do fun animations when opening the
+/// Media Viewer
+#[derive(Debug, Clone)]
+pub struct MediaInfo {
+ /// The original screen position where it
+ /// was rendered from. This is not where
+ /// it should be rendered in the scene.
+ pub original_position: egui::Rect,
+ pub url: String,
+}
+
+/// Contains various information for when a user
+/// clicks a piece of media. It contains the current
+/// location on screen for each piece of media.
+///
+/// Viewers can use this to smoothly transition from
+/// the timeline to the viewer
+#[derive(Debug, Clone, Default)]
+pub struct ViewMediaInfo {
+ pub clicked_index: usize,
+ pub medias: Vec<MediaInfo>,
+}
+
/// Actions generated by media ui interactions
pub enum MediaAction {
/// An image was clicked on in a carousel, we have
/// the opportunity to open into a fullscreen media viewer
/// with a list of url values
- ViewMedias(Vec<String>),
+ ViewMedias(ViewMediaInfo),
FetchImage {
url: String,
@@ -22,7 +46,14 @@ pub enum MediaAction {
impl std::fmt::Debug for MediaAction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
- Self::ViewMedias(urls) => f.debug_struct("ViewMedias").field("urls", urls).finish(),
+ Self::ViewMedias(ViewMediaInfo {
+ clicked_index,
+ medias,
+ }) => f
+ .debug_struct("ViewMedias")
+ .field("clicked_index", clicked_index)
+ .field("media", medias)
+ .finish(),
Self::FetchImage {
url,
cache_type,
@@ -44,9 +75,9 @@ impl std::fmt::Debug for MediaAction {
impl MediaAction {
/// Handle view media actions
- pub fn on_view_media(&self, handler: impl FnOnce(Vec<String>)) {
- if let MediaAction::ViewMedias(urls) = self {
- handler(urls.clone())
+ pub fn on_view_media(&self, handler: impl FnOnce(&ViewMediaInfo)) {
+ if let MediaAction::ViewMedias(view_medias) = self {
+ handler(view_medias)
}
}
diff --git a/crates/notedeck/src/media/mod.rs b/crates/notedeck/src/media/mod.rs
@@ -5,7 +5,7 @@ pub mod images;
pub mod imeta;
pub mod renderable;
-pub use action::MediaAction;
+pub use action::{MediaAction, MediaInfo, ViewMediaInfo};
pub use blur::{
compute_blurhash, update_imeta_blurhashes, ImageMetadata, ObfuscationType, PixelDimensions,
PointDimensions,
diff --git a/crates/notedeck_columns/src/actionbar.rs b/crates/notedeck_columns/src/actionbar.rs
@@ -158,7 +158,8 @@ fn execute_note_action(
},
NoteAction::Media(media_action) => {
media_action.on_view_media(|medias| {
- view_state.media_viewer.urls = medias;
+ view_state.media_viewer.media_info = medias.clone();
+ tracing::debug!("on_view_media {:?}", &medias);
app_options.set(AppOptions::FullscreenMedia, true);
});
diff --git a/crates/notedeck_columns/src/app.rs b/crates/notedeck_columns/src/app.rs
@@ -390,7 +390,7 @@ fn fullscreen_media_viewer_ui(
viewer_state: &mut MediaViewerState,
img_cache: &mut Images,
) {
- if !options.contains(AppOptions::FullscreenMedia) || viewer_state.urls.is_empty() {
+ if !options.contains(AppOptions::FullscreenMedia) || viewer_state.media_info.medias.is_empty() {
return;
}
diff --git a/crates/notedeck_ui/src/media/viewer.rs b/crates/notedeck_ui/src/media/viewer.rs
@@ -1,11 +1,12 @@
use egui::{pos2, Color32, Rect};
+use notedeck::media::{MediaInfo, ViewMediaInfo};
use notedeck::{ImageType, Images};
/// State used in the MediaViewer ui widget.
-///
#[derive(Default)]
pub struct MediaViewerState {
- pub urls: Vec<String>,
+ /// When
+ pub media_info: ViewMediaInfo,
pub scene_rect: Option<Rect>,
}
@@ -60,7 +61,7 @@ impl<'a> MediaViewer<'a> {
let resp = egui::Scene::new()
.zoom_range(0.0..=10.0) // enhance 🔬
.show(ui, &mut scene_rect, |ui| {
- self.render_image_tiles(images, ui);
+ Self::render_image_tiles(&self.state.media_info.medias, images, ui);
});
self.state.scene_rect = Some(scene_rect);
@@ -74,8 +75,10 @@ impl<'a> MediaViewer<'a> {
/// 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(&self, images: &mut Images, ui: &mut egui::Ui) {
- for url in &self.state.urls {
+ fn render_image_tiles(infos: &[MediaInfo], images: &mut Images, ui: &mut egui::Ui) {
+ for info in infos {
+ let url = &info.url;
+
// fetch image texture
let Some(texture) = images.latest_texture(ui, url, ImageType::Content(None)) else {
continue;
diff --git a/crates/notedeck_ui/src/note/media.rs b/crates/notedeck_ui/src/note/media.rs
@@ -10,6 +10,7 @@ use notedeck::{
use notedeck::media::gif::ensure_latest_texture;
use notedeck::media::images::{fetch_no_pfp_promise, ImageType};
+use notedeck::media::{MediaInfo, ViewMediaInfo};
use crate::{app_images, AnimationHelper, PulseAlpha};
@@ -43,7 +44,9 @@ pub(crate) fn image_carousel(
.id_salt(carousel_id)
.show(ui, |ui| {
ui.horizontal(|ui| {
+ let mut media_infos: Vec<MediaInfo> = Vec::with_capacity(medias.len());
let mut media_action: Option<(usize, MediaUIAction)> = None;
+
for (i, media) in medias.iter().enumerate() {
let RenderableMedia {
url,
@@ -68,23 +71,32 @@ pub(crate) fn image_carousel(
blur_type,
);
- if let Some(cur_action) = render_media(
+ let media_response = render_media(
ui,
&mut img_cache.gif_states,
media_state,
url,
height,
i18n,
- ) {
- media_action = Some((i, cur_action));
+ );
+
+ if let Some(action) = media_response.inner {
+ media_action = Some((i, action))
}
+
+ let rect = media_response.response.rect;
+ media_infos.push(MediaInfo {
+ url: url.clone(),
+ original_position: rect,
+ })
}
- if let Some((i, media_action)) = &media_action {
- action = media_action.to_media_action(
+ if let Some((i, media_action)) = media_action {
+ action = media_action.into_media_action(
ui.ctx(),
medias,
- *i,
+ media_infos,
+ i,
img_cache,
ImageType::Content(Some((width as u32, height as u32))),
);
@@ -106,18 +118,24 @@ enum MediaUIAction {
}
impl MediaUIAction {
- pub fn to_media_action(
- &self,
+ pub fn into_media_action(
+ self,
ctx: &egui::Context,
medias: &[RenderableMedia],
+ responses: Vec<MediaInfo>,
selected: usize,
img_cache: &Images,
img_type: ImageType,
) -> Option<MediaAction> {
match self {
- MediaUIAction::Clicked => Some(MediaAction::ViewMedias(
- medias.iter().map(|m| m.url.to_owned()).collect(),
- )),
+ // We've clicked on some media, let's package up
+ // all of the rendered media responses, and send
+ // them to the ViewMedias action so that our fullscreen
+ // media viewer can smoothly transition from them
+ MediaUIAction::Clicked => Some(MediaAction::ViewMedias(ViewMediaInfo {
+ clicked_index: selected,
+ medias: responses,
+ })),
MediaUIAction::Unblur => {
let url = &medias[selected].url;
@@ -291,44 +309,44 @@ fn render_media(
url: &str,
height: f32,
i18n: &mut Localization,
-) -> Option<MediaUIAction> {
+) -> egui::InnerResponse<Option<MediaUIAction>> {
match render_state {
MediaRenderState::ActualImage(image) => {
- if render_success_media(ui, url, image, gifs, height, i18n).clicked() {
- Some(MediaUIAction::Clicked)
+ let resp = render_success_media(ui, url, image, gifs, height, i18n);
+ if resp.clicked() {
+ egui::InnerResponse::new(Some(MediaUIAction::Clicked), resp)
} else {
- None
+ egui::InnerResponse::new(None, resp)
}
}
MediaRenderState::Transitioning { image, obfuscation } => match obfuscation {
ObfuscatedTexture::Blur(texture) => {
- if render_blur_transition(ui, url, height, texture, image.get_first_texture()) {
- Some(MediaUIAction::DoneLoading)
+ let resp =
+ render_blur_transition(ui, url, height, texture, image.get_first_texture());
+ if resp.inner {
+ egui::InnerResponse::new(Some(MediaUIAction::DoneLoading), resp.response)
} else {
- None
+ egui::InnerResponse::new(None, resp.response)
}
}
- ObfuscatedTexture::Default => {
- ui.add(texture_to_image(image.get_first_texture(), height));
- Some(MediaUIAction::DoneLoading)
- }
+ ObfuscatedTexture::Default => egui::InnerResponse::new(
+ Some(MediaUIAction::DoneLoading),
+ ui.add(texture_to_image(image.get_first_texture(), height)),
+ ),
},
MediaRenderState::Error(e) => {
- ui.allocate_space(egui::vec2(height, height));
+ let resp = ui.allocate_response(egui::vec2(height, height), egui::Sense::click());
show_one_error_message(ui, &format!("Could not render media {url}: {e}"));
- Some(MediaUIAction::Error)
+ egui::InnerResponse::new(Some(MediaUIAction::Error), resp)
}
- MediaRenderState::Shimmering(obfuscated_texture) => {
- match obfuscated_texture {
- ObfuscatedTexture::Blur(texture_handle) => {
- shimmer_blurhash(texture_handle, ui, url, height);
- }
- ObfuscatedTexture::Default => {
- render_default_blur_bg(ui, height, url, true);
- }
+ MediaRenderState::Shimmering(obfuscated_texture) => match obfuscated_texture {
+ ObfuscatedTexture::Blur(texture_handle) => {
+ egui::InnerResponse::new(None, shimmer_blurhash(texture_handle, ui, url, height))
}
- None
- }
+ ObfuscatedTexture::Default => {
+ egui::InnerResponse::new(None, render_default_blur_bg(ui, height, url, true))
+ }
+ },
MediaRenderState::Obfuscated(obfuscated_texture) => {
let resp = match obfuscated_texture {
ObfuscatedTexture::Blur(texture_handle) => {
@@ -338,13 +356,11 @@ fn render_media(
ObfuscatedTexture::Default => render_default_blur(ui, i18n, height, url),
};
- if resp
- .on_hover_cursor(egui::CursorIcon::PointingHand)
- .clicked()
- {
- Some(MediaUIAction::Unblur)
+ let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand);
+ if resp.clicked() {
+ egui::InnerResponse::new(Some(MediaUIAction::Unblur), resp)
} else {
- None
+ egui::InnerResponse::new(None, resp)
}
}
}
@@ -442,12 +458,17 @@ fn render_default_blur(
height: f32,
url: &str,
) -> egui::Response {
- let rect = render_default_blur_bg(ui, height, url, false);
- render_blur_text(ui, i18n, url, rect)
+ let response = render_default_blur_bg(ui, height, url, false);
+ render_blur_text(ui, i18n, url, response.rect)
}
-fn render_default_blur_bg(ui: &mut egui::Ui, height: f32, url: &str, shimmer: bool) -> egui::Rect {
- let (rect, _) = ui.allocate_exact_size(egui::vec2(height, height), egui::Sense::click());
+fn render_default_blur_bg(
+ ui: &mut egui::Ui,
+ height: f32,
+ url: &str,
+ shimmer: bool,
+) -> egui::Response {
+ let (rect, response) = ui.allocate_exact_size(egui::vec2(height, height), egui::Sense::click());
let painter = ui.painter_at(rect);
@@ -460,7 +481,7 @@ fn render_default_blur_bg(ui: &mut egui::Ui, height: f32, url: &str, shimmer: bo
painter.rect_filled(rect, CornerRadius::same(8), color);
- rect
+ response
}
pub enum MediaRenderState<'a> {
@@ -540,24 +561,28 @@ fn get_blur_current_alpha(ui: &mut egui::Ui, url: &str) -> u8 {
.animate()
}
-fn shimmer_blurhash(tex: &TextureHandle, ui: &mut egui::Ui, url: &str, max_height: f32) {
+fn shimmer_blurhash(
+ tex: &TextureHandle,
+ ui: &mut egui::Ui,
+ url: &str,
+ max_height: f32,
+) -> egui::Response {
let cur_alpha = get_blur_current_alpha(ui, url);
let scaled = ScaledTexture::new(tex, max_height);
let img = scaled.get_image();
- show_blurhash_with_alpha(ui, img, cur_alpha);
+ show_blurhash_with_alpha(ui, img, cur_alpha)
}
fn fade_color(alpha: u8) -> egui::Color32 {
Color32::from_rgba_unmultiplied(255, 255, 255, alpha)
}
-fn show_blurhash_with_alpha(ui: &mut egui::Ui, img: Image, alpha: u8) {
+fn show_blurhash_with_alpha(ui: &mut egui::Ui, img: Image, alpha: u8) -> egui::Response {
let cur_color = fade_color(alpha);
-
let img = img.tint(cur_color);
- ui.add(img);
+ ui.add(img)
}
type FinishedTransition = bool;
@@ -569,14 +594,13 @@ fn render_blur_transition(
max_height: f32,
blur_texture: &TextureHandle,
image_texture: &TextureHandle,
-) -> FinishedTransition {
+) -> egui::InnerResponse<FinishedTransition> {
let scaled_texture = ScaledTexture::new(image_texture, max_height);
let blur_img = texture_to_image(blur_texture, max_height);
match get_blur_transition_state(ui.ctx(), url) {
BlurTransitionState::StoppingShimmer { cur_alpha } => {
- show_blurhash_with_alpha(ui, blur_img, cur_alpha);
- false
+ egui::InnerResponse::new(false, show_blurhash_with_alpha(ui, blur_img, cur_alpha))
}
BlurTransitionState::FadingBlur => render_blur_fade(ui, url, blur_img, &scaled_texture),
}
@@ -621,7 +645,7 @@ fn render_blur_fade(
url: &str,
blur_img: Image,
image_texture: &ScaledTexture,
-) -> FinishedTransition {
+) -> egui::InnerResponse<FinishedTransition> {
let blur_fade_id = ui.id().with(("blur_fade", url));
let cur_alpha = {
@@ -637,12 +661,12 @@ fn render_blur_fade(
let alloc_size = image_texture.scaled_size;
- let (rect, _) = ui.allocate_exact_size(alloc_size, egui::Sense::hover());
+ let (rect, resp) = ui.allocate_exact_size(alloc_size, egui::Sense::hover());
img.paint_at(ui, rect);
blur_img.paint_at(ui, rect);
- cur_alpha == 0
+ egui::InnerResponse::new(cur_alpha == 0, resp)
}
fn get_blur_transition_state(ctx: &Context, url: &str) -> BlurTransitionState {