feat(contacts): tall edit dialog — fill vertical space, accent Save

The dialog was a squat auto-height box with a big dead zone below and an icon
grid that clipped at ~3 rows. Make it a fixed, tall card (up to 84% of the
viewport) whose body flexes to fill the height:

- The icon grid grows into the space — ~8–9 rows visible instead of 3.
- Notes expands to fill the left column above the now bottom-pinned Global
  checkbox, so the left side uses the height too.
- The image-mode preview circle is bigger (r44 → r56) and vertically centered,
  with glyphs scaled to it.
- Footer pins to the bottom; Save/Add is now accent-colored so the primary
  action reads above Cancel.

Notes/actionButton schema lookups that only fed the old fixed heights are gone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-15 08:29:04 -05:00
parent 1b3446e43c
commit 9bc2a0b62a

View File

@@ -156,7 +156,6 @@ void RenderContactsTab(App* app)
auto addrTable = S.table("dialogs.address-book", "address-table"); auto addrTable = S.table("dialogs.address-book", "address-table");
auto addrFrontLbl = S.label("dialogs.address-book", "address-front-label"); auto addrFrontLbl = S.label("dialogs.address-book", "address-front-label");
auto addrBackLbl = S.label("dialogs.address-book", "address-back-label"); auto addrBackLbl = S.label("dialogs.address-book", "address-back-label");
auto notesInput = S.input("dialogs.address-book", "notes-input");
auto actionBtn = S.button("dialogs.address-book", "action-button"); auto actionBtn = S.button("dialogs.address-book", "action-button");
auto& book = app->addressBook(); auto& book = app->addressBook();
@@ -228,14 +227,16 @@ void RenderContactsTab(App* app)
const char* id = isEdit ? "AddressBookEdit" : "AddressBookAdd"; const char* id = isEdit ? "AddressBookEdit" : "AddressBookAdd";
const float dp = Layout::dpiScale(); const float dp = Layout::dpiScale();
const bool light = material::IsLightTheme(); const bool light = material::IsLightTheme();
float notesH = (notesInput.height > 0 ? notesInput.height : 56.0f) * dp;
ImFont* btnFont = S.resolveFont(actionBtn.font); ImFont* btnFont = S.resolveFont(actionBtn.font);
material::OverlayDialogSpec ov; material::OverlayDialogSpec ov;
ov.title = title; ov.p_open = open; ov.title = title; ov.p_open = open;
ov.style = material::OverlayStyle::BlurFloat; ov.style = material::OverlayStyle::BlurFloat;
ov.cardWidth = 880.0f; ov.idSuffix = id; // two-column body: form (left) + avatar picker (right) ov.cardWidth = 880.0f; ov.idSuffix = id; // two-column body: form (left) + avatar picker (right)
ov.cardBottomViewportRatio = 0.92f; // Fixed, tall card — fill the vertical space so the icon grid shows many rows and the notes
// field has room, rather than a squat auto-height box with dead space below.
const float vpH = ImGui::GetMainViewport()->Size.y;
ov.cardHeight = std::min(vpH * 0.84f, 820.0f * dp) / dp; // logical; framework re-applies dp
if (material::BeginOverlayDialog(ov)) { if (material::BeginOverlayDialog(ov)) {
// End-truncate a string to fit maxW (whole UTF-8 code points), appending an ellipsis. // End-truncate a string to fit maxW (whole UTF-8 code points), appending an ellipsis.
auto fitText = [&](std::string s, ImFont* f, float maxW) { auto fitText = [&](std::string s, ImFont* f, float maxW) {
@@ -305,7 +306,10 @@ void RenderContactsTab(App* app)
ImGui::Dummy(ImVec2(0, Layout::spacingMd())); ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
// ================= Two-column body: form (left) | avatar picker (right) ================= // ================= Two-column body: form (left) | avatar picker (right) =================
const float bodyH = 300.0f * dp; // Body flexes to fill the tall card, leaving just the footer button row — so the icon grid
// and notes field grow into the vertical space instead of leaving it empty.
const float footerReserve = 44.0f * dp + Layout::spacingMd();
const float bodyH = std::max(260.0f * dp, ImGui::GetContentRegionAvail().y - footerReserve);
const float colGap = Layout::spacingLg(); const float colGap = Layout::spacingLg();
const float bodyAvail = ImGui::GetContentRegionAvail().x; const float bodyAvail = ImGui::GetContentRegionAvail().x;
const float colW = (bodyAvail - colGap) * 0.5f; const float colW = (bodyAvail - colGap) * 0.5f;
@@ -341,8 +345,15 @@ void RenderContactsTab(App* app)
ImGui::Spacing(); ImGui::Spacing();
material::LabeledInputMultiline(TR("notes_optional"), isEdit ? "##EditNotes" : "##AddNotes", // Notes grows to fill the column above the Global checkbox, which is pinned to the bottom.
s_edit_notes, sizeof(s_edit_notes), ImVec2(-1.0f, notesH)); {
float globalReserve = ImGui::GetFrameHeight() + Layout::spacingSm();
float labelLine = ImGui::GetTextLineHeightWithSpacing();
float notesFill = std::max(60.0f * dp,
ImGui::GetContentRegionAvail().y - globalReserve - labelLine);
material::LabeledInputMultiline(TR("notes_optional"), isEdit ? "##EditNotes" : "##AddNotes",
s_edit_notes, sizeof(s_edit_notes), ImVec2(-1.0f, notesFill));
}
ImGui::Spacing(); ImGui::Spacing();
ImGui::Checkbox(TR("contact_global"), &s_edit_global); ImGui::Checkbox(TR("contact_global"), &s_edit_global);
@@ -494,15 +505,20 @@ void RenderContactsTab(App* app)
ImGui::EndChild(); ImGui::EndChild();
ImGui::PopStyleColor(); ImGui::PopStyleColor();
} else { } else {
// Image: a big circular preview (or a placeholder), the filename, and choose / remove. // Image: a big circular preview (or a placeholder), the filename, and choose / remove,
// vertically centered in the (now tall) column.
const bool hasImg = s_edit_avatar.rfind("img:", 0) == 0; const bool hasImg = s_edit_avatar.rfind("img:", 0) == 0;
ImDrawList* mdl = ImGui::GetWindowDrawList(); ImDrawList* mdl = ImGui::GetWindowDrawList();
ImFont* capF = material::Type().caption(); ImFont* capF = material::Type().caption();
const float prR = 44.0f * dp; const float prR = 56.0f * dp;
ImVec2 a = ImGui::GetContentRegionAvail(); ImVec2 a = ImGui::GetContentRegionAvail();
ImVec2 origin = ImGui::GetCursorScreenPos(); ImVec2 origin = ImGui::GetCursorScreenPos();
float btnH = 40.0f * dp;
float blockH = prR * 2.0f + Layout::spacingSm()
+ (hasImg ? capF->LegacySize + Layout::spacingSm() : 0.0f) + btnH;
float topPad = std::max(Layout::spacingSm(), (a.y - blockH) * 0.32f);
float cx = origin.x + a.x * 0.5f; float cx = origin.x + a.x * 0.5f;
float cy = origin.y + Layout::spacingSm() + prR; float cy = origin.y + topPad + prR;
ImVec2 cc(cx, cy); ImVec2 cc(cx, cy);
if (hasImg) { if (hasImg) {
if (const AvatarTex* t = getAvatarTexture(s_edit_avatar.substr(4))) { if (const AvatarTex* t = getAvatarTexture(s_edit_avatar.substr(4))) {
@@ -516,17 +532,19 @@ void RenderContactsTab(App* app)
// Image set but no longer loadable (moved/deleted) — show a broken-image glyph. // Image set but no longer loadable (moved/deleted) — show a broken-image glyph.
mdl->AddCircleFilled(cc, prR, material::WithAlpha(material::ReadableError(), 35)); mdl->AddCircleFilled(cc, prR, material::WithAlpha(material::ReadableError(), 35));
mdl->AddCircle(cc, prR, material::WithAlpha(material::ReadableError(), 150), 0, 1.5f * dp); mdl->AddCircle(cc, prR, material::WithAlpha(material::ReadableError(), 150), 0, 1.5f * dp);
ImFont* gf = material::Type().iconLarge(); ImFont* gf = material::Type().iconXL();
ImVec2 gs = gf->CalcTextSizeA(gf->LegacySize, FLT_MAX, 0, ICON_MD_BROKEN_IMAGE); float gsz = prR * 0.72f;
mdl->AddText(gf, gf->LegacySize, ImVec2(cc.x - gs.x*0.5f, cc.y - gs.y*0.5f), ImVec2 gs = gf->CalcTextSizeA(gsz, FLT_MAX, 0, ICON_MD_BROKEN_IMAGE);
mdl->AddText(gf, gsz, ImVec2(cc.x - gs.x*0.5f, cc.y - gs.y*0.5f),
material::ReadableError(), ICON_MD_BROKEN_IMAGE); material::ReadableError(), ICON_MD_BROKEN_IMAGE);
} }
} else { } else {
mdl->AddCircleFilled(cc, prR, material::WithAlpha(material::OnSurface(), 18)); mdl->AddCircleFilled(cc, prR, material::WithAlpha(material::OnSurface(), 18));
mdl->AddCircle(cc, prR, material::WithAlpha(material::OnSurface(), 55), 0, 1.5f * dp); mdl->AddCircle(cc, prR, material::WithAlpha(material::OnSurface(), 55), 0, 1.5f * dp);
ImFont* gf = material::Type().iconLarge(); ImFont* gf = material::Type().iconXL();
ImVec2 gs = gf->CalcTextSizeA(gf->LegacySize, FLT_MAX, 0, ICON_MD_ADD_PHOTO_ALTERNATE); float gsz = prR * 0.72f;
mdl->AddText(gf, gf->LegacySize, ImVec2(cc.x - gs.x*0.5f, cc.y - gs.y*0.5f), ImVec2 gs = gf->CalcTextSizeA(gsz, FLT_MAX, 0, ICON_MD_ADD_PHOTO_ALTERNATE);
mdl->AddText(gf, gsz, ImVec2(cc.x - gs.x*0.5f, cc.y - gs.y*0.5f),
material::OnSurfaceDisabled(), ICON_MD_ADD_PHOTO_ALTERNATE); material::OnSurfaceDisabled(), ICON_MD_ADD_PHOTO_ALTERNATE);
} }
float rowY = cy + prR + Layout::spacingSm(); float rowY = cy + prR + Layout::spacingSm();
@@ -571,8 +589,14 @@ void RenderContactsTab(App* app)
if (!canSubmit) ImGui::BeginDisabled(); if (!canSubmit) ImGui::BeginDisabled();
// Accent the primary action (Save/Add) so it reads above Cancel.
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 205)));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(material::Primary()));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 235)));
const char* primaryLabel = isEdit ? TR("save") : TR("add"); const char* primaryLabel = isEdit ? TR("save") : TR("add");
if (material::TactileButton(primaryLabel, ImVec2(actionW, 0), btnFont)) { bool doSubmit = material::TactileButton(primaryLabel, ImVec2(actionW, 0), btnFont);
ImGui::PopStyleColor(3);
if (doSubmit) {
// Trim the label/address (a pasted address often carries a trailing newline); keep notes as-is. // Trim the label/address (a pasted address often carries a trailing newline); keep notes as-is.
auto trimAB = [](std::string s) { auto trimAB = [](std::string s) {
while (!s.empty() && (s.front()==' '||s.front()=='\t'||s.front()=='\n'||s.front()=='\r')) s.erase(s.begin()); while (!s.empty() && (s.front()==' '||s.front()=='\t'||s.front()=='\n'||s.front()=='\r')) s.erase(s.begin());