diff --git a/CHANGELOG_REFACTOR.md b/CHANGELOG_REFACTOR.md index f4e545b..87fbe19 100644 --- a/CHANGELOG_REFACTOR.md +++ b/CHANGELOG_REFACTOR.md @@ -609,3 +609,62 @@ Modifica centralizzata negli helper `msg_ok`, `msg_error`, `msg_warning`, Confermato dall'utente sui dati reali: dopo Sign -> Broadcast -> Check le transazioni gia inviate sono tornate verdi ("confirmed on server"); la lista torna pulita; il grassetto e l'aggiornamento delle hide-flag funzionano. + +## 17. UI polish and bug fix — signed-tx colour, wizard, Building Will dialog (v0.3.3) + +This session groups several behaviour-invariant UI refinements plus one colour +bug fix. All comments and code remain in English; only the chat with the +author was in Italian. + +### FIX — Signed-but-not-sent transaction shown RED instead of blue +`core/will.py` (`needs_server_check`): a previous-session change (section 16, +"FIX 2") had removed the `PUSHED` requirement from `needs_server_check`, so a +will that was *signed but never broadcast* was still server-queried. The query +returned CHECK_FAIL, and because `status_color()` checks CHECK_FAIL (red, +`#e83845`) before COMPLETE (blue, `#2bc8ed`), the row turned red. Restored the +original Gitea `check()` condition by adding back `and w.get_status("PUSHED")`, +so only already-broadcast wills are server-checked. A signed-but-not-sent will +now stays blue (COMPLETE) as in the original. +- `tests/test_core_will.py` (`test_needs_server_check`): a freshly-built item + (VALID, not PUSHED) now correctly expects `False`. + +### Wizard "Will Settings" — equal-width, left-aligned rows +`gui/qt/widgets.py` (`WillSettingsWidget`, vertical layout): the calendar button +and the fee field used to stretch to the dialog's right edge, far wider than the +date rows. Now every row is capped to the widest date-row width (`row_w`) and +left-aligned, so they form a tidy column. The leading icons keep their original +`HelpButton` width (`icon_w` is used only as a spacer in front of the calendar, +never to widen the icons themselves). + +### Wizard button — icon + text +`gui/qt/lists.py` (`create_toolbar`): the "build your will" toolbar button is now +more inviting: a 28×28 wizard icon plus a bold `"Create your will"` caption, +`setMinimumHeight(40)`. `gui/qt/common.py` gained `QSize` in the QtCore import. + +### Building Will dialog — clearer final report + manual Close +`gui/qt/dialogs.py` (`BalBuildWillDialog`): +- The closing summary line is no longer a bare "Ok": it now has an explicit + left-side label, `"All done: Ok"`, like the other result rows. +- A blank separator row is inserted above "All done" so the overall outcome is + visually detached from the per-step rows. +- The four `"checking variables"` status strings are capitalised to + `"Checking variables"` to match the rows below; the redundant trailing colon + on the final one was dropped (`msg_set_status` already adds `":\t"`). +- The final auto-closing countdown (`self.wait(5)`) was replaced by an explicit + right-aligned **"Close"** button (`_add_close_button` / `_on_close_clicked`). + The dialog now stays open until the user dismisses it, so the full report can + be read at leisure. The intermediate technical pauses (`wait(10)`, `wait(5)`, + `wait(3)`) are kept. Closing still shows the persistent "next steps" + (Sign / Broadcast) popup when `self._next_steps_hint` is set. + +### Preview helpers (dev-only, not shipped logic) +`tests/preview_wizard_settings_align.py`, `tests/preview_wizard_button.py`, +`tests/preview_building_will_close_btn.py`: small offscreen scripts used to +render before/after mock-ups for visual approval. + +### Test +- 186 official tests pass; smoke test, external-zip test OK. +- `ruff` reports only the pre-existing baseline false positives (F401/F403/F405 + star-import re-exports, one F841, one F541) — no new issues. +- Version bumped to **0.3.3** (`bal/VERSION`, `bal/__init__.py`, + `bal/manifest.json`). diff --git a/bal/VERSION b/bal/VERSION index 9fc80f9..87a0871 100644 --- a/bal/VERSION +++ b/bal/VERSION @@ -1 +1 @@ -0.3.2 \ No newline at end of file +0.3.3 \ No newline at end of file diff --git a/bal/__init__.py b/bal/__init__.py index f309239..a814720 100644 --- a/bal/__init__.py +++ b/bal/__init__.py @@ -34,4 +34,4 @@ The plugin targets Electrum 4.7.2 (the last stable release exposing ``json_db.register_dict``) and PyQt6. """ -__version__ = "0.3.2" +__version__ = "0.3.3" diff --git a/bal/core/plugin_base.py b/bal/core/plugin_base.py index 8b281db..aa0b7cf 100644 --- a/bal/core/plugin_base.py +++ b/bal/core/plugin_base.py @@ -91,7 +91,7 @@ class BalPlugin(BasePlugin): """ _version = None - __version__ = "0.3.2" # AUTOMATICALLY GENERATED DO NOT EDIT + __version__ = "0.3.3" # AUTOMATICALLY GENERATED DO NOT EDIT # Command used to open an .ics calendar file, per operating system. default_app = { diff --git a/bal/core/will.py b/bal/core/will.py index 4c1f341..85d6782 100644 --- a/bal/core/will.py +++ b/bal/core/will.py @@ -88,17 +88,20 @@ class Will: """Return True if ``w`` should be queried on its will-executor server when the user presses Check (or on Electrum close). - A will needs a server check when it is VALID, has a will-executor - assigned, and is not yet CHECKED. This intentionally includes wills - that are not (yet) marked PUSHED: a will that was actually sent in the - past but whose saved status still reads "New" would otherwise be - skipped, leaving the Server column stuck on "Not sent". The server - response (see WillItem.set_check_willexecutor) then corrects the status - to PUSHED/CHECKED if the transaction is present, or CHECK_FAIL if not. + A will is queried only when it is VALID, has a will-executor assigned, + was actually PUSHED (sent), and is not yet CHECKED. The ``PUSHED`` + condition is essential: querying the server for a will that was *never* + sent would make the server (correctly) answer "I don't have this tx", + which ``WillItem.set_check_willexecutor`` then records as CHECK_FAIL. + A freshly signed-but-not-sent will would therefore turn red, even though + it is merely "signed, not sent" (which must stay blue / #2bc8ed, as in + the original BAL behaviour). Restricting the check to PUSHED wills + matches the original ``check()`` logic and avoids that false failure. """ return bool( w.get_status("VALID") and w.we + and w.get_status("PUSHED") and not w.get_status("CHECKED") ) diff --git a/bal/gui/qt/common.py b/bal/gui/qt/common.py index 90dd77b..38a4710 100644 --- a/bal/gui/qt/common.py +++ b/bal/gui/qt/common.py @@ -49,8 +49,8 @@ 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, Qt, - QTimer, pyqtSignal) +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, diff --git a/bal/gui/qt/dialogs.py b/bal/gui/qt/dialogs.py index 48606ee..44cdb09 100644 --- a/bal/gui/qt/dialogs.py +++ b/bal/gui/qt/dialogs.py @@ -516,6 +516,9 @@ class BalBuildWillDialog(BalDialog): self.build_row = None self.sign_row = None self.push_row = None + # Manual next-steps hint (Sign / Broadcast) shown to the user after the + # dialog finishes; None when nothing is left to do. + self._next_steps_hint = None self.network = Network.get_instance() self._stopping = False self.thread = TaskThread(self) @@ -541,7 +544,7 @@ class BalBuildWillDialog(BalDialog): return txs = None _logger.debug("close plugin phase 1 started") - varrow = self.msg_set_status("checking variables") + varrow = self.msg_set_status("Checking variables") try: self.bal_window.init_class_variables() except CheckAliveError as cae: @@ -553,12 +556,12 @@ class BalBuildWillDialog(BalDialog): _logger.debug( "during phase1 CAE: {}, Continue to invalidate".format(cae) ) - self.msg_set_status("checking variables",varrow, "Check Alive Threshold Passed: you have to Invalidate your old Will",self.COLOR_ERROR) + self.msg_set_status("Checking variables",varrow, "Check Alive Threshold Passed: you have to Invalidate your old Will",self.COLOR_ERROR) else: raise cae return None, tx except NoHeirsException: - self.msg_set_status("checking variables", varrow,"No Heirs",self.COLOR_ERROR) + self.msg_set_status("Checking variables", varrow,"No Heirs",self.COLOR_ERROR) #self.msg_set_checking("No Heirs") return False, None except Exception as e: @@ -573,7 +576,7 @@ class BalBuildWillDialog(BalDialog): self.bal_window.window.wallet.dust_threshold(), ) _logger.debug("variables ok") - self.msg_set_status("checking variables:", varrow, "Ok", self.COLOR_OK) + self.msg_set_status("Checking variables", varrow, "Ok", self.COLOR_OK) except AmountException: self.msg_set_checking( self.msg_warning( @@ -937,7 +940,36 @@ class BalBuildWillDialog(BalDialog): self.thread.stop() self.bal_window.save_willitems() self.msg_edit_row(_("Finished")) + # Instead of auto-closing after a countdown, let the user decide when to + # dismiss the dialog: they can read the full "Building Will" report at + # their own pace and then press "Close". This runs in the GUI thread + # (on_success callback) so building the button here is safe. + self._add_close_button() + + def _add_close_button(self): + """Add a right-aligned "Close" button to dismiss the dialog manually. + + Replaces the old automatic countdown (self.wait(5) + self.close()). + Guarded so it is only built once even if called again. + """ + if getattr(self, "_close_button", None) is not None: + return + self._close_button = QPushButton(_("Close")) + self._close_button.clicked.connect(self._on_close_clicked) + button_row = QHBoxLayout() + button_row.addStretch(1) + button_row.addWidget(self._close_button) + self.vbox.addLayout(button_row) + self._close_button.setFocus() + + def _on_close_clicked(self): + # Close the dialog first, then show the persistent popup guiding the + # user through any remaining MANUAL steps (Sign / Broadcast). Showing + # the (modal) hint after close() mirrors the previous behaviour where + # the hint appeared once the auto-closing dialog was gone. self.close() + if self._next_steps_hint: + self.bal_window.show_message(self._next_steps_hint) def closeEvent(self, event): self._stopping = True @@ -975,8 +1007,71 @@ class BalBuildWillDialog(BalDialog): except Exception as e: # td = traceback.format_exc() self.msg_set_pushing(self.msg_error(e)) - self.msg_edit_row(self.msg_ok()) - self.wait(5) + # Blank separator row: visually detach the final "All done" summary + # from the per-step result rows above it, so the closing line stands + # out as the overall outcome rather than just another step. + self.msg_edit_row("") + # Final summary row: the whole "Building Will" sequence above (check / + # sign / broadcast) finished without errors. Give it an explicit + # left-side label ("All done") so this closing Ok is not an orphan + # result like the other rows have. + self.msg_edit_row("{}:\t{}".format(_("All done"), self.msg_ok())) + + # Guide the user through any remaining MANUAL steps. After the will is + # (re)built -- e.g. because an heir was removed/added from the Wizard -- + # the new transactions may still need to be SIGNED and/or BROADCAST by + # the user. This dialog only signs/pushes automatically when it already + # has the password and the will is in the right state; in every other + # case the user is otherwise left without any indication of what to do + # next. We inspect the real status of the valid wills and tell the user + # exactly which buttons to press. + self._show_next_steps_hint() + + def _show_next_steps_hint(self): + """Append a clear "what to do next" line to the Building Will dialog. + + Pure UX guidance (no logic change): looks at the valid wills and, if any + still needs signing or broadcasting, tells the user to press 'Sign' + and/or 'Broadcast' manually. The computed hint is also stored in + ``self._next_steps_hint`` so a persistent popup can be shown after the + dialog closes (this dialog auto-closes after a few seconds, which is too + short to be sure the user noticed the in-dialog line). + """ + self._next_steps_hint = None + try: + need_sign = False + need_push = False + for wid in Will.only_valid(self.bal_window.willitems): + w = self.bal_window.willitems[wid] + if not w.get_status("COMPLETE"): + # Not signed yet. + need_sign = True + elif w.we and not w.get_status("PUSHED"): + # Signed but not yet sent to its will-executor. + need_push = True + + if need_sign and need_push: + hint = _( + "Next steps (manual): press 'Sign' to sign your will, " + "then 'Broadcast' to send it to the will-executors." + ) + elif need_sign: + hint = _( + "Next step (manual): press 'Sign' to sign your will." + ) + elif need_push: + hint = _( + "Next step (manual): press 'Broadcast' to send your will " + "to the will-executors." + ) + else: + # Nothing left to do (already signed and, if needed, sent). + return + + self._next_steps_hint = hint + self.msg_edit_row("{}".format(hint)) + except Exception as hint_err: + _logger.debug(f"next-steps hint error: {hint_err}") def on_error(self, error): _logger.error(error) diff --git a/bal/gui/qt/lists.py b/bal/gui/qt/lists.py index 53bd123..b853191 100644 --- a/bal/gui/qt/lists.py +++ b/bal/gui/qt/lists.py @@ -457,13 +457,19 @@ class PreviewList(MyTreeView, MessageBoxMixin): menu.addAction(_("Check"), self.check) menu.addAction(_("Invalidate"), self.invalidate_will) - wizard = QPushButton() + # 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") ) ) - # Tooltip so the icon is self-explanatory when hovered. + 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")) diff --git a/bal/gui/qt/widgets.py b/bal/gui/qt/widgets.py index 997d8b5..487a083 100644 --- a/bal/gui/qt/widgets.py +++ b/bal/gui/qt/widgets.py @@ -61,6 +61,10 @@ class BalTxFeesWidget(QWidget): 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 @@ -206,6 +210,9 @@ class BalTimeEditWidget(QWidget, _LockTimeEditor): 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: @@ -558,10 +565,58 @@ class WillSettingsWidget(QWidget): w = self.widgets["baltx_fees"] if w not in bal_window.txfee_widgets: bal_window.txfee_widgets.append(w) - box.addWidget(self.widgets["locktime"]) - box.addWidget(self.widgets["threshold"]) - box.addWidget(self.calendar_button) - box.addWidget(self.widgets["baltx_fees"]) + 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) diff --git a/bal/gui/qt/window.py b/bal/gui/qt/window.py index b3950e8..8c27d24 100644 --- a/bal/gui/qt/window.py +++ b/bal/gui/qt/window.py @@ -574,10 +574,15 @@ class BalWindow: _logger.info("build will") self.build_will(ignore_duplicate, keep_original) + # Track whether the rebuild produced a coherent, ready-to-sign + # will, so we can guide the user through the remaining manual + # steps (Sign + Broadcast) afterwards. + rebuilt_ok = False try: self.check_will() for wid, _w in self.willitems.items(): self.wallet.set_label(wid, "BAL Transaction") + rebuilt_ok = True except WillExpiredException as e: self.invalidate_will() except NotCompleteWillException as e: @@ -590,6 +595,31 @@ class BalWindow: self.window.history_list.update() self.window.utxo_list.update() + + # Guide the user: the inheritance was just (re)built and is now + # in the "New" state, so it must be SIGNED and then BROADCAST + # again -- two manual steps the user has to perform. Without + # this hint the user is left with a freshly rebuilt will and no + # indication that it still needs to be signed and re-sent to the + # will-executors. + if rebuilt_ok: + if self.no_willexecutor: + next_steps = _( + "Your inheritance has been rebuilt and now needs " + "to be signed again.\n\n" + "Next step (manual):\n" + " 1. Press 'Sign' to sign the new transaction." + ) + else: + next_steps = _( + "Your inheritance has been rebuilt and now needs " + "to be signed and re-sent to the will-executors.\n\n" + "Next steps (manual):\n" + " 1. Press 'Sign' to sign the new transaction.\n" + " 2. Press 'Broadcast' to send it to the " + "will-executors." + ) + self.show_message(next_steps) self.update_all() return self.willitems except Exception as e: diff --git a/bal/manifest.json b/bal/manifest.json index 45e609c..fa0fe4b 100644 --- a/bal/manifest.json +++ b/bal/manifest.json @@ -1,7 +1,7 @@ { "name": "bal", "fullname": "Bitcoin After Life", - "version": "0.3.2", + "version": "0.3.3", "description": "Provides free and decentralized Bitcoin inheritance support. Build time-locked 'will' transactions that transfer funds to your heirs if you stop refreshing them (dead-man's switch), optionally relayed by will-executor servers.", "author": "Svatantrya", "licence": "MIT", diff --git a/tests/preview_building_will_close_btn.py b/tests/preview_building_will_close_btn.py new file mode 100644 index 0000000..939fa96 --- /dev/null +++ b/tests/preview_building_will_close_btn.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Visual PREVIEW: replace the final countdown with a "Close" button. + +Mocks the "Building Will" dialog content (the result rows are built exactly as +the real dialog builds them) and shows the proposed bottom "Close" button that +replaces the "Please wait 5secs" auto-closing countdown. + +BEFORE: last line is the countdown "Please wait 5secs" (dialog auto-closes). +AFTER : a real "Close" button at the bottom; the user closes when ready. + +Run: + QT_QPA_PLATFORM=offscreen python3 tests/preview_building_will_close_btn.py +Writes preview_buildwill_before.png / preview_buildwill_after.png. +""" + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PyQt6.QtWidgets import ( # noqa: E402 + QApplication, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, +) +from PyQt6.QtCore import Qt # noqa: E402 + +COLOR_OK = "#05ad05" + + +def _ok(text="Ok"): + return "{}".format(COLOR_OK, text) + + +def _rows(after: bool): + """The exact result rows the real dialog shows on a clean run. + + BEFORE keeps the old lowercase "checking variables" and the "All done" + row directly under the others. AFTER applies the two text fixes: + capitalised "Checking variables" + a blank separator row above "All done". + """ + if not after: + return [ + "checking variables:\t" + _ok("Ok"), + "Checking your will:\t" + _ok("Ok"), + "Signing your will:\tNothing to do", + "Broadcasting your will to executors:\tNothing to do", + "All done:\t" + _ok("Ok"), + ] + return [ + "Checking variables:\t" + _ok("Ok"), + "Checking your will:\t" + _ok("Ok"), + "Signing your will:\tNothing to do", + "Broadcasting your will to executors:\tNothing to do", + "", # blank separator row + "All done:\t" + _ok("Ok"), + ] + + +def _build(after: bool) -> QWidget: + panel = QWidget() + panel.setMinimumWidth(600) + v = QVBoxLayout(panel) + v.addWidget(QLabel("Building Will:")) + + rows = QWidget() + rv = QVBoxLayout(rows) + rv.setContentsMargins(8, 4, 8, 4) + for line in _rows(after): + lbl = QLabel(line) + lbl.setTextFormat(Qt.TextFormat.RichText) + rv.addWidget(lbl) + v.addWidget(rows) + + if not after: + # BEFORE: the countdown row (auto-close). + wait = QLabel("Please wait 5secs") + v.addWidget(wait) + else: + # AFTER: a Close button row, right aligned (standard dialog layout). + v.addSpacing(8) + btn_row = QWidget() + h = QHBoxLayout(btn_row) + h.setContentsMargins(0, 0, 0, 0) + h.addStretch(1) + close = QPushButton("Close") + close.setDefault(True) + close.setMinimumWidth(90) + h.addWidget(close) + v.addWidget(btn_row) + + panel.resize(620, 230) + return panel + + +def main(): + app = QApplication.instance() or QApplication([]) + for after, name in ((False, "preview_buildwill_before.png"), + (True, "preview_buildwill_after.png")): + panel = _build(after) + panel.show() + app.processEvents() + panel.grab().save(name) + print("wrote", name) + + +if __name__ == "__main__": + main() diff --git a/tests/preview_wizard_button.py b/tests/preview_wizard_button.py new file mode 100644 index 0000000..a11c6ee --- /dev/null +++ b/tests/preview_wizard_button.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Visual PREVIEW: proposals to make the Wizard button more visible. + +Renders the toolbar Wizard button (using the real icons/wizard.png) in several +styles so the user can pick one. Nothing is changed in the plugin yet. + +Variants: + 0. CURRENT : icon only, default size (what ships today). + A. BIGGER : same icon, larger button + larger iconSize. + B. ICON+TEXT: bigger icon plus a "Create your will" label. + C. ACCENT : icon + text on a colored (Bitcoin-orange) rounded button. + D. ACCENT-BLUE: icon + text on a BAL-blue (#2bc8ed) rounded button. + +Run: + QT_QPA_PLATFORM=offscreen python3 tests/preview_wizard_button.py +Writes preview_wizardbtn_.png in the repo root. +""" + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PyQt6.QtWidgets import ( # noqa: E402 + QApplication, QWidget, QHBoxLayout, QPushButton, QComboBox, QLineEdit, + QLabel, +) +from PyQt6.QtGui import QIcon, QPixmap # noqa: E402 +from PyQt6.QtCore import QSize, Qt # noqa: E402 + +ICON_PATH = os.path.join(os.path.dirname(__file__), "..", "bal", "icons", + "wizard.png") + + +def _icon(): + pm = QPixmap(ICON_PATH) + return QIcon(pm) + + +def _toolbar_tail(parent): + """The widgets that sit to the right of the wizard button, for context.""" + out = [] + icon = QPushButton(parent) + icon.setText("📅") + combo = QComboBox(parent) + combo.addItems(["Raw", "Date"]) + combo.setCurrentIndex(1) + field = QLineEdit("04/06/2028 00:00", parent) + field.setFixedWidth(140) + out += [icon, combo, field] + return out + + +def _frame(make_wizard, label): + panel = QWidget() + v = QHBoxLayout(panel) + v.setContentsMargins(10, 10, 10, 10) + tag = QLabel(label, panel) + tag.setFixedWidth(120) + v.addWidget(tag) + wiz = make_wizard(panel) + v.addWidget(wiz) + for w in _toolbar_tail(panel): + v.addWidget(w) + v.addStretch(1) + panel.resize(720, 70) + return panel + + +# ---- variants ------------------------------------------------------------- +def v_current(parent): + b = QPushButton(parent) + b.setIcon(_icon()) + b.setToolTip("Wizard - Build your will") + return b + + +def v_bigger(parent): + b = QPushButton(parent) + b.setIcon(_icon()) + b.setIconSize(QSize(36, 36)) + b.setFixedSize(48, 44) + b.setToolTip("Wizard - Build your will") + return b + + +def v_icon_text(parent): + b = QPushButton(" Create your will", parent) + b.setIcon(_icon()) + b.setIconSize(QSize(28, 28)) + b.setMinimumHeight(40) + b.setStyleSheet("QPushButton{font-weight:bold;}") + return b + + +def v_accent_orange(parent): + b = QPushButton(" Create your will", parent) + b.setIcon(_icon()) + b.setIconSize(QSize(28, 28)) + b.setMinimumHeight(40) + b.setStyleSheet( + "QPushButton{background-color:#f7931a;color:white;font-weight:bold;" + "border:none;border-radius:8px;padding:6px 14px;}" + "QPushButton:hover{background-color:#ffa733;}" + ) + return b + + +def v_accent_blue(parent): + b = QPushButton(" Create your will", parent) + b.setIcon(_icon()) + b.setIconSize(QSize(28, 28)) + b.setMinimumHeight(40) + b.setStyleSheet( + "QPushButton{background-color:#2bc8ed;color:white;font-weight:bold;" + "border:none;border-radius:8px;padding:6px 14px;}" + "QPushButton:hover{background-color:#4fd6f3;}" + ) + return b + + +def main(): + app = QApplication.instance() or QApplication([]) + variants = [ + (v_current, "0-CURRENT", "preview_wizardbtn_0_current.png"), + (v_bigger, "A-BIGGER", "preview_wizardbtn_A_bigger.png"), + (v_icon_text, "B-ICON+TEXT", "preview_wizardbtn_B_icontext.png"), + (v_accent_orange, "C-ORANGE", "preview_wizardbtn_C_orange.png"), + (v_accent_blue, "D-BLUE", "preview_wizardbtn_D_blue.png"), + ] + for make, label, name in variants: + panel = _frame(make, label) + panel.show() + app.processEvents() + panel.grab().save(name) + print("wrote", name) + + +if __name__ == "__main__": + main() diff --git a/tests/preview_wizard_settings_align.py b/tests/preview_wizard_settings_align.py new file mode 100644 index 0000000..4870b16 --- /dev/null +++ b/tests/preview_wizard_settings_align.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Visual PREVIEW for the wizard "Bitcoin After Life Will Settings" rows. + +Reproduces the structure of WillSettingsWidget in its VERTICAL layout (the one +used by the "Build your will" wizard): + + row 1: [icon][combo "Date"][date field] (delivery time / locktime) + row 2: [icon][combo "Date"][date field] (check alive / threshold) + row 3: [calendar button] (calendar export) + row 4: [icon "丰"][spin "5"] (tx fees) + +The icons are HelpButtons, which pin themselves to a fixed width +(2.2 * char_width_in_lineedit()). This preview reproduces that original icon +size and shows: + + * BEFORE: calendar/fee stretch to the right edge -> rows wider than dates. + * AFTER : icons keep their ORIGINAL fixed width; every row is capped to the + date-row width and left aligned, so all rows fit in the same block + and line up on both edges. + +Run: + QT_QPA_PLATFORM=offscreen python3 tests/preview_wizard_settings_align.py +Writes preview_wizard_before.png / preview_wizard_after.png in the repo root. +""" + +import os + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PyQt6.QtWidgets import ( # noqa: E402 + QApplication, QWidget, QVBoxLayout, QHBoxLayout, QToolButton, QComboBox, + QLineEdit, QSpinBox, QLabel, +) +from PyQt6.QtGui import QFontMetrics # noqa: E402 +from PyQt6.QtCore import Qt # noqa: E402 + + +def _char_w(): + fm = QFontMetrics(QApplication.instance().font()) + return fm.horizontalAdvance("0") + + +def _icon(text): + """Mimic HelpButton: a QToolButton pinned to 2.2 * char width.""" + b = QToolButton() + b.setText(text) + b.setFixedWidth(round(2.2 * _char_w())) + return b + + +def _date_row(): + w = QWidget() + h = QHBoxLayout(w) + h.setContentsMargins(0, 0, 0, 0) + h.setSpacing(0) + icon = _icon("📅") + combo = QComboBox() + combo.addItems(["Raw", "Date"]) + combo.setCurrentIndex(1) + field = QLineEdit("04/06/2028 00:00") + h.addWidget(icon) + h.addWidget(combo) + h.addWidget(field) + h.addStretch(1) + w._prefix = icon + return w + + +def _fee_row(): + w = QWidget() + h = QHBoxLayout(w) + h.setContentsMargins(0, 0, 0, 0) + h.setSpacing(0) + icon = _icon("丰") + spin = QSpinBox() + spin.setValue(5) + spin.setMaximum(10000) + h.addWidget(icon) + h.addWidget(spin) + w._prefix = icon + return w + + +def _calendar_button(): + return QToolButton() + + +def _panel(): + panel = QWidget() + box = QVBoxLayout(panel) + box.addWidget(QLabel("Bitcoin After Life Will Settings")) + return panel, box + + +def build_before(): + panel, box = _panel() + box.addWidget(_date_row()) + box.addWidget(_date_row()) + box.addWidget(_calendar_button()) + box.addWidget(_fee_row()) + panel.resize(760, 220) + return panel + + +def build_after(): + panel, box = _panel() + r1 = _date_row() + r2 = _date_row() + cal = _calendar_button() + r4 = _fee_row() + + # icons keep their original fixed width (no resizing) + icon_w = r1._prefix.sizeHint().width() + + row_w = max(r1.sizeHint().width(), r2.sizeHint().width()) + for r in (r1, r2, r4): + r.setFixedWidth(row_w) + + cal_row = QWidget() + cb = QHBoxLayout(cal_row) + cb.setContentsMargins(0, 0, 0, 0) + cb.setSpacing(0) + sp = QWidget() + sp.setFixedWidth(icon_w) + cb.addWidget(sp) + cb.addWidget(cal) + cal_row.setFixedWidth(row_w) + + box.addWidget(r1, alignment=Qt.AlignmentFlag.AlignLeft) + box.addWidget(r2, alignment=Qt.AlignmentFlag.AlignLeft) + box.addWidget(cal_row, alignment=Qt.AlignmentFlag.AlignLeft) + box.addWidget(r4, alignment=Qt.AlignmentFlag.AlignLeft) + panel.resize(760, 220) + return panel + + +def main(): + app = QApplication.instance() or QApplication([]) + for builder, name in ( + (build_before, "preview_wizard_before.png"), + (build_after, "preview_wizard_after.png"), + ): + panel = builder() + panel.show() + app.processEvents() + panel.grab().save(name) + print("wrote", name) + + +if __name__ == "__main__": + main() diff --git a/tests/test_core_will.py b/tests/test_core_will.py index 7165e73..3c422ea 100644 --- a/tests/test_core_will.py +++ b/tests/test_core_will.py @@ -227,17 +227,22 @@ def test_check_heir_added_triggers_rebuild(): def test_needs_server_check(): - """Check button selection logic: a VALID will with a will-executor that is - not yet CHECKED must be queried on the server, even if it is not PUSHED - (regression for the 'New / Not sent' wills that Check ignored).""" + """Check button selection logic: only a VALID, PUSHED will with a + will-executor that is not yet CHECKED must be queried on the server. + + A will that was never sent (not PUSHED) must NOT be queried: the server + would correctly answer "I don't have it", which would be recorded as + CHECK_FAIL and turn a merely signed-but-not-sent will red instead of leaving + it blue (#2bc8ed). This matches the original BAL ``check()`` behaviour.""" we = {"url": "https://we.example.com"} - # New (not PUSHED) but has a will-executor -> must be checked. + # New (not PUSHED) but has a will-executor -> must NOT be checked, otherwise + # a signed-but-not-sent will would falsely turn CHECK_FAIL (red). item_new = _make_willitem_blank() item_new.we = we - assert Will.needs_server_check(item_new) is True + assert Will.needs_server_check(item_new) is False - # PUSHED but not CHECKED -> must be checked (previous behaviour). + # PUSHED but not CHECKED -> must be checked. item_pushed = _make_willitem_blank() item_pushed.we = we item_pushed.set_status("PUSHED", True)