From 1dc3c79486a226cf305472ae4143a78aef40fb48 Mon Sep 17 00:00:00 2001 From: svatantrya Date: Sat, 1 Aug 2026 19:11:35 -0400 Subject: [PATCH] Will list: context menu actions and will-executor bulk tools PreviewList: right-click menu with sign / details / broadcast / check / copy id / copy / merge from txn / delete, with multi-selection support and availability rules (broadcast force-repushes, delete restricted to invalid or unsigned txs, sign skips non-valid txs). WillExecutorWidget: Export, Ping All and Select All become dropdown buttons (export all/selected/valid, ping all/selected, select all/only valid, deselect all/only invalid). WillExecutorListWidget: single-cell Copy action. BalWindow: txids-scoped sign/broadcast plumbing and merge_single_transaction. --- bal/gui/qt/common.py | 3 + bal/gui/qt/lists.py | 273 +++++++++++++++++++++++++++++++++--- bal/gui/qt/window.py | 36 ++++- tests/test_gui_will_menu.py | 150 ++++++++++++++++++++ 4 files changed, 439 insertions(+), 23 deletions(-) create mode 100644 tests/test_gui_will_menu.py diff --git a/bal/gui/qt/common.py b/bal/gui/qt/common.py index c9305ed..dd6cf38 100644 --- a/bal/gui/qt/common.py +++ b/bal/gui/qt/common.py @@ -44,6 +44,7 @@ from electrum.gui.qt.util import ( TaskThread, WindowModalDialog, char_width_in_lineedit, + getOpenFileName, getSaveFileName, import_meta_gui, read_QIcon_from_bytes, @@ -78,6 +79,7 @@ from PyQt6.QtGui import QColor, QPainter, QPalette, QStandardItem, QStandardItem from PyQt6.QtWidgets import ( QAbstractItemView, QAbstractSpinBox, + QApplication, QCheckBox, QComboBox, QDateTimeEdit, @@ -97,6 +99,7 @@ from PyQt6.QtWidgets import ( QStyle, QStyleOptionFrame, QTextEdit, + QToolButton, QVBoxLayout, QWidget, ) diff --git a/bal/gui/qt/lists.py b/bal/gui/qt/lists.py index bacddd2..387251a 100644 --- a/bal/gui/qt/lists.py +++ b/bal/gui/qt/lists.py @@ -23,6 +23,48 @@ from .dialogs import BalBuildWillDialog, BalDialog from .widgets import BalCheckBox, WillSettingsWidget +def _can_sign(will_item): + """True if a will transaction may still be signed (not fully signed).""" + return bool(will_item) and not will_item.get_status("COMPLETE") + + +def _can_broadcast(will_item): + """True if a will transaction is ready to broadcast (fully signed).""" + return bool(will_item) and will_item.get_status("COMPLETE") + + +def _can_delete(will_item): + """True if a will transaction may be deleted (invalid or unsigned). + + Valid AND fully-signed transactions are never deletable: removing them + would silently drop a committed inheritance. + """ + return bool(will_item) and ( + not will_item.get_status("VALID") or not will_item.get_status("COMPLETE") + ) + + +def _apply_select_all(willexecutors, select, valid=None): + """Apply a bulk selection across a ``{url: we_dict}`` mapping. + + ``select`` is the target selection value. When ``valid`` is given (a + ``{url: bool}`` validity map), the selection is restricted by validity: + selecting sets valid ones to True and invalid ones to False, while + deselecting only clears the invalid ones (valid ones keep their state). + Without ``valid`` every entry is simply set to ``select``. The mapping is + mutated in place and returned. + """ + for url, we in willexecutors.items(): + if valid is not None: + if select: + we["selected"] = valid.get(url, False) + elif not valid.get(url, False): + we["selected"] = False + else: + we["selected"] = select + return willexecutors + + class HeirListWidget(MyTreeView, MessageBoxMixin): class Columns(MyTreeView.BaseColumnsEnum): NAME = enum.auto() @@ -308,29 +350,64 @@ class PreviewList(MyTreeView, MessageBoxMixin): selected_keys.append(sel_key) if selected_keys and idx.isValid(): column_title = self.model().horizontalHeaderItem(column).text() - # column_data = "\n".join( - # self.model().itemFromIndex(s_idx).text() - # for s_idx in self.selected_in_column(column) - # ) menu.addAction( _("details").format(column_title), lambda: self.show_transaction(selected_keys), - ).setEnabled(len(selected_keys) < 2) + ).setEnabled(len(selected_keys) == 1) + menu.addAction( + _("sign").format(column_title), + lambda: self.sign_transactions(selected_keys), + ).setEnabled( + any(_can_sign(self.will.get(k)) for k in selected_keys) + ) + menu.addAction( + _("broadcast").format(column_title), + lambda: self.broadcast_transactions(selected_keys), + ).setEnabled( + any(_can_broadcast(self.will.get(k)) for k in selected_keys) + ) menu.addAction( _("check ").format(column_title), lambda: self.check_transactions(selected_keys), ) + menu.addSeparator() + menu.addAction( + _("copy id").format(column_title), + lambda: self.copy_txids(selected_keys), + ) + menu.addAction( + _("copy").format(column_title), + lambda: self.copy_tx_hexes(selected_keys), + ) + menu.addAction( + _("merge from txn").format(column_title), + lambda: self.merge_from_txn(), + ) + menu.addSeparator() menu.addAction( _("delete").format(column_title), lambda: self.delete(selected_keys) + ).setEnabled( + any(_can_delete(self.will.get(k)) for k in selected_keys) ) menu.exec(self.viewport().mapToGlobal(position)) + def is_deletable(self, key): + """True if the will transaction ``key`` may be deleted. + + Deletion is only allowed for transactions that are NOT valid or NOT + fully signed (invalidated/replaced/mempool/confirmed items, or + unsigned/partially signed ones). Valid and complete transactions are + kept (deleting them would silently drop a committed inheritance). + """ + return _can_delete(self.will.get(key)) + def delete(self, selected_keys): - for key in selected_keys: + keys = [k for k in selected_keys if self.is_deletable(k)] + for key in keys: del self.will[key] try: del self.bal_window.willitems[key] @@ -342,6 +419,75 @@ class PreviewList(MyTreeView, MessageBoxMixin): pass self.update() + def sign_transactions(self, selected_keys): + """Sign all selected transactions that are not fully signed yet.""" + keys = [ + k for k in selected_keys + if _can_sign(self.will.get(k)) + ] + if keys: + self.bal_window.ask_password_and_sign_transactions( + callback=self.update, txids=keys + ) + + def broadcast_transactions(self, selected_keys): + """Force-broadcast all selected fully-signed transactions. + + ``force=True`` makes the will-executors re-accept transactions that + were already pushed before. + """ + keys = [ + k for k in selected_keys + if _can_broadcast(self.will.get(k)) + ] + if keys: + self.bal_window.broadcast_transactions(force=True, txids=keys) + self.update() + + def copy_txids(self, selected_keys): + """Copy the selected transaction IDs to the clipboard (one per line).""" + self.place_text_on_clipboard( + "\n".join(selected_keys), title=_("Transaction IDs") + ) + + def copy_tx_hexes(self, selected_keys): + """Copy the selected transactions (serialised hex) to the clipboard.""" + hexes = "\n".join(str(self.will[k].tx) for k in selected_keys) + self.place_text_on_clipboard(hexes, title=_("Transactions")) + + def merge_from_txn(self): + """Merge a transaction read from the clipboard, or from a file if the + clipboard does not contain a valid transaction. + """ + tx = None + try: + tx = tx_from_any(QApplication.clipboard().text()) + except Exception: + tx = None + if tx is None: + filename = getOpenFileName( + parent=self.bal_window.window, + title=_("Open transaction file"), + filter="All files (*)", + config=self.bal_window.window.config, + ) + if not filename: + return + try: + with open(filename, "r") as f: + data = f.read() + tx = tx_from_any(data) + except Exception as e: + self.bal_window.show_error( + _("Invalid transaction file: {}").format(e) + ) + return + try: + self.bal_window.merge_single_transaction(tx) + except Exception as e: + self.bal_window.show_error(str(e)) + self.update() + def check_transactions(self, selected_keys): wout = {} for k in selected_keys: @@ -778,6 +924,15 @@ class WillExecutorListWidget(MyTreeView): # self.model().itemFromIndex(s_idx).text() # for s_idx in self.selected_in_column(column) # ) + # When exactly ONE cell is selected, offer "Copy" to copy the value + # of that cell to the clipboard (e.g. a single url / address / fee). + # This list has no ``main_window``, so use QApplication directly. + if len(self.selectionModel().selectedIndexes()) == 1: + cell_value = self.model().itemFromIndex(idx).text() + menu.addAction( + _("Copy"), + lambda: QApplication.clipboard().setText(cell_value), + ) if Willexecutors.is_selected(self._bal_parent.willexecutors_list[sel_key]): menu.addAction( _("deselect").format(column_title), @@ -1026,13 +1181,44 @@ class WillExecutorWidget(QWidget, MessageBoxMixin): b.clicked.connect(self.import_file) buttonbox.addWidget(b) - b = QPushButton(_("Export")) - b.clicked.connect(self.export_file) - buttonbox.addWidget(b) + def _menu_button(label): + btn = QToolButton() + btn.setText(_(label)) + btn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) + buttonbox.addWidget(btn) + return btn - b = QPushButton(_("Ping All")) - b.clicked.connect(self.update_willexecutors) - buttonbox.addWidget(b) + export_btn = _menu_button("Export") + export_menu = QMenu(export_btn) + export_menu.addAction(_("Export all"), lambda: self.export_file()) + export_menu.addAction( + _("Export selected"), lambda: self.export_file(subset="selected") + ) + export_menu.addAction( + _("Export only valid"), lambda: self.export_file(subset="valid") + ) + export_btn.setMenu(export_menu) + + ping_btn = _menu_button("Ping All") + ping_menu = QMenu(ping_btn) + ping_menu.addAction(_("Ping all"), lambda: self.update_willexecutors()) + ping_menu.addAction( + _("Ping selected"), lambda: self.ping_selected_willexecutors() + ) + ping_btn.setMenu(ping_menu) + + select_btn = _menu_button("Select All") + select_menu = QMenu(select_btn) + select_menu.addAction(_("Select all"), lambda: self.set_select_all(True)) + select_menu.addAction( + _("Select only valid"), lambda: self.set_select_all(True, only_valid=True) + ) + select_menu.addAction(_("Deselect all"), lambda: self.set_select_all(False)) + select_menu.addAction( + _("Deselect only invalid"), + lambda: self.set_select_all(False, only_valid=True), + ) + select_btn.setMenu(select_menu) vbox.addLayout(buttonbox) # self.will_executor_list_widget.update() @@ -1245,13 +1431,68 @@ class WillExecutorWidget(QWidget, MessageBoxMixin): self.bal_window.download_list(self.bal_window.willexecutors, on_success) - def export_file(self, path): + def export_file(self, subset=None): + data = self.export_data(subset) + if subset and not data: + self.show_message(_("No will-executor matches the selected filter")) + return export_meta_gui( - self.bal_window.window, "willexecutors.json", self.export_json_file + self.bal_window.window, + "willexecutors.json", + partial(self.export_json_file, subset=subset), ) - def export_json_file(self, path): - write_json_file(path, self.willexecutors_list) + def export_data(self, subset=None): + data = self.willexecutors_list + if subset == "selected": + data = { + url: we + for url, we in data.items() + if Willexecutors.is_selected(we) + } + elif subset == "valid": + valid = self._validity() + data = { + url: we for url, we in data.items() if valid.get(url, False) + } + return data + + def export_json_file(self, path, subset=None): + write_json_file(path, self.export_data(subset)) + + def _validity(self): + max_fee = self.bal_plugin.MAX_WILLEXECUTOR_FEE.get() + dust = self.bal_window.window.wallet.dust_threshold() + return { + url: Willexecutors.is_valid(we, max_fee=max_fee, dust=dust) + for url, we in self.willexecutors_list.items() + } + + def _selected_willexecutors(self): + return { + url: we + for url, we in self.willexecutors_list.items() + if Willexecutors.is_selected(we) + } + + def set_select_all(self, select, only_valid=False): + """Apply a bulk selection across all will-executors. + + ``select=True`` selects all (or only the valid ones when + ``only_valid=True``, deselecting the invalid ones); ``select=False`` + deselects all (or only the invalid ones when ``only_valid=True``, + leaving the valid ones selected). + """ + valid = self._validity() if only_valid else None + _apply_select_all(self.willexecutors_list, select, valid) + self.save_willexecutors() + + def ping_selected_willexecutors(self): + wes = self._selected_willexecutors() + if not wes: + self.show_message(_("No will-executor is selected")) + return + self.update_willexecutors(wes) def import_file(self): import_meta_gui( diff --git a/bal/gui/qt/window.py b/bal/gui/qt/window.py index 7cd5571..7b79405 100644 --- a/bal/gui/qt/window.py +++ b/bal/gui/qt/window.py @@ -923,7 +923,7 @@ class BalWindow: ) self.waiting_dialog.exe() - def sign_transactions(self, password, will=None): + def sign_transactions(self, password, will=None, txids=None): try: willitems = will if will is not None else self.willitems txs = {} @@ -936,7 +936,14 @@ class BalWindow: msg = _(f"signed: {signed}\n") return msg + _(f"signing: {tosign}") - for txid in Will.only_valid(willitems): + if txids is not None: + targets = [ + t for t in txids + if t in willitems and willitems[t].get_status("VALID") + ] + else: + targets = Will.only_valid(willitems) + for txid in targets: wi = willitems[txid] tx = copy.deepcopy(wi.tx) if wi.get_status("COMPLETE"): @@ -1043,7 +1050,7 @@ class BalWindow: # re-wire them if this same window is reused for another wallet. self._menubar_initialized = False - def ask_password_and_sign_transactions(self, callback=None, will=None): + def ask_password_and_sign_transactions(self, callback=None, will=None, txids=None): external = will is not None willitems = will if external else self.willitems @@ -1076,14 +1083,14 @@ class BalWindow: log_error(exec_info, self.bal_window) password = self.get_wallet_password() - task = partial(self.sign_transactions, password, will=will) + task = partial(self.sign_transactions, password, will=will, txids=txids) msg = _("Signing transactions...") self.waiting_dialog = BalWaitingDialog( self, msg, task, on_success, on_failure, exe=False ) self.waiting_dialog.exe() - def broadcast_transactions(self, force=False, will=None): + def broadcast_transactions(self, force=False, will=None, txids=None): external = will is not None def on_success(sulcess): @@ -1113,15 +1120,19 @@ class BalWindow: # _logger.error("lasti:", tb.tb_lasti) # tb = tb.tb_next - task = partial(self.push_transactions_to_willexecutors, force, will=will) + task = partial(self.push_transactions_to_willexecutors, force, will=will, txids=txids) msg = _("Selecting Will-Executors") self.waiting_dialog = BalWaitingDialog( self, msg, task, on_success, on_failure, exe=False ) self.waiting_dialog.exe() - def push_transactions_to_willexecutors(self, force=False, will=None): + def push_transactions_to_willexecutors(self, force=False, will=None, txids=None): willitems = will if will is not None else self.willitems + if txids is not None: + willitems = { + t: willitems[t] for t in txids if t in willitems + } willexecutors = Willexecutors.get_willexecutor_transactions(willitems, force=force) def getMsg(willexecutors): @@ -1299,6 +1310,17 @@ class BalWindow: Will.normalize_will(willitems, self.wallet) self.merge_will(willitems) + def merge_single_transaction(self, tx): + """Merge a single raw transaction (e.g. from the clipboard or a file) + into the live will. + + The transaction is wrapped in a fresh :class:`WillItem` and merged + through :meth:`merge_will`, so existing items are combined/updated and + new transactions are added wholesale, exactly like a will-file merge. + """ + wi = WillItem({"tx": str(tx)}, _id=tx.txid(), wallet=self.wallet) + self.merge_will({wi._id: wi}) + def merge_will_ui(self): def on_success(): self.will_list_widget.update_will(self.willitems) diff --git a/tests/test_gui_will_menu.py b/tests/test_gui_will_menu.py new file mode 100644 index 0000000..2af33ea --- /dev/null +++ b/tests/test_gui_will_menu.py @@ -0,0 +1,150 @@ +""" +Tests for the Will-tab context menu and Will-Executor bulk-selection helpers +in ``bal.gui.qt.lists``. + +Covers ``_can_sign`` / ``_can_broadcast`` / ``_can_delete`` and +``_apply_select_all`` (the pure logic behind the new context-menu actions and +the "Select All" dropdown). + +Run: + QT_QPA_PLATFORM=offscreen python3 tests/test_gui_will_menu.py +""" + +import sys + +sys.path.insert(0, __file__.rsplit("/", 2)[0]) + +from bal.gui.qt.lists import ( + _apply_select_all, + _can_broadcast, + _can_delete, + _can_sign, +) + + +class FakeWillItem: + """Minimal stand-in for ``bal.core.will.WillItem`` (get_status only).""" + + def __init__(self, **status_flags): + self._status = dict(status_flags) + + def get_status(self, name): + return self._status.get(name, False) + + +def _we(selected=False): + return {"selected": selected} + + +# ------------------------------------------------------------------ # +# _can_sign / _can_broadcast +# ------------------------------------------------------------------ # + +def test_can_sign_unsigned(): + assert _can_sign(FakeWillItem()) is True + + +def test_can_sign_partially_signed(): + assert _can_sign(FakeWillItem(PARTIALLY_SIGNED=True)) is True + + +def test_can_sign_complete_false(): + assert _can_sign(FakeWillItem(COMPLETE=True)) is False + + +def test_can_sign_none_false(): + assert _can_sign(None) is False + + +def test_can_broadcast_complete(): + assert _can_broadcast(FakeWillItem(COMPLETE=True)) is True + + +def test_can_broadcast_unsigned_false(): + assert _can_broadcast(FakeWillItem()) is False + + +def test_can_broadcast_none_false(): + assert _can_broadcast(None) is False + + +# ------------------------------------------------------------------ # +# _can_delete +# ------------------------------------------------------------------ # + +def test_can_delete_invalid(): + assert _can_delete(FakeWillItem(VALID=False, COMPLETE=True)) is True + + +def test_can_delete_unsigned(): + assert _can_delete(FakeWillItem(VALID=True)) is True + + +def test_can_delete_invalid_and_unsigned(): + assert _can_delete(FakeWillItem(VALID=False)) is True + + +def test_can_delete_valid_and_complete_false(): + assert _can_delete(FakeWillItem(VALID=True, COMPLETE=True)) is False + + +def test_can_delete_none_false(): + assert _can_delete(None) is False + + +# ------------------------------------------------------------------ # +# _apply_select_all +# ------------------------------------------------------------------ # + +def test_select_all_sets_everything(): + wes = {"a": _we(False), "b": _we(False)} + _apply_select_all(wes, True) + assert wes["a"]["selected"] is True + assert wes["b"]["selected"] is True + + +def test_deselect_all_clears_everything(): + wes = {"a": _we(True), "b": _we(True)} + _apply_select_all(wes, False) + assert wes["a"]["selected"] is False + assert wes["b"]["selected"] is False + + +def test_select_only_valid(): + wes = {"a": _we(False), "b": _we(True), "c": _we(True)} + valid = {"a": True, "b": True, "c": False} + _apply_select_all(wes, True, valid) + # a, b are valid -> selected; c is invalid -> deselected + assert wes["a"]["selected"] is True + assert wes["b"]["selected"] is True + assert wes["c"]["selected"] is False + + +def test_deselect_only_invalid(): + wes = {"a": _we(True), "b": _we(True), "c": _we(True)} + valid = {"a": True, "b": False, "c": True} + _apply_select_all(wes, False, valid) + # b is invalid -> deselected; a, c are valid -> keep their state + assert wes["a"]["selected"] is True + assert wes["b"]["selected"] is False + assert wes["c"]["selected"] is True + + +def test_select_only_valid_missing_url_treated_invalid(): + wes = {"a": _we(False), "b": _we(True)} + valid = {"a": True} + _apply_select_all(wes, True, valid) + assert wes["a"]["selected"] is True + assert wes["b"]["selected"] is False + + +# ------------------------------------------------------------------ # +# Main +# ------------------------------------------------------------------ # + +if __name__ == "__main__": + for name in sorted(dir()): + if name.startswith("test_"): + globals()[name]() + print(f" [OK] {name}") + print("[OK] All will-menu tests passed")