Contacts were scoped by activeWalletIdentityHash() — a hash of the wallet's
ENTIRE address set. Creating a new receive address grows the set, changing the
hash, so every contact stamped with the old hash falls out of the scope filter
(contacts_tab.cpp:915) while still being counted — the "3 saved, 1 showing"
symptom, where only the one set to global (which bypasses the scope) survives.
It also hid scoped contacts on every startup before the daemon connected (hash
empty until addresses load).
Introduce a stable per-wallet scope id: WalletIndexEntry.scopeId ("w:"+random
hex), generated once and persisted in the wallet index (keyed by wallet file),
never recomputed from the mutable address set — so creating addresses, locking,
or disconnecting never changes it. App::activeWalletScopeId() establishes it on
first use. Contacts now scope + filter on this instead of the drifting hash. The
tx-history-cache identity (the hash's real purpose) is untouched.
Recovery for already-orphaned contacts:
- AddressBook::reattachLegacyScopes() re-attaches non-global, non-"w:" contacts
to the active wallet's stable id; run once when there's a single known wallet
(unambiguous attribution). Idempotent.
- The scope filter fails OPEN for legacy scopes (multi-wallet case where recovery
can't attribute them) so no contact is ever hidden; stable "w:" scopes still
match strictly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1256 lines
73 KiB
C++
1256 lines
73 KiB
C++
// 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 <filesystem>
|
|
#include <unordered_map>
|
|
#include <algorithm>
|
|
#include <cctype>
|
|
#include <cmath>
|
|
#include <cstdint>
|
|
#include <cstring>
|
|
#include <string>
|
|
#include <vector>
|
|
|
|
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:<name>", or "img:<abs path>".
|
|
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<char>(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<ImTextureID> frames; // 1 = still, N = animated
|
|
std::vector<float> delays; // seconds per frame (parallel to frames)
|
|
float totalDur = 0.0f;
|
|
int w = 0, h = 0;
|
|
};
|
|
static std::unordered_map<std::string, AvatarTex> 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 <config>/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 <config>/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<std::string> 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<char>(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<int>(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<int>(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<unsigned char>(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<int> 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 <config>/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<size_t> 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<int>(i));
|
|
const bool shielded = isShieldedAddr(entry.address);
|
|
ImGui::TableNextColumn();
|
|
bool is_selected = (s_selected_index == static_cast<int>(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<int>(i)) s_confirm_delete_idx = -1;
|
|
s_selected_index = static_cast<int>(i);
|
|
if (ImGui::IsMouseDoubleClicked(0)) openEdit();
|
|
}
|
|
if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Right)) {
|
|
s_selected_index = static_cast<int>(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<int>(i));
|
|
const bool selected = (s_selected_index == static_cast<int>(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<int>(i);
|
|
if (ImGui::IsMouseDoubleClicked(0)) openEdit();
|
|
}
|
|
const bool selHov = ImGui::IsItemHovered();
|
|
if (selHov && ImGui::IsMouseClicked(ImGuiMouseButton_Right)) {
|
|
s_selected_index = static_cast<int>(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<int>(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<int>(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<int>(visibleRows[k]) == s_selected_index) { curPos = static_cast<int>(k); break; }
|
|
|
|
if (ImGui::IsKeyPressed(ImGuiKey_DownArrow)) {
|
|
curPos = (curPos < 0) ? 0 : std::min(curPos + 1, static_cast<int>(visibleRows.size()) - 1);
|
|
s_selected_index = static_cast<int>(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<int>(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
|