From c3f981b52e9c9c1e92cee79b528f981f4900f4a4 Mon Sep 17 00:00:00 2001 From: DanS Date: Tue, 13 Oct 2026 09:24:51 -0500 Subject: [PATCH] fix(history): keep the open detail row across paging and filtering The inline detail panel was tracked by the filtered list index. The display list is rebuilt and re-sorted on every data change, sort switch or filter edit, so a stored index pointed at a different transaction after the rebuild, and a page reset was triggered by any change in the row count, bouncing the view back to page 1. - The open row is now tracked by a stable content key (txid, display type, address and the amount's exact bits) so the right row stays open across rebuilds. - The page resets and the open row collapses only when a filter input changes (type, sort or the full search text). A new or removed row is data and now just clamps the current page. - The open row is also cleared on page navigation and when the list empties, so a wallet switch does not resurrect an old expansion. Adds pure helpers rowKey/filterInputsChanged to transactions_tab_helpers.h. Tests: rowKey distinguishes each field and separates +0.0 from -0.0, and filterInputsChanged latches each input change but not the first frame. Co-Authored-By: Claude Opus 4.8 --- src/ui/windows/transactions_tab.cpp | 29 +++++++++------- src/ui/windows/transactions_tab_helpers.h | 41 +++++++++++++++++++++++ tests/test_phase4.cpp | 26 ++++++++++++++ 3 files changed, 83 insertions(+), 13 deletions(-) diff --git a/src/ui/windows/transactions_tab.cpp b/src/ui/windows/transactions_tab.cpp index ae9bbbc..10d2ccd 100644 --- a/src/ui/windows/transactions_tab.cpp +++ b/src/ui/windows/transactions_tab.cpp @@ -177,12 +177,13 @@ void RenderTransactionsTab(App* app) std::string txLoadingText = app->transactionRefreshProgressText(); bool txLoading = !txLoadingText.empty(); - // Expanded row index for inline detail - static int s_expanded_row = -1; + // Open inline detail, keyed by row identity (history::rowKey), NOT by list index: the display list is rebuilt + // and re-sorted on every change, so an index would land on a different transaction. Empty = none open. + static std::string s_expanded_key; // Pagination state static int s_current_page = 0; - static int s_prev_filter_hash = 0; // detect filter changes to reset page + static history::FilterInputs s_prev_filter; // detect filter-input changes to reset page // ================================================================ // Summary Cards — Received | Sent | Mined @@ -550,12 +551,13 @@ void RenderTransactionsTab(App* app) int perPage = std::max(10, (int)schema::UI().drawElement("tabs.transactions", "rows-per-page").sizeOr(50.0f)); int totalPages = std::max(1, (filtered_count + perPage - 1) / perPage); - // Reset page when filters change - int filterHash = type_filter * 1000003 + s_sort_mode * 7919 + filtered_count * 31 + static_cast(search_str.size()); - if (filterHash != s_prev_filter_hash) { + // Reset page (and collapse the open row) only when a filter INPUT changes (type, sort, or the search text). + // The row count is data: a new/removed row just clamps the page below instead of bouncing to page 1. + if (history::filterInputsChanged(s_prev_filter, type_filter, s_sort_mode, search_str)) { s_current_page = 0; - s_prev_filter_hash = filterHash; + s_expanded_key.clear(); } + if (filtered_count == 0) s_expanded_key.clear(); // wallet switch / emptied list: don't resurrect an old expansion if (s_current_page >= totalPages) s_current_page = totalPages - 1; if (s_current_page < 0) s_current_page = 0; @@ -582,7 +584,7 @@ void RenderTransactionsTab(App* app) ImGui::BeginDisabled(s_current_page == 0); if (TactileButton(ICON_MD_FIRST_PAGE "##txFirst", ImVec2(btnW, btnW), Type().iconSmall())) { s_current_page = 0; - s_expanded_row = -1; + s_expanded_key.clear(); } ImGui::EndDisabled(); @@ -592,7 +594,7 @@ void RenderTransactionsTab(App* app) ImGui::BeginDisabled(s_current_page == 0); if (TactileButton(ICON_MD_CHEVRON_LEFT "##txPrev", ImVec2(btnW, btnW), Type().iconSmall())) { s_current_page--; - s_expanded_row = -1; + s_expanded_key.clear(); } ImGui::EndDisabled(); @@ -619,7 +621,7 @@ void RenderTransactionsTab(App* app) ImGui::BeginDisabled(s_current_page >= totalPages - 1); if (TactileButton(ICON_MD_CHEVRON_RIGHT "##txNext", ImVec2(btnW, btnW), Type().iconSmall())) { s_current_page++; - s_expanded_row = -1; + s_expanded_key.clear(); } ImGui::EndDisabled(); @@ -629,7 +631,7 @@ void RenderTransactionsTab(App* app) ImGui::BeginDisabled(s_current_page >= totalPages - 1); if (TactileButton(ICON_MD_LAST_PAGE "##txLast", ImVec2(btnW, btnW), Type().iconSmall())) { s_current_page = totalPages - 1; - s_expanded_row = -1; + s_expanded_key.clear(); } ImGui::EndDisabled(); } @@ -686,7 +688,8 @@ void RenderTransactionsTab(App* app) for (int fi = pageStart; fi < pageEnd; fi++) { size_t i = filtered_indices[fi]; const auto& tx = display_txns[i]; - bool is_expanded = (s_expanded_row == static_cast(i)); + const std::string rk = history::rowKey(tx.txid, tx.display_type, tx.address, tx.amount); // <= perPage rows/frame + bool is_expanded = !s_expanded_key.empty() && s_expanded_key == rk; ImGui::PushID(static_cast(i)); ImVec2 rowPos = ImGui::GetCursorScreenPos(); @@ -832,7 +835,7 @@ void RenderTransactionsTab(App* app) // Click to expand/collapse + invisible button for interaction ImGui::InvisibleButton("##txRow", ImVec2(innerW, rowH)); if (ImGui::IsItemClicked(0)) { - s_expanded_row = is_expanded ? -1 : static_cast(i); + if (is_expanded) s_expanded_key.clear(); else s_expanded_key = rk; } // Tooltip diff --git a/src/ui/windows/transactions_tab_helpers.h b/src/ui/windows/transactions_tab_helpers.h index bd8a330..e953bc2 100644 --- a/src/ui/windows/transactions_tab_helpers.h +++ b/src/ui/windows/transactions_tab_helpers.h @@ -5,6 +5,8 @@ // Pure helpers behind the History tab (no ImGui), so they can be unit-tested. #include "../../data/wallet_state.h" #include +#include +#include #include #include #include @@ -45,6 +47,45 @@ inline TxidGroups groupByTxid(const std::vector& txs) return g; } +// Stable identity of a display row, used to keep an inline detail panel open across list rebuilds. +// txid alone is not unique (a tx can show several rows: multiple receive outputs, unmerged legs), so +// add the display type, address and the amount's exact bits. Two rows identical in all four are the same +// logical payment and expanding both is acceptable. +inline std::string rowKey(const std::string& txid, const std::string& displayType, + const std::string& address, double amount) +{ + std::uint64_t bits = 0; + std::memcpy(&bits, &amount, sizeof bits); + char hex[17]; + std::snprintf(hex, sizeof hex, "%016llx", static_cast(bits)); + std::string k; + k.reserve(txid.size() + displayType.size() + address.size() + 20); + k.append(txid).push_back('|'); + k.append(displayType).push_back('|'); + k.append(address).push_back('|'); + k.append(hex); + return k; +} + +// The inputs that decide which rows the History list shows and in what order. +struct FilterInputs { + int type = 0; + int sort = 0; + std::string search; +}; + +// True (and `prev` updated) when type, sort or the FULL search text changed since last call. The caller +// then resets to page 1 and collapses the open row. Starts at the defaults (0, 0, "") so the first frame +// does not count as a change. +inline bool filterInputsChanged(FilterInputs& prev, int type, int sort, const std::string& search) +{ + if (prev.type == type && prev.sort == sort && prev.search == search) return false; + prev.type = type; + prev.sort = sort; + prev.search = search; + return true; +} + } // namespace history } // namespace ui } // namespace dragonx diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 5698398..d408ced 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -10570,6 +10570,31 @@ void testHistoryTxidGroups() } } + +// History row identity + filter-change detection (c196): a kept-open detail panel must survive list +// rebuilds (keyed by content, not index) and page/filter changes must reset and collapse. +void testHistoryFilterInputs() +{ + using namespace dragonx::ui::history; + // rowKey distinguishes each field, and amount by exact bits (so +0.0 != -0.0). + EXPECT_TRUE(rowKey("tx","Sent","zaddr",1.0) == rowKey("tx","Sent","zaddr",1.0)); + EXPECT_TRUE(rowKey("tx","Sent","zaddr",1.0) != rowKey("tx2","Sent","zaddr",1.0)); + EXPECT_TRUE(rowKey("tx","Sent","zaddr",1.0) != rowKey("tx","Recv","zaddr",1.0)); + EXPECT_TRUE(rowKey("tx","Sent","zaddr",1.0) != rowKey("tx","Sent","taddr",1.0)); + EXPECT_TRUE(rowKey("tx","Sent","zaddr",1.0) != rowKey("tx","Sent","zaddr",2.0)); + EXPECT_TRUE(rowKey("tx","Sent","zaddr",0.0) != rowKey("tx","Sent","zaddr",-0.0)); + + // filterInputsChanged: first call at defaults is not a change; each input change is, and latches. + FilterInputs prev; + EXPECT_TRUE(!filterInputsChanged(prev, 0, 0, "")); // defaults -> no change + EXPECT_TRUE(filterInputsChanged(prev, 1, 0, "")); // type changed + EXPECT_TRUE(!filterInputsChanged(prev, 1, 0, "")); // same -> latched + EXPECT_TRUE(filterInputsChanged(prev, 1, 2, "")); // sort changed + EXPECT_TRUE(filterInputsChanged(prev, 1, 2, "abc")); // search changed + EXPECT_TRUE(!filterInputsChanged(prev, 1, 2, "abc")); // same -> latched + EXPECT_TRUE(filterInputsChanged(prev, 1, 2, "ab")); // full-text change (prefix differs) +} + // tx_revision starts at >=1 (a UI cache's 0 sentinel never matches), each helper adds exactly 1, the helpers // never touch last_tx_update, and clear() bumps without resetting, so no earlier revision ever recurs. void testWalletStateTxRevision() @@ -18060,6 +18085,7 @@ int main() testLiteTransactionsAreNewestFirst(); testSameTransactionList(); testHistoryTxidGroups(); + testHistoryFilterInputs(); testWalletStateTxRevision(); testTransactionRefreshRevision(); testLiteSendShowsRecipientFromOutgoing();