notedeck

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

media.rs (22866B)


      1 use std::path::Path;
      2 
      3 use egui::{
      4     vec2, Button, Color32, Context, CornerRadius, FontId, Image, Response, TextureHandle, Vec2,
      5 };
      6 use notedeck::{
      7     compute_blurhash, fonts::get_font_size, show_one_error_message, tr, BlurhashParams,
      8     GifStateMap, Images, Job, JobId, JobParams, JobPool, JobState, JobsCache, Localization,
      9     MediaAction, MediaCacheType, NotedeckTextStyle, ObfuscationType, PointDimensions,
     10     RenderableMedia, TexturedImage, TexturesCache,
     11 };
     12 
     13 use crate::NoteOptions;
     14 use notedeck::media::gif::ensure_latest_texture;
     15 use notedeck::media::images::{fetch_no_pfp_promise, ImageType};
     16 use notedeck::media::{MediaInfo, ViewMediaInfo};
     17 
     18 use crate::{app_images, AnimationHelper, PulseAlpha};
     19 
     20 pub enum MediaViewAction {
     21     /// Used to handle escape presses when the media viewer is open
     22     EscapePressed,
     23 }
     24 
     25 #[allow(clippy::too_many_arguments)]
     26 pub fn image_carousel(
     27     ui: &mut egui::Ui,
     28     img_cache: &mut Images,
     29     job_pool: &mut JobPool,
     30     jobs: &mut JobsCache,
     31     medias: &[RenderableMedia],
     32     carousel_id: egui::Id,
     33     trusted_media: bool,
     34     i18n: &mut Localization,
     35     note_options: NoteOptions,
     36 ) -> Option<MediaAction> {
     37     // let's make sure everything is within our area
     38 
     39     let size = {
     40         let height = 360.0;
     41         let width = ui.available_width();
     42         egui::vec2(width, height)
     43     };
     44 
     45     let mut action = None;
     46 
     47     //let has_touch_screen = ui.ctx().input(|i| i.has_touch_screen());
     48     ui.add_sized(size, |ui: &mut egui::Ui| {
     49         egui::ScrollArea::horizontal()
     50             .drag_to_scroll(false)
     51             .id_salt(carousel_id)
     52             .show(ui, |ui| {
     53                 let response = ui
     54                     .horizontal(|ui| {
     55                         let spacing = ui.spacing_mut();
     56                         spacing.item_spacing.x = 8.0;
     57 
     58                         let mut media_infos: Vec<MediaInfo> = Vec::with_capacity(medias.len());
     59                         let mut media_action: Option<(usize, MediaUIAction)> = None;
     60 
     61                         for (i, media) in medias.iter().enumerate() {
     62                             let RenderableMedia {
     63                                 url,
     64                                 media_type,
     65                                 obfuscation_type: blur_type,
     66                             } = media;
     67 
     68                             let cache = match media_type {
     69                                 MediaCacheType::Image => &mut img_cache.static_imgs,
     70                                 MediaCacheType::Gif => &mut img_cache.gifs,
     71                             };
     72                             let media_state = get_content_media_render_state(
     73                                 ui,
     74                                 job_pool,
     75                                 jobs,
     76                                 trusted_media,
     77                                 size,
     78                                 &mut cache.textures_cache,
     79                                 url,
     80                                 *media_type,
     81                                 &cache.cache_dir,
     82                                 blur_type,
     83                             );
     84 
     85                             let media_response = render_media(
     86                                 ui,
     87                                 &mut img_cache.gif_states,
     88                                 media_state,
     89                                 url,
     90                                 size,
     91                                 i18n,
     92                                 note_options.contains(NoteOptions::Wide),
     93                             );
     94 
     95                             if let Some(action) = media_response.inner {
     96                                 media_action = Some((i, action))
     97                             }
     98 
     99                             let rect = media_response.response.rect;
    100                             media_infos.push(MediaInfo {
    101                                 url: url.clone(),
    102                                 original_position: rect,
    103                             })
    104                         }
    105 
    106                         if let Some((i, media_action)) = media_action {
    107                             action = media_action.into_media_action(
    108                                 ui.ctx(),
    109                                 medias,
    110                                 media_infos,
    111                                 i,
    112                                 img_cache,
    113                                 ImageType::Content(Some((size.x as u32, size.y as u32))),
    114                             );
    115                         }
    116                     })
    117                     .response;
    118                 ui.add_space(8.0);
    119                 response
    120             })
    121             .inner
    122     });
    123 
    124     action
    125 }
    126 
    127 enum MediaUIAction {
    128     Unblur,
    129     Error,
    130     DoneLoading,
    131     Clicked,
    132 }
    133 
    134 impl MediaUIAction {
    135     pub fn into_media_action(
    136         self,
    137         ctx: &egui::Context,
    138         medias: &[RenderableMedia],
    139         responses: Vec<MediaInfo>,
    140         selected: usize,
    141         img_cache: &Images,
    142         img_type: ImageType,
    143     ) -> Option<MediaAction> {
    144         match self {
    145             // We've clicked on some media, let's package up
    146             // all of the rendered media responses, and send
    147             // them to the ViewMedias action so that our fullscreen
    148             // media viewer can smoothly transition from them
    149             MediaUIAction::Clicked => Some(MediaAction::ViewMedias(ViewMediaInfo {
    150                 clicked_index: selected,
    151                 medias: responses,
    152             })),
    153 
    154             MediaUIAction::Unblur => {
    155                 let url = &medias[selected].url;
    156                 let cache = img_cache.get_cache(medias[selected].media_type);
    157                 let cache_type = cache.cache_type;
    158                 let no_pfp_promise = notedeck::media::images::fetch_img(
    159                     &cache.cache_dir,
    160                     ctx,
    161                     url,
    162                     img_type,
    163                     cache_type,
    164                 );
    165                 Some(MediaAction::FetchImage {
    166                     url: url.to_owned(),
    167                     cache_type,
    168                     no_pfp_promise,
    169                 })
    170             }
    171 
    172             MediaUIAction::Error => {
    173                 if !matches!(img_type, ImageType::Profile(_)) {
    174                     return None;
    175                 };
    176 
    177                 let cache = img_cache.get_cache(medias[selected].media_type);
    178                 let cache_type = cache.cache_type;
    179                 Some(MediaAction::FetchImage {
    180                     url: medias[selected].url.to_owned(),
    181                     cache_type,
    182                     no_pfp_promise: fetch_no_pfp_promise(ctx, cache),
    183                 })
    184             }
    185             MediaUIAction::DoneLoading => Some(MediaAction::DoneLoading {
    186                 url: medias[selected].url.to_owned(),
    187                 cache_type: img_cache.get_cache(medias[selected].media_type).cache_type,
    188             }),
    189         }
    190     }
    191 }
    192 
    193 #[allow(clippy::too_many_arguments)]
    194 pub fn get_content_media_render_state<'a>(
    195     ui: &mut egui::Ui,
    196     job_pool: &'a mut JobPool,
    197     jobs: &'a mut JobsCache,
    198     media_trusted: bool,
    199     size: Vec2,
    200     cache: &'a mut TexturesCache,
    201     url: &'a str,
    202     cache_type: MediaCacheType,
    203     cache_dir: &Path,
    204     obfuscation_type: &'a ObfuscationType,
    205 ) -> MediaRenderState<'a> {
    206     let render_type = if media_trusted {
    207         cache.handle_and_get_or_insert_loadable(url, || {
    208             notedeck::media::images::fetch_img(
    209                 cache_dir,
    210                 ui.ctx(),
    211                 url,
    212                 ImageType::Content(None),
    213                 cache_type,
    214             )
    215         })
    216     } else if let Some(render_type) = cache.get_and_handle(url) {
    217         render_type
    218     } else {
    219         return MediaRenderState::Obfuscated(get_obfuscated(
    220             ui,
    221             url,
    222             obfuscation_type,
    223             job_pool,
    224             jobs,
    225             size,
    226         ));
    227     };
    228 
    229     match render_type {
    230         notedeck::LoadableTextureState::Pending => MediaRenderState::Shimmering(get_obfuscated(
    231             ui,
    232             url,
    233             obfuscation_type,
    234             job_pool,
    235             jobs,
    236             size,
    237         )),
    238         notedeck::LoadableTextureState::Error(e) => MediaRenderState::Error(e),
    239         notedeck::LoadableTextureState::Loading { actual_image_tex } => {
    240             let obfuscation = get_obfuscated(ui, url, obfuscation_type, job_pool, jobs, size);
    241             MediaRenderState::Transitioning {
    242                 image: actual_image_tex,
    243                 obfuscation,
    244             }
    245         }
    246         notedeck::LoadableTextureState::Loaded(textured_image) => {
    247             MediaRenderState::ActualImage(textured_image)
    248         }
    249     }
    250 }
    251 
    252 fn get_obfuscated<'a>(
    253     ui: &mut egui::Ui,
    254     url: &str,
    255     obfuscation_type: &'a ObfuscationType,
    256     job_pool: &'a mut JobPool,
    257     jobs: &'a mut JobsCache,
    258     size: Vec2,
    259 ) -> ObfuscatedTexture<'a> {
    260     let ObfuscationType::Blurhash(renderable_blur) = obfuscation_type else {
    261         return ObfuscatedTexture::Default;
    262     };
    263 
    264     let params = BlurhashParams {
    265         blurhash: &renderable_blur.blurhash,
    266         url,
    267         ctx: ui.ctx(),
    268     };
    269 
    270     let available_points = PointDimensions {
    271         x: size.x,
    272         y: size.y,
    273     };
    274 
    275     let pixel_sizes = renderable_blur.scaled_pixel_dimensions(ui, available_points);
    276 
    277     let job_state = jobs.get_or_insert_with(
    278         job_pool,
    279         &JobId::Blurhash(url),
    280         Some(JobParams::Blurhash(params)),
    281         move |params| compute_blurhash(params, pixel_sizes),
    282     );
    283 
    284     let JobState::Completed(m_blur_job) = job_state else {
    285         return ObfuscatedTexture::Default;
    286     };
    287 
    288     #[allow(irrefutable_let_patterns)]
    289     let Job::Blurhash(m_texture_handle) = m_blur_job
    290     else {
    291         tracing::error!("Did not get the correct job type: {:?}", m_blur_job);
    292         return ObfuscatedTexture::Default;
    293     };
    294 
    295     let Some(texture_handle) = m_texture_handle else {
    296         return ObfuscatedTexture::Default;
    297     };
    298 
    299     ObfuscatedTexture::Blur(texture_handle)
    300 }
    301 
    302 fn copy_link(i18n: &mut Localization, url: &str, img_resp: &Response) {
    303     img_resp.context_menu(|ui| {
    304         if ui
    305             .button(tr!(
    306                 i18n,
    307                 "Copy Link",
    308                 "Button to copy media link to clipboard"
    309             ))
    310             .clicked()
    311         {
    312             ui.ctx().copy_text(url.to_owned());
    313             ui.close_menu();
    314         }
    315     });
    316 }
    317 
    318 #[allow(clippy::too_many_arguments)]
    319 fn render_media(
    320     ui: &mut egui::Ui,
    321     gifs: &mut GifStateMap,
    322     render_state: MediaRenderState,
    323     url: &str,
    324     size: egui::Vec2,
    325     i18n: &mut Localization,
    326     is_scaled: bool,
    327 ) -> egui::InnerResponse<Option<MediaUIAction>> {
    328     match render_state {
    329         MediaRenderState::ActualImage(image) => {
    330             let resp = render_success_media(ui, url, image, gifs, size, i18n, is_scaled);
    331             if resp.clicked() {
    332                 egui::InnerResponse::new(Some(MediaUIAction::Clicked), resp)
    333             } else {
    334                 egui::InnerResponse::new(None, resp)
    335             }
    336         }
    337         MediaRenderState::Transitioning { image, obfuscation } => match obfuscation {
    338             ObfuscatedTexture::Blur(texture) => {
    339                 let resp = render_blur_transition(
    340                     ui,
    341                     url,
    342                     size,
    343                     texture,
    344                     image.get_first_texture(),
    345                     is_scaled,
    346                 );
    347                 if resp.inner {
    348                     egui::InnerResponse::new(Some(MediaUIAction::DoneLoading), resp.response)
    349                 } else {
    350                     egui::InnerResponse::new(None, resp.response)
    351                 }
    352             }
    353             ObfuscatedTexture::Default => {
    354                 let scaled = ScaledTexture::new(image.get_first_texture(), size, is_scaled);
    355                 let resp = ui.add(scaled.get_image());
    356                 egui::InnerResponse::new(Some(MediaUIAction::DoneLoading), resp)
    357             }
    358         },
    359         MediaRenderState::Error(e) => {
    360             let response = ui.allocate_response(size, egui::Sense::hover());
    361             show_one_error_message(ui, &format!("Could not render media {url}: {e}"));
    362             egui::InnerResponse::new(Some(MediaUIAction::Error), response)
    363         }
    364         MediaRenderState::Shimmering(obfuscated_texture) => match obfuscated_texture {
    365             ObfuscatedTexture::Blur(texture_handle) => egui::InnerResponse::new(
    366                 None,
    367                 shimmer_blurhash(texture_handle, ui, url, size, is_scaled),
    368             ),
    369             ObfuscatedTexture::Default => {
    370                 let shimmer = true;
    371                 egui::InnerResponse::new(
    372                     None,
    373                     render_default_blur_bg(ui, size, url, shimmer, is_scaled),
    374                 )
    375             }
    376         },
    377         MediaRenderState::Obfuscated(obfuscated_texture) => {
    378             let resp = match obfuscated_texture {
    379                 ObfuscatedTexture::Blur(texture_handle) => {
    380                     let scaled = ScaledTexture::new(texture_handle, size, is_scaled);
    381 
    382                     let resp = ui.add(scaled.get_image());
    383                     render_blur_text(ui, i18n, url, resp.rect)
    384                 }
    385                 ObfuscatedTexture::Default => render_default_blur(ui, i18n, size, url, is_scaled),
    386             };
    387 
    388             let resp = resp.on_hover_cursor(egui::CursorIcon::PointingHand);
    389             if resp.clicked() {
    390                 egui::InnerResponse::new(Some(MediaUIAction::Unblur), resp)
    391             } else {
    392                 egui::InnerResponse::new(None, resp)
    393             }
    394         }
    395     }
    396 }
    397 
    398 fn render_blur_text(
    399     ui: &mut egui::Ui,
    400     i18n: &mut Localization,
    401     url: &str,
    402     render_rect: egui::Rect,
    403 ) -> egui::Response {
    404     let helper = AnimationHelper::new_from_rect(ui, ("show_media", url), render_rect);
    405 
    406     let painter = ui.painter_at(helper.get_animation_rect());
    407 
    408     let text_style = NotedeckTextStyle::Button;
    409 
    410     let icon_size = helper.scale_1d_pos(30.0);
    411     let animation_fontid = FontId::new(
    412         helper.scale_1d_pos(get_font_size(ui.ctx(), &text_style)),
    413         text_style.font_family(),
    414     );
    415     let info_galley = painter.layout(
    416         tr!(
    417             i18n,
    418             "Media from someone you don't follow",
    419             "Text shown on blurred media from unfollowed users"
    420         )
    421         .to_owned(),
    422         animation_fontid.clone(),
    423         ui.visuals().text_color(),
    424         render_rect.width() / 2.0,
    425     );
    426 
    427     let load_galley = painter.layout_no_wrap(
    428         tr!(i18n, "Tap to Load", "Button text to load blurred media"),
    429         animation_fontid,
    430         egui::Color32::BLACK,
    431         // ui.visuals().widgets.inactive.bg_fill,
    432     );
    433 
    434     let items_height = info_galley.rect.height() + load_galley.rect.height() + icon_size;
    435 
    436     let spacing = helper.scale_1d_pos(8.0);
    437     let icon_rect = {
    438         let mut center = helper.get_animation_rect().center();
    439         center.y -= (items_height / 2.0) + (spacing * 3.0) - (icon_size / 2.0);
    440 
    441         egui::Rect::from_center_size(center, egui::vec2(icon_size, icon_size))
    442     };
    443 
    444     (if ui.visuals().dark_mode {
    445         app_images::eye_slash_dark_image()
    446     } else {
    447         app_images::eye_slash_light_image()
    448     })
    449     .max_width(icon_size)
    450     .paint_at(ui, icon_rect);
    451 
    452     let info_galley_pos = {
    453         let mut pos = icon_rect.center();
    454         pos.x -= info_galley.rect.width() / 2.0;
    455         pos.y = icon_rect.bottom() + spacing;
    456         pos
    457     };
    458 
    459     let load_galley_pos = {
    460         let mut pos = icon_rect.center();
    461         pos.x -= load_galley.rect.width() / 2.0;
    462         pos.y = icon_rect.bottom() + info_galley.rect.height() + (4.0 * spacing);
    463         pos
    464     };
    465 
    466     let button_rect = egui::Rect::from_min_size(load_galley_pos, load_galley.size()).expand(8.0);
    467 
    468     let button_fill = egui::Color32::from_rgba_unmultiplied(0xFF, 0xFF, 0xFF, 0x1F);
    469 
    470     painter.rect(
    471         button_rect,
    472         egui::CornerRadius::same(8),
    473         button_fill,
    474         egui::Stroke::NONE,
    475         egui::StrokeKind::Middle,
    476     );
    477 
    478     painter.galley(info_galley_pos, info_galley, egui::Color32::WHITE);
    479     painter.galley(load_galley_pos, load_galley, egui::Color32::WHITE);
    480 
    481     helper.take_animation_response()
    482 }
    483 
    484 fn render_default_blur(
    485     ui: &mut egui::Ui,
    486     i18n: &mut Localization,
    487     size: egui::Vec2,
    488     url: &str,
    489     is_scaled: bool,
    490 ) -> egui::Response {
    491     let shimmer = false;
    492     let response = render_default_blur_bg(ui, size, url, shimmer, is_scaled);
    493     render_blur_text(ui, i18n, url, response.rect)
    494 }
    495 
    496 fn render_default_blur_bg(
    497     ui: &mut egui::Ui,
    498     size: egui::Vec2,
    499     url: &str,
    500     shimmer: bool,
    501     is_scaled: bool,
    502 ) -> egui::Response {
    503     let size = if is_scaled {
    504         size
    505     } else {
    506         vec2(size.y, size.y)
    507     };
    508 
    509     let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click());
    510 
    511     let painter = ui.painter_at(rect);
    512 
    513     let mut color = crate::colors::MID_GRAY;
    514     if shimmer {
    515         let [r, g, b, _a] = color.to_srgba_unmultiplied();
    516         let cur_alpha = get_blur_current_alpha(ui, url);
    517         color = Color32::from_rgba_unmultiplied(r, g, b, cur_alpha)
    518     }
    519 
    520     painter.rect_filled(rect, CornerRadius::same(8), color);
    521 
    522     response
    523 }
    524 
    525 pub enum MediaRenderState<'a> {
    526     ActualImage(&'a mut TexturedImage),
    527     Transitioning {
    528         image: &'a mut TexturedImage,
    529         obfuscation: ObfuscatedTexture<'a>,
    530     },
    531     Error(&'a notedeck::Error),
    532     Shimmering(ObfuscatedTexture<'a>),
    533     Obfuscated(ObfuscatedTexture<'a>),
    534 }
    535 
    536 pub enum ObfuscatedTexture<'a> {
    537     Blur(&'a TextureHandle),
    538     Default,
    539 }
    540 
    541 /*
    542 pub(crate) fn find_renderable_media<'a>(
    543     urls: &mut UrlMimes,
    544     imeta: &'a HashMap<String, ImageMetadata>,
    545     url: &'a str,
    546 ) -> Option<RenderableMedia> {
    547     let media_type = supported_mime_hosted_at_url(urls, url)?;
    548 
    549     let obfuscation_type = match imeta.get(url) {
    550         Some(blur) => ObfuscationType::Blurhash(blur.clone()),
    551         None => ObfuscationType::Default,
    552     };
    553 
    554     Some(RenderableMedia {
    555         url,
    556         media_type,
    557         obfuscation_type,
    558     })
    559 }
    560 */
    561 
    562 fn render_success_media(
    563     ui: &mut egui::Ui,
    564     url: &str,
    565     tex: &mut TexturedImage,
    566     gifs: &mut GifStateMap,
    567     size: Vec2,
    568     i18n: &mut Localization,
    569     is_scaled: bool,
    570 ) -> Response {
    571     let texture = ensure_latest_texture(ui, url, gifs, tex);
    572 
    573     let scaled = ScaledTexture::new(&texture, size, is_scaled);
    574 
    575     let img_resp = ui.add(Button::image(scaled.get_image()).frame(false));
    576 
    577     copy_link(i18n, url, &img_resp);
    578 
    579     img_resp
    580 }
    581 
    582 fn texture_to_image<'a>(tex: &TextureHandle, size: Vec2) -> egui::Image<'a> {
    583     Image::new(tex)
    584         .corner_radius(5.0)
    585         .fit_to_exact_size(size)
    586         .maintain_aspect_ratio(true)
    587 }
    588 
    589 static BLUR_SHIMMER_ID: fn(&str) -> egui::Id = |url| egui::Id::new(("blur_shimmer", url));
    590 
    591 fn get_blur_current_alpha(ui: &mut egui::Ui, url: &str) -> u8 {
    592     let id = BLUR_SHIMMER_ID(url);
    593 
    594     let (alpha_min, alpha_max) = if ui.visuals().dark_mode {
    595         (150, 255)
    596     } else {
    597         (220, 255)
    598     };
    599     PulseAlpha::new(ui.ctx(), id, alpha_min, alpha_max)
    600         .with_speed(0.3)
    601         .start_max_alpha()
    602         .animate()
    603 }
    604 
    605 fn shimmer_blurhash(
    606     tex: &TextureHandle,
    607     ui: &mut egui::Ui,
    608     url: &str,
    609     size: Vec2,
    610     is_scaled: bool,
    611 ) -> egui::Response {
    612     let cur_alpha = get_blur_current_alpha(ui, url);
    613 
    614     let scaled = ScaledTexture::new(tex, size, is_scaled);
    615     let img = scaled.get_image();
    616     show_blurhash_with_alpha(ui, img, cur_alpha)
    617 }
    618 
    619 fn fade_color(alpha: u8) -> egui::Color32 {
    620     Color32::from_rgba_unmultiplied(255, 255, 255, alpha)
    621 }
    622 
    623 fn show_blurhash_with_alpha(ui: &mut egui::Ui, img: Image, alpha: u8) -> egui::Response {
    624     let cur_color = fade_color(alpha);
    625     let img = img.tint(cur_color);
    626 
    627     ui.add(img)
    628 }
    629 
    630 type FinishedTransition = bool;
    631 
    632 // return true if transition is finished
    633 fn render_blur_transition(
    634     ui: &mut egui::Ui,
    635     url: &str,
    636     size: Vec2,
    637     blur_texture: &TextureHandle,
    638     image_texture: &TextureHandle,
    639     is_scaled: bool,
    640 ) -> egui::InnerResponse<FinishedTransition> {
    641     let scaled_texture = ScaledTexture::new(image_texture, size, is_scaled);
    642     let scaled_blur_img = ScaledTexture::new(blur_texture, size, is_scaled);
    643 
    644     match get_blur_transition_state(ui.ctx(), url) {
    645         BlurTransitionState::StoppingShimmer { cur_alpha } => egui::InnerResponse::new(
    646             false,
    647             show_blurhash_with_alpha(ui, scaled_blur_img.get_image(), cur_alpha),
    648         ),
    649         BlurTransitionState::FadingBlur => {
    650             render_blur_fade(ui, url, scaled_blur_img.get_image(), &scaled_texture)
    651         }
    652     }
    653 }
    654 
    655 struct ScaledTexture<'a> {
    656     tex: &'a TextureHandle,
    657     size: Vec2,
    658     pub scaled_size: Vec2,
    659 }
    660 
    661 impl<'a> ScaledTexture<'a> {
    662     pub fn new(tex: &'a TextureHandle, max_size: Vec2, is_narrow: bool) -> Self {
    663         let tex_size = tex.size_vec2();
    664 
    665         let scaled_size = if !is_narrow {
    666             if tex_size.y > max_size.y {
    667                 let scale = max_size.y / tex_size.y;
    668                 tex_size * scale
    669             } else {
    670                 tex_size
    671             }
    672         } else if tex_size.x != max_size.x {
    673             let scale = max_size.x / tex_size.x;
    674             tex_size * scale
    675         } else {
    676             tex_size
    677         };
    678 
    679         Self {
    680             tex,
    681             size: max_size,
    682             scaled_size,
    683         }
    684     }
    685 
    686     pub fn get_image(&self) -> Image {
    687         texture_to_image(self.tex, self.size).fit_to_exact_size(self.scaled_size)
    688     }
    689 }
    690 
    691 fn render_blur_fade(
    692     ui: &mut egui::Ui,
    693     url: &str,
    694     blur_img: Image,
    695     image_texture: &ScaledTexture,
    696 ) -> egui::InnerResponse<FinishedTransition> {
    697     let blur_fade_id = ui.id().with(("blur_fade", url));
    698 
    699     let cur_alpha = {
    700         PulseAlpha::new(ui.ctx(), blur_fade_id, 0, 255)
    701             .start_max_alpha()
    702             .with_speed(0.3)
    703             .animate()
    704     };
    705 
    706     let img = image_texture.get_image();
    707 
    708     let blur_img = blur_img.tint(fade_color(cur_alpha));
    709 
    710     let alloc_size = image_texture.scaled_size;
    711 
    712     let (rect, resp) = ui.allocate_exact_size(alloc_size, egui::Sense::hover());
    713 
    714     img.paint_at(ui, rect);
    715     blur_img.paint_at(ui, rect);
    716 
    717     egui::InnerResponse::new(cur_alpha == 0, resp)
    718 }
    719 
    720 fn get_blur_transition_state(ctx: &Context, url: &str) -> BlurTransitionState {
    721     let shimmer_id = BLUR_SHIMMER_ID(url);
    722 
    723     let max_alpha = 255.0;
    724     let cur_shimmer_alpha = ctx.animate_value_with_time(shimmer_id, max_alpha, 0.3);
    725     if cur_shimmer_alpha == max_alpha {
    726         BlurTransitionState::FadingBlur
    727     } else {
    728         let cur_alpha = (cur_shimmer_alpha).clamp(0.0, max_alpha) as u8;
    729         BlurTransitionState::StoppingShimmer { cur_alpha }
    730     }
    731 }
    732 
    733 enum BlurTransitionState {
    734     StoppingShimmer { cur_alpha: u8 },
    735     FadingBlur,
    736 }