From 1ce37aa0fa956e4a05787a2ff05b3a9d0d4983be Mon Sep 17 00:00:00 2001 From: DanS Date: Wed, 15 Jul 2026 06:36:50 -0500 Subject: [PATCH] feat(contacts): deselect on empty click, actions left of globe, right-click menu Three interaction refinements to the address list: - The globe badge now stays pinned far-right; the per-row copy/edit/delete actions appear to its LEFT on hover/selection instead of replacing it. - A left-click on empty space in the Cards/List area clears the current selection (no row/action hovered -> deselect). - Right-clicking a row (any view) selects it and opens a shared context menu (Copy address / Edit / Delete), rendered once after the list. Build + hygiene clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/ui/windows/contacts_tab.cpp | 54 +++++++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 12 deletions(-) diff --git a/src/ui/windows/contacts_tab.cpp b/src/ui/windows/contacts_tab.cpp index c9f466e..33894ac 100644 --- a/src/ui/windows/contacts_tab.cpp +++ b/src/ui/windows/contacts_tab.cpp @@ -352,6 +352,10 @@ void RenderContactsTab(App* app) material::Type().textColored(material::TypeStyle::Body2, material::OnSurfaceMedium(), msg); }; + // Right-click a row (any view) -> select it and open the shared context menu (rendered after the + // list). A left-click on empty list space clears the selection (handled in the Cards/List child). + bool openContextMenu = false; + if (viewMode == 2) { // ── TABLE mode (material-ized): no outer/grid borders, row backgrounds, interactive sort. ── if (ImGui::BeginTable("AddressBookTable", 3, @@ -393,6 +397,9 @@ void RenderContactsTab(App* app) s_selected_index = static_cast(i); if (ImGui::IsMouseDoubleClicked(0)) openEdit(); } + if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { + s_selected_index = static_cast(i); s_confirm_delete_idx = -1; openContextMenu = true; + } ImGui::TableNextColumn(); bool shielded = isShieldedAddr(entry.address); ImGui::PushFont(material::Type().subtitle2()); @@ -458,6 +465,9 @@ void RenderContactsTab(App* app) if (ImGui::IsMouseDoubleClicked(0)) openEdit(); } const bool selHov = ImGui::IsItemHovered(); + if (selHov && ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { + s_selected_index = static_cast(i); s_confirm_delete_idx = -1; openContextMenu = true; + } 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) @@ -483,26 +493,36 @@ 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) — reserve trailing room for the actions/globe. + // Label (line 1) + muted address (line 2) — reserve trailing room for the globe + actions. const float actHit = icoF->LegacySize + 8.0f * dp; // per-action square hit area - const float trailW = 3.0f * actHit + 6.0f * dp; + const float globeW = entry.isGlobal() ? (icoF->LegacySize + 8.0f * dp) : 0.0f; + const float trailW = 3.0f * actHit + 6.0f * dp + globeW; + const float cy = mn.y + rowH * 0.5f; const float tx = mn.x + pad + avR * 2.0f + pad; 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; + const float ty = cy - blockH * 0.5f; dl->PushClipRect(ImVec2(tx, mn.y), ImVec2(textMaxX, mx.y), true); dl->AddText(lblF, lblF->LegacySize, ImVec2(tx, ty), material::OnSurface(), entry.label.c_str()); dl->PopClipRect(); 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: per-row copy/edit/delete actions on hover/selection; else the globe badge. + // Trailing: the globe badge stays pinned far-right (global contacts); per-row copy/edit/ + // delete actions appear to its LEFT on hover/selection. + float rightX = mx.x - pad; + if (entry.isGlobal()) { + const ImVec2 gs = icoF->CalcTextSizeA(icoF->LegacySize, FLT_MAX, 0, ICON_MD_PUBLIC); + dl->AddText(icoF, icoF->LegacySize, ImVec2(rightX - gs.x, cy - gs.y * 0.5f), + material::OnSurfaceMedium(), ICON_MD_PUBLIC); + rightX -= gs.x + 8.0f * dp; + } 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) + float ax = rightX - actHit; // rightmost action (delete), just left of the globe for (int a = 2; a >= 0; --a) { - ImGui::SetCursorScreenPos(ImVec2(ax, mn.y + rowH * 0.5f - actHit * 0.5f)); + ImGui::SetCursorScreenPos(ImVec2(ax, cy - actHit * 0.5f)); ImGui::PushID(a + 100); const bool aClk = ImGui::InvisibleButton("##act", ImVec2(actHit, actHit)); const bool aHov = ImGui::IsItemHovered(); @@ -519,16 +539,11 @@ void RenderContactsTab(App* app) : 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), + ImVec2(ax + (actHit - gsz.x) * 0.5f, cy - 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 (selHov) material::Tooltip("%s", entry.address.c_str()); ImGui::SetCursorScreenPos(afterRow); // undo the action buttons' cursor moves @@ -540,10 +555,25 @@ void RenderContactsTab(App* app) ImGui::PopStyleVar(); ImGui::PopStyleColor(3); } + // A left-click on empty list space (no row/action hovered) clears the selection. + if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && !ImGui::IsAnyItemHovered()) { + s_selected_index = -1; + s_confirm_delete_idx = -1; + } ImGui::EndChild(); ImGui::PopStyleColor(); } + // Shared right-click context menu (opened by either view's row right-click; acts on the selection). + if (openContextMenu) ImGui::OpenPopup("##contactCtx"); + if (ImGui::BeginPopup("##contactCtx")) { + if (ImGui::MenuItem(TR("copy_address"))) doCopy(); + if (ImGui::MenuItem(TR("edit"))) openEdit(); + ImGui::Separator(); + if (ImGui::MenuItem(TR("delete"))) doDelete(); + ImGui::EndPopup(); + } + // Status line — singular form for exactly one contact ("1 address saved", not "1 addresses saved"). ImGui::TextDisabled(TR(book.size() == 1 ? "address_book_count_one" : "address_book_count"), book.size());