feat(settings): two-column page masonry for the section cards (A)

Render the section cards in two columns when the page is wide (>1000px):
left = Theme, Wallet, Node & Security; right = Explorer, Chat & Contacts,
About; Debug spans full width below. Driven by shadowing `availWidth` to the
column width for the whole card region and floating the right column with
Indent() (a one-shot cursor set can't hold a column across line-advances);
each card's own responsive gates collapse their internal columns at the
narrower width. Collapses to one column below the breakpoint.

Fix the half-width overflows a parallel audit surfaced (cards previously only
ever rendered full-width):
- WALLET Tools & Actions: merge-to-address was a 3rd button on a 2-button row
  (unconditional SameLine) — flip the separators so 2-per-row pairs cleanly.
- EXPLORER: wrap the Block Explorer button to its own row when the checkboxes
  + button won't fit (measured, locale-safe).
- NODE & SECURITY: wrap "Remove Encryption" below when the encrypted row's
  three buttons + status don't fit the column.
- ABOUT: 2x2 button grid instead of 1x4 so labels don't clip.
- THEME: reserve the real gaps + custom-skin "*" marker in the theme combo
  width so Refresh doesn't spill past the edge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-21 19:15:21 -05:00
parent e393b0d847
commit fdf2502ca8

View File

@@ -643,6 +643,27 @@ void RenderSettingsPage(App* app) {
ImFont* body2 = Type().body2();
ImFont* sub1 = Type().subtitle1();
// ====================================================================
// Masonry: render the section cards in two columns when the page is wide
// enough. Left column: Theme, Wallet, Node & Security. Right column:
// Explorer, Chat & Contacts, About. Debug spans full width below. We shadow
// `availWidth` to the column width for the whole card region (every card
// reads it), and float the right column with Indent() (a one-shot cursor
// set can't hold a column across line-advances). Each card's own responsive
// gates collapse their internal columns at this narrower width.
// ====================================================================
const float msFullWidth = availWidth;
const float msColGap = Layout::spacingXl();
const bool msTwoCol = availWidth > 1000.0f * Layout::dpiScale();
const float msColW = msTwoCol ? (msFullWidth - msColGap) * 0.5f : msFullWidth;
const ImVec2 msColTop = ImGui::GetCursorPos();
float msLeftBottomY = 0.0f;
if (msTwoCol) {
availWidth = msColW;
inputW = std::max(S.drawElement("components.settings-page", "input-min-width").size,
availWidth - labelW - pad * 2);
}
// ====================================================================
// THEME & LANGUAGE — card (draw-first approach; avoids ChannelsSplit
// which breaks BeginCombo popup rendering in some ImGui versions)
@@ -1037,8 +1058,11 @@ void RenderSettingsPage(App* app) {
ImGui::TextUnformatted(TR("theme"));
ImGui::SameLine(labelW);
// Reserve the real gaps (default ItemSpacing, not spacingSm) plus the
// custom-skin "*" marker so the Refresh button never spills past the edge.
float themeComboW = std::max(S.drawElement("components.settings-page", "theme-combo-min-width").size,
availWidth - pad * 2 - labelW - refreshBtnW - Layout::spacingSm());
availWidth - pad * 2 - labelW - refreshBtnW - ImGui::GetStyle().ItemSpacing.x
- (active_is_custom ? (ImGui::GetStyle().ItemSpacing.x + ImGui::CalcTextSize("*").x) : 0.0f));
ImGui::SetNextItemWidth(themeComboW);
if (ImGui::BeginCombo("##Theme", active_preview.c_str())) {
renderThemeComboPopup();
@@ -1377,11 +1401,11 @@ void RenderSettingsPage(App* app) {
if (TactileButton(TR("settings_shield_mining"), ImVec2(bw, 0), S.resolveFont("button")))
ShieldDialog::show(ShieldDialog::Mode::ShieldCoinbase);
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_shield_mining"));
ImGui::SameLine(0, btnSpacing);
if (btnsPerRow >= 3) { ImGui::SameLine(0, btnSpacing); } else { ImGui::Dummy(ImVec2(0, Layout::spacingXs())); }
if (TactileButton(TR("settings_merge_to_address"), ImVec2(bw, 0), S.resolveFont("button")))
ShieldDialog::show(ShieldDialog::Mode::MergeToAddress);
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_merge"));
if (btnsPerRow >= 3) { ImGui::SameLine(0, btnSpacing); } else { ImGui::Dummy(ImVec2(0, Layout::spacingXs())); }
ImGui::SameLine(0, btnSpacing);
if (TactileButton(TR("settings_clear_ztx"), ImVec2(bw, 0), S.resolveFont("button"))) {
s_settingsState.confirm_clear_ztx = true;
}
@@ -1498,6 +1522,9 @@ void RenderSettingsPage(App* app) {
// also gets the RPC-backed encrypt/change/lock/remove block, includeRpcEncrypt).
// `secX` is the row start X (== content-column left); `secColW` its width.
auto renderSecuritySection = [&](float secX, float secColW, bool includeRpcEncrypt) {
// Narrow (two-column) card: the encrypt buttons, auto-lock and PIN
// controls can't all share one row, so wrap groups onto fresh rows.
const bool secNarrow = secColW < 740.0f * Layout::dpiScale();
// Encrypt / Change passphrase / Lock / Remove — RPC-backed, full-node only.
// In lite the wallet's encryption controls live in the WALLET block above.
if (includeRpcEncrypt) {
@@ -1535,8 +1562,14 @@ void RenderSettingsPage(App* app) {
ImGui::SameLine(0, Layout::spacingXs());
ImGui::TextColored(ImVec4(0.3f,1.0f,0.5f,1.0f), "%s", TR("settings_unlocked"));
}
// Remove Encryption button — trails the row.
ImGui::SameLine(0, Layout::spacingMd());
// Remove Encryption button — trails the row, or wraps below
// when the column is too narrow for three buttons + status.
if (secNarrow) {
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
ImGui::SetCursorScreenPos(ImVec2(secX, ImGui::GetCursorScreenPos().y));
} else {
ImGui::SameLine(0, Layout::spacingMd());
}
if (TactileButton(TR("settings_remove_encryption"), ImVec2(secBtnW, 0), S.resolveFont("button")))
app->showDecryptDialog();
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_remove_encrypt"));
@@ -1558,7 +1591,7 @@ void RenderSettingsPage(App* app) {
}
// In a narrow (two-column) card the encrypt controls + auto-lock + PIN don't fit on
// one row, so wrap the auto-lock/PIN group onto its own row at the section's left edge.
const bool secWrap = includeRpcEncrypt && secColW < 740.0f * Layout::dpiScale();
const bool secWrap = includeRpcEncrypt && secNarrow;
if (includeRpcEncrypt && !secWrap) {
ImGui::SameLine(0, Layout::spacingLg());
} else {
@@ -2338,7 +2371,17 @@ void RenderSettingsPage(App* app) {
}
}
ImGui::Dummy(ImVec2(0, gap));
// End of the left column (Theme / Wallet / Node & Security). In two-column
// mode jump back to the top and indent so Explorer / Chat / About stack in
// the right column; otherwise just add the usual inter-card gap.
if (msTwoCol) {
msLeftBottomY = ImGui::GetCursorPosY();
ImGui::SetCursorPosY(msColTop.y);
ImGui::Indent(msColW + msColGap);
ImGui::SetCursorPosX(msColTop.x + msColW + msColGap);
} else {
ImGui::Dummy(ImVec2(0, gap));
}
// ====================================================================
// EXPLORER & OPTIONS — full-width card
@@ -2376,13 +2419,21 @@ void RenderSettingsPage(App* app) {
ImGui::PopFont();
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
// Row 2: Checkboxes + Block Explorer button (on one line)
// Row 2: Checkboxes + Block Explorer button. Keep the two checkboxes
// side-by-side, but wrap the Block Explorer button onto its own row when
// it won't fit the (narrow, two-column) card — measured, so it's locale-safe.
const float expRowRight = ImGui::GetCursorScreenPos().x + contentW;
ImGui::Checkbox(TrId("custom_fees", "custom_fees").c_str(), &s_settingsState.allow_custom_fees);
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_custom_fees"));
ImGui::SameLine(0, Layout::spacingLg());
ImGui::Checkbox(TrId("fetch_prices", "fetch_prices").c_str(), &s_settingsState.fetch_prices);
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_fetch_prices"));
ImGui::SameLine(0, Layout::spacingLg());
const float expBtnW = ImGui::CalcTextSize(TR("block_explorer")).x
+ ImGui::GetStyle().FramePadding.x * 2.0f + Layout::spacingLg();
if (ImGui::GetItemRectMax().x + expBtnW <= expRowRight)
ImGui::SameLine(0, Layout::spacingLg());
else
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
if (TactileButton(TR("block_explorer"), ImVec2(0, 0), S.resolveFont("button"))) {
util::Platform::openUrl("https://explorer.dragonx.is");
}
@@ -2488,7 +2539,11 @@ void RenderSettingsPage(App* app) {
}
{
float fullContentW = availWidth - pad * 2;
float aboutBtnW = (fullContentW - Layout::spacingMd() * 3) / 4.0f;
// 2x2 grid in the narrow (two-column) card so labels don't clip;
// one 1x4 row at full width.
const bool aboutGrid = fullContentW < 720.0f * Layout::dpiScale();
float aboutBtnW = aboutGrid ? (fullContentW - Layout::spacingMd()) / 2.0f
: (fullContentW - Layout::spacingMd() * 3) / 4.0f;
if (TactileButton(TrId("website", "about_website").c_str(), ImVec2(aboutBtnW, 0), S.resolveFont("button"))) {
util::Platform::openUrl("https://dragonx.is");
@@ -2499,7 +2554,7 @@ void RenderSettingsPage(App* app) {
util::Platform::openUrl("https://git.dragonx.is/dragonx/ObsidianDragon/issues");
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_report_bug"));
ImGui::SameLine(0, Layout::spacingMd());
if (aboutGrid) ImGui::Dummy(ImVec2(0, Layout::spacingXs())); else ImGui::SameLine(0, Layout::spacingMd());
if (TactileButton(TrId("save_settings", "about_save").c_str(), ImVec2(aboutBtnW, 0), S.resolveFont("button"))) {
saveSettingsPageState(app->settings());
Notifications::instance().success("Settings saved");
@@ -2541,6 +2596,15 @@ void RenderSettingsPage(App* app) {
ImGui::Dummy(ImVec2(availWidth, 0));
}
// End of the right column. Un-indent, drop below the taller of the two
// columns, and restore full width so Debug spans the page.
if (msTwoCol) {
ImGui::Unindent(msColW + msColGap);
const float msRightBottomY = ImGui::GetCursorPosY();
ImGui::SetCursorPosX(msColTop.x);
ImGui::SetCursorPosY(std::max(msLeftBottomY, msRightBottomY));
availWidth = msFullWidth;
}
ImGui::Dummy(ImVec2(0, gap));
// ====================================================================