feat(contacts): per-row hover actions + fix add/edit modal HiDPI widths

Two follow-ups from the contacts audit:

- Per-row actions: in the Cards/List views, copy/edit/delete icon buttons now appear
  on the right of a row on hover or selection (the globe badge shows otherwise). The
  row Selectable uses SetNextItemAllowOverlap so the action InvisibleButtons take
  click priority; whole-row hover (IsMouseHoveringRect) drives the highlight so it
  survives hovering an icon; the delete icon turns red while armed (two-click confirm);
  each has a tooltip. Trailing space is reserved so the text never reflows on hover,
  and the cursor is restored after the manual action layout.

- Add/edit modal HiDPI: the Layout::kDialog* helpers fold dpiScale() (physical px)
  while raw schema widths are logical, so the schema-path formW/actionW/actionGap/
  notesH were unscaled vs their scaled fallbacks. Scale the schema-path values; and
  since BeginOverlayDialog re-applies dpiScale to cardWidth, divide the already-scaled
  dialogW back out (it was double-scaled). No visible change at 100%; correct at 150%.

Build + ctest + hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-15 06:19:01 -05:00
parent 03dde25339
commit 33317c4e78

View File

@@ -126,11 +126,16 @@ void RenderContactsTab(App* app)
const char* title = isEdit ? TR("address_book_edit") : TR("address_book_add");
const char* id = isEdit ? "AddressBookEdit" : "AddressBookAdd";
float dialogW = std::max(Layout::kDialogMinWidth(), Layout::kDialogDefaultWidth());
float formW = addrInput.width > 0 ? addrInput.width : Layout::kDialogFormWidth();
float actionW = actionBtn.width > 0 ? actionBtn.width : Layout::kDialogActionWidth();
float actionGap = actionBtn.gap > 0 ? actionBtn.gap : Layout::kDialogActionGap();
float notesH = notesInput.height > 0 ? notesInput.height : 60.0f;
// DPI: the Layout::kDialog* helpers fold dpiScale() (physical px) but the raw schema widths are
// logical, so scale the schema-path values to keep both branches consistent physical px inside
// the (native-scale) overlay. cardWidth is the exception — BeginOverlayDialog re-applies dpiScale,
// so it must be LOGICAL; divide the already-scaled dialogW back out.
const float dp = Layout::dpiScale();
float dialogW = std::max(Layout::kDialogMinWidth(), Layout::kDialogDefaultWidth()) / dp;
float formW = addrInput.width > 0 ? addrInput.width * dp : Layout::kDialogFormWidth();
float actionW = actionBtn.width > 0 ? actionBtn.width * dp : Layout::kDialogActionWidth();
float actionGap = actionBtn.gap > 0 ? actionBtn.gap * dp : Layout::kDialogActionGap();
float notesH = (notesInput.height > 0 ? notesInput.height : 60.0f) * dp;
material::OverlayDialogSpec ov;
ov.title = title; ov.p_open = open;
@@ -444,6 +449,7 @@ void RenderContactsTab(App* app)
const bool selected = (s_selected_index == static_cast<int>(i));
const ImVec2 p = ImGui::GetCursorScreenPos();
const float w = ImGui::GetContentRegionAvail().x;
ImGui::SetNextItemAllowOverlap(); // let the per-row action buttons overlap the row
if (ImGui::Selectable("##it", selected,
ImGuiSelectableFlags_SpanAvailWidth | ImGuiSelectableFlags_AllowDoubleClick,
ImVec2(0, rowH))) {
@@ -451,18 +457,20 @@ void RenderContactsTab(App* app)
s_selected_index = static_cast<int>(i);
if (ImGui::IsMouseDoubleClicked(0)) openEdit();
}
const bool hov = ImGui::IsItemHovered();
if (hov) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
const bool selHov = ImGui::IsItemHovered();
const ImVec2 afterRow = ImGui::GetCursorScreenPos(); // restore this for the next row
const ImVec2 mn = p, mx(p.x + w, p.y + rowH);
const bool rowHovered = ImGui::IsMouseHoveringRect(mn, mx); // whole-row hover (survives action-icon hover)
if (rowHovered && !selected) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (asCard) {
ImU32 fill = selected ? material::WithAlpha(material::Primary(), 42)
: hov ? material::WithAlpha(material::OnSurface(), 26)
: material::WithAlpha(material::OnSurface(), 12);
ImU32 fill = selected ? material::WithAlpha(material::Primary(), 42)
: rowHovered ? material::WithAlpha(material::OnSurface(), 26)
: material::WithAlpha(material::OnSurface(), 12);
dl->AddRectFilled(mn, mx, fill, round);
if (selected) dl->AddRect(mn, mx, material::WithAlpha(material::Primary(), 150), round, 0, 1.6f * dp);
} else {
if (selected) dl->AddRectFilled(mn, mx, material::WithAlpha(material::Primary(), 34), 0);
else if (hov) dl->AddRectFilled(mn, mx, material::WithAlpha(material::OnSurface(), 16), 0);
if (selected) dl->AddRectFilled(mn, mx, material::WithAlpha(material::Primary(), 34), 0);
else if (rowHovered) dl->AddRectFilled(mn, mx, material::WithAlpha(material::OnSurface(), 16), 0);
dl->AddLine(ImVec2(mn.x + pad, mx.y - 0.5f), ImVec2(mx.x - pad, mx.y - 0.5f),
material::WithAlpha(material::OnSurface(), 24), 1.0f);
}
@@ -475,9 +483,10 @@ void RenderContactsTab(App* app)
const char* letter = shielded ? "Z" : "T";
const ImVec2 ls = lblF->CalcTextSizeA(lblF->LegacySize, FLT_MAX, 0, letter);
dl->AddText(lblF, lblF->LegacySize, ImVec2(avC.x - ls.x * 0.5f, avC.y - ls.y * 0.5f), tu, letter);
// Label (line 1) + muted address (line 2).
// Label (line 1) + muted address (line 2) — reserve trailing room for the actions/globe.
const float actHit = icoF->LegacySize + 8.0f * dp; // per-action square hit area
const float trailW = 3.0f * actHit + 6.0f * dp;
const float tx = mn.x + pad + avR * 2.0f + pad;
const float trailW = entry.isGlobal() ? 28.0f * dp : 0.0f;
const float textMaxX = mx.x - pad - trailW;
const float blockH = lblF->LegacySize + adrF->LegacySize + 3.0f * dp;
const float ty = mn.y + rowH * 0.5f - blockH * 0.5f;
@@ -487,16 +496,47 @@ void RenderContactsTab(App* app)
std::string addr = util::truncateMiddle(entry.address, addrFrontLbl.truncate, addrBackLbl.truncate);
dl->AddText(adrF, adrF->LegacySize, ImVec2(tx, ty + lblF->LegacySize + 3.0f * dp),
material::OnSurfaceMedium(), addr.c_str());
// Trailing globe badge for global contacts.
if (entry.isGlobal()) {
// Trailing: per-row copy/edit/delete actions on hover/selection; else the globe badge.
if (rowHovered || selected) {
const char* actGlyph[3] = { ICON_MD_CONTENT_COPY, ICON_MD_EDIT, ICON_MD_DELETE };
const char* actTip[3] = { TR("copy_address"), TR("edit"), TR("delete") };
float ax = mx.x - pad - actHit; // rightmost action's left edge (delete)
for (int a = 2; a >= 0; --a) {
ImGui::SetCursorScreenPos(ImVec2(ax, mn.y + rowH * 0.5f - actHit * 0.5f));
ImGui::PushID(a + 100);
const bool aClk = ImGui::InvisibleButton("##act", ImVec2(actHit, actHit));
const bool aHov = ImGui::IsItemHovered();
if (aHov) { ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); material::Tooltip("%s", actTip[a]); }
if (aClk) {
s_selected_index = static_cast<int>(i);
if (a == 0) doCopy();
else if (a == 1) openEdit();
else doDelete();
}
const bool armedDel = (a == 2 && s_confirm_delete_idx == static_cast<int>(i));
const ImU32 acol = armedDel ? material::ReadableError()
: aHov ? material::OnSurface()
: material::OnSurfaceMedium();
const ImVec2 gsz = icoF->CalcTextSizeA(icoF->LegacySize, FLT_MAX, 0, actGlyph[a]);
dl->AddText(icoF, icoF->LegacySize,
ImVec2(ax + (actHit - gsz.x) * 0.5f, mn.y + rowH * 0.5f - gsz.y * 0.5f),
acol, actGlyph[a]);
ImGui::PopID();
ax -= actHit + 2.0f * dp;
}
} else if (entry.isGlobal()) {
const ImVec2 gs = icoF->CalcTextSizeA(icoF->LegacySize, FLT_MAX, 0, ICON_MD_PUBLIC);
dl->AddText(icoF, icoF->LegacySize,
ImVec2(mx.x - pad - gs.x, mn.y + rowH * 0.5f - gs.y * 0.5f),
material::OnSurfaceMedium(), ICON_MD_PUBLIC);
}
if (hov) material::Tooltip("%s", entry.address.c_str());
if (selHov) material::Tooltip("%s", entry.address.c_str());
ImGui::SetCursorScreenPos(afterRow); // undo the action buttons' cursor moves
ImGui::PopID();
}
// The per-row SetCursorScreenPos leaves the cursor at the last row's bottom with no item
// submitted there; commit it so ImGui sizes the scroll child (avoids the extend-boundary warning).
ImGui::Dummy(ImVec2(0.0f, 0.0f));
ImGui::PopStyleVar();
ImGui::PopStyleColor(3);
}