forked from bitcoinafterlife/bal-electrum-plugin
1609 lines
70 KiB
Python
1609 lines
70 KiB
Python
"""
|
||
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 # underscore names are not re-exported by "import *"
|
||
from .calendar import BalCalendar, BalCalendarButton
|
||
|
||
|
||
def compute_reminder_offsets(days, count):
|
||
"""Return the reminder offsets (in days BEFORE the deadline) for an .ics event.
|
||
|
||
Group D / D1. The reminders are spread uniformly across the check-alive
|
||
period and always fall *before* the delivery deadline, i.e. every returned
|
||
offset is ``>= 1`` (a reminder exactly on the deadline would be useless).
|
||
|
||
Rules:
|
||
* ``count`` is the requested number of reminders (the settings dialog
|
||
caps it at 5, default 3).
|
||
* at most ONE reminder per available day: the effective number is
|
||
``min(count, days)``;
|
||
* with ``days`` available days, offsets are chosen as evenly spaced
|
||
points inside ``[1, days]`` (1 = the day before the deadline, ``days``
|
||
= the first day of the period), de-duplicated and returned sorted
|
||
descending (earliest reminder first).
|
||
|
||
Args:
|
||
days: number of whole days between check-alive and the deadline.
|
||
count: requested number of reminders.
|
||
|
||
Returns:
|
||
A list of integer day-offsets (each ``>= 1``), e.g. ``[22, 15, 8]`` for
|
||
``days=30, count=3``. Empty if there is no room for any reminder.
|
||
"""
|
||
# No room for any reminder (deadline today or already passed).
|
||
if days < 1 or count < 1:
|
||
return []
|
||
|
||
# Never more reminders than available days (one per day at most).
|
||
effective = min(int(count), int(days))
|
||
|
||
# A single reminder: put it one day before the deadline.
|
||
if effective == 1:
|
||
return [1]
|
||
|
||
# Spread "effective" points evenly inside [1, days]. Using i/(effective-1)
|
||
# for i in 0..effective-1 gives fractions 0..1; map them onto [1, days].
|
||
# This places the first reminder at the start of the period (offset ~days)
|
||
# and the last one one day before the deadline (offset 1).
|
||
offsets = set()
|
||
for i in range(effective):
|
||
frac = i / (effective - 1) # 0.0 .. 1.0
|
||
# offset = days at frac 0 (start), 1 at frac 1 (just before deadline).
|
||
offset = round(days - frac * (days - 1))
|
||
offset = max(1, min(days, offset))
|
||
offsets.add(offset)
|
||
|
||
# Sorted descending: earliest reminder (largest offset) first.
|
||
return sorted(offsets, reverse=True)
|
||
|
||
|
||
# Fixed reminder offsets (in days BEFORE the delivery date) used in BASIC mode.
|
||
# In BASIC the check-alive parameter is hidden/unmanaged, so reminders cannot be
|
||
# spread over it; instead the owner asked for three fixed reminders: 30, 10 and
|
||
# 1 day before the inheritance delivery date.
|
||
BASIC_REMINDER_OFFSETS = (30, 10, 1)
|
||
|
||
|
||
def basic_reminder_offsets(days_to_deadline):
|
||
"""Return the BASIC-mode reminder offsets that still fall in the future.
|
||
|
||
BASIC mode uses the fixed offsets in ``BASIC_REMINDER_OFFSETS`` (30, 10 and
|
||
1 day before the delivery date). Any offset that would land in the past is
|
||
dropped, because a reminder before "today" is useless: if the delivery date
|
||
is only ``days_to_deadline`` days away, only the offsets that are ``<=
|
||
days_to_deadline`` are kept.
|
||
|
||
Args:
|
||
days_to_deadline: whole days from now until the delivery date.
|
||
|
||
Returns:
|
||
A list of integer day-offsets (each ``>= 1``), sorted as in
|
||
``BASIC_REMINDER_OFFSETS`` (descending: earliest reminder first). Empty
|
||
when the delivery date is less than one day away.
|
||
"""
|
||
horizon = max(int(days_to_deadline), 0)
|
||
return [off for off in BASIC_REMINDER_OFFSETS if 1 <= off <= horizon]
|
||
|
||
|
||
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)
|
||
# Group C / C5: hovering the fee field explains what the number means.
|
||
self.txfee_widget.setToolTip(
|
||
_("Mining fee rate in sat/vByte used for the will transactions")
|
||
)
|
||
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("丰")
|
||
# Hover tooltip for the small "丰" help icon (the long explanation still
|
||
# appears on click via the HelpButton); makes the icon self-explanatory.
|
||
button.setToolTip(_("Miner fee, click for more information"))
|
||
# Enlarge only the "丰" glyph on the button itself; without scoping the
|
||
# rule to QPushButton it also enlarged the tooltip font (making it bigger
|
||
# than the other tooltips, e.g. the calendar one). Scoping it keeps the
|
||
# tooltip at the default size like everywhere else.
|
||
button.setStyleSheet("QPushButton{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)
|
||
# SIMPLE / ADVANCED (task: hide the Raw/Date selector in BASIC mode).
|
||
#
|
||
# In BASIC mode the user must not see or use the Raw/Date selector:
|
||
# every date field is forced to the calendar ("Date") editor and the
|
||
# combo is hidden. We keep a flag so the rest of __init__ can force the
|
||
# Date editor regardless of the stored value's format.
|
||
self._basic_mode = self.bal_window.bal_plugin.is_basic_mode()
|
||
# Tracks whether the user PICKED an editor by hand from the Raw/Date
|
||
# selector (set only on the combo's `activated` signal, i.e. real user
|
||
# interaction). It lets the runtime BASIC<->ADVANCED switch respect a
|
||
# manual "Date" choice in ADVANCED instead of forcing RAW back. It stays
|
||
# False for programmatic changes (defaults, switches).
|
||
self._user_picked_editor = False
|
||
default_index = 0
|
||
if not default_locktime:
|
||
default_locktime = self.bal_window.bal_plugin.WILL_SETTINGS.get()[self.base_field]
|
||
# Default editor per mode (owner request):
|
||
# * BASIC -> Date editor (index 1); the Raw/Date selector is hidden,
|
||
# so the user always picks a date and never sees RAW.
|
||
# * ADVANCED -> RAW editor (index 0) by default; the selector stays
|
||
# visible so the user can switch to Date manually.
|
||
if self._basic_mode:
|
||
default_index = 1
|
||
else:
|
||
default_index = 0
|
||
# In ADVANCED we default to RAW. If the stored value is an absolute
|
||
# timestamp (a bare number), showing it in RAW would display that
|
||
# raw number; substitute the relative default ("1y"/"30d") so the
|
||
# RAW editor opens with a human-readable duration instead.
|
||
try:
|
||
int(default_locktime)
|
||
is_absolute = True
|
||
except Exception:
|
||
is_absolute = False
|
||
if is_absolute:
|
||
default_locktime = (
|
||
self.bal_window.bal_plugin.default_will_settings_relative()[
|
||
self.base_field
|
||
]
|
||
)
|
||
#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)
|
||
# `activated` fires ONLY on real user interaction with the selector (not
|
||
# on programmatic setCurrentIndex), so we use it to remember that the
|
||
# user picked the editor by hand. This is what lets the runtime
|
||
# BASIC<->ADVANCED switch respect a manual "Date" choice in ADVANCED.
|
||
self.combo.activated.connect(self._on_user_picked_editor)
|
||
|
||
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)
|
||
# In BASIC mode hide the Raw/Date selector entirely: the field stays
|
||
# locked on the calendar editor chosen above, so the user only ever
|
||
# picks a Date and cannot switch to RAW.
|
||
if self._basic_mode:
|
||
self.combo.setVisible(False)
|
||
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_user_picked_editor(self, i):
|
||
"""Record that the user chose the Raw/Date editor by hand.
|
||
|
||
Connected to the combo's ``activated`` signal, which only fires on real
|
||
user interaction (not on programmatic ``setCurrentIndex``). Used by the
|
||
runtime BASIC<->ADVANCED switch to respect a manual "Date" choice in
|
||
ADVANCED instead of forcing RAW back.
|
||
"""
|
||
self._user_picked_editor = True
|
||
|
||
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 apply_user_type_visibility(self):
|
||
"""Re-apply the BASIC/ADVANCED visibility of the Raw/Date selector.
|
||
|
||
WHY this is needed (bug reported by the owner, task #04): the Raw/Date
|
||
combo is hidden in BASIC mode and shown in ADVANCED mode. That visibility
|
||
used to be decided ONLY in ``__init__`` (see the ``self._basic_mode``
|
||
block there). The WILL and HEIR tab toolbars are created once and then
|
||
REUSED for the whole session (they are not rebuilt when the user switches
|
||
USER TYPE), so after switching from BASIC to ADVANCED the combo stayed
|
||
hidden there and the user could only use the "Date" editor. The same
|
||
happened when a RAW value was pushed from the wizard: the raw number
|
||
showed (the active editor was updated) but the selector box stayed hidden
|
||
because nothing ever re-showed it. The wizard worked only because it is
|
||
recreated every time it is opened.
|
||
|
||
This method re-reads ``is_basic_mode()`` and shows/hides ONLY the combo.
|
||
It deliberately does NOT change the current value or the active editor
|
||
(owner request: keep the current value to avoid confusing the delivery
|
||
date with the inheritance). It is called from
|
||
``WillSettingsWidget.apply_user_type_visibility()`` (triggered by
|
||
``BalWindow.update_all()``, which also runs on every CHECK/refresh), so
|
||
it must be side-effect free on the editor - otherwise pressing CHECK
|
||
would reset a manual Date/RAW choice back to the default. The actual
|
||
per-mode editor default lives in ``apply_user_type_editor_default()``,
|
||
which is called ONLY on a real USER TYPE change.
|
||
|
||
It is safe to call repeatedly and on either layout (horizontal toolbar or
|
||
vertical wizard): it only flips the visibility of the Raw/Date combo.
|
||
"""
|
||
try:
|
||
basic = self.bal_window.bal_plugin.is_basic_mode()
|
||
except Exception:
|
||
# If the mode cannot be read, show the combo (the safe, most
|
||
# capable default for an existing widget).
|
||
basic = False
|
||
self.combo.setVisible(not basic)
|
||
|
||
def apply_user_type_editor_default(self):
|
||
"""Set the ACTIVE Raw/Date editor to the per-mode default.
|
||
|
||
Split out from ``apply_user_type_visibility`` (Opzione 2) so it runs
|
||
ONLY on a real BASIC<->ADVANCED USER TYPE change, not on every
|
||
``update_all()``/CHECK refresh. That is what stopped pressing CHECK from
|
||
resetting a manual Date choice back to RAW.
|
||
|
||
Behaviour:
|
||
* BASIC -> always the Date editor (index 1); the selector is hidden,
|
||
so the field must not stay on RAW.
|
||
* ADVANCED -> RAW by default (index 0), UNLESS the user previously
|
||
picked an editor by hand (``_user_picked_editor``), in
|
||
which case their choice is left untouched.
|
||
"""
|
||
try:
|
||
basic = self.bal_window.bal_plugin.is_basic_mode()
|
||
except Exception:
|
||
basic = False
|
||
try:
|
||
if basic:
|
||
self.set_index(1)
|
||
else:
|
||
if not self._user_picked_editor:
|
||
self.set_index(0)
|
||
except Exception:
|
||
# Never let a cosmetic editor switch break mode toggling.
|
||
pass
|
||
|
||
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("")
|
||
# Group C / C3: this trailing label shows the ABSOLUTE date computed
|
||
# from the RAW value (e.g. "30d" -> "2027-06-23"), so it needs room for a
|
||
# full "YYYY-MM-DD" string (~10 characters). Only the input box itself
|
||
# (LockTimeRawEdit) is narrowed; shrinking this label was a mistake that
|
||
# truncated the computed date.
|
||
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)
|
||
# Group C / C3: narrow the RAW input to roughly a third of its former
|
||
# width. The accepted values are short relative durations such as "30d"
|
||
# or "1y", so a handful of characters is plenty while still leaving room
|
||
# for larger day counts (e.g. "3650d").
|
||
self.setFixedWidth(6 * 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):
|
||
"""Strip the relative-time suffixes (d/y) from the text.
|
||
|
||
Only days ("d") and years ("y") are supported. The block-height
|
||
suffix ("b") was removed (A1): locktimes are always timestamps now.
|
||
"""
|
||
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):
|
||
# Only digits plus the day ("d") and year ("y") suffixes are accepted.
|
||
# The block-height suffix ("b") was removed (A1): locktimes are always
|
||
# UNIX timestamps now, so block-relative input is no longer allowed.
|
||
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
|
||
|
||
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.set_value(s, force=False)
|
||
self.current_value = s
|
||
# setText sets Modified to False. Instead we want to remember
|
||
# if updates were because of user modification.
|
||
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):
|
||
# GUARD (kept on purpose, A1): NLOCKTIME_BLOCKHEIGHT_MAX is the highest value
|
||
# Bitcoin interprets as a *block height*. By forcing the minimum to one above
|
||
# it, every locktime entered here is guaranteed to be a UNIX *timestamp*,
|
||
# never a block height. This is NOT block-height ordering; it is the
|
||
# "bouncer" that prevents block-height values from ever being used again.
|
||
min_allowed_value = NLOCKTIME_BLOCKHEIGHT_MAX + 1
|
||
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>"
|
||
"In DATA mode:<br>"
|
||
"set the date for the \u201ccheck alive\u201d parameter.<br>"
|
||
"When you open the wallet, if the \u201ccheck alive\u201d date has passed, "
|
||
"the plugin will ask if you want to postpone the inheritance, since it "
|
||
"assumes you still have control of the wallet and that you are still "
|
||
"alive.<br><br>"
|
||
"In RAW mode:<br>"
|
||
"When less than 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 current day(ex: 1y means one year from today)<br>"
|
||
"<br><b>Note:</b> the date/time is expressed in your computer's "
|
||
"<b>Local time</b> (not UTC time).<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>"
|
||
# The Raw locktime syntax below is only available in ADVANCED mode
|
||
# (in BASIC mode the Raw/Date selector is hidden and only the Date
|
||
# picker is shown), so we say so explicitly to avoid confusing users.
|
||
"(ONLY IN ADVANCED MODE)<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>"
|
||
"<br><b>Note:</b> the date/time is expressed in your computer's "
|
||
"<b>Local time</b> (not UTC time).<br>"
|
||
)
|
||
label_text = "🚛"
|
||
#label_text = "Locktime"
|
||
# Hover tooltip for the delivery-time icon; mirrors the style of the fee
|
||
# icon tooltip ("..., click for more information") so the two are consistent.
|
||
tooltip_text = "Delivery Time, click for more information"
|
||
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
|
||
# Remember which layout this widget was built with, so the per-update
|
||
# hooks (apply_editable_dates / apply_user_type_visibility) can tell the
|
||
# main-window toolbar ("h") apart from the wizard (vertical) - needed
|
||
# for the BASIC-mode Check-Alive "visible but read-only in the main
|
||
# window only" behaviour.
|
||
self.layout_type = layout_type
|
||
box = QHBoxLayout(self) if layout_type == "h" else QVBoxLayout(self)
|
||
|
||
self.calendar_button = BalCalendarButton(self.bal_window, self._ics_provider)
|
||
self.calendar_button.setIcon(
|
||
read_QIcon_from_bytes(
|
||
self.bal_window.bal_plugin.read_file("icons/calendar.png")
|
||
)
|
||
)
|
||
self.calendar_button.setToolTip(
|
||
_("Export reminder dates to your calendar (.ics)")
|
||
)
|
||
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)
|
||
# SIMPLE / ADVANCED: in BASIC mode the "Check Alive" (threshold) row is
|
||
# hidden EVERYWHERE (both the main-window toolbar and the wizard). The
|
||
# user must never see or touch the Check Alive in BASIC. The widget is
|
||
# still created and kept in self.widgets so the rest of the code and the
|
||
# saved settings keep working; it is only hidden from view. (This
|
||
# reverts the v0.5.2 experiment that had shown it read-only in the main
|
||
# window - owner asked to hide it again.)
|
||
if bal_window.bal_plugin.is_basic_mode():
|
||
self.widgets["threshold"].setVisible(False)
|
||
# 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) - Layout H. #
|
||
# #
|
||
# User requirements (allegato4): #
|
||
# * the leading ICONS (delivery time, calendar, fee and - in #
|
||
# ADVANCED mode - check-alive) must be aligned one under the #
|
||
# other on the left; #
|
||
# * the editable FIELDS must all start from the SAME x position, #
|
||
# just to the right of their icon; #
|
||
# * the fee field must be WIDER (its up/down arrows were covering #
|
||
# the digits) - not a tiny box. #
|
||
# #
|
||
# DESIGN NOTE - why we do NOT pull the icon/field out of each #
|
||
# composite into a shared grid: the LockTime / Check-Alive composites #
|
||
# hold TWO editors (Raw and Date) plus a Raw/Date combo that the user #
|
||
# can switch at runtime in ADVANCED mode; ``self.editor`` is swapped #
|
||
# live (see on_current_index_changed). Reparenting only the currently #
|
||
# active editor would orphan the other editor and the combo and break #
|
||
# ADVANCED mode. So we keep every composite INTACT and instead align #
|
||
# them by: #
|
||
# 1. forcing every leading icon (prefix_widget) to the SAME fixed #
|
||
# width, so each composite's field starts at the same x; and #
|
||
# 2. stacking the whole composites left-aligned in the VBox. #
|
||
# This is robust to the Raw/Date switching and keeps all internal #
|
||
# logic working untouched. #
|
||
# ----------------------------------------------------------------- #
|
||
locktime_w = self.widgets["locktime"]
|
||
threshold_w = self.widgets["threshold"]
|
||
fees_w = self.widgets["baltx_fees"]
|
||
|
||
# Common icon width = the widest leading icon (incl. the calendar
|
||
# button). Forcing every icon to this width makes the icons line up
|
||
# one under the other and, because each field sits immediately to the
|
||
# right of its icon, makes every field start at the same x too.
|
||
icon_w = max(
|
||
locktime_w.prefix_widget.sizeHint().width(),
|
||
threshold_w.prefix_widget.sizeHint().width(),
|
||
fees_w.prefix_widget.sizeHint().width(),
|
||
self.calendar_button.sizeHint().width(),
|
||
)
|
||
for icon in (
|
||
locktime_w.prefix_widget,
|
||
threshold_w.prefix_widget,
|
||
fees_w.prefix_widget,
|
||
self.calendar_button,
|
||
):
|
||
icon.setFixedWidth(icon_w)
|
||
|
||
# WIDEN the fee field (user feedback: the spin-box up/down arrows
|
||
# were covering the digits). ~8 chars leaves room for the value and
|
||
# the arrows.
|
||
fees_w.field_widget.setFixedWidth(8 * char_width_in_lineedit())
|
||
|
||
# WHY THE TEXT WAS TRUNCATED (allegato16, fixed here):
|
||
# A QLabel added to a box layout WITH an alignment flag (the old
|
||
# ``alignment=Qt.AlignmentFlag.AlignLeft``) is NOT stretched to the
|
||
# layout width - Qt gives it only its sizeHint. For a word-wrapped
|
||
# label that sizeHint width is ambiguous/narrow, so the text wrapped
|
||
# against an almost-minimum width and the reserved height was too
|
||
# small, cutting the sentence in half. setMinimumWidth alone did not
|
||
# help because the alignment flag still prevented horizontal stretch.
|
||
#
|
||
# TARGETED FIX:
|
||
# 1. give the whole vertical WillSettingsWidget a sensible minimum
|
||
# width, so the box (and thus the labels) has real width to work
|
||
# with even before the parent dialog stretches it;
|
||
# 2. add the two explanatory labels WITHOUT an alignment flag, so
|
||
# they expand to the full box width and word-wrap correctly;
|
||
# 3. set an Expanding/Minimum size policy so the label takes the
|
||
# available width and computes its height from that width
|
||
# (heightForWidth), guaranteeing the full text is shown.
|
||
hint_min_width = 44 * char_width_in_lineedit()
|
||
self.setMinimumWidth(hint_min_width)
|
||
|
||
# Explanatory hint ABOVE the date field (wizard only): tell the user
|
||
# what the delivery date means. Wrapped so it fits the dialog width.
|
||
# The explicit "\n" forces the line break exactly where the owner
|
||
# asked (allegato2): after "(or backup)". With setWordWrap(True) the
|
||
# QLabel honours the newline, so the sentence always shows on two
|
||
# tidy lines instead of wrapping at an arbitrary point.
|
||
date_hint = QLabel(
|
||
_(
|
||
"Enter the date on which you want the inheritance (or "
|
||
"backup)\nof your Electrum wallet to take effect."
|
||
)
|
||
)
|
||
date_hint.setWordWrap(True)
|
||
date_hint.setMinimumWidth(hint_min_width)
|
||
date_hint.setSizePolicy(
|
||
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum
|
||
)
|
||
# NOTE: added WITHOUT an alignment flag on purpose (see above), so
|
||
# the label is stretched to the full width and wraps correctly.
|
||
box.addWidget(date_hint)
|
||
|
||
# Stack the composites one under the other, all left-aligned so the
|
||
# fixed-width icons share a common left edge.
|
||
box.addWidget(locktime_w, alignment=Qt.AlignmentFlag.AlignLeft)
|
||
box.addWidget(threshold_w, alignment=Qt.AlignmentFlag.AlignLeft)
|
||
# In BASIC mode the Check-Alive (threshold) row is hidden entirely
|
||
# (it was already set invisible above); in ADVANCED mode it shows
|
||
# with its icon aligned under the delivery-time icon.
|
||
box.addWidget(
|
||
self.calendar_button, alignment=Qt.AlignmentFlag.AlignLeft
|
||
)
|
||
box.addWidget(fees_w, alignment=Qt.AlignmentFlag.AlignLeft)
|
||
|
||
# Cautionary note BELOW the miner-fee field (wizard only): warn the
|
||
# user not to lower the miner fee unless they know what they do.
|
||
# Explicit "\n" break after "miner fees" (allegato2), same rationale
|
||
# as date_hint above.
|
||
fee_note = QLabel(
|
||
_(
|
||
"Please note: Do not reduce the miner fees\nunless you "
|
||
"know what you\u2019re doing"
|
||
)
|
||
)
|
||
fee_note.setWordWrap(True)
|
||
fee_note.setMinimumWidth(hint_min_width)
|
||
fee_note.setSizePolicy(
|
||
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum
|
||
)
|
||
# Added WITHOUT an alignment flag (see date_hint above) so it
|
||
# stretches to full width and wraps correctly instead of truncating.
|
||
box.addWidget(fee_note)
|
||
|
||
self.apply_editable_dates()
|
||
return
|
||
|
||
# Group C / C2: apply the current "Editable dates" setting to the date
|
||
# fields. Done once at creation here, and re-applied later by
|
||
# apply_editable_dates() whenever the setting changes (called from
|
||
# BalWindow.update_all()), so toggling the checkbox takes effect
|
||
# immediately, exactly like the "Hide Invalidated" filter does.
|
||
self.apply_editable_dates()
|
||
|
||
def apply_editable_dates(self):
|
||
"""Re-read the EDITABLE_DATES setting and lock/unlock the editable fields.
|
||
|
||
Outside the "Build your will" wizard (``read_only=True``) the
|
||
delivery-time, check-alive dates AND the mining fee are display-only by
|
||
default. When the user ticks "Editable dates" in the settings, all three
|
||
fields (locktime, threshold and fee) become editable here too; when it
|
||
is unticked they all go back to read-only. The fee follows exactly the
|
||
same rule as the dates (it used to stay always read-only, but the user
|
||
asked for the fee to be editable together with the dates).
|
||
|
||
This is safe to call repeatedly: it only adjusts the read-only state of
|
||
the already-created sub-widgets, it does not rebuild anything. It is the
|
||
per-update hook that lets the settings checkbox take effect without
|
||
having to recreate the toolbar / re-open the window.
|
||
"""
|
||
# Inside the wizard the dates are always editable; nothing to do.
|
||
if not self.read_only:
|
||
return
|
||
|
||
basic = self.bal_window.bal_plugin.is_basic_mode()
|
||
if not basic:
|
||
# Advanced mode: fields are always editable.
|
||
editable_dates = True
|
||
else:
|
||
try:
|
||
editable_dates = self.bal_window.bal_plugin.EDITABLE_DATES.get()
|
||
except Exception:
|
||
# If the setting cannot be read, fall back to the safe default
|
||
# (dates remain read-only outside the wizard).
|
||
editable_dates = False
|
||
|
||
self.widgets["locktime"].set_read_only(not editable_dates)
|
||
self.widgets["threshold"].set_read_only(not editable_dates)
|
||
# The fee now follows the very same "Editable dates" rule as the dates:
|
||
# editable outside the wizard only when the setting is ticked.
|
||
self.widgets["baltx_fees"].set_read_only(not editable_dates)
|
||
|
||
def apply_user_type_visibility(self):
|
||
"""Re-apply the BASIC/ADVANCED visibility of the Check-Alive field.
|
||
|
||
WHY this is needed (bug reported by the owner): the Check-Alive
|
||
(threshold) field is hidden in BASIC mode and shown in ADVANCED mode.
|
||
That visibility used to be decided ONLY in __init__. The toolbar
|
||
settings widgets of the WILL and HEIR tabs are created once and then
|
||
REUSED across the session (they are not rebuilt when the user switches
|
||
USER TYPE), so after switching from BASIC to ADVANCED the Check-Alive
|
||
field stayed hidden there. The wizard worked only because it is recreated
|
||
every time it is opened.
|
||
|
||
This method re-reads is_basic_mode() and shows/hides the Check-Alive
|
||
field accordingly. It is called from BalWindow.update_all() (which the
|
||
USER TYPE combo triggers when changed), exactly like apply_editable_dates
|
||
is, so toggling BASIC/ADVANCED takes effect immediately on the already
|
||
existing WILL/HEIR toolbars without restarting Electrum.
|
||
|
||
It is safe to call repeatedly and on either layout (horizontal toolbar
|
||
or vertical wizard): it only flips the visibility of the threshold
|
||
widget.
|
||
"""
|
||
try:
|
||
basic = self.bal_window.bal_plugin.is_basic_mode()
|
||
except Exception:
|
||
# If the mode cannot be read, keep the field visible (the safe,
|
||
# information-preserving default).
|
||
basic = False
|
||
threshold = self.widgets.get("threshold")
|
||
if threshold is not None:
|
||
# Hidden in BASIC, visible in ADVANCED - in BOTH layouts (main
|
||
# window toolbar and wizard). Reverts the v0.5.2 experiment that
|
||
# kept it visible in the main window in BASIC.
|
||
threshold.setVisible(not basic)
|
||
|
||
# Task #04: also re-apply the Raw/Date selector visibility on BOTH the
|
||
# delivery-time (locktime) and the check-alive (threshold) boxes. The
|
||
# combo was hidden once at construction (BASIC) and the reused WILL/HEIR
|
||
# toolbars never re-showed it after switching to ADVANCED. Each composite
|
||
# decides its own combo visibility from is_basic_mode() WITHOUT touching
|
||
# its current value or editor.
|
||
for field in ("locktime", "threshold"):
|
||
widget = self.widgets.get(field)
|
||
apply_combo = getattr(widget, "apply_user_type_visibility", None)
|
||
if callable(apply_combo):
|
||
apply_combo()
|
||
|
||
def apply_user_type_editor_default(self):
|
||
"""Apply the per-mode Raw/Date editor default to both time fields.
|
||
|
||
Called ONLY on a real BASIC<->ADVANCED USER TYPE change (not on every
|
||
refresh/CHECK), so a manual Date/RAW choice is preserved when the user
|
||
just presses CHECK. See BalTimeEditWidget.apply_user_type_editor_default.
|
||
"""
|
||
for field in ("locktime", "threshold"):
|
||
widget = self.widgets.get(field)
|
||
apply_default = getattr(widget, "apply_user_type_editor_default", None)
|
||
if callable(apply_default):
|
||
apply_default()
|
||
|
||
def open_or_save_calendar(self):
|
||
"""Build and save an .ics calendar file with SEPARATE reminder events.
|
||
|
||
Group D / D1 (revised). Instead of a single calendar event holding
|
||
internal VALARM reminders (which most calendars show as just one entry),
|
||
this exports N *separate* VEVENTs, one per reminder date, so the user
|
||
sees several distinct appointments in their calendar.
|
||
|
||
The number of events is read from the NUM_REMINDERS setting (default 3,
|
||
capped at 5 by the settings dialog). Their dates are computed with
|
||
``compute_reminder_offsets``: the offsets are spread uniformly across the
|
||
check-alive period and the LAST event always falls one day before the
|
||
delivery deadline (locktime). If the period is shorter than the
|
||
requested number of reminders, at most one event per day is produced.
|
||
|
||
Each event:
|
||
* is placed on ``locktime - offset`` days (its own visible date);
|
||
* carries a unique UID (``bal-<wallet>-<offset>d``) so calendars do
|
||
not merge the events into one;
|
||
* has its summary suffixed with " (reminder N/total)" to tell the
|
||
events apart at a glance;
|
||
* reuses the configured EVENT_DESCRIPTION (with the usual
|
||
``$wallet_name`` / ``$heirs_complete`` substitutions).
|
||
|
||
The resulting file is written to a temp location and then copied to the
|
||
path the user picks in the save dialog (default name "BAL_will_event.ics"
|
||
on the Desktop).
|
||
"""
|
||
now = BalCalendar.format_time(datetime.now())
|
||
|
||
# locktime = delivery deadline. It is exposed by the date widget as
|
||
# ``.alarm`` and already reflects the (possibly auto-anticipated) minimum
|
||
# transaction locktime, so the calendar uses the correct delivery date.
|
||
locktime = self.widgets["locktime"].alarm
|
||
|
||
# BASIC vs ADVANCED reminder strategy.
|
||
#
|
||
# In ADVANCED mode the reminders are spread uniformly across the
|
||
# check-alive (threshold) period, ending one day before the deadline.
|
||
#
|
||
# In BASIC mode the check-alive parameter is NOT shown nor managed by the
|
||
# user (it stays at an arbitrary default), so spreading reminders over it
|
||
# is meaningless. The owner asked that, in BASIC, the calendar simply
|
||
# saves the inheritance delivery date with three fixed reminders: 30 days
|
||
# before, 10 days before and 1 day before. We also drop any fixed offset
|
||
# that would fall in the past (a reminder before "today" is useless), so
|
||
# a short-dated will still gets the reminders that are still in the
|
||
# future.
|
||
if self.bal_window.bal_plugin.is_basic_mode():
|
||
# Whole days from now until the delivery date. Fixed offsets (30, 10,
|
||
# 1 day before) are applied by basic_reminder_offsets, which also
|
||
# drops any offset that would fall in the past.
|
||
days_to_deadline = (locktime - datetime.now()).days
|
||
offsets = basic_reminder_offsets(days_to_deadline)
|
||
else:
|
||
# ADVANCED: spread reminders over the check-alive period as before.
|
||
threshold = self.widgets["threshold"].alarm
|
||
# Whole days available between check-alive and the deadline.
|
||
days = (locktime - threshold).days
|
||
# How many reminders the user asked for (default 3 if unreadable).
|
||
try:
|
||
count = int(self.bal_window.bal_plugin.NUM_REMINDERS.get())
|
||
except Exception:
|
||
count = 3
|
||
# Day-offsets BEFORE the deadline, e.g. [30, 16, 1]. The list always
|
||
# ends with 1 (one day before the locktime) when >= 2 reminders fit.
|
||
offsets = compute_reminder_offsets(days, count)
|
||
|
||
# Per-event heir details and the shared description/summary templates.
|
||
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
|
||
)
|
||
# BASIC mode: use factory defaults (the hidden settings are ignored).
|
||
# ADVANCED mode: use the user-configured values.
|
||
if self.bal_window.bal_plugin.is_basic_mode():
|
||
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.default
|
||
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.default
|
||
else:
|
||
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.get()
|
||
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.get()
|
||
event_description = BalCalendar.ical_escape(
|
||
f"{raw_description}"
|
||
.replace("$wallet_name", str(self.bal_window.wallet))
|
||
.replace("$heirs_complete", heirs_details)
|
||
)
|
||
summary_base = (
|
||
f"{raw_summary}"
|
||
.replace("$wallet_name", str(self.bal_window.wallet))
|
||
)
|
||
|
||
lines = [
|
||
"BEGIN:VCALENDAR",
|
||
"VERSION:2.0",
|
||
f"PRODID:-//Bitcoin After Life//Electrum Plugin/{BalPlugin.__version__}",
|
||
]
|
||
|
||
# One separate VEVENT per reminder offset (its own date in the calendar).
|
||
total = len(offsets)
|
||
for idx, offset in enumerate(offsets, start=1):
|
||
# The visible date of this event: "offset" days before the deadline.
|
||
event_dt = BalCalendar.format_time(locktime - timedelta(days=offset))
|
||
# Suffix the summary so the N events are easy to tell apart.
|
||
summary = BalCalendar.ical_escape(
|
||
f"{summary_base} (reminder {idx}/{total})"
|
||
)
|
||
lines.extend([
|
||
"BEGIN:VEVENT",
|
||
# Offset in the UID keeps each event unique (no merging).
|
||
f"UID:bal-{str(self.bal_window.wallet)}-{offset}d",
|
||
f"DTSTAMP:{now}",
|
||
f"DTSTART:{event_dt}",
|
||
f"DTEND:{event_dt}",
|
||
f"SUMMARY:{summary}",
|
||
f"DESCRIPTION:{event_description}",
|
||
"END:VEVENT",
|
||
])
|
||
|
||
lines.append("END:VCALENDAR")
|
||
|
||
lines = [s.rstrip("\r\n") for s in lines]
|
||
ics_content = "\r\n".join(lines) + "\r\n"
|
||
# Keep the generated .ics in a temp file; it is copied to the path the
|
||
# user picks below.
|
||
self.temp_path = BalCalendar.write_temp_ics(ics_content)
|
||
|
||
# Group D / D1b: always ask the user WHERE to save the .ics file (the
|
||
# plugin no longer tries to open it with a calendar app). The save
|
||
# dialog opens on the user's Desktop by default, with an .ics filter and
|
||
# a sensible default filename.
|
||
desktop = BalCalendar.desktop_dir()
|
||
default_path = os.path.join(desktop, "BAL_will_event.ics")
|
||
target = getSaveFileName(
|
||
parent=self.bal_window.window,
|
||
title=_("Save calendar reminder (.ics)"),
|
||
# An absolute filename makes getSaveFileName ignore its remembered
|
||
# IO_DIRECTORY and open on the Desktop instead.
|
||
filename=default_path,
|
||
filter="iCalendar (*.ics);;All files (*)",
|
||
default_extension="ics",
|
||
config=self.bal_window.window.config,
|
||
)
|
||
if not target:
|
||
# User cancelled the save dialog: nothing to do.
|
||
return
|
||
try:
|
||
self.save_ics_to(target)
|
||
except Exception as save_err:
|
||
_logger.error(f"saving .ics failed: {save_err}")
|
||
self.bal_window.show_warning(
|
||
_("Could not save the calendar file: {}").format(save_err)
|
||
)
|
||
return
|
||
self.bal_window.show_message(
|
||
_("Calendar file saved to:\n{}").format(target)
|
||
)
|
||
|
||
def save_ics_to(self, target):
|
||
"""Copy the generated .ics from the temp file to ``target`` (Group D / D1b).
|
||
|
||
Overwrites the destination if it already exists. Used by
|
||
open_or_save_calendar after the user picks a save location.
|
||
|
||
Args:
|
||
target: absolute destination path chosen by the user.
|
||
|
||
Returns:
|
||
The destination path.
|
||
"""
|
||
_logger.debug(f"save_ics_to {self.temp_path} -> {target}")
|
||
with open(self.temp_path, "rb") as src, open(target, "wb") as dst:
|
||
dst.write(src.read())
|
||
return target
|
||
|
||
def _ics_provider(self):
|
||
"""Return the .ics content for the current locktime/threshold values.
|
||
|
||
Used by :class:`BalCalendarButton` as its content provider.
|
||
"""
|
||
from datetime import datetime, timedelta
|
||
|
||
try:
|
||
locktime = self.widgets["locktime"].alarm
|
||
|
||
if self.bal_window.bal_plugin.is_basic_mode():
|
||
days_to_deadline = (locktime - datetime.now()).days
|
||
offsets = basic_reminder_offsets(days_to_deadline)
|
||
else:
|
||
threshold = self.widgets["threshold"].alarm
|
||
days = (locktime - threshold).days
|
||
try:
|
||
count = int(self.bal_window.bal_plugin.NUM_REMINDERS.get())
|
||
except Exception:
|
||
count = 3
|
||
offsets = compute_reminder_offsets(days, count)
|
||
|
||
now = BalCalendar.format_time(datetime.now())
|
||
|
||
heirs_details = "\r\n".join(
|
||
f" {heir} - {self.bal_window.heirs[heir][0]}, "
|
||
f"{self.bal_window.heirs[heir][1]}"
|
||
for heir in self.bal_window.heirs
|
||
)
|
||
|
||
if self.bal_window.bal_plugin.is_basic_mode():
|
||
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.default
|
||
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.default
|
||
else:
|
||
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.get()
|
||
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.get()
|
||
|
||
event_description = BalCalendar.ical_escape(
|
||
f"{raw_description}"
|
||
.replace("$wallet_name", str(self.bal_window.wallet))
|
||
.replace("$heirs_complete", heirs_details)
|
||
)
|
||
summary_base = (
|
||
f"{raw_summary}"
|
||
.replace("$wallet_name", str(self.bal_window.wallet))
|
||
)
|
||
|
||
lines = [
|
||
"BEGIN:VCALENDAR",
|
||
"VERSION:2.0",
|
||
"PRODID:-//Bitcoin After Life//Electrum Plugin/"
|
||
f"{BalPlugin.__version__}",
|
||
]
|
||
|
||
total = len(offsets)
|
||
for idx, offset in enumerate(offsets, start=1):
|
||
event_dt = BalCalendar.format_time(locktime - timedelta(days=offset))
|
||
summary = BalCalendar.ical_escape(
|
||
f"{summary_base} (reminder {idx}/{total})"
|
||
)
|
||
lines.extend([
|
||
"BEGIN:VEVENT",
|
||
f"UID:bal-{str(self.bal_window.wallet)}-{offset}d",
|
||
f"DTSTAMP:{now}",
|
||
f"DTSTART:{event_dt}",
|
||
f"DTEND:{event_dt}",
|
||
f"SUMMARY:{summary}",
|
||
f"DESCRIPTION:{event_description}",
|
||
"END:VEVENT",
|
||
])
|
||
|
||
lines.append("END:VCALENDAR")
|
||
lines = [s.rstrip("\r\n") for s in lines]
|
||
return "\r\n".join(lines) + "\r\n"
|
||
except Exception as e:
|
||
_logger.error(f"failed to generate .ics: {e}")
|
||
return None
|
||
|
||
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
|
||
|
||
# VISUAL WARNING (ToDo #5): the Check Alive must always be EARLIER than
|
||
# the delivery time. If the user sets a Check Alive that is later than
|
||
# (or equal to) the delivery time, tint its box with a soft red so the
|
||
# problem is obvious immediately, instead of only surfacing later as a
|
||
# build error. Applied only in ADVANCED mode (in BASIC the Check Alive
|
||
# is hidden and not editable, so there is nothing to warn about). The
|
||
# tint is cleared again as soon as the relationship becomes valid.
|
||
try:
|
||
SOFT_RED = "#FCE4E4"
|
||
threshold_box = self.widgets["threshold"]
|
||
if not self.bal_window.bal_plugin.is_basic_mode():
|
||
if threshold.to_timestamp() >= locktime.to_timestamp():
|
||
# Only set a background colour, and DO NOT touch the border:
|
||
# styling the border of a QDateTimeEdit/QAbstractSpinBox via
|
||
# stylesheet makes Qt stop drawing its native up/down spin
|
||
# arrows. Scoping the rule to the inner editor widget types
|
||
# (rather than the whole container) keeps the tint on the
|
||
# value box while leaving the arrows intact.
|
||
threshold_box.setStyleSheet(
|
||
"QDateTimeEdit, QLineEdit, QAbstractSpinBox "
|
||
"{ background-color: %s; }" % SOFT_RED
|
||
)
|
||
threshold_box.setToolTip(
|
||
_(
|
||
"The Check Alive date must be earlier than the "
|
||
"delivery time."
|
||
)
|
||
)
|
||
else:
|
||
threshold_box.setStyleSheet("")
|
||
threshold_box.setToolTip("")
|
||
else:
|
||
# BASIC: never show the warning styling (field is hidden).
|
||
threshold_box.setStyleSheet("")
|
||
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 BalSpinBox(QSpinBox):
|
||
"""Integer spin box bound to a BalConfig value (Group D / D1).
|
||
|
||
Mirrors BalCheckBox / BalLineEdit: it shows the persisted value on creation
|
||
and writes the new value back to the config whenever the user changes it.
|
||
|
||
Args:
|
||
variable: the BalConfig accessor to read from / write to.
|
||
minimum: smallest selectable value (default 1).
|
||
maximum: largest selectable value (default 5, used by "Number of
|
||
reminders").
|
||
on_change: optional callback invoked after the value is persisted.
|
||
"""
|
||
|
||
def __init__(self, variable, minimum=1, maximum=5, on_change=None):
|
||
QSpinBox.__init__(self)
|
||
self.setMinimum(minimum)
|
||
self.setMaximum(maximum)
|
||
# Coerce the stored value to int and clamp it into the allowed range,
|
||
# so a stale/invalid config value can never push the spin box out of
|
||
# bounds.
|
||
try:
|
||
current = int(variable.get())
|
||
except Exception:
|
||
current = minimum
|
||
current = max(minimum, min(maximum, current))
|
||
self.setValue(current)
|
||
self.on_change = on_change
|
||
|
||
def on_value_changed(v):
|
||
variable.set(int(v))
|
||
if self.on_change:
|
||
self.on_change()
|
||
|
||
self.valueChanged.connect(on_value_changed)
|
||
|
||
|
||
|
||
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))
|
||
|
||
|