feat(market): icon-picker search + Settings-style scroll for portfolio modal

Rework the Manage-portfolio detail pane's Appearance and Addresses sections:

- Icon picker: search field inline with the "Icon" heading (filters by name,
  hides the "None" slot while searching); grid now always fits 12 icons per
  row, scaling cell size to width and centering the block.
- Both the icon grid and the address list scroll via a shared helper
  (pfBeginScrollChild/pfEndScrollChild): smooth scrolling + the Settings-tab
  edge-fade shader + a thicker, rounded scrollbar inset from the container edge
  (bordered outer frame wrapping an inner scrolling child). Smaller container
  radius. Fade gates off in low-spec and while the acrylic backdrop is being
  captured — latched per-frame so the last capture frame no longer clashes with
  the render-state reset.
- Addresses: All/Shielded/Transparent segmented control moved to its own row,
  capped at 580px; Select all / Clear pills moved left of the Funded checkbox
  on one left-aligned row (Funded vertically centered against the pills).
- Custom colour "+" swatch inset a few px so its selection outline no longer
  clips at the modal's right edge.
- Remove the divider above the footer Revert/Save/Close buttons.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-03 22:50:26 -05:00
parent 32d8d5663a
commit 1affea0273

View File

@@ -17,6 +17,8 @@
#include "../material/typography.h" #include "../material/typography.h"
#include "../material/project_icons.h" #include "../material/project_icons.h"
#include "../effects/imgui_acrylic.h" #include "../effects/imgui_acrylic.h"
#include "../effects/scroll_fade_shader.h"
#include "../effects/low_spec.h"
#include "../../util/text_format.h" #include "../../util/text_format.h"
#include "../../embedded/IconsMaterialDesign.h" #include "../../embedded/IconsMaterialDesign.h"
#include "../../util/platform.h" #include "../../util/platform.h"
@@ -101,6 +103,7 @@ static int s_pf_section = 0; // right-pane segmented control: 0=Appearance 1=
// frozen snapshot while the modal is open — efficient and flicker-free. // frozen snapshot while the modal is open — efficient and flicker-free.
static bool s_pf_was_open = false; static bool s_pf_was_open = false;
static int s_pf_capture_frames = 0; // frames left to (re)capture the live backdrop static int s_pf_capture_frames = 0; // frames left to (re)capture the live backdrop
static bool s_pf_capturing_frame = false; // true on any frame a live-backdrop capture is armed
static float s_pf_capture_w = 0.0f, s_pf_capture_h = 0.0f; // viewport size at the last capture trigger static float s_pf_capture_w = 0.0f, s_pf_capture_h = 0.0f; // viewport size at the last capture trigger
static char s_pf_label[64] = {0}; static char s_pf_label[64] = {0};
static std::vector<std::string> s_pf_addrs; static std::vector<std::string> s_pf_addrs;
@@ -108,8 +111,12 @@ static std::string s_pf_icon; // selected wallet-icon name ("" = none)
static unsigned int s_pf_color = 0; // selected accent (packed IM_COL32; 0 = default) static unsigned int s_pf_color = 0; // selected accent (packed IM_COL32; 0 = default)
static int s_pf_outline_opacity = 25; // accent-outline opacity (percent) static int s_pf_outline_opacity = 25; // accent-outline opacity (percent)
static char s_pf_search[64] = {0}; // address-list filter text static char s_pf_search[64] = {0}; // address-list filter text
static char s_pf_icon_search[64] = {0}; // icon-picker filter text
static int s_pf_type_filter = 0; // 0 = all, 1 = shielded, 2 = transparent static int s_pf_type_filter = 0; // 0 = all, 1 = shielded, 2 = transparent
static bool s_pf_funded_only = false; static bool s_pf_funded_only = false;
// Edge-fade shaders for the two internal scroll regions (one persistent instance each).
static effects::ScrollFadeShader s_pf_icon_fade;
static effects::ScrollFadeShader s_pf_addr_fade;
static float s_pf_custom_rgb[3] = {0.31f, 0.62f, 1.0f}; // working RGB for the custom color picker static float s_pf_custom_rgb[3] = {0.31f, 0.62f, 1.0f}; // working RGB for the custom color picker
// Price-data config (working copy while editing a group). // Price-data config (working copy while editing a group).
static int s_pf_price_basis = 0; static int s_pf_price_basis = 0;
@@ -121,6 +128,54 @@ static bool s_pf_show_24h = false;
static bool s_pf_show_sparkline = false; static bool s_pf_show_sparkline = false;
static int s_pf_spark_interval = 0; // 0=min 1=hour 2=day 3=week 4=month static int s_pf_spark_interval = 0; // 0=min 1=hour 2=day 3=week 4=month
// Wrap a scroll region with smooth scrolling + the Settings-tab edge fade + a thicker, rounded
// scrollbar that is inset from the container edge. Uses a bordered/rounded OUTER frame whose
// padding holds an INNER scrolling child, so the inner scrollbar floats inside the frame rather
// than hugging its border. `fade` must persist across frames (one instance per scroll region).
// `pad` is the inner content inset; returns the inner draw list. Pair with pfEndScrollChild().
static ImDrawList* pfBeginScrollChild(const char* id, effects::ScrollFadeShader& fade,
const ImVec2& pad, float dp)
{
// Outer: rounded bordered frame; its padding is the gap that insets the scrollbar from the edge.
ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, 6.0f * dp);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.0f * dp, 6.0f * dp));
ImGui::BeginChild(id, ImVec2(0, std::max(120.0f * dp, ImGui::GetContentRegionAvail().y)), true,
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
ImGui::PopStyleVar(2); // ChildRounding + outer WindowPadding (both captured by the outer child)
// Inner: borderless scrolling child with a thicker rounded scrollbar at its own (inset) edge.
ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarSize, 11.0f * dp);
ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarRounding, 5.5f * dp);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, pad);
std::string innerId = std::string(id) + "_i";
ImGui::BeginChild(innerId.c_str(), ImVec2(0, 0), false, ImGuiWindowFlags_NoScrollWithMouse);
ImGui::PopStyleVar(); // inner WindowPadding — captured by this child; don't cascade to tooltips
ApplySmoothScroll();
ImDrawList* dl = ImGui::GetWindowDrawList();
// Fade the top/bottom edges; off in low-spec and while the backdrop is being captured (the
// acrylic overlay rebinds render state on those frames, which would clash with the fade shader).
const float fadeH = 22.0f * dp;
if (!effects::isLowSpecMode() && !s_pf_capturing_frame && fade.init()) {
ImVec2 cMin = ImGui::GetWindowPos();
ImVec2 cMax(cMin.x + ImGui::GetWindowSize().x, cMin.y + ImGui::GetWindowSize().y);
float sY = ImGui::GetScrollY(), sMax = ImGui::GetScrollMaxY();
fade.fadeTopY = cMin.y;
fade.fadeBottomY = cMax.y;
fade.fadeZoneTop = (sY > 1.0f) ? fadeH : 0.0f;
fade.fadeZoneBottom = (sMax > 0.0f && sY < sMax - 1.0f) ? fadeH : 0.0f;
fade.addBind(dl);
}
return dl;
}
static void pfEndScrollChild(effects::ScrollFadeShader& fade, ImDrawList* dl)
{
if (!effects::isLowSpecMode() && !s_pf_capturing_frame && fade.ready)
effects::ScrollFadeShader::addUnbind(dl);
ImGui::EndChild(); // inner
ImGui::PopStyleVar(2); // ScrollbarSize + ScrollbarRounding
ImGui::EndChild(); // outer
}
// price_history is ~1 sample/minute; resample it to the group's chosen interval by averaging // price_history is ~1 sample/minute; resample it to the group's chosen interval by averaging
// each block of K minute-samples into one point. // each block of K minute-samples into one point.
static std::vector<double> pfResampleHistory(const std::vector<double>& hist, int interval) static std::vector<double> pfResampleHistory(const std::vector<double>& hist, int interval)
@@ -440,7 +495,11 @@ static void RenderPortfolioEditor(App* app)
// Only on capture frames (open/resize): capture the live UI drawn below this overlay (then reset // Only on capture frames (open/resize): capture the live UI drawn below this overlay (then reset
// ImGui's render state) so the backdrop blurs the live tab content. Other frames reuse the frozen // ImGui's render state) so the backdrop blurs the live tab content. Other frames reuse the frozen
// blurred snapshot (blurCacheValid_), so there's no per-frame capture/re-blur cost or flicker. // blurred snapshot (blurCacheValid_), so there's no per-frame capture/re-blur cost or flicker.
if (s_pf_capture_frames > 0) { // Latch "capturing" for the WHOLE frame before decrementing, so the scroll children (which draw
// later this frame) suppress their fade shader on every capture frame — including the last one,
// where the counter would otherwise already read 0 and clash with the capture's render-state reset.
s_pf_capturing_frame = (s_pf_capture_frames > 0);
if (s_pf_capturing_frame) {
if (ImDrawCallback liveCap = effects::ImGuiAcrylic::GetLiveCaptureCallback()) { if (ImDrawCallback liveCap = effects::ImGuiAcrylic::GetLiveCaptureCallback()) {
dl->AddCallback(liveCap, nullptr); dl->AddCallback(liveCap, nullptr);
dl->AddCallback(ImDrawCallback_ResetRenderState, nullptr); dl->AddCallback(ImDrawCallback_ResetRenderState, nullptr);
@@ -706,7 +765,9 @@ static void RenderPortfolioEditor(App* app)
int nColors = (int)(sizeof(kPfPalette) / sizeof(kPfPalette[0])); int nColors = (int)(sizeof(kPfPalette) / sizeof(kPfPalette[0]));
float availW = ImGui::GetContentRegionAvail().x; float availW = ImGui::GetContentRegionAvail().x;
ImVec2 rowStart = ImGui::GetCursorScreenPos(); ImVec2 rowStart = ImGui::GetCursorScreenPos();
float plusX = rowStart.x + std::max(sw, availW - sw); // reserved "+" slot at the right // 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_pf_color != 0u); bool isCustom = (s_pf_color != 0u);
for (int c = 0; c < nColors; c++) if (s_pf_color == kPfPalette[c]) isCustom = false; for (int c = 0; c < nColors; c++) if (s_pf_color == kPfPalette[c]) isCustom = false;
bool openCustom = false; bool openCustom = false;
@@ -780,26 +841,46 @@ static void RenderPortfolioEditor(App* app)
if (s_pf_outline_opacity > 100) s_pf_outline_opacity = 100; if (s_pf_outline_opacity > 100) s_pf_outline_opacity = 100;
ImGui::EndDisabled(); ImGui::EndDisabled();
ImGui::Dummy(ImVec2(0, Layout::spacingSm())); ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// Icon picker grid (large cells, fixed height, scrolls internally). // 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 cell = 56.0f * dp, cellGap = 6.0f * dp; float cellGap = 6.0f * dp;
ImFont* gIconFont = Type().iconXL(); ImFont* gIconFont = Type().iconXL();
float gIconSz = cell * 0.5f;
ImGui::TextUnformatted(TR("portfolio_icon")); ImGui::TextUnformatted(TR("portfolio_icon"));
ImGui::BeginChild("##pfIconGrid", ImVec2(0, std::max(120.0f * dp, ImGui::GetContentRegionAvail().y)), true); ImGui::SameLine();
ImDrawList* gdl = ImGui::GetWindowDrawList(); ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x);
ImGui::InputTextWithHint("##pfIconSearch", TR("portfolio_search"),
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;
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_pf_icon_fade,
ImVec2(Layout::spacingSm(), Layout::spacingSm()), dp);
const int cols = 12;
float availW = ImGui::GetContentRegionAvail().x; float availW = ImGui::GetContentRegionAvail().x;
int cols = std::max(1, (int)((availW + cellGap) / (cell + cellGap))); float cell = std::max(14.0f * dp, (availW - cellGap * (cols - 1)) / (float)cols);
int total = material::project_icons::walletIconCount() + 1; // slot 0 = "None" 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; int gcol = 0;
for (int k = 0; k < total; k++) { for (size_t n = 0; n < iconIdx.size(); n++) {
if (gcol != 0) ImGui::SameLine(0, cellGap); 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 mn = ImGui::GetCursorScreenPos();
ImVec2 mx(mn.x + cell, mn.y + cell); ImVec2 mx(mn.x + cell, mn.y + cell);
ImVec2 cc(mn.x + cell * 0.5f, mn.y + cell * 0.5f); ImVec2 cc(mn.x + cell * 0.5f, mn.y + cell * 0.5f);
bool hov = ImGui::IsMouseHoveringRect(mn, mx); bool hov = ImGui::IsMouseHoveringRect(mn, mx);
bool isNone = (k == 0); bool isNone = (idx < 0);
const char* nm2 = isNone ? "" : material::project_icons::walletIconName(k - 1); const char* nm2 = isNone ? "" : material::project_icons::walletIconName(idx);
bool sel = isNone ? s_pf_icon.empty() : (s_pf_icon == nm2); bool sel = isNone ? s_pf_icon.empty() : (s_pf_icon == nm2);
if (sel) { if (sel) {
gdl->AddRectFilled(mn, mx, WithAlpha(Primary(), 40), 6.0f * dp); gdl->AddRectFilled(mn, mx, WithAlpha(Primary(), 40), 6.0f * dp);
@@ -816,14 +897,14 @@ static void RenderPortfolioEditor(App* app)
} else { } else {
material::project_icons::drawByName(gdl, nm2, cc, icol, gIconFont, gIconSz); material::project_icons::drawByName(gdl, nm2, cc, icol, gIconFont, gIconSz);
} }
ImGui::PushID(k); ImGui::PushID(idx + 2);
ImGui::InvisibleButton("##pfic", ImVec2(cell, cell)); ImGui::InvisibleButton("##pfic", ImVec2(cell, cell));
if (ImGui::IsItemClicked()) s_pf_icon = isNone ? std::string() : std::string(nm2); if (ImGui::IsItemClicked()) s_pf_icon = isNone ? std::string() : std::string(nm2);
if (hov) material::Tooltip("%s", isNone ? TR("portfolio_no_icon") : nm2); if (hov) material::Tooltip("%s", isNone ? TR("portfolio_no_icon") : nm2);
ImGui::PopID(); ImGui::PopID();
gcol = (gcol + 1) % cols; gcol = (gcol + 1) % cols;
} }
ImGui::EndChild(); pfEndScrollChild(s_pf_icon_fade, gdl);
} }
} }
@@ -877,15 +958,12 @@ static void RenderPortfolioEditor(App* app)
ImGui::SetNextItemWidth(-1); ImGui::SetNextItemWidth(-1);
ImGui::InputTextWithHint("##pfSearch", TR("portfolio_search"), s_pf_search, sizeof(s_pf_search)); ImGui::InputTextWithHint("##pfSearch", TR("portfolio_search"), s_pf_search, sizeof(s_pf_search));
ImGui::Dummy(ImVec2(0, Layout::spacingXs())); ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
// Type filter (segmented) + Funded toggle on one row: the segmented control is sized to // Type filter segmented control on its own row, capped at 580px wide.
// leave room for the Funded checkbox to its right.
{ {
const char* flabels[3] = { TR("portfolio_select_all"), TR("shielded"), TR("transparent") }; const char* flabels[3] = { TR("portfolio_select_all"), TR("shielded"), TR("transparent") };
float segH = 26.0f * dp; float segH = 26.0f * dp;
float fundedW = ImGui::GetFrameHeight() + ImGui::GetStyle().ItemInnerSpacing.x float availW = ImGui::GetContentRegionAvail().x;
+ ImGui::CalcTextSize(TR("portfolio_funded")).x; float segW = std::min(availW, 580.0f * dp);
float rowW = ImGui::GetContentRegionAvail().x;
float segW = std::max(180.0f * dp, rowW - fundedW - Layout::spacingMd());
float cellW = segW / 3.0f; float cellW = segW / 3.0f;
ImVec2 sMin = ImGui::GetCursorScreenPos(); ImVec2 sMin = ImGui::GetCursorScreenPos();
ImDrawList* fdl = ImGui::GetWindowDrawList(); ImDrawList* fdl = ImGui::GetWindowDrawList();
@@ -909,12 +987,8 @@ static void RenderPortfolioEditor(App* app)
if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImGui::PopID(); ImGui::PopID();
} }
// Funded checkbox — vertically centered, to the right of the segmented control.
ImGui::SetCursorScreenPos(ImVec2(sMin.x + segW + Layout::spacingMd(),
sMin.y + (segH - ImGui::GetFrameHeight()) * 0.5f));
ImGui::Checkbox(TR("portfolio_funded"), &s_pf_funded_only);
ImGui::SetCursorScreenPos(ImVec2(sMin.x, sMin.y + segH)); ImGui::SetCursorScreenPos(ImVec2(sMin.x, sMin.y + segH));
ImGui::Dummy(ImVec2(rowW, 0)); ImGui::Dummy(ImVec2(availW, 0));
} }
ImGui::Dummy(ImVec2(0, Layout::spacingXs())); ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
std::string search = s_pf_search; std::string search = s_pf_search;
@@ -935,27 +1009,26 @@ static void RenderPortfolioEditor(App* app)
if (sx != sy) return sx; if (sx != sy) return sx;
return x->balance > y->balance; return x->balance > y->balance;
}); });
// Select all / Clear rounded (pill) buttons with padding, right-aligned. // Select all / Clear (rounded pills) + Funded toggle — one row, left-aligned.
{ {
const char* selLbl = TR("portfolio_select_shown"); const char* selLbl = TR("portfolio_select_shown");
const char* clrLbl = TR("portfolio_clear_sel"); const char* clrLbl = TR("portfolio_clear_sel");
float rowY = ImGui::GetCursorPosY();
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 12.0f * dp); ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 12.0f * dp);
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(Layout::spacingMd(), Layout::spacingXs())); ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(Layout::spacingMd(), Layout::spacingXs()));
ImGuiStyle& st = ImGui::GetStyle(); float pillH = ImGui::GetFrameHeight();
float grpW = ImGui::CalcTextSize(selLbl).x + ImGui::CalcTextSize(clrLbl).x
+ st.FramePadding.x * 4.0f + st.ItemSpacing.x;
float avail = ImGui::GetContentRegionAvail().x;
if (avail > grpW) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + avail - grpW);
if (ImGui::Button(selLbl)) if (ImGui::Button(selLbl))
for (const AddressInfo* a : filtered) data::PortfolioEntryAdd(s_pf_addrs, a->address); for (const AddressInfo* a : filtered) data::PortfolioEntryAdd(s_pf_addrs, a->address);
ImGui::SameLine(); ImGui::SameLine();
if (ImGui::Button(clrLbl)) s_pf_addrs.clear(); if (ImGui::Button(clrLbl)) s_pf_addrs.clear();
ImGui::PopStyleVar(2); ImGui::PopStyleVar(2);
ImGui::SameLine();
ImGui::SetCursorPosY(rowY + (pillH - ImGui::GetFrameHeight()) * 0.5f); // center vs. pills
ImGui::Checkbox(TR("portfolio_funded"), &s_pf_funded_only);
} }
ImGui::Dummy(ImVec2(0, Layout::spacingXs())); ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(Layout::spacingMd(), Layout::spacingSm())); ImDrawList* ldl = pfBeginScrollChild("##pfAddrList", s_pf_addr_fade,
ImGui::BeginChild("##pfAddrList", ImVec2(0, std::max(120.0f * dp, ImGui::GetContentRegionAvail().y)), true); ImVec2(Layout::spacingMd(), Layout::spacingSm()), dp);
ImDrawList* ldl = ImGui::GetWindowDrawList();
float rowH = std::max(26.0f * dp, body2f->LegacySize + Layout::spacingSm()); float rowH = std::max(26.0f * dp, body2f->LegacySize + Layout::spacingSm());
float lw = ImGui::GetContentRegionAvail().x; float lw = ImGui::GetContentRegionAvail().x;
if (filtered.empty()) if (filtered.empty())
@@ -1017,14 +1090,12 @@ static void RenderPortfolioEditor(App* app)
} }
ImGui::PopID(); ImGui::PopID();
} }
ImGui::EndChild(); pfEndScrollChild(s_pf_addr_fade, ldl);
ImGui::PopStyleVar(); // ##pfAddrList WindowPadding
} }
} }
ImGui::EndChild(); // ##pfDetail ImGui::EndChild(); // ##pfDetail
// ================= FOOTER: revert / save / close (right-aligned, large) ================= // ================= FOOTER: revert / save / close (right-aligned, large) =================
ImGui::Separator();
{ {
float bh = 40.0f * dp, bw = 112.0f * dp, sp = Layout::spacingSm(); float bh = 40.0f * dp, bw = 112.0f * dp, sp = Layout::spacingSm();
float aw = bw * 3 + sp * 2; float aw = bw * 3 + sp * 2;