// DragonX Wallet - ImGui Edition // Copyright 2024-2026 The Hush Developers // Released under the GPLv3 // // In-app, Material-styled image picker. A modal overlay that browses the filesystem and lets the // user pick an image file, shown as a thumbnail grid. Used by the Contacts edit dialog to choose a // custom contact avatar. Like FolderPicker, only one overlay renders at a time (the framework does // not nest), so the caller suppresses its own overlay while ImagePicker::isOpen(). // // Thumbnails are read, decoded and box-downscaled on background workers (util::StartImageDecode), then // uploaded to small textures on the UI thread a few per frame, and cached per directory (destroyed on // navigate/close, LRU-capped within a directory) so browsing a folder of large photos never stalls the UI. #pragma once #include #include #include #include #include #include #include #include #include #include #include #include #include "imgui.h" #include "../../util/i18n.h" #include "../../util/platform.h" #include "../../util/image_decode_core.h" #include "../../util/texture_loader.h" #include "../../embedded/IconsMaterialDesign.h" #include "../layout.h" #include "../material/colors.h" #include "../material/draw_helpers.h" #include "../material/type.h" namespace dragonx { namespace ui { class ImagePicker { public: // Open the picker starting at `startDir` (falls back to the user's Pictures dir, then home, if // empty/invalid). `onPick` receives the chosen absolute image path when the user confirms. static void open(const std::string& startDir, std::function onPick) { namespace fs = std::filesystem; std::error_code ec; std::string start = startDir; if (start.empty() || !fs::is_directory(fs::path(start), ec)) { if (!s_dir.empty() && fs::is_directory(fs::path(s_dir), ec)) start = s_dir; // reopen last else start = defaultStartDir(); } navigate(start); s_onPick = std::move(onPick); s_selected.clear(); s_open = true; } static bool isOpen() { return s_open; } static void close() { s_open = false; clearThumbs(); } static void render() { if (!s_open) return; using namespace material; const float dp = Layout::dpiScale(); s_uploadsThisFrame = 0; // budget: upload at most a few finished thumbnails per frame (no scroll hitch) ImFont* rowFont = Type().body1(); ImFont* icoFont = Type().iconSmall(); ImFont* metaFont = Type().caption(); const float vpH = ImGui::GetMainViewport()->Size.y; const float cardH = (vpH * 0.82f) / dp; // logical; the framework re-applies dp + caps at vp-32 OverlayDialogSpec ov; ov.title = TR("img_picker_title"); ov.p_open = &s_open; ov.style = OverlayStyle::BlurFloat; ov.cardWidth = 760.0f; ov.cardHeight = cardH; ov.idSuffix = "imagepicker"; if (!BeginOverlayDialog(ov)) { if (!s_open) clearThumbs(); return; } if (ImGui::IsKeyPressed(ImGuiKey_Escape)) s_open = false; auto tw = [](ImFont* f, const std::string& s){ return f->CalcTextSizeA(f->LegacySize, FLT_MAX, 0, s.c_str()).x; }; auto fitPath = [&](std::string s, ImFont* f, float maxW){ const std::string ell = "\xE2\x80\xA6"; if (tw(f,s) <= maxW) return s; while (s.size()>1 && tw(f, ell+s) > maxW){ s.erase(s.begin()); while (!s.empty() && (static_cast(s.front()) & 0xC0) == 0x80) s.erase(s.begin()); } return ell + s; }; auto fit = [&](std::string s, ImFont* f, float maxW){ bool t=false; while (s.size()>1 && tw(f,s)>maxW){ while (s.size()>1 && (static_cast(s.back()) & 0xC0) == 0x80) s.pop_back(); if (s.size()>1) s.pop_back(); t=true; } if (t) s += "\xE2\x80\xA6"; return s; }; std::string pendingNav; // Inner padded body so the filled list + thumbnail grid keep a clear margin from the card's // rounded edges (the overlay's own content padding is tight for edge-to-edge filled content). ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(10.0f * dp, 8.0f * dp)); ImGui::BeginChild("##imgPickBody", ImVec2(0, 0), ImGuiChildFlags_AlwaysUseWindowPadding, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); // ---- Path bar: Up + Home + Pictures + current path strip ------------------------------ { const float bh = ImGui::GetFrameHeight(); IconButtonStyle st; st.color = OnSurfaceMedium(); st.hoverBg = StateHover(); st.bgRounding = 5.0f * dp; std::filesystem::path cur(s_dir); const bool hasParent = cur.has_parent_path() && cur.parent_path() != cur; st.tooltip = TR("picker_up"); if (IconButton("##imgUp", ICON_MD_ARROW_UPWARD, icoFont, ImVec2(bh, bh), st) && hasParent) pendingNav = cur.parent_path().string(); ImGui::SameLine(0.0f, Layout::spacingXs()); IconButtonStyle hst = st; hst.tooltip = TR("picker_home"); if (IconButton("##imgHome", ICON_MD_HOME, icoFont, ImVec2(bh, bh), hst)) pendingNav = util::Platform::getHomeDir(); ImGui::SameLine(0.0f, Layout::spacingXs()); IconButtonStyle pst = st; pst.tooltip = TR("img_picker_pictures"); if (IconButton("##imgPics", ICON_MD_IMAGE, icoFont, ImVec2(bh, bh), pst)) pendingNav = defaultStartDir(); ImGui::SameLine(0.0f, Layout::spacingSm()); ImVec2 sMin = ImGui::GetCursorScreenPos(); const float stripW = ImGui::GetContentRegionAvail().x; ImVec2 sMax(sMin.x + stripW, sMin.y + bh); ImDrawList* wdl = ImGui::GetWindowDrawList(); wdl->AddRectFilled(sMin, sMax, WithAlpha(OnSurface(), 14), 6.0f * dp); wdl->AddRect(sMin, sMax, WithAlpha(OnSurface(), 40), 6.0f * dp, 0, 1.0f); const float tpad = Layout::spacingSm(); wdl->AddText(rowFont, rowFont->LegacySize, ImVec2(sMin.x + tpad, sMin.y + (bh - rowFont->LegacySize) * 0.5f), OnSurface(), fitPath(s_dir, rowFont, stripW - tpad * 2.0f).c_str()); ImGui::Dummy(ImVec2(stripW, bh)); } ImGui::Dummy(ImVec2(0, Layout::spacingSm())); // ---- Body: subfolder rows (to navigate) then a thumbnail grid of images ---------------- const ImGuiStyle& gstyle = ImGui::GetStyle(); const float footerBlockH = Layout::spacingXs() + metaFont->LegacySize // status line + Layout::spacingSm() + 1.0f + Layout::spacingSm() // separator block + ImGui::GetFrameHeight() // footer buttons + 6.0f * gstyle.ItemSpacing.y; float listH = ImGui::GetContentRegionAvail().y - footerBlockH; listH = std::max(listH, 160.0f * dp); const float fullW = ImGui::GetContentRegionAvail().x; // Bordered/rounded outer frame whose 6px padding insets the inner scrollbar so it clears the // card's rounded corners; the inner child scrolls smoothly (ApplySmoothScroll lerps the wheel). ImGui::PushStyleColor(ImGuiCol_ChildBg, WithAlpha(OnSurface(), 20)); ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, 6.0f * dp); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.0f * dp, 6.0f * dp)); ImGui::BeginChild("##imgListFrame", ImVec2(fullW, listH), true, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); ImGui::PopStyleVar(2); // ChildRounding + outer WindowPadding (captured by the frame child) ImGui::PopStyleColor(); // ChildBg — the frame already drew it; the inner child stays transparent ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarSize, 11.0f * dp); ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarRounding, 5.5f * dp); ImGui::BeginChild("##imgList", ImVec2(0, 0), false, ImGuiWindowFlags_NoScrollWithMouse); ApplySmoothScroll(); ImDrawList* dl = ImGui::GetWindowDrawList(); const float padX = Layout::spacingSm(); if (s_subdirs.empty() && s_images.empty()) { ImGui::Dummy(ImVec2(0, Layout::spacingSm())); ImGui::Indent(padX); Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("img_picker_empty")); ImGui::Unindent(padX); } // Folders: a 2-column grid of thin rounded rectangles (folder icon + name), clickable to descend. if (!s_subdirs.empty()) { const int cols = 2; const float cellGap = Layout::spacingSm(); const float availW = ImGui::GetContentRegionAvail().x; const float cellW = (availW - cellGap * (cols - 1)) / (float)cols; const float cellH = rowFont->LegacySize + Layout::spacingSm() * 1.5f; int col = 0; for (std::size_t i = 0; i < s_subdirs.size(); ++i) { if (col != 0) ImGui::SameLine(0, cellGap); ImVec2 mn = ImGui::GetCursorScreenPos(); ImVec2 mx(mn.x + cellW, mn.y + cellH); // Off-screen cell: reserve the same space (scroll extent unchanged) but skip hit-test/draw. if (!ImGui::IsRectVisible(mn, mx)) { ImGui::Dummy(ImVec2(cellW, cellH)); col = (col + 1) % cols; continue; } ImGui::PushID((int)(i + 1)); const bool clicked = ImGui::InvisibleButton("##idir", ImVec2(cellW, cellH)); const bool hov = ImGui::IsItemHovered(); ImGui::PopID(); dl->AddRectFilled(mn, mx, WithAlpha(OnSurface(), hov ? 34 : 16), 6.0f * dp); if (hov) { dl->AddRect(mn, mx, WithAlpha(OnSurface(), 70), 6.0f * dp, 0, 1.0f); ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); } const float midY = (mn.y + mx.y) * 0.5f; const float icoSz = rowFont->LegacySize * 1.05f; float x = mn.x + padX; dl->AddText(icoFont, icoSz, ImVec2(x, midY - icoSz * 0.5f), hov ? Primary() : OnSurfaceMedium(), ICON_MD_FOLDER); x += icoSz + Layout::spacingSm(); std::string nm = fit(s_subdirs[i], rowFont, (mx.x - padX) - x); dl->AddText(rowFont, rowFont->LegacySize, ImVec2(x, midY - rowFont->LegacySize * 0.5f), OnSurface(), nm.c_str()); if (clicked) pendingNav = (std::filesystem::path(s_dir) / s_subdirs[i]).string(); col = (col + 1) % cols; } if (!s_images.empty()) ImGui::Dummy(ImVec2(0, Layout::spacingSm())); // gap before the thumbnails } // Thumbnail grid: a fixed 6 columns whose square cells scale to fill the width. { const float availW = ImGui::GetContentRegionAvail().x; const float gap = Layout::spacingSm(); const int cols = 6; const float cell = std::max(24.0f * dp, (availW - gap * (cols - 1)) / (float)cols); int col = 0; for (std::size_t i = 0; i < s_images.size(); ++i) { if (col != 0) ImGui::SameLine(0, gap); ImVec2 mn = ImGui::GetCursorScreenPos(); ImVec2 mx(mn.x + cell, mn.y + cell); // Off-screen cell: reserve the same space (scroll extent unchanged) but skip hit-test/draw. if (!ImGui::IsRectVisible(mn, mx)) { ImGui::Dummy(ImVec2(cell, cell)); col = (col + 1) % cols; continue; } const std::string abs = (std::filesystem::path(s_dir) / s_images[i]).string(); const bool sel = (s_selected == abs); ImGui::PushID((int)(i + 1)); const bool clicked = ImGui::InvisibleButton("##thumb", ImVec2(cell, cell)); const bool dbl = ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left) && ImGui::IsItemHovered(); const bool hov = ImGui::IsItemHovered(); ImGui::PopID(); dl->AddRectFilled(mn, mx, WithAlpha(OnSurface(), 18), 6.0f * dp); // Only cells actually on-screen start a (background) decode; finished ones upload a few per frame. const Thumb* t = ImGui::IsRectVisible(mn, mx) ? thumbFor(abs) : nullptr; ImTextureID tex = t ? t->tex : 0; int tw = t ? t->w : 0, thh = t ? t->h : 0; // Hovering an animated image loads its frames in the BACKGROUND (no UI hang) and plays // them once ready; the still thumbnail shows meanwhile. if (t && hov && t->animated) { Thumb& mt = s_thumbs[abs]; // t is non-null => the entry exists driveThumbAnim(mt, abs); if (mt.animReady && 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 (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(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); dl->AddText(icoFont, cell*0.34f, ImVec2(cc.x - cell*0.17f, cc.y - cell*0.17f), OnSurfaceDisabled(), ICON_MD_IMAGE); } // "Animated" badge (bottom-right) on GIF/WebP that move — hidden while it plays on hover. if (t && t->animated && !hov) { float bh = std::max(13.0f * dp, cell * 0.24f); float bw = bh * 1.15f; ImVec2 bmax(mx.x - 4.0f * dp, mx.y - 4.0f * dp); ImVec2 bmin(bmax.x - bw, bmax.y - bh); dl->AddRectFilled(bmin, bmax, IM_COL32(0, 0, 0, 180), 4.0f * dp); float gsz = bh * 0.92f; ImVec2 gs = icoFont->CalcTextSizeA(gsz, FLT_MAX, 0, ICON_MD_PLAY_ARROW); dl->AddText(icoFont, gsz, ImVec2((bmin.x + bmax.x) * 0.5f - gs.x * 0.5f, (bmin.y + bmax.y) * 0.5f - gs.y * 0.5f), IM_COL32(255, 255, 255, 235), ICON_MD_PLAY_ARROW); } if (sel) dl->AddRect(mn, mx, WithAlpha(Primary(), 220), 6.0f * dp, 0, 2.0f * dp); else if (hov) { dl->AddRect(mn, mx, WithAlpha(OnSurface(), 90), 6.0f * dp, 0, 1.5f * dp); ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); } if (hov) material::Tooltip("%s", s_images[i].c_str()); if (dbl && s_onPick) { s_onPick(abs); s_open = false; } else if (clicked) s_selected = abs; col = (col + 1) % cols; } } maintainThumbs(); // after every Thumb* of this frame was used: cancel left hovers, LRU-evict ImGui::EndChild(); // inner scrolling list ImGui::PopStyleVar(2); // ScrollbarSize + ScrollbarRounding ImGui::EndChild(); // outer frame // ---- Status ---------------------------------------------------------------------------- ImGui::Dummy(ImVec2(0, Layout::spacingXs())); if (!s_images.empty()) { char buf[96]; snprintf(buf, sizeof(buf), TR("img_picker_count"), (int)s_images.size()); Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), buf); } else { Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("img_picker_none")); } // ---- Footer: Use image / Cancel (centered) --------------------------------------------- ImGui::Dummy(ImVec2(0, Layout::spacingSm())); { const float useW = 200.0f * dp, cancelW = 120.0f * dp; const float total = useW + cancelW + ImGui::GetStyle().ItemSpacing.x; ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, (ImGui::GetContentRegionAvail().x - total) * 0.5f)); ImGui::BeginDisabled(s_selected.empty()); if (StyledButton(TR("img_picker_use"), ImVec2(useW, 0))) { if (s_onPick && !s_selected.empty()) s_onPick(s_selected); s_open = false; } ImGui::EndDisabled(); ImGui::SameLine(); if (StyledButton(TR("cancel"), ImVec2(cancelW, 0))) s_open = false; } ImGui::EndChild(); // ##imgPickBody ImGui::PopStyleVar(); // WindowPadding EndOverlayDialog(); if (!pendingNav.empty()) navigate(pendingNav); 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: // Decode jobs (util::StartImageDecode) run on detached workers that co-own the job: if the owning Thumb // is erased mid-decode (navigate/close/evict) the worker stops early or finishes and frees it itself. struct Thumb { ImTextureID tex = 0; int w = 0, h = 0; bool tried = false; // static frame-0 thumbnail bool animated = false; // multi-frame GIF/WebP (badge) util::ImageDecodeJobPtr thumbJob; // still-thumbnail decode in flight uint64_t lastUse = 0; // ImGui frame the cell was last drawn (LRU) uint64_t hoverFrame = 0; // ImGui frame it was last hovered (LRU) // Async animation load (kicked off on hover): the frame decode runs on a worker thread, then frames // are uploaded to textures a few per UI frame — so hovering never blocks the UI. bool animRequested = false, staged = false, animReady = false; util::ImageDecodeJobPtr job; // hover-animation decode in flight util::AnimFrames pending; // decoded frames awaiting GPU upload size_t uploaded = 0; std::vector frames; std::vector delays; int aw = 0, ah = 0; float totalDur = 0.0f; }; // Drive the async animation load for a hovered thumbnail: (1) start a background decode once (hover // lane, at most 2 at once), (2) move its result to a staging buffer when done, (3) upload a few frames // per UI frame. Never blocks — the static thumbnail keeps showing until the sequence is fully uploaded. static void driveThumbAnim(Thumb& th, const std::string& path) { // FIRST, before any early return: marks this thumb's frames as drawn this frame, so maintainThumbs() // never destroys a playing animation in the frame that draws it. th.hoverFrame = static_cast(ImGui::GetFrameCount()); if (th.animReady) return; if (!th.animRequested) { th.job = util::StartImageDecode(s_animLane, path, kThumbMaxFrames, util::ImageLimits{}); material::RequestProgressFrame(); // poll for the result (or a free slot) without input if (!th.job) return; // both hover slots busy: retry next frame th.animRequested = true; return; } if (th.job) { if (!th.job->finished()) { material::RequestProgressFrame(); return; } // still decoding if (th.job->ok()) th.pending = std::move(th.job->result); th.job.reset(); if (th.pending.frames.size() <= 1) { // turned out not to animate (or failed) th.animated = false; th.pending = util::AnimFrames{}; th.animReady = true; return; } th.staged = true; } if (th.staged) { s_animatingThisFrame = true; // keep redrawing so the upload progresses int budget = 4; while (th.uploaded < th.pending.frames.size() && budget-- > 0) { ImTextureID t = 0; if (util::CreateRawTexture(th.pending.frames[th.uploaded].data(), th.pending.w, th.pending.h, false, &t)) { th.frames.push_back(t); th.delays.push_back(th.uploaded < th.pending.delaysSec.size() ? th.pending.delaysSec[th.uploaded] : 0.1f); } th.uploaded++; } if (th.uploaded >= th.pending.frames.size()) { th.aw = th.pending.w; th.ah = th.pending.h; for (float d : th.delays) th.totalDur += d; th.pending = util::AnimFrames{}; th.animReady = true; } } } static bool isImageName(const std::string& name) { auto lower = name; for (char& c : lower) c = (char)std::tolower((unsigned char)c); static const char* kExt[] = { ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp", ".tga", ".psd", ".pnm", ".ppm", ".pgm", ".pic" }; for (const char* e : kExt) { const std::string ext(e); if (lower.size() > ext.size() && lower.compare(lower.size()-ext.size(), ext.size(), ext) == 0) return true; } return false; } static std::string defaultStartDir() { namespace fs = std::filesystem; std::error_code ec; const std::string home = util::Platform::getHomeDir(); fs::path pics = fs::path(home) / "Pictures"; if (fs::is_directory(pics, ec)) return pics.string(); return home; } // Lazy thumbnail loader. A visible, uncached cell starts one background decode (file read, header probe // + limits, decode, downscale to <= kThumbPx all run on a worker; at most 2 at once, so the top of the // visible grid fills first) and shows the placeholder icon until it is done; the UI thread then uploads // the small texture (<= kUploadsPerFrame per frame). Returns null while both decode slots are busy (the // cell retries next frame). The worker also reports whether the source animates (badge), from the same // bytes, so there is no second file read. static const Thumb* thumbFor(const std::string& absPath) { auto it = s_thumbs.find(absPath); if (it == s_thumbs.end()) { util::ImageLimits lim; lim.stillMax = lim.animMax = kThumbPx; util::ImageDecodeJobPtr job = util::StartImageDecode(s_thumbLane, absPath, /*maxFrames=*/1, lim); if (!job) { material::RequestProgressFrame(); return nullptr; } // slots busy: placeholder, retry Thumb th; th.thumbJob = std::move(job); it = s_thumbs.emplace(absPath, std::move(th)).first; } Thumb& th = it->second; th.lastUse = static_cast(ImGui::GetFrameCount()); if (th.thumbJob) { if (!th.thumbJob->finished() || s_uploadsThisFrame >= kUploadsPerFrame) { material::RequestProgressFrame(); // still decoding / upload budget spent: come back next frame return &th; } const util::AnimFrames& r = th.thumbJob->result; if (th.thumbJob->ok() && !r.frames.empty()) { ++s_uploadsThisFrame; if (util::CreateRawTexture(r.frames[0].data(), r.w, r.h, false, &th.tex)) { th.w = r.w; th.h = r.h; } th.animated = r.animated; } th.thumbJob.reset(); // frees the decoded pixels th.tried = true; // failed / too large: placeholder, no retry (as before) } return &th; } static void releaseThumb(Thumb& th) { if (th.thumbJob) th.thumbJob->cancel.store(true, std::memory_order_relaxed); // worker stops early if (th.job) th.job->cancel.store(true, std::memory_order_relaxed); if (th.tex) util::DestroyTexture(th.tex); for (ImTextureID f : th.frames) if (f) util::DestroyTexture(f); } static void clearThumbs() { for (auto& kv : s_thumbs) releaseThumb(kv.second); s_thumbs.clear(); } // Free a thumbnail's hover-animation frames (GPU + staged CPU copies) but keep its still; the next // hover decodes the animation again. static void dropThumbAnim(Thumb& th) { if (th.job) th.job->cancel.store(true, std::memory_order_relaxed); th.job.reset(); for (ImTextureID f : th.frames) if (f) util::DestroyTexture(f); th.frames.clear(); th.delays.clear(); th.pending = util::AnimFrames{}; th.uploaded = 0; th.aw = th.ah = 0; th.totalDur = 0.0f; th.animRequested = th.staged = th.animReady = false; } static size_t stillBytes(const Thumb& th) { return th.tex ? (size_t)th.w * (size_t)th.h * 4u : 0u; } static size_t animBytes(const Thumb& th) { const size_t fw = (size_t)(th.aw > 0 ? th.aw : th.pending.w); const size_t fh = (size_t)(th.ah > 0 ? th.ah : th.pending.h); size_t bytes = (th.frames.size() + th.pending.frames.size()) * fw * fh * 4u; // GPU frames + staged CPU copies if (th.job && th.job->finished()) { // a finished hover decode not consumed yet (the mouse left): evictable const util::AnimFrames& r = th.job->result; bytes += r.frames.size() * (size_t)r.w * (size_t)r.h * 4u; } return bytes; } // Per-frame upkeep, run after the grid has drawn (so every texture drawn this frame has lastUse/hoverFrame // == this frame and is protected): (1) cancel a hover decode the mouse has left, freeing its slot for // what the user is looking at now (a FINISHED one is kept for the next hover); (2) above // kThumbBudgetBytes, evict least-recently-used hover-frame sets and still thumbnails (re-decoded if needed). static void maintainThumbs() { const uint64_t frame = static_cast(ImGui::GetFrameCount()); size_t total = 0; for (auto& kv : s_thumbs) { Thumb& th = kv.second; if (th.job && !th.job->finished() && th.hoverFrame + 1 < frame) { th.job->cancel.store(true, std::memory_order_relaxed); th.job.reset(); th.animRequested = false; // a later hover restarts it } total += stillBytes(th) + animBytes(th); } if (total <= kThumbBudgetBytes) return; std::vector items; std::vector keys; items.reserve(s_thumbs.size() * 2); keys.reserve(s_thumbs.size()); for (const auto& kv : s_thumbs) { const size_t k = keys.size(); keys.push_back(&kv.first); items.push_back({k * 2, stillBytes(kv.second), kv.second.lastUse}); // protected while visible items.push_back({k * 2 + 1, animBytes(kv.second), kv.second.hoverFrame}); // protected while hovered } // Copy the victims' paths BEFORE mutating the map (erasing invalidates the key pointers). std::vector> victims; // (path, wholeEntry) for (size_t key : util::selectEvictions(std::move(items), kThumbBudgetBytes, frame)) victims.emplace_back(*keys[key / 2], key % 2 == 0); for (const auto& v : victims) { auto it = s_thumbs.find(v.first); if (it == s_thumbs.end()) continue; if (v.second) { releaseThumb(it->second); s_thumbs.erase(it); } else dropThumbAnim(it->second); } } static void navigate(const std::string& dir) { namespace fs = std::filesystem; std::error_code ec; fs::path p(dir); if (!fs::is_directory(p, ec)) return; clearThumbs(); // thumbnails are per-directory fs::path abs = fs::absolute(p, ec); s_dir = ec ? dir : abs.lexically_normal().string(); s_subdirs.clear(); s_images.clear(); fs::directory_iterator it(p, ec), end; int guard = 0; for (; !ec && it != end && guard < 8000; it.increment(ec), ++guard) { std::error_code fec; std::string name = it->path().filename().string(); if (name.empty() || name[0] == '.') continue; // skip hidden/dotfiles if (it->is_directory(fec)) s_subdirs.push_back(name); else if (it->is_regular_file(fec) && isImageName(name)) s_images.push_back(name); } auto ci = [](const std::string& a, const std::string& b){ return std::lexicographical_compare(a.begin(), a.end(), b.begin(), b.end(), [](char x, char y){ return std::tolower((unsigned char)x) < std::tolower((unsigned char)y); }); }; std::sort(s_subdirs.begin(), s_subdirs.end(), ci); std::sort(s_images.begin(), s_images.end(), ci); } static constexpr int kThumbPx = 128; // max thumbnail dimension (downscaled) static constexpr int kUploadsPerFrame = 8; // finished thumbnails uploaded per frame (<= 64 KB each) static constexpr int kThumbMaxFrames = 300; // cap frames per hovered animation static constexpr size_t kThumbBudgetBytes = 64u * 1024u * 1024u; // resident thumbnail + hover-frame bytes // Decode lanes: at most 2 thumbnail decodes and 2 hover-animation decodes at once (separate, so hover // previews are never starved by a folder of thumbnails). A lane's counter is heap-owned and co-owned // by its detached workers, so a worker still running at process exit never touches a destroyed static. static inline util::ImageDecodeLane s_thumbLane{2}; static inline util::ImageDecodeLane s_animLane{2}; static inline bool s_open = false; // Main-thread-only (set in render/driveThumbAnim, read+cleared in consumeAnimationActive); NOT atomic // on purpose — do not read/write it from a worker thread. static inline bool s_animatingThisFrame = false; // a hover-preview animation is playing static inline int s_uploadsThisFrame = 0; static inline std::string s_dir; static inline std::string s_selected; static inline std::vector s_subdirs; static inline std::vector s_images; static inline std::unordered_map s_thumbs; static inline std::function s_onPick; }; } // namespace ui } // namespace dragonx