add bal/gui

This commit is contained in:
bot
2026-06-20 09:48:56 -04:00
parent b9c00446c4
commit 574efa7539
11 changed files with 5610 additions and 0 deletions

0
bal/gui/__init__.py Normal file
View File

17
bal/gui/qt/__init__.py Normal file
View File

@@ -0,0 +1,17 @@
"""
bal.gui.qt
==========
The PyQt6 graphical interface of the Bitcoin After Life plugin.
Module map (was previously one 4000-line ``qt.py``):
common.py - shared imports + tiny helpers (shown_cv, add_widget, ...)
theme.py - colour mapping for will-item statuses (was WillItem.get_color)
calendar.py - .ics calendar generation
widgets.py - reusable leaf widgets (editors, checkboxes, will box, ...)
dialogs.py - all dialogs (settings, wizard, build-will, detail, ...)
lists.py - tree views (heirs, preview, will-executors)
window.py - BalWindow controller (one per wallet window)
plugin.py - Plugin class with the Electrum @hook methods (entry point)
"""

160
bal/gui/qt/calendar.py Normal file
View File

@@ -0,0 +1,160 @@
"""
bal.gui.qt.calendar
===================
iCalendar (.ics) generation and "open with default calendar app" helper.
When a will is built, the plugin can create a calendar event reminding the user
to "check in" before the locktime expires. This module turns the event data
into an RFC-5545 .ics file and opens it with the OS default application.
"""
from .common import *
from .common import _, _logger # underscore names are not re-exported by "import *"
class BalCalendar:
@staticmethod
def write_temp_ics(content):
fd, path = tempfile.mkstemp(prefix="event_", suffix=".ics")
with os.fdopen(fd, "wb") as f:
f.write(content.encode("utf-8"))
return path
@staticmethod
def open_with_default_app(calendar_app, path):
_logger.debug("opening calendar app")
try:
subprocess.check_call([calendar_app, path])
return True
except Exception as e:
_logger.error(f"starting calendar app {e}")
return False
@staticmethod
def format_time(time):
return time.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
#return time.astimezone(timezone.utc).strftime("%Y%m%d")
@staticmethod
def compute_event_timestamp(will_settings_locktime, min_valid_tx_locktime):
"""Return the UNIX timestamp the calendar event should start at.
The reminder must fire at the EARLIEST moment the inheritance could be
executed, i.e. the lower value between:
* ``will_settings_locktime`` - the deadline configured in the will
settings, and
* ``min_valid_tx_locktime`` - the locktime of the valid will
transaction that expires first (``None`` when there is no valid
transaction yet).
When there is no valid transaction the will-settings locktime is used.
"""
ws = int(will_settings_locktime)
if min_valid_tx_locktime is None:
return ws
return min(ws, int(min_valid_tx_locktime))
@staticmethod
def build_alarms(num_alarms, alarm_start, alarm_end):
"""Build RFC-5545 ``VALARM`` blocks spread before the deadline.
The window between ``alarm_start`` (the later of "now" and the
check-alive threshold) and ``alarm_end`` (the deadline / event time) is
divided into ``num_alarms + 1`` equal slices; an alarm is placed at each
of the first ``num_alarms`` division points. Each alarm uses a
``TRIGGER;RELATED=END`` negative duration so it fires *before* the event
end, which every major online calendar (Google, Apple, Outlook)
understands.
Args:
num_alarms: how many reminders to create (0 -> no alarms).
alarm_start: ``datetime`` when reminders should start being useful.
alarm_end: ``datetime`` of the deadline (the event end).
Returns:
A list of .ics lines (``BEGIN:VALARM`` ... ``END:VALARM`` blocks).
"""
if num_alarms < 1:
return []
total_seconds = max(1, int((alarm_end - alarm_start).total_seconds()))
# Divide the total time by (num_alarms + 1) and place alarms on the
# first num_alarms division points (measured back from the deadline).
interval = total_seconds // (num_alarms + 1)
lines = []
for i in range(1, num_alarms + 1):
# Offset measured from the END (deadline): the closest alarm to the
# deadline is the smallest offset.
offset_seconds = interval * (num_alarms + 1 - i)
trigger = BalCalendar._duration_trigger(offset_seconds)
lines.extend([
"BEGIN:VALARM",
f"TRIGGER;RELATED=END:-{trigger}",
"ACTION:DISPLAY",
"DESCRIPTION:Bitcoin After Life reminder",
"END:VALARM",
])
return lines
@staticmethod
def _duration_trigger(offset_seconds):
"""Format ``offset_seconds`` as an RFC-5545 duration (e.g. ``P3D``).
Uses the coarsest sensible unit so the value stays readable and within
what calendar clients expect: days (``P<n>D``), hours (``PT<n>H``),
minutes (``PT<n>M``) or seconds (``PT<n>S``).
"""
if offset_seconds <= 0:
return "PT0S"
if offset_seconds >= 86400:
return f"P{offset_seconds // 86400}D"
if offset_seconds >= 3600:
return f"PT{offset_seconds // 3600}H"
if offset_seconds >= 60:
return f"PT{offset_seconds // 60}M"
return f"PT{offset_seconds}S"
@staticmethod
def ical_escape(text: str) -> str:
# Escape special characters per RFC 5545: backslash, ";", ",", newlines.
text = text.encode("utf-8")
text = (
text.replace(b"\\", b"\\\\")
.replace(b";", b"\\;")
.replace(b",", b"\\,")
)
out =""
temp=text.split(b"\r\n")
for s in temp:
encoded= s
cut =0
while len(encoded) >75:
cut+=5
encoded=f"{s[:len(s)-cut]}"
if encoded[-1]==b"\\" and encoded[-2]!=b"\\\\":
cut += 1
encoded=f"{s[:len(s)-cut]}"
encoded=f"{encoded}...\r\n".encode("utf-8")
if cut>0:
out+=str(f"{s[:len(s)-cut].decode()}...\r\n")
else:
out+=str(f"{s.decode()}\r\n")
return out[:-2]
@staticmethod
def fold_ical_line(line: str, limit: int = 75) -> str:
# Return the line folded per RFC 5545: split into CRLF-separated chunks
# with a leading space on every continuation line.
encoded = line.encode("utf-8")
parts = []
while len(encoded) > limit:
# Cut without splitting a multi-byte UTF-8 character.
cut = limit
while (encoded[cut] & 0xC0) == 0x80: # UTF-8 continuation byte
cut -= 1
parts.append(encoded[:cut].decode("utf-8"))
encoded = encoded[cut:]
parts.append(encoded.decode("utf-8"))
return "\r\n ".join(parts)

173
bal/gui/qt/common.py Normal file
View File

@@ -0,0 +1,173 @@
"""
bal.gui.qt.common
=================
Shared imports and tiny helper utilities for the Qt GUI layer.
Every other ``bal.gui.qt`` module does ``from .common import *`` so that the
long list of Electrum / PyQt6 imports lives in a single place. This file also
hosts a few GUI helpers that do not deserve a module of their own:
* :class:`shown_cv` - trivial mutable "is this tab shown?" holder.
* :func:`add_widget` - add a labelled widget (plus optional help) to a grid.
* :func:`log_error` - format an exception traceback for a dialog.
* :func:`export_meta_gui` - export plugin metadata to a JSON file.
* :class:`CheckAliveError`- raised when the "check alive" date is in the past.
"""
import copy
import enum
import os
import subprocess
import tempfile
import time
import traceback
from datetime import datetime, timezone
from decimal import Decimal
from functools import partial
from typing import Any, Callable, Mapping, Optional, Union
from electrum.bitcoin import (NLOCKTIME_MAX,
NLOCKTIME_MIN)
from electrum.gui.qt.amountedit import BTCAmountEdit
from electrum.gui.qt.main_window import ElectrumWindow, StatusBarButton
from electrum.gui.qt.my_treeview import MyTreeView
from electrum.gui.qt.password_dialog import PasswordDialog
from electrum.gui.qt.transaction_dialog import TxDialog
from electrum.gui.qt.util import (Buttons, CancelButton, ColorScheme,
EnterButton, HelpButton, MessageBoxMixin,
OkButton, TaskThread, WindowModalDialog,
char_width_in_lineedit, getSaveFileName,
import_meta_gui, read_QIcon_from_bytes,
read_QPixmap_from_bytes)
from electrum.i18n import _
from electrum.logging import get_logger
from electrum.network import BestEffortRequestFailed, Network, TxBroadcastError
from electrum.payment_identifier import PaymentIdentifier
from electrum.plugin import hook
from electrum.transaction import SerializationError, Transaction, tx_from_any
from electrum.util import (DECIMAL_POINT, FileExportFailed, UserCancelled,
decimal_point_to_base_unit_name, read_json_file,
write_json_file)
from PyQt6.QtCore import (QDateTime, QModelIndex, QPersistentModelIndex, QSize,
Qt, QTimer, pyqtSignal)
from PyQt6.QtGui import (QColor, QPainter, QPalette, QStandardItem,
QStandardItemModel)
from PyQt6.QtWidgets import (QAbstractItemView, QAbstractSpinBox, QCheckBox,
QComboBox, QDateTimeEdit, QGridLayout, QHBoxLayout,
QLabel, QLineEdit, QTextEdit, QMenu, QMenuBar,
QPushButton, QScrollArea, QSizePolicy, QSpinBox,
QStackedWidget, QStyle, QStyleOptionFrame,
QVBoxLayout, QWidget, QDialog)
# --- Core (GUI-free) logic layer ---
from ...core.plugin_base import BalPlugin, BalTimestamp
from ...core.heirs import HEIR_DUST_AMOUNT, HEIR_REAL_AMOUNT, Heirs
from ...core.util import Util
from ...core.will import (AmountException, HeirChangeException,
HeirNotFoundException, NoHeirsException,
NotCompleteWillException, NoWillExecutorNotPresent,
TxFeesChangedException, Will,
WillexecutorChangeException, WillExecutorNotPresent,
WillExpiredException, WillItem, WillPostponedException)
from ...core.willexecutors import Willexecutors
# --- Presentation helpers ---
from .theme import server_status_text, server_status_tooltip, status_color
from .window_utils import (bring_to_front, show_modal, show_on_top,
stop_thread, top_level_of)
_logger = get_logger(__name__)
class shown_cv:
_type = bool
def __init__(self, value):
self.value = value
def get(self):
return self.value
def set(self, value):
self.value = value
def add_widget(grid, label, widget, row, help_):
grid.addWidget(QLabel(_(label)), row, 0)
grid.addWidget(widget, row, 1)
grid.addWidget(HelpButton(help_), row, 2)
class CheckAliveError(Exception):
def __init__(self, timestamp_to_check):
self.timestamp_to_check = timestamp_to_check
def __str__(self):
return "Check alive expired please update it: {}".format(
datetime.fromtimestamp(self.timestamp_to_check).isoformat()
)
def log_error(exec_info, window=None):
"""Log an error and optionally show it.
``exec_info`` may be either a ``sys.exc_info()`` triple
``(type, value, traceback)`` or a single exception instance (callers use
both forms), so we handle both and always try to log a full traceback.
"""
_logger.error(f"LOG_ERROR: {exec_info}")
exc = None
if isinstance(exec_info, BaseException):
exc = exec_info
elif isinstance(exec_info, (tuple, list)) and len(exec_info) >= 2:
# sys.exc_info() form: the exception instance is the 2nd element.
exc = exec_info[1]
try:
if exc is not None:
_logger.error(
"".join(
traceback.format_exception(type(exc), exc, exc.__traceback__)
)
)
else:
_logger.error(traceback.format_exc())
except Exception:
_logger.error(traceback.format_exc())
if window is not None:
# show_error expects a human-readable message, not a triple.
window.show_error(str(exc) if exc is not None else str(exec_info))
def export_meta_gui(electrum_window, title, exporter):
filter_ = "All files (*)"
filename = getSaveFileName(
parent=electrum_window,
title=_("Select file to save your {}".format(title)),
filename="BALplugin_{}_{}_{}".format(
BalPlugin.chainname, str(electrum_window.wallet), title
),
filter=filter_,
config=electrum_window.config,
)
if not filename:
return
try:
exporter(filename)
except FileExportFailed as e:
electrum_window.show_critical(str(e))
else:
electrum_window.show_message(
_("Your {0} were exported to '{1}'".format(title, str(filename)))
)

