perf(contacts): decode avatars off the UI thread
The Contacts tab read, decoded and downscaled custom avatars inside the draw call, two per frame, rows scrolled out of view included: a 12 MP photo froze the window for 0.25-0.45 s, a long GIF for up to 1.5 s, and the avatar cache was never evicted. "Add image" decoded the picked file and copied it inside the picker's confirm callback, freezing it again. Avatars now decode on util::StartImageDecode workers (at most 2 at once). The UI thread only uploads finished frames, at most about 4 MB per UI frame, and asks for a frame every 100 ms while anything is pending; under idle pacing a deferred avatar used to wait for input or the 2 s tick. Behaviour changes: - an avatar scrolled out of view is not decoded until it scrolls in (the badge shows for 0.1-0.3 s the first time); - avatar textures are LRU-capped at 128 MiB; the least recently drawn go first, never one drawn on-screen in the previous Contacts frame, and they re-decode on scroll-back; - an animated avatar starts playing once all its frames are uploaded; - an import is validated and copied on a worker: the avatar appears 0.1-0.5 s after confirming and Save/Add stays disabled until it does; a result only lands in the dialog session that started it; - the copy goes to a temporary file renamed over the target (retried briefly), so a crash or a failed copy never leaves a truncated avatar or removes the existing library image; - imports get the decoder's limits, and an animated WebP can now be imported (it always failed with "couldn't be loaded"). Applying an import also no longer destroys an avatar texture the same frame has already drawn (a use-after-free on DX11). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+185
-45
@@ -13,6 +13,7 @@
|
||||
#include "../material/draw_helpers.h"
|
||||
#include "../material/type.h"
|
||||
#include "../material/project_icons.h"
|
||||
#include "../../util/image_decode_core.h"
|
||||
#include "../../util/texture_loader.h"
|
||||
#include "../../util/platform.h"
|
||||
#include "image_picker.h"
|
||||
@@ -21,11 +22,14 @@
|
||||
#include <filesystem>
|
||||
#include <unordered_map>
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <cctype>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
@@ -71,44 +75,87 @@ static bool matchesSearch(const data::AddressBookEntry& e, const std::string& ne
|
||||
}
|
||||
|
||||
// Lazily-loaded, path-keyed cache of custom avatar image textures. Each entry is a frame SEQUENCE
|
||||
// (1 frame for stills, N for animated GIF/WebP), box-downscaled by texture_loader to bound VRAM.
|
||||
// Decoding is budgeted per frame so opening a large library/contact list doesn't stall the UI — callers
|
||||
// show a placeholder and the image fills in over the next frames.
|
||||
// (1 frame for stills, N for animated GIF/WebP), box-downscaled to bound VRAM. Read + decode + downscale
|
||||
// run on a background worker (util::StartImageDecode, at most 2 at once); the UI thread only uploads the
|
||||
// finished frames under a per-frame byte budget, so a large contact list or image library never stalls
|
||||
// the UI — callers show the Z/T badge until the image is ready. Above kAvatarCacheBudgetBytes of GPU
|
||||
// memory the least-recently-drawn entries are evicted (never one drawn on-screen last frame).
|
||||
struct AvatarTex {
|
||||
std::vector<ImTextureID> frames; // 1 = still, N = animated
|
||||
std::vector<ImTextureID> frames; // uploaded so far: 1 = still, N = animated
|
||||
std::vector<float> delays; // seconds per frame (parallel to frames)
|
||||
float totalDur = 0.0f;
|
||||
int w = 0, h = 0;
|
||||
util::ImageDecodeJobPtr job; // decode running on a worker (null once collected)
|
||||
util::AnimFrames pending; // decoded frames waiting for GPU upload
|
||||
size_t uploaded = 0; // frames of `pending` processed so far
|
||||
bool complete = false; // every frame uploaded (an animation only plays once complete)
|
||||
bool failed = false; // decode failed: badge, no retry until invalidated (as before)
|
||||
uint64_t lastUse = 0; // ImGui frame this avatar was last drawn ON-SCREEN (LRU)
|
||||
};
|
||||
static std::unordered_map<std::string, AvatarTex> s_avatarTexCache;
|
||||
static int s_avatarLoadsThisFrame = 0;
|
||||
static util::ImageDecodeLane s_avatarDecodeLane(2); // at most 2 avatar decodes at once
|
||||
static size_t s_avatarUploadBytesThisFrame = 0;
|
||||
static uint64_t s_avatarPrevFrame = 0; // ImGui frame of the previous RenderContactsTab (eviction guard)
|
||||
static bool s_animateAvatars = true; // mirrors the setting; refreshed each frame in RenderContactsTab
|
||||
static int s_contactsShape = 0; // avatar shape (mirrors getContactsAvatarShape: 0/1/2)
|
||||
static float s_contactsScale = 1.0f; // card/list row scale (mirrors getContactsListScale)
|
||||
static bool s_avatarAnimatedThisFrame = false; // set when a live animated frame is drawn -> keep redrawing
|
||||
static constexpr int kAvatarLoadsPerFrame = 2;
|
||||
static constexpr size_t kAvatarUploadBytesPerFrame = 4u * 1024u * 1024u; // GPU upload budget per UI frame
|
||||
static constexpr int kAvatarMaxFrames = 300; // cap frames per animated avatar (bounds VRAM/decode)
|
||||
static constexpr size_t kAvatarCacheBudgetBytes = 128u * 1024u * 1024u; // GPU bytes before LRU eviction
|
||||
|
||||
static const AvatarTex* getAvatarTexture(const std::string& path) {
|
||||
auto it = s_avatarTexCache.find(path);
|
||||
if (it != s_avatarTexCache.end()) return it->second.frames.empty() ? nullptr : &it->second;
|
||||
if (s_avatarLoadsThisFrame >= kAvatarLoadsPerFrame) return nullptr; // defer to a later frame
|
||||
s_avatarLoadsThisFrame++;
|
||||
AvatarTex at;
|
||||
util::AnimFrames af;
|
||||
if (util::LoadAnimatedRGBA(path.c_str(), kAvatarMaxFrames, af) && !af.frames.empty()) {
|
||||
at.w = af.w; at.h = 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)) {
|
||||
at.frames.push_back(t);
|
||||
at.delays.push_back(i < af.delaysSec.size() ? af.delaysSec[i] : 0.0f);
|
||||
}
|
||||
// Collect a finished decode and upload its frames within the per-frame byte budget. Never blocks. Keeps
|
||||
// the idle render loop polling (RequestProgressFrame, ~10 fps) while a decode or an upload is outstanding.
|
||||
static void advanceAvatarLoad(AvatarTex& at) {
|
||||
if (at.job) {
|
||||
if (!at.job->finished()) { material::RequestProgressFrame(); return; }
|
||||
if (at.job->ok() && !at.job->result.frames.empty()) {
|
||||
at.pending = std::move(at.job->result);
|
||||
at.w = at.pending.w; at.h = at.pending.h;
|
||||
} else {
|
||||
at.failed = true;
|
||||
}
|
||||
for (float d : at.delays) at.totalDur += d;
|
||||
at.job.reset();
|
||||
}
|
||||
auto& slot = (s_avatarTexCache[path] = std::move(at));
|
||||
return slot.frames.empty() ? nullptr : &slot;
|
||||
if (at.failed || at.complete) return;
|
||||
while (at.uploaded < at.pending.frames.size() && s_avatarUploadBytesThisFrame < kAvatarUploadBytesPerFrame) {
|
||||
const std::vector<unsigned char>& px = at.pending.frames[at.uploaded];
|
||||
ImTextureID t = 0;
|
||||
if (util::CreateRawTexture(px.data(), at.w, at.h, false, &t)) {
|
||||
at.frames.push_back(t);
|
||||
at.delays.push_back(at.uploaded < at.pending.delaysSec.size() ? at.pending.delaysSec[at.uploaded] : 0.0f);
|
||||
}
|
||||
s_avatarUploadBytesThisFrame += px.size();
|
||||
++at.uploaded;
|
||||
}
|
||||
if (at.uploaded >= at.pending.frames.size()) {
|
||||
for (float d : at.delays) at.totalDur += d;
|
||||
at.pending = util::AnimFrames{}; // release the CPU copies
|
||||
at.complete = true;
|
||||
} else {
|
||||
material::RequestProgressFrame(); // more frames to upload on the next frame
|
||||
}
|
||||
}
|
||||
|
||||
// `visible` is false for an avatar scrolled out of its list/table viewport: a cached or in-flight image is
|
||||
// still advanced and returned, but nothing new is decoded for an avatar nobody can see, and an off-screen
|
||||
// draw does not count as a use for the LRU.
|
||||
static const AvatarTex* getAvatarTexture(const std::string& path, bool visible = true) {
|
||||
auto it = s_avatarTexCache.find(path);
|
||||
if (it == s_avatarTexCache.end()) {
|
||||
if (!visible) return nullptr;
|
||||
// ImageLimits defaults = the old sizes: still 256 px, animation 128 px.
|
||||
util::ImageDecodeJobPtr job =
|
||||
util::StartImageDecode(s_avatarDecodeLane, path, kAvatarMaxFrames, util::ImageLimits{});
|
||||
if (!job) { material::RequestProgressFrame(); return nullptr; } // both slots busy: badge now, retry
|
||||
AvatarTex fresh;
|
||||
fresh.job = std::move(job);
|
||||
it = s_avatarTexCache.emplace(path, std::move(fresh)).first;
|
||||
}
|
||||
AvatarTex& at = it->second;
|
||||
if (visible) at.lastUse = static_cast<uint64_t>(ImGui::GetFrameCount());
|
||||
advanceAvatarLoad(at);
|
||||
return at.frames.empty() ? nullptr : &at;
|
||||
}
|
||||
|
||||
// The texture to draw for `t` right now: a still's single frame, or — when the animate-avatars setting
|
||||
@@ -118,7 +165,7 @@ static const AvatarTex* getAvatarTexture(const std::string& path) {
|
||||
// culled animated avatar can't pin the app awake.
|
||||
static ImTextureID currentAvatarFrame(const AvatarTex* t, bool onScreen = true) {
|
||||
if (!t || t->frames.empty()) return 0;
|
||||
if (onScreen && t->frames.size() > 1 && s_animateAvatars && t->totalDur > 0.0f) {
|
||||
if (onScreen && t->complete && t->frames.size() > 1 && s_animateAvatars && t->totalDur > 0.0f) {
|
||||
s_avatarAnimatedThisFrame = true;
|
||||
double phase = std::fmod(ImGui::GetTime(), (double)t->totalDur);
|
||||
double acc = 0.0;
|
||||
@@ -130,14 +177,37 @@ static ImTextureID currentAvatarFrame(const AvatarTex* t, bool onScreen = true)
|
||||
return t->frames[0];
|
||||
}
|
||||
|
||||
// Drop a cached avatar's textures (all frames) so the next getAvatarTexture reloads from disk.
|
||||
// Drop a cached avatar's textures (all frames) so the next getAvatarTexture reloads from disk. A decode
|
||||
// still running is orphaned: its worker stops at the next frame boundary and frees its own result.
|
||||
static void invalidateAvatarTexture(const std::string& path) {
|
||||
auto it = s_avatarTexCache.find(path);
|
||||
if (it == s_avatarTexCache.end()) return;
|
||||
if (it->second.job) it->second.job->cancel.store(true, std::memory_order_relaxed);
|
||||
for (ImTextureID t : it->second.frames) if (t) util::DestroyTexture(t);
|
||||
s_avatarTexCache.erase(it);
|
||||
}
|
||||
|
||||
// LRU eviction, run at the TOP of RenderContactsTab (nothing drawn yet this frame, so no evicted texture is
|
||||
// referenced by this frame's draw lists). Avatars drawn on-screen by the previous Contacts frame have
|
||||
// lastUse >= protectFromFrame and are never evicted; evicted ones re-decode when they scroll back in.
|
||||
static void evictAvatarCache(uint64_t protectFromFrame) {
|
||||
size_t total = 0;
|
||||
for (const auto& kv : s_avatarTexCache)
|
||||
total += (size_t)kv.second.w * (size_t)kv.second.h * 4u * kv.second.frames.size();
|
||||
if (total <= kAvatarCacheBudgetBytes) return;
|
||||
std::vector<util::CacheItem> items;
|
||||
std::vector<std::string> keys;
|
||||
items.reserve(s_avatarTexCache.size());
|
||||
keys.reserve(s_avatarTexCache.size());
|
||||
for (const auto& kv : s_avatarTexCache) {
|
||||
const AvatarTex& a = kv.second;
|
||||
items.push_back({keys.size(), (size_t)a.w * (size_t)a.h * 4u * a.frames.size(), a.lastUse});
|
||||
keys.push_back(kv.first);
|
||||
}
|
||||
for (size_t idx : util::selectEvictions(std::move(items), kAvatarCacheBudgetBytes, protectFromFrame))
|
||||
invalidateAvatarTexture(keys[idx]);
|
||||
}
|
||||
|
||||
// Draw a contact's avatar: a custom image, a Material icon in a tinted shape, or the Z/T type badge.
|
||||
// `shape` picks the outline: 0 = circle (centered at `c`, radius `r`); 1 = rounded square (same box as
|
||||
// the circle, all corners rounded); 2 = a full-row-height tab spanning [tabMin, tabMax] with rounded
|
||||
@@ -169,7 +239,7 @@ static void drawContactAvatar(ImDrawList* dl, ImVec2 c, float r, const data::Add
|
||||
};
|
||||
const std::string& av = e.avatar;
|
||||
if (av.rfind("img:", 0) == 0) {
|
||||
const AvatarTex* t = getAvatarTexture(av.substr(4));
|
||||
const AvatarTex* t = getAvatarTexture(av.substr(4), onScreen);
|
||||
ImTextureID tex = currentAvatarFrame(t, onScreen);
|
||||
if (tex) {
|
||||
const float aspect = (sMax.x - sMin.x) / std::max(1.0f, sMax.y - sMin.y); // crop to the shape's aspect
|
||||
@@ -237,15 +307,20 @@ static ImU32 contactTypeColor(bool shielded, bool light) {
|
||||
return ImGui::ColorConvertFloat4ToU32(c);
|
||||
}
|
||||
|
||||
// Copy a chosen avatar image into <config>/contact-avatars/, returning the destination absolute path
|
||||
// Copy a chosen avatar image into <configDir>/contact-avatars/, returning the destination absolute path
|
||||
// (empty on failure). Named by the source stem + an 8-hex FNV hash of the full source path, so
|
||||
// re-picking the same file is idempotent and two different files never clobber each other.
|
||||
static std::string copyAvatarImage(const std::string& srcPath) {
|
||||
// Runs on a decode worker: pure std::filesystem, no UI state, config dir passed in (resolved on the UI
|
||||
// thread). Copies to a per-job "<dst>.part.<n>" and renames it over the final name, so a crash mid-copy never
|
||||
// leaves a truncated avatar (".part.<n>" is not an image name; the library scan ignores it) and two imports of
|
||||
// the same source at once never share (and steal) one temp file.
|
||||
static std::string copyAvatarImage(const std::string& srcPath, const std::string& configDir) {
|
||||
static std::atomic<unsigned> s_partSeq{0};
|
||||
namespace fs = std::filesystem;
|
||||
std::error_code ec;
|
||||
fs::path src(srcPath);
|
||||
if (!fs::is_regular_file(src, ec)) return "";
|
||||
fs::path dir = fs::path(util::Platform::getConfigDir()) / "contact-avatars";
|
||||
fs::path dir = fs::path(configDir) / "contact-avatars";
|
||||
fs::create_directories(dir, ec);
|
||||
uint64_t h = 1469598103934665603ull; // FNV-1a
|
||||
for (unsigned char c : srcPath) { h ^= c; h *= 1099511628211ull; }
|
||||
@@ -254,8 +329,22 @@ static std::string copyAvatarImage(const std::string& srcPath) {
|
||||
std::string stem = src.stem().string();
|
||||
if (stem.size() > 40) stem.resize(40);
|
||||
fs::path dst = dir / (stem + "-" + suffix + src.extension().string());
|
||||
fs::copy_file(src, dst, fs::copy_options::overwrite_existing, ec);
|
||||
return ec ? std::string() : dst.string();
|
||||
fs::path tmp = dst;
|
||||
tmp += ".part." + std::to_string(s_partSeq.fetch_add(1, std::memory_order_relaxed));
|
||||
fs::copy_file(src, tmp, fs::copy_options::overwrite_existing, ec);
|
||||
if (!ec) {
|
||||
// Atomic replace (POSIX rename / MoveFileExW REPLACE_EXISTING): a failure is a transient sharing/access
|
||||
// error (antivirus, indexer, a handle a spawned child inherited). Retry briefly (we are on a worker) and
|
||||
// NEVER delete dst: the existing library image, and every contact using it, survives a failed import.
|
||||
int delayMs = 20;
|
||||
for (int attempt = 0; attempt < 5; ++attempt) {
|
||||
fs::rename(tmp, dst, ec);
|
||||
if (!ec) break;
|
||||
if (attempt < 4) { std::this_thread::sleep_for(std::chrono::milliseconds(delayMs)); delayMs *= 2; }
|
||||
}
|
||||
}
|
||||
if (ec) { std::error_code rec; fs::remove(tmp, rec); return std::string(); }
|
||||
return dst.string();
|
||||
}
|
||||
|
||||
// Avatar image LIBRARY: the images the user has added live in <config>/contact-avatars/ and persist as
|
||||
@@ -305,6 +394,54 @@ static void deleteAvatarLibraryFile(const std::string& path) {
|
||||
s_avatarLibraryDirty = true;
|
||||
}
|
||||
|
||||
// Avatar imports ("Add image" in the edit dialog). A worker validates the source (header probe + limits +
|
||||
// first-frame decode: the same "does it decode" gate as before) and copies it into the library; the UI
|
||||
// thread applies the result in pumpAvatarImports() at the top of the next Contacts frame. A result only
|
||||
// lands in the dialog session that started it (s_editSession), so a late import can never set another
|
||||
// contact's avatar, and Save/Add waits until this session's import has been applied.
|
||||
struct AvatarImport { util::ImageDecodeJobPtr job; uint64_t session = 0; };
|
||||
static std::vector<AvatarImport> s_avatarImports;
|
||||
static util::ImageDecodeLane s_avatarImportLane(8);
|
||||
static uint64_t s_editSession = 0; // bumped whenever the add/edit dialog is (re)opened
|
||||
|
||||
static void startAvatarImport(const std::string& src) {
|
||||
const std::string cfg = util::Platform::getConfigDir(); // resolved here, on the UI thread
|
||||
util::ImageDecodeJobPtr job = util::StartImageDecode(
|
||||
s_avatarImportLane, src, /*maxFrames=*/1, util::ImageLimits{},
|
||||
[cfg](const std::string& p) { return copyAvatarImage(p, cfg); }); // worker; "" => PostFailed
|
||||
if (!job) { Notifications::instance().error(TR("contact_avatar_copy_failed")); return; }
|
||||
s_avatarImports.push_back({std::move(job), s_editSession});
|
||||
material::RequestProgressFrame();
|
||||
}
|
||||
|
||||
// True while an import started by the CURRENT dialog session has not been applied yet (finished or not:
|
||||
// a job can finish after this frame's pump, and Save must not run before its result lands).
|
||||
static bool avatarImportPending() {
|
||||
for (const AvatarImport& imp : s_avatarImports)
|
||||
if (imp.session == s_editSession) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
static void pumpAvatarImports() {
|
||||
for (size_t i = 0; i < s_avatarImports.size();) {
|
||||
if (!s_avatarImports[i].job->finished()) { material::RequestProgressFrame(); ++i; continue; }
|
||||
const AvatarImport imp = s_avatarImports[i];
|
||||
s_avatarImports.erase(s_avatarImports.begin() + (std::ptrdiff_t)i);
|
||||
const util::ImageDecodeJob& job = *imp.job;
|
||||
if (job.ok() && !job.outPath.empty()) {
|
||||
invalidateAvatarTexture(job.outPath); // re-picked file may have replaced an old copy
|
||||
s_avatarLibraryDirty = true; // surface the new image in the grid
|
||||
if ((s_show_edit_dialog || s_show_add_dialog) && imp.session == s_editSession) {
|
||||
s_edit_avatar = "img:" + job.outPath; s_edit_avatar_mode = 2;
|
||||
}
|
||||
} else if (job.status == util::ImageStatus::PostFailed) {
|
||||
Notifications::instance().error(TR("contact_avatar_copy_failed"));
|
||||
} else {
|
||||
Notifications::instance().error(TR("contact_avatar_bad_image"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -318,7 +455,12 @@ bool ConsumeContactsAvatarAnimation() {
|
||||
|
||||
void RenderContactsTab(App* app)
|
||||
{
|
||||
s_avatarLoadsThisFrame = 0; // reset the per-frame avatar-decode budget (see getAvatarTexture)
|
||||
// Avatar upkeep before anything is drawn this frame: reset the GPU upload budget, apply finished
|
||||
// imports, and LRU-evict while protecting what the previous Contacts frame drew on-screen.
|
||||
s_avatarUploadBytesThisFrame = 0;
|
||||
pumpAvatarImports();
|
||||
evictAvatarCache(s_avatarPrevFrame);
|
||||
s_avatarPrevFrame = static_cast<uint64_t>(ImGui::GetFrameCount());
|
||||
s_animateAvatars = !app->settings() || app->settings()->getAnimateAvatars();
|
||||
s_contactsShape = app->settings() ? app->settings()->getContactsAvatarShape() : 0;
|
||||
s_contactsScale = app->settings() ? app->settings()->getContactsListScale() : 1.0f;
|
||||
@@ -351,6 +493,7 @@ void RenderContactsTab(App* app)
|
||||
s_edit_avatar_mode = 0;
|
||||
s_edit_icon_search[0] = '\0';
|
||||
s_avatarLibraryDirty = true; // rescan the image library on open (files may have changed)
|
||||
++s_editSession; // a pending avatar import from an earlier dialog must not land here
|
||||
};
|
||||
|
||||
auto loadEditFields = [](const data::AddressBookEntry& entry) {
|
||||
@@ -363,6 +506,7 @@ void RenderContactsTab(App* app)
|
||||
: (entry.avatar.rfind("img:", 0) == 0) ? 2 : 0;
|
||||
s_edit_icon_search[0] = '\0';
|
||||
s_avatarLibraryDirty = true; // rescan the image library on open (files may have changed)
|
||||
++s_editSession; // a pending avatar import from an earlier dialog must not land here
|
||||
};
|
||||
|
||||
bool has_selection = s_selected_index >= 0 && s_selected_index < static_cast<int>(book.size());
|
||||
@@ -727,17 +871,9 @@ void RenderContactsTab(App* app)
|
||||
ImGui::PushID(0);
|
||||
if (ImGui::InvisibleButton("##avadd", ImVec2(cell, cell))) {
|
||||
ImagePicker::open("", [](const std::string& src) {
|
||||
// Verify the source decodes before committing (guards corrupt/unsupported files).
|
||||
int iw = 0, ih = 0;
|
||||
unsigned char* px = util::LoadRawPixelsFromFile(src.c_str(), &iw, &ih);
|
||||
if (!px) { Notifications::instance().error(TR("contact_avatar_bad_image")); return; }
|
||||
util::FreeRawPixels(px);
|
||||
std::string dst = copyAvatarImage(src);
|
||||
if (!dst.empty()) {
|
||||
invalidateAvatarTexture(dst);
|
||||
s_edit_avatar = "img:" + dst; s_edit_avatar_mode = 2;
|
||||
s_avatarLibraryDirty = true; // surface the new image in the grid
|
||||
} else Notifications::instance().error(TR("contact_avatar_copy_failed"));
|
||||
// Validate (decode) + copy on a worker; pumpAvatarImports() applies the
|
||||
// result (or the same error toasts) at the top of a later frame.
|
||||
startAvatarImport(src);
|
||||
});
|
||||
}
|
||||
if (ImGui::IsItemHovered()) { ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); material::Tooltip("%s", TR("contact_avatar_choose")); }
|
||||
@@ -812,7 +948,10 @@ void RenderContactsTab(App* app)
|
||||
ImGui::EndChild(); // ##contactAvatarCol
|
||||
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||
bool canSubmit = std::strlen(s_edit_label) > 0 && std::strlen(s_edit_address) > 0;
|
||||
// An image picked a moment ago is still being validated/copied: wait for it (typically < 0.5 s)
|
||||
// so the contact is never saved without the avatar the user just chose.
|
||||
bool canSubmit = std::strlen(s_edit_label) > 0 && std::strlen(s_edit_address) > 0
|
||||
&& !avatarImportPending();
|
||||
float actionW = std::max(110.0f * dp,
|
||||
btnFont->CalcTextSizeA(btnFont->LegacySize, FLT_MAX, 0, TR("save")).x
|
||||
+ ImGui::GetStyle().FramePadding.x * 2.0f + 28.0f * dp);
|
||||
@@ -1481,6 +1620,7 @@ void ContactsSweepOpenEditDialog(int avatarMode)
|
||||
s_edit_avatar_mode = (avatarMode < 0 || avatarMode > 2) ? 0 : avatarMode;
|
||||
s_edit_avatar = (s_edit_avatar_mode == 1) ? "icon:account_balance" : "";
|
||||
s_edit_icon_search[0] = '\0';
|
||||
++s_editSession;
|
||||
s_show_add_dialog = false;
|
||||
s_show_edit_dialog = true;
|
||||
s_focus_edit_field = false;
|
||||
|
||||
Reference in New Issue
Block a user