From 9f23b2781c9fe192218ab8ae389d4fb0bb4609fd Mon Sep 17 00:00:00 2001 From: dan_s Date: Sun, 12 Apr 2026 17:29:56 -0500 Subject: [PATCH] feat: modernize address list with drag-transfer, labels, and UX polish - Rewrite RenderSharedAddressList with two-pass layout architecture - Add drag-to-transfer: drag address onto another to open transfer dialog - Add AddressLabelDialog with custom label text and 20-icon picker - Add AddressTransferDialog with amount input, fee, and balance preview - Add AddressMeta persistence (label, icon, sortOrder) in settings.json - Gold favorite border inset 2dp from container edge - Show hide button on all addresses, not just zero-balance - Smaller star/hide buttons to clear favorite border - Semi-transparent dragged row with context-aware tooltip - Copy-to-clipboard deferred to mouse-up (no copy on drag) - Themed colors via resolveColor() with CSS variable fallbacks - Keyboard nav (Up/Down/J/K, Enter to copy, F2 to edit label) - Add i18n keys for all new UI strings --- src/app.cpp | 10 +- src/app.h | 10 + src/app_network.cpp | 55 +++ src/config/settings.cpp | 25 + src/config/settings.h | 35 ++ src/ui/windows/address_label_dialog.h | 184 ++++++++ src/ui/windows/address_transfer_dialog.h | 293 ++++++++++++ src/ui/windows/balance_tab.cpp | 556 ++++++++++++++++------- src/ui/windows/peers_tab.cpp | 6 +- src/util/i18n.cpp | 26 ++ 10 files changed, 1026 insertions(+), 174 deletions(-) create mode 100644 src/ui/windows/address_label_dialog.h create mode 100644 src/ui/windows/address_transfer_dialog.h diff --git a/src/app.cpp b/src/app.cpp index aa4bfbe..e0d9d33 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -35,6 +35,8 @@ #include "ui/windows/block_info_dialog.h" #include "ui/windows/export_all_keys_dialog.h" #include "ui/windows/export_transactions_dialog.h" +#include "ui/windows/address_label_dialog.h" +#include "ui/windows/address_transfer_dialog.h" #include "ui/windows/console_tab.h" #include "ui/pages/settings_page.h" #include "ui/theme.h" @@ -1347,7 +1349,13 @@ void App::render() // Export transactions to CSV dialog (triggered from File menu) ui::ExportTransactionsDialog::render(this); - + + // Address label/icon editor + ui::AddressLabelDialog::render(); + + // Address-to-address transfer confirmation + ui::AddressTransferDialog::render(); + // Windows Defender antivirus help dialog renderAntivirusHelpDialog(); diff --git a/src/app.h b/src/app.h index 5211dc9..39b782e 100644 --- a/src/app.h +++ b/src/app.h @@ -202,6 +202,16 @@ public: void unfavoriteAddress(const std::string& addr); bool isAddressFavorite(const std::string& addr) const; + // Address metadata (labels, icons, custom ordering) + void setAddressLabel(const std::string& addr, const std::string& label); + void setAddressIcon(const std::string& addr, const std::string& icon); + std::string getAddressLabel(const std::string& addr) const; + std::string getAddressIcon(const std::string& addr) const; + int getAddressSortOrder(const std::string& addr) const; + void setAddressSortOrder(const std::string& addr, int order); + int getNextSortOrder() const; + void swapAddressOrder(const std::string& a, const std::string& b); + // Key export/import void exportPrivateKey(const std::string& address, std::function callback); void exportAllKeys(std::function callback); diff --git a/src/app_network.cpp b/src/app_network.cpp index 64e2426..4dddd8a 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -1640,6 +1640,61 @@ bool App::isAddressFavorite(const std::string& addr) const return settings_ && settings_->isAddressFavorite(addr); } +void App::setAddressLabel(const std::string& addr, const std::string& label) +{ + if (settings_) { + settings_->setAddressLabel(addr, label); + settings_->save(); + } +} + +void App::setAddressIcon(const std::string& addr, const std::string& icon) +{ + if (settings_) { + settings_->setAddressIcon(addr, icon); + settings_->save(); + } +} + +std::string App::getAddressLabel(const std::string& addr) const +{ + if (!settings_) return ""; + return settings_->getAddressMeta(addr).label; +} + +std::string App::getAddressIcon(const std::string& addr) const +{ + if (!settings_) return ""; + return settings_->getAddressMeta(addr).icon; +} + +int App::getAddressSortOrder(const std::string& addr) const +{ + if (!settings_) return -1; + return settings_->getAddressMeta(addr).sortOrder; +} + +void App::setAddressSortOrder(const std::string& addr, int order) +{ + if (settings_) { + settings_->setAddressSortOrder(addr, order); + settings_->save(); + } +} + +int App::getNextSortOrder() const +{ + return settings_ ? settings_->getNextSortOrder() : 0; +} + +void App::swapAddressOrder(const std::string& a, const std::string& b) +{ + if (settings_) { + settings_->swapAddressOrder(a, b); + settings_->save(); + } +} + // ============================================================================ // Key Export/Import Operations // ============================================================================ diff --git a/src/config/settings.cpp b/src/config/settings.cpp index f6f1254..e9296e6 100644 --- a/src/config/settings.cpp +++ b/src/config/settings.cpp @@ -126,6 +126,19 @@ bool Settings::load(const std::string& path) for (const auto& a : j["favorite_addresses"]) if (a.is_string()) favorite_addresses_.insert(a.get()); } + if (j.contains("address_meta") && j["address_meta"].is_object()) { + address_meta_.clear(); + for (auto& [addr, meta] : j["address_meta"].items()) { + AddressMeta m; + if (meta.contains("label") && meta["label"].is_string()) + m.label = meta["label"].get(); + if (meta.contains("icon") && meta["icon"].is_string()) + m.icon = meta["icon"].get(); + if (meta.contains("order") && meta["order"].is_number_integer()) + m.sortOrder = meta["order"].get(); + address_meta_[addr] = m; + } + } if (j.contains("wizard_completed")) wizard_completed_ = j["wizard_completed"].get(); if (j.contains("auto_lock_timeout")) auto_lock_timeout_ = j["auto_lock_timeout"].get(); if (j.contains("unlock_duration")) unlock_duration_ = j["unlock_duration"].get(); @@ -230,6 +243,18 @@ bool Settings::save(const std::string& path) j["favorite_addresses"] = json::array(); for (const auto& addr : favorite_addresses_) j["favorite_addresses"].push_back(addr); + { + json meta_obj = json::object(); + for (const auto& [addr, m] : address_meta_) { + if (m.label.empty() && m.icon.empty() && m.sortOrder < 0) continue; + json entry = json::object(); + if (!m.label.empty()) entry["label"] = m.label; + if (!m.icon.empty()) entry["icon"] = m.icon; + if (m.sortOrder >= 0) entry["order"] = m.sortOrder; + meta_obj[addr] = entry; + } + j["address_meta"] = meta_obj; + } j["wizard_completed"] = wizard_completed_; j["auto_lock_timeout"] = auto_lock_timeout_; j["unlock_duration"] = unlock_duration_; diff --git a/src/config/settings.h b/src/config/settings.h index b778ae2..73130dd 100644 --- a/src/config/settings.h +++ b/src/config/settings.h @@ -5,6 +5,7 @@ #pragma once #include +#include #include #include #include @@ -141,6 +142,39 @@ public: void unfavoriteAddress(const std::string& addr) { favorite_addresses_.erase(addr); } int getFavoriteAddressCount() const { return (int)favorite_addresses_.size(); } + // Address metadata (labels, icons, custom ordering) + struct AddressMeta { + std::string label; + std::string icon; // material icon name, e.g. "savings" + int sortOrder = -1; // -1 = auto (use default sort) + }; + const AddressMeta& getAddressMeta(const std::string& addr) const { + static const AddressMeta empty{}; + auto it = address_meta_.find(addr); + return it != address_meta_.end() ? it->second : empty; + } + void setAddressLabel(const std::string& addr, const std::string& label) { + address_meta_[addr].label = label; + } + void setAddressIcon(const std::string& addr, const std::string& icon) { + address_meta_[addr].icon = icon; + } + void setAddressSortOrder(const std::string& addr, int order) { + address_meta_[addr].sortOrder = order; + } + int getNextSortOrder() const { + int mx = -1; + for (const auto& [k, v] : address_meta_) + if (v.sortOrder > mx) mx = v.sortOrder; + return mx + 1; + } + void swapAddressOrder(const std::string& a, const std::string& b) { + int oa = address_meta_[a].sortOrder; + int ob = address_meta_[b].sortOrder; + address_meta_[a].sortOrder = ob; + address_meta_[b].sortOrder = oa; + } + // First-run wizard bool getWizardCompleted() const { return wizard_completed_; } void setWizardCompleted(bool v) { wizard_completed_ = v; } @@ -301,6 +335,7 @@ private: bool scanline_enabled_ = true; std::set hidden_addresses_; std::set favorite_addresses_; + std::map address_meta_; bool wizard_completed_ = false; int auto_lock_timeout_ = 900; // 15 minutes int unlock_duration_ = 600; // 10 minutes diff --git a/src/ui/windows/address_label_dialog.h b/src/ui/windows/address_label_dialog.h new file mode 100644 index 0000000..04ff547 --- /dev/null +++ b/src/ui/windows/address_label_dialog.h @@ -0,0 +1,184 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 + +#pragma once + +#include +#include +#include "../../app.h" +#include "../../util/i18n.h" +#include "../material/draw_helpers.h" +#include "../theme.h" +#include "../../embedded/IconsMaterialDesign.h" +#include "imgui.h" + +namespace dragonx { +namespace ui { + +class AddressLabelDialog { +public: + static void show(App* app, const std::string& address, bool isZ) { + s_open = true; + s_app = app; + s_address = address; + s_isZ = isZ; + s_selectedIcon = -1; + + // Pre-fill from existing metadata + std::string existing = app->getAddressLabel(address); + std::strncpy(s_label, existing.c_str(), sizeof(s_label) - 1); + s_label[sizeof(s_label) - 1] = '\0'; + + std::string existingIcon = app->getAddressIcon(address); + for (int i = 0; i < kIconCount; ++i) { + if (kIconNames[i] == existingIcon) { + s_selectedIcon = i; + break; + } + } + } + + static void render() { + if (!s_open || !s_app) return; + + using namespace material; + + if (BeginOverlayDialog(TR("set_label"), &s_open, 420.0f, 0.92f)) { + float dp = Layout::dpiScale(); + + // Address preview + Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), + s_isZ ? TR("shielded_address") : TR("transparent_address")); + ImGui::Spacing(); + { + std::string display = s_address; + if (display.size() > 42) display = display.substr(0, 20) + "..." + display.substr(display.size() - 16); + Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), display.c_str()); + } + ImGui::Spacing(); + ImGui::Spacing(); + + // Label input + Type().text(TypeStyle::Subtitle2, TR("label")); + ImGui::SetNextItemWidth(-1); + ImGui::InputTextWithHint("##AddrLabel", TR("label_placeholder"), s_label, sizeof(s_label)); + ImGui::Spacing(); + ImGui::Spacing(); + + // Icon picker grid + Type().text(TypeStyle::Subtitle2, TR("choose_icon")); + ImGui::Spacing(); + + ImFont* iconFont = Type().iconMed(); + float iconFsz = ScaledFontSize(iconFont); + float cellSz = iconFsz + 16.0f * dp; + float avail = ImGui::GetContentRegionAvail().x; + int cols = std::max(1, (int)(avail / (cellSz + 4.0f * dp))); + + ImDrawList* dl = ImGui::GetWindowDrawList(); + + for (int i = 0; i < kIconCount; ++i) { + if (i % cols != 0) ImGui::SameLine(0, 4.0f * dp); + ImVec2 pos = ImGui::GetCursorScreenPos(); + ImVec2 mn = pos; + ImVec2 mx(pos.x + cellSz, pos.y + cellSz); + bool hov = ImGui::IsMouseHoveringRect(mn, mx); + bool sel = (s_selectedIcon == i); + + // Background + if (sel) { + dl->AddRectFilled(mn, mx, WithAlpha(Primary(), 40), 6.0f * dp); + dl->AddRect(mn, mx, WithAlpha(Primary(), 120), 6.0f * dp, 0, 1.5f * dp); + } else if (hov) { + dl->AddRectFilled(mn, mx, IM_COL32(255, 255, 255, 20), 6.0f * dp); + } + + // Icon centered in cell + ImVec2 iSz = iconFont->CalcTextSizeA(iconFsz, 1000.0f, 0.0f, kIconGlyphs[i]); + dl->AddText(iconFont, iconFsz, + ImVec2(mn.x + (cellSz - iSz.x) * 0.5f, mn.y + (cellSz - iSz.y) * 0.5f), + sel ? Primary() : (hov ? OnSurface() : OnSurfaceMedium()), + kIconGlyphs[i]); + + ImGui::PushID(i); + ImGui::InvisibleButton("##icon", ImVec2(cellSz, cellSz)); + if (ImGui::IsItemClicked()) s_selectedIcon = i; + if (hov) ImGui::SetTooltip("%s", kIconNames[i]); + ImGui::PopID(); + } + + // "No icon" option + ImGui::Spacing(); + if (s_selectedIcon >= 0) { + if (ImGui::SmallButton(TR("clear_icon"))) { + s_selectedIcon = -1; + } + } + + ImGui::Spacing(); + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + + // Buttons + float btnW = 120.0f; + float totalW = btnW * 2 + Layout::spacingMd(); + ImGui::SetCursorPosX((ImGui::GetContentRegionAvail().x - totalW) * 0.5f); + + if (TactileButton(TR("cancel"), ImVec2(btnW, 0))) { + s_open = false; + } + ImGui::SameLine(0, Layout::spacingMd()); + if (TactileButton(TR("save"), ImVec2(btnW, 0))) { + // Apply changes + s_app->setAddressLabel(s_address, s_label); + if (s_selectedIcon >= 0) + s_app->setAddressIcon(s_address, kIconNames[s_selectedIcon]); + else + s_app->setAddressIcon(s_address, ""); + s_open = false; + } + + EndOverlayDialog(); + } + } + + static bool isOpen() { return s_open; } + +private: + static inline bool s_open = false; + static inline App* s_app = nullptr; + static inline std::string s_address; + static inline bool s_isZ = false; + static inline char s_label[128] = {}; + static inline int s_selectedIcon = -1; + + // Icon palette — wallet-relevant Material Design icons + static constexpr int kIconCount = 20; + static inline const char* kIconNames[kIconCount] = { + "savings", "account_balance", "wallet", "payments", + "diamond", "shield", "lock", "swap_horiz", + "store", "home", "work", "rocket_launch", + "favorite", "bolt", "token", "category", + "label", "coffee", "volunteer", "star" + }; + static inline const char* kIconGlyphs[kIconCount] = { + ICON_MD_SAVINGS, ICON_MD_ACCOUNT_BALANCE, ICON_MD_WALLET, ICON_MD_PAYMENTS, + ICON_MD_DIAMOND, ICON_MD_SHIELD, ICON_MD_LOCK, ICON_MD_SWAP_HORIZ, + ICON_MD_STORE, ICON_MD_HOME, ICON_MD_WORK, ICON_MD_ROCKET_LAUNCH, + ICON_MD_FAVORITE, ICON_MD_BOLT, ICON_MD_TOKEN, ICON_MD_CATEGORY, + ICON_MD_LABEL, ICON_MD_LOCAL_CAFE, ICON_MD_VOLUNTEER_ACTIVISM, ICON_MD_STAR + }; + +public: + // Expose for the address list to look up icon glyphs by name + static const char* iconGlyphForName(const std::string& name) { + for (int i = 0; i < kIconCount; ++i) + if (kIconNames[i] == name) return kIconGlyphs[i]; + return nullptr; + } +}; + +} // namespace ui +} // namespace dragonx diff --git a/src/ui/windows/address_transfer_dialog.h b/src/ui/windows/address_transfer_dialog.h new file mode 100644 index 0000000..839b44d --- /dev/null +++ b/src/ui/windows/address_transfer_dialog.h @@ -0,0 +1,293 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 + +#pragma once + +#include +#include +#include +#include +#include "../../app.h" +#include "../../util/i18n.h" +#include "../material/draw_helpers.h" +#include "../theme.h" +#include "imgui.h" + +namespace dragonx { +namespace ui { + +/** + * @brief Modal dialog for transferring funds between two addresses. + * + * Shown when a user drags one address row onto another in the address list. + * Displays from/to addresses, balances, amount input, fee, and a preview of + * the resulting balances after transfer. + */ +struct AddressTransferInfo { + std::string fromAddr; + std::string toAddr; + double fromBalance = 0.0; + double toBalance = 0.0; + bool fromIsZ = false; + bool toIsZ = false; +}; + +class AddressTransferDialog { +public: + using TransferInfo = AddressTransferInfo; + + static void show(App* app, const TransferInfo& info) { + s_open = true; + s_app = app; + s_info = info; + s_sending = false; + s_resultMsg.clear(); + s_success = false; + + // Pre-fill amount with full source balance + snprintf(s_amount, sizeof(s_amount), "%.8f", info.fromBalance); + + // Default fee + s_fee = 0.0001; + } + + static void render() { + if (!s_open || !s_app) return; + + using namespace material; + + if (BeginOverlayDialog(TR("transfer_funds"), &s_open, 480.0f, 0.94f)) { + float dp = Layout::dpiScale(); + ImDrawList* dl = ImGui::GetWindowDrawList(); + + // If we got a result, show it + if (!s_resultMsg.empty()) { + renderResult(); + EndOverlayDialog(); + return; + } + + // Shielding / deshielding warning + if (s_info.fromIsZ && !s_info.toIsZ) { + ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Warning())); + ImGui::TextWrapped("%s", TR("deshielding_warning")); + ImGui::PopStyleColor(); + ImGui::Spacing(); + } else if (!s_info.fromIsZ && s_info.toIsZ) { + ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Success())); + ImGui::TextWrapped("%s", TR("shielding_notice")); + ImGui::PopStyleColor(); + ImGui::Spacing(); + } + + // From address + Type().text(TypeStyle::Overline, TR("from")); + renderAddressRow(s_info.fromAddr, s_info.fromBalance, s_info.fromIsZ, dp); + ImGui::Spacing(); + + // Arrow + { + float arrowCX = ImGui::GetContentRegionAvail().x * 0.5f; + ImGui::SetCursorPosX(arrowCX - 8.0f); + ImFont* iconFont = Type().iconMed(); + float fsz = ScaledFontSize(iconFont); + ImVec2 pos = ImGui::GetCursorScreenPos(); + dl->AddText(iconFont, fsz, pos, OnSurfaceMedium(), ICON_MD_ARROW_DOWNWARD); + ImGui::Dummy(ImVec2(fsz, fsz)); + } + ImGui::Spacing(); + + // To address + Type().text(TypeStyle::Overline, TR("to")); + renderAddressRow(s_info.toAddr, s_info.toBalance, s_info.toIsZ, dp); + ImGui::Spacing(); + ImGui::Spacing(); + + // Amount input + Type().text(TypeStyle::Subtitle2, TR("amount")); + ImGui::SetNextItemWidth(-1); + ImGui::InputText("##TransferAmt", s_amount, sizeof(s_amount), + ImGuiInputTextFlags_CharsDecimal); + + // Max button + ImGui::SameLine(); + if (ImGui::SmallButton(TR("max"))) { + double maxAmt = s_info.fromBalance - s_fee; + if (maxAmt < 0) maxAmt = 0; + snprintf(s_amount, sizeof(s_amount), "%.8f", maxAmt); + } + ImGui::Spacing(); + + // Fee display + { + char feeBuf[64]; + snprintf(feeBuf, sizeof(feeBuf), "%s: %.4f DRGX", TR("fee"), s_fee); + Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), feeBuf); + } + ImGui::Spacing(); + + // Preview after transfer + double amount = atof(s_amount); + double totalDeduct = amount + s_fee; + double newFromBal = s_info.fromBalance - totalDeduct; + double newToBal = s_info.toBalance + amount; + bool amountValid = amount > 0 && totalDeduct <= s_info.fromBalance; + + ImGui::Separator(); + ImGui::Spacing(); + Type().text(TypeStyle::Subtitle2, TR("result_preview")); + ImGui::Spacing(); + { + char buf[128]; + snprintf(buf, sizeof(buf), "%s: %.8f DRGX → %.8f DRGX", + TR("sender_balance"), s_info.fromBalance, amountValid ? newFromBal : s_info.fromBalance); + Type().textColored(TypeStyle::Caption, + (amountValid && newFromBal < 1e-9) ? Warning() : OnSurfaceMedium(), buf); + } + { + char buf[128]; + snprintf(buf, sizeof(buf), "%s: %.8f DRGX → %.8f DRGX", + TR("recipient_balance"), s_info.toBalance, amountValid ? newToBal : s_info.toBalance); + Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), buf); + } + + // Warnings + if (!amountValid && amount > 0) { + ImGui::Spacing(); + ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Error())); + ImGui::TextWrapped("%s", TR("insufficient_funds")); + ImGui::PopStyleColor(); + } + if (amountValid && newFromBal < 1e-9) { + ImGui::Spacing(); + ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Warning())); + ImGui::TextWrapped("%s", TR("sends_full_balance_warning")); + ImGui::PopStyleColor(); + } + + ImGui::Spacing(); + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + + // Buttons + float btnW = 140.0f; + float totalW = btnW * 2 + Layout::spacingMd(); + ImGui::SetCursorPosX((ImGui::GetContentRegionAvail().x - totalW) * 0.5f); + + if (TactileButton(TR("cancel"), ImVec2(btnW, 0))) { + s_open = false; + } + ImGui::SameLine(0, Layout::spacingMd()); + + ImGui::BeginDisabled(!amountValid || s_sending); + if (TactileButton(s_sending ? TR("sending") : TR("confirm_transfer"), ImVec2(btnW, 0))) { + s_sending = true; + s_app->sendTransaction(s_info.fromAddr, s_info.toAddr, + amount, s_fee, "", + [](bool ok, const std::string& result) { + s_sending = false; + s_success = ok; + if (ok) + s_resultMsg = result; // opid + else + s_resultMsg = result; // error message + }); + } + ImGui::EndDisabled(); + + EndOverlayDialog(); + } + } + + static bool isOpen() { return s_open; } + static void close() { s_open = false; } + +private: + static void renderAddressRow(const std::string& addr, double balance, bool isZ, float dp) { + using namespace material; + ImDrawList* dl = ImGui::GetWindowDrawList(); + ImVec2 pos = ImGui::GetCursorScreenPos(); + float w = ImGui::GetContentRegionAvail().x; + float h = 40.0f * dp; + ImVec2 mn = pos, mx(pos.x + w, pos.y + h); + + GlassPanelSpec g; + g.rounding = 8.0f * dp; + g.fillAlpha = 18; + DrawGlassPanel(dl, mn, mx, g); + + float pad = 12.0f * dp; + // Type icon + ImFont* iconFont = Type().iconSmall(); + const char* icon = isZ ? ICON_MD_SHIELD : ICON_MD_CIRCLE; + ImU32 col = isZ ? Success() : Warning(); + float iconFsz = ScaledFontSize(iconFont); + ImVec2 iSz = iconFont->CalcTextSizeA(iconFsz, 1000.0f, 0.0f, icon); + dl->AddText(iconFont, iconFsz, ImVec2(mn.x + pad, mn.y + (h - iSz.y) * 0.5f), col, icon); + + // Address (truncated) + float textX = mn.x + pad + iSz.x + 8.0f * dp; + std::string display = addr; + if (display.size() > 42) display = display.substr(0, 18) + "..." + display.substr(display.size() - 14); + ImFont* capFont = Type().caption(); + float capFsz = ScaledFontSize(capFont); + dl->AddText(capFont, capFsz, ImVec2(textX, mn.y + (h * 0.5f - capFsz)), OnSurfaceMedium(), display.c_str()); + + // Balance (right-aligned) + char balBuf[32]; + snprintf(balBuf, sizeof(balBuf), "%.8f DRGX", balance); + ImFont* body = Type().body2(); + float bodyFsz = ScaledFontSize(body); + ImVec2 balSz = body->CalcTextSizeA(bodyFsz, 1000.0f, 0.0f, balBuf); + dl->AddText(body, bodyFsz, ImVec2(mx.x - pad - balSz.x, mn.y + (h - balSz.y) * 0.5f), + balance > 0 ? OnSurface() : OnSurfaceDisabled(), balBuf); + + ImGui::Dummy(ImVec2(w, h)); + } + + static void renderResult() { + using namespace material; + if (s_success) { + ImGui::Spacing(); + Type().text(TypeStyle::H6, TR("transfer_sent")); + ImGui::Spacing(); + Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("transfer_sent_desc")); + ImGui::Spacing(); + { + char buf[128]; + snprintf(buf, sizeof(buf), "Operation ID: %s", + s_resultMsg.size() > 40 + ? (s_resultMsg.substr(0, 20) + "..." + s_resultMsg.substr(s_resultMsg.size() - 10)).c_str() + : s_resultMsg.c_str()); + Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), buf); + } + } else { + ImGui::Spacing(); + Type().textColored(TypeStyle::H6, Error(), TR("transfer_failed")); + ImGui::Spacing(); + ImGui::TextWrapped("%s", s_resultMsg.c_str()); + } + + ImGui::Spacing(); + ImGui::Spacing(); + float btnW = 120.0f; + ImGui::SetCursorPosX((ImGui::GetContentRegionAvail().x - btnW) * 0.5f); + if (TactileButton(TR("close"), ImVec2(btnW, 0))) { + s_open = false; + } + } + + static inline bool s_open = false; + static inline App* s_app = nullptr; + static inline TransferInfo s_info; + static inline char s_amount[64] = {}; + static inline double s_fee = 0.0001; + static inline bool s_sending = false; + static inline bool s_success = false; + static inline std::string s_resultMsg; +}; + +} // namespace ui +} // namespace dragonx diff --git a/src/ui/windows/balance_tab.cpp b/src/ui/windows/balance_tab.cpp index e900f85..2801e2f 100644 --- a/src/ui/windows/balance_tab.cpp +++ b/src/ui/windows/balance_tab.cpp @@ -5,6 +5,8 @@ #include "balance_tab.h" #include "key_export_dialog.h" #include "qr_popup_dialog.h" +#include "address_label_dialog.h" +#include "address_transfer_dialog.h" #include "send_tab.h" #include "../../app.h" #include "../../config/settings.h" @@ -1360,50 +1362,70 @@ static void RenderSharedAddressList(App* app, float listH, float availW, Type().text(TypeStyle::H6, TR("your_addresses")); ImGui::Spacing(); + // ---- Persistent state ---- static char addr_search[128] = ""; static bool s_hideZeroBalances = true; static bool s_showHidden = false; - struct AddrRow { const AddressInfo* info; bool isZ; bool hidden; bool favorite; }; + // Drag state + static int s_dragIdx = -1; // row being dragged (-1 = none) + static float s_dragOffsetY = 0.0f; // mouse offset from row top + static float s_dragStartY = 0.0f; // mouse Y at drag start + static bool s_dragActive = false; // drag distance threshold passed + static int s_dropTargetIdx = -1; // row hovered during drag + + // Copy feedback + static int s_copiedRow = -1; + static float s_copiedTime = 0.0f; + + // ---- Build and filter address rows ---- + struct AddrRow { + const AddressInfo* info; + bool isZ, hidden, favorite; + std::string label, icon; + int sortOrder; + }; std::vector rows; rows.reserve(state.z_addresses.size() + state.t_addresses.size()); - for (const auto& a : state.z_addresses) { - std::string filter(addr_search); - if (!containsIgnoreCase(a.address, filter) && - !containsIgnoreCase(a.label, filter)) continue; - bool isHidden = app->isAddressHidden(a.address); - if (isHidden && !s_showHidden) continue; - bool isFav = app->isAddressFavorite(a.address); - if (s_hideZeroBalances && a.balance < 1e-9 && !isHidden && !isFav) continue; - rows.push_back({&a, true, isHidden, isFav}); - } - for (const auto& a : state.t_addresses) { - std::string filter(addr_search); - if (!containsIgnoreCase(a.address, filter) && - !containsIgnoreCase(a.label, filter)) continue; - bool isHidden = app->isAddressHidden(a.address); - if (isHidden && !s_showHidden) continue; - bool isFav = app->isAddressFavorite(a.address); - if (s_hideZeroBalances && a.balance < 1e-9 && !isHidden && !isFav) continue; - rows.push_back({&a, false, isHidden, isFav}); - } - static int s_sortCol = 3; - static bool s_sortAsc = false; + + auto addRows = [&](const auto& addrs, bool isZ) { + for (const auto& a : addrs) { + std::string filter(addr_search); + std::string addrLabel = app->getAddressLabel(a.address); + if (!containsIgnoreCase(a.address, filter) && + !containsIgnoreCase(a.label, filter) && + !containsIgnoreCase(addrLabel, filter)) continue; + bool isHidden = app->isAddressHidden(a.address); + if (isHidden && !s_showHidden) continue; + bool isFav = app->isAddressFavorite(a.address); + if (s_hideZeroBalances && a.balance < 1e-9 && !isHidden && !isFav) continue; + rows.push_back({&a, isZ, isHidden, isFav, + addrLabel, app->getAddressIcon(a.address), + app->getAddressSortOrder(a.address)}); + } + }; + addRows(state.z_addresses, true); + addRows(state.t_addresses, false); + + // Sort: custom order (if any) → favorites → Z first → balance desc std::sort(rows.begin(), rows.end(), [](const AddrRow& a, const AddrRow& b) -> bool { + bool aHasOrder = a.sortOrder >= 0; + bool bHasOrder = b.sortOrder >= 0; + if (aHasOrder && bHasOrder) return a.sortOrder < b.sortOrder; + if (aHasOrder != bHasOrder) return aHasOrder > bHasOrder; if (a.favorite != b.favorite) return a.favorite > b.favorite; if (a.isZ != b.isZ) return a.isZ > b.isZ; - if (s_sortAsc) return a.info->balance < b.info->balance; - else return a.info->balance > b.info->balance; + return a.info->balance > b.info->balance; }); - // Search + create buttons row + // ---- Toolbar: search + checkboxes + create buttons ---- float avail = ImGui::GetContentRegionAvail().x; float schemaMaxW = (searchIn.maxWidth >= 0) ? searchIn.maxWidth : 250.0f; float schemaRatio = (searchIn.widthRatio >= 0) ? searchIn.widthRatio : 0.30f; float searchW = std::min(schemaMaxW * hs, avail * schemaRatio); ImGui::SetNextItemWidth(searchW); - ImGui::InputTextWithHint("##AddrSearch", "Filter...", addr_search, sizeof(addr_search)); + ImGui::InputTextWithHint("##AddrSearch", TR("filter"), addr_search, sizeof(addr_search)); ImGui::SameLine(0, Layout::spacingLg()); ImGui::Checkbox(TrId("hide_zero_balances", "hide0_v2").c_str(), &s_hideZeroBalances); { @@ -1455,6 +1477,7 @@ static void RenderSharedAddressList(App* app, float listH, float availW, ImGui::Dummy(ImVec2(0, Layout::spacingXs())); + // ---- Glass panel container ---- float addrListH = listH; if (addrListH < 40.0f * dp) addrListH = 40.0f * dp; @@ -1477,6 +1500,7 @@ static void RenderSharedAddressList(App* app, float listH, float availW, ImGui::Dummy(ImVec2(0, Layout::spacingSm())); + // ---- Empty states ---- if (!app->isConnected()) { ImGui::Dummy(ImVec2(0, 16 * dp)); float cw = ImGui::GetContentRegionAvail().x; @@ -1487,23 +1511,17 @@ static void RenderSharedAddressList(App* app, float listH, float availW, float cw = ImGui::GetContentRegionAvail().x; float ch = ImGui::GetContentRegionAvail().y; if (ch < 60) ch = 60; - if (addr_search[0]) { - ImVec2 textSz = ImGui::CalcTextSize(TR("no_addresses_match")); - ImGui::SetCursorPosX((cw - textSz.x) * 0.5f); - ImGui::SetCursorPosY(ImGui::GetCursorPosY() + ch * 0.25f); - ImGui::TextDisabled("%s", TR("no_addresses_match")); - } else { - const char* msg = "No addresses yet"; - ImVec2 msgSz = ImGui::CalcTextSize(msg); - ImGui::SetCursorPosX((cw - msgSz.x) * 0.5f); - ImGui::SetCursorPosY(ImGui::GetCursorPosY() + ch * 0.25f); - ImGui::TextDisabled("%s", msg); - } + const char* emptyMsg = addr_search[0] ? TR("no_addresses_match") : TR("no_addresses_yet"); + ImVec2 msgSz = ImGui::CalcTextSize(emptyMsg); + ImGui::SetCursorPosX((cw - msgSz.x) * 0.5f); + ImGui::SetCursorPosY(ImGui::GetCursorPosY() + ch * 0.25f); + ImGui::TextDisabled("%s", emptyMsg); } else { + // ---- PASS 1: Compute row layout ---- ImDrawList* dl = ImGui::GetWindowDrawList(); ImFont* capFont = Type().caption(); ImFont* body2 = Type().body2(); - float rowH = body2->LegacySize + capFont->LegacySize + Layout::spacingLg() + Layout::spacingMd(); + float rowH = body2->LegacySize + capFont->LegacySize + Layout::spacingLg() + Layout::spacingMd() + Layout::spacingMd(); static int selected_row = -1; addrScrollY = ImGui::GetScrollY(); addrScrollMaxY = ImGui::GetScrollMaxY(); @@ -1515,181 +1533,345 @@ static void RenderSharedAddressList(App* app, float listH, float availW, S.drawElement("tabs.balance", "address-icon-size").size * hs); float innerW = ImGui::GetContentRegionAvail().x; + // Theme colors for buttons (resolved once) + ImU32 favGoldFill = S.resolveColor("var(--favorite-fill)", IM_COL32(255, 200, 50, 40)); + ImU32 favGoldBorder = S.resolveColor("var(--favorite-border)", IM_COL32(255, 200, 50, 100)); + ImU32 favGoldIcon = S.resolveColor("var(--favorite-icon)", IM_COL32(255, 200, 50, 255)); + ImU32 favGoldOutline= S.resolveColor("var(--favorite-outline)", IM_COL32(255, 200, 50, 120)); + ImU32 btnFill = S.resolveColor("var(--row-button-fill)", IM_COL32(255, 255, 255, 12)); + ImU32 btnFillHov = S.resolveColor("var(--row-button-fill-hover)", IM_COL32(255, 255, 255, 25)); + ImU32 btnBorder = S.resolveColor("var(--row-button-border)", IM_COL32(255, 255, 255, 25)); + ImU32 btnBorderHov = S.resolveColor("var(--row-button-border-hover)", IM_COL32(255, 255, 255, 50)); + ImU32 rowHoverCol = S.resolveColor("var(--row-hover)", IM_COL32(255, 255, 255, 15)); + ImU32 rowSelectCol = S.resolveColor("var(--row-select)", IM_COL32(255, 255, 255, 20)); + ImU32 dividerCol = S.resolveColor("var(--sidebar-divider)", IM_COL32(255, 255, 255, 15)); + + // Compute Y positions for all rows (pass 1) + std::vector rowY(rows.size()); + float cursorStartY = ImGui::GetCursorScreenPos().y; + for (int i = 0; i < (int)rows.size(); ++i) + rowY[i] = cursorStartY + i * rowH; + + // Drag logic — detect drag start, compute drag position + ImVec2 mousePos = ImGui::GetMousePos(); + bool mouseDown = ImGui::IsMouseDown(ImGuiMouseButton_Left); + bool mouseClicked = ImGui::IsMouseClicked(ImGuiMouseButton_Left); + + // Reset drop target each frame — it gets set only if mouse is over a row + if (s_dragActive) s_dropTargetIdx = -1; + + if (s_dragIdx >= 0 && !mouseDown) { + // Mouse released — copy if it was a click (no drag activated) + if (!s_dragActive && s_dragIdx >= 0 && s_dragIdx < (int)rows.size()) { + const auto& clickRow = rows[s_dragIdx]; + ImGui::SetClipboardText(clickRow.info->address.c_str()); + selected_row = s_dragIdx; + s_copiedRow = s_dragIdx; + s_copiedTime = (float)ImGui::GetTime(); + } + // Drop + if (s_dragActive && s_dropTargetIdx >= 0 && s_dropTargetIdx != s_dragIdx) { + const auto& srcRow = rows[s_dragIdx]; + const auto& dstRow = rows[s_dropTargetIdx]; + // Transfer: if dropped on another row and drag was active + if (srcRow.info->balance > 1e-9) { + AddressTransferDialog::TransferInfo ti; + ti.fromAddr = srcRow.info->address; + ti.toAddr = dstRow.info->address; + ti.fromBalance = srcRow.info->balance; + ti.toBalance = dstRow.info->balance; + ti.fromIsZ = srcRow.isZ; + ti.toIsZ = dstRow.isZ; + AddressTransferDialog::show(app, ti); + } + } else if (s_dragActive && s_dropTargetIdx < 0) { + // Reorder: dropped in gap — compute insert position from mouseY + int insertIdx = 0; + for (int i = 0; i < (int)rows.size(); ++i) { + if (mousePos.y > rowY[i] + rowH * 0.5f) insertIdx = i + 1; + } + if (insertIdx != s_dragIdx && insertIdx != s_dragIdx + 1) { + int targetIdx = (insertIdx > s_dragIdx) ? insertIdx - 1 : insertIdx; + if (targetIdx >= 0 && targetIdx < (int)rows.size()) { + app->swapAddressOrder(rows[s_dragIdx].info->address, + rows[targetIdx].info->address); + } + } + } + s_dragIdx = -1; + s_dragActive = false; + s_dropTargetIdx = -1; + } + + // ---- PASS 2: Render rows ---- for (int row_idx = 0; row_idx < (int)rows.size(); row_idx++) { const auto& row = rows[row_idx]; const auto& addr = *row.info; - ImVec2 rowPos = ImGui::GetCursorScreenPos(); - ImVec2 rowEnd(rowPos.x + innerW, rowPos.y + rowH); + bool isDragged = (s_dragIdx == row_idx && s_dragActive); + + // For dragged row, offset Y to follow mouse + float drawY = rowY[row_idx]; + if (isDragged) { + drawY = mousePos.y - s_dragOffsetY; + } + + ImVec2 rowPos(ImGui::GetCursorScreenPos().x, drawY); + ImVec2 rowEnd(rowPos.x + innerW, drawY + rowH); ImU32 typeCol = row.isZ ? greenCol : goldCol; if (row.hidden) typeCol = OnSurfaceDisabled(); - // Golden border for favorites - if (row.favorite) { - ImU32 favBorder = IM_COL32(255, 200, 50, 120); - dl->AddRect(rowPos, rowEnd, favBorder, 4.0f * dp, 0, 1.5f * dp); + // Dragged row: draw with semi-transparent elevation + if (isDragged) { + dl->AddRectFilled(ImVec2(rowPos.x - 2*dp, rowPos.y - 2*dp), + ImVec2(rowEnd.x + 2*dp, rowEnd.y + 2*dp), + IM_COL32(0, 0, 0, 30), 6.0f * dp); + dl->AddRectFilled(rowPos, rowEnd, IM_COL32(30, 30, 40, 120), 4.0f * dp); + // Tooltip following cursor — show transfer intent if over a target row + if (s_dropTargetIdx >= 0 && s_dropTargetIdx < (int)rows.size()) { + const auto& target = rows[s_dropTargetIdx]; + ImGui::SetTooltip("%s\n%s\n\n%s %s", + truncateAddress(addr.address, 32).c_str(), + row.isZ ? TR("shielded") : TR("transparent"), + TR("transfer_to"), + truncateAddress(target.info->address, 32).c_str()); + } else { + ImGui::SetTooltip("%s\n%s", + truncateAddress(addr.address, 32).c_str(), + row.isZ ? TR("shielded") : TR("transparent")); + } } - if (selected_row == row_idx) { + // Golden border for favorites — inset so it doesn't touch container + if (row.favorite) { + float inset = 2.0f * dp; + dl->AddRect(ImVec2(rowPos.x + inset, rowPos.y + inset), + ImVec2(rowEnd.x - inset, rowEnd.y - inset), + favGoldOutline, 4.0f * dp, 0, 1.5f * dp); + } + + // Selection highlight + if (selected_row == row_idx && !isDragged) { ImDrawFlags accentFlags = 0; float accentRound = 2.0f * dp; if (row_idx == 0) { accentFlags = ImDrawFlags_RoundCornersTopLeft; accentRound = glassRound; } if (row_idx == (int)rows.size() - 1) { accentFlags |= ImDrawFlags_RoundCornersBottomLeft; accentRound = glassRound; } dl->AddRectFilled(rowPos, ImVec2(rowPos.x + 3 * dp, rowEnd.y), typeCol, accentRound, accentFlags); - dl->AddRectFilled(rowPos, rowEnd, IM_COL32(255, 255, 255, 20), 4.0f * dp); + dl->AddRectFilled(rowPos, rowEnd, rowSelectCol, 4.0f * dp); } - bool hovered = material::IsRectHovered(rowPos, rowEnd); - if (hovered && selected_row != row_idx) { - dl->AddRectFilled(rowPos, rowEnd, IM_COL32(255, 255, 255, 15), 4.0f * dp); + // Hover effects + bool hovered = !isDragged && material::IsRectHovered(rowPos, rowEnd); + + // Drop target highlight when dragging another row over this one + if (s_dragActive && s_dragIdx != row_idx && material::IsRectHovered(rowPos, rowEnd)) { + s_dropTargetIdx = row_idx; + ImU32 dropCol = WithAlpha(Primary(), 40); + dl->AddRectFilled(rowPos, rowEnd, dropCol, 4.0f * dp); + dl->AddRect(rowPos, rowEnd, WithAlpha(Primary(), 140), 4.0f * dp, 0, 2.0f * dp); + } + + if (hovered && selected_row != row_idx && !s_dragActive) { + dl->AddRectFilled(rowPos, rowEnd, rowHoverCol, 4.0f * dp); ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); } float cx = rowPos.x + rowPadLeft; float cy = rowPos.y + Layout::spacingMd(); - // --- Button zone (right edge): [eye] [star] --- - float btnH = rowH - Layout::spacingSm() * 2.0f; + // ---- Button zone (right edge): [eye] [star] ---- + float btnH = rowH - Layout::spacingMd() * 2.0f; float btnW = btnH; float btnGap = Layout::spacingSm(); float btnY = rowPos.y + (rowH - btnH) * 0.5f; float rightEdge = rowPos.x + innerW; - float starX = rightEdge - btnW - rowPadRight; + float starX = rightEdge - btnW - Layout::spacingXs(); float eyeX = starX - btnGap - btnW; float btnRound = 6.0f * dp; bool btnClicked = false; - // Star button (always shown, rightmost) - { - ImVec2 bMin(starX, btnY), bMax(starX + btnW, btnY + btnH); - bool bHov = ImGui::IsMouseHoveringRect(bMin, bMax); - ImU32 starFill = row.favorite ? IM_COL32(255, 200, 50, 40) : IM_COL32(255, 255, 255, bHov ? 25 : 12); - ImU32 starBorder = row.favorite ? IM_COL32(255, 200, 50, 100) : IM_COL32(255, 255, 255, bHov ? 50 : 25); - dl->AddRectFilled(bMin, bMax, starFill, btnRound); - dl->AddRect(bMin, bMax, starBorder, btnRound, 0, 1.0f * dp); - ImFont* iconFont = material::Type().iconSmall(); - const char* starIcon = row.favorite ? ICON_MD_STAR : ICON_MD_STAR_BORDER; - ImVec2 iSz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, starIcon); - ImU32 starCol = row.favorite ? IM_COL32(255, 200, 50, 255) : (bHov ? OnSurface() : OnSurfaceDisabled()); - dl->AddText(iconFont, iconFont->LegacySize, - ImVec2(starX + (btnW - iSz.x) * 0.5f, btnY + (btnH - iSz.y) * 0.5f), starCol, starIcon); - if (bHov && ImGui::IsMouseClicked(0)) { - if (row.favorite) app->unfavoriteAddress(addr.address); - else app->favoriteAddress(addr.address); - btnClicked = true; + if (!isDragged) { + // Star button + { + ImVec2 bMin(starX, btnY), bMax(starX + btnW, btnY + btnH); + bool bHov = ImGui::IsMouseHoveringRect(bMin, bMax); + dl->AddRectFilled(bMin, bMax, row.favorite ? favGoldFill : (bHov ? btnFillHov : btnFill), btnRound); + dl->AddRect(bMin, bMax, row.favorite ? favGoldBorder : (bHov ? btnBorderHov : btnBorder), btnRound, 0, 1.0f * dp); + ImFont* iconFont = Type().iconSmall(); + const char* starIcon = row.favorite ? ICON_MD_STAR : ICON_MD_STAR_BORDER; + ImVec2 iSz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, starIcon); + ImU32 starCol = row.favorite ? favGoldIcon : (bHov ? OnSurface() : OnSurfaceDisabled()); + dl->AddText(iconFont, iconFont->LegacySize, + ImVec2(starX + (btnW - iSz.x) * 0.5f, btnY + (btnH - iSz.y) * 0.5f), starCol, starIcon); + if (bHov && mouseClicked) { + if (row.favorite) app->unfavoriteAddress(addr.address); + else app->favoriteAddress(addr.address); + btnClicked = true; + } + if (bHov) ImGui::SetTooltip("%s", row.favorite ? TR("remove_favorite") : TR("favorite_address")); } - if (bHov) ImGui::SetTooltip("%s", row.favorite ? TR("remove_favorite") : TR("favorite_address")); - } - // Eye button (zero balance or hidden) - if (addr.balance < 1e-9 || row.hidden) { - ImVec2 bMin(eyeX, btnY), bMax(eyeX + btnW, btnY + btnH); - bool bHov = ImGui::IsMouseHoveringRect(bMin, bMax); - ImU32 eyeFill = IM_COL32(255, 255, 255, bHov ? 25 : 12); - ImU32 eyeBorder = IM_COL32(255, 255, 255, bHov ? 50 : 25); - dl->AddRectFilled(bMin, bMax, eyeFill, btnRound); - dl->AddRect(bMin, bMax, eyeBorder, btnRound, 0, 1.0f * dp); - ImFont* iconFont = material::Type().iconSmall(); - const char* hideIcon = row.hidden ? ICON_MD_VISIBILITY : ICON_MD_VISIBILITY_OFF; - ImVec2 iSz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, hideIcon); - ImU32 iconCol = bHov ? OnSurface() : OnSurfaceDisabled(); - dl->AddText(iconFont, iconFont->LegacySize, - ImVec2(eyeX + (btnW - iSz.x) * 0.5f, btnY + (btnH - iSz.y) * 0.5f), iconCol, hideIcon); - if (bHov && ImGui::IsMouseClicked(0)) { - if (row.hidden) app->unhideAddress(addr.address); - else app->hideAddress(addr.address); - btnClicked = true; + // Eye button (zero balance or hidden) + bool showEye = true; + // Always reserve space for both buttons so content doesn't shift + float contentRight = eyeX - Layout::spacingSm(); + + if (showEye) { + ImVec2 bMin(eyeX, btnY), bMax(eyeX + btnW, btnY + btnH); + bool bHov = ImGui::IsMouseHoveringRect(bMin, bMax); + dl->AddRectFilled(bMin, bMax, bHov ? btnFillHov : btnFill, btnRound); + dl->AddRect(bMin, bMax, bHov ? btnBorderHov : btnBorder, btnRound, 0, 1.0f * dp); + ImFont* iconFont = Type().iconSmall(); + const char* hideIcon = row.hidden ? ICON_MD_VISIBILITY : ICON_MD_VISIBILITY_OFF; + ImVec2 iSz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, hideIcon); + ImU32 iconCol = bHov ? OnSurface() : OnSurfaceDisabled(); + dl->AddText(iconFont, iconFont->LegacySize, + ImVec2(eyeX + (btnW - iSz.x) * 0.5f, btnY + (btnH - iSz.y) * 0.5f), iconCol, hideIcon); + if (bHov && mouseClicked) { + if (row.hidden) app->unhideAddress(addr.address); + else app->hideAddress(addr.address); + btnClicked = true; + } + if (bHov) ImGui::SetTooltip("%s", row.hidden ? TR("restore_address") : TR("hide_address")); } - if (bHov) ImGui::SetTooltip("%s", row.hidden ? TR("restore_address") : TR("hide_address")); - } - // Content zone ends before buttons - float contentRight = eyeX - Layout::spacingSm(); + // ---- Type icon or custom icon ---- + float iconCx = cx + rowIconSz; + float iconCy = cy + body2->LegacySize * 0.5f; + { + const char* customGlyph = row.icon.empty() ? nullptr : AddressLabelDialog::iconGlyphForName(row.icon); + ImFont* iconFont = Type().iconSmall(); + const char* glyph = customGlyph ? customGlyph : (row.isZ ? ICON_MD_SHIELD : ICON_MD_CIRCLE); + ImU32 icCol = customGlyph ? OnSurfaceMedium() : typeCol; + ImVec2 iSz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, glyph); + dl->AddText(iconFont, iconFont->LegacySize, + ImVec2(iconCx - iSz.x * 0.5f, iconCy - iSz.y * 0.5f), icCol, glyph); + } - float iconCx = cx + rowIconSz; - float iconCy = cy + body2->LegacySize * 0.5f; - if (row.isZ) { - ImFont* iconFont = material::Type().iconSmall(); - const char* shieldIcon = ICON_MD_SHIELD; - ImVec2 iSz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, shieldIcon); - dl->AddText(iconFont, iconFont->LegacySize, - ImVec2(iconCx - iSz.x * 0.5f, iconCy - iSz.y * 0.5f), typeCol, shieldIcon); - } else { - ImFont* iconFont = material::Type().iconSmall(); - const char* circIcon = ICON_MD_CIRCLE; - ImVec2 iSz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, circIcon); - dl->AddText(iconFont, iconFont->LegacySize, - ImVec2(iconCx - iSz.x * 0.5f, iconCy - iSz.y * 0.5f), typeCol, circIcon); - } + // ---- Type label (first line) ---- + float labelX = cx + rowIconSz * 2.0f + Layout::spacingMd(); + { + const char* typeLabel = row.isZ ? TR("shielded") : TR("transparent"); + const char* hiddenTag = row.hidden ? TR("hidden_tag") : ""; + char typeBuf[64]; + snprintf(typeBuf, sizeof(typeBuf), "%s%s", typeLabel, hiddenTag); + dl->AddText(capFont, capFont->LegacySize, ImVec2(labelX, cy), typeCol, typeBuf); - float labelX = cx + rowIconSz * 2.0f + Layout::spacingSm(); - const char* typeLabel = row.isZ ? "Shielded" : "Transparent"; - const char* hiddenTag = row.hidden ? " (hidden)" : ""; - char typeBuf[64]; - snprintf(typeBuf, sizeof(typeBuf), "%s%s", typeLabel, hiddenTag); - dl->AddText(capFont, capFont->LegacySize, ImVec2(labelX, cy), typeCol, typeBuf); - if (!addr.label.empty()) { - float typeLabelW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, typeBuf).x; - dl->AddText(capFont, capFont->LegacySize, - ImVec2(labelX + typeLabelW + Layout::spacingLg(), cy), - OnSurfaceMedium(), addr.label.c_str()); - } + // User label next to type + if (!row.label.empty()) { + float typeLabelW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, typeBuf).x; + dl->AddText(capFont, capFont->LegacySize, + ImVec2(labelX + typeLabelW + Layout::spacingLg(), cy), + OnSurfaceMedium(), row.label.c_str()); + } + } - // Address (second line) — show full if it fits, otherwise truncate - float addrAvailW = contentRight - labelX; - ImVec2 fullAddrSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0.0f, addr.address.c_str()); - std::string display_addr; - if (fullAddrSz.x <= addrAvailW) { - display_addr = addr.address; - } else { - int addrTruncLen = (addrTable.columns.count("address") && addrTable.columns.at("address").truncate > 0) - ? addrTable.columns.at("address").truncate : 32; - display_addr = truncateAddress(addr.address, addrTruncLen); - } - dl->AddText(capFont, capFont->LegacySize, - ImVec2(labelX, cy + body2->LegacySize + Layout::spacingXs()), - OnSurfaceMedium(), display_addr.c_str()); + // ---- Address (second line) ---- + { + float addrAvailW = contentRight - labelX; + ImVec2 fullAddrSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0.0f, addr.address.c_str()); + std::string display_addr; + if (fullAddrSz.x <= addrAvailW) { + display_addr = addr.address; + } else { + int addrTruncLen = (addrTable.columns.count("address") && addrTable.columns.at("address").truncate > 0) + ? addrTable.columns.at("address").truncate : 32; + display_addr = truncateAddress(addr.address, addrTruncLen); + } + dl->AddText(capFont, capFont->LegacySize, + ImVec2(labelX, cy + body2->LegacySize + Layout::spacingXs()), + OnSurfaceMedium(), display_addr.c_str()); + } - // Balance (right-aligned within content zone) - char balBuf[32]; - snprintf(balBuf, sizeof(balBuf), "%.8f", addr.balance); - ImVec2 balSz = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, balBuf); - float balX = contentRight - balSz.x; - ImU32 balCol = addr.balance > 0.0 - ? (row.isZ ? greenCol : OnSurface()) - : OnSurfaceDisabled(); - if (row.hidden) balCol = OnSurfaceDisabled(); - DrawTextShadow(dl, body2, body2->LegacySize, ImVec2(balX, cy), balCol, balBuf); + // ---- Balance (right-aligned, first line) ---- + { + char balBuf[32]; + snprintf(balBuf, sizeof(balBuf), "%.8f", addr.balance); + ImVec2 balSz = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, balBuf); + float balX = contentRight - balSz.x; + ImU32 balCol = addr.balance > 0.0 + ? (row.isZ ? greenCol : OnSurface()) + : OnSurfaceDisabled(); + if (row.hidden) balCol = OnSurfaceDisabled(); + DrawTextShadow(dl, body2, body2->LegacySize, ImVec2(balX, cy), balCol, balBuf); + } - double priceUsd = state.market.price_usd; - if (priceUsd > 0.0 && addr.balance > 0.0) { - char usdBuf[32]; - double usdVal = addr.balance * priceUsd; - if (usdVal >= 1.0) snprintf(usdBuf, sizeof(usdBuf), "$%.2f", usdVal); - else if (usdVal >= 0.01) snprintf(usdBuf, sizeof(usdBuf), "$%.4f", usdVal); - else snprintf(usdBuf, sizeof(usdBuf), "$%.6f", usdVal); - ImVec2 usdSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, usdBuf); - dl->AddText(capFont, capFont->LegacySize, - ImVec2(contentRight - usdSz.x, - cy + body2->LegacySize + Layout::spacingXs()), - OnSurfaceDisabled(), usdBuf); - } + // ---- USD value (second line, right-aligned) ---- + { + double priceUsd = state.market.price_usd; + if (priceUsd > 0.0 && addr.balance > 0.0) { + char usdBuf[32]; + double usdVal = addr.balance * priceUsd; + if (usdVal >= 1.0) snprintf(usdBuf, sizeof(usdBuf), "$%.2f", usdVal); + else if (usdVal >= 0.01) snprintf(usdBuf, sizeof(usdBuf), "$%.4f", usdVal); + else snprintf(usdBuf, sizeof(usdBuf), "$%.6f", usdVal); + ImVec2 usdSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, usdBuf); + dl->AddText(capFont, capFont->LegacySize, + ImVec2(contentRight - usdSz.x, + cy + body2->LegacySize + Layout::spacingXs()), + OnSurfaceDisabled(), usdBuf); + } + } - if (hovered && ImGui::IsMouseClicked(0) && !btnClicked) { - ImGui::SetClipboardText(addr.address.c_str()); - selected_row = row_idx; - } + // ---- Copy feedback flash ---- + if (s_copiedRow == row_idx) { + float elapsed = (float)ImGui::GetTime() - s_copiedTime; + if (elapsed < 1.0f) { + float alpha = 1.0f - elapsed; + ImFont* capF = Type().caption(); + const char* copiedTxt = TR("copied"); + ImVec2 sz = capF->CalcTextSizeA(capF->LegacySize, 1000.0f, 0.0f, copiedTxt); + ImVec4 fc = ImGui::ColorConvertU32ToFloat4(Success()); + fc.w = alpha; + float tx = rowPos.x + (innerW - sz.x) * 0.5f; + float ty = rowPos.y + (rowH - sz.y) * 0.5f; + dl->AddText(capF, capF->LegacySize, ImVec2(tx, ty), + ImGui::ColorConvertFloat4ToU32(fc), copiedTxt); + } else { + s_copiedRow = -1; + } + } + // ---- Click: begin drag tracking (copy deferred to mouse-up) ---- + if (hovered && mouseClicked && !btnClicked) { + s_dragIdx = row_idx; + s_dragOffsetY = mousePos.y - rowPos.y; + s_dragStartY = mousePos.y; + s_dragActive = false; + } + + // Activate drag after a small threshold + if (s_dragIdx == row_idx && !s_dragActive && mouseDown) { + if (std::abs(mousePos.y - s_dragStartY) > 6.0f * dp) { + s_dragActive = true; + s_dropTargetIdx = -1; + } + } + } // end !isDragged + + // Advance cursor for interaction area ImGui::PushID(row_idx); + ImGui::SetCursorScreenPos(ImVec2(rowPos.x, rowY[row_idx])); ImGui::InvisibleButton("##addr", ImVec2(innerW, rowH)); - if (ImGui::IsItemHovered() && !btnClicked) ImGui::SetTooltip("%s", addr.address.c_str()); + if (ImGui::IsItemHovered() && !s_dragActive) { + ImGui::SetTooltip("%s", addr.address.c_str()); + } + + // Context menu const auto& acrTheme = GetCurrentAcrylicTheme(); if (effects::ImGuiAcrylic::BeginAcrylicContextItem("AddressContext", 0, acrTheme.menu)) { - if (ImGui::MenuItem(TR("copy_address"))) ImGui::SetClipboardText(addr.address.c_str()); + if (ImGui::MenuItem(TR("copy_address"))) { + ImGui::SetClipboardText(addr.address.c_str()); + s_copiedRow = row_idx; + s_copiedTime = (float)ImGui::GetTime(); + } if (ImGui::MenuItem(TR("send_from_this_address"))) { SetSendFromAddress(addr.address); app->setCurrentPage(NavPage::Send); } ImGui::Separator(); + if (ImGui::MenuItem(TR("set_label"))) { + AddressLabelDialog::show(app, addr.address, row.isZ); + } if (ImGui::MenuItem(TR("export_private_key"))) KeyExportDialog::show(addr.address, KeyExportDialog::KeyType::Private); if (row.isZ) { @@ -1697,7 +1879,7 @@ static void RenderSharedAddressList(App* app, float listH, float availW, KeyExportDialog::show(addr.address, KeyExportDialog::KeyType::Viewing); } if (ImGui::MenuItem(TR("show_qr_code"))) - QRPopupDialog::show(addr.address, row.isZ ? "Z-Address" : "T-Address"); + QRPopupDialog::show(addr.address, row.isZ ? TR("z_address") : TR("t_address")); ImGui::Separator(); if (row.hidden) { if (ImGui::MenuItem(TR("restore_address"))) @@ -1717,20 +1899,49 @@ static void RenderSharedAddressList(App* app, float listH, float availW, } ImGui::PopID(); + // Separator between rows if (row_idx < (int)rows.size() - 1 && selected_row != row_idx) { - ImVec2 divStart = ImGui::GetCursorScreenPos(); + float sepY = rowY[row_idx] + rowH; dl->AddLine( - ImVec2(divStart.x + rowPadLeft + rowIconSz * 2.0f, divStart.y), - ImVec2(divStart.x + innerW - Layout::spacingLg(), divStart.y), - IM_COL32(255, 255, 255, 15)); + ImVec2(rowPos.x + rowPadLeft + rowIconSz * 2.0f, sepY), + ImVec2(rowPos.x + innerW - Layout::spacingLg(), sepY), + dividerCol); } } + + // Advance cursor past all rows + ImGui::SetCursorScreenPos(ImVec2(ImGui::GetCursorScreenPos().x, + cursorStartY + (float)rows.size() * rowH)); + + // ---- Keyboard navigation ---- + if (!ImGui::GetIO().WantTextInput && !ImGui::GetIO().KeyCtrl && !s_dragActive) { + bool nav = false; + if (ImGui::IsKeyPressed(ImGuiKey_UpArrow) || ImGui::IsKeyPressed(ImGuiKey_K)) { + if (selected_row > 0) { selected_row--; nav = true; } + else if (selected_row < 0 && !rows.empty()) { selected_row = 0; nav = true; } + } + if (ImGui::IsKeyPressed(ImGuiKey_DownArrow) || ImGui::IsKeyPressed(ImGuiKey_J)) { + if (selected_row < (int)rows.size() - 1) { selected_row++; nav = true; } + else if (selected_row < 0 && !rows.empty()) { selected_row = 0; nav = true; } + } + if (ImGui::IsKeyPressed(ImGuiKey_Enter) && selected_row >= 0 && selected_row < (int)rows.size()) { + ImGui::SetClipboardText(rows[selected_row].info->address.c_str()); + s_copiedRow = selected_row; + s_copiedTime = (float)ImGui::GetTime(); + } + if (ImGui::IsKeyPressed(ImGuiKey_F2) && selected_row >= 0 && selected_row < (int)rows.size()) { + const auto& r = rows[selected_row]; + AddressLabelDialog::show(app, r.info->address, r.isZ); + } + (void)nav; + } } ImGui::Dummy(ImVec2(0, Layout::spacingSm())); ImGui::EndChild(); ImGui::PopStyleVar(); + // Scroll fade { float fadeZone = std::min( (Type().body2()->LegacySize + Type().caption()->LegacySize + @@ -1740,11 +1951,12 @@ static void RenderSharedAddressList(App* app, float listH, float availW, listPanelMin.y, listPanelMax.y, fadeZone, addrScrollY, addrScrollMaxY); } + // Address count { ImGui::Dummy(ImVec2(0, Layout::spacingSm())); char countBuf[128]; int totalAddrs = (int)(state.z_addresses.size() + state.t_addresses.size()); - snprintf(countBuf, sizeof(countBuf), "Showing %d of %d addresses", + snprintf(countBuf, sizeof(countBuf), TR("showing_x_of_y"), (int)rows.size(), totalAddrs); Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), countBuf); } diff --git a/src/ui/windows/peers_tab.cpp b/src/ui/windows/peers_tab.cpp index 88d8b2e..4c8ae84 100644 --- a/src/ui/windows/peers_tab.cpp +++ b/src/ui/windows/peers_tab.cpp @@ -550,9 +550,13 @@ void RenderPeersTab(App* app) float iconSz = iconFont->LegacySize; const char* label = isRefreshing ? TR("peers_refreshing") : TR("peers_refresh"); ImVec2 lblSz = ovFont->CalcTextSizeA(ovFont->LegacySize, FLT_MAX, 0, label); + // Use the wider label so button doesn't resize between states + ImVec2 altSz = ovFont->CalcTextSizeA(ovFont->LegacySize, FLT_MAX, 0, + isRefreshing ? TR("peers_refresh") : TR("peers_refreshing")); + float maxLblW = std::max(lblSz.x, altSz.x); float padH = Layout::spacingSm(); float padV = Layout::spacingSm(); - float btnW = padH + iconSz + Layout::spacingXs() + lblSz.x + padH; + float btnW = padH + iconSz + Layout::spacingXs() + maxLblW + padH; float btnH = std::max(iconSz, lblSz.y) + padV * 2; float btnX = ImGui::GetWindowPos().x + availWidth - btnW - Layout::spacingSm(); float btnY = toggleY + (toggleH - btnH) * 0.5f; diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index 410fa6d..470a17d 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -401,6 +401,32 @@ void I18n::loadBuiltinEnglish() strings_["remove_favorite"] = "Remove Favorite"; strings_["favorite_address"] = "Favorite Address"; strings_["show_hidden"] = "Show Hidden (%d)"; + strings_["filter"] = "Filter..."; + strings_["no_addresses_yet"] = "No addresses yet"; + strings_["showing_x_of_y"] = "Showing %d of %d addresses"; + strings_["set_label"] = "Set Label..."; + strings_["copied"] = "Copied!"; + strings_["hidden_tag"] = " (hidden)"; + strings_["z_address"] = "Z-Address"; + strings_["t_address"] = "T-Address"; + strings_["shielded_address"] = "Shielded Address"; + strings_["transparent_address"] = "Transparent Address"; + strings_["label_placeholder"] = "e.g. Savings, Mining..."; + strings_["choose_icon"] = "Choose Icon"; + strings_["clear_icon"] = "Clear Icon"; + strings_["transfer_funds"] = "Transfer Funds"; + strings_["transfer_to"] = "Transfer to:"; + strings_["deshielding_warning"] = "Warning: This will de-shield funds from a private (Z) address to a transparent (T) address."; + strings_["shielding_notice"] = "Note: This will shield funds from a transparent (T) address to a private (Z) address."; + strings_["result_preview"] = "Result Preview"; + strings_["sender_balance"] = "Sender: %.8f \xe2\x86\x92 %.8f DRGX"; + strings_["recipient_balance"] = "Recipient: %.8f \xe2\x86\x92 %.8f DRGX"; + strings_["insufficient_funds"] = "Insufficient funds for this amount plus fee."; + strings_["sends_full_balance_warning"] = "This sends the full balance. The sending address will have a zero balance."; + strings_["confirm_transfer"] = "Confirm Transfer"; + strings_["transfer_sent"] = "Transfer Sent"; + strings_["transfer_sent_desc"] = "Your transfer has been submitted to the network."; + strings_["transfer_failed"] = "Transfer Failed"; // Misc dialog strings strings_["shield_operation_id"] = "Operation ID: %s";