diff --git a/src/ui/material/draw_helpers.h b/src/ui/material/draw_helpers.h index db65d38..a1709f5 100644 --- a/src/ui/material/draw_helpers.h +++ b/src/ui/material/draw_helpers.h @@ -476,6 +476,24 @@ inline void DrawGlassPanel(ImDrawList* dl, const ImVec2& pMin, } } +// Full-window backdrop for a modal overlay. Lays an OPAQUE base first (so the sheet reads as +// opaque regardless of the UI-opacity slider — the acrylic path multiplies its alpha by +// GetUIOpacity(), so it can't guarantee opacity on its own), then — only when the acrylic backdrop +// is active and we are NOT in low-performance mode — draws a strong blur of the (static) window +// backdrop on top plus a dim. In low-perf mode only the opaque base is drawn (no blur cost), +// mirroring DrawGlassPanel's gating. +inline void DrawFullWindowBlurBackdrop(ImDrawList* dl, const ImVec2& pMin, const ImVec2& pMax) +{ + dl->AddRectFilled(pMin, pMax, IM_COL32(9, 10, 16, 255)); // opaque base — guarantees opacity + if (IsBackdropActive() && !dragonx::ui::effects::isLowSpecMode() + && effects::ImGuiAcrylic::IsEnabled() && effects::ImGuiAcrylic::IsAvailable()) { + auto params = GetCurrentAcrylicTheme().card; + params.blurRadius = 40.0f; // strong — spans the whole window + effects::ImGuiAcrylic::DrawAcrylicRect(dl, pMin, pMax, params, 0.0f); + dl->AddRectFilled(pMin, pMax, IM_COL32(6, 8, 18, 90)); // dim so the card reads as foreground + } +} + // ============================================================================ // Glass Card Scope (RAII) — channel-split glass-card scaffold // ============================================================================ diff --git a/src/ui/windows/market_tab.cpp b/src/ui/windows/market_tab.cpp index adeb001..878b679 100644 --- a/src/ui/windows/market_tab.cpp +++ b/src/ui/windows/market_tab.cpp @@ -94,7 +94,10 @@ static std::string FormatPrice(double price) // ---- Portfolio editor (modal) state ---- static bool s_portfolio_editor_open = false; -static int s_pf_editing = -1; // -1 = list mode; -2 = adding new; >=0 = editing that index +static int s_pf_sel = -1; // selected group index in the master list; -1 = none, -2 = unsaved draft +static bool s_pf_sec_appearance = true; // right-pane accordion: which section is expanded +static bool s_pf_sec_price = false; +static bool s_pf_sec_addresses = false; static char s_pf_label[64] = {0}; static std::vector s_pf_addrs; static std::string s_pf_icon; // selected wallet-icon name ("" = none) @@ -302,7 +305,7 @@ static const ImU32 kPfPalette[] = { static void PortfolioBeginEdit(App* app, int index) { const auto& entries = app->settings()->getPortfolioEntries(); - s_pf_editing = index; + s_pf_sel = index; if (index >= 0 && index < (int)entries.size()) { const auto& src = entries[index]; snprintf(s_pf_label, sizeof(s_pf_label), "%s", src.label.c_str()); @@ -343,144 +346,246 @@ static void PortfolioBeginEdit(App* app, int index) static void RenderPortfolioEditor(App* app) { if (!s_portfolio_editor_open) return; - // Edit mode is a wider two-column layout; list mode stays compact. - float dialogW = std::min((s_pf_editing == -1 ? 560.0f : 780.0f), - ImGui::GetMainViewport()->Size.x * 0.92f); - if (!material::BeginOverlayDialog(TR("portfolio_manage_title"), &s_portfolio_editor_open, dialogW)) { - return; - } config::Settings* settings = app->settings(); const auto& state = app->getWalletState(); + float dp = Layout::dpiScale(); + ImFont* body2f = Type().body2(); + ImFont* capF = Type().caption(); + ImFont* sub1f = Type().subtitle1(); + ImFont* iconFont = Type().iconSmall(); + float iconFsz = iconFont->LegacySize; auto persist = [&](const std::vector& entries) { settings->setPortfolioEntries(entries); settings->save(); }; + // Build a PortfolioEntry from the working set, preserving off-form fields (grid geometry) from base. + auto buildWorking = [&](const config::Settings::PortfolioEntry& base) { + config::Settings::PortfolioEntry e = base; + e.label = s_pf_label; e.addresses = s_pf_addrs; e.icon = s_pf_icon; + e.color = s_pf_color; e.outlineOpacity = s_pf_outline_opacity; + e.priceBasis = s_pf_price_basis; e.manualPrice = s_pf_manual_price; e.manualCurrency = s_pf_manual_ccy; + e.showDrgx = s_pf_show_drgx; e.showValue = s_pf_show_value; e.show24h = s_pf_show_24h; + e.showSparkline = s_pf_show_sparkline; e.sparklineInterval = s_pf_spark_interval; + return e; + }; + auto workingMatches = [&](const config::Settings::PortfolioEntry& e) { + return e.label == std::string(s_pf_label) && e.addresses == s_pf_addrs && e.icon == s_pf_icon + && e.color == s_pf_color && e.outlineOpacity == s_pf_outline_opacity + && e.priceBasis == s_pf_price_basis && e.manualPrice == s_pf_manual_price + && e.manualCurrency == std::string(s_pf_manual_ccy) + && e.showDrgx == s_pf_show_drgx && e.showValue == s_pf_show_value && e.show24h == s_pf_show_24h + && e.showSparkline == s_pf_show_sparkline && e.sparklineInterval == s_pf_spark_interval; + }; + // Persist the working group when it is named and actually changed; unnamed drafts are dropped. + auto commitIfNeeded = [&]() { + if (s_pf_label[0] == '\0') return; + auto entries = settings->getPortfolioEntries(); + bool existing = (s_pf_sel >= 0 && s_pf_sel < (int)entries.size()); + config::Settings::PortfolioEntry base; + if (existing) { base = entries[s_pf_sel]; if (workingMatches(base)) return; } + auto e = buildWorking(base); + if (existing) entries[s_pf_sel] = e; + else { entries.push_back(e); s_pf_sel = (int)entries.size() - 1; } + persist(entries); + }; - if (s_pf_editing == -1) { - // ---- LIST MODE ---- + // ---------------- Full-window overlay frame ---------------- + material::MarkOverlayDialogActive(); + ImGuiViewport* vp = ImGui::GetMainViewport(); + ImVec2 vpPos = vp->Pos, vpSize = vp->Size; + // Don't force-focus the scrim while a popup (basis/interval combo, color picker) is open, or it + // would close the popup; the same guard gates the outside-click dismiss below. + const bool popupOpen = ImGui::IsPopupOpen("", ImGuiPopupFlags_AnyPopupId | ImGuiPopupFlags_AnyPopupLevel); + if (!popupOpen) ImGui::SetNextWindowFocus(); + ImGui::SetNextWindowPos(vpPos); + ImGui::SetNextWindowSize(vpSize); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0, 0, 0, 0)); // transparent — we draw the backdrop + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0)); + ImGui::Begin("##PortfolioOverlay", nullptr, + ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse | + ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoBringToFrontOnFocus); + ImDrawList* dl = ImGui::GetWindowDrawList(); + material::DrawFullWindowBlurBackdrop(dl, vpPos, ImVec2(vpPos.x + vpSize.x, vpPos.y + vpSize.y)); + ImGui::SetCursorScreenPos(vpPos); + ImGui::InvisibleButton("##pfInputBlocker", vpSize, + ImGuiButtonFlags_MouseButtonLeft | ImGuiButtonFlags_MouseButtonRight | ImGuiButtonFlags_MouseButtonMiddle); + + // Card: large, centered, fixed size. + float cardW = std::min(vpSize.x - 64.0f * dp, 1120.0f * dp); + float cardH = std::min(vpSize.y - 64.0f * dp, 760.0f * dp); + ImVec2 cardMin(vpPos.x + (vpSize.x - cardW) * 0.5f, vpPos.y + (vpSize.y - cardH) * 0.5f); + ImVec2 cardMax(cardMin.x + cardW, cardMin.y + cardH); + { GlassPanelSpec cs; cs.rounding = 20.0f; cs.fillAlpha = 40; cs.borderAlpha = 60; cs.borderWidth = 1.0f; + DrawGlassPanel(dl, cardMin, cardMax, cs); } + + ImGui::SetCursorScreenPos(cardMin); + ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, 20.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(28.0f, 20.0f)); + ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0, 0, 0, 0)); + ImGui::BeginChild("##pfCard", ImVec2(cardW, cardH), ImGuiChildFlags_None, ImGuiWindowFlags_NoScrollbar); + + material::DrawDialogTitleBar(TR("portfolio_manage_title"), &s_portfolio_editor_open); + if (ImGui::IsKeyPressed(ImGuiKey_Escape)) s_portfolio_editor_open = false; + + { // clamp/validate the selection against the current entry list + const auto& entriesRO = settings->getPortfolioEntries(); + if (s_pf_sel >= (int)entriesRO.size()) s_pf_sel = entriesRO.empty() ? -1 : 0; + } + + float footerH = 40.0f * dp; + float bodyH = std::max(120.0f, ImGui::GetContentRegionAvail().y - footerH - Layout::spacingSm()); + float contentW = ImGui::GetContentRegionAvail().x; + float gap = Layout::spacingLg(); + float masterW = std::min(std::max(contentW * 0.28f, 240.0f * dp), 320.0f * dp); + float detailW = contentW - masterW - gap; + + // ================= MASTER: group list ================= + ImGui::BeginChild("##pfMaster", ImVec2(masterW, bodyH), false, ImGuiWindowFlags_NoScrollbar); + { const auto& entries = settings->getPortfolioEntries(); - if (entries.empty()) { - Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("portfolio_no_entries")); - } - for (int i = 0; i < (int)entries.size(); i++) { - ImGui::PushID(i); - double bal = data::SumPortfolioBalance(entries[i].addresses, state.addresses); - char row[160]; - snprintf(row, sizeof(row), "%s \xE2\x80\x94 %.4f %s (%d)", - entries[i].label.c_str(), bal, DRAGONX_TICKER, (int)entries[i].addresses.size()); - ImGui::TextUnformatted(row); - ImGui::SameLine(); - float bw = 72.0f; - float avail = ImGui::GetContentRegionAvail().x; - if (avail > bw * 2 + Layout::spacingSm()) - ImGui::SetCursorPosX(ImGui::GetCursorPosX() + avail - bw * 2 - Layout::spacingSm()); - if (ImGui::Button(TR("portfolio_edit"), ImVec2(bw, 0))) PortfolioBeginEdit(app, i); - ImGui::SameLine(); - if (ImGui::Button(TR("portfolio_delete"), ImVec2(bw, 0))) { - auto e = entries; e.erase(e.begin() + i); persist(e); + float addH = 30.0f * dp; + ImGui::BeginChild("##pfMasterList", + ImVec2(0, std::max(48.0f, ImGui::GetContentRegionAvail().y - addH - Layout::spacingXs())), false); + { + ImDrawList* mdl = ImGui::GetWindowDrawList(); + if (entries.empty()) + Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("portfolio_no_entries")); + float rowH = 46.0f * dp; + int clickedSel = -999; // deferred: don't mutate `entries` mid-loop + for (int i = 0; i < (int)entries.size(); i++) { + ImGui::PushID(i); + const auto& en = entries[i]; + bool selRow = (s_pf_sel == i); + ImVec2 rmn = ImGui::GetCursorScreenPos(); + if (ImGui::Selectable("##pfgrp", selRow, ImGuiSelectableFlags_SpanAvailWidth, ImVec2(0, rowH))) { + if (i != s_pf_sel) clickedSel = i; + } + ImVec2 rmx(rmn.x + ImGui::GetItemRectSize().x, rmn.y + rowH); + ImU32 accent = en.color ? (ImU32)en.color : WithAlpha(OnSurface(), 60); + mdl->AddRectFilled(ImVec2(rmn.x, rmn.y + 4.0f * dp), + ImVec2(rmn.x + 3.0f * dp, rmx.y - 4.0f * dp), accent, 1.5f * dp); + float ix = rmn.x + Layout::spacingSm() + 4.0f * dp; + if (!en.icon.empty()) { + material::project_icons::drawByName(mdl, en.icon, + ImVec2(ix + sub1f->LegacySize * 0.5f, rmn.y + rowH * 0.5f), + en.color ? (ImU32)en.color : OnSurfaceMedium(), iconFont, sub1f->LegacySize); + ix += sub1f->LegacySize + Layout::spacingSm(); + } + float txtRight = rmx.x - Layout::spacingSm(); + std::string nm = en.label.empty() ? std::string(TR("portfolio_new_entry")) : en.label; + while (nm.size() > 1 && body2f->CalcTextSizeA(body2f->LegacySize, FLT_MAX, 0, nm.c_str()).x > (txtRight - ix)) + nm.pop_back(); + mdl->AddText(body2f, body2f->LegacySize, ImVec2(ix, rmn.y + rowH * 0.28f), + en.label.empty() ? OnSurfaceDisabled() : OnSurface(), nm.c_str()); + double bal = data::SumPortfolioBalance(en.addresses, state.addresses); + char vl[80]; snprintf(vl, sizeof(vl), "%.4f %s \xC2\xB7 %d", bal, DRAGONX_TICKER, (int)en.addresses.size()); + mdl->AddText(capF, capF->LegacySize, ImVec2(ix, rmn.y + rowH * 0.56f), OnSurfaceMedium(), vl); ImGui::PopID(); - break; } - ImGui::PopID(); + if (clickedSel != -999) { commitIfNeeded(); PortfolioBeginEdit(app, clickedSel); } + } + ImGui::EndChild(); + ImGui::Dummy(ImVec2(0, Layout::spacingXs())); + if (ImGui::Button(TR("portfolio_add_entry"), ImVec2(-1, addH))) { commitIfNeeded(); PortfolioBeginEdit(app, -2); } + } + ImGui::EndChild(); + + ImGui::SameLine(0, gap); + + // ================= DETAIL: options for the selected group ================= + ImGui::BeginChild("##pfDetail", ImVec2(detailW, bodyH), false); + if (s_pf_sel == -1) { + const char* msg = TR("portfolio_detail_empty"); + ImVec2 av = ImGui::GetContentRegionAvail(); + ImVec2 ts = capF->CalcTextSizeA(capF->LegacySize, FLT_MAX, 0, msg); + ImGui::SetCursorPos(ImVec2(std::max(0.0f, (av.x - ts.x) * 0.5f), av.y * 0.42f)); + Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), msg); + } else { + // ---- Header: live preview + label (always visible above the accordion) ---- + { + ImDrawList* pdl = ImGui::GetWindowDrawList(); + float ppad = Layout::spacingMd(); + float pw = ImGui::GetContentRegionAvail().x; + float ph = ppad * 2.0f + sub1f->LegacySize + Layout::spacingXs() + capF->LegacySize; + ImVec2 pMin = ImGui::GetCursorScreenPos(); + ImVec2 pMax(pMin.x + pw, pMin.y + ph); + ImU32 accent = s_pf_color ? (ImU32)s_pf_color : 0; + GlassPanelSpec g; g.rounding = 10.0f; g.fillAlpha = 22; g.borderAlpha = 40; + DrawGlassPanel(pdl, pMin, pMax, g); + if (accent) { + int oa = (int)(std::max(0, std::min(100, s_pf_outline_opacity)) * 2.55f + 0.5f); + pdl->AddRect(pMin, pMax, WithAlpha(accent, oa), 10.0f, 0, 2.0f); + } + if (s_pf_show_sparkline && (s_pf_price_basis == 0 || s_pf_price_basis == 1)) { + std::vector h = pfSparklineSeries(state.market, s_pf_spark_interval); + if (h.size() >= 2) { + ImU32 spCol = WithAlpha(h.back() >= h.front() ? Success() : Error(), 80); + ImVec2 spMin(pMin.x + ppad, pMin.y + ph * 0.46f); + ImVec2 spMax(pMax.x - ppad, pMax.y - ppad * 0.6f); + pfDrawSparkline(pdl, spMin, spMax, h, spCol); + } + } + float rowY = pMin.y + ppad, nameX = pMin.x + ppad; + if (!s_pf_icon.empty()) { + material::project_icons::drawByName(pdl, s_pf_icon, + ImVec2(nameX + sub1f->LegacySize * 0.5f, rowY + sub1f->LegacySize * 0.5f), + accent ? accent : OnSurface(), iconFont, sub1f->LegacySize); + nameX += sub1f->LegacySize + Layout::spacingSm(); + } + const char* nm = s_pf_label[0] ? s_pf_label : TR("portfolio_group_name"); + pdl->AddText(sub1f, sub1f->LegacySize, ImVec2(nameX, rowY), + s_pf_label[0] ? OnSurface() : OnSurfaceDisabled(), nm); + double pbal = data::SumPortfolioBalance(s_pf_addrs, state.addresses); + config::Settings::PortfolioEntry tmp; + tmp.priceBasis = s_pf_price_basis; + tmp.manualPrice = s_pf_manual_price; + tmp.manualCurrency = s_pf_manual_ccy; + tmp.showDrgx = s_pf_show_drgx; + tmp.showValue = s_pf_show_value; + tmp.show24h = s_pf_show_24h; + PfDisplay pd = pfBuildDisplay(tmp, pbal, state.market); + float primW = pd.primary.empty() ? 0.0f + : body2f->CalcTextSizeA(body2f->LegacySize, FLT_MAX, 0, pd.primary.c_str()).x; + if (!pd.primary.empty()) + pdl->AddText(body2f, body2f->LegacySize, + ImVec2(pMax.x - ppad - primW, rowY + (sub1f->LegacySize - body2f->LegacySize) * 0.5f), + OnSurface(), pd.primary.c_str()); + { + float belowY = rowY + sub1f->LegacySize + Layout::spacingXs(); + float cursorR = pMax.x - ppad; + if (!pd.change.empty()) { + float cw = capF->CalcTextSizeA(capF->LegacySize, FLT_MAX, 0, pd.change.c_str()).x; + pdl->AddText(capF, capF->LegacySize, ImVec2(cursorR - cw, belowY), pd.changeCol, pd.change.c_str()); + cursorR -= cw + Layout::spacingSm(); + } + if (!pd.secondary.empty()) { + float sw2 = capF->CalcTextSizeA(capF->LegacySize, FLT_MAX, 0, pd.secondary.c_str()).x; + pdl->AddText(capF, capF->LegacySize, ImVec2(cursorR - sw2, belowY), OnSurfaceDisabled(), pd.secondary.c_str()); + } + } + ImGui::Dummy(ImVec2(pw, ph)); } ImGui::Dummy(ImVec2(0, Layout::spacingSm())); - if (ImGui::Button(TR("portfolio_add_entry"))) PortfolioBeginEdit(app, -2); - } else { - // ---- EDIT MODE: two columns (Appearance | Addresses) ---- - float dp = Layout::dpiScale(); - ImFont* body2f = Type().body2(); - ImFont* capF = Type().caption(); - ImFont* sub1f = Type().subtitle1(); - ImFont* iconFont = Type().iconSmall(); - float iconFsz = iconFont->LegacySize; + ImGui::SetNextItemWidth(-1); + ImGui::InputTextWithHint("##pfLabel", TR("portfolio_group_name"), s_pf_label, sizeof(s_pf_label)); + ImGui::Dummy(ImVec2(0, Layout::spacingSm())); - float contentW = ImGui::GetContentRegionAvail().x; - float colGap = Layout::spacingLg(); - float leftW = std::max(220.0f, (contentW - colGap) * 0.40f); - float rightW = contentW - colGap - leftW; - float colH = std::min(ImGui::GetMainViewport()->Size.y * 0.52f, 460.0f * dp); - - // ===================== LEFT: APPEARANCE ===================== - ImGui::BeginChild("##pfLeft", ImVec2(leftW, colH), false); - { - Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("portfolio_appearance")); - ImGui::Dummy(ImVec2(0, Layout::spacingSm())); - - // Live preview of the group card (icon + color + name + summed balance). - { - ImDrawList* pdl = ImGui::GetWindowDrawList(); - float ppad = Layout::spacingMd(); - float pw = ImGui::GetContentRegionAvail().x; - float ph = ppad * 2.0f + sub1f->LegacySize + Layout::spacingXs() + capF->LegacySize; - ImVec2 pMin = ImGui::GetCursorScreenPos(); - ImVec2 pMax(pMin.x + pw, pMin.y + ph); - ImU32 accent = s_pf_color ? (ImU32)s_pf_color : 0; - GlassPanelSpec g; g.rounding = 10.0f; g.fillAlpha = 22; g.borderAlpha = 40; - DrawGlassPanel(pdl, pMin, pMax, g); - // Colored outline around the whole preview card (matches the live group cards). - if (accent) { - int oa = (int)(std::max(0, std::min(100, s_pf_outline_opacity)) * 2.55f + 0.5f); - pdl->AddRect(pMin, pMax, WithAlpha(accent, oa), 10.0f, 0, 2.0f); - } - if (s_pf_show_sparkline && (s_pf_price_basis == 0 || s_pf_price_basis == 1)) { - std::vector h = pfSparklineSeries(state.market, s_pf_spark_interval); - if (h.size() >= 2) { - ImU32 spCol = WithAlpha(h.back() >= h.front() ? Success() : Error(), 80); - ImVec2 spMin(pMin.x + ppad, pMin.y + ph * 0.46f); - ImVec2 spMax(pMax.x - ppad, pMax.y - ppad * 0.6f); - pfDrawSparkline(pdl, spMin, spMax, h, spCol); - } - } - float rowY = pMin.y + ppad, nameX = pMin.x + ppad; - if (!s_pf_icon.empty()) { - material::project_icons::drawByName(pdl, s_pf_icon, - ImVec2(nameX + sub1f->LegacySize * 0.5f, rowY + sub1f->LegacySize * 0.5f), - accent ? accent : OnSurface(), iconFont, sub1f->LegacySize); - nameX += sub1f->LegacySize + Layout::spacingSm(); - } - const char* nm = s_pf_label[0] ? s_pf_label : TR("portfolio_group_name"); - pdl->AddText(sub1f, sub1f->LegacySize, ImVec2(nameX, rowY), - s_pf_label[0] ? OnSurface() : OnSurfaceDisabled(), nm); - double pbal = data::SumPortfolioBalance(s_pf_addrs, state.addresses); - config::Settings::PortfolioEntry tmp; - tmp.priceBasis = s_pf_price_basis; - tmp.manualPrice = s_pf_manual_price; - tmp.manualCurrency = s_pf_manual_ccy; - tmp.showDrgx = s_pf_show_drgx; - tmp.showValue = s_pf_show_value; - tmp.show24h = s_pf_show_24h; - PfDisplay pd = pfBuildDisplay(tmp, pbal, state.market); - float primW = pd.primary.empty() ? 0.0f - : body2f->CalcTextSizeA(body2f->LegacySize, FLT_MAX, 0, pd.primary.c_str()).x; - if (!pd.primary.empty()) - pdl->AddText(body2f, body2f->LegacySize, - ImVec2(pMax.x - ppad - primW, rowY + (sub1f->LegacySize - body2f->LegacySize) * 0.5f), - OnSurface(), pd.primary.c_str()); - { - float belowY = rowY + sub1f->LegacySize + Layout::spacingXs(); - float cursorR = pMax.x - ppad; - if (!pd.change.empty()) { - float cw = capF->CalcTextSizeA(capF->LegacySize, FLT_MAX, 0, pd.change.c_str()).x; - pdl->AddText(capF, capF->LegacySize, ImVec2(cursorR - cw, belowY), pd.changeCol, pd.change.c_str()); - cursorR -= cw + Layout::spacingSm(); - } - if (!pd.secondary.empty()) { - float sw2 = capF->CalcTextSizeA(capF->LegacySize, FLT_MAX, 0, pd.secondary.c_str()).x; - pdl->AddText(capF, capF->LegacySize, ImVec2(cursorR - sw2, belowY), OnSurfaceDisabled(), pd.secondary.c_str()); - } - } - ImGui::Dummy(ImVec2(pw, ph)); - } - ImGui::Dummy(ImVec2(0, Layout::spacingMd())); - - // Label - ImGui::TextUnformatted(TR("portfolio_label")); - ImGui::SetNextItemWidth(-1); - ImGui::InputText("##pfLabel", s_pf_label, sizeof(s_pf_label)); - ImGui::Dummy(ImVec2(0, Layout::spacingSm())); + // Accordion: exactly one section expanded at a time. + auto section = [&](const char* label, bool& openFlag) -> bool { + ImGui::SetNextItemOpen(openFlag, ImGuiCond_Always); + bool now = ImGui::CollapsingHeader(label); + if (now != openFlag) { s_pf_sec_appearance = s_pf_sec_price = s_pf_sec_addresses = false; openFlag = now; } + return openFlag; + }; + // ---- APPEARANCE (color + outline opacity + icon) ---- + if (section(TR("portfolio_appearance"), s_pf_sec_appearance)) { + ImGui::Indent(Layout::spacingSm()); // Color picker: preset swatches + a custom-color circle that opens a picker popup. { float sw = 22.0f * dp; @@ -503,9 +608,6 @@ static void RenderPortfolioEditor(App* app) if (ImGui::IsItemClicked()) s_pf_color = isDefault ? 0u : kPfPalette[c - 1]; ImGui::PopID(); } - - // Custom-color circle: shows the current color when it isn't a preset, else a "+"; - // click opens a color-picker popup. bool isCustom = (s_pf_color != 0u); for (int c = 0; c < nColors; c++) if (s_pf_color == kPfPalette[c]) isCustom = false; @@ -541,7 +643,6 @@ static void RenderPortfolioEditor(App* app) ImGui::EndPopup(); } } - // Accent-outline opacity (only meaningful when an accent color is set). ImGui::BeginDisabled(s_pf_color == 0u); ImGui::TextUnformatted(TR("portfolio_outline_opacity")); @@ -551,56 +652,11 @@ static void RenderPortfolioEditor(App* app) if (s_pf_outline_opacity > 100) s_pf_outline_opacity = 100; ImGui::EndDisabled(); ImGui::Dummy(ImVec2(0, Layout::spacingSm())); - - // Price data: how this group's value is priced + which fields the card shows. - { - ImGui::TextUnformatted(TR("portfolio_price")); - const char* basisItems[4] = { TR("portfolio_price_usd"), TR("portfolio_price_btc"), - TR("portfolio_price_drgx"), TR("portfolio_price_manual") }; - ImGui::SetNextItemWidth(-1); - ImGui::Combo("##pfBasis", &s_pf_price_basis, basisItems, 4); - - if (s_pf_price_basis == 3) { // Manual: price-per-DRGX + currency label - float half = (ImGui::GetContentRegionAvail().x - Layout::spacingSm()) * 0.62f; - ImGui::SetNextItemWidth(half); - ImGui::InputDouble("##pfManPrice", &s_pf_manual_price, 0.0, 0.0, "%.6f"); - if (s_pf_manual_price < 0.0) s_pf_manual_price = 0.0; - ImGui::SameLine(); - ImGui::SetNextItemWidth(-1); - ImGui::InputTextWithHint("##pfManCcy", TR("portfolio_currency"), s_pf_manual_ccy, sizeof(s_pf_manual_ccy)); - } - - // Field toggles (which price fields appear on the card). - ImGui::Checkbox(DRAGONX_TICKER "##pfShowDrgx", &s_pf_show_drgx); - ImGui::SameLine(); - ImGui::BeginDisabled(s_pf_price_basis == 2); // DRGX-only -> no value field - ImGui::Checkbox(TR("portfolio_show_value"), &s_pf_show_value); - ImGui::EndDisabled(); - ImGui::SameLine(); - ImGui::BeginDisabled(!(s_pf_price_basis == 0 || s_pf_price_basis == 1)); // 24h: live-market only - ImGui::Checkbox(TR("portfolio_show_24h"), &s_pf_show_24h); - ImGui::EndDisabled(); - // Sparkline on its own line (also live-market only) + its interval. - ImGui::BeginDisabled(!(s_pf_price_basis == 0 || s_pf_price_basis == 1)); - ImGui::Checkbox(TR("portfolio_show_sparkline"), &s_pf_show_sparkline); - ImGui::SameLine(); - ImGui::BeginDisabled(!s_pf_show_sparkline); - const char* spItems[5] = { TR("portfolio_spark_min"), TR("portfolio_spark_hour"), - TR("portfolio_spark_day"), TR("portfolio_spark_week"), - TR("portfolio_spark_month") }; - ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x); - ImGui::Combo("##pfSparkInterval", &s_pf_spark_interval, spItems, 5); - ImGui::EndDisabled(); - ImGui::EndDisabled(); - } - ImGui::Dummy(ImVec2(0, Layout::spacingSm())); - - // Icon picker — fills the remaining height of the left column. + // Icon picker grid (fixed height, scrolls internally). { float cell = 28.0f * dp, cellGap = 4.0f * dp; ImGui::TextUnformatted(TR("portfolio_icon")); - float iconGridH = std::max(cell * 2.0f + cellGap, ImGui::GetContentRegionAvail().y); - ImGui::BeginChild("##pfIconGrid", ImVec2(0, iconGridH), true); + ImGui::BeginChild("##pfIconGrid", ImVec2(0, 132.0f * dp), true); ImDrawList* gdl = ImGui::GetWindowDrawList(); float availW = ImGui::GetContentRegionAvail().x; int cols = std::max(1, (int)((availW + cellGap) / (cell + cellGap))); @@ -639,32 +695,59 @@ static void RenderPortfolioEditor(App* app) } ImGui::EndChild(); } + ImGui::Unindent(Layout::spacingSm()); } - ImGui::EndChild(); - ImGui::SameLine(0, colGap); - - // ===================== RIGHT: ADDRESSES ===================== - ImGui::BeginChild("##pfRight", ImVec2(rightW, colH), false); - { - Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("portfolio_addresses_hdr")); + // ---- PRICE (basis, manual price, shown fields, sparkline) ---- + if (section(TR("portfolio_price"), s_pf_sec_price)) { + ImGui::Indent(Layout::spacingSm()); + const char* basisItems[4] = { TR("portfolio_price_usd"), TR("portfolio_price_btc"), + TR("portfolio_price_drgx"), TR("portfolio_price_manual") }; + ImGui::SetNextItemWidth(-1); + ImGui::Combo("##pfBasis", &s_pf_price_basis, basisItems, 4); + if (s_pf_price_basis == 3) { // Manual: price-per-DRGX + currency label + float half = (ImGui::GetContentRegionAvail().x - Layout::spacingSm()) * 0.62f; + ImGui::SetNextItemWidth(half); + ImGui::InputDouble("##pfManPrice", &s_pf_manual_price, 0.0, 0.0, "%.6f"); + if (s_pf_manual_price < 0.0) s_pf_manual_price = 0.0; + ImGui::SameLine(); + ImGui::SetNextItemWidth(-1); + ImGui::InputTextWithHint("##pfManCcy", TR("portfolio_currency"), s_pf_manual_ccy, sizeof(s_pf_manual_ccy)); + } + ImGui::Checkbox(DRAGONX_TICKER "##pfShowDrgx", &s_pf_show_drgx); ImGui::SameLine(); + ImGui::BeginDisabled(s_pf_price_basis == 2); // DRGX-only -> no value field + ImGui::Checkbox(TR("portfolio_show_value"), &s_pf_show_value); + ImGui::EndDisabled(); + ImGui::SameLine(); + ImGui::BeginDisabled(!(s_pf_price_basis == 0 || s_pf_price_basis == 1)); // 24h: live-market only + ImGui::Checkbox(TR("portfolio_show_24h"), &s_pf_show_24h); + ImGui::EndDisabled(); + ImGui::BeginDisabled(!(s_pf_price_basis == 0 || s_pf_price_basis == 1)); + ImGui::Checkbox(TR("portfolio_show_sparkline"), &s_pf_show_sparkline); + ImGui::SameLine(); + ImGui::BeginDisabled(!s_pf_show_sparkline); + const char* spItems[5] = { TR("portfolio_spark_min"), TR("portfolio_spark_hour"), + TR("portfolio_spark_day"), TR("portfolio_spark_week"), + TR("portfolio_spark_month") }; + ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x); + ImGui::Combo("##pfSparkInterval", &s_pf_spark_interval, spItems, 5); + ImGui::EndDisabled(); + ImGui::EndDisabled(); + ImGui::Unindent(Layout::spacingSm()); + } + + // ---- ADDRESSES (search, filter, select) ---- + if (section(TR("portfolio_addresses_hdr"), s_pf_sec_addresses)) { + ImGui::Indent(Layout::spacingSm()); { char selc[48]; snprintf(selc, sizeof(selc), TR("portfolio_addresses_sel"), (int)s_pf_addrs.size()); - float w = capF->CalcTextSizeA(capF->LegacySize, FLT_MAX, 0, selc).x; - float ax = ImGui::GetContentRegionAvail().x; - if (ax > w) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + ax - w); Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), selc); } - ImGui::Dummy(ImVec2(0, Layout::spacingXs())); - - // Search ImGui::SetNextItemWidth(-1); ImGui::InputTextWithHint("##pfSearch", TR("portfolio_search"), s_pf_search, sizeof(s_pf_search)); ImGui::Dummy(ImVec2(0, Layout::spacingXs())); - - // Type filter chips + funded toggle { const char* flabels[3] = { TR("portfolio_select_all"), TR("shielded"), TR("transparent") }; for (int f = 0; f < 3; f++) { @@ -679,8 +762,6 @@ static void RenderPortfolioEditor(App* app) ImGui::Checkbox(TR("portfolio_funded"), &s_pf_funded_only); } ImGui::Dummy(ImVec2(0, Layout::spacingXs())); - - // Build the filtered address list (selected first, then by balance desc). std::string search = s_pf_search; std::vector filtered; for (const auto& a : state.addresses) { @@ -699,17 +780,12 @@ static void RenderPortfolioEditor(App* app) if (sx != sy) return sx; return x->balance > y->balance; }); - - // Select-shown / clear if (ImGui::SmallButton(TR("portfolio_select_shown"))) for (const AddressInfo* a : filtered) data::PortfolioEntryAdd(s_pf_addrs, a->address); ImGui::SameLine(); if (ImGui::SmallButton(TR("portfolio_clear_sel"))) s_pf_addrs.clear(); ImGui::Dummy(ImVec2(0, Layout::spacingXs())); - - // Address list fills the remaining height in the right column. - float listH = std::max(60.0f, ImGui::GetContentRegionAvail().y); - ImGui::BeginChild("##pfAddrList", ImVec2(0, listH), true); + ImGui::BeginChild("##pfAddrList", ImVec2(0, 300.0f * dp), true); ImDrawList* ldl = ImGui::GetWindowDrawList(); float rowH = std::max(26.0f * dp, body2f->LegacySize + Layout::spacingSm()); float lw = ImGui::GetContentRegionAvail().x; @@ -725,8 +801,6 @@ static void RenderPortfolioEditor(App* app) else if (rhov) ldl->AddRectFilled(rmn, rmx, IM_COL32(255, 255, 255, 14), 4.0f); float rcy = rmn.y + rowH * 0.5f; float rx = rmn.x + Layout::spacingSm(); - - // Checkbox indicator float cb = 15.0f * dp; ImVec2 cbMin(rx, rcy - cb * 0.5f), cbMax(rx + cb, rcy + cb * 0.5f); if (inSet) { @@ -739,8 +813,6 @@ static void RenderPortfolioEditor(App* app) ldl->AddRect(cbMin, cbMax, WithAlpha(OnSurface(), 120), 3.0f * dp, 0, 1.2f * dp); } rx += cb + Layout::spacingSm(); - - // Type chip (Z / T) bool isZ = (a.type == "shielded"); const char* chip = isZ ? "Z" : "T"; ImU32 chipCol = isZ ? Success() : Warning(); @@ -749,15 +821,11 @@ static void RenderPortfolioEditor(App* app) ImVec2(rx + chipW, rcy + capF->LegacySize * 0.5f + 2.0f), WithAlpha(chipCol, 40), 4.0f * dp); ldl->AddText(capF, capF->LegacySize, ImVec2(rx + 4.0f * dp, rcy - capF->LegacySize * 0.5f), chipCol, chip); rx += chipW + Layout::spacingSm(); - - // Address's own icon (if set) std::string aicon = app->getAddressIcon(a.address); if (!aicon.empty()) { material::project_icons::drawByName(ldl, aicon, ImVec2(rx + iconFsz * 0.5f, rcy), OnSurfaceMedium(), iconFont, iconFsz); rx += iconFsz + Layout::spacingSm(); } - - // Balance (right) then primary text (label or truncated address). char balbuf[48]; snprintf(balbuf, sizeof(balbuf), "%.4f", a.balance); float balW = capF->CalcTextSizeA(capF->LegacySize, FLT_MAX, 0, balbuf).x; @@ -772,8 +840,6 @@ static void RenderPortfolioEditor(App* app) inSet ? OnSurface() : OnSurfaceMedium(), primary.c_str()); ldl->AddText(capF, capF->LegacySize, ImVec2(rmx.x - Layout::spacingSm() - balW, rcy - capF->LegacySize * 0.5f), a.balance > 1e-9 ? OnSurface() : OnSurfaceDisabled(), balbuf); - - // Whole row toggles selection. ImGui::PushID(a.address.c_str()); ImGui::InvisibleButton("##pfrow", ImVec2(lw, rowH)); if (ImGui::IsItemClicked()) { @@ -783,43 +849,54 @@ static void RenderPortfolioEditor(App* app) ImGui::PopID(); } ImGui::EndChild(); + ImGui::Unindent(Layout::spacingSm()); } - ImGui::EndChild(); + } + ImGui::EndChild(); // ##pfDetail - // ===================== ACTIONS ===================== - ImGui::Dummy(ImVec2(0, Layout::spacingSm())); - bool canSave = s_pf_label[0] != '\0'; - ImGui::BeginDisabled(!canSave); - if (ImGui::Button(TR("portfolio_save"))) { + // ================= FOOTER: delete / revert / save ================= + ImGui::Separator(); + { + ImGui::BeginDisabled(s_pf_sel < 0); + if (ImGui::Button(TR("portfolio_delete"))) { auto entries = settings->getPortfolioEntries(); - // Start from the existing entry when editing so its grid placement/size (and any - // other fields not on this form) are preserved — editing must not reset the card. - config::Settings::PortfolioEntry e; - if (s_pf_editing >= 0 && s_pf_editing < (int)entries.size()) e = entries[s_pf_editing]; - e.label = s_pf_label; - e.addresses = s_pf_addrs; - e.icon = s_pf_icon; - e.color = s_pf_color; - e.outlineOpacity = s_pf_outline_opacity; - e.priceBasis = s_pf_price_basis; - e.manualPrice = s_pf_manual_price; - e.manualCurrency = s_pf_manual_ccy; - e.showDrgx = s_pf_show_drgx; - e.showValue = s_pf_show_value; - e.show24h = s_pf_show_24h; - e.showSparkline = s_pf_show_sparkline; - e.sparklineInterval = s_pf_spark_interval; - if (s_pf_editing >= 0 && s_pf_editing < (int)entries.size()) entries[s_pf_editing] = e; - else entries.push_back(e); - persist(entries); - s_pf_editing = -1; + if (s_pf_sel >= 0 && s_pf_sel < (int)entries.size()) { + entries.erase(entries.begin() + s_pf_sel); + persist(entries); + if (entries.empty()) s_pf_sel = -1; + else { s_pf_sel = std::min(s_pf_sel, (int)entries.size() - 1); PortfolioBeginEdit(app, s_pf_sel); } + } } ImGui::EndDisabled(); + + float bw = 92.0f * dp, aw = bw * 2 + Layout::spacingSm(); ImGui::SameLine(); - if (ImGui::Button(TR("portfolio_cancel"))) s_pf_editing = -1; + float availF = ImGui::GetContentRegionAvail().x; + if (availF > aw) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + availF - aw); + ImGui::BeginDisabled(s_pf_sel == -1); + if (ImGui::Button(TR("portfolio_revert"), ImVec2(bw, 0))) + PortfolioBeginEdit(app, s_pf_sel); // reload from the stored entry (or defaults for a draft) + ImGui::EndDisabled(); + ImGui::SameLine(); + ImGui::BeginDisabled(s_pf_label[0] == '\0'); + if (ImGui::Button(TR("portfolio_save"), ImVec2(bw, 0))) commitIfNeeded(); + ImGui::EndDisabled(); } - material::EndOverlayDialog(); + ImGui::EndChild(); // ##pfCard + ImGui::PopStyleColor(); + ImGui::PopStyleVar(2); + + // Outside-click dismiss (guarded against the appearing frame and open popups). + if (!popupOpen && !ImGui::IsWindowAppearing() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) + && !ImGui::IsMouseHoveringRect(cardMin, cardMax)) + s_portfolio_editor_open = false; + // Commit named edits on any close path (X / Esc / outside-click); unnamed drafts are dropped. + if (!s_portfolio_editor_open) commitIfNeeded(); + + ImGui::End(); + ImGui::PopStyleColor(); + ImGui::PopStyleVar(3); } void RenderMarketTab(App* app) @@ -1448,7 +1525,11 @@ void RenderMarketTab(App* app) ImGui::SameLine(); float availX = ImGui::GetContentRegionAvail().x; if (availX > mBtnW) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + availX - mBtnW); - if (ImGui::Button(ml, ImVec2(mBtnW, 0))) s_portfolio_editor_open = true; + if (ImGui::Button(ml, ImVec2(mBtnW, 0))) { + // Open the combined editor with a valid selection: first group, or a blank draft. + PortfolioBeginEdit(app, app->settings()->getPortfolioEntries().empty() ? -2 : 0); + s_portfolio_editor_open = true; + } } ImGui::Dummy(ImVec2(0, Layout::spacingXs())); diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index 20f1f6a..e920b43 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -1165,6 +1165,8 @@ void I18n::loadBuiltinEnglish() strings_["portfolio_delete"] = "Delete"; strings_["portfolio_save"] = "Save"; strings_["portfolio_cancel"] = "Cancel"; + strings_["portfolio_revert"] = "Revert"; + strings_["portfolio_detail_empty"] = "Select a group on the left, or add one, to edit it."; strings_["portfolio_add_to"] = "Add to portfolio"; strings_["portfolio_remove_from"] = "Remove from portfolio"; strings_["market_24h_change"] = "24h";