fix(contacts): audit fixes — clip, tofu badge, plural, DPI, columns, toolbar

Address the confirmed findings from a workflow audit of the Contacts tab body:

- Label clip: the Label column was WidthFixed 150px and clipped long labels
  mid-word ("drgx pool payout a…"). Make all three columns WidthStretch (label 1.5,
  address 2.6, notes 1.0) so the label grows with the tab and the notes column no
  longer reserves a fixed empty block on the right (notes = low weight, per request).
- Globe badge tofu: the global-contact ICON_MD_PUBLIC badge was drawn with the text
  font (no Material glyphs) → rendered as "?". Push Type().iconSmall() around it.
- Plural: "1 addresses saved" -> add address_book_count_one ("%zu address saved",
  8 langs, %zu kept so the format signature matches) and branch on count == 1.
- DPI: the SameLine badge gaps (6/8px) and the table-height floor (120px) are now
  * Layout::dpiScale(); the WidthStretch columns are relative so need no scaling.
- Toolbar: give the four actions leading Material icons (person-add / edit / delete /
  content-copy) and accent the primary "Add New" — via a small local icon+label
  tactile-button helper (ImGui has no two-font button), icons inherit the
  disabled-aware text alpha so they gray out with BeginDisabled().

Build + ctest + hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-14 20:49:15 -05:00
parent ba13875eaf
commit 1b0006a4af
10 changed files with 60 additions and 16 deletions

View File

@@ -30,6 +30,7 @@
"address_book_added": "Adresse zum Buch hinzugefügt",
"address_book_confirm_delete": "Löschen bestätigen?",
"address_book_count": "%zu Adressen gespeichert",
"address_book_count_one": "%zu Adresse gespeichert",
"address_book_deleted": "Eintrag gelöscht",
"address_book_edit": "Adresse bearbeiten",
"address_book_empty": "Keine gespeicherten Adressen. Klicken Sie auf 'Neue hinzufügen', um eine hinzuzufügen.",

View File

@@ -30,6 +30,7 @@
"address_book_added": "Dirección agregada a la libreta",
"address_book_confirm_delete": "¿Confirmar eliminación?",
"address_book_count": "%zu direcciones guardadas",
"address_book_count_one": "%zu dirección guardada",
"address_book_deleted": "Entrada eliminada",
"address_book_edit": "Editar Dirección",
"address_book_empty": "No hay direcciones guardadas. Haz clic en 'Agregar Nueva' para añadir una.",

View File

@@ -30,6 +30,7 @@
"address_book_added": "Adresse ajoutée au carnet",
"address_book_confirm_delete": "Confirmer la suppression ?",
"address_book_count": "%zu adresses enregistrées",
"address_book_count_one": "%zu adresse enregistrée",
"address_book_deleted": "Entrée supprimée",
"address_book_edit": "Modifier l'adresse",
"address_book_empty": "Aucune adresse enregistrée. Cliquez sur 'Ajouter' pour en créer une.",

View File

@@ -30,6 +30,7 @@
"address_book_added": "アドレスをアドレス帳に追加しました",
"address_book_confirm_delete": "削除しますか?",
"address_book_count": "%zu 件のアドレスを保存済み",
"address_book_count_one": "%zu 件のアドレスを保存",
"address_book_deleted": "エントリを削除しました",
"address_book_edit": "アドレスを編集",
"address_book_empty": "保存されたアドレスがありません。「新規追加」をクリックして追加してください。",

View File

@@ -30,6 +30,7 @@
"address_book_added": "주소록에 주소를 추가했습니다",
"address_book_confirm_delete": "삭제하시겠습니까?",
"address_book_count": "저장된 주소 %zu개",
"address_book_count_one": "주소 %zu개 저장됨",
"address_book_deleted": "항목이 삭제되었습니다",
"address_book_edit": "주소 편집",
"address_book_empty": "저장된 주소가 없습니다. '새로 추가'를 클릭하여 추가하세요.",

