feat(contacts): search, sort, keyboard nav, contrast + Z/T badges

Phase 0d — the accessibility pass on the Contacts tab.

- Search box filters by label/address/notes (case-insensitive); a distinct
  "no matching contacts" empty state.
- Sortable columns (ImGuiTableFlags_Sortable) — click a header to sort the
  view by label/address/notes, ascending or descending.
- Keyboard nav (when the tab owns focus, no field/modal active): Up/Down move
  the selection through the *visible* order, Enter edits, Delete deletes
  (feeding the two-click confirm), Ctrl+C copies.
- Contrast: the address is no longer rendered as muted TextDisabled (it's the
  row's key data) — normal legible text, with a coloured Z/T type badge.
  Notes are legible too.
- The add/edit form focuses its first field on open (SetKeyboardFocusHere).
- The delete confirm is now visible: the Delete button relabels to
  "Confirm delete?" while armed, instead of a transient toast.

Correctness: selection is tracked by STORAGE index, decoupled from the
filtered/sorted visible order, so edit/delete/copy always target the right
entry. (ImGui renders to a canvas with no OS accessibility tree, so this is a
keyboard/contrast/findability pass, not screen-reader support.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-05 17:10:24 -05:00
parent ddd53dc006
commit 0ff56bf5a4
2 changed files with 152 additions and 44 deletions

View File

@@ -11,19 +11,27 @@
#include "../schema/ui_schema.h"
#include "../material/draw_helpers.h"
#include "imgui.h"
#include <algorithm>
#include <cctype>
#include <cstring>
#include <string>
#include <vector>
namespace dragonx {
namespace ui {
// Tab state (formerly AddressBookDialog's class statics). One selection/edit
// buffer set for the whole tab; the add/edit form is a modal layered on top.
// Tab state (formerly AddressBookDialog's class statics). s_selected_index is a
// STORAGE index into book.entries() — decoupled from the visible row order so
// search + sort can't corrupt edit/delete/copy targets.
static int s_selected_index = -1;
static bool s_show_add_dialog = false;
static bool s_show_edit_dialog = false;
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] = "";
static char s_edit_address[512] = "";
static char s_edit_notes[512] = "";
static char s_search[128] = "";
static void copyEditField(char* dest, size_t destSize, const std::string& source) {
if (destSize == 0) return;
@@ -31,6 +39,23 @@ static void copyEditField(char* dest, size_t destSize, const std::string& source
dest[destSize - 1] = '\0';
}
static std::string toLower(std::string s) {
std::transform(s.begin(), s.end(), s.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return s;
}
static bool matchesSearch(const data::AddressBookEntry& e, const std::string& needleLower) {
if (needleLower.empty()) return true;
return toLower(e.label).find(needleLower) != std::string::npos
|| toLower(e.address).find(needleLower) != std::string::npos
|| toLower(e.notes).find(needleLower) != std::string::npos;
}
static bool isShieldedAddr(const std::string& a) {
return !a.empty() && a[0] == 'z';
}
void RenderContactsTab(App* app)
{
auto& S = schema::UI();
@@ -42,6 +67,8 @@ void RenderContactsTab(App* app)
auto notesInput = S.input("dialogs.address-book", "notes-input");
auto actionBtn = S.button("dialogs.address-book", "action-button");
auto& book = app->addressBook();
auto clearEditFields = []() {
s_edit_label[0] = '\0';
s_edit_address[0] = '\0';
@@ -54,6 +81,33 @@ void RenderContactsTab(App* app)
copyEditField(s_edit_notes, sizeof(s_edit_notes), entry.notes);
};
bool has_selection = s_selected_index >= 0 && s_selected_index < static_cast<int>(book.size());
// Shared delete/copy actions (used by both the toolbar buttons and keyboard shortcuts).
auto doDelete = [&]() {
if (!has_selection) return;
if (s_confirm_delete_idx == s_selected_index) {
book.removeEntry(s_selected_index);
s_selected_index = -1;
s_confirm_delete_idx = -1;
Notifications::instance().success(TR("address_book_deleted"));
} else {
// Require a second, deliberate confirm (no undo for a removed contact).
s_confirm_delete_idx = s_selected_index;
}
};
auto doCopy = [&]() {
if (!has_selection) return;
ImGui::SetClipboardText(book.entries()[s_selected_index].address.c_str());
Notifications::instance().info(TR("address_copied"));
};
auto openEdit = [&]() {
if (!has_selection) return;
loadEditFields(book.entries()[s_selected_index]);
s_show_edit_dialog = true;
s_focus_edit_field = true;
};
// Add/edit form — a modal popup layered over the tab.
auto renderEntryDialog = [&]() {
bool isEdit = s_show_edit_dialog;
@@ -70,6 +124,11 @@ void RenderContactsTab(App* app)
if (material::BeginOverlayDialog(title, open, dialogW, 0.94f,
Layout::kDialogCompactBottomRatio(), id)) {
// Focus the first field the frame the dialog opens so it's keyboard-ready.
if (s_focus_edit_field) {
ImGui::SetKeyboardFocusHere();
s_focus_edit_field = false;
}
material::LabeledInput(TR("label"), isEdit ? "##EditLabel" : "##AddLabel",
s_edit_label, sizeof(s_edit_label), formW);
@@ -138,84 +197,102 @@ void RenderContactsTab(App* app)
ImGui::BeginChild("##ContactsScroll", avail, false,
ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoScrollbar);
auto& book = app->addressBook();
// Toolbar
if (material::StyledButton(TR("address_book_add_new"), ImVec2(0,0), S.resolveFont(actionBtn.font))) {
s_show_add_dialog = true;
s_focus_edit_field = true;
clearEditFields();
}
ImGui::SameLine();
bool has_selection = s_selected_index >= 0 && s_selected_index < static_cast<int>(book.size());
if (!has_selection) ImGui::BeginDisabled();
if (material::StyledButton(TR("edit"), ImVec2(0,0), S.resolveFont(actionBtn.font))) {
if (has_selection) {
const auto& entry = book.entries()[s_selected_index];
loadEditFields(entry);
s_show_edit_dialog = true;
}
}
if (material::StyledButton(TR("edit"), ImVec2(0,0), S.resolveFont(actionBtn.font)))
openEdit();
ImGui::SameLine();
static int s_confirmDeleteIdx = -1;
if (material::StyledButton(TR("delete"), ImVec2(0,0), S.resolveFont(actionBtn.font))) {
if (has_selection) {
if (s_confirmDeleteIdx == s_selected_index) {
book.removeEntry(s_selected_index);
s_selected_index = -1;
s_confirmDeleteIdx = -1;
Notifications::instance().success(TR("address_book_deleted"));
} else {
// Require a second click to confirm (no undo for a removed contact).
s_confirmDeleteIdx = s_selected_index;
Notifications::instance().warning("Click Delete again to remove this entry.");
}
}
}
// 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::StyledButton(delLabel, ImVec2(0,0), S.resolveFont(actionBtn.font)))
doDelete();
ImGui::SameLine();
if (material::StyledButton(TR("copy_address"), ImVec2(0,0), S.resolveFont(actionBtn.font))) {
if (has_selection) {
ImGui::SetClipboardText(book.entries()[s_selected_index].address.c_str());
Notifications::instance().info(TR("address_copied"));
}
}
if (material::StyledButton(TR("copy_address"), ImVec2(0,0), S.resolveFont(actionBtn.font)))
doCopy();
if (!has_selection) ImGui::EndDisabled();
// Search / filter
ImGui::Spacing();
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x);
ImGui::InputTextWithHint("##ContactSearch", TR("contacts_search_placeholder"),
s_search, sizeof(s_search));
bool searchActive = ImGui::IsItemActive();
std::string needle = toLower(s_search);
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
// Filtered, sorted view — storage indices only (keeps s_selected_index meaningful).
std::vector<size_t> visibleRows;
visibleRows.reserve(book.size());
for (size_t i = 0; i < book.size(); ++i) {
if (matchesSearch(book.entries()[i], needle)) 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) tableH = 120.0f;
if (ImGui::BeginTable("AddressBookTable", 3,
ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg |
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, labelColW);
ImGui::TableSetupColumn(TR("label"), ImGuiTableColumnFlags_WidthFixed | ImGuiTableColumnFlags_DefaultSort, labelColW);
ImGui::TableSetupColumn(TR("address_label"), ImGuiTableColumnFlags_WidthStretch);
ImGui::TableSetupColumn(TR("notes"), ImGuiTableColumnFlags_WidthFixed, notesColW);
ImGui::TableSetupScrollFreeze(0, 1);
ImGui::TableHeadersRow();
// 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 std::string* ka; const std::string* kb;
switch (sc.ColumnIndex) {
case 1: ka = &entries[a].address; kb = &entries[b].address; break;
case 2: ka = &entries[a].notes; kb = &entries[b].notes; break;
default: ka = &entries[a].label; kb = &entries[b].label; break;
}
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 i = 0; i < book.size(); i++) {
for (size_t vi = 0; vi < visibleRows.size(); ++vi) {
size_t i = visibleRows[vi];
const auto& entry = book.entries()[i];
ImGui::TableNextRow();
@@ -225,30 +302,33 @@ void RenderContactsTab(App* app)
bool is_selected = (s_selected_index == static_cast<int>(i));
if (ImGui::Selectable(entry.label.c_str(), is_selected,
ImGuiSelectableFlags_SpanAllColumns)) {
if (s_selected_index != static_cast<int>(i)) s_confirm_delete_idx = -1;
s_selected_index = static_cast<int>(i);
}
// Double-click to edit
if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(0)) {
s_selected_index = static_cast<int>(i);
loadEditFields(entry);
s_show_edit_dialog = true;
openEdit();
}
ImGui::TableNextColumn();
// Truncate long addresses
// Z/T type badge + address in normal (legible) text — not muted.
bool shielded = isShieldedAddr(entry.address);
ImGui::TextColored(shielded ? ImVec4(0.35f, 0.80f, 0.60f, 1.0f)
: ImVec4(0.95f, 0.72f, 0.30f, 1.0f),
"%s", shielded ? "Z" : "T");
ImGui::SameLine(0.0f, 6.0f);
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);
}
ImGui::TextDisabled("%s", addr_display.c_str());
ImGui::TextUnformatted(addr_display.c_str());
if (ImGui::IsItemHovered()) {
material::Tooltip("%s", entry.address.c_str());
}
ImGui::TableNextColumn();
ImGui::TextDisabled("%s", entry.notes.c_str());
ImGui::TextUnformatted(entry.notes.c_str());
ImGui::PopID();
}
@@ -260,6 +340,31 @@ void RenderContactsTab(App* app)
// Status line
ImGui::TextDisabled(TR("address_book_count"), book.size());
// Keyboard shortcuts — only when the tab owns focus (no field being edited, no modal up).
if (!searchActive && !ImGui::IsAnyItemActive() &&
!s_show_add_dialog && !s_show_edit_dialog && !visibleRows.empty()) {
// Current selection's position within the visible view.
int curPos = -1;
for (size_t k = 0; k < visibleRows.size(); ++k)
if (static_cast<int>(visibleRows[k]) == s_selected_index) { curPos = static_cast<int>(k); break; }
if (ImGui::IsKeyPressed(ImGuiKey_DownArrow)) {
curPos = (curPos < 0) ? 0 : std::min(curPos + 1, static_cast<int>(visibleRows.size()) - 1);
s_selected_index = static_cast<int>(visibleRows[curPos]);
s_confirm_delete_idx = -1;
} else if (ImGui::IsKeyPressed(ImGuiKey_UpArrow)) {
curPos = (curPos < 0) ? 0 : std::max(curPos - 1, 0);
s_selected_index = static_cast<int>(visibleRows[curPos]);
s_confirm_delete_idx = -1;
} else if (ImGui::IsKeyPressed(ImGuiKey_Enter, false) || ImGui::IsKeyPressed(ImGuiKey_KeypadEnter, false)) {
openEdit();
} else if (ImGui::IsKeyPressed(ImGuiKey_Delete, false)) {
doDelete();
} else if (ImGui::GetIO().KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_C, false)) {
doCopy();
}
}
ImGui::EndChild();
renderEntryDialog();

View File

@@ -211,6 +211,9 @@ void I18n::loadBuiltinEnglish()
strings_["transactions"] = "Transactions";
strings_["history"] = "History";
strings_["contacts"] = "Contacts";
strings_["contacts_search_placeholder"] = "Search contacts...";
strings_["contacts_search_no_match"] = "No matching contacts";
strings_["address_book_confirm_delete"] = "Confirm delete?";
strings_["mining"] = "Mining";
strings_["peers"] = "Peers";
strings_["market"] = "Market";