diff --git a/electrum/gui/icons/kangaroo.png b/electrum/gui/icons/kangaroo.png new file mode 100644 index 000000000..e27d4f81b Binary files /dev/null and b/electrum/gui/icons/kangaroo.png differ diff --git a/electrum/gui/icons/nocloud.png b/electrum/gui/icons/nocloud.png new file mode 100644 index 000000000..9034821e8 Binary files /dev/null and b/electrum/gui/icons/nocloud.png differ diff --git a/electrum/gui/qt/channels_list.py b/electrum/gui/qt/channels_list.py index f025fad22..11c54905d 100644 --- a/electrum/gui/qt/channels_list.py +++ b/electrum/gui/qt/channels_list.py @@ -2,12 +2,14 @@ import traceback from enum import IntEnum from typing import Sequence, Optional, Dict +from abc import abstractmethod, ABC from PyQt5 import QtCore, QtGui -from PyQt5.QtCore import Qt +from PyQt5.QtCore import Qt, QRect, QSize from PyQt5.QtWidgets import (QMenu, QHBoxLayout, QLabel, QVBoxLayout, QGridLayout, QLineEdit, - QPushButton, QAbstractItemView, QComboBox, QCheckBox) -from PyQt5.QtGui import QFont, QStandardItem, QBrush + QPushButton, QAbstractItemView, QComboBox, QCheckBox, + QToolTip) +from PyQt5.QtGui import QFont, QStandardItem, QBrush, QPainter, QIcon, QHelpEvent from electrum.util import bh2u, NotEnoughFunds, NoDynamicFeeEstimates from electrum.i18n import _ @@ -35,14 +37,16 @@ class ChannelsList(MyTreeView): class Columns(IntEnum): SHORT_CHANID = 0 NODE_ALIAS = 1 - CAPACITY = 2 - LOCAL_BALANCE = 3 - REMOTE_BALANCE = 4 - CHANNEL_STATUS = 5 + FEATURES = 2 + CAPACITY = 3 + LOCAL_BALANCE = 4 + REMOTE_BALANCE = 5 + CHANNEL_STATUS = 6 headers = { Columns.SHORT_CHANID: _('Short Channel ID'), Columns.NODE_ALIAS: _('Node alias'), + Columns.FEATURES: _('Features'), Columns.CAPACITY: _('Capacity'), Columns.LOCAL_BALANCE: _('Can send'), Columns.REMOTE_BALANCE: _('Can receive'), @@ -92,6 +96,7 @@ class ChannelsList(MyTreeView): return { self.Columns.SHORT_CHANID: chan.short_id_for_GUI(), self.Columns.NODE_ALIAS: node_alias, + self.Columns.FEATURES: '', self.Columns.CAPACITY: capacity_str, self.Columns.LOCAL_BALANCE: '' if closed else labels[LOCAL], self.Columns.REMOTE_BALANCE: '' if closed else labels[REMOTE], @@ -296,6 +301,7 @@ class ChannelsList(MyTreeView): items[self.Columns.NODE_ALIAS].setFont(QFont(MONOSPACE_FONT)) items[self.Columns.LOCAL_BALANCE].setFont(QFont(MONOSPACE_FONT)) items[self.Columns.REMOTE_BALANCE].setFont(QFont(MONOSPACE_FONT)) + items[self.Columns.FEATURES].setData(ChannelFeatureIcons.from_channel(chan), self.ROLE_CUSTOM_PAINT) items[self.Columns.CAPACITY].setFont(QFont(MONOSPACE_FONT)) icon = "lightning" if not chan.is_backup() else "lightning_disconnected" items[self.Columns.SHORT_CHANID].setIcon(read_QIcon(icon)) @@ -486,3 +492,76 @@ class ChannelsList(MyTreeView): from .swap_dialog import SwapDialog d = SwapDialog(self.parent) d.run() + + +class ChannelFeature(ABC): + def __init__(self): + self.rect = QRect() + + @abstractmethod + def tooltip(self) -> str: + pass + + @abstractmethod + def icon(self) -> QIcon: + pass + + +class ChanFeatTrampoline(ChannelFeature): + def tooltip(self) -> str: + return _("The channel peer can route Trampoline payments.") + def icon(self) -> QIcon: + return read_QIcon("kangaroo") + + +class ChanFeatNoOnchainBackup(ChannelFeature): + def tooltip(self) -> str: + return _("This channel cannot be recovered from your seed. You must back it up manually.") + def icon(self) -> QIcon: + return read_QIcon("nocloud") + + +class ChannelFeatureIcons: + ICON_SIZE = QSize(16, 16) + + def __init__(self, features: Sequence['ChannelFeature']): + self.features = features + + @classmethod + def from_channel(cls, chan: AbstractChannel) -> 'ChannelFeatureIcons': + if not isinstance(chan, Channel): + return ChannelFeatureIcons([]) + feats = [] + if chan.lnworker.is_trampoline_peer(chan.node_id): + feats.append(ChanFeatTrampoline()) + if not chan.lnworker.has_recoverable_channels(): + feats.append(ChanFeatNoOnchainBackup()) + return ChannelFeatureIcons(feats) + + def paint(self, painter: QPainter, rect: QRect) -> None: + painter.save() + cur_x = rect.x() + for feat in self.features: + icon_rect = QRect(cur_x, rect.y(), self.ICON_SIZE.width(), self.ICON_SIZE.height()) + feat.rect = icon_rect + if rect.contains(icon_rect): # stay inside parent + painter.drawPixmap(icon_rect, feat.icon().pixmap(self.ICON_SIZE)) + cur_x += self.ICON_SIZE.width() + 1 + painter.restore() + + def sizeHint(self, default_size: QSize) -> QSize: + if not self.features: + return default_size + width = len(self.features) * (self.ICON_SIZE.width() + 1) + return QSize(width, default_size.height()) + + def show_tooltip(self, evt: QHelpEvent) -> bool: + assert isinstance(evt, QHelpEvent) + for feat in self.features: + if feat.rect.contains(evt.pos()): + QToolTip.showText(evt.globalPos(), feat.tooltip()) + break + else: + QToolTip.hideText() + evt.ignore() + return True diff --git a/electrum/gui/qt/util.py b/electrum/gui/qt/util.py index 36ed71617..676e85fc7 100644 --- a/electrum/gui/qt/util.py +++ b/electrum/gui/qt/util.py @@ -13,16 +13,17 @@ from typing import (NamedTuple, Callable, Optional, TYPE_CHECKING, Union, List, Sequence, Iterable) from PyQt5.QtGui import (QFont, QColor, QCursor, QPixmap, QStandardItem, - QPalette, QIcon, QFontMetrics, QShowEvent) + QPalette, QIcon, QFontMetrics, QShowEvent, QPainter, QHelpEvent) from PyQt5.QtCore import (Qt, QPersistentModelIndex, QModelIndex, pyqtSignal, QCoreApplication, QItemSelectionModel, QThread, - QSortFilterProxyModel, QSize, QLocale, QAbstractItemModel) + QSortFilterProxyModel, QSize, QLocale, QAbstractItemModel, + QEvent) 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) + QMenu, QStyleOptionViewItem) from electrum.i18n import _, languages from electrum.util import FileImportFailed, FileExportFailed, make_aiohttp_session, resource_path @@ -518,9 +519,38 @@ class ElectrumItemDelegate(QStyledItemDelegate): 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 filter_columns: Iterable[int]