1355
bal/gui/qt/dialogs.py Normal file

File diff suppressed because it is too large Load Diff

995
bal/gui/qt/lists.py Normal file
View File

@@ -0,0 +1,995 @@
"""
bal.gui.qt.lists
================
Tree/list views (subclasses of Electrum's ``MyTreeView``) and their toolbars.
* HeirListWidget - editable list of heirs (address / amount / locktime).
* PreviewList - preview of the will transactions before signing.
* WillExecutorListWidget- list of will-executor servers.
* WillExecutorWidget - container combining the list with add/import buttons.
These views call back into the :class:`BalWindow` controller (passed at
construction) for all business actions, so the heavy logic stays in ``window``
and ``dialogs``.
"""
from .common import *
from .common import _, _logger # underscore names are not re-exported by "import *"
from .widgets import BalCheckBox, PercAmountEdit, WillSettingsWidget
from .dialogs import BalBuildWillDialog
class HeirListWidget(MyTreeView, MessageBoxMixin):
class Columns(MyTreeView.BaseColumnsEnum):
NAME = enum.auto()
ADDRESS = enum.auto()
AMOUNT = enum.auto()
headers = {
Columns.NAME: _("Name"),
Columns.ADDRESS: _("Address"),
Columns.AMOUNT: _("Amount"),
}
filter_columns = [Columns.NAME, Columns.ADDRESS]
ROLE_SORT_ORDER = Qt.ItemDataRole.UserRole + 1000
ROLE_HEIR_KEY = Qt.ItemDataRole.UserRole + 4000
key_role = ROLE_HEIR_KEY
def createEditor(self, parent, option, index):
return QLineEdit(parent)
def setEditorData(self, editor, index):
editor.setText(index.data())
def setModelData(self, editor, model, index):
model.setData(index, editor.text())
def __init__(self, bal_window: "BalWindow", parent):
super().__init__(
parent=parent,
main_window=bal_window.window,
stretch_column=self.Columns.NAME,
editable_columns=[
self.Columns.NAME,
self.Columns.ADDRESS,
self.Columns.AMOUNT,
],
)
self.decimal_point = bal_window.window.get_decimal_point()
self.bal_window = bal_window
try:
self.setModel(QStandardItemModel(self))
self.sortByColumn(self.Columns.NAME, Qt.SortOrder.AscendingOrder)
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
except Exception:
pass
self.setSortingEnabled(True)
self.std_model = self.model()
self.update()
def on_activated(self, idx):
self.on_double_click(idx)
def on_double_click(self, idx):
edit_key = self.get_edit_key_from_coordinate(idx.row(), idx.column())
self.bal_window.heirs.get(edit_key)
self.bal_window.new_heir_dialog(edit_key)
def on_edited(self, idx, edit_key, *, text):
original = prior_name = self.bal_window.heirs.get(edit_key)
if not prior_name:
return
col = idx.column()
try:
if col == 2:
text = Util.encode_amount(text, self.decimal_point)
elif col == 0:
self.bal_window.delete_heirs([edit_key])
edit_key = text
prior_name[col - 1] = text
prior_name.insert(0, edit_key)
prior_name = tuple(prior_name)
except Exception:
prior_name = (
(edit_key,) + prior_name[: col - 1] + (text,) + prior_name[col:]
)
try:
self.bal_window.set_heir(prior_name)
except Exception:
pass
try:
self.bal_window.set_heir((edit_key,) + original)
except Exception:
self.update()
def delete_heirs(self, selected_keys):
self.bal_window.delete_heirs(selected_keys)
self.update()
def create_menu(self, position):
menu = QMenu()
idx = self.indexAt(position)
column = idx.column() or self.Columns.NAME
selected_keys = []
for s_idx in self.selected_in_column(self.Columns.NAME):
sel_key = self.model().itemFromIndex(s_idx).data(0)
selected_keys.append(sel_key)
if selected_keys and idx.isValid():
column_title = self.model().horizontalHeaderItem(column).text()
# ok
column_data = "\n".join(
self.model().itemFromIndex(s_idx).text()
for s_idx in self.selected_in_column(column)
)
menu.addAction(
_("Copy {}").format(column_title),
lambda: self.place_text_on_clipboard(column_data, title=column_title),
)
if column in self.editable_columns:
item = self.model().itemFromIndex(idx)
if item.isEditable():
persistent = QPersistentModelIndex(idx)
menu.addAction(
_("Edit {}").format(column_title),
lambda p=persistent: self.edit(QModelIndex(p)),
)
menu.addAction(_("Delete"), lambda: self.delete_heirs(selected_keys))
menu.exec(self.viewport().mapToGlobal(position))
def update(self):
current_key = self.get_role_data_for_current_item(
col=self.Columns.NAME, role=self.ROLE_HEIR_KEY
)
self.model().clear()
self.update_headers(self.__class__.headers)
set_current = None
for key in sorted(self.bal_window.heirs.keys()):
heir = self.bal_window.heirs[key]
labels = [""] * len(self.Columns)
labels[self.Columns.NAME] = key
labels[self.Columns.ADDRESS] = heir[0]
labels[self.Columns.AMOUNT] = Util.decode_amount(
heir[1], self.decimal_point
)
items = [QStandardItem(x) for x in labels]
items[self.Columns.NAME].setEditable(True)
items[self.Columns.ADDRESS].setEditable(True)
items[self.Columns.AMOUNT].setEditable(True)
items[self.Columns.NAME].setData(
key, self.ROLE_HEIR_KEY + self.Columns.NAME
)
items[self.Columns.ADDRESS].setData(
key, self.ROLE_HEIR_KEY + self.Columns.ADDRESS
)
items[self.Columns.AMOUNT].setData(
key, self.ROLE_HEIR_KEY + self.Columns.AMOUNT
)
row_count = self.model().rowCount()
self.model().insertRow(row_count, items)
if key == current_key:
idx = self.model().index(row_count, self.Columns.NAME)
set_current = QPersistentModelIndex(idx)
try:
self.will_settings_widget.on_locktime_change()
except Exception as e:
pass
self.set_current_idx(set_current)
# FIXME refresh loses sort order; so set "default" here:
self.filter()
def refresh_row(self, key, row):
# nothing to update here
pass
def get_edit_key_from_coordinate(self, row, col):
a = self.get_role_data_from_coordinate(row, col, role=self.ROLE_HEIR_KEY + col)
return a
def create_toolbar(self, config):
toolbar, menu = self.create_toolbar_with_menu("")
menu.addAction(_("&New Heir"), self.bal_window.new_heir_dialog)
menu.addAction(_("Import"), self.bal_window.import_heirs)
menu.addAction(_("Export"), lambda: self.bal_window.export_heirs())
newHeirButton = QPushButton(_("New Heir"))
newHeirButton.clicked.connect(self.bal_window.new_heir_dialog)
widget = QWidget(self)
layout = QHBoxLayout(widget)
self.will_settings_widget = WillSettingsWidget(self.bal_window, self)
layout.addWidget(self.will_settings_widget)
layout.addWidget(newHeirButton)
toolbar.insertWidget(2, widget)
return toolbar
def build_transactions(self):
# will = self.bal_window.prepare_will()
self.bal_window.prepare_will()
class PreviewList(MyTreeView, MessageBoxMixin):
class Columns(MyTreeView.BaseColumnsEnum):
LOCKTIME = enum.auto()
TXID = enum.auto()
WILLEXECUTOR = enum.auto()
STATUS = enum.auto()
SERVER = enum.auto()
headers = {
Columns.LOCKTIME: _("Locktime"),
Columns.TXID: _("Txid"),
Columns.WILLEXECUTOR: _("Will-Executor"),
Columns.STATUS: _("Status"),
Columns.SERVER: _("Server"),
}
ROLE_HEIR_KEY = Qt.ItemDataRole.UserRole + 2000
key_role = ROLE_HEIR_KEY
def createEditor(self, parent, option, index):
return QLineEdit(parent)
def setEditorData(self, editor, index):
editor.setText(index.data())
def setModelData(self, editor, model, index):
model.setData(index, editor.text())
def __init__(self, bal_window: "BalWindow", parent, will):
super().__init__(
parent=parent,
main_window=bal_window.window,
stretch_column=self.Columns.TXID,
)
# self._bal_parent = parent
self.bal_window = bal_window
self.decimal_point = bal_window.window.get_decimal_point
if will is not None:
self.will = will
else:
self.will = bal_window.willitems
try:
self.setModel(QStandardItemModel(self))
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.sortByColumn(self.Columns.NAME, Qt.SortOrder.AscendingOrder)
except Exception as e:
pass
self.setSortingEnabled(True)
self.std_model = self.model()
self.update()
def on_activated(self, idx):
self.on_double_click(idx)
def on_double_click(self, idx):
idx = self.model().index(idx.row(), self.Columns.TXID)
sel_key = self.model().itemFromIndex(idx).data(0)
self.show_transaction([sel_key])
def create_menu(self, position):
menu = QMenu()
idx = self.indexAt(position)
column = idx.column() or self.Columns.TXID
selected_keys = []
for s_idx in self.selected_in_column(self.Columns.TXID):
sel_key = self.model().itemFromIndex(s_idx).data(0)
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)
menu.addAction(
_("check ").format(column_title),
lambda: self.check_transactions(selected_keys),
)
if self.bal_window.bal_plugin.ENABLE_MULTIVERSE.get():
try:
self.importaction = self.menu.addAction(
_("Import"), self.import_will
)
except Exception:
pass
menu.addSeparator()
menu.addAction(
_("delete").format(column_title), lambda: self.delete(selected_keys)
)
menu.exec(self.viewport().mapToGlobal(position))
def delete(self, selected_keys):
for key in selected_keys:
del self.will[key]
try:
del self.bal_window.willitems[key]
except Exception:
pass
try:
del self.bal_window.will[key]
except Exception:
pass
self.update()
def check_transactions(self, selected_keys):
wout = {}
for k in selected_keys:
wout[k] = self.will[k]
if wout:
self.bal_window.check_transactions(wout)
self.update()
def show_transaction(self, selected_keys):
for key in selected_keys:
self.bal_window.show_transaction(self.will[key].tx)
self.update()
def select(self, selected_keys):
self.selected += selected_keys
self.update()
def deselect(self, selected_keys):
for key in selected_keys:
self.selected.remove(key)
self.update()
def update_will(self, will):
self.will.update(will)
self.update()
def replace(self, set_current, current_key, txid, bal_tx):
if self.bal_window.bal_plugin._hide_replaced and bal_tx.get_status("REPLACED"):
return False
if self.bal_window.bal_plugin._hide_invalidated and bal_tx.get_status(
"INVALIDATED"
):
return False
if not isinstance(bal_tx, WillItem):
bal_tx = WillItem(bal_tx)
tx = bal_tx.tx
labels = [""] * len(self.Columns)
labels[self.Columns.LOCKTIME] = str(BalTimestamp(tx.locktime))
labels[self.Columns.TXID] = txid
we = "None"
if bal_tx.we:
we = bal_tx.we["url"]
labels[self.Columns.WILLEXECUTOR] = we
status = bal_tx.status
if len(bal_tx.status) > 53:
status = "...{}".format(status[-50:])
labels[self.Columns.STATUS] = status
# Dedicated, always-readable label describing whether the inheritance
# transaction is actually stored on the will-executor servers.
labels[self.Columns.SERVER] = server_status_text(bal_tx)
items = []
for e in labels:
if isinstance(e, list):
try:
items.append(QStandardItem(*e))
except Exception as e:
pass
else:
items.append(QStandardItem(str(e)))
items[-1].setBackground(QColor(status_color(bal_tx)))
# Tooltip on the Server column: shows the will-executor URL (if any)
# plus the current server state, so the user can always inspect details.
try:
items[self.Columns.SERVER].setToolTip(server_status_tooltip(bal_tx))
except Exception as tip_err:
_logger.debug(f"server tooltip error: {tip_err}")
row_count = self.model().rowCount()
self.model().insertRow(row_count, items)
if txid == current_key:
idx = self.model().index(row_count, self.Columns.TXID)
set_current = QPersistentModelIndex(idx)
self.set_current_idx(set_current)
return set_current
def update(self):
try:
self.menu.removeAction(self.importaction)
except Exception:
pass
if self.will is None:
return
current_key = self.get_role_data_for_current_item(
col=self.Columns.TXID, role=self.ROLE_HEIR_KEY
)
self.model().clear()
self.update_headers(self.__class__.headers)
set_current = None
for txid, bal_tx in self.will.items():
tmp = self.replace(set_current, current_key, txid, bal_tx)
if tmp:
set_current = tmp
self.sortByColumn(self.Columns.LOCKTIME, Qt.SortOrder.AscendingOrder)
self.setSortingEnabled(True)
try:
self.will_settings_widget.on_locktime_change()
except Exception as _e:
pass
def create_toolbar(self, config):
toolbar, menu = self.create_toolbar_with_menu("")
menu.addAction(_("Prepare"), self.build_transactions)
menu.addAction(_("Display"), self.bal_window.preview_modal_dialog)
menu.addAction(_("Sign"), self.ask_password_and_sign_transactions)
menu.addAction(_("Export"), self.export_will)
if self.bal_window.bal_plugin.ENABLE_MULTIVERSE.get():
self.importaction = menu.addAction(_("Import"), self.import_will)
menu.addAction(_("Broadcast"), self.broadcast)
menu.addAction(_("Check"), self.check)
menu.addAction(_("Invalidate"), self.invalidate_will)
# The Wizard is the main entry point to create an inheritance, so make
# it stand out: show a bold label next to a slightly larger icon (the
# plain icon-only button was too easy to overlook).
wizard = QPushButton(" " + _("Create your will"))
wizard.setIcon(
read_QIcon_from_bytes(
self.bal_window.bal_plugin.read_file("icons/wizard.png")
)
)
wizard.setIconSize(QSize(28, 28))
wizard.setMinimumHeight(40)
wizard.setStyleSheet("QPushButton{font-weight:bold;}")
# Tooltip so the button is self-explanatory when hovered.
wizard.setToolTip(_("Wizard - Build your will"))
wizard.clicked.connect(self.bal_window.init_wizard)
# display = QPushButton(_("Display"))
# display.clicked.connect(self.bal_window.preview_modal_dialog)
refresh = QPushButton()
refresh.setIcon(
read_QIcon_from_bytes(
self.bal_window.bal_plugin.read_file("icons/reload.png")
)
)
# Tooltip so the icon is self-explanatory when hovered.
refresh.setToolTip(_("Check"))
refresh.clicked.connect(self.check)
widget = QWidget(self)
hlayout = QHBoxLayout(widget)
hlayout.setContentsMargins(0, 0, 0, 0)
self.will_settings_widget = WillSettingsWidget(self.bal_window, self)
# Toolbar order (left -> right):
# Wizard | Delivery time | Check Alive | Calendar | Check (refresh)
# The Wizard button goes first (leftmost); the settings widget already
# lays out delivery/check-alive/calendar in that order internally.
hlayout.addWidget(wizard)
hlayout.addWidget(self.will_settings_widget)
hlayout.addWidget(refresh)
toolbar.insertWidget(2, widget)
self.menu = menu
self.toolbar = toolbar
return toolbar
def hide_replaced(self):
self.bal_window.bal_plugin.hide_replaced()
self.update()
def hide_invalidated(self):
self.bal_window.bal_plugin.hide_invalidated()
self.update()
def build_transactions(self):
will = self.bal_window.prepare_will()
if will:
self.update_will(will)
def export_json_file(self, path):
write_json_file(path, self.will)
def export_will(self):
self.bal_window.export_will()
self.update()
def import_will(self):
self.bal_window.import_will()
def ask_password_and_sign_transactions(self):
self.bal_window.ask_password_and_sign_transactions(callback=self.update)
def broadcast(self):
self.bal_window.broadcast_transactions()
self.update()
def check(self):
close_window = BalBuildWillDialog(self.bal_window)
close_window.build_will_task()
will = {}
for wid, w in self.bal_window.willitems.items():
# Query the will-executor server for every valid will that HAS a
# will-executor assigned and is not yet CHECKED. Previously only
# transactions already marked PUSHED were checked, so a will that
# had actually been sent in the past but whose saved status still
# read "New" (not PUSHED) was skipped and the Check button reported
# "nothing to do". Will.needs_server_check now also includes such
# non-PUSHED wills, so the server can confirm the transaction is
# present and correct the status (see set_check_willexecutor).
if Will.needs_server_check(w):
will[wid] = w
if will:
self.bal_window.check_transactions(will)
self.update()
def invalidate_will(self):
self.bal_window.invalidate_will()
self.update()
# class PreviewDialog(BalDialog, MessageBoxMixin):
# def __init__(self, bal_window, will):
# self._bal_parent = bal_window.window
# BalDialog.__init__(
# self, bal_window=bal_window, bal_plugin=bal_window.bal_plugin
# )
# self.bal_plugin = bal_window.bal_plugin
# self.gui_object = self.bal_plugin.gui_object
# self.config = self.bal_plugin.config
# self.bal_window = bal_window
# self.wallet = bal_window.window.wallet
# self.format_amount = bal_window.window.format_amount
# self.base_unit = bal_window.window.base_unit
# self.format_fiat_and_units = bal_window.window.format_fiat_and_units
# self.fx = bal_window.window.fx
# self.format_fee_rate = bal_window.window.format_fee_rate
# self.show_address = bal_window.window.show_address
# if not will:
# self.will = bal_window.willitems
# else:
# self.will = will
# self.setWindowTitle(_("Transactions Preview"))
# self.setMinimumSize(1000, 200)
# self.size_label = QLabel()
# self.transactions_list = PreviewList(self.bal_window,self, self.will)
#
# try:
# self.bal_window.init_class_variables()
# except Exception as e:
# _logger.error(f"PreviewDialog Exception: {e}")
# self.check_will()
#
# vbox = QVBoxLayout(self)
# vbox.addWidget(self.size_label)
# vbox.addWidget(self.transactions_list)
# buttonbox = QHBoxLayout()
#
# b = QPushButton(_("Sign"))
# b.clicked.connect(self.transactions_list.ask_password_and_sign_transactions)
# buttonbox.addWidget(b)
#
# b = QPushButton(_("Export Will"))
# b.clicked.connect(self.transactions_list.export_will)
# buttonbox.addWidget(b)
#
# b = QPushButton(_("Broadcast"))
# b.clicked.connect(self.transactions_list.broadcast)
# buttonbox.addWidget(b)
#
# b = QPushButton(_("Invalidate will"))
# b.clicked.connect(self.transactions_list.invalidate_will)
# buttonbox.addWidget(b)
#
# vbox.addLayout(buttonbox)
#
# self.update()
#
# def update_will(self, will):
# self.will.update(will)
# self.transactions_list.update_will(will)
# self.update()
#
# def update(self):
# self.transactions_list.update()
#
# def is_hidden(self):
# return self.isMinimized() or self.isHidden()
#
# def show_or_hide(self):
# if self.is_hidden():
# self.bring_to_top()
# else:
# self.hide()
#
# def bring_to_top(self):
# self.show()
# self.raise_()
#
# def closeEvent(self, event):
# event.accept()
class WillExecutorListWidget(MyTreeView):
class Columns(MyTreeView.BaseColumnsEnum):
SELECTED = enum.auto()
URL = enum.auto()
STATUS = enum.auto()
BASE_FEE = enum.auto()
INFO = enum.auto()
ADDRESS = enum.auto()
headers = {
Columns.SELECTED: _(""),
Columns.URL: _("Url"),
Columns.STATUS: _("S"),
Columns.BASE_FEE: _("Base fee"),
Columns.INFO: _("Info"),
Columns.ADDRESS: _("Default Address"),
}
filter_columns = [Columns.URL]
ROLE_SORT_ORDER = Qt.ItemDataRole.UserRole + 3000
ROLE_HEIR_KEY = Qt.ItemDataRole.UserRole + 3001
key_role = ROLE_HEIR_KEY
def __init__(self, parent: "WillExecutorWidget"):
super().__init__(
parent=parent,
stretch_column=self.Columns.ADDRESS,
editable_columns=[
self.Columns.URL,
self.Columns.BASE_FEE,
self.Columns.ADDRESS,
self.Columns.INFO,
],
)
self._bal_parent = parent
try:
self.setModel(QStandardItemModel(self))
self.sortByColumn(self.Columns.SELECTED, Qt.SortOrder.AscendingOrder)
self.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
except Exception:
pass
self.setSortingEnabled(True)
self.std_model = self.model()
self.config = parent.bal_plugin.config
self.get_decimal_point = parent.bal_plugin.get_decimal_point
self.update()
def create_menu(self, position):
menu = QMenu()
idx = self.indexAt(position)
column = idx.column() or self.Columns.URL
selected_keys = []
for s_idx in self.selected_in_column(self.Columns.URL):
sel_key = self.model().itemFromIndex(s_idx).data(0)
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)
# )
if Willexecutors.is_selected(self._bal_parent.willexecutors_list[sel_key]):
menu.addAction(
_("deselect").format(column_title),
lambda: self.deselect(selected_keys),
)
else:
menu.addAction(
_("select").format(column_title), lambda: self.select(selected_keys)
)
if column in self.editable_columns:
item = self.model().itemFromIndex(idx)
if item.isEditable():
persistent = QPersistentModelIndex(idx)
menu.addAction(
_("Edit {}").format(column_title),
lambda p=persistent: self.edit(QModelIndex(p)),
)
menu.addAction(
_("Ping").format(column_title),
lambda: self.ping_willexecutors(selected_keys),
)
menu.addSeparator()
menu.addAction(
_("delete").format(column_title), lambda: self.delete(selected_keys)
)
menu.exec(self.viewport().mapToGlobal(position))
def ping_willexecutors(self, selected_keys):
wout = {}
for k in selected_keys:
wout[k] = self._bal_parent.willexecutors_list[k]
self._bal_parent.update_willexecutors(wout)
self._bal_parent.save_willexecutors()
self.update()
def get_edit_key_from_coordinate(self, row, col):
role = self.ROLE_HEIR_KEY + col
a = self.get_role_data_from_coordinate(row, col, role=role)
return a
def delete(self, selected_keys):
for key in selected_keys:
del self._bal_parent.willexecutors_list[key]
self._bal_parent.save_willexecutors()
self.update()
def select(self, selected_keys):
for wid, w in self._bal_parent.willexecutors_list.items():
if wid in selected_keys:
w["selected"] = True
self._bal_parent.save_willexecutors()
self.update()
def deselect(self, selected_keys):
for wid, w in self._bal_parent.willexecutors_list.items():
if wid in selected_keys:
w["selected"] = False
self._bal_parent.save_willexecutors()
self.update()
def on_edited(self, idx, edit_key, *, text):
# prior_name = self._bal_parent.willexecutors_list[edit_key]
col = idx.column()
try:
if col == self.Columns.URL:
self._bal_parent.willexecutors_list[text] = self._bal_parent.willexecutors_list[
edit_key
]
del self._bal_parent.willexecutors_list[edit_key]
if col == self.Columns.BASE_FEE:
self._bal_parent.willexecutors_list[edit_key]["base_fee"] = (
Util.encode_amount(text, self.get_decimal_point())
)
if col == self.Columns.ADDRESS:
self._bal_parent.willexecutors_list[edit_key]["address"] = text
if col == self.Columns.INFO:
self._bal_parent.willexecutors_list[edit_key]["info"] = text
self._bal_parent.save_willexecutors()
self.update()
except Exception:
pass
def update(self):
if self._bal_parent.willexecutors_list is None:
return
try:
current_key = self.get_role_data_for_current_item(
col=self.Columns.URL, role=self.ROLE_HEIR_KEY
)
self.model().clear()
self.update_headers(self.__class__.headers)
set_current = None
for url, value in self._bal_parent.willexecutors_list.items():
labels = [""] * len(self.Columns)
labels[self.Columns.URL] = url
if Willexecutors.is_selected(value):
labels[self.Columns.SELECTED] = [
read_QIcon_from_bytes(
self._bal_parent.bal_plugin.read_file("icons/confirmed.png")
),
"",
]
else:
labels[self.Columns.SELECTED] = ""
labels[self.Columns.BASE_FEE] = Util.decode_amount(
value.get("base_fee", 0), self.get_decimal_point()
)
if str(value.get("status", 0)) == "200":
labels[self.Columns.STATUS] = [
read_QIcon_from_bytes(
self._bal_parent.bal_plugin.read_file(
"icons/status_connected.png"
)
),
"",
]
else:
labels[self.Columns.STATUS] = [
read_QIcon_from_bytes(
self._bal_parent.bal_plugin.read_file("icons/unconfirmed.png")
),
"",
]
labels[self.Columns.ADDRESS] = str(value.get("address", ""))
labels[self.Columns.INFO] = str(value.get("info", ""))
items = []
for e in labels:
if isinstance(e, list):
try:
items.append(QStandardItem(*e))
except Exception as e:
pass
else:
items.append(QStandardItem(e))
items[self.Columns.SELECTED].setEditable(False)
items[self.Columns.URL].setEditable(True)
items[self.Columns.ADDRESS].setEditable(True)
items[self.Columns.INFO].setEditable(True)
items[self.Columns.BASE_FEE].setEditable(True)
items[self.Columns.STATUS].setEditable(False)
items[self.Columns.URL].setData(
url, self.ROLE_HEIR_KEY + self.Columns.URL
)
items[self.Columns.BASE_FEE].setData(
url, self.ROLE_HEIR_KEY + self.Columns.BASE_FEE
)
items[self.Columns.INFO].setData(
url, self.ROLE_HEIR_KEY + self.Columns.INFO
)
items[self.Columns.ADDRESS].setData(
url, self.ROLE_HEIR_KEY + self.Columns.ADDRESS
)
row_count = self.model().rowCount()
self.model().insertRow(row_count, items)
if url == current_key:
idx = self.model().index(row_count, self.Columns.URL)
set_current = QPersistentModelIndex(idx)
self.set_current_idx(set_current)
self.filter()
except Exception as e:
_logger.error(f"error updating willexcutor {e}")
raise e
class WillExecutorWidget(QWidget, MessageBoxMixin):
def __init__(self, parent, bal_window, willexecutors=None):
self.bal_window = bal_window
self.bal_plugin = bal_window.bal_plugin
self._bal_parent = parent
MessageBoxMixin.__init__(self)
QWidget.__init__(self, parent)
if willexecutors:
self.willexecutors_list = willexecutors
else:
self.willexecutors_list = Willexecutors.get_willexecutors(self.bal_plugin)
self.size_label = QLabel()
self.will_executor_list_widget = WillExecutorListWidget(self)
vbox = QVBoxLayout(self)
vbox.addWidget(self.size_label)
widget = QWidget()
hbox = QHBoxLayout(widget)
hbox.addWidget(QLabel(_("Add transactions without willexecutor")))
heir_no_willexecutor = BalCheckBox(self.bal_plugin.NO_WILLEXECUTOR)
hbox.addWidget(heir_no_willexecutor)
spacer_widget = QWidget()
spacer_widget.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
)
hbox.addWidget(spacer_widget)
vbox.addWidget(widget)
vbox.addWidget(self.will_executor_list_widget)
buttonbox = QHBoxLayout()
b = QPushButton(_("Add"))
b.clicked.connect(self.add)
buttonbox.addWidget(b)
b = QPushButton(_("Download List"))
b.clicked.connect(self.download_list)
buttonbox.addWidget(b)
b = QPushButton(_("Import"))
b.clicked.connect(self.import_file)
buttonbox.addWidget(b)
b = QPushButton(_("Export"))
b.clicked.connect(self.export_file)
buttonbox.addWidget(b)
b = QPushButton(_("Ping All"))
b.clicked.connect(self.update_willexecutors)
buttonbox.addWidget(b)
vbox.addLayout(buttonbox)
# self.will_executor_list_widget.update()
def add(self):
self.willexecutors_list["http://localhost:8080"] = {
"info": "New Will Executor",
"base_fee": 0,
"status": "-1",
}
self.will_executor_list_widget.update()
def download_list(self, wes=None):
# Both this button and the wizard go through the same code path on
# BalWindow, which shows a "Downloading..." dialog (non-blocking GUI),
# tries the configured + fallback servers, logs the technical details
# and shows a simple message on failure.
def on_success(result):
self.willexecutors_list.update(result)
self.will_executor_list_widget.update()
Willexecutors.save(self.bal_window.bal_plugin, self.willexecutors_list)
self.update()
self.bal_window.download_list(self.bal_window.willexecutors, on_success)
def export_file(self, path):
export_meta_gui(
self.bal_window.window, "willexecutors.json", self.export_json_file
)
def export_json_file(self, path):
write_json_file(path, self.willexecutors_list)
def import_file(self):
import_meta_gui(
self.bal_window.window,
_("willexecutors"),
self.import_json_file,
self.willexecutors_list.update,
)
def update_willexecutors(self, wes=None):
if not wes:
wes = self.willexecutors_list
self.bal_window.ping_willexecutors(wes, self.save_willexecutors)
def import_json_file(self, path):
data = read_json_file(path)
data = self._validate(data)
self.willexecutors_list.update(data)
self.will_executor_list_widget.update()
# TODO validate willexecutor json import file
def _validate(self, data):
return data
def save_willexecutors(self, wes=None):
if not wes:
wes = self.willexecutors_list
self.willexecutors_list.update(wes)
self.will_executor_list_widget.update()
Willexecutors.save(self.bal_window.bal_plugin, self.willexecutors_list)

