fix(send/receive): unify card width, justify receive footer, fix recipient-row button height/clip/glyph

Send and Receive are now consistent in layout, and the Send recipient row's buttons
render correctly.

Card envelope (Send ⇄ Receive consistency):
- Add Layout::mainComposeCardBox(availW) — a single shared source for the compose card's
  width + centering (fill the available column up to content-max-width, then center). Both
  tabs derive their card from it, so they can't drift again. Previously Send capped at
  760dp and Receive at 860dp, so the Send card rendered ~150px narrower on any window wider
  than ~860dp; now they fill available width identically.

Receive:
- Justify the footer buttons edge-to-edge (equal shares over the live count) instead of
  left-clustering with dead space, matching Send's full-width footer rhythm.
- Build the address-dropdown preview to the combo's real pixel width so the trailing
  balance ("— 12.00000000 DRGX") no longer hard-clips at 150% (was char-count truncation).

Send recipient row (input | Paste | contacts-icon):
- Pin the contacts icon button to the frame height so the larger iconMed font doesn't
  auto-size it taller than Paste/the input.
- Reserve the real ItemSpacing.x gaps (not the smaller spacingSm token) so the row no
  longer overshoots the card and clips the icon's right border.

draw_helpers (root cause, app-wide):
- TactileButton's icon path measured/drew the label INCLUDING the "##id" suffix (which
  CalcTextSizeA/AddText don't strip the way ImGui's text render does), shoving the glyph
  off-center-left. Strip at "##" before measuring/drawing. Corrects any icon button that
  passes an explicit size and a "##id" label; no-op for labels without "##".

Verified via headless sweeps at 1.0x and 1.5x, plus a real 3800px-wide render (both cards
byte-identical at L=1174/R=2773). ctest 1/1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-19 16:10:02 -05:00
parent 8778398d31
commit b37d3d97b6
4 changed files with 68 additions and 23 deletions

View File

