// DragonX Wallet - ImGui Edition // Copyright 2024-2026 The Hush Developers // Released under the GPLv3 #include "network_tab.h" #include "../../app.h" #include "../../config/settings.h" #include "../../data/wallet_state.h" #include "../../util/i18n.h" #include "../../util/lite_server_probe.h" #include "../../wallet/lite_connection_service.h" #include "../theme.h" #include "../layout.h" #include "../material/type.h" #include "../material/draw_helpers.h" #include "../material/colors.h" #include "../effects/theme_effects.h" #include "../../embedded/IconsMaterialDesign.h" #include "imgui.h" #include #include #include #include #include #include namespace dragonx { namespace ui { using namespace material; using LiteMode = config::Settings::LiteServerSelectionPreferenceMode; namespace { util::LiteServerProbe s_probe; bool s_probeStarted = false; bool s_showHidden = false; char s_addUrl[256] = ""; char s_addLabel[128] = ""; std::string s_addError; // (Re)probe every server in the list (visible + hidden), so latency is ready when unhidden too. void startProbe(config::Settings* st) { std::vector urls; for (const auto& s : st->getLiteServers()) urls.push_back(s.url); s_probe.start(std::move(urls)); } ImU32 latencyColor(int ms) { if (ms < 100) return Success(); if (ms < 500) return Warning(); return Error(); } } // namespace void RenderLiteNetworkTab(App* app) { if (!app || !app->settings()) return; config::Settings* st = app->settings(); const float dp = Layout::dpiScale(); if (!s_probeStarted) { startProbe(st); s_probeStarted = true; } const auto probe = s_probe.results(); const bool randomMode = st->getLiteServerSelectionMode() == LiteMode::Random; const std::string sticky = st->getLiteStickyServerUrl(); auto applyAndRebuild = [&]() { st->save(); app->rebuildLiteWallet(/*force=*/true); }; auto pinServer = [&](const std::string& url) { st->setLiteServerSelectionMode(LiteMode::Sticky); st->setLiteStickyServerUrl(url); applyAndRebuild(); }; ImFont* sub2 = Type().subtitle2(); ImFont* body2 = Type().body2(); ImFont* cap = Type().caption(); // ── Header ─────────────────────────────────────────────────────────────── Type().text(TypeStyle::H6, TR("lite_net_title")); Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("lite_net_intro")); ImGui::Spacing(); // ── Connection + sync status panel ───────────────────────────────────────── { const WalletState& ws = app->state(); const SyncInfo& sync = ws.sync; const bool connected = ws.connected; ImDrawList* sdl = ImGui::GetWindowDrawList(); const float panelH = 56.0f * dp; const float panelW = ImGui::GetContentRegionAvail().x; const float pad2 = 12.0f * dp; ImVec2 pMin = ImGui::GetCursorScreenPos(); ImVec2 pMax(pMin.x + panelW, pMin.y + panelH); GlassPanelSpec sspec; sspec.rounding = 8.0f * dp; DrawGlassPanel(sdl, pMin, pMax, sspec); // Top row: status dot + connection label (left); in-use server + latency (right). ImU32 dotCol; const char* connLabel; if (!connected) { dotCol = OnSurfaceDisabled(); connLabel = TR("lite_net_disconnected"); } else if (sync.syncing) { dotCol = Warning(); connLabel = TR("lite_net_syncing"); } else { dotCol = Success(); connLabel = TR("lite_net_synced"); } const float dotR = 5.0f * dp; sdl->AddCircleFilled(ImVec2(pMin.x + pad2 + dotR, pMin.y + 17.0f * dp), dotR, dotCol); sdl->AddText(sub2, sub2->LegacySize, ImVec2(pMin.x + pad2 + dotR * 2 + 8.0f * dp, pMin.y + 9.0f * dp), OnSurface(), connLabel); std::string inUse = randomMode ? std::string(TR("lite_net_random_server")) : (sticky.empty() ? std::string("—") : util::liteServerHost(sticky)); if (!randomMode && !sticky.empty()) { const auto it = probe.find(sticky); if (it != probe.end() && it->second.online) inUse += " · " + std::to_string(it->second.latencyMs) + "ms"; } const ImVec2 inUseSz = cap->CalcTextSizeA(cap->LegacySize, FLT_MAX, 0, inUse.c_str()); sdl->AddText(cap, cap->LegacySize, ImVec2(pMax.x - pad2 - inUseSz.x, pMin.y + 12.0f * dp), OnSurfaceMedium(), inUse.c_str()); // Bottom row: sync detail + (while syncing) a thin progress bar. char syncBuf[160]; if (!connected) { // If an auto-open actually failed, show why (e.g. server unreachable) instead of a // bare "no wallet open" — otherwise the disconnected state is silent/confusing. const std::string& openErr = app->liteOpenError(); snprintf(syncBuf, sizeof(syncBuf), "%s: %s", TR("lite_net_sync_label"), openErr.empty() ? TR("lite_net_no_wallet") : openErr.c_str()); } else if (sync.syncing) snprintf(syncBuf, sizeof(syncBuf), "%s: %.1f%% · %d / %d", TR("lite_net_sync_label"), sync.verification_progress * 100.0, sync.blocks, sync.headers); else snprintf(syncBuf, sizeof(syncBuf), "%s: %s · block %d", TR("lite_net_sync_label"), TR("lite_net_synced"), sync.blocks); sdl->AddText(cap, cap->LegacySize, ImVec2(pMin.x + pad2, pMin.y + 32.0f * dp), OnSurfaceMedium(), syncBuf); if (connected && sync.syncing) { const float barY = pMin.y + panelH - 6.0f * dp; const float barX = pMin.x + pad2; const float barW = panelW - pad2 * 2; const float barH = 2.5f * dp; const float pct = static_cast(std::max(0.0, std::min(1.0, sync.verification_progress))); sdl->AddRectFilled(ImVec2(barX, barY), ImVec2(barX + barW, barY + barH), WithAlpha(OnSurface(), 25), barH * 0.5f); if (pct > 0) sdl->AddRectFilled(ImVec2(barX, barY), ImVec2(barX + barW * pct, barY + barH), Primary(), barH * 0.5f); } ImGui::Dummy(ImVec2(panelW, panelH)); // reserve the panel (proper boundary growth) ImGui::Spacing(); } bool useRandom = randomMode; if (ImGui::Checkbox(TR("lite_net_use_random"), &useRandom)) { st->setLiteServerSelectionMode(useRandom ? LiteMode::Random : LiteMode::Sticky); applyAndRebuild(); } ImGui::SameLine(ImGui::GetContentRegionAvail().x - 110.0f * dp); if (TactileButton(TR("lite_net_refresh"), ImVec2(100.0f * dp, 0))) startProbe(st); if (randomMode) Type().textColored(TypeStyle::Caption, Primary(), TR("lite_net_random_active")); ImGui::Spacing(); // ── Add custom server ────────────────────────────────────────────────────── { const float availW = ImGui::GetContentRegionAvail().x; const float addBtnW = 80.0f * dp; const float urlW = (availW - addBtnW - 16.0f * dp) * 0.6f; const float lblW = (availW - addBtnW - 16.0f * dp) * 0.4f; ImGui::SetNextItemWidth(urlW); ImGui::InputTextWithHint("##LiteAddUrl", TR("lite_net_add_url_hint"), s_addUrl, sizeof(s_addUrl)); ImGui::SameLine(); ImGui::SetNextItemWidth(lblW); ImGui::InputTextWithHint("##LiteAddLabel", TR("lite_net_add_label_hint"), s_addLabel, sizeof(s_addLabel)); ImGui::SameLine(); if (TactileButton(TR("lite_net_add"), ImVec2(addBtnW, 0))) { auto trimStr = [](std::string s) { while (!s.empty() && (s.front()==' '||s.front()=='\t'||s.front()=='\n'||s.front()=='\r')) s.erase(s.begin()); while (!s.empty() && (s.back()==' '||s.back()=='\t'||s.back()=='\n'||s.back()=='\r')) s.pop_back(); return s; }; std::string url = trimStr(s_addUrl); std::string addLabel = trimStr(s_addLabel); if (!wallet::isLiteServerUrlUsable(url)) { s_addError = TR("lite_net_invalid_url"); } else { auto servers = st->getLiteServers(); bool exists = false; for (const auto& s : servers) if (s.url == url) { exists = true; break; } if (!exists) { config::Settings::LiteServerPreference p; p.url = url; p.label = !addLabel.empty() ? addLabel : url; p.enabled = true; servers.push_back(p); st->setLiteServers(servers); st->save(); startProbe(st); } s_addUrl[0] = '\0'; s_addLabel[0] = '\0'; s_addError.clear(); } } if (!s_addError.empty()) Type().textColored(TypeStyle::Caption, Error(), s_addError.c_str()); } ImGui::Spacing(); // ── Card renderer ────────────────────────────────────────────────────────── const float cardH = 58.0f * dp; const float pad = 12.0f * dp; const float gap = 8.0f * dp; const float hideW = 44.0f * dp; ImDrawList* dl = ImGui::GetWindowDrawList(); auto drawCard = [&](const config::Settings::LiteServerPreference& sv, bool hiddenList) { const std::string host = util::liteServerHost(sv.url); const bool official = wallet::isOfficialLiteServer(sv.url); const bool selected = !randomMode && sv.url == sticky; util::LiteServerProbeResult pr; auto it = probe.find(sv.url); if (it != probe.end()) pr = it->second; const float cardW = ImGui::GetContentRegionAvail().x; ImVec2 cardMin = ImGui::GetCursorScreenPos(); ImVec2 cardMax(cardMin.x + cardW, cardMin.y + cardH); const float rnd = 8.0f * dp; const float cardBtnW = cardW - hideW; // Background + selection/official emphasis. GlassPanelSpec spec; spec.rounding = rnd; spec.fillAlpha = selected ? 34 : 18; DrawGlassPanel(dl, cardMin, cardMax, spec); // Glow only the ACTIVE (selected/in-use) node — officials are distinguished by their pill. if (selected) { auto& fx = effects::ThemeEffects::instance(); if (fx.isEnabled()) { fx.drawGlowPulse(dl, cardMin, cardMax, rnd); } // Always-visible pulsing outline so the active node stands out even without effects. float pulse = 0.75f + 0.25f * (float)std::sin(ImGui::GetTime() * 2.0); dl->AddRect(cardMin, cardMax, WithAlpha(Primary(), (int)(150 * pulse)), rnd, 0, 1.6f * dp); } // Main click area selects the server (left of the hide strip). ImGui::SetCursorScreenPos(cardMin); ImGui::InvisibleButton(("##litecard_" + sv.url).c_str(), ImVec2(cardBtnW, cardH)); const bool cardHovered = ImGui::IsItemHovered(); const bool cardClicked = ImGui::IsItemClicked(); if (cardHovered && !hiddenList) { ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); if (!selected) dl->AddRectFilled(cardMin, cardMax, WithAlpha(OnSurface(), 8), rnd); } if (cardClicked && !hiddenList) pinServer(sv.url); // Left: label + host + ip. float tx = cardMin.x + pad + 4.0f * dp; dl->AddText(sub2, sub2->LegacySize, ImVec2(tx, cardMin.y + 9.0f * dp), OnSurface(), sv.label.c_str()); std::string sub = host; if (!pr.ip.empty() && pr.ip != host) sub += " · " + pr.ip; dl->AddText(cap, cap->LegacySize, ImVec2(tx, cardMin.y + 9.0f * dp + sub2->LegacySize + 3.0f * dp), OnSurfaceMedium(), sub.c_str()); // Right (within the card-button area): latency dot + text, official/custom pill. float rx = cardMin.x + cardBtnW - pad; // latency text char lat[24]; ImU32 latCol; if (!pr.probed) { snprintf(lat, sizeof(lat), "%s", TR("lite_net_checking")); latCol = OnSurfaceDisabled(); } else if (!pr.online) { snprintf(lat, sizeof(lat), "%s", TR("lite_net_offline")); latCol = Error(); } else { snprintf(lat, sizeof(lat), "%dms", pr.latencyMs); latCol = latencyColor(pr.latencyMs); } ImVec2 latSz = cap->CalcTextSizeA(cap->LegacySize, FLT_MAX, 0, lat); float latX = rx - latSz.x; dl->AddText(cap, cap->LegacySize, ImVec2(latX, cardMin.y + cardH * 0.5f + 2.0f * dp), latCol, lat); // status dot left of latency text if (pr.probed) { float dotR = 3.5f * dp; dl->AddCircleFilled(ImVec2(latX - dotR - 4.0f * dp, cardMin.y + cardH * 0.5f + 2.0f * dp + cap->LegacySize * 0.5f), dotR, pr.online ? latencyColor(pr.latencyMs) : Error()); } // official/custom + in-use pills (top-right) const char* tag = official ? TR("lite_net_official") : TR("lite_net_custom"); ImU32 tagBg = official ? WithAlpha(Primary(), 40) : WithAlpha(Secondary(), 30); ImU32 tagFg = official ? WithAlpha(Primary(), 230) : WithAlpha(Secondary(), 220); ImVec2 tagSz = cap->CalcTextSizeA(cap->LegacySize, FLT_MAX, 0, tag); float tagX = rx - tagSz.x; float tagY = cardMin.y + 9.0f * dp; material::DrawPill(dl, ImVec2(tagX - 6 * dp, tagY - 1 * dp), tag, cap, tagFg, tagBg, 0, ImVec2(6 * dp, 1 * dp), 4.0f * dp); if (selected) { const char* inUse = TR("lite_net_in_use"); ImVec2 uSz = cap->CalcTextSizeA(cap->LegacySize, FLT_MAX, 0, inUse); dl->AddText(cap, cap->LegacySize, ImVec2(tagX - uSz.x - 10 * dp, tagY), Success(), inUse); } // Hide / unhide button (far-right strip). ImGui::SetCursorScreenPos(ImVec2(cardMin.x + cardBtnW, cardMin.y)); material::IconButtonStyle hideSt; hideSt.color = OnSurfaceDisabled(); hideSt.hoverColor = OnSurface(); hideSt.tooltip = hiddenList ? TR("lite_net_unhide") : TR("lite_net_hide"); if (material::IconButton(("##litehide_" + sv.url).c_str(), hiddenList ? ICON_MD_VISIBILITY : ICON_MD_VISIBILITY_OFF, Type().iconSmall(), ImVec2(hideW, cardH), hideSt)) { if (hiddenList) st->unhideLiteServer(sv.url); else st->hideLiteServer(sv.url); st->save(); } // Advance to the next card via a real item (Dummy) below the card, so the scroll region's // content height actually grows — ImGui won't extend bounds from a bare SetCursorScreenPos. ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMax.y)); ImGui::Dummy(ImVec2(cardW, gap)); }; // ── Visible servers ──────────────────────────────────────────────────────── ImVec2 scrollAvail = ImGui::GetContentRegionAvail(); ImGui::BeginChild("##LiteServerScroll", scrollAvail, false, ImGuiWindowFlags_NoBackground); for (const auto& sv : st->getLiteServers()) { if (st->isLiteServerHidden(sv.url)) continue; drawCard(sv, /*hiddenList=*/false); } // ── Hidden servers (toggle) ───────────────────────────────────────────────── { bool anyHidden = false; for (const auto& sv : st->getLiteServers()) if (st->isLiteServerHidden(sv.url)) { anyHidden = true; break; } if (anyHidden) { ImGui::Spacing(); ImGui::Checkbox(TR("lite_net_show_hidden"), &s_showHidden); if (s_showHidden) { Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("lite_net_hidden_section")); ImGui::Spacing(); for (const auto& sv : st->getLiteServers()) { if (!st->isLiteServerHidden(sv.url)) continue; drawCard(sv, /*hiddenList=*/true); } } } } ImGui::EndChild(); (void)body2; } } // namespace ui } // namespace dragonx