diff --git a/electrum/gui/qml/components/Addresses.qml b/electrum/gui/qml/components/Addresses.qml index 48ef0cf1b..dabf50e6b 100644 --- a/electrum/gui/qml/components/Addresses.qml +++ b/electrum/gui/qml/components/Addresses.qml @@ -2,6 +2,7 @@ import QtQuick 2.6 import QtQuick.Layouts 1.0 import QtQuick.Controls 2.0 import QtQuick.Controls.Material 2.0 +import QtQml.Models 2.2 import org.electrum 1.0 @@ -14,34 +15,246 @@ Pane { padding: 0 ColumnLayout { - id: layout anchors.fill: parent + spacing: 0 - ElListView { - id: listview - + ColumnLayout { + id: layout Layout.fillWidth: true Layout.fillHeight: true - clip: true - model: Daemon.currentWallet.addressModel - currentIndex: -1 + Pane { + id: filtersPane + Layout.fillWidth: true + GridLayout { + columns: 3 + width: parent.width - section.property: 'type' - section.criteria: ViewSection.FullString - section.delegate: sectionDelegate + CheckBox { + id: showUsed + text: qsTr('Show Used') + enabled: listview.filterModel.showAddressesCoins != 2 + onCheckedChanged: listview.filterModel.showUsed = checked + Component.onCompleted: checked = listview.filterModel.showUsed + } - delegate: AddressDelegate { - onClicked: { - var page = app.stack.push(Qt.resolvedUrl('AddressDetails.qml'), {'address': model.address}) - page.addressDetailsChanged.connect(function() { - // update listmodel when details change - listview.model.updateAddress(model.address) - }) + RowLayout { + Layout.columnSpan: 2 + Layout.fillWidth: true + Layout.alignment: Qt.AlignRight + Label { + text: qsTr('Show') + } + ElComboBox { + id: showCoinsAddresses + textRole: 'text' + valueRole: 'value' + model: ListModel { + id: showCoinsAddressesModel + Component.onCompleted: { + // we need to fill the model like this, as ListElement can't evaluate script + showCoinsAddressesModel.append({'text': qsTr('Addresses'), 'value': 1}) + showCoinsAddressesModel.append({'text': qsTr('Coins'), 'value': 2}) + showCoinsAddressesModel.append({'text': qsTr('Both'), 'value': 3}) + showCoinsAddresses.currentIndex = 0 + for (let i=0; i < showCoinsAddressesModel.count; i++) { + if (showCoinsAddressesModel.get(i).value == listview.filterModel.showAddressesCoins) { + showCoinsAddresses.currentIndex = i + break + } + } + } + } + onCurrentValueChanged: { + if (activeFocus && currentValue) { + listview.filterModel.showAddressesCoins = currentValue + } + } + } + } + RowLayout { + Layout.columnSpan: 3 + Layout.fillWidth: true + TextField { + id: searchEdit + Layout.fillWidth: true + placeholderText: qsTr('text search') + onTextChanged: listview.filterModel.filterText = text + } + Image { + source: Qt.resolvedUrl('../../icons/zoom.png') + sourceSize.width: constants.iconSizeMedium + sourceSize.height: constants.iconSizeMedium + } + } } } - ScrollIndicator.vertical: ScrollIndicator { } + Frame { + id: channelsFrame + Layout.fillWidth: true + Layout.fillHeight: true + + verticalPadding: 0 + horizontalPadding: 0 + background: PaneInsetBackground {} + + ElListView { + id: listview + + anchors.fill: parent + clip: true + + property QtObject backingModel: Daemon.currentWallet.addressCoinModel + property QtObject filterModel: Daemon.currentWallet.addressCoinModel.filterModel + property bool selectMode: false + property bool freeze: true + model: visualModel + currentIndex: -1 + + section.property: 'type' + section.criteria: ViewSection.FullString + section.delegate: sectionDelegate + + function getSelectedItems() { + var items = [] + for (let i = 0; i < selectedGroup.count; i++) { + let modelitem = selectedGroup.get(i).model + if (modelitem.outpoint) + items.push(modelitem.outpoint) + else + items.push(modelitem.address) + } + return items + } + + DelegateModel { + id: visualModel + model: listview.filterModel + groups: [ + DelegateModelGroup { + id: selectedGroup; + name: 'selected' + onCountChanged: { + if (count == 0) + listview.selectMode = false + } + } + ] + + delegate: Loader { + id: loader + width: parent.width + + sourceComponent: model.outpoint ? _coinDelegate : _addressDelegate + + function toggle() { + loader.DelegateModel.inSelected = !loader.DelegateModel.inSelected + } + + Component { + id: _addressDelegate + AddressDelegate { + id: addressDelegate + width: parent.width + property bool selected: loader.DelegateModel.inSelected + highlighted: selected + onClicked: { + if (!listview.selectMode) { + var page = app.stack.push(Qt.resolvedUrl('AddressDetails.qml'), { + address: model.address + }) + page.addressDetailsChanged.connect(function() { + // update listmodel when details change + listview.backingModel.updateAddress(model.address) + }) + } else { + loader.toggle() + } + } + onPressAndHold: { + loader.toggle() + if (!listview.selectMode && selectedGroup.count > 0) + listview.selectMode = true + } + } + } + Component { + id: _coinDelegate + Pane { + height: coinDelegate.height + padding: 0 + background: Rectangle { + color: Qt.darker(constants.darkerBackground, 1.10) + } + + CoinDelegate { + id: coinDelegate + width: parent.width + property bool selected: loader.DelegateModel.inSelected + highlighted: selected + onClicked: { + if (!listview.selectMode) { + var page = app.stack.push(Qt.resolvedUrl('TxDetails.qml'), { + txid: model.txid + }) + } else { + loader.toggle() + } + } + onPressAndHold: { + loader.toggle() + if (!listview.selectMode && selectedGroup.count > 0) + listview.selectMode = true + } + } + } + } + } + + } + add: Transition { + NumberAnimation { properties: "opacity"; from: 0.0; to: 1.0; duration: 300 + easing.type: Easing.OutQuad + } + } + + onSelectModeChanged: { + if (selectMode) { + listview.freeze = !selectedGroup.get(0).model.held + } + } + + ScrollIndicator.vertical: ScrollIndicator { } + } + } + } + + ButtonContainer { + Layout.fillWidth: true + FlatButton { + Layout.fillWidth: true + Layout.preferredWidth: 1 + text: listview.freeze ? qsTr('Freeze') : qsTr('Unfreeze') + icon.source: '../../icons/seal.png' + visible: listview.selectMode + onClicked: { + var items = listview.getSelectedItems() + listview.backingModel.setFrozenForItems(listview.freeze, items) + selectedGroup.remove(0, selectedGroup.count) + } + } + FlatButton { + Layout.fillWidth: true + Layout.preferredWidth: 1 + text: qsTr('Pay from...') + icon.source: '../../icons/tab_send.png' + visible: listview.selectMode + enabled: false // TODO + onClicked: { + // + } + } } } @@ -52,7 +265,6 @@ Pane { id: root width: ListView.view.width height: childrenRect.height - required property string section property string section_label: section == 'receive' ? qsTr('receive addresses') @@ -74,6 +286,6 @@ Pane { } Component.onCompleted: { - Daemon.currentWallet.addressModel.initModel() + Daemon.currentWallet.addressCoinModel.initModel() } } diff --git a/electrum/gui/qml/components/controls/AddressDelegate.qml b/electrum/gui/qml/components/controls/AddressDelegate.qml index f81171537..6b8d235ec 100644 --- a/electrum/gui/qml/components/controls/AddressDelegate.qml +++ b/electrum/gui/qml/components/controls/AddressDelegate.qml @@ -27,9 +27,9 @@ ItemDelegate { Label { id: indexLabel font.bold: true - text: model.iaddr < 10 - ? '#' + ('0'+model.iaddr).slice(-2) - : '#' + model.iaddr + text: model.addridx < 10 + ? '#' + ('0'+model.addridx).slice(-2) + : '#' + model.addridx Layout.fillWidth: true } Label { diff --git a/electrum/gui/qml/components/controls/CoinDelegate.qml b/electrum/gui/qml/components/controls/CoinDelegate.qml new file mode 100644 index 000000000..1dc93be2c --- /dev/null +++ b/electrum/gui/qml/components/controls/CoinDelegate.qml @@ -0,0 +1,97 @@ +import QtQuick 2.6 +import QtQuick.Controls 2.0 +import QtQuick.Layouts 1.0 +import QtQuick.Controls.Material 2.0 + +import org.electrum 1.0 + +ItemDelegate { + id: delegate + width: ListView.view.width + height: delegateLayout.height + highlighted: ListView.isCurrentItem + + font.pixelSize: constants.fontSizeMedium // set default font size for child controls + + ColumnLayout { + id: delegateLayout + width: parent.width + spacing: 0 + + GridLayout { + columns: 3 + Layout.topMargin: constants.paddingSmall + Layout.leftMargin: constants.paddingLarge + 2*constants.paddingLarge + Layout.rightMargin: constants.paddingLarge + + Rectangle { + id: useIndicator + Layout.rowSpan: 2 + Layout.preferredWidth: constants.iconSizeSmall + Layout.preferredHeight: constants.iconSizeSmall + Layout.alignment: Qt.AlignTop + color: model.held + ? constants.colorAddressFrozen + : constants.colorAddressUsedWithBalance + } + + RowLayout { + Layout.fillWidth: true + Label { + font.family: FixedFont + text: model.outpoint + elide: Text.ElideMiddle + Layout.preferredWidth: implicitWidth + constants.paddingMedium + } + Label { + Layout.fillWidth: true + visible: model.short_id + font.family: FixedFont + font.pixelSize: constants.fontSizeSmall + text: '[' + model.short_id + ']' + } + Item { + Layout.fillWidth: true + Layout.alignment: Qt.AlignLeft | Qt.AlignTop + visible: !model.short_id + Image { + source: Qt.resolvedUrl('../../../icons/unconfirmed.png') + sourceSize.width: constants.iconSizeSmall + sourceSize.height: constants.iconSizeSmall + } + } + } + + RowLayout { + Label { + font.family: FixedFont + text: Config.formatSats(model.amount, false) + visible: model.amount.satsInt != 0 + } + Label { + color: Material.accentColor + text: Config.baseUnit + visible: model.amount.satsInt != 0 + } + } + + Label { + id: labelLabel + Layout.fillWidth: true + Layout.columnSpan: 2 + visible: model.label + font.pixelSize: constants.fontSizeMedium + text: model.label + elide: Text.ElideRight + maximumLineCount: 2 + wrapMode: Text.WordWrap + } + + } + + Item { + Layout.preferredWidth: 1 + Layout.preferredHeight: constants.paddingSmall + } + } +} diff --git a/electrum/gui/qml/qeaddresslistmodel.py b/electrum/gui/qml/qeaddresslistmodel.py index 1a989ec92..f2537d342 100644 --- a/electrum/gui/qml/qeaddresslistmodel.py +++ b/electrum/gui/qml/qeaddresslistmodel.py @@ -1,7 +1,6 @@ -import itertools -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, List -from PyQt5.QtCore import pyqtSlot +from PyQt5.QtCore import pyqtSlot, QSortFilterProxyModel, pyqtSignal, pyqtProperty from PyQt5.QtCore import Qt, QAbstractListModel, QModelIndex from electrum.logging import get_logger @@ -11,38 +10,126 @@ from .qetypes import QEAmount if TYPE_CHECKING: from electrum.wallet import Abstract_Wallet + from electrum.transaction import PartialTxInput -class QEAddressListModel(QAbstractListModel): +class QEAddressCoinFilterProxyModel(QSortFilterProxyModel): + _logger = get_logger(__name__) + + def __init__(self, parent_model, parent=None): + super().__init__(parent) + self._filter_text = None + self._show_coins = True + self._show_addresses = True + self._show_used = False + self._parent_model = parent_model + self.setSourceModel(parent_model) + + countChanged = pyqtSignal() + @pyqtProperty(int, notify=countChanged) + def count(self): + return self.rowCount(QModelIndex()) + + def filterAcceptsRow(self, s_row, s_parent): + parent_model = self.sourceModel() + addridx = parent_model.data(parent_model.index(s_row, 0, s_parent), parent_model._ROLE_RMAP['addridx']) + if addridx is None: # coin + if not self._show_coins: + return False + else: + if not self._show_addresses: + return False + balance = parent_model.data(parent_model.index(s_row, 0, s_parent), parent_model._ROLE_RMAP['balance']) + numtx = parent_model.data(parent_model.index(s_row, 0, s_parent), parent_model._ROLE_RMAP['numtx']) + if balance.isEmpty and numtx and not self._show_used: + return False + if self._filter_text: + label = parent_model.data(parent_model.index(s_row, 0, s_parent), parent_model._ROLE_RMAP['label']) + address = parent_model.data(parent_model.index(s_row, 0, s_parent), parent_model._ROLE_RMAP['address']) + for item in [label, address]: + if self._filter_text in str(item): + return True + return False + return True + + showAddressesCoinsChanged = pyqtSignal() + @pyqtProperty(int, notify=showAddressesCoinsChanged) + def showAddressesCoins(self) -> int: + result = 0 + if self._show_addresses: + result += 1 + if self._show_coins: + result += 2 + return result + + @showAddressesCoins.setter + def showAddressesCoins(self, show_addresses_coins: int): + show_addresses = show_addresses_coins in [1, 3] + show_coins = show_addresses_coins in [2, 3] + + if self._show_addresses != show_addresses or self._show_coins != show_coins: + self._show_addresses = show_addresses + self._show_coins = show_coins + self.invalidateFilter() + self.showAddressesCoinsChanged.emit() + + showUsedChanged = pyqtSignal() + @pyqtProperty(bool, notify=showUsedChanged) + def showUsed(self) -> bool: + return self._show_used + + @showUsed.setter + def showUsed(self, show_used: bool): + if self._show_used != show_used: + self._show_used = show_used + self.invalidateFilter() + self.showUsedChanged.emit() + + filterTextChanged = pyqtSignal() + @pyqtProperty(str, notify=filterTextChanged) + def filterText(self) -> str: + return self._filter_text + + @filterText.setter + def filterText(self, filter_text: str): + if self._filter_text != filter_text: + self._filter_text = filter_text + self.invalidateFilter() + self.filterTextChanged.emit() + + +class QEAddressCoinListModel(QAbstractListModel): _logger = get_logger(__name__) # define listmodel rolemap - _ROLE_NAMES=('type', 'iaddr', 'address', 'label', 'balance', 'numtx', 'held') + _ROLE_NAMES=('type', 'addridx', 'address', 'label', 'balance', 'numtx', 'held', 'height', 'amount', 'outpoint', + 'short_outpoint', 'short_id', 'txid') _ROLE_KEYS = range(Qt.UserRole, Qt.UserRole + len(_ROLE_NAMES)) _ROLE_MAP = dict(zip(_ROLE_KEYS, [bytearray(x.encode()) for x in _ROLE_NAMES])) + _ROLE_RMAP = dict(zip(_ROLE_NAMES, _ROLE_KEYS)) def __init__(self, wallet: 'Abstract_Wallet', parent=None): super().__init__(parent) self.wallet = wallet - self._receive_addresses = [] - self._change_addresses = [] + self._items = [] + self._filterModel = None self._dirty = True self.initModel() def rowCount(self, index): - return len(self._receive_addresses) + len(self._change_addresses) + return len(self._items) def roleNames(self): return self._ROLE_MAP def data(self, index, role): - if index.row() > len(self._receive_addresses) - 1: - address = self._change_addresses[index.row() - len(self._receive_addresses)] - else: - address = self._receive_addresses[index.row()] + address = self._items[index.row()] role_index = role - Qt.UserRole - value = address[self._ROLE_NAMES[role_index]] + try: + value = address[self._ROLE_NAMES[role_index]] + except KeyError: + return None if isinstance(value, (bool, list, int, str, QEAmount)) or value is None: return value if isinstance(value, Satoshis): @@ -51,13 +138,14 @@ class QEAddressListModel(QAbstractListModel): def clear(self): self.beginResetModel() - self._receive_addresses = [] - self._change_addresses = [] + self._items = [] self.endResetModel() - def addr_to_model(self, address): + def addr_to_model(self, addrtype: str, addridx: int, address: str): c, u, x = self.wallet.get_addr_balance(address) item = { + 'type': addrtype, + 'addridx': addridx, 'address': address, 'numtx': self.wallet.adb.get_address_history_len(address), 'label': self.wallet.get_label_for_address(address), @@ -66,6 +154,27 @@ class QEAddressListModel(QAbstractListModel): } return item + def coin_to_model(self, addrtype: str, coin: 'PartialTxInput'): + txid = coin.prevout.txid.hex() + short_id = '' + # check below duplicated from TxInput as we cannot get short_id unambiguously + if coin.block_txpos is not None and coin.block_txpos >= 0: + short_id = str(coin.short_id) + item = { + 'type': addrtype, + 'amount': QEAmount(amount_sat=coin.value_sats()), + 'address': coin.address, + 'height': coin.block_height, + 'outpoint': coin.prevout.to_str(), + 'short_outpoint': coin.prevout.short_name(), + 'short_id': short_id, + 'txid': txid, + 'label': self.wallet.get_label_for_txid(txid) or '', + 'held': self.wallet.is_frozen_coin(coin), + 'coin': coin + } + return item + @pyqtSlot() def setDirty(self): self._dirty = True @@ -80,36 +189,70 @@ class QEAddressListModel(QAbstractListModel): c_addresses = self.wallet.get_change_addresses() if self.wallet.wallet_type != 'imported' else [] n_addresses = len(r_addresses) + len(c_addresses) - def insert_row(atype, alist, address, iaddr): - item = self.addr_to_model(address) - item['type'] = atype - item['iaddr'] = iaddr - alist.append(item) + def insert_address(atype, address, addridx): + item = self.addr_to_model(atype, addridx, address) + self._items.append(item) + + utxos = self.wallet.get_utxos([address]) + utxos.sort(key=lambda x: x.block_height) + for i, coin in enumerate(utxos): + self._items.append(self.coin_to_model(atype, coin)) self.clear() self.beginInsertRows(QModelIndex(), 0, n_addresses - 1) if self.wallet.wallet_type != 'imported': for i, address in enumerate(r_addresses): - insert_row('receive', self._receive_addresses, address, i) + insert_address('receive', address, i) for i, address in enumerate(c_addresses): - insert_row('change', self._change_addresses, address, i) + insert_address('change', address, i) else: for i, address in enumerate(r_addresses): - insert_row('imported', self._receive_addresses, address, i) + insert_address('imported', address, i) self.endInsertRows() self._dirty = False @pyqtSlot(str) def updateAddress(self, address): - for i, a in enumerate(itertools.chain(self._receive_addresses, self._change_addresses)): + for i, a in enumerate(self._items): if a['address'] == address: self.do_update(i, a) return + def updateCoin(self, outpoint): + for i, a in enumerate(self._items): + if a.get('outpoint') == outpoint: + self.do_update(i, a) + return + def do_update(self, modelindex, modelitem): mi = self.createIndex(modelindex, 0) self._logger.debug(repr(modelitem)) - modelitem.update(self.addr_to_model(modelitem['address'])) + if modelitem.get('outpoint'): + modelitem.update(self.coin_to_model(modelitem['type'], modelitem['coin'])) + else: + modelitem.update(self.addr_to_model(modelitem['type'], modelitem['addridx'], modelitem['address'])) self._logger.debug(repr(modelitem)) self.dataChanged.emit(mi, mi, self._ROLE_KEYS) + + filterModelChanged = pyqtSignal() + @pyqtProperty(QEAddressCoinFilterProxyModel, notify=filterModelChanged) + def filterModel(self): + if self._filterModel is None: + self._filterModel = QEAddressCoinFilterProxyModel(self) + return self._filterModel + + @pyqtSlot(bool, list) + def setFrozenForItems(self, freeze: bool, items: List[str]): + self._logger.debug(f'set frozen to {freeze} for {items!r}') + coins = list(filter(lambda x: ':' in x, items)) + if len(coins): + self.wallet.set_frozen_state_of_coins(coins, freeze) + for coin in coins: + self.updateCoin(coin) + addresses = list(filter(lambda x: ':' not in x, items)) + if len(addresses): + self.wallet.set_frozen_state_of_addresses(addresses, freeze) + for address in addresses: + self.updateAddress(address) + diff --git a/electrum/gui/qml/qemodelfilter.py b/electrum/gui/qml/qemodelfilter.py index 1c098009d..d5bd75af2 100644 --- a/electrum/gui/qml/qemodelfilter.py +++ b/electrum/gui/qml/qemodelfilter.py @@ -1,7 +1,8 @@ -from PyQt5.QtCore import pyqtSignal, pyqtProperty, QSortFilterProxyModel, QModelIndex +from PyQt5.QtCore import pyqtSignal, pyqtProperty, QSortFilterProxyModel, QModelIndex, pyqtSlot from electrum.logging import get_logger + class QEFilterProxyModel(QSortFilterProxyModel): _logger = get_logger(__name__) @@ -18,8 +19,10 @@ class QEFilterProxyModel(QSortFilterProxyModel): def isCustomFilter(self): return self._filter_value is not None + @pyqtSlot(str) def setFilterValue(self, filter_value): self._filter_value = filter_value + self.invalidate() def filterAcceptsRow(self, s_row, s_parent): if not self.isCustomFilter: diff --git a/electrum/gui/qml/qewallet.py b/electrum/gui/qml/qewallet.py index 72b0d5cbc..f21985384 100644 --- a/electrum/gui/qml/qewallet.py +++ b/electrum/gui/qml/qewallet.py @@ -19,7 +19,7 @@ from electrum.wallet import Multisig_Wallet from electrum.crypto import pw_decode_with_version_and_mac from .auth import AuthMixin, auth_protect -from .qeaddresslistmodel import QEAddressListModel +from .qeaddresslistmodel import QEAddressCoinListModel from .qechannellistmodel import QEChannelListModel from .qeinvoicelistmodel import QEInvoiceListModel, QERequestListModel from .qetransactionlistmodel import QETransactionListModel @@ -91,7 +91,7 @@ class QEWallet(AuthMixin, QObject, QtEventListener): self._synchronizing_progress = '' self._historyModel = None - self._addressModel = None + self._addressCoinModel = None self._requestModel = None self._invoiceModel = None self._channelModel = None @@ -184,7 +184,7 @@ class QEWallet(AuthMixin, QObject, QtEventListener): if wallet == self.wallet: self._logger.info(f'new transaction {tx.txid()}') self.add_tx_notification(tx) - self.addressModel.setDirty() + self.addressCoinModel.setDirty() self.historyModel.setDirty() # assuming wallet.is_up_to_date triggers after self.balanceChanged.emit() @@ -198,7 +198,7 @@ class QEWallet(AuthMixin, QObject, QtEventListener): def on_event_removed_transaction(self, wallet, tx): if wallet == self.wallet: self._logger.info(f'removed transaction {tx.txid()}') - self.addressModel.setDirty() + self.addressCoinModel.setDirty() self.historyModel.initModel(True) # setDirty()? self.balanceChanged.emit() @@ -295,12 +295,12 @@ class QEWallet(AuthMixin, QObject, QtEventListener): self._historyModel = QETransactionListModel(self.wallet) return self._historyModel - addressModelChanged = pyqtSignal() - @pyqtProperty(QEAddressListModel, notify=addressModelChanged) - def addressModel(self): - if self._addressModel is None: - self._addressModel = QEAddressListModel(self.wallet) - return self._addressModel + addressCoinModelChanged = pyqtSignal() + @pyqtProperty(QEAddressCoinListModel, notify=addressCoinModelChanged) + def addressCoinModel(self): + if self._addressCoinModel is None: + self._addressCoinModel = QEAddressCoinListModel(self.wallet) + return self._addressCoinModel requestModelChanged = pyqtSignal() @pyqtProperty(QERequestListModel, notify=requestModelChanged) @@ -658,7 +658,7 @@ class QEWallet(AuthMixin, QObject, QtEventListener): assert key is not None self._logger.debug(f'created request with key {key} addr {addr}') - self.addressModel.setDirty() + self.addressCoinModel.setDirty() self.requestModel.add_invoice(self.wallet.get_request(key)) self.requestCreateSuccess.emit(key)