From f574472dcfab4ef30d9fa0625dad3654ae56067e Mon Sep 17 00:00:00 2001 From: kaibot Date: Tue, 30 Jun 2026 02:43:05 -0400 Subject: [PATCH] refactor: extract BalCalendarButton common widget + basic-mode default app --- bal/gui/qt/calendar.py | 123 +++++++++++++++++++++++++++++++++++++++++ bal/gui/qt/dialogs.py | 104 ++-------------------------------- bal/gui/qt/widgets.py | 84 ++++++++++++++++++++++++++-- 3 files changed, 209 insertions(+), 102 deletions(-) diff --git a/bal/gui/qt/calendar.py b/bal/gui/qt/calendar.py index 651f261..8f8bf91 100644 --- a/bal/gui/qt/calendar.py +++ b/bal/gui/qt/calendar.py @@ -11,6 +11,129 @@ into an RFC-5545 .ics file and opens it with the OS default application. from .common import * from .common import _, _logger # underscore names are not re-exported by "import *" +from PyQt6.QtGui import QAction +from PyQt6.QtWidgets import QToolButton + + +class BalCalendarButton(QToolButton): + """A QToolButton with a dropdown menu for .ics calendar file actions. + + Provides three actions: Open (with configured app), Open with..., and Save. + Accepts an ``ics_provider`` callable that returns the .ics content string. + """ + + def __init__(self, bal_window, ics_provider, parent=None): + super().__init__(parent) + self._bal_window = bal_window + self._ics_provider = ics_provider + self._calendar_temp_path = None + + calendar_menu = QMenu(self) + open_action = QAction(_("Open"), self) + open_action.triggered.connect(self._on_open) + calendar_menu.addAction(open_action) + open_with_action = QAction(_("Open with..."), self) + open_with_action.triggered.connect(self._on_open_with) + calendar_menu.addAction(open_with_action) + save_action = QAction(_("Save"), self) + save_action.triggered.connect(self._on_save) + calendar_menu.addAction(save_action) + + self.setMenu(calendar_menu) + self.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) + + # ------------------------------------------------------------------ # + # ICS generation (lazy: generated on first action) # + # ------------------------------------------------------------------ # + + def _ensure_ics(self): + """Generate the .ics content and cache the temp file path.""" + try: + content = self._ics_provider() + if content: + self._calendar_temp_path = BalCalendar.write_temp_ics(content) + else: + self._calendar_temp_path = None + except Exception as e: + _logger.error(f"failed to generate .ics: {e}") + self._calendar_temp_path = None + return self._calendar_temp_path + + # ------------------------------------------------------------------ # + # Menu action handlers # + # ------------------------------------------------------------------ # + + def _on_open(self): + """Open the .ics with the app configured in CALENDAR_APP.""" + path = self._ensure_ics() + if not path: + return + import shlex, subprocess + if self._bal_window.bal_plugin.is_basic_mode(): + app = self._bal_window.bal_plugin.CALENDAR_APP.default + else: + app = self._bal_window.bal_plugin.CALENDAR_APP.get() + if not app: + return + try: + args = shlex.split(app) + [path] + subprocess.check_call(args) + except Exception as e: + _logger.error(f"opening calendar file failed: {e}") + self._bal_window.show_warning( + _("Could not open the calendar file: {}").format(e) + ) + + def _on_open_with(self): + """Let the user pick an application and open the .ics with it.""" + path = self._ensure_ics() + if not path: + return + app, ok = QInputDialog.getText( + self, + _("Open calendar with..."), + _("Enter the application command:"), + ) + if ok and app: + app = app.strip() + if app: + try: + BalCalendar.open_with_default_app(app, path) + except Exception as e: + _logger.error(f"opening calendar with custom app failed: {e}") + self._bal_window.show_warning( + _("Could not open the calendar file: {}").format(e) + ) + + def _on_save(self): + """Show a "Save As" dialog and save the .ics file.""" + path = self._ensure_ics() + if not path: + return + 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)"), + filename=default_path, + filter="iCalendar (*.ics);;All files (*)", + default_extension="ics", + config=self._bal_window.window.config, + ) + if not target: + return + try: + with open(path, "rb") as src, open(target, "wb") as dst: + dst.write(src.read()) + self._bal_window.show_message( + _("Calendar file saved to:\n{}").format(target) + ) + except Exception as e: + _logger.error(f"saving .ics failed: {e}") + self._bal_window.show_warning( + _("Could not save the calendar file: {}").format(e) + ) + class BalCalendar: @staticmethod diff --git a/bal/gui/qt/dialogs.py b/bal/gui/qt/dialogs.py index 567e09f..6ae2a7b 100644 --- a/bal/gui/qt/dialogs.py +++ b/bal/gui/qt/dialogs.py @@ -23,9 +23,7 @@ from .widgets import (BalCheckBox, BalLineEdit, BalTextEdit, BalTxFeesWidget, LockTimeWidget, PercAmountEdit, ThresholdTimeWidget, WillSettingsWidget, WillWidget, basic_reminder_offsets, compute_reminder_offsets) -from .calendar import BalCalendar -from PyQt6.QtGui import QAction -from PyQt6.QtWidgets import QToolButton +from .calendar import BalCalendar, BalCalendarButton # NOTE: list views (HeirListWidget, PreviewList, WillExecutorWidget) are # imported lazily where needed to avoid a dialogs<->lists import cycle. @@ -1455,24 +1453,8 @@ class BalBuildWillDialog(BalDialog): if getattr(self, "_close_button", None) is not None: return - # Generate .ics content once for all calendar actions. - self._generate_calendar_ics() - - calendar_menu = QMenu() - open_action = QAction(_("Open"), self) - open_action.triggered.connect(self._on_calendar_open) - calendar_menu.addAction(open_action) - open_with_action = QAction(_("Open with..."), self) - open_with_action.triggered.connect(self._on_calendar_open_with) - calendar_menu.addAction(open_with_action) - save_action = QAction(_("Save"), self) - save_action.triggered.connect(self._on_calendar_save) - calendar_menu.addAction(save_action) - - calendar_button = QToolButton() + calendar_button = BalCalendarButton(self.bal_window, self._ics_provider) calendar_button.setText(_("Calendar")) - calendar_button.setMenu(calendar_menu) - calendar_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) self._close_button = QPushButton(_("Close")) self._close_button.clicked.connect(self._on_close_clicked) @@ -1483,15 +1465,10 @@ class BalBuildWillDialog(BalDialog): self.vbox.addLayout(button_row) self._close_button.setFocus() - def _generate_calendar_ics(self): - """Build the .ics content and write it to a temp file. - - Stores the temp path in ``self._calendar_temp_path`` (or ``None`` - on failure) so all three calendar actions reuse the same data. - """ + def _ics_provider(self): + """Return the .ics content for the current will data.""" from datetime import datetime, timedelta - self._calendar_temp_path = None try: locktime_ts = Util.parse_locktime_string( self.bal_window.will_settings["locktime"] @@ -1564,79 +1541,10 @@ class BalBuildWillDialog(BalDialog): lines.append("END:VCALENDAR") lines = [s.rstrip("\r\n") for s in lines] - ics_content = "\r\n".join(lines) + "\r\n" - self._calendar_temp_path = BalCalendar.write_temp_ics(ics_content) + return "\r\n".join(lines) + "\r\n" except Exception as e: _logger.error(f"failed to generate .ics: {e}") - - def _on_calendar_open(self): - """Open the .ics calendar file with the configured app.""" - path = getattr(self, "_calendar_temp_path", None) - if not path: - return - import shlex, subprocess - app = self.bal_window.bal_plugin.CALENDAR_APP.get() - if not app: - return - try: - args = shlex.split(app) + [path] - subprocess.check_call(args) - except Exception as e: - _logger.error(f"opening calendar file failed: {e}") - self.bal_window.show_warning( - _("Could not open the calendar file: {}").format(e) - ) - - def _on_calendar_open_with(self): - """Let the user pick an application and open the .ics file with it.""" - path = getattr(self, "_calendar_temp_path", None) - if not path: - return - from PyQt6.QtWidgets import QInputDialog - app, ok = QInputDialog.getText( - self, - _("Open calendar with..."), - _("Enter the application command:"), - ) - if ok and app: - app = app.strip() - if app: - try: - BalCalendar.open_with_default_app(app, path) - except Exception as e: - _logger.error(f"opening calendar with custom app failed: {e}") - self.bal_window.show_warning( - _("Could not open the calendar file: {}").format(e) - ) - - def _on_calendar_save(self): - """Show a "Save As" dialog and save the .ics calendar file.""" - path = getattr(self, "_calendar_temp_path", None) - if not path: - return - 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)"), - filename=default_path, - filter="iCalendar (*.ics);;All files (*)", - default_extension="ics", - config=self.bal_window.window.config, - ) - if not target: - return - try: - with open(path, "rb") as src, open(target, "wb") as dst: - dst.write(src.read()) - self.bal_window.show_message( - _("Calendar file saved to:\n{}").format(target) - ) - except Exception as e: - _logger.error(f"saving .ics failed: {e}") - self.bal_window.show_warning( - _("Could not save the calendar file: {}").format(e) - ) + return None def _on_close_clicked(self): # Close the dialog first, then show the persistent popup guiding the diff --git a/bal/gui/qt/widgets.py b/bal/gui/qt/widgets.py index ebbc797..9e135c1 100644 --- a/bal/gui/qt/widgets.py +++ b/bal/gui/qt/widgets.py @@ -20,7 +20,7 @@ Contents: from .common import * from .common import _, _logger # underscore names are not re-exported by "import *" -from .calendar import BalCalendar +from .calendar import BalCalendar, BalCalendarButton def compute_reminder_offsets(days, count): @@ -713,17 +713,15 @@ class WillSettingsWidget(QWidget): self.read_only = read_only box = QHBoxLayout(self) if layout_type == "h" else QVBoxLayout(self) - self.calendar_button = QPushButton() + 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") ) ) - # 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) self.widgets["locktime"].valueEdited.connect(self.on_locktime_change) @@ -1145,6 +1143,84 @@ class WillSettingsWidget(QWidget): 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()