From 206bacbcb38805012e6eb15850e5f004bf3d6bd9 Mon Sep 17 00:00:00 2001 From: ThomasV Date: Wed, 15 Mar 2023 14:25:42 +0100 Subject: [PATCH] move MyTreeView and related classes to own submodule --- electrum/gui/qt/address_list.py | 3 +- electrum/gui/qt/channels_list.py | 3 +- electrum/gui/qt/contact_list.py | 3 +- electrum/gui/qt/history_list.py | 3 +- electrum/gui/qt/invoice_list.py | 4 +- electrum/gui/qt/my_treeview.py | 476 ++++++++++++++++++++++++++ electrum/gui/qt/new_channel_dialog.py | 4 +- electrum/gui/qt/request_list.py | 3 +- electrum/gui/qt/transaction_dialog.py | 4 +- electrum/gui/qt/util.py | 406 ---------------------- electrum/gui/qt/utxo_list.py | 3 +- electrum/gui/qt/watchtower_dialog.py | 3 +- 12 files changed, 497 insertions(+), 418 deletions(-) create mode 100644 electrum/gui/qt/my_treeview.py diff --git a/electrum/gui/qt/address_list.py b/electrum/gui/qt/address_list.py index 9251faec1..2331359ca 100644 --- a/electrum/gui/qt/address_list.py +++ b/electrum/gui/qt/address_list.py @@ -37,7 +37,8 @@ from electrum.plugin import run_hook from electrum.bitcoin import is_address from electrum.wallet import InternalAddressCorruption -from .util import MyTreeView, MONOSPACE_FONT, ColorScheme, webopen, MySortModel +from .util import MONOSPACE_FONT, ColorScheme, webopen +from .my_treeview import MyTreeView, MySortModel if TYPE_CHECKING: from .main_window import ElectrumWindow diff --git a/electrum/gui/qt/channels_list.py b/electrum/gui/qt/channels_list.py index c47b16b67..18c9dadc3 100644 --- a/electrum/gui/qt/channels_list.py +++ b/electrum/gui/qt/channels_list.py @@ -19,10 +19,11 @@ from electrum.lnutil import LOCAL, REMOTE, format_short_channel_id from electrum.lnworker import LNWallet from electrum.gui import messages -from .util import (MyTreeView, WindowModalDialog, Buttons, OkButton, CancelButton, +from .util import (WindowModalDialog, Buttons, OkButton, CancelButton, EnterButton, WaitingDialog, MONOSPACE_FONT, ColorScheme) from .amountedit import BTCAmountEdit, FreezableLineEdit from .util import read_QIcon, font_height +from .my_treeview import MyTreeView if TYPE_CHECKING: from .main_window import ElectrumWindow diff --git a/electrum/gui/qt/contact_list.py b/electrum/gui/qt/contact_list.py index 8dcb5a4b2..712d7f6f9 100644 --- a/electrum/gui/qt/contact_list.py +++ b/electrum/gui/qt/contact_list.py @@ -35,7 +35,8 @@ from electrum.bitcoin import is_address from electrum.util import block_explorer_URL from electrum.plugin import run_hook -from .util import MyTreeView, webopen +from .util import webopen +from .my_treeview import MyTreeView if TYPE_CHECKING: from .main_window import ElectrumWindow diff --git a/electrum/gui/qt/history_list.py b/electrum/gui/qt/history_list.py index d4fafe3b2..5dc01dc37 100644 --- a/electrum/gui/qt/history_list.py +++ b/electrum/gui/qt/history_list.py @@ -50,8 +50,9 @@ from electrum.logging import get_logger, Logger from .custom_model import CustomNode, CustomModel from .util import (read_QIcon, MONOSPACE_FONT, Buttons, CancelButton, OkButton, - filename_field, MyTreeView, AcceptFileDragDrop, WindowModalDialog, + filename_field, AcceptFileDragDrop, WindowModalDialog, CloseButton, webopen, WWLabel) +from .my_treeview import MyTreeView if TYPE_CHECKING: from electrum.wallet import Abstract_Wallet diff --git a/electrum/gui/qt/invoice_list.py b/electrum/gui/qt/invoice_list.py index 9cc820b1b..cd5d59bbf 100644 --- a/electrum/gui/qt/invoice_list.py +++ b/electrum/gui/qt/invoice_list.py @@ -36,10 +36,12 @@ from electrum.util import format_time from electrum.invoices import Invoice, PR_UNPAID, PR_PAID, PR_INFLIGHT, PR_FAILED from electrum.lnutil import HtlcLog -from .util import MyTreeView, read_QIcon, MySortModel, pr_icons +from .util import read_QIcon, pr_icons from .util import CloseButton, Buttons from .util import WindowModalDialog +from .my_treeview import MyTreeView, MySortModel + if TYPE_CHECKING: from .main_window import ElectrumWindow from .send_tab import SendTab diff --git a/electrum/gui/qt/my_treeview.py b/electrum/gui/qt/my_treeview.py new file mode 100644 index 000000000..27af3a4a9 --- /dev/null +++ b/electrum/gui/qt/my_treeview.py @@ -0,0 +1,476 @@ +#!/usr/bin/env python +# +# Electrum - lightweight Bitcoin client +# Copyright (C) 2023 The Electrum Developers +# +# Permission is hereby granted, free of charge, to any person +# obtaining a copy of this software and associated documentation files +# (the "Software"), to deal in the Software without restriction, +# including without limitation the rights to use, copy, modify, merge, +# publish, distribute, sublicense, and/or sell copies of the Software, +# and to permit persons to whom the Software is furnished to do so, +# subject to the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +import asyncio +import enum +import os.path +import time +import sys +import platform +import queue +import traceback +import os +import webbrowser +from decimal import Decimal +from functools import partial, lru_cache, wraps +from typing import (NamedTuple, Callable, Optional, TYPE_CHECKING, Union, List, Dict, Any, + Sequence, Iterable, Tuple, Type) + +from PyQt5 import QtWidgets, QtCore +from PyQt5.QtGui import (QFont, QColor, QCursor, QPixmap, QStandardItem, QImage, + QPalette, QIcon, QFontMetrics, QShowEvent, QPainter, QHelpEvent, QMouseEvent) +from PyQt5.QtCore import (Qt, QPersistentModelIndex, QModelIndex, pyqtSignal, + QCoreApplication, QItemSelectionModel, QThread, + QSortFilterProxyModel, QSize, QLocale, QAbstractItemModel, + QEvent, QRect, QPoint, QObject) +from PyQt5.QtWidgets import (QPushButton, QLabel, QMessageBox, QHBoxLayout, + QAbstractItemView, QVBoxLayout, QLineEdit, + QStyle, QDialog, QGroupBox, QButtonGroup, QRadioButton, + QFileDialog, QWidget, QToolButton, QTreeView, QPlainTextEdit, + QHeaderView, QApplication, QToolTip, QTreeWidget, QStyledItemDelegate, + QMenu, QStyleOptionViewItem, QLayout, QLayoutItem, QAbstractButton, + QGraphicsEffect, QGraphicsScene, QGraphicsPixmapItem, QSizePolicy) + +from electrum.i18n import _, languages +from electrum.util import FileImportFailed, FileExportFailed, make_aiohttp_session, resource_path +from electrum.util import EventListener, event_listener +from electrum.invoices import PR_UNPAID, PR_PAID, PR_EXPIRED, PR_INFLIGHT, PR_UNKNOWN, PR_FAILED, PR_ROUTING, PR_UNCONFIRMED +from electrum.logging import Logger +from electrum.qrreader import MissingQrDetectionLib + +from .util import read_QIcon + + +class MyMenu(QMenu): + + def __init__(self, config): + QMenu.__init__(self) + self.setToolTipsVisible(True) + self.config = config + + def addToggle(self, text: str, callback, *, tooltip=''): + m = self.addAction(text, callback) + m.setCheckable(True) + m.setToolTip(tooltip) + return m + + def addConfig(self, text:str, name:str, default:bool, *, tooltip='', callback=None): + b = self.config.get(name, default) + m = self.addAction(text, lambda: self._do_toggle_config(name, default, callback)) + m.setCheckable(True) + m.setChecked(b) + m.setToolTip(tooltip) + return m + + def _do_toggle_config(self, name, default, callback): + b = self.config.get(name, default) + self.config.set_key(name, not b) + if callback: + callback() + + +def create_toolbar_with_menu(config, title): + menu = MyMenu(config) + toolbar_button = QToolButton() + toolbar_button.setIcon(read_QIcon("preferences.png")) + toolbar_button.setMenu(menu) + toolbar_button.setPopupMode(QToolButton.InstantPopup) + toolbar_button.setFocusPolicy(Qt.NoFocus) + toolbar = QHBoxLayout() + toolbar.addWidget(QLabel(title)) + toolbar.addStretch() + toolbar.addWidget(toolbar_button) + return toolbar, menu + + + +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 ElectrumItemDelegate(QStyledItemDelegate): + def __init__(self, tv: 'MyTreeView'): + super().__init__(tv) + self.tv = tv + self.opened = None + def on_closeEditor(editor: QLineEdit, hint): + self.opened = None + self.tv.is_editor_open = False + if self.tv._pending_update: + self.tv.update() + def on_commitData(editor: QLineEdit): + new_text = editor.text() + idx = QModelIndex(self.opened) + row, col = idx.row(), idx.column() + edit_key = self.tv.get_edit_key_from_coordinate(row, col) + assert edit_key is not None, (idx.row(), idx.column()) + self.tv.on_edited(idx, edit_key=edit_key, text=new_text) + self.closeEditor.connect(on_closeEditor) + self.commitData.connect(on_commitData) + + def createEditor(self, parent, option, idx): + self.opened = QPersistentModelIndex(idx) + self.tv.is_editor_open = True + return super().createEditor(parent, option, idx) + + def paint(self, painter: QPainter, option: QStyleOptionViewItem, idx: QModelIndex) -> None: + custom_data = idx.data(MyTreeView.ROLE_CUSTOM_PAINT) + if custom_data is None: + return super().paint(painter, option, idx) + else: + # let's call the default paint method first; to paint the background (e.g. selection) + super().paint(painter, option, idx) + # and now paint on top of that + custom_data.paint(painter, option.rect) + + def helpEvent(self, evt: QHelpEvent, view: QAbstractItemView, option: QStyleOptionViewItem, idx: QModelIndex) -> bool: + custom_data = idx.data(MyTreeView.ROLE_CUSTOM_PAINT) + if custom_data is None: + return super().helpEvent(evt, view, option, idx) + else: + if evt.type() == QEvent.ToolTip: + if custom_data.show_tooltip(evt): + return True + return super().helpEvent(evt, view, option, idx) + + def sizeHint(self, option: QStyleOptionViewItem, idx: QModelIndex) -> QSize: + custom_data = idx.data(MyTreeView.ROLE_CUSTOM_PAINT) + if custom_data is None: + return super().sizeHint(option, idx) + else: + default_size = super().sizeHint(option, idx) + return custom_data.sizeHint(default_size) + +class MyTreeView(QTreeView): + + ROLE_CLIPBOARD_DATA = Qt.UserRole + 100 + ROLE_CUSTOM_PAINT = Qt.UserRole + 101 + ROLE_EDIT_KEY = Qt.UserRole + 102 + ROLE_FILTER_DATA = Qt.UserRole + 103 + + filter_columns: Iterable[int] + + class BaseColumnsEnum(enum.IntEnum): + @staticmethod + def _generate_next_value_(name: str, start: int, count: int, last_values): + # this is overridden to get a 0-based counter + return count + + Columns: Type[BaseColumnsEnum] + + def __init__( + self, + *, + parent: Optional[QWidget] = None, + main_window: Optional['ElectrumWindow'] = None, + stretch_column: Optional[int] = None, + editable_columns: Optional[Sequence[int]] = None, + ): + parent = parent or main_window + super().__init__(parent) + self.main_window = main_window + self.config = self.main_window.config if self.main_window else None + self.stretch_column = stretch_column + self.setContextMenuPolicy(Qt.CustomContextMenu) + self.customContextMenuRequested.connect(self.create_menu) + self.setUniformRowHeights(True) + + # Control which columns are editable + if editable_columns is None: + editable_columns = [] + self.editable_columns = set(editable_columns) + self.setItemDelegate(ElectrumItemDelegate(self)) + self.current_filter = "" + self.is_editor_open = False + + self.setRootIsDecorated(False) # remove left margin + self.toolbar_shown = False + + # When figuring out the size of columns, Qt by default looks at + # the first 1000 rows (at least if resize mode is QHeaderView.ResizeToContents). + # This would be REALLY SLOW, and it's not perfect anyway. + # So to speed the UI up considerably, set it to + # only look at as many rows as currently visible. + self.header().setResizeContentsPrecision(0) + + self._pending_update = False + self._forced_update = False + + self._default_bg_brush = QStandardItem().background() + self.proxy = None # history, and address tabs use a proxy + + def create_menu(self, position: QPoint) -> None: + pass + + 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 get_role_data_for_current_item(self, *, col, role) -> Any: + idx = self.selectionModel().currentIndex() + idx = idx.sibling(idx.row(), col) + item = self.item_from_index(idx) + if item: + return item.data(role) + + 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) + assert set_current.isValid() + self.selectionModel().select(QModelIndex(set_current), QItemSelectionModel.SelectCurrent) + + def update_headers(self, headers: Union[List[str], Dict[int, str]]): + # headers is either a list of column names, or a dict: (col_idx->col_name) + if not isinstance(headers, dict): # convert to dict + headers = dict(enumerate(headers)) + col_names = [headers[col_idx] for col_idx in sorted(headers.keys())] + 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 + self.header().setSectionResizeMode(col_idx, sm) + + def keyPressEvent(self, event): + if self.itemDelegate().opened: + return + if event.key() in [Qt.Key_F2, Qt.Key_Return, Qt.Key_Enter]: + self.on_activated(self.selectionModel().currentIndex()) + return + super().keyPressEvent(event) + + def mouseDoubleClickEvent(self, event: QMouseEvent): + idx: QModelIndex = self.indexAt(event.pos()) + if self.proxy: + idx = self.proxy.mapToSource(idx) + if not idx.isValid(): + # can happen e.g. before list is populated for the first time + return + self.on_double_click(idx) + + def on_double_click(self, idx): + pass + + def on_activated(self, idx): + # on 'enter' we show the menu + pt = self.visualRect(idx).bottomLeft() + pt.setX(50) + self.customContextMenuRequested.emit(pt) + + 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, edit_key, *, text: str) -> None: + raise NotImplementedError() + + def should_hide(self, row): + """ + row_num is for self.model(). So if there is a proxy, it is the row number + in that! + """ + return False + + def get_text_from_coordinate(self, row, col) -> str: + idx = self.model().index(row, col) + item = self.item_from_index(idx) + return item.text() + + def get_role_data_from_coordinate(self, row, col, *, role) -> Any: + idx = self.model().index(row, col) + item = self.item_from_index(idx) + role_data = item.data(role) + return role_data + + def get_edit_key_from_coordinate(self, row, col) -> Any: + # overriding this might allow avoiding storing duplicate data + return self.get_role_data_from_coordinate(row, col, role=self.ROLE_EDIT_KEY) + + def get_filter_data_from_coordinate(self, row, col) -> str: + filter_data = self.get_role_data_from_coordinate(row, col, role=self.ROLE_FILTER_DATA) + if filter_data: + return filter_data + txt = self.get_text_from_coordinate(row, col) + txt = txt.lower() + return txt + + 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: + filter_data = self.get_filter_data_from_coordinate(row_num, column) + if self.current_filter in filter_data: + # 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, hide the item + self.setRowHidden(row_num, QModelIndex(), True) + + def filter(self, p=None): + if p is not None: + p = p.lower() + self.current_filter = p + self.hide_rows() + + def hide_rows(self): + for row in range(self.model().rowCount()): + self.hide_row(row) + + def create_toolbar(self, config): + return + + def create_toolbar_buttons(self): + hbox = QHBoxLayout() + buttons = self.get_toolbar_buttons() + for b in buttons: + b.setVisible(False) + hbox.addWidget(b) + self.toolbar_buttons = buttons + return hbox + + def create_toolbar_with_menu(self, title): + return create_toolbar_with_menu(self.config, title) + + def show_toolbar(self, state, config=None): + if state == self.toolbar_shown: + return + self.toolbar_shown = state + for b in self.toolbar_buttons: + b.setVisible(state) + if not state: + self.on_hide_toolbar() + + def toggle_toolbar(self, config=None): + self.show_toolbar(not self.toolbar_shown, config) + + def add_copy_menu(self, menu: QMenu, idx) -> QMenu: + cc = menu.addMenu(_("Copy")) + for column in self.Columns: + if self.isColumnHidden(column): + continue + column_title = self.original_model().horizontalHeaderItem(column).text() + if not column_title: + continue + 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() + cc.addAction(column_title, + lambda text=clipboard_data, title=column_title: + self.place_text_on_clipboard(text, title=title)) + return cc + + def place_text_on_clipboard(self, text: str, *, title: str = None) -> None: + self.main_window.do_copy(text, title=title) + + def showEvent(self, e: 'QShowEvent'): + super().showEvent(e) + if e.isAccepted() and self._pending_update: + self._forced_update = True + self.update() + self._forced_update = False + + def maybe_defer_update(self) -> bool: + """Returns whether we should defer an update/refresh.""" + defer = (not self._forced_update + and (not self.isVisible() or self.is_editor_open)) + # side-effect: if we decide to defer update, the state will become stale: + self._pending_update = defer + return defer + + def find_row_by_key(self, key) -> Optional[int]: + for row in range(0, self.std_model.rowCount()): + item = self.std_model.item(row, 0) + if item.data(self.key_role) == key: + return row + + def refresh_all(self): + if self.maybe_defer_update(): + return + for row in range(0, self.std_model.rowCount()): + item = self.std_model.item(row, 0) + key = item.data(self.key_role) + self.refresh_row(key, row) + + def refresh_row(self, key: str, row: int) -> None: + pass + + def refresh_item(self, key): + row = self.find_row_by_key(key) + if row is not None: + self.refresh_row(key, row) + + def delete_item(self, key): + row = self.find_row_by_key(key) + if row is not None: + self.std_model.takeRow(row) + self.hide_if_empty() + + diff --git a/electrum/gui/qt/new_channel_dialog.py b/electrum/gui/qt/new_channel_dialog.py index 717b53dbc..d48b661a2 100644 --- a/electrum/gui/qt/new_channel_dialog.py +++ b/electrum/gui/qt/new_channel_dialog.py @@ -15,7 +15,7 @@ from .util import (WindowModalDialog, Buttons, OkButton, CancelButton, EnterButton, ColorScheme, WWLabel, read_QIcon, IconLabel, char_width_in_lineedit) from .amountedit import BTCAmountEdit - +from .my_treeview import create_toolbar_with_menu if TYPE_CHECKING: from .main_window import ElectrumWindow @@ -34,7 +34,7 @@ class NewChannelDialog(WindowModalDialog): self.trampoline_names = list(self.trampolines.keys()) self.min_amount_sat = min_amount_sat or MIN_FUNDING_SAT vbox = QVBoxLayout(self) - toolbar, menu = util.create_toolbar_with_menu(self.config, '') + toolbar, menu = create_toolbar_with_menu(self.config, '') recov_tooltip = messages.to_rtf(_(messages.MSG_RECOVERABLE_CHANNELS)) menu.addConfig( _("Create recoverable channels"), 'use_recoverable_channels', True, diff --git a/electrum/gui/qt/request_list.py b/electrum/gui/qt/request_list.py index f9fb2ff14..b3e96bbf5 100644 --- a/electrum/gui/qt/request_list.py +++ b/electrum/gui/qt/request_list.py @@ -35,7 +35,8 @@ from electrum.util import format_time from electrum.plugin import run_hook from electrum.invoices import Invoice -from .util import MyTreeView, pr_icons, read_QIcon, webopen, MySortModel +from .util import pr_icons, read_QIcon, webopen +from .my_treeview import MyTreeView, MySortModel if TYPE_CHECKING: from .main_window import ElectrumWindow diff --git a/electrum/gui/qt/transaction_dialog.py b/electrum/gui/qt/transaction_dialog.py index feb47d8a5..e66a861fe 100644 --- a/electrum/gui/qt/transaction_dialog.py +++ b/electrum/gui/qt/transaction_dialog.py @@ -65,7 +65,7 @@ from .util import (MessageBoxMixin, read_QIcon, Buttons, icon_path, BlockingWaitingDialog, getSaveFileName, ColorSchemeItem, get_iconname_qrcode) from .rate_limiter import rate_limited - +from .my_treeview import create_toolbar_with_menu if TYPE_CHECKING: from .main_window import ElectrumWindow @@ -409,7 +409,7 @@ class TxDialog(QDialog, MessageBoxMixin): vbox = QVBoxLayout() self.setLayout(vbox) - toolbar, menu = util.create_toolbar_with_menu(self.config, '') + toolbar, menu = create_toolbar_with_menu(self.config, '') menu.addConfig( _('Download missing data'), 'tx_dialog_fetch_txin_data', False, tooltip=_( diff --git a/electrum/gui/qt/util.py b/electrum/gui/qt/util.py index 3985da79e..a03a78cb5 100644 --- a/electrum/gui/qt/util.py +++ b/electrum/gui/qt/util.py @@ -516,413 +516,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) - self.tv = tv - self.opened = None - def on_closeEditor(editor: QLineEdit, hint): - self.opened = None - self.tv.is_editor_open = False - if self.tv._pending_update: - self.tv.update() - def on_commitData(editor: QLineEdit): - new_text = editor.text() - idx = QModelIndex(self.opened) - row, col = idx.row(), idx.column() - edit_key = self.tv.get_edit_key_from_coordinate(row, col) - assert edit_key is not None, (idx.row(), idx.column()) - self.tv.on_edited(idx, edit_key=edit_key, text=new_text) - self.closeEditor.connect(on_closeEditor) - self.commitData.connect(on_commitData) - - def createEditor(self, parent, option, idx): - self.opened = QPersistentModelIndex(idx) - self.tv.is_editor_open = True - return super().createEditor(parent, option, idx) - - def paint(self, painter: QPainter, option: QStyleOptionViewItem, idx: QModelIndex) -> None: - custom_data = idx.data(MyTreeView.ROLE_CUSTOM_PAINT) - if custom_data is None: - return super().paint(painter, option, idx) - else: - # let's call the default paint method first; to paint the background (e.g. selection) - super().paint(painter, option, idx) - # and now paint on top of that - custom_data.paint(painter, option.rect) - - def helpEvent(self, evt: QHelpEvent, view: QAbstractItemView, option: QStyleOptionViewItem, idx: QModelIndex) -> bool: - custom_data = idx.data(MyTreeView.ROLE_CUSTOM_PAINT) - if custom_data is None: - return super().helpEvent(evt, view, option, idx) - else: - if evt.type() == QEvent.ToolTip: - if custom_data.show_tooltip(evt): - return True - return super().helpEvent(evt, view, option, idx) - - def sizeHint(self, option: QStyleOptionViewItem, idx: QModelIndex) -> QSize: - custom_data = idx.data(MyTreeView.ROLE_CUSTOM_PAINT) - if custom_data is None: - return super().sizeHint(option, idx) - else: - default_size = super().sizeHint(option, idx) - return custom_data.sizeHint(default_size) - - -class MyMenu(QMenu): - - def __init__(self, config): - QMenu.__init__(self) - self.setToolTipsVisible(True) - self.config = config - - def addToggle(self, text: str, callback, *, tooltip=''): - m = self.addAction(text, callback) - m.setCheckable(True) - m.setToolTip(tooltip) - return m - - def addConfig(self, text:str, name:str, default:bool, *, tooltip='', callback=None): - b = self.config.get(name, default) - m = self.addAction(text, lambda: self._do_toggle_config(name, default, callback)) - m.setCheckable(True) - m.setChecked(b) - m.setToolTip(tooltip) - return m - - def _do_toggle_config(self, name, default, callback): - b = self.config.get(name, default) - self.config.set_key(name, not b) - if callback: - callback() - -def create_toolbar_with_menu(config, title): - menu = MyMenu(config) - toolbar_button = QToolButton() - toolbar_button.setIcon(read_QIcon("preferences.png")) - toolbar_button.setMenu(menu) - toolbar_button.setPopupMode(QToolButton.InstantPopup) - toolbar_button.setFocusPolicy(Qt.NoFocus) - toolbar = QHBoxLayout() - toolbar.addWidget(QLabel(title)) - toolbar.addStretch() - toolbar.addWidget(toolbar_button) - return toolbar, menu - -class MyTreeView(QTreeView): - ROLE_CLIPBOARD_DATA = Qt.UserRole + 100 - ROLE_CUSTOM_PAINT = Qt.UserRole + 101 - ROLE_EDIT_KEY = Qt.UserRole + 102 - ROLE_FILTER_DATA = Qt.UserRole + 103 - - filter_columns: Iterable[int] - - class BaseColumnsEnum(enum.IntEnum): - @staticmethod - def _generate_next_value_(name: str, start: int, count: int, last_values): - # this is overridden to get a 0-based counter - return count - - Columns: Type[BaseColumnsEnum] - - def __init__( - self, - *, - parent: Optional[QWidget] = None, - main_window: Optional['ElectrumWindow'] = None, - stretch_column: Optional[int] = None, - editable_columns: Optional[Sequence[int]] = None, - ): - parent = parent or main_window - super().__init__(parent) - self.main_window = main_window - self.config = self.main_window.config if self.main_window else None - self.stretch_column = stretch_column - self.setContextMenuPolicy(Qt.CustomContextMenu) - self.customContextMenuRequested.connect(self.create_menu) - self.setUniformRowHeights(True) - - # Control which columns are editable - if editable_columns is None: - editable_columns = [] - self.editable_columns = set(editable_columns) - self.setItemDelegate(ElectrumItemDelegate(self)) - self.current_filter = "" - self.is_editor_open = False - - self.setRootIsDecorated(False) # remove left margin - self.toolbar_shown = False - - # When figuring out the size of columns, Qt by default looks at - # the first 1000 rows (at least if resize mode is QHeaderView.ResizeToContents). - # This would be REALLY SLOW, and it's not perfect anyway. - # So to speed the UI up considerably, set it to - # only look at as many rows as currently visible. - self.header().setResizeContentsPrecision(0) - - self._pending_update = False - self._forced_update = False - - self._default_bg_brush = QStandardItem().background() - self.proxy = None # history, and address tabs use a proxy - - def create_menu(self, position: QPoint) -> None: - pass - - 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 get_role_data_for_current_item(self, *, col, role) -> Any: - idx = self.selectionModel().currentIndex() - idx = idx.sibling(idx.row(), col) - item = self.item_from_index(idx) - if item: - return item.data(role) - - 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) - assert set_current.isValid() - self.selectionModel().select(QModelIndex(set_current), QItemSelectionModel.SelectCurrent) - - def update_headers(self, headers: Union[List[str], Dict[int, str]]): - # headers is either a list of column names, or a dict: (col_idx->col_name) - if not isinstance(headers, dict): # convert to dict - headers = dict(enumerate(headers)) - col_names = [headers[col_idx] for col_idx in sorted(headers.keys())] - 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 - self.header().setSectionResizeMode(col_idx, sm) - - def keyPressEvent(self, event): - if self.itemDelegate().opened: - return - if event.key() in [Qt.Key_F2, Qt.Key_Return, Qt.Key_Enter]: - self.on_activated(self.selectionModel().currentIndex()) - return - super().keyPressEvent(event) - - def mouseDoubleClickEvent(self, event: QMouseEvent): - idx: QModelIndex = self.indexAt(event.pos()) - if self.proxy: - idx = self.proxy.mapToSource(idx) - if not idx.isValid(): - # can happen e.g. before list is populated for the first time - return - self.on_double_click(idx) - - def on_double_click(self, idx): - pass - - def on_activated(self, idx): - # on 'enter' we show the menu - pt = self.visualRect(idx).bottomLeft() - pt.setX(50) - self.customContextMenuRequested.emit(pt) - - 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, edit_key, *, text: str) -> None: - raise NotImplementedError() - - def should_hide(self, row): - """ - row_num is for self.model(). So if there is a proxy, it is the row number - in that! - """ - return False - - def get_text_from_coordinate(self, row, col) -> str: - idx = self.model().index(row, col) - item = self.item_from_index(idx) - return item.text() - - def get_role_data_from_coordinate(self, row, col, *, role) -> Any: - idx = self.model().index(row, col) - item = self.item_from_index(idx) - role_data = item.data(role) - return role_data - - def get_edit_key_from_coordinate(self, row, col) -> Any: - # overriding this might allow avoiding storing duplicate data - return self.get_role_data_from_coordinate(row, col, role=self.ROLE_EDIT_KEY) - - def get_filter_data_from_coordinate(self, row, col) -> str: - filter_data = self.get_role_data_from_coordinate(row, col, role=self.ROLE_FILTER_DATA) - if filter_data: - return filter_data - txt = self.get_text_from_coordinate(row, col) - txt = txt.lower() - return txt - - 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: - filter_data = self.get_filter_data_from_coordinate(row_num, column) - if self.current_filter in filter_data: - # 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, hide the item - self.setRowHidden(row_num, QModelIndex(), True) - - def filter(self, p=None): - if p is not None: - p = p.lower() - self.current_filter = p - self.hide_rows() - def hide_rows(self): - for row in range(self.model().rowCount()): - self.hide_row(row) - - def create_toolbar(self, config): - return - - def create_toolbar_buttons(self): - hbox = QHBoxLayout() - buttons = self.get_toolbar_buttons() - for b in buttons: - b.setVisible(False) - hbox.addWidget(b) - self.toolbar_buttons = buttons - return hbox - - def create_toolbar_with_menu(self, title): - return create_toolbar_with_menu(self.config, title) - - def show_toolbar(self, state, config=None): - if state == self.toolbar_shown: - return - self.toolbar_shown = state - for b in self.toolbar_buttons: - b.setVisible(state) - if not state: - self.on_hide_toolbar() - - def toggle_toolbar(self, config=None): - self.show_toolbar(not self.toolbar_shown, config) - - def add_copy_menu(self, menu: QMenu, idx) -> QMenu: - cc = menu.addMenu(_("Copy")) - for column in self.Columns: - if self.isColumnHidden(column): - continue - column_title = self.original_model().horizontalHeaderItem(column).text() - if not column_title: - continue - 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() - cc.addAction(column_title, - lambda text=clipboard_data, title=column_title: - self.place_text_on_clipboard(text, title=title)) - return cc - - def place_text_on_clipboard(self, text: str, *, title: str = None) -> None: - self.main_window.do_copy(text, title=title) - - def showEvent(self, e: 'QShowEvent'): - super().showEvent(e) - if e.isAccepted() and self._pending_update: - self._forced_update = True - self.update() - self._forced_update = False - - def maybe_defer_update(self) -> bool: - """Returns whether we should defer an update/refresh.""" - defer = (not self._forced_update - and (not self.isVisible() or self.is_editor_open)) - # side-effect: if we decide to defer update, the state will become stale: - self._pending_update = defer - return defer - - def find_row_by_key(self, key) -> Optional[int]: - for row in range(0, self.std_model.rowCount()): - item = self.std_model.item(row, 0) - if item.data(self.key_role) == key: - return row - - def refresh_all(self): - if self.maybe_defer_update(): - return - for row in range(0, self.std_model.rowCount()): - item = self.std_model.item(row, 0) - key = item.data(self.key_role) - self.refresh_row(key, row) - - def refresh_row(self, key: str, row: int) -> None: - pass - - def refresh_item(self, key): - row = self.find_row_by_key(key) - if row is not None: - self.refresh_row(key, row) - - def delete_item(self, key): - row = self.find_row_by_key(key) - if row is not None: - self.std_model.takeRow(row) - self.hide_if_empty() - - -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 def get_iconname_qrcode() -> str: diff --git a/electrum/gui/qt/utxo_list.py b/electrum/gui/qt/utxo_list.py index a28b3ee82..e41704eae 100644 --- a/electrum/gui/qt/utxo_list.py +++ b/electrum/gui/qt/utxo_list.py @@ -36,7 +36,8 @@ from electrum.bitcoin import is_address from electrum.transaction import PartialTxInput, PartialTxOutput from electrum.lnutil import LN_MAX_FUNDING_SAT, MIN_FUNDING_SAT -from .util import MyTreeView, ColorScheme, MONOSPACE_FONT, EnterButton +from .util import ColorScheme, MONOSPACE_FONT, EnterButton +from .my_treeview import MyTreeView from .new_channel_dialog import NewChannelDialog if TYPE_CHECKING: diff --git a/electrum/gui/qt/watchtower_dialog.py b/electrum/gui/qt/watchtower_dialog.py index 37e943d0b..79db87b90 100644 --- a/electrum/gui/qt/watchtower_dialog.py +++ b/electrum/gui/qt/watchtower_dialog.py @@ -30,7 +30,8 @@ from PyQt5.QtCore import Qt from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QPushButton, QLabel) from electrum.i18n import _ -from .util import MyTreeView, Buttons +from .util import Buttons +from .my_treeview import MyTreeView class WatcherList(MyTreeView):