forked from bitcoinafterlife/bal-electrum-plugin
add bal/gui
This commit is contained in:
17
bal/gui/qt/__init__.py
Normal file
17
bal/gui/qt/__init__.py
Normal 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
160
bal/gui/qt/calendar.py
Normal 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
173
bal/gui/qt/common.py
Normal 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
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
995
bal/gui/qt/lists.py
Normal 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
493
bal/gui/qt/plugin.py
Normal 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
97
bal/gui/qt/theme.py
Normal 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
921
bal/gui/qt/widgets.py
Normal 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
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
119
bal/gui/qt/window_utils.py
Normal 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
|
||||
Reference in New Issue
Block a user