feat: Full UI internationalization, pool hashrate stats, and layout caching

- Replace all hardcoded English strings with TR() translation keys across
  every tab, dialog, and component (~20 UI files)
- Expand all 8 language files (de, es, fr, ja, ko, pt, ru, zh) with
  complete translations (~37k lines added)
- Improve i18n loader with exe-relative path fallback and English base
  fallback for missing keys
- Add pool-side hashrate polling via pool stats API in xmrig_manager
- Introduce Layout::beginFrame() per-frame caching and refresh balance
  layout config only on schema generation change
- Offload daemon output parsing to worker thread
- Add CJK subset fallback font for Chinese/Japanese/Korean glyphs
This commit is contained in:
dan_s
2026-03-11 00:40:50 -05:00
parent cc617dd5be
commit 96c27bb949
71 changed files with 43567 additions and 5267 deletions

View File

@@ -8,6 +8,7 @@
#include "../../data/wallet_state.h"
#include "../../config/settings.h"
#include "../../data/exchange_info.h"
#include "../../util/i18n.h"
#include "../schema/ui_schema.h"
#include "../material/type.h"
#include "../material/draw_helpers.h"
@@ -147,15 +148,17 @@ void RenderMarketTab(App* app)
float mkCardBudget = std::max(200.0f, marketAvail.y - mkOverhead);
Layout::SectionBudget mb(mkCardBudget);
float statsMarketBudH = mb.allocate(0.14f, S.drawElement("tabs.market", "stats-card-min-height").size);
float portfolioBudgetH = mb.allocate(0.18f, 50.0f);
// ================================================================
// PRICE HERO — Large glass card with price + change badge
// PRICE SUMMARY — Combined hero card with price, stats, and exchange
// ================================================================
{
float dp = Layout::dpiScale();
ImVec2 cardMin = ImGui::GetCursorScreenPos();
float cardH = std::max(S.drawElement("tabs.market", "hero-card-min-height").size, S.drawElement("tabs.market", "hero-card-height").size * vs);
float heroMinH = S.drawElement("tabs.market", "hero-card-min-height").size;
float statsRowH = ovFont->LegacySize + Layout::spacingXs() + sub1->LegacySize + pad;
float cardH = std::max(heroMinH + statsRowH + pad, (S.drawElement("tabs.market", "hero-card-height").size + statsRowH + pad) * vs);
ImVec2 cardMax(cardMin.x + availWidth, cardMin.y + cardH);
DrawGlassPanel(dl, cardMin, cardMax, glassSpec);
@@ -172,36 +175,118 @@ void RenderMarketTab(App* app)
float cy = cardMin.y + Layout::spacingLg();
if (market.price_usd > 0) {
// Large price with text shadow
// ---- HERO PRICE (large, prominent) ----
ImFont* h3 = Type().h3();
std::string priceStr = FormatPrice(market.price_usd);
ImU32 priceCol = Success();
DrawTextShadow(dl, h4, h4->LegacySize, ImVec2(cx, cy), priceCol, priceStr.c_str());
DrawTextShadow(dl, h3, h3->LegacySize, ImVec2(cx, cy), priceCol, priceStr.c_str());
// BTC price beside it
float priceW = h4->CalcTextSizeA(h4->LegacySize, FLT_MAX, 0, priceStr.c_str()).x;
snprintf(buf, sizeof(buf), "%.10f BTC", market.price_btc);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(cx + priceW + Layout::spacingXl(), cy + (h4->LegacySize - capFont->LegacySize)),
OnSurfaceMedium(), buf);
// Ticker label after price
float priceW = h3->CalcTextSizeA(h3->LegacySize, FLT_MAX, 0, priceStr.c_str()).x;
dl->AddText(body2, body2->LegacySize,
ImVec2(cx + priceW + Layout::spacingSm(), cy + (h3->LegacySize - body2->LegacySize)),
OnSurfaceMedium(), DRAGONX_TICKER);
// 24h change badge
float badgeY = cy + h4->LegacySize + 8;
// 24h change badge — to the right of ticker
float tickerW = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, DRAGONX_TICKER).x;
float badgeX = cx + priceW + Layout::spacingSm() + tickerW + Layout::spacingMd();
ImU32 chgCol = market.change_24h >= 0 ? Success() : Error();
snprintf(buf, sizeof(buf), "%s%.2f%%", market.change_24h >= 0 ? "+" : "", market.change_24h);
ImVec2 txtSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf);
float badgePad = Layout::spacingSm() + Layout::spacingXs();
ImVec2 bMin(cx, badgeY);
ImVec2 bMax(cx + txtSz.x + badgePad * 2, badgeY + txtSz.y + badgePad);
snprintf(buf, sizeof(buf), "%s%.2f%% 24h", market.change_24h >= 0 ? "+" : "", market.change_24h);
ImVec2 chgSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, buf);
float badgePadH = Layout::spacingSm();
float badgePadV = Layout::spacingXs();
ImVec2 bMin(badgeX, cy + (h3->LegacySize - chgSz.y - badgePadV * 2) * 0.5f);
ImVec2 bMax(badgeX + chgSz.x + badgePadH * 2, bMin.y + chgSz.y + badgePadV * 2);
ImU32 badgeBg = market.change_24h >= 0 ? WithAlpha(Success(), 30) : WithAlpha(Error(), 30);
dl->AddRectFilled(bMin, bMax, badgeBg, 4.0f);
dl->AddText(capFont, capFont->LegacySize, ImVec2(cx + badgePad, badgeY + badgePad * 0.5f), chgCol, buf);
dl->AddRectFilled(bMin, bMax, badgeBg, 4.0f * dp);
dl->AddText(capFont, capFont->LegacySize, ImVec2(bMin.x + badgePadH, bMin.y + badgePadV), chgCol, buf);
// "24h" label after badge
dl->AddText(capFont, capFont->LegacySize,
ImVec2(bMax.x + 6, badgeY + badgePad * 0.5f), OnSurfaceDisabled(), "24h");
// ---- SEPARATOR ----
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 (BTC Price | Volume | Market Cap) ----
float statsY = sepY + Layout::spacingSm();
float colW = (availWidth - Layout::spacingLg() * 2) / 3.0f;
struct StatItem { const char* label; std::string value; ImU32 valueCol; };
StatItem stats[3] = {
{TR("market_btc_price"), "", OnSurface()},
{TR("market_24h_volume"), FormatCompactUSD(market.volume_24h), OnSurface()},
{TR("market_cap"), FormatCompactUSD(market.market_cap), OnSurface()},
};
snprintf(buf, sizeof(buf), "%.10f", market.price_btc);
stats[0].value = buf;
for (int i = 0; i < 3; 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();
ImFont* iconFont = Type().iconSmall();
ImVec2 textSz = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, pairName);
ImVec2 iconSz = iconFont->CalcTextSizeA(iconFont->LegacySize, FLT_MAX, 0, ICON_MD_OPEN_IN_NEW);
float iconGap = Layout::spacingSm();
float tradePadH = Layout::spacingMd();
float tradePadV = Layout::spacingSm();
float tradeBtnW = textSz.x + iconGap + iconSz.x + tradePadH * 2;
float tradeBtnH = std::max(textSz.y, iconSz.y) + tradePadV * 2;
float tradeBtnX = cardMax.x - pad - tradeBtnW;
float tradeBtnY = cardMin.y + Layout::spacingSm();
ImVec2 tMin(tradeBtnX, tradeBtnY), tMax(tradeBtnX + tradeBtnW, tradeBtnY + tradeBtnH);
bool tradeHov = material::IsRectHovered(tMin, tMax);
// Glass pill background
GlassPanelSpec tradeBtnGlass;
tradeBtnGlass.rounding = tradeBtnH * 0.5f;
tradeBtnGlass.fillAlpha = tradeHov ? 35 : 20;
DrawGlassPanel(dl, tMin, tMax, tradeBtnGlass);
if (tradeHov)
dl->AddRectFilled(tMin, tMax, WithAlpha(Primary(), 20), tradeBtnH * 0.5f);
// Text (pair name with body2, icon with icon font)
ImU32 tradeCol = tradeHov ? OnSurface() : OnSurfaceMedium();
float contentY = tradeBtnY + tradePadV;
float curX = tradeBtnX + tradePadH;
dl->AddText(body2, body2->LegacySize,
ImVec2(curX, contentY), tradeCol, pairName);
curX += textSz.x + iconGap;
dl->AddText(iconFont, iconFont->LegacySize,
ImVec2(curX, contentY + (textSz.y - iconSz.y) * 0.5f), tradeCol, ICON_MD_OPEN_IN_NEW);
// Click
ImVec2 savedCur = ImGui::GetCursorScreenPos();
ImGui::SetCursorScreenPos(tMin);
ImGui::InvisibleButton("##TradeOnExchange", ImVec2(tradeBtnW, tradeBtnH));
if (ImGui::IsItemClicked()) {
util::Platform::openUrl(currentExchange.pairs[s_pair_idx].tradeUrl);
}
if (ImGui::IsItemHovered()) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImGui::SetTooltip(TR("market_trade_on"), currentExchange.name.c_str());
}
ImGui::SetCursorScreenPos(savedCur);
}
} else {
DrawTextShadow(dl, sub1, sub1->LegacySize, ImVec2(cx, cy + 10), OnSurfaceDisabled(), "Price data unavailable");
DrawTextShadow(dl, sub1, sub1->LegacySize, ImVec2(cx, cy + 10), OnSurfaceDisabled(), TR("market_price_unavailable"));
}
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMax.y));
@@ -209,42 +294,6 @@ void RenderMarketTab(App* app)
ImGui::Dummy(ImVec2(0, gap));
}
// ================================================================
// STATS — Three glass cards (Price | Volume | Market Cap)
// ================================================================
{
float cardW = (availWidth - 2 * gap) / 3.0f;
float cardH = std::min(StatCardHeight(vs), statsMarketBudH);
ImVec2 origin = ImGui::GetCursorScreenPos();
struct StatInfo { const char* label; std::string value; ImU32 col; ImU32 accent; };
StatInfo cards[3] = {
{"PRICE", market.price_usd > 0 ? FormatPrice(market.price_usd) : "N/A",
OnSurface(), WithAlpha(Success(), 200)},
{"24H VOLUME", FormatCompactUSD(market.volume_24h),
OnSurface(), WithAlpha(Secondary(), 200)},
{"MARKET CAP", FormatCompactUSD(market.market_cap),
OnSurface(), WithAlpha(Warning(), 200)},
};
for (int i = 0; i < 3; i++) {
float xOff = i * (cardW + gap);
ImVec2 cMin(origin.x + xOff, origin.y);
ImVec2 cMax(cMin.x + cardW, cMin.y + cardH);
StatCardSpec sc;
sc.overline = cards[i].label;
sc.value = cards[i].value.c_str();
sc.valueCol = cards[i].col;
sc.accentCol = cards[i].accent;
sc.centered = true;
DrawStatCard(dl, cMin, cMax, sc, glassSpec);
}
ImGui::Dummy(ImVec2(availWidth, cardH));
ImGui::Dummy(ImVec2(0, gap));
}
// ================================================================
// PRICE CHART — Custom drawn inside glass panel (matches app design)
// ================================================================
@@ -340,7 +389,7 @@ void RenderMarketTab(App* app)
// X-axis labels
const int xlabels[] = {0, 6, 12, 18, 23};
const char* xlabelText[] = {"24h", "18h", "12h", "6h", "Now"};
const char* xlabelText[] = {TR("market_24h"), TR("market_18h"), TR("market_12h"), TR("market_6h"), TR("market_now")};
for (int xi = 0; xi < 5; xi++) {
int idx = xlabels[xi];
float t = (float)idx / (float)(n - 1);
@@ -395,7 +444,7 @@ void RenderMarketTab(App* app)
ImVec2(tipX + tipPad, tipY + tipPad), dotCol, buf);
}
} else {
const char* msg = "No price history available";
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),
@@ -429,7 +478,7 @@ void RenderMarketTab(App* app)
s_history_initialized = false;
s_last_refresh_time = ImGui::GetTime();
}
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Refresh price data");
if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", TR("market_refresh_price"));
// Timestamp text to the left of refresh button
if (s_last_refresh_time > 0.0) {
@@ -452,7 +501,7 @@ void RenderMarketTab(App* app)
}
// ================================================================
// EXCHANGE SELECTOR — Combo dropdown + pair name + trade link
// EXCHANGE SELECTOR — Combo dropdown + attribution
// ================================================================
ImGui::Dummy(ImVec2(0, S.drawElement("tabs.market", "exchange-top-gap").size));
{
@@ -483,28 +532,9 @@ void RenderMarketTab(App* app)
}
ImGui::PopItemWidth();
// Show current pair name beside combo
if (!currentExchange.pairs.empty()) {
ImGui::SameLine(0, Layout::spacingLg());
Type().textColored(TypeStyle::Subtitle1, OnSurface(),
currentExchange.pairs[s_pair_idx].displayName.c_str());
// "Open on exchange" button
ImGui::SameLine(0, Layout::spacingSm());
ImGui::PushFont(material::Typography::instance().iconSmall());
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(4, 4));
snprintf(buf, sizeof(buf), ICON_MD_OPEN_IN_NEW "##TradeLink");
if (ImGui::Button(buf)) {
util::Platform::openUrl(currentExchange.pairs[s_pair_idx].tradeUrl);
}
ImGui::PopStyleVar();
ImGui::PopFont();
if (ImGui::IsItemHovered()) ImGui::SetTooltip("Open on %s", currentExchange.name.c_str());
}
// Attribution
ImGui::SameLine(0, Layout::spacingLg());
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), "Price data from CoinGecko API");
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("market_attribution"));
if (!market.last_updated.empty()) {
ImGui::SameLine(0, 12);
@@ -722,7 +752,7 @@ void RenderMarketTab(App* app)
// PORTFOLIO — Glass card with balance breakdown
// ================================================================
{
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), "PORTFOLIO");
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("market_portfolio"));
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
double total_balance = state.totalBalance;
@@ -760,7 +790,7 @@ void RenderMarketTab(App* app)
dl->AddText(capFont, capFont->LegacySize,
ImVec2(cardMax.x - valW - pad, cy + 2), OnSurfaceMedium(), buf);
} else {
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, cy), OnSurfaceDisabled(), "No price data");
dl->AddText(sub1, sub1->LegacySize, ImVec2(cx, cy), OnSurfaceDisabled(), TR("market_no_price"));
}
cy += sub1->LegacySize + 8;
@@ -799,7 +829,7 @@ void RenderMarketTab(App* app)
shieldedW > 0.5f ? ImDrawFlags_RoundCornersRight : ImDrawFlags_RoundCornersAll, 3.0f);
int pct = (int)(shieldedRatio * 100.0f + 0.5f);
snprintf(buf, sizeof(buf), "%d%% Shielded", pct);
snprintf(buf, sizeof(buf), TR("market_pct_shielded"), pct);
dl->AddText(capFont, capFont->LegacySize,
ImVec2(cx, cy + barH + 2), OnSurfaceDisabled(), buf);
}