@@ -179,6 +179,17 @@ inline float kSidePanelWidthRatio() { return schema::UI().drawElement("panels",
// [layout] content-max-width; default is generous so data-dense screens stay comfortable. // [layout] content-max-width; default is generous so data-dense screens stay comfortable.
inline float kContentMaxWidth() { return schema::UI().drawElement("layout", "content-max-width").sizeOr(1600.0f) * dpiScale(); } inline float kContentMaxWidth() { return schema::UI().drawElement("layout", "content-max-width").sizeOr(1600.0f) * dpiScale(); }
// Shared compose-card envelope for the Send + Receive tabs (and any tab wanting the same box): fill the
// available column up to the content-max-width cap, then center the leftover as margin. Both tabs MUST
// derive their card width/offset from this so the two envelopes stay byte-for-byte identical — they
// previously drifted (Send capped at 760dp, Receive at 860dp), so the Send card rendered narrower than
// Receive on any window wider than ~860dp. Returns {width, offsetX} in the same units as availW.
struct CardBox { float width; float offsetX; };
inline CardBox mainComposeCardBox(float availW) {
float w = std::min(availW, kContentMaxWidth());
return CardBox{ w, std::max(0.0f, (availW - w) * 0.5f) };
}
inline float kTableMinHeight() { return schema::UI().drawElement("panels", "table").getFloat("min-height", 150.0f) * dpiScale(); } inline float kTableMinHeight() { return schema::UI().drawElement("panels", "table").getFloat("min-height", 150.0f) * dpiScale(); }
inline float kTableHeightRatio() { return schema::UI().drawElement("panels", "table").getFloat("height-ratio", 0.45f); } inline float kTableHeightRatio() { return schema::UI().drawElement("panels", "table").getFloat("height-ratio", 0.45f); }

View File

@@ -544,11 +544,16 @@ inline bool TactileButton(const char* label, const ImVec2& size = ImVec2(0, 0),
ImVec2 bMin = ImGui::GetItemRectMin(); ImVec2 bMin = ImGui::GetItemRectMin();
ImVec2 bMax = ImGui::GetItemRectMax(); ImVec2 bMax = ImGui::GetItemRectMax();
// For icon fonts, manually draw centered icon after getting button rect // For icon fonts, manually draw centered icon after getting button rect. Measure/draw only the
// VISIBLE label (up to the "##id" separator): CalcTextSizeA/AddText don't strip "##" the way
// ImGui's own text render does, so an id suffix like "##pickContact" would inflate textSz and
// shove the glyph left off-center (and try to draw the notdef id chars).
if (isIconFont && size.x > 0 && size.y > 0) { if (isIconFont && size.x > 0 && size.y > 0) {
ImVec2 textSz = useFont->CalcTextSizeA(useFont->LegacySize, FLT_MAX, 0, label); const char* labelEnd = label;
while (*labelEnd && !(labelEnd[0] == '#' && labelEnd[1] == '#')) ++labelEnd;
ImVec2 textSz = useFont->CalcTextSizeA(useFont->LegacySize, FLT_MAX, 0, label, labelEnd);
ImVec2 textPos(bMin.x + (size.x - textSz.x) * 0.5f, bMin.y + (size.y - textSz.y) * 0.5f); ImVec2 textPos(bMin.x + (size.x - textSz.x) * 0.5f, bMin.y + (size.y - textSz.y) * 0.5f);
dl->AddText(useFont, useFont->LegacySize, textPos, ImGui::GetColorU32(ImGuiCol_Text), label); dl->AddText(useFont, useFont->LegacySize, textPos, ImGui::GetColorU32(ImGuiCol_Text), label, labelEnd);
} }
float rounding = ImGui::GetStyle().FrameRounding; float rounding = ImGui::GetStyle().FrameRounding;

View File

@@ -175,6 +175,11 @@ static void RenderAddressDropdown(App* app, float width) {
} }
} }
// Combo/button widths first — the preview truncates to the combo's real pixel width below.
float copyBtnW = std::max(schema::UI().drawElement("tabs.receive", "copy-btn-min-width").size, schema::UI().drawElement("tabs.receive", "copy-btn-width").size * Layout::hScale(width));
float newBtnW = std::max(schema::UI().drawElement("tabs.receive", "new-btn-min-width").size, schema::UI().drawElement("tabs.receive", "new-btn-width").size * Layout::hScale(width));
float dropdownW = width - copyBtnW - newBtnW - Layout::spacingSm() * 2;
// Build preview string // Build preview string
if (!app->isConnected()) { if (!app->isConnected()) {
s_source_preview = TR(app->isLiteBuild() ? "lite_no_wallet_short" : "not_connected"); s_source_preview = TR(app->isLiteBuild() ? "lite_no_wallet_short" : "not_connected");
@@ -183,18 +188,27 @@ static void RenderAddressDropdown(App* app, float width) {
const auto& addr = state.addresses[s_selected_address_idx]; const auto& addr = state.addresses[s_selected_address_idx];
bool isZ = addr.type == "shielded"; bool isZ = addr.type == "shielded";
const char* tag = isZ ? "[Z]" : "[T]"; const char* tag = isZ ? "[Z]" : "[T]";
std::string trunc = util::truncateMiddle(addr.address, // Reserve pixel room for the tag prefix and the trailing balance, then middle-truncate the
static_cast<int>(std::max(schema::UI().drawElement("tabs.receive", "addr-preview-trunc-min").size, width / schema::UI().drawElement("tabs.receive", "addr-preview-trunc-divisor").size))); // address to whatever remains — measured with the combo's own Body2 font. Char-count
snprintf(buf, sizeof(buf), "%s %s \xe2\x80\x94 %.8f %s", // truncation kept MORE chars as the column widened, so at 150% the scaled font overflowed
tag, trunc.c_str(), addr.balance, DRAGONX_TICKER); // and the combo hard-clipped "— 12.00000000 DRGX" to "— 1"; measuring in pixels keeps the
// balance visible at any scale.
ImFont* comboFont = Type().getFont(TypeStyle::Body2);
float comboFontSz = comboFont->LegacySize;
char prefix[16]; snprintf(prefix, sizeof(prefix), "%s ", tag);
char suffix[64]; snprintf(suffix, sizeof(suffix), " \xe2\x80\x94 %.8f %s", addr.balance, DRAGONX_TICKER);
float fixedW = comboFont->CalcTextSizeA(comboFontSz, FLT_MAX, 0.0f, prefix).x
+ comboFont->CalcTextSizeA(comboFontSz, FLT_MAX, 0.0f, suffix).x;
// Combo interior = dropdownW minus its dropdown-arrow button (~frame height) and both frame paddings.
float addrBudget = dropdownW - ImGui::GetFrameHeight() - ImGui::GetStyle().FramePadding.x * 2.0f - fixedW;
if (addrBudget < 24.0f) addrBudget = 24.0f; // floor: truncate to a stub rather than overflow
std::string trunc = material::TruncateToWidth(addr.address, comboFont, comboFontSz, addrBudget);
snprintf(buf, sizeof(buf), "%s%s%s", prefix, trunc.c_str(), suffix);
s_source_preview = buf; s_source_preview = buf;
} else { } else {
s_source_preview = TR("select_receiving_address"); s_source_preview = TR("select_receiving_address");
} }
float copyBtnW = std::max(schema::UI().drawElement("tabs.receive", "copy-btn-min-width").size, schema::UI().drawElement("tabs.receive", "copy-btn-width").size * Layout::hScale(width));
float newBtnW = std::max(schema::UI().drawElement("tabs.receive", "new-btn-min-width").size, schema::UI().drawElement("tabs.receive", "new-btn-width").size * Layout::hScale(width));
float dropdownW = width - copyBtnW - newBtnW - Layout::spacingSm() * 2;
ImGui::SetNextItemWidth(dropdownW); ImGui::SetNextItemWidth(dropdownW);
ImGui::PushFont(Type().getFont(TypeStyle::Body2)); ImGui::PushFont(Type().getFont(TypeStyle::Body2));
if (ImGui::BeginCombo("##RecvAddr", s_source_preview.c_str())) { if (ImGui::BeginCombo("##RecvAddr", s_source_preview.c_str())) {
@@ -584,12 +598,14 @@ void RenderReceiveTab(App* app)
// ================================================================ // ================================================================
{ {
// Cap + center the card so the address/amount column stops stretching while the // Cap + center the card so the address/amount column stops stretching while the
// QR plateaus. cardW never exceeds formW (only shrinks); leftover becomes margin. // QR plateaus. Shared with the Send tab via mainComposeCardBox() so the two card
// envelopes are identical (they must never drift in width/position again).
// The RECENT RECEIVED list below stays on the uncapped formW (handled separately). // The RECENT RECEIVED list below stays on the uncapped formW (handled separately).
float cardDp = Layout::dpiScale(); float cardDp = Layout::dpiScale();
float cardW = std::min(formW, 860.0f * cardDp); Layout::CardBox cardBox = Layout::mainComposeCardBox(formW);
float cardW = cardBox.width;
float cardLeftX = ImGui::GetCursorScreenPos().x; float cardLeftX = ImGui::GetCursorScreenPos().x;
float cardOffsetX = std::max(0.0f, (formW - cardW) * 0.5f); float cardOffsetX = cardBox.offsetX;
if (cardOffsetX > 0.0f) if (cardOffsetX > 0.0f)
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + cardOffsetX); ImGui::SetCursorPosX(ImGui::GetCursorPosX() + cardOffsetX);
@@ -911,7 +927,13 @@ void RenderReceiveTab(App* app)
{ {
float btnGap = Layout::spacingMd(); float btnGap = Layout::spacingMd();
float btnH = std::max(schema::UI().drawElement("tabs.receive", "action-btn-min-height").size, schema::UI().drawElement("tabs.receive", "action-btn-height").size * vScale); float btnH = std::max(schema::UI().drawElement("tabs.receive", "action-btn-min-height").size, schema::UI().drawElement("tabs.receive", "action-btn-height").size * vScale);
float otherBtnW = std::max(S.drawElement("tabs.receive", "action-btn-min-width").size, innerW * S.drawElement("tabs.receive", "action-btn-width-ratio").size); // Justify the footer edge-to-edge like Send's [Review Send][Cancel] row instead of packing
// fixed-width buttons from the left (which left a large dead gap on the right). Split innerW
// into equal shares over the live button count: 2 by default (Clear Request + Explorer),
// 4 when an amount is requested (+ Copy URI + Share).
int nBtns = (s_request_amount > 0 ? 2 : 0) + 2;
float otherBtnW = std::max(S.drawElement("tabs.receive", "action-btn-min-width").size,
(innerW - (nBtns - 1) * btnGap) / (float)nBtns);
bool firstBtn = true; bool firstBtn = true;

View File

@@ -1272,12 +1272,13 @@ void RenderSendTab(App* app)
float contentStartY = ImGui::GetCursorPosY(); float contentStartY = ImGui::GetCursorPosY();
float formAvailW = ImGui::GetContentRegionAvail().x; float formAvailW = ImGui::GetContentRegionAvail().x;
// The compose form reads best as a centered fixed-width column, not edge-to-edge. // Fill the available column up to the content-max-width cap, then center. Shared with the
// Cap the card to a readable form width and center it; the recent-sends list below // Receive tab via mainComposeCardBox() so the two card envelopes are identical in width and
// deliberately keeps the full column width (formAvailW). // position (Send previously capped at 760dp vs Receive's 860dp, so Send rendered narrower).
const float sendDp = Layout::dpiScale(); // The recent-sends list below deliberately keeps the full column width (formAvailW).
float formCardW = std::min(formAvailW, 760.0f * sendDp); Layout::CardBox formBox = Layout::mainComposeCardBox(formAvailW);
float formOffsetX = std::max(0.0f, (formAvailW - formCardW) * 0.5f); float formCardW = formBox.width;
float formOffsetX = formBox.offsetX;
float formW = formAvailW; float formW = formAvailW;
ImGui::BeginGroup(); ImGui::BeginGroup();
@@ -1367,7 +1368,10 @@ void RenderSendTab(App* app)
float pasteW = std::max(schema::UI().drawElement("tabs.send", "paste-btn-min-width").size, colW * schema::UI().drawElement("tabs.send", "paste-btn-width-ratio").size); float pasteW = std::max(schema::UI().drawElement("tabs.send", "paste-btn-min-width").size, colW * schema::UI().drawElement("tabs.send", "paste-btn-width-ratio").size);
float contactsW = ImGui::GetFrameHeight(); // compact square icon button for the contact picker float contactsW = ImGui::GetFrameHeight(); // compact square icon button for the contact picker
ImGui::PushItemWidth(colW - pasteW - contactsW - Layout::spacingSm() * 2.0f); // Reserve the TWO real SameLine gaps (each = ItemSpacing.x) between input|Paste|icon.
// Reserving spacingSm (a smaller token) under-counted the gap, so the row overshot colW by
// ~2*(ItemSpacing.x - spacingSm) and the icon's right border clipped past the card edge.
ImGui::PushItemWidth(colW - pasteW - contactsW - ImGui::GetStyle().ItemSpacing.x * 2.0f);
// Show clipboard preview as transparent overlay when paste button is hovered // Show clipboard preview as transparent overlay when paste button is hovered
bool paste_hovered = false; bool paste_hovered = false;
@@ -1425,9 +1429,12 @@ void RenderSendTab(App* app)
} }
} }
// Contact picker — pick a saved contact's address as the recipient. // Contact picker — pick a saved contact's address as the recipient. Pin the height to the
// frame height (== the input + Paste height) so the larger iconMed font doesn't auto-size
// this square button taller than its row-mates. (Passing a non-zero size also routes
// TactileButton through its precise InvisibleButton + centered-glyph path.)
ImGui::SameLine(); ImGui::SameLine();
if (material::TactileButton(ICON_MD_CONTACTS "##pickContact", ImVec2(contactsW, 0), if (material::TactileButton(ICON_MD_CONTACTS "##pickContact", ImVec2(contactsW, ImGui::GetFrameHeight()),
material::Type().iconMed())) material::Type().iconMed()))
ImGui::OpenPopup("##ContactPickerPopup"); ImGui::OpenPopup("##ContactPickerPopup");
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("send_contacts_button")); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("send_contacts_button"));