feat(bal): Group C settings-dialog improvements (editable dates, narrower RAW box, warning/reset/support, tooltips, bold locktime)

C2 - 'Editable dates' option (default OFF):
  - new EDITABLE_DATES config; checkbox in settings dialog
  - WillSettingsWidget.apply_editable_dates() re-locks/unlocks the
    delivery-time and check-alive fields, called from update_all() so the
    toggle takes effect immediately (same mechanism as Hide Invalidated)
  - EDITABLE_DATES included in the Reset-to-defaults list

C3 - RAW date box narrowed to ~1/3 (input box 6 chars); the trailing
  label that shows the computed absolute date kept at 10 chars (fixed a
  truncation regression)

C4 - settings dialog:
  (a) bold red warning at the top
  (b) 'Reset setting' button restoring all 7 dialog settings to defaults
      (single source of truth: BalConfig.default), not touching wills
  (c) bold 'Support: bitcoin-after.life' link (opens via webopen)

C5 - tooltips: calendar 'Export reminder dates to your calendar (.ics)',
  fee field 'Mining fee rate in sat/vByte used for the will transactions',
  fee '丰' icon 'Miner fee, click for more information' (tooltip font scoped
  to QPushButton so it matches the other tooltips)

C6 - Locktime column rendered in bold in the will list

tests/test_group_c_settings.py: 4 new tests (EDITABLE_DATES default/toggle,
  reset restores all dialog settings incl. EDITABLE_DATES, reset leaves
  unrelated config untouched). Full suite: 210 passed.
This commit is contained in:
2026-06-28 23:00:30 -04:00
parent dc166d04ff
commit 9d2cbc2814
8 changed files with 504 additions and 18 deletions

View File

@@ -39,7 +39,7 @@ from electrum.gui.qt.util import (Buttons, CancelButton, ColorScheme,
OkButton, TaskThread, WindowModalDialog,
char_width_in_lineedit, getSaveFileName,
import_meta_gui, read_QIcon_from_bytes,
read_QPixmap_from_bytes)
read_QPixmap_from_bytes, webopen)
from electrum.i18n import _
from electrum.logging import get_logger
from electrum.network import BestEffortRequestFailed, Network, TxBroadcastError

View File

@@ -403,6 +403,16 @@ class PreviewList(MyTreeView, MessageBoxMixin):
items[-1].setBackground(QColor(status_color(bal_tx)))
# Group C / C6: emphasise the Locktime column by rendering it in bold,
# so the delivery time stands out at a glance in the list.
try:
locktime_item = items[self.Columns.LOCKTIME]
bold_font = locktime_item.font()
bold_font.setBold(True)
locktime_item.setFont(bold_font)
except Exception as bold_err:
_logger.debug(f"locktime bold error: {bold_err}")
# 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:

View File

@@ -396,13 +396,42 @@ class Plugin(BalPlugin):
# default is ON (see plugin_base.py).
heir_auto_sign = BalCheckBox(self.AUTO_SIGN)
# Editable dates checkbox (Group C / C2). When ticked, the delivery-time
# and check-alive date fields become editable everywhere (toolbar /
# Heirs tab), not only inside the "Build your will" wizard. Bound to the
# persisted EDITABLE_DATES config; the default is OFF (see
# plugin_base.py). Changing it refreshes the open windows so the date
# fields immediately become editable/read-only.
heir_editable_dates = BalCheckBox(self.EDITABLE_DATES, on_multiverse_change)
# Editable line/text widgets are created once and kept in named
# variables so the "Reset" button (Group C / C4b) can refresh the
# displayed values after resetting the underlying config.
edit_calendar_app = BalLineEdit(self.CALENDAR_APP)
edit_event_summary = BalLineEdit(self.EVENT_SUMMARY)
edit_event_description = BalTextEdit(self.EVENT_DESCRIPTION)
heir_repush = QPushButton("Rebroadcast transactions")
heir_repush.clicked.connect(partial(self.broadcast_transactions, True))
bal_mode = QComboBox()
options = ["Easy", "Advanced", "Experimental"]
bal_mode.addItems(options)
grid = QGridLayout(d)
# Group C / C4a: warning shown at the very top of the dialog, in red and
# bold, so the (non-technical) user is reminded not to touch these
# settings unless they understand them. Placed above the grid via the
# outer vertical layout below.
lbl_warning = QLabel(
_("Warning: change these settings only if you know what you are doing.")
)
lbl_warning.setStyleSheet("color: red; font-weight: bold;")
lbl_warning.setWordWrap(True)
# The grid is created WITHOUT a parent so it can be embedded inside an
# outer QVBoxLayout together with the warning label (top) and the
# Reset/support button row (bottom). Assigning ``QGridLayout(d)`` would
# have made the grid the dialog's only layout, leaving no room for them.
grid = QGridLayout()
add_widget(
grid,
"Hide Replaced",
@@ -431,16 +460,28 @@ class Plugin(BalPlugin):
)
add_widget(
grid,
"Calendar App",
BalLineEdit(self.CALENDAR_APP),
"Editable dates",
heir_editable_dates,
4,
(
"When enabled, the delivery-time and check-alive date fields "
"can be edited everywhere (toolbar / Heirs tab), not only in "
"the will-building wizard.\n"
"When disabled, those dates are display-only outside the wizard."
),
)
add_widget(
grid,
"Calendar App",
edit_calendar_app,
5,
"Default app used to open calendar",
)
add_widget(
grid,
"Event summary",
BalLineEdit(self.EVENT_SUMMARY),
5,
edit_event_summary,
6,
(
"Default message to be used in event summary\n"
"Variables:\n"
@@ -452,8 +493,8 @@ class Plugin(BalPlugin):
add_widget(
grid,
"Event description",
BalTextEdit(self.EVENT_DESCRIPTION),
6,
edit_event_description,
7,
(
"Default message to be used in event description\n"
"Variables:\n"
@@ -486,15 +527,87 @@ class Plugin(BalPlugin):
# "Add transactions without willexecutor",
# )
# add_widget(grid,"Enable Multiverse(EXPERIMENTAL/BROKEN)",heir_enable_multiverse,6,"enable multiple locktimes, will import.... ")
grid.addWidget(heir_repush, 7, 0)
grid.addWidget(heir_repush, 8, 0)
grid.addWidget(
HelpButton(
"Broadcast all transactions to willexecutors including those already pushed"
),
7,
8,
2,
)
# ----------------------------------------------------------------- #
# Group C / C4b: "Reset" button that restores the dialog settings to #
# their factory defaults. It only resets the settings exposed by THIS #
# dialog (the 6 below) and refreshes the corresponding widgets so the #
# change is visible immediately. It deliberately does NOT touch the #
# wills, will-executors or any other configuration. #
# ----------------------------------------------------------------- #
def on_reset_defaults():
"""Reset the six dialog settings to their defaults and refresh widgets.
The default value of each setting is taken from ``BalConfig.default``
(the third argument used when the config was created in
``plugin_base.py``), so there is a single source of truth and no
hard-coded duplicates here.
"""
# Map each config object to the widget that displays it, so we can
# both reset the stored value and update what the user sees.
resets = [
(self.HIDE_REPLACED, heir_hide_replaced, "check"),
(self.HIDE_INVALIDATED, heir_hide_invalidated, "check"),
(self.AUTO_SIGN, heir_auto_sign, "check"),
(self.EDITABLE_DATES, heir_editable_dates, "check"),
(self.CALENDAR_APP, edit_calendar_app, "line"),
(self.EVENT_SUMMARY, edit_event_summary, "line"),
(self.EVENT_DESCRIPTION, edit_event_description, "text"),
]
for cfg, widget, kind in resets:
# Persist the default value back into the Electrum config.
cfg.set(cfg.default)
# Refresh the widget so the reset is immediately visible. The
# widgets' own signal handlers will re-persist the same default
# value, which is harmless.
if kind == "check":
widget.setChecked(bool(cfg.default))
elif kind == "line":
widget.setText(cfg.default)
elif kind == "text":
widget.setPlainText(cfg.default)
# Refresh the open BAL windows so any dependent view (e.g. the
# editable-dates state is not in this list, but hide filters are)
# reflects the reset values.
self.update_all()
btn_reset = QPushButton(_("Reset setting"))
btn_reset.setToolTip(_("Reset these settings to their default values"))
btn_reset.clicked.connect(on_reset_defaults)
# Group C / C4c: clickable support link to the project website.
lbl_support = QLabel(
'<a href="https://bitcoin-after.life"><b>bitcoin-after.life</b></a>'
)
lbl_support.setToolTip(_("Open the Bitcoin After Life support website"))
# Open the link in the user's browser via Electrum's helper instead of
# letting Qt open it directly, so it goes through Electrum's policy.
lbl_support.setOpenExternalLinks(False)
lbl_support.linkActivated.connect(
lambda _url: webopen("https://bitcoin-after.life")
)
# Bottom row: Reset on the left, support link on the right.
bottom_row = QHBoxLayout()
bottom_row.addWidget(btn_reset)
bottom_row.addStretch(1)
bottom_row.addWidget(QLabel("<b>" + _("Support:") + "</b>"))
bottom_row.addWidget(lbl_support)
# Outer layout: warning (top) -> settings grid -> bottom button row.
outer = QVBoxLayout(d)
outer.addWidget(lbl_warning)
outer.addLayout(grid)
outer.addLayout(bottom_row)
if ret := bool(show_modal(d)):
try:
self.update_all()

View File

@@ -43,6 +43,10 @@ class BalTxFeesWidget(QWidget):
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
@@ -58,7 +62,14 @@ class BalTxFeesWidget(QWidget):
#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;")
# 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
@@ -322,6 +333,11 @@ class TimeRawEditWidget(QWidget):
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)
@@ -343,7 +359,11 @@ class TimeRawEditWidget(QWidget):
class LockTimeRawEdit(QLineEdit, _LockTimeEditor):
def __init__(self, parent=None, time_edit=None):
QLineEdit.__init__(self, parent)
self.setFixedWidth(12 * char_width_in_lineedit())
# 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
@@ -554,8 +574,10 @@ class WillSettingsWidget(QWidget):
self.bal_window.bal_plugin.read_file("icons/calendar.png")
)
)
# Tooltip so the icon is self-explanatory when hovered.
self.calendar_button.setToolTip(_("Calendar"))
# Tooltip so the icon is self-explanatory when hovered (Group C / C5).
self.calendar_button.setToolTip(
_("Export reminder dates to your calendar (.ics)")
)
self.calendar_button.clicked.connect(self.open_or_save_calendar)
self.widgets["locktime"] = LockTimeWidget(bal_window, self)
self.widgets["threshold"] = ThresholdTimeWidget(bal_window, self)
@@ -623,10 +645,42 @@ class WillSettingsWidget(QWidget):
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)
# 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 date fields.
Outside the "Build your will" wizard (``read_only=True``) the
delivery-time and check-alive dates are display-only by default. When
the user ticks "Editable dates" in the settings they become editable
here too. The fee field always stays read-only outside the wizard,
because C2 only concerns 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
editable_dates = False
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)
self.widgets["baltx_fees"].set_read_only(True)
def create_alarms(self, alarm_start, alarm_end):
days = (alarm_end - alarm_start).days+1

View File

@@ -1300,6 +1300,20 @@ class BalWindow:
self.heirs_tab.update()
self.will_tab.update()
self.will_list_widget.update()
# Group C / C2: re-apply the "Editable dates" setting to the date
# fields of the toolbars / Heirs tab. The settings checkbox calls
# update_all() when toggled, so this makes the change take effect
# immediately (same pattern as sync_hide_filters above for the
# "Hide Invalidated/Replaced" checkboxes). Guarded so a missing
# widget never breaks the rest of the refresh.
for _list in (self.heir_list_widget, self.will_list_widget):
_settings_widget = getattr(_list, "will_settings_widget", None)
if _settings_widget is not None:
try:
_settings_widget.apply_editable_dates()
except Exception as _edit_err:
_logger.debug(f"apply_editable_dates error: {_edit_err}")
except Exception as e:
_logger.error(f"error while updating window: {e}")