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 <noreply@anthropic.com>
This commit is contained in:
@@ -177,12 +177,13 @@ void RenderTransactionsTab(App* app)
|
|||||||
std::string txLoadingText = app->transactionRefreshProgressText();
|
std::string txLoadingText = app->transactionRefreshProgressText();
|
||||||
bool txLoading = !txLoadingText.empty();
|
bool txLoading = !txLoadingText.empty();
|
||||||
|
|
||||||
// Expanded row index for inline detail
|
// Open inline detail, keyed by row identity (history::rowKey), NOT by list index: the display list is rebuilt
|
||||||
static int s_expanded_row = -1;
|
// 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
|
// Pagination state
|
||||||
static int s_current_page = 0;
|
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
|
// 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 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);
|
int totalPages = std::max(1, (filtered_count + perPage - 1) / perPage);
|
||||||
|
|
||||||
// Reset page when filters change
|
// Reset page (and collapse the open row) only when a filter INPUT changes (type, sort, or the search text).
|
||||||
int filterHash = type_filter * 1000003 + s_sort_mode * 7919 + filtered_count * 31 + static_cast<int>(search_str.size());
|
// The row count is data: a new/removed row just clamps the page below instead of bouncing to page 1.
|
||||||
if (filterHash != s_prev_filter_hash) {
|
if (history::filterInputsChanged(s_prev_filter, type_filter, s_sort_mode, search_str)) {
|
||||||
s_current_page = 0;
|
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 >= totalPages) s_current_page = totalPages - 1;
|
||||||
if (s_current_page < 0) s_current_page = 0;
|
if (s_current_page < 0) s_current_page = 0;
|
||||||
|
|
||||||
@@ -582,7 +584,7 @@ void RenderTransactionsTab(App* app)
|
|||||||
ImGui::BeginDisabled(s_current_page == 0);
|
ImGui::BeginDisabled(s_current_page == 0);
|
||||||
if (TactileButton(ICON_MD_FIRST_PAGE "##txFirst", ImVec2(btnW, btnW), Type().iconSmall())) {
|
if (TactileButton(ICON_MD_FIRST_PAGE "##txFirst", ImVec2(btnW, btnW), Type().iconSmall())) {
|
||||||
s_current_page = 0;
|
s_current_page = 0;
|
||||||
s_expanded_row = -1;
|
s_expanded_key.clear();
|
||||||
}
|
}
|
||||||
ImGui::EndDisabled();
|
ImGui::EndDisabled();
|
||||||
|
|
||||||
@@ -592,7 +594,7 @@ void RenderTransactionsTab(App* app)
|
|||||||
ImGui::BeginDisabled(s_current_page == 0);
|
ImGui::BeginDisabled(s_current_page == 0);
|
||||||
if (TactileButton(ICON_MD_CHEVRON_LEFT "##txPrev", ImVec2(btnW, btnW), Type().iconSmall())) {
|
if (TactileButton(ICON_MD_CHEVRON_LEFT "##txPrev", ImVec2(btnW, btnW), Type().iconSmall())) {
|
||||||
s_current_page--;
|
s_current_page--;
|
||||||
s_expanded_row = -1;
|
s_expanded_key.clear();
|
||||||
}
|
}
|
||||||
ImGui::EndDisabled();
|
ImGui::EndDisabled();
|
||||||
|
|
||||||
@@ -619,7 +621,7 @@ void RenderTransactionsTab(App* app)
|
|||||||
ImGui::BeginDisabled(s_current_page >= totalPages - 1);
|
ImGui::BeginDisabled(s_current_page >= totalPages - 1);
|
||||||
if (TactileButton(ICON_MD_CHEVRON_RIGHT "##txNext", ImVec2(btnW, btnW), Type().iconSmall())) {
|
if (TactileButton(ICON_MD_CHEVRON_RIGHT "##txNext", ImVec2(btnW, btnW), Type().iconSmall())) {
|
||||||
s_current_page++;
|
s_current_page++;
|
||||||
s_expanded_row = -1;
|
s_expanded_key.clear();
|
||||||
}
|
}
|
||||||
ImGui::EndDisabled();
|
ImGui::EndDisabled();
|
||||||
|
|
||||||
@@ -629,7 +631,7 @@ void RenderTransactionsTab(App* app)
|
|||||||
ImGui::BeginDisabled(s_current_page >= totalPages - 1);
|
ImGui::BeginDisabled(s_current_page >= totalPages - 1);
|
||||||
if (TactileButton(ICON_MD_LAST_PAGE "##txLast", ImVec2(btnW, btnW), Type().iconSmall())) {
|
if (TactileButton(ICON_MD_LAST_PAGE "##txLast", ImVec2(btnW, btnW), Type().iconSmall())) {
|
||||||
s_current_page = totalPages - 1;
|
s_current_page = totalPages - 1;
|
||||||
s_expanded_row = -1;
|
s_expanded_key.clear();
|
||||||
}
|
}
|
||||||
ImGui::EndDisabled();
|
ImGui::EndDisabled();
|
||||||
}
|
}
|
||||||
@@ -686,7 +688,8 @@ void RenderTransactionsTab(App* app)
|
|||||||
for (int fi = pageStart; fi < pageEnd; fi++) {
|
for (int fi = pageStart; fi < pageEnd; fi++) {
|
||||||
size_t i = filtered_indices[fi];
|
size_t i = filtered_indices[fi];
|
||||||
const auto& tx = display_txns[i];
|
const auto& tx = display_txns[i];
|
||||||
bool is_expanded = (s_expanded_row == static_cast<int>(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<int>(i));
|
ImGui::PushID(static_cast<int>(i));
|
||||||
ImVec2 rowPos = ImGui::GetCursorScreenPos();
|
ImVec2 rowPos = ImGui::GetCursorScreenPos();
|
||||||
@@ -832,7 +835,7 @@ void RenderTransactionsTab(App* app)
|
|||||||
// Click to expand/collapse + invisible button for interaction
|
// Click to expand/collapse + invisible button for interaction
|
||||||
ImGui::InvisibleButton("##txRow", ImVec2(innerW, rowH));
|
ImGui::InvisibleButton("##txRow", ImVec2(innerW, rowH));
|
||||||
if (ImGui::IsItemClicked(0)) {
|
if (ImGui::IsItemClicked(0)) {
|
||||||
s_expanded_row = is_expanded ? -1 : static_cast<int>(i);
|
if (is_expanded) s_expanded_key.clear(); else s_expanded_key = rk;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tooltip
|
// Tooltip
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
// Pure helpers behind the History tab (no ImGui), so they can be unit-tested.
|
// Pure helpers behind the History tab (no ImGui), so they can be unit-tested.
|
||||||
#include "../../data/wallet_state.h"
|
#include "../../data/wallet_state.h"
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <string_view>
|
#include <string_view>
|
||||||
@@ -45,6 +47,45 @@ inline TxidGroups groupByTxid(const std::vector<TransactionInfo>& txs)
|
|||||||
return g;
|
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<unsigned long long>(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 history
|
||||||
} // namespace ui
|
} // namespace ui
|
||||||
} // namespace dragonx
|
} // namespace dragonx
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// never touch last_tx_update, and clear() bumps without resetting, so no earlier revision ever recurs.
|
||||||
void testWalletStateTxRevision()
|
void testWalletStateTxRevision()
|
||||||
@@ -18060,6 +18085,7 @@ int main()
|
|||||||
testLiteTransactionsAreNewestFirst();
|
testLiteTransactionsAreNewestFirst();
|
||||||
testSameTransactionList();
|
testSameTransactionList();
|
||||||
testHistoryTxidGroups();
|
testHistoryTxidGroups();
|
||||||
|
testHistoryFilterInputs();
|
||||||
testWalletStateTxRevision();
|
testWalletStateTxRevision();
|
||||||
testTransactionRefreshRevision();
|
testTransactionRefreshRevision();
|
||||||
testLiteSendShowsRecipientFromOutgoing();
|
testLiteSendShowsRecipientFromOutgoing();
|
||||||
|
|||||||
Reference in New Issue
Block a user