From 33317c4e78c7b0a00687aed487a461010054737d Mon Sep 17 00:00:00 2001 From: DanS Date: Wed, 15 Jul 2026 06:19:01 -0500 Subject: [PATCH] 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) --- src/ui/windows/contacts_tab.cpp | 74 +++++++++++++++++++++++++-------- 1 file changed, 57 insertions(+), 17 deletions(-) diff --git a/src/ui/windows/contacts_tab.cpp b/src/ui/windows/contacts_tab.cpp index 950fa5e..c9f466e 100644 --- a/src/ui/windows/contacts_tab.cpp +++ b/src/ui/windows/contacts_tab.cpp @@ -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(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(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(i); + if (a == 0) doCopy(); + else if (a == 1) openEdit(); + else doDelete(); + } + const bool armedDel = (a == 2 && s_confirm_delete_idx == static_cast(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); }