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.
This commit is contained in:
2026-08-01 19:11:35 -04:00
parent 30a5720ceb
commit 1dc3c79486
4 changed files with 439 additions and 23 deletions

View File

@@ -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(