feat(ui): layout polish — fill dead space, pair/box/collapse, warning weight, dialog glass

Implements the layout-improvement suggestions from the layout audit (visual arrangement
only; no functionality added or removed):

- Send/Receive: the recent-activity list now grows to fill the space below the fixed
  compose/receive card (more history visible), with a centered empty-state when there is
  none — instead of leaving dead canvas.
- Shield/Merge: pair the Fee and UTXO-Limit fields on one row to tighten vertical rhythm.
- Market: extend + frame the portfolio group-list as one contained panel (with a bottom
  edge) and center its empty-state, closing the previously un-anchored gap.
- Overlay dialogs: raise the card glass fill/border alpha (35/50 -> 60/90 of 255) so the
  dialog card reads as a distinct surface over busy backdrops (global, all overlays).
- Wallets: size the list height to the actual wallet count instead of always reserving 7
  rows, removing the large gap before the scan/create prompts (still scrolls when many).
- Contacts: width-aware address truncation shows more of the address on wide rows.
- Transfer Funds: give the "sends the full balance" warning a warning icon + color so the
  stakes stand out from the neutral result-preview lines.
- First-run wizard: collapse a completed Step 1 (Appearance) to the compact pill like
  Step 2, so a finished step is no longer taller than the active one.
- Explorer: distribute the Chain card's two stats to match the density of the sibling
  metrics grid.
- Validate Address: a "Results will appear here" caption fills the pre-interaction blank.
- Change Passphrase: add the warning banner its sibling security dialogs have.
- Migration ShowSeed: box the 24-word mnemonic grid (a GlassSectionScope behind the
  existing RenderSeedWordGrid) so the critical secret reads as a distinct artifact —
  purely visual, no seed/logic/state change.

Verified at 1280 across full-node + Lite + Windows (ctest green) and an adversarial diff
review (clean). New i18n key backfilled into all 8 languages.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-19 13:56:13 -05:00
parent d5c30237d2
commit 8778398d31
22 changed files with 189 additions and 59 deletions

View File

@@ -1672,6 +1672,7 @@
"validate_not_mine": "Nicht im Besitz dieser Wallet", "validate_not_mine": "Nicht im Besitz dieser Wallet",
"validate_ownership": "Eigentum:", "validate_ownership": "Eigentum:",
"validate_results": "Ergebnisse:", "validate_results": "Ergebnisse:",
"validate_results_placeholder": "Ergebnisse erscheinen hier",
"validate_shielded_type": "Abgeschirmt (z-Adresse)", "validate_shielded_type": "Abgeschirmt (z-Adresse)",
"validate_status": "Status:", "validate_status": "Status:",
"validate_title": "Adresse validieren", "validate_title": "Adresse validieren",
@@ -1830,4 +1831,4 @@
"your_addresses": "Ihre Adressen", "your_addresses": "Ihre Adressen",
"z_address": "Z-Adresse", "z_address": "Z-Adresse",
"z_addresses": "Z-Adressen" "z_addresses": "Z-Adressen"
} }

View File

@@ -1672,6 +1672,7 @@
"validate_not_mine": "No es propiedad de esta cartera", "validate_not_mine": "No es propiedad de esta cartera",
"validate_ownership": "Propiedad:", "validate_ownership": "Propiedad:",
"validate_results": "Resultados:", "validate_results": "Resultados:",
"validate_results_placeholder": "Los resultados aparecerán aquí",
"validate_shielded_type": "Protegida (dirección z)", "validate_shielded_type": "Protegida (dirección z)",
"validate_status": "Estado:", "validate_status": "Estado:",
"validate_title": "Validar Dirección", "validate_title": "Validar Dirección",
@@ -1830,4 +1831,4 @@
"your_addresses": "Sus Direcciones", "your_addresses": "Sus Direcciones",
"z_address": "Dirección Z", "z_address": "Dirección Z",
"z_addresses": "Direcciones Z" "z_addresses": "Direcciones Z"
} }

View File

@@ -1672,6 +1672,7 @@
"validate_not_mine": "N'appartient pas à ce portefeuille", "validate_not_mine": "N'appartient pas à ce portefeuille",
"validate_ownership": "Propriété :", "validate_ownership": "Propriété :",
"validate_results": "Résultats :", "validate_results": "Résultats :",
"validate_results_placeholder": "Les résultats apparaîtront ici",
"validate_shielded_type": "Blindée (z-adresse)", "validate_shielded_type": "Blindée (z-adresse)",
"validate_status": "Statut :", "validate_status": "Statut :",
"validate_title": "Valider l'adresse", "validate_title": "Valider l'adresse",
@@ -1830,4 +1831,4 @@
"your_addresses": "Vos adresses", "your_addresses": "Vos adresses",
"z_address": "Adresse Z", "z_address": "Adresse Z",
"z_addresses": "Adresses Z" "z_addresses": "Adresses Z"
} }

