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) <noreply@anthropic.com>
This commit is contained in:
2026-07-15 06:36:50 -05:00
parent 33317c4e78
commit 1ce37aa0fa

View File

@@ -352,6 +352,10 @@ void RenderContactsTab(App* app)
material::Type().textColored(material::TypeStyle::Body2, material::OnSurfaceMedium(), msg); 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) { if (viewMode == 2) {
// ── TABLE mode (material-ized): no outer/grid borders, row backgrounds, interactive sort. ── // ── TABLE mode (material-ized): no outer/grid borders, row backgrounds, interactive sort. ──
if (ImGui::BeginTable("AddressBookTable", 3, if (ImGui::BeginTable("AddressBookTable", 3,
@@ -393,6 +397,9 @@ void RenderContactsTab(App* app)
s_selected_index = static_cast<int>(i); s_selected_index = static_cast<int>(i);
if (ImGui::IsMouseDoubleClicked(0)) openEdit(); if (ImGui::IsMouseDoubleClicked(0)) openEdit();
} }
if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Right)) {
s_selected_index = static_cast<int>(i); s_confirm_delete_idx = -1; openContextMenu = true;
}
ImGui::TableNextColumn(); ImGui::TableNextColumn();
bool shielded = isShieldedAddr(entry.address); bool shielded = isShieldedAddr(entry.address);
ImGui::PushFont(material::Type().subtitle2()); ImGui::PushFont(material::Type().subtitle2());
@@ -458,6 +465,9 @@ void RenderContactsTab(App* app)
if (ImGui::IsMouseDoubleClicked(0)) openEdit(); if (ImGui::IsMouseDoubleClicked(0)) openEdit();
} }
const bool selHov = ImGui::IsItemHovered(); const bool selHov = ImGui::IsItemHovered();
if (selHov && ImGui::IsMouseClicked(ImGuiMouseButton_Right)) {
s_selected_index = static_cast<int>(i); s_confirm_delete_idx = -1; openContextMenu = true;
}
const ImVec2 afterRow = ImGui::GetCursorScreenPos(); // restore this for the next row const ImVec2 afterRow = ImGui::GetCursorScreenPos(); // restore this for the next row
const ImVec2 mn = p, mx(p.x + w, p.y + rowH); 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) 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 char* letter = shielded ? "Z" : "T";
const ImVec2 ls = lblF->CalcTextSizeA(lblF->LegacySize, FLT_MAX, 0, letter); 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); 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 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 tx = mn.x + pad + avR * 2.0f + pad;
const float textMaxX = mx.x - pad - trailW; const float textMaxX = mx.x - pad - trailW;
const float blockH = lblF->LegacySize + adrF->LegacySize + 3.0f * dp; 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->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->AddText(lblF, lblF->LegacySize, ImVec2(tx, ty), material::OnSurface(), entry.label.c_str());
dl->PopClipRect(); dl->PopClipRect();
std::string addr = util::truncateMiddle(entry.address, addrFrontLbl.truncate, addrBackLbl.truncate); std::string addr = util::truncateMiddle(entry.address, addrFrontLbl.truncate, addrBackLbl.truncate);
dl->AddText(adrF, adrF->LegacySize, ImVec2(tx, ty + lblF->LegacySize + 3.0f * dp), dl->AddText(adrF, adrF->LegacySize, ImVec2(tx, ty + lblF->LegacySize + 3.0f * dp),
material::OnSurfaceMedium(), addr.c_str()); 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) { if (rowHovered || selected) {
const char* actGlyph[3] = { ICON_MD_CONTENT_COPY, ICON_MD_EDIT, ICON_MD_DELETE }; 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") }; 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) { 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); ImGui::PushID(a + 100);
const bool aClk = ImGui::InvisibleButton("##act", ImVec2(actHit, actHit)); const bool aClk = ImGui::InvisibleButton("##act", ImVec2(actHit, actHit));
const bool aHov = ImGui::IsItemHovered(); const bool aHov = ImGui::IsItemHovered();
@@ -519,16 +539,11 @@ void RenderContactsTab(App* app)
: material::OnSurfaceMedium(); : material::OnSurfaceMedium();
const ImVec2 gsz = icoF->CalcTextSizeA(icoF->LegacySize, FLT_MAX, 0, actGlyph[a]); const ImVec2 gsz = icoF->CalcTextSizeA(icoF->LegacySize, FLT_MAX, 0, actGlyph[a]);
dl->AddText(icoF, icoF->LegacySize, 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]); acol, actGlyph[a]);
ImGui::PopID(); ImGui::PopID();
ax -= actHit + 2.0f * dp; 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()); if (selHov) material::Tooltip("%s", entry.address.c_str());
ImGui::SetCursorScreenPos(afterRow); // undo the action buttons' cursor moves ImGui::SetCursorScreenPos(afterRow); // undo the action buttons' cursor moves
@@ -540,10 +555,25 @@ void RenderContactsTab(App* app)
ImGui::PopStyleVar(); ImGui::PopStyleVar();
ImGui::PopStyleColor(3); 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::EndChild();
ImGui::PopStyleColor(); 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"). // 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()); ImGui::TextDisabled(TR(book.size() == 1 ? "address_book_count_one" : "address_book_count"), book.size());