493
bal/gui/qt/plugin.py Normal file
View File

@@ -0,0 +1,493 @@
"""
bal.gui.qt.plugin
=================
The Qt entry point of the plugin.
:class:`Plugin` subclasses :class:`bal.core.plugin_base.BalPlugin` and adds the
Electrum ``@hook`` methods that wire the plugin into the Qt GUI (status-bar
button, Tools menu, wallet load/close, settings dialog). Electrum instantiates
this class because the package ``manifest.json`` declares ``available_for:
["qt"]`` and the loader imports ``qt.py`` (a thin shim re-exporting this class).
One :class:`bal.gui.qt.window.BalWindow` is created per top-level wallet window
and cached in ``self.bal_windows``.
"""
from electrum.gui.qt.main_window import StatusBarButton
from .common import *
from .common import _, _logger # underscore names are not re-exported by "import *"
from .common import read_QIcon_from_bytes
from .widgets import BalCheckBox, BalLineEdit, BalTextEdit
from .window import BalWindow
from .dialogs import BalDialog
def _window_key(window):
"""Return a stable, hashable identity for an Electrum top-level window.
The original code used ``window.winId`` (the *bound method*, not its
result) as a dict key. That happened to work because the same window
object yields the same bound method, but it is semantically wrong and
fragile across window re-creation / multiple wallets. ``id(window)`` is a
stable, correct identity for the lifetime of the window object.
"""
return id(window)
class Plugin(BalPlugin):
def __init__(self, parent, config, name):
_logger.info("INIT BALPLUGIN")
BalPlugin.__init__(self, parent, config, name)
self.bal_windows = {}
# Status-bar buttons, keyed by id(sb.window()). Tracking them lets us
# remove a stale button before creating a fresh one when a wallet is
# switched / Electrum is restarted, so the icon is never duplicated.
self._statusbar_buttons = {}
@hook
def init_qt(self, gui_object):
# Called when the plugin is enabled, including *hot* (while a wallet is
# already open). The original code gave up here and asked the user to
# restart Electrum; instead we fully initialise the already-open
# window(s) so the plugin works immediately.
_logger.info("HOOK bal init qt")
try:
self.gui_object = gui_object
for window in gui_object.windows:
self._setup_window(window, load_open_wallet=True)
except Exception as e:
_logger.error("Error loading plugin {}".format(e))
raise e
@staticmethod
def _close_plugins_manager_dialog():
"""Close Electrum's "Electrum Plugins" manager dialog if it is open.
This is the native Electrum ``PluginsDialog`` (a ``WindowModalDialog``);
it is not owned by this plugin, so we locate it among the application's
top-level widgets and close it. Failures are non-fatal: leaving the
dialog open is harmless, so we never propagate exceptions from here.
"""
Plugin._handle_plugins_manager_dialog(attempt=0)
@staticmethod
def _find_plugins_manager_dialogs():
"""Return the open Electrum "Electrum Plugins" manager dialog(s).
The match is intentionally permissive: when our plugin is loaded from a
zip (``electrum_external_plugins``), ``isinstance`` against the imported
``PluginsDialog`` class can fail due to differing module identities, so
we also match by class name and by window title (including the localized
title, since the user runs Electrum under a non-English locale).
"""
try:
from PyQt6.QtWidgets import QApplication
except Exception:
return []
try:
from electrum.gui.qt.plugins_dialog import PluginsDialog
except Exception:
PluginsDialog = None
app = QApplication.instance()
if app is None:
return []
# Accept both the English title and the translated one. We cannot rely
# only on _() because the dialog object may have been built with a
# different gettext binding than ours when loaded from a zip.
titles = {"Electrum Plugins"}
try:
titles.add(_("Electrum Plugins"))
except Exception:
pass
found = []
for w in app.topLevelWidgets():
try:
is_match = False
if PluginsDialog is not None and isinstance(w, PluginsDialog):
is_match = True
elif type(w).__name__ == "PluginsDialog":
is_match = True
elif w.windowTitle() in titles:
is_match = True
if not is_match:
continue
# Only count it as "open" if it is actually visible: after a
# successful close()/reject() the QDialog object still lives in
# topLevelWidgets() but becomes invisible, so filtering by
# isVisible() is what tells "still open" from "already closed".
visible = w.isVisible()
_logger.info(
"plugins manager dialog match: cls={} title={!r} "
"visible={}".format(
type(w).__name__, w.windowTitle(), visible
)
)
if visible:
found.append(w)
except Exception as e:
_logger.debug("inspecting top-level widget failed: {}".format(e))
return found
@staticmethod
def _try_dismiss_dialog(d):
"""Attempt to dismiss a (possibly modal) dialog as robustly as we can.
A ``PluginsDialog`` is opened with ``exec()`` (a nested, *application-
modal* event loop). Inside such a loop a plain ``close()`` is not
always honoured, so we also try ``reject()`` / ``done()`` which end the
modal loop directly. Any of these may fail depending on Qt state, so
each is guarded independently.
"""
try:
from PyQt6.QtWidgets import QDialog
except Exception:
QDialog = None
# 1) reject() / done(): the reliable way to end an exec() modal loop.
if QDialog is not None and isinstance(d, QDialog):
try:
d.reject()
except Exception as e:
_logger.debug("reject() failed: {}".format(e))
try:
d.done(QDialog.DialogCode.Rejected)
except Exception as e:
_logger.debug("done() failed: {}".format(e))
# 2) close(): covers non-QDialog top-levels and is a harmless extra.
try:
d.close()
except Exception as e:
_logger.debug("could not close plugins dialog: {}".format(e))
@staticmethod
def _handle_plugins_manager_dialog(attempt=0):
"""Try to auto-close the manager dialog; retry a few times.
Enabling the plugin happens while Electrum's ``PluginsDialog`` may still
be running its own modal event loop, so a single ``close()`` can be
ignored. We retry on a short schedule and, if it is still open after the
last attempt, fall back to bringing it to the front so the user notices
it and closes it themselves (it must not linger in the background).
"""
try:
from PyQt6.QtCore import QTimer
except Exception:
QTimer = None
# Schedule of retry delays (ms) measured from each call.
retry_delays = [400, 800, 1500]
dialogs = Plugin._find_plugins_manager_dialogs()
_logger.info(
"auto-close plugins dialog: attempt={} found={}".format(
attempt, len(dialogs)
)
)
for d in dialogs:
Plugin._try_dismiss_dialog(d)
# Re-check: anything still visible?
still_open = Plugin._find_plugins_manager_dialogs()
if not still_open:
_logger.info("plugins dialog closed successfully")
return
if attempt < len(retry_delays) and QTimer is not None:
QTimer.singleShot(
retry_delays[attempt],
lambda: Plugin._handle_plugins_manager_dialog(attempt + 1),
)
return
# Final fallback: we could not close it -> at least raise it to the
# front so it does not stay hidden in the background.
_logger.info(
"could not close plugins dialog after {} attempts; "
"bringing it to front".format(attempt + 1)
)
for d in still_open:
try:
d.showNormal()
d.raise_()
d.activateWindow()
except Exception as e:
_logger.debug("could not raise plugins dialog: {}".format(e))
def _setup_window(self, window, *, load_open_wallet):
"""Create the BalWindow for *window* and wire its menu (and, when
enabling hot, the already-open wallet).
This mirrors what the ``init_menubar`` + ``load_wallet`` hooks do at
normal startup, so enabling the plugin while a wallet is open no longer
requires restarting Electrum.
"""
w = self.get_window(window)
# Use Electrum's official tools_menu instead of searching the menubar
# for a menu whose *translated* title equals "&Tools" (which breaks
# under non-English locales).
tools_menu = getattr(window, "tools_menu", None)
if tools_menu is not None:
try:
w.init_menubar_tools(tools_menu)
except Exception as e:
_logger.error("init_qt: failed wiring tools menu: {}".format(e))
if load_open_wallet and getattr(window, "wallet", None):
# Replicate load_wallet() for the wallet that is already open.
try:
w.wallet = window.wallet
w.init_will()
w.willexecutors = Willexecutors.get_willexecutors(
self, update=False, bal_window=w
)
w.disable_plugin = False
w.ok = True
except Exception as e:
_logger.error("init_qt: failed initialising open wallet: {}".format(e))
return w
@hook
def create_status_bar(self, sb):
# Show the BAL icon in the status bar (bottom-right): it signals that
# the Bitcoin After Life plugin is installed and, when clicked, quickly
# opens the plugin settings (settings_dialog).
#
# NOTE: this was NOT the "condensed menu/tabs" bug under the Electrum
# logo -- that one was a Windows OverflowError (year 2038), fixed
# separately. The icon must therefore be kept.
#
# To avoid a duplicated icon on restart / wallet switch, we track the
# button by id(sb.window()) and remove the stale one before creating a
# fresh one.
_logger.info("HOOK create status bar")
key = id(sb.window())
old = self._statusbar_buttons.pop(key, None)
if old is not None:
try:
old.setParent(None)
old.deleteLater()
except Exception:
pass
b = StatusBarButton(
read_QIcon_from_bytes(self.read_file("icons/bal32x32.png")),
"Bal " + _("Bitcoin After Life"),
lambda: self.settings_dialog(sb.window()),
sb.height(),
)
sb.addPermanentWidget(b)
self._statusbar_buttons[key] = b
# When the plugin is enabled "hot" from Tools -> Plugins, Electrum keeps
# its "Electrum Plugins" manager dialog open and even calls
# bring_to_front on it. Enabling triggers reload_windows(), which
# recreates the window and therefore fires this create_status_bar hook;
# that makes this the right place to auto-close the leftover manager
# dialog (Electrum 4.7.x no longer calls the old init_qt hook).
#
# We use a QTimer so this runs *after* Electrum's own bring_to_front
# (QTimer.singleShot(100, ...)); a slightly larger delay makes our close
# win. On a normal startup no PluginsDialog is open, so the helper is a
# harmless no-op.
QTimer.singleShot(250, self._close_plugins_manager_dialog)
@hook
def init_menubar(self, window):
_logger.info("HOOK init_menubar")
w = self.get_window(window)
w.init_menubar_tools(window.tools_menu)
# Also try here: init_menubar is one of the hooks fired when Electrum
# recreates the window during a hot enable (reload_windows()), so it is
# another reliable trigger to auto-close the leftover manager dialog.
QTimer.singleShot(300, self._close_plugins_manager_dialog)
@hook
def load_wallet(self, wallet, main_window):
_logger.debug("HOOK load wallet")
w = self.get_window(main_window)
# havetoupdate = Util.fix_will_settings_tx_fees(wallet.db)
w.wallet = wallet
w.init_will()
w.willexecutors = Willexecutors.get_willexecutors(
self, update=False, bal_window=w
)
w.disable_plugin = False
w.ok = True
# load_wallet is fired on the recreated window during a hot enable too;
# use it as an extra trigger to auto-close the leftover manager dialog.
QTimer.singleShot(350, self._close_plugins_manager_dialog)
@hook
def close_wallet(self, wallet):
_logger.debug("HOOK close wallet")
# Iterate over a snapshot: on_close() may mutate the GUI/state.
for win in list(self.bal_windows.values()):
if getattr(win, "wallet", None) == wallet:
try:
win.on_close()
except Exception as e:
_logger.error("close_wallet: on_close failed: {}".format(e))
@hook
def init_keystore(self):
_logger.debug("init keystore")
@hook
def daemon_wallet_loaded(self, boh, wallet):
_logger.debug("daemon wallet loaded")
def get_window(self, window):
window = window.top_level_window()
key = _window_key(window)
w = self.bal_windows.get(key, None)
if w is None:
w = BalWindow(self, window)
self.bal_windows[key] = w
return w
def requires_settings(self):
return True
def settings_widget(self, window):
w = self.get_window(window.window)
widget = QWidget()
enterbutton = EnterButton(_("Settings"), partial(w.settings_dialog, window))
widget.setLayout(Buttons(enterbutton, widget))
return widget
def password_dialog(self, msg=None, parent=None):
parent = parent or self
d = PasswordDialog(parent, msg)
return d.run()
def get_seed(self):
password = None
if self.wallet.has_keystore_encryption():
password = self.password_dialog(parent=self.d.parent())
if not password:
raise UserCancelled()
keystore = self.wallet.get_keystore()
if not keystore or not keystore.has_seed():
return
self.extension = bool(keystore.get_passphrase(password))
return keystore.get_seed(password)
def settings_dialog(self, window=None, wallet=None):
d = BalDialog(window, self, self.get_window_title("Settings"))
d.setMinimumSize(100, 200)
qicon = read_QPixmap_from_bytes(self.read_file("icons/bal16x16.png"))
lbl_logo = QLabel()
lbl_logo.setPixmap(qicon)
# heir_ping_willexecutors = BalCheckBox(self.PING_WILLEXECUTORS)
# heir_ask_ping_willexecutors = BalCheckBox(self.ASK_PING_WILLEXECUTORS)
# heir_no_willexecutor = BalCheckBox(self.NO_WILLEXECUTOR)
def on_multiverse_change():
self.update_all()
# heir_enable_multiverse = BalCheckBox(self.ENABLE_MULTIVERSE,on_multiverse_change)
heir_hide_replaced = BalCheckBox(self.HIDE_REPLACED, on_multiverse_change)
heir_hide_invalidated = BalCheckBox(self.HIDE_INVALIDATED, on_multiverse_change)
heir_auto_sign = BalCheckBox(self.AUTO_SIGN, on_multiverse_change)
heir_repush = QPushButton("Rebroadcast transactions")
heir_repush.clicked.connect(partial(self.broadcast_transactions, True))
grid = QGridLayout(d)
heir_alarm_number = QSpinBox()
heir_alarm_number.setMinimum(0)
heir_alarm_number.setMaximum(100)
heir_alarm_number.setValue(self.ALARM_NUMBER.get())
def on_alarm_number_changed(value):
self.ALARM_NUMBER.set(value)
heir_alarm_number.valueChanged.connect(on_alarm_number_changed)
add_widget(
grid,
"Hide Replaced",
heir_hide_replaced,
1,
"Hide replaced transactions from will detail and list",
)
add_widget(
grid,
"Hide Invalidated",
heir_hide_invalidated,
2,
"Hide invalidated transactions from will detail and list",
)
add_widget(
grid,
"Auto Sign",
heir_auto_sign,
3,
"Automatically sign transactions when Check is pressed (requires password)",
)
add_widget(
grid,
"Calendar App",
BalLineEdit(self.CALENDAR_APP),
4,
"Default app used to open calendar",
)
add_widget(
grid,
"Event summary",
BalLineEdit(self.EVENT_SUMMARY),
5,
(
"Default message to be used in event summary\n"
"Variables:\n"
" $wallet_name: name of wallet\n"
" $heirs_complete: list of heirs name,address,amount\n"
)
)
add_widget(
grid,
"Event description",
BalTextEdit(self.EVENT_DESCRIPTION),
6,
(
"Default message to be used in event description\n"
"Variables:\n"
" $wallet_name: name of wallet\n"
" $heirs_complete: list of heirs name,address,amount\n"
)
)
add_widget(
grid,
"Number of alarms",
heir_alarm_number,
7,
"Number of calendar alarms before the deadline (default 3)",
)
grid.addWidget(heir_repush, 8, 0)
grid.addWidget(
HelpButton(
"Broadcast all transactions to willexecutors including those already pushed"
),
8,
2,
)
if ret := bool(show_modal(d)):
try:
self.update_all()
return ret
except Exception:
pass
return False
def broadcast_transactions(self, force):
for _k, w in self.bal_windows.items():
w.broadcast_transactions(force)
def update_all(self):
for _k, w in self.bal_windows.items():
w.update_all()
def get_window_title(self, title):
return _("BAL - ") + _(title)

