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:
@@ -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();
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user