View File

@@ -30,6 +30,7 @@
"address_book_added": "Endereço adicionado ao livro",
"address_book_confirm_delete": "Confirmar exclusão?",
"address_book_count": "%zu endereços salvos",
"address_book_count_one": "%zu endereço salvo",
"address_book_deleted": "Entrada excluída",
"address_book_edit": "Editar Endereço",
"address_book_empty": "Nenhum endereço salvo. Clique em 'Adicionar Novo' para criar um.",

View File

@@ -30,6 +30,7 @@
"address_book_added": "Адрес добавлен в книгу",
"address_book_confirm_delete": "Подтвердить удаление?",
"address_book_count": "%zu адресов сохранено",
"address_book_count_one": "Сохранён %zu адрес",
"address_book_deleted": "Запись удалена",
"address_book_edit": "Редактировать адрес",
"address_book_empty": "Нет сохранённых адресов. Нажмите 'Добавить новый', чтобы создать.",

View File

@@ -30,6 +30,7 @@
"address_book_added": "地址已添加到通讯录",
"address_book_confirm_delete": "确认删除?",
"address_book_count": "已保存 %zu 个地址",
"address_book_count_one": "已保存 %zu 个地址",
"address_book_deleted": "条目已删除",
"address_book_edit": "编辑地址",
"address_book_empty": "没有保存的地址。点击'添加新地址'创建一个。",

View File