97
bal/gui/qt/theme.py Normal file
View File

@@ -0,0 +1,97 @@
"""
bal.gui.qt.theme
================
Pure presentation helpers for the Qt layer.
This is where colours and other look-and-feel decisions live, kept apart from
the core inheritance logic. In particular it hosts :func:`status_color`, which
used to be ``WillItem.get_color()`` inside ``will.py``.
The status flags themselves are computed by the core layer
(:class:`bal.core.will.WillItem`); this module only translates a will item's
status into a colour for the transaction list / detail views.
"""
# Status -> hex colour. The first matching status (checked in priority order)
# wins. These are exactly the colours the original ``WillItem.get_color`` used,
# so the GUI looks identical after the refactor.
#
# The order matters: e.g. an INVALIDATED tx must show orange even if it also
# carries other flags, so INVALIDATED is checked before everything else.
_STATUS_COLOR_PRIORITY = (
("INVALIDATED", "#f87838"), # orange - tx can no longer be mined
("REPLACED", "#ff97e9"), # pink - superseded by a lower-locktime tx
("CONFIRMED", "#bfbfbf"), # grey - already mined
("PENDING", "#ffce30"), # yellow - tx is in the mempool (unconfirmed)
)
# Default colour used when no status in the priority list matches.
_DEFAULT_COLOR = "#ffffff"
def status_color(will_item) -> str:
"""Return the display colour (``"#rrggbb"``) for a :class:`WillItem`.
This is a faithful, behaviour-preserving port of the old
``WillItem.get_color()`` method. The slightly irregular handling of the
push/check states (which is not a simple priority list) is reproduced
exactly as in the original code.
"""
# First, the simple priority-ordered statuses.
for status, color in _STATUS_COLOR_PRIORITY:
if will_item.get_status(status):
return color
# The remaining states need the original branching because of the
# CHECK_FAIL / CHECKED interaction.
if will_item.get_status("CHECK_FAIL") and not will_item.get_status("CHECKED"):
return "#e83845" # red - server check failed
elif will_item.get_status("CHECKED"):
return "#8afa6c" # green - server confirmed it stored the tx
elif will_item.get_status("PUSH_FAIL"):
return "#e83845" # red - failed to push to will-executor
elif will_item.get_status("PUSHED"):
return "#73f3c8" # teal - pushed to will-executor
elif will_item.get_status("COMPLETE"):
return "#2bc8ed" # blue - signed
else:
return _DEFAULT_COLOR
def server_status_text(will_item) -> str:
"""Return a short, human-readable label describing the state of a will
item on the will-executor servers (the online inheritance backup).
This is shown in the dedicated "Server" column of the transaction list so
the user always knows whether each inheritance transaction is actually
stored on the will-executor servers, regardless of the row colour.
"""
from electrum.i18n import _
if will_item.get_status("CHECK_FAIL") and not will_item.get_status("CHECKED"):
return _("Not on server")
if will_item.get_status("CHECKED"):
return _("Confirmed on server")
if will_item.get_status("PUSH_FAIL"):
return _("Send failed")
if will_item.get_status("PUSHED"):
return _("Sent (not checked)")
if will_item.get_status("COMPLETE"):
return _("Signed (not sent)")
return _("Not sent")
def server_status_tooltip(will_item) -> str:
"""Return a detailed tooltip for the "Server" column, including the
will-executor URL (if any) and the current server state."""
from electrum.i18n import _
url = None
we = getattr(will_item, "we", None)
if we:
url = we.get("url")
state = server_status_text(will_item)
if url:
return "{}: {}\n{}".format(_("Will-Executor"), url, state)
return "{}\n{}".format(_("No will-executor"), state)

