feat(market): rework portfolio-modal actions, addresses row, price & unsaved state

- Move the group-scoped Revert/Save buttons into the detail container (sized to
  the Add-entry height) so they read as lower-hierarchy than the modal-level
  Close, which now stands alone in the footer. The detail body is wrapped in a
  child that reserves the bottom row.
- Add an amber "unsaved changes" dot on the selected group row while its working
  copy differs from the stored entry; also gate the detail Revert/Save on that
  dirty state.
- Addresses: narrow the All/Shielded/Transparent segmented control (360px) and
  right-align Select all / Clear / Funded on one line; give the Select all /
  Clear pills more text padding.
- Appearance: shrink the icon search field and right-align it on the heading
  line, with a "Search icons…" hint.
- Price: move the sparkline toggle onto the 24h line; the interval radios stay
  below, enabled only when the sparkline is shown.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-03 23:14:40 -05:00
parent 1affea0273
commit faf8e4e430
2 changed files with 76 additions and 28 deletions

View File

@@ -551,6 +551,15 @@ static void RenderPortfolioEditor(App* app)
float masterW = std::min(std::max(contentW * 0.28f, 240.0f * dp), 320.0f * dp);
float detailW = contentW - masterW - gap;
// Does the selected group have uncommitted edits? (Only the selected group can differ from the
// stored copy — others were committed on deselect.) Drives the master-list "unsaved" dot and the
// detail-pane Revert/Save enable state.
bool selDirty = false;
{
const auto& es = settings->getPortfolioEntries();
if (s_pf_sel >= 0 && s_pf_sel < (int)es.size()) selDirty = !workingMatches(es[s_pf_sel]);
}
// ================= MASTER: scrollable group list + a pinned Add button below it =================
float addH = 34.0f * dp;
ImGui::BeginGroup();
@@ -584,6 +593,13 @@ static void RenderPortfolioEditor(App* app)
en.color ? (ImU32)en.color : OnSurfaceMedium(), iconFont, sub1f->LegacySize);
ix += sub1f->LegacySize + Layout::spacingSm();
}
// Unsaved-changes dot: shown on the selected row while its edits aren't committed.
if (selRow && selDirty) {
float dr = 3.5f * dp;
mdl->AddCircleFilled(ImVec2(ix + dr, rmn.y + rowH * 0.28f + body2f->LegacySize * 0.5f),
dr, Warning());
ix += dr * 2.0f + Layout::spacingXs();
}
float txtRight = rmx.x - delSlot; // reserve the trailing delete-icon slot
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))
@@ -651,6 +667,12 @@ static void RenderPortfolioEditor(App* app)
ImGui::SetCursorPos(ImVec2(std::max(0.0f, (av.x - ts.x) * 0.5f), av.y * 0.42f));
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), msg);
} else {
// Body fills the detail column minus a bottom row reserved for the group-scoped
// Revert/Save buttons (kept inside this container, below the modal-level Close in the footer).
float pfBtnRowH = addH + Layout::spacingMd();
ImGui::BeginChild("##pfDetailBody",
ImVec2(0, std::max(80.0f * dp, ImGui::GetContentRegionAvail().y - pfBtnRowH)),
false, ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
// ---- Header: live preview + label (always visible above the accordion) ----
{
ImDrawList* pdl = ImGui::GetWindowDrawList();
@@ -848,9 +870,14 @@ static void RenderPortfolioEditor(App* app)
ImFont* gIconFont = Type().iconXL();
ImGui::TextUnformatted(TR("portfolio_icon"));
ImGui::SameLine();
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x);
ImGui::InputTextWithHint("##pfIconSearch", TR("portfolio_search"),
s_pf_icon_search, sizeof(s_pf_icon_search));
{
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_pf_icon_search, sizeof(s_pf_icon_search));
}
// Filtered icon list (-1 = "None", shown only when not searching).
std::string isearch = s_pf_icon_search;
std::vector<int> iconIdx;
@@ -932,20 +959,20 @@ static void RenderPortfolioEditor(App* app)
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
// 24h + sparkline toggles share one line (both live-market only).
ImGui::BeginDisabled(!(s_pf_price_basis == 0 || s_pf_price_basis == 1));
ImGui::Checkbox(TR("portfolio_show_24h"), &s_pf_show_24h);
ImGui::SameLine();
ImGui::Checkbox(TR("portfolio_show_sparkline"), &s_pf_show_sparkline);
ImGui::EndDisabled();
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// Sparkline + its interval — radio group.
ImGui::BeginDisabled(!(s_pf_price_basis == 0 || s_pf_price_basis == 1));
ImGui::Checkbox(TR("portfolio_show_sparkline"), &s_pf_show_sparkline);
ImGui::BeginDisabled(!s_pf_show_sparkline);
// Sparkline interval — radio group (enabled only when the sparkline is shown).
ImGui::BeginDisabled(!s_pf_show_sparkline || !(s_pf_price_basis == 0 || s_pf_price_basis == 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_pf_spark_interval, s); }
ImGui::EndDisabled();
ImGui::EndDisabled();
}
// ---- ADDRESSES (search, filter, select) ----
@@ -958,12 +985,12 @@ static void RenderPortfolioEditor(App* app)
ImGui::SetNextItemWidth(-1);
ImGui::InputTextWithHint("##pfSearch", TR("portfolio_search"), s_pf_search, sizeof(s_pf_search));
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
// Type filter — segmented control on its own row, capped at 580px wide.
// Type filter — segmented control on its own row (compact).
{
const char* flabels[3] = { TR("portfolio_select_all"), TR("shielded"), TR("transparent") };
float segH = 26.0f * dp;
float availW = ImGui::GetContentRegionAvail().x;
float segW = std::min(availW, 580.0f * dp);
float segW = std::min(availW, 360.0f * dp);
float cellW = segW / 3.0f;
ImVec2 sMin = ImGui::GetCursorScreenPos();
ImDrawList* fdl = ImGui::GetWindowDrawList();
@@ -1009,21 +1036,34 @@ static void RenderPortfolioEditor(App* app)
if (sx != sy) return sx;
return x->balance > y->balance;
});
// Select all / Clear (rounded pills) + Funded toggle — one row, left-aligned.
// Select all / Clear (rounded pills) + Funded toggle — one row, right-aligned.
{
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
// Measure the checkbox at default padding and the pills at their (larger) padding, so
// the whole group can be right-aligned before it's drawn.
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 rowH = std::max(pillH, defFrameH);
float rowY = ImGui::GetCursorPosY();
float availW = ImGui::GetContentRegionAvail().x;
if (availW > groupW) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + availW - groupW);
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 12.0f * dp);
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(Layout::spacingMd(), Layout::spacingXs()));
float pillH = ImGui::GetFrameHeight();
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, pillPad);
ImGui::SetCursorPosY(rowY + (rowH - pillH) * 0.5f);
if (ImGui::Button(selLbl))
for (const AddressInfo* a : filtered) data::PortfolioEntryAdd(s_pf_addrs, a->address);
ImGui::SameLine();
if (ImGui::Button(clrLbl)) s_pf_addrs.clear();
ImGui::PopStyleVar(2);
ImGui::SameLine();
ImGui::SetCursorPosY(rowY + (pillH - ImGui::GetFrameHeight()) * 0.5f); // center vs. pills
ImGui::SetCursorPosY(rowY + (rowH - defFrameH) * 0.5f); // center vs. pills
ImGui::Checkbox(TR("portfolio_funded"), &s_pf_funded_only);
}
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
@@ -1092,24 +1132,31 @@ static void RenderPortfolioEditor(App* app)
}
pfEndScrollChild(s_pf_addr_fade, ldl);
}
ImGui::EndChild(); // ##pfDetailBody
// Group-scoped actions (Add-entry height → lower hierarchy than the modal Close in the footer).
{
float bw = 104.0f * dp, sp = Layout::spacingSm();
float grp = bw * 2 + sp;
float availD = ImGui::GetContentRegionAvail().x;
if (availD > grp) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + availD - grp);
ImGui::SetCursorPosY(ImGui::GetCursorPosY() + Layout::spacingSm());
ImGui::BeginDisabled(!selDirty);
if (ImGui::Button(TR("portfolio_revert"), ImVec2(bw, addH)))
PortfolioBeginEdit(app, s_pf_sel); // reload the working set from the stored entry
ImGui::SameLine(0, sp);
ImGui::BeginDisabled(s_pf_label[0] == '\0');
if (ImGui::Button(TR("portfolio_save"), ImVec2(bw, addH))) commitIfNeeded();
ImGui::EndDisabled();
ImGui::EndDisabled();
}
}
ImGui::EndChild(); // ##pfDetail
// ================= FOOTER: revert / save / close (right-aligned, large) =================
// ================= FOOTER: close (modal-level action, larger → higher hierarchy) =================
{
float bh = 40.0f * dp, bw = 112.0f * dp, sp = Layout::spacingSm();
float aw = bw * 3 + sp * 2;
float bh = 40.0f * dp, bw = 120.0f * dp;
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, bh)))
PortfolioBeginEdit(app, s_pf_sel); // reload the working set from the stored entry
ImGui::EndDisabled();
ImGui::SameLine(0, sp);
ImGui::BeginDisabled(s_pf_label[0] == '\0');
if (ImGui::Button(TR("portfolio_save"), ImVec2(bw, bh))) commitIfNeeded();
ImGui::EndDisabled();
ImGui::SameLine(0, sp);
if (availF > bw) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + availF - bw);
if (ImGui::Button(TR("portfolio_close"), ImVec2(bw, bh))) { commitIfNeeded(); s_portfolio_editor_open = false; }
}

View File

@@ -1147,6 +1147,7 @@ void I18n::loadBuiltinEnglish()
strings_["portfolio_appearance"] = "Appearance";
strings_["portfolio_addresses_hdr"] = "Addresses";
strings_["portfolio_search"] = "Search addresses\xE2\x80\xA6";
strings_["portfolio_search_icons"] = "Search icons\xE2\x80\xA6";
strings_["portfolio_funded"] = "Funded";
strings_["portfolio_select_all"] = "All";
strings_["portfolio_select_shown"] = "Select all";