refactor(market): extract portfolio detail sections into helpers

Pull the three detail-pane section blocks out of RenderPortfolioEditor into
pfDrawAppearanceSection / pfDrawPriceSection / pfDrawAddressSection (each
recomputes its own dp/fonts/state; the section dispatch is a 3-line switch).
Verbatim move — same widgets, IDs and drawing. RenderPortfolioEditor drops from
~760 to ~357 lines. No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-04 00:20:25 -05:00
parent 88f1415bcc
commit 529a74f6a7

View File

@@ -431,6 +431,356 @@ static void pfCommitIfNeeded(config::Settings* settings)
pfPersist(settings, entries); pfPersist(settings, entries);
} }
// ---- Detail-pane section renderers (drawn inside ##pfDetailBody; operate on s_pfEdit) ----
// Appearance: accent-color picker (+ custom), outline-opacity slider, icon grid.
static void pfDrawAppearanceSection()
{
float dp = Layout::dpiScale();
// Color picker: preset swatches in ONE row (overflow hidden) + a custom "+" cell that is
// always visible at the right and opens a picker.
{
float sw = 22.0f * dp, gap = 5.0f * dp;
ImGui::TextUnformatted(TR("portfolio_color"));
ImDrawList* cdl = ImGui::GetWindowDrawList();
int nColors = (int)(sizeof(kPfPalette) / sizeof(kPfPalette[0]));
float availW = ImGui::GetContentRegionAvail().x;
ImVec2 rowStart = ImGui::GetCursorScreenPos();
// Reserved "+" slot at the right; inset by a few px so its selection outline
// (drawn at radius sw*0.5) stays inside the modal instead of clipping at the edge.
float plusX = rowStart.x + std::max(sw, availW - sw - 4.0f * dp);
bool isCustom = (s_pfEdit.color != 0u);
for (int c = 0; c < nColors; c++) if (s_pfEdit.color == kPfPalette[c]) isCustom = false;
bool openCustom = false;
// Preset swatches (0 = default/no color, 1..N = palette); stop before the "+" slot.
float x = rowStart.x;
for (int idx = 0; idx <= nColors; idx++) {
if (x + sw > plusX - gap) break; // would reach the "+" -> hide the remaining colors
ImVec2 mn(x, rowStart.y);
ImVec2 cc(mn.x + sw * 0.5f, mn.y + sw * 0.5f);
bool isDefault = (idx == 0);
ImU32 col32 = isDefault ? 0u : kPfPalette[idx - 1];
bool sel = isDefault ? (s_pfEdit.color == 0u) : (s_pfEdit.color == col32);
bool hov = ImGui::IsMouseHoveringRect(mn, ImVec2(mn.x + sw, mn.y + sw));
if (isDefault) cdl->AddCircle(cc, sw * 0.40f, OnSurfaceMedium(), 0, 1.5f * dp);
else cdl->AddCircleFilled(cc, sw * 0.40f, col32);
if (sel) cdl->AddCircle(cc, sw * 0.50f, OnSurface(), 0, 2.0f * dp);
else if (hov) cdl->AddCircle(cc, sw * 0.50f, WithAlpha(OnSurface(), 120), 0, 1.5f * dp);
ImGui::PushID(2000 + idx);
ImGui::SetCursorScreenPos(mn);
ImGui::InvisibleButton("##pfcol", ImVec2(sw, sw));
if (ImGui::IsItemClicked()) s_pfEdit.color = isDefault ? 0u : col32;
ImGui::PopID();
x += sw + gap;
}
// Custom "+" cell — always visible at the reserved right slot.
{
ImVec2 mn(plusX, rowStart.y);
ImVec2 cc(mn.x + sw * 0.5f, mn.y + sw * 0.5f);
bool hov = ImGui::IsMouseHoveringRect(mn, ImVec2(mn.x + sw, mn.y + sw));
if (isCustom) {
cdl->AddCircleFilled(cc, sw * 0.40f, (ImU32)s_pfEdit.color);
} else {
cdl->AddCircle(cc, sw * 0.40f, OnSurfaceMedium(), 0, 1.5f * dp);
float pr = sw * 0.18f;
cdl->AddLine(ImVec2(cc.x - pr, cc.y), ImVec2(cc.x + pr, cc.y), OnSurfaceMedium(), 1.5f * dp);
cdl->AddLine(ImVec2(cc.x, cc.y - pr), ImVec2(cc.x, cc.y + pr), OnSurfaceMedium(), 1.5f * dp);
}
if (isCustom) cdl->AddCircle(cc, sw * 0.50f, OnSurface(), 0, 2.0f * dp);
else if (hov) cdl->AddCircle(cc, sw * 0.50f, WithAlpha(OnSurface(), 120), 0, 1.5f * dp);
ImGui::PushID(2999);
ImGui::SetCursorScreenPos(mn);
ImGui::InvisibleButton("##pfcustomcol", ImVec2(sw, sw));
if (ImGui::IsItemClicked()) {
ImU32 base = s_pfEdit.color ? (ImU32)s_pfEdit.color : IM_COL32(0x4F, 0x9D, 0xFF, 0xFF);
s_pfEdit.customRgb[0] = ((base >> IM_COL32_R_SHIFT) & 0xFF) / 255.0f;
s_pfEdit.customRgb[1] = ((base >> IM_COL32_G_SHIFT) & 0xFF) / 255.0f;
s_pfEdit.customRgb[2] = ((base >> IM_COL32_B_SHIFT) & 0xFF) / 255.0f;
openCustom = true;
}
if (hov) material::Tooltip("%s", TR("portfolio_custom_color"));
ImGui::PopID();
}
ImGui::SetCursorScreenPos(ImVec2(rowStart.x, rowStart.y + sw));
ImGui::Dummy(ImVec2(availW, 0));
if (openCustom) ImGui::OpenPopup("##pfCustomColor");
if (ImGui::BeginPopup("##pfCustomColor")) {
ImGui::ColorPicker3("##pfpick", s_pfEdit.customRgb,
ImGuiColorEditFlags_NoSidePreview | ImGuiColorEditFlags_NoAlpha | ImGuiColorEditFlags_NoLabel);
s_pfEdit.color = IM_COL32((int)(s_pfEdit.customRgb[0] * 255.0f + 0.5f),
(int)(s_pfEdit.customRgb[1] * 255.0f + 0.5f),
(int)(s_pfEdit.customRgb[2] * 255.0f + 0.5f), 255);
ImGui::EndPopup();
}
}
// Accent-outline opacity (only meaningful when an accent color is set).
ImGui::BeginDisabled(s_pfEdit.color == 0u);
ImGui::TextUnformatted(TR("portfolio_outline_opacity"));
ImGui::SetNextItemWidth(-1);
ImGui::SliderInt("##pfOutlineOpacity", &s_pfEdit.outlineOpacity, 0, 100, "%d%%");
if (s_pfEdit.outlineOpacity < 0) s_pfEdit.outlineOpacity = 0;
if (s_pfEdit.outlineOpacity > 100) s_pfEdit.outlineOpacity = 100;
ImGui::EndDisabled();
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// Icon picker: search on the heading line + a 12-per-row grid that scales to width,
// smooth-scrolled with the Settings-tab edge fade.
{
float cellGap = 6.0f * dp;
ImFont* gIconFont = Type().iconXL();
ImGui::TextUnformatted(TR("portfolio_icon"));
ImGui::SameLine();
{
float searchW = std::min(ImGui::GetContentRegionAvail().x, 200.0f * dp);
float off = ImGui::GetContentRegionAvail().x - searchW; // right-align the field
if (off > 0.0f) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + off);
ImGui::SetNextItemWidth(searchW);
ImGui::InputTextWithHint("##pfIconSearch", TR("portfolio_search_icons"),
s_pfEdit.iconSearch, sizeof(s_pfEdit.iconSearch));
}
// Filtered icon list (-1 = "None", shown only when not searching).
std::string isearch = s_pfEdit.iconSearch;
std::vector<int> iconIdx;
if (isearch.empty()) iconIdx.push_back(-1);
int nIcons = material::project_icons::walletIconCount();
for (int i = 0; i < nIcons; i++) {
if (!isearch.empty() &&
!util::containsIgnoreCase(material::project_icons::walletIconName(i), isearch))
continue;
iconIdx.push_back(i);
}
ImDrawList* gdl = pfBeginScrollChild("##pfIconGrid", s_pfEdit.iconFade,
ImVec2(Layout::spacingSm(), Layout::spacingSm()), dp);
const int cols = 12;
float availW = ImGui::GetContentRegionAvail().x;
float cell = std::max(14.0f * dp, (availW - cellGap * (cols - 1)) / (float)cols);
float gIconSz = cell * 0.5f;
float gridW = cell * cols + cellGap * (cols - 1);
float indent = std::max(0.0f, (availW - gridW) * 0.5f);
int gcol = 0;
for (size_t n = 0; n < iconIdx.size(); n++) {
int idx = iconIdx[n];
if (gcol == 0) { if (indent > 0.0f) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + indent); }
else ImGui::SameLine(0, cellGap);
ImVec2 mn = ImGui::GetCursorScreenPos();
ImVec2 mx(mn.x + cell, mn.y + cell);
ImVec2 cc(mn.x + cell * 0.5f, mn.y + cell * 0.5f);
bool hov = ImGui::IsMouseHoveringRect(mn, mx);
bool isNone = (idx < 0);
const char* nm2 = isNone ? "" : material::project_icons::walletIconName(idx);
bool sel = isNone ? s_pfEdit.icon.empty() : (s_pfEdit.icon == nm2);
if (sel) {
gdl->AddRectFilled(mn, mx, WithAlpha(Primary(), 40), 6.0f * dp);
gdl->AddRect(mn, mx, WithAlpha(Primary(), 120), 6.0f * dp, 0, 1.5f * dp);
} else if (hov) {
gdl->AddRectFilled(mn, mx, IM_COL32(255, 255, 255, 20), 6.0f * dp);
}
ImU32 icol = sel ? Primary() : (hov ? OnSurface() : OnSurfaceMedium());
if (isNone) {
float r = cell * 0.26f;
gdl->AddCircle(cc, r, icol, 0, 1.5f * dp);
gdl->AddLine(ImVec2(cc.x - r * 0.7f, cc.y + r * 0.7f),
ImVec2(cc.x + r * 0.7f, cc.y - r * 0.7f), icol, 1.5f * dp);
} else {
material::project_icons::drawByName(gdl, nm2, cc, icol, gIconFont, gIconSz);
}
ImGui::PushID(idx + 2);
ImGui::InvisibleButton("##pfic", ImVec2(cell, cell));
if (ImGui::IsItemClicked()) s_pfEdit.icon = isNone ? std::string() : std::string(nm2);
if (hov) material::Tooltip("%s", isNone ? TR("portfolio_no_icon") : nm2);
ImGui::PopID();
gcol = (gcol + 1) % cols;
}
pfEndScrollChild(s_pfEdit.iconFade, gdl);
}
}
// Price: basis radios, optional manual price/currency, shown-field toggles, sparkline interval.
static void pfDrawPriceSection()
{
// Price basis — radio group (all options visible; no dropdown).
const char* basisItems[4] = { TR("portfolio_price_usd"), TR("portfolio_price_btc"),
TR("portfolio_price_drgx"), TR("portfolio_price_manual") };
for (int b = 0; b < 4; b++) { if (b) ImGui::SameLine(); ImGui::RadioButton(basisItems[b], &s_pfEdit.priceBasis, b); }
if (s_pfEdit.priceBasis == 3) { // Manual: price-per-DRGX + currency label
float half = (ImGui::GetContentRegionAvail().x - Layout::spacingSm()) * 0.62f;
ImGui::SetNextItemWidth(half);
ImGui::InputDouble("##pfManPrice", &s_pfEdit.manualPrice, 0.0, 0.0, "%.6f");
if (s_pfEdit.manualPrice < 0.0) s_pfEdit.manualPrice = 0.0;
ImGui::SameLine();
ImGui::SetNextItemWidth(-1);
ImGui::InputTextWithHint("##pfManCcy", TR("portfolio_currency"), s_pfEdit.manualCcy, sizeof(s_pfEdit.manualCcy));
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// Shown fields.
ImGui::TextUnformatted(TR("portfolio_show"));
ImGui::Checkbox(DRAGONX_TICKER "##pfShowDrgx", &s_pfEdit.showDrgx);
ImGui::SameLine();
ImGui::BeginDisabled(s_pfEdit.priceBasis == 2); // DRGX-only -> no value field
ImGui::Checkbox(TR("portfolio_show_value"), &s_pfEdit.showValue);
ImGui::EndDisabled();
ImGui::SameLine();
// 24h + sparkline toggles share one line (both live-market only).
ImGui::BeginDisabled(!(s_pfEdit.priceBasis == 0 || s_pfEdit.priceBasis == 1));
ImGui::Checkbox(TR("portfolio_show_24h"), &s_pfEdit.show24h);
ImGui::SameLine();
ImGui::Checkbox(TR("portfolio_show_sparkline"), &s_pfEdit.showSparkline);
ImGui::EndDisabled();
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// Sparkline interval — radio group (enabled only when the sparkline is shown).
ImGui::BeginDisabled(!s_pfEdit.showSparkline || !(s_pfEdit.priceBasis == 0 || s_pfEdit.priceBasis == 1));
const char* spItems[5] = { TR("portfolio_spark_min"), TR("portfolio_spark_hour"),
TR("portfolio_spark_day"), TR("portfolio_spark_week"),
TR("portfolio_spark_month") };
for (int s = 0; s < 5; s++) { if (s) ImGui::SameLine(); ImGui::RadioButton(spItems[s], &s_pfEdit.sparkInterval, s); }
ImGui::EndDisabled();
}
// Addresses: selection count, search, type-filter/select row, and the pickable address list.
static void pfDrawAddressSection(App* app)
{
float dp = Layout::dpiScale();
const auto& state = app->getWalletState();
ImFont* body2f = Type().body2();
ImFont* capF = Type().caption();
ImFont* iconFont = Type().iconSmall();
float iconFsz = iconFont->LegacySize;
{
char selc[48];
snprintf(selc, sizeof(selc), TR("portfolio_addresses_sel"), (int)s_pfEdit.addrs.size());
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), selc);
}
ImGui::SetNextItemWidth(-1);
ImGui::InputTextWithHint("##pfSearch", TR("portfolio_search"), s_pfEdit.search, sizeof(s_pfEdit.search));
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
// Filter + selection on one row: type-filter segmented (left) + Select all / Clear /
// Funded (right). Select-all is deferred so it applies to the freshly-filtered set below.
bool selAllClicked = false;
{
const char* flabels[3] = { TR("portfolio_select_all"), TR("shielded"), TR("transparent") };
const char* selLbl = TR("portfolio_select_shown");
const char* clrLbl = TR("portfolio_clear_sel");
ImGuiStyle& st = ImGui::GetStyle();
ImVec2 pillPad(Layout::spacingLg(), Layout::spacingSm()); // roomier text padding
float defFrameH = ImGui::GetFrameHeight();
float fundedW = defFrameH + st.ItemInnerSpacing.x + ImGui::CalcTextSize(TR("portfolio_funded")).x;
float pillH = ImGui::GetTextLineHeight() + pillPad.y * 2.0f;
float selW = ImGui::CalcTextSize(selLbl).x + pillPad.x * 2.0f;
float clrW = ImGui::CalcTextSize(clrLbl).x + pillPad.x * 2.0f;
float groupW = selW + clrW + fundedW + st.ItemSpacing.x * 2.0f;
float segH = 26.0f * dp;
float rowW = ImGui::GetContentRegionAvail().x;
float segW = std::min(360.0f * dp, std::max(160.0f * dp, rowW - groupW - Layout::spacingMd()));
float rowH = std::max(segH, pillH);
ImVec2 rowStart = ImGui::GetCursorScreenPos();
// Segmented type filter (left), vertically centered in the row.
float segY = rowStart.y + (rowH - segH) * 0.5f;
int tclk = pfSegmentedControl(ImGui::GetWindowDrawList(), ImVec2(rowStart.x, segY),
segW, segH, flabels, 3, s_pfEdit.typeFilter, capF, "##pftf", dp);
if (tclk >= 0) s_pfEdit.typeFilter = tclk;
// Select all / Clear (rounded pills) + Funded toggle (right), vertically centered.
float grpX = rowStart.x + std::max(0.0f, rowW - groupW);
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 12.0f * dp);
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, pillPad);
ImGui::SetCursorScreenPos(ImVec2(grpX, rowStart.y + (rowH - pillH) * 0.5f));
if (ImGui::Button(selLbl)) selAllClicked = true;
ImGui::SameLine();
if (ImGui::Button(clrLbl)) s_pfEdit.addrs.clear();
ImGui::PopStyleVar(2);
ImGui::SameLine();
ImGui::SetCursorScreenPos(ImVec2(ImGui::GetCursorScreenPos().x,
rowStart.y + (rowH - defFrameH) * 0.5f)); // center vs. pills
ImGui::Checkbox(TR("portfolio_funded"), &s_pfEdit.fundedOnly);
ImGui::SetCursorScreenPos(ImVec2(rowStart.x, rowStart.y + rowH));
ImGui::Dummy(ImVec2(rowW, 0));
}
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
std::string search = s_pfEdit.search;
std::vector<const AddressInfo*> filtered;
for (const auto& a : state.addresses) {
if (s_pfEdit.typeFilter == 1 && a.type != "shielded") continue;
if (s_pfEdit.typeFilter == 2 && a.type != "transparent") continue;
if (s_pfEdit.fundedOnly && a.balance <= 1e-9) continue;
if (!search.empty()) {
std::string hay = a.address + " " + app->getAddressLabel(a.address);
if (!util::containsIgnoreCase(hay, search)) continue;
}
filtered.push_back(&a);
}
std::sort(filtered.begin(), filtered.end(), [&](const AddressInfo* x, const AddressInfo* y) {
bool sx = data::PortfolioEntryContains(s_pfEdit.addrs, x->address);
bool sy = data::PortfolioEntryContains(s_pfEdit.addrs, y->address);
if (sx != sy) return sx;
return x->balance > y->balance;
});
if (selAllClicked)
for (const AddressInfo* a : filtered) data::PortfolioEntryAdd(s_pfEdit.addrs, a->address);
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
ImDrawList* ldl = pfBeginScrollChild("##pfAddrList", s_pfEdit.addrFade,
ImVec2(Layout::spacingMd(), Layout::spacingSm()), dp);
float rowH = std::max(26.0f * dp, body2f->LegacySize + Layout::spacingSm());
float lw = ImGui::GetContentRegionAvail().x;
if (filtered.empty())
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("portfolio_no_addr_match"));
for (const AddressInfo* ap : filtered) {
const AddressInfo& a = *ap;
bool inSet = data::PortfolioEntryContains(s_pfEdit.addrs, a.address);
ImVec2 rmn = ImGui::GetCursorScreenPos();
ImVec2 rmx(rmn.x + lw, rmn.y + rowH);
bool rhov = ImGui::IsMouseHoveringRect(rmn, rmx);
if (inSet) ldl->AddRectFilled(rmn, rmx, WithAlpha(Primary(), 22), 4.0f);
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();
float cb = 15.0f * dp;
ImVec2 cbMin(rx, rcy - cb * 0.5f), cbMax(rx + cb, rcy + cb * 0.5f);
if (inSet) {
ldl->AddRectFilled(cbMin, cbMax, Primary(), 3.0f * dp);
ldl->AddLine(ImVec2(cbMin.x + cb * 0.22f, rcy),
ImVec2(cbMin.x + cb * 0.42f, cbMax.y - cb * 0.25f), IM_COL32(255, 255, 255, 255), 1.6f * dp);
ldl->AddLine(ImVec2(cbMin.x + cb * 0.42f, cbMax.y - cb * 0.25f),
ImVec2(cbMin.x + cb * 0.80f, cbMin.y + cb * 0.22f), IM_COL32(255, 255, 255, 255), 1.6f * dp);
} else {
ldl->AddRect(cbMin, cbMax, WithAlpha(OnSurface(), 120), 3.0f * dp, 0, 1.2f * dp);
}
rx += cb + Layout::spacingSm();
bool isZ = (a.type == "shielded");
const char* chip = isZ ? "Z" : "T";
ImU32 chipCol = isZ ? Success() : Warning();
float chipW = capF->CalcTextSizeA(capF->LegacySize, FLT_MAX, 0, chip).x + 8.0f * dp;
ldl->AddRectFilled(ImVec2(rx, rcy - capF->LegacySize * 0.5f - 2.0f),
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();
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();
}
char balbuf[48];
snprintf(balbuf, sizeof(balbuf), "%.4f", a.balance);
float balW = capF->CalcTextSizeA(capF->LegacySize, FLT_MAX, 0, balbuf).x;
float textRight = rmx.x - Layout::spacingSm() - balW - Layout::spacingSm();
std::string alabel = app->getAddressLabel(a.address);
std::string primary = alabel.empty() ? util::truncateMiddle(a.address, 22) : alabel;
float availTextW = textRight - rx;
while (primary.size() > 1 &&
body2f->CalcTextSizeA(body2f->LegacySize, FLT_MAX, 0, primary.c_str()).x > availTextW)
primary.pop_back();
ldl->AddText(body2f, body2f->LegacySize, ImVec2(rx, rcy - body2f->LegacySize * 0.5f),
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);
ImGui::PushID(a.address.c_str());
ImGui::InvisibleButton("##pfrow", ImVec2(lw, rowH));
if (ImGui::IsItemClicked()) {
if (inSet) data::PortfolioEntryRemove(s_pfEdit.addrs, a.address);
else data::PortfolioEntryAdd(s_pfEdit.addrs, a.address);
}
ImGui::PopID();
}
pfEndScrollChild(s_pfEdit.addrFade, ldl);
}
// The "Manage portfolio" modal: list/add/edit/delete entries, each a label tied to a group // The "Manage portfolio" modal: list/add/edit/delete entries, each a label tied to a group
// of wallet addresses. Persists to Settings on every mutation (like the address book). // of wallet addresses. Persists to Settings on every mutation (like the address book).
static void RenderPortfolioEditor(App* app) static void RenderPortfolioEditor(App* app)
@@ -444,7 +794,6 @@ static void RenderPortfolioEditor(App* app)
ImFont* capF = Type().caption(); ImFont* capF = Type().caption();
ImFont* sub1f = Type().subtitle1(); ImFont* sub1f = Type().subtitle1();
ImFont* iconFont = Type().iconSmall(); ImFont* iconFont = Type().iconSmall();
float iconFsz = iconFont->LegacySize;
// Persistence helpers are file-static (pfBuildWorking / pfWorkingMatches / pfCommitIfNeeded). // Persistence helpers are file-static (pfBuildWorking / pfWorkingMatches / pfCommitIfNeeded).
@@ -740,343 +1089,11 @@ static void RenderPortfolioEditor(App* app)
} }
ImGui::Dummy(ImVec2(0, Layout::spacingMd())); ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
// ---- APPEARANCE (color + outline opacity + icon) ---- // ---- Detail sections (one shown at a time via the segmented control above) ----
if (s_pfEdit.section == 0) { if (s_pfEdit.section == 0) pfDrawAppearanceSection();
// Color picker: preset swatches in ONE row (overflow hidden) + a custom "+" cell that is else if (s_pfEdit.section == 1) pfDrawPriceSection();
// always visible at the right and opens a picker. else if (s_pfEdit.section == 2) pfDrawAddressSection(app);
{
float sw = 22.0f * dp, gap = 5.0f * dp;
ImGui::TextUnformatted(TR("portfolio_color"));
ImDrawList* cdl = ImGui::GetWindowDrawList();
int nColors = (int)(sizeof(kPfPalette) / sizeof(kPfPalette[0]));
float availW = ImGui::GetContentRegionAvail().x;
ImVec2 rowStart = ImGui::GetCursorScreenPos();
// Reserved "+" slot at the right; inset by a few px so its selection outline
// (drawn at radius sw*0.5) stays inside the modal instead of clipping at the edge.
float plusX = rowStart.x + std::max(sw, availW - sw - 4.0f * dp);
bool isCustom = (s_pfEdit.color != 0u);
for (int c = 0; c < nColors; c++) if (s_pfEdit.color == kPfPalette[c]) isCustom = false;
bool openCustom = false;
// Preset swatches (0 = default/no color, 1..N = palette); stop before the "+" slot.
float x = rowStart.x;
for (int idx = 0; idx <= nColors; idx++) {
if (x + sw > plusX - gap) break; // would reach the "+" -> hide the remaining colors
ImVec2 mn(x, rowStart.y);
ImVec2 cc(mn.x + sw * 0.5f, mn.y + sw * 0.5f);
bool isDefault = (idx == 0);
ImU32 col32 = isDefault ? 0u : kPfPalette[idx - 1];
bool sel = isDefault ? (s_pfEdit.color == 0u) : (s_pfEdit.color == col32);
bool hov = ImGui::IsMouseHoveringRect(mn, ImVec2(mn.x + sw, mn.y + sw));
if (isDefault) cdl->AddCircle(cc, sw * 0.40f, OnSurfaceMedium(), 0, 1.5f * dp);
else cdl->AddCircleFilled(cc, sw * 0.40f, col32);
if (sel) cdl->AddCircle(cc, sw * 0.50f, OnSurface(), 0, 2.0f * dp);
else if (hov) cdl->AddCircle(cc, sw * 0.50f, WithAlpha(OnSurface(), 120), 0, 1.5f * dp);
ImGui::PushID(2000 + idx);
ImGui::SetCursorScreenPos(mn);
ImGui::InvisibleButton("##pfcol", ImVec2(sw, sw));
if (ImGui::IsItemClicked()) s_pfEdit.color = isDefault ? 0u : col32;
ImGui::PopID();
x += sw + gap;
}
// Custom "+" cell — always visible at the reserved right slot.
{
ImVec2 mn(plusX, rowStart.y);
ImVec2 cc(mn.x + sw * 0.5f, mn.y + sw * 0.5f);
bool hov = ImGui::IsMouseHoveringRect(mn, ImVec2(mn.x + sw, mn.y + sw));
if (isCustom) {
cdl->AddCircleFilled(cc, sw * 0.40f, (ImU32)s_pfEdit.color);
} else {
cdl->AddCircle(cc, sw * 0.40f, OnSurfaceMedium(), 0, 1.5f * dp);
float pr = sw * 0.18f;
cdl->AddLine(ImVec2(cc.x - pr, cc.y), ImVec2(cc.x + pr, cc.y), OnSurfaceMedium(), 1.5f * dp);
cdl->AddLine(ImVec2(cc.x, cc.y - pr), ImVec2(cc.x, cc.y + pr), OnSurfaceMedium(), 1.5f * dp);
}
if (isCustom) cdl->AddCircle(cc, sw * 0.50f, OnSurface(), 0, 2.0f * dp);
else if (hov) cdl->AddCircle(cc, sw * 0.50f, WithAlpha(OnSurface(), 120), 0, 1.5f * dp);
ImGui::PushID(2999);
ImGui::SetCursorScreenPos(mn);
ImGui::InvisibleButton("##pfcustomcol", ImVec2(sw, sw));
if (ImGui::IsItemClicked()) {
ImU32 base = s_pfEdit.color ? (ImU32)s_pfEdit.color : IM_COL32(0x4F, 0x9D, 0xFF, 0xFF);
s_pfEdit.customRgb[0] = ((base >> IM_COL32_R_SHIFT) & 0xFF) / 255.0f;
s_pfEdit.customRgb[1] = ((base >> IM_COL32_G_SHIFT) & 0xFF) / 255.0f;
s_pfEdit.customRgb[2] = ((base >> IM_COL32_B_SHIFT) & 0xFF) / 255.0f;
openCustom = true;
}
if (hov) material::Tooltip("%s", TR("portfolio_custom_color"));
ImGui::PopID();
}
ImGui::SetCursorScreenPos(ImVec2(rowStart.x, rowStart.y + sw));
ImGui::Dummy(ImVec2(availW, 0));
if (openCustom) ImGui::OpenPopup("##pfCustomColor");
if (ImGui::BeginPopup("##pfCustomColor")) {
ImGui::ColorPicker3("##pfpick", s_pfEdit.customRgb,
ImGuiColorEditFlags_NoSidePreview | ImGuiColorEditFlags_NoAlpha | ImGuiColorEditFlags_NoLabel);
s_pfEdit.color = IM_COL32((int)(s_pfEdit.customRgb[0] * 255.0f + 0.5f),
(int)(s_pfEdit.customRgb[1] * 255.0f + 0.5f),
(int)(s_pfEdit.customRgb[2] * 255.0f + 0.5f), 255);
ImGui::EndPopup();
}
}
// Accent-outline opacity (only meaningful when an accent color is set).
ImGui::BeginDisabled(s_pfEdit.color == 0u);
ImGui::TextUnformatted(TR("portfolio_outline_opacity"));
ImGui::SetNextItemWidth(-1);
ImGui::SliderInt("##pfOutlineOpacity", &s_pfEdit.outlineOpacity, 0, 100, "%d%%");
if (s_pfEdit.outlineOpacity < 0) s_pfEdit.outlineOpacity = 0;
if (s_pfEdit.outlineOpacity > 100) s_pfEdit.outlineOpacity = 100;
ImGui::EndDisabled();
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// Icon picker: search on the heading line + a 12-per-row grid that scales to width,
// smooth-scrolled with the Settings-tab edge fade.
{
float cellGap = 6.0f * dp;
ImFont* gIconFont = Type().iconXL();
ImGui::TextUnformatted(TR("portfolio_icon"));
ImGui::SameLine();
{
float searchW = std::min(ImGui::GetContentRegionAvail().x, 200.0f * dp);
float off = ImGui::GetContentRegionAvail().x - searchW; // right-align the field
if (off > 0.0f) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + off);
ImGui::SetNextItemWidth(searchW);
ImGui::InputTextWithHint("##pfIconSearch", TR("portfolio_search_icons"),
s_pfEdit.iconSearch, sizeof(s_pfEdit.iconSearch));
}
// Filtered icon list (-1 = "None", shown only when not searching).
std::string isearch = s_pfEdit.iconSearch;
std::vector<int> iconIdx;
if (isearch.empty()) iconIdx.push_back(-1);
int nIcons = material::project_icons::walletIconCount();
for (int i = 0; i < nIcons; i++) {
if (!isearch.empty() &&
!util::containsIgnoreCase(material::project_icons::walletIconName(i), isearch))
continue;
iconIdx.push_back(i);
}
ImDrawList* gdl = pfBeginScrollChild("##pfIconGrid", s_pfEdit.iconFade,
ImVec2(Layout::spacingSm(), Layout::spacingSm()), dp);
const int cols = 12;
float availW = ImGui::GetContentRegionAvail().x;
float cell = std::max(14.0f * dp, (availW - cellGap * (cols - 1)) / (float)cols);
float gIconSz = cell * 0.5f;
float gridW = cell * cols + cellGap * (cols - 1);
float indent = std::max(0.0f, (availW - gridW) * 0.5f);
int gcol = 0;
for (size_t n = 0; n < iconIdx.size(); n++) {
int idx = iconIdx[n];
if (gcol == 0) { if (indent > 0.0f) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + indent); }
else ImGui::SameLine(0, cellGap);
ImVec2 mn = ImGui::GetCursorScreenPos();
ImVec2 mx(mn.x + cell, mn.y + cell);
ImVec2 cc(mn.x + cell * 0.5f, mn.y + cell * 0.5f);
bool hov = ImGui::IsMouseHoveringRect(mn, mx);
bool isNone = (idx < 0);
const char* nm2 = isNone ? "" : material::project_icons::walletIconName(idx);
bool sel = isNone ? s_pfEdit.icon.empty() : (s_pfEdit.icon == nm2);
if (sel) {
gdl->AddRectFilled(mn, mx, WithAlpha(Primary(), 40), 6.0f * dp);
gdl->AddRect(mn, mx, WithAlpha(Primary(), 120), 6.0f * dp, 0, 1.5f * dp);
} else if (hov) {
gdl->AddRectFilled(mn, mx, IM_COL32(255, 255, 255, 20), 6.0f * dp);
}
ImU32 icol = sel ? Primary() : (hov ? OnSurface() : OnSurfaceMedium());
if (isNone) {
float r = cell * 0.26f;
gdl->AddCircle(cc, r, icol, 0, 1.5f * dp);
gdl->AddLine(ImVec2(cc.x - r * 0.7f, cc.y + r * 0.7f),
ImVec2(cc.x + r * 0.7f, cc.y - r * 0.7f), icol, 1.5f * dp);
} else {
material::project_icons::drawByName(gdl, nm2, cc, icol, gIconFont, gIconSz);
}
ImGui::PushID(idx + 2);
ImGui::InvisibleButton("##pfic", ImVec2(cell, cell));
if (ImGui::IsItemClicked()) s_pfEdit.icon = isNone ? std::string() : std::string(nm2);
if (hov) material::Tooltip("%s", isNone ? TR("portfolio_no_icon") : nm2);
ImGui::PopID();
gcol = (gcol + 1) % cols;
}
pfEndScrollChild(s_pfEdit.iconFade, gdl);
}
}
// ---- PRICE (basis, manual price, shown fields, sparkline) ----
if (s_pfEdit.section == 1) {
// Price basis — radio group (all options visible; no dropdown).
const char* basisItems[4] = { TR("portfolio_price_usd"), TR("portfolio_price_btc"),
TR("portfolio_price_drgx"), TR("portfolio_price_manual") };
for (int b = 0; b < 4; b++) { if (b) ImGui::SameLine(); ImGui::RadioButton(basisItems[b], &s_pfEdit.priceBasis, b); }
if (s_pfEdit.priceBasis == 3) { // Manual: price-per-DRGX + currency label
float half = (ImGui::GetContentRegionAvail().x - Layout::spacingSm()) * 0.62f;
ImGui::SetNextItemWidth(half);
ImGui::InputDouble("##pfManPrice", &s_pfEdit.manualPrice, 0.0, 0.0, "%.6f");
if (s_pfEdit.manualPrice < 0.0) s_pfEdit.manualPrice = 0.0;
ImGui::SameLine();
ImGui::SetNextItemWidth(-1);
ImGui::InputTextWithHint("##pfManCcy", TR("portfolio_currency"), s_pfEdit.manualCcy, sizeof(s_pfEdit.manualCcy));
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// Shown fields.
ImGui::TextUnformatted(TR("portfolio_show"));
ImGui::Checkbox(DRAGONX_TICKER "##pfShowDrgx", &s_pfEdit.showDrgx);
ImGui::SameLine();
ImGui::BeginDisabled(s_pfEdit.priceBasis == 2); // DRGX-only -> no value field
ImGui::Checkbox(TR("portfolio_show_value"), &s_pfEdit.showValue);
ImGui::EndDisabled();
ImGui::SameLine();
// 24h + sparkline toggles share one line (both live-market only).
ImGui::BeginDisabled(!(s_pfEdit.priceBasis == 0 || s_pfEdit.priceBasis == 1));
ImGui::Checkbox(TR("portfolio_show_24h"), &s_pfEdit.show24h);
ImGui::SameLine();
ImGui::Checkbox(TR("portfolio_show_sparkline"), &s_pfEdit.showSparkline);
ImGui::EndDisabled();
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// Sparkline interval — radio group (enabled only when the sparkline is shown).
ImGui::BeginDisabled(!s_pfEdit.showSparkline || !(s_pfEdit.priceBasis == 0 || s_pfEdit.priceBasis == 1));
const char* spItems[5] = { TR("portfolio_spark_min"), TR("portfolio_spark_hour"),
TR("portfolio_spark_day"), TR("portfolio_spark_week"),
TR("portfolio_spark_month") };
for (int s = 0; s < 5; s++) { if (s) ImGui::SameLine(); ImGui::RadioButton(spItems[s], &s_pfEdit.sparkInterval, s); }
ImGui::EndDisabled();
}
// ---- ADDRESSES (search, filter, select) ----
if (s_pfEdit.section == 2) {
{
char selc[48];
snprintf(selc, sizeof(selc), TR("portfolio_addresses_sel"), (int)s_pfEdit.addrs.size());
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), selc);
}
ImGui::SetNextItemWidth(-1);
ImGui::InputTextWithHint("##pfSearch", TR("portfolio_search"), s_pfEdit.search, sizeof(s_pfEdit.search));
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
// Filter + selection on one row: type-filter segmented (left) + Select all / Clear /
// Funded (right). Select-all is deferred so it applies to the freshly-filtered set below.
bool selAllClicked = false;
{
const char* flabels[3] = { TR("portfolio_select_all"), TR("shielded"), TR("transparent") };
const char* selLbl = TR("portfolio_select_shown");
const char* clrLbl = TR("portfolio_clear_sel");
ImGuiStyle& st = ImGui::GetStyle();
ImVec2 pillPad(Layout::spacingLg(), Layout::spacingSm()); // roomier text padding
float defFrameH = ImGui::GetFrameHeight();
float fundedW = defFrameH + st.ItemInnerSpacing.x + ImGui::CalcTextSize(TR("portfolio_funded")).x;
float pillH = ImGui::GetTextLineHeight() + pillPad.y * 2.0f;
float selW = ImGui::CalcTextSize(selLbl).x + pillPad.x * 2.0f;
float clrW = ImGui::CalcTextSize(clrLbl).x + pillPad.x * 2.0f;
float groupW = selW + clrW + fundedW + st.ItemSpacing.x * 2.0f;
float segH = 26.0f * dp;
float rowW = ImGui::GetContentRegionAvail().x;
float segW = std::min(360.0f * dp, std::max(160.0f * dp, rowW - groupW - Layout::spacingMd()));
float rowH = std::max(segH, pillH);
ImVec2 rowStart = ImGui::GetCursorScreenPos();
// Segmented type filter (left), vertically centered in the row.
float segY = rowStart.y + (rowH - segH) * 0.5f;
int tclk = pfSegmentedControl(ImGui::GetWindowDrawList(), ImVec2(rowStart.x, segY),
segW, segH, flabels, 3, s_pfEdit.typeFilter, capF, "##pftf", dp);
if (tclk >= 0) s_pfEdit.typeFilter = tclk;
// Select all / Clear (rounded pills) + Funded toggle (right), vertically centered.
float grpX = rowStart.x + std::max(0.0f, rowW - groupW);
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 12.0f * dp);
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, pillPad);
ImGui::SetCursorScreenPos(ImVec2(grpX, rowStart.y + (rowH - pillH) * 0.5f));
if (ImGui::Button(selLbl)) selAllClicked = true;
ImGui::SameLine();
if (ImGui::Button(clrLbl)) s_pfEdit.addrs.clear();
ImGui::PopStyleVar(2);
ImGui::SameLine();
ImGui::SetCursorScreenPos(ImVec2(ImGui::GetCursorScreenPos().x,
rowStart.y + (rowH - defFrameH) * 0.5f)); // center vs. pills
ImGui::Checkbox(TR("portfolio_funded"), &s_pfEdit.fundedOnly);
ImGui::SetCursorScreenPos(ImVec2(rowStart.x, rowStart.y + rowH));
ImGui::Dummy(ImVec2(rowW, 0));
}
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
std::string search = s_pfEdit.search;
std::vector<const AddressInfo*> filtered;
for (const auto& a : state.addresses) {
if (s_pfEdit.typeFilter == 1 && a.type != "shielded") continue;
if (s_pfEdit.typeFilter == 2 && a.type != "transparent") continue;
if (s_pfEdit.fundedOnly && a.balance <= 1e-9) continue;
if (!search.empty()) {
std::string hay = a.address + " " + app->getAddressLabel(a.address);
if (!util::containsIgnoreCase(hay, search)) continue;
}
filtered.push_back(&a);
}
std::sort(filtered.begin(), filtered.end(), [&](const AddressInfo* x, const AddressInfo* y) {
bool sx = data::PortfolioEntryContains(s_pfEdit.addrs, x->address);
bool sy = data::PortfolioEntryContains(s_pfEdit.addrs, y->address);
if (sx != sy) return sx;
return x->balance > y->balance;
});
if (selAllClicked)
for (const AddressInfo* a : filtered) data::PortfolioEntryAdd(s_pfEdit.addrs, a->address);
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
ImDrawList* ldl = pfBeginScrollChild("##pfAddrList", s_pfEdit.addrFade,
ImVec2(Layout::spacingMd(), Layout::spacingSm()), dp);
float rowH = std::max(26.0f * dp, body2f->LegacySize + Layout::spacingSm());
float lw = ImGui::GetContentRegionAvail().x;
if (filtered.empty())
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("portfolio_no_addr_match"));
for (const AddressInfo* ap : filtered) {
const AddressInfo& a = *ap;
bool inSet = data::PortfolioEntryContains(s_pfEdit.addrs, a.address);
ImVec2 rmn = ImGui::GetCursorScreenPos();
ImVec2 rmx(rmn.x + lw, rmn.y + rowH);
bool rhov = ImGui::IsMouseHoveringRect(rmn, rmx);
if (inSet) ldl->AddRectFilled(rmn, rmx, WithAlpha(Primary(), 22), 4.0f);
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();
float cb = 15.0f * dp;
ImVec2 cbMin(rx, rcy - cb * 0.5f), cbMax(rx + cb, rcy + cb * 0.5f);
if (inSet) {
ldl->AddRectFilled(cbMin, cbMax, Primary(), 3.0f * dp);
ldl->AddLine(ImVec2(cbMin.x + cb * 0.22f, rcy),
ImVec2(cbMin.x + cb * 0.42f, cbMax.y - cb * 0.25f), IM_COL32(255, 255, 255, 255), 1.6f * dp);
ldl->AddLine(ImVec2(cbMin.x + cb * 0.42f, cbMax.y - cb * 0.25f),
ImVec2(cbMin.x + cb * 0.80f, cbMin.y + cb * 0.22f), IM_COL32(255, 255, 255, 255), 1.6f * dp);
} else {
ldl->AddRect(cbMin, cbMax, WithAlpha(OnSurface(), 120), 3.0f * dp, 0, 1.2f * dp);
}
rx += cb + Layout::spacingSm();
bool isZ = (a.type == "shielded");
const char* chip = isZ ? "Z" : "T";
ImU32 chipCol = isZ ? Success() : Warning();
float chipW = capF->CalcTextSizeA(capF->LegacySize, FLT_MAX, 0, chip).x + 8.0f * dp;
ldl->AddRectFilled(ImVec2(rx, rcy - capF->LegacySize * 0.5f - 2.0f),
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();
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();
}
char balbuf[48];
snprintf(balbuf, sizeof(balbuf), "%.4f", a.balance);
float balW = capF->CalcTextSizeA(capF->LegacySize, FLT_MAX, 0, balbuf).x;
float textRight = rmx.x - Layout::spacingSm() - balW - Layout::spacingSm();
std::string alabel = app->getAddressLabel(a.address);
std::string primary = alabel.empty() ? util::truncateMiddle(a.address, 22) : alabel;
float availTextW = textRight - rx;
while (primary.size() > 1 &&
body2f->CalcTextSizeA(body2f->LegacySize, FLT_MAX, 0, primary.c_str()).x > availTextW)
primary.pop_back();
ldl->AddText(body2f, body2f->LegacySize, ImVec2(rx, rcy - body2f->LegacySize * 0.5f),
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);
ImGui::PushID(a.address.c_str());
ImGui::InvisibleButton("##pfrow", ImVec2(lw, rowH));
if (ImGui::IsItemClicked()) {
if (inSet) data::PortfolioEntryRemove(s_pfEdit.addrs, a.address);
else data::PortfolioEntryAdd(s_pfEdit.addrs, a.address);
}
ImGui::PopID();
}
pfEndScrollChild(s_pfEdit.addrFade, ldl);
}
ImGui::EndChild(); // ##pfDetailBody ImGui::EndChild(); // ##pfDetailBody
// Group-scoped actions (Add-entry height → lower hierarchy than the modal Close in the footer). // Group-scoped actions (Add-entry height → lower hierarchy than the modal Close in the footer).
{ {