diff --git a/CMakeLists.txt b/CMakeLists.txt index e65eb1f..6f6fe43 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -433,6 +433,7 @@ set_source_files_properties( ${CMAKE_SOURCE_DIR}/res/fonts/Ubuntu-Light.ttf;\ ${CMAKE_SOURCE_DIR}/res/fonts/Ubuntu-Medium.ttf;\ ${CMAKE_SOURCE_DIR}/res/fonts/MaterialIcons-Regular.ttf;\ +${CMAKE_SOURCE_DIR}/res/fonts/MaterialDesignIcons-Pickaxe-Subset.ttf;\ ${CMAKE_SOURCE_DIR}/res/fonts/NotoSansCJK-Subset.ttf" ) diff --git a/README.md b/README.md index 6e85098..47cb3e7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ -# DragonX Wallet - ImGui Edition +# ObsidianDragon - DragonX Wallet -A lightweight, portable cryptocurrency wallet for DragonX (DRGX), built with Dear ImGui. +A lightweight, portable full-node cryptocurrency wallet for DragonX (DRGX), built with Dear ImGui. + +Current pre-release: **1.2.0-rc1**. ![License](https://img.shields.io/badge/License-GPLv3-blue.svg) ![Platform](https://img.shields.io/badge/Platform-Linux%20%7C%20Windows%20%7C%20macOS-green.svg) @@ -9,10 +11,13 @@ A lightweight, portable cryptocurrency wallet for DragonX (DRGX), built with Dea - **Full Node Support**: Connects to dragonxd for complete blockchain verification - **Shielded Transactions**: Full z-address support with encrypted memos -- **Integrated Mining**: CPU mining controls with hashrate monitoring +- **Address Management**: Labels, icons, favorites, hidden addresses, and address-to-address transfers +- **Integrated Mining**: Solo CPU mining plus pool mining through xmrig, with idle-mining controls +- **Explorer Tools**: Block/transaction lookup and bootstrap snapshot download - **Market Data**: Real-time price charts from CoinGecko - **QR Codes**: Generate and display QR codes for receiving addresses -- **Multi-language**: i18n support (English, Spanish, more coming) +- **Multi-language**: i18n support for English, German, Spanish, French, Japanese, Korean, Portuguese, Russian, and Chinese +- **CJK Fonts**: Bundled CJK subset font for translated interfaces - **Lightweight**: ~5-10MB binary vs ~50MB+ for Qt version - **Fast Builds**: Compiles in seconds, not minutes @@ -116,7 +121,8 @@ cd ObsidianDragon/ ./ObsidianDragon ``` -The wallet will automatically connect to the daemon using credentials from \`~/.hush/DRAGONX/DRAGONX.conf\`. +The wallet will automatically connect to the daemon using credentials from `~/.hush/DRAGONX/DRAGONX.conf`. + ### Using Custom Node Binaries The wallet checks its **own directory first** when looking for DragonX node binaries. This means you can test new or different branch builds of `hush-arrakis-chain`/`hushd` without waiting for a new wallet release: @@ -131,9 +137,10 @@ The wallet checks its **own directory first** when looking for DragonX node bina 3. System-wide locations (`/usr/local/bin`, `~/dragonx/src`, etc.) This is useful for testing new branches or hotfixes to the node software before they are bundled into a wallet release. + ## Configuration -Configuration is stored in \`~/.hush/DRAGONX/DRAGONX.conf\`: +Configuration is stored in `~/.hush/DRAGONX/DRAGONX.conf`: ``` rpcuser=your_rpc_user @@ -148,44 +155,46 @@ ObsidianDragon/ ├── src/ │ ├── main.cpp # Entry point, SDL/ImGui setup │ ├── app.cpp/h # Main application class -│ ├── wallet_state.h # Wallet data structures -│ ├── version.h # Version definitions +│ ├── data/ # WalletState, address book, exchange info +│ ├── config/ # Settings persistence and generated version.h │ ├── ui/ -│ │ ├── theme.cpp/h # DragonX theme -│ │ └── windows/ # UI tabs and dialogs +│ │ ├── schema/ # TOML UI schema and skin manager +│ │ ├── material/ # Material components, typography, layout +│ │ ├── windows/ # Tabs and dialogs +│ │ └── pages/ # Multi-page screens such as Settings │ ├── rpc/ │ │ ├── rpc_client.cpp # JSON-RPC client │ │ └── connection.cpp # Daemon connection -│ ├── config/ -│ │ └── settings.cpp # Settings persistence +│ ├── resources/ # Embedded resource extraction +│ ├── platform/ # Windows DX11/backdrop helpers │ ├── util/ │ │ ├── i18n.cpp # Internationalization │ │ └── ... │ └── daemon/ │ └── embedded_daemon.cpp ├── res/ -│ ├── fonts/ # Ubuntu font +│ ├── fonts/ # Ubuntu, icon, and CJK fonts │ └── lang/ # Translation files ├── libs/ │ └── qrcode/ # QR code generation ├── CMakeLists.txt -├── build-release.sh # Build script -└── create-appimage.sh # AppImage packaging +├── build.sh # Release/cross-platform build script +└── scripts/create-appimage.sh # AppImage packaging ``` ## Dependencies -Fetched automatically by CMake (no manual install needed): +Fetched or discovered by CMake: - **[SDL3](https://github.com/libsdl-org/SDL)** — Cross-platform windowing/input - **[nlohmann/json](https://github.com/nlohmann/json)** — JSON parsing - **[toml++](https://github.com/marzer/tomlplusplus)** — TOML parsing (UI schema/themes) -- **[libcurl](https://curl.se/libcurl/)** — HTTPS RPC transport (system on Linux, fetched on Windows) +- **[libcurl](https://curl.se/libcurl/)** — HTTP/HTTPS transport for daemon RPC and network calls (system on Linux/macOS, fetched on Windows) Bundled in `libs/`: - **[Dear ImGui](https://github.com/ocornut/imgui)** — Immediate mode GUI -- **[libsodium](https://libsodium.org)** — Cryptographic operations (fetched by `scripts/fetch-libsodium.sh`) +- **[libsodium](https://libsodium.org)** — Cryptographic operations (system on Linux or fetched by `scripts/fetch-libsodium.sh`) - **[QR-Code-generator](https://github.com/nayuki/QR-Code-generator)** — QR code rendering - **[miniz](https://github.com/richgel999/miniz)** — ZIP compression - **[GLAD](https://glad.dav1d.de/)** — OpenGL loader (Linux/macOS) @@ -202,9 +211,11 @@ Bundled in `libs/`: ## Translation +Current language files live in `res/lang/` as `de`, `es`, `fr`, `ja`, `ko`, `pt`, `ru`, and `zh` JSON files, with built-in English fallbacks. + To add a new language: -1. Copy \`res/lang/es.json\` to \`res/lang/.json\` +1. Copy `res/lang/es.json` to `res/lang/.json` 2. Translate all strings 3. The language will appear in Settings automatically diff --git a/THIRD_PARTY_LICENSES b/THIRD_PARTY_LICENSES index 013221f..f11ce27 100644 --- a/THIRD_PARTY_LICENSES +++ b/THIRD_PARTY_LICENSES @@ -361,7 +361,22 @@ https://www.apache.org/licenses/LICENSE-2.0 --- -## 13. IconFontCppHeaders +## 13. Material Design Icons Pickaxe Subset Font + +- **Location:** `res/fonts/MaterialDesignIcons-Pickaxe-Subset.ttf` +- **Source:** https://github.com/Templarian/MaterialDesign-Webfont +- **Derived from:** Pictogrammers Material Design Icons webfont (`materialdesignicons-webfont.ttf`) +- **Copyright:** Pictogrammers contributors +- **License:** Apache License 2.0 + +This bundled font is a local one-glyph subset containing only the MDI pickaxe +icon, remapped onto a BMP private-use codepoint for Dear ImGui compatibility. +The full text of the Apache License 2.0 is available at: +https://www.apache.org/licenses/LICENSE-2.0 + +--- + +## 14. IconFontCppHeaders - **Location:** `src/embedded/IconsMaterialDesign.h` - **Source:** https://github.com/juliettef/IconFontCppHeaders @@ -390,7 +405,7 @@ freely, subject to the following restrictions: --- -## 14. Ubuntu Font Family +## 15. Ubuntu Font Family - **Location:** `res/fonts/Ubuntu-Light.ttf`, `Ubuntu-Medium.ttf`, `Ubuntu-R.ttf` - **Source:** https://design.ubuntu.com/font diff --git a/docs/codebase-overview.md b/docs/codebase-overview.md new file mode 100644 index 0000000..a6ea1cd --- /dev/null +++ b/docs/codebase-overview.md @@ -0,0 +1,70 @@ +# Codebase Overview + +Current as of 2026-04-27 for ObsidianDragon `1.2.0-rc1`. + +## Purpose + +ObsidianDragon is a Dear ImGui full-node wallet for DragonX (DRGX). It manages an embedded or external `dragonxd`, renders a schema-driven desktop UI, and provides shielded transactions, mining, market data, address management, explorer tools, and bootstrap download support. + +## Runtime Architecture + +- `src/main.cpp` initializes SDL3, graphics backends, ImGui, and the main loop. +- `src/app.cpp` owns application lifecycle, navigation, rendering, dialogs, and daemon/RPC startup. +- `src/app_network.cpp` contains refresh and transaction flows, including balance, address, transaction, mining, market, peer, and address metadata updates. +- `src/app_security.cpp` covers wallet encryption, PIN unlock, auto-lock, and secure vault integration. +- `src/app_wizard.cpp` handles first-run setup and bootstrap/encryption flow. + +The UI thread renders ImGui and drains callback queues. RPC work runs through `RPCWorker`, which posts worker-thread RPC calls and returns UI-thread callbacks. Console commands can use a separate fast-lane RPC client/worker so they do not queue behind regular refresh batches. + +## Source Map + +| Path | Role | +|------|------| +| `src/config/` | Settings JSON persistence and generated `version.h` | +| `src/data/` | Wallet state, address book, exchange info | +| `src/rpc/` | libcurl JSON-RPC client, connection config, worker queue | +| `src/daemon/` | Embedded `dragonxd` manager and xmrig pool miner manager | +| `src/ui/windows/` | Main tabs and modal dialogs | +| `src/ui/pages/` | Page-style screens such as Settings | +| `src/ui/schema/` | TOML schema loader, skin manager, color resolver | +| `src/ui/material/` | Material-style typography, layout, drawing, components | +| `src/ui/effects/` | Acrylic, blur, noise, theme effects, low-spec fallback | +| `src/resources/` | Embedded resource extraction for params, daemon assets, themes, images | +| `src/platform/` | Windows DX11 and backdrop helpers | +| `src/util/` | i18n, logging, platform paths, bootstrap, vault, URI/base64/texture utilities | + +## Build And Resources + +- CMake uses C++17 and outputs `build/bin/ObsidianDragon`. +- Version comes from `project(... VERSION 1.2.0)` plus `DRAGONX_VERSION_SUFFIX=-rc1`. +- SDL3 is found from the system first, then fetched by CMake if unavailable. +- nlohmann/json and toml++ are fetched with CMake FetchContent. +- libcurl is system-provided on Linux/macOS and fetched statically for Windows. +- libsodium is system-provided on Linux or local under `libs/libsodium/`, `libs/libsodium-win/`, or `libs/libsodium-mac/`. +- Fonts are embedded with INCBIN: Ubuntu, Material Icons, a one-glyph MDI pickaxe subset, and Noto CJK subset. +- `res/themes/ui.toml` is embedded as a fallback and expanded into build themes with `scripts/expand_themes.py`. +- `res/default_banlist.txt` is embedded into `build/generated/default_banlist_embedded.h`. + +## RPC And Daemon Notes + +- Default RPC port is `21769`. +- `RPCClient` uses local HTTP with Basic auth. TLS is not assumed for localhost daemon RPC. +- DragonX daemon config paths: + - Linux: `~/.hush/DRAGONX/DRAGONX.conf` + - Windows: `%APPDATA%/Hush/DRAGONX/DRAGONX.conf` + - macOS: `~/Library/Application Support/Hush/DRAGONX/DRAGONX.conf` +- `Connection::autoDetectConfig()` creates missing config files, appends `exportdir`, `experimentalfeatures=1`, and `developerencryptwallet=1`, and falls back to `.cookie` auth if no `rpcpassword` is configured. +- The embedded daemon detects an external daemon on the RPC port and connects to it instead of taking ownership. +- Chain args include TLS-only mode, adaptive `-dbcache`, DragonX asset parameters, node seeds `node.dragonx.is` through `node4.dragonx.is`, and optional `-maxconnections=` from Settings. + +## UI And Data Notes + +- Sidebar navigation is driven by `NavPage`: Overview, Send, Receive, History, Mining, Market, Console, Network, Explorer, Settings. +- Explorer lives in `src/ui/windows/explorer_tab.cpp`; Settings uses `src/ui/pages/settings_page.cpp`. +- Address labels, icons, favorites, hidden state, and manual ordering are persisted in Settings, especially `address_meta`. +- `AddressInfo::has_spending_key` tracks view-only shielded addresses; send flows filter or reject non-spendable z-addresses. +- The pickaxe icon is not a normal `ICON_MD_*` glyph. Use `AddressLabelDialog::drawIconByName()` or `Typography::pickaxeFontForSize()` for that special case. + +## Remaining Work + +- Investigate `todo.md`: determine whether DragonX/Komodo wallet storage supports a safe compaction or consolidation workflow for wallets with too many addresses. \ No newline at end of file diff --git a/res/fonts/MaterialDesignIcons-Pickaxe-Subset.ttf b/res/fonts/MaterialDesignIcons-Pickaxe-Subset.ttf new file mode 100644 index 0000000..87df5b4 Binary files /dev/null and b/res/fonts/MaterialDesignIcons-Pickaxe-Subset.ttf differ diff --git a/res/themes/ui.toml b/res/themes/ui.toml index 27f4759..2563264 100644 --- a/res/themes/ui.toml +++ b/res/themes/ui.toml @@ -1279,7 +1279,7 @@ page-fade-speed = { size = 8.0 } collapse-hysteresis = { size = 60.0 } header-icon = { icon-dark = "logos/logo_ObsidianDragon_dark.png", icon-light = "logos/logo_ObsidianDragon_light.png" } coin-icon = { icon = "logos/logo_dragonx_128.png" } -header-title = { font = "subtitle1", size = 14.0, pad-x = 22.0, pad-y = 6.0, logo-gap = 4.0, opacity = 0.7, offset-y = 4.0 } +header-title = { font = "subtitle1", size = 12.0, pad-x = 8.0, pad-y = 10.0, logo-gap = 4.0, opacity = 0.7, offset-y = -2.0 } [components.main-window.window] padding = [12, 36] diff --git a/src/app_network.cpp b/src/app_network.cpp index 7877cfb..638cfa6 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -750,6 +750,19 @@ void App::refreshAddressData() AddressInfo info; info.address = addr.get(); info.type = "shielded"; + // Check if we have the spending key for this address + try { + json vResult = rpc_->call("z_validateaddress", json::array({info.address})); + if (vResult.is_object() && vResult.contains("ismine") && vResult["ismine"].get()) { + // "ismine" means we have the spending key + info.has_spending_key = true; + } else { + info.has_spending_key = false; + } + } catch (...) { + // If validation fails, assume spendable (safe default for older daemons) + info.has_spending_key = true; + } zAddrs.push_back(info); } } catch (const std::exception& e) { @@ -1850,6 +1863,21 @@ void App::sendTransaction(const std::string& from, const std::string& to, return; } + // Check that we have the spending key for the from address + if (!from.empty() && from[0] == 'z') { + bool spendable = false; + for (const auto& addr : state_.z_addresses) { + if (addr.address == from) { + spendable = addr.has_spending_key; + break; + } + } + if (!spendable) { + if (callback) callback(false, "This is a view-only address (no spending key). Import the spending key to send from this address."); + return; + } + } + // Build recipients array nlohmann::json recipients = nlohmann::json::array(); nlohmann::json recipient; diff --git a/src/data/wallet_state.h b/src/data/wallet_state.h index c952672..8a6b26c 100644 --- a/src/data/wallet_state.h +++ b/src/data/wallet_state.h @@ -18,6 +18,7 @@ struct AddressInfo { std::string address; double balance = 0.0; std::string type; // "shielded" or "transparent" + bool has_spending_key = true; // false for view-only (imported via z_importviewingkey) // For display std::string label; @@ -25,6 +26,7 @@ struct AddressInfo { // Derived bool isZAddr() const { return !address.empty() && address[0] == 'z'; } bool isShielded() const { return type == "shielded"; } + bool isSpendable() const { return has_spending_key; } }; /** diff --git a/src/embedded/embedded_fonts.cpp.in b/src/embedded/embedded_fonts.cpp.in index b2d16a3..204998b 100644 --- a/src/embedded/embedded_fonts.cpp.in +++ b/src/embedded/embedded_fonts.cpp.in @@ -12,4 +12,5 @@ INCBIN(ubuntu_regular, "@CMAKE_SOURCE_DIR@/res/fonts/Ubuntu-R.ttf"); INCBIN(ubuntu_light, "@CMAKE_SOURCE_DIR@/res/fonts/Ubuntu-Light.ttf"); INCBIN(ubuntu_medium, "@CMAKE_SOURCE_DIR@/res/fonts/Ubuntu-Medium.ttf"); INCBIN(material_icons, "@CMAKE_SOURCE_DIR@/res/fonts/MaterialIcons-Regular.ttf"); +INCBIN(mdi_pickaxe_subset, "@CMAKE_SOURCE_DIR@/res/fonts/MaterialDesignIcons-Pickaxe-Subset.ttf"); INCBIN(noto_cjk_subset, "@CMAKE_SOURCE_DIR@/res/fonts/NotoSansCJK-Subset.ttf"); diff --git a/src/embedded/embedded_fonts.h b/src/embedded/embedded_fonts.h index 57acc50..46ab61f 100644 --- a/src/embedded/embedded_fonts.h +++ b/src/embedded/embedded_fonts.h @@ -27,6 +27,9 @@ extern "C" { extern const unsigned char g_material_icons_data[]; extern const unsigned int g_material_icons_size; + extern const unsigned char g_mdi_pickaxe_subset_data[]; + extern const unsigned int g_mdi_pickaxe_subset_size; + extern const unsigned char g_noto_cjk_subset_data[]; extern const unsigned int g_noto_cjk_subset_size; } diff --git a/src/ui/material/draw_helpers.h b/src/ui/material/draw_helpers.h index d878c07..901e087 100644 --- a/src/ui/material/draw_helpers.h +++ b/src/ui/material/draw_helpers.h @@ -90,10 +90,40 @@ inline void DrawTextShadow(ImDrawList* dl, const ImVec2& pos, ImU32 col, // and will return true even when a modal popup covers the rect, which // causes background elements to show hover highlights through dialogs. +inline int& OverlayDialogActiveFrame() +{ + static int s_frame = -1; + return s_frame; +} + +inline void MarkOverlayDialogActive() +{ + OverlayDialogActiveFrame() = ImGui::GetFrameCount(); +} + +inline bool IsCurrentWindowOverlayDialog() +{ + ImGuiWindow* window = ImGui::GetCurrentWindow(); + for (ImGuiWindow* node = window; node; node = node->ParentWindow) { + if (node->Name && strcmp(node->Name, "##OverlayScrim") == 0) + return true; + } + return false; +} + +inline bool IsOverlayDialogBlockingInput() +{ + int activeFrame = OverlayDialogActiveFrame(); + int currentFrame = ImGui::GetFrameCount(); + return activeFrame == currentFrame || activeFrame == (currentFrame - 1); +} + inline bool IsRectHovered(const ImVec2& r_min, const ImVec2& r_max, bool clip = true) { if (!ImGui::IsMouseHoveringRect(r_min, r_max, clip)) return false; + if (IsOverlayDialogBlockingInput() && !IsCurrentWindowOverlayDialog()) + return false; // If a modal popup is open and it is not the current window, treat // the content as non-hoverable (same logic ImGui uses internally // inside IsWindowContentHoverable for modal blocking). @@ -885,8 +915,11 @@ inline bool DrawDialogTitleBar(const char* title, bool* p_open, ImU32 accent_col // Creates a fullscreen semi-transparent overlay with a centered card dialog. // Similar to the shutdown screen pattern but for interactive dialogs. -inline bool BeginOverlayDialog(const char* title, bool* p_open, float cardWidth = 460.0f, float scrimOpacity = 0.92f) +inline bool BeginOverlayDialog(const char* title, bool* p_open, float cardWidth = 460.0f, float scrimOpacity = 0.92f, + float cardBottomViewportRatio = 0.85f) { + MarkOverlayDialogActive(); + ImGuiViewport* vp = ImGui::GetMainViewport(); ImVec2 vp_pos = vp->Pos; ImVec2 vp_size = vp->Size; @@ -914,6 +947,14 @@ inline bool BeginOverlayDialog(const char* title, bool* p_open, float cardWidth } ImDrawList* dl = ImGui::GetWindowDrawList(); + + // Consume pointer input on the scrim so the overlay owns clicks and wheel + // events even when the click lands outside the card content. + ImGui::SetCursorScreenPos(vp_pos); + ImGui::InvisibleButton("##OverlayInputBlocker", vp_size, + ImGuiButtonFlags_MouseButtonLeft | + ImGuiButtonFlags_MouseButtonRight | + ImGuiButtonFlags_MouseButtonMiddle); // Calculate card position (centered) float cardX = vp_pos.x + (vp_size.x - cardWidth) * 0.5f; @@ -921,7 +962,7 @@ inline bool BeginOverlayDialog(const char* title, bool* p_open, float cardWidth // Draw glass card background ImVec2 cardMin(cardX, cardY); - ImVec2 cardMax(cardX + cardWidth, vp_pos.y + vp_size.y * 0.85f); + ImVec2 cardMax(cardX + cardWidth, vp_pos.y + vp_size.y * cardBottomViewportRatio); // Card background with glass effect GlassPanelSpec cardGlass; @@ -930,6 +971,11 @@ inline bool BeginOverlayDialog(const char* title, bool* p_open, float cardWidth cardGlass.borderAlpha = 50; cardGlass.borderWidth = 1.0f; DrawGlassPanel(dl, cardMin, cardMax, cardGlass); + + if (p_open && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && + !ImGui::IsMouseHoveringRect(cardMin, cardMax, false)) { + *p_open = false; + } // Set up child region for card content ImGui::SetCursorScreenPos(ImVec2(cardX, cardY)); diff --git a/src/ui/material/typography.cpp b/src/ui/material/typography.cpp index e4f1490..f19a6ae 100644 --- a/src/ui/material/typography.cpp +++ b/src/ui/material/typography.cpp @@ -101,6 +101,7 @@ bool Typography::reload(ImGuiIO& io, float dpiScale) loaded_ = false; for (int i = 0; i < kNumStyles; ++i) fonts_[i] = nullptr; for (int i = 0; i < kNumIconSizes; ++i) iconFonts_[i] = nullptr; + for (int i = 0; i < kNumIconSizes; ++i) pickaxeFonts_[i] = nullptr; return load(io, dpiScale); } @@ -190,6 +191,13 @@ bool Typography::load(ImGuiIO& io, float dpiScale) iconFonts_[2] = loadIconFont(io, 24.0f * scale, "IconLarge"); iconFonts_[3] = loadIconFont(io, 40.0f * scale, "IconXL"); + // Load a one-glyph MDI subset for pickaxe. The glyph is remapped onto a + // BMP private-use codepoint so it remains renderable with 16-bit ImWchar. + pickaxeFonts_[0] = loadPickaxeFont(io, 14.0f * scale, "PickaxeSmall"); + pickaxeFonts_[1] = loadPickaxeFont(io, 18.0f * scale, "PickaxeMed"); + pickaxeFonts_[2] = loadPickaxeFont(io, 24.0f * scale, "PickaxeLarge"); + pickaxeFonts_[3] = loadPickaxeFont(io, 40.0f * scale, "PickaxeXL"); + // Verify all fonts loaded bool allLoaded = true; for (int i = 0; i < kNumStyles; ++i) { @@ -346,6 +354,51 @@ ImFont* Typography::loadIconFont(ImGuiIO& io, float size, const char* name) return font; } +ImFont* Typography::loadPickaxeFont(ImGuiIO& io, float size, const char* name) +{ + if (g_mdi_pickaxe_subset_size == 0) { + DEBUG_LOGF("Typography: Pickaxe subset font is empty\n"); + return nullptr; + } + + void* fontDataCopy = IM_ALLOC(g_mdi_pickaxe_subset_size); + memcpy(fontDataCopy, g_mdi_pickaxe_subset_data, g_mdi_pickaxe_subset_size); + + ImFontConfig cfg; + cfg.FontDataOwnedByAtlas = true; + cfg.OversampleH = 2; + cfg.OversampleV = 1; + cfg.PixelSnapH = true; + cfg.MergeMode = false; + cfg.GlyphMinAdvanceX = size; + + static const ImWchar pickaxeRange[] = { + Typography::kPickaxeCodepoint, + Typography::kPickaxeCodepoint, + 0, + }; + cfg.GlyphRanges = pickaxeRange; + + snprintf(cfg.Name, sizeof(cfg.Name), "MDIPickaxe %.0fpx", size); + + ImFont* font = io.Fonts->AddFontFromMemoryTTF(fontDataCopy, g_mdi_pickaxe_subset_size, size, &cfg); + if (font) { + DEBUG_LOGF("Typography: Loaded pickaxe font %s (%.0fpx)\n", name, size); + } else { + DEBUG_LOGF("Typography: Failed to load pickaxe font %s\n", name); + IM_FREE(fontDataCopy); + } + return font; +} + +ImFont* Typography::pickaxeFontForSize(float size) const +{ + if (size <= 15.0f) return pickaxeSmall(); + if (size <= 20.0f) return pickaxeMed(); + if (size <= 32.0f) return pickaxeLarge(); + return pickaxeXL(); +} + ImFont* Typography::getFont(TypeStyle style) const { int index = static_cast(style); diff --git a/src/ui/material/typography.h b/src/ui/material/typography.h index a22a4fb..f6de2be 100644 --- a/src/ui/material/typography.h +++ b/src/ui/material/typography.h @@ -175,6 +175,14 @@ public: ImFont* iconMed() const { return iconFonts_[1] ? iconFonts_[1] : getFont(TypeStyle::Body1); } ImFont* iconLarge() const { return iconFonts_[2] ? iconFonts_[2] : getFont(TypeStyle::H5); } ImFont* iconXL() const { return iconFonts_[3] ? iconFonts_[3] : getFont(TypeStyle::H3); } + ImFont* pickaxeSmall() const { return pickaxeFonts_[0] ? pickaxeFonts_[0] : iconSmall(); } + ImFont* pickaxeMed() const { return pickaxeFonts_[1] ? pickaxeFonts_[1] : iconMed(); } + ImFont* pickaxeLarge() const { return pickaxeFonts_[2] ? pickaxeFonts_[2] : iconLarge(); } + ImFont* pickaxeXL() const { return pickaxeFonts_[3] ? pickaxeFonts_[3] : iconXL(); } + + ImFont* pickaxeFontForSize(float size) const; + + static constexpr ImWchar kPickaxeCodepoint = 0xE001; /** * @brief Resolve a font name string to ImFont* @@ -254,10 +262,12 @@ private: // Icon fonts at different sizes: [0]=small(14), [1]=med(18), [2]=large(24), [3]=xl(40) ImFont* iconFonts_[4] = {}; + ImFont* pickaxeFonts_[4] = {}; static constexpr int kNumIconSizes = 4; // Load an icon-only font at a specific pixel size ImFont* loadIconFont(ImGuiIO& io, float size, const char* name); + ImFont* loadPickaxeFont(ImGuiIO& io, float size, const char* name); // Type specifications static const TypeSpec* getTypeSpecs(); diff --git a/src/ui/windows/address_label_dialog.h b/src/ui/windows/address_label_dialog.h index 04ff547..1f9de88 100644 --- a/src/ui/windows/address_label_dialog.h +++ b/src/ui/windows/address_label_dialog.h @@ -5,10 +5,14 @@ #pragma once #include +#include +#include #include +#include #include "../../app.h" #include "../../util/i18n.h" #include "../material/draw_helpers.h" +#include "../material/typography.h" #include "../theme.h" #include "../../embedded/IconsMaterialDesign.h" #include "imgui.h" @@ -18,12 +22,41 @@ namespace ui { class AddressLabelDialog { public: + static constexpr const char* kPickaxeGlyph = "\xEE\x80\x81"; + + static bool drawIconByName(ImDrawList* dl, + const std::string& name, + ImVec2 center, + float /*emSize*/, + ImU32 color, + ImFont* iconFont, + float iconFontSize) { + if (name == "pickaxe") { + ImFont* pickaxeFont = material::Typography::instance().pickaxeFontForSize(iconFontSize); + if (!pickaxeFont) return false; + + ImVec2 iSz = pickaxeFont->CalcTextSizeA(iconFontSize, 1000.0f, 0.0f, kPickaxeGlyph); + dl->AddText(pickaxeFont, iconFontSize, + ImVec2(center.x - iSz.x * 0.5f, center.y - iSz.y * 0.5f), color, kPickaxeGlyph); + return true; + } + + const char* glyph = iconGlyphForName(name); + if (!glyph || !iconFont) return false; + + ImVec2 iSz = iconFont->CalcTextSizeA(iconFontSize, 1000.0f, 0.0f, glyph); + dl->AddText(iconFont, iconFontSize, + ImVec2(center.x - iSz.x * 0.5f, center.y - iSz.y * 0.5f), color, glyph); + return true; + } + 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; + s_iconSearch[0] = '\0'; // Pre-fill from existing metadata std::string existing = app->getAddressLabel(address); @@ -44,7 +77,9 @@ public: using namespace material; - if (BeginOverlayDialog(TR("set_label"), &s_open, 420.0f, 0.92f)) { + constexpr float cardTopViewportRatio = 0.15f; + constexpr float cardBottomViewportRatio = 0.80f; + if (BeginOverlayDialog(TR("set_label"), &s_open, 660.0f, 0.92f, cardBottomViewportRatio)) { float dp = Layout::dpiScale(); // Address preview @@ -70,16 +105,64 @@ public: Type().text(TypeStyle::Subtitle2, TR("choose_icon")); ImGui::Spacing(); + // Search bar + ImGui::SetNextItemWidth(-1); + ImGui::InputTextWithHint("##IconSearch", TR("search_icons"), s_iconSearch, sizeof(s_iconSearch)); + 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))); + const char* cancelLabel = TR("cancel"); + const char* saveLabel = TR("save"); + ImFont* buttonFont = Type().button(); + float buttonFontSize = ScaledFontSize(buttonFont); + const ImGuiStyle& style = ImGui::GetStyle(); + const float cardHeight = ImGui::GetMainViewport()->Size.y * (cardBottomViewportRatio - cardTopViewportRatio); + const float bottomPadding = style.WindowPadding.y; + const bool showClearIcon = (s_selectedIcon >= 0); + const float clearRowH = showClearIcon ? ImGui::GetTextLineHeight() : 0.0f; + const float buttonRowH = ImGui::GetFrameHeight(); + const float separatorH = 1.0f; + const float preButtonReserve = + (showClearIcon ? (style.ItemSpacing.y + clearRowH) : 0.0f) + + style.ItemSpacing.y * 3.0f + separatorH; + const float buttonY = cardHeight - bottomPadding - buttonRowH; + + // Build filtered index list + std::vector visible; + visible.reserve(kIconCount); + { + // Simple case-insensitive substring match on icon name + std::string needle(s_iconSearch); + for (char& c : needle) c = (char)std::tolower((unsigned char)c); + for (int i = 0; i < kIconCount; ++i) { + if (needle.empty() || std::strstr(kIconNames[i], needle.c_str()) != nullptr) + visible.push_back(i); + } + } + + // The grid owns all vertical space above the bottom action band. + const float gridStartY = ImGui::GetCursorPosY(); + const float controlsTopY = std::max(gridStartY + cellSz * 2.0f, buttonY - preButtonReserve); + const float gridMaxH = std::max(cellSz * 2.0f, controlsTopY - gridStartY); + ImGui::PushStyleColor(ImGuiCol_ChildBg, IM_COL32(0, 0, 0, 0)); + ImGui::BeginChild("##IconGrid", ImVec2(avail, gridMaxH), ImGuiChildFlags_None, + ImGuiWindowFlags_NoScrollbar); + ImDrawList* dl = ImGui::GetWindowDrawList(); - for (int i = 0; i < kIconCount; ++i) { - if (i % cols != 0) ImGui::SameLine(0, 4.0f * dp); + if (visible.empty()) { + Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("no_icons_found")); + } + + int col = 0; + for (int vi = 0; vi < (int)visible.size(); ++vi) { + int i = visible[vi]; + if (col != 0) ImGui::SameLine(0, 4.0f * dp); ImVec2 pos = ImGui::GetCursorScreenPos(); ImVec2 mn = pos; ImVec2 mx(pos.x + cellSz, pos.y + cellSz); @@ -95,22 +178,33 @@ public: } // 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]); + drawIconByName(dl, + kIconNames[i], + ImVec2(mn.x + cellSz * 0.5f, mn.y + cellSz * 0.5f), + iconFsz, + sel ? Primary() : (hov ? OnSurface() : OnSurfaceMedium()), + iconFont, + iconFsz); ImGui::PushID(i); ImGui::InvisibleButton("##icon", ImVec2(cellSz, cellSz)); if (ImGui::IsItemClicked()) s_selectedIcon = i; if (hov) ImGui::SetTooltip("%s", kIconNames[i]); ImGui::PopID(); + + col = (col + 1) % cols; + } + + ImGui::EndChild(); + ImGui::PopStyleColor(); + + if (ImGui::GetCursorPosY() < controlsTopY) { + ImGui::SetCursorPosY(controlsTopY); } // "No icon" option - ImGui::Spacing(); - if (s_selectedIcon >= 0) { + if (showClearIcon) { + ImGui::Spacing(); if (ImGui::SmallButton(TR("clear_icon"))) { s_selectedIcon = -1; } @@ -121,16 +215,27 @@ public: ImGui::Separator(); ImGui::Spacing(); - // Buttons - float btnW = 120.0f; - float totalW = btnW * 2 + Layout::spacingMd(); - ImGui::SetCursorPosX((ImGui::GetContentRegionAvail().x - totalW) * 0.5f); + if (ImGui::GetCursorPosY() < buttonY) { + ImGui::SetCursorPosY(buttonY); + } - if (TactileButton(TR("cancel"), ImVec2(btnW, 0))) { + // Buttons + float minBtnW = 120.0f * dp; + float buttonPadW = ImGui::GetStyle().FramePadding.x * 2.0f + 24.0f * dp; + float cancelW = std::max(minBtnW, + buttonFont->CalcTextSizeA(buttonFontSize, 1000.0f, 0.0f, cancelLabel).x + buttonPadW); + float saveW = std::max(minBtnW, + buttonFont->CalcTextSizeA(buttonFontSize, 1000.0f, 0.0f, saveLabel).x + buttonPadW); + float totalW = cancelW + saveW + Layout::spacingMd(); + float rowStartX = ImGui::GetCursorPosX(); + float contentW = ImGui::GetContentRegionAvail().x; + ImGui::SetCursorPosX(rowStartX + std::max(0.0f, (contentW - totalW) * 0.5f)); + + if (TactileButton(cancelLabel, ImVec2(cancelW, 0), buttonFont)) { s_open = false; } ImGui::SameLine(0, Layout::spacingMd()); - if (TactileButton(TR("save"), ImVec2(btnW, 0))) { + if (TactileButton(saveLabel, ImVec2(saveW, 0), buttonFont)) { // Apply changes s_app->setAddressLabel(s_address, s_label); if (s_selectedIcon >= 0) @@ -153,23 +258,118 @@ private: static inline bool s_isZ = false; static inline char s_label[128] = {}; static inline int s_selectedIcon = -1; + static inline char s_iconSearch[64] = {}; // 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* kIconNames[] = { + // Finance / Crypto + "savings", "account_balance", "account_balance_wallet", "wallet", + "payments", "credit_card", "local_atm", "diamond", + "attach_money", "currency_bitcoin", "currency_exchange", "balance", + "calculate", "trending_up", "euro", "leaderboard", + "paid", "sell", "receipt", "percent", + "price_change", "price_check", "toll", "money", + // Charts / Analytics + "show_chart", "candlestick_chart", "bar_chart", "pie_chart", + "area_chart", "stacked_bar_chart", "waterfall_chart", "scatter_plot", + "query_stats", "speed", "donut_large", + // Mining / Tools + "pickaxe", + "hardware", "construction", "handyman", "build", + "carpenter", "plumbing", "home_repair_service", "precision_manufacturing", + "factory", "warehouse", "inventory", "recycling", + "oil_barrel", "offline_bolt", "thunderstorm", "terminal", + "storage", "memory", "developer_board", + // Security / Auth + "shield", "security", "lock", "swap_horiz", + "verified", "verified_user", "key", "badge", + // Commerce / Business + "store", "storefront", "shopping_bag", "business", + "work", "real_estate_agent", "gavel", "local_shipping", + // Home / Property + "home", "apartment", "cottage", "landscape", + // People / Identity + "account_circle", "face", "manage_accounts", "groups", "mood", + // Travel / Transport + "rocket_launch", "flight", "directions_car", "travel_explore", + "explore", "location_on", "map", "luggage", "anchor", + // Nature / Outdoors + "public", "language", "forest", "park", + "water_drop", "beach_access", "energy_savings_leaf", "solar_power", + // Social / Lifestyle + "favorite", "star", "celebration", "casino", + "auto_awesome", "emoji_events", "military_tech", "flag", + // Tech / Science + "bolt", "tungsten", "lightbulb", "insights", + "hub", "token", "electric_bolt", "science", "biotech", + // Organisation + "category", "label", "school", "local_hospital", "local_florist", + // Food / Drink + "coffee", "restaurant", "wine_bar", "liquor", + "outdoor_grill", "nightlife", "sports_bar", + // Recreation / Health + "pets", "fitness_center", "spa", "self_improvement", + "psychology", "sports_soccer", "sports_esports", + "hiking", "palette", "museum", "church", "surfing", + // Community + "redeem", "handshake", "healing", "volunteer", + "stadium", "temple_buddhist", "theater_comedy", "watch", }; - 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 + static inline const char* kIconGlyphs[] = { + // Finance / Crypto + ICON_MD_SAVINGS, ICON_MD_ACCOUNT_BALANCE, ICON_MD_ACCOUNT_BALANCE_WALLET, ICON_MD_WALLET, + ICON_MD_PAYMENTS, ICON_MD_CREDIT_CARD, ICON_MD_LOCAL_ATM, ICON_MD_DIAMOND, + ICON_MD_ATTACH_MONEY, ICON_MD_CURRENCY_BITCOIN, ICON_MD_CURRENCY_EXCHANGE, ICON_MD_BALANCE, + ICON_MD_CALCULATE, ICON_MD_TRENDING_UP, ICON_MD_EURO, ICON_MD_LEADERBOARD, + ICON_MD_PAID, ICON_MD_SELL, ICON_MD_RECEIPT, ICON_MD_PERCENT, + ICON_MD_PRICE_CHANGE, ICON_MD_PRICE_CHECK, ICON_MD_TOLL, ICON_MD_MONEY, + // Charts / Analytics + ICON_MD_SHOW_CHART, ICON_MD_CANDLESTICK_CHART, ICON_MD_BAR_CHART, ICON_MD_PIE_CHART, + ICON_MD_AREA_CHART, ICON_MD_STACKED_BAR_CHART, ICON_MD_WATERFALL_CHART, ICON_MD_SCATTER_PLOT, + ICON_MD_QUERY_STATS, ICON_MD_SPEED, ICON_MD_DONUT_LARGE, + // Mining / Tools + nullptr, + ICON_MD_HARDWARE, ICON_MD_CONSTRUCTION, ICON_MD_HANDYMAN, ICON_MD_BUILD, + ICON_MD_CARPENTER, ICON_MD_PLUMBING, ICON_MD_HOME_REPAIR_SERVICE, ICON_MD_PRECISION_MANUFACTURING, + ICON_MD_FACTORY, ICON_MD_WAREHOUSE, ICON_MD_INVENTORY, ICON_MD_RECYCLING, + ICON_MD_OIL_BARREL, ICON_MD_OFFLINE_BOLT, ICON_MD_THUNDERSTORM, ICON_MD_TERMINAL, + ICON_MD_STORAGE, ICON_MD_MEMORY, ICON_MD_DEVELOPER_BOARD, + // Security / Auth + ICON_MD_SHIELD, ICON_MD_SECURITY, ICON_MD_LOCK, ICON_MD_SWAP_HORIZ, + ICON_MD_VERIFIED, ICON_MD_VERIFIED_USER, ICON_MD_KEY, ICON_MD_BADGE, + // Commerce / Business + ICON_MD_STORE, ICON_MD_STOREFRONT, ICON_MD_SHOPPING_BAG, ICON_MD_BUSINESS, + ICON_MD_WORK, ICON_MD_REAL_ESTATE_AGENT, ICON_MD_GAVEL, ICON_MD_LOCAL_SHIPPING, + // Home / Property + ICON_MD_HOME, ICON_MD_APARTMENT, ICON_MD_COTTAGE, ICON_MD_LANDSCAPE, + // People / Identity + ICON_MD_ACCOUNT_CIRCLE, ICON_MD_FACE, ICON_MD_MANAGE_ACCOUNTS, ICON_MD_GROUPS, ICON_MD_MOOD, + // Travel / Transport + ICON_MD_ROCKET_LAUNCH, ICON_MD_FLIGHT, ICON_MD_DIRECTIONS_CAR, ICON_MD_TRAVEL_EXPLORE, + ICON_MD_EXPLORE, ICON_MD_LOCATION_ON, ICON_MD_MAP, ICON_MD_LUGGAGE, ICON_MD_ANCHOR, + // Nature / Outdoors + ICON_MD_PUBLIC, ICON_MD_LANGUAGE, ICON_MD_FOREST, ICON_MD_PARK, + ICON_MD_WATER_DROP, ICON_MD_BEACH_ACCESS, ICON_MD_ENERGY_SAVINGS_LEAF, ICON_MD_SOLAR_POWER, + // Social / Lifestyle + ICON_MD_FAVORITE, ICON_MD_STAR, ICON_MD_CELEBRATION, ICON_MD_CASINO, + ICON_MD_AUTO_AWESOME, ICON_MD_EMOJI_EVENTS, ICON_MD_MILITARY_TECH, ICON_MD_FLAG, + // Tech / Science + ICON_MD_BOLT, ICON_MD_TUNGSTEN, ICON_MD_LIGHTBULB, ICON_MD_INSIGHTS, + ICON_MD_HUB, ICON_MD_TOKEN, ICON_MD_ELECTRIC_BOLT, ICON_MD_SCIENCE, ICON_MD_BIOTECH, + // Organisation + ICON_MD_CATEGORY, ICON_MD_LABEL, ICON_MD_SCHOOL, ICON_MD_LOCAL_HOSPITAL, ICON_MD_LOCAL_FLORIST, + // Food / Drink + ICON_MD_LOCAL_CAFE, ICON_MD_RESTAURANT, ICON_MD_WINE_BAR, ICON_MD_LIQUOR, + ICON_MD_OUTDOOR_GRILL, ICON_MD_NIGHTLIFE, ICON_MD_SPORTS_BAR, + // Recreation / Health + ICON_MD_PETS, ICON_MD_FITNESS_CENTER, ICON_MD_SPA, ICON_MD_SELF_IMPROVEMENT, + ICON_MD_PSYCHOLOGY, ICON_MD_SPORTS_SOCCER, ICON_MD_SPORTS_ESPORTS, + ICON_MD_HIKING, ICON_MD_PALETTE, ICON_MD_MUSEUM, ICON_MD_CHURCH, ICON_MD_SURFING, + // Community + ICON_MD_REDEEM, ICON_MD_HANDSHAKE, ICON_MD_HEALING, ICON_MD_VOLUNTEER_ACTIVISM, + ICON_MD_STADIUM, ICON_MD_TEMPLE_BUDDHIST, ICON_MD_THEATER_COMEDY, ICON_MD_WATCH, }; + static constexpr int kIconCount = static_cast(std::size(kIconNames)); public: // Expose for the address list to look up icon glyphs by name diff --git a/src/ui/windows/address_transfer_dialog.h b/src/ui/windows/address_transfer_dialog.h index 839b44d..ea7eaf4 100644 --- a/src/ui/windows/address_transfer_dialog.h +++ b/src/ui/windows/address_transfer_dialog.h @@ -45,11 +45,12 @@ public: 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; + + // Pre-fill with the maximum sendable amount so the dialog is valid + // immediately without requiring the user to press Max. + snprintf(s_amount, sizeof(s_amount), "%.8f", maxSendableAmount(info.fromBalance, s_fee)); } static void render() { @@ -57,7 +58,7 @@ public: using namespace material; - if (BeginOverlayDialog(TR("transfer_funds"), &s_open, 480.0f, 0.94f)) { + if (BeginOverlayDialog(TR("transfer_funds"), &s_open, 620.0f, 0.94f)) { float dp = Layout::dpiScale(); ImDrawList* dl = ImGui::GetWindowDrawList(); @@ -104,18 +105,22 @@ public: ImGui::Spacing(); ImGui::Spacing(); - // Amount input + // Amount input + Max button on same row without overflow Type().text(TypeStyle::Subtitle2, TR("amount")); - ImGui::SetNextItemWidth(-1); + { + float spacing = ImGui::GetStyle().ItemSpacing.x; + float maxBtnW = ImGui::CalcTextSize(TR("max")).x + + ImGui::GetStyle().FramePadding.x * 2.0f; + ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - maxBtnW - spacing); + } 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); + snprintf(s_amount, sizeof(s_amount), "%.8f", + maxSendableAmount(s_info.fromBalance, s_fee)); } ImGui::Spacing(); @@ -140,15 +145,15 @@ public: 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); + snprintf(buf, sizeof(buf), 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); + snprintf(buf, sizeof(buf), TR("recipient_balance"), + s_info.toBalance, amountValid ? newToBal : s_info.toBalance); Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), buf); } @@ -172,17 +177,32 @@ public: ImGui::Spacing(); // Buttons - float btnW = 140.0f; - float totalW = btnW * 2 + Layout::spacingMd(); - ImGui::SetCursorPosX((ImGui::GetContentRegionAvail().x - totalW) * 0.5f); + const char* cancelLabel = TR("cancel"); + const char* confirmLabel = TR("confirm_transfer"); + const char* sendingLabel = TR("sending"); + ImFont* buttonFont = Type().button(); + float buttonFontSize = ScaledFontSize(buttonFont); + float minBtnW = 120.0f * dp; + float confirmMinW = 160.0f * dp; + float buttonPadW = ImGui::GetStyle().FramePadding.x * 2.0f + 24.0f * dp; + float cancelW = std::max(minBtnW, + buttonFont->CalcTextSizeA(buttonFontSize, 1000.0f, 0.0f, cancelLabel).x + buttonPadW); + float confirmTextW = std::max( + buttonFont->CalcTextSizeA(buttonFontSize, 1000.0f, 0.0f, confirmLabel).x, + buttonFont->CalcTextSizeA(buttonFontSize, 1000.0f, 0.0f, sendingLabel).x); + float confirmW = std::max(confirmMinW, confirmTextW + buttonPadW); + float totalW = cancelW + confirmW + Layout::spacingMd(); + float rowStartX = ImGui::GetCursorPosX(); + float contentW = ImGui::GetContentRegionAvail().x; + ImGui::SetCursorPosX(rowStartX + std::max(0.0f, (contentW - totalW) * 0.5f)); - if (TactileButton(TR("cancel"), ImVec2(btnW, 0))) { + if (TactileButton(cancelLabel, ImVec2(cancelW, 0), buttonFont)) { 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))) { + if (TactileButton(s_sending ? sendingLabel : confirmLabel, ImVec2(confirmW, 0), buttonFont)) { s_sending = true; s_app->sendTransaction(s_info.fromAddr, s_info.toAddr, amount, s_fee, "", @@ -205,6 +225,11 @@ public: static void close() { s_open = false; } private: + static double maxSendableAmount(double balance, double fee) { + double maxAmt = balance - fee; + return maxAmt > 0.0 ? maxAmt : 0.0; + } + static void renderAddressRow(const std::string& addr, double balance, bool isZ, float dp) { using namespace material; ImDrawList* dl = ImGui::GetWindowDrawList(); @@ -228,22 +253,25 @@ private: 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) + // Balance (right-aligned) — computed first so we know how much space address gets 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), + float balX = mx.x - pad - balSz.x; + dl->AddText(body, bodyFsz, ImVec2(balX, mn.y + (h - balSz.y) * 0.5f), balance > 0 ? OnSurface() : OnSurfaceDisabled(), balBuf); + float textX = mn.x + pad + iSz.x + 8.0f * dp; + ImFont* capFont = Type().caption(); + float capFsz = ScaledFontSize(capFont); + // Clip address text so it never overlaps the balance + float addrMaxW = balX - textX - 8.0f * dp; + dl->PushClipRect(ImVec2(textX, mn.y), ImVec2(textX + addrMaxW, mn.y + h), true); + dl->AddText(capFont, capFsz, ImVec2(textX, mn.y + (h * 0.5f - capFsz)), OnSurfaceMedium(), addr.c_str()); + dl->PopClipRect(); + ImGui::Dummy(ImVec2(w, h)); } diff --git a/src/ui/windows/balance_tab.cpp b/src/ui/windows/balance_tab.cpp index 3e989c0..4ff5265 100644 --- a/src/ui/windows/balance_tab.cpp +++ b/src/ui/windows/balance_tab.cpp @@ -1027,8 +1027,9 @@ static void RenderBalanceClassic(App* app) float labelX = cx + rowIconSz * 2.0f + Layout::spacingSm(); const char* typeLabel = row.isZ ? "Shielded" : "Transparent"; const char* hiddenTag = row.hidden ? " (hidden)" : ""; + const char* viewOnlyTag = (!addr.has_spending_key) ? " (view-only)" : ""; char typeBuf[64]; - snprintf(typeBuf, sizeof(typeBuf), "%s%s", typeLabel, hiddenTag); + snprintf(typeBuf, sizeof(typeBuf), "%s%s%s", typeLabel, hiddenTag, viewOnlyTag); dl->AddText(capFont, capFont->LegacySize, ImVec2(labelX, cy), typeCol, typeBuf); // Label (if present, next to type) @@ -1739,13 +1740,19 @@ static void RenderSharedAddressList(App* app, float listH, float availW, 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); + bool drewCustom = false; + if (!row.icon.empty()) { + drewCustom = AddressLabelDialog::drawIconByName( + dl, row.icon, ImVec2(iconCx, iconCy), iconFont->LegacySize, + OnSurfaceMedium(), iconFont, iconFont->LegacySize); + } + if (!drewCustom) { + const char* glyph = row.isZ ? ICON_MD_SHIELD : ICON_MD_CIRCLE; + 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), typeCol, glyph); + } } // ---- Type label (first line) ---- diff --git a/src/ui/windows/send_tab.cpp b/src/ui/windows/send_tab.cpp index d6de4a9..f604b4c 100644 --- a/src/ui/windows/send_tab.cpp +++ b/src/ui/windows/send_tab.cpp @@ -222,7 +222,7 @@ static void RenderSourceDropdown(App* app, float width) { int bestIdx = -1; double bestBal = 0.0; for (size_t i = 0; i < state.addresses.size(); i++) { - if (state.addresses[i].balance > bestBal) { + if (state.addresses[i].balance > bestBal && state.addresses[i].isSpendable()) { bestBal = state.addresses[i].balance; bestIdx = static_cast(i); } @@ -259,11 +259,11 @@ static void RenderSourceDropdown(App* app, float width) { if (!app->isConnected() || state.addresses.empty()) { ImGui::TextDisabled("%s", TR("no_addresses_available")); } else { - // Sort by balance descending, only show addresses with balance + // Sort by balance descending, only show spendable addresses with balance std::vector sortedIdx; sortedIdx.reserve(state.addresses.size()); for (size_t i = 0; i < state.addresses.size(); i++) { - if (state.addresses[i].balance > 0) + if (state.addresses[i].balance > 0 && state.addresses[i].isSpendable()) sortedIdx.push_back(i); } std::sort(sortedIdx.begin(), sortedIdx.end(), diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index b40edd7..b028cc0 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -434,6 +434,8 @@ void I18n::loadBuiltinEnglish() strings_["label_placeholder"] = "e.g. Savings, Mining..."; strings_["choose_icon"] = "Choose Icon"; strings_["clear_icon"] = "Clear Icon"; + strings_["search_icons"] = "Search icons..."; + strings_["no_icons_found"] = "No icons match your search."; 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.";