From fd0cab41f0f8ed9b904255d6b3a976c6ffe43251 Mon Sep 17 00:00:00 2001 From: DanS Date: Wed, 15 Jul 2026 11:22:57 -0500 Subject: [PATCH] feat(image-picker): play animated thumbnails on hover Hovering a GIF/WebP thumbnail in the picker now previews its animation: - The picker's Thumb gains a lazily-loaded frame sequence (via LoadAnimatedRGBA) fetched the first time an animatable (.gif/.webp) thumbnail is hovered; still images and other formats keep their single static thumbnail (no re-decode). - On hover the current frame is drawn on the ImGui clock; leaving the thumbnail returns it to the still frame-0 preview. - A clear-on-read flag (ImagePicker::consumeAnimationActive) is OR'd into ConsumeContactsAvatarAnimation so the render loop keeps drawing while a hover preview plays and idles otherwise. clearThumbs frees all frame textures too. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ui/windows/contacts_tab.cpp | 8 +++- src/ui/windows/image_picker.h | 69 ++++++++++++++++++++++++++++++--- 2 files changed, 70 insertions(+), 7 deletions(-) diff --git a/src/ui/windows/contacts_tab.cpp b/src/ui/windows/contacts_tab.cpp index 273b011..452bfe2 100644 --- a/src/ui/windows/contacts_tab.cpp +++ b/src/ui/windows/contacts_tab.cpp @@ -254,7 +254,13 @@ static void deleteAvatarLibraryFile(const std::string& path) { // UI-loop accessor: true if an animated avatar frame was drawn since the last call (clear-on-read), so // the main render loop keeps producing frames while an animation plays and idles once it stops / the // contacts view is hidden. Declared in contacts_tab.h. -bool ConsumeContactsAvatarAnimation() { bool v = s_avatarAnimatedThisFrame; s_avatarAnimatedThisFrame = false; return v; } +bool ConsumeContactsAvatarAnimation() { + // Also consume the image picker's hover-preview animation flag (the picker renders within this tab). + bool picker = ImagePicker::consumeAnimationActive(); + bool v = s_avatarAnimatedThisFrame || picker; + s_avatarAnimatedThisFrame = false; + return v; +} void RenderContactsTab(App* app) { diff --git a/src/ui/windows/image_picker.h b/src/ui/windows/image_picker.h index f65c76b..817f2ab 100644 --- a/src/ui/windows/image_picker.h +++ b/src/ui/windows/image_picker.h @@ -14,6 +14,7 @@ #include #include +#include #include #include #include @@ -229,12 +230,28 @@ public: dl->AddRectFilled(mn, mx, WithAlpha(OnSurface(), 18), 6.0f * dp); // Only decode thumbnails for cells actually on-screen, and only a few per frame. const Thumb* t = ImGui::IsRectVisible(mn, mx) ? thumbFor(abs) : nullptr; - if (t && t->tex) { + ImTextureID tex = t ? t->tex : 0; + int tw = t ? t->w : 0, thh = t ? t->h : 0; + // Hovering an animatable image (GIF/WebP) loads its full sequence (once) and plays it. + if (t && hov && isAnimatableName(s_images[i])) { + Thumb& mt = s_thumbs[abs]; // t is non-null => the entry exists + loadThumbAnim(mt, abs); + if (mt.frames.size() > 1 && mt.totalDur > 0.0f) { + s_animatingThisFrame = true; + double phase = std::fmod(ImGui::GetTime(), (double)mt.totalDur); + double acc = 0.0; + for (size_t k = 0; k < mt.delays.size() && k < mt.frames.size(); ++k) { + acc += mt.delays[k]; + if (phase < acc) { tex = mt.frames[k]; tw = mt.aw; thh = mt.ah; break; } + } + } + } + if (tex) { float u0=0,v0=0,u1=1,v1=1; // centre-crop to a square - if (t->w > t->h) { float m=(t->w-t->h)*0.5f/t->w; u0=m; u1=1-m; } - else if (t->h > t->w) { float m=(t->h-t->w)*0.5f/t->h; v0=m; v1=1-m; } + if (tw > thh) { float m=(tw-thh)*0.5f/tw; u0=m; u1=1-m; } + else if (thh > tw) { float m=(thh-tw)*0.5f/thh; v0=m; v1=1-m; } const float ins = 3.0f * dp; - dl->AddImageRounded(t->tex, ImVec2(mn.x+ins, mn.y+ins), ImVec2(mx.x-ins, mx.y-ins), + dl->AddImageRounded(tex, ImVec2(mn.x+ins, mn.y+ins), ImVec2(mx.x-ins, mx.y-ins), ImVec2(u0,v0), ImVec2(u1,v1), IM_COL32_WHITE, 5.0f * dp); } else { ImVec2 cc(mn.x + cell*0.5f, mn.y + cell*0.5f); @@ -291,8 +308,35 @@ public: if (!s_open) clearThumbs(); // release GL textures the frame the picker dismisses } +public: + // Clear-on-read: true if an animated thumbnail was playing this frame (mouse over it), so the render + // loop keeps drawing while a hover-preview animates. Consumed by ConsumeContactsAvatarAnimation. + static bool consumeAnimationActive() { bool v = s_animatingThisFrame; s_animatingThisFrame = false; return v; } + private: - struct Thumb { ImTextureID tex = 0; int w = 0, h = 0; bool tried = false; }; + struct Thumb { + ImTextureID tex = 0; int w = 0, h = 0; bool tried = false; // static frame-0 thumbnail + std::vector frames; // full sequence (loaded on hover) + std::vector delays; int aw = 0, ah = 0; float totalDur = 0.0f; bool animTried = false; + }; + + // Load the full frame sequence for an animated image on first hover (still images stay single-frame). + static void loadThumbAnim(Thumb& th, const std::string& path) { + if (th.animTried) return; + th.animTried = true; + util::AnimFrames af; + if (util::LoadAnimatedRGBA(path.c_str(), kThumbMaxFrames, af) && af.frames.size() > 1) { + th.aw = af.w; th.ah = af.h; + for (size_t i = 0; i < af.frames.size(); ++i) { + ImTextureID t = 0; + if (util::CreateRawTexture(af.frames[i].data(), af.w, af.h, false, &t)) { + th.frames.push_back(t); + th.delays.push_back(i < af.delaysSec.size() ? af.delaysSec[i] : 0.1f); + } + } + for (float d : th.delays) th.totalDur += d; + } + } static bool isImageName(const std::string& name) { auto lower = name; @@ -307,6 +351,14 @@ private: return false; } + // Only these can hold multiple frames in our decoders; skip the hover re-decode for other formats. + static bool isAnimatableName(const std::string& name) { + auto lo = name; + for (char& c : lo) c = (char)std::tolower((unsigned char)c); + auto ends = [&](const char* e){ std::string s(e); return lo.size()>s.size() && lo.compare(lo.size()-s.size(), s.size(), s)==0; }; + return ends(".gif") || ends(".webp"); + } + static std::string defaultStartDir() { namespace fs = std::filesystem; std::error_code ec; @@ -356,7 +408,10 @@ private: } static void clearThumbs() { - for (auto& kv : s_thumbs) if (kv.second.tex) util::DestroyTexture(kv.second.tex); + for (auto& kv : s_thumbs) { + if (kv.second.tex) util::DestroyTexture(kv.second.tex); + for (ImTextureID f : kv.second.frames) if (f) util::DestroyTexture(f); + } s_thumbs.clear(); } @@ -389,8 +444,10 @@ private: static constexpr int kThumbPx = 128; // max thumbnail dimension (downscaled) static constexpr int kLoadsPerFrame = 6; // decode budget per frame (spreads a big folder over frames) + static constexpr int kThumbMaxFrames = 300; // cap frames per hovered animation static inline bool s_open = false; + static inline bool s_animatingThisFrame = false; // a hover-preview animation is playing static inline int s_loadedThisFrame = 0; static inline std::string s_dir; static inline std::string s_selected;