diff --git a/src/ui/windows/contacts_tab.cpp b/src/ui/windows/contacts_tab.cpp index 95ea52f..df51135 100644 --- a/src/ui/windows/contacts_tab.cpp +++ b/src/ui/windows/contacts_tab.cpp @@ -11,19 +11,27 @@ #include "../schema/ui_schema.h" #include "../material/draw_helpers.h" #include "imgui.h" +#include +#include #include +#include +#include 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(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(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(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 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(i)); if (ImGui::Selectable(entry.label.c_str(), is_selected, ImGuiSelectableFlags_SpanAllColumns)) { + if (s_selected_index != static_cast(i)) s_confirm_delete_idx = -1; s_selected_index = static_cast(i); } - - // Double-click to edit if (ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(0)) { s_selected_index = static_cast(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(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(visibleRows[k]) == s_selected_index) { curPos = static_cast(k); break; } + + if (ImGui::IsKeyPressed(ImGuiKey_DownArrow)) { + curPos = (curPos < 0) ? 0 : std::min(curPos + 1, static_cast(visibleRows.size()) - 1); + s_selected_index = static_cast(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(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(); diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index de75f8d..4c6909a 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -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";