move MyTreeView and related classes to own submodule
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
476
electrum/gui/qt/my_treeview.py
Normal file
476
electrum/gui/qt/my_treeview.py
Normal file
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=_(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user