From 2249bc31d5421f63ae1e0e037263e22bd8bf4316 Mon Sep 17 00:00:00 2001 From: DanS Date: Wed, 15 Jul 2026 06:49:52 -0500 Subject: [PATCH] =?UTF-8?q?feat(contacts):=20contact=20avatars=20=E2=80=94?= =?UTF-8?q?=20custom=20image=20/=20Material=20icon=20/=20Z-T=20badge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an `avatar` field to AddressBookEntry ("" = default Z/T type badge, "icon:" = a Material wallet-icon, "img:" = a custom image), serialized additively in addressbook.json (only written when non-empty, so existing books are untouched). Render it in the Cards/List views via a new drawContactAvatar helper: custom images are loaded once through a path-keyed texture cache and drawn circular-cropped (centre-cropped UVs + a thin border ring); icons reuse the project-icon set (incl. the special pickaxe font path) in a tinted circle; everything else falls back to the existing Z/T badge (also the fallback when an image fails to load or an icon name is unknown). Seed one sweep contact with an icon avatar to exercise the icon-badge path. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/app_sweep.cpp | 1 + src/data/address_book.cpp | 2 ++ src/data/address_book.h | 3 ++ src/ui/windows/contacts_tab.cpp | 60 +++++++++++++++++++++++++++++---- 4 files changed, 60 insertions(+), 6 deletions(-) diff --git a/src/app_sweep.cpp b/src/app_sweep.cpp index fb182bc..deccd0b 100644 --- a/src/app_sweep.cpp +++ b/src/app_sweep.cpp @@ -100,6 +100,7 @@ void seedSweepContacts(App& a) { s_contactsSweepBackup = a.addressBook().entries(); data::AddressBookEntry e1("drgx pool payout address", kDemoZAddr, "mining pool payouts"); // Z, global + e1.avatar = "icon:account_balance"; // icon avatar (verifies the icon-badge render path) data::AddressBookEntry e2("exchange deposit", "t1DemoTransparentAddressForUiSweep00000", ""); // T // Scope to the active wallet so it's visible (the tab hides contacts not in the active wallet); // non-empty hash also keeps it non-global -> no globe badge, so the list shows a global/non-global mix. diff --git a/src/data/address_book.cpp b/src/data/address_book.cpp index 2246c1c..b386632 100644 --- a/src/data/address_book.cpp +++ b/src/data/address_book.cpp @@ -54,6 +54,7 @@ bool AddressBook::load() // Legacy entries (no "scope") migrate to "global" so nothing disappears when // multi-wallet scoping lands — a contact you already had stays visible everywhere. e.scope = entry.value("scope", "global"); + e.avatar = entry.value("avatar", ""); if (!e.address.empty()) { entries_.push_back(e); @@ -86,6 +87,7 @@ bool AddressBook::save() e["address"] = entry.address; e["notes"] = entry.notes; e["scope"] = entry.scope.empty() ? std::string("global") : entry.scope; + if (!entry.avatar.empty()) e["avatar"] = entry.avatar; j["entries"].push_back(e); } diff --git a/src/data/address_book.h b/src/data/address_book.h index afaab5e..498a19a 100644 --- a/src/data/address_book.h +++ b/src/data/address_book.h @@ -21,6 +21,9 @@ struct AddressBookEntry { // (shown only when that wallet is the active one). Empty is treated as "global" so legacy // entries — written before multi-wallet scoping — keep showing everywhere. std::string scope = "global"; + // Contact avatar: "" = default type badge (Z/T), "icon:" = a Material wallet-icon, + // "img:" = a custom image (copied into /contact-avatars/). + std::string avatar; AddressBookEntry() = default; AddressBookEntry(const std::string& l, const std::string& a, const std::string& n = "", diff --git a/src/ui/windows/contacts_tab.cpp b/src/ui/windows/contacts_tab.cpp index 33894ac..bd2ca3e 100644 --- a/src/ui/windows/contacts_tab.cpp +++ b/src/ui/windows/contacts_tab.cpp @@ -12,8 +12,11 @@ #include "../schema/ui_schema.h" #include "../material/draw_helpers.h" #include "../material/type.h" +#include "../material/project_icons.h" +#include "../../util/texture_loader.h" #include "../layout.h" #include "imgui.h" +#include #include #include #include @@ -56,6 +59,55 @@ static bool matchesSearch(const data::AddressBookEntry& e, const std::string& ne || toLower(e.notes).find(needleLower) != std::string::npos; } +// Lazily-loaded, path-keyed cache of custom avatar image textures (loaded once, kept for the session). +struct AvatarTex { ImTextureID tex = 0; int w = 0, h = 0; }; +static std::unordered_map s_avatarTexCache; +static const AvatarTex* getAvatarTexture(const std::string& path) { + auto it = s_avatarTexCache.find(path); + if (it != s_avatarTexCache.end()) return it->second.tex ? &it->second : nullptr; + AvatarTex at; + util::LoadTextureFromFile(path.c_str(), &at.tex, &at.w, &at.h); // tex stays 0 on failure + auto& slot = (s_avatarTexCache[path] = at); + return slot.tex ? &slot : nullptr; +} + +// Draw a contact's avatar into the circle at `c` (radius `r`): a custom image (circular-cropped), a +// Material icon in a tinted circle, or — the default — the Z/T type badge. +static void drawContactAvatar(ImDrawList* dl, ImVec2 c, float r, const data::AddressBookEntry& e, + bool shielded, ImU32 typeCol, bool light, float dp, + ImFont* letterFont, ImFont* iconFont) { + const std::string& av = e.avatar; + if (av.rfind("img:", 0) == 0) { + if (const AvatarTex* t = getAvatarTexture(av.substr(4))) { + float u0 = 0, v0 = 0, u1 = 1, v1 = 1; // centre-crop to a square so the circle isn't stretched + 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; } + dl->AddImageRounded(t->tex, ImVec2(c.x - r, c.y - r), ImVec2(c.x + r, c.y + r), + ImVec2(u0, v0), ImVec2(u1, v1), IM_COL32_WHITE, r); + dl->AddCircle(c, r, material::WithAlpha(material::OnSurface(), 45), 0, 1.0f * dp); + return; + } + // fall through to the type badge if the image failed to load + } else if (av.rfind("icon:", 0) == 0) { + const std::string iconName = av.substr(5); + const bool known = (iconName == material::project_icons::kPickaxeName) || + (material::project_icons::glyphForName(iconName) != nullptr); + if (known) { + dl->AddCircleFilled(c, r, material::WithAlpha(typeCol, light ? 45 : 60)); + dl->AddCircle(c, r, material::WithAlpha(typeCol, 190), 0, 1.4f * dp); + material::project_icons::drawByName(dl, iconName, c, typeCol, iconFont, iconFont->LegacySize); + return; + } + // fall through to the type badge if the icon name is unknown + } + // Default: Z/T type badge. + dl->AddCircleFilled(c, r, material::WithAlpha(typeCol, light ? 45 : 60)); + dl->AddCircle(c, r, material::WithAlpha(typeCol, 190), 0, 1.4f * dp); + const char* letter = shielded ? "Z" : "T"; + const ImVec2 ls = letterFont->CalcTextSizeA(letterFont->LegacySize, FLT_MAX, 0, letter); + dl->AddText(letterFont, letterFont->LegacySize, ImVec2(c.x - ls.x * 0.5f, c.y - ls.y * 0.5f), typeCol, letter); +} + static bool isShieldedAddr(const std::string& a) { return !a.empty() && a[0] == 'z'; } @@ -484,15 +536,11 @@ void RenderContactsTab(App* app) dl->AddLine(ImVec2(mn.x + pad, mx.y - 0.5f), ImVec2(mx.x - pad, mx.y - 0.5f), material::WithAlpha(material::OnSurface(), 24), 1.0f); } - // Circular type avatar with the Z/T letter. + // Avatar: custom image (circular) / Material icon / default Z/T type badge. const bool shielded = isShieldedAddr(entry.address); const ImU32 tu = typeColor(shielded); const ImVec2 avC(mn.x + pad + avR, mn.y + rowH * 0.5f); - dl->AddCircleFilled(avC, avR, material::WithAlpha(tu, lightTheme ? 45 : 60)); - dl->AddCircle(avC, avR, material::WithAlpha(tu, 190), 0, 1.4f * dp); - const char* letter = shielded ? "Z" : "T"; - const ImVec2 ls = lblF->CalcTextSizeA(lblF->LegacySize, FLT_MAX, 0, letter); - dl->AddText(lblF, lblF->LegacySize, ImVec2(avC.x - ls.x * 0.5f, avC.y - ls.y * 0.5f), tu, letter); + drawContactAvatar(dl, avC, avR, entry, shielded, tu, lightTheme, dp, lblF, icoF); // Label (line 1) + muted address (line 2) — reserve trailing room for the globe + actions. const float actHit = icoF->LegacySize + 8.0f * dp; // per-action square hit area const float globeW = entry.isGlobal() ? (icoF->LegacySize + 8.0f * dp) : 0.0f;