electrum

Electrum Bitcoin wallet
git clone https://git.parazyd.org/electrum
Log | Files | Refs | Submodules

commit 5473320ce459b3076d60f71dab490ed3a07b86a5
parent 9350709f13bc7e3d79b8e0f1515a3fdba4f2cbff
Author: Janus <ysangkok@gmail.com>
Date:   Tue, 27 Nov 2018 21:32:55 +0100

qt: use QStandardItemModel

Diffstat:
Melectrum/contacts.py | 3++-
Melectrum/gui/qt/address_list.py | 82++++++++++++++++++++++++++++++++++++++++---------------------------------------
Melectrum/gui/qt/contact_list.py | 80++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Melectrum/gui/qt/history_list.py | 408++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Melectrum/gui/qt/invoice_list.py | 28++++++++++++++++------------
Melectrum/gui/qt/main_window.py | 10++--------
Melectrum/gui/qt/network_dialog.py | 3+--
Melectrum/gui/qt/request_list.py | 68+++++++++++++++++++++++++++++++++++---------------------------------
Melectrum/gui/qt/util.py | 232++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Melectrum/gui/qt/utxo_list.py | 61++++++++++++++++++++++++++++++++++---------------------------
10 files changed, 572 insertions(+), 403 deletions(-)

diff --git a/electrum/contacts.py b/electrum/contacts.py @@ -65,8 +65,9 @@ class Contacts(dict): def pop(self, key): if key in self.keys(): - dict.pop(self, key) + res = dict.pop(self, key) self.save() + return res def resolve(self, k): if bitcoin.is_address(k): diff --git a/electrum/gui/qt/address_list.py b/electrum/gui/qt/address_list.py @@ -31,13 +31,11 @@ from electrum.bitcoin import is_address from .util import * - -class AddressList(MyTreeWidget): +class AddressList(MyTreeView): filter_columns = [0, 1, 2, 3] # Type, Address, Label, Balance def __init__(self, parent=None): - MyTreeWidget.__init__(self, parent, self.create_menu, [], 2) - self.refresh_headers() + super().__init__(parent, self.create_menu, 2) self.setSelectionMode(QAbstractItemView.ExtendedSelection) self.setSortingEnabled(True) self.show_change = 0 @@ -50,6 +48,8 @@ class AddressList(MyTreeWidget): self.used_button.currentIndexChanged.connect(self.toggle_used) for t in [_('All'), _('Unused'), _('Funded'), _('Used')]: self.used_button.addItem(t) + self.setModel(QStandardItemModel(self)) + self.update() def get_toolbar_buttons(self): return QLabel(_("Filter:")), self.change_button, self.used_button @@ -82,18 +82,19 @@ class AddressList(MyTreeWidget): self.show_used = state self.update() - def on_update(self): + def update(self): self.wallet = self.parent.wallet - item = self.currentItem() - current_address = item.data(0, Qt.UserRole) if item else None + current_address = self.current_item_user_role(col=2) if self.show_change == 1: addr_list = self.wallet.get_receiving_addresses() elif self.show_change == 2: addr_list = self.wallet.get_change_addresses() else: addr_list = self.wallet.get_addresses() - self.clear() + self.model().clear() + self.refresh_headers() fx = self.parent.fx + set_address = None for address in addr_list: num = self.wallet.get_address_history_len(address) label = self.wallet.labels.get(address, '') @@ -111,61 +112,66 @@ class AddressList(MyTreeWidget): if fx and fx.get_fiat_address_config(): rate = fx.exchange_rate() fiat_balance = fx.value_str(balance, rate) - address_item = SortableTreeWidgetItem(['', address, label, balance_text, fiat_balance, "%d"%num]) + labels = ['', address, label, balance_text, fiat_balance, "%d"%num] + address_item = [QStandardItem(e) for e in labels] else: - address_item = SortableTreeWidgetItem(['', address, label, balance_text, "%d"%num]) + labels = ['', address, label, balance_text, "%d"%num] + address_item = [QStandardItem(e) for e in labels] # align text and set fonts - for i in range(address_item.columnCount()): - address_item.setTextAlignment(i, Qt.AlignVCenter) + for i, item in enumerate(address_item): + item.setTextAlignment(Qt.AlignVCenter) if i not in (0, 2): - address_item.setFont(i, QFont(MONOSPACE_FONT)) + item.setFont(QFont(MONOSPACE_FONT)) + item.setEditable(i in self.editable_columns) if fx and fx.get_fiat_address_config(): - address_item.setTextAlignment(4, Qt.AlignRight | Qt.AlignVCenter) + address_item[4].setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) # setup column 0 if self.wallet.is_change(address): - address_item.setText(0, _('change')) - address_item.setBackground(0, ColorScheme.YELLOW.as_color(True)) + address_item[0].setText(_('change')) + address_item[0].setBackground(ColorScheme.YELLOW.as_color(True)) else: - address_item.setText(0, _('receiving')) - address_item.setBackground(0, ColorScheme.GREEN.as_color(True)) - address_item.setData(0, Qt.UserRole, address) # column 0; independent from address column + address_item[0].setText(_('receiving')) + address_item[0].setBackground(ColorScheme.GREEN.as_color(True)) + address_item[2].setData(address, Qt.UserRole) # setup column 1 if self.wallet.is_frozen(address): - address_item.setBackground(1, ColorScheme.BLUE.as_color(True)) + address_item[1].setBackground(ColorScheme.BLUE.as_color(True)) if self.wallet.is_beyond_limit(address): - address_item.setBackground(1, ColorScheme.RED.as_color(True)) + address_item[1].setBackground(ColorScheme.RED.as_color(True)) # add item - self.addChild(address_item) + count = self.model().rowCount() + self.model().insertRow(count, address_item) + address_idx = self.model().index(count, 2) if address == current_address: - self.setCurrentItem(address_item) + set_address = QPersistentModelIndex(address_idx) + self.set_current_idx(set_address) def create_menu(self, position): from electrum.wallet import Multisig_Wallet is_multisig = isinstance(self.wallet, Multisig_Wallet) can_delete = self.wallet.can_delete_address() - selected = self.selectedItems() + selected = self.selected_in_column(1) multi_select = len(selected) > 1 - addrs = [item.text(1) for item in selected] - if not addrs: - return + addrs = [self.model().itemFromIndex(item).text() for item in selected] if not multi_select: - item = self.itemAt(position) - col = self.currentColumn() + idx = self.indexAt(position) + col = idx.column() + item = self.model().itemFromIndex(idx) if not item: return addr = addrs[0] - if not is_address(addr): - item.setExpanded(not item.isExpanded()) - return menu = QMenu() if not multi_select: - column_title = self.headerItem().text(col) - copy_text = item.text(col) + addr_column_title = self.model().horizontalHeaderItem(2).text() + addr_idx = idx.sibling(idx.row(), 2) + + column_title = self.model().horizontalHeaderItem(col).text() + copy_text = self.model().itemFromIndex(idx).text() menu.addAction(_("Copy {}").format(column_title), lambda: self.parent.app.clipboard().setText(copy_text)) menu.addAction(_('Details'), lambda: self.parent.show_address(addr)) - if col in self.editable_columns: - menu.addAction(_("Edit {}").format(column_title), lambda: self.editItem(item, col)) + persistent = QPersistentModelIndex(addr_idx) + menu.addAction(_("Edit {}").format(addr_column_title), lambda p=persistent: self.edit(QModelIndex(p))) menu.addAction(_("Request payment"), lambda: self.parent.receive_at(addr)) if self.wallet.can_export(): menu.addAction(_("Private key"), lambda: self.parent.show_private_key(addr)) @@ -189,7 +195,3 @@ class AddressList(MyTreeWidget): run_hook('receive_menu', menu, addrs, self.wallet) menu.exec_(self.viewport().mapToGlobal(position)) - - def on_permit_edit(self, item, column): - # labels for headings, e.g. "receiving" or "used" should not be editable - return item.childCount() == 0 diff --git a/electrum/gui/qt/contact_list.py b/electrum/gui/qt/contact_list.py @@ -34,67 +34,81 @@ from electrum.bitcoin import is_address from electrum.util import block_explorer_URL from electrum.plugin import run_hook -from .util import MyTreeWidget, import_meta_gui, export_meta_gui +from .util import MyTreeView, import_meta_gui, export_meta_gui -class ContactList(MyTreeWidget): +class ContactList(MyTreeView): filter_columns = [0, 1] # Key, Value def __init__(self, parent): - MyTreeWidget.__init__(self, parent, self.create_menu, [_('Name'), _('Address')], 0, [0]) + super().__init__(parent, self.create_menu, stretch_column=0, editable_columns=[0]) + self.setModel(QStandardItemModel(self)) self.setSelectionMode(QAbstractItemView.ExtendedSelection) self.setSortingEnabled(True) + self.update() - def on_permit_edit(self, item, column): - # openalias items shouldn't be editable - return item.text(1) != "openalias" + def on_edited(self, idx, user_role, text): + _type, prior_name = self.parent.contacts.pop(user_role) - def on_edited(self, item, column, prior): - if column == 0: # Remove old contact if renamed - self.parent.contacts.pop(prior) - self.parent.set_contact(item.text(0), item.text(1)) + # TODO when min Qt >= 5.11, use siblingAtColumn + col_1_sibling = idx.sibling(idx.row(), 1) + col_1_item = self.model().itemFromIndex(col_1_sibling) + + self.parent.set_contact(text, col_1_item.text()) def import_contacts(self): - import_meta_gui(self.parent, _('contacts'), self.parent.contacts.import_file, self.on_update) + import_meta_gui(self.parent, _('contacts'), self.parent.contacts.import_file, self.update) def export_contacts(self): export_meta_gui(self.parent, _('contacts'), self.parent.contacts.export_file) def create_menu(self, position): menu = QMenu() - selected = self.selectedItems() - if not selected: + selected = self.selected_in_column(0) + selected_keys = [] + for idx in selected: + sel_key = self.model().itemFromIndex(idx).data(Qt.UserRole) + selected_keys.append(sel_key) + idx = self.indexAt(position) + if not selected or not idx.isValid(): menu.addAction(_("New contact"), lambda: self.parent.new_contact_dialog()) menu.addAction(_("Import file"), lambda: self.import_contacts()) menu.addAction(_("Export file"), lambda: self.export_contacts()) else: - names = [item.text(0) for item in selected] - keys = [item.text(1) for item in selected] - column = self.currentColumn() - column_title = self.headerItem().text(column) - column_data = '\n'.join([item.text(column) for item in selected]) + column = idx.column() + column_title = self.model().horizontalHeaderItem(column).text() + column_data = '\n'.join(self.model().itemFromIndex(idx).text() for idx in selected) menu.addAction(_("Copy {}").format(column_title), lambda: self.parent.app.clipboard().setText(column_data)) if column in self.editable_columns: - item = self.currentItem() - menu.addAction(_("Edit {}").format(column_title), lambda: self.editItem(item, column)) - menu.addAction(_("Pay to"), lambda: self.parent.payto_contacts(keys)) - menu.addAction(_("Delete"), lambda: self.parent.delete_contacts(keys)) - URLs = [block_explorer_URL(self.config, 'addr', key) for key in filter(is_address, keys)] + item = self.model().itemFromIndex(idx) + if item.isEditable(): + # would not be editable if openalias + persistent = QPersistentModelIndex(idx) + menu.addAction(_("Edit {}").format(column_title), lambda p=persistent: self.edit(QModelIndex(p))) + menu.addAction(_("Pay to"), lambda: self.parent.payto_contacts(selected_keys)) + menu.addAction(_("Delete"), lambda: self.parent.delete_contacts(selected_keys)) + URLs = [block_explorer_URL(self.config, 'addr', key) for key in filter(is_address, selected_keys)] if URLs: menu.addAction(_("View on block explorer"), lambda: map(webbrowser.open, URLs)) - run_hook('create_contact_menu', menu, selected) + run_hook('create_contact_menu', menu, selected_keys) menu.exec_(self.viewport().mapToGlobal(position)) - def on_update(self): - item = self.currentItem() - current_key = item.data(0, Qt.UserRole) if item else None - self.clear() + def update(self): + current_key = self.current_item_user_role(col=0) + self.model().clear() + self.update_headers([_('Name'), _('Address')]) + set_current = None for key in sorted(self.parent.contacts.keys()): - _type, name = self.parent.contacts[key] - item = QTreeWidgetItem([name, key]) - item.setData(0, Qt.UserRole, key) - self.addTopLevelItem(item) + contact_type, name = self.parent.contacts[key] + items = [QStandardItem(x) for x in (name, key)] + items[0].setEditable(contact_type != 'openalias') + items[1].setEditable(False) + items[0].setData(key, Qt.UserRole) + row_count = self.model().rowCount() + self.model().insertRow(row_count, items) if key == current_key: - self.setCurrentItem(item) + idx = self.model().index(row_count, 0) + set_current = QPersistentModelIndex(idx) + self.set_current_idx(set_current) run_hook('update_contacts_tab', self) diff --git a/electrum/gui/qt/history_list.py b/electrum/gui/qt/history_list.py @@ -27,10 +27,11 @@ import webbrowser import datetime from datetime import date from typing import TYPE_CHECKING +from collections import OrderedDict from electrum.address_synchronizer import TX_HEIGHT_LOCAL from electrum.i18n import _ -from electrum.util import block_explorer_URL, profiler, print_error, TxMinedStatus +from electrum.util import block_explorer_URL, profiler, print_error, TxMinedStatus, Fiat from .util import * @@ -57,40 +58,111 @@ TX_ICONS = [ "confirmed.png", ] - -class HistoryList(MyTreeWidget, AcceptFileDragDrop): - filter_columns = [2, 3, 4] # Date, Description, Amount +class HistorySortModel(QSortFilterProxyModel): + def lessThan(self, source_left: QModelIndex, source_right: QModelIndex): + item1 = self.sourceModel().itemFromIndex(source_left) + item2 = self.sourceModel().itemFromIndex(source_right) + data1 = item1.data(HistoryList.SORT_ROLE) + data2 = item2.data(HistoryList.SORT_ROLE) + if data1 is not None and data2 is not None: + return data1 < data2 + return item1.text() < item2.text() + +class HistoryList(MyTreeView, AcceptFileDragDrop): + filter_columns = [1, 2, 3] # Date, Description, Amount TX_HASH_ROLE = Qt.UserRole - TX_VALUE_ROLE = Qt.UserRole + 1 + SORT_ROLE = Qt.UserRole + 1 + + def should_hide(self, proxy_row): + if self.start_timestamp and self.end_timestamp: + source_idx = self.proxy.mapToSource(self.proxy.index(proxy_row, 0)) + item = self.std_model.itemFromIndex(source_idx) + txid = item.data(self.TX_HASH_ROLE) + date = self.transactions[txid]['date'] + if date: + in_interval = self.start_timestamp <= date <= self.end_timestamp + if not in_interval: + return True + return False def __init__(self, parent=None): - MyTreeWidget.__init__(self, parent, self.create_menu, [], 3) + super().__init__(parent, self.create_menu, 2) + self.std_model = QStandardItemModel(self) + self.proxy = HistorySortModel(self) + self.proxy.setSourceModel(self.std_model) + self.setModel(self.proxy) + + self.txid_to_items = {} + self.transactions = OrderedDict() + self.summary = {} + self.blue_brush = QBrush(QColor("#1E1EFF")) + self.red_brush = QBrush(QColor("#BC1E1E")) + self.monospace_font = QFont(MONOSPACE_FONT) + self.default_color = self.parent.app.palette().text().color() + self.config = parent.config AcceptFileDragDrop.__init__(self, ".txn") - self.refresh_headers() - self.setColumnHidden(1, True) self.setSortingEnabled(True) - self.sortByColumn(0, Qt.AscendingOrder) self.start_timestamp = None self.end_timestamp = None self.years = [] self.create_toolbar_buttons() self.wallet = None + root = self.std_model.invisibleRootItem() + + self.wallet = self.parent.wallet # type: Abstract_Wallet + fx = self.parent.fx + r = self.wallet.get_full_history(domain=self.get_domain(), from_timestamp=None, to_timestamp=None, fx=fx) + self.transactions.update([(x['txid'], x) for x in r['transactions']]) + self.summary = r['summary'] + if not self.years and self.transactions: + start_date = next(iter(self.transactions.values())).get('date') or date.today() + end_date = next(iter(reversed(self.transactions.values()))).get('date') or date.today() + self.years = [str(i) for i in range(start_date.year, end_date.year + 1)] + self.period_combo.insertItems(1, self.years) + if fx: fx.history_used_spot = False + self.refresh_headers() + for tx_item in self.transactions.values(): + self.insert_tx(tx_item) + self.sortByColumn(0, Qt.AscendingOrder) + + #def on_activated(self, idx: QModelIndex): + # # TODO use siblingAtColumn when min Qt version is >=5.11 + # self.edit(idx.sibling(idx.row(), 2)) + def format_date(self, d): return str(datetime.date(d.year, d.month, d.day)) if d else _('None') def refresh_headers(self): - headers = ['', '', _('Date'), _('Description'), _('Amount'), _('Balance')] + headers = ['', _('Date'), _('Description'), _('Amount'), _('Balance')] fx = self.parent.fx if fx and fx.show_history(): headers.extend(['%s '%fx.ccy + _('Value')]) - self.editable_columns |= {6} + self.editable_columns |= {5} if fx.get_history_capital_gains_config(): headers.extend(['%s '%fx.ccy + _('Acquisition price')]) headers.extend(['%s '%fx.ccy + _('Capital Gains')]) else: - self.editable_columns -= {6} - self.update_headers(headers) + self.editable_columns -= {5} + col_count = self.std_model.columnCount() + diff = col_count-len(headers) + grew = False + if col_count > len(headers): + if diff == 2: + self.std_model.removeColumns(6, diff) + else: + assert diff in [1, 3] + self.std_model.removeColumns(5, diff) + for items in self.txid_to_items.values(): + while len(items) > col_count: + items.pop() + elif col_count < len(headers): + grew = True + self.std_model.clear() + self.txid_to_items.clear() + self.transactions.clear() + self.summary.clear() + self.update_headers(headers, self.std_model) def get_domain(self): '''Replaced in address_dialog.py''' @@ -111,13 +183,11 @@ class HistoryList(MyTreeWidget, AcceptFileDragDrop): year = int(s) except: return - start_date = datetime.datetime(year, 1, 1) - end_date = datetime.datetime(year+1, 1, 1) - self.start_timestamp = time.mktime(start_date.timetuple()) - self.end_timestamp = time.mktime(end_date.timetuple()) + self.start_timestamp = start_date = datetime.datetime(year, 1, 1) + self.end_timestamp = end_date = datetime.datetime(year+1, 1, 1) self.start_button.setText(_('From') + ' ' + self.format_date(start_date)) self.end_button.setText(_('To') + ' ' + self.format_date(end_date)) - self.update() + self.hide_rows() def create_toolbar_buttons(self): self.period_combo = QComboBox() @@ -136,18 +206,18 @@ class HistoryList(MyTreeWidget, AcceptFileDragDrop): def on_hide_toolbar(self): self.start_timestamp = None self.end_timestamp = None - self.update() + self.hide_rows() def save_toolbar_state(self, state, config): config.set_key('show_toolbar_history', state) def select_start_date(self): self.start_timestamp = self.select_date(self.start_button) - self.update() + self.hide_rows() def select_end_date(self): self.end_timestamp = self.select_date(self.end_button) - self.update() + self.hide_rows() def select_date(self, button): d = WindowModalDialog(self, _("Select date")) @@ -167,7 +237,7 @@ class HistoryList(MyTreeWidget, AcceptFileDragDrop): return None date = d.date.toPyDate() button.setText(self.format_date(date)) - return time.mktime(date.timetuple()) + return datetime.datetime(date.year, date.month, date.day) def show_summary(self): h = self.summary @@ -215,104 +285,167 @@ class HistoryList(MyTreeWidget, AcceptFileDragDrop): _("Perhaps some dependencies are missing...") + " (matplotlib?)") return try: - plt = plot_history(self.transactions) + plt = plot_history(list(self.transactions.values())) plt.show() except NothingToPlotException as e: self.parent.show_message(str(e)) + def insert_tx(self, tx_item): + fx = self.parent.fx + tx_hash = tx_item['txid'] + height = tx_item['height'] + conf = tx_item['confirmations'] + timestamp = tx_item['timestamp'] + value = tx_item['value'].value + balance = tx_item['balance'].value + label = tx_item['label'] + tx_mined_status = TxMinedStatus(height, conf, timestamp, None) + status, status_str = self.wallet.get_tx_status(tx_hash, tx_mined_status) + has_invoice = self.wallet.invoices.paid.get(tx_hash) + icon = self.icon_cache.get(":icons/" + TX_ICONS[status]) + v_str = self.parent.format_amount(value, is_diff=True, whitespaces=True) + balance_str = self.parent.format_amount(balance, whitespaces=True) + entry = ['', status_str, label, v_str, balance_str] + fiat_value = None + item = [QStandardItem(e) for e in entry] + item[3].setData(value, self.SORT_ROLE) + item[4].setData(balance, self.SORT_ROLE) + if has_invoice: + item[2].setIcon(self.icon_cache.get(":icons/seal")) + for i in range(len(entry)): + self.set_item_properties(item[i], i, tx_hash) + if value and value < 0: + item[2].setForeground(self.red_brush) + item[3].setForeground(self.red_brush) + self.txid_to_items[tx_hash] = item + self.update_item(tx_hash, self.parent.wallet.get_tx_height(tx_hash)) + source_row_idx = self.std_model.rowCount() + self.std_model.insertRow(source_row_idx, item) + new_idx = self.std_model.index(source_row_idx, 0) + history = self.parent.fx.show_history() + if history: + self.update_fiat(tx_hash, tx_item) + self.hide_row(self.proxy.mapFromSource(new_idx).row()) + + def set_item_properties(self, item, i, tx_hash): + if i>2: + item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) + if i!=1: + item.setFont(self.monospace_font) + item.setEditable(i in self.editable_columns) + item.setData(tx_hash, self.TX_HASH_ROLE) + + def ensure_fields_available(self, items, idx, txid): + while len(items) < idx + 1: + row = list(self.transactions.keys()).index(txid) + qidx = self.std_model.index(row, len(items)) + assert qidx.isValid(), (self.std_model.columnCount(), idx) + item = self.std_model.itemFromIndex(qidx) + self.set_item_properties(item, len(items), txid) + items.append(item) + @profiler - def on_update(self): + def update(self): self.wallet = self.parent.wallet # type: Abstract_Wallet fx = self.parent.fx - r = self.wallet.get_full_history(domain=self.get_domain(), from_timestamp=self.start_timestamp, to_timestamp=self.end_timestamp, fx=fx) - self.transactions = r['transactions'] - self.summary = r['summary'] - if not self.years and self.transactions: - start_date = self.transactions[0].get('date') or date.today() - end_date = self.transactions[-1].get('date') or date.today() - self.years = [str(i) for i in range(start_date.year, end_date.year + 1)] - self.period_combo.insertItems(1, self.years) - item = self.currentItem() - current_tx = item.data(0, self.TX_HASH_ROLE) if item else None - self.clear() - if fx: fx.history_used_spot = False - blue_brush = QBrush(QColor("#1E1EFF")) - red_brush = QBrush(QColor("#BC1E1E")) - monospace_font = QFont(MONOSPACE_FONT) - for tx_item in self.transactions: - tx_hash = tx_item['txid'] - height = tx_item['height'] - conf = tx_item['confirmations'] - timestamp = tx_item['timestamp'] - value_sat = tx_item['value'].value - balance = tx_item['balance'].value - label = tx_item['label'] - tx_mined_status = TxMinedStatus(height, conf, timestamp, None) - status, status_str = self.wallet.get_tx_status(tx_hash, tx_mined_status) - has_invoice = self.wallet.invoices.paid.get(tx_hash) - icon = self.icon_cache.get(":icons/" + TX_ICONS[status]) - v_str = self.parent.format_amount(value_sat, is_diff=True, whitespaces=True) - balance_str = self.parent.format_amount(balance, whitespaces=True) - entry = ['', tx_hash, status_str, label, v_str, balance_str] - fiat_value = None - if value_sat is not None and fx and fx.show_history(): - fiat_value = tx_item['fiat_value'].value - value_str = fx.format_fiat(fiat_value) - entry.append(value_str) - # fixme: should use is_mine - if value_sat < 0: - entry.append(fx.format_fiat(tx_item['acquisition_price'].value)) - entry.append(fx.format_fiat(tx_item['capital_gain'].value)) - item = SortableTreeWidgetItem(entry) - item.setIcon(0, icon) - item.setToolTip(0, str(conf) + " confirmation" + ("s" if conf != 1 else "")) - if has_invoice: - item.setIcon(3, self.icon_cache.get(":icons/seal")) - for i in range(len(entry)): - if i>3: - item.setTextAlignment(i, Qt.AlignRight | Qt.AlignVCenter) - if i!=2: - item.setFont(i, monospace_font) - if value_sat and value_sat < 0: - item.setForeground(3, red_brush) - item.setForeground(4, red_brush) - if fiat_value is not None and not tx_item['fiat_default']: - item.setForeground(6, blue_brush) - # sort orders - item.setData(0, SortableTreeWidgetItem.DataRole, (status, conf)) - item.setData(4, SortableTreeWidgetItem.DataRole, value_sat) - item.setData(5, SortableTreeWidgetItem.DataRole, balance) - if fiat_value is not None: - item.setData(6, SortableTreeWidgetItem.DataRole, fiat_value) - if value_sat < 0: - item.setData(7, SortableTreeWidgetItem.DataRole, tx_item['acquisition_price'].value) - item.setData(8, SortableTreeWidgetItem.DataRole, tx_item['capital_gain'].value) - if tx_hash: - item.setData(0, self.TX_HASH_ROLE, tx_hash) - item.setData(0, self.TX_VALUE_ROLE, value_sat) - self.insertTopLevelItem(0, item) - if current_tx == tx_hash: - self.setCurrentItem(item) - - def on_edited(self, item, column, prior): - '''Called only when the text actually changes''' - key = item.data(0, self.TX_HASH_ROLE) - value_sat = item.data(0, self.TX_VALUE_ROLE) - text = item.text(column) + r = self.wallet.get_full_history(domain=self.get_domain(), from_timestamp=None, to_timestamp=None, fx=fx) + seen = set() + history = fx.show_history() + tx_list = list(self.transactions.values()) + if r['transactions'] == tx_list: + return + if r['transactions'][:-1] == tx_list: + print_error('history_list: one new transaction') + row = r['transactions'][-1] + txid = row['txid'] + if txid not in self.transactions: + self.transactions[txid] = row + self.transactions.move_to_end(txid, last=True) + self.insert_tx(row) + return + else: + print_error('history_list: tx added but txid is already in list (weird), txid: ', txid) + for idx, row in enumerate(r['transactions']): + txid = row['txid'] + seen.add(txid) + if txid not in self.transactions: + self.transactions[txid] = row + self.transactions.move_to_end(txid, last=True) + self.insert_tx(row) + continue + old = self.transactions[txid] + if old == row: + continue + self.update_item(txid, self.parent.wallet.get_tx_height(txid)) + if history: + self.update_fiat(txid, row) + balance_str = self.parent.format_amount(row['balance'].value, whitespaces=True) + self.txid_to_items[txid][4].setText(balance_str) + self.txid_to_items[txid][4].setData(row['balance'].value, self.SORT_ROLE) + old.clear() + old.update(**row) + removed = 0 + l = list(enumerate(self.transactions.keys())) + for idx, txid in l: + if txid not in seen: + del self.transactions[txid] + del self.txid_to_items[txid] + items = self.std_model.takeRow(idx - removed) + removed_txid = items[0].data(self.TX_HASH_ROLE) + assert removed_txid == txid, (idx, removed) + removed += 1 + self.apply_filter() + + def update_fiat(self, txid, row): + cap_gains = self.parent.fx.get_history_capital_gains_config() + items = self.txid_to_items[txid] + self.ensure_fields_available(items, 7 if cap_gains else 5, txid) + items[5].setForeground(self.blue_brush if not row['fiat_default'] and row['fiat_value'] else self.default_color) + value_str = self.parent.fx.format_fiat(row['fiat_value'].value) + items[5].setText(value_str) + items[5].setData(row['fiat_value'].value, self.SORT_ROLE) + # fixme: should use is_mine + if row['value'].value < 0 and cap_gains: + acq = row['acquisition_price'].value + items[6].setText(self.parent.fx.format_fiat(acq)) + items[6].setData(acq, self.SORT_ROLE) + cg = row['capital_gain'].value + items[7].setText(self.parent.fx.format_fiat(cg)) + items[7].setData(cg, self.SORT_ROLE) + + def update_on_new_fee_histogram(self): + pass + # TODO update unconfirmed tx'es + + def on_edited(self, index, user_role, text): + column = index.column() + index = self.proxy.mapToSource(index) + item = self.std_model.itemFromIndex(index) + key = item.data(self.TX_HASH_ROLE) # fixme - if column == 3: + if column == 2: self.parent.wallet.set_label(key, text) self.update_labels() self.parent.update_completions() - elif column == 6: - self.parent.wallet.set_fiat_value(key, self.parent.fx.ccy, text, self.parent.fx, value_sat) - self.on_update() - - def on_doubleclick(self, item, column): - if self.permit_edit(item, column): - super(HistoryList, self).on_doubleclick(item, column) + elif column == 5: + tx_item = self.transactions[key] + self.parent.wallet.set_fiat_value(key, self.parent.fx.ccy, text, self.parent.fx, tx_item['value'].value) + value = tx_item['value'].value + if value is not None: + fee = tx_item['fee'] + fiat_fields = self.parent.wallet.get_tx_item_fiat(key, value, self.parent.fx, fee.value if fee else None) + tx_item.update(fiat_fields) + self.update_fiat(key, tx_item) else: - tx_hash = item.data(0, self.TX_HASH_ROLE) + assert False + + def mouseDoubleClickEvent(self, event: QMouseEvent): + idx = self.indexAt(event.pos()) + item = self.std_model.itemFromIndex(self.proxy.mapToSource(idx)) + if not item or item.isEditable(): + super().mouseDoubleClickEvent(event) + elif item: + tx_hash = item.data(self.TX_HASH_ROLE) self.show_transaction(tx_hash) def show_transaction(self, tx_hash): @@ -323,13 +456,13 @@ class HistoryList(MyTreeWidget, AcceptFileDragDrop): self.parent.show_transaction(tx, label) def update_labels(self): - root = self.invisibleRootItem() - child_count = root.childCount() + root = self.std_model.invisibleRootItem() + child_count = root.rowCount() for i in range(child_count): - item = root.child(i) - txid = item.data(0, self.TX_HASH_ROLE) + item = root.child(i, 2) + txid = item.data(self.TX_HASH_ROLE) label = self.wallet.get_label(txid) - item.setText(3, label) + item.setText(label) def update_item(self, tx_hash, tx_mined_status): if self.wallet is None: @@ -337,31 +470,30 @@ class HistoryList(MyTreeWidget, AcceptFileDragDrop): conf = tx_mined_status.conf status, status_str = self.wallet.get_tx_status(tx_hash, tx_mined_status) icon = self.icon_cache.get(":icons/" + TX_ICONS[status]) - items = self.findItems(tx_hash, Qt.MatchExactly, column=1) - if items: - item = items[0] - item.setIcon(0, icon) - item.setData(0, SortableTreeWidgetItem.DataRole, (status, conf)) - item.setText(2, status_str) - - def create_menu(self, position): - self.selectedIndexes() - item = self.currentItem() - if not item: - return - column = self.currentColumn() - tx_hash = item.data(0, self.TX_HASH_ROLE) - if not tx_hash: + if tx_hash not in self.txid_to_items: return + items = self.txid_to_items[tx_hash] + items[0].setIcon(icon) + items[0].setToolTip(str(conf) + _(" confirmation" + ("s" if conf != 1 else ""))) + items[0].setData((status, conf), self.SORT_ROLE) + items[1].setText(status_str) + + def create_menu(self, position: QPoint): + org_idx: QModelIndex = self.indexAt(position) + idx = self.proxy.mapToSource(org_idx) + item: QStandardItem = self.std_model.itemFromIndex(idx) + assert item, 'create_menu: index not found in model' + tx_hash = idx.data(self.TX_HASH_ROLE) + column = idx.column() + assert tx_hash, "create_menu: no tx hash" tx = self.wallet.transactions.get(tx_hash) - if not tx: - return - if column is 0: - column_title = "ID" + assert tx, "create_menu: no tx" + if column == 0: + column_title = _('Transaction ID') column_data = tx_hash else: - column_title = self.headerItem().text(column) - column_data = item.text(column) + column_title = self.std_model.horizontalHeaderItem(column).text() + column_data = item.text() tx_URL = block_explorer_URL(self.config, 'tx', tx_hash) height = self.wallet.get_tx_height(tx_hash).height is_relevant, is_mine, v, fee = self.wallet.get_wallet_delta(tx) @@ -372,8 +504,10 @@ class HistoryList(MyTreeWidget, AcceptFileDragDrop): menu.addAction(_("Remove"), lambda: self.remove_local_tx(tx_hash)) menu.addAction(_("Copy {}").format(column_title), lambda: self.parent.app.clipboard().setText(column_data)) for c in self.editable_columns: - menu.addAction(_("Edit {}").format(self.headerItem().text(c)), - lambda bound_c=c: self.editItem(item, bound_c)) + label = self.std_model.horizontalHeaderItem(c).text() + # TODO use siblingAtColumn when min Qt version is >=5.11 + persistent = QPersistentModelIndex(org_idx.sibling(org_idx.row(), c)) + menu.addAction(_("Edit {}").format(label), lambda p=persistent: self.edit(QModelIndex(p))) menu.addAction(_("Details"), lambda: self.show_transaction(tx_hash)) if is_unconfirmed and tx: # note: the current implementation of RBF *needs* the old tx fee @@ -442,7 +576,7 @@ class HistoryList(MyTreeWidget, AcceptFileDragDrop): self.parent.show_message(_("Your wallet history has been successfully exported.")) def do_export_history(self, file_name, is_csv): - history = self.transactions + history = self.transactions.values() lines = [] if is_csv: for item in history: diff --git a/electrum/gui/qt/invoice_list.py b/electrum/gui/qt/invoice_list.py @@ -29,36 +29,40 @@ from electrum.util import format_time from .util import * -class InvoiceList(MyTreeWidget): +class InvoiceList(MyTreeView): filter_columns = [0, 1, 2, 3] # Date, Requestor, Description, Amount def __init__(self, parent): - MyTreeWidget.__init__(self, parent, self.create_menu, [_('Expires'), _('Requestor'), _('Description'), _('Amount'), _('Status')], 2) + super().__init__(parent, self.create_menu, 2) self.setSortingEnabled(True) - self.header().setSectionResizeMode(1, QHeaderView.Interactive) self.setColumnWidth(1, 200) + self.setModel(QStandardItemModel(self)) + self.update() - def on_update(self): + def update(self): inv_list = self.parent.invoices.unpaid_invoices() - self.clear() + self.model().clear() + self.update_headers([_('Expires'), _('Requestor'), _('Description'), _('Amount'), _('Status')]) + self.header().setSectionResizeMode(1, QHeaderView.Interactive) for pr in inv_list: key = pr.get_id() status = self.parent.invoices.get_status(key) requestor = pr.get_requestor() exp = pr.get_expiration_date() date_str = format_time(exp) if exp else _('Never') - item = QTreeWidgetItem([date_str, requestor, pr.memo, self.parent.format_amount(pr.get_amount(), whitespaces=True), pr_tooltips.get(status,'')]) - item.setIcon(4, self.icon_cache.get(pr_icons.get(status))) - item.setData(0, Qt.UserRole, key) - item.setFont(1, QFont(MONOSPACE_FONT)) - item.setFont(3, QFont(MONOSPACE_FONT)) + labels = [date_str, requestor, pr.memo, self.parent.format_amount(pr.get_amount(), whitespaces=True), pr_tooltips.get(status,'')] + item = [QStandardItem(e) for e in labels] + item[4].setIcon(self.icon_cache.get(pr_icons.get(status))) + item[0].setData(Qt.UserRole, key) + item[1].setFont(QFont(MONOSPACE_FONT)) + item[3].setFont(QFont(MONOSPACE_FONT)) self.addTopLevelItem(item) - self.setCurrentItem(self.topLevelItem(0)) + self.selectionModel().select(self.model().index(0,0), QItemSelectionModel.SelectCurrent) self.setVisible(len(inv_list)) self.parent.invoices_label.setVisible(len(inv_list)) def import_invoices(self): - import_meta_gui(self.parent, _('invoices'), self.parent.invoices.import_file, self.on_update) + import_meta_gui(self.parent, _('invoices'), self.parent.invoices.import_file, self.update) def export_invoices(self): export_meta_gui(self.parent, _('invoices'), self.parent.invoices.export_file) diff --git a/electrum/gui/qt/main_window.py b/electrum/gui/qt/main_window.py @@ -353,8 +353,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): if self.config.is_dynfee(): self.fee_slider.update() self.do_update_fee() - # todo: update only unconfirmed tx - self.history_list.update() + self.history_list.update_on_new_fee_histogram() else: self.print_error("unexpected network_qt signal:", event, args) @@ -379,9 +378,6 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): def load_wallet(self, wallet): wallet.thread = TaskThread(self, self.on_error) self.update_recently_visited(wallet.storage.path) - # update(==init) all tabs; expensive for large wallets.. - # so delay it somewhat, hence __init__ can finish and the window can appear sooner - QTimer.singleShot(50, self.update_tabs) self.need_update.set() # Once GUI has been initialized check if we want to announce something since the callback has been called before the GUI was initialized # update menus @@ -1111,9 +1107,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError): self.from_label = QLabel(_('From')) grid.addWidget(self.from_label, 3, 0) - self.from_list = MyTreeWidget(self, self.from_list_menu, ['','']) - self.from_list.setHeaderHidden(True) - self.from_list.setMaximumHeight(80) + self.from_list = FromList(self, self.from_list_menu) grid.addWidget(self.from_list, 3, 1, 1, -1) self.set_pay_from([]) diff --git a/electrum/gui/qt/network_dialog.py b/electrum/gui/qt/network_dialog.py @@ -100,7 +100,6 @@ class NodesListWidget(QTreeWidget): def update(self, network: Network): self.clear() - self.addChild = self.addTopLevelItem chains = network.get_blockchains() n_chains = len(chains) for chain_id, interfaces in chains.items(): @@ -118,7 +117,7 @@ class NodesListWidget(QTreeWidget): item = QTreeWidgetItem([i.host + star, '%d'%i.tip]) item.setData(0, Qt.UserRole, 0) item.setData(1, Qt.UserRole, i.server) - x.addChild(item) + x.addTopLevelItem(item) if n_chains > 1: self.addTopLevelItem(x) x.setExpanded(True) diff --git a/electrum/gui/qt/request_list.py b/electrum/gui/qt/request_list.py @@ -23,43 +23,39 @@ # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. -from PyQt5.QtGui import * -from PyQt5.QtCore import * -from PyQt5.QtWidgets import QTreeWidgetItem, QMenu +from PyQt5.QtGui import QStandardItemModel, QStandardItem +from PyQt5.QtWidgets import QMenu +from PyQt5.QtCore import Qt from electrum.i18n import _ from electrum.util import format_time, age from electrum.plugin import run_hook from electrum.paymentrequest import PR_UNKNOWN -from .util import MyTreeWidget, pr_tooltips, pr_icons +from .util import MyTreeView, pr_tooltips, pr_icons - -class RequestList(MyTreeWidget): +class RequestList(MyTreeView): filter_columns = [0, 1, 2, 3, 4] # Date, Account, Address, Description, Amount def __init__(self, parent): - MyTreeWidget.__init__(self, parent, self.create_menu, [_('Date'), _('Address'), '', _('Description'), _('Amount'), _('Status')], 3) - self.currentItemChanged.connect(self.item_changed) - self.itemClicked.connect(self.item_changed) + super().__init__(parent, self.create_menu, 3, editable_columns=[]) + self.setModel(QStandardItemModel(self)) self.setSortingEnabled(True) self.setColumnWidth(0, 180) - self.hideColumn(1) + self.update() + self.selectionModel().currentRowChanged.connect(self.item_changed) - def item_changed(self, item): - if item is None: - return - if not item.isSelected(): - return - addr = str(item.text(1)) + def item_changed(self, idx): + # TODO use siblingAtColumn when min Qt version is >=5.11 + addr = self.model().itemFromIndex(idx.sibling(idx.row(), 1)).text() req = self.wallet.receive_requests.get(addr) if req is None: self.update() return expires = age(req['time'] + req['exp']) if req.get('exp') else _('Never') amount = req['amount'] - message = self.wallet.labels.get(addr, '') + message = req['memo'] self.parent.receive_address_e.setText(addr) self.parent.receive_message_e.setText(message) self.parent.receive_amount_e.setAmount(amount) @@ -68,7 +64,7 @@ class RequestList(MyTreeWidget): self.parent.expires_label.setText(expires) self.parent.new_request_button.setEnabled(True) - def on_update(self): + def update(self): self.wallet = self.parent.wallet # hide receive tab if no receive requests available b = len(self.wallet.receive_requests) > 0 @@ -86,8 +82,9 @@ class RequestList(MyTreeWidget): self.parent.set_receive_address(addr) self.parent.new_request_button.setEnabled(addr != current_address) - # clear the list and fill it again - self.clear() + self.model().clear() + self.update_headers([_('Date'), _('Address'), '', _('Description'), _('Amount'), _('Status')]) + self.hideColumn(1) # hide address column for req in self.wallet.get_sorted_requests(self.config): address = req['address'] if address not in domain: @@ -95,35 +92,40 @@ class RequestList(MyTreeWidget): timestamp = req.get('time', 0) amount = req.get('amount') expiration = req.get('exp', None) - message = req.get('memo', '') + message = req['memo'] date = format_time(timestamp) status = req.get('status') signature = req.get('sig') requestor = req.get('name', '') amount_str = self.parent.format_amount(amount) if amount else "" - item = QTreeWidgetItem([date, address, '', message, amount_str, pr_tooltips.get(status,'')]) + labels = [date, address, '', message, amount_str, pr_tooltips.get(status,'')] + items = [QStandardItem(e) for e in labels] + self.set_editability(items) if signature is not None: - item.setIcon(2, self.icon_cache.get(":icons/seal.png")) - item.setToolTip(2, 'signed by '+ requestor) + items[2].setIcon(self.icon_cache.get(":icons/seal.png")) + items[2].setToolTip('signed by '+ requestor) if status is not PR_UNKNOWN: - item.setIcon(6, self.icon_cache.get(pr_icons.get(status))) - self.addTopLevelItem(item) - + items[5].setIcon(self.icon_cache.get(pr_icons.get(status))) + items[3].setData(address, Qt.UserRole) + self.model().insertRow(self.model().rowCount(), items) def create_menu(self, position): - item = self.itemAt(position) + idx = self.indexAt(position) + # TODO use siblingAtColumn when min Qt version is >=5.11 + item = self.model().itemFromIndex(idx.sibling(idx.row(), 1)) if not item: return - addr = str(item.text(1)) + addr = item.text() req = self.wallet.receive_requests.get(addr) if req is None: self.update() return - column = self.currentColumn() - column_title = self.headerItem().text(column) - column_data = item.text(column) + column = idx.column() + column_title = self.model().horizontalHeaderItem(column).text() + column_data = item.text() menu = QMenu(self) - menu.addAction(_("Copy {}").format(column_title), lambda: self.parent.app.clipboard().setText(column_data)) + if column != 2: + menu.addAction(_("Copy {}").format(column_title), lambda: self.parent.app.clipboard().setText(column_data)) menu.addAction(_("Copy URI"), lambda: self.parent.view_and_paste('URI', '', self.parent.get_request_URI(addr))) menu.addAction(_("Save as BIP70 file"), lambda: self.parent.export_payment_request(addr)) menu.addAction(_("Delete"), lambda: self.parent.delete_payment_request(addr)) diff --git a/electrum/gui/qt/util.py b/electrum/gui/qt/util.py @@ -5,6 +5,7 @@ import platform import queue from functools import partial from typing import NamedTuple, Callable, Optional +from abc import abstractmethod from PyQt5.QtGui import * from PyQt5.QtCore import * @@ -398,20 +399,16 @@ class ElectrumItemDelegate(QStyledItemDelegate): def createEditor(self, parent, option, index): return self.parent().createEditor(parent, option, index) -class MyTreeWidget(QTreeWidget): +class MyTreeView(QTreeView): - def __init__(self, parent, create_menu, headers, stretch_column=None, - editable_columns=None): - QTreeWidget.__init__(self, parent) + def __init__(self, parent, create_menu, stretch_column=None, editable_columns=None): + super().__init__(parent) self.parent = parent self.config = self.parent.config self.stretch_column = stretch_column self.setContextMenuPolicy(Qt.CustomContextMenu) self.customContextMenuRequested.connect(create_menu) self.setUniformRowHeights(True) - # extend the syntax for consistency - self.addChild = self.addTopLevelItem - self.insertChild = self.insertTopLevelItem self.icon_cache = IconCache() @@ -424,127 +421,143 @@ class MyTreeWidget(QTreeWidget): editable_columns = set(editable_columns) self.editable_columns = editable_columns self.setItemDelegate(ElectrumItemDelegate(self)) - self.itemDoubleClicked.connect(self.on_doubleclick) - self.update_headers(headers) self.current_filter = "" self.setRootIsDecorated(False) # remove left margin self.toolbar_shown = False - def update_headers(self, headers): - self.setColumnCount(len(headers)) - self.setHeaderLabels(headers) + def set_editability(self, items): + for idx, i in enumerate(items): + i.setEditable(idx in self.editable_columns) + + def selected_in_column(self, column: int): + items = self.selectionModel().selectedIndexes() + return list(x for x in items if x.column() == column) + + def current_item_user_role(self, col) -> Optional[QStandardItem]: + idx = self.selectionModel().currentIndex() + idx = idx.sibling(idx.row(), col) + item = self.model().itemFromIndex(idx) + if item: + return item.data(Qt.UserRole) + + def set_current_idx(self, set_current: QPersistentModelIndex): + if set_current: + assert isinstance(set_current, QPersistentModelIndex) + assert set_current.isValid() + self.selectionModel().select(QModelIndex(set_current), QItemSelectionModel.SelectCurrent) + + def update_headers(self, headers, model=None): + if model is None: + model = self.model() + model.setHorizontalHeaderLabels(headers) self.header().setStretchLastSection(False) for col in range(len(headers)): sm = QHeaderView.Stretch if col == self.stretch_column else QHeaderView.ResizeToContents self.header().setSectionResizeMode(col, sm) - def editItem(self, item, column): - if column in self.editable_columns: - try: - self.editing_itemcol = (item, column, item.text(column)) - # Calling setFlags causes on_changed events for some reason - item.setFlags(item.flags() | Qt.ItemIsEditable) - QTreeWidget.editItem(self, item, column) - item.setFlags(item.flags() & ~Qt.ItemIsEditable) - except RuntimeError: - # (item) wrapped C/C++ object has been deleted - pass - def keyPressEvent(self, event): if event.key() in [ Qt.Key_F2, Qt.Key_Return ] and self.editor is None: - self.on_activated(self.currentItem(), self.currentColumn()) - else: - QTreeWidget.keyPressEvent(self, event) - - def permit_edit(self, item, column): - return (column in self.editable_columns - and self.on_permit_edit(item, column)) - - def on_permit_edit(self, item, column): - return True - - def on_doubleclick(self, item, column): - if self.permit_edit(item, column): - self.editItem(item, column) + self.on_activated(self.selectionModel().currentIndex()) + return + super().keyPressEvent(event) - def on_activated(self, item, column): + def on_activated(self, idx): # on 'enter' we show the menu - pt = self.visualItemRect(item).bottomLeft() + pt = self.visualRect(idx).bottomLeft() pt.setX(50) self.customContextMenuRequested.emit(pt) def createEditor(self, parent, option, index): self.editor = QStyledItemDelegate.createEditor(self.itemDelegate(), parent, option, index) - self.editor.editingFinished.connect(self.editing_finished) + persistent = QPersistentModelIndex(index) + user_role = index.data(Qt.UserRole) + assert user_role is not None + idx = QModelIndex(persistent) + index = self.proxy.mapToSource(idx) + item = self.std_model.itemFromIndex(index) + prior_text = item.text() + def editing_finished(): + # Long-time QT bug - pressing Enter to finish editing signals + # editingFinished twice. If the item changed the sequence is + # Enter key: editingFinished, on_change, editingFinished + # Mouse: on_change, editingFinished + # This mess is the cleanest way to ensure we make the + # on_edited callback with the updated item + if self.editor is None: + return + if self.editor.text() == prior_text: + self.editor = None # Unchanged - ignore any 2nd call + return + if item.text() == prior_text: + return # Buggy first call on Enter key, item not yet updated + if not idx.isValid(): + return + self.on_edited(idx, user_role, self.editor.text()) + self.editor = None + self.editor.editingFinished.connect(editing_finished) return self.editor - def editing_finished(self): - # Long-time QT bug - pressing Enter to finish editing signals - # editingFinished twice. If the item changed the sequence is - # Enter key: editingFinished, on_change, editingFinished - # Mouse: on_change, editingFinished - # This mess is the cleanest way to ensure we make the - # on_edited callback with the updated item - if self.editor: - (item, column, prior_text) = self.editing_itemcol - if self.editor.text() == prior_text: - self.editor = None # Unchanged - ignore any 2nd call - elif item.text(column) == prior_text: - pass # Buggy first call on Enter key, item not yet updated - else: - # What we want - the updated item - self.on_edited(*self.editing_itemcol) - self.editor = None - - # Now do any pending updates - if self.editor is None and self.pending_update: - self.pending_update = False - self.on_update() - - def on_edited(self, item, column, prior): - '''Called only when the text actually changes''' - key = item.data(0, Qt.UserRole) - text = item.text(column) - self.parent.wallet.set_label(key, text) + def edit(self, idx, trigger=QAbstractItemView.AllEditTriggers, event=None): + """ + this is to prevent: + edit: editing failed + from inside qt + """ + return super().edit(idx, trigger, event) + + def on_edited(self, idx: QModelIndex, user_role, text): + self.parent.wallet.set_label(user_role, text) self.parent.history_list.update_labels() self.parent.update_completions() - def update(self): - # Defer updates if editing - if self.editor: - self.pending_update = True - else: - self.setUpdatesEnabled(False) - scroll_pos = self.verticalScrollBar().value() - self.on_update() - self.setUpdatesEnabled(True) - # To paint the list before resetting the scroll position - self.parent.app.processEvents() - self.verticalScrollBar().setValue(scroll_pos) + def apply_filter(self): if self.current_filter: self.filter(self.current_filter) - def on_update(self): + @abstractmethod + def should_hide(self, row): + """ + row_num is for self.model(). So if there is a proxy, it is the row number + in that! + """ pass - def get_leaves(self, root): - child_count = root.childCount() - if child_count == 0: - yield root - for i in range(child_count): - item = root.child(i) - for x in self.get_leaves(item): - yield x + def hide_row(self, row_num): + """ + row_num is for self.model(). So if there is a proxy, it is the row number + in that! + """ + should_hide = self.should_hide(row_num) + if not self.current_filter and should_hide is None: + # no filters at all, neither date nor search + self.setRowHidden(row_num, QModelIndex(), False) + return + for column in self.filter_columns: + if isinstance(self.model(), QSortFilterProxyModel): + idx = self.model().mapToSource(self.model().index(row_num, column)) + item = self.model().sourceModel().itemFromIndex(idx) + else: + idx = self.model().index(row_num, column) + item = self.model().itemFromIndex(idx) + txt = item.text().lower() + if self.current_filter in txt: + # the filter matched, but the date filter might apply + self.setRowHidden(row_num, QModelIndex(), bool(should_hide)) + break + else: + # we did not find the filter in any columns, show the item + self.setRowHidden(row_num, QModelIndex(), True) def filter(self, p): - columns = self.__class__.filter_columns p = p.lower() self.current_filter = p - for item in self.get_leaves(self.invisibleRootItem()): - item.setHidden(all([item.text(column).lower().find(p) == -1 - for column in columns])) + self.hide_rows() + + def hide_rows(self): + for row in range(self.model().rowCount()): + self.hide_row(row) def create_toolbar(self, config=None): hbox = QHBoxLayout() @@ -790,22 +803,6 @@ def get_parent_main_window(widget): return widget return None -class SortableTreeWidgetItem(QTreeWidgetItem): - DataRole = Qt.UserRole + 100 - - def __lt__(self, other): - column = self.treeWidget().sortColumn() - if None not in [x.data(column, self.DataRole) for x in [self, other]]: - # We have set custom data to sort by - return self.data(column, self.DataRole) < other.data(column, self.DataRole) - try: - # Is the value something numeric? - return float(self.text(column)) < float(other.text(column)) - except ValueError: - # If not, we will just do string comparison - return self.text(column) < other.text(column) - - class IconCache: def __init__(self): @@ -821,6 +818,21 @@ def get_default_language(): name = QLocale.system().name() return name if name in languages else 'en_UK' +class FromList(QTreeWidget): + def __init__(self, parent, create_menu): + super().__init__(parent) + self.setHeaderHidden(True) + self.setMaximumHeight(300) + self.setContextMenuPolicy(Qt.CustomContextMenu) + self.customContextMenuRequested.connect(create_menu) + self.setUniformRowHeights(True) + # remove left margin + self.setRootIsDecorated(False) + self.setColumnCount(2) + self.header().setStretchLastSection(False) + sm = QHeaderView.ResizeToContents + self.header().setSectionResizeMode(0, sm) + self.header().setSectionResizeMode(1, sm) if __name__ == "__main__": app = QApplication([]) diff --git a/electrum/gui/qt/utxo_list.py b/electrum/gui/qt/utxo_list.py @@ -23,49 +23,60 @@ # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. +from typing import Optional, List + from electrum.i18n import _ from .util import * - -class UTXOList(MyTreeWidget): - filter_columns = [0, 2] # Address, Label +class UTXOList(MyTreeView): + filter_columns = [0, 1] # Address, Label def __init__(self, parent=None): - MyTreeWidget.__init__(self, parent, self.create_menu, [ _('Address'), _('Label'), _('Amount'), _('Height'), _('Output point')], 1) + super().__init__(parent, self.create_menu, 1) + self.setModel(QStandardItemModel(self)) self.setSelectionMode(QAbstractItemView.ExtendedSelection) self.setSortingEnabled(True) + self.update() - def get_name(self, x): - return x.get('prevout_hash') + ":%d"%x.get('prevout_n') - - def on_update(self): + def update(self): self.wallet = self.parent.wallet - item = self.currentItem() - self.clear() - self.utxos = self.wallet.get_utxos() - for x in self.utxos: + utxos = self.wallet.get_utxos() + self.utxo_dict = {} + self.model().clear() + self.update_headers([ _('Address'), _('Label'), _('Amount'), _('Height'), _('Output point')]) + for idx, x in enumerate(utxos): address = x.get('address') height = x.get('height') - name = self.get_name(x) + name = x.get('prevout_hash') + ":%d"%x.get('prevout_n') + self.utxo_dict[name] = x label = self.wallet.get_label(x.get('prevout_hash')) amount = self.parent.format_amount(x['value'], whitespaces=True) - utxo_item = SortableTreeWidgetItem([address, label, amount, '%d'%height, name[0:10] + '...' + name[-2:]]) - utxo_item.setFont(0, QFont(MONOSPACE_FONT)) - utxo_item.setFont(2, QFont(MONOSPACE_FONT)) - utxo_item.setFont(4, QFont(MONOSPACE_FONT)) - utxo_item.setData(0, Qt.UserRole, name) + labels = [address, label, amount, '%d'%height, name[0:10] + '...' + name[-2:]] + utxo_item = [QStandardItem(x) for x in labels] + self.set_editability(utxo_item) + utxo_item[0].setFont(QFont(MONOSPACE_FONT)) + utxo_item[2].setFont(QFont(MONOSPACE_FONT)) + utxo_item[4].setFont(QFont(MONOSPACE_FONT)) + utxo_item[0].setData(name, Qt.UserRole) if self.wallet.is_frozen(address): - utxo_item.setBackground(0, ColorScheme.BLUE.as_color(True)) - self.addChild(utxo_item) + utxo_item[0].setBackground(ColorScheme.BLUE.as_color(True)) + self.model().insertRow(idx, utxo_item) + + def selected_column_0_user_roles(self) -> Optional[List[str]]: + if not self.model(): + return None + items = self.selected_in_column(0) + if not items: + return None + return [x.data(Qt.UserRole) for x in items] def create_menu(self, position): - selected = [x.data(0, Qt.UserRole) for x in self.selectedItems()] + selected = self.selected_column_0_user_roles() if not selected: return menu = QMenu() - coins = filter(lambda x: self.get_name(x) in selected, self.utxos) - + coins = (self.utxo_dict[name] for name in selected) menu.addAction(_("Spend"), lambda: self.parent.spend_coins(coins)) if len(selected) == 1: txid = selected[0].split(':')[0] @@ -75,7 +86,3 @@ class UTXOList(MyTreeWidget): menu.addAction(_("Details"), lambda: self.parent.show_transaction(tx, label)) menu.exec_(self.viewport().mapToGlobal(position)) - - def on_permit_edit(self, item, column): - # disable editing fields in this tab (labels) - return False