From 9db73323b661b6e732c6bc1c1be8365e6b78f840 Mon Sep 17 00:00:00 2001 From: DanS Date: Sat, 17 Oct 2026 16:53:37 -0500 Subject: [PATCH] feat(ui): friendly RPC errors + pause-on-hover/ellipsis toasts Two of the systemic error-surfacing foundations from the UX audit: - util::friendlyRpcError() maps the common raw daemon/JSON-RPC errors (warming up / -28, locked / -13, method-not-found / -32601, insufficient funds, unreachable node) to short translated guidance with a next step, falling back to the trimmed raw text. Wired into the send (money) path: the toast shows the friendly cause, the overlay keeps the raw text below it for diagnostics. The helper is reusable for the remaining raw-error sites. - Toasts now pause their auto-dismiss while hovered and ellipsize over-long text with the full message in a hover tooltip, instead of hard-clipping it mid-word (durations were already lengthened earlier). Co-Authored-By: Claude Opus 4.8 --- src/ui/notifications.cpp | 31 ++++++++++++++++++++++++++++-- src/ui/notifications.h | 1 + src/ui/windows/send_tab.cpp | 8 ++++++-- src/util/i18n.cpp | 38 ++++++++++++++++++++++++++++++++++++- src/util/i18n.h | 10 ++++++++++ 5 files changed, 83 insertions(+), 5 deletions(-) diff --git a/src/ui/notifications.cpp b/src/ui/notifications.cpp index 04cebfb..967d722 100644 --- a/src/ui/notifications.cpp +++ b/src/ui/notifications.cpp @@ -14,6 +14,11 @@ namespace ui { void Notifications::render() { + // Pause-on-hover: while the pointer rests on the toast, keep it alive so a long error message can + // actually be read (errors/warnings already last longer, but hovering holds any of them open). + if (toast_hovered_ && !notifications_.empty()) + notifications_.back().created_at = std::chrono::steady_clock::now(); + // Remove every expired notification; the newest remaining one is then both the last and a live one. pruneExpired(notifications_, std::chrono::steady_clock::now()); @@ -146,13 +151,35 @@ void Notifications::render() cursorX += iconW + iconGap; } - // Message text (clipped to pill bounds) + // Message text — ellipsized to fit the (viewport-clamped) pill rather than hard-clipped mid-word, + // with the full text shown in a tooltip on hover. + bool clipped = false; if (textFont) { ImU32 textCol = ImGui::ColorConvertFloat4ToU32(text_color); + const float availTextW = (pMax.x - pillPadX) - cursorX; + std::string shown = notif.message; + if (availTextW > 0.0f && + textFont->CalcTextSizeA(textFont->LegacySize, FLT_MAX, 0.0f, shown.c_str()).x > availTextW) { + clipped = true; + const std::string ell = "\xE2\x80\xA6"; // "…" + while (!shown.empty() && + textFont->CalcTextSizeA(textFont->LegacySize, FLT_MAX, 0.0f, + (shown + ell).c_str()).x > availTextW) { + shown.pop_back(); + while (!shown.empty() && (static_cast(shown.back()) & 0xC0) == 0x80) + shown.pop_back(); // drop any trailing UTF-8 continuation bytes + } + shown += ell; + } dl->PushClipRect(ImVec2(cursorX, pillY), ImVec2(pMax.x - pillPadX, pMax.y), true); - dl->AddText(textFont, textFont->LegacySize, ImVec2(cursorX, contentY), textCol, notif.message.c_str()); + dl->AddText(textFont, textFont->LegacySize, ImVec2(cursorX, contentY), textCol, shown.c_str()); dl->PopClipRect(); } + + // Hover: pause the auto-dismiss (handled next frame) and reveal the full message if it was clipped. + toast_hovered_ = ImGui::IsMouseHoveringRect(pMin, pMax); + if (toast_hovered_ && clipped) + ImGui::SetTooltip("%s", notif.message.c_str()); } } // namespace ui diff --git a/src/ui/notifications.h b/src/ui/notifications.h index 1527b72..298fc85 100644 --- a/src/ui/notifications.h +++ b/src/ui/notifications.h @@ -192,6 +192,7 @@ private: std::deque notifications_; size_t max_notifications_ = 5; + bool toast_hovered_ = false; // pointer was over the toast last frame → pause its auto-dismiss std::chrono::steady_clock::time_point last_drawn_{}; std::function console_callback_; diff --git a/src/ui/windows/send_tab.cpp b/src/ui/windows/send_tab.cpp index 7f26843..ed159b4 100644 --- a/src/ui/windows/send_tab.cpp +++ b/src/ui/windows/send_tab.cpp @@ -927,10 +927,14 @@ void RenderSendConfirmPopup(App* app) { s_memo[0] = '\0'; s_send_max = false; } else { - s_tx_status = std::string(TR("send_error_prefix")) + result; + // Show a friendly cause in the toast; keep the raw backend text below it in + // the overlay (copyable) so the detail isn't lost for diagnostics. + const std::string friendly = util::friendlyRpcError(result); + s_tx_status = std::string(TR("send_error_prefix")) + friendly + + (friendly != result ? "\n\n" + result : std::string()); s_result_txid.clear(); s_status_success = false; - Notifications::instance().error(std::string(TR("send_tx_failed")) + result); + Notifications::instance().error(std::string(TR("send_tx_failed")) + friendly); } } ); diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index d3075f0..e9236ce 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include @@ -1911,6 +1912,13 @@ void I18n::loadBuiltinEnglish() strings_["console_not_connected_lite"] = "Error: no wallet open"; strings_["console_stop_confirm_node"] = "'stop' will shut down the node and disconnect the wallet. Type 'stop' again to confirm."; strings_["console_destructive_confirm"] = "This command can move funds or reveal secret keys. Never run a command someone else gave you. Run the exact same command again to confirm."; + // Friendly mappings for common daemon/RPC errors (see util::friendlyRpcError). + strings_["err_warming_up"] = "The node is still starting up. Try again in a moment."; + strings_["err_wallet_locked"] = "Unlock your wallet first, then try again."; + strings_["err_method_missing"] = "Your node doesn't support this action. Updating the daemon may fix it."; + strings_["err_insufficient"] = "Not enough spendable balance to cover the amount and fee."; + strings_["err_cant_reach"] = "Can't reach the node. Check that it's running and try again."; + strings_["err_generic"] = "Something went wrong. Please try again."; strings_["console_capturing_output"] = "Capturing daemon output..."; strings_["console_clear"] = "Clear"; strings_["console_clear_console"] = "Clear Console"; @@ -2935,9 +2943,37 @@ void I18n::registerLanguage(const std::string& code, const std::string& name) for (const auto& lang : available_languages_) { if (lang.first == code) return; } - + available_languages_.emplace_back(code, name); } +std::string friendlyRpcError(const std::string& raw) +{ + // Lowercased copy for case-insensitive matching. + std::string lc = raw; + for (char& c : lc) c = static_cast(std::tolower(static_cast(c))); + auto has = [&](const char* needle) { return lc.find(needle) != std::string::npos; }; + + // Order matters: the most specific / most common user-facing causes first. + if (has("-28") || has("warming up") || has("loading block") || has("initializing") || has("rewinding")) + return tr("err_warming_up"); + if (has("-13") || has("walletpassphrase") || has("wallet is locked") || has("please enter the wallet passphrase")) + return tr("err_wallet_locked"); + if (has("-32601") || has("method not found")) + return tr("err_method_missing"); + if (has("insufficient") || has("not enough funds") || has("no funds")) + return tr("err_insufficient"); + if (has("connection refused") || has("couldn't connect") || has("could not connect") || + has("failed to connect") || has("timed out") || has("timeout") || has("no response")) + return tr("err_cant_reach"); + + // Nothing recognized — hand back the raw text, trimmed of surrounding whitespace so a stray + // newline/indent from the daemon doesn't look like a formatting glitch. + const auto b = raw.find_first_not_of(" \t\r\n"); + if (b == std::string::npos) return tr("err_generic"); + const auto e = raw.find_last_not_of(" \t\r\n"); + return raw.substr(b, e - b + 1); +} + } // namespace util } // namespace dragonx diff --git a/src/util/i18n.h b/src/util/i18n.h index 0349eb5..d49e89d 100644 --- a/src/util/i18n.h +++ b/src/util/i18n.h @@ -79,6 +79,16 @@ inline const char* tr(const char* key) { return I18n::instance().translate(key); } +/** + * @brief Map a raw daemon / JSON-RPC error string to a short, friendly, TRANSLATED explanation. + * + * Recognizes the common cases a user actually hits — a warming-up node, a locked wallet, an RPC the + * daemon is too old to have, insufficient funds, and an unreachable node — and returns plain guidance + * with a next step. Falls back to the raw text (trimmed) when nothing matches, so detail is never lost. + * Keep the raw text available elsewhere (a Details view / the console) for diagnostics. + */ +std::string friendlyRpcError(const std::string& raw); + } // namespace util } // namespace dragonx