feat(contacts): switchable Cards / List / Table view for the address list

Add a persisted view toggle so the contacts address list can be rendered three
ways, moving it away from a bare data-grid toward a Material look:

- Cards: tactile rounded cards with a circular Z/T type-avatar, label over a muted
  truncated address, trailing globe badge, primary-tint + outline selection, hover
  fill, and a centred Material empty state.
- List: borderless two-line rows (same avatar + label/address) with hover tint and a
  thin per-row divider — denser than cards.
- Table: the previous 3-column table, material-ized (grid borders dropped, row
  backgrounds + interactive sort kept).

The toggle is an icon SegmentedControl (view-agenda / view-list / table-rows)
right-aligned on the toolbar; the choice persists via a new contacts_view_mode_
setting (0 cards / 1 list / 2 table, mirroring portfolio_style_). Cards/List sort by
label; Table keeps its sortable headers. All geometry is dpiScale()-aware; the globe
badge is drawn in the icon font in every mode. Default is Cards.

Build + ctest + hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-14 21:07:33 -05:00
parent 1b0006a4af
commit 3fbb64d14c
3 changed files with 173 additions and 66 deletions

View File

@@ -4,6 +4,7 @@
#include "contacts_tab.h"
#include "../../app.h"
#include "../../config/settings.h"
#include "../../data/address_book.h"
#include "../../util/i18n.h"
#include "../../util/text_format.h"
@@ -275,6 +276,26 @@ void RenderContactsTab(App* app)
if (!has_selection) ImGui::EndDisabled();
// Address-list view toggle (Cards / List / Table) — right-aligned on the toolbar row; persisted.
int viewMode = app->settings() ? app->settings()->getContactsViewMode() : 0;
{
const char* segIcons[3] = { ICON_MD_VIEW_AGENDA, ICON_MD_VIEW_LIST, ICON_MD_TABLE_ROWS };
const float segH = ImGui::GetFrameHeight();
const float segW = 44.0f * dp * 3.0f;
ImGui::SameLine();
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, ImGui::GetContentRegionAvail().x - segW));
ImVec2 segOrigin = ImGui::GetCursorScreenPos();
int segClk = material::SegmentedControl(ImGui::GetWindowDrawList(), segOrigin, segW, segH,
segIcons, 3, viewMode, material::Type().iconSmall(),
"##contactsView", dp);
if (segClk >= 0 && app->settings()) {
app->settings()->setContactsViewMode(segClk);
app->settings()->save();
viewMode = segClk;
}
ImGui::Dummy(ImVec2(segW, segH));
}
// Search / filter
ImGui::Spacing();
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x);
@@ -299,32 +320,50 @@ void RenderContactsTab(App* app)
visibleRows.push_back(i);
}
// Address list — size the table to fill the tab, leaving room for the count footer.
float footerH = ImGui::GetTextLineHeightWithSpacing() + ImGui::GetStyle().ItemSpacing.y;
float tableH = ImGui::GetContentRegionAvail().y - footerH;
if (tableH < 120.0f * dp) tableH = 120.0f * dp;
if (ImGui::BeginTable("AddressBookTable", 3,
ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | ImGuiTableFlags_Sortable |
ImGuiTableFlags_Resizable | ImGuiTableFlags_ScrollY,
ImVec2(0, tableH)))
{
// All columns stretch to share the tab width (address widest, notes a small share) so long labels
// no longer clip mid-word and the notes column no longer reserves a fixed empty block on the right.
// Stretch weights are relative, so no dpiScale() is needed here.
ImGui::TableSetupColumn(TR("label"), ImGuiTableColumnFlags_WidthStretch | ImGuiTableColumnFlags_DefaultSort, 1.5f);
ImGui::TableSetupColumn(TR("address_label"), ImGuiTableColumnFlags_WidthStretch, 2.6f);
ImGui::TableSetupColumn(TR("notes"), ImGuiTableColumnFlags_WidthStretch, 1.0f);
ImGui::TableSetupScrollFreeze(0, 1);
ImGui::TableHeadersRow();
// Address list — fill the tab, leaving room for the count footer.
const float footerH = ImGui::GetTextLineHeightWithSpacing() + ImGui::GetStyle().ItemSpacing.y;
float listH = ImGui::GetContentRegionAvail().y - footerH;
if (listH < 120.0f * dp) listH = 120.0f * dp;
// Sort the visible view by the active column (indices stay storage indices).
if (ImGuiTableSortSpecs* specs = ImGui::TableGetSortSpecs()) {
if (specs->SpecsCount > 0) {
const ImGuiTableColumnSortSpecs& sc = specs->Specs[0];
bool asc = sc.SortDirection != ImGuiSortDirection_Descending;
const auto& entries = book.entries();
std::stable_sort(visibleRows.begin(), visibleRows.end(),
[&](size_t a, size_t b) {
const bool lightTheme = material::IsLightTheme();
auto typeColor = [&](bool shielded) -> ImU32 {
ImVec4 c = shielded ? (lightTheme ? ImVec4(0.10f,0.55f,0.38f,1.0f) : ImVec4(0.35f,0.80f,0.60f,1.0f))
: (lightTheme ? ImVec4(0.72f,0.48f,0.05f,1.0f) : ImVec4(0.95f,0.72f,0.30f,1.0f));
return ImGui::ColorConvertFloat4ToU32(c);
};
// Centered Material empty state (no contacts yet / no search match).
auto emptyState = [&](const char* iconGlyph, const char* msgKey) {
const char* msg = TR(msgKey);
ImFont* icoF = material::Type().iconLarge();
ImFont* txtF = material::Type().body2();
const float availW = ImGui::GetContentRegionAvail().x;
ImVec2 is = icoF->CalcTextSizeA(icoF->LegacySize, FLT_MAX, 0, iconGlyph);
ImVec2 ts = txtF->CalcTextSizeA(txtF->LegacySize, FLT_MAX, 0, msg);
ImGui::SetCursorPos(ImVec2((availW - is.x) * 0.5f, ImGui::GetCursorPosY() + listH * 0.32f));
ImGui::PushFont(icoF);
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(material::OnSurfaceDisabled()), "%s", iconGlyph);
ImGui::PopFont();
ImGui::SetCursorPosX((availW - ts.x) * 0.5f);
material::Type().textColored(material::TypeStyle::Body2, material::OnSurfaceMedium(), msg);
};
if (viewMode == 2) {
// ── TABLE mode (material-ized): no outer/grid borders, row backgrounds, interactive sort. ──
if (ImGui::BeginTable("AddressBookTable", 3,
ImGuiTableFlags_RowBg | ImGuiTableFlags_Sortable |
ImGuiTableFlags_Resizable | ImGuiTableFlags_ScrollY, ImVec2(0, listH)))
{
ImGui::TableSetupColumn(TR("label"), ImGuiTableColumnFlags_WidthStretch | ImGuiTableColumnFlags_DefaultSort, 1.5f);
ImGui::TableSetupColumn(TR("address_label"), ImGuiTableColumnFlags_WidthStretch, 2.6f);
ImGui::TableSetupColumn(TR("notes"), ImGuiTableColumnFlags_WidthStretch, 1.0f);
ImGui::TableSetupScrollFreeze(0, 1);
ImGui::TableHeadersRow();
if (ImGuiTableSortSpecs* specs = ImGui::TableGetSortSpecs()) {
if (specs->SpecsCount > 0) {
const ImGuiTableColumnSortSpecs& sc = specs->Specs[0];
bool asc = sc.SortDirection != ImGuiSortDirection_Descending;
const auto& entries = book.entries();
std::stable_sort(visibleRows.begin(), visibleRows.end(), [&](size_t a, size_t b) {
const std::string* ka; const std::string* kb;
switch (sc.ColumnIndex) {
case 1: ka = &entries[a].address; kb = &entries[b].address; break;
@@ -334,74 +373,135 @@ void RenderContactsTab(App* app)
int cmp = toLower(*ka).compare(toLower(*kb));
return asc ? (cmp < 0) : (cmp > 0);
});
}
}
}
if (book.empty()) {
ImGui::TableNextRow();
ImGui::TableNextColumn();
ImGui::TextDisabled("%s", TR("address_book_empty"));
} else if (visibleRows.empty()) {
ImGui::TableNextRow();
ImGui::TableNextColumn();
ImGui::TextDisabled("%s", TR("contacts_search_no_match"));
} else {
for (size_t vi = 0; vi < visibleRows.size(); ++vi) {
size_t i = visibleRows[vi];
const auto& entry = book.entries()[i];
ImGui::TableNextRow();
ImGui::PushID(static_cast<int>(i));
ImGui::TableNextColumn();
bool is_selected = (s_selected_index == static_cast<int>(i));
if (ImGui::Selectable(entry.label.c_str(), is_selected,
ImGuiSelectableFlags_SpanAllColumns)) {
ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowDoubleClick)) {
if (s_selected_index != static_cast<int>(i)) s_confirm_delete_idx = -1;
s_selected_index = static_cast<int>(i);
if (ImGui::IsMouseDoubleClicked(0)) openEdit();
}
if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(0)) {
s_selected_index = static_cast<int>(i);
openEdit();
}
ImGui::TableNextColumn();
// Z/T type badge + address in normal (legible) text — not muted.
// Darker/more-saturated variants on light skins so the badge doesn't wash out.
bool shielded = isShieldedAddr(entry.address);
bool lightTheme = material::IsLightTheme();
ImVec4 zCol = lightTheme ? ImVec4(0.10f, 0.55f, 0.38f, 1.0f) : ImVec4(0.35f, 0.80f, 0.60f, 1.0f);
ImVec4 tCol = lightTheme ? ImVec4(0.72f, 0.48f, 0.05f, 1.0f) : ImVec4(0.95f, 0.72f, 0.30f, 1.0f);
ImGui::TextColored(shielded ? zCol : tCol, "%s", shielded ? "Z" : "T");
ImGui::PushFont(material::Type().subtitle2());
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(typeColor(shielded)), "%s", shielded ? "Z" : "T");
ImGui::PopFont();
ImGui::SameLine(0.0f, 6.0f * dp);
std::string addr_display = entry.address;
int addrTruncLen = (addrTable.columns.count("address") && addrTable.columns.at("address").truncate > 0) ? addrTable.columns.at("address").truncate : 40;
if (addr_display.length() > static_cast<size_t>(addrTruncLen)) {
addr_display = util::truncateMiddle(addr_display, addrFrontLbl.truncate, addrBackLbl.truncate);
}
std::string addr_display = util::truncateMiddle(entry.address, addrFrontLbl.truncate, addrBackLbl.truncate);
ImGui::TextUnformatted(addr_display.c_str());
if (ImGui::IsItemHovered()) {
material::Tooltip("%s", entry.address.c_str());
}
// Globe badge marks a global contact (shown in every wallet). Must be drawn in the icon
// font — the text font has no Material glyphs, so it rendered as tofu ("?") before.
if (ImGui::IsItemHovered()) material::Tooltip("%s", entry.address.c_str());
if (entry.isGlobal()) {
ImGui::SameLine(0.0f, 8.0f * dp);
ImGui::PushFont(material::Type().iconSmall());
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(material::OnSurfaceMedium()),
"%s", ICON_MD_PUBLIC);
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(material::OnSurfaceMedium()), "%s", ICON_MD_PUBLIC);
ImGui::PopFont();
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("contact_global_badge_tt"));
}
ImGui::TableNextColumn();
ImGui::TextUnformatted(entry.notes.c_str());
ImGui::PopID();
}
ImGui::EndTable();
}
ImGui::EndTable();
} else {
// ── CARDS (0) / LIST (1) mode — tactile Material items, no grid lines. ──
const bool asCard = (viewMode == 0);
// Cards/list have no interactive sort header — sort by label ascending.
{
const auto& entries = book.entries();
std::stable_sort(visibleRows.begin(), visibleRows.end(), [&](size_t a, size_t b) {
return toLower(entries[a].label).compare(toLower(entries[b].label)) < 0;
});
}
ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0,0,0,0));
ImGui::BeginChild("##contactList", ImVec2(0, listH), false, ImGuiWindowFlags_NoBackground);
if (visibleRows.empty()) {
emptyState(ICON_MD_CONTACTS, book.empty() ? "address_book_empty" : "contacts_search_no_match");
} else {
ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0,0,0,0));
ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImVec4(0,0,0,0));
ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImVec4(0,0,0,0));
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0, asCard ? 8.0f * dp : 0.0f));
ImDrawList* dl = ImGui::GetWindowDrawList();
ImFont* lblF = material::Type().subtitle2();
ImFont* adrF = material::Type().caption();
ImFont* icoF = material::Type().iconSmall();
const float pad = 12.0f * dp;
const float avR = 15.0f * dp;
const float rowH = avR * 2.0f + (asCard ? 16.0f * dp : 12.0f * dp);
const float round = 10.0f * dp;
for (size_t vi = 0; vi < visibleRows.size(); ++vi) {
size_t i = visibleRows[vi];
const auto& entry = book.entries()[i];
ImGui::PushID(static_cast<int>(i));
const bool selected = (s_selected_index == static_cast<int>(i));
const ImVec2 p = ImGui::GetCursorScreenPos();
const float w = ImGui::GetContentRegionAvail().x;
if (ImGui::Selectable("##it", selected,
ImGuiSelectableFlags_SpanAvailWidth | ImGuiSelectableFlags_AllowDoubleClick,
ImVec2(0, rowH))) {
if (!selected) s_confirm_delete_idx = -1;
s_selected_index = static_cast<int>(i);
if (ImGui::IsMouseDoubleClicked(0)) openEdit();
}
const bool hov = ImGui::IsItemHovered();
if (hov) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
const ImVec2 mn = p, mx(p.x + w, p.y + rowH);
if (asCard) {
ImU32 fill = selected ? material::WithAlpha(material::Primary(), 42)
: hov ? 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);
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);
}
// Circular type avatar with the Z/T letter.
const bool shielded = isShieldedAddr(entry.address);
const ImU32 tu = typeColor(shielded);
const ImVec2 avC(mn.x + pad + avR, mn.y + rowH * 0.5f);
dl->AddCircleFilled(avC, avR, material::WithAlpha(tu, lightTheme ? 45 : 60));
dl->AddCircle(avC, avR, material::WithAlpha(tu, 190), 0, 1.4f * dp);
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).
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;
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 globe badge for global contacts.
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());
ImGui::PopID();
}
ImGui::PopStyleVar();
ImGui::PopStyleColor(3);
}
ImGui::EndChild();
ImGui::PopStyleColor();
}
// Status line — singular form for exactly one contact ("1 address saved", not "1 addresses saved").