feat(contacts): richer edit-dialog avatar picker (preview, chips, image, icons)
Four refinements now that the two-column layout has room: 1. Image mode shows a large circular preview of the chosen picture (or a placeholder circle with an add-photo glyph, or a broken-image glyph if the file went missing), the filename, and centered Choose/Remove buttons — instead of a bare button. 2. The live-preview avatar is larger (r20 → r26) and the address is now middle-truncated (head + tail) so both ends read, like the real list row. 3. Badge mode shows the two actual chips — Z (shielded) and T (transparent) — with labels, making clear the badge is auto-picked from the address type, rather than a line of text. 4. The Badge/Icon/Image segmented control gains glyphs (badge / palette / image) beside its labels via a small two-font inline control (the shared SegmentedControl helper is single-font). Adds contact_avatar_shielded / _transparent keys and rewords the badge hint (the chips now carry the Z/T meaning); +8-language translations, all within the existing CJK subset. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -248,16 +248,30 @@ void RenderContactsTab(App* app)
|
||||
if (t) s += "\xE2\x80\xA6";
|
||||
return s;
|
||||
};
|
||||
// Middle-truncate to fit maxW, keeping both ends — an address reads best head + tail
|
||||
// (addresses are ASCII base58/bech32, so byte-wise trimming is safe).
|
||||
auto fitMiddle = [&](std::string s, ImFont* f, float maxW) {
|
||||
auto w = [&](const std::string& t){ return f->CalcTextSizeA(f->LegacySize, FLT_MAX, 0, t.c_str()).x; };
|
||||
if (w(s) <= maxW || s.size() <= 8) return s;
|
||||
const std::string ell = "\xE2\x80\xA6";
|
||||
size_t head = s.size() / 2, tail = s.size() - head;
|
||||
while (head + tail > 6) {
|
||||
std::string cand = s.substr(0, head) + ell + s.substr(s.size() - tail);
|
||||
if (w(cand) <= maxW) return cand;
|
||||
if (head >= tail) --head; else --tail;
|
||||
}
|
||||
return s.substr(0, 3) + ell + s.substr(s.size() - 3);
|
||||
};
|
||||
|
||||
// ---- Live preview: the contact card as it will appear in the list --------------------
|
||||
{
|
||||
ImDrawList* pdl = ImGui::GetWindowDrawList();
|
||||
ImFont* nameF = material::Type().subtitle1();
|
||||
ImFont* addrF = material::Type().caption();
|
||||
ImFont* letF = material::Type().subtitle2();
|
||||
ImFont* icoF = material::Type().iconSmall();
|
||||
ImFont* letF = material::Type().subtitle1();
|
||||
ImFont* icoF = material::Type().iconMed();
|
||||
const float pad = Layout::spacingMd();
|
||||
const float avR = 20.0f * dp;
|
||||
const float avR = 26.0f * dp;
|
||||
const float blockH = nameF->LegacySize + Layout::spacingXs() + addrF->LegacySize;
|
||||
const float ph = pad * 2.0f + std::max(avR * 2.0f, blockH);
|
||||
const float pw = ImGui::GetContentRegionAvail().x;
|
||||
@@ -282,8 +296,8 @@ void RenderContactsTab(App* app)
|
||||
pdl->AddText(nameF, nameF->LegacySize, ImVec2(tx, ty),
|
||||
hasName ? material::OnSurface() : material::OnSurfaceDisabled(), nm.c_str());
|
||||
const bool hasAddr = s_edit_address[0] != '\0';
|
||||
std::string ad = fitText(hasAddr ? std::string(s_edit_address) : std::string(TR("contact_preview_addr")),
|
||||
addrF, textRight - tx);
|
||||
std::string ad = hasAddr ? fitMiddle(std::string(s_edit_address), addrF, textRight - tx)
|
||||
: fitText(std::string(TR("contact_preview_addr")), addrF, textRight - tx);
|
||||
pdl->AddText(addrF, addrF->LegacySize, ImVec2(tx, ty + nameF->LegacySize + Layout::spacingXs()),
|
||||
hasAddr ? material::OnSurfaceMedium() : material::OnSurfaceDisabled(), ad.c_str());
|
||||
ImGui::Dummy(ImVec2(pw, ph));
|
||||
@@ -342,13 +356,43 @@ void RenderContactsTab(App* app)
|
||||
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
|
||||
material::Type().textColored(material::TypeStyle::Caption, material::OnSurfaceMedium(), TR("contact_avatar"));
|
||||
{
|
||||
const char* segs[3] = { TR("contact_avatar_badge"), TR("contact_avatar_icon"),
|
||||
TR("contact_avatar_image") };
|
||||
float segH = 30.0f * dp;
|
||||
// Custom icon + label segmented control (the shared helper is single-font; avatar modes
|
||||
// read clearer with a glyph). Track + active pill mirror material::SegmentedControl.
|
||||
const char* segLbl[3] = { TR("contact_avatar_badge"), TR("contact_avatar_icon"),
|
||||
TR("contact_avatar_image") };
|
||||
const char* segIco[3] = { ICON_MD_BADGE, ICON_MD_PALETTE, ICON_MD_IMAGE };
|
||||
ImFont* segIcoF = material::Type().iconSmall();
|
||||
float segH = 34.0f * dp;
|
||||
float segW = ImGui::GetContentRegionAvail().x;
|
||||
float cellW = segW / 3.0f;
|
||||
ImVec2 sMin = ImGui::GetCursorScreenPos();
|
||||
int clk = material::SegmentedControl(ImGui::GetWindowDrawList(), sMin, segW, segH,
|
||||
segs, 3, s_edit_avatar_mode, btnFont, "##avseg", dp);
|
||||
ImDrawList* sdl = ImGui::GetWindowDrawList();
|
||||
sdl->AddRectFilled(sMin, ImVec2(sMin.x + segW, sMin.y + segH),
|
||||
material::WithAlpha(material::OnSurface(), 20), segH * 0.5f);
|
||||
int clk = -1;
|
||||
for (int i = 0; i < 3; i++) {
|
||||
ImVec2 cMin(sMin.x + i * cellW, sMin.y), cMax(cMin.x + cellW, sMin.y + segH);
|
||||
bool active = (s_edit_avatar_mode == i);
|
||||
bool hov = ImGui::IsMouseHoveringRect(cMin, cMax);
|
||||
if (active)
|
||||
sdl->AddRectFilled(ImVec2(cMin.x + 2.0f * dp, cMin.y + 2.0f * dp),
|
||||
ImVec2(cMax.x - 2.0f * dp, cMax.y - 2.0f * dp),
|
||||
material::WithAlpha(material::Primary(), 210), (segH - 4.0f * dp) * 0.5f);
|
||||
ImU32 fg = active ? IM_COL32(255, 255, 255, 255) : (hov ? material::OnSurface() : material::OnSurfaceMedium());
|
||||
float igW = segIcoF->CalcTextSizeA(segIcoF->LegacySize, FLT_MAX, 0, segIco[i]).x;
|
||||
float lbW = btnFont->CalcTextSizeA(btnFont->LegacySize, FLT_MAX, 0, segLbl[i]).x;
|
||||
float gapI = 5.0f * dp;
|
||||
float startX = cMin.x + (cellW - (igW + gapI + lbW)) * 0.5f;
|
||||
sdl->AddText(segIcoF, segIcoF->LegacySize,
|
||||
ImVec2(startX, cMin.y + (segH - segIcoF->LegacySize) * 0.5f), fg, segIco[i]);
|
||||
sdl->AddText(btnFont, btnFont->LegacySize,
|
||||
ImVec2(startX + igW + gapI, cMin.y + (segH - btnFont->LegacySize) * 0.5f), fg, segLbl[i]);
|
||||
ImGui::PushID(i);
|
||||
ImGui::SetCursorScreenPos(cMin);
|
||||
if (ImGui::InvisibleButton("##avseg", ImVec2(cellW, segH))) clk = i;
|
||||
if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
|
||||
ImGui::PopID();
|
||||
}
|
||||
if (clk >= 0 && clk != s_edit_avatar_mode) {
|
||||
s_edit_avatar_mode = clk;
|
||||
// Switching mode resets the value unless it already matches that mode's prefix.
|
||||
@@ -363,16 +407,42 @@ void RenderContactsTab(App* app)
|
||||
|
||||
// The mode content fills the rest of the avatar column (fixed height → no jump on switch).
|
||||
if (s_edit_avatar_mode == 0) {
|
||||
// Badge: explanatory hint, centered in the remaining space below the segmented control.
|
||||
// Badge: show the two example chips (Z shielded / T transparent) — the badge is picked
|
||||
// automatically from the address type — plus a short caption. Centered in the space below.
|
||||
ImDrawList* bdl = ImGui::GetWindowDrawList();
|
||||
ImFont* letF = material::Type().subtitle1();
|
||||
ImFont* capF = material::Type().caption();
|
||||
ImVec2 a = ImGui::GetContentRegionAvail();
|
||||
ImVec2 origin = ImGui::GetCursorScreenPos();
|
||||
const float chipR = 24.0f * dp;
|
||||
const float chipGap = 40.0f * dp;
|
||||
const float labelY = chipR * 2.0f + Layout::spacingXs() + capF->LegacySize;
|
||||
const char* msg = TR("contact_avatar_badge_hint");
|
||||
ImFont* f = material::Type().caption();
|
||||
ImVec2 a = ImGui::GetContentRegionAvail(); // remaining w,h from the current cursor
|
||||
float wrapW = a.x - 16.0f * dp;
|
||||
ImVec2 ts = f->CalcTextSizeA(f->LegacySize, wrapW, 0, msg);
|
||||
float x0 = ImGui::GetCursorPosX(), y0 = ImGui::GetCursorPosY();
|
||||
ImGui::SetCursorPosX(x0 + std::max(0.0f, (a.x - ts.x) * 0.5f));
|
||||
ImGui::SetCursorPosY(y0 + std::max(0.0f, (a.y - ts.y) * 0.4f));
|
||||
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + std::min(ts.x, wrapW));
|
||||
float wrapW = a.x - 8.0f * dp;
|
||||
ImVec2 ms = capF->CalcTextSizeA(capF->LegacySize, wrapW, 0, msg);
|
||||
const float blockH = labelY + Layout::spacingMd() + ms.y;
|
||||
float cy = origin.y + std::max(0.0f, (a.y - blockH) * 0.42f);
|
||||
float cx = origin.x + a.x * 0.5f;
|
||||
struct Chip { bool sh; const char* lbl; };
|
||||
Chip chips[2] = { { true, TR("contact_avatar_shielded") }, { false, TR("contact_avatar_transparent") } };
|
||||
for (int i = 0; i < 2; i++) {
|
||||
float ccx = cx + (i == 0 ? -(chipR + chipGap * 0.5f) : (chipR + chipGap * 0.5f));
|
||||
ImVec2 cc(ccx, cy + chipR);
|
||||
ImU32 col = contactTypeColor(chips[i].sh, light);
|
||||
bdl->AddCircleFilled(cc, chipR, material::WithAlpha(col, light ? 45 : 60));
|
||||
bdl->AddCircle(cc, chipR, material::WithAlpha(col, 190), 0, 1.6f * dp);
|
||||
const char* L = chips[i].sh ? "Z" : "T";
|
||||
ImVec2 ls = letF->CalcTextSizeA(letF->LegacySize, FLT_MAX, 0, L);
|
||||
bdl->AddText(letF, letF->LegacySize, ImVec2(cc.x - ls.x * 0.5f, cc.y - ls.y * 0.5f), col, L);
|
||||
ImVec2 lb = capF->CalcTextSizeA(capF->LegacySize, FLT_MAX, 0, chips[i].lbl);
|
||||
bdl->AddText(capF, capF->LegacySize,
|
||||
ImVec2(cc.x - lb.x * 0.5f, cy + chipR * 2.0f + Layout::spacingXs()),
|
||||
material::OnSurfaceMedium(), chips[i].lbl);
|
||||
}
|
||||
// Caption below the chips (wrapped, centered).
|
||||
ImGui::SetCursorScreenPos(ImVec2(origin.x + std::max(0.0f, (a.x - std::min(ms.x, wrapW)) * 0.5f),
|
||||
cy + labelY + Layout::spacingMd()));
|
||||
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + std::min(ms.x, wrapW));
|
||||
material::Type().textColored(material::TypeStyle::Caption, material::OnSurfaceDisabled(), msg);
|
||||
ImGui::PopTextWrapPos();
|
||||
} else if (s_edit_avatar_mode == 1) {
|
||||
@@ -424,28 +494,69 @@ void RenderContactsTab(App* app)
|
||||
ImGui::EndChild();
|
||||
ImGui::PopStyleColor();
|
||||
} else {
|
||||
// Image: choose / remove a custom picture (copied into app data on pick).
|
||||
if (s_edit_avatar.rfind("img:", 0) == 0) {
|
||||
std::string path = s_edit_avatar.substr(4);
|
||||
std::string fname = std::filesystem::path(path).filename().string();
|
||||
material::Type().textColored(material::TypeStyle::Body2, material::OnSurface(),
|
||||
fitText(fname, material::Type().body2(), ImGui::GetContentRegionAvail().x).c_str());
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
|
||||
// Image: a big circular preview (or a placeholder), the filename, and choose / remove.
|
||||
const bool hasImg = s_edit_avatar.rfind("img:", 0) == 0;
|
||||
ImDrawList* mdl = ImGui::GetWindowDrawList();
|
||||
ImFont* capF = material::Type().caption();
|
||||
const float prR = 44.0f * dp;
|
||||
ImVec2 a = ImGui::GetContentRegionAvail();
|
||||
ImVec2 origin = ImGui::GetCursorScreenPos();
|
||||
float cx = origin.x + a.x * 0.5f;
|
||||
float cy = origin.y + Layout::spacingSm() + prR;
|
||||
ImVec2 cc(cx, cy);
|
||||
if (hasImg) {
|
||||
if (const AvatarTex* t = getAvatarTexture(s_edit_avatar.substr(4))) {
|
||||
float u0=0,v0=0,u1=1,v1=1;
|
||||
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; }
|
||||
mdl->AddImageRounded(t->tex, ImVec2(cc.x-prR, cc.y-prR), ImVec2(cc.x+prR, cc.y+prR),
|
||||
ImVec2(u0,v0), ImVec2(u1,v1), IM_COL32_WHITE, prR);
|
||||
mdl->AddCircle(cc, prR, material::WithAlpha(material::OnSurface(), 60), 0, 1.5f * dp);
|
||||
} else {
|
||||
// Image set but no longer loadable (moved/deleted) — show a broken-image glyph.
|
||||
mdl->AddCircleFilled(cc, prR, material::WithAlpha(material::ReadableError(), 35));
|
||||
mdl->AddCircle(cc, prR, material::WithAlpha(material::ReadableError(), 150), 0, 1.5f * dp);
|
||||
ImFont* gf = material::Type().iconLarge();
|
||||
ImVec2 gs = gf->CalcTextSizeA(gf->LegacySize, FLT_MAX, 0, ICON_MD_BROKEN_IMAGE);
|
||||
mdl->AddText(gf, gf->LegacySize, ImVec2(cc.x - gs.x*0.5f, cc.y - gs.y*0.5f),
|
||||
material::ReadableError(), ICON_MD_BROKEN_IMAGE);
|
||||
}
|
||||
} else {
|
||||
mdl->AddCircleFilled(cc, prR, material::WithAlpha(material::OnSurface(), 18));
|
||||
mdl->AddCircle(cc, prR, material::WithAlpha(material::OnSurface(), 55), 0, 1.5f * dp);
|
||||
ImFont* gf = material::Type().iconLarge();
|
||||
ImVec2 gs = gf->CalcTextSizeA(gf->LegacySize, FLT_MAX, 0, ICON_MD_ADD_PHOTO_ALTERNATE);
|
||||
mdl->AddText(gf, gf->LegacySize, ImVec2(cc.x - gs.x*0.5f, cc.y - gs.y*0.5f),
|
||||
material::OnSurfaceDisabled(), ICON_MD_ADD_PHOTO_ALTERNATE);
|
||||
}
|
||||
if (material::TactileButton(TR("contact_avatar_choose"), ImVec2(0, 0), btnFont)) {
|
||||
float rowY = cy + prR + Layout::spacingSm();
|
||||
if (hasImg) {
|
||||
std::string fname = std::filesystem::path(s_edit_avatar.substr(4)).filename().string();
|
||||
std::string fit = fitText(fname, capF, a.x - 8.0f * dp);
|
||||
ImVec2 fs = capF->CalcTextSizeA(capF->LegacySize, FLT_MAX, 0, fit.c_str());
|
||||
mdl->AddText(capF, capF->LegacySize, ImVec2(cx - fs.x*0.5f, rowY), material::OnSurfaceMedium(), fit.c_str());
|
||||
rowY += capF->LegacySize + Layout::spacingSm();
|
||||
}
|
||||
// Buttons row, centered.
|
||||
ImGui::SetCursorScreenPos(ImVec2(origin.x, rowY));
|
||||
float chooseW = btnFont->CalcTextSizeA(btnFont->LegacySize, FLT_MAX, 0, TR("contact_avatar_choose")).x
|
||||
+ ImGui::GetStyle().FramePadding.x * 2.0f + 20.0f * dp;
|
||||
float removeW = hasImg ? (btnFont->CalcTextSizeA(btnFont->LegacySize, FLT_MAX, 0, TR("contact_avatar_remove")).x
|
||||
+ ImGui::GetStyle().FramePadding.x * 2.0f + 20.0f * dp) : 0.0f;
|
||||
float rowW = chooseW + (hasImg ? removeW + Layout::spacingSm() : 0.0f);
|
||||
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, (a.x - rowW) * 0.5f));
|
||||
if (material::TactileButton(TR("contact_avatar_choose"), ImVec2(chooseW, 0), btnFont)) {
|
||||
ImagePicker::open("", [](const std::string& src) {
|
||||
std::string dst = copyAvatarImage(src);
|
||||
if (!dst.empty()) { s_edit_avatar = "img:" + dst; s_edit_avatar_mode = 2; }
|
||||
else Notifications::instance().error(TR("contact_avatar_copy_failed"));
|
||||
});
|
||||
}
|
||||
if (s_edit_avatar.rfind("img:", 0) == 0) {
|
||||
ImGui::SameLine();
|
||||
if (material::TactileButton(TR("contact_avatar_remove"), ImVec2(0, 0), btnFont))
|
||||
if (hasImg) {
|
||||
ImGui::SameLine(0, Layout::spacingSm());
|
||||
if (material::TactileButton(TR("contact_avatar_remove"), ImVec2(removeW, 0), btnFont))
|
||||
s_edit_avatar.clear();
|
||||
}
|
||||
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
|
||||
material::Type().textColored(material::TypeStyle::Caption, material::OnSurfaceDisabled(), TR("contact_avatar_image_hint"));
|
||||
}
|
||||
ImGui::EndChild(); // ##contactAvatarCol
|
||||
|
||||
|
||||
Reference in New Issue
Block a user