feat(market): historical chart intervals, stats overlay, configurable outline, easier resize

Chart:
- The price chart now plots the historical CoinGecko series with a Live/1H/1D/1W/1M
  interval selector (top-left of the chart). Live = in-session buffer; 1H uses the
  intraday 5-min series, 1D/1W/1M the daily series (via pfSparklineSeries). X-axis
  labels are interval-aware ("~5h"/"~3d"/"~2w"/"~4mo"). Market-tab open kicks the
  self-throttled market_chart fetch so history populates promptly.
- 24H volume + market cap moved from their own row onto the chart's top strip
  (right side, next to the refresh button), reclaiming the empty band above the
  chart; the hero header now holds just the price row + separator.
- More bottom padding under the plot so the time labels and the DRGX/USDT pair
  buttons aren't crowded against the card edges.

Portfolio groups:
- Accent-outline opacity is now configurable per group (0-100%, default 25%) via a
  slider in the editor's Appearance column; applied to the card + preview outline.
- Resize grip enlarged (14->20px) and a live "W x H" size readout is drawn while
  resizing, so shrinking a group to the 4x3 minimum is reliable and visible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-03 15:37:31 -05:00
parent 4e15a2b1ba
commit 5aa3cc4d51
4 changed files with 166 additions and 111 deletions

View File

@@ -318,6 +318,8 @@ bool Settings::load(const std::string& path)
entry.icon = e["icon"].get<std::string>();
if (e.contains("color") && e["color"].is_number())
entry.color = e["color"].get<unsigned int>();
if (e.contains("outline_opacity") && e["outline_opacity"].is_number_integer())
entry.outlineOpacity = e["outline_opacity"].get<int>();
if (e.contains("price_basis") && e["price_basis"].is_number_integer())
entry.priceBasis = e["price_basis"].get<int>();
if (e.contains("manual_price") && e["manual_price"].is_number())
@@ -493,6 +495,7 @@ bool Settings::save(const std::string& path)
for (const auto& a : e.addresses) entry["addresses"].push_back(a);
entry["icon"] = e.icon;
entry["color"] = e.color;
entry["outline_opacity"] = e.outlineOpacity;
entry["price_basis"] = e.priceBasis;
entry["manual_price"] = e.manualPrice;
entry["manual_currency"] = e.manualCurrency;

View File

@@ -80,6 +80,7 @@ public:
std::vector<std::string> addresses;
std::string icon; // project_icons wallet-icon name; empty = no icon
unsigned int color = 0; // packed IM_COL32 accent; 0 = theme default
int outlineOpacity = 25; // accent-outline opacity, percent (0-100)
// Per-group price data. priceBasis: 0 = live market (USD), 1 = live market (BTC),
// 2 = DRGX only (no fiat value), 3 = manual price. Defaults preserve prior behavior
// (show DRGX + USD value).

View File

@@ -99,6 +99,7 @@ static char s_pf_label[64] = {0};
static std::vector<std::string> s_pf_addrs;
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 int s_pf_outline_opacity = 25; // accent-outline opacity (percent)
static char s_pf_search[64] = {0}; // address-list filter text
static int s_pf_type_filter = 0; // 0 = all, 1 = shielded, 2 = transparent
static bool s_pf_funded_only = false;
@@ -164,6 +165,21 @@ static std::vector<double> pfSparklineSeries(const MarketInfo& m, int interval)
return pfResampleHistory(m.price_history, interval);
}
// Main price-chart interval selector (same semantics as pfSparklineSeries):
// 0=Live (in-session minute buffer) 1=hour 2=day 3=week 4=month. Session-scoped.
static int s_chart_interval = 2; // default: Day (historical)
// Approx seconds represented by one plotted point at the given chart interval, for X-axis labels.
static float pfChartSecPerPoint(int interval) {
switch (interval) {
case 1: return 3600.0f; // hour
case 2: return 86400.0f; // day
case 3: return 604800.0f; // week
case 4: return 2592000.0f; // month (~30d)
default: return 60.0f; // minute (live buffer)
}
}
// A group's value trend tracks the DRGX price, so a group sparkline plots the price history
// (normalized) across a rect. Colored by overall direction. Only meaningful for market bases.
static void pfDrawSparkline(ImDrawList* dl, ImVec2 mn, ImVec2 mx,
@@ -275,6 +291,7 @@ static void PortfolioBeginEdit(App* app, int index)
s_pf_addrs = src.addresses;
s_pf_icon = src.icon;
s_pf_color = src.color;
s_pf_outline_opacity = src.outlineOpacity;
s_pf_price_basis = src.priceBasis;
s_pf_manual_price = src.manualPrice;
snprintf(s_pf_manual_ccy, sizeof(s_pf_manual_ccy), "%s", src.manualCurrency.c_str());
@@ -288,6 +305,7 @@ static void PortfolioBeginEdit(App* app, int index)
s_pf_addrs.clear();
s_pf_icon.clear();
s_pf_color = 0;
s_pf_outline_opacity = 25;
s_pf_price_basis = 0;
s_pf_manual_price = 0.0;
snprintf(s_pf_manual_ccy, sizeof(s_pf_manual_ccy), "USD");
@@ -384,7 +402,10 @@ static void RenderPortfolioEditor(App* app)
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) pdl->AddRect(pMin, pMax, accent, 10.0f, 0, 2.0f);
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<double> h = pfSparklineSeries(state.market, s_pf_spark_interval);
if (h.size() >= 2) {
@@ -502,6 +523,15 @@ 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"));
ImGui::SetNextItemWidth(-1);
ImGui::SliderInt("##pfOutlineOpacity", &s_pf_outline_opacity, 0, 100, "%d%%");
if (s_pf_outline_opacity < 0) s_pf_outline_opacity = 0;
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.
@@ -752,6 +782,7 @@ static void RenderPortfolioEditor(App* app)
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;
@@ -792,6 +823,9 @@ void RenderMarketTab(App* app)
// Kick a once-per-session live exchange-list fetch (self-guarded), then source the
// registry from it (falling back to the compiled-in list until it arrives).
app->refreshExchanges();
// Also kick the historical price-chart fetch (self-throttled) so the chart's hour/day/week/
// month intervals populate promptly when the user opens the Market tab.
app->refreshMarketChart();
const auto& registry = EffectiveRegistry(market);
// Load persisted exchange/pair on first frame
@@ -836,11 +870,11 @@ void RenderMarketTab(App* app)
// -- Hero header: size to the ACTUAL content (price row + separator gap + stats row) so the
// chart starts right after "24H VOLUME"/"Market Cap" instead of below a reserved empty band. --
ImFont* mktH3 = Type().h3();
float heroHeaderH = Layout::spacingLg() // top pad
+ mktH3->LegacySize + Layout::spacingMd() // price row -> separator
+ Layout::spacingSm() // separator -> stats
+ ovFont->LegacySize + Layout::spacingXs() + sub1->LegacySize // stats label + value
+ Layout::spacingSm(); // bottom pad before the chart (tight)
// Header now holds only the price row + separator; 24H volume + market cap moved onto the
// chart's top strip (with the interval buttons) so they no longer create an empty band.
float heroHeaderH = Layout::spacingLg() // top pad
+ mktH3->LegacySize + Layout::spacingMd() // price row -> separator
+ Layout::spacingSm(); // gap below separator, before the chart
// -- Portfolio geometry. No parent card background: the summary floats under the header
// and the custom-group cards float below it (each with its own glass background). --
@@ -944,41 +978,13 @@ void RenderMarketTab(App* app)
dl->AddRectFilled(bMin, bMax, badgeBg, 4.0f * dp);
dl->AddText(capFont, capFont->LegacySize, ImVec2(bMin.x + badgePadH, bMin.y + badgePadV), chgCol, buf);
// ---- SEPARATOR ----
// ---- SEPARATOR (below the price row; 24H volume + market cap now overlay the chart) ----
float sepY = cy + h3->LegacySize + Layout::spacingMd();
float rnd = glassSpec.rounding;
dl->AddLine(ImVec2(cardMin.x + rnd * 0.5f, sepY),
ImVec2(cardMax.x - rnd * 0.5f, sepY),
WithAlpha(OnSurface(), 15), 1.0f * dp);
// ---- STATS ROW (24h Volume | Market Cap) ----
// (BTC-price column + the "updated Ns ago" staleness line were dropped to
// simplify; freshness is still shown by the attribution "Updated" line.)
float statsY = sepY + Layout::spacingSm();
float colW = (availWidth - Layout::spacingLg() * 2) / 2.0f;
struct StatItem { const char* label; std::string value; ImU32 valueCol; };
StatItem stats[2] = {
{TR("market_24h_volume"), FormatCompactUSD(market.volume_24h), OnSurface()},
{TR("market_cap"), FormatCompactUSD(market.market_cap), OnSurface()},
};
for (int i = 0; i < 2; i++) {
float sx = cardMin.x + Layout::spacingLg() + i * colW;
float centerX = sx + colW * 0.5f;
// Label (overline, centered)
ImVec2 lblSz = ovFont->CalcTextSizeA(ovFont->LegacySize, 10000, 0, stats[i].label);
dl->AddText(ovFont, ovFont->LegacySize,
ImVec2(centerX - lblSz.x * 0.5f, statsY), OnSurfaceMedium(), stats[i].label);
// Value (subtitle1, centered)
float valY = statsY + ovFont->LegacySize + Layout::spacingXs();
ImVec2 valSz = sub1->CalcTextSizeA(sub1->LegacySize, 10000, 0, stats[i].value.c_str());
dl->AddText(sub1, sub1->LegacySize,
ImVec2(centerX - valSz.x * 0.5f, valY), stats[i].valueCol, stats[i].value.c_str());
}
// ---- TRADE BUTTON (top-right of card) ----
if (!currentExchange.pairs.empty()) {
const char* pairName = currentExchange.pairs[s_pair_idx].displayName.c_str();
@@ -1053,30 +1059,100 @@ void RenderMarketTab(App* app)
// PRICE CHART — drawn inside the combined hero card's glass panel (above)
// ================================================================
{
// Plot the REAL accumulated price history (one point per refresh, oldest→newest).
// No synthetic data: until at least two real samples exist, the empty-state below
// ("not enough history yet") is shown rather than inventing a price curve.
s_price_history = market.price_history;
// Plot the historical price series for the selected interval (Live/1H/1D/1W/1M). The
// historical intervals come from CoinGecko market_chart; "Live" is the in-session buffer.
// Until two points exist, the empty-state below is shown instead of a synthetic curve.
s_price_history = pfSparklineSeries(market, s_chart_interval);
s_history_initialized = true;
// Chart height precomputed above (responsive — the chart absorbs the vertical slack
// left by the hero header + portfolio card). No glass panel here — it shares the
// combined hero+chart card's panel drawn in PRICE SUMMARY above.
// The chart shares the combined hero+chart glass panel drawn in PRICE SUMMARY above.
ImVec2 chartMin = ImGui::GetCursorScreenPos();
ImVec2 chartMax(chartMin.x + availWidth, chartMin.y + chartH);
if (!s_price_history.empty() && s_price_history.size() >= 2) {
float chartPad = pad;
float labelPadLeft = std::max(S.drawElement("tabs.market", "chart-y-axis-min-padding").size, S.drawElement("tabs.market", "chart-y-axis-padding").size * hs);
float labelPadBottom = Layout::spacingXl();
float chartPad = pad;
float pillH = capFont->LegacySize + Layout::spacingXs() * 2.0f;
float stripH = pillH + Layout::spacingSm(); // top control/info strip height
float plotLeft = chartMin.x + labelPadLeft;
float plotRight = chartMax.x - chartPad;
float plotTop = chartMin.y + chartPad;
float plotBottom = chartMax.y - labelPadBottom;
float plotW = plotRight - plotLeft;
float plotH = plotBottom - plotTop;
// ---- Top strip: interval buttons (left) + 24H volume / market cap + refresh (right) ----
{
float rowTop = chartMin.y + chartPad;
float textY = rowTop + (pillH - capFont->LegacySize) * 0.5f;
// Interval buttons (left). Live = in-session buffer; the rest are historical.
const struct { const char* lbl; int iv; } kIvs[] = {
{ TR("market_iv_live"), 0 }, { "1H", 1 }, { "1D", 2 }, { "1W", 3 }, { "1M", 4 }
};
float bx = chartMin.x + chartPad;
for (int b = 0; b < 5; b++) {
float tw = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, kIvs[b].lbl).x;
float bw = tw + Layout::spacingSm() * 2.0f;
ImVec2 bmn(bx, rowTop), bmx(bx + bw, rowTop + pillH);
bool sel = (s_chart_interval == kIvs[b].iv);
bool bhov = material::IsRectHovered(bmn, bmx);
ImU32 bg = sel ? WithAlpha(Primary(), 200)
: (bhov ? WithAlpha(OnSurface(), 35) : WithAlpha(OnSurface(), 18));
dl->AddRectFilled(bmn, bmx, bg, 4.0f);
dl->AddText(capFont, capFont->LegacySize, ImVec2(bx + Layout::spacingSm(), textY),
sel ? IM_COL32(255, 255, 255, 255) : OnSurface(), kIvs[b].lbl);
ImGui::SetCursorScreenPos(bmn);
ImGui::PushID(9100 + b);
if (ImGui::InvisibleButton("##civ", ImVec2(bw, pillH))) {
s_chart_interval = kIvs[b].iv;
s_history_initialized = false;
}
if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImGui::PopID();
bx += bw + Layout::spacingXs();
}
// Refresh button (far right).
float rEdge = chartMax.x - chartPad;
ImFont* iconSmall = material::Typography::instance().iconSmall();
ImVec2 rbMin(rEdge - pillH, rowTop), rbMax(rEdge, rowTop + pillH);
bool refreshHov = material::IsRectHovered(rbMin, rbMax);
if (refreshHov) { dl->AddRectFilled(rbMin, rbMax, IM_COL32(255, 255, 255, 20), 4.0f);
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); }
ImVec2 icSz = iconSmall->CalcTextSizeA(iconSmall->LegacySize, FLT_MAX, 0, ICON_MD_REFRESH);
dl->AddText(iconSmall, iconSmall->LegacySize,
ImVec2(rbMin.x + (pillH - icSz.x) * 0.5f, rbMin.y + (pillH - icSz.y) * 0.5f),
refreshHov ? OnSurface() : OnSurfaceMedium(), ICON_MD_REFRESH);
ImGui::SetCursorScreenPos(rbMin);
if (ImGui::InvisibleButton("##RefreshMarket", ImVec2(pillH, pillH))) {
app->refreshMarketData();
s_history_initialized = false;
s_last_refresh_time = ImGui::GetTime();
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("market_refresh_price"));
// 24H volume + market cap, right-aligned to the left of the refresh button. Skipped
// when there's no room (narrow window) so they never overlap the interval buttons.
float sxr = rbMin.x - Layout::spacingMd();
auto drawStat = [&](const char* label, const std::string& val) {
char sb[64]; snprintf(sb, sizeof(sb), "%s %s", label, val.c_str());
float w = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, sb).x;
if (sxr - w < bx + Layout::spacingMd()) return;
sxr -= w;
dl->AddText(capFont, capFont->LegacySize, ImVec2(sxr, textY), OnSurfaceMedium(), sb);
sxr -= Layout::spacingMd();
};
if (market.price_usd > 0) {
drawStat(TR("market_cap_short"), FormatCompactUSD(market.market_cap));
drawStat(TR("market_vol_short"), FormatCompactUSD(market.volume_24h));
}
}
float labelPadLeft = std::max(S.drawElement("tabs.market", "chart-y-axis-min-padding").size,
S.drawElement("tabs.market", "chart-y-axis-padding").size * hs);
// Extra bottom room so the time labels aren't crowded against the card's bottom edge.
float labelPadBottom = Layout::spacingXl() + Layout::spacingSm();
float plotLeft = chartMin.x + labelPadLeft;
float plotRight = chartMax.x - chartPad;
float plotTop = chartMin.y + chartPad + stripH;
float plotBottom = chartMax.y - labelPadBottom;
float plotW = plotRight - plotLeft;
float plotH = plotBottom - plotTop;
if (s_price_history.size() >= 2) {
// Compute Y range with padding
double yMin = *std::min_element(s_price_history.begin(), s_price_history.end());
double yMax = *std::max_element(s_price_history.begin(), s_price_history.end());
@@ -1138,28 +1214,30 @@ void RenderMarketTab(App* app)
dl->AddCircleFilled(points[i], dotR, dotCol);
}
// X-axis time labels. The price series is one sample per market refresh (~60s
// cadence, see RefreshScheduler::kPrice), so label the axis in approximate
// minutes-ago (rightmost = "now"). Approximate — a refresh can be delayed — hence "~".
// X-axis time labels. Each point spans one interval unit (minute/hour/day/week/month);
// convert points-before-now to a "~<time> ago" label (rightmost = "now"). Approximate — "~".
{
const float kSecPerPoint = 60.0f; // RefreshScheduler::kPrice
float secPerPoint = pfChartSecPerPoint(s_chart_interval);
int nPts = (int)n;
int ticks = std::min(4, nPts);
for (int tk = 0; tk < ticks; tk++) {
float t = (ticks > 1) ? (float)tk / (float)(ticks - 1) : 1.0f;
float xpos = plotLeft + t * plotW;
int idx = (int)(t * (nPts - 1) + 0.5f);
int minsAgo = (int)(((float)(nPts - 1 - idx) * kSecPerPoint) / 60.0f + 0.5f);
float secAgo = (float)(nPts - 1 - idx) * secPerPoint;
char tlbl[32];
if (minsAgo <= 0) snprintf(tlbl, sizeof(tlbl), "%s", TR("market_now"));
else if (minsAgo < 90) snprintf(tlbl, sizeof(tlbl), "~%dm", minsAgo);
else snprintf(tlbl, sizeof(tlbl), "~%.1fh", minsAgo / 60.0f);
if (secAgo < 45.0f) snprintf(tlbl, sizeof(tlbl), "%s", TR("market_now"));
else if (secAgo < 5400.0f) snprintf(tlbl, sizeof(tlbl), "~%.0fm", secAgo / 60.0f);
else if (secAgo < 172800.0f) snprintf(tlbl, sizeof(tlbl), "~%.0fh", secAgo / 3600.0f);
else if (secAgo < 1209600.0f) snprintf(tlbl, sizeof(tlbl), "~%.0fd", secAgo / 86400.0f);
else if (secAgo < 7776000.0f) snprintf(tlbl, sizeof(tlbl), "~%.0fw", secAgo / 604800.0f);
else snprintf(tlbl, sizeof(tlbl), "~%.0fmo", secAgo / 2592000.0f);
ImVec2 lblSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, tlbl);
float lx = (tk == 0) ? plotLeft
: (tk == ticks - 1) ? plotRight - lblSz.x
: xpos - lblSz.x * 0.5f;
dl->AddText(capFont, capFont->LegacySize,
ImVec2(lx, plotBottom + 2), OnSurfaceDisabled(), tlbl);
ImVec2(lx, plotBottom + 4), OnSurfaceDisabled(), tlbl);
}
}
@@ -1206,58 +1284,14 @@ void RenderMarketTab(App* app)
ImVec2(tipX + tipPad, tipY + tipPad), dotCol, buf);
}
} else {
// Empty state, centered in the plot area (the interval strip + refresh are already drawn).
const char* msg = TR("market_no_history");
ImVec2 ts = sub1->CalcTextSizeA(sub1->LegacySize, FLT_MAX, 0, msg);
dl->AddText(sub1, sub1->LegacySize,
ImVec2(chartMin.x + (availWidth - ts.x) * 0.5f, chartMin.y + chartH * 0.45f),
ImVec2(chartMin.x + (availWidth - ts.x) * 0.5f, (plotTop + plotBottom) * 0.5f - ts.y * 0.5f),
OnSurfaceDisabled(), msg);
}
// --- Refresh button + timestamp pinned in chart top-right ---
{
float iconBtnSz = capFont->LegacySize + 8.0f;
float refreshX = chartMax.x - pad;
float refreshY = chartMin.y + pad * 0.5f;
// Draw refresh icon button
ImVec2 btnMin(refreshX - iconBtnSz, refreshY);
ImVec2 btnMax(refreshX, refreshY + iconBtnSz);
bool refreshHov = material::IsRectHovered(btnMin, btnMax);
if (refreshHov) {
dl->AddRectFilled(btnMin, btnMax, IM_COL32(255, 255, 255, 20), 4.0f);
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
}
ImFont* iconSmall = material::Typography::instance().iconSmall();
ImVec2 iconSz = iconSmall->CalcTextSizeA(iconSmall->LegacySize, FLT_MAX, 0, ICON_MD_REFRESH);
dl->AddText(iconSmall, iconSmall->LegacySize,
ImVec2(btnMin.x + (iconBtnSz - iconSz.x) * 0.5f, btnMin.y + (iconBtnSz - iconSz.y) * 0.5f),
refreshHov ? OnSurface() : OnSurfaceMedium(), ICON_MD_REFRESH);
ImGui::SetCursorScreenPos(btnMin);
if (ImGui::InvisibleButton("##RefreshMarket", ImVec2(iconBtnSz, iconBtnSz))) {
app->refreshMarketData();
s_history_initialized = false;
s_last_refresh_time = ImGui::GetTime();
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("market_refresh_price"));
// Timestamp text to the left of refresh button
if (s_last_refresh_time > 0.0) {
double elapsed = ImGui::GetTime() - s_last_refresh_time;
if (elapsed < 60.0)
snprintf(buf, sizeof(buf), "%.0fs ago", elapsed);
else if (elapsed < 3600.0)
snprintf(buf, sizeof(buf), "%.0fm ago", elapsed / 60.0);
else
snprintf(buf, sizeof(buf), "%.1fh ago", elapsed / 3600.0);
ImVec2 tsSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(btnMin.x - tsSz.x - 6, btnMin.y + (iconBtnSz - tsSz.y) * 0.5f),
OnSurfaceDisabled(), buf);
}
}
ImGui::SetCursorScreenPos(ImVec2(chartMin.x, chartMin.y + chartH));
ImGui::Dummy(ImVec2(availWidth, 0));
}
@@ -1448,7 +1482,7 @@ void RenderMarketTab(App* app)
float gy = cardMin.y + pfSummaryH;
ImGuiIO& io = ImGui::GetIO();
bool interacting = (s_pf_drag >= 0 || s_pf_resize >= 0);
float rh = 14.0f * mktDp; // corner resize-grip size
float rh = 20.0f * mktDp; // corner resize-grip size (generous so a short resize is easy to grab)
auto cellMin = [&](const PfCell& L) {
return ImVec2(cx + L.col * (pfCellW + pfCardGap), gy + L.row * (pfCellH + pfCardGap));
@@ -1469,9 +1503,12 @@ void RenderMarketTab(App* app)
GlassPanelSpec gcGlass; gcGlass.rounding = 10.0f;
gcGlass.fillAlpha = hov ? 34 : 22; gcGlass.borderAlpha = 40;
DrawGlassPanel(dl, gcMin, gcMax, gcGlass);
// Accented groups get a colored outline around the whole card; others a subtle hover ring.
if (accent) dl->AddRect(gcMin, gcMax, accent, 10.0f, 0, hov ? 2.5f : 2.0f);
else if (hov) dl->AddRect(gcMin, gcMax, WithAlpha(OnSurface(), 70), 10.0f, 0, 1.0f);
// Accented groups get a colored outline around the whole card (configurable opacity);
// others get a subtle hover ring.
if (accent) {
int oa = (int)(std::max(0, std::min(100, e.outlineOpacity)) * 2.55f + 0.5f);
dl->AddRect(gcMin, gcMax, WithAlpha(accent, oa), 10.0f, 0, hov ? 2.5f : 2.0f);
} else if (hov) dl->AddRect(gcMin, gcMax, WithAlpha(OnSurface(), 70), 10.0f, 0, 1.0f);
float left = gcMin.x + pad;
float right = gcMax.x - pad;
@@ -1620,6 +1657,16 @@ void RenderMarketTab(App* app)
ImVec2 sz = cellSize(nw, nh), mx(mn.x + sz.x, mn.y + sz.y);
drawCard(mn, mx, pfEntriesGeo[i], true);
dl->AddRect(mn, mx, WithAlpha(Primary(), 200), 10.0f, 0, 2.0f * mktDp);
// Live size readout (e.g. "6\xC3\x973") so the reached grid span is visible — the
// minimum is pfMinW x pfMinH (4x3).
{
char szb[16]; snprintf(szb, sizeof(szb), "%d\xC3\x97%d", nw, nh);
ImVec2 tsz = sub1->CalcTextSizeA(sub1->LegacySize, FLT_MAX, 0, szb);
ImVec2 tp((mn.x + mx.x) * 0.5f - tsz.x * 0.5f, (mn.y + mx.y) * 0.5f - tsz.y * 0.5f);
dl->AddRectFilled(ImVec2(tp.x - 6, tp.y - 3), ImVec2(tp.x + tsz.x + 6, tp.y + tsz.y + 3),
IM_COL32(20, 20, 30, 205), 4.0f);
dl->AddText(sub1, sub1->LegacySize, tp, OnSurface(), szb);
}
ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeNWSE);
if (!io.MouseDown[0]) {
auto entries = app->settings()->getPortfolioEntries();

View File

@@ -1111,6 +1111,9 @@ void I18n::loadBuiltinEnglish()
strings_["market_24h"] = "24h";
strings_["market_24h_volume"] = "24H VOLUME";
strings_["market_6h"] = "6h";
strings_["market_iv_live"] = "Live";
strings_["market_vol_short"] = "Vol";
strings_["market_cap_short"] = "Cap";
strings_["market_attribution"] = "Price data from CoinGecko";
strings_["market_btc_price"] = "BTC PRICE";
strings_["market_no_history"] = "No price history available";
@@ -1124,6 +1127,7 @@ void I18n::loadBuiltinEnglish()
strings_["portfolio_color"] = "Color";
strings_["portfolio_no_icon"] = "None";
strings_["portfolio_custom_color"] = "Custom color\xE2\x80\xA6";
strings_["portfolio_outline_opacity"] = "Outline opacity";
strings_["portfolio_price"] = "Price";
strings_["portfolio_price_usd"] = "Market \xC2\xB7 USD";
strings_["portfolio_price_btc"] = "Market \xC2\xB7 BTC";