From defe14d913ba86efbfa56f192a7c17546927e51f Mon Sep 17 00:00:00 2001 From: DanS Date: Fri, 17 Jul 2026 22:35:03 -0500 Subject: [PATCH] feat(contacts): avatar shapes, list scale & live settings preview Add a settings-gear modal with a segmented avatar-shape picker (circle / rounded square / full-row left tab), a list-scale slider, and a live two-row preview. Smooth-scroll the address list and fix a latent wheel double-scroll on the outer scroll child. (settings.h also refreshes the portfolio-style doc comment.) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/config/settings.cpp | 4 + src/config/settings.h | 14 +- src/ui/windows/contacts_tab.cpp | 235 +++++++++++++++++++++++++++----- 3 files changed, 219 insertions(+), 34 deletions(-) diff --git a/src/config/settings.cpp b/src/config/settings.cpp index 858dfd8..852c4c5 100644 --- a/src/config/settings.cpp +++ b/src/config/settings.cpp @@ -196,6 +196,8 @@ bool Settings::load(const std::string& path) 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, "contacts_avatar_shape", contacts_avatar_shape_); setContactsAvatarShape(contacts_avatar_shape_); + loadScalar(j, "contacts_list_scale", contacts_list_scale_); setContactsListScale(contacts_list_scale_); loadScalar(j, "animate_avatars", animate_avatars_); loadScalar(j, "scanline_enabled", scanline_enabled_); loadScalar(j, "console_line_accents", console_line_accents_); @@ -467,6 +469,8 @@ bool Settings::save(const std::string& path) j["balance_layout"] = balance_layout_; // saved as string ID j["portfolio_style"] = portfolio_style_; j["contacts_view_mode"] = contacts_view_mode_; + j["contacts_avatar_shape"] = contacts_avatar_shape_; + j["contacts_list_scale"] = contacts_list_scale_; j["animate_avatars"] = animate_avatars_; j["scanline_enabled"] = scanline_enabled_; j["console_line_accents"] = console_line_accents_; diff --git a/src/config/settings.h b/src/config/settings.h index 527b3ca..b0ef931 100644 --- a/src/config/settings.h +++ b/src/config/settings.h @@ -226,13 +226,19 @@ public: std::string getBalanceLayout() const { return balance_layout_; } void setBalanceLayout(const std::string& v) { balance_layout_ = v; } - // Market-tab portfolio row style: 0 = single-line, 1 = two-line, 2 = value-hero. Cycled with - // Left/Right arrows on the Market tab (like the Overview layouts). + // Market-tab portfolio row style: 0 = Table (borderless grid), 1 = Cards (glass card + Z/T bar), + // 2 = Spotlight (hero value). Cycled with Left/Right arrows or set in the Market settings modal. 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; } + // Contacts customization (gear modal): avatar shape + card/list row scale. + // Avatar shape: 0 = circle, 1 = rounded square, 2 = full-row-height left tab (rounded-left, flat right). + int getContactsAvatarShape() const { return contacts_avatar_shape_; } + void setContactsAvatarShape(int v) { contacts_avatar_shape_ = (v < 0 || v > 2) ? 0 : v; } + float getContactsListScale() const { return contacts_list_scale_; } + void setContactsListScale(float v) { contacts_list_scale_ = std::max(0.8f, std::min(1.5f, v)); } // Play animated contact avatars (GIF/WebP). Off = show the first frame only. bool getAnimateAvatars() const { return animate_avatars_; } void setAnimateAvatars(bool v) { animate_avatars_ = v; } @@ -553,8 +559,10 @@ private: float window_opacity_ = 1.0f; // Mac/Linux: default fully opaque #endif std::string balance_layout_ = "classic"; - int portfolio_style_ = 0; // Market portfolio row style (0 single / 1 two-line / 2 hero) + int portfolio_style_ = 0; // Market portfolio row style (0 Table / 1 Cards / 2 Spotlight) int contacts_view_mode_ = 0; // Contacts address-list view (0 cards / 1 list / 2 table) + int contacts_avatar_shape_ = 0; // 0 = circle, 1 = rounded square, 2 = full-height left tab + float contacts_list_scale_ = 1.0f; // card/list row scale (does not affect the table view) bool animate_avatars_ = true; // play animated (GIF/WebP) contact avatars bool scanline_enabled_ = true; bool console_line_accents_ = true; // left color accent bars in console output diff --git a/src/ui/windows/contacts_tab.cpp b/src/ui/windows/contacts_tab.cpp index e791ab5..e682657 100644 --- a/src/ui/windows/contacts_tab.cpp +++ b/src/ui/windows/contacts_tab.cpp @@ -37,6 +37,7 @@ namespace ui { static int s_selected_index = -1; static bool s_show_add_dialog = false; static bool s_show_edit_dialog = false; +static bool s_show_contacts_settings = false; // Contacts customization modal (gear button) static bool s_focus_edit_field = false; // focus the first field the frame the add/edit dialog opens static int s_confirm_delete_idx = -1; // armed storage index; a 2nd Delete confirms static char s_edit_label[128] = ""; @@ -81,6 +82,8 @@ struct AvatarTex { static std::unordered_map s_avatarTexCache; static int s_avatarLoadsThisFrame = 0; static bool s_animateAvatars = true; // mirrors the setting; refreshed each frame in RenderContactsTab +static int s_contactsShape = 0; // avatar shape (mirrors getContactsAvatarShape: 0/1/2) +static float s_contactsScale = 1.0f; // card/list row scale (mirrors getContactsListScale) static bool s_avatarAnimatedThisFrame = false; // set when a live animated frame is drawn -> keep redrawing static constexpr int kAvatarLoadsPerFrame = 2; static constexpr int kAvatarMaxFrames = 300; // cap frames per animated avatar (bounds VRAM/decode) @@ -134,22 +137,48 @@ static void invalidateAvatarTexture(const std::string& path) { s_avatarTexCache.erase(it); } -// Draw a contact's avatar into the circle at `c` (radius `r`): a custom image (circular-cropped), a -// Material icon in a tinted circle, or — the default — the Z/T type badge. +// Draw a contact's avatar: a custom image, a Material icon in a tinted shape, or the Z/T type badge. +// `shape` picks the outline: 0 = circle (centered at `c`, radius `r`); 1 = rounded square (same box as +// the circle, all corners rounded); 2 = a full-row-height tab spanning [tabMin, tabMax] with rounded +// LEFT corners (radius tabRound) + a straight right edge. The inner glyph is sized to the shape, so it +// stays proportional at any list scale. static void drawContactAvatar(ImDrawList* dl, ImVec2 c, float r, const data::AddressBookEntry& e, bool shielded, ImU32 typeCol, bool light, float dp, - ImFont* letterFont, ImFont* iconFont, bool onScreen = true) { + ImFont* letterFont, ImFont* iconFont, bool onScreen = true, int shape = 0, + ImVec2 tabMin = ImVec2(0, 0), ImVec2 tabMax = ImVec2(0, 0), float tabRound = 0.0f) { + const bool circle = (shape == 0); + // Resolve the shape rectangle, its rounding, the content center + radius, and the glyph size. + ImVec2 cc = c; float cr = r; + ImVec2 sMin(c.x - r, c.y - r), sMax(c.x + r, c.y + r); + float sRound = r * 0.42f; + ImDrawFlags sFlags = ImDrawFlags_RoundCornersAll; + if (shape == 2) { + sMin = tabMin; sMax = tabMax; sRound = tabRound; sFlags = ImDrawFlags_RoundCornersLeft; + cc = ImVec2((tabMin.x + tabMax.x) * 0.5f, (tabMin.y + tabMax.y) * 0.5f); + cr = (tabMax.y - tabMin.y) * 0.5f; + } + const float glyphSz = cr * 0.92f; // ~46% of the shape diameter — matches the classic badge ratio + auto fillShape = [&](ImU32 col) { + if (circle) dl->AddCircleFilled(cc, cr, col); + else dl->AddRectFilled(sMin, sMax, col, sRound, sFlags); + }; + auto borderShape = [&](ImU32 col, float th) { + if (circle) dl->AddCircle(cc, cr, col, 0, th); + else dl->AddRect(sMin, sMax, col, sRound, sFlags, th); + }; const std::string& av = e.avatar; if (av.rfind("img:", 0) == 0) { const AvatarTex* t = getAvatarTexture(av.substr(4)); ImTextureID tex = currentAvatarFrame(t, onScreen); if (tex) { - float u0 = 0, v0 = 0, u1 = 1, v1 = 1; // centre-crop to a square so the circle isn't stretched - if (t->w > t->h) { float m = (t->w - t->h) * 0.5f / t->w; u0 = m; u1 = 1 - m; } - else if (t->h > t->w) { float m = (t->h - t->w) * 0.5f / t->h; v0 = m; v1 = 1 - m; } - dl->AddImageRounded(tex, ImVec2(c.x - r, c.y - r), ImVec2(c.x + r, c.y + r), - ImVec2(u0, v0), ImVec2(u1, v1), IM_COL32_WHITE, r); - dl->AddCircle(c, r, material::WithAlpha(material::OnSurface(), 45), 0, 1.0f * dp); + const float aspect = (sMax.x - sMin.x) / std::max(1.0f, sMax.y - sMin.y); // crop to the shape's aspect + float u0 = 0, v0 = 0, u1 = 1, v1 = 1; + const float tw = static_cast(t->w), th2 = static_cast(t->h); + if (tw / th2 > aspect) { float keep = aspect * th2 / tw; float m = (1 - keep) * 0.5f; u0 = m; u1 = 1 - m; } + else { float keep = (tw / aspect) / th2; float m = (1 - keep) * 0.5f; v0 = m; v1 = 1 - m; } + dl->AddImageRounded(tex, sMin, sMax, ImVec2(u0, v0), ImVec2(u1, v1), IM_COL32_WHITE, + circle ? cr : sRound, circle ? ImDrawFlags_RoundCornersAll : sFlags); + borderShape(material::WithAlpha(material::OnSurface(), 45), 1.0f * dp); return; } // fall through to the type badge if the image failed to load @@ -158,19 +187,19 @@ static void drawContactAvatar(ImDrawList* dl, ImVec2 c, float r, const data::Add const bool known = (iconName == material::project_icons::kPickaxeName) || (material::project_icons::glyphForName(iconName) != nullptr); if (known) { - dl->AddCircleFilled(c, r, material::WithAlpha(typeCol, light ? 45 : 60)); - dl->AddCircle(c, r, material::WithAlpha(typeCol, 190), 0, 1.4f * dp); - material::project_icons::drawByName(dl, iconName, c, typeCol, iconFont, iconFont->LegacySize); + fillShape(material::WithAlpha(typeCol, light ? 45 : 60)); + borderShape(material::WithAlpha(typeCol, 190), 1.4f * dp); + material::project_icons::drawByName(dl, iconName, cc, typeCol, iconFont, glyphSz); return; } // fall through to the type badge if the icon name is unknown } // Default: Z/T type badge. - dl->AddCircleFilled(c, r, material::WithAlpha(typeCol, light ? 45 : 60)); - dl->AddCircle(c, r, material::WithAlpha(typeCol, 190), 0, 1.4f * dp); + fillShape(material::WithAlpha(typeCol, light ? 45 : 60)); + borderShape(material::WithAlpha(typeCol, 190), 1.4f * dp); const char* letter = shielded ? "Z" : "T"; - const ImVec2 ls = letterFont->CalcTextSizeA(letterFont->LegacySize, FLT_MAX, 0, letter); - dl->AddText(letterFont, letterFont->LegacySize, ImVec2(c.x - ls.x * 0.5f, c.y - ls.y * 0.5f), typeCol, letter); + const ImVec2 ls = letterFont->CalcTextSizeA(glyphSz, FLT_MAX, 0, letter); + dl->AddText(letterFont, glyphSz, ImVec2(cc.x - ls.x * 0.5f, cc.y - ls.y * 0.5f), typeCol, letter); } static bool isShieldedAddr(const std::string& a) { @@ -268,6 +297,8 @@ void RenderContactsTab(App* app) { s_avatarLoadsThisFrame = 0; // reset the per-frame avatar-decode budget (see getAvatarTexture) s_animateAvatars = !app->settings() || app->settings()->getAnimateAvatars(); + s_contactsShape = app->settings() ? app->settings()->getContactsAvatarShape() : 0; + s_contactsScale = app->settings() ? app->settings()->getContactsListScale() : 1.0f; auto& S = schema::UI(); // Reuse the existing address-book schema/column config for the table + add/edit form. auto addrTable = S.table("dialogs.address-book", "address-table"); @@ -416,9 +447,12 @@ void RenderContactsTab(App* app) const bool sh = isShieldedAddr(s_edit_address); const ImU32 tu = contactTypeColor(sh, light); ImVec2 avC(pMin.x + pad + avR, pMin.y + ph * 0.5f); - drawContactAvatar(pdl, avC, avR, pv, sh, tu, light, dp, letF, icoF); + const float pTabW = ph; // full preview-height square tab (shape 2) + drawContactAvatar(pdl, avC, avR, pv, sh, tu, light, dp, letF, icoF, true, + s_contactsShape, pMin, ImVec2(pMin.x + pTabW, pMax.y), 10.0f * dp); - float tx = avC.x + avR + Layout::spacingMd(); + float tx = (s_contactsShape == 2) ? (pMin.x + pTabW + Layout::spacingMd()) + : (avC.x + avR + Layout::spacingMd()); float textRight = pMax.x - pad; float ty = pMin.y + (ph - blockH) * 0.5f; const bool hasName = s_edit_label[0] != '\0'; @@ -819,7 +853,8 @@ void RenderContactsTab(App* app) // Inline tab content lives in a scroll child (mirrors peers_tab / explorer_tab). ImVec2 avail = ImGui::GetContentRegionAvail(); ImGui::BeginChild("##ContactsScroll", avail, false, - ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar); + ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoScrollWithMouse); // inner ##contactList owns the wheel (smooth scroll) // Toolbar — icon + label actions; the primary "Add New" is accented so it stands out. const float dp = ui::Layout::dpiScale(); @@ -885,8 +920,10 @@ void RenderContactsTab(App* app) 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; + const float segGap = 6.0f * dp; + const float grpW = segW + segGap + segH; // view toggle + gap + a square settings gear ImGui::SameLine(); - ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, ImGui::GetContentRegionAvail().x - segW)); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, ImGui::GetContentRegionAvail().x - grpW)); ImVec2 segOrigin = ImGui::GetCursorScreenPos(); int segClk = material::SegmentedControl(ImGui::GetWindowDrawList(), segOrigin, segW, segH, segIcons, 3, viewMode, material::Type().iconSmall(), @@ -896,11 +933,21 @@ void RenderContactsTab(App* app) app->settings()->save(); viewMode = segClk; } - ImGui::Dummy(ImVec2(segW, segH)); + // Contacts-customization gear, immediately right of the view toggle. + ImGui::SetCursorScreenPos(ImVec2(segOrigin.x + segW + segGap, segOrigin.y)); + material::IconButtonStyle gst; + gst.tooltip = TR("contacts_settings_tip"); + gst.hoverBg = material::WithAlpha(material::OnSurface(), 30); + gst.restBg = material::WithAlpha(material::OnSurface(), 20); + gst.bgRounding = 7.0f * dp; + if (material::IconButton("##contactsSettings", ICON_MD_SETTINGS, material::Type().iconSmall(), + ImVec2(segH, segH), gst)) + s_show_contacts_settings = true; + ImGui::SetCursorScreenPos(segOrigin); + ImGui::Dummy(ImVec2(grpW, segH)); } - // Search / filter - ImGui::Spacing(); + // Search / filter (tight against the toolbar row above — no extra spacer) ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x); ImGui::InputTextWithHint("##ContactSearch", TR("contacts_search_placeholder"), s_search, sizeof(s_search)); @@ -1009,9 +1056,12 @@ void RenderContactsTab(App* app) s_selected_index = static_cast(i); s_confirm_delete_idx = -1; openContextMenu = true; } bool tAvOnScreen = ImGui::IsRectVisible(tAvP, ImVec2(tAvP.x + tAvR * 2.0f, tAvP.y + tLineH)); + // The table is a compact inline view: a full-height "tab" (shape 2) can't render inside a + // table cell, so it falls back to the rounded square there (table scale is fixed too). + const int tShape = (s_contactsShape == 2) ? 1 : s_contactsShape; drawContactAvatar(ImGui::GetWindowDrawList(), ImVec2(tAvP.x + tAvR, tAvP.y + tLineH * 0.5f), tAvR, entry, shielded, typeColor(shielded), lightTheme, dp, - material::Type().caption(), material::Type().iconSmall(), tAvOnScreen); + material::Type().caption(), material::Type().iconSmall(), tAvOnScreen, tShape); ImGui::TableNextColumn(); ImGui::PushFont(material::Type().subtitle2()); ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(typeColor(shielded)), "%s", shielded ? "Z" : "T"); @@ -1044,7 +1094,9 @@ void RenderContactsTab(App* app) }); } ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0,0,0,0)); - ImGui::BeginChild("##contactList", ImVec2(0, listH), false, ImGuiWindowFlags_NoBackground); + ImGui::BeginChild("##contactList", ImVec2(0, listH), false, + ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollWithMouse); + material::ApplySmoothScroll(); // wheel-lerp scroll, matching the rest of the app if (visibleRows.empty()) { emptyState(ICON_MD_CONTACTS, book.empty() ? "address_book_empty" : "contacts_search_no_match"); } else { @@ -1056,10 +1108,13 @@ void RenderContactsTab(App* app) ImFont* lblF = material::Type().subtitle2(); ImFont* adrF = material::Type().caption(); ImFont* icoF = material::Type().iconSmall(); + const float sc = s_contactsScale; // card/list scale (avatar + text + row height; not the table) const float pad = 12.0f * dp; - const float avR = 15.0f * dp; + const float avR = 15.0f * dp * sc; const float rowH = avR * 2.0f + (asCard ? 16.0f * dp : 12.0f * dp); const float round = 10.0f * dp; + const float lblSz = lblF->LegacySize * sc; // scaled label + address text + const float adrSz = adrF->LegacySize * sc; bool rowDeleteRequested = false; // per-row delete is deferred until after the loop for (size_t vi = 0; vi < visibleRows.size(); ++vi) { size_t i = visibleRows[vi]; @@ -1103,18 +1158,23 @@ void RenderContactsTab(App* app) // Only animate when the row is actually on-screen, so a scrolled-off animated avatar // doesn't hold the app awake (this loop has no clipper). const bool rowOnScreen = ImGui::IsRectVisible(mn, mx); - drawContactAvatar(dl, avC, avR, entry, shielded, tu, lightTheme, dp, lblF, icoF, rowOnScreen); + // Shape 2 = a full-row-height left tab (a square the height of the row, rounded-left to + // match the row corner); shapes 0/1 are inset badges. Text shifts to clear the tab. + const float tabW = rowH; + drawContactAvatar(dl, avC, avR, entry, shielded, tu, lightTheme, dp, lblF, icoF, rowOnScreen, + s_contactsShape, mn, ImVec2(mn.x + tabW, mx.y), asCard ? round : 0.0f); // 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 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 = (s_contactsShape == 2) ? (mn.x + tabW + pad) + : (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 blockH = lblSz + adrSz + 3.0f * dp; 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->AddText(lblF, lblSz, ImVec2(tx, ty), material::OnSurface(), entry.label.c_str()); dl->PopClipRect(); // Un-collapse to the full address on hover (clipped to the text column so it never // runs under the trailing actions); middle-truncated otherwise. @@ -1122,7 +1182,7 @@ void RenderContactsTab(App* app) ? entry.address : util::truncateMiddle(entry.address, addrFrontLbl.truncate, addrBackLbl.truncate); dl->PushClipRect(ImVec2(tx, mn.y), ImVec2(textMaxX, mx.y), true); - dl->AddText(adrF, adrF->LegacySize, ImVec2(tx, ty + lblF->LegacySize + 3.0f * dp), + dl->AddText(adrF, adrSz, ImVec2(tx, ty + lblSz + 3.0f * dp), material::OnSurfaceMedium(), addr.c_str()); dl->PopClipRect(); // Trailing: the globe badge stays pinned far-right (global contacts); per-row copy/edit/ @@ -1226,6 +1286,119 @@ void RenderContactsTab(App* app) ImGui::EndChild(); renderEntryDialog(); + + // ---- Contacts customization modal (opened by the toolbar gear) — house BlurFloat overlay ---- + if (s_show_contacts_settings && app->settings()) { + auto* st = app->settings(); + material::OverlayDialogSpec ov; + ov.title = TR("contacts_settings_title"); + ov.p_open = &s_show_contacts_settings; + ov.style = material::OverlayStyle::BlurFloat; + ov.cardWidth = 460.0f; ov.idSuffix = "contactsettings"; + if (material::BeginOverlayDialog(ov)) { + const float ctrlW = 210.0f * dp; + auto rowStart = [&](const char* label) { + ImGui::AlignTextToFramePadding(); + ImGui::TextUnformatted(label); + ImGui::SameLine(); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, ImGui::GetContentRegionAvail().x - ctrlW)); + ImGui::SetNextItemWidth(ctrlW); + }; + // Draw one sample contact card (avatar + label + muted address) at a given shape/scale, using + // the SAME drawContactAvatar + row geometry the real list uses — so the preview matches reality. + auto drawSampleRow = [&](ImDrawList* pdl, ImVec2 mn, float w, + const data::AddressBookEntry& e, int shape, float sc) { + 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 * sc; + const float rowH = avR * 2.0f + 16.0f * dp; + const float round = 10.0f * dp; + const float lblSz = lblF->LegacySize * sc; + const float adrSz = adrF->LegacySize * sc; + const ImVec2 mx(mn.x + w, mn.y + rowH); + pdl->AddRectFilled(mn, mx, material::WithAlpha(material::OnSurface(), 18), round); + const bool shielded = isShieldedAddr(e.address); + const ImU32 tu = contactTypeColor(shielded, lightTheme); + const ImVec2 avC(mn.x + pad + avR, mn.y + rowH * 0.5f); + const float tabW = rowH; // shape 2 = full-row-height square tab on the left + drawContactAvatar(pdl, avC, avR, e, shielded, tu, lightTheme, dp, lblF, icoF, true, + shape, mn, ImVec2(mn.x + tabW, mx.y), round); + const float tx = (shape == 2) ? (mn.x + tabW + pad) : (mn.x + pad + avR * 2.0f + pad); + const float blockH = lblSz + adrSz + 3.0f * dp; + const float ty = mn.y + rowH * 0.5f - blockH * 0.5f; + const std::string addr = util::truncateMiddle(e.address, addrFrontLbl.truncate, addrBackLbl.truncate); + pdl->PushClipRect(ImVec2(tx, mn.y), ImVec2(mx.x - pad, mx.y), true); + pdl->AddText(lblF, lblSz, ImVec2(tx, ty), material::OnSurface(), e.label.c_str()); + pdl->AddText(adrF, adrSz, ImVec2(tx, ty + lblSz + 3.0f * dp), material::OnSurfaceMedium(), addr.c_str()); + pdl->PopClipRect(); + }; + // ---- Live preview: two sample cards at the current shape + scale (updates as controls move). ---- + { + const float sc = st->getContactsListScale(); + const int shape = st->getContactsAvatarShape(); + data::AddressBookEntry sA; sA.label = "Ava Reyes"; sA.address = "zs1preview0shielded0contact0demo0address0q9k"; + data::AddressBookEntry sB; sB.label = "Node Ops"; sB.address = "t1PreviewTransparentContactDemoAddr8xQ"; + ImDrawList* pdl = ImGui::GetWindowDrawList(); + const float padIn = 10.0f * dp, gap = 8.0f * dp; + const float w = ImGui::GetContentRegionAvail().x; + const float avR = 15.0f * dp * sc; + const float rowH = avR * 2.0f + 16.0f * dp; + const float panelH = 2.0f * rowH + gap + 2.0f * padIn; + const ImVec2 pOrigin = ImGui::GetCursorScreenPos(); + material::GlassPanelSpec g; g.rounding = 12.0f * dp; g.fillAlpha = 14; g.borderAlpha = 34; + material::DrawGlassPanel(pdl, pOrigin, ImVec2(pOrigin.x + w, pOrigin.y + panelH), g); + const ImVec2 rmn(pOrigin.x + padIn, pOrigin.y + padIn); + const float rowW = w - 2.0f * padIn; + drawSampleRow(pdl, rmn, rowW, sA, shape, sc); + drawSampleRow(pdl, ImVec2(rmn.x, rmn.y + rowH + gap), rowW, sB, shape, sc); + ImGui::Dummy(ImVec2(w, panelH)); + ImGui::Dummy(ImVec2(0.0f, 10.0f * dp)); + } + // Avatar shape — circle, rounded square, or a full-row-height left tab (segmented control, + // matching the chat settings modal's segmented style; the live preview above reflects it). + { + rowStart(TR("contacts_avatar_shape")); + const char* items[] = { TR("contacts_shape_circle"), TR("contacts_shape_square"), + TR("contacts_shape_tab") }; + const float segH = ImGui::GetFrameHeight(); + const ImVec2 segOrigin = ImGui::GetCursorScreenPos(); + const int cur = st->getContactsAvatarShape(); + const int clk = material::SegmentedControl(ImGui::GetWindowDrawList(), segOrigin, ctrlW, segH, + items, 3, cur, material::Type().caption(), + "##contactsShape", dp); + ImGui::SetCursorScreenPos(segOrigin); + ImGui::Dummy(ImVec2(ctrlW, segH)); // restore + reserve the control's rect for layout flow + if (clk >= 0 && clk != cur) { st->setContactsAvatarShape(clk); st->save(); } + } + // Card/list row scale (does not affect the table view). + { + ImGui::Dummy(ImVec2(0.0f, 4.0f * dp)); + rowStart(TR("contacts_list_scale")); + float v = st->getContactsListScale(); + if (ImGui::SliderFloat("##contactsScale", &v, 0.8f, 1.5f, "%.2fx", ImGuiSliderFlags_AlwaysClamp)) { + st->setContactsListScale(v); st->save(); + } + } + ImGui::Dummy(ImVec2(0.0f, 8.0f * dp)); + { + // Done: sized to its text (+ a little shoulder room) and centered, not full width. + const char* doneLbl = TR("chat_settings_done"); + ImGui::PushFont(material::Type().button()); + const float bw = ImGui::CalcTextSize(doneLbl).x + ImGui::GetStyle().FramePadding.x * 2.0f + 28.0f * dp; + ImGui::PopFont(); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, (ImGui::GetContentRegionAvail().x - bw) * 0.5f)); + ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 205))); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(material::Primary())); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 235))); + if (material::TactileButton(doneLbl, ImVec2(bw, 0))) + s_show_contacts_settings = false; + ImGui::PopStyleColor(3); + } + material::EndOverlayDialog(); + } + } } void ContactsSweepOpenEditDialog(int avatarMode)