View File

@@ -1669,6 +1669,7 @@
"validate_not_mine": "このウォレットに属していません", "validate_not_mine": "このウォレットに属していません",
"validate_ownership": "所有者:", "validate_ownership": "所有者:",
"validate_results": "結果:", "validate_results": "結果:",
"validate_results_placeholder": "ここに結果が表示されます",
"validate_shielded_type": "シールドzアドレス", "validate_shielded_type": "シールドzアドレス",
"validate_status": "ステータス:", "validate_status": "ステータス:",
"validate_title": "アドレスを検証", "validate_title": "アドレスを検証",
@@ -1827,4 +1828,4 @@
"your_addresses": "あなたのアドレス", "your_addresses": "あなたのアドレス",
"z_address": "Zアドレス", "z_address": "Zアドレス",
"z_addresses": "Zアドレス" "z_addresses": "Zアドレス"
} }

View File

@@ -1671,6 +1671,7 @@
"validate_not_mine": "이 지갑에 속하지 않음", "validate_not_mine": "이 지갑에 속하지 않음",
"validate_ownership": "소유자:", "validate_ownership": "소유자:",
"validate_results": "결과:", "validate_results": "결과:",
"validate_results_placeholder": "결과가 여기에 표시됩니다",
"validate_shielded_type": "차폐 (z 주소)", "validate_shielded_type": "차폐 (z 주소)",
"validate_status": "상태:", "validate_status": "상태:",
"validate_title": "주소 검증", "validate_title": "주소 검증",
@@ -1829,4 +1830,4 @@
"your_addresses": "내 주소", "your_addresses": "내 주소",
"z_address": "Z 주소", "z_address": "Z 주소",
"z_addresses": "Z 주소" "z_addresses": "Z 주소"
} }

View File

@@ -1672,6 +1672,7 @@
"validate_not_mine": "Não pertence a esta carteira", "validate_not_mine": "Não pertence a esta carteira",
"validate_ownership": "Propriedade:", "validate_ownership": "Propriedade:",
"validate_results": "Resultados:", "validate_results": "Resultados:",
"validate_results_placeholder": "Os resultados aparecerão aqui",
"validate_shielded_type": "Blindado (z-endereço)", "validate_shielded_type": "Blindado (z-endereço)",
"validate_status": "Status:", "validate_status": "Status:",
"validate_title": "Validar Endereço", "validate_title": "Validar Endereço",
@@ -1830,4 +1831,4 @@
"your_addresses": "Seus Endereços", "your_addresses": "Seus Endereços",
"z_address": "Endereço Z", "z_address": "Endereço Z",
"z_addresses": "Endereços Z" "z_addresses": "Endereços Z"
} }

View File

@@ -1672,6 +1672,7 @@
"validate_not_mine": "Не принадлежит этому кошельку", "validate_not_mine": "Не принадлежит этому кошельку",
"validate_ownership": "Принадлежность:", "validate_ownership": "Принадлежность:",
"validate_results": "Результаты:", "validate_results": "Результаты:",
"validate_results_placeholder": "Результаты появятся здесь",
"validate_shielded_type": "Экранированный (z-адрес)", "validate_shielded_type": "Экранированный (z-адрес)",
"validate_status": "Статус:", "validate_status": "Статус:",
"validate_title": "Проверить адрес", "validate_title": "Проверить адрес",
@@ -1830,4 +1831,4 @@
"your_addresses": "Ваши адреса", "your_addresses": "Ваши адреса",
"z_address": "Z-адрес", "z_address": "Z-адрес",
"z_addresses": "Z-адреса" "z_addresses": "Z-адреса"
} }

View File

@@ -1670,6 +1670,7 @@
"validate_not_mine": "不属于此钱包", "validate_not_mine": "不属于此钱包",
"validate_ownership": "所有权:", "validate_ownership": "所有权:",
"validate_results": "结果:", "validate_results": "结果:",
"validate_results_placeholder": "结果将显示在此处",
"validate_shielded_type": "屏蔽z 地址)", "validate_shielded_type": "屏蔽z 地址)",
"validate_status": "状态:", "validate_status": "状态:",
"validate_title": "验证地址", "validate_title": "验证地址",
@@ -1828,4 +1829,4 @@
"your_addresses": "您的地址", "your_addresses": "您的地址",
"z_address": "Z 地址", "z_address": "Z 地址",
"z_addresses": "Z 地址" "z_addresses": "Z 地址"
} }

View File

