feat(contacts): contact avatars — custom image / Material icon / Z-T badge

Add an `avatar` field to AddressBookEntry ("" = default Z/T type badge,
"icon:<name>" = a Material wallet-icon, "img:<path>" = 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) <noreply@anthropic.com>
This commit is contained in:
2026-07-15 06:49:52 -05:00
parent 1ce37aa0fa
commit 2249bc31d5
4 changed files with 60 additions and 6 deletions

View File

@@ -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 <unordered_map>
#include <algorithm>
#include <cctype>
#include <cstring>
@@ -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<std::string, AvatarTex> 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;