diff --git a/electrum/gui/qt/address_list.py b/electrum/gui/qt/address_list.py index ee077041f96..0199e30754d 100644 --- a/electrum/gui/qt/address_list.py +++ b/electrum/gui/qt/address_list.py @@ -184,13 +184,51 @@ def update(self): addr_list = self.wallet.get_change_addresses() else: addr_list = self.wallet.get_addresses() + should_show_fiat = self.should_show_fiat() + fiat_ccy = self.main_window.fx.get_currency() if should_show_fiat else None + fiat_rate = self.main_window.fx.exchange_rate() if should_show_fiat else None + self.addresses_beyond_gap_limit = self.wallet.get_all_known_addresses_beyond_gap_limit() + num_shown = 0 + new_address_list_status = hash(( + self.show_change, + self.show_used, + should_show_fiat, + fiat_ccy, + fiat_rate, + )) + for address in addr_list: + c, u, x = self.wallet.get_addr_balance(address) + balance = c + u + x + is_used_and_empty = self.wallet.adb.is_used(address) and balance == 0 + if self.show_used == AddressUsageStateFilter.UNUSED and (balance or is_used_and_empty): + continue + if self.show_used == AddressUsageStateFilter.FUNDED and balance == 0: + continue + if self.show_used == AddressUsageStateFilter.USED_AND_EMPTY and not is_used_and_empty: + continue + if self.show_used == AddressUsageStateFilter.FUNDED_OR_UNUSED and is_used_and_empty: + continue + num_shown += 1 + new_address_list_status = hash(( + new_address_list_status, + address, + c, + u, + x, + is_used_and_empty, + self.wallet.get_label_for_address(address), + self.wallet.adb.get_address_history_len(address), + self.wallet.is_change(address), + self.wallet.is_frozen_address(address), + address in self.addresses_beyond_gap_limit, + )) + if (self._address_list_status == new_address_list_status + and self.std_model.rowCount() == num_shown): + return self.proxy.setDynamicSortFilter(False) # temp. disable re-sorting after every change self.std_model.clear() self.refresh_headers() set_address = None - num_shown = 0 - new_address_list_status = 0 - self.addresses_beyond_gap_limit = self.wallet.get_all_known_addresses_beyond_gap_limit() for address in addr_list: c, u, x = self.wallet.get_addr_balance(address) balance = c + u + x @@ -203,8 +241,6 @@ def update(self): continue if self.show_used == AddressUsageStateFilter.FUNDED_OR_UNUSED and is_used_and_empty: continue - num_shown += 1 - new_address_list_status = hash((new_address_list_status, address, c, u, x, is_used_and_empty)) labels = [""] * len(self.Columns) labels[self.Columns.ADDRESS] = address address_item = [QStandardItem(e) for e in labels] diff --git a/electrum/gui/qt/history_list.py b/electrum/gui/qt/history_list.py index c1d44fff8a0..7c8ed4f33f8 100644 --- a/electrum/gui/qt/history_list.py +++ b/electrum/gui/qt/history_list.py @@ -302,9 +302,17 @@ def refresh(self, reason: str): onchain_domain=self.get_domain(), include_lightning=self.should_include_lightning_payments(), ) + balance = 0 + for tx_item in transactions.values(): + balance += tx_item['value'].value + tx_item['balance'] = Satoshis(balance) old_length = self._root.childCount() + if old_length == len(transactions) and transactions == self.transactions: + if self.view: + self.view.num_tx_label.setText(_("{} transactions").format(len(transactions))) + return if old_length != 0: - self.beginRemoveRows(QModelIndex(), 0, old_length) + self.beginRemoveRows(QModelIndex(), 0, old_length - 1) self.transactions.clear() self._root = HistoryNode(self, None) self.endRemoveRows() @@ -318,10 +326,8 @@ def refresh(self, reason: str): node.addChild(child_node) # compute balance once all children have been added - balance = 0 for node in self._root._children: - balance += node._data['value'].value - node.set_balance(balance) + node.set_balance(node._data['balance'].value) # update tx_status_cache (before endInsertRows() triggers get_data_for_role() calls) self.tx_status_cache.clear() @@ -331,11 +337,12 @@ def refresh(self, reason: str): self.tx_status_cache[txid] = self.window.wallet.get_tx_status(txid, tx_mined_info) new_length = self._root.childCount() - self.beginInsertRows(QModelIndex(), 0, new_length-1) self.transactions = transactions - self.endInsertRows() + if new_length: + self.beginInsertRows(QModelIndex(), 0, new_length - 1) + self.endInsertRows() - if selected_row: + if selected_row is not None: self.view.selectionModel().select( self.createIndex(selected_row, 0), QItemSelectionModel.SelectionFlag.Rows | QItemSelectionModel.SelectionFlag.SelectCurrent) diff --git a/electrum/gui/qt/utxo_list.py b/electrum/gui/qt/utxo_list.py index 94c94e0ef24..e491254f19b 100644 --- a/electrum/gui/qt/utxo_list.py +++ b/electrum/gui/qt/utxo_list.py @@ -101,10 +101,16 @@ def cb(): @profiler(min_threshold=0.05) def update(self): # not calling maybe_defer_update() as it interferes with coincontrol status bar - self.proxy.setDynamicSortFilter(False) # temp. disable re-sorting after every change utxos = self.wallet.get_utxos() self._maybe_reset_coincontrol(utxos) self._utxo_dict = dict([(utxo.prevout.to_str(), utxo) for utxo in utxos]) + if self.maybe_defer_update(): + self.update_coincontrol_bar() + self.num_coins_label.setText( + _('{} unspent transaction outputs').format(len(utxos)) + ) + return + self.proxy.setDynamicSortFilter(False) # temp. disable re-sorting after every change self.std_model.clear() self.update_headers(self.__class__.headers) for idx, utxo in enumerate(utxos): diff --git a/tests/gui/__init__.py b/tests/gui/__init__.py new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/tests/gui/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/gui/test_qt_address_list.py b/tests/gui/test_qt_address_list.py new file mode 100644 index 00000000000..4aa434b37cb --- /dev/null +++ b/tests/gui/test_qt_address_list.py @@ -0,0 +1,123 @@ +import importlib.util +import os +import subprocess +import sys +import textwrap +import unittest + + +class TestAddressList(unittest.TestCase): + def test_visible_update_skips_model_rebuild_when_rows_are_unchanged(self): + if importlib.util.find_spec("PyQt6") is None: + self.skipTest("PyQt6 not available") + + env = os.environ.copy() + env.setdefault("QT_QPA_PLATFORM", "offscreen") + script = textwrap.dedent( + r""" + from PyQt6.QtWidgets import QApplication, QLabel, QWidget + + from electrum.simple_config import SimpleConfig + from electrum.gui.qt.address_list import AddressList + + + class _AddressDB: + def __init__(self, wallet): + self.wallet = wallet + + def is_used(self, address): + return self.wallet._used.get(address, False) + + def get_address_history_len(self, address): + return self.wallet._history_len.get(address, 0) + + + class _Wallet: + def __init__(self, address_count): + self._addresses = [f"addr{n}" for n in range(address_count)] + self._balances = {address: (n, 0, 0) + for n, address in enumerate(self._addresses)} + self._labels = {address: "" for address in self._addresses} + self._used = {address: False for address in self._addresses} + self._history_len = {address: 0 for address in self._addresses} + self.adb = _AddressDB(self) + + def get_receiving_addresses(self): + return self._addresses + + def get_change_addresses(self): + return [] + + def get_addresses(self): + return self._addresses + + def get_all_known_addresses_beyond_gap_limit(self): + return set() + + def get_addr_balance(self, address): + return self._balances[address] + + def get_label_for_address(self, address): + return self._labels[address] + + def is_change(self, address): + return False + + def get_address_index(self, address): + return (self._addresses.index(address),) + + def get_address_path_str(self, address): + return None + + def is_frozen_address(self, address): + return False + + + class _MainWindow(QWidget): + def __init__(self, address_count): + super().__init__() + self.config = SimpleConfig({"electrum_path": "/tmp/electrum-test"}) + self.wallet = _Wallet(address_count) + self.fx = None + + def format_amount(self, amount, **kwargs): + return str(amount) + + + app = QApplication([]) + window = _MainWindow(100) + address_list = AddressList(window) + address_list.num_addr_label = QLabel() + + address_list._forced_update = True + address_list.update() + first_item = address_list.std_model.item(0, AddressList.Columns.ADDRESS) + assert address_list.std_model.rowCount() == 100 + assert address_list.num_addr_label.text() == "100 addresses" + + address_list.update() + assert first_item is address_list.std_model.item(0, AddressList.Columns.ADDRESS) + + window.wallet._labels["addr0"] = "updated" + address_list.update() + updated_item = address_list.std_model.item(0, AddressList.Columns.ADDRESS) + assert first_item is not updated_item + assert address_list.std_model.item(0, AddressList.Columns.LABEL).text() == "updated" + + address_list._forced_update = False + app.quit() + """ + ) + + result = subprocess.run( + [sys.executable, "-c", script], + env=env, + text=True, + capture_output=True, + timeout=30, + ) + self.assertEqual( + 0, + result.returncode, + f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}", + ) diff --git a/tests/gui/test_qt_history_list.py b/tests/gui/test_qt_history_list.py new file mode 100644 index 00000000000..0ab4b6911ce --- /dev/null +++ b/tests/gui/test_qt_history_list.py @@ -0,0 +1,106 @@ +import importlib.util +import os +import subprocess +import sys +import textwrap +import unittest + + +class TestHistoryList(unittest.TestCase): + def test_visible_refresh_skips_model_rebuild_when_history_is_unchanged(self): + if importlib.util.find_spec("PyQt6") is None: + self.skipTest("PyQt6 not available") + + env = os.environ.copy() + env.setdefault("QT_QPA_PLATFORM", "offscreen") + script = textwrap.dedent( + r""" + import datetime + import threading + + from PyQt6.QtCore import QModelIndex + from PyQt6.QtWidgets import QApplication, QLabel, QWidget + + from electrum.simple_config import SimpleConfig + from electrum.util import OrderedDictWithIndex, Satoshis + from electrum.gui.qt.history_list import HistoryList, HistoryModel + + + class _Wallet: + def __init__(self, tx_count): + self._labels = {f"txid{n}": f"label {n}" for n in range(tx_count)} + self.status_calls = 0 + + def get_full_history(self, **kwargs): + history = OrderedDictWithIndex() + for n, txid in enumerate(self._labels): + history[txid] = { + "txid": txid, + "height": n + 1, + "confirmations": 10, + "timestamp": 1_700_000_000 + n, + "txpos_in_block": n, + "date": datetime.datetime(2024, 1, 1), + "label": self._labels[txid], + "value": Satoshis(1000 + n), + "bc_value": Satoshis(1000 + n), + } + return history + + def get_tx_status(self, txid, tx_mined_info): + self.status_calls += 1 + return 9, "confirmed" + + + class _MainWindow(QWidget): + def __init__(self, tx_count): + super().__init__() + self.config = SimpleConfig({"electrum_path": "/tmp/electrum-test"}) + self.wallet = _Wallet(tx_count) + self.fx = None + self.gui_thread = threading.current_thread() + + def format_amount(self, amount, **kwargs): + return str(amount) + + + app = QApplication([]) + window = _MainWindow(100) + history_model = HistoryModel(window) + history_list = HistoryList(window, history_model) + history_model.set_view(history_list) + history_list.num_tx_label = QLabel() + + history_list._forced_update = True + history_list.update() + first_node = history_model._root.child(0) + first_status_calls = window.wallet.status_calls + assert history_model.rowCount(QModelIndex()) == 100 + assert history_list.num_tx_label.text() == "100 transactions" + + history_list.update() + assert first_node is history_model._root.child(0) + assert window.wallet.status_calls == first_status_calls + + window.wallet._labels["txid0"] = "updated" + history_list.update() + assert first_node is not history_model._root.child(0) + assert history_model._root.child(0).get_data()["label"] == "updated" + + history_list._forced_update = False + app.quit() + """ + ) + + result = subprocess.run( + [sys.executable, "-c", script], + env=env, + text=True, + capture_output=True, + timeout=30, + ) + self.assertEqual( + 0, + result.returncode, + f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}", + ) diff --git a/tests/gui/test_qt_utxo_list.py b/tests/gui/test_qt_utxo_list.py new file mode 100644 index 00000000000..c23cfe9fab0 --- /dev/null +++ b/tests/gui/test_qt_utxo_list.py @@ -0,0 +1,130 @@ +import importlib.util +import os +import subprocess +import sys +import textwrap +import unittest + + +class TestUTXOList(unittest.TestCase): + def test_hidden_update_defers_expensive_model_rebuild(self): + if importlib.util.find_spec("PyQt6") is None: + self.skipTest("PyQt6 not available") + + env = os.environ.copy() + env.setdefault("QT_QPA_PLATFORM", "offscreen") + script = textwrap.dedent( + r""" + from PyQt6.QtWidgets import QApplication, QLabel, QWidget + + from electrum.simple_config import SimpleConfig + from electrum.gui.qt.utxo_list import UTXOList + + + class _Prevout: + def __init__(self, n): + self.txid = bytes([n % 256]) * 32 + self.n = n + + def to_str(self): + return f"{self.txid.hex()}:{self.n}" + + + class _Utxo: + def __init__(self, n): + self.prevout = _Prevout(n) + self.short_id = f"{n}:0" + self.address = f"addr{n}" + self.block_height = n + + def value_sats(self): + return 1000 + + + class _AddressDB: + def tx_height_to_sort_height(self, height): + return height + + + class _Wallet: + def __init__(self, utxo_count): + self.adb = _AddressDB() + self._utxos = [_Utxo(n) for n in range(utxo_count)] + + def get_utxos(self): + return self._utxos + + def get_num_parents(self, txid): + return 0 + + def get_label_for_txid(self, txid): + return "" + + def is_frozen_address(self, address): + return False + + def is_frozen_coin(self, utxo): + return False + + + class _MainWindow(QWidget): + def __init__(self, utxo_count): + super().__init__() + self.config = SimpleConfig({"electrum_path": "/tmp/electrum-test"}) + self.wallet = _Wallet(utxo_count) + self.coincontrol_msg = None + + def format_amount(self, amount, **kwargs): + return str(amount) + + def format_amount_and_units(self, amount): + return str(amount) + + def set_coincontrol_msg(self, msg): + self.coincontrol_msg = msg + + + app = QApplication([]) + window = _MainWindow(100) + utxo_list = UTXOList(window) + utxo_list.num_coins_label = QLabel() + + utxo_list._forced_update = True + utxo_list.update() + utxo_list._forced_update = False + first_item = utxo_list.std_model.item(0, UTXOList.Columns.OUTPOINT) + assert utxo_list.std_model.rowCount() == 100 + + utxo_list.update() + + assert first_item is utxo_list.std_model.item(0, UTXOList.Columns.OUTPOINT) + assert utxo_list._pending_update + assert len(utxo_list._utxo_dict) == 100 + assert utxo_list.num_coins_label.text() == "100 unspent transaction outputs" + + window.wallet._utxos = [_Utxo(n) for n in range(50)] + utxo_list.update() + assert utxo_list.std_model.rowCount() == 100 + assert len(utxo_list._utxo_dict) == 50 + assert utxo_list.num_coins_label.text() == "50 unspent transaction outputs" + + utxo_list._forced_update = True + utxo_list.update() + utxo_list._forced_update = False + assert utxo_list.std_model.rowCount() == 50 + app.quit() + """ + ) + + result = subprocess.run( + [sys.executable, "-c", script], + env=env, + text=True, + capture_output=True, + timeout=30, + ) + self.assertEqual( + 0, + result.returncode, + f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}", + )