@@ -4174,7 +4174,12 @@ void App::renderSeedMigrationDialog()
case SeedMigrationStep::ShowSeed: { case SeedMigrationStep::ShowSeed: {
ui::material::DialogWarningHeader(TR("mig_seed_warning")); ui::material::DialogWarningHeader(TR("mig_seed_warning"));
ImGui::Spacing(); ImGui::Spacing();
ui::RenderSeedWordGrid(ui::SplitSeedWords(seed_migration_seed_)); // Box the 24-word grid in a glass panel so the wallet's most critical secret reads as a
// distinct artifact, not body copy. Purely visual — the words/derivation/logic are untouched.
{
ui::material::GlassSectionScope seedPanel;
ui::RenderSeedWordGrid(ui::SplitSeedWords(seed_migration_seed_));
}
ImGui::Spacing(); ImGui::Spacing();
ImGui::TextColored(kMedium, "%s", TR("mig_receive_addr")); ImGui::TextColored(kMedium, "%s", TR("mig_receive_addr"));
ImGui::TextWrapped("%s", seed_migration_dest_.c_str()); ImGui::TextWrapped("%s", seed_migration_dest_.c_str());

View File

@@ -1450,6 +1450,11 @@ void App::renderEncryptWalletDialog() {
ov.cardWidth = 460.0f; ov.idSuffix = "changepass"; ov.cardWidth = 460.0f; ov.idSuffix = "changepass";
if (BeginOverlayDialog(ov)) { if (BeginOverlayDialog(ov)) {
// Same fund-loss consequence as Encrypt/Remove Encryption if the new
// passphrase is lost — reuse their warning string/header for consistency.
DialogWarningHeader(TR("wiz_encrypt_warning"));
ImGui::Spacing();
ImGui::TextUnformatted(TR("change_pass_current")); ImGui::TextUnformatted(TR("change_pass_current"));
ImGui::PushItemWidth(-1); ImGui::PushItemWidth(-1);
ImGui::InputText("##chg_old", change_old_pass_buf_, sizeof(change_old_pass_buf_), ImGui::InputText("##chg_old", change_old_pass_buf_, sizeof(change_old_pass_buf_),

View File

@@ -294,29 +294,43 @@ void App::renderFirstRunWizard() {
{ {
int state = cardState(0); int state = cardState(0);
bool isFocused = (state == 1); bool isFocused = (state == 1);
bool isCollapsed = (state == 2); // Completed: minimize to a compact pill (mirrors Card 1)
float cx = leftX + cardPad; float cx = leftX + cardPad;
float cy = card0Top + cardPad; float cy = card0Top + cardPad;
float contentW = colW - 2 * cardPad; float contentW = colW - 2 * cardPad;
// Step indicator // Step indicator + title (inline when collapsed)
{ if (isCollapsed) {
// Compact single-line: check icon + "Step 1" + "Appearance"
float iconW = iconFont->CalcTextSizeA(iconFont->LegacySize, FLT_MAX, 0, stepIcon(state)).x; float iconW = iconFont->CalcTextSizeA(iconFont->LegacySize, FLT_MAX, 0, stepIcon(state)).x;
dl->AddText(iconFont, iconFont->LegacySize, ImVec2(cx, cy), dimCol, stepIcon(state)); dl->AddText(iconFont, iconFont->LegacySize, ImVec2(cx, cy), dimCol, stepIcon(state));
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx + iconW + 4.0f * dp, cy), dimCol, TR("wiz_step1")); float labelX = cx + iconW + 4.0f * dp;
cy += captionFont->LegacySize + 6.0f * dp; dl->AddText(captionFont, captionFont->LegacySize, ImVec2(labelX, cy), dimCol, TR("wiz_step1"));
} float step1W = captionFont->CalcTextSizeA(captionFont->LegacySize, FLT_MAX, 0, TR("wiz_step1")).x;
float titleX = labelX + step1W + 12.0f * dp;
dl->AddText(bodyFont, bodyFont->LegacySize, ImVec2(titleX, cy), dimCol, TR("wiz_appearance"));
cy += captionFont->LegacySize + 4.0f * dp;
} else {
// Step indicator
{
float iconW = iconFont->CalcTextSizeA(iconFont->LegacySize, FLT_MAX, 0, stepIcon(state)).x;
dl->AddText(iconFont, iconFont->LegacySize, ImVec2(cx, cy), dimCol, stepIcon(state));
dl->AddText(captionFont, captionFont->LegacySize, ImVec2(cx + iconW + 4.0f * dp, cy), dimCol, TR("wiz_step1"));
cy += captionFont->LegacySize + 6.0f * dp;
}
// Title // Title
{ {
const char* t = TR("wiz_appearance"); const char* t = TR("wiz_appearance");
dl->AddText(titleFont, titleFont->LegacySize, ImVec2(cx, cy), textCol, t); dl->AddText(titleFont, titleFont->LegacySize, ImVec2(cx, cy), textCol, t);
cy += titleFont->LegacySize + 10.0f * dp; cy += titleFont->LegacySize + 10.0f * dp;
} }
// Separator // Separator
dl->AddLine(ImVec2(cx, cy), ImVec2(cx + contentW, cy), dl->AddLine(ImVec2(cx, cy), ImVec2(cx + contentW, cy),
(textCol & 0x00FFFFFF) | IM_COL32(0,0,0,40), 1.0f * dp); (textCol & 0x00FFFFFF) | IM_COL32(0,0,0,40), 1.0f * dp);
cy += 14.0f * dp; cy += 14.0f * dp;
}
float& wiz_blur_amount = wizardUi.blur_amount; float& wiz_blur_amount = wizardUi.blur_amount;
bool& wiz_theme_effects = wizardUi.theme_effects; bool& wiz_theme_effects = wizardUi.theme_effects;
@@ -352,6 +366,9 @@ void App::renderFirstRunWizard() {
wiz_appearance_init = true; wiz_appearance_init = true;
} }
// Controls: rendered for the focused and upcoming states so content is visible under
// the dim overlay; skipped entirely once completed so the card shrinks to a compact pill.
if (!isCollapsed) {
// Render controls always so content is visible under the dim // Render controls always so content is visible under the dim
// overlay when not focused; disable interaction when not active. // overlay when not focused; disable interaction when not active.
ImGui::BeginDisabled(!isFocused); ImGui::BeginDisabled(!isFocused);
@@ -668,13 +685,21 @@ void App::renderFirstRunWizard() {
cy += btnH; cy += btnH;
} }
cy += cardPad; } // if (!isCollapsed)
// Lock card height to the tallest content ever seen
float& card0MaxH = wizardUi.card0_max_h;
card0MaxH = std::max(card0MaxH, cy - card0Top);
card0Bot = card0Top + card0MaxH;
// Card 0 finalization deferred until after cards 1+2 are sized cy += cardPad;
// Lock card height to the tallest content ever seen (but not when collapsed)
float& card0MaxH = wizardUi.card0_max_h;
if (isCollapsed) {
// Completed: finalize immediately as a compact pill (do not stretch to the
// right column height, and skip the deferred stretch below).
card0Bot = card0Top + (cy - card0Top);
finalizeCard(leftX, colW, card0Top, card0Bot, state);
} else {
card0MaxH = std::max(card0MaxH, cy - card0Top);
card0Bot = card0Top + card0MaxH;
// Card 0 finalization deferred until after cards 1+2 are sized
}
} }
@@ -1447,7 +1472,9 @@ void App::renderFirstRunWizard() {
} }
// --- Deferred Card 0 finalization: match right column total height --- // --- Deferred Card 0 finalization: match right column total height ---
{ // Only for the focused/upcoming Appearance card; a completed one was already finalized
// above as a compact pill and must not be re-stretched.
if (cardState(0) != 2) {
float rightColBot = card2Bot; float rightColBot = card2Bot;
if (rightColBot > card0Bot) card0Bot = rightColBot; if (rightColBot > card0Bot) card0Bot = rightColBot;
finalizeCard(leftX, colW, card0Top, card0Bot, cardState(0)); finalizeCard(leftX, colW, card0Top, card0Bot, cardState(0));

View File

@@ -1640,7 +1640,10 @@ inline bool BeginOverlayDialog(const OverlayDialogSpec& spec)
// the measuring frame (its geometry is a placeholder; the whole card is hidden until centered). // the measuring frame (its geometry is a placeholder; the whole card is hidden until centered).
if (!floating && !hideForMeasure) { if (!floating && !hideForMeasure) {
GlassPanelSpec cardGlass; GlassPanelSpec cardGlass;
cardGlass.rounding = 16.0f; cardGlass.fillAlpha = 35; cardGlass.borderAlpha = 50; cardGlass.borderWidth = 1.0f; // Fill/border alpha govern every overlay dialog's card boundary — kept well above the default
// glass panel so the card reads as a distinct surface over busy backdrops (tx lists, mining
// tiles, chat) while staying translucent rather than opaque.
cardGlass.rounding = 16.0f; cardGlass.fillAlpha = 60; cardGlass.borderAlpha = 90; cardGlass.borderWidth = 1.0f;
DrawGlassPanel(dl, cardMin, cardMax, cardGlass); DrawGlassPanel(dl, cardMin, cardMax, cardGlass);
} }

View File

@@ -169,9 +169,9 @@ public:
} }
if (amountValid && newFromBal < 1e-9) { if (amountValid && newFromBal < 1e-9) {
ImGui::Spacing(); ImGui::Spacing();
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(Warning())); // Full-balance send: same warning-icon treatment as the de-shielding header above,
ImGui::TextWrapped("%s", TR("sends_full_balance_warning")); // so this stakes-bearing line reads as distinct from the neutral preview text.
ImGui::PopStyleColor(); DialogWarningHeader(TR("sends_full_balance_warning"));
} }
// Buttons // Buttons

View File

@@ -207,6 +207,28 @@ static bool isShieldedAddr(const std::string& a) {
return !a.empty() && a[0] == 'z'; return !a.empty() && a[0] == 'z';
} }
// Width-aware middle-ellipsis truncation (mirrors the add/edit dialog's local fitMiddle lambda, but
// file-scope so the Cards/List rows can share it too): keeps head + tail, shrinking symmetrically
// until the rendered width fits maxW. minFront/minBack are the schema-configured floor — below that
// the fixed-length util::truncateMiddle result is used instead, so very cramped rows still read as
// "front...back" rather than collapsing to a near-empty stub. Addresses are ASCII, so byte-wise
// trimming is safe.
static std::string truncateAddressToWidth(const std::string& s, ImFont* f, float size, float maxW,
int minFront, int minBack) {
const std::string floor = util::truncateMiddle(s, minFront, minBack);
auto w = [&](const std::string& t){ return f->CalcTextSizeA(size, FLT_MAX, 0, t.c_str()).x; };
if (w(s) <= maxW) return s; // fits in full — no truncation needed at all
if (maxW <= 0.0f) return floor; // no room to measure against — fall back to the floor
const std::string ell = "\xE2\x80\xA6";
size_t head = s.size() / 2, tail = s.size() - head;
while (head + tail > static_cast<size_t>(minFront + minBack)) {
std::string cand = s.substr(0, head) + ell + s.substr(s.size() - tail);
if (w(cand) <= maxW) return cand;
if (head >= tail) --head; else --tail;
}
return floor; // couldn't fit even at the configured floor — use the fixed-length result
}
// Accent colour for a contact's address type (Z = shielded/green, T = transparent/amber), tuned per // Accent colour for a contact's address type (Z = shielded/green, T = transparent/amber), tuned per
// theme. File-scope so both the list rows and the edit-dialog preview share one source of truth. // theme. File-scope so both the list rows and the edit-dialog preview share one source of truth.
static ImU32 contactTypeColor(bool shielded, bool light) { static ImU32 contactTypeColor(bool shielded, bool light) {
@@ -1219,10 +1241,13 @@ void RenderContactsTab(App* app)
dl->AddText(lblF, lblSz, ImVec2(tx, ty), material::OnSurface(), entry.label.c_str()); dl->AddText(lblF, lblSz, ImVec2(tx, ty), material::OnSurface(), entry.label.c_str());
dl->PopClipRect(); dl->PopClipRect();
// Un-collapse to the full address on hover (clipped to the text column so it never // Un-collapse to the full address on hover (clipped to the text column so it never
// runs under the trailing actions); middle-truncated otherwise. // runs under the trailing actions); otherwise middle-truncated to FIT the actual text
// column width (tx..textMaxX) rather than a fixed char count — wide rows show more of
// the address instead of leaving a dead gap before the action-icon cluster.
std::string addr = rowHovered std::string addr = rowHovered
? entry.address ? entry.address
: util::truncateMiddle(entry.address, addrFrontLbl.truncate, addrBackLbl.truncate); : truncateAddressToWidth(entry.address, adrF, adrSz, textMaxX - tx,
addrFrontLbl.truncate, addrBackLbl.truncate);
dl->PushClipRect(ImVec2(tx, mn.y), ImVec2(textMaxX, mx.y), true); dl->PushClipRect(ImVec2(tx, mn.y), ImVec2(textMaxX, mx.y), true);
dl->AddText(adrF, adrSz, ImVec2(tx, ty + lblSz + 3.0f * dp), dl->AddText(adrF, adrSz, ImVec2(tx, ty + lblSz + 3.0f * dp),
material::OnSurfaceMedium(), addr.c_str()); material::OnSurfaceMedium(), addr.c_str());

View File

@@ -620,7 +620,16 @@ static void renderChainStats(App* app, float availWidth) {
ImVec2(cardMin.x + pad, cardMin.y + pad * 0.5f), Primary(), TR("explorer_chain_stats")); ImVec2(cardMin.x + pad, cardMin.y + pad * 0.5f), Primary(), TR("explorer_chain_stats"));
drawStatusPill(cardMin, cardW); drawStatusPill(cardMin, cardW);
float labelY = cardMin.y + pad * 0.5f + headerH + Layout::spacingLg(); // Distribute the two stat blocks (Height, Best Block) evenly through the
// content region so the card matches the density of the sibling 2x2 metric
// grid instead of pinning Height to the top and Best Block to the bottom
// edge with a large empty gap between them.
float contentTop = cardMin.y + pad * 0.5f + headerH;
float contentBottom = cardMax.y - pad;
float blockGap = std::max(Layout::spacingMd(),
(contentBottom - contentTop - heroLineH - hashLineH) / 3.0f);
float labelY = contentTop + blockGap;
dl->AddText(capFont, capFont->LegacySize, dl->AddText(capFont, capFont->LegacySize,
ImVec2(cardMin.x + pad, labelY), OnSurfaceMedium(), TR("explorer_block_height")); ImVec2(cardMin.x + pad, labelY), OnSurfaceMedium(), TR("explorer_block_height"));
@@ -640,7 +649,7 @@ static void renderChainStats(App* app, float availWidth) {
ImVec2(cardMin.x + pad + barW * progress, barY + barH), WithAlpha(Warning(), 180), barH * 0.5f); ImVec2(cardMin.x + pad + barW * progress, barY + barH), WithAlpha(Warning(), 180), barH * 0.5f);
} }
float hashLabelY = cardMax.y - pad - hashLineH; float hashLabelY = labelY + heroLineH + blockGap;
dl->AddText(capFont, capFont->LegacySize, dl->AddText(capFont, capFont->LegacySize,
ImVec2(cardMin.x + pad, hashLabelY), OnSurfaceMedium(), TR("peers_best_block")); ImVec2(cardMin.x + pad, hashLabelY), OnSurfaceMedium(), TR("peers_best_block"));