@@ -10,6 +10,8 @@
#include "../notifications.h"
#include "../schema/ui_schema.h"
#include "../material/draw_helpers.h"
#include "../material/type.h"
#include "../layout.h"
#include "imgui.h"
#include <algorithm>
#include <cctype>
@@ -215,8 +217,37 @@ void RenderContactsTab(App* app)
ImGui::BeginChild("##ContactsScroll", avail, false,
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar);
// Toolbar
if (material::TactileButton(TR("address_book_add_new"), ImVec2(0,0), S.resolveFont(actionBtn.font))) {
// Toolbar — icon + label actions; the primary "Add New" is accented so it stands out.
const float dp = ui::Layout::dpiScale();
ImFont* tbTxtF = S.resolveFont(actionBtn.font);
ImFont* tbIcoF = material::Type().iconSmall();
// Renders a tactile button with a leading Material icon (icon font) + label (text font). ImGui has no
// native two-font button, so draw the styled rect via TactileButton (empty ##id label) then paint the
// icon + label over it. `primary` accents the main action; the icon/label inherit the (disabled-aware)
// text alpha so they gray out correctly inside BeginDisabled().
auto toolbarBtn = [&](const char* id, const char* glyph, const char* label, bool primary) -> bool {
ImGui::PushFont(tbIcoF); const float iw = ImGui::CalcTextSize(glyph).x; ImGui::PopFont();
ImGui::PushFont(tbTxtF); const float tw = ImGui::CalcTextSize(label).x; ImGui::PopFont();
const float gap = 6.0f * dp;
const float w = ImGui::GetStyle().FramePadding.x * 2.0f + iw + gap + tw;
const float h = ImGui::GetFrameHeight();
if (primary) {
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)));
}
const ImVec2 p = ImGui::GetCursorScreenPos();
const bool clicked = material::TactileButton(id, ImVec2(w, h), tbTxtF);
if (primary) ImGui::PopStyleColor(3);
ImDrawList* dl = ImGui::GetWindowDrawList();
const ImU32 col = ImGui::GetColorU32(ImGuiCol_Text);
const float sx = p.x + (w - (iw + gap + tw)) * 0.5f;
dl->AddText(tbIcoF, tbIcoF->LegacySize, ImVec2(sx, p.y + (h - tbIcoF->LegacySize) * 0.5f), col, glyph);
dl->AddText(tbTxtF, tbTxtF->LegacySize, ImVec2(sx + iw + gap, p.y + (h - tbTxtF->LegacySize) * 0.5f), col, label);
return clicked;
};
if (toolbarBtn("##ab_add", ICON_MD_PERSON_ADD, TR("address_book_add_new"), /*primary=*/true)) {
s_show_add_dialog = true;
s_focus_edit_field = true;
clearEditFields();
@@ -226,7 +257,7 @@ void RenderContactsTab(App* app)
if (!has_selection) ImGui::BeginDisabled();
if (material::TactileButton(TR("edit"), ImVec2(0,0), S.resolveFont(actionBtn.font)))
if (toolbarBtn("##ab_edit", ICON_MD_EDIT, TR("edit"), false))
openEdit();
ImGui::SameLine();
@@ -234,12 +265,12 @@ void RenderContactsTab(App* app)
// Delete — relabel to a visible confirm prompt while armed (not just a toast).
bool armed = has_selection && s_confirm_delete_idx == s_selected_index;
const char* delLabel = armed ? TR("address_book_confirm_delete") : TR("delete");
if (material::TactileButton(delLabel, ImVec2(0,0), S.resolveFont(actionBtn.font)))
if (toolbarBtn("##ab_del", ICON_MD_DELETE, delLabel, false))
doDelete();
ImGui::SameLine();
if (material::TactileButton(TR("copy_address"), ImVec2(0,0), S.resolveFont(actionBtn.font)))
if (toolbarBtn("##ab_copy", ICON_MD_CONTENT_COPY, TR("copy_address"), false))
doCopy();
if (!has_selection) ImGui::EndDisabled();
@@ -271,17 +302,18 @@ void RenderContactsTab(App* app)
// 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) tableH = 120.0f;
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)))
{
float labelColW = (addrTable.columns.count("label") && addrTable.columns.at("label").width > 0) ? addrTable.columns.at("label").width : 150;
float notesColW = (addrTable.columns.count("notes") && addrTable.columns.at("notes").width > 0) ? addrTable.columns.at("notes").width : 150;
ImGui::TableSetupColumn(TR("label"), ImGuiTableColumnFlags_WidthFixed | ImGuiTableColumnFlags_DefaultSort, labelColW);
ImGui::TableSetupColumn(TR("address_label"), ImGuiTableColumnFlags_WidthStretch);
ImGui::TableSetupColumn(TR("notes"), ImGuiTableColumnFlags_WidthFixed, notesColW);
// 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();
@@ -341,7 +373,7 @@ void RenderContactsTab(App* app)
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::SameLine(0.0f, 6.0f);
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)) {
@@ -351,11 +383,14 @@ void RenderContactsTab(App* app)
if (ImGui::IsItemHovered()) {
material::Tooltip("%s", entry.address.c_str());
}
// Globe badge marks a global contact (shown in every wallet).
// 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 (entry.isGlobal()) {
ImGui::SameLine(0.0f, 8.0f);
ImGui::SameLine(0.0f, 8.0f * dp);
ImGui::PushFont(material::Type().iconSmall());
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(material::OnSurfaceMedium()),
"%s", ICON_MD_PUBLIC);
ImGui::PopFont();
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("contact_global_badge_tt"));
}
@@ -369,8 +404,8 @@ void RenderContactsTab(App* app)
ImGui::EndTable();
}
// Status line
ImGui::TextDisabled(TR("address_book_count"), book.size());
// 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());
// Keyboard shortcuts — only when the tab owns focus (no field being edited, no modal up).
if (!searchActive && !ImGui::IsAnyItemActive() &&

View File

@@ -1225,6 +1225,7 @@ void I18n::loadBuiltinEnglish()
strings_["contact_global_badge_tt"] = "Global contact — visible in every wallet";
strings_["address_book_added"] = "Address added to book";
strings_["address_book_count"] = "%zu addresses saved";
strings_["address_book_count_one"] = "%zu address saved";
strings_["address_book_deleted"] = "Entry deleted";
strings_["address_book_edit"] = "Edit Address";
strings_["address_book_empty"] = "No saved addresses. Click 'Add New' to add one.";