// DragonX Wallet - ImGui Edition // Copyright 2024-2026 The Hush Developers // Released under the GPLv3 #include "contacts_tab.h" #include "../../app.h" #include "../../config/settings.h" #include "../../data/address_book.h" #include "../../util/i18n.h" #include "../../util/text_format.h" #include "../notifications.h" #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 "../../util/platform.h" #include "image_picker.h" #include "../layout.h" #include "imgui.h" #include #include #include #include #include #include #include #include #include namespace dragonx { namespace ui { // Tab state (formerly AddressBookDialog's class statics). s_selected_index is a // STORAGE index into book.entries() — decoupled from the visible row order so // search + sort can't corrupt edit/delete/copy targets. static int s_selected_index = -1; static bool s_show_add_dialog = false; static bool s_show_edit_dialog = false; static bool s_focus_edit_field = false; // focus the first field the frame the add/edit dialog opens static int s_confirm_delete_idx = -1; // armed storage index; a 2nd Delete confirms static char s_edit_label[128] = ""; static char s_edit_address[512] = ""; static char s_edit_notes[512] = ""; static bool s_edit_global = false; // add/edit dialog: "visible in every wallet" toggle static char s_search[128] = ""; // Avatar being edited: "" = default Z/T badge, "icon:", or "img:". static std::string s_edit_avatar; static int s_edit_avatar_mode = 0; // 0 = badge, 1 = icon, 2 = image (segmented picker) static char s_edit_icon_search[64] = ""; // icon-grid filter in the edit dialog static void copyEditField(char* dest, size_t destSize, const std::string& source) { if (destSize == 0) return; std::strncpy(dest, source.c_str(), destSize - 1); dest[destSize - 1] = '\0'; } static std::string toLower(std::string s) { std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); return s; } static bool matchesSearch(const data::AddressBookEntry& e, const std::string& needleLower) { if (needleLower.empty()) return true; return toLower(e.label).find(needleLower) != std::string::npos || toLower(e.address).find(needleLower) != std::string::npos || toLower(e.notes).find(needleLower) != std::string::npos; } // 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. struct AvatarTex { std::vector frames; // 1 = still, N = animated std::vector delays; // seconds per frame (parallel to frames) float totalDur = 0.0f; int w = 0, h = 0; }; static std::unordered_map s_avatarTexCache; static int s_avatarLoadsThisFrame = 0; static bool s_animateAvatars = true; // mirrors the setting; refreshed each frame in RenderContactsTab static bool s_avatarAnimatedThisFrame = false; // set when a live animated frame is drawn -> keep redrawing static constexpr int kAvatarLoadsPerFrame = 2; static constexpr int kAvatarMaxFrames = 300; // cap frames per animated avatar (bounds VRAM/decode) 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); } } for (float d : at.delays) at.totalDur += d; } auto& slot = (s_avatarTexCache[path] = std::move(at)); return slot.frames.empty() ? nullptr : &slot; } // The texture to draw for `t` right now: a still's single frame, or — when the animate-avatars setting // is on AND the avatar is actually on-screen — the animated frame for the current ImGui clock time (and // flag that an animation is live, so the render loop keeps producing frames instead of idling). Passing // onScreen=false (an avatar scrolled out of a list/table viewport) shows frame 0 and does NOT flag, so a // 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) { s_avatarAnimatedThisFrame = true; double phase = std::fmod(ImGui::GetTime(), (double)t->totalDur); double acc = 0.0; for (size_t i = 0; i < t->delays.size() && i < t->frames.size(); ++i) { acc += t->delays[i]; if (phase < acc) return t->frames[i]; } } return t->frames[0]; } // Drop a cached avatar's textures (all frames) so the next getAvatarTexture reloads from disk. static void invalidateAvatarTexture(const std::string& path) { auto it = s_avatarTexCache.find(path); if (it == s_avatarTexCache.end()) return; for (ImTextureID t : it->second.frames) if (t) util::DestroyTexture(t); s_avatarTexCache.erase(it); } // 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, bool onScreen = true) { const std::string& av = e.avatar; if (av.rfind("img:", 0) == 0) { const AvatarTex* t = getAvatarTexture(av.substr(4)); ImTextureID tex = currentAvatarFrame(t, onScreen); if (tex) { 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(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'; } // Accent colour for a contact's address type (Z = shielded/green, T = transparent/amber), tuned per // theme. File-scope so both the list rows and the edit-dialog preview share one source of truth. static ImU32 contactTypeColor(bool shielded, bool light) { ImVec4 c = shielded ? (light ? ImVec4(0.10f,0.55f,0.38f,1.0f) : ImVec4(0.35f,0.80f,0.60f,1.0f)) : (light ? ImVec4(0.72f,0.48f,0.05f,1.0f) : ImVec4(0.95f,0.72f,0.30f,1.0f)); return ImGui::ColorConvertFloat4ToU32(c); } // Copy a chosen avatar image into /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) { 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::create_directories(dir, ec); uint64_t h = 1469598103934665603ull; // FNV-1a for (unsigned char c : srcPath) { h ^= c; h *= 1099511628211ull; } char suffix[9]; snprintf(suffix, sizeof(suffix), "%08x", (unsigned)(h & 0xffffffffu)); 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(); } // Avatar image LIBRARY: the images the user has added live in /contact-avatars/ and persist as // a reusable, portable set (they travel with the wallet data dir). The Image avatar tab is a grid over // this library; images are only removed when the user explicitly deletes them. static std::vector s_avatarLibrary; // absolute paths, sorted static bool s_avatarLibraryDirty = true; static bool isImageFileName(const std::string& name) { std::string lo = name; for (char& c : lo) c = static_cast(std::tolower((unsigned char)c)); for (const char* e : { ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp", ".tga", ".psd", ".pnm", ".ppm", ".pgm", ".pic" }) { const std::string ext(e); if (lo.size() > ext.size() && lo.compare(lo.size() - ext.size(), ext.size(), ext) == 0) return true; } return false; } static void rescanAvatarLibrary() { s_avatarLibrary.clear(); s_avatarLibraryDirty = false; namespace fs = std::filesystem; std::error_code ec; fs::path dir = fs::path(util::Platform::getConfigDir()) / "contact-avatars"; if (!fs::is_directory(dir, ec)) return; for (fs::directory_iterator it(dir, ec), end; !ec && it != end; it.increment(ec)) { std::error_code fec; if (it->is_regular_file(fec) && isImageFileName(it->path().filename().string())) s_avatarLibrary.push_back(it->path().string()); } std::sort(s_avatarLibrary.begin(), s_avatarLibrary.end()); } // Explicitly delete a library image (user pressed its delete icon). Only touches files directly inside // the managed dir; drops the cached texture, clears the current selection if it pointed here, and marks // the library for rescan. Contacts still referencing a deleted image fall back to their Z/T badge. static void deleteAvatarLibraryFile(const std::string& path) { namespace fs = std::filesystem; std::error_code ec; fs::path managed = fs::weakly_canonical(fs::path(util::Platform::getConfigDir()) / "contact-avatars", ec); fs::path target = fs::weakly_canonical(fs::path(path), ec); if (managed.empty() || target.parent_path() != managed) return; // safety: never escape the dir fs::remove(target, ec); invalidateAvatarTexture(path); if (s_edit_avatar == "img:" + path) s_edit_avatar.clear(); s_avatarLibraryDirty = true; } // 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. bool ConsumeContactsAvatarAnimation() { // Also consume the image picker's hover-preview animation flag (the picker renders within this tab). bool picker = ImagePicker::consumeAnimationActive(); bool v = s_avatarAnimatedThisFrame || picker; s_avatarAnimatedThisFrame = false; return v; } void RenderContactsTab(App* app) { s_avatarLoadsThisFrame = 0; // reset the per-frame avatar-decode budget (see getAvatarTexture) s_animateAvatars = !app->settings() || app->settings()->getAnimateAvatars(); auto& S = schema::UI(); // Reuse the existing address-book schema/column config for the table + add/edit form. auto addrTable = S.table("dialogs.address-book", "address-table"); auto addrFrontLbl = S.label("dialogs.address-book", "address-front-label"); auto addrBackLbl = S.label("dialogs.address-book", "address-back-label"); auto actionBtn = S.button("dialogs.address-book", "action-button"); auto& book = app->addressBook(); // Stable per-wallet id that scopes which contacts show + tags newly-added ones. Unlike the old // address-hash identity it doesn't drift when the address set changes, and it's known even before // connect. Empty only when there's no active wallet file. const std::string activeHash = app->activeWalletScopeId(); // One-time recovery: contacts saved under the old drifting address-hash scope were orphaned when the // wallet's address set changed. When there's a single known wallet (so attribution is unambiguous), // re-attach those legacy-scoped contacts to this wallet's stable id. Idempotent (converted contacts // become "w:"-scoped), so it does real work only once. if (!activeHash.empty() && app->walletIndex().entries().size() <= 1) app->addressBook().reattachLegacyScopes(activeHash); auto clearEditFields = []() { s_edit_label[0] = '\0'; s_edit_address[0] = '\0'; s_edit_notes[0] = '\0'; s_edit_global = false; // new contacts default to the current wallet s_edit_avatar.clear(); s_edit_avatar_mode = 0; s_edit_icon_search[0] = '\0'; s_avatarLibraryDirty = true; // rescan the image library on open (files may have changed) }; auto loadEditFields = [](const data::AddressBookEntry& entry) { copyEditField(s_edit_label, sizeof(s_edit_label), entry.label); copyEditField(s_edit_address, sizeof(s_edit_address), entry.address); copyEditField(s_edit_notes, sizeof(s_edit_notes), entry.notes); s_edit_global = entry.isGlobal(); s_edit_avatar = entry.avatar; s_edit_avatar_mode = (entry.avatar.rfind("icon:", 0) == 0) ? 1 : (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) }; bool has_selection = s_selected_index >= 0 && s_selected_index < static_cast(book.size()); // Shared delete/copy/edit actions (used by the toolbar, keyboard shortcuts, and per-row icons). // Each validates s_selected_index FRESHLY rather than the frame-top has_selection bool: a per-row // icon sets the selection then invokes the action in the SAME frame, so the cached bool is stale. auto selValid = [&]() { return s_selected_index >= 0 && s_selected_index < static_cast(book.size()); }; auto doDelete = [&]() { if (!selValid()) return; if (s_confirm_delete_idx == s_selected_index) { // The contact's avatar image (if any) stays in the library for reuse — not deleted here. book.removeEntry(s_selected_index); s_selected_index = -1; s_confirm_delete_idx = -1; Notifications::instance().success(TR("address_book_deleted")); } else { // Require a second, deliberate confirm (no undo for a removed contact). s_confirm_delete_idx = s_selected_index; } }; auto doCopy = [&]() { if (!selValid()) return; ImGui::SetClipboardText(book.entries()[s_selected_index].address.c_str()); Notifications::instance().info(TR("address_copied")); }; auto openEdit = [&]() { if (!selValid()) return; loadEditFields(book.entries()[s_selected_index]); s_show_edit_dialog = true; s_focus_edit_field = true; }; // Add/edit form — a modal popup layered over the tab, with a live contact preview and an avatar // picker (default Z/T badge, a Material icon, or a custom image via the in-app image picker). auto renderEntryDialog = [&]() { // The image picker takes over the modal surface while it's up (the overlay framework doesn't // nest) — render it instead of the edit dialog, exactly as WalletsDialog does for FolderPicker. if (ImagePicker::isOpen()) { ImagePicker::render(); return; } bool isEdit = s_show_edit_dialog; bool* open = isEdit ? &s_show_edit_dialog : &s_show_add_dialog; if (!*open) return; const char* title = isEdit ? TR("address_book_edit") : TR("address_book_add"); const char* id = isEdit ? "AddressBookEdit" : "AddressBookAdd"; const float dp = Layout::dpiScale(); const bool light = material::IsLightTheme(); ImFont* btnFont = S.resolveFont(actionBtn.font); material::OverlayDialogSpec ov; ov.title = title; ov.p_open = open; ov.style = material::OverlayStyle::BlurFloat; ov.cardWidth = 880.0f; ov.idSuffix = id; // two-column body: form (left) + avatar picker (right) // Fixed, tall card — fill the vertical space so the icon grid shows many rows and the notes // field has room, rather than a squat auto-height box with dead space below. const float vpH = ImGui::GetMainViewport()->Size.y; ov.cardHeight = std::min(vpH * 0.84f, 820.0f * dp) / dp; // logical; framework re-applies dp if (material::BeginOverlayDialog(ov)) { // End-truncate a string to fit maxW (whole UTF-8 code points), appending an ellipsis. auto fitText = [&](std::string s, ImFont* f, float maxW) { bool t = false; while (s.size() > 1 && f->CalcTextSizeA(f->LegacySize, FLT_MAX, 0, s.c_str()).x > maxW) { while (s.size() > 1 && (static_cast(s.back()) & 0xC0) == 0x80) s.pop_back(); if (s.size() > 1) s.pop_back(); t = true; } 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().subtitle1(); ImFont* icoF = material::Type().iconMed(); const float pad = Layout::spacingMd(); 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; ImVec2 pMin = ImGui::GetCursorScreenPos(); ImVec2 pMax(pMin.x + pw, pMin.y + ph); material::GlassPanelSpec g; g.rounding = 10.0f * dp; g.fillAlpha = 22; g.borderAlpha = 40; material::DrawGlassPanel(pdl, pMin, pMax, g); data::AddressBookEntry pv; pv.label = s_edit_label; pv.address = s_edit_address; pv.avatar = s_edit_avatar; const bool sh = isShieldedAddr(s_edit_address); const ImU32 tu = contactTypeColor(sh, light); ImVec2 avC(pMin.x + pad + avR, pMin.y + ph * 0.5f); drawContactAvatar(pdl, avC, avR, pv, sh, tu, light, dp, letF, icoF); float tx = avC.x + avR + Layout::spacingMd(); float textRight = pMax.x - pad; float ty = pMin.y + (ph - blockH) * 0.5f; const bool hasName = s_edit_label[0] != '\0'; std::string nm = fitText(hasName ? std::string(s_edit_label) : std::string(TR("contact_preview_name")), nameF, textRight - tx); 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 = 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)); } ImGui::Dummy(ImVec2(0, Layout::spacingMd())); // ================= Two-column body: form (left) | avatar picker (right) ================= // Body flexes to fill the tall card, leaving just the footer button row — so the icon grid // and notes field grow into the vertical space instead of leaving it empty. const float footerReserve = 44.0f * dp + Layout::spacingMd(); // Never floor above what's available — the footer (Save/Cancel) must stay inside the fixed, // non-scrolling card even on short windows. A tiny floor only guards a degenerate size. const float bodyH = std::max(48.0f * dp, ImGui::GetContentRegionAvail().y - footerReserve); const float colGap = Layout::spacingLg(); const float bodyAvail = ImGui::GetContentRegionAvail().x; const float colW = (bodyAvail - colGap) * 0.5f; // ---- LEFT: label / address / notes / global ---------------------------------------- ImGui::BeginChild("##contactFormCol", ImVec2(colW, bodyH), false, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); // Focus the first field the frame the dialog opens so it's keyboard-ready. if (s_focus_edit_field) { ImGui::SetKeyboardFocusHere(); s_focus_edit_field = false; } material::LabeledInput(TR("label"), isEdit ? "##EditLabel" : "##AddLabel", s_edit_label, sizeof(s_edit_label), -1.0f); ImGui::Spacing(); // Address (full width) with a centered Paste button beneath it. material::LabeledInput(TR("address_label"), isEdit ? "##EditAddress" : "##AddAddress", s_edit_address, sizeof(s_edit_address), -1.0f); { float pasteW = btnFont->CalcTextSizeA(btnFont->LegacySize, FLT_MAX, 0, TR("paste")).x + ImGui::GetStyle().FramePadding.x * 2.0f + 24.0f * dp; ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, (ImGui::GetContentRegionAvail().x - pasteW) * 0.5f)); if (material::TactileButton(TR("paste"), ImVec2(pasteW, 0), btnFont)) { const char* clipboard = ImGui::GetClipboardText(); if (clipboard) copyEditField(s_edit_address, sizeof(s_edit_address), clipboard); } } ImGui::Spacing(); // Notes grows to fill the column above the Global checkbox, which is pinned to the bottom. // Reserve the checkbox frame + the item spacing before it + a clear bottom margin so its // descenders ("y"/"g") never clip against the child's edge. { float globalReserve = ImGui::GetFrameHeight() + Layout::spacingLg() * 2.0f; float labelLine = ImGui::GetTextLineHeightWithSpacing(); float notesFill = std::max(60.0f * dp, ImGui::GetContentRegionAvail().y - globalReserve - labelLine); material::LabeledInputMultiline(TR("notes_optional"), isEdit ? "##EditNotes" : "##AddNotes", s_edit_notes, sizeof(s_edit_notes), ImVec2(-1.0f, notesFill)); } ImGui::Spacing(); ImGui::Checkbox(TR("contact_global"), &s_edit_global); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("contact_global_tt")); ImGui::EndChild(); // ##contactFormCol ImGui::SameLine(0, colGap); // ---- RIGHT: avatar picker (Badge / Icon / Image), fills the column height ----------- ImGui::BeginChild("##contactAvatarCol", ImVec2(colW, bodyH), false, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse); material::Type().textColored(material::TypeStyle::Caption, material::OnSurfaceMedium(), TR("contact_avatar")); { // 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(); 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. if (clk == 0) s_edit_avatar.clear(); else if (clk == 1 && s_edit_avatar.rfind("icon:", 0) != 0) s_edit_avatar.clear(); else if (clk == 2 && s_edit_avatar.rfind("img:", 0) != 0) s_edit_avatar.clear(); } ImGui::SetCursorScreenPos(ImVec2(sMin.x, sMin.y + segH)); ImGui::Dummy(ImVec2(segW, 0)); } ImGui::Dummy(ImVec2(0, Layout::spacingSm())); // The mode content fills the rest of the avatar column (fixed height → no jump on switch). if (s_edit_avatar_mode == 0) { // 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"); 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) { // Icon grid: search on top, then a scrollable name-filtered grid. ImGui::SetNextItemWidth(-1); ImGui::InputTextWithHint("##avIconSearch", TR("portfolio_search_icons"), s_edit_icon_search, sizeof(s_edit_icon_search)); std::string needle = toLower(s_edit_icon_search); std::vector vis; for (int i = 0; i < material::project_icons::walletIconCount(); ++i) { const char* nm = material::project_icons::walletIconName(i); if (needle.empty() || toLower(nm).find(needle) != std::string::npos) vis.push_back(i); } ImGui::PushStyleColor(ImGuiCol_ChildBg, IM_COL32(0, 0, 0, 0)); ImGui::BeginChild("##avIconGrid", ImVec2(-1, ImGui::GetContentRegionAvail().y), false); ImDrawList* gdl = ImGui::GetWindowDrawList(); ImFont* gIcoF = material::Type().iconXL(); if (vis.empty()) material::Type().textColored(material::TypeStyle::Caption, material::OnSurfaceDisabled(), TR("no_icons_found")); const float cellGap = 6.0f * dp; const float availW = ImGui::GetContentRegionAvail().x; const int cols = std::max(4, (int)((availW + cellGap) / (54.0f * dp + cellGap))); const float cell = std::max(28.0f * dp, (availW - cellGap * (cols - 1)) / (float)cols); int col = 0; for (int vi = 0; vi < (int)vis.size(); ++vi) { int i = vis[vi]; const char* nm = material::project_icons::walletIconName(i); if (col != 0) ImGui::SameLine(0, cellGap); ImVec2 mn = ImGui::GetCursorScreenPos(); ImVec2 mx(mn.x + cell, mn.y + cell); ImVec2 cc(mn.x + cell * 0.5f, mn.y + cell * 0.5f); bool hov = ImGui::IsMouseHoveringRect(mn, mx); bool sel = (s_edit_avatar == std::string("icon:") + nm); if (sel) { gdl->AddRectFilled(mn, mx, material::WithAlpha(material::Primary(), 40), 6.0f * dp); gdl->AddRect(mn, mx, material::WithAlpha(material::Primary(), 120), 6.0f * dp, 0, 1.5f * dp); } else if (hov) { gdl->AddRectFilled(mn, mx, IM_COL32(255, 255, 255, 20), 6.0f * dp); } ImU32 icol = sel ? material::Primary() : (hov ? material::OnSurface() : material::OnSurfaceMedium()); material::project_icons::drawByName(gdl, nm, cc, icol, gIcoF, cell * 0.5f); ImGui::PushID(i); ImGui::InvisibleButton("##avic", ImVec2(cell, cell)); if (ImGui::IsItemClicked()) s_edit_avatar = std::string("icon:") + nm; if (hov) material::Tooltip("%s", nm); ImGui::PopID(); col = (col + 1) % cols; } ImGui::EndChild(); ImGui::PopStyleColor(); } else { // Image: a grid over the avatar image LIBRARY. The first cell adds a new image (opens the // picker + copies it into the library); each library image is selectable and has a delete // badge to remove it. Images persist in /contact-avatars/ so they're reusable and // travel with the wallet data — deleting a contact never removes them. if (s_avatarLibraryDirty) rescanAvatarLibrary(); ImGui::PushStyleColor(ImGuiCol_ChildBg, IM_COL32(0, 0, 0, 0)); ImGui::BeginChild("##avImgGrid", ImVec2(-1, ImGui::GetContentRegionAvail().y), false); ImDrawList* gdl = ImGui::GetWindowDrawList(); const float cellGap = 6.0f * dp; const float availW = ImGui::GetContentRegionAvail().x; const int cols = std::max(3, (int)((availW + cellGap) / (74.0f * dp + cellGap))); const float cell = std::max(48.0f * dp, (availW - cellGap * (cols - 1)) / (float)cols); const int total = 1 + (int)s_avatarLibrary.size(); int pendingDelete = -1; int col = 0; for (int n = 0; n < total; ++n) { if (col != 0) ImGui::SameLine(0, cellGap); ImVec2 mn = ImGui::GetCursorScreenPos(); ImVec2 mx(mn.x + cell, mn.y + cell); ImVec2 cc(mn.x + cell * 0.5f, mn.y + cell * 0.5f); if (n == 0) { // "Add image" cell — always first. bool hov = ImGui::IsMouseHoveringRect(mn, mx); gdl->AddRectFilled(mn, mx, material::WithAlpha(material::OnSurface(), hov ? 30 : 14), 6.0f * dp); gdl->AddRect(mn, mx, material::WithAlpha(material::OnSurface(), hov ? 110 : 55), 6.0f * dp, 0, 1.5f * dp); ImFont* gf = material::Type().iconXL(); float gsz = cell * 0.4f; ImVec2 gs = gf->CalcTextSizeA(gsz, FLT_MAX, 0, ICON_MD_ADD_PHOTO_ALTERNATE); gdl->AddText(gf, gsz, ImVec2(cc.x - gs.x * 0.5f, cc.y - gs.y * 0.5f), hov ? material::Primary() : material::OnSurfaceMedium(), ICON_MD_ADD_PHOTO_ALTERNATE); 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")); }); } if (ImGui::IsItemHovered()) { ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); material::Tooltip("%s", TR("contact_avatar_choose")); } ImGui::PopID(); } else { const std::string& path = s_avatarLibrary[n - 1]; const bool sel = (s_edit_avatar == "img:" + path); bool hov = ImGui::IsMouseHoveringRect(mn, mx); gdl->AddRectFilled(mn, mx, material::WithAlpha(material::OnSurface(), 18), 6.0f * dp); const AvatarTex* t = ImGui::IsRectVisible(mn, mx) ? getAvatarTexture(path) : nullptr; ImTextureID ttex = currentAvatarFrame(t); if (ttex) { float u0=0,v0=0,u1=1,v1=1; // centre-crop to a square 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; } float ins = 2.0f * dp; gdl->AddImageRounded(ttex, 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 { ImFont* gf = material::Type().iconLarge(); ImVec2 gs = gf->CalcTextSizeA(cell * 0.34f, FLT_MAX, 0, ICON_MD_IMAGE); gdl->AddText(gf, cell * 0.34f, ImVec2(cc.x - gs.x*0.5f, cc.y - gs.y*0.5f), material::OnSurfaceDisabled(), ICON_MD_IMAGE); } if (sel) gdl->AddRect(mn, mx, material::WithAlpha(material::Primary(), 230), 6.0f * dp, 0, 2.5f * dp); else if (hov) gdl->AddRect(mn, mx, material::WithAlpha(material::OnSurface(), 100), 6.0f * dp, 0, 1.5f * dp); // The delete badge is hit-tested MANUALLY (not an ImGui item) so it never moves the // layout cursor — an InvisibleButton here would leave CursorPosPrevLine at the badge // corner and jitter the rest of the row's cells via the next SameLine. float dr = std::max(7.0f * dp, cell * 0.15f); ImVec2 dcc(mx.x - dr - 3.0f * dp, mn.y + dr + 3.0f * dp); bool dhov = ImGui::IsMouseHoveringRect(ImVec2(dcc.x-dr, dcc.y-dr), ImVec2(dcc.x+dr, dcc.y+dr)); ImGui::PushID(n); bool thumbClicked = ImGui::InvisibleButton("##avthumb", ImVec2(cell, cell)); bool thumbHov = ImGui::IsItemHovered(); ImGui::PopID(); if (thumbHov || sel || hov) { // draw the delete badge gdl->AddCircleFilled(dcc, dr, dhov ? material::ReadableError() : IM_COL32(0, 0, 0, 175)); ImFont* xf = material::Type().iconSmall(); float xsz = dr * 1.35f; ImVec2 xs = xf->CalcTextSizeA(xsz, FLT_MAX, 0, ICON_MD_CLOSE); gdl->AddText(xf, xsz, ImVec2(dcc.x - xs.x*0.5f, dcc.y - xs.y*0.5f), IM_COL32(255,255,255,235), ICON_MD_CLOSE); } if (dhov) { // badge takes priority over selecting the thumbnail ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); material::Tooltip("%s", TR("delete")); if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) pendingDelete = n - 1; } else { if (thumbHov) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); if (thumbClicked) s_edit_avatar = "img:" + path; } } col = (col + 1) % cols; } ImGui::EndChild(); ImGui::PopStyleColor(); // Deferred: mutate the library only after the grid loop is done. if (pendingDelete >= 0 && pendingDelete < (int)s_avatarLibrary.size()) deleteAvatarLibraryFile(s_avatarLibrary[pendingDelete]); } ImGui::EndChild(); // ##contactAvatarCol ImGui::Dummy(ImVec2(0, Layout::spacingSm())); bool canSubmit = std::strlen(s_edit_label) > 0 && std::strlen(s_edit_address) > 0; 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); float actionGap = Layout::spacingSm(); float totalActionsW = actionW * 2.0f + actionGap; material::BeginOverlayDialogFooter(totalActionsW, /*drawSeparator=*/false); if (!canSubmit) ImGui::BeginDisabled(); // Accent the primary action (Save/Add) so it reads above Cancel. ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 205))); ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(material::Primary())); ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 235))); const char* primaryLabel = isEdit ? TR("save") : TR("add"); bool doSubmit = material::TactileButton(primaryLabel, ImVec2(actionW, 0), btnFont); ImGui::PopStyleColor(3); if (doSubmit) { // Trim the label/address (a pasted address often carries a trailing newline); keep notes as-is. auto trimAB = [](std::string s) { while (!s.empty() && (s.front()==' '||s.front()=='\t'||s.front()=='\n'||s.front()=='\r')) s.erase(s.begin()); while (!s.empty() && (s.back()==' '||s.back()=='\t'||s.back()=='\n'||s.back()=='\r')) s.pop_back(); return s; }; data::AddressBookEntry entry(trimAB(s_edit_label), trimAB(s_edit_address), s_edit_notes); entry.avatar = s_edit_avatar; // Scope: global if the user asked; otherwise this wallet — but ONLY when we actually // know which wallet is loaded. While the identity is unknown (the switch / first-load // window) don't silently make a wallet-scoped contact global: preserve an edited // contact's existing scope, and refuse a NEW wallet-scoped one (tick global or wait). bool blocked = false; if (s_edit_global) { entry.scope = "global"; } else if (!activeHash.empty()) { entry.scope = activeHash; } else if (isEdit && s_selected_index >= 0 && s_selected_index < (int)book.size()) { entry.scope = book.entries()[s_selected_index].scope; // keep; don't demote to global if (entry.scope.empty()) entry.scope = "global"; } else { Notifications::instance().error(TR("contact_wallet_loading")); blocked = true; // don't create a wallet-scoped contact with an unknown wallet } if (blocked) { // leave the dialog open so the user can tick global or retry once loaded } else if (isEdit) { if (app->addressBook().updateEntry(s_selected_index, entry)) { Notifications::instance().success(TR("address_book_updated")); s_show_edit_dialog = false; } else { Notifications::instance().error(TR("address_book_update_failed")); } } else { if (app->addressBook().addEntry(entry)) { Notifications::instance().success(TR("address_book_added")); s_show_add_dialog = false; } else { Notifications::instance().error(TR("address_book_exists")); } } } if (!canSubmit) ImGui::EndDisabled(); ImGui::SameLine(0, actionGap); if (material::TactileButton(TR("cancel"), ImVec2(actionW, 0), btnFont)) { *open = false; } material::EndOverlayDialog(); } }; // Inline tab content lives in a scroll child (mirrors peers_tab / explorer_tab). ImVec2 avail = ImGui::GetContentRegionAvail(); ImGui::BeginChild("##ContactsScroll", avail, false, ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar); // Toolbar — icon + label actions; the primary "Add New" is accented so it stands out. const float dp = ui::Layout::dpiScale(); ImFont* tbTxtF = S.resolveFont(actionBtn.font); ImFont* tbIcoF = material::Type().iconSmall(); // Renders a tactile button with a leading Material icon (icon font) + label (text font). ImGui has no // native two-font button, so draw the styled rect via TactileButton (empty ##id label) then paint the // icon + label over it. `primary` accents the main action; the icon/label inherit the (disabled-aware) // text alpha so they gray out correctly inside BeginDisabled(). auto toolbarBtn = [&](const char* id, const char* glyph, const char* label, bool primary) -> bool { ImGui::PushFont(tbIcoF); const float iw = ImGui::CalcTextSize(glyph).x; ImGui::PopFont(); ImGui::PushFont(tbTxtF); const float tw = ImGui::CalcTextSize(label).x; ImGui::PopFont(); const float gap = 6.0f * dp; const float w = ImGui::GetStyle().FramePadding.x * 2.0f + iw + gap + tw; const float h = ImGui::GetFrameHeight(); if (primary) { ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 205))); ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(material::Primary())); ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 235))); } const ImVec2 p = ImGui::GetCursorScreenPos(); const bool clicked = material::TactileButton(id, ImVec2(w, h), tbTxtF); if (primary) ImGui::PopStyleColor(3); ImDrawList* dl = ImGui::GetWindowDrawList(); const ImU32 col = ImGui::GetColorU32(ImGuiCol_Text); const float sx = p.x + (w - (iw + gap + tw)) * 0.5f; dl->AddText(tbIcoF, tbIcoF->LegacySize, ImVec2(sx, p.y + (h - tbIcoF->LegacySize) * 0.5f), col, glyph); dl->AddText(tbTxtF, tbTxtF->LegacySize, ImVec2(sx + iw + gap, p.y + (h - tbTxtF->LegacySize) * 0.5f), col, label); return clicked; }; if (toolbarBtn("##ab_add", ICON_MD_PERSON_ADD, TR("address_book_add_new"), /*primary=*/true)) { s_show_add_dialog = true; s_focus_edit_field = true; clearEditFields(); } ImGui::SameLine(); if (!has_selection) ImGui::BeginDisabled(); if (toolbarBtn("##ab_edit", ICON_MD_EDIT, TR("edit"), false)) openEdit(); ImGui::SameLine(); // Delete — relabel to a visible confirm prompt while armed (not just a toast). bool armed = has_selection && s_confirm_delete_idx == s_selected_index; const char* delLabel = armed ? TR("address_book_confirm_delete") : TR("delete"); if (toolbarBtn("##ab_del", ICON_MD_DELETE, delLabel, false)) doDelete(); ImGui::SameLine(); if (toolbarBtn("##ab_copy", ICON_MD_CONTENT_COPY, TR("copy_address"), false)) doCopy(); if (!has_selection) ImGui::EndDisabled(); // Address-list view toggle (Cards / List / Table) — right-aligned on the toolbar row; persisted. int viewMode = app->settings() ? app->settings()->getContactsViewMode() : 0; { const char* segIcons[3] = { ICON_MD_VIEW_AGENDA, ICON_MD_VIEW_LIST, ICON_MD_TABLE_ROWS }; const float segH = ImGui::GetFrameHeight(); const float segW = 44.0f * dp * 3.0f; ImGui::SameLine(); ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, ImGui::GetContentRegionAvail().x - segW)); ImVec2 segOrigin = ImGui::GetCursorScreenPos(); int segClk = material::SegmentedControl(ImGui::GetWindowDrawList(), segOrigin, segW, segH, segIcons, 3, viewMode, material::Type().iconSmall(), "##contactsView", dp); if (segClk >= 0 && app->settings()) { app->settings()->setContactsViewMode(segClk); app->settings()->save(); viewMode = segClk; } ImGui::Dummy(ImVec2(segW, segH)); } // Search / filter ImGui::Spacing(); ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x); ImGui::InputTextWithHint("##ContactSearch", TR("contacts_search_placeholder"), s_search, sizeof(s_search)); bool searchActive = ImGui::IsItemActive(); std::string needle = toLower(s_search); ImGui::Spacing(); ImGui::Separator(); ImGui::Spacing(); // Filtered, sorted view — storage indices only (keeps s_selected_index meaningful). std::vector visibleRows; visibleRows.reserve(book.size()); for (size_t i = 0; i < book.size(); ++i) { const auto& e = book.entries()[i]; if (!matchesSearch(e, needle)) continue; // Scope filter: show global + this wallet's contacts. A legacy (non-"w:") scope belongs to a // wallet whose old address-hash identity has drifted and can't be reattributed here (multi-wallet // case where recovery didn't run) — fail OPEN so those contacts are never hidden. Stable "w:" // scopes match strictly. When no wallet is active, show global + legacy only. bool visible; if (e.isGlobal()) visible = true; else if (e.scope.rfind("w:", 0) == 0) visible = !activeHash.empty() && e.scope == activeHash; else visible = true; // legacy scope → fail open (recovery) if (!visible) continue; visibleRows.push_back(i); } // Address list — fill the tab, leaving room for the count footer. const float footerH = ImGui::GetTextLineHeightWithSpacing() + ImGui::GetStyle().ItemSpacing.y; float listH = ImGui::GetContentRegionAvail().y - footerH; if (listH < 120.0f * dp) listH = 120.0f * dp; const bool lightTheme = material::IsLightTheme(); auto typeColor = [&](bool shielded) -> ImU32 { return contactTypeColor(shielded, lightTheme); }; // Centered Material empty state (no contacts yet / no search match). auto emptyState = [&](const char* iconGlyph, const char* msgKey) { const char* msg = TR(msgKey); ImFont* icoF = material::Type().iconLarge(); ImFont* txtF = material::Type().body2(); const float availW = ImGui::GetContentRegionAvail().x; ImVec2 is = icoF->CalcTextSizeA(icoF->LegacySize, FLT_MAX, 0, iconGlyph); ImVec2 ts = txtF->CalcTextSizeA(txtF->LegacySize, FLT_MAX, 0, msg); ImGui::SetCursorPos(ImVec2((availW - is.x) * 0.5f, ImGui::GetCursorPosY() + listH * 0.32f)); ImGui::PushFont(icoF); ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(material::OnSurfaceDisabled()), "%s", iconGlyph); ImGui::PopFont(); ImGui::SetCursorPosX((availW - ts.x) * 0.5f); material::Type().textColored(material::TypeStyle::Body2, material::OnSurfaceMedium(), msg); }; // Right-click a row (any view) -> select it and open the shared context menu (rendered after the // list). A left-click on empty list space clears the selection (handled in the Cards/List child). bool openContextMenu = false; if (viewMode == 2) { // ── TABLE mode (material-ized): no outer/grid borders, row backgrounds, interactive sort. ── if (ImGui::BeginTable("AddressBookTable", 3, ImGuiTableFlags_RowBg | ImGuiTableFlags_Sortable | ImGuiTableFlags_Resizable | ImGuiTableFlags_ScrollY, ImVec2(0, listH))) { ImGui::TableSetupColumn(TR("label"), ImGuiTableColumnFlags_WidthStretch | ImGuiTableColumnFlags_DefaultSort, 1.5f); ImGui::TableSetupColumn(TR("address_label"), ImGuiTableColumnFlags_WidthStretch, 2.6f); ImGui::TableSetupColumn(TR("notes"), ImGuiTableColumnFlags_WidthStretch, 1.0f); ImGui::TableSetupScrollFreeze(0, 1); ImGui::TableHeadersRow(); if (ImGuiTableSortSpecs* specs = ImGui::TableGetSortSpecs()) { if (specs->SpecsCount > 0) { const ImGuiTableColumnSortSpecs& sc = specs->Specs[0]; bool asc = sc.SortDirection != ImGuiSortDirection_Descending; const auto& entries = book.entries(); std::stable_sort(visibleRows.begin(), visibleRows.end(), [&](size_t a, size_t b) { const std::string* ka; const std::string* kb; switch (sc.ColumnIndex) { case 1: ka = &entries[a].address; kb = &entries[b].address; break; case 2: ka = &entries[a].notes; kb = &entries[b].notes; break; default: ka = &entries[a].label; kb = &entries[b].label; break; } int cmp = toLower(*ka).compare(toLower(*kb)); return asc ? (cmp < 0) : (cmp > 0); }); } } for (size_t vi = 0; vi < visibleRows.size(); ++vi) { size_t i = visibleRows[vi]; const auto& entry = book.entries()[i]; ImGui::TableNextRow(); ImGui::PushID(static_cast(i)); const bool shielded = isShieldedAddr(entry.address); ImGui::TableNextColumn(); bool is_selected = (s_selected_index == static_cast(i)); // Small avatar before the label. SpanAllColumns keeps the whole row clickable; the avatar // is drawn AFTER the Selectable so its selection/hover fill doesn't paint over it. const float tLineH = ImGui::GetTextLineHeight(); const float tAvR = tLineH * 0.5f; const ImVec2 tAvP = ImGui::GetCursorScreenPos(); ImGui::Dummy(ImVec2(tAvR * 2.0f, tLineH)); ImGui::SameLine(0.0f, 6.0f * dp); if (ImGui::Selectable(entry.label.c_str(), is_selected, ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowDoubleClick)) { if (s_selected_index != static_cast(i)) s_confirm_delete_idx = -1; s_selected_index = static_cast(i); if (ImGui::IsMouseDoubleClicked(0)) openEdit(); } if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { s_selected_index = static_cast(i); s_confirm_delete_idx = -1; openContextMenu = true; } bool tAvOnScreen = ImGui::IsRectVisible(tAvP, ImVec2(tAvP.x + tAvR * 2.0f, tAvP.y + tLineH)); drawContactAvatar(ImGui::GetWindowDrawList(), ImVec2(tAvP.x + tAvR, tAvP.y + tLineH * 0.5f), tAvR, entry, shielded, typeColor(shielded), lightTheme, dp, material::Type().caption(), material::Type().iconSmall(), tAvOnScreen); ImGui::TableNextColumn(); ImGui::PushFont(material::Type().subtitle2()); ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(typeColor(shielded)), "%s", shielded ? "Z" : "T"); ImGui::PopFont(); ImGui::SameLine(0.0f, 6.0f * dp); std::string addr_display = util::truncateMiddle(entry.address, addrFrontLbl.truncate, addrBackLbl.truncate); ImGui::TextUnformatted(addr_display.c_str()); if (ImGui::IsItemHovered()) material::Tooltip("%s", entry.address.c_str()); if (entry.isGlobal()) { ImGui::SameLine(0.0f, 8.0f * dp); ImGui::PushFont(material::Type().iconSmall()); ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(material::OnSurfaceMedium()), "%s", ICON_MD_PUBLIC); ImGui::PopFont(); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("contact_global_badge_tt")); } ImGui::TableNextColumn(); ImGui::TextUnformatted(entry.notes.c_str()); ImGui::PopID(); } ImGui::EndTable(); } } else { // ── CARDS (0) / LIST (1) mode — tactile Material items, no grid lines. ── const bool asCard = (viewMode == 0); // Cards/list have no interactive sort header — sort by label ascending. { const auto& entries = book.entries(); std::stable_sort(visibleRows.begin(), visibleRows.end(), [&](size_t a, size_t b) { return toLower(entries[a].label).compare(toLower(entries[b].label)) < 0; }); } ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0,0,0,0)); ImGui::BeginChild("##contactList", ImVec2(0, listH), false, ImGuiWindowFlags_NoBackground); if (visibleRows.empty()) { emptyState(ICON_MD_CONTACTS, book.empty() ? "address_book_empty" : "contacts_search_no_match"); } else { ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0,0,0,0)); ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImVec4(0,0,0,0)); ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImVec4(0,0,0,0)); ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0, asCard ? 8.0f * dp : 0.0f)); ImDrawList* dl = ImGui::GetWindowDrawList(); ImFont* lblF = material::Type().subtitle2(); ImFont* adrF = material::Type().caption(); ImFont* icoF = material::Type().iconSmall(); const float pad = 12.0f * dp; const float avR = 15.0f * dp; const float rowH = avR * 2.0f + (asCard ? 16.0f * dp : 12.0f * dp); const float round = 10.0f * dp; bool rowDeleteRequested = false; // per-row delete is deferred until after the loop for (size_t vi = 0; vi < visibleRows.size(); ++vi) { size_t i = visibleRows[vi]; const auto& entry = book.entries()[i]; ImGui::PushID(static_cast(i)); const bool selected = (s_selected_index == static_cast(i)); const ImVec2 p = ImGui::GetCursorScreenPos(); const float w = ImGui::GetContentRegionAvail().x; ImGui::SetNextItemAllowOverlap(); // let the per-row action buttons overlap the row if (ImGui::Selectable("##it", selected, ImGuiSelectableFlags_SpanAvailWidth | ImGuiSelectableFlags_AllowDoubleClick, ImVec2(0, rowH))) { if (!selected) s_confirm_delete_idx = -1; s_selected_index = static_cast(i); if (ImGui::IsMouseDoubleClicked(0)) openEdit(); } const bool selHov = ImGui::IsItemHovered(); if (selHov && ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { s_selected_index = static_cast(i); s_confirm_delete_idx = -1; openContextMenu = true; } const ImVec2 afterRow = ImGui::GetCursorScreenPos(); // restore this for the next row const ImVec2 mn = p, mx(p.x + w, p.y + rowH); const bool rowHovered = ImGui::IsMouseHoveringRect(mn, mx); // whole-row hover (survives action-icon hover) if (rowHovered && !selected) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); if (asCard) { ImU32 fill = selected ? material::WithAlpha(material::Primary(), 42) : rowHovered ? material::WithAlpha(material::OnSurface(), 26) : material::WithAlpha(material::OnSurface(), 12); dl->AddRectFilled(mn, mx, fill, round); if (selected) dl->AddRect(mn, mx, material::WithAlpha(material::Primary(), 150), round, 0, 1.6f * dp); } else { if (selected) dl->AddRectFilled(mn, mx, material::WithAlpha(material::Primary(), 34), 0); else if (rowHovered) dl->AddRectFilled(mn, mx, material::WithAlpha(material::OnSurface(), 16), 0); 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); } // 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); // Only animate when the row is actually on-screen, so a scrolled-off animated avatar // doesn't hold the app awake (this loop has no clipper). const bool rowOnScreen = ImGui::IsRectVisible(mn, mx); drawContactAvatar(dl, avC, avR, entry, shielded, tu, lightTheme, dp, lblF, icoF, rowOnScreen); // 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; const float trailW = 3.0f * actHit + 6.0f * dp + globeW; const float cy = mn.y + rowH * 0.5f; const float tx = mn.x + pad + avR * 2.0f + pad; const float textMaxX = mx.x - pad - trailW; const float blockH = lblF->LegacySize + adrF->LegacySize + 3.0f * dp; const float ty = cy - blockH * 0.5f; dl->PushClipRect(ImVec2(tx, mn.y), ImVec2(textMaxX, mx.y), true); dl->AddText(lblF, lblF->LegacySize, ImVec2(tx, ty), material::OnSurface(), entry.label.c_str()); dl->PopClipRect(); // Un-collapse to the full address on hover (clipped to the text column so it never // runs under the trailing actions); middle-truncated otherwise. std::string addr = rowHovered ? entry.address : util::truncateMiddle(entry.address, addrFrontLbl.truncate, addrBackLbl.truncate); dl->PushClipRect(ImVec2(tx, mn.y), ImVec2(textMaxX, mx.y), true); dl->AddText(adrF, adrF->LegacySize, ImVec2(tx, ty + lblF->LegacySize + 3.0f * dp), material::OnSurfaceMedium(), addr.c_str()); dl->PopClipRect(); // Trailing: the globe badge stays pinned far-right (global contacts); per-row copy/edit/ // delete actions appear to its LEFT on hover/selection. float rightX = mx.x - pad; if (entry.isGlobal()) { const ImVec2 gs = icoF->CalcTextSizeA(icoF->LegacySize, FLT_MAX, 0, ICON_MD_PUBLIC); dl->AddText(icoF, icoF->LegacySize, ImVec2(rightX - gs.x, cy - gs.y * 0.5f), material::OnSurfaceMedium(), ICON_MD_PUBLIC); rightX -= gs.x + 8.0f * dp; } if (rowHovered || selected) { const char* actGlyph[3] = { ICON_MD_CONTENT_COPY, ICON_MD_EDIT, ICON_MD_DELETE }; const char* actTip[3] = { TR("copy_address"), TR("edit"), TR("delete") }; float ax = rightX - actHit; // rightmost action (delete), just left of the globe for (int a = 2; a >= 0; --a) { ImGui::SetCursorScreenPos(ImVec2(ax, cy - actHit * 0.5f)); ImGui::PushID(a + 100); const bool aClk = ImGui::InvisibleButton("##act", ImVec2(actHit, actHit)); const bool aHov = ImGui::IsItemHovered(); if (aHov) { ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); material::Tooltip("%s", actTip[a]); } if (aClk) { s_selected_index = static_cast(i); if (a == 0) doCopy(); else if (a == 1) openEdit(); // Delete mutates book.entries() (removeEntry -> erase) — deferring it past the // row loop avoids iterating a vector we shrink mid-loop (OOB on later rows). else rowDeleteRequested = true; } const bool armedDel = (a == 2 && s_confirm_delete_idx == static_cast(i)); const ImU32 acol = armedDel ? material::ReadableError() : aHov ? material::OnSurface() : material::OnSurfaceMedium(); const ImVec2 gsz = icoF->CalcTextSizeA(icoF->LegacySize, FLT_MAX, 0, actGlyph[a]); dl->AddText(icoF, icoF->LegacySize, ImVec2(ax + (actHit - gsz.x) * 0.5f, cy - gsz.y * 0.5f), acol, actGlyph[a]); ImGui::PopID(); ax -= actHit + 2.0f * dp; } } ImGui::SetCursorScreenPos(afterRow); // undo the action buttons' cursor moves ImGui::PopID(); } // Run the deferred per-row delete now that the loop over book.entries() has finished // (doDelete's two-stage confirm arms on the first click and removes on the second). if (rowDeleteRequested) doDelete(); // The per-row SetCursorScreenPos leaves the cursor at the last row's bottom with no item // submitted there; commit it so ImGui sizes the scroll child (avoids the extend-boundary warning). ImGui::Dummy(ImVec2(0.0f, 0.0f)); ImGui::PopStyleVar(); ImGui::PopStyleColor(3); } // A left-click on empty list space (no row/action hovered) clears the selection. if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && !ImGui::IsAnyItemHovered()) { s_selected_index = -1; s_confirm_delete_idx = -1; } ImGui::EndChild(); ImGui::PopStyleColor(); } // Shared right-click context menu (opened by either view's row right-click; acts on the selection). if (openContextMenu) ImGui::OpenPopup("##contactCtx"); if (ImGui::BeginPopup("##contactCtx")) { if (ImGui::MenuItem(TR("copy_address"))) doCopy(); if (ImGui::MenuItem(TR("edit"))) openEdit(); ImGui::Separator(); if (ImGui::MenuItem(TR("delete"))) doDelete(); ImGui::EndPopup(); } // Status line — singular form for exactly one contact ("1 address saved", not "1 addresses saved"). ImGui::TextDisabled(TR(book.size() == 1 ? "address_book_count_one" : "address_book_count"), book.size()); // Keyboard shortcuts — only when the tab owns focus (no field being edited, no modal up). if (!searchActive && !ImGui::IsAnyItemActive() && !s_show_add_dialog && !s_show_edit_dialog && !visibleRows.empty()) { // Current selection's position within the visible view. int curPos = -1; for (size_t k = 0; k < visibleRows.size(); ++k) if (static_cast(visibleRows[k]) == s_selected_index) { curPos = static_cast(k); break; } if (ImGui::IsKeyPressed(ImGuiKey_DownArrow)) { curPos = (curPos < 0) ? 0 : std::min(curPos + 1, static_cast(visibleRows.size()) - 1); s_selected_index = static_cast(visibleRows[curPos]); s_confirm_delete_idx = -1; } else if (ImGui::IsKeyPressed(ImGuiKey_UpArrow)) { curPos = (curPos < 0) ? 0 : std::max(curPos - 1, 0); s_selected_index = static_cast(visibleRows[curPos]); s_confirm_delete_idx = -1; } else if (ImGui::IsKeyPressed(ImGuiKey_Enter, false) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter, false)) { openEdit(); } else if (ImGui::IsKeyPressed(ImGuiKey_Delete, false)) { doDelete(); } else if (ImGui::GetIO().KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_C, false)) { doCopy(); } } ImGui::EndChild(); renderEntryDialog(); } void ContactsSweepOpenEditDialog(int avatarMode) { s_selected_index = 0; // seedSweepContacts seeds entry 0 as a valid target copyEditField(s_edit_label, sizeof(s_edit_label), "drgx pool payout address"); copyEditField(s_edit_address, sizeof(s_edit_address), "zs1sweepdemocoldsavingsaddressforuicapture0000000000000000000000000000"); copyEditField(s_edit_notes, sizeof(s_edit_notes), "mining pool payouts"); s_edit_global = true; 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_show_add_dialog = false; s_show_edit_dialog = true; s_focus_edit_field = false; } void ContactsSweepCloseDialog() { s_show_edit_dialog = false; s_show_add_dialog = false; ImagePicker::close(); } } // namespace ui } // namespace dragonx