View File

@@ -2136,11 +2136,29 @@ static void mktDrawPortfolio(const MktCtx& cx)
float rowGap = pfRowGapFor(style, mktDp); float rowGap = pfRowGapFor(style, mktDp);
float rowsH = std::max(rowH, portfolioH - pfSummaryH); float rowsH = std::max(rowH, portfolioH - pfSummaryH);
// Anchor the bottom of the portfolio: the group list otherwise ends at its content height,
// leaving un-anchored dead space between the last row and the content-area bottom. Grow the
// rows region to fill the remaining vertical space (down to the scroll area's bottom, less a
// small margin) and wrap it in a contained glass panel — so it reads as one framed table with
// a real bottom edge (like Explorer's block-list card) instead of trailing off into nothing.
// The empty state then centres in this full-height panel rather than in a one-row band.
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMin.y + pfSummaryH)); ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMin.y + pfSummaryH));
float floorH = ImGui::GetContentRegionAvail().y - gap; // to the scroll bottom, keep a margin
float panelH = std::max(rowsH, floorH);
{
ImVec2 pMin(cardMin.x, cardMin.y + pfSummaryH);
ImVec2 pMax(rightEdge, pMin.y + panelH);
GlassPanelSpec pg;
pg.rounding = Layout::glassRounding();
pg.fillAlpha = 12; pg.borderAlpha = 24; // faint container: frames without competing with the per-row cards
DrawGlassPanel(dl, pMin, pMax, pg);
}
// Flush-left child (no window padding) so rows align with the summary; a scrollbar appears // Flush-left child (no window padding) so rows align with the summary; a scrollbar appears
// only when the visible groups overflow the bounded height. // only when the visible groups overflow the bounded height. NoBackground so the contained
// glass panel drawn above is the sole surface — otherwise the opaque ChildBg (non-acrylic
// mode) would paint over the panel fill + border.
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0)); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0));
ImGui::BeginChild("##pfRows", ImVec2(availWidth, rowsH), false); ImGui::BeginChild("##pfRows", ImVec2(availWidth, panelH), false, ImGuiWindowFlags_NoBackground);
ImGui::PopStyleVar(); ImGui::PopStyleVar();
// Zero item-spacing INSIDE the child: each row emits an InvisibleButton + a gap Dummy, and // Zero item-spacing INSIDE the child: each row emits an InvisibleButton + a gap Dummy, and
// ImGui's default ItemSpacing.y between those items would inflate the content past rowsH and // ImGui's default ItemSpacing.y between those items would inflate the content past rowsH and

View File

@@ -362,9 +362,14 @@ static void RenderRecentReceived(const AddressInfo& /* addr */,
if (tx.type != "receive" && tx.type != "mined") continue; if (tx.type != "receive" && tx.type != "mined") continue;
recvs.push_back(&tx); recvs.push_back(&tx);
} }
if (recvs.size() > 4) recvs.resize(4); // show only the 4 most recent (newest-first)
// Grow the list to fill the dead space beneath the (fixed-height) receive card:
// fit as many newest-first rows as the remaining region can show, instead of a
// fixed 4. The child scrolls if the real history exceeds what fits, so nothing is
// lost. A floor of 4 keeps the section substantial when the region is short.
float listH = std::max(rowH, ImGui::GetContentRegionAvail().y); float listH = std::max(rowH, ImGui::GetContentRegionAvail().y);
size_t maxRows = std::max<size_t>(4, (size_t)std::floor(listH / rowH));
if (recvs.size() > maxRows) recvs.resize(maxRows); // newest-first
ImGui::BeginChild("##RecentReceivedRows", ImVec2(width, listH), false, ImGui::BeginChild("##RecentReceivedRows", ImVec2(width, listH), false,
ImGuiWindowFlags_NoBackground); ImGuiWindowFlags_NoBackground);
@@ -372,8 +377,9 @@ static void RenderRecentReceived(const AddressInfo& /* addr */,
char buf[64]; char buf[64];
if (recvs.empty()) { if (recvs.empty()) {
ImGui::SetCursorPosY(Layout::spacingMd()); // Fill the empty canvas with a centered material empty-state (icon + title)
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("no_recent_receives")); // rather than a lone left-aligned caption stranded at the top of dead space.
material::DrawEmptyState(ICON_MD_CALL_RECEIVED, TR("no_recent_receives"));
ImGui::EndChild(); ImGui::EndChild();
return; return;
} }

View File

@@ -1123,9 +1123,14 @@ static void RenderRecentSends(const WalletState& state, float width, ImFont* cap
if (tx.type != "send" && tx.type != "shield") continue; if (tx.type != "send" && tx.type != "shield") continue;
sends.push_back(&tx); sends.push_back(&tx);
} }
if (sends.size() > 4) sends.resize(4); // show only the 4 most recent (newest-first)
// Grow the list to fill the dead space beneath the (fixed-height) compose card:
// fit as many newest-first rows as the remaining region can show, instead of a
// fixed 4. The child scrolls if the real history exceeds what fits, so nothing is
// lost. A floor of 4 keeps the section substantial when the region is short.
float listH = std::max(rowH, ImGui::GetContentRegionAvail().y); float listH = std::max(rowH, ImGui::GetContentRegionAvail().y);
size_t maxRows = std::max<size_t>(4, (size_t)std::floor(listH / rowH));
if (sends.size() > maxRows) sends.resize(maxRows); // newest-first
ImGui::BeginChild("##RecentSendRows", ImVec2(width, listH), false, ImGui::BeginChild("##RecentSendRows", ImVec2(width, listH), false,
ImGuiWindowFlags_NoBackground); ImGuiWindowFlags_NoBackground);
@@ -1133,8 +1138,9 @@ static void RenderRecentSends(const WalletState& state, float width, ImFont* cap
char buf[64]; char buf[64];
if (sends.empty()) { if (sends.empty()) {
ImGui::SetCursorPosY(Layout::spacingMd()); // Fill the empty canvas with a centered material empty-state (icon + title)
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("send_no_recent")); // rather than a lone left-aligned caption stranded at the top of dead space.
material::DrawEmptyState(ICON_MD_CALL_MADE, TR("send_no_recent"));
ImGui::EndChild(); ImGui::EndChild();
return; return;
} }

View File

@@ -146,7 +146,10 @@ void ShieldDialog::render(App* app)
ImGui::Spacing(); ImGui::Spacing();
// Fee // Fee + UTXO limit share one row (two columns) to tighten vertical rhythm.
float pairColX = ImGui::GetContentRegionAvail().x * 0.5f;
// Fee (left column)
ImGui::Text("%s", TR("fee_label")); ImGui::Text("%s", TR("fee_label"));
ImGui::SetNextItemWidth(feeInput.width * Layout::dpiScale()); ImGui::SetNextItemWidth(feeInput.width * Layout::dpiScale());
ImGui::InputDouble("##Fee", &s_fee, 0.0001, 0.001, "%.8f"); ImGui::InputDouble("##Fee", &s_fee, 0.0001, 0.001, "%.8f");
@@ -154,17 +157,19 @@ void ShieldDialog::render(App* app)
if (s_fee > 1.0) s_fee = 1.0; // guard a fat-fingered huge fee (mirrors utxo clamp) if (s_fee > 1.0) s_fee = 1.0; // guard a fat-fingered huge fee (mirrors utxo clamp)
ImGui::SameLine(); ImGui::SameLine();
ImGui::TextDisabled("DRGX"); ImGui::TextDisabled("DRGX");
ImGui::Spacing();
// UTXO limit // UTXO limit (right column) — hint drops under the input (rather than beside it) since
// "Max UTXOs per operation" is too long to share the narrower half-width column with "DRGX".
ImGui::SameLine(pairColX);
ImGui::BeginGroup();
ImGui::Text("%s", TR("shield_utxo_limit")); ImGui::Text("%s", TR("shield_utxo_limit"));
ImGui::SetNextItemWidth(utxoInput.width * Layout::dpiScale()); ImGui::SetNextItemWidth(utxoInput.width * Layout::dpiScale());
ImGui::InputInt("##Limit", &s_utxo_limit); ImGui::InputInt("##Limit", &s_utxo_limit);
ImGui::SameLine();
ImGui::TextDisabled("%s", TR("shield_max_utxos"));
if (s_utxo_limit < 1) s_utxo_limit = 1; if (s_utxo_limit < 1) s_utxo_limit = 1;
if (s_utxo_limit > 100) s_utxo_limit = 100; if (s_utxo_limit > 100) s_utxo_limit = 100;
material::Type().textColored(material::TypeStyle::Caption, material::OnSurfaceMedium(),
TR("shield_max_utxos"));
ImGui::EndGroup();
ImGui::Spacing(); ImGui::Spacing();

View File

@@ -208,6 +208,14 @@ void ValidateAddressDialog::render(App* app)
} }
} else if (!app->isConnected()) { } else if (!app->isConnected()) {
ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.0f, 1.0f), "%s", TR("not_connected")); ImGui::TextColored(ImVec4(1.0f, 0.6f, 0.0f, 1.0f), "%s", TR("not_connected"));
} else {
// Pre-interaction placeholder: keeps the results area from reading as a large
// dead gap between the Validate/Paste row and the Close button until a result exists.
ImGui::Spacing();
float cw = ImGui::GetContentRegionAvail().x;
ImVec2 ts = ImGui::CalcTextSize(TR("validate_results_placeholder"));
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + (cw - ts.x) * 0.5f);
ImGui::TextDisabled("%s", TR("validate_results_placeholder"));
} }
// Close button at bottom — centered via the shared footer helper (no separator, matching // Close button at bottom — centered via the shared footer helper (no separator, matching

View File

@@ -71,9 +71,12 @@ public:
const int kMaxVisibleRows = 7; const int kMaxVisibleRows = 7;
ImFont* nameFont = Type().subtitle1(); ImFont* nameFont = Type().subtitle1();
ImFont* metaFont = Type().caption(); ImFont* metaFont = Type().caption();
// The list is always sized to its MAX height (kMaxVisibleRows) for a consistent modal size — it // Size the list to the ACTUAL wallet count so a few wallets sit tight against the controls below
// does not shrink to fit a few wallets; fewer rows leave empty space, more than 7 scroll. // instead of stranding ~200px of empty rows before the create/scan prompts; cap at kMaxVisibleRows
const int visRows = kMaxVisibleRows; // (more than that scrolls within the cap). An empty list keeps two rows of height so the centered
// empty-state hint (drawn inside the list child) still has room to show.
const int numWallets = (int)s_rows.size();
const int visRows = numWallets <= 0 ? 2 : std::min(numWallets, kMaxVisibleRows);
const float cardPadY = Layout::spacingMd(); // roomier cards (was spacingSm) const float cardPadY = Layout::spacingMd(); // roomier cards (was spacingSm)
const float walRowH = cardPadY * 2.0f + nameFont->LegacySize + Layout::spacingSm() + metaFont->LegacySize; const float walRowH = cardPadY * 2.0f + nameFont->LegacySize + Layout::spacingSm() + metaFont->LegacySize;
const float cardGap = Layout::spacingMd(); // more breathing room between wallet cards const float cardGap = Layout::spacingMd(); // more breathing room between wallet cards
@@ -421,9 +424,10 @@ public:
ImGui::Dummy(ImVec2(rowW, walRowH)); ImGui::Dummy(ImVec2(rowW, walRowH));
if (i + 1 < s_rows.size()) ImGui::Dummy(ImVec2(rowW, cardGap)); if (i + 1 < s_rows.size()) ImGui::Dummy(ImVec2(rowW, cardGap));
} }
// Empty-state nudge: the list sits at a fixed max height, so a handful of wallets leave blank // Empty-state nudge: the list is now sized to the actual wallet count, so a populated list has
// space below. When there's real room to spare, fill it with a subtle centered hint (a folder // no room to spare and this draws nothing (the cards butt up against the controls below). It
// glyph + one line) instead of dead space; it's purely decorative — the actions live below. // fires only when the list is EMPTY (the reserved rows leave room): a subtle centered hint (a
// folder glyph + one line) instead of a blank box; purely decorative — the actions live below.
{ {
const float remainY = ImGui::GetContentRegionAvail().y; const float remainY = ImGui::GetContentRegionAvail().y;
if (remainY > walRowH * 1.6f) { if (remainY > walRowH * 1.6f) {

View File

@@ -2242,6 +2242,7 @@ void I18n::loadBuiltinEnglish()
strings_["validate_not_mine"] = "Not owned by this wallet"; strings_["validate_not_mine"] = "Not owned by this wallet";
strings_["validate_ownership"] = "Ownership:"; strings_["validate_ownership"] = "Ownership:";
strings_["validate_results"] = "Results:"; strings_["validate_results"] = "Results:";
strings_["validate_results_placeholder"] = "Results will appear here";
strings_["validate_shielded_type"] = "Shielded (z-address)"; strings_["validate_shielded_type"] = "Shielded (z-address)";
strings_["validate_status"] = "Status:"; strings_["validate_status"] = "Status:";
strings_["validate_title"] = "Validate Address"; strings_["validate_title"] = "Validate Address";