921
bal/gui/qt/widgets.py Normal file
View File

@@ -0,0 +1,921 @@
"""
bal.gui.qt.widgets
==================
Reusable, self-contained Qt widgets used to build the BAL tabs and dialogs.
These are "leaf" widgets: they receive the :class:`BalWindow` controller (and
any data they need) as constructor arguments at runtime, so this module does
not import ``window``/``dialogs`` and therefore introduces no import cycles.
Contents:
* ClickableLabel, BalLineEdit, BalTextEdit, BalCheckBox - thin Qt wrappers
* BalTxFeesWidget - fee-rate editor
* _LockTimeEditor + BalTimeEditWidget + raw/date editors - locktime editing
* ThresholdTimeWidget / LockTimeWidget - threshold & locktime
* WillSettingsWidget - the settings panel
* PercAmountEdit - amount-or-percentage editor
* WillWidget - single will-tx box
"""
from .common import *
from .common import _, _logger, Will
from .calendar import BalCalendar
class ClickableLabel(QLabel):
doubleClicked = pyqtSignal()
def mouseDoubleClickEvent(self, event):
self.doubleClicked.emit()
super().mouseDoubleClickEvent(event)
class BalTxFeesWidget(QWidget):
valueChanged = pyqtSignal()
current_value = None
def __init__(self, bal_window, parent, value=None):
super().__init__(parent)
self.bal_window = bal_window
layout = QHBoxLayout(self)
self.txfee_widget = QSpinBox(self)
self.txfee_widget.setMinimum(1)
self.txfee_widget.setMaximum(10000)
value = (
value
if value
else self.bal_window.bal_plugin.WILL_SETTINGS.get()["baltx_fees"]
)
self.set_value(value)
self.default_value = self.bal_window.bal_plugin.default_will_settings()[
"baltx_fees"
]
self.txfee_widget.valueChanged.connect(self.on_heir_tx_fees)
#label = ClickableLabel("")
#label.doubleClicked.connect(self.doubleclick)
#layout.addWidget(label)
button = HelpButton(_("mining fees expressed in sats/vbyte to be used in the Bitcoin transaction.\nHigher value ensure your transaction will be confirmed"))
button.setText("")
button.setStyleSheet("font-size: 16px;")
layout.addWidget(button)
layout.addWidget(self.txfee_widget)
# Expose the leading icon (prefix) and the editable field so the parent
# WillSettingsWidget can align them on a grid (see its vertical layout).
self.prefix_widget = button
self.field_widget = self.txfee_widget
def doubleclick(self, event=None):
pass
def set_read_only(self, read_only=True):
# Show the fee but make it non-editable (no spin arrows, no keyboard),
# so it can only be changed from the "Build your will" wizard.
self.txfee_widget.setReadOnly(read_only)
self.txfee_widget.setButtonSymbols(
QAbstractSpinBox.ButtonSymbols.NoButtons
if read_only
else QAbstractSpinBox.ButtonSymbols.UpDownArrows
)
# Light-grey background when locked, so the read-only state is visible
# (same look as the date fields); empty stylesheet restores the
# editable appearance used inside the wizard.
self.txfee_widget.setStyleSheet(
"QSpinBox{background-color:#f0f0f0;}" if read_only else ""
)
def get_value(self):
return self.txfee_widget.value()
def set_value(self, value, emit=True):
value = int(value) if value is not None else 20
if getattr(self, "_updating", False):
return
self._updating = True
try:
self.current_value = value
spin = self.txfee_widget
spin.blockSignals(True)
spin.setValue(value)
spin.blockSignals(False)
finally:
self._updating = False
if emit:
spin.valueChanged.emit(value)
def on_heir_tx_fees(self, value=None, update_all=True):
if value != self.current_value:
try:
self.set_value(value)
if update_all:
self.bal_window.update_setting_widgets(
self.get_value(), "baltx_fees", True
)
except Exception as e:
_logger.error(f"error while trying to update txfees{e}")
log_error(e)
else:
pass
class _LockTimeEditor:
min_allowed_value = NLOCKTIME_MIN
max_allowed_value = NLOCKTIME_MAX
alarm = None
def get_value(self) -> Optional[int]:
raise NotImplementedError()
def set_value(self, x: Any, force=True) -> None:
raise NotImplementedError()
@classmethod
def is_acceptable_locktime(cls, x: Any) -> bool:
if not x: # e.g. empty string
return True
try:
x = int(x)
except Exception as _e:
return False
return cls.min_allowed_value <= x <= cls.max_allowed_value
@staticmethod
def get_max_allowed_timestamp() -> int:
ts = NLOCKTIME_MAX
# Test if this value is within the valid timestamp limits (which is platform-dependent).
# see #6170
try:
datetime.fromtimestamp(ts)
except (OSError, OverflowError):
ts = 2**31 - 1 # INT32_MAX
datetime.fromtimestamp(ts) # test if raises
return ts
class BalTimeEditWidget(QWidget, _LockTimeEditor):
valueEdited = pyqtSignal()
_setting_locktime = False
current_value = None
current_index = None
default_value = None
help_text = (
"if you choose Raw, you can insert various options based on suffix:\n"
+ " - d: number of days after current day(ex: 1d means tomorrow)\n"
+ " - y: number of years after currrent day(ex: 1y means one year from today)\n"
)
label_text = None
tooltip_text = None
base_field = None
def __init__(self, bal_window, parent, default_locktime=None):
super().__init__(parent)
self.bal_window = bal_window
hbox = QHBoxLayout()
self.setLayout(hbox)
hbox.setContentsMargins(0, 0, 0, 0)
hbox.setSpacing(0)
self.setMinimumWidth(40 * char_width_in_lineedit())
self.locktime_raw_e = TimeRawEditWidget(self, time_edit=self)
self.locktime_date_e = LockTimeDateEdit(self, time_edit=self)
self.editors = [self.locktime_raw_e, self.locktime_date_e]
self.combo = QComboBox()
options = [_("Raw"), _("Date")]
self.option_index_to_editor_map = {
0: self.locktime_raw_e,
1: self.locktime_date_e,
}
self.combo.addItems(options)
default_index = 0
if not default_locktime:
default_locktime = self.bal_window.bal_plugin.WILL_SETTINGS.get()[self.base_field]
try:
int(default_locktime)
default_index = 1
except Exception:
default_index = 0
#hbox.addWidget(QLabel(self.label_text))
help_button=HelpButton(self.help_text)
help_button.setText(self.label_text)
# Show a short label (e.g. "Delivery time" / "Check Alive") when the
# user hovers the icon, so the emoji button is self-explanatory.
if self.tooltip_text:
help_button.setToolTip(_(self.tooltip_text))
#help_button.setStyleSheet("font-size: 155555);
hbox.addWidget(help_button)
# Expose the leading icon (prefix) so the parent WillSettingsWidget can
# align all rows on a common left edge (see its vertical layout).
self.prefix_widget = help_button
self.combo.currentIndexChanged.connect(self.on_current_index_changed)
for w in self.editors:
w.setVisible(False)
w.setEnabled(False)
self.editor = self.option_index_to_editor_map[default_index]
self.editor.setVisible(True)
self.editor.setEnabled(True)
self.set_index(default_index)
#self.on_current_index_changed(default_index)
self.set_value(default_locktime)
self.current_value=default_locktime
hbox.addWidget(self.combo)
for w in self.editors:
hbox.addWidget(w)
hbox.addStretch(1)
# spssscer_widget = QWidget()
# spacer_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
# hbox.addWidget(spacer_widget)
self.valueEdited.connect(lambda: self.update_will_settings(True))
self.locktime_raw_e.editingFinished.connect(self.valueEdited.emit)
self.locktime_date_e.dateTimeChanged.connect(self.valueEdited.emit)
#self.combo.currentIndexChanged.connect(self.valueEdited.emit)
def update_will_settings(
self,
update_all=False,
update_will_dialog=False,
update_heirs_dialog=False,
):
self.bal_window.update_setting_widgets(
self.get_value(),
self.base_field,
update_all,
update_will_dialog,
update_heirs_dialog,
)
def on_current_index_changed(self, i):
self.current_index = i
for w in self.editors:
w.setVisible(False)
w.setEnabled(False)
# prev_locktime = self.editor.get_value()
self.editor = self.option_index_to_editor_map[i]
if i==0:
self.editor.set_value(self.bal_window.bal_plugin.default_will_settings_relative()[self.base_field])
else:
self.editor.set_value(self.bal_window.bal_plugin.default_will_settings_absolute()[self.base_field])
self.valueEdited.emit()
# if self.editor.is_acceptable_locktime(prev_locktime):
# self.editor.set_value(prev_locktime, force=False)
self.editor.setVisible(True)
self.editor.setEnabled(True)
self.bal_window.update_combo_setting_widgets(i, self.base_field,True)
def get_value(self) -> Optional[str]:
val = self.editor.get_value()
#return self.current_value
return val
def set_index(self, index):
if self.current_index != index:
self.combo.setCurrentIndex(index)
#self.on_current_index_changed(index, force)
def set_value(
self,
x: Any,
force=None,
update_all=False,
update_will_dialog=False,
update_heirs_dialog=False,
) -> None:
if not x:
if self.current_index == 0:
x = self.bal_window.bal_plugin.default_will_settings_relative()[self.base_field]
elif self.current_index == 1:
x = self.bal_window.bal_plugin.default_will_settings_absolute()[self.base_field]
if x != self.get_value():
self.editor.set_value(x)
self.current_value = x
self.bal_window.update_setting_widgets(x, self.base_field)
def set_read_only(self, read_only=True):
"""Show the value but make it non-editable.
Used everywhere except the "Build your will" wizard, where the date is
the only place the user is allowed to change it. The Raw/Date combo is
disabled and both editors become read-only with no spin buttons.
"""
self.combo.setEnabled(not read_only)
for w in self.editors:
w.set_read_only(read_only)
class TimeRawEditWidget(QWidget):
editingFinished = pyqtSignal()
def is_acceptable_locktime(self, value):
return True
def __init__(self, parent, time_edit=None):
super().__init__(parent)
self.editor = LockTimeRawEdit(parent, time_edit)
self.label = QLabel("")
self.label.setFixedWidth(10 * char_width_in_lineedit())
self.layout = QHBoxLayout(self)
self.layout.addWidget(self.editor)
self.layout.addWidget(self.label)
self.editor.editingFinished.connect(self.editingFinished.emit)
self.get_value = self.editor.get_value
self.set_value = self.editor.set_value
def set_read_only(self, read_only=True):
self.editor.setReadOnly(read_only)
# Match the Date editor: grey background when locked so the read-only
# state is visible; empty stylesheet restores the editable look.
self.editor.setStyleSheet(
"QLineEdit{background-color:#f0f0f0;}" if read_only else ""
)
class LockTimeRawEdit(QLineEdit, _LockTimeEditor):
def __init__(self, parent=None, time_edit=None):
QLineEdit.__init__(self, parent)
self.setFixedWidth(12 * char_width_in_lineedit())
self.textChanged.connect(self.numbify)
self.isdays = False
self.isyears = False
self.time_edit = time_edit
@staticmethod
def replace_str(text):
return str(text).replace("d", "").replace("y", "")
def checkbdy(self, s, pos, appendix):
try:
charpos = pos - 1
charpos = max(0, charpos)
charpos = min(len(s) - 1, charpos)
if appendix == s[charpos]:
s = self.replace_str(s) + appendix
pos = charpos
except Exception:
pass
return pos, s
def numbify(self):
text = self.text().strip()
chars = "0123456789dy"
pos = self.cursorPosition()
pos = len("".join([i for i in text[:pos] if i in chars]))
s = "".join([i for i in text if i in chars])
self.isdays = False
self.isyears = False
self.isblocks = False
pos, s = self.checkbdy(s, pos, "d")
pos, s = self.checkbdy(s, pos, "y")
if "d" in s:
self.isdays = True
if "y" in s:
self.isyears = True
if self.isdays:
s = self.replace_str(s) + "d"
if self.isyears:
s = self.replace_str(s) + "y"
self.blockSignals(True)
self.setText(s)
self.blockSignals(False)
self.current_value = s
self.setModified(self.hasFocus())
self.setCursorPosition(pos)
def get_value(self) -> Optional[str]:
try:
return str(self.text())
except Exception:
return None
def set_value(self, x: Any, force=True) -> None:
if x != self.get_value():
self.blockSignals(True)
self.setText(str(x))
self.blockSignals(False)
self.numbify()
class LockTimeDateEdit(QDateTimeEdit, _LockTimeEditor):
min_allowed_value = 0
max_allowed_value = _LockTimeEditor.get_max_allowed_timestamp()
def __init__(self, parent=None, time_edit=None):
QDateTimeEdit.__init__(self, parent)
self.setMinimumDateTime(datetime.fromtimestamp(self.min_allowed_value))
self.setMaximumDateTime(datetime.fromtimestamp(self.max_allowed_value))
#self.setDateTime(QDateTime.currentDateTime())
self.time_edit = time_edit
def set_read_only(self, read_only=True):
# Read-only display: keyboard editing disabled and the up/down spin
# arrows removed, so the date can only be changed from the wizard.
self.setReadOnly(read_only)
self.setButtonSymbols(
QAbstractSpinBox.ButtonSymbols.NoButtons
if read_only
else QAbstractSpinBox.ButtonSymbols.UpDownArrows
)
# A read-only QDateTimeEdit keeps a white background by default, which
# does not visually signal that it is locked. Paint it light grey (like
# the disabled combo/fee fields next to it) so the user sees at a glance
# that the date is not editable here; an empty stylesheet restores the
# default look when the field is made editable again (in the wizard).
self.setStyleSheet(
"QDateTimeEdit{background-color:#f0f0f0;}" if read_only else ""
)
def get_value(self) -> Optional[int]:
#dt = self.dateTime().toPyDateTime()
#locktime = int(time.mktime(dt.timetuple()))
#p#
#dt = dt_edit.dateTime()
## QDateTimets = dt.toSecsSinceEpoch()
dt = self.dateTime()
_ts = dt.toSecsSinceEpoch()
return _ts
def set_value(self, x: Any, force=False) -> None:
if not self.is_acceptable_locktime(x):
self.setDateTime(QDateTime.currentDateTime())
return
try:
x = int(x)
except Exception as e:
x = QDateTime.currentDateTime().timestamp()
finally:
# Use the overflow-safe converter: on Windows datetime.fromtimestamp
# raises OverflowError for timestamps past 2038 (e.g. NLOCKTIME_MAX).
_dt = BalTimestamp._safe_fromtimestamp(x)
#if self.alarm != dt:
self.setDateTime(_dt)
self.alarm = _dt
class ThresholdTimeWidget(BalTimeEditWidget):
# rich_text=True is used by the HelpButton, so HTML tags (<b>, <br>) render.
help_text = (
"<b>CHECK ALIVE</b><br><br>"
"Check to ask for invalidation.<br><br>"
"When less then this time is missing, ask to invalidate.<br>"
"If you fail to invalidate during this time, your transactions will be delivered to your heirs.<br><br>"
"if you choose Raw, you can insert various options based on suffix:<br>"
" - d: number of days after current day(ex: 1d means tomorrow)<br>"
" - y: number of years after currrent day(ex: 1y means one year from today)<br>"
)
label_text = "🚨"
#label_text = "Check Alive"
tooltip_text = "Check Alive"
base_field = "threshold"
def __init__(self, bal_window, parent, init_value=None):
if init_value is None:
init_value = bal_window.bal_plugin.WILL_SETTINGS.get()["threshold"]
super().__init__(bal_window, parent, init_value)
self.default_value = self.bal_window.bal_plugin.default_will_settings()[
"threshold"
]
class LockTimeWidget(BalTimeEditWidget):
# rich_text=True is used by the HelpButton, so HTML tags (<b>, <br>) render.
help_text = (
"<b>DELIVERY TIME</b><br><br>"
"Set Locktime for transactions.<br>"
"Any time is needed transaction will be anticipated by 1day<br><br>"
"if you choose Raw, you can insert various options based on suffix:<br>"
" - d: number of days after current day(ex: 1d means tomorrow)<br>"
" - y: number of years after currrent day(ex: 1y means one year from today)<br>"
)
label_text = "🚛"
#label_text = "Locktime"
tooltip_text = "Delivery time"
base_field = "locktime"
def __init__(self, bal_window, parent, init_value=None):
if init_value is None:
init_value = bal_window.bal_plugin.WILL_SETTINGS.get()["locktime"]
super().__init__(bal_window, parent, init_value)
self.default_value = self.bal_window.bal_plugin.default_will_settings()[
"locktime"
]
class WillSettingsWidget(QWidget):
def __init__(self, bal_window: "BalWindow", parent, layout_type="h",
read_only=True):
self.widgets = {}
QWidget.__init__(self, parent)
self.bal_window = bal_window
# When read_only=True (toolbars, Heirs tab) the delivery time, check
# alive and fee fields are display-only; they can only be edited from
# the "Build your will" wizard, which passes read_only=False.
self.read_only = read_only
box = QHBoxLayout(self) if layout_type == "h" else QVBoxLayout(self)
self.calendar_button = QPushButton()
self.calendar_button.setIcon(
read_QIcon_from_bytes(
self.bal_window.bal_plugin.read_file("icons/calendar.png")
)
)
# Tooltip so the icon is self-explanatory when hovered.
self.calendar_button.setToolTip(_("Calendar"))
self.calendar_button.clicked.connect(self.open_or_save_calendar)
self.widgets["locktime"] = LockTimeWidget(bal_window, self)
self.widgets["threshold"] = ThresholdTimeWidget(bal_window, self)
self.widgets["locktime"].valueEdited.connect(self.on_locktime_change)
self.widgets["threshold"].valueEdited.connect(self.on_locktime_change)
# self.widgets['baltx_fees'].valueChange.connect(self.bal_window.update_setting_widgets)
self.on_locktime_change()
self.widgets["baltx_fees"] = BalTxFeesWidget(bal_window, self)
if not hasattr(bal_window, "txfee_widgets"):
bal_window.txfee_widgets = []
w = self.widgets["baltx_fees"]
if w not in bal_window.txfee_widgets:
bal_window.txfee_widgets.append(w)
if layout_type == "h":
box.addWidget(self.widgets["locktime"])
box.addWidget(self.widgets["threshold"])
box.addWidget(self.calendar_button)
box.addWidget(self.widgets["baltx_fees"])
else:
# Vertical layout (the "Build your will" wizard): make every row the
# same width and left aligned so they all fit in one tidy block,
# instead of letting the calendar button and the fee field stretch to
# the dialog's right edge (which made them far wider than the date
# rows above).
#
# IMPORTANT: the leading icons keep their ORIGINAL size. The icons
# are HelpButtons, which already pin themselves to a fixed width
# (2.2 * char_width_in_lineedit()); we must NOT widen them, otherwise
# they look oversized compared with the original toolbar layout. We
# only need to (1) align the calendar row's left edge with the icons'
# original width and (2) cap every row to the date-row width.
locktime_w = self.widgets["locktime"]
threshold_w = self.widgets["threshold"]
fees_w = self.widgets["baltx_fees"]
# Original icon width (HelpButton's own fixed width); used only to
# offset the calendar button so its field starts under the others.
icon_w = locktime_w.prefix_widget.sizeHint().width()
# Common row width = natural width of the date rows (the reference).
row_w = max(
locktime_w.sizeHint().width(),
threshold_w.sizeHint().width(),
)
for w in (locktime_w, threshold_w, fees_w):
w.setFixedWidth(row_w)
# The calendar row has no prefix icon: wrap it so it starts with an
# empty spacer of the icon width (calendar field aligned with the
# date/fee fields) and cap it to the same total width as the rows
# above, so it no longer stretches to the dialog's right edge.
calendar_row = QWidget(self)
calendar_box = QHBoxLayout(calendar_row)
calendar_box.setContentsMargins(0, 0, 0, 0)
calendar_box.setSpacing(0)
calendar_spacer = QWidget()
calendar_spacer.setFixedWidth(icon_w)
calendar_box.addWidget(calendar_spacer)
calendar_box.addWidget(self.calendar_button)
calendar_row.setFixedWidth(row_w)
box.addWidget(locktime_w, alignment=Qt.AlignmentFlag.AlignLeft)
box.addWidget(threshold_w, alignment=Qt.AlignmentFlag.AlignLeft)
box.addWidget(calendar_row, alignment=Qt.AlignmentFlag.AlignLeft)
box.addWidget(fees_w, alignment=Qt.AlignmentFlag.AlignLeft)
if self.read_only:
self.widgets["locktime"].set_read_only(True)
self.widgets["threshold"].set_read_only(True)
self.widgets["baltx_fees"].set_read_only(True)
def create_alarms(self, alarm_start, alarm_end):
"""Delegate to the pure :meth:`BalCalendar.build_alarms` helper.
The reminder count comes from the plugin's ``ALARM_NUMBER`` setting; the
actual VALARM generation lives in ``BalCalendar`` so it can be unit
tested without a Qt widget.
"""
num_alarms = self.bal_window.bal_plugin.ALARM_NUMBER.get()
return BalCalendar.build_alarms(num_alarms, alarm_start, alarm_end)
def open_or_save_calendar(self):
now = BalCalendar.format_time(datetime.now())
locktime = self.widgets["locktime"].alarm
threshold = self.widgets["threshold"].alarm
# Use the larger of current time and threshold as alarm start
alarm_start_dt = max(datetime.now(), threshold)
alarm_end_dt = locktime
alarm_end = BalCalendar.format_time(alarm_end_dt)
heirs_details = "\r\n".join(f" {heir} - {self.bal_window.heirs[heir][0]}, {self.bal_window.heirs[heir][1]}" for heir in self.bal_window.heirs)
event_description = BalCalendar.ical_escape(
f"{self.bal_window.bal_plugin.EVENT_DESCRIPTION.get()}".replace("$wallet_name",str(self.bal_window.wallet)).replace("$heirs_complete",heirs_details)
)
uid = f"bal-{str(self.bal_window.wallet)}"
summary = BalCalendar.ical_escape(
f"{self.bal_window.bal_plugin.EVENT_SUMMARY.get()}".replace("$wallet_name",str(self.bal_window.wallet))
)
# The event date/time is the lower value between the will-settings
# locktime (here represented by ``alarm_end_dt``) and the valid will
# transaction with the lowest locktime.
min_tx_locktime = Will.get_min_locktime(
self.bal_window.willitems, None
)
event_ts = BalCalendar.compute_event_timestamp(
alarm_end_dt.timestamp(), min_tx_locktime
)
event_dt = datetime.fromtimestamp(event_ts)
event_time = BalCalendar.format_time(event_dt)
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
f"PRODID:-//Bitcoin After Life//Electrum Plugin/{BalPlugin.__version__}",
"BEGIN:VEVENT",
f"UID:{uid}",
f"DTSTAMP:{now}",
f"DTSTART:{event_time}",
f"DTEND:{event_time}",
f"SUMMARY:{summary}",
f"DESCRIPTION:{event_description}",
]
lines.extend(self.create_alarms(alarm_start_dt, alarm_end_dt))
lines.extend([
"END:VEVENT",
"END:VCALENDAR",
])
lines = [s.rstrip("\r\n") for s in lines]
ics_content = "\r\n".join(lines) + "\r\n"
self.temp_path = BalCalendar.write_temp_ics(ics_content)
opened = BalCalendar.open_with_default_app(
self.bal_window.bal_plugin.CALENDAR_APP.get(), self.temp_path
)
if opened:
_logger.info(f"File opened with default app: {self.temp_path}")
else:
export_meta_gui(
self.bal_window.window, f"will_event.ics",self.save_to_cwd
)
def save_to_cwd(self, filename="event.ics"):
"""Copy the temporary .ics file to ``filename`` in the current dir.
Overwrites the destination file if it already exists. Returns the
absolute path of the written file.
"""
target = os.path.abspath(filename)
_logger.debug(f"save_to_cwd {self.temp_path},{filename}")
with open(self.temp_path, "rb") as src, open(target, "wb") as dst:
dst.write(src.read())
return target
def on_locktime_change(self):
locktime = self.widgets["locktime"].get_value()
threshold = self.widgets["threshold"].get_value()
locktime = BalTimestamp(locktime)
threshold = BalTimestamp(threshold)
min_locktime = min(
Will.get_min_locktime(self.bal_window.willitems, NLOCKTIME_MAX),
locktime.to_timestamp(),
)
td = threshold.to_date(min_locktime, True)
self.widgets["threshold"].alarm=td
self.bal_window.will_settings["real_threshold"]=td.timestamp()
try:
self.widgets["threshold"].editor.label.setText(td.strftime("%Y-%m-%d"))
except Exception as _e:
pass
td = locktime.to_date()
alarm = BalTimestamp(min_locktime).to_date()
self.widgets["locktime"].alarm=alarm
self.bal_window.will_settings["real_locktime"]=td.timestamp()
try:
self.widgets["locktime"].editor.label.setText(td.strftime("%Y-%m-%d"))
except Exception as _e:
pass
class PercAmountEdit(BTCAmountEdit):
def __init__(self, decimal_point, is_int=False, parent=None, *, max_amount=None):
super().__init__(decimal_point, is_int, parent, max_amount=max_amount)
def numbify(self):
text = self.text().strip()
if text == "!":
self.shortcut.emit()
return
pos = self.cursorPosition()
chars = "0123456789%"
chars += DECIMAL_POINT
s = "".join([i for i in text if i in chars])
if "%" in s:
self.is_perc = True
s = s.replace("%", "")
else:
self.is_perc = False
if DECIMAL_POINT in s:
p = s.find(DECIMAL_POINT)
s = s.replace(DECIMAL_POINT, "")
s = s[:p] + DECIMAL_POINT + s[p : p + 8]
if self.is_perc:
s += "%"
self.setText(s)
self.setModified(self.hasFocus())
self.setCursorPosition(pos)
def _get_amount_from_text(self, text: str) -> Union[None, Decimal, int]:
try:
text = text.replace(DECIMAL_POINT, ".")
text = text.replace("%", "")
return (Decimal)(text)
except Exception:
return None
def _get_text_from_amount(self, amount):
out = super()._get_text_from_amount(amount)
if self.is_perc:
out += "%"
return out
def paintEvent(self, event):
QLineEdit.paintEvent(self, event)
if self.base_unit:
panel = QStyleOptionFrame()
self.initStyleOption(panel)
textRect = self.style().subElementRect(
QStyle.SubElement.SE_LineEditContents, panel, self
)
textRect.adjust(2, 0, -10, 0)
painter = QPainter(self)
painter.setPen(ColorScheme.GRAY.as_color())
if len(self.text()) == 0:
painter.drawText(
textRect,
int(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter),
self.base_unit() + " or perc value",
)
class BalLineEdit(QLineEdit):
def __init__(self,variable):
QLineEdit.__init__(self)
self.setText(variable.get())
def on_edit():
variable.set(self.text())
self.editingFinished.connect(on_edit)
class BalTextEdit(QTextEdit):
def __init__(self,variable):
QTextEdit.__init__(self)
self.setPlainText(variable.get())
def on_edit():
variable.set(self.toPlainText())
self.textChanged.connect(on_edit)
class BalCheckBox(QCheckBox):
def __init__(self, variable, on_click=None):
QCheckBox.__init__(self)
self.setChecked(variable.get())
self.on_click = on_click
def on_check(v):
variable.set(v == 2)
#variable.get()
if self.on_click:
self.on_click()
self.stateChanged.connect(on_check)
class WillWidget(QWidget):
def __init__(self, father=None, parent=None):
super().__init__()
vlayout = QVBoxLayout()
self.setLayout(vlayout)
self.will = parent.bal_window.willitems
self._bal_parent = parent
for w in self.will:
if (
self.will[w].get_status("REPLACED")
and self._bal_parent.bal_window.bal_plugin._hide_replaced
):
continue
if (
self.will[w].get_status("INVALIDATED")
and self._bal_parent.bal_window.bal_plugin._hide_invalidated
):
continue
f = self.will[w].father
if father == f:
qwidget = QWidget()
# childWidget = QWidget()
hlayout = QHBoxLayout(qwidget)
qwidget.setLayout(hlayout)
vlayout.addWidget(qwidget)
detailw = QWidget()
detaillayout = QVBoxLayout()
detailw.setLayout(detaillayout)
willpushbutton = QPushButton(w)
willpushbutton.clicked.connect(
partial(self._bal_parent.bal_window.show_transaction, txid=w)
)
detaillayout.addWidget(willpushbutton)
locktime = str(BalTimestamp(self.will[w].tx.locktime))
creation = str(BalTimestamp(self.will[w].time))
def qlabel(title, value):
label = "<b>" + _(str(title)) + f":</b>\t{str(value)}"
return QLabel(label)
detaillayout.addWidget(qlabel("Locktime", locktime))
detaillayout.addWidget(qlabel("Creation Time", creation))
try:
total_fees = (
self.will[w].tx.input_value() - self.will[w].tx.output_value()
)
except Exception:
total_fees = -1
decoded_fees = total_fees
fee_per_byte = round(total_fees / self.will[w].tx.estimated_size(), 3)
fees_str = str(decoded_fees) + " (" + str(fee_per_byte) + " sats/vbyte)"
detaillayout.addWidget(qlabel("Transaction fees:", fees_str))
detaillayout.addWidget(qlabel("Status:", self.will[w].status))
detaillayout.addWidget(QLabel(""))
detaillayout.addWidget(QLabel("<b>Heirs:</b>"))
for heir in self.will[w].heirs:
if 'w!ll3x3c"' not in heir:
decoded_amount = Util.decode_amount(
self.will[w].heirs[heir][3], self._bal_parent.decimal_point
)
detaillayout.addWidget(
qlabel(
heir, f"{decoded_amount} {self._bal_parent.base_unit_name}"
)
)
if self.will[w].we:
detaillayout.addWidget(QLabel(""))
detaillayout.addWidget(QLabel(_("<b>Willexecutor:</b:")))
decoded_amount = Util.decode_amount(
self.will[w].we["base_fee"], self._bal_parent.decimal_point
)
detaillayout.addWidget(
qlabel(
self.will[w].we["url"],
f"{decoded_amount} {self._bal_parent.base_unit_name}",
)
)
detaillayout.addStretch()
pal = QPalette()
pal.setColor(
QPalette.ColorRole.Window, QColor(status_color(self.will[w]))
)
detailw.setAutoFillBackground(True)
detailw.setPalette(pal)
hlayout.addWidget(detailw)
hlayout.addWidget(WillWidget(w, parent=parent))

1280
bal/gui/qt/window.py Normal file

File diff suppressed because it is too large Load Diff

119
bal/gui/qt/window_utils.py Normal file
View File

@@ -0,0 +1,119 @@
"""
bal.gui.qt.window_utils
=======================
Centralized window/dialog presentation helpers.
The original plugin opened dialogs inconsistently: some with ``exec()``
(modal, stays on top) and some with ``show()`` (modeless, can fall *behind*
the main Electrum window). It also relied on a per-instance ``self.parent``
attribute that shadows :meth:`QWidget.parent`, and it never gave non-modal
dialogs focus, so they could disappear behind Electrum.
To fix this *without changing the business logic*, all the "how is this window
shown / focused / parented" concerns are collected here. The rest of the GUI
code just calls these helpers, so the behaviour is consistent and easy to
audit.
None of these helpers change *what* a dialog does — only its parenting,
modality and z-order/focus.
"""
from PyQt6.QtCore import Qt
from PyQt6.QtWidgets import QWidget
def top_level_of(widget):
"""Return the proper top-level window to use as a dialog parent.
Electrum widgets expose ``top_level_window()``; when available we use it so
the dialog is anchored to the real top-level Electrum window (and therefore
stays in front of it). Falls back to the widget's own ``window()`` or the
widget itself.
"""
if widget is None:
return None
# Electrum's MessageBoxMixin / ElectrumWindow provide top_level_window().
tlw = getattr(widget, "top_level_window", None)
if callable(tlw):
try:
return tlw()
except Exception:
pass
# Plain QWidget: window() returns the top-level container.
if isinstance(widget, QWidget):
try:
return widget.window()
except Exception:
pass
return widget
def bring_to_front(dialog):
"""Make a *visible* dialog actually appear in front and take focus.
``raise_()`` alone is not enough on some window managers (notably Windows):
without ``activateWindow()`` the dialog can stay behind the main window.
"""
try:
dialog.raise_()
dialog.activateWindow()
except Exception:
pass
def stop_thread(thread):
"""Safely stop and join an Electrum ``TaskThread`` if present.
The original code commented out thread teardown, leaving background
threads running after a dialog closed (which could touch destroyed widgets
or keep network connections open until Electrum was restarted). This
stops the thread and waits for it to finish, guarding against ``None`` and
any teardown error.
"""
if thread is None:
return
try:
thread.stop()
except Exception:
pass
try:
thread.wait()
except Exception:
pass
def show_modal(dialog):
"""Show *dialog* modally and return the result of ``exec()``.
Modal dialogs always stay in front of their parent, which is the desired
behaviour for the plugin's editing/confirmation dialogs.
"""
try:
dialog.setWindowModality(Qt.WindowModality.WindowModal)
except Exception:
pass
bring_to_front(dialog)
return dialog.exec()
def show_on_top(dialog, *, modal_to_window=True):
"""Show *dialog* non-modally but guaranteed in front of Electrum.
Use this for the few dialogs that must remain non-modal (e.g. the
transaction dialog the user may want to keep open alongside the wallet).
It sets window-modality (so it stays above its parent window without
blocking the whole application) and gives it focus.
Set ``modal_to_window=False`` for a completely modeless window.
"""
try:
if modal_to_window:
dialog.setWindowModality(Qt.WindowModality.WindowModal)
else:
dialog.setWindowModality(Qt.WindowModality.NonModal)
except Exception:
pass
dialog.show()
bring_to_front(dialog)
return dialog