Adversarial review of the off-thread decode found the one real defect: a detached decode worker decremented the static s_animInFlight counter, which at process exit could run after static destruction begins (shutdown UB). Make the counter a heap std::shared_ptr<atomic> the worker co-owns, so it safely outlives teardown; the worker now touches only heap objects it holds a share of. Also noted s_animatingThisFrame is intentionally main-thread-only. (Review confirmed the rest: worker never touches the thumb map, done release/acquire publishes the frames, GL upload stays on the UI thread, stb is thread_local + libwebp per-instance so concurrent decodes are safe, the in-flight slot is never leaked, and growth is bounded/cleared on navigate.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
532 lines
27 KiB
C++
532 lines
27 KiB
C++
// 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 decoded to raw pixels, box-downscaled to a small texture, and cached per directory
|
|
// (destroyed on navigate/close) so browsing a Pictures folder full of large photos stays cheap.
|
|
|
|
#pragma once
|
|
|
|
#include <algorithm>
|
|
#include <atomic>
|
|
#include <cctype>
|
|
#include <cmath>
|
|
#include <cstdint>
|
|
#include <filesystem>
|
|
#include <functional>
|
|
#include <memory>
|
|
#include <string>
|
|
#include <thread>
|
|
#include <unordered_map>
|
|
#include <vector>
|
|
|
|
#include "imgui.h"
|
|
|
|
#include "../../util/i18n.h"
|
|
#include "../../util/platform.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<void(const std::string&)> 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_loadedThisFrame = 0; // budget: decode at most a few 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<unsigned char>(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<unsigned char>(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);
|
|
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);
|
|
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 decode thumbnails for cells actually on-screen, and only 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;
|
|
}
|
|
}
|
|
|
|
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:
|
|
// Background decode job: a detached worker fills `result` off the UI thread and sets `done`. The
|
|
// shared_ptr keeps it alive if the owning Thumb is destroyed mid-decode (no blocking on navigate).
|
|
struct AnimJob {
|
|
std::atomic<bool> done{false};
|
|
util::AnimFrames result;
|
|
};
|
|
|
|
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)
|
|
// Async animation load (kicked off on hover): the full 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;
|
|
std::shared_ptr<AnimJob> job;
|
|
util::AnimFrames pending; // decoded frames awaiting GPU upload
|
|
size_t uploaded = 0;
|
|
std::vector<ImTextureID> frames;
|
|
std::vector<float> delays; int aw = 0, ah = 0; float totalDur = 0.0f;
|
|
};
|
|
|
|
// Drive the async animation load for a hovered thumbnail: (1) kick off a background decode 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) {
|
|
if (th.animReady) return;
|
|
if (!th.animRequested) {
|
|
if (s_animInFlight->load() >= kMaxAnimInFlight) return; // cap workers; retry a later frame
|
|
th.animRequested = true;
|
|
th.job = std::make_shared<AnimJob>();
|
|
s_animInFlight->fetch_add(1);
|
|
std::shared_ptr<AnimJob> job = th.job;
|
|
std::shared_ptr<std::atomic<int>> inflight = s_animInFlight; // co-owned: safe past teardown
|
|
std::string p = path;
|
|
std::thread([job, inflight, p]() {
|
|
util::LoadAnimatedRGBA(p.c_str(), kThumbMaxFrames, job->result);
|
|
job->done.store(true, std::memory_order_release);
|
|
inflight->fetch_sub(1);
|
|
}).detach();
|
|
return;
|
|
}
|
|
if (th.job && th.job->done.load(std::memory_order_acquire)) {
|
|
th.pending = std::move(th.job->result);
|
|
th.job.reset();
|
|
if (th.pending.frames.size() <= 1) { // turned out not to animate
|
|
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;
|
|
}
|
|
|
|
// 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;
|
|
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, budgeted thumbnail loader: decodes to raw pixels, box-downscales to <=THUMB px, uploads a
|
|
// small texture. Returns null (placeholder icon shown) until the budget lets it load.
|
|
static const Thumb* thumbFor(const std::string& absPath) {
|
|
auto it = s_thumbs.find(absPath);
|
|
if (it != s_thumbs.end()) return &it->second;
|
|
if (s_loadedThisFrame >= kLoadsPerFrame) return nullptr; // defer to a later frame
|
|
s_loadedThisFrame++;
|
|
Thumb th; th.tried = true;
|
|
// Cheap animation probe (header/structure only) so animated items can show a badge without
|
|
// decoding all frames; only .gif/.webp can be animations in our decoders.
|
|
if (isAnimatableName(absPath)) th.animated = util::IsAnimatedImageFile(absPath.c_str());
|
|
int sw = 0, sh = 0;
|
|
unsigned char* raw = util::LoadRawPixelsFromFile(absPath.c_str(), &sw, &sh);
|
|
if (raw && sw > 0 && sh > 0) {
|
|
int dw = sw, dh = sh;
|
|
if (sw > kThumbPx || sh > kThumbPx) {
|
|
float s = (float)kThumbPx / (float)std::max(sw, sh);
|
|
dw = std::max(1, (int)(sw * s));
|
|
dh = std::max(1, (int)(sh * s));
|
|
}
|
|
std::vector<unsigned char> small((size_t)dw * dh * 4);
|
|
for (int y = 0; y < dh; y++) {
|
|
int sy0 = y * sh / dh, sy1 = std::max(sy0 + 1, (y + 1) * sh / dh);
|
|
for (int x = 0; x < dw; x++) {
|
|
int sx0 = x * sw / dw, sx1 = std::max(sx0 + 1, (x + 1) * sw / dw);
|
|
uint32_t r=0,g=0,b=0,a=0,n=0;
|
|
for (int yy = sy0; yy < sy1; yy++)
|
|
for (int xx = sx0; xx < sx1; xx++) {
|
|
const unsigned char* p = raw + ((size_t)yy * sw + xx) * 4;
|
|
r+=p[0]; g+=p[1]; b+=p[2]; a+=p[3]; ++n;
|
|
}
|
|
unsigned char* d = small.data() + ((size_t)y * dw + x) * 4;
|
|
d[0]=(unsigned char)(r/n); d[1]=(unsigned char)(g/n); d[2]=(unsigned char)(b/n); d[3]=(unsigned char)(a/n);
|
|
}
|
|
}
|
|
if (util::CreateRawTexture(small.data(), dw, dh, false, &th.tex)) { th.w = dw; th.h = dh; }
|
|
}
|
|
if (raw) util::FreeRawPixels(raw);
|
|
auto& slot = (s_thumbs[absPath] = th);
|
|
return &slot;
|
|
}
|
|
|
|
static void clearThumbs() {
|
|
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();
|
|
}
|
|
|
|
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 kLoadsPerFrame = 6; // decode budget per frame (spreads a big folder over frames)
|
|
static constexpr int kThumbMaxFrames = 300; // cap frames per hovered animation
|
|
static constexpr int kMaxAnimInFlight = 2; // max concurrent background decodes
|
|
|
|
// Heap-owned so a detached worker (which decrements it) can safely outlive static teardown at
|
|
// process exit — the worker holds a shared_ptr copy, so it never touches a destroyed static.
|
|
static inline std::shared_ptr<std::atomic<int>> s_animInFlight = std::make_shared<std::atomic<int>>(0);
|
|
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_loadedThisFrame = 0;
|
|
static inline std::string s_dir;
|
|
static inline std::string s_selected;
|
|
static inline std::vector<std::string> s_subdirs;
|
|
static inline std::vector<std::string> s_images;
|
|
static inline std::unordered_map<std::string, Thumb> s_thumbs;
|
|
static inline std::function<void(const std::string&)> s_onPick;
|
|
};
|
|
|
|
} // namespace ui
|
|
} // namespace dragonx
|