notedeck

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

commit 41053dd5a57bd33ef84fcc58854c1b8f98c184f7
parent e97574fcdc3e6a6654b71f1153b167b81a5a991b
Author: William Casarin <jb55@jb55.com>
Date:   Thu, 10 Jul 2025 12:09:30 -0700

ui/carousel: refactor to use indices

This refactors our carousel control a bit, it was getting
messy

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

Diffstat:
Mcrates/notedeck_ui/src/blur.rs | 4+++-
Mcrates/notedeck_ui/src/gif.rs | 4++++
Mcrates/notedeck_ui/src/note/contents.rs | 2+-
Mcrates/notedeck_ui/src/note/media.rs | 270+++++++++++++++++++++++++++++++++++++++++--------------------------------------
4 files changed, 147 insertions(+), 133 deletions(-)

diff --git a/crates/notedeck_ui/src/blur.rs b/crates/notedeck_ui/src/blur.rs @@ -4,6 +4,7 @@ use nostrdb::Note; use crate::jobs::{Job, JobError, JobParamsOwned}; +#[derive(Clone)] pub struct Blur<'a> { pub blurhash: &'a str, pub dimensions: Option<PixelDimensions>, // width and height in pixels @@ -145,8 +146,9 @@ fn find_blur(tag_iter: nostrdb::TagIter) -> Option<(&str, Blur)> { )) } +#[derive(Clone)] pub enum ObfuscationType<'a> { - Blurhash(&'a Blur<'a>), + Blurhash(Blur<'a>), Default, } diff --git a/crates/notedeck_ui/src/gif.rs b/crates/notedeck_ui/src/gif.rs @@ -113,6 +113,10 @@ pub fn retrieve_latest_texture<'a>( gifs.insert(url.to_owned(), new_state); } + if let Some(req) = request_next_repaint { + tracing::trace!("requesting repaint for {url} after {req:?}"); + } + LatextTexture { texture, request_next_repaint, diff --git a/crates/notedeck_ui/src/note/contents.rs b/crates/notedeck_ui/src/note/contents.rs @@ -303,7 +303,7 @@ pub fn render_note_contents( note_context.img_cache, note_context.job_pool, jobs, - supported_medias, + &supported_medias, carousel_id, trusted_media, ); diff --git a/crates/notedeck_ui/src/note/media.rs b/crates/notedeck_ui/src/note/media.rs @@ -24,7 +24,7 @@ pub(crate) fn image_carousel( img_cache: &mut Images, job_pool: &mut JobPool, jobs: &mut JobsCache, - medias: Vec<RenderableMedia>, + medias: &[RenderableMedia], carousel_id: egui::Id, trusted_media: bool, ) -> Option<MediaAction> { @@ -34,31 +34,9 @@ pub(crate) fn image_carousel( let width = ui.available_width(); - let show_popup = ui.ctx().memory(|mem| { - mem.data - .get_temp(carousel_id.with("show_popup")) - .unwrap_or(false) - }); - - let current_image = 'scope: { - if !show_popup { - break 'scope None; - } - - let Some(media) = medias.first() else { - break 'scope None; - }; - - Some(ui.ctx().memory(|mem| { - mem.data - .get_temp::<(String, MediaCacheType)>(carousel_id.with("current_image")) - .unwrap_or_else(|| (media.url.to_owned(), media.media_type)) - })) - }; + let show_popup = get_show_popup(ui, popup_id(carousel_id)); let mut action = None; - let media_urls = &medias.iter().map(|m| m.url.to_string()).collect::<Vec<_>>(); - //let has_touch_screen = ui.ctx().input(|i| i.has_touch_screen()); ui.add_sized([width, height], |ui: &mut egui::Ui| { egui::ScrollArea::horizontal() @@ -66,7 +44,7 @@ pub(crate) fn image_carousel( .id_salt(carousel_id) .show(ui, |ui| { ui.horizontal(|ui| { - for media in medias { + for (i, media) in medias.iter().enumerate() { let RenderableMedia { url, media_type, @@ -86,23 +64,23 @@ pub(crate) fn image_carousel( height, &mut cache.textures_cache, url, - media_type, + *media_type, &cache.cache_dir, - blur_type, + blur_type.clone(), ); - if let Some(cur_action) = render_media( - ui, - &mut img_cache.gif_states, - media_state, - url, - media_type, - height, - carousel_id, - ) { + if let Some(cur_action) = + render_media(ui, &mut img_cache.gif_states, media_state, url, height) + { + // clicked the media, lets set the active index + if let MediaUIAction::Clicked = cur_action { + set_show_popup(ui, popup_id(carousel_id), true); + set_selected_index(ui, selection_id(carousel_id), i); + } + action = cur_action.to_media_action( ui.ctx(), url, - media_type, + *media_type, cache, ImageType::Content, ); @@ -115,16 +93,13 @@ pub(crate) fn image_carousel( }); if show_popup { - if let Some((image_url, cache_type)) = current_image { - show_full_screen_media( - ui, - &media_urls, - &image_url, - cache_type, - img_cache, - carousel_id, - ); - } + if medias.is_empty() { + return None; + }; + + let current_image_index = update_selected_image_index(ui, carousel_id, medias.len() as i32); + + show_full_screen_media(ui, medias, current_image_index, img_cache, carousel_id); } action } @@ -133,6 +108,7 @@ enum MediaUIAction { Unblur, Error, DoneLoading, + Clicked, } impl MediaUIAction { @@ -145,6 +121,11 @@ impl MediaUIAction { img_type: ImageType, ) -> Option<MediaAction> { match self { + MediaUIAction::Clicked => { + tracing::debug!("{} clicked", url); + None + } + MediaUIAction::Unblur => Some(MediaAction::FetchImage { url: url.to_owned(), cache_type, @@ -177,9 +158,8 @@ impl MediaUIAction { fn show_full_screen_media( ui: &mut egui::Ui, - media_urls: &[String], - image_url: &str, - cache_type: MediaCacheType, + medias: &[RenderableMedia], + index: usize, img_cache: &mut Images, carousel_id: egui::Id, ) { @@ -190,10 +170,19 @@ fn show_full_screen_media( .frame(egui::Frame::NONE) .show(ui.ctx(), |ui| { ui.centered_and_justified(|ui| 's: { + let image_url = medias[index].url; + let media_type = medias[index].media_type; + tracing::trace!( + "show_full_screen_media using img {} @ {} for carousel_id {:?}", + image_url, + index, + carousel_id + ); + let cur_state = get_render_state( ui.ctx(), img_cache, - cache_type, + media_type, image_url, ImageType::Content, ); @@ -204,7 +193,8 @@ fn show_full_screen_media( render_full_screen_media( ui, - &media_urls, + medias.len(), + index, textured_image, cur_state.gifs, image_url, @@ -214,6 +204,35 @@ fn show_full_screen_media( }); } +fn set_selected_index(ui: &mut egui::Ui, sel_id: egui::Id, index: usize) { + ui.data_mut(|d| { + d.insert_temp(sel_id, index); + }); +} + +fn get_selected_index(ui: &egui::Ui, selection_id: egui::Id) -> usize { + ui.data(|d| d.get_temp(selection_id).unwrap_or(0)) +} + +/// Checks to see if we have any left/right key presses and updates the carousel index +fn update_selected_image_index(ui: &mut egui::Ui, carousel_id: egui::Id, num_urls: i32) -> usize { + if num_urls > 1 { + if ui.input(|i| i.key_pressed(egui::Key::ArrowRight) || i.key_pressed(egui::Key::L)) { + let ind = select_next_media(ui, carousel_id, num_urls, 1); + tracing::debug!("carousel selecting right {}/{}", ind + 1, num_urls); + ind + } else if ui.input(|i| i.key_pressed(egui::Key::ArrowLeft) || i.key_pressed(egui::Key::H)) { + let ind = select_next_media(ui, carousel_id, num_urls, -1); + tracing::debug!("carousel selecting left {}/{}", ind + 1, num_urls); + ind + } else { + get_selected_index(ui, selection_id(carousel_id)) + } + } else { + 0 + } +} + #[allow(clippy::too_many_arguments)] pub fn get_content_media_render_state<'a>( ui: &mut egui::Ui, @@ -317,9 +336,35 @@ fn get_obfuscated<'a>( ObfuscatedTexture::Blur(texture_handle) } +// simple selector memory +fn select_next_media( + ui: &mut egui::Ui, + carousel_id: egui::Id, + num_urls: i32, + direction: i32, +) -> usize { + let sel_id = selection_id(carousel_id); + let current = get_selected_index(ui, sel_id) as i32; + let next = current + direction; + let next = if next >= num_urls { + 0 + } else if next < 0 { + num_urls - 1 + } else { + next + }; + + if next != current { + set_selected_index(ui, sel_id, next as usize); + } + + next as usize +} + fn render_full_screen_media( ui: &mut egui::Ui, - media_urls: &[String], + num_urls: usize, + index: usize, renderable_media: &mut TexturedImage, gifs: &mut HashMap<String, GifState>, image_url: &str, @@ -334,51 +379,6 @@ fn render_full_screen_media( }); } - if media_urls.len() > 1 { - if ui.input(|i| i.key_pressed(egui::Key::ArrowRight)) { - ui.ctx().memory_mut(|mem| { - let curr = media_urls.iter().position(|m| m == image_url).unwrap_or(0) as i32; - - let next: i32 = if curr + 1 >= media_urls.len() as i32 { - 0 - } else { - curr + 1 - }; - let next_url = media_urls.get(next as usize).cloned(); - - mem.data.insert_temp( - carousel_id.with("current_image"), - ( - next_url.unwrap_or_else(|| image_url.to_owned()), - MediaCacheType::Image, - ), - ); - }); - } - - if ui.input(|i| i.key_pressed(egui::Key::ArrowLeft)) { - ui.ctx().memory_mut(|mem| { - let curr = media_urls.iter().position(|m| m == image_url).unwrap_or(0) as i32; - - let next: i32 = if curr - 1 < 0 { - media_urls.len() as i32 - 1 - } else { - curr - 1 - }; - - let next_url = media_urls.get(next as usize).cloned(); - - mem.data.insert_temp( - carousel_id.with("current_image"), - ( - next_url.unwrap_or_else(|| image_url.to_owned()), - MediaCacheType::Image, - ), - ); - }); - } - } - // background ui.painter() .rect_filled(screen_rect, 0.0, Color32::from_black_alpha(230)); @@ -466,12 +466,12 @@ fn render_full_screen_media( let img_rect = ui.allocate_rect(rect, Sense::click()); if img_rect.clicked() { - ui.ctx().memory_mut(|mem| { - mem.data.insert_temp(carousel_id.with("show_popup"), true); + ui.data_mut(|data| { + data.insert_temp(carousel_id.with("show_popup"), true); }); } else if img_rect.clicked_elsewhere() { - ui.ctx().memory_mut(|mem| { - mem.data.insert_temp(carousel_id.with("show_popup"), false); + ui.data_mut(|data| { + data.insert_temp(carousel_id.with("show_popup"), false); }); } @@ -494,8 +494,8 @@ fn render_full_screen_media( pan_offset.y = 0.0; } - ui.ctx().memory_mut(|mem| { - mem.data.insert_temp(pan_id, pan_offset); + ui.data_mut(|data| { + data.insert_temp(pan_id, pan_offset); }); } @@ -509,22 +509,17 @@ fn render_full_screen_media( }); } - if media_urls.len() > 1 { + if num_urls > 1 { let color = ui.style().visuals.noninteractive().fg_stroke.color; - let curr = media_urls.iter().position(|m| m == image_url).unwrap_or(0) + 1; - - let text = format!("{}/{}", curr, media_urls.len()); - - println!("Rendering media: {text}"); - + let text = format!("{}/{}", index + 1, num_urls); ui.label(RichText::new(text).size(10.0).color(color)); } - copy_link(image_url, response); + copy_link(image_url, &response); } -fn copy_link(url: &str, img_resp: Response) { +fn copy_link(url: &str, img_resp: &Response) { img_resp.context_menu(|ui| { if ui.button("Copy Link").clicked() { ui.ctx().copy_text(url.to_owned()); @@ -539,14 +534,15 @@ fn render_media( gifs: &mut GifStateMap, render_state: MediaRenderState, url: &str, - cache_type: MediaCacheType, height: f32, - carousel_id: egui::Id, ) -> Option<MediaUIAction> { match render_state { MediaRenderState::ActualImage(image) => { - render_success_media(ui, url, image, gifs, cache_type, height, carousel_id); - None + if render_success_media(ui, url, image, gifs, height).clicked() { + Some(MediaUIAction::Clicked) + } else { + None + } } MediaRenderState::Transitioning { image, obfuscation } => match obfuscation { ObfuscatedTexture::Blur(texture) => { @@ -726,7 +722,7 @@ pub(crate) fn find_renderable_media<'a>( let media_type = supported_mime_hosted_at_url(urls, url)?; let obfuscation_type = match blurhashes.get(url) { - Some(blur) => ObfuscationType::Blurhash(blur), + Some(blur) => ObfuscationType::Blurhash(blur.clone()), None => ObfuscationType::Default, }; @@ -737,30 +733,42 @@ pub(crate) fn find_renderable_media<'a>( }) } +#[inline] +fn selection_id(carousel_id: egui::Id) -> egui::Id { + carousel_id.with("sel") +} + +/// get the popup carousel window state +#[inline] +fn get_show_popup(ui: &egui::Ui, popup_id: egui::Id) -> bool { + ui.data(|data| data.get_temp(popup_id).unwrap_or(false)) +} + +/// set the popup carousel window state +#[inline] +fn set_show_popup(ui: &mut egui::Ui, popup_id: egui::Id, show_popup: bool) { + ui.data_mut(|data| data.insert_temp(popup_id, show_popup)); +} + +#[inline] +fn popup_id(carousel_id: egui::Id) -> egui::Id { + carousel_id.with("show_popup") +} + fn render_success_media( ui: &mut egui::Ui, url: &str, tex: &mut TexturedImage, gifs: &mut GifStateMap, - cache_type: MediaCacheType, height: f32, - carousel_id: egui::Id, -) { +) -> Response { let texture = handle_repaint(ui, retrieve_latest_texture(url, gifs, tex)); let img = texture_to_image(texture, height); let img_resp = ui.add(Button::image(img).frame(false)); - if img_resp.clicked() { - ui.ctx().memory_mut(|mem| { - mem.data.insert_temp(carousel_id.with("show_popup"), true); - mem.data.insert_temp( - carousel_id.with("current_image"), - (url.to_owned(), cache_type), - ); - }); - } + copy_link(url, &img_resp); - copy_link(url, img_resp); + img_resp } fn texture_to_image(tex: &TextureHandle, max_height: f32) -> egui::Image {