From 3fbb64d14c05c020e5df579abb082d7a9f863735 Mon Sep 17 00:00:00 2001 From: DanS Date: Tue, 14 Jul 2026 21:07:33 -0500 Subject: [PATCH] feat(contacts): switchable Cards / List / Table view for the address list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/config/settings.cpp | 3 + src/config/settings.h | 4 + src/ui/windows/contacts_tab.cpp | 232 +++++++++++++++++++++++--------- 3 files changed, 173 insertions(+), 66 deletions(-) diff --git a/src/config/settings.cpp b/src/config/settings.cpp index ffd7ddb..f8895a7 100644 --- a/src/config/settings.cpp +++ b/src/config/settings.cpp @@ -174,6 +174,8 @@ bool Settings::load(const std::string& path) } loadScalar(j, "portfolio_style", portfolio_style_); if (portfolio_style_ < 0 || portfolio_style_ > 2) portfolio_style_ = 0; + loadScalar(j, "contacts_view_mode", contacts_view_mode_); + if (contacts_view_mode_ < 0 || contacts_view_mode_ > 2) contacts_view_mode_ = 0; loadScalar(j, "scanline_enabled", scanline_enabled_); loadScalar(j, "console_line_accents", console_line_accents_); loadScalar(j, "console_text_color", console_text_color_); @@ -428,6 +430,7 @@ bool Settings::save(const std::string& path) j["window_opacity"] = window_opacity_; j["balance_layout"] = balance_layout_; // saved as string ID j["portfolio_style"] = portfolio_style_; + j["contacts_view_mode"] = contacts_view_mode_; j["scanline_enabled"] = scanline_enabled_; j["console_line_accents"] = console_line_accents_; j["console_text_color"] = console_text_color_; diff --git a/src/config/settings.h b/src/config/settings.h index f303705..89b0cdf 100644 --- a/src/config/settings.h +++ b/src/config/settings.h @@ -184,6 +184,9 @@ public: // Left/Right arrows on the Market tab (like the Overview layouts). int getPortfolioStyle() const { return portfolio_style_; } void setPortfolioStyle(int v) { portfolio_style_ = (v < 0 || v > 2) ? 0 : v; } + // Contacts tab address-list view: 0 = cards, 1 = list, 2 = table. + int getContactsViewMode() const { return contacts_view_mode_; } + void setContactsViewMode(int v) { contacts_view_mode_ = (v < 0 || v > 2) ? 0 : v; } // Console scanline effect bool getScanlineEnabled() const { return scanline_enabled_; } @@ -490,6 +493,7 @@ private: #endif std::string balance_layout_ = "classic"; int portfolio_style_ = 0; // Market portfolio row style (0 single / 1 two-line / 2 hero) + int contacts_view_mode_ = 0; // Contacts address-list view (0 cards / 1 list / 2 table) bool scanline_enabled_ = true; bool console_line_accents_ = true; // left color accent bars in console output bool console_text_color_ = true; // per-channel text coloring in console output diff --git a/src/ui/windows/contacts_tab.cpp b/src/ui/windows/contacts_tab.cpp index aa499ce..950fa5e 100644 --- a/src/ui/windows/contacts_tab.cpp +++ b/src/ui/windows/contacts_tab.cpp @@ -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(i)); - ImGui::TableNextColumn(); bool is_selected = (s_selected_index == static_cast(i)); if (ImGui::Selectable(entry.label.c_str(), is_selected, - ImGuiSelectableFlags_SpanAllColumns)) { + ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowDoubleClick)) { if (s_selected_index != static_cast(i)) s_confirm_delete_idx = -1; s_selected_index = static_cast(i); + if (ImGui::IsMouseDoubleClicked(0)) openEdit(); } - if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(0)) { - s_selected_index = static_cast(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(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(i)); + const bool selected = (s_selected_index == static_cast(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(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").