From 93c90a30f03ce2ab1254e83ce5e143dbfd6e1b0f Mon Sep 17 00:00:00 2001 From: SomberNight Date: Fri, 15 May 2020 15:17:47 +0200 Subject: [PATCH] qt MyTreeView: impl custom sort order framework, and use for invoices sort invoices and payreqs (for Date column) based on timestamps (timestamps have second resolution while the displayed date has minute resolution) --- electrum/gui/qt/history_list.py | 2 +- electrum/gui/qt/invoice_list.py | 26 ++++++++------ electrum/gui/qt/request_list.py | 30 +++++++++------- electrum/gui/qt/util.py | 64 ++++++++++++++++++++++++++------- 4 files changed, 84 insertions(+), 38 deletions(-) diff --git a/electrum/gui/qt/history_list.py b/electrum/gui/qt/history_list.py index 60f21a294..84cacc048 100644 --- a/electrum/gui/qt/history_list.py +++ b/electrum/gui/qt/history_list.py @@ -755,7 +755,7 @@ class HistoryList(MyTreeView, AcceptFileDragDrop): from electrum.util import json_encode f.write(json_encode(txns)) - def text_txid_from_coordinate(self, row, col): + def get_text_and_userrole_from_coordinate(self, row, col): idx = self.model().mapToSource(self.model().index(row, col)) tx_item = self.hm.transactions.value_from_pos(idx.row()) return self.hm.data(idx, Qt.DisplayRole).value(), get_item_key(tx_item) diff --git a/electrum/gui/qt/invoice_list.py b/electrum/gui/qt/invoice_list.py index 9b38db6f5..537e64111 100644 --- a/electrum/gui/qt/invoice_list.py +++ b/electrum/gui/qt/invoice_list.py @@ -37,7 +37,7 @@ from electrum.util import get_request_status from electrum.util import PR_TYPE_ONCHAIN, PR_TYPE_LN from electrum.lnutil import PaymentAttemptLog -from .util import (MyTreeView, read_QIcon, +from .util import (MyTreeView, read_QIcon, MySortModel, import_meta_gui, export_meta_gui, pr_icons) from .util import CloseButton, Buttons from .util import WindowModalDialog @@ -46,6 +46,7 @@ from .util import WindowModalDialog ROLE_REQUEST_TYPE = Qt.UserRole ROLE_REQUEST_ID = Qt.UserRole + 1 +ROLE_SORT_ORDER = Qt.UserRole + 2 class InvoiceList(MyTreeView): @@ -68,8 +69,11 @@ class InvoiceList(MyTreeView): super().__init__(parent, self.create_menu, stretch_column=self.Columns.DESCRIPTION, editable_columns=[]) + self.std_model = QStandardItemModel(self) + self.proxy = MySortModel(self, sort_role=ROLE_SORT_ORDER) + self.proxy.setSourceModel(self.std_model) + self.setModel(self.proxy) self.setSortingEnabled(True) - self.setModel(QStandardItemModel(self)) self.setSelectionMode(QAbstractItemView.ExtendedSelection) self.update() @@ -92,7 +96,8 @@ class InvoiceList(MyTreeView): def update(self): # not calling maybe_defer_update() as it interferes with conditional-visibility - self.model().clear() + self.proxy.setDynamicSortFilter(False) # temp. disable re-sorting after every change + self.std_model.clear() self.update_headers(self.__class__.headers) for idx, item in enumerate(self.parent.wallet.get_invoices()): invoice_type = item['type'] @@ -119,17 +124,17 @@ class InvoiceList(MyTreeView): items[self.Columns.STATUS].setIcon(read_QIcon(pr_icons.get(status))) items[self.Columns.DATE].setData(key, role=ROLE_REQUEST_ID) items[self.Columns.DATE].setData(invoice_type, role=ROLE_REQUEST_TYPE) - self.model().insertRow(idx, items) - - self.selectionModel().select(self.model().index(0,0), QItemSelectionModel.SelectCurrent) + items[self.Columns.DATE].setData(timestamp, role=ROLE_SORT_ORDER) + self.std_model.insertRow(idx, items) + self.filter() + self.proxy.setDynamicSortFilter(True) # sort requests by date self.sortByColumn(self.Columns.DATE, Qt.DescendingOrder) # hide list if empty if self.parent.isVisible(): - b = self.model().rowCount() > 0 + b = self.std_model.rowCount() > 0 self.setVisible(b) self.parent.invoices_label.setVisible(b) - self.filter() def import_invoices(self): import_meta_gui(self.parent, _('invoices'), self.parent.invoices.import_file, self.update) @@ -150,12 +155,11 @@ class InvoiceList(MyTreeView): menu.exec_(self.viewport().mapToGlobal(position)) return idx = self.indexAt(position) - item = self.model().itemFromIndex(idx) - item_col0 = self.model().itemFromIndex(idx.sibling(idx.row(), self.Columns.DATE)) + item = self.item_from_index(idx) + item_col0 = self.item_from_index(idx.sibling(idx.row(), self.Columns.DATE)) if not item or not item_col0: return key = item_col0.data(ROLE_REQUEST_ID) - request_type = item_col0.data(ROLE_REQUEST_TYPE) menu = QMenu(self) self.add_copy_menu(menu, idx) invoice = self.parent.wallet.get_invoice(key) diff --git a/electrum/gui/qt/request_list.py b/electrum/gui/qt/request_list.py index 4bc874a37..dd686974c 100644 --- a/electrum/gui/qt/request_list.py +++ b/electrum/gui/qt/request_list.py @@ -27,21 +27,21 @@ from enum import IntEnum from typing import Optional from PyQt5.QtGui import QStandardItemModel, QStandardItem -from PyQt5.QtWidgets import QMenu +from PyQt5.QtWidgets import QMenu, QAbstractItemView from PyQt5.QtCore import Qt, QItemSelectionModel, QModelIndex -from PyQt5.QtWidgets import QAbstractItemView from electrum.i18n import _ from electrum.util import format_time, get_request_status from electrum.util import PR_TYPE_ONCHAIN, PR_TYPE_LN -from electrum.util import PR_PAID from electrum.plugin import run_hook -from .util import MyTreeView, pr_icons, read_QIcon, webopen +from .util import MyTreeView, pr_icons, read_QIcon, webopen, MySortModel ROLE_REQUEST_TYPE = Qt.UserRole ROLE_KEY = Qt.UserRole + 1 +ROLE_SORT_ORDER = Qt.UserRole + 2 + class RequestList(MyTreeView): @@ -64,7 +64,10 @@ class RequestList(MyTreeView): stretch_column=self.Columns.DESCRIPTION, editable_columns=[]) self.wallet = self.parent.wallet - self.setModel(QStandardItemModel(self)) + self.std_model = QStandardItemModel(self) + self.proxy = MySortModel(self, sort_role=ROLE_SORT_ORDER) + self.proxy.setSourceModel(self.std_model) + self.setModel(self.proxy) self.setSortingEnabled(True) self.selectionModel().currentRowChanged.connect(self.item_changed) self.setSelectionMode(QAbstractItemView.ExtendedSelection) @@ -86,7 +89,7 @@ class RequestList(MyTreeView): if not idx.isValid(): return # TODO use siblingAtColumn when min Qt version is >=5.11 - item = self.model().itemFromIndex(idx.sibling(idx.row(), self.Columns.DATE)) + item = self.item_from_index(idx.sibling(idx.row(), self.Columns.DATE)) request_type = item.data(ROLE_REQUEST_TYPE) key = item.data(ROLE_KEY) req = self.wallet.get_request(key) @@ -107,14 +110,13 @@ class RequestList(MyTreeView): self.selectionModel().clearCurrentIndex() def refresh_status(self): - m = self.model() + m = self.std_model for r in range(m.rowCount()): idx = m.index(r, self.Columns.STATUS) date_idx = idx.sibling(idx.row(), self.Columns.DATE) date_item = m.itemFromIndex(date_idx) status_item = m.itemFromIndex(idx) key = date_item.data(ROLE_KEY) - is_lightning = date_item.data(ROLE_REQUEST_TYPE) == PR_TYPE_LN req = self.wallet.get_request(key) if req: status, status_str = get_request_status(req) @@ -124,7 +126,8 @@ class RequestList(MyTreeView): def update(self): # not calling maybe_defer_update() as it interferes with conditional-visibility self.parent.update_receive_address_styling() - self.model().clear() + self.proxy.setDynamicSortFilter(False) # temp. disable re-sorting after every change + self.std_model.clear() self.update_headers(self.__class__.headers) for req in self.wallet.get_sorted_requests(): status, status_str = get_request_status(req) @@ -147,16 +150,18 @@ class RequestList(MyTreeView): self.set_editability(items) items[self.Columns.DATE].setData(request_type, ROLE_REQUEST_TYPE) items[self.Columns.DATE].setData(key, ROLE_KEY) + items[self.Columns.DATE].setData(timestamp, ROLE_SORT_ORDER) items[self.Columns.DATE].setIcon(icon) items[self.Columns.STATUS].setIcon(read_QIcon(pr_icons.get(status))) items[self.Columns.DATE].setToolTip(tooltip) - self.model().insertRow(self.model().rowCount(), items) + self.std_model.insertRow(self.std_model.rowCount(), items) self.filter() + self.proxy.setDynamicSortFilter(True) # sort requests by date self.sortByColumn(self.Columns.DATE, Qt.DescendingOrder) # hide list if empty if self.parent.isVisible(): - b = self.model().rowCount() > 0 + b = self.std_model.rowCount() > 0 self.setVisible(b) self.parent.receive_requests_label.setVisible(b) if not b: @@ -172,9 +177,8 @@ class RequestList(MyTreeView): menu.exec_(self.viewport().mapToGlobal(position)) return idx = self.indexAt(position) - item = self.model().itemFromIndex(idx) # TODO use siblingAtColumn when min Qt version is >=5.11 - item = self.model().itemFromIndex(idx.sibling(idx.row(), self.Columns.DATE)) + item = self.item_from_index(idx.sibling(idx.row(), self.Columns.DATE)) if not item: return key = item.data(ROLE_KEY) diff --git a/electrum/gui/qt/util.py b/electrum/gui/qt/util.py index 1449d344a..ec37753f4 100644 --- a/electrum/gui/qt/util.py +++ b/electrum/gui/qt/util.py @@ -7,15 +7,16 @@ import queue import traceback import os import webbrowser - +from decimal import Decimal from functools import partial, lru_cache -from typing import NamedTuple, Callable, Optional, TYPE_CHECKING, Union, List, Dict, Any +from typing import (NamedTuple, Callable, Optional, TYPE_CHECKING, Union, List, Dict, Any, + Sequence, Iterable) from PyQt5.QtGui import (QFont, QColor, QCursor, QPixmap, QStandardItem, QPalette, QIcon, QFontMetrics, QShowEvent) from PyQt5.QtCore import (Qt, QPersistentModelIndex, QModelIndex, pyqtSignal, QCoreApplication, QItemSelectionModel, QThread, - QSortFilterProxyModel, QSize, QLocale) + QSortFilterProxyModel, QSize, QLocale, QAbstractItemModel) from PyQt5.QtWidgets import (QPushButton, QLabel, QMessageBox, QHBoxLayout, QAbstractItemView, QVBoxLayout, QLineEdit, QStyle, QDialog, QGroupBox, QButtonGroup, QRadioButton, @@ -466,6 +467,7 @@ def filename_field(parent, config, defaultname, select_msg): return vbox, filename_e, b1 + class ElectrumItemDelegate(QStyledItemDelegate): def __init__(self, tv: 'MyTreeView'): super().__init__(tv) @@ -477,7 +479,7 @@ class ElectrumItemDelegate(QStyledItemDelegate): new_text = editor.text() idx = QModelIndex(self.opened) row, col = idx.row(), idx.column() - _prior_text, user_role = self.tv.text_txid_from_coordinate(row, col) + _prior_text, user_role = self.tv.get_text_and_userrole_from_coordinate(row, col) # check that we didn't forget to set UserRole on an editable field assert user_role is not None, (row, col) self.tv.on_edited(idx, user_role, new_text) @@ -488,9 +490,12 @@ class ElectrumItemDelegate(QStyledItemDelegate): self.opened = QPersistentModelIndex(idx) return super().createEditor(parent, option, idx) + class MyTreeView(QTreeView): ROLE_CLIPBOARD_DATA = Qt.UserRole + 100 + filter_columns: Iterable[int] + def __init__(self, parent: 'ElectrumWindow', create_menu, *, stretch_column=None, editable_columns=None): super().__init__(parent) @@ -536,10 +541,25 @@ class MyTreeView(QTreeView): def current_item_user_role(self, col) -> Any: idx = self.selectionModel().currentIndex() idx = idx.sibling(idx.row(), col) - item = self.model().itemFromIndex(idx) + item = self.item_from_index(idx) if item: return item.data(Qt.UserRole) + def item_from_index(self, idx: QModelIndex) -> Optional[QStandardItem]: + model = self.model() + if isinstance(model, QSortFilterProxyModel): + idx = model.mapToSource(idx) + return model.sourceModel().itemFromIndex(idx) + else: + return model.itemFromIndex(idx) + + def original_model(self) -> QAbstractItemModel: + model = self.model() + if isinstance(model, QSortFilterProxyModel): + return model.sourceModel() + else: + return model + def set_current_idx(self, set_current: QPersistentModelIndex): if set_current: assert isinstance(set_current, QPersistentModelIndex) @@ -551,8 +571,7 @@ class MyTreeView(QTreeView): if not isinstance(headers, dict): # convert to dict headers = dict(enumerate(headers)) col_names = [headers[col_idx] for col_idx in sorted(headers.keys())] - model = self.model() - model.setHorizontalHeaderLabels(col_names) + self.original_model().setHorizontalHeaderLabels(col_names) self.header().setStretchLastSection(False) for col_idx in headers: sm = QHeaderView.Stretch if col_idx == self.stretch_column else QHeaderView.ResizeToContents @@ -592,10 +611,9 @@ class MyTreeView(QTreeView): """ return False - def text_txid_from_coordinate(self, row_num, column): - assert not isinstance(self.model(), QSortFilterProxyModel) + def get_text_and_userrole_from_coordinate(self, row_num, column): idx = self.model().index(row_num, column) - item = self.model().itemFromIndex(idx) + item = self.item_from_index(idx) user_role = item.data(Qt.UserRole) return item.text(), user_role @@ -610,7 +628,7 @@ class MyTreeView(QTreeView): self.setRowHidden(row_num, QModelIndex(), False) return for column in self.filter_columns: - txt, _ = self.text_txid_from_coordinate(row_num, column) + txt, _ = self.get_text_and_userrole_from_coordinate(row_num, column) txt = txt.lower() if self.current_filter in txt: # the filter matched, but the date filter might apply @@ -664,8 +682,8 @@ class MyTreeView(QTreeView): def add_copy_menu(self, menu: QMenu, idx) -> QMenu: cc = menu.addMenu(_("Copy")) for column in self.Columns: - column_title = self.model().horizontalHeaderItem(column).text() - item_col = self.model().itemFromIndex(idx.sibling(idx.row(), column)) + column_title = self.original_model().horizontalHeaderItem(column).text() + item_col = self.item_from_index(idx.sibling(idx.row(), column)) clipboard_data = item_col.data(self.ROLE_CLIPBOARD_DATA) if clipboard_data is None: clipboard_data = item_col.text().strip() @@ -692,6 +710,26 @@ class MyTreeView(QTreeView): return defer +class MySortModel(QSortFilterProxyModel): + def __init__(self, parent, *, sort_role): + super().__init__(parent) + self._sort_role = sort_role + + def lessThan(self, source_left: QModelIndex, source_right: QModelIndex): + item1 = self.sourceModel().itemFromIndex(source_left) + item2 = self.sourceModel().itemFromIndex(source_right) + data1 = item1.data(self._sort_role) + data2 = item2.data(self._sort_role) + if data1 is not None and data2 is not None: + return data1 < data2 + v1 = item1.text() + v2 = item2.text() + try: + return Decimal(v1) < Decimal(v2) + except: + return v1 < v2 + + class ButtonsWidget(QWidget): def __init__(self):