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) <noreply@anthropic.com>
This commit is contained in:
2026-07-17 22:35:03 -05:00
parent 8b55a90102
commit defe14d913
3 changed files with 219 additions and 34 deletions

View File

@@ -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_;

View File

@@ -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

View File

@@ -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<std::string, AvatarTex> 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<float>(t->w), th2 = static_cast<float>(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<int>(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)