Browse Source

Merge pull request #4915 from spesmilo/qabstractitemmodel

use QAbstractItemModel in History tab
3.3.3.1
ghost43 6 years ago
committed by GitHub
parent
commit
53b64a6367
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 11
      RELEASE-NOTES
  2. 2
      electrum/daemon.py
  3. 1
      electrum/gui/qt/__init__.py
  4. 19
      electrum/gui/qt/address_dialog.py
  5. 8
      electrum/gui/qt/contact_list.py
  6. 543
      electrum/gui/qt/history_list.py
  7. 24
      electrum/gui/qt/main_window.py
  8. 93
      electrum/gui/qt/util.py
  9. 2
      electrum/gui/qt/utxo_list.py
  10. 42
      electrum/util.py
  11. 15
      electrum/wallet.py

11
RELEASE-NOTES

@ -23,6 +23,17 @@
- Trezor: refactoring and compat with python-trezor 0.11
- Digital BitBox: support firmware v5.0.0
* fix bitcoin URI handling when app already running (#4796)
* Qt listing fixes:
- Selection by arrow keys disabled while editing e.g. label
- Enter key on unedited value does not pop up context menu
- Contacts:
- Prevent editing of OpenAlias names
- Receive:
- Icon for status of payment requests
- Disable editing of 'Description' in list, interaction
between labels and memo of invoice confusing
- Addresses:
- Fiat prices would show "No Data" incorrectly upon start
* Several other minor bugfixes and usability improvements.

2
electrum/daemon.py

@ -319,12 +319,12 @@ class Daemon(DaemonThread):
DaemonThread.stop(self)
def init_gui(self, config, plugins):
threading.current_thread().setName('GUI')
gui_name = config.get('gui', 'qt')
if gui_name in ['lite', 'classic']:
gui_name = 'qt'
gui = __import__('electrum.gui.' + gui_name, fromlist=['electrum'])
self.gui = gui.ElectrumGui(config, self, plugins)
threading.current_thread().setName('GUI')
try:
self.gui.main()
except BaseException as e:

1
electrum/gui/qt/__init__.py

@ -97,6 +97,7 @@ class ElectrumGui(PrintError):
QtCore.QCoreApplication.setAttribute(QtCore.Qt.AA_ShareOpenGLContexts)
if hasattr(QGuiApplication, 'setDesktopFileName'):
QGuiApplication.setDesktopFileName('electrum.desktop')
self.gui_thread = threading.current_thread()
self.config = config
self.daemon = daemon
self.plugins = plugins

19
electrum/gui/qt/address_dialog.py

@ -30,9 +30,16 @@ from PyQt5.QtGui import *
from PyQt5.QtWidgets import *
from .util import *
from .history_list import HistoryList
from .history_list import HistoryList, HistoryModel
from .qrtextedit import ShowQRTextEdit
class AddressHistoryModel(HistoryModel):
def __init__(self, parent, address):
super().__init__(parent)
self.address = address
def get_domain(self):
return [self.address]
class AddressDialog(WindowModalDialog):
@ -80,16 +87,14 @@ class AddressDialog(WindowModalDialog):
vbox.addWidget(redeem_e)
vbox.addWidget(QLabel(_("History")))
self.hw = HistoryList(self.parent)
self.hw.get_domain = self.get_domain
addr_hist_model = AddressHistoryModel(self.parent, self.address)
self.hw = HistoryList(self.parent, addr_hist_model)
addr_hist_model.set_view(self.hw)
vbox.addWidget(self.hw)
vbox.addLayout(Buttons(CloseButton(self)))
self.format_amount = self.parent.format_amount
self.hw.update()
def get_domain(self):
return [self.address]
addr_hist_model.refresh('address dialog constructor')
def show_qr(self):
text = self.address

8
electrum/gui/qt/contact_list.py

@ -49,12 +49,8 @@ class ContactList(MyTreeView):
def on_edited(self, idx, user_role, text):
_type, prior_name = self.parent.contacts.pop(user_role)
# 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())
self.parent.set_contact(text, user_role)
self.update()
def import_contacts(self):
import_meta_gui(self.parent, _('contacts'), self.parent.contacts.import_file, self.update)

543
electrum/gui/qt/history_list.py

@ -26,12 +26,13 @@
import webbrowser
import datetime
from datetime import date
from typing import TYPE_CHECKING
from collections import OrderedDict
from typing import TYPE_CHECKING, Tuple, Dict
import threading
from electrum.address_synchronizer import TX_HEIGHT_LOCAL
from electrum.i18n import _
from electrum.util import block_explorer_URL, profiler, print_error, TxMinedInfo, OrderedDictWithIndex
from electrum.util import (block_explorer_URL, profiler, print_error, TxMinedInfo,
OrderedDictWithIndex, PrintError)
from .util import *
@ -60,43 +61,288 @@ TX_ICONS = [
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()
item1 = self.sourceModel().data(source_left, Qt.UserRole)
item2 = self.sourceModel().data(source_right, Qt.UserRole)
if item1 is None or item2 is None:
raise Exception(f'UserRole not set for column {source_left.column()}')
if item1.value() is None or item2.value() is None:
return False
return item1.value() < item2.value()
class HistoryModel(QAbstractItemModel, PrintError):
NUM_COLUMNS = 9
def __init__(self, parent):
super().__init__(parent)
self.parent = parent
self.view = None # type: HistoryList
self.transactions = OrderedDictWithIndex()
self.tx_status_cache = {} # type: Dict[str, Tuple[int, str]]
def set_view(self, history_list: 'HistoryList'):
# FIXME HistoryModel and HistoryList mutually depend on each other.
# After constructing both, this method needs to be called.
self.view = history_list # type: HistoryList
self.set_visibility_of_columns()
def columnCount(self, parent: QModelIndex):
return self.NUM_COLUMNS
def rowCount(self, parent: QModelIndex):
return len(self.transactions)
def index(self, row: int, column: int, parent: QModelIndex):
return self.createIndex(row, column)
def data(self, index: QModelIndex, role: Qt.ItemDataRole):
# note: this method is performance-critical.
# it is called a lot, and so must run extremely fast.
assert index.isValid()
col = index.column()
tx_item = self.transactions.value_from_pos(index.row())
tx_hash = tx_item['txid']
conf = tx_item['confirmations']
txpos = tx_item['txpos_in_block'] or 0
height = tx_item['height']
try:
status, status_str = self.tx_status_cache[tx_hash]
except KeyError:
tx_mined_info = self.tx_mined_info_from_tx_item(tx_item)
status, status_str = self.parent.wallet.get_tx_status(tx_hash, tx_mined_info)
if role == Qt.UserRole:
# for sorting
d = {
# height breaks ties for unverified txns
# txpos breaks ties for verified same block txns
0: (status, conf, -height, -txpos),
1: status_str,
2: tx_item['label'],
3: tx_item['value'].value,
4: tx_item['balance'].value,
5: tx_item['fiat_value'].value if 'fiat_value' in tx_item else None,
6: tx_item['acquisition_price'].value if 'acquisition_price' in tx_item else None,
7: tx_item['capital_gain'].value if 'capital_gain' in tx_item else None,
8: tx_hash,
}
return QVariant(d[col])
if role not in (Qt.DisplayRole, Qt.EditRole):
if col == 0 and role == Qt.DecorationRole:
return QVariant(self.view.icon_cache.get(":icons/" + TX_ICONS[status]))
elif col == 0 and role == Qt.ToolTipRole:
return QVariant(str(conf) + _(" confirmation" + ("s" if conf != 1 else "")))
elif col > 2 and role == Qt.TextAlignmentRole:
return QVariant(Qt.AlignRight | Qt.AlignVCenter)
elif col != 1 and role == Qt.FontRole:
monospace_font = QFont(MONOSPACE_FONT)
return QVariant(monospace_font)
elif col == 2 and role == Qt.DecorationRole and self.parent.wallet.invoices.paid.get(tx_hash):
return QVariant(self.view.icon_cache.get(":icons/seal"))
elif col in (2, 3) and role == Qt.ForegroundRole and tx_item['value'].value < 0:
red_brush = QBrush(QColor("#BC1E1E"))
return QVariant(red_brush)
elif col == 5 and role == Qt.ForegroundRole and not tx_item.get('fiat_default') and tx_item.get('fiat_value') is not None:
blue_brush = QBrush(QColor("#1E1EFF"))
return QVariant(blue_brush)
return None
if col == 1:
return QVariant(status_str)
elif col == 2:
return QVariant(tx_item['label'])
elif col == 3:
value = tx_item['value'].value
v_str = self.parent.format_amount(value, is_diff=True, whitespaces=True)
return QVariant(v_str)
elif col == 4:
balance = tx_item['balance'].value
balance_str = self.parent.format_amount(balance, whitespaces=True)
return QVariant(balance_str)
elif col == 5 and 'fiat_value' in tx_item:
value_str = self.parent.fx.format_fiat(tx_item['fiat_value'].value)
return QVariant(value_str)
elif col == 6 and tx_item['value'].value < 0 and 'acquisition_price' in tx_item:
# fixme: should use is_mine
acq = tx_item['acquisition_price'].value
return QVariant(self.parent.fx.format_fiat(acq))
elif col == 7 and 'capital_gain' in tx_item:
cg = tx_item['capital_gain'].value
return QVariant(self.parent.fx.format_fiat(cg))
elif col == 8:
return QVariant(tx_hash)
return None
def parent(self, index: QModelIndex):
return QModelIndex()
def hasChildren(self, index: QModelIndex):
return not index.isValid()
def update_label(self, row):
tx_item = self.transactions.value_from_pos(row)
tx_item['label'] = self.parent.wallet.get_label(tx_item['txid'])
topLeft = bottomRight = self.createIndex(row, 2)
self.dataChanged.emit(topLeft, bottomRight, [Qt.DisplayRole])
def get_domain(self):
'''Overridden in address_dialog.py'''
return self.parent.wallet.get_addresses()
@profiler
def refresh(self, reason: str):
self.print_error(f"refreshing... reason: {reason}")
assert self.parent.gui_thread == threading.current_thread(), 'must be called from GUI thread'
assert self.view, 'view not set'
selected = self.view.selectionModel().currentIndex()
selected_row = None
if selected:
selected_row = selected.row()
fx = self.parent.fx
if fx: fx.history_used_spot = False
r = self.parent.wallet.get_full_history(domain=self.get_domain(), from_timestamp=None, to_timestamp=None, fx=fx)
self.set_visibility_of_columns()
if r['transactions'] == list(self.transactions.values()):
return
old_length = len(self.transactions)
if old_length != 0:
self.beginRemoveRows(QModelIndex(), 0, old_length)
self.transactions.clear()
self.endRemoveRows()
self.beginInsertRows(QModelIndex(), 0, len(r['transactions'])-1)
for tx_item in r['transactions']:
txid = tx_item['txid']
self.transactions[txid] = tx_item
self.endInsertRows()
if selected_row:
self.view.selectionModel().select(self.createIndex(selected_row, 0), QItemSelectionModel.Rows | QItemSelectionModel.SelectCurrent)
f = self.view.current_filter
if f:
self.view.filter(f)
# update summary
self.summary = r['summary']
if not self.view.years and self.transactions:
start_date = date.today()
end_date = date.today()
if len(self.transactions) > 0:
start_date = self.transactions.value_from_pos(0).get('date') or start_date
end_date = self.transactions.value_from_pos(len(self.transactions) - 1).get('date') or end_date
self.view.years = [str(i) for i in range(start_date.year, end_date.year + 1)]
self.view.period_combo.insertItems(1, self.view.years)
# update tx_status_cache
self.tx_status_cache.clear()
for txid, tx_item in self.transactions.items():
tx_mined_info = self.tx_mined_info_from_tx_item(tx_item)
self.tx_status_cache[txid] = self.parent.wallet.get_tx_status(txid, tx_mined_info)
def set_visibility_of_columns(self):
hide = self.view.hideColumn
show = self.view.showColumn
# txid
hide(8)
# fiat
history = self.parent.fx.show_history()
cap_gains = self.parent.fx.get_history_capital_gains_config()
if history and cap_gains:
show(5)
show(6)
show(7)
elif history:
show(5)
hide(6)
hide(7)
else:
hide(5)
hide(6)
hide(7)
def update_fiat(self, row, idx):
tx_item = self.transactions.value_from_pos(row)
key = tx_item['txid']
fee = tx_item.get('fee')
value = tx_item['value'].value
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.dataChanged.emit(idx, idx, [Qt.DisplayRole, Qt.ForegroundRole])
def update_tx_mined_status(self, tx_hash: str, tx_mined_info: TxMinedInfo):
self.tx_status_cache[tx_hash] = self.parent.wallet.get_tx_status(tx_hash, tx_mined_info)
try:
row = self.transactions.pos_from_key(tx_hash)
except KeyError:
return
topLeft = self.createIndex(row, 0)
bottomRight = self.createIndex(row, self.NUM_COLUMNS-1)
self.dataChanged.emit(topLeft, bottomRight)
def on_fee_histogram(self):
for tx_hash, tx_item in self.transactions.items():
tx_mined_info = self.tx_mined_info_from_tx_item(tx_item)
if tx_mined_info.conf > 0:
# note: we could actually break here if we wanted to rely on the order of txns in self.transactions
continue
self.tx_status_cache[tx_hash] = self.parent.wallet.get_tx_status(tx_hash, tx_mined_info)
self.update_tx_mined_status(tx_hash, tx_mined_info)
def headerData(self, section: int, orientation: Qt.Orientation, role: Qt.ItemDataRole):
assert orientation == Qt.Horizontal
if role != Qt.DisplayRole:
return None
fx = self.parent.fx
fiat_title = 'n/a fiat value'
fiat_acq_title = 'n/a fiat acquisition price'
fiat_cg_title = 'n/a fiat capital gains'
if fx and fx.show_history():
fiat_title = '%s '%fx.ccy + _('Value')
fiat_acq_title = '%s '%fx.ccy + _('Acquisition price')
fiat_cg_title = '%s '%fx.ccy + _('Capital Gains')
return {
0: '',
1: _('Date'),
2: _('Description'),
3: _('Amount'),
4: _('Balance'),
5: fiat_title,
6: fiat_acq_title,
7: fiat_cg_title,
8: 'TXID',
}[section]
def flags(self, idx):
extra_flags = Qt.NoItemFlags # type: Qt.ItemFlag
if idx.column() in self.view.editable_columns:
extra_flags |= Qt.ItemIsEditable
return super().flags(idx) | extra_flags
@staticmethod
def tx_mined_info_from_tx_item(tx_item):
tx_mined_info = TxMinedInfo(height=tx_item['height'],
conf=tx_item['confirmations'],
timestamp=tx_item['timestamp'])
return tx_mined_info
class HistoryList(MyTreeView, AcceptFileDragDrop):
filter_columns = [1, 2, 3] # Date, Description, Amount
TX_HASH_ROLE = Qt.UserRole
SORT_ROLE = Qt.UserRole + 1
filter_columns = [1, 2, 3, 8] # Date, Description, Amount, TXID
def tx_item_from_proxy_row(self, proxy_row):
hm_idx = self.model().mapToSource(self.model().index(proxy_row, 0))
return self.hm.transactions.value_from_pos(hm_idx.row())
def should_hide(self, proxy_row):
if self.start_timestamp and self.end_timestamp:
item = self.item_from_coordinate(proxy_row, 0)
txid = item.data(self.TX_HASH_ROLE)
date = self.transactions[txid]['date']
tx_item = self.tx_item_from_proxy_row(proxy_row)
date = tx_item['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):
def __init__(self, parent, model: HistoryModel):
super().__init__(parent, self.create_menu, 2)
self.std_model = QStandardItemModel(self)
self.hm = model
self.proxy = HistorySortModel(self)
self.proxy.setSourceModel(self.std_model)
self.proxy.setSourceModel(model)
self.setModel(self.proxy)
self.txid_to_items = {}
self.transactions = OrderedDictWithIndex()
self.summary = {}
self.blue_brush = QBrush(QColor("#1E1EFF"))
self.red_brush = QBrush(QColor("#BC1E1E"))
self.monospace_font = QFont(MONOSPACE_FONT)
self.config = parent.config
AcceptFileDragDrop.__init__(self, ".txn")
self.setSortingEnabled(True)
@ -105,45 +351,17 @@ class HistoryList(MyTreeView, AcceptFileDragDrop):
self.years = []
self.create_toolbar_buttons()
self.wallet = self.parent.wallet # type: Abstract_Wallet
self.refresh_headers()
self.sortByColumn(0, Qt.AscendingOrder)
self.editable_columns |= {5}
self.header().setStretchLastSection(False)
for col in range(HistoryModel.NUM_COLUMNS):
sm = QHeaderView.Stretch if col == self.stretch_column else QHeaderView.ResizeToContents
self.header().setSectionResizeMode(col, sm)
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')]
fx = self.parent.fx
if fx and fx.show_history():
headers.extend(['%s '%fx.ccy + _('Value')])
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 -= {5}
col_count = self.std_model.columnCount()
diff = col_count-len(headers)
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):
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'''
return self.wallet.get_addresses()
def on_combo(self, x):
s = self.period_combo.itemText(x)
x = s == _('Custom')
@ -216,7 +434,7 @@ class HistoryList(MyTreeView, AcceptFileDragDrop):
return datetime.datetime(date.year, date.month, date.day)
def show_summary(self):
h = self.summary
h = self.model().sourceModel().summary
if not h:
self.parent.show_message(_("Nothing to summarize."))
return
@ -261,171 +479,38 @@ class HistoryList(MyTreeView, AcceptFileDragDrop):
_("Perhaps some dependencies are missing...") + " (matplotlib?)")
return
try:
plt = plot_history(list(self.transactions.values()))
plt = plot_history(list(self.hm.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 = TxMinedInfo(height=height, conf=conf, timestamp=timestamp)
status, status_str = self.wallet.get_tx_status(tx_hash, tx_mined_status)
has_invoice = self.wallet.invoices.paid.get(tx_hash)
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]
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.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 = 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 = self.transactions.get_pos_of_key(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 update(self):
fx = self.parent.fx
if fx: fx.history_used_spot = False
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.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.insert_tx(row)
continue
old = self.transactions[txid]
if old == row:
continue
self.update_item(txid, self.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()
# update summary
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)
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)
if not row['fiat_default'] and row['fiat_value']:
items[5].setForeground(self.blue_brush)
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):
index = self.model().mapToSource(index)
row, column = index.row(), index.column()
item = self.item_from_coordinate(row, column)
key = item.data(self.TX_HASH_ROLE)
tx_item = self.hm.transactions.value_from_pos(row)
key = tx_item['txid']
# fixme
if column == 2:
self.wallet.set_label(key, text)
self.update_labels()
self.parent.update_completions()
if self.wallet.set_label(key, text): #changed
self.hm.update_label(row)
self.parent.update_completions()
elif column == 5:
tx_item = self.transactions[key]
self.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.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)
self.hm.update_fiat(row, index)
else:
assert False
def mouseDoubleClickEvent(self, event: QMouseEvent):
idx = self.indexAt(event.pos())
item = self.item_from_coordinate(idx.row(), idx.column())
if not item or item.isEditable():
if not idx.isValid():
return
tx_item = self.tx_item_from_proxy_row(idx.row())
if self.hm.flags(self.model().mapToSource(idx)) & Qt.ItemIsEditable:
super().mouseDoubleClickEvent(event)
elif item:
tx_hash = item.data(self.TX_HASH_ROLE)
self.show_transaction(tx_hash)
else:
self.show_transaction(tx_item['txid'])
def show_transaction(self, tx_hash):
tx = self.wallet.transactions.get(tx_hash)
@ -434,45 +519,22 @@ class HistoryList(MyTreeView, AcceptFileDragDrop):
label = self.wallet.get_label(tx_hash) or None # prefer 'None' if not defined (force tx dialog to hide Description field if missing)
self.parent.show_transaction(tx, label)
def update_labels(self):
root = self.std_model.invisibleRootItem()
child_count = root.rowCount()
for i in range(child_count):
item = root.child(i, 2)
txid = item.data(self.TX_HASH_ROLE)
label = self.wallet.get_label(txid)
item.setText(label)
def update_item(self, tx_hash, tx_mined_status):
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])
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)
if not item:
if not idx.isValid():
# can happen e.g. before list is populated for the first time
return
tx_hash = idx.data(self.TX_HASH_ROLE)
tx_item = self.hm.transactions.value_from_pos(idx.row())
column = idx.column()
assert tx_hash, "create_menu: no tx hash"
tx = self.wallet.transactions.get(tx_hash)
assert tx, "create_menu: no tx"
if column == 0:
column_title = _('Transaction ID')
column_data = tx_hash
column_data = tx_item['txid']
else:
column_title = self.std_model.horizontalHeaderItem(column).text()
column_data = item.text()
column_title = self.hm.headerData(column, Qt.Horizontal, Qt.DisplayRole)
column_data = self.hm.data(idx, Qt.DisplayRole).value()
tx_hash = tx_item['txid']
tx = self.wallet.transactions[tx_hash]
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)
@ -483,7 +545,7 @@ class HistoryList(MyTreeView, 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:
label = self.std_model.horizontalHeaderItem(c).text()
label = self.hm.headerData(c, Qt.Horizontal, Qt.DisplayRole)
# 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)))
@ -589,3 +651,8 @@ class HistoryList(MyTreeView, AcceptFileDragDrop):
else:
from electrum.util import json_encode
f.write(json_encode(txns))
def text_txid_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(), tx_item['txid']

24
electrum/gui/qt/main_window.py

@ -73,6 +73,7 @@ from .transaction_dialog import show_transaction
from .fee_slider import FeeSlider
from .util import *
from .installwizard import WIF_HELP_TEXT
from .history_list import HistoryList, HistoryModel
class StatusBarButton(QPushButton):
@ -113,6 +114,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError):
self.gui_object = gui_object
self.config = config = gui_object.config # type: SimpleConfig
self.gui_thread = gui_object.gui_thread
self.setup_exception_hook()
@ -230,8 +232,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError):
Exception_Hook(self)
def on_fx_history(self):
self.history_list.refresh_headers()
self.history_list.update()
self.history_model.refresh('fx_history')
self.address_list.update()
def on_quotes(self, b):
@ -246,7 +247,8 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError):
edit.textEdited.emit(edit.text())
# History tab needs updating if it used spot
if self.fx.history_used_spot:
self.history_list.update()
self.history_model.refresh('fx_quotes')
self.address_list.update()
def toggle_tab(self, tab):
show = not self.config.get('show_{}_tab'.format(tab.tab_name), False)
@ -345,7 +347,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError):
elif event == 'verified':
wallet, tx_hash, tx_mined_status = args
if wallet == self.wallet:
self.history_list.update_item(tx_hash, tx_mined_status)
self.history_model.update_tx_mined_status(tx_hash, tx_mined_status)
elif event == 'fee':
if self.config.is_dynfee():
self.fee_slider.update()
@ -354,7 +356,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError):
if self.config.is_dynfee():
self.fee_slider.update()
self.do_update_fee()
self.history_list.update_on_new_fee_histogram()
self.history_model.on_fee_histogram()
else:
self.print_error("unexpected network_qt signal:", event, args)
@ -799,7 +801,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError):
wallet = self.wallet
if wallet != self.wallet:
return
self.history_list.update()
self.history_model.refresh('update_tabs')
self.request_list.update()
self.address_list.update()
self.utxo_list.update()
@ -808,8 +810,9 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError):
self.update_completions()
def create_history_tab(self):
from .history_list import HistoryList
self.history_list = l = HistoryList(self)
self.history_model = HistoryModel(self)
self.history_list = l = HistoryList(self, self.history_model)
self.history_model.set_view(self.history_list)
l.searchable_list = l
toolbar = l.create_toolbar(self.config)
toolbar_shown = self.config.get('show_toolbar_history', False)
@ -2664,7 +2667,6 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError):
b = self.fx and self.fx.is_enabled()
self.fiat_send_e.setVisible(b)
self.fiat_receive_e.setVisible(b)
self.history_list.refresh_headers()
self.history_list.update()
self.address_list.refresh_headers()
self.address_list.update()
@ -3022,7 +3024,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError):
if not self.fx: return
self.fx.set_history_config(checked)
update_exchanges()
self.history_list.refresh_headers()
self.history_model.refresh('on_history')
if self.fx.is_enabled() and checked:
self.fx.trigger_update()
update_history_capgains_cb()
@ -3030,7 +3032,7 @@ class ElectrumWindow(QMainWindow, MessageBoxMixin, PrintError):
def on_history_capgains(checked):
if not self.fx: return
self.fx.set_history_capital_gains_config(checked)
self.history_list.refresh_headers()
self.history_model.refresh('on_history_capgains')
def on_fiat_address(checked):
if not self.fx: return

93
electrum/gui/qt/util.py

@ -398,8 +398,26 @@ def filename_field(parent, config, defaultname, select_msg):
return vbox, filename_e, b1
class ElectrumItemDelegate(QStyledItemDelegate):
def createEditor(self, parent, option, index):
return self.parent().createEditor(parent, option, index)
def __init__(self, tv):
super().__init__(tv)
self.tv = tv
self.opened = None
def on_closeEditor(editor: QLineEdit, hint):
self.opened = None
def on_commitData(editor: QLineEdit):
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)
# 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)
self.closeEditor.connect(on_closeEditor)
self.commitData.connect(on_commitData)
def createEditor(self, parent, option, idx):
self.opened = QPersistentModelIndex(idx)
return super().createEditor(parent, option, idx)
class MyTreeView(QTreeView):
@ -415,8 +433,6 @@ class MyTreeView(QTreeView):
self.icon_cache = IconCache()
# Control which columns are editable
self.editor = None
self.pending_update = False
if editable_columns is None:
editable_columns = {stretch_column}
else:
@ -428,6 +444,13 @@ class MyTreeView(QTreeView):
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)
def set_editability(self, items):
for idx, i in enumerate(items):
i.setEditable(idx in self.editable_columns)
@ -449,9 +472,8 @@ class MyTreeView(QTreeView):
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()
def update_headers(self, headers):
model = self.model()
model.setHorizontalHeaderLabels(headers)
self.header().setStretchLastSection(False)
for col in range(len(headers)):
@ -459,7 +481,9 @@ class MyTreeView(QTreeView):
self.header().setSectionResizeMode(col, sm)
def keyPressEvent(self, event):
if event.key() in [ Qt.Key_F2, Qt.Key_Return ] and self.editor is None:
if self.itemDelegate().opened:
return
if event.key() in [ Qt.Key_F2, Qt.Key_Return ]:
self.on_activated(self.selectionModel().currentIndex())
return
super().keyPressEvent(event)
@ -470,34 +494,6 @@ class MyTreeView(QTreeView):
pt.setX(50)
self.customContextMenuRequested.emit(pt)
def createEditor(self, parent, option, idx):
self.editor = QStyledItemDelegate.createEditor(self.itemDelegate(),
parent, option, idx)
item = self.item_from_coordinate(idx.row(), idx.column())
user_role = item.data(Qt.UserRole)
assert user_role is not None
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 edit(self, idx, trigger=QAbstractItemView.AllEditTriggers, event=None):
"""
this is to prevent:
@ -508,13 +504,9 @@ class MyTreeView(QTreeView):
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.history_model.refresh('on_edited in MyTreeView')
self.parent.update_completions()
def apply_filter(self):
if self.current_filter:
self.filter(self.current_filter)
def should_hide(self, row):
"""
row_num is for self.model(). So if there is a proxy, it is the row number
@ -522,13 +514,12 @@ class MyTreeView(QTreeView):
"""
return False
def item_from_coordinate(self, row_num, column):
if isinstance(self.model(), QSortFilterProxyModel):
idx = self.model().mapToSource(self.model().index(row_num, column))
return self.model().sourceModel().itemFromIndex(idx)
else:
idx = self.model().index(row_num, column)
return self.model().itemFromIndex(idx)
def text_txid_from_coordinate(self, row_num, column):
assert not isinstance(self.model(), QSortFilterProxyModel)
idx = self.model().index(row_num, column)
item = self.model().itemFromIndex(idx)
user_role = item.data(Qt.UserRole)
return item.text(), user_role
def hide_row(self, row_num):
"""
@ -541,14 +532,14 @@ class MyTreeView(QTreeView):
self.setRowHidden(row_num, QModelIndex(), False)
return
for column in self.filter_columns:
item = self.item_from_coordinate(row_num, column)
txt = item.text().lower()
txt, _ = self.text_txid_from_coordinate(row_num, column)
txt = txt.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
# we did not find the filter in any columns, hide the item
self.setRowHidden(row_num, QModelIndex(), True)
def filter(self, p):

2
electrum/gui/qt/utxo_list.py

@ -33,7 +33,7 @@ class UTXOList(MyTreeView):
filter_columns = [0, 1] # Address, Label
def __init__(self, parent=None):
super().__init__(parent, self.create_menu, 1)
super().__init__(parent, self.create_menu, 1, editable_columns=[])
self.setModel(QStandardItemModel(self))
self.setSelectionMode(QAbstractItemView.ExtendedSelection)
self.setSortingEnabled(True)

42
electrum/util.py

@ -146,6 +146,12 @@ class Satoshis(object):
def __str__(self):
return format_satoshis(self.value) + " BTC"
def __eq__(self, other):
return self.value == other.value
def __ne__(self, other):
return not (self == other)
# note: this is not a NamedTuple as then its json encoding cannot be customized
class Fiat(object):
@ -166,6 +172,12 @@ class Fiat(object):
else:
return "{:.2f}".format(self.value) + ' ' + self.ccy
def __eq__(self, other):
return self.ccy == other.ccy and self.value == other.value
def __ne__(self, other):
return not (self == other)
class MyEncoder(json.JSONEncoder):
def default(self, obj):
@ -995,47 +1007,57 @@ class OrderedDictWithIndex(OrderedDict):
Note: very inefficient to modify contents, except to add new items.
"""
_key_to_pos = {}
def __init__(self):
super().__init__()
self._key_to_pos = {}
self._pos_to_key = {}
def _recalc_key_to_pos(self):
def _recalc_index(self):
self._key_to_pos = {key: pos for (pos, key) in enumerate(self.keys())}
self._pos_to_key = {pos: key for (pos, key) in enumerate(self.keys())}
def get_pos_of_key(self, key):
def pos_from_key(self, key):
return self._key_to_pos[key]
def value_from_pos(self, pos):
key = self._pos_to_key[pos]
return self[key]
def popitem(self, *args, **kwargs):
ret = super().popitem(*args, **kwargs)
self._recalc_key_to_pos()
self._recalc_index()
return ret
def move_to_end(self, *args, **kwargs):
ret = super().move_to_end(*args, **kwargs)
self._recalc_key_to_pos()
self._recalc_index()
return ret
def clear(self):
ret = super().clear()
self._recalc_key_to_pos()
self._recalc_index()
return ret
def pop(self, *args, **kwargs):
ret = super().pop(*args, **kwargs)
self._recalc_key_to_pos()
self._recalc_index()
return ret
def update(self, *args, **kwargs):
ret = super().update(*args, **kwargs)
self._recalc_key_to_pos()
self._recalc_index()
return ret
def __delitem__(self, *args, **kwargs):
ret = super().__delitem__(*args, **kwargs)
self._recalc_key_to_pos()
self._recalc_index()
return ret
def __setitem__(self, key, *args, **kwargs):
is_new_key = key not in self
ret = super().__setitem__(key, *args, **kwargs)
if is_new_key:
self._key_to_pos[key] = len(self) - 1
pos = len(self) - 1
self._key_to_pos[key] = pos
self._pos_to_key[pos] = key
return ret

15
electrum/wallet.py

@ -45,7 +45,7 @@ from .util import (NotEnoughFunds, PrintError, UserCancelled, profiler,
format_satoshis, format_fee_satoshis, NoDynamicFeeEstimates,
WalletFileException, BitcoinException,
InvalidPassword, format_time, timestamp_to_datetime, Satoshis,
Fiat, bfh, bh2u)
Fiat, bfh, bh2u, TxMinedInfo)
from .bitcoin import (COIN, TYPE_ADDRESS, is_address, address_to_script,
is_minikey, relayfee, dust_threshold)
from .version import *
@ -239,7 +239,7 @@ class Abstract_Wallet(AddressSynchronizer):
self.labels[name] = text
changed = True
else:
if old_text:
if old_text is not None:
self.labels.pop(name)
changed = True
if changed:
@ -420,6 +420,7 @@ class Abstract_Wallet(AddressSynchronizer):
'balance': Satoshis(balance),
'date': timestamp_to_datetime(timestamp),
'label': self.get_label(tx_hash),
'txpos_in_block': tx_mined_status.txpos,
}
tx_fee = None
if show_fees:
@ -522,11 +523,11 @@ class Abstract_Wallet(AddressSynchronizer):
return ', '.join(labels)
return ''
def get_tx_status(self, tx_hash, tx_mined_status):
def get_tx_status(self, tx_hash, tx_mined_info: TxMinedInfo):
extra = []
height = tx_mined_status.height
conf = tx_mined_status.conf
timestamp = tx_mined_status.timestamp
height = tx_mined_info.height
conf = tx_mined_info.conf
timestamp = tx_mined_info.timestamp
if conf == 0:
tx = self.transactions.get(tx_hash)
if not tx:
@ -553,7 +554,7 @@ class Abstract_Wallet(AddressSynchronizer):
elif height == TX_HEIGHT_UNCONFIRMED:
status = 0
else:
status = 2
status = 2 # not SPV verified
else:
status = 3 + min(conf, 6)
time_str = format_time(timestamp) if timestamp else _("unknown")

Loading…
Cancel
Save