1# -*- coding: utf-8 -*- 2import traceback 3from enum import IntEnum 4from typing import Sequence, Optional, Dict 5from abc import abstractmethod, ABC 6 7from PyQt5 import QtCore, QtGui 8from PyQt5.QtCore import Qt, QRect, QSize 9from PyQt5.QtWidgets import (QMenu, QHBoxLayout, QLabel, QVBoxLayout, QGridLayout, QLineEdit, 10 QPushButton, QAbstractItemView, QComboBox, QCheckBox, 11 QToolTip) 12from PyQt5.QtGui import QFont, QStandardItem, QBrush, QPainter, QIcon, QHelpEvent 13 14from electrum.util import bh2u, NotEnoughFunds, NoDynamicFeeEstimates 15from electrum.i18n import _ 16from electrum.lnchannel import AbstractChannel, PeerState, ChannelBackup, Channel, ChannelState 17from electrum.wallet import Abstract_Wallet 18from electrum.lnutil import LOCAL, REMOTE, format_short_channel_id, LN_MAX_FUNDING_SAT 19from electrum.lnworker import LNWallet 20from electrum import ecc 21from electrum.gui import messages 22 23from .util import (MyTreeView, WindowModalDialog, Buttons, OkButton, CancelButton, 24 EnterButton, WaitingDialog, MONOSPACE_FONT, ColorScheme) 25from .amountedit import BTCAmountEdit, FreezableLineEdit 26from .util import read_QIcon 27 28 29ROLE_CHANNEL_ID = Qt.UserRole 30 31 32class ChannelsList(MyTreeView): 33 update_rows = QtCore.pyqtSignal(Abstract_Wallet) 34 update_single_row = QtCore.pyqtSignal(Abstract_Wallet, AbstractChannel) 35 gossip_db_loaded = QtCore.pyqtSignal() 36 37 class Columns(IntEnum): 38 FEATURES = 0 39 SHORT_CHANID = 1 40 NODE_ALIAS = 2 41 CAPACITY = 3 42 LOCAL_BALANCE = 4 43 REMOTE_BALANCE = 5 44 CHANNEL_STATUS = 6 45 46 headers = { 47 Columns.SHORT_CHANID: _('Short Channel ID'), 48 Columns.NODE_ALIAS: _('Node alias'), 49 Columns.FEATURES: "", 50 Columns.CAPACITY: _('Capacity'), 51 Columns.LOCAL_BALANCE: _('Can send'), 52 Columns.REMOTE_BALANCE: _('Can receive'), 53 Columns.CHANNEL_STATUS: _('Status'), 54 } 55 56 filter_columns = [ 57 Columns.SHORT_CHANID, 58 Columns.NODE_ALIAS, 59 Columns.CHANNEL_STATUS, 60 ] 61 62 _default_item_bg_brush = None # type: Optional[QBrush] 63 64 def __init__(self, parent): 65 super().__init__(parent, self.create_menu, stretch_column=self.Columns.NODE_ALIAS) 66 self.setModel(QtGui.QStandardItemModel(self)) 67 self.setSelectionMode(QAbstractItemView.ExtendedSelection) 68 self.main_window = parent 69 self.gossip_db_loaded.connect(self.on_gossip_db) 70 self.update_rows.connect(self.do_update_rows) 71 self.update_single_row.connect(self.do_update_single_row) 72 self.network = self.parent.network 73 self.wallet = self.parent.wallet 74 self.setSortingEnabled(True) 75 76 @property 77 # property because lnworker might be initialized at runtime 78 def lnworker(self): 79 return self.wallet.lnworker 80 81 def format_fields(self, chan: AbstractChannel) -> Dict['ChannelsList.Columns', str]: 82 labels = {} 83 for subject in (REMOTE, LOCAL): 84 if isinstance(chan, Channel): 85 can_send = chan.available_to_spend(subject) / 1000 86 label = self.parent.format_amount(can_send) 87 other = subject.inverted() 88 bal_other = chan.balance(other)//1000 89 bal_minus_htlcs_other = chan.balance_minus_outgoing_htlcs(other)//1000 90 if bal_other != bal_minus_htlcs_other: 91 label += ' (+' + self.parent.format_amount(bal_other - bal_minus_htlcs_other) + ')' 92 else: 93 assert isinstance(chan, ChannelBackup) 94 label = '' 95 labels[subject] = label 96 status = chan.get_state_for_GUI() 97 closed = chan.is_closed() 98 node_alias = self.lnworker.get_node_alias(chan.node_id) or chan.node_id.hex() 99 capacity_str = self.parent.format_amount(chan.get_capacity(), whitespaces=True) 100 return { 101 self.Columns.SHORT_CHANID: chan.short_id_for_GUI(), 102 self.Columns.NODE_ALIAS: node_alias, 103 self.Columns.FEATURES: '', 104 self.Columns.CAPACITY: capacity_str, 105 self.Columns.LOCAL_BALANCE: '' if closed else labels[LOCAL], 106 self.Columns.REMOTE_BALANCE: '' if closed else labels[REMOTE], 107 self.Columns.CHANNEL_STATUS: status, 108 } 109 110 def on_channel_closed(self, txid): 111 self.main_window.show_error('Channel closed' + '\n' + txid) 112 113 def on_request_sent(self, b): 114 self.main_window.show_message(_('Request sent')) 115 116 def on_failure(self, exc_info): 117 type_, e, tb = exc_info 118 traceback.print_tb(tb) 119 self.main_window.show_error('Failed to close channel:\n{}'.format(repr(e))) 120 121 def close_channel(self, channel_id): 122 self.is_force_close = False 123 msg = _('Close channel?') 124 force_cb = QCheckBox('Request force close from remote peer') 125 tooltip = _(messages.MSG_REQUEST_FORCE_CLOSE) 126 tooltip = messages.to_rtf(tooltip) 127 def on_checked(b): 128 self.is_force_close = bool(b) 129 force_cb.stateChanged.connect(on_checked) 130 force_cb.setToolTip(tooltip) 131 if not self.parent.question(msg, checkbox=force_cb): 132 return 133 if self.is_force_close: 134 coro = self.lnworker.request_force_close(channel_id) 135 on_success = self.on_request_sent 136 else: 137 coro = self.lnworker.close_channel(channel_id) 138 on_success = self.on_channel_closed 139 def task(): 140 return self.network.run_from_another_thread(coro) 141 WaitingDialog(self, 'please wait..', task, on_success, self.on_failure) 142 143 def force_close(self, channel_id): 144 self.save_backup = True 145 backup_cb = QCheckBox('Create a backup now', checked=True) 146 def on_checked(b): 147 self.save_backup = bool(b) 148 backup_cb.stateChanged.connect(on_checked) 149 chan = self.lnworker.channels[channel_id] 150 to_self_delay = chan.config[REMOTE].to_self_delay 151 msg = '<b>' + _('Force-close channel?') + '</b><br/>'\ 152 + '<p>' + _('If you force-close this channel, the funds you have in it will not be available for {} blocks.').format(to_self_delay) + ' '\ 153 + _('After that delay, funds will be swept to an address derived from your wallet seed.') + '</p>'\ 154 + '<u>' + _('Please create a backup of your wallet file!') + '</u> '\ 155 + '<p>' + _('Funds in this channel will not be recoverable from seed until they are swept back into your wallet, and might be lost if you lose your wallet file.') + ' '\ 156 + _('To prevent that, you should save a backup of your wallet on another device.') + '</p>' 157 if not self.parent.question(msg, title=_('Force-close channel'), rich_text=True, checkbox=backup_cb): 158 return 159 if self.save_backup: 160 if not self.parent.backup_wallet(): 161 return 162 def task(): 163 coro = self.lnworker.force_close_channel(channel_id) 164 return self.network.run_from_another_thread(coro) 165 WaitingDialog(self, 'please wait..', task, self.on_channel_closed, self.on_failure) 166 167 def remove_channel(self, channel_id): 168 if self.main_window.question(_('Are you sure you want to delete this channel? This will purge associated transactions from your wallet history.')): 169 self.lnworker.remove_channel(channel_id) 170 171 def remove_channel_backup(self, channel_id): 172 if self.main_window.question(_('Remove channel backup?')): 173 self.lnworker.remove_channel_backup(channel_id) 174 175 def export_channel_backup(self, channel_id): 176 msg = ' '.join([ 177 _("Channel backups can be imported in another instance of the same wallet, by scanning this QR code."), 178 _("Please note that channel backups cannot be used to restore your channels."), 179 _("If you lose your wallet file, the only thing you can do with a backup is to request your channel to be closed, so that your funds will be sent on-chain."), 180 ]) 181 data = self.lnworker.export_channel_backup(channel_id) 182 self.main_window.show_qrcode(data, 'channel backup', help_text=msg, 183 show_copy_text_btn=True) 184 185 def request_force_close(self, channel_id): 186 def task(): 187 coro = self.lnworker.request_force_close(channel_id) 188 return self.network.run_from_another_thread(coro) 189 WaitingDialog(self, 'please wait..', task, self.on_request_sent, self.on_failure) 190 191 def freeze_channel_for_sending(self, chan, b): 192 if self.lnworker.channel_db or self.lnworker.is_trampoline_peer(chan.node_id): 193 chan.set_frozen_for_sending(b) 194 else: 195 msg = messages.MSG_NON_TRAMPOLINE_CHANNEL_FROZEN_WITHOUT_GOSSIP 196 self.main_window.show_warning(msg, title=_('Channel is frozen for sending')) 197 198 def create_menu(self, position): 199 menu = QMenu() 200 menu.setSeparatorsCollapsible(True) # consecutive separators are merged together 201 selected = self.selected_in_column(self.Columns.NODE_ALIAS) 202 if not selected: 203 menu.addAction(_("Import channel backup"), lambda: self.parent.do_process_from_text_channel_backup()) 204 menu.exec_(self.viewport().mapToGlobal(position)) 205 return 206 multi_select = len(selected) > 1 207 if multi_select: 208 return 209 idx = self.indexAt(position) 210 if not idx.isValid(): 211 return 212 item = self.model().itemFromIndex(idx) 213 if not item: 214 return 215 channel_id = idx.sibling(idx.row(), self.Columns.NODE_ALIAS).data(ROLE_CHANNEL_ID) 216 chan = self.lnworker.channel_backups.get(channel_id) 217 if chan: 218 funding_tx = self.parent.wallet.db.get_transaction(chan.funding_outpoint.txid) 219 menu.addAction(_("View funding transaction"), lambda: self.parent.show_transaction(funding_tx)) 220 if chan.get_state() == ChannelState.FUNDED: 221 menu.addAction(_("Request force-close"), lambda: self.request_force_close(channel_id)) 222 if chan.can_be_deleted(): 223 menu.addAction(_("Delete"), lambda: self.remove_channel_backup(channel_id)) 224 menu.exec_(self.viewport().mapToGlobal(position)) 225 return 226 chan = self.lnworker.channels[channel_id] 227 menu.addAction(_("Details..."), lambda: self.parent.show_channel(channel_id)) 228 cc = self.add_copy_menu(menu, idx) 229 cc.addAction(_("Node ID"), lambda: self.place_text_on_clipboard( 230 chan.node_id.hex(), title=_("Node ID"))) 231 cc.addAction(_("Long Channel ID"), lambda: self.place_text_on_clipboard( 232 channel_id.hex(), title=_("Long Channel ID"))) 233 if not chan.is_closed(): 234 if not chan.is_frozen_for_sending(): 235 menu.addAction(_("Freeze (for sending)"), lambda: self.freeze_channel_for_sending(chan, True)) # 236 else: 237 menu.addAction(_("Unfreeze (for sending)"), lambda: self.freeze_channel_for_sending(chan, False)) 238 if not chan.is_frozen_for_receiving(): 239 menu.addAction(_("Freeze (for receiving)"), lambda: chan.set_frozen_for_receiving(True)) 240 else: 241 menu.addAction(_("Unfreeze (for receiving)"), lambda: chan.set_frozen_for_receiving(False)) 242 243 funding_tx = self.parent.wallet.db.get_transaction(chan.funding_outpoint.txid) 244 if funding_tx: 245 menu.addAction(_("View funding transaction"), lambda: self.parent.show_transaction(funding_tx)) 246 if not chan.is_closed(): 247 menu.addSeparator() 248 if chan.peer_state == PeerState.GOOD: 249 menu.addAction(_("Close channel"), lambda: self.close_channel(channel_id)) 250 menu.addAction(_("Force-close channel"), lambda: self.force_close(channel_id)) 251 else: 252 item = chan.get_closing_height() 253 if item: 254 txid, height, timestamp = item 255 closing_tx = self.lnworker.lnwatcher.db.get_transaction(txid) 256 if closing_tx: 257 menu.addAction(_("View closing transaction"), lambda: self.parent.show_transaction(closing_tx)) 258 menu.addSeparator() 259 menu.addAction(_("Export backup"), lambda: self.export_channel_backup(channel_id)) 260 if chan.can_be_deleted(): 261 menu.addSeparator() 262 menu.addAction(_("Delete"), lambda: self.remove_channel(channel_id)) 263 menu.exec_(self.viewport().mapToGlobal(position)) 264 265 @QtCore.pyqtSlot(Abstract_Wallet, AbstractChannel) 266 def do_update_single_row(self, wallet: Abstract_Wallet, chan: AbstractChannel): 267 if wallet != self.parent.wallet: 268 return 269 for row in range(self.model().rowCount()): 270 item = self.model().item(row, self.Columns.NODE_ALIAS) 271 if item.data(ROLE_CHANNEL_ID) != chan.channel_id: 272 continue 273 for column, v in self.format_fields(chan).items(): 274 self.model().item(row, column).setData(v, QtCore.Qt.DisplayRole) 275 items = [self.model().item(row, column) for column in self.Columns] 276 self._update_chan_frozen_bg(chan=chan, items=items) 277 if wallet.lnworker: 278 self.update_can_send(wallet.lnworker) 279 280 @QtCore.pyqtSlot() 281 def on_gossip_db(self): 282 self.do_update_rows(self.parent.wallet) 283 284 @QtCore.pyqtSlot(Abstract_Wallet) 285 def do_update_rows(self, wallet): 286 if wallet != self.parent.wallet: 287 return 288 channels = list(wallet.lnworker.channels.values()) if wallet.lnworker else [] 289 backups = list(wallet.lnworker.channel_backups.values()) if wallet.lnworker else [] 290 if wallet.lnworker: 291 self.update_can_send(wallet.lnworker) 292 self.model().clear() 293 self.update_headers(self.headers) 294 for chan in channels + backups: 295 field_map = self.format_fields(chan) 296 items = [QtGui.QStandardItem(field_map[col]) for col in sorted(field_map)] 297 self.set_editability(items) 298 if self._default_item_bg_brush is None: 299 self._default_item_bg_brush = items[self.Columns.NODE_ALIAS].background() 300 items[self.Columns.NODE_ALIAS].setData(chan.channel_id, ROLE_CHANNEL_ID) 301 items[self.Columns.NODE_ALIAS].setFont(QFont(MONOSPACE_FONT)) 302 items[self.Columns.LOCAL_BALANCE].setFont(QFont(MONOSPACE_FONT)) 303 items[self.Columns.REMOTE_BALANCE].setFont(QFont(MONOSPACE_FONT)) 304 items[self.Columns.FEATURES].setData(ChannelFeatureIcons.from_channel(chan), self.ROLE_CUSTOM_PAINT) 305 items[self.Columns.CAPACITY].setFont(QFont(MONOSPACE_FONT)) 306 self._update_chan_frozen_bg(chan=chan, items=items) 307 self.model().insertRow(0, items) 308 309 self.sortByColumn(self.Columns.SHORT_CHANID, Qt.DescendingOrder) 310 311 def _update_chan_frozen_bg(self, *, chan: AbstractChannel, items: Sequence[QStandardItem]): 312 assert self._default_item_bg_brush is not None 313 # frozen for sending 314 item = items[self.Columns.LOCAL_BALANCE] 315 if chan.is_frozen_for_sending(): 316 item.setBackground(ColorScheme.BLUE.as_color(True)) 317 item.setToolTip(_("This channel is frozen for sending. It will not be used for outgoing payments.")) 318 else: 319 item.setBackground(self._default_item_bg_brush) 320 item.setToolTip("") 321 # frozen for receiving 322 item = items[self.Columns.REMOTE_BALANCE] 323 if chan.is_frozen_for_receiving(): 324 item.setBackground(ColorScheme.BLUE.as_color(True)) 325 item.setToolTip(_("This channel is frozen for receiving. It will not be included in invoices.")) 326 else: 327 item.setBackground(self._default_item_bg_brush) 328 item.setToolTip("") 329 330 def update_can_send(self, lnworker: LNWallet): 331 msg = _('Can send') + ' ' + self.parent.format_amount(lnworker.num_sats_can_send())\ 332 + ' ' + self.parent.base_unit() + '; '\ 333 + _('can receive') + ' ' + self.parent.format_amount(lnworker.num_sats_can_receive())\ 334 + ' ' + self.parent.base_unit() 335 self.can_send_label.setText(msg) 336 self.update_swap_button(lnworker) 337 338 def update_swap_button(self, lnworker: LNWallet): 339 if lnworker.num_sats_can_send() or lnworker.num_sats_can_receive(): 340 self.swap_button.setEnabled(True) 341 else: 342 self.swap_button.setEnabled(False) 343 344 def get_toolbar(self): 345 h = QHBoxLayout() 346 self.can_send_label = QLabel('') 347 h.addWidget(self.can_send_label) 348 h.addStretch() 349 self.swap_button = EnterButton(_('Swap'), self.swap_dialog) 350 self.swap_button.setToolTip("Have at least one channel to do swaps.") 351 self.swap_button.setDisabled(True) 352 self.new_channel_button = EnterButton(_('Open Channel'), self.new_channel_with_warning) 353 self.new_channel_button.setEnabled(self.parent.wallet.has_lightning()) 354 h.addWidget(self.new_channel_button) 355 h.addWidget(self.swap_button) 356 return h 357 358 def new_channel_with_warning(self): 359 lnworker = self.parent.wallet.lnworker 360 if not lnworker.channels and not lnworker.channel_backups: 361 warning = _(messages.MSG_LIGHTNING_WARNING) 362 answer = self.parent.question( 363 _('Do you want to create your first channel?') + '\n\n' + warning) 364 if answer: 365 self.new_channel_dialog() 366 else: 367 self.new_channel_dialog() 368 369 def statistics_dialog(self): 370 channel_db = self.parent.network.channel_db 371 capacity = self.parent.format_amount(channel_db.capacity()) + ' '+ self.parent.base_unit() 372 d = WindowModalDialog(self.parent, _('Lightning Network Statistics')) 373 d.setMinimumWidth(400) 374 vbox = QVBoxLayout(d) 375 h = QGridLayout() 376 h.addWidget(QLabel(_('Nodes') + ':'), 0, 0) 377 h.addWidget(QLabel('{}'.format(channel_db.num_nodes)), 0, 1) 378 h.addWidget(QLabel(_('Channels') + ':'), 1, 0) 379 h.addWidget(QLabel('{}'.format(channel_db.num_channels)), 1, 1) 380 h.addWidget(QLabel(_('Capacity') + ':'), 2, 0) 381 h.addWidget(QLabel(capacity), 2, 1) 382 vbox.addLayout(h) 383 vbox.addLayout(Buttons(OkButton(d))) 384 d.exec_() 385 386 def new_channel_dialog(self): 387 lnworker = self.parent.wallet.lnworker 388 d = WindowModalDialog(self.parent, _('Open Channel')) 389 vbox = QVBoxLayout(d) 390 if self.parent.network.channel_db: 391 vbox.addWidget(QLabel(_('Enter Remote Node ID or connection string or invoice'))) 392 remote_nodeid = QLineEdit() 393 remote_nodeid.setMinimumWidth(700) 394 suggest_button = QPushButton(d, text=_('Suggest Peer')) 395 def on_suggest(): 396 self.parent.wallet.network.start_gossip() 397 nodeid = bh2u(lnworker.suggest_peer() or b'') 398 if not nodeid: 399 remote_nodeid.setText("") 400 remote_nodeid.setPlaceholderText( 401 "Please wait until the graph is synchronized to 30%, and then try again.") 402 else: 403 remote_nodeid.setText(nodeid) 404 remote_nodeid.repaint() # macOS hack for #6269 405 suggest_button.clicked.connect(on_suggest) 406 else: 407 from electrum.lnworker import hardcoded_trampoline_nodes 408 vbox.addWidget(QLabel(_('Choose a trampoline node to open a channel with'))) 409 trampolines = hardcoded_trampoline_nodes() 410 trampoline_names = list(trampolines.keys()) 411 trampoline_combo = QComboBox() 412 trampoline_combo.addItems(trampoline_names) 413 trampoline_combo.setCurrentIndex(1) 414 415 amount_e = BTCAmountEdit(self.parent.get_decimal_point) 416 # max button 417 def spend_max(): 418 amount_e.setFrozen(max_button.isChecked()) 419 if not max_button.isChecked(): 420 return 421 dummy_nodeid = ecc.GENERATOR.get_public_key_bytes(compressed=True) 422 make_tx = self.parent.mktx_for_open_channel(funding_sat='!', node_id=dummy_nodeid) 423 try: 424 tx = make_tx(None) 425 except (NotEnoughFunds, NoDynamicFeeEstimates) as e: 426 max_button.setChecked(False) 427 amount_e.setFrozen(False) 428 self.main_window.show_error(str(e)) 429 return 430 amount = tx.output_value() 431 amount = min(amount, LN_MAX_FUNDING_SAT) 432 amount_e.setAmount(amount) 433 max_button = EnterButton(_("Max"), spend_max) 434 max_button.setFixedWidth(100) 435 max_button.setCheckable(True) 436 437 clear_button = QPushButton(d, text=_('Clear')) 438 def on_clear(): 439 amount_e.setText('') 440 amount_e.setFrozen(False) 441 amount_e.repaint() # macOS hack for #6269 442 if self.parent.network.channel_db: 443 remote_nodeid.setText('') 444 remote_nodeid.repaint() # macOS hack for #6269 445 max_button.setChecked(False) 446 max_button.repaint() # macOS hack for #6269 447 clear_button.clicked.connect(on_clear) 448 clear_button.setFixedWidth(100) 449 h = QGridLayout() 450 if self.parent.network.channel_db: 451 h.addWidget(QLabel(_('Remote Node ID')), 0, 0) 452 h.addWidget(remote_nodeid, 0, 1, 1, 4) 453 h.addWidget(suggest_button, 0, 5) 454 else: 455 h.addWidget(QLabel(_('Trampoline')), 0, 0) 456 h.addWidget(trampoline_combo, 0, 1, 1, 4) 457 458 h.addWidget(QLabel('Amount'), 2, 0) 459 h.addWidget(amount_e, 2, 1) 460 h.addWidget(max_button, 2, 2) 461 h.addWidget(clear_button, 2, 3) 462 vbox.addLayout(h) 463 vbox.addStretch() 464 ok_button = OkButton(d) 465 ok_button.setDefault(True) 466 vbox.addLayout(Buttons(CancelButton(d), ok_button)) 467 if not d.exec_(): 468 return 469 if max_button.isChecked() and amount_e.get_amount() < LN_MAX_FUNDING_SAT: 470 # if 'max' enabled and amount is strictly less than max allowed, 471 # that means we have fewer coins than max allowed, and hence we can 472 # spend all coins 473 funding_sat = '!' 474 else: 475 funding_sat = amount_e.get_amount() 476 if self.parent.network.channel_db: 477 connect_str = str(remote_nodeid.text()).strip() 478 else: 479 name = trampoline_names[trampoline_combo.currentIndex()] 480 connect_str = str(trampolines[name]) 481 if not connect_str or not funding_sat: 482 return 483 self.parent.open_channel(connect_str, funding_sat, 0) 484 485 def swap_dialog(self): 486 from .swap_dialog import SwapDialog 487 d = SwapDialog(self.parent) 488 d.run() 489 490 491class ChannelFeature(ABC): 492 def __init__(self): 493 self.rect = QRect() 494 495 @abstractmethod 496 def tooltip(self) -> str: 497 pass 498 499 @abstractmethod 500 def icon(self) -> QIcon: 501 pass 502 503 504class ChanFeatChannel(ChannelFeature): 505 def tooltip(self) -> str: 506 return _("This is a channel") 507 def icon(self) -> QIcon: 508 return read_QIcon("lightning") 509 510 511class ChanFeatBackup(ChannelFeature): 512 def tooltip(self) -> str: 513 return _("This is a static channel backup") 514 def icon(self) -> QIcon: 515 return read_QIcon("lightning_disconnected") 516 517 518class ChanFeatTrampoline(ChannelFeature): 519 def tooltip(self) -> str: 520 return _("The channel peer can route Trampoline payments.") 521 def icon(self) -> QIcon: 522 return read_QIcon("kangaroo") 523 524 525class ChanFeatNoOnchainBackup(ChannelFeature): 526 def tooltip(self) -> str: 527 return _("This channel cannot be recovered from your seed. You must back it up manually.") 528 def icon(self) -> QIcon: 529 return read_QIcon("nocloud") 530 531 532class ChannelFeatureIcons: 533 ICON_SIZE = QSize(16, 16) 534 535 def __init__(self, features: Sequence['ChannelFeature']): 536 self.features = features 537 538 @classmethod 539 def from_channel(cls, chan: AbstractChannel) -> 'ChannelFeatureIcons': 540 feats = [] 541 if chan.is_backup(): 542 feats.append(ChanFeatBackup()) 543 if chan.is_imported: 544 feats.append(ChanFeatNoOnchainBackup()) 545 else: 546 feats.append(ChanFeatChannel()) 547 if chan.lnworker.is_trampoline_peer(chan.node_id): 548 feats.append(ChanFeatTrampoline()) 549 if not chan.has_onchain_backup(): 550 feats.append(ChanFeatNoOnchainBackup()) 551 return ChannelFeatureIcons(feats) 552 553 def paint(self, painter: QPainter, rect: QRect) -> None: 554 painter.save() 555 cur_x = rect.x() 556 for feat in self.features: 557 icon_rect = QRect(cur_x, rect.y(), self.ICON_SIZE.width(), self.ICON_SIZE.height()) 558 feat.rect = icon_rect 559 if rect.contains(icon_rect): # stay inside parent 560 painter.drawPixmap(icon_rect, feat.icon().pixmap(self.ICON_SIZE)) 561 cur_x += self.ICON_SIZE.width() + 1 562 painter.restore() 563 564 def sizeHint(self, default_size: QSize) -> QSize: 565 if not self.features: 566 return default_size 567 width = len(self.features) * (self.ICON_SIZE.width() + 1) 568 return QSize(width, default_size.height()) 569 570 def show_tooltip(self, evt: QHelpEvent) -> bool: 571 assert isinstance(evt, QHelpEvent) 572 for feat in self.features: 573 if feat.rect.contains(evt.pos()): 574 QToolTip.showText(evt.globalPos(), feat.tooltip()) 575 break 576 else: 577 QToolTip.hideText() 578 evt.ignore() 579 return True 580