diff --git a/.gitignore b/.gitignore index e7f4a4f02..55aca25ed 100644 --- a/.gitignore +++ b/.gitignore @@ -206,6 +206,7 @@ bitcoin_safe.dist-info *.xcf screenshots*/ +.codex-artifacts/ .DS_Store profile.html diff --git a/bitcoin_safe/__main__.py b/bitcoin_safe/__main__.py index 51ddbe9e2..c5761657a 100644 --- a/bitcoin_safe/__main__.py +++ b/bitcoin_safe/__main__.py @@ -47,10 +47,11 @@ from PyQt6.QtWidgets import QApplication # noqa: E402 from bitcoin_safe.compatibility import check_compatibility # noqa: E402 -from bitcoin_safe.gnome_darkmode import is_gnome_dark_mode, set_dark_palette # noqa: E402 +from bitcoin_safe.config import UserConfig # noqa: E402 from bitcoin_safe.gui.qt.main import MainWindow # noqa: E402 from bitcoin_safe.gui.qt.startup_window_probe import StartupWindowProbe # noqa: E402 from bitcoin_safe.gui.qt.util import custom_exception_handler # noqa: E402 +from bitcoin_safe.theme import apply_theme_mode # noqa: E402 logger = logging.getLogger(__name__) NETWORK_ARG_NAMES = tuple(sorted(network.name.lower() for network in bdk.Network)) @@ -130,11 +131,12 @@ def main(args: StartupArgs | None = None) -> None: check_compatibility() - if is_gnome_dark_mode(): - set_dark_palette(app) + config = UserConfig.from_file() if UserConfig.exists() else None + apply_theme_mode(app, config.theme_mode if config else UserConfig().theme_mode) window = MainWindow( network=startup_args.network, + config=config, open_files_at_startup=startup_args.open_files_at_startup, ) if startup_args.trace_startup_windows: diff --git a/bitcoin_safe/config.py b/bitcoin_safe/config.py index 4a326951a..88db11326 100644 --- a/bitcoin_safe/config.py +++ b/bitcoin_safe/config.py @@ -48,6 +48,7 @@ from bitcoin_safe.fx_types import FXProvider from bitcoin_safe.gui.qt.unique_deque import UniqueDeque from bitcoin_safe.pythonbdk_types import BlockchainType +from bitcoin_safe.theme import ThemeMode from bitcoin_safe.util import current_project_dir from .execute_config import DEFAULT_LANG_CODE, DEFAULT_MAINNET @@ -98,8 +99,9 @@ class UserConfig(BaseSaveableClass): BitcoinSymbol.__name__: BitcoinSymbol, BtcPayInvoiceDetails.__name__: BtcPayInvoiceDetails, FXProvider.__name__: FXProvider, + ThemeMode.__name__: ThemeMode, } - VERSION = "0.3.8" + VERSION = "0.3.9" app_name = "bitcoin_safe" locales_path = current_project_dir() / "gui" / "locales" @@ -130,6 +132,7 @@ def __init__(self) -> None: self.language_code: str = DEFAULT_LANG_CODE self.currency: str = "USD" self.bitcoin_symbol: BitcoinSymbol = BitcoinSymbol.ISO + self.theme_mode: ThemeMode = ThemeMode.SYSTEM self.rates: dict[str, dict[str, Any]] = {} self.historical_rates: dict[float, dict[str, float]] = {} self.last_tab_title: list[str] = [] diff --git a/bitcoin_safe/gui/qt/address_edit.py b/bitcoin_safe/gui/qt/address_edit.py index 6a9dabd94..0b23f5f1e 100644 --- a/bitcoin_safe/gui/qt/address_edit.py +++ b/bitcoin_safe/gui/qt/address_edit.py @@ -39,7 +39,7 @@ from bitcoin_safe_lib.util_os import webopen from PyQt6 import QtCore, QtGui from PyQt6.QtCore import pyqtSignal -from PyQt6.QtWidgets import QMessageBox, QSizePolicy +from PyQt6.QtWidgets import QApplication, QMessageBox, QSizePolicy from bitcoin_safe.gui.qt.analyzers import AddressAnalyzer from bitcoin_safe.gui.qt.buttonedit import ButtonEdit, SquareButton @@ -212,17 +212,13 @@ def get_color(is_change: bool) -> QtGui.QColor: def format_address_field(self, wallet: Wallet | None) -> None: """Format address field.""" - palette = QtGui.QPalette() - background_color = None - - background_color = None + palette = QtGui.QPalette(QApplication.palette()) if wallet: background_color = self.color_address( self.address, wallet, wallet_signals=self.wallet_functions.wallet_signals[wallet.id] ) - - if background_color: - palette.setColor(QtGui.QPalette.ColorRole.Base, background_color) + if background_color: + palette.setColor(QtGui.QPalette.ColorRole.Base, background_color) self.input_field.setPalette(palette) self.input_field.update() diff --git a/bitcoin_safe/gui/qt/address_list.py b/bitcoin_safe/gui/qt/address_list.py index 66074262b..7eb63e7b9 100644 --- a/bitcoin_safe/gui/qt/address_list.py +++ b/bitcoin_safe/gui/qt/address_list.py @@ -687,6 +687,7 @@ def update_content(self) -> None: """Update content.""" if self.maybe_defer_update(): return + ColorScheme.update_from_widget(self) logger.debug(f"{self.__class__.__name__} update") self._before_update_content() diff --git a/bitcoin_safe/gui/qt/buttonedit.py b/bitcoin_safe/gui/qt/buttonedit.py index c755e3b0e..e7c8e784a 100644 --- a/bitcoin_safe/gui/qt/buttonedit.py +++ b/bitcoin_safe/gui/qt/buttonedit.py @@ -39,7 +39,7 @@ from bitcoin_qr_tools.gui.bitcoin_video_widget import BitcoinVideoWidget from bitcoin_safe_lib.gui.qt.signal_tracker import SignalProtocol, SignalTools, SignalTracker from bitcoin_safe_lib.gui.qt.util import question_dialog -from PyQt6.QtCore import QObject, QSize, Qt, pyqtSignal +from PyQt6.QtCore import QEvent, QObject, QSize, Qt, pyqtSignal from PyQt6.QtGui import QIcon, QResizeEvent, QTextCharFormat from PyQt6.QtWidgets import ( QApplication, @@ -61,19 +61,40 @@ AnalyzerState, AnalyzerTextEdit, ) -from bitcoin_safe.gui.qt.util import Message, MessageType, clear_layout, do_copy, get_icon_path, svg_tools +from bitcoin_safe.gui.qt.util import ( + Message, + MessageType, + clear_layout, + do_copy, + get_icon_path, + should_process_theme_change, + svg_tools, +) from bitcoin_safe.i18n import translate logger = logging.getLogger(__name__) class SquareButton(QToolButton): - def __init__(self, qicon: QIcon, parent) -> None: + def __init__(self, qicon: QIcon | str, parent) -> None: """Initialize instance.""" super().__init__(parent) + self._icon_name: str | None = None + self._icon: QIcon | None = None self.setIcon(qicon) self.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly) + def setIcon(self, icon: QIcon | str) -> None: + self._icon_name = icon if isinstance(icon, str) else None + self._icon = icon if isinstance(icon, QIcon) else None + effective_icon = svg_tools.get_QIcon(self._icon_name) if self._icon_name else self._icon + super().setIcon(effective_icon if effective_icon else QIcon()) + + def changeEvent(self, a0: QEvent | None) -> None: + super().changeEvent(a0) + if self._icon_name and should_process_theme_change(self, a0): + self.setIcon(self._icon_name) + class ButtonsField(QWidget): def __init__(self, vertical_align: Qt.AlignmentFlag = Qt.AlignmentFlag.AlignBottom, parent=None) -> None: @@ -272,7 +293,7 @@ def updateUi(self) -> None: def add_button(self, icon_path: str | None, button_callback: Callable, tooltip: str = "") -> SquareButton: """Add button.""" - button = SquareButton(svg_tools.get_QIcon(icon_path), parent=self) # Create the button with the icon + button = SquareButton(icon_path if icon_path else QIcon(), parent=self) if tooltip: button.setToolTip(tooltip) button.clicked.connect(button_callback) # Connect the button's clicked signal to the callback diff --git a/bitcoin_safe/gui/qt/card_base.py b/bitcoin_safe/gui/qt/card_base.py index 586dc4a82..f28836125 100644 --- a/bitcoin_safe/gui/qt/card_base.py +++ b/bitcoin_safe/gui/qt/card_base.py @@ -31,12 +31,14 @@ import enum from collections.abc import Callable +from pathlib import Path from typing import cast from bitcoin_safe_lib.gui.qt.signal_tracker import SignalProtocol from PyQt6.QtCore import QEvent, QObject, QSize, Qt, pyqtSignal from PyQt6.QtGui import QIcon, QMouseEvent, QPalette, QPixmap from PyQt6.QtWidgets import ( + QApplication, QFrame, QHBoxLayout, QLabel, @@ -49,7 +51,14 @@ from bitcoin_safe.gui.qt.invisible_scroll_area import InvisibleScrollArea from .styled_card_frame import BaseCardFrame -from .util import get_neutral_surface_colors, set_no_margins, svg_tools, to_color_name +from .util import ( + SvgTools, + get_neutral_surface_colors, + set_no_margins, + should_process_theme_change, + svg_tools, + to_color_name, +) class CardExpansionMode(enum.Enum): @@ -73,6 +82,9 @@ def __init__( self._body_content_visible = True self._header_clickable = False self._header_click_targets: list[QWidget] = [] + self._icon_source: QIcon | QPixmap | Path | str | None = None + self._icon_size: tuple[int, int] | None = None + self._icon_svg_tools: SvgTools | None = None self.root_layout = QVBoxLayout(self) @@ -110,10 +122,6 @@ def __init__( self.header_subtitle = QLabel(self.header_text_widget) self.header_subtitle.setWordWrap(True) - subtitle_palette = self.header_subtitle.palette() - surface_colors = get_neutral_surface_colors() - subtitle_palette.setColor(self.header_subtitle.foregroundRole(), surface_colors.muted_text) - self.header_subtitle.setPalette(subtitle_palette) self.header_text_layout.addWidget(self.header_subtitle) self.header_right_widget = QWidget(self.header_widget) @@ -144,7 +152,7 @@ def __init__( self._refresh_body_visibility() self._update_header_cursor() - self.refresh_style() + self._refresh_theme_dependent_ui() @property def is_expanded(self) -> bool: @@ -198,9 +206,13 @@ def set_subtitle(self, subtitle: str) -> None: def set_icon( self, - icon: QIcon | QPixmap | str | None, + icon: QIcon | QPixmap | Path | str | None, size: tuple[int, int] | None = None, + svg_tools_custom: SvgTools | None = None, ) -> None: + self._icon_source = icon + self._icon_size = size + self._icon_svg_tools = svg_tools_custom if icon is None: self.header_icon.clear() return @@ -293,9 +305,37 @@ def _update_header_cursor(self) -> None: for widget in self._header_click_targets: widget.setCursor(cursor_shape) + def changeEvent(self, a0: QEvent | None) -> None: + """Refresh theme-dependent card colors and SVG icons.""" + super().changeEvent(a0) + if should_process_theme_change(self, a0): + self._on_theme_change() + + def _on_theme_change(self) -> None: + self._refresh_theme_dependent_ui() + + def _refresh_theme_dependent_ui(self) -> None: + surface_colors = get_neutral_surface_colors() + self.refresh_style() + self.hline.setStyleSheet(f"color: {to_color_name(QPalette.ColorRole.Mid)}") + + title_palette = self.header_title.palette() + title_palette.setColor( + self.header_title.foregroundRole(), + QApplication.palette().color(QPalette.ColorRole.WindowText), + ) + self.header_title.setPalette(title_palette) + + subtitle_palette = self.header_subtitle.palette() + subtitle_palette.setColor(self.header_subtitle.foregroundRole(), surface_colors.muted_text) + self.header_subtitle.setPalette(subtitle_palette) + + if self._icon_source is not None: + self.set_icon(self._icon_source, self._icon_size, svg_tools_custom=self._icon_svg_tools) + def _coerce_pixmap( self, - icon: QIcon | QPixmap | str, + icon: QIcon | QPixmap | Path | str, size: tuple[int, int], ) -> QPixmap: if isinstance(icon, QPixmap): @@ -306,7 +346,8 @@ def _coerce_pixmap( ) if isinstance(icon, QIcon): return icon.pixmap(QSize(*size), self.devicePixelRatioF()) - return svg_tools.get_pixmap(icon, size=size) + icon_svg_tools = self._icon_svg_tools if self._icon_svg_tools else svg_tools + return icon_svg_tools.get_pixmap(str(icon), size=size) class CardList(QWidget): diff --git a/bitcoin_safe/gui/qt/color_corrected_treeview.py b/bitcoin_safe/gui/qt/color_corrected_treeview.py index 8c873ef6c..c3a8cf0ce 100644 --- a/bitcoin_safe/gui/qt/color_corrected_treeview.py +++ b/bitcoin_safe/gui/qt/color_corrected_treeview.py @@ -32,6 +32,8 @@ from PyQt6.QtCore import QEvent from PyQt6.QtWidgets import QApplication, QTreeView, QWidget +from .util import is_theme_change_event + class ColorCorrectedTreeView(QTreeView): """QTreeView variant that fixes selected item text colors for Windows styles.""" @@ -49,9 +51,7 @@ def __init__(self, parent: QWidget | None = None) -> None: def changeEvent(self, event: QEvent | None) -> None: """React to palette and style changes that affect selection colors.""" super().changeEvent(event) - if not event: - return - if event.type() in {QEvent.Type.StyleChange, QEvent.Type.PaletteChange}: + if is_theme_change_event(event, include_style_change=True): self._refresh_selection_style_sheet() def setStyleSheet(self, styleSheet: str | None) -> None: diff --git a/bitcoin_safe/gui/qt/custom_edits.py b/bitcoin_safe/gui/qt/custom_edits.py index e90311ae8..2d188137e 100644 --- a/bitcoin_safe/gui/qt/custom_edits.py +++ b/bitcoin_safe/gui/qt/custom_edits.py @@ -38,10 +38,12 @@ import bdkpython as bdk from bitcoin_safe_lib.gui.qt.signal_tracker import SignalProtocol -from PyQt6.QtCore import QRect, QSize, QStringListModel, Qt, pyqtSignal +from PyQt6.QtCore import QEvent, QRect, QSize, QStringListModel, Qt, pyqtSignal from PyQt6.QtGui import QFocusEvent, QKeyEvent, QPainter, QPaintEvent, QPalette from PyQt6.QtWidgets import QApplication, QCompleter, QLineEdit, QTextEdit, QWidget +from .util import should_process_theme_change + logger = logging.getLogger(__name__) ENABLE_COMPLETERS = True @@ -259,6 +261,11 @@ def paintEvent(self, a0: QPaintEvent | None) -> None: self.displayText(), ) + def changeEvent(self, a0: QEvent | None) -> None: + super().changeEvent(a0) + if should_process_theme_change(self, a0): + self.update() + class FlexibleHeightTextedit(QTextEdit): def sizeHint(self) -> QSize: @@ -319,6 +326,11 @@ def setCursorPosition(self, a0: int) -> None: def setText(self, a0: str | None) -> None: # type: ignore self.setPlainText(a0 or "") + def changeEvent(self, e: QEvent | None) -> None: + super().changeEvent(e) + if should_process_theme_change(self, e): + self.update() + class QCompleterLineEdit(AnalyzerLineEdit): signal_focus_out = cast(SignalProtocol[[]], pyqtSignal()) diff --git a/bitcoin_safe/gui/qt/hist_list.py b/bitcoin_safe/gui/qt/hist_list.py index 884d79911..4b2a4cbc6 100644 --- a/bitcoin_safe/gui/qt/hist_list.py +++ b/bitcoin_safe/gui/qt/hist_list.py @@ -73,7 +73,7 @@ from bitcoin_safe_lib.util import fast_version, time_logger from bitcoin_safe_lib.util_os import webopen from PyQt6.QtCore import QMimeData, QModelIndex, QPoint, Qt, pyqtSignal -from PyQt6.QtGui import QBrush, QColor, QFont, QStandardItem +from PyQt6.QtGui import QBrush, QColor, QFont, QIcon, QStandardItem from PyQt6.QtWidgets import QAbstractItemView, QFileDialog, QWidget from bitcoin_safe.client import ProgressInfo, SyncStatus @@ -955,7 +955,7 @@ def __init__(self, hist_list: HistList, config: UserConfig, parent: QWidget | No self.sync_button = SpinningButton( signal_stop_spinning=self.signal_disable_spinning_button, - enabled_icon=svg_tools.get_QIcon("bi--arrow-clockwise.svg"), + enabled_icon=QIcon(), parent=self, timeout=60 * 60, text="", @@ -979,6 +979,7 @@ def __init__(self, hist_list: HistList, config: UserConfig, parent: QWidget | No self.hist_list.signals.language_switch.connect(self.updateUi) for wallet in self.hist_list.wallets: self.hist_list.wallet_functions.wallet_signals[wallet.id].updated.connect(self.update_with_filter) + self.updateUi() def _on_sync_button_clicked(self): """On sync button clicked.""" @@ -997,6 +998,9 @@ def update_with_filter(self, update_filter: UpdateFilter): def updateUi(self) -> None: """UpdateUi.""" super().updateUi() + self.sync_button.enabled_icon = svg_tools.get_QIcon("bi--arrow-clockwise.svg") + if not self.sync_button._spinning: + self.sync_button.setIcon(self.sync_button.enabled_icon) if self.balance_label: balance_total = Satoshis(self.hist_list.balance, self.config.network) self.balance_label.setText( diff --git a/bitcoin_safe/gui/qt/icon_label.py b/bitcoin_safe/gui/qt/icon_label.py index df2e90fc3..ad400b5ed 100644 --- a/bitcoin_safe/gui/qt/icon_label.py +++ b/bitcoin_safe/gui/qt/icon_label.py @@ -30,17 +30,18 @@ from __future__ import annotations import logging +from pathlib import Path from typing import cast from bitcoin_safe_lib.gui.qt.signal_tracker import SignalProtocol from bitcoin_safe_lib.util_os import webopen from PyQt6.QtCore import QEvent, QObject, QSize, Qt, pyqtSignal -from PyQt6.QtGui import QIcon, QMouseEvent +from PyQt6.QtGui import QMouseEvent from PyQt6.QtWidgets import QHBoxLayout, QLabel, QWidget from bitcoin_safe.gui.qt.util import svg_tools -from .util import set_no_margins +from .util import set_no_margins, should_process_theme_change logger = logging.getLogger(__name__) @@ -73,6 +74,8 @@ def __init__( self._icon_on_right = icon_on_right self.click_url: str | None = None + self._icon_name: str | Path | None = None + self._icon_sizes: tuple[int | None, int | None] = (None, None) # Icon Label self.icon_label = ClickableLabel() @@ -113,17 +116,25 @@ def setText(self, a0: str | None) -> None: a0 = a0.replace("\n", "
") self.textLabel.setText(a0) - def set_icon(self, icon: QIcon | None, sizes: tuple[int | None, int | None] = (None, None)) -> None: + def set_icon(self, icon: str | Path | None, sizes: tuple[int | None, int | None] = (None, None)) -> None: """Set icon.""" + self._icon_name = icon + self._icon_sizes = sizes + self._apply_icon() + + def _apply_icon(self) -> None: + icon_name = str(self._icon_name) if self._icon_name else None + icon = svg_tools.get_QIcon(icon_name) if icon_name else None self.icon_label.setVisible(bool(icon)) - if icon: - # compute single-line text height (font metrics) - fm = self.textLabel.fontMetrics() - line_height = fm.height() + if not icon: + self.icon_label.clear() + return - pixmap_sizes = [s if s else line_height for s in sizes] - self.icon_label.setPixmap(icon.pixmap(QSize(*pixmap_sizes), self.devicePixelRatioF())) - self.icon_label.setFixedSize(QSize(*pixmap_sizes)) + fm = self.textLabel.fontMetrics() + line_height = fm.height() + pixmap_sizes = [s if s else line_height for s in self._icon_sizes] + self.icon_label.setPixmap(icon.pixmap(QSize(*pixmap_sizes), self.devicePixelRatioF())) + self.icon_label.setFixedSize(QSize(*pixmap_sizes)) def on_icon_click(self): """On icon click.""" @@ -139,16 +150,22 @@ def set_icon_as_help(self, tooltip: str | None, click_url: str | None = None): self.textLabel.setToolTip(effective_tooltip) self.click_url = click_url if self.click_url: - self.set_icon(svg_tools.get_QIcon("bi--question-circle-link.svg")) + self.set_icon("bi--question-circle-link.svg") self.icon_label.setCursor(Qt.CursorShape.PointingHandCursor) self.textLabel.setCursor(Qt.CursorShape.PointingHandCursor) self.setCursor(Qt.CursorShape.PointingHandCursor) else: - self.set_icon(svg_tools.get_QIcon("bi--question-circle.svg")) + self.set_icon("bi--question-circle.svg") self.icon_label.unsetCursor() self.textLabel.unsetCursor() self.unsetCursor() + def changeEvent(self, a0: QEvent | None) -> None: + """Re-render theme-colored SVG icons when the palette changes.""" + super().changeEvent(a0) + if should_process_theme_change(self, a0): + self._apply_icon() + def eventFilter(self, a0: QObject | None, a1: QEvent | None) -> bool: """Handle clicks locally for clickable help labels.""" if ( diff --git a/bitcoin_safe/gui/qt/initial_cbf_sync_widget.py b/bitcoin_safe/gui/qt/initial_cbf_sync_widget.py index f6d0e23e3..2e0bf98a9 100644 --- a/bitcoin_safe/gui/qt/initial_cbf_sync_widget.py +++ b/bitcoin_safe/gui/qt/initial_cbf_sync_widget.py @@ -39,7 +39,7 @@ from bitcoin_safe_lib.async_tools.loop_in_thread import LoopInThread, MultipleStrategy from bitcoin_safe_lib.gui.qt.signal_tracker import SignalProtocol, SignalTracker from bitcoin_safe_lib.time_util import AgeStyle, age -from PyQt6.QtCore import QLocale, QPointF, QRectF, Qt, pyqtSignal +from PyQt6.QtCore import QEvent, QLocale, QPointF, QRectF, Qt, pyqtSignal from PyQt6.QtGui import ( QColor, QFont, @@ -78,6 +78,7 @@ from .cbf_progress_bar import CBFProgressBar from .icon_label import IconLabel from .styled_card_frame import BaseBorderCardFrame +from .util import get_neutral_surface_colors, should_process_theme_change logger = logging.getLogger(__name__) @@ -730,6 +731,11 @@ def updateUi(self) -> None: self._sync_wallet_progress_visibility() self._refresh_points_and_legend() + def changeEvent(self, a0: QEvent | None) -> None: + super().changeEvent(a0) + if should_process_theme_change(self, a0): + self._refresh_progress_card_styles() + def _has_active_wallet_scan(self) -> bool: if self.config.network_config.server_type != BlockchainType.CompactBlockFilter: return False @@ -896,6 +902,11 @@ def _sync_wallet_progress_visibility(self) -> None: and has_cards ) + def _refresh_progress_card_styles(self) -> None: + for card in [self.local_progress_card, *self._wallet_progress_cards.values()]: + card.background_color = get_neutral_surface_colors().panel_background + card.refresh_style() + def _refresh_points_and_legend(self) -> None: p2p_connections = tuple(self._p2p_connections) nodes = tuple(self._nodes) diff --git a/bitcoin_safe/gui/qt/interface_settings_ui.py b/bitcoin_safe/gui/qt/interface_settings_ui.py index e58b10ec3..532c71180 100644 --- a/bitcoin_safe/gui/qt/interface_settings_ui.py +++ b/bitcoin_safe/gui/qt/interface_settings_ui.py @@ -36,6 +36,7 @@ from bitcoin_safe_lib.gui.qt.signal_tracker import SignalProtocol from PyQt6.QtCore import pyqtSignal from PyQt6.QtWidgets import ( + QApplication, QComboBox, QFormLayout, QHBoxLayout, @@ -51,6 +52,7 @@ LanguageChooser, create_language_combobox, ) +from bitcoin_safe.theme import ThemeMode, apply_theme_mode from ...fx import FX from .currency_combobox import CurrencyComboBox, CurrencyGroup, CurrencyGroupFormatting @@ -100,12 +102,21 @@ def __init__( idx = self.bitcoin_symbol_combo.findData(self.config.bitcoin_symbol) self.bitcoin_symbol_combo.setCurrentIndex(idx if idx >= 0 else 0) + self.theme_combo: QComboBox | None = None + self.theme_combo = QComboBox(self) + self.theme_combo.addItem("", ThemeMode.SYSTEM) + self.theme_combo.addItem("", ThemeMode.LIGHT) + self.theme_combo.addItem("", ThemeMode.DARK) + idx = self.theme_combo.findData(self.config.theme_mode) + self.theme_combo.setCurrentIndex(idx if idx >= 0 else 0) + # 3) Layout form = QFormLayout(self) form.setHorizontalSpacing(FORM_LABEL_FIELD_SPACING) self.label_language = QLabel("") self.label_currency = QLabel("") self.label_bitcoin_symbol = QLabel("") + self.label_theme = QLabel("") self.label_app_lock_password = QLabel("") self.app_lock_password_status = QLabel("") @@ -122,6 +133,8 @@ def __init__( form.addRow(self.label_language, self.language_combo) form.addRow(self.label_currency, self.currency_combo) form.addRow(self.label_bitcoin_symbol, self.bitcoin_symbol_combo) + if self.theme_combo is not None: + form.addRow(self.label_theme, self.theme_combo) form.addRow(self.label_app_lock_password, self.app_lock_controls) # 4) initial selection @@ -133,6 +146,8 @@ def __init__( self.language_combo.currentIndexChanged.connect(self._on_language_changed) self.currency_combo.currentIndexChanged.connect(self._on_currency_changed) self.bitcoin_symbol_combo.currentIndexChanged.connect(self._on_bitcoin_symbol_changed) + if self.theme_combo is not None: + self.theme_combo.currentIndexChanged.connect(self._on_theme_changed) self.button_set_app_lock_password.clicked.connect(self._on_set_app_lock_password) self.button_clear_app_lock_password.clicked.connect(self._on_clear_app_lock_password) @@ -161,6 +176,18 @@ def _on_bitcoin_symbol_changed(self, _idx: int): self.config.bitcoin_symbol = symbol or BitcoinSymbol.ISO self.language_chooser.signals_currency_switch.emit() + def _on_theme_changed(self, _idx: int) -> None: + """Apply the selected app theme.""" + if self.theme_combo is None: + return + + selected_theme_mode = self.theme_combo.currentData() + theme_mode = selected_theme_mode if isinstance(selected_theme_mode, ThemeMode) else ThemeMode.SYSTEM + self.config.theme_mode = theme_mode + app = QApplication.instance() + if isinstance(app, QApplication): + apply_theme_mode(app, theme_mode) + def refresh_app_lock_status(self) -> None: has_password = self.config.has_app_lock_password() self.app_lock_password_status.setText( @@ -211,6 +238,11 @@ def updateUi(self): self.label_language.setText("Language") self.label_currency.setText("Currency") self.label_bitcoin_symbol.setText("Bitcoin symbol") + if self.theme_combo is not None: + self.label_theme.setText("Theme") + self.theme_combo.setItemText(0, self.tr("System")) + self.theme_combo.setItemText(1, self.tr("Light")) + self.theme_combo.setItemText(2, self.tr("Dark")) self.label_app_lock_password.setText("App lock") self.button_clear_app_lock_password.setText("Clear") self.refresh_app_lock_status() diff --git a/bitcoin_safe/gui/qt/invisible_scroll_area.py b/bitcoin_safe/gui/qt/invisible_scroll_area.py index 149c31564..c57fe0fc9 100644 --- a/bitcoin_safe/gui/qt/invisible_scroll_area.py +++ b/bitcoin_safe/gui/qt/invisible_scroll_area.py @@ -29,8 +29,11 @@ from __future__ import annotations +from PyQt6.QtCore import QEvent from PyQt6.QtWidgets import QScrollArea, QWidget +from .util import should_process_theme_change + class InvisibleScrollArea(QScrollArea): def __init__(self, parent=None) -> None: @@ -47,3 +50,9 @@ def __init__(self, parent=None) -> None: ) self.setWidget(self.content_widget) + + def changeEvent(self, a0: QEvent | None) -> None: + super().changeEvent(a0) + if should_process_theme_change(self, a0): + if viewport := self.viewport(): + viewport.update() diff --git a/bitcoin_safe/gui/qt/keystore_ui.py b/bitcoin_safe/gui/qt/keystore_ui.py index f471df015..0591956c8 100644 --- a/bitcoin_safe/gui/qt/keystore_ui.py +++ b/bitcoin_safe/gui/qt/keystore_ui.py @@ -45,8 +45,8 @@ from bitcoin_usb.dialogs import AutoScanMode from bitcoin_usb.seed_tools import derive from bitcoin_usb.usb_gui import USBGui -from PyQt6.QtCore import QObject, Qt, pyqtSignal -from PyQt6.QtGui import QIcon +from PyQt6.QtCore import QEvent, QObject, Qt, pyqtSignal +from PyQt6.QtGui import QIcon, QPalette, QPixmap from PyQt6.QtWidgets import ( QComboBox, QGridLayout, @@ -63,7 +63,7 @@ QWidget, ) -from bitcoin_safe.constants import FORM_LABEL_FIELD_SPACING, LOGO_NAME +from bitcoin_safe.constants import FORM_LABEL_FIELD_SPACING from bitcoin_safe.gui.qt.analyzers import ( FingerprintAnalyzer, KeyOriginAnalyzer, @@ -98,7 +98,15 @@ from ...signals import SignalsMin from .block_change_signals import BlockChangesSignals from .dialog_import import ImportDialog -from .util import Message, MessageType, set_margins +from .util import ( + ColorScheme, + Message, + MessageType, + get_neutral_surface_colors, + is_theme_change_event, + set_margins, + to_color_name, +) logger = logging.getLogger(__name__) @@ -136,6 +144,8 @@ def __init__( show_register_button: bool = True, ) -> None: """Initialize instance.""" + self._theme_assets_ready = False + self._theme_refresh_in_progress = False super().__init__(parent=parent, expansion_mode=CardExpansionMode.EXPANDABLE) self.signals_min = signals_min self._fallback_hardware_signer_label = hardware_signer_label @@ -152,20 +162,16 @@ def __init__( self._duplicate_xpub_message = "" self.signal_tracker = SignalTracker() self._state = KeyStoreUiState.Add - self._status_pixmaps = { - AnalyzerState.Valid: svg_tools.get_pixmap("checkmark.svg", size=(22, 22)), - AnalyzerState.Warning: svg_tools.get_pixmap("warning.svg", size=(22, 22)), - AnalyzerState.Invalid: svg_tools.get_pixmap("error.svg", size=(22, 22)), - } + self._status_pixmaps = self._build_status_pixmaps() self.usb_gui = USBGui( self.network, initalization_label=self.hardware_signer_label, loop_in_thread=loop_in_thread, - window_icon=svg_tools.get_QIcon(LOGO_NAME), ) self._build_widgets() + self._theme_assets_ready = True self._connect_signals() self.updateUi() @@ -195,6 +201,40 @@ def _header_title_text(self) -> str: else self.hardware_signer_label ) + def _build_status_pixmaps(self) -> dict[AnalyzerState, QPixmap]: + return { + AnalyzerState.Valid: svg_tools.get_pixmap("checkmark.svg", size=(22, 22)), + AnalyzerState.Warning: svg_tools.get_pixmap("warning.svg", size=(22, 22)), + AnalyzerState.Invalid: svg_tools.get_pixmap("error.svg", size=(22, 22)), + } + + def _refresh_theme_assets(self) -> None: + if not self._theme_assets_ready: + return + self._status_pixmaps = self._build_status_pixmaps() + self.button_device_instructions.setIcon(svg_tools.get_QIcon("bi--question-circle.svg")) + self.button_menu.setIcon(svg_tools.get_QIcon("bi--gear.svg")) + self.button_connect_qr.setIcon(svg_tools.get_QIcon(KeyStoreImporterTypes.qr.icon_filename)) + self.button_connect_usb.enabled_icon = svg_tools.get_QIcon(KeyStoreImporterTypes.hwi.icon_filename) + if not self.button_connect_usb.timer.isActive(): + self.button_connect_usb.setIcon(self.button_connect_usb.enabled_icon) + self.button_connect_bluetooth.enabled_icon = svg_tools.get_QIcon("bi--bluetooth.svg") + if not self.button_connect_bluetooth.timer.isActive(): + self.button_connect_bluetooth.setIcon(self.button_connect_bluetooth.enabled_icon) + self.button_connect_import.setIcon(svg_tools.get_QIcon(KeyStoreImporterTypes.file.icon_filename)) + + def _refresh_card_style(self) -> None: + if not self._theme_assets_ready: + return + surface_colors = get_neutral_surface_colors() + self.background_color = surface_colors.panel_background + self.refresh_style() + subtitle_palette = self.header_subtitle.palette() + subtitle_palette.setColor(self.header_subtitle.foregroundRole(), surface_colors.muted_text) + self.header_subtitle.setPalette(subtitle_palette) + self.hline.setStyleSheet(f"color: {to_color_name(QPalette.ColorRole.Mid)}") + self._update_header_icon() + def _build_widgets(self) -> None: self.card_frame = self self.card_layout = self.root_layout @@ -233,7 +273,6 @@ def _build_widgets(self) -> None: self.header_right_layout.addWidget(self.header_actions_widget) self.button_device_instructions = QPushButton(self.header_widget) - self.button_device_instructions.setIcon(svg_tools.get_QIcon("bi--question-circle.svg")) self.header_actions_layout.addWidget(self.button_device_instructions) self.button_register = QPushButton(self.header_widget) @@ -242,7 +281,6 @@ def _build_widgets(self) -> None: self.button_menu = QToolButton(self.header_widget) self.button_menu.setAutoRaise(True) self.button_menu.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) - self.button_menu.setIcon(svg_tools.get_QIcon("bi--gear.svg")) self.button_menu.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly) self.menu_button_menu = Menu(self.button_menu) self.button_menu.setMenu(self.menu_button_menu) @@ -284,13 +322,12 @@ def _build_widgets(self) -> None: self.connect_layout.addStretch() self.button_connect_qr = QPushButton(self.left_widget) - self.button_connect_qr.setIcon(svg_tools.get_QIcon(KeyStoreImporterTypes.qr.icon_filename)) self.connect_layout.addWidget(self.button_connect_qr) self.button_connect_usb = SpinningButton( text="", signal_stop_spinning=self.usb_gui.signal_end_hwi_blocker, - enabled_icon=svg_tools.get_QIcon(KeyStoreImporterTypes.hwi.icon_filename), + enabled_icon=QIcon(), timeout=60, parent=self.left_widget, ) @@ -299,14 +336,13 @@ def _build_widgets(self) -> None: self.button_connect_bluetooth = SpinningButton( text="", signal_stop_spinning=self.usb_gui.signal_end_hwi_blocker, - enabled_icon=svg_tools.get_QIcon("bi--bluetooth.svg"), + enabled_icon=QIcon(), timeout=60, parent=self.left_widget, ) self.connect_layout.addWidget(self.button_connect_bluetooth) self.button_connect_import = QPushButton(self.left_widget) - self.button_connect_import.setIcon(svg_tools.get_QIcon(KeyStoreImporterTypes.file.icon_filename)) self.connect_layout.addWidget(self.button_connect_import) self.label_fingerprint = IconLabel(parent=self.left_widget) @@ -1149,6 +1185,9 @@ def xpub_validator(self) -> bool: def updateUi(self) -> None: """UpdateUi.""" + ColorScheme.update_from_widget(self) + self._refresh_card_style() + self._refresh_theme_assets() self.label_description.setText(self.tr("Personal notes:")) self.connect_help_label.setText(self.tr("Device instructions")) self.connect_help_label.set_icon_as_help( @@ -1222,6 +1261,25 @@ def updateUi(self) -> None: self._update_header_subtitle() self._apply_state() + def changeEvent(self, a0: QEvent | None) -> None: + super().changeEvent(a0) + if self._theme_assets_ready and is_theme_change_event(a0): + self._refresh_theme_dependent_ui() + + def eventFilter(self, a0: QObject | None, a1: QEvent | None) -> bool: + return super().eventFilter(a0, a1) + + def _refresh_theme_dependent_ui(self) -> None: + if not self._theme_assets_ready or self._theme_refresh_in_progress: + return + + self._theme_refresh_in_progress = True + try: + self.updateUi() + self.format_all_fields() + finally: + self._theme_refresh_in_progress = False + def _on_hwi_click(self, autoscan_mode: AutoScanMode) -> None: """On hwi click.""" key_origin = self.get_key_origin() diff --git a/bitcoin_safe/gui/qt/labeledit.py b/bitcoin_safe/gui/qt/labeledit.py index e012531a0..1d03674db 100644 --- a/bitcoin_safe/gui/qt/labeledit.py +++ b/bitcoin_safe/gui/qt/labeledit.py @@ -43,7 +43,6 @@ QHBoxLayout, QLineEdit, QSizePolicy, - QStyle, QVBoxLayout, QWidget, ) @@ -58,6 +57,8 @@ get_wallets, ) +from .util import should_process_theme_change + logger = logging.getLogger(__name__) @@ -124,6 +125,11 @@ def keyPressEvent(self, a0: QKeyEvent | None): else: super().keyPressEvent(a0) + def changeEvent(self, a0: QEvent | None) -> None: + super().changeEvent(a0) + if should_process_theme_change(self, a0): + self.update() + class LabelAndCategoryEdit(QWidget): def __init__( @@ -164,14 +170,10 @@ def on_signal_textEditedAndFocusLost(self): def _format_category_edit(self) -> None: """Format category edit.""" - palette = QtGui.QPalette() - background_color = None + palette = QtGui.QPalette(QApplication.palette()) if self.category_edit.text(): - background_color = category_color(self.category_edit.text()) - palette.setColor(QtGui.QPalette.ColorRole.Base, background_color) - else: - palette = (self.category_edit.style() or QStyle()).standardPalette() + palette.setColor(QtGui.QPalette.ColorRole.Base, category_color(self.category_edit.text())) self.category_edit.setPalette(palette) self.category_edit.update() @@ -217,6 +219,11 @@ def set_label_readonly(self, value: bool): """Set label readonly.""" self.label_edit.setReadOnly(value) + def changeEvent(self, a0: QEvent | None) -> None: + super().changeEvent(a0) + if should_process_theme_change(self, a0): + self._format_category_edit() + class WalletLabelAndCategoryEdit(LabelAndCategoryEdit): def __init__( diff --git a/bitcoin_safe/gui/qt/loading_wallet_tab.py b/bitcoin_safe/gui/qt/loading_wallet_tab.py index d487f373a..eb11bdabe 100644 --- a/bitcoin_safe/gui/qt/loading_wallet_tab.py +++ b/bitcoin_safe/gui/qt/loading_wallet_tab.py @@ -42,7 +42,6 @@ ) from bitcoin_safe.gui.qt.sidebar.sidebar_tree import SidebarNode, SidebarTree -from bitcoin_safe.gui.qt.util import svg_tools logger = logging.getLogger(__name__) @@ -76,7 +75,7 @@ def __enter__(self) -> None: """Enter context manager.""" self.tabs.root.addChildNode( SidebarNode( - icon=svg_tools.get_QIcon("status_waiting.svg"), + icon="status_waiting.svg", title=self.name, data=self, widget=self, diff --git a/bitcoin_safe/gui/qt/main.py b/bitcoin_safe/gui/qt/main.py index 5e4a2ee61..cd6eb5169 100644 --- a/bitcoin_safe/gui/qt/main.py +++ b/bitcoin_safe/gui/qt/main.py @@ -59,6 +59,7 @@ from cryptography.fernet import InvalidToken from PyQt6.QtCore import ( QCoreApplication, + QEvent, QLocale, QPoint, QSettings, @@ -126,6 +127,7 @@ from bitcoin_safe.pdfrecovery import make_and_open_pdf from bitcoin_safe.plugin_framework.external_plugin_registry import ExternalPluginRegistry from bitcoin_safe.pyqt6_restart import restart_application +from bitcoin_safe.theme import apply_theme_mode from bitcoin_safe.tx import HiddenTxUiInfos, TxBuilderInfos, TxUiInfos, short_tx_id from bitcoin_safe.util import OptExcInfo, filename_clean from bitcoin_safe.wallet import ProtoWallet, ToolsTxUiInfo, Wallet @@ -162,6 +164,8 @@ center_on_screen, delayed_execution, do_copy, + is_theme_change_event, + propagate_theme_change_to_descendants, ) logger = logging.getLogger(__name__) @@ -198,6 +202,9 @@ def __init__( logger.debug("UserConfig will be created new") self.config = config if config else UserConfig.from_file() self.config.network = network if network else self.config.network + app = QApplication.instance() + if isinstance(app, QApplication): + apply_theme_mode(app, self.config.theme_mode) self.external_registry = ExternalPluginRegistry.from_config(self.config) self.new_startup_network: bdk.Network | None = None self._before_close_was_run = False @@ -602,6 +609,14 @@ def setupUi(self, config_present: bool) -> None: self.refresh_plugin_notification_bars() + def changeEvent(self, a0: QEvent | None) -> None: + """Forward application palette changes to descendants that rely on changeEvent hooks.""" + super().changeEvent(a0) + if is_theme_change_event(a0): + central_widget = self.centralWidget() + if central_widget is not None: + propagate_theme_change_to_descendants(central_widget) + def p2p_listening_on_block(self, block_hash: str): """P2p listening on block.""" logger.info(f"Block hash {block_hash} received via the p2p network") @@ -1392,7 +1407,11 @@ def rebuild_current_wallet_tab_menu(self) -> None: if plugins_node := qt_wallet.get_plugins_node(): plugin_node_icon = plugins_node.icon if plugin_node_icon: - self.menu_action_open_plugins.setIcon(plugin_node_icon) + self.menu_action_open_plugins.setIcon( + svg_tools.get_QIcon(str(plugin_node_icon)) + if isinstance(plugin_node_icon, (str, Path)) + else plugin_node_icon + ) tab_nodes = [node for node in qt_wallet.tabs.child_nodes if node.widget and not node.isHidden()] @@ -1479,7 +1498,7 @@ def open_network_map(self) -> None: self.global_network_map_node = SidebarNode[object]( widget=self.global_network_map_widget, data=self.global_network_map_widget, - icon=svg_tools.get_QIcon("bi--map.svg"), + icon="bi--map.svg", title=self.tr("Network Map"), hidable=True, closable=False, @@ -2613,7 +2632,7 @@ def open_qtprotowallet_setup( loop_in_thread=self.loop_in_thread, ) - qt_protowallet.tabs.setIcon(svg_tools.get_QIcon("file.svg")) + qt_protowallet.tabs.setIcon("file.svg") qt_protowallet.tabs.setTitle(qt_protowallet.protowallet.id) qt_protowallet.signal_close_wallet.connect( @@ -2716,7 +2735,7 @@ def on_set_tab_properties(self, tab: object, tab_text: str, icon_name: str, tool for root in self.tab_wallets.roots: if root.data == tab: root.setTitle(tab_text) - root.setIcon(svg_tools.get_QIcon(icon_name)) + root.setIcon(icon_name) if ( isinstance(tab, QTWallet) @@ -2751,7 +2770,7 @@ def add_qt_wallet( # it can save exactly there again qt_wallet.file_path = file_path - qt_wallet.tabs.setIcon(svg_tools.get_QIcon("status_waiting.svg")) + qt_wallet.tabs.setIcon("status_waiting.svg") qt_wallet.tabs.setTitle(qt_wallet.wallet.id) with LoadingWalletTab(self.tab_wallets, qt_wallet.wallet.id, focus=True): diff --git a/bitcoin_safe/gui/qt/my_treeview.py b/bitcoin_safe/gui/qt/my_treeview.py index 98025bf61..d4819838f 100644 --- a/bitcoin_safe/gui/qt/my_treeview.py +++ b/bitcoin_safe/gui/qt/my_treeview.py @@ -136,7 +136,7 @@ from ...config import UserConfig from ...i18n import translate from .color_corrected_treeview import ColorCorrectedTreeView -from .util import do_copy, set_no_margins +from .util import do_copy, set_no_margins, should_process_theme_change logger = logging.getLogger(__name__) @@ -677,6 +677,13 @@ def dump(self) -> dict[str, Any]: d["selected_ids"] = self.get_selected_keys(role=MyItemDataRole.ROLE_CLIPBOARD_DATA) return d + def changeEvent(self, event: QEvent | None) -> None: + """Refresh toolbar icons when the palette changes.""" + super().changeEvent(event) + if should_process_theme_change(self, event): + self.updateUi() + self.update_content() + @classmethod def _do_hidden_column_migration(cls, dct: dict[str, Any]): hidden_columns_int = dct.get("hidden_columns") @@ -1695,13 +1702,12 @@ def create_toolbar_with_menu(self, title: str): "", ) - toolbar_button = QToolButton(self) + self.toolbar_button = QToolButton(self) - toolbar_button.clicked.connect(partial(self.menu.exec, QCursor.pos())) - toolbar_button.setIcon(svg_tools.get_QIcon("bi--gear.svg")) - toolbar_button.setMenu(self.menu) - toolbar_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) - toolbar_button.setFocusPolicy(Qt.FocusPolicy.NoFocus) + self.toolbar_button.clicked.connect(partial(self.menu.exec, QCursor.pos())) + self.toolbar_button.setMenu(self.menu) + self.toolbar_button.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) + self.toolbar_button.setFocusPolicy(Qt.FocusPolicy.NoFocus) self.toolbar = QWidget(self) self.toolbar_layout = QHBoxLayout(self.toolbar) set_no_margins(self.toolbar_layout) @@ -1718,7 +1724,7 @@ def create_toolbar_with_menu(self, title: str): self.toolbar_layout.addWidget(self.balance_label) self.toolbar_layout.addStretch() self.toolbar_layout.addWidget(self.search_edit) - self.toolbar_layout.addWidget(toolbar_button) + self.toolbar_layout.addWidget(self.toolbar_button) self.fill_menu_hiddden_columns() def insert_filter_widget(self, widget: QWidget) -> None: @@ -1763,8 +1769,15 @@ def updateUi(self) -> None: self.search_edit.setPlaceholderText(translate("mytreeview", "Type to filter")) self.action_export_as_csv.setText(translate("mytreeview", "Export as CSV")) self.menu_hiddden_columns.setTitle(translate("mytreeview", "Visible columns")) + self.toolbar_button.setIcon(svg_tools.get_QIcon("bi--gear.svg")) self.fill_menu_hiddden_columns() + def changeEvent(self, a0: QEvent | None) -> None: + """Refresh toolbar icons when the palette changes.""" + super().changeEvent(a0) + if should_process_theme_change(self, a0): + self.updateUi() + def close(self) -> bool: """Close.""" if self.searchable_list: diff --git a/bitcoin_safe/gui/qt/network_settings/main.py b/bitcoin_safe/gui/qt/network_settings/main.py index b8628168f..29a3f1a86 100644 --- a/bitcoin_safe/gui/qt/network_settings/main.py +++ b/bitcoin_safe/gui/qt/network_settings/main.py @@ -71,7 +71,6 @@ from bitcoin_safe.gui.qt.notification_bar_cbf import get_p2p_tooltip_text from bitcoin_safe.gui.qt.util import ( Message, - adjust_bg_color_for_darkmode, ensure_scheme, get_host_and_port, remove_scheme, @@ -412,8 +411,8 @@ def __init__( self._layout.addWidget(self.groupbox_proxy) self.proxy_warning_label = NotificationBar("") - self.proxy_warning_label.set_background_color(adjust_bg_color_for_darkmode(QColor("#FFDF00"))) - self.proxy_warning_label.set_icon(svg_tools.get_QIcon("warning.svg")) + self.proxy_warning_label.set_background_base_color(QColor("#FFDF00")) + self.proxy_warning_label.set_icon("warning.svg") self._layout.addWidget(self.proxy_warning_label) # Create buttons and layout diff --git a/bitcoin_safe/gui/qt/new_wallet_welcome_screen.py b/bitcoin_safe/gui/qt/new_wallet_welcome_screen.py index 849098342..bd4c9bd3b 100644 --- a/bitcoin_safe/gui/qt/new_wallet_welcome_screen.py +++ b/bitcoin_safe/gui/qt/new_wallet_welcome_screen.py @@ -39,6 +39,7 @@ from PyQt6.QtGui import QHideEvent, QPalette, QShowEvent from PyQt6.QtWidgets import ( QDialog, + QGraphicsOpacityEffect, QHBoxLayout, QLabel, QLineEdit, @@ -52,7 +53,14 @@ from bitcoin_safe.gui.qt.icon_label import IconLabel from bitcoin_safe.gui.qt.sidebar.sidebar_tree import SidebarNode, SidebarTree from bitcoin_safe.gui.qt.styled_card_frame import BaseBorderCardFrame -from bitcoin_safe.gui.qt.util import color_with_alpha, get_neutral_surface_colors, svg_tools, to_color_name +from bitcoin_safe.gui.qt.util import ( + color_with_alpha, + get_neutral_surface_colors, + is_theme_change_event, + should_process_theme_change, + svg_tools, + to_color_name, +) from bitcoin_safe.hardware_signers import SUPPORTED_HARDWARE_SIGNERS_URL from bitcoin_safe.signals import Signals @@ -69,6 +77,7 @@ class _WindowWithConfig(Protocol): class WelcomeActionCard(CardBase): clicked = cast(SignalProtocol[[]], pyqtSignal()) + _disabled_opacity = 0.55 def __init__(self, icon_name: str, parent: QWidget | None = None) -> None: """Initialize instance.""" @@ -93,15 +102,35 @@ def __init__(self, icon_name: str, parent: QWidget | None = None) -> None: self.label_description = self.header_subtitle self.label_description.setWordWrap(True) self.label_description.setTextFormat(Qt.TextFormat.RichText) + self._opacity_effect = QGraphicsOpacityEffect(self) + self.setGraphicsEffect(self._opacity_effect) + self.updateUi() def set_content(self, title: str, description: str) -> None: """Set the card content.""" self.label_title.setText(f"{title}") self.label_description.setText(description) + def updateUi(self) -> None: + """Refresh palette-derived card colors.""" + self._apply_visual_state() + self._refresh_theme_dependent_ui() + + def _apply_visual_state(self) -> None: + self.background_color = get_neutral_surface_colors().panel_background + self._opacity_effect.setOpacity(self._disabled_opacity if not self.isEnabled() else 1.0) + + def changeEvent(self, a0: QEvent | None) -> None: + """Refresh card styling when the theme or enabled state changes.""" + super().changeEvent(a0) + if a0 is not None and (a0.type() == QEvent.Type.EnabledChange or is_theme_change_event(a0)): + self._apply_visual_state() + self._refresh_theme_dependent_ui() + class NetworkChoiceCard(CardBase): clicked = cast(SignalProtocol[[]], pyqtSignal()) + _refreshing_style = False def __init__(self, icon_name: str, parent: QWidget | None = None) -> None: """Initialize instance.""" @@ -194,7 +223,7 @@ def __init__(self, icon_name: str, parent: QWidget | None = None) -> None: self.register_header_click_target(widget) widget.setCursor(Qt.CursorShape.PointingHandCursor) - self.refresh_style() + self.updateUi() def set_content( self, @@ -220,16 +249,11 @@ def changeEvent(self, a0: QEvent | None) -> None: """Refresh card styling when the theme changes.""" super().changeEvent(a0) if ( - a0 - and a0.type() - in { - QEvent.Type.ApplicationPaletteChange, - QEvent.Type.EnabledChange, - QEvent.Type.PaletteChange, - } - and not self._refreshing_style + not self._refreshing_style + and a0 is not None + and is_theme_change_event(a0, include_enabled_change=True) ): - self.refresh_style() + self.updateUi() def _get_style_content(self) -> str: surface_colors = get_neutral_surface_colors() @@ -242,6 +266,16 @@ def refresh_style(self) -> None: if not hasattr(self, "cta_panel") or self._refreshing_style: return self._refreshing_style = True + try: + super().refresh_style() + self.cta_panel.refresh_style() + finally: + self._refreshing_style = False + + def updateUi(self) -> None: + """Refresh palette-derived card colors and text styling.""" + if not hasattr(self, "cta_panel") or self._refreshing_style: + return surface_colors = get_neutral_surface_colors() palette = self.palette() eyebrow_color = to_color_name(color_with_alpha(palette.color(QPalette.ColorRole.WindowText), 120)) @@ -250,17 +284,11 @@ def refresh_style(self) -> None: self.background_color = surface_colors.content_background self.cta_panel.background_color = surface_colors.panel_background - try: - super().refresh_style() - self.cta_panel.refresh_style() - self.label_eyebrow.setStyleSheet( - f"color: {eyebrow_color}; font-weight: 600; letter-spacing: 0.08em;" - ) - self.label_description.setStyleSheet(f"color: {muted_color};") - self.label_bullets.setStyleSheet(f"color: {muted_color};") - self.label_cta_caption.setStyleSheet(f"color: {cta_caption_color};") - finally: - self._refreshing_style = False + self.refresh_style() + self.label_eyebrow.setStyleSheet(f"color: {eyebrow_color}; font-weight: 600; letter-spacing: 0.08em;") + self.label_description.setStyleSheet(f"color: {muted_color};") + self.label_bullets.setStyleSheet(f"color: {muted_color};") + self.label_cta_caption.setStyleSheet(f"color: {cta_caption_color};") class NetworkChoiceWelcomeScreen(QWidget): @@ -284,6 +312,10 @@ def remove_me(self) -> None: """Remove me.""" self.signal_remove_me.emit(self) + def showEvent(self, a0: QShowEvent | None) -> None: + super().showEvent(a0) + self.updateUi() + def on_click_secure_wallet(self) -> None: """Continue on mainnet.""" self.signal_onclick_secure_wallet.emit() @@ -301,7 +333,7 @@ def add_network_choice_welcome_tab(self, main_tabs: SidebarTree[object]) -> None return main_tabs.root.addChildNode( SidebarNode( - icon=svg_tools.get_QIcon("file.svg"), + icon="file.svg", title=self.tr("Create new wallet"), data=self, widget=self, @@ -355,6 +387,8 @@ def updateUi(self) -> None: "Start transact with sound money or learn in a secure playground. Either way, you can always create another wallet later." ) ) + self.card_secure_wallet.updateUi() + self.card_safe_playground.updateUi() self.card_secure_wallet.set_content( eyebrow=self.tr("Real sound money (BTC)"), @@ -386,6 +420,12 @@ def updateUi(self) -> None: cta_caption=self.tr("Uses Signet test network"), ) + def changeEvent(self, a0: QEvent | None) -> None: + """Refresh child cards when the application theme changes.""" + super().changeEvent(a0) + if should_process_theme_change(self, a0): + self.updateUi() + class NewWalletWelcomeScreen(QWidget): signal_onclick_hot_wallet = cast(SignalProtocol[[]], pyqtSignal()) @@ -419,6 +459,7 @@ def __init__( def showEvent(self, a0: QShowEvent | None) -> None: super().showEvent(a0) + self.updateUi() self.visibilityChanged.emit(True) def hideEvent(self, a0: QHideEvent | None) -> None: @@ -483,7 +524,7 @@ def add_new_wallet_welcome_tab(self, main_tabs: SidebarTree[object]) -> None: return main_tabs.root.addChildNode( SidebarNode( - icon=svg_tools.get_QIcon("file.svg"), + icon="file.svg", title=self.tr("Create new wallet"), data=self, widget=self, @@ -546,6 +587,10 @@ def updateUi(self) -> None: self.card_demo_wallet.setVisible(on_testnet) self.card_hot_wallet.setEnabled(on_testnet) + self.card_demo_wallet.updateUi() + self.card_hot_wallet.updateUi() + self.card_connect_devices.updateUi() + self.card_custom_wallet.updateUi() self.card_demo_wallet.set_content( title=self.tr("Public Demo wallet"), @@ -580,3 +625,9 @@ def updateUi(self) -> None: title=self.tr("Custom / Recovery"), description=self.tr("Restore a wallet from hardware wallet(s) or a descriptor."), ) + + def changeEvent(self, a0: QEvent | None) -> None: + """Refresh child cards when the application theme changes.""" + super().changeEvent(a0) + if should_process_theme_change(self, a0): + self.updateUi() diff --git a/bitcoin_safe/gui/qt/notification_bar.py b/bitcoin_safe/gui/qt/notification_bar.py index 287f39269..764475c20 100644 --- a/bitcoin_safe/gui/qt/notification_bar.py +++ b/bitcoin_safe/gui/qt/notification_bar.py @@ -32,9 +32,10 @@ import logging import sys from collections.abc import Callable +from pathlib import Path -from PyQt6.QtCore import Qt -from PyQt6.QtGui import QColor, QIcon +from PyQt6.QtCore import QEvent, Qt +from PyQt6.QtGui import QColor from PyQt6.QtWidgets import ( QApplication, QHBoxLayout, @@ -49,7 +50,12 @@ from bitcoin_safe.gui.qt.icon_label import IconLabel from .qr_components.square_buttons import CloseButton -from .util import adjust_bg_color_for_darkmode, set_margins +from .util import ( + adjust_brightness, + is_dark_mode, + set_margins, + should_process_theme_change, +) logger = logging.getLogger(__name__) @@ -67,6 +73,8 @@ def __init__( super().__init__(parent) self._layout = QHBoxLayout(self) self.color: QColor | None = None + self._base_background_color: QColor | None = None + self._styled_widgets: list[QWidget] = [] self._layout.setAlignment(Qt.AlignmentFlag.AlignVCenter) self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) set_margins( @@ -104,6 +112,8 @@ def add_styled_widget(self, widget: QWidget): """Add styled widget.""" if widget.parent() is not self: widget.setParent(self) + if widget not in self._styled_widgets: + self._styled_widgets.append(widget) if self.color: self.style_widget(widget, color=self.color) self._layout.insertWidget(self._layout.count() - 2, widget) @@ -114,17 +124,38 @@ def set_has_close_button(self, has_close_button: bool): def style_widget(self, button: QWidget, color: QColor): """Style widget.""" - button.setAttribute(Qt.WidgetAttribute.WA_StyledBackground) - button.setObjectName(button.__class__.__name__) - button.setStyleSheet(f"#{button.objectName()} {{ background-color: {color.name()};}}") - - def set_background_color(self, color: QColor) -> None: - """Set background color.""" + button.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) + object_name = button.__class__.__name__ + if button.objectName() != object_name: + button.setObjectName(object_name) + stylesheet = f"#{button.objectName()} {{ background-color: {color.name()};}}" + if button.styleSheet() != stylesheet: + button.setStyleSheet(stylesheet) + + def _apply_background_color(self, color: QColor, clear_base_background: bool) -> None: + """Apply the current background color to all notification bar widgets.""" + if clear_base_background: + self._base_background_color = None self.color = color self.style_widget(self, color=color) self.style_widget(self.optionalButton, color=color) + for widget in self._styled_widgets: + self.style_widget(widget, color=color) - def set_icon(self, icon: QIcon | None, sizes: tuple[int | None, int | None] = (None, None)) -> None: + def set_background_color(self, color: QColor) -> None: + """Set background color.""" + self._apply_background_color(color, clear_base_background=True) + + def set_background_base_color(self, color: QColor) -> None: + """Set a background color that should be adjusted for the active theme.""" + self._base_background_color = color + self._apply_background_color(self._themed_background_color(color), clear_base_background=False) + + def _themed_background_color(self, color: QColor) -> QColor: + """Adjust a base background color using this widget's current palette.""" + return adjust_brightness(color, -0.4) if is_dark_mode() else color + + def set_icon(self, icon: str | Path | None, sizes: tuple[int | None, int | None] = (None, None)) -> None: """Set icon.""" self.icon_label.set_icon(icon=icon, sizes=sizes) @@ -132,6 +163,18 @@ def updateUi(self) -> None: """UpdateUi.""" self.closeButton.setAccessibleName(self.tr("Close notification")) + def _refresh_theme_background(self) -> None: + if self._base_background_color is not None: + self.set_background_base_color(self._base_background_color) + elif self.color is not None: + self.set_background_color(self.color) + + def changeEvent(self, a0: QEvent | None) -> None: + """Refresh theme-derived notification colors and icons.""" + super().changeEvent(a0) + if should_process_theme_change(self, a0): + self._refresh_theme_background() + if __name__ == "__main__": @@ -146,8 +189,10 @@ def __init__(self) -> None: layout.setSpacing(0) self.notificationBar = NotificationBar(text="my notification") - self.notificationBar.set_background_color(adjust_bg_color_for_darkmode(QColor("lightblue"))) - self.notificationBar.set_icon(QIcon("../icons/bitcoin-testnet.svg")) + self.notificationBar.set_background_color( + self.notificationBar._themed_background_color(QColor("lightblue")) + ) + self.notificationBar.set_icon("bitcoin-testnet.svg") layout.addWidget(self.notificationBar) layout.addWidget(QTextEdit("some text")) diff --git a/bitcoin_safe/gui/qt/notification_bar_cbf.py b/bitcoin_safe/gui/qt/notification_bar_cbf.py index 7b7eecd84..61577bfac 100644 --- a/bitcoin_safe/gui/qt/notification_bar_cbf.py +++ b/bitcoin_safe/gui/qt/notification_bar_cbf.py @@ -42,8 +42,6 @@ from bitcoin_safe.pythonbdk_types import BlockchainType from bitcoin_safe.signals import SignalsMin -from .util import svg_tools - logger = logging.getLogger(__name__) @@ -80,7 +78,7 @@ def __init__( self.signals_min = signals_min self.optionalButton.setHidden(False) # color = adjust_bg_color_for_darkmode(QColor("#7ad19f")) - self.set_icon(svg_tools.get_QIcon("node.svg")) + self.set_icon("node.svg") self.learn_more_button = IconLabel(parent=self) self._layout.insertWidget(1, self.learn_more_button) diff --git a/bitcoin_safe/gui/qt/notification_bar_regtest.py b/bitcoin_safe/gui/qt/notification_bar_regtest.py index ecea6bd10..8599f0897 100644 --- a/bitcoin_safe/gui/qt/notification_bar_regtest.py +++ b/bitcoin_safe/gui/qt/notification_bar_regtest.py @@ -41,7 +41,6 @@ from bitcoin_safe.signals import SignalsMin from .testnet_faucet import open_testnet_faucet -from .util import adjust_bg_color_for_darkmode, svg_tools logger = logging.getLogger(__name__) @@ -64,9 +63,8 @@ def __init__( ) self.network = network self.signals_min = signals_min - color = adjust_bg_color_for_darkmode(QColor("lightblue")) - self.set_background_color(color) - self.set_icon(svg_tools.get_QIcon(f"bitcoin-{network.name.lower()}.svg")) + self.set_background_base_color(QColor("lightblue")) + self.set_icon(f"bitcoin-{network.name.lower()}.svg") self.optionalButton.setHidden(False) self.faucet = get_testnet_faucet(network=self.network) @@ -85,6 +83,8 @@ def open_faucet(self): def updateUi(self) -> None: """UpdateUi.""" super().updateUi() + self.set_background_base_color(QColor("lightblue")) + self.set_icon(f"bitcoin-{self.network.name.lower()}.svg") self.faucet_button.setText(self.tr("Get {testnet} coins").format(testnet=self.network.name.lower())) self.faucet_button.setHidden(not bool(self.faucet)) self.optionalButton.setText(self.tr("Open Network Settings")) diff --git a/bitcoin_safe/gui/qt/qr_components/quick_receive.py b/bitcoin_safe/gui/qt/qr_components/quick_receive.py index 5f1160dc1..5660c165a 100644 --- a/bitcoin_safe/gui/qt/qr_components/quick_receive.py +++ b/bitcoin_safe/gui/qt/qr_components/quick_receive.py @@ -36,27 +36,26 @@ from bitcoin_qr_tools.gui.qr_widgets import QRCodeWidgetSVG from bitcoin_safe_lib.gui.qt.signal_tracker import SignalProtocol, SignalTools, SignalTracker from bitcoin_safe_lib.util import insert_invisible_spaces_for_wordwrap -from PyQt6.QtCore import QSize, Qt, pyqtSignal +from PyQt6.QtCore import QEvent, QSize, Qt, pyqtSignal from PyQt6.QtGui import QFont, QKeyEvent, QMouseEvent, QPalette from PyQt6.QtWidgets import ( - QFrame, QGraphicsOpacityEffect, QHBoxLayout, QLabel, QPushButton, - QScrollArea, QSizePolicy, QSpacerItem, QVBoxLayout, QWidget, ) +from bitcoin_safe.gui.qt.invisible_scroll_area import InvisibleScrollArea from bitcoin_safe.gui.qt.qr_components.square_buttons import FlatSquareButton from bitcoin_safe.gui.qt.util import do_copy, set_translucent, svg_tools from bitcoin_safe.i18n import translate from bitcoin_safe.pythonbdk_types import AddressInfoMin -from ..util import set_margins, to_color_name +from ..util import category_color, set_margins, should_process_theme_change, to_color_name logger = logging.getLogger(__name__) @@ -67,6 +66,8 @@ def __init__(self, title, hex_color: str, border_color: str | None = None, paren super().__init__(parent=parent) self._layout = QVBoxLayout(self) self._layout.setSpacing(3) + self._background_color = hex_color + self._border_color = border_color self.title = QLabel(title, self) self._radius = 20 @@ -89,11 +90,15 @@ def __init__(self, title, hex_color: str, border_color: str | None = None, paren self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) self.setObjectName(str(id(self))) + self.refresh_style() + + def refresh_style(self) -> None: + """Reapply the component stylesheet.""" self.setStyleSheet( f""" #{self.objectName()} {{ - background-color: {hex_color}; - border: {"none" if border_color is None else f"2px dashed {border_color}"}; + background-color: {self._background_color}; + border: {"none" if self._border_color is None else f"2px dashed {self._border_color}"}; border-radius: 10px; }} """ @@ -173,6 +178,11 @@ def category(self) -> str: def updateUi(self): """UpdateUi.""" + self._background_color = category_color(self.category).name() + self.refresh_style() + self.force_new_button.setIcon(svg_tools.get_QIcon("reset-update.svg")) + self.copy_button.setIcon(svg_tools.get_QIcon("bi--copy.svg")) + self.qr_button.setIcon(svg_tools.get_QIcon("bi--qr-code.svg")) self.force_new_button.setToolTip(self.tr("Get next address")) self.copy_button.setToolTip(self.tr("Copy address to clipboard")) self.qr_button.setToolTip(self.tr("Magnify QR code")) @@ -246,9 +256,18 @@ def sizeHint(self): def updateUi(self) -> None: # self.title.setText(self.tr("Add New Category")) """UpdateUi.""" + icon = svg_tools.get_QIcon("bi--plus-lg.svg") + self.icon_label.setPixmap(icon.pixmap(QSize(36, 36), self.devicePixelRatioF())) self.caption_label.setText(self.tr("Add new category")) self.caption_label_sub.setText(self.tr("KYC Exchange, Private, ...")) self.setToolTip(self.tr("Add new category")) + self.refresh_style() + + def refresh_style(self) -> None: + """Refresh palette-derived styling.""" + self._background_color = to_color_name(QPalette.ColorRole.Midlight) + self._border_color = to_color_name(QPalette.ColorRole.Mid) + super().refresh_style() def mouseReleaseEvent(self, a0: QMouseEvent | None) -> None: """MouseReleaseEvent.""" @@ -286,7 +305,9 @@ def __init__(self, title="Quick Receive", parent=None) -> None: # Horizontal Layout for Scroll Area content # Content Widget for the Scroll Area - self.content_widget = QWidget(parent) + self.scroll_area = InvisibleScrollArea(self) + self.scroll_area.setWidgetResizable(True) + self.content_widget = self.scroll_area.content_widget self.content_widget.setAutoFillBackground(True) self.content_widget_layout = QHBoxLayout(self.content_widget) # self.content_widget_layout.setContentsMargins(0, 0, 0, 0) # Left, Top, Right, Bottom margins @@ -298,18 +319,8 @@ def __init__(self, title="Quick Receive", parent=None) -> None: ) self.content_widget_layout.addItem(self._trailing_spacer) - # Scroll Area - self.scroll_area = QScrollArea() - self.scroll_area.setWidgetResizable(True) - self.scroll_area.setWidget(self.content_widget) - self.scroll_area.setFrameShape(QFrame.Shape.NoFrame) - set_translucent(self.content_widget) - # 2) scroll‐area viewport (where it actually paints) - if viewport := self.scroll_area.viewport(): - set_translucent(viewport) - # Main Layout main_layout = QVBoxLayout(self) header_widget = QWidget(self) @@ -405,8 +416,15 @@ def _insert_before_widget(self, widget: QWidget, before_widget: QWidget) -> None def updateUi(self): """UpdateUi.""" self.label_title.setText(translate("QuickReceive", "Quick Receive")) + self.manage_categories_button.setIcon(svg_tools.get_QIcon("bi--gear.svg")) self.manage_categories_button.setText(translate("QuickReceive", "Manage Categories")) self.manage_categories_button.setToolTip(translate("QuickReceive", "Open the category manager")) self.add_category_button.updateUi() for gb in self.group_boxes: gb.updateUi() + + def changeEvent(self, a0: QEvent | None) -> None: + """Refresh translucent surfaces when the app palette changes.""" + super().changeEvent(a0) + if should_process_theme_change(self, a0): + self.updateUi() diff --git a/bitcoin_safe/gui/qt/qt_wallet.py b/bitcoin_safe/gui/qt/qt_wallet.py index 925c77fd0..6f97b7e35 100644 --- a/bitcoin_safe/gui/qt/qt_wallet.py +++ b/bitcoin_safe/gui/qt/qt_wallet.py @@ -84,7 +84,6 @@ from bitcoin_safe.gui.qt.sidebar.sidebar_tree import SidebarNode from bitcoin_safe.gui.qt.ui_tx.ui_tx_creator import UITx_Creator from bitcoin_safe.gui.qt.ui_tx.ui_tx_viewer import UITx_Viewer -from bitcoin_safe.gui.qt.util import svg_tools from bitcoin_safe.gui.qt.utxo_list import UTXOList, UtxoListWithToolbar from bitcoin_safe.keystore import KeyStore from bitcoin_safe.labels import LabelType @@ -254,7 +253,7 @@ def create_and_add_settings_tab(self, protowallet: ProtoWallet) -> tuple[Descrip settings_node = SidebarNode[object]( widget=wallet_descriptor_ui, data=wallet_descriptor_ui, - icon=svg_tools.get_QIcon("bi--text-left.svg"), + icon="bi--text-left.svg", title=self.tr("Setup wallet"), parent=self, ) @@ -715,8 +714,11 @@ def updateUi(self) -> None: self.fiat_value_label_title.setText(self.tr("Value")) self.category_manager.updateUi() self.quick_receive.updateUi() + self.wallet_balance_chart.updateUi() if self.network_map_widget: self.network_map_widget.updateUi() + if self.plugin_manager: + self.plugin_manager.updateUi() self.wallet_corruption_warning_bar.updateUi() self._update_history_initial_sync_overlay_visibility() @@ -885,7 +887,7 @@ def create_and_add_settings_tab(self) -> tuple[DescriptorUI, SidebarNode]: settings_node = SidebarNode[object]( data=wallet_descriptor_ui, widget=wallet_descriptor_ui, - icon=svg_tools.get_QIcon("bi--text-left.svg"), + icon="bi--text-left.svg", title="", parent=self, ) @@ -1482,7 +1484,7 @@ def _create_send_tab( send_node = SidebarNode[object]( data=uitx_creator, widget=uitx_creator, - icon=svg_tools.get_QIcon("bi--send.svg"), + icon="bi--send.svg", title="", parent=self, ) @@ -1760,7 +1762,7 @@ def _create_hist_tab( hist_node = SidebarNode( data=tab, widget=tab, - icon=svg_tools.get_QIcon("ic--sharp-timeline.svg"), + icon="ic--sharp-timeline.svg", title="", ) tabs.insertChildNode( @@ -1844,9 +1846,7 @@ def _create_addresses_tab( tab = self.create_list_tab(address_list_with_toolbar, tabs) - address_node = SidebarNode( - data=tab, widget=tab, icon=svg_tools.get_QIcon("ic--baseline-call-received.svg"), title="" - ) + address_node = SidebarNode(data=tab, widget=tab, icon="ic--baseline-call-received.svg", title="") tabs.insertChildNode( 1, address_node, diff --git a/bitcoin_safe/gui/qt/recent_wallets_sidebar.py b/bitcoin_safe/gui/qt/recent_wallets_sidebar.py index 6f760c0f4..0c7bbdeac 100644 --- a/bitcoin_safe/gui/qt/recent_wallets_sidebar.py +++ b/bitcoin_safe/gui/qt/recent_wallets_sidebar.py @@ -37,13 +37,13 @@ from bitcoin_safe_lib.gui.qt.signal_tracker import SignalProtocol from bitcoin_safe_lib.util_os import show_file_in_explorer -from PyQt6.QtCore import QPoint, pyqtSignal +from PyQt6.QtCore import QEvent, QPoint, pyqtSignal from PyQt6.QtWidgets import QVBoxLayout, QWidget from bitcoin_safe.gui.qt.sidebar.search_sidebar_tree import SearchSidebarTree from bitcoin_safe.gui.qt.sidebar.search_tree_view import ResultItem, SearchTreeView from bitcoin_safe.gui.qt.sidebar.sidebar_tree import SidebarNode, SidebarTree -from bitcoin_safe.gui.qt.util import svg_tools +from bitcoin_safe.gui.qt.util import should_process_theme_change from bitcoin_safe.gui.qt.wrappers import Menu logger = logging.getLogger(__name__) @@ -105,7 +105,7 @@ def __init__( self.signal_open_wallet = signal_open_wallet self.wallet_dir = Path(wallet_dir) self.hide_extension = hide_extension - self.wallet_icon = svg_tools.get_QIcon("bi--wallet2.svg") + self.wallet_icon = "bi--wallet2.svg" self.recent_wallet_paths: list[str] = [] self.all_wallet_paths: list[str] = [] self._rebuilding = False @@ -247,3 +247,9 @@ def _on_context_menu_requested(self, node: object, position: QPoint) -> None: slot=partial(show_file_in_explorer, filename=Path(file_path)), ) menu.exec(position) + + def changeEvent(self, a0: QEvent | None) -> None: + """Re-render sidebar icons when the application theme changes.""" + super().changeEvent(a0) + if should_process_theme_change(self, a0): + self.sidebar_tree.root.refresh_style() diff --git a/bitcoin_safe/gui/qt/settings.py b/bitcoin_safe/gui/qt/settings.py index 80260cb39..e4692c1b6 100644 --- a/bitcoin_safe/gui/qt/settings.py +++ b/bitcoin_safe/gui/qt/settings.py @@ -60,7 +60,6 @@ def __init__( ) -> None: """Initialize instance.""" super().__init__(parent=parent) - self.setWindowModality(Qt.WindowModality.ApplicationModal) self.signals = signals self.language_chooser = language_chooser self._license_dialog = LicenseDialog(self) diff --git a/bitcoin_safe/gui/qt/sidebar/search_tree_view.py b/bitcoin_safe/gui/qt/sidebar/search_tree_view.py index 91f167ca3..fa745a17a 100644 --- a/bitcoin_safe/gui/qt/sidebar/search_tree_view.py +++ b/bitcoin_safe/gui/qt/sidebar/search_tree_view.py @@ -32,6 +32,7 @@ import logging import sys from collections.abc import Callable +from pathlib import Path from PyQt6.QtCore import QItemSelectionModel, QModelIndex, QRectF, QSize, Qt, pyqtSignal from PyQt6.QtGui import ( @@ -60,6 +61,8 @@ QWidget, ) +from bitcoin_safe.gui.qt.util import svg_tools + logger = logging.getLogger(__name__) @@ -171,7 +174,7 @@ def __init__( self, text: str, parent: ResultItem | None = None, - icon: QIcon | None = None, + icon: QIcon | Path | str | None = None, obj=None, obj_key: str | None = None, ) -> None: @@ -355,7 +358,11 @@ def add_child(child: ResultItem) -> CustomItem: model_item = CustomItem() model_item.setText(child.text) if child.icon: - model_item.setIcon(child.icon) + if isinstance(child.icon, QIcon): + icon = child.icon + else: + icon = svg_tools.get_QIcon(str(child.icon)) + model_item.setIcon(icon) model_item.setEditable(False) model_item.result_item = child if model_parent: diff --git a/bitcoin_safe/gui/qt/sidebar/sidebar_tree.py b/bitcoin_safe/gui/qt/sidebar/sidebar_tree.py index e784ea7f2..ecd02d24a 100644 --- a/bitcoin_safe/gui/qt/sidebar/sidebar_tree.py +++ b/bitcoin_safe/gui/qt/sidebar/sidebar_tree.py @@ -33,10 +33,11 @@ import sys from collections.abc import Callable from functools import partial +from pathlib import Path from typing import Generic, TypeVar, cast from bitcoin_safe_lib.gui.qt.signal_tracker import SignalProtocol, SignalTracker -from PyQt6.QtCore import QPoint, Qt, pyqtSignal +from PyQt6.QtCore import QEvent, QPoint, Qt, pyqtSignal from PyQt6.QtGui import QAction, QColor, QFocusEvent, QIcon, QKeySequence, QPalette, QShortcut from PyQt6.QtWidgets import ( QApplication, @@ -59,8 +60,12 @@ ) from bitcoin_safe.gui.qt.util import ( get_neutral_surface_colors, + is_theme_change_event, + propagate_theme_change_to_descendants, set_no_margins, set_translucent, + should_process_theme_change, + svg_tools, to_color_name, ) @@ -152,7 +157,9 @@ def get_css(self, widget: QWidget): def style_widget(self, widget: QWidget): """Style widget.""" - self.setStyleSheet(self.get_css(widget=widget)) + css = self.get_css(widget=widget) + if self.styleSheet() != css: + self.setStyleSheet(css) def set_focus(self, focused: bool) -> None: """Mirror focus state onto the parent row for custom styling.""" @@ -166,16 +173,18 @@ def set_focus(self, focused: bool) -> None: class SidebarButton(QPushButton): """Checkable sidebar button with instance-scoped styles and adjustable indent.""" - def __init__(self, text: str, icon: QIcon | None = None, indent: float = 0, bf=False): + def __init__(self, text: str, icon: QIcon | Path | str | None = None, indent: float = 0, bf=False): """Initialize instance.""" super().__init__(text) self._bold = bf self._indent = indent + self._icon_name = icon if isinstance(icon, (str, Path)) else None + self._icon = icon if isinstance(icon, QIcon) else None self.setFlat(True) set_translucent(self) if icon: - self.setIcon(icon) + self.set_icon_source(icon) self.setObjectName(str(id(self)) + "button") self.setFixedHeight(36) @@ -197,12 +206,35 @@ def focusOutEvent(self, a0: QFocusEvent | None) -> None: super().focusOutEvent(a0) self._update_row_focus(False) + def changeEvent(self, e: QEvent | None) -> None: + """Refresh palette-dependent button styling and icons.""" + super().changeEvent(e) + if should_process_theme_change(self, e): + self._apply_style() + self.refresh_icon() + def setIndent(self, indent: float, bf=False) -> None: """SetIndent.""" self._indent = indent self._bold = bf self._apply_style() + def set_icon_source(self, icon: QIcon | Path | str | None) -> None: + """Set the icon source and apply it.""" + self._icon_name = icon if isinstance(icon, (str, Path)) else None + self._icon = icon if isinstance(icon, QIcon) else None + self.refresh_icon() + + def refresh_icon(self) -> None: + """Re-render the icon from its current source.""" + if self._icon_name: + super().setIcon(svg_tools.get_QIcon(str(self._icon_name))) + return + if self._icon: + super().setIcon(self._icon) + return + super().setIcon(QIcon()) + def _apply_style(self) -> None: """Apply style.""" padding = 12 + self._indent * 16 @@ -216,7 +248,8 @@ def _apply_style(self) -> None: {font_weight} }}""" css += f"\n#{self.objectName()}:focus {{\n outline: none;\n background-color: transparent;\n}}" - self.setStyleSheet(css) + if self.styleSheet() != css: + self.setStyleSheet(css) TT = TypeVar("TT") @@ -248,7 +281,7 @@ def __init__( data: TT, widget: QWidget | None = None, hide_header: bool = False, - icon: QIcon | None = None, + icon: QIcon | Path | str | None = None, closable: bool = False, hidable: bool = False, collapsible: bool = True, @@ -284,6 +317,9 @@ def __init__( surface_colors = get_neutral_surface_colors() self.background_color = background_color + self._uses_default_selected_color = selected_color is None + self._uses_default_hover_color = hover_color is None + self._uses_default_selected_hover_color = selected_hover_color is None self.selected_color = selected_color or surface_colors.content_background self.hover_color = hover_color or surface_colors.row_hover self.selected_hover_color = selected_hover_color or self.selected_color @@ -327,10 +363,10 @@ def setTitle(self, text: str) -> None: text = text.replace("&", "&&") self.header_btn.setText(text) - def setIcon(self, icon: QIcon) -> None: + def setIcon(self, icon: QIcon | Path | str | None) -> None: """SetIcon.""" self.icon = icon - self.header_btn.setIcon(icon) + self.header_btn.set_icon_source(icon) def setToolTip(self, a0: str | None) -> None: """SetToolTip.""" @@ -618,6 +654,26 @@ def _sync_content_visibility(self) -> None: """Sync content visibility.""" self.content.setVisible(bool(self.child_nodes and self.expanded and self.collapsible)) + def refresh_style(self) -> None: + """Refresh palette-dependent colors and icons for this subtree.""" + surface_colors = get_neutral_surface_colors() + if self._uses_default_selected_color: + self.selected_color = surface_colors.content_background + if self._uses_default_hover_color: + self.hover_color = surface_colors.row_hover + if self._uses_default_selected_hover_color: + self.selected_hover_color = self.selected_color + + self.header_row.hover_color = self.hover_color + self.header_row.selected_color = self.selected_color + self.header_row.selected_hover_color = self.selected_hover_color + self.header_row.style_widget(self.header_row) + self.header_btn.refresh_icon() + self.header_btn.update() + + for child in self.child_nodes: + child.refresh_style() + # -------------------- Tree helpers -------------------- def _attach_to_stack(self, stack: QStackedWidget) -> None: @@ -927,9 +983,7 @@ def __init__(self, parent=None, show_stack: bool = True): self._surface_colors = get_neutral_surface_colors() self.stack.setAutoFillBackground(True) # ensure it actually fills from its palette - pal = self.stack.palette() - pal.setColor(QPalette.ColorRole.Window, self._surface_colors.content_background) - self.stack.setPalette(pal) + self._refresh_stack_palette() self.stack.currentChanged.connect(self._on_stack_current_changed) # NEW @@ -1013,6 +1067,23 @@ def __init__(self, parent=None, show_stack: bool = True): self._shortcut_prev.activated.connect(partial(self._select_relative, -1)) self._shortcut_next.activated.connect(partial(self._select_relative, +1)) + def changeEvent(self, a0: QEvent | None) -> None: + """Refresh the stacked-page background when the application palette changes.""" + super().changeEvent(a0) + if should_process_theme_change(self, a0): + self._refresh_stack_palette() + for node in self.roots: + node.refresh_style() + if is_theme_change_event(a0): + propagate_theme_change_to_descendants(self.stack) + + def _refresh_stack_palette(self) -> None: + """Apply the current neutral surface color to the stacked-page background.""" + self._surface_colors = get_neutral_surface_colors() + palette = self.stack.palette() + palette.setColor(QPalette.ColorRole.Window, self._surface_colors.content_background) + self.stack.setPalette(palette) + @property def roots(self) -> list[SidebarNode[TT]]: """Roots.""" diff --git a/bitcoin_safe/gui/qt/styled_card_frame.py b/bitcoin_safe/gui/qt/styled_card_frame.py index 5adcca4b8..533dd5a5c 100644 --- a/bitcoin_safe/gui/qt/styled_card_frame.py +++ b/bitcoin_safe/gui/qt/styled_card_frame.py @@ -54,13 +54,13 @@ def _get_style_content(self): ) def refresh_style(self) -> None: - self.setStyleSheet( - f""" + stylesheet = f""" #{self.objectName()} {{ {self._get_style_content()} }} """ - ) + if self.styleSheet() != stylesheet: + self.setStyleSheet(stylesheet) class BaseBorderCardFrame(BaseCardFrame): diff --git a/bitcoin_safe/gui/qt/sync_tab.py b/bitcoin_safe/gui/qt/sync_tab.py index 29856e202..4a8a17ee7 100644 --- a/bitcoin_safe/gui/qt/sync_tab.py +++ b/bitcoin_safe/gui/qt/sync_tab.py @@ -41,7 +41,7 @@ from bitcoin_qr_tools.data import DataType from bitcoin_safe_lib.async_tools.loop_in_thread import LoopInThread from bitcoin_safe_lib.storage import filtered_for_init -from PyQt6.QtCore import QObject, Qt +from PyQt6.QtCore import QEvent, QObject, Qt from PyQt6.QtGui import QAction, QColor from PyQt6.QtWidgets import QPushButton, QWidget @@ -51,8 +51,8 @@ from bitcoin_safe.gui.qt.notification_bar import NotificationBar from bitcoin_safe.gui.qt.util import ( Message, - adjust_bg_color_for_darkmode, save_file_dialog, + should_process_theme_change, svg_tools, ) from bitcoin_safe.signals import Signals @@ -73,15 +73,14 @@ def __init__(self, parent: QWidget | None = None) -> None: ) self.nsec = "" self.wallet_id = "" - self.set_background_color(adjust_bg_color_for_darkmode(QColor("lightblue"))) - self.optionalButton.setIcon(svg_tools.get_QIcon("bi--download.svg")) - self.set_icon(svg_tools.get_QIcon("bi--download.svg")) + self.set_background_base_color(QColor("lightblue")) + self.set_icon("bi--download.svg") self.setVisible(False) self.icon_label.textLabel.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) self.import_button = QPushButton(self) - self.import_button.setIcon(svg_tools.get_QIcon("bi--upload.svg")) self.add_styled_widget(self.import_button) + self.updateUi() def on_optional_button(self): """On optional button.""" @@ -120,9 +119,16 @@ def set_nsec(self, nsec: str, wallet_id: str) -> None: def updateUi(self): """UpdateUi.""" + self.optionalButton.setIcon(svg_tools.get_QIcon("bi--download.svg")) + self.import_button.setIcon(svg_tools.get_QIcon("bi--upload.svg")) self.import_button.setText(self.tr("Restore labels")) self.optionalButton.setText(self.tr("Save sync key")) + def changeEvent(self, a0: QEvent | None) -> None: + super().changeEvent(a0) + if should_process_theme_change(self, a0): + self.updateUi() + class SyncTab(ControlledGroupbox): def __init__( diff --git a/bitcoin_safe/gui/qt/tutorial_screenshots.py b/bitcoin_safe/gui/qt/tutorial_screenshots.py index 39fe002a1..4026525c6 100644 --- a/bitcoin_safe/gui/qt/tutorial_screenshots.py +++ b/bitcoin_safe/gui/qt/tutorial_screenshots.py @@ -39,12 +39,11 @@ from bitcoin_safe.gui.qt.notification_bar import NotificationBar from bitcoin_safe.gui.qt.synced_tab_widget import SyncedTabWidget -from bitcoin_safe.gui.qt.util import svg_tools from bitcoin_safe.i18n import translate from bitcoin_safe.pdfrecovery import TEXT_24_WORDS from ...hardware_signers import HardwareSigners -from .util import adjust_bg_color_for_darkmode, screenshot_path +from .util import screenshot_path logger = logging.getLogger(__name__) @@ -120,8 +119,8 @@ def __init__(self, parent: QWidget | None = None) -> None: has_close_button=False, parent=parent, ) - self.set_background_color(adjust_bg_color_for_darkmode(QColor("#FFDF00"))) - self.set_icon(svg_tools.get_QIcon("warning.svg")) + self.set_background_base_color(QColor("#FFDF00")) + self.set_icon("warning.svg") self.optionalButton.setVisible(False) diff --git a/bitcoin_safe/gui/qt/tx_signing_steps.py b/bitcoin_safe/gui/qt/tx_signing_steps.py index e0a435d4d..b1d3fe6b0 100644 --- a/bitcoin_safe/gui/qt/tx_signing_steps.py +++ b/bitcoin_safe/gui/qt/tx_signing_steps.py @@ -63,7 +63,13 @@ from bitcoin_safe.gui.qt.signer_ui import SignedUI, SignerUI from bitcoin_safe.gui.qt.step_progress_bar import StepProgressContainer from bitcoin_safe.gui.qt.tx_util import get_clients -from bitcoin_safe.gui.qt.util import clear_layout, set_no_margins, svg_tools, svg_tools_hardware_signer +from bitcoin_safe.gui.qt.util import ( + clear_layout, + get_neutral_surface_colors, + set_no_margins, + svg_tools, + svg_tools_hardware_signer, +) from bitcoin_safe.hardware_signers import FeatureLevel, HardwareSigner, HardwareSigners from bitcoin_safe.keystore import KeyStore, KeyStoreImporterTypes from bitcoin_safe.plugin_framework.plugins.chat_sync.client import SyncClient @@ -223,7 +229,7 @@ def __init__( self.set_title(device.label) self.set_subtitle(device.subtitle) - self.set_icon(svg_tools_hardware_signer.get_QIcon(device.hardware_signer.icon_name)) + self.set_icon(device.hardware_signer.icon_name, svg_tools_custom=svg_tools_hardware_signer) self.button_sign = QPushButton(self.header_right_widget) self.button_sign.clicked.connect(self.signal_expand_requested.emit) @@ -246,6 +252,10 @@ def __init__( self.collapse() self.updateUi() + def _on_theme_change(self) -> None: + self.background_color = get_neutral_surface_colors().panel_background + super()._on_theme_change() + def expand(self) -> None: """Expand and show the correct first body for this device state.""" if not self._can_expand(): @@ -298,7 +308,7 @@ def _update_header_status(self) -> None: return if self.guidance.state == TxSigningHeaderState.test_verified: self.header_status.setText(self.tr("Test verified")) - self.header_status.set_icon(svg_tools.get_QIcon("checkmark.svg")) + self.header_status.set_icon("checkmark.svg") return if self.guidance.state == TxSigningHeaderState.keep_ready: next_test_number = self.guidance.next_test_number or 1 diff --git a/bitcoin_safe/gui/qt/ui_tx/columns.py b/bitcoin_safe/gui/qt/ui_tx/columns.py index 0cd5a11b1..3578e869f 100644 --- a/bitcoin_safe/gui/qt/ui_tx/columns.py +++ b/bitcoin_safe/gui/qt/ui_tx/columns.py @@ -32,7 +32,7 @@ import logging from bitcoin_safe_lib.async_tools.loop_in_thread import LoopInThread -from PyQt6.QtCore import QSignalBlocker, Qt +from PyQt6.QtCore import QEvent, QSignalBlocker, Qt from PyQt6.QtGui import QAction from PyQt6.QtWidgets import ( QCheckBox, @@ -54,6 +54,7 @@ from bitcoin_safe.gui.qt.util import ( set_margins, set_no_margins, + should_process_theme_change, sort_id_to_icon, svg_tools, ) @@ -281,7 +282,6 @@ def __init__( self.toolbutton_advanced = QToolButton() self.toolbutton_advanced.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonIconOnly) - self.toolbutton_advanced.setIcon(svg_tools.get_QIcon("bi--gear.svg")) self.menu_advanced = Menu(self) self.action_set_nlocktime = QAction(self) @@ -291,6 +291,7 @@ def __init__( self.toolbutton_advanced.setMenu(self.menu_advanced) self.toolbutton_advanced.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) self.header_widget.h_laylout.addWidget(self.toolbutton_advanced) + self.updateUi() def _on_nlocktime_toggled(self, checked: bool) -> None: """On nlocktime toggled.""" @@ -338,6 +339,13 @@ def updateUi(self) -> None: title = self.tr("Confirmed") self.header_widget.set_icon(icon_text) self.header_widget.label_title.setText(title) + self.toolbutton_advanced.setIcon(svg_tools.get_QIcon("bi--gear.svg")) self.action_set_nlocktime.setText(self.tr("Show nLocktime")) self.nlocktime_group.updateUi() self.fee_group.updateUi() + + def changeEvent(self, a0: QEvent | None) -> None: + """Refresh toolbar SVGs when the app theme changes.""" + super().changeEvent(a0) + if should_process_theme_change(self, a0, include_style_change=True): + self.updateUi() diff --git a/bitcoin_safe/gui/qt/ui_tx/fee_group.py b/bitcoin_safe/gui/qt/ui_tx/fee_group.py index 250270dbe..3e4cdec69 100644 --- a/bitcoin_safe/gui/qt/ui_tx/fee_group.py +++ b/bitcoin_safe/gui/qt/ui_tx/fee_group.py @@ -50,7 +50,6 @@ from bitcoin_safe.gui.qt.ui_tx.spinbox import FeerateSpinBox from bitcoin_safe.gui.qt.ui_tx.totals_box import TotalsBox from bitcoin_safe.gui.qt.ui_tx.util import get_cpfp_label, get_rbf_fee_label -from bitcoin_safe.gui.qt.util import svg_tools from bitcoin_safe.html_utils import html_f, link from bitcoin_safe.psbt_util import FeeInfo from bitcoin_safe.pythonbdk_types import TransactionDetails @@ -61,7 +60,6 @@ from ....mempool_manager import MempoolManager, TxPrio from ..icon_label import IconLabel from ..util import ( - adjust_bg_color_for_darkmode, block_explorer_URL, open_website, set_margins, @@ -84,8 +82,8 @@ def __init__(self, network: bdk.Network, parent: QWidget | None = None) -> None: has_close_button=False, parent=parent, ) - self.set_background_color(adjust_bg_color_for_darkmode(QColor("#FFDF00"))) - self.set_icon(svg_tools.get_QIcon("warning.svg")) + self.set_background_base_color(QColor("#FFDF00")) + self.set_icon("warning.svg") self.network = network self.optionalButton.setVisible(False) @@ -133,8 +131,8 @@ def __init__(self, network: bdk.Network, btc_symbol: str, parent: QWidget | None ) self.network = network self.btc_symbol = btc_symbol - self.set_background_color(adjust_bg_color_for_darkmode(QColor("#FFDF00"))) - self.set_icon(svg_tools.get_QIcon("warning.svg")) + self.set_background_base_color(QColor("#FFDF00")) + self.set_icon("warning.svg") self.optionalButton.setVisible(False) diff --git a/bitcoin_safe/gui/qt/ui_tx/header_widget.py b/bitcoin_safe/gui/qt/ui_tx/header_widget.py index a565d5302..097131f24 100644 --- a/bitcoin_safe/gui/qt/ui_tx/header_widget.py +++ b/bitcoin_safe/gui/qt/ui_tx/header_widget.py @@ -32,11 +32,11 @@ import logging import platform -from PyQt6.QtCore import QSize +from PyQt6.QtCore import QEvent, QSize from PyQt6.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget from bitcoin_safe.gui.qt.ui_tx.height_synced_widget import HeightSyncedWidget -from bitcoin_safe.gui.qt.util import HLine, SvgTools, set_no_margins, svg_tools +from bitcoin_safe.gui.qt.util import HLine, SvgTools, set_no_margins, should_process_theme_change, svg_tools logger = logging.getLogger(__name__) @@ -62,6 +62,7 @@ def __init__( self.label_title = QLabel() self.icon = QLabel() self.icon_name = "" + self.svg_tools_custom: SvgTools | None = None self.icon.setFixedSize(self._ICON_SIZE) self.h_laylout.addWidget(self.icon) self.h_laylout.addWidget(self.label_title) @@ -72,5 +73,11 @@ def __init__( def set_icon(self, icon_name: str, svg_tools_custom: SvgTools | None = None) -> None: """Set icon.""" self.icon_name = icon_name + self.svg_tools_custom = svg_tools_custom icon = (svg_tools_custom if svg_tools_custom else svg_tools).get_QIcon(icon_name) self.icon.setPixmap(icon.pixmap(self._ICON_SIZE, self.devicePixelRatioF())) + + def changeEvent(self, a0: QEvent | None) -> None: + super().changeEvent(a0) + if should_process_theme_change(self, a0, include_style_change=True): + self.set_icon(self.icon_name, svg_tools_custom=self.svg_tools_custom) diff --git a/bitcoin_safe/gui/qt/ui_tx/recipients.py b/bitcoin_safe/gui/qt/ui_tx/recipients.py index 8119c159d..1cd774471 100644 --- a/bitcoin_safe/gui/qt/ui_tx/recipients.py +++ b/bitcoin_safe/gui/qt/ui_tx/recipients.py @@ -40,6 +40,7 @@ from bitcoin_safe_lib.util import is_int from PyQt6 import QtCore, QtWidgets from PyQt6.QtCore import ( + QEvent, Qt, pyqtSignal, ) @@ -71,6 +72,7 @@ MessageType, set_margins, set_no_margins, + should_process_theme_change, svg_tools, ) from bitcoin_safe.gui.qt.wrappers import Menu @@ -390,12 +392,12 @@ def updateUi(self) -> None: super().updateUi() if self.wallet_id is None: self.icon_label.setText("") - self.set_icon(svg_tools.get_QIcon("bi--person-no-left-margin.svg")) + self.set_icon("bi--person-no-left-margin.svg") else: self.icon_label.setText( self.tr("This address belongs to wallet: {wallet_id}").format(wallet_id=self.wallet_id) ) - self.set_icon(svg_tools.get_QIcon("bi--wallet2.svg")) + self.set_icon("bi--wallet2.svg") self.icon_label.setToolTip("") def set_wallet_id(self, wallet_id: str | None): @@ -639,24 +641,16 @@ def setup_header_widget(self): self.header_widget.label_title.setText(self.tr("Recipients")) self.add_recipient_button = QPushButton("") - self.add_recipient_button.setIcon(svg_tools.get_QIcon("bi--person-add.svg")) self.add_recipient_button.clicked.connect(self.add_recipient) self.toolbutton_csv = QToolButton() self.toolbutton_csv.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) - self.toolbutton_csv.setIcon(svg_tools.get_QIcon("bi--filetype-csv.svg")) menu = Menu(self) - self.action_import_csv = menu.add_action( - "", self.import_csv, icon=svg_tools.get_QIcon("bi--upload.svg") - ) - self.action_export_csv = menu.add_action( - "", self.on_action_export_csv, icon=svg_tools.get_QIcon("bi--download.svg") - ) + self.action_import_csv = menu.add_action("", self.import_csv) + self.action_export_csv = menu.add_action("", self.on_action_export_csv) menu.addSeparator() - self.action_export_csv_template = menu.add_action( - "", self.on_action_export_csv_template, icon=svg_tools.get_QIcon("bi--layout-three-columns.svg") - ) + self.action_export_csv_template = menu.add_action("", self.on_action_export_csv_template) self.toolbutton_csv.setMenu(menu) self.toolbutton_csv.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup) @@ -756,18 +750,29 @@ def import_csv(self, file_path: str | None = None): def updateUi(self) -> None: """UpdateUi.""" self.add_recipient_button.setText(self.tr("Add Recipient")) + self.add_recipient_button.setIcon(svg_tools.get_QIcon("bi--person-add.svg")) if self.header_widget: self.update_recipient_title() + self.toolbutton_csv.setIcon(svg_tools.get_QIcon("bi--filetype-csv.svg")) self.toolbutton_csv.setText(self.tr("Import/Export") if self.allow_edit else self.tr("Export")) + self.action_export_csv_template.setIcon(svg_tools.get_QIcon("bi--layout-three-columns.svg")) self.action_export_csv_template.setText(self.tr("Export CSV Template")) + self.action_import_csv.setIcon(svg_tools.get_QIcon("bi--upload.svg")) self.action_import_csv.setText(self.tr("Import CSV file")) + self.action_export_csv.setIcon(svg_tools.get_QIcon("bi--download.svg")) self.action_export_csv.setText(self.tr("Export as CSV file")) for placeholder in self._hidden_recipient_placeholders: placeholder.updateUi() + def changeEvent(self, a0: QEvent | None) -> None: + """Refresh toolbar SVGs when the app theme changes.""" + super().changeEvent(a0) + if should_process_theme_change(self, a0, include_style_change=True): + self.updateUi() + def _insert_list_widget(self, widget: QWidget) -> None: """Insert a widget into the recipients list.""" index = self.recipient_list_content_layout.indexOf(self.add_recipient_button) diff --git a/bitcoin_safe/gui/qt/ui_tx/spinbox.py b/bitcoin_safe/gui/qt/ui_tx/spinbox.py index f97da7d32..a486d9ecf 100644 --- a/bitcoin_safe/gui/qt/ui_tx/spinbox.py +++ b/bitcoin_safe/gui/qt/ui_tx/spinbox.py @@ -33,7 +33,7 @@ from bitcoin_safe_lib.gui.qt.satoshis import Satoshis from bitcoin_safe_lib.gui.qt.signal_tracker import SignalProtocol from PyQt6 import QtGui -from PyQt6.QtCore import QLocale, QSignalBlocker, Qt, pyqtBoundSignal +from PyQt6.QtCore import QEvent, QLocale, QSignalBlocker, Qt, pyqtBoundSignal from PyQt6.QtWidgets import ( QAbstractSpinBox, QApplication, @@ -47,9 +47,35 @@ from bitcoin_safe.fx import FX from bitcoin_safe.gui.qt.analyzers import AmountAnalyzer from bitcoin_safe.gui.qt.custom_edits import AnalyzerState +from bitcoin_safe.gui.qt.util import should_process_theme_change class LabelStyleReadOnlQDoubleSpinBox(QDoubleSpinBox): + def __init__(self, parent: QWidget | None = None) -> None: + """Initialize instance.""" + super().__init__(parent) + self._refreshing_theme_style = False + + def _refresh_theme_style(self) -> None: + """Reapply the style that matches the current read-only state.""" + if self._refreshing_theme_style: + return + + self._refreshing_theme_style = True + try: + self._apply_theme_style() + if lineedit := self.lineEdit(): + lineedit.update() + self.update() + finally: + self._refreshing_theme_style = False + + def _apply_theme_style(self) -> None: + """Apply the stylesheet for the current read-only mode.""" + style_sheet = self.get_style_sheet(self.isReadOnly()) + if self.styleSheet() != style_sheet: + self.setStyleSheet(style_sheet) + def get_style_sheet(self, ro: bool) -> str: """Get style sheet.""" self.setObjectName(f"{id(self)}") @@ -93,7 +119,15 @@ def setReadOnly(self, r: bool): if lineedit := self.lineEdit(): lineedit.setReadOnly(r) - self.setStyleSheet(self.get_style_sheet(r)) + self._refresh_theme_style() + + def changeEvent(self, e: QEvent | None) -> None: + """Refresh custom spinbox styling when the app theme changes.""" + super().changeEvent(e) + if self._refreshing_theme_style: + return + if should_process_theme_change(self, e): + self._refresh_theme_style() class AnalyzerSpinBox(LabelStyleReadOnlQDoubleSpinBox): @@ -131,6 +165,10 @@ def format_and_apply_validator(self) -> None: self.format_as_error(error) self.setToolTip(analysis.msg if error else "") + def _apply_theme_style(self) -> None: + """Reapply analyzer-dependent styling for the active theme.""" + self.format_and_apply_validator() + class FiatSpinBox(LabelStyleReadOnlQDoubleSpinBox): "A Satoshi Spin Box. The value stored is in Satoshis." diff --git a/bitcoin_safe/gui/qt/ui_tx/ui_tx_base.py b/bitcoin_safe/gui/qt/ui_tx/ui_tx_base.py index bc0aec55f..9856f0b55 100644 --- a/bitcoin_safe/gui/qt/ui_tx/ui_tx_base.py +++ b/bitcoin_safe/gui/qt/ui_tx/ui_tx_base.py @@ -53,7 +53,6 @@ ) from bitcoin_safe.gui.qt.ui_tx.recipients import Recipients from bitcoin_safe.gui.qt.ui_tx.util import get_rbf_fee_label -from bitcoin_safe.gui.qt.util import adjust_bg_color_for_darkmode from bitcoin_safe.gui.qt.warning_bars import LinkingWarningBar from bitcoin_safe.locktime_estimation import ( MAX_NLOCKTIME, @@ -88,7 +87,7 @@ def __init__( self.network = network self.setVisible(False) - self.set_background_color(adjust_bg_color_for_darkmode(QColor("lightblue"))) + self.set_background_base_color(QColor("lightblue")) def set_infos( self, conflicing_txids: set[str], current_fee: FeeInfo | None, min_fee_rate: float | None @@ -123,7 +122,7 @@ def __init__( self.network = network self.setVisible(False) - self.set_background_color(adjust_bg_color_for_darkmode(QColor("lightblue"))) + self.set_background_base_color(QColor("lightblue")) def set_infos( self, @@ -154,7 +153,7 @@ def __init__(self, network: bdk.Network, text: str = "", parent=None) -> None: """Initialize instance.""" super().__init__(text=text, optional_button_text="", has_close_button=False, parent=parent) self.network = network - self.set_background_color(adjust_bg_color_for_darkmode(QColor("#FFDF00"))) + self.set_background_base_color(QColor("#FFDF00")) self.setVisible(False) @staticmethod diff --git a/bitcoin_safe/gui/qt/ui_tx/ui_tx_viewer.py b/bitcoin_safe/gui/qt/ui_tx/ui_tx_viewer.py index 01dbd46a8..240a92918 100644 --- a/bitcoin_safe/gui/qt/ui_tx/ui_tx_viewer.py +++ b/bitcoin_safe/gui/qt/ui_tx/ui_tx_viewer.py @@ -119,7 +119,6 @@ Message, MessageType, add_to_buttonbox, - adjust_bg_color_for_darkmode, caught_exception_message, clear_layout, set_margins, @@ -146,8 +145,7 @@ def __init__(self, parent: QWidget | None = None) -> None: has_close_button=True, parent=parent, ) - color = adjust_bg_color_for_darkmode(QColor("lightblue")) - self.set_background_color(color) + self.set_background_base_color(QColor("lightblue")) self.optionalButton.setVisible(False) @@ -370,28 +368,28 @@ def __init__( self.button_edit_tx = add_to_buttonbox( self.buttonBox, "", - button_info(ButtonInfoType.edit).icon, + button_info(ButtonInfoType.edit).icon_name, on_clicked=partial(self.edit, None), role=QDialogButtonBox.ButtonRole.ResetRole, ) self.button_rbf = add_to_buttonbox( self.buttonBox, "", - button_info(ButtonInfoType.rbf).icon, + button_info(ButtonInfoType.rbf).icon_name, on_clicked=self.rbf, role=QDialogButtonBox.ButtonRole.ResetRole, ) self.button_cpfp_tx = add_to_buttonbox( self.buttonBox, "", - button_info(ButtonInfoType.cpfp).icon, + button_info(ButtonInfoType.cpfp).icon_name, on_clicked=self.cpfp, role=QDialogButtonBox.ButtonRole.ResetRole, ) self.button_back = add_to_buttonbox( self.buttonBox, "", - icon_name=svg_tools.get_QIcon("bi--arrow-left-short.svg"), + icon_name="bi--arrow-left-short.svg", on_clicked=self.navigate_tab_history_backward, role=QDialogButtonBox.ButtonRole.ResetRole, ) diff --git a/bitcoin_safe/gui/qt/util.py b/bitcoin_safe/gui/qt/util.py index 8e0c07ef7..0ce0f1fc0 100644 --- a/bitcoin_safe/gui/qt/util.py +++ b/bitcoin_safe/gui/qt/util.py @@ -38,17 +38,12 @@ from dataclasses import dataclass from datetime import datetime from functools import partial -from typing import ( - Any, - Literal, - cast, -) +from typing import TYPE_CHECKING, Any, Literal, cast from urllib.parse import urlparse import bdkpython as bdk import PIL.Image as PilImage from bitcoin_qr_tools.data import ConverterAddress -from bitcoin_safe_lib.caching import register_cache from bitcoin_safe_lib.gui.qt.icons import SvgTools from bitcoin_safe_lib.gui.qt.signal_tracker import SignalProtocol from bitcoin_safe_lib.gui.qt.util import adjust_brightness, is_dark_mode @@ -56,6 +51,7 @@ from PyQt6.QtCore import ( QByteArray, QCoreApplication, + QEvent, QRectF, QSize, Qt, @@ -99,7 +95,6 @@ ) from bitcoin_safe.execute_config import ENABLE_TIMERS -from bitcoin_safe.gui.qt.custom_edits import AnalyzerState from bitcoin_safe.gui.qt.wrappers import Menu from bitcoin_safe.i18n import translate from bitcoin_safe.pythonbdk_types import TransactionDetails @@ -107,6 +102,9 @@ logger = logging.getLogger(__name__) +if TYPE_CHECKING: + from bitcoin_safe.gui.qt.custom_edits import AnalyzerState + @dataclass class TabInfo: @@ -115,6 +113,97 @@ class TabInfo: icon: QIcon +_THEME_STATE_PROPERTY = "_bitcoin_safe_theme_state" +_THEME_PALETTE_ROLES: tuple[QPalette.ColorRole, ...] = ( + QPalette.ColorRole.Window, + QPalette.ColorRole.WindowText, + QPalette.ColorRole.Base, + QPalette.ColorRole.AlternateBase, + QPalette.ColorRole.Text, + QPalette.ColorRole.Button, + QPalette.ColorRole.ButtonText, + QPalette.ColorRole.Highlight, + QPalette.ColorRole.HighlightedText, + QPalette.ColorRole.Link, + QPalette.ColorRole.Mid, + QPalette.ColorRole.Dark, +) + + +def _style_identity(target: QApplication | QWidget) -> tuple[str, str]: + style = target.style() + if style is None: + return ("", "") + + meta_object = style.metaObject() + return (style.objectName(), meta_object.className() if meta_object else "") + + +def current_theme_state(target: QApplication | QWidget) -> tuple[object, ...]: + palette = target.palette() + return _style_identity(target) + tuple(palette.color(role).rgba() for role in _THEME_PALETTE_ROLES) + + +def remember_theme_state( + target: QApplication | QWidget, + state_source: QApplication | QWidget | None = None, +) -> tuple[object, ...]: + theme_state = current_theme_state(state_source or target) + target.setProperty(_THEME_STATE_PROPERTY, theme_state) + return theme_state + + +def consume_theme_state( + target: QApplication | QWidget, + state_source: QApplication | QWidget | None = None, +) -> bool: + theme_state = current_theme_state(state_source or target) + if target.property(_THEME_STATE_PROPERTY) == theme_state: + return False + target.setProperty(_THEME_STATE_PROPERTY, theme_state) + return True + + +def should_process_theme_change( + target: QWidget, + event: QEvent | None, + include_style_change: bool = False, + include_enabled_change: bool = False, + state_source: QApplication | QWidget | None = None, +) -> bool: + """Return whether ``target`` should refresh for this theme-related event.""" + if not is_theme_change_event( + event, + include_style_change=include_style_change, + include_enabled_change=include_enabled_change, + ): + return False + + if event is not None and event.type() == QEvent.Type.EnabledChange: + return True + + if state_source is None: + state_source = target + if event is not None and event.type() == QEvent.Type.ApplicationPaletteChange and state_source is target: + app = QApplication.instance() + if isinstance(app, QApplication): + state_source = app + + return consume_theme_state(target, state_source=state_source) + + +def propagate_theme_change_to_descendants(root: QWidget) -> None: + """Forward an application palette change to descendant widgets. + + Some composite widgets override an intermediate palette or background, which can + prevent Qt from delivering palette-change events deep enough for child + ``changeEvent`` hooks to run. Forwarding a standard application palette change + keeps the refresh logic in the widgets themselves. + """ + for widget in root.findChildren(QWidget): + QApplication.sendEvent(widget, QEvent(QEvent.Type.ApplicationPaletteChange)) + + if platform.system() == "Windows": MONOSPACE_FONT = "Lucida Console" elif platform.system() == "Darwin": @@ -202,6 +291,23 @@ def get_generated_hardware_signer_path(signer_basename: str) -> str: ) +def is_theme_change_event( + event: QEvent | None, + include_style_change: bool = False, + include_enabled_change: bool = False, +) -> bool: + """Return whether a Qt event should trigger theme-dependent UI refresh.""" + if not event: + return False + + event_type = event.type() + if event_type in {QEvent.Type.ApplicationPaletteChange, QEvent.Type.PaletteChange}: + return True + if include_style_change and event_type == QEvent.Type.StyleChange: + return True + return include_enabled_change and event_type == QEvent.Type.EnabledChange + + def block_explorer_URL( mempool_url: str, kind: Literal["tx", "addr", "block", "mempool"], item: str | int ) -> str | None: @@ -440,7 +546,7 @@ def add_centered_icons( def add_to_buttonbox( buttonBox: QDialogButtonBox, text: str, - icon_name: str | QIcon | None = None, + icon_name: str | None = None, on_clicked=None, role=QDialogButtonBox.ButtonRole.ActionRole, button=None, @@ -448,9 +554,7 @@ def add_to_buttonbox( # Create a custom QPushButton with an icon """Add to buttonbox.""" button = button if button else QPushButton(text) - if isinstance(icon_name, QIcon): - button.setIcon(icon_name) - elif icon_name: + if icon_name: button.setIcon(svg_tools.get_QIcon(icon_name)) # Add the button to the QDialogButtonBox @@ -893,7 +997,6 @@ def __init__(self, fg_color: str, bg_color: str): """Initialize instance.""" self.colors = (fg_color, bg_color) - @register_cache(always_keep=True) def _get_color(self, background): """Get color.""" return self.colors[(int(background) + int(ColorScheme.dark_scheme)) % 2] @@ -928,18 +1031,11 @@ class ColorScheme: Purple = ColorSchemeItem("#7616ff", "#7616ff") OrangeBitcoin = ColorSchemeItem("#f7931a", "#f7931a") - @staticmethod - def has_dark_background(widget: QWidget): - """Has dark background.""" - background_color = widget.palette().color(QPalette.ColorGroup.Normal, QPalette.ColorRole.Window) - rgb = background_color.getRgb()[0:3] - brightness = sum(c for c in rgb if c) - return brightness < (255 * 3 / 2) - @staticmethod def update_from_widget(widget, force_dark=False): """Update from widget.""" - ColorScheme.dark_scheme = bool(force_dark or ColorScheme.has_dark_background(widget)) + del widget + ColorScheme.dark_scheme = bool(force_dark or is_dark_mode()) def screenshot_path(basename: str): @@ -1435,7 +1531,7 @@ def to_color_name(color: str | QColor | QPalette.ColorRole) -> str: if isinstance(color, QPalette.ColorRole): return QApplication.palette().color(color).name() if isinstance(color, QColor): - return color.name(QColor.NameFormat.HexArgb) + return f"#{color.alpha():02x}{color.red():02x}{color.green():02x}{color.blue():02x}" return color diff --git a/bitcoin_safe/gui/qt/wallet_balance_chart.py b/bitcoin_safe/gui/qt/wallet_balance_chart.py index e97d433c9..e4fd73e8b 100644 --- a/bitcoin_safe/gui/qt/wallet_balance_chart.py +++ b/bitcoin_safe/gui/qt/wallet_balance_chart.py @@ -75,7 +75,13 @@ from bitcoin_safe.execute_config import ENABLE_TIMERS from bitcoin_safe.gui.qt.pan_zoom_mixin import PanZoomInputMixin -from bitcoin_safe.gui.qt.util import ColorScheme, blend_qcolors, set_translucent, svg_tools +from bitcoin_safe.gui.qt.util import ( + ColorScheme, + blend_qcolors, + set_translucent, + should_process_theme_change, + svg_tools, +) from bitcoin_safe.pythonbdk_types import TransactionDetails from bitcoin_safe.signals import UpdateFilter, WalletSignals from bitcoin_safe.util import monotone_increasing_timestamps @@ -512,13 +518,6 @@ def __init__( self._full_time_range: tuple[datetime.datetime, datetime.datetime] | None = None self._full_value_range: tuple[float, float] | None = None self._suppress_range_signal = False - color_text = blend_qcolors( - self.palette().color(QPalette.ColorRole.Dark), self.palette().color(QPalette.ColorRole.Text) - ) - color_major_grid = ( - adjust_brightness(color_text, -0.3) if is_dark_mode() else adjust_brightness(color_text, 0.5) - ) - self.chart = QChart() if legend := self.chart.legend(): legend.hide() @@ -555,9 +554,6 @@ def __init__( # Create DateTime axis for X self.datetime_axis = QDateTimeAxis() - self.datetime_axis.setLabelsColor(color_text) - self.datetime_axis.setTitleBrush(color_text) - self.datetime_axis.setGridLineColor(color_major_grid) x_values = [ datetime.datetime.now().timestamp() - self.default_buffer_time_in_sec, datetime.datetime.now().timestamp() + self.default_buffer_time_in_sec, @@ -569,9 +565,6 @@ def __init__( # Create Value axis for Y self.value_axis = QValueAxis() - self.value_axis.setLabelsColor(color_text) - self.value_axis.setTitleBrush(color_text) - self.value_axis.setGridLineColor(color_major_grid) self.chart.addAxis(self.datetime_axis, Qt.AlignmentFlag.AlignBottom) self.chart.addAxis(self.value_axis, Qt.AlignmentFlag.AlignLeft) @@ -610,6 +603,32 @@ def __init__( # Set layout self.setLayout(self._layout) + self._refresh_palette_colors() + + def changeEvent(self, a0: QEvent | None) -> None: + """Refresh palette-derived chart colors when the app theme changes.""" + super().changeEvent(a0) + if should_process_theme_change(self, a0): + self._refresh_palette_colors() + + def _refresh_palette_colors(self) -> None: + """Update chart colors that depend on the current palette.""" + color_text = blend_qcolors( + self.palette().color(QPalette.ColorRole.Dark), self.palette().color(QPalette.ColorRole.Text) + ) + color_major_grid = ( + adjust_brightness(color_text, -0.3) if is_dark_mode() else adjust_brightness(color_text, 0.5) + ) + self.datetime_axis.setLabelsColor(color_text) + self.datetime_axis.setTitleBrush(color_text) + self.datetime_axis.setGridLineColor(color_major_grid) + self.value_axis.setLabelsColor(color_text) + self.value_axis.setTitleBrush(color_text) + self.value_axis.setGridLineColor(color_major_grid) + if viewport := self.chart_view.viewport(): + viewport.update() + self.chart_view.update() + self.update() def set_value_axis_label_format(self, max_value: float) -> None: """Set value axis label format.""" diff --git a/bitcoin_safe/gui/qt/warning_bars.py b/bitcoin_safe/gui/qt/warning_bars.py index f059adeb8..f451b8e95 100644 --- a/bitcoin_safe/gui/qt/warning_bars.py +++ b/bitcoin_safe/gui/qt/warning_bars.py @@ -40,11 +40,9 @@ from bitcoin_safe.address_comparer import FuzzyMatch from bitcoin_safe.gui.qt.notification_bar import NotificationBar -from bitcoin_safe.gui.qt.util import svg_tools from bitcoin_safe.html_utils import html_f from ...signals import SignalsMin -from .util import adjust_bg_color_for_darkmode logger = logging.getLogger(__name__) @@ -60,8 +58,8 @@ def __init__(self, signals_min: SignalsMin, parent: QWidget | None = None) -> No ) self.category_dict: dict[str, set[str]] = {} self.signals_min = signals_min - self.set_background_color(adjust_bg_color_for_darkmode(QColor("#FFDF00"))) - self.set_icon(svg_tools.get_QIcon("warning.svg")) + self.set_background_base_color(QColor("#FFDF00")) + self.set_icon("warning.svg") self.optionalButton.setVisible(False) self.icon_label.textLabel.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) @@ -117,8 +115,8 @@ def __init__(self, signals_min: SignalsMin, parent: QWidget | None = None) -> No ) self.signals_min = signals_min self.poisonous_matches: list[tuple[str, str, FuzzyMatch]] = [] - self.set_background_color(adjust_bg_color_for_darkmode(QColor("#FFDF00"))) - self.set_icon(svg_tools.get_QIcon("warning.svg")) + self.set_background_base_color(QColor("#FFDF00")) + self.set_icon("warning.svg") self.optionalButton.setVisible(False) self.icon_label.textLabel.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) @@ -197,8 +195,8 @@ def __init__( self.signals_min = signals_min self.new_gap = new_gap # self.set_background_color(adjust_bg_color_for_darkmode(QColor("#f5c2c7"))) - self.set_background_color(adjust_bg_color_for_darkmode(QColor("#FFDF00"))) - self.set_icon(svg_tools.get_QIcon("warning.svg")) + self.set_background_base_color(QColor("#FFDF00")) + self.set_icon("warning.svg") self.optionalButton.setVisible(True) self.icon_label.textLabel.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) diff --git a/bitcoin_safe/gui/qt/wizard/wizard_base.py b/bitcoin_safe/gui/qt/wizard/wizard_base.py index e4a20de8d..467eab10f 100644 --- a/bitcoin_safe/gui/qt/wizard/wizard_base.py +++ b/bitcoin_safe/gui/qt/wizard/wizard_base.py @@ -37,7 +37,6 @@ from bitcoin_safe.gui.qt.sidebar.sidebar_tree import SidebarNode from bitcoin_safe.gui.qt.step_progress_bar import StepProgressContainer -from bitcoin_safe.gui.qt.util import svg_tools from bitcoin_safe.signals import SignalsMin logger = logging.getLogger(__name__) @@ -80,7 +79,7 @@ def __init__( title=self.tr("Wizard"), data=self, widget=self, - icon=svg_tools.get_QIcon("stars4.svg"), + icon="stars4.svg", parent=self, ) diff --git a/bitcoin_safe/gui/qt/wizard/wizard_step_distribution.py b/bitcoin_safe/gui/qt/wizard/wizard_step_distribution.py index fb99ffd12..d231f59d0 100644 --- a/bitcoin_safe/gui/qt/wizard/wizard_step_distribution.py +++ b/bitcoin_safe/gui/qt/wizard/wizard_step_distribution.py @@ -65,7 +65,7 @@ svg_tools_hardware_signer, to_color_name, ) -from .wizard_support import BaseTab, WizardTabInfo +from .wizard_support import BaseTab, ThemeAwareStepWidget, WizardTabInfo class RegisterMultisig(BaseTab): @@ -199,7 +199,7 @@ def __init__( def create(self) -> TutorialWidget: """Create.""" - widget = QWidget() + widget = ThemeAwareStepWidget(self) widget_layout = QVBoxLayout(widget) widget_layout.setContentsMargins(0, 0, 0, 0) widget_layout.setSpacing(18) @@ -689,6 +689,14 @@ def _refresh_styles(self) -> None: accent_text_color = palette.color(QPalette.ColorRole.HighlightedText).name() link_color = palette.color(QPalette.ColorRole.Link).name() + for widget in ( + self.left_panel, + self.print_section, + self.seed_words_section, + self.right_panel, + self.distribution_table, + ): + widget.refresh_style() self.distribution_table.setStyleSheet( self.distribution_table.styleSheet() + f"\nQLabel a {{ color: {link_color}; text-decoration: none; }}" diff --git a/bitcoin_safe/gui/qt/wizard/wizard_step_setup.py b/bitcoin_safe/gui/qt/wizard/wizard_step_setup.py index 7019bfff0..d5119bea9 100644 --- a/bitcoin_safe/gui/qt/wizard/wizard_step_setup.py +++ b/bitcoin_safe/gui/qt/wizard/wizard_step_setup.py @@ -63,7 +63,7 @@ svg_tools, to_color_name, ) -from .wizard_support import BaseTab, WizardTabInfo +from .wizard_support import BaseTab, ThemeAwareStepWidget, WizardTabInfo @dataclass(frozen=True) @@ -284,7 +284,7 @@ class WalletTemplateStatIcon(enum.Enum): def create(self) -> TutorialWidget: """Create.""" - widget = QWidget() + widget = ThemeAwareStepWidget(self) widget_layout = QVBoxLayout(widget) widget_layout.setContentsMargins(28, 24, 28, 24) widget_layout.setSpacing(22) diff --git a/bitcoin_safe/gui/qt/wizard/wizard_support.py b/bitcoin_safe/gui/qt/wizard/wizard_support.py index f5d53d67c..a49762bb7 100644 --- a/bitcoin_safe/gui/qt/wizard/wizard_support.py +++ b/bitcoin_safe/gui/qt/wizard/wizard_support.py @@ -29,13 +29,14 @@ from __future__ import annotations +import weakref from abc import abstractmethod from collections.abc import Callable from typing import TYPE_CHECKING, cast from bitcoin_safe_lib.async_tools.loop_in_thread import LoopInThread from bitcoin_safe_lib.gui.qt.signal_tracker import SignalProtocol, SignalTracker -from PyQt6.QtCore import QObject +from PyQt6.QtCore import QEvent, QObject from PyQt6.QtGui import QIcon from PyQt6.QtWidgets import QHBoxLayout, QPushButton, QWidget @@ -45,7 +46,7 @@ from ....pdfrecovery import TEXT_24_WORDS from ..qt_wallet import QTWallet, QtWalletBase from ..step_progress_bar import StepProgressContainer, TutorialWidget -from ..util import svg_tools +from ..util import should_process_theme_change, svg_tools if TYPE_CHECKING: from .wizard import TutorialStep, Wizard @@ -224,3 +225,19 @@ def close(self) -> None: self._is_closed = True self.signal_tracker.disconnect_all() self.setParent(None) + + +class ThemeAwareStepWidget(QWidget): + def __init__(self, tab: BaseTab, parent: QWidget | None = None) -> None: + """Initialize instance.""" + super().__init__(parent) + self._tab_ref: weakref.ReferenceType[BaseTab] = weakref.ref(tab) + + def changeEvent(self, a0: QEvent | None) -> None: + """Refresh the owning wizard step when the app palette changes.""" + super().changeEvent(a0) + if not should_process_theme_change(self, a0): + return + if tab := self._tab_ref(): + if not tab.is_closed: + tab.updateUi() diff --git a/bitcoin_safe/plugin_framework/paid_plugin_client.py b/bitcoin_safe/plugin_framework/paid_plugin_client.py index c5a5e83c3..6769d0da9 100644 --- a/bitcoin_safe/plugin_framework/paid_plugin_client.py +++ b/bitcoin_safe/plugin_framework/paid_plugin_client.py @@ -32,6 +32,7 @@ import logging from collections.abc import Callable from functools import partial +from pathlib import Path from typing import TYPE_CHECKING, Any, cast from bitcoin_safe_lib.async_tools.loop_in_thread import LoopInThread @@ -100,7 +101,7 @@ def __init__( config: UserConfig, fx: FX, loop_in_thread: LoopInThread | None, - icon: QIcon, + icon: QIcon | Path | str, btcpay_config: BTCPayConfig, enabled: bool, additional_access_providers: list[Callable[[], bool]] | None, diff --git a/bitcoin_safe/plugin_framework/plugin_client.py b/bitcoin_safe/plugin_framework/plugin_client.py index c5ee1a0c4..0709e98b0 100644 --- a/bitcoin_safe/plugin_framework/plugin_client.py +++ b/bitcoin_safe/plugin_framework/plugin_client.py @@ -31,6 +31,7 @@ import logging from abc import abstractmethod +from pathlib import Path from typing import TYPE_CHECKING, Any, cast from bitcoin_safe_lib.gui.qt.signal_tracker import SignalProtocol, SignalTracker @@ -92,7 +93,7 @@ def cls_kwargs(cls, parent: QWidget | None): ) return d - def __init__(self, enabled: bool, icon: QIcon, parent: QWidget | None = None) -> None: + def __init__(self, enabled: bool, icon: QIcon | Path | str, parent: QWidget | None = None) -> None: """Initialize instance.""" super().__init__(parent) self.server: PluginServerView | None = None @@ -160,7 +161,7 @@ def set_display_metadata( title: str, description: str, provider: str, - icon: QIcon, + icon: QIcon | Path | str, ) -> None: self.title = title self.description = description diff --git a/bitcoin_safe/plugin_framework/plugin_list_widget.py b/bitcoin_safe/plugin_framework/plugin_list_widget.py index 0c5aa1ce9..fb02efade 100644 --- a/bitcoin_safe/plugin_framework/plugin_list_widget.py +++ b/bitcoin_safe/plugin_framework/plugin_list_widget.py @@ -32,10 +32,11 @@ from collections.abc import Callable, Sequence from dataclasses import dataclass from functools import partial +from pathlib import Path from typing import TYPE_CHECKING from bitcoin_safe_lib.gui.qt.spinning_button import SpinningButton -from PyQt6.QtCore import QSize, Qt, QTimer +from PyQt6.QtCore import QEvent, QSize, Qt, QTimer from PyQt6.QtGui import QIcon, QPalette from PyQt6.QtWidgets import ( QApplication, @@ -51,7 +52,12 @@ ) from bitcoin_safe.gui.qt.card_base import CardBase, CardExpansionMode -from bitcoin_safe.gui.qt.util import svg_tools +from bitcoin_safe.gui.qt.util import ( + get_neutral_surface_colors, + should_process_theme_change, + svg_tools, + to_color_name, +) if TYPE_CHECKING: from bitcoin_safe.plugin_framework.paid_plugin_client import PaidPluginClient @@ -65,10 +71,11 @@ def __init__( title: str, description: str, provider: str, - icon: QIcon, + icon: QIcon | Path | str, icon_size: tuple[int, int] = (40, 40), parent: QWidget | None = None, ) -> None: + self._theme_assets_ready = False super().__init__(parent=parent, expansion_mode=CardExpansionMode.FIXED_EXPANDED) self.icon_size = icon_size @@ -169,19 +176,42 @@ def __init__( self.management_section.setVisible(False) self.controls_container.setVisible(False) self.set_body_content_visible(False) + self._theme_assets_ready = True self.set_plugin_metadata(title=title, description=description, provider=provider, icon=icon) + def _refresh_theme_assets(self) -> None: + surface_colors = get_neutral_surface_colors() + title_color = to_color_name(self.palette().color(QPalette.ColorRole.WindowText)) + muted_color = to_color_name(surface_colors.muted_text) + + self.background_color = surface_colors.panel_background + self.refresh_style() + self.hline.setStyleSheet(f"color: {to_color_name(QPalette.ColorRole.Mid)}") + self.title_label.setStyleSheet(f"color: {title_color};") + self.version_label.setStyleSheet(f"color: {muted_color};") + self.metadata_separator_label.setStyleSheet(f"color: {muted_color};") + self.provider_label.setStyleSheet(f"color: {muted_color};") + self.description_label.setStyleSheet(f"color: {muted_color};") + self.status_label.setStyleSheet(f"color: {muted_color};") + def set_plugin_metadata( self, title: str, description: str, provider: str, - icon: QIcon, + icon: QIcon | Path | str, ) -> None: self.title_label.setText(f"{title}") self.provider_label.setText(self.tr("Provided by: {provider}").format(provider=provider)) self.description_label.setText(description) - self.icon_label.setPixmap(icon.pixmap(QSize(*self.icon_size), self.devicePixelRatioF())) + self._refresh_theme_assets() + self.set_icon(icon, self.icon_size) + + def _on_theme_change(self) -> None: + super()._on_theme_change() + if not self._theme_assets_ready: + return + self._refresh_theme_assets() def set_icon_action(self, callback: Callable[[], None] | None, enabled: bool) -> None: try: @@ -806,20 +836,32 @@ def __init__(self, title: str, description: str = "", parent: QWidget | None = N self.title_label.setFont(title_font) self.title_row.addWidget(self.title_label) - divider = QFrame(self) - divider.setFrameShape(QFrame.Shape.HLine) - divider.setFrameShadow(QFrame.Shadow.Sunken) - self.title_row.addWidget(divider, stretch=1) + self.divider = QFrame(self) + self.divider.setFrameShape(QFrame.Shape.HLine) + self.divider.setFrameShadow(QFrame.Shadow.Sunken) + self.title_row.addWidget(self.divider, stretch=1) self.description_label = QLabel(description, self) self.description_label.setWordWrap(True) self.description_label.setForegroundRole(QPalette.ColorRole.Dark) self.description_label.setVisible(bool(description)) layout.addWidget(self.description_label) + self.refresh_style() def add_title_widget(self, widget: QWidget) -> None: self.title_row.addWidget(widget) + def refresh_style(self) -> None: + """Refresh palette-aware colors.""" + surface_colors = get_neutral_surface_colors() + self.description_label.setStyleSheet(f"color: {to_color_name(surface_colors.muted_text)};") + self.divider.setStyleSheet(f"color: {to_color_name(QPalette.ColorRole.Mid)}") + + def changeEvent(self, a0: QEvent | None) -> None: + super().changeEvent(a0) + if should_process_theme_change(self, a0): + self.refresh_style() + class PluginListWidget(QWidget): def __init__( diff --git a/bitcoin_safe/plugin_framework/plugin_manager.py b/bitcoin_safe/plugin_framework/plugin_manager.py index 34622bdeb..da62aea82 100644 --- a/bitcoin_safe/plugin_framework/plugin_manager.py +++ b/bitcoin_safe/plugin_framework/plugin_manager.py @@ -60,7 +60,6 @@ Message, MessageType, set_no_margins, - svg_tools, ) from bitcoin_safe.plugin_framework.builtin_plugins import ( BUILTIN_PLUGIN_BUNDLES, @@ -160,7 +159,7 @@ def __init__( self.node = SidebarNode[object]( data=self, widget=self, - icon=svg_tools.get_QIcon("bi--gear.svg"), + icon="bi--gear.svg", title="", ) self.updateUi() @@ -1467,13 +1466,6 @@ def _delete_installed_source_plugin(self, bundle_id: str, plugin_name: str) -> N if existing_client.plugin_source == PluginClientSource.EXTERNAL and existing_client.plugin_bundle_id == bundle_id ] - for removed_client in removed_clients: - if not removed_client.enabled: - continue - try: - removed_client.set_enabled(False) - except Exception: - logger.exception("Could not disable plugin %s before deletion.", removed_client.plugin_id) if not question_dialog( text=self.widget.tr("Delete installed plugin {plugin}?").format(plugin=plugin_name), title=self.widget.tr("Delete Installed Plugin"), @@ -1482,7 +1474,13 @@ def _delete_installed_source_plugin(self, bundle_id: str, plugin_name: str) -> N ): return + reenable_clients: list[PluginClient] = [] try: + for removed_client in removed_clients: + if not removed_client.enabled: + continue + removed_client.set_enabled(False) + reenable_clients.append(removed_client) self.external_registry.remove_installed_plugin(bundle_id) self.serialized_client_dumps = self._merge_serialized_client_payloads( [ @@ -1501,6 +1499,13 @@ def _delete_installed_source_plugin(self, bundle_id: str, plugin_name: str) -> N self._refresh_after_registry_change(runtime_changed) logger.info("Deleted plugin %s.", plugin_name) except ExternalPluginError as exc: + for removed_client in reenable_clients: + try: + removed_client.set_enabled(True) + except Exception: + logger.exception( + "Could not restore plugin %s after failed deletion.", removed_client.plugin_id + ) Message(str(exc), type=MessageType.Error, parent=self.parent or self.widget) def clone(self, class_kwargs: dict | None = None): diff --git a/bitcoin_safe/plugin_framework/plugins/business_plan/client.py b/bitcoin_safe/plugin_framework/plugins/business_plan/client.py index 2bf679651..a56f5296d 100644 --- a/bitcoin_safe/plugin_framework/plugins/business_plan/client.py +++ b/bitcoin_safe/plugin_framework/plugins/business_plan/client.py @@ -38,7 +38,6 @@ from bitcoin_safe.btcpay_config import BTCPAY_SUBSCRIPTION_CONFIG from bitcoin_safe.config import UserConfig from bitcoin_safe.fx import FX -from bitcoin_safe.gui.qt.util import svg_tools from bitcoin_safe.i18n import translate from bitcoin_safe.plugin_framework.paid_plugin_client import PaidPluginClient from bitcoin_safe.plugin_framework.subscription_price_lookup import SubscriptionPriceLookup @@ -103,7 +102,7 @@ def __init__( super().__init__( config=config, fx=fx, - icon=svg_tools.get_QIcon("stars4.svg"), + icon="stars4.svg", loop_in_thread=loop_in_thread, enabled=False, additional_access_providers=[], diff --git a/bitcoin_safe/plugin_framework/plugins/chat_sync/client.py b/bitcoin_safe/plugin_framework/plugins/chat_sync/client.py index 770cbe0cb..920212e2b 100644 --- a/bitcoin_safe/plugin_framework/plugins/chat_sync/client.py +++ b/bitcoin_safe/plugin_framework/plugins/chat_sync/client.py @@ -86,7 +86,7 @@ def __init__(self) -> None: self.wallet_id = "" self.set_background_color(adjust_bg_color_for_darkmode(QColor("lightblue"))) self.optionalButton.setIcon(svg_tools.get_QIcon("bi--download.svg")) - self.set_icon(svg_tools.get_QIcon("bi--download.svg")) + self.set_icon("bi--download.svg") self.setVisible(False) self.icon_label.textLabel.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) @@ -203,7 +203,7 @@ def __init__( device_info: dict[str, str] | None = None, ): """Initialize instance.""" - super().__init__(enabled=enabled, icon=svg_tools.get_QIcon(SYNC_CHAT_ICON_NAME)) + super().__init__(enabled=enabled, icon=SYNC_CHAT_ICON_NAME) self.close_all_video_widgets: SignalProtocol[[]] | None = None self.label_syncer: LabelSyncer | None = None self.device_info = device_info or {} diff --git a/bitcoin_safe/plugin_framework/plugins/walletgraph/client.py b/bitcoin_safe/plugin_framework/plugins/walletgraph/client.py index d4b14fbf5..83a90898a 100644 --- a/bitcoin_safe/plugin_framework/plugins/walletgraph/client.py +++ b/bitcoin_safe/plugin_framework/plugins/walletgraph/client.py @@ -50,7 +50,7 @@ ) from bitcoin_safe.gui.qt.packaged_tx_like import PackagedTxLike, UiElements -from bitcoin_safe.gui.qt.util import ColorScheme, Message, MessageType, svg_tools +from bitcoin_safe.gui.qt.util import ColorScheme, Message, MessageType from bitcoin_safe.i18n import translate from bitcoin_safe.plugin_framework.plugin_client import PluginClient from bitcoin_safe.plugin_framework.plugin_conditions import PluginConditions @@ -99,7 +99,7 @@ def __init__( self, signals: Signals, network: bdk.Network, enabled: bool = False, parent: QWidget | None = None ) -> None: """Initialize instance.""" - super().__init__(enabled=enabled, icon=svg_tools.get_QIcon("wallet-graph-icon.svg"), parent=parent) + super().__init__(enabled=enabled, icon="wallet-graph-icon.svg", parent=parent) self.signals = signals self.network = network self.wallet_id: str | None = None diff --git a/bitcoin_safe/signature_manager.py b/bitcoin_safe/signature_manager.py index 08bf1623f..708f1b95f 100644 --- a/bitcoin_safe/signature_manager.py +++ b/bitcoin_safe/signature_manager.py @@ -47,7 +47,7 @@ import pysequoia import requests -from pysequoia.packet import Packet, PacketPile, Tag +from pysequoia.packet import Packet, PacketPile, Tag # pyright: ignore[reportMissingModuleSource] from bitcoin_safe.i18n import translate from bitcoin_safe.util import default_timeout diff --git a/bitcoin_safe/theme.py b/bitcoin_safe/theme.py new file mode 100644 index 000000000..5f2c20599 --- /dev/null +++ b/bitcoin_safe/theme.py @@ -0,0 +1,101 @@ +# +# Bitcoin Safe +# Copyright (C) 2026 Andreas Griffin +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of version 3 of the GNU General Public License as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see https://www.gnu.org/licenses/gpl-3.0.html +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# + +from __future__ import annotations + +import enum +import logging + +from PyQt6.QtGui import QColor, QPalette +from PyQt6.QtWidgets import QApplication + +logger = logging.getLogger(__name__) + + +class ThemeMode(enum.Enum): + SYSTEM = enum.auto() + LIGHT = enum.auto() + DARK = enum.auto() + + +def _standard_palette(app: QApplication) -> QPalette: + style = app.style() + if style: + return QPalette(style.standardPalette()) + return QPalette(app.palette()) + + +def create_dark_palette(base_palette: QPalette | None = None) -> QPalette: + """Create the app's dark palette.""" + dark_palette = QPalette(base_palette) if base_palette is not None else QPalette() + dark_palette.setColor(QPalette.ColorRole.Window, QColor(53, 53, 53)) + dark_palette.setColor(QPalette.ColorRole.WindowText, QColor(255, 255, 255)) + dark_palette.setColor(QPalette.ColorRole.Base, QColor(42, 42, 42)) + dark_palette.setColor(QPalette.ColorRole.AlternateBase, QColor(66, 66, 66)) + dark_palette.setColor(QPalette.ColorRole.ToolTipBase, QColor(0, 0, 0)) + dark_palette.setColor(QPalette.ColorRole.ToolTipText, QColor(255, 255, 255)) + dark_palette.setColor(QPalette.ColorRole.Text, QColor(255, 255, 255)) + dark_palette.setColor(QPalette.ColorRole.Button, QColor(53, 53, 53)) + dark_palette.setColor(QPalette.ColorRole.ButtonText, QColor(255, 255, 255)) + dark_palette.setColor(QPalette.ColorRole.BrightText, QColor(255, 0, 0)) + dark_palette.setColor(QPalette.ColorRole.Link, QColor(42, 130, 218)) + dark_palette.setColor(QPalette.ColorRole.Highlight, QColor(42, 130, 218)) + dark_palette.setColor(QPalette.ColorRole.HighlightedText, QColor(0, 0, 0)) + return dark_palette + + +def create_light_palette(base_palette: QPalette | None = None) -> QPalette: + """Create a light palette independent of the desktop theme.""" + light_palette = QPalette(base_palette) if base_palette is not None else QPalette() + light_palette.setColor(QPalette.ColorRole.Window, QColor("#efefef")) + light_palette.setColor(QPalette.ColorRole.WindowText, QColor("#000000")) + light_palette.setColor(QPalette.ColorRole.Base, QColor("#ffffff")) + light_palette.setColor(QPalette.ColorRole.AlternateBase, QColor("#f7f7f7")) + light_palette.setColor(QPalette.ColorRole.ToolTipBase, QColor("#ffffdc")) + light_palette.setColor(QPalette.ColorRole.ToolTipText, QColor("#000000")) + light_palette.setColor(QPalette.ColorRole.Text, QColor("#000000")) + light_palette.setColor(QPalette.ColorRole.Button, QColor("#efefef")) + light_palette.setColor(QPalette.ColorRole.ButtonText, QColor("#000000")) + light_palette.setColor(QPalette.ColorRole.BrightText, QColor("#ffffff")) + return light_palette + + +def apply_theme_mode(app: QApplication, theme_mode: ThemeMode) -> None: + """Apply the configured theme mode to the application palette.""" + if theme_mode == ThemeMode.SYSTEM: + # Clear any app-level override so Qt can follow the desktop palette again. + app.setPalette(QPalette()) + return + + if theme_mode == ThemeMode.DARK: + standard_palette = _standard_palette(app) + app.setPalette(create_dark_palette(standard_palette)) + return + + app.setPalette(create_light_palette(_standard_palette(app))) diff --git a/tests/gui/qt/conftest.py b/tests/gui/qt/conftest.py index f40980f71..0b46793e8 100644 --- a/tests/gui/qt/conftest.py +++ b/tests/gui/qt/conftest.py @@ -36,7 +36,9 @@ import pytest from bitcoin_safe.constants import MIN_RELAY_FEE +from bitcoin_safe.pythonbdk_types import Balance from bitcoin_safe.signature_manager import Asset, GitHubRelease +from bitcoin_safe.wallet import Wallet def _disable_fx_update(self) -> None: @@ -166,3 +168,25 @@ def mock__ask_if_wallet_should_remain_open(monkeypatch): "bitcoin_safe.gui.qt.main.MainWindow._ask_if_wallet_should_remain_open", lambda self: False ) yield + + +_GLOBAL_PALETTE_GUI_TEST_MODULES = { + "test_button_edit.py", + "test_card_base.py", + "test_icon_label.py", + "test_initial_cbf_sync_widget.py", + "test_keystore_ui.py", + "test_my_treeview.py", + "test_notification_bar.py", + "test_theme_change_guard.py", + "test_theme_switching.py", + "test_tx_signing_steps_theme.py", +} + + +@pytest.fixture(autouse=True) +def suspend_wallet_balance_queries_for_global_palette_tests(request, monkeypatch: pytest.MonkeyPatch) -> None: + """Keep global palette GUI tests from triggering backend balance queries in stale widgets.""" + if request.node.fspath.basename not in _GLOBAL_PALETTE_GUI_TEST_MODULES: + return + monkeypatch.setattr(Wallet, "get_balance", lambda self: Balance()) diff --git a/tests/gui/qt/plugin_framework/test_plugin_list_widget_theme.py b/tests/gui/qt/plugin_framework/test_plugin_list_widget_theme.py new file mode 100644 index 000000000..58956f907 --- /dev/null +++ b/tests/gui/qt/plugin_framework/test_plugin_list_widget_theme.py @@ -0,0 +1,90 @@ +# +# Bitcoin Safe +# Copyright (C) 2026 Andreas Griffin +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of version 3 of the GNU General Public License as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see https://www.gnu.org/licenses/gpl-3.0.html +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# + +from PyQt6.QtCore import QEvent +from PyQt6.QtGui import QColor, QPalette +from PyQt6.QtWidgets import QApplication +from pytestqt.qtbot import QtBot + +from bitcoin_safe.gui.qt.util import NeutralSurfaceColors, color_with_alpha +from bitcoin_safe.plugin_framework.plugin_list_widget import BasePluginWidget + + +def test_base_plugin_widget_refreshes_theme_assets_on_palette_change(qtbot: QtBot, monkeypatch) -> None: + app = QApplication.instance() + assert app is not None + + widget = BasePluginWidget( + title="Plugin", + description="Plugin description", + provider="Provider", + icon="bi--question-circle.svg", + ) + qtbot.addWidget(widget) + widget.show() + qtbot.waitExposed(widget) + + def local_surface_colors() -> NeutralSurfaceColors: + palette = widget.palette() + return NeutralSurfaceColors( + panel_background=QColor(palette.color(QPalette.ColorRole.Window)), + content_background=QColor(palette.color(QPalette.ColorRole.Base)), + panel_border=color_with_alpha(palette.color(QPalette.ColorRole.Mid), 110), + row_hover=color_with_alpha(palette.color(QPalette.ColorRole.Mid), 55), + muted_text=color_with_alpha(palette.color(QPalette.ColorRole.WindowText), 170), + ) + + monkeypatch.setattr( + "bitcoin_safe.plugin_framework.plugin_list_widget.get_neutral_surface_colors", local_surface_colors + ) + + def render_with_palette(window: str, text: str) -> tuple[str, str]: + palette = QPalette(widget.palette()) + palette.setColor(QPalette.ColorRole.Window, QColor(window)) + palette.setColor(QPalette.ColorRole.Base, QColor(window)) + palette.setColor(QPalette.ColorRole.WindowText, QColor(text)) + palette.setColor(QPalette.ColorRole.Text, QColor(text)) + palette.setColor(QPalette.ColorRole.ButtonText, QColor(text)) + palette.setColor(QPalette.ColorRole.Dark, QColor(text)) + widget.setPalette(palette) + QApplication.sendEvent(widget, QEvent(QEvent.Type.PaletteChange)) + qtbot.waitUntil(lambda: widget.background_color is not None, timeout=1000) + qtbot.wait(10) + pixmap = widget.icon_label.pixmap() + assert pixmap is not None and not pixmap.isNull() + return ( + widget.description_label.styleSheet(), + widget.provider_label.styleSheet(), + ) + + light_description, light_provider = render_with_palette("#ffffff", "#101010") + dark_description, dark_provider = render_with_palette("#101010", "#f5f5f5") + + assert light_description != dark_description + assert light_provider != dark_provider diff --git a/tests/gui/qt/test_button_edit.py b/tests/gui/qt/test_button_edit.py index 03326c486..7b5eec794 100644 --- a/tests/gui/qt/test_button_edit.py +++ b/tests/gui/qt/test_button_edit.py @@ -35,8 +35,8 @@ import pytest from bitcoin_safe_lib.gui.qt.signal_tracker import SignalProtocol -from PyQt6.QtCore import QObject, QSize, Qt, pyqtSignal -from PyQt6.QtGui import QIcon, QResizeEvent +from PyQt6.QtCore import QEvent, QObject, QSize, Qt, pyqtSignal +from PyQt6.QtGui import QColor, QIcon, QPalette, QResizeEvent from PyQt6.QtWidgets import QApplication, QPushButton, QToolButton, QWidget from pytestqt.qtbot import QtBot @@ -84,6 +84,41 @@ def test_square_button(qapp: QApplication) -> None: assert button.icon().cacheKey() == icon.cacheKey() +def test_square_button_re_renders_theme_icon_on_palette_change(qtbot: QtBot) -> None: + app = QApplication.instance() + assert app is not None + original_palette = QPalette(app.palette()) + + parent = QWidget() + button = SquareButton("bi--copy.svg", parent) + qtbot.addWidget(parent) + button.show() + qtbot.waitExposed(button) + + def render_with_palette(window: str, text: str): + palette = QPalette(original_palette) + palette.setColor(QPalette.ColorRole.Window, QColor(window)) + palette.setColor(QPalette.ColorRole.WindowText, QColor(text)) + palette.setColor(QPalette.ColorRole.Text, QColor(text)) + palette.setColor(QPalette.ColorRole.ButtonText, QColor(text)) + palette.setColor(QPalette.ColorRole.Dark, QColor(text)) + app.setPalette(palette) + QApplication.sendEvent(button, QEvent(QEvent.Type.ApplicationPaletteChange)) + qtbot.wait(10) + pixmap = button.icon().pixmap(button.iconSize()) + assert not pixmap.isNull() + return pixmap.toImage() + + try: + light_image = render_with_palette("#ffffff", "#101010") + dark_image = render_with_palette("#101010", "#f5f5f5") + finally: + app.setPalette(original_palette) + QApplication.sendEvent(button, QEvent(QEvent.Type.ApplicationPaletteChange)) + + assert light_image != dark_image + + def test_buttons_field_initialization(qapp: QApplication) -> None: """Test buttons field initialization.""" # Create with an explicit alignment and parent to verify stored state. diff --git a/tests/gui/qt/test_card_base.py b/tests/gui/qt/test_card_base.py index c14960eb0..a11af888f 100644 --- a/tests/gui/qt/test_card_base.py +++ b/tests/gui/qt/test_card_base.py @@ -29,13 +29,23 @@ from __future__ import annotations -from PyQt6.QtCore import Qt -from PyQt6.QtWidgets import QLabel +import bdkpython as bdk +from PyQt6.QtCore import QEvent, Qt +from PyQt6.QtGui import QColor, QPalette +from PyQt6.QtWidgets import QApplication, QGraphicsOpacityEffect, QLabel from pytestqt.qtbot import QtBot from bitcoin_safe.gui.qt.card_base import CardBase, CardExpansionMode, CardList -from bitcoin_safe.gui.qt.new_wallet_welcome_screen import WelcomeActionCard +from bitcoin_safe.gui.qt.new_wallet_welcome_screen import ( + NetworkChoiceCard, + NetworkChoiceWelcomeScreen, + NewWalletWelcomeScreen, + WelcomeActionCard, +) +from bitcoin_safe.gui.qt.util import get_neutral_surface_colors, to_color_name from bitcoin_safe.gui.qt.wizard.wizard_step_cards import TutorialTxCard, TutorialTxCardState +from bitcoin_safe.signals import Signals +from bitcoin_safe.theme import create_dark_palette, create_light_palette def _make_card(title: str, body_height: int = 120) -> CardBase: @@ -237,3 +247,181 @@ def test_welcome_action_card_uses_card_base_clickable_header(qtbot: QtBot) -> No with qtbot.waitSignal(card.clicked): qtbot.mouseClick(card.header_title, Qt.MouseButton.LeftButton) + + +def test_welcome_action_card_update_ui_refreshes_background(qtbot: QtBot) -> None: + app = QApplication.instance() + assert app is not None + original_palette = QPalette(app.palette()) + + card = WelcomeActionCard("bi--wallet2.svg") + qtbot.addWidget(card) + card.set_content("Demo", "Description") + + def apply_palette(window: str, text: str) -> tuple[str, str, str]: + palette = QPalette(original_palette) + palette.setColor(QPalette.ColorRole.Window, QColor(window)) + palette.setColor(QPalette.ColorRole.Base, QColor(window)) + palette.setColor(QPalette.ColorRole.WindowText, QColor(text)) + palette.setColor(QPalette.ColorRole.Text, QColor(text)) + palette.setColor(QPalette.ColorRole.ButtonText, QColor(text)) + app.setPalette(palette) + card.changeEvent(QEvent(QEvent.Type.PaletteChange)) + return ( + to_color_name(card.background_color), + card.label_title.palette().color(card.label_title.foregroundRole()).name(), + card.label_description.palette().color(card.label_description.foregroundRole()).name(), + ) + + try: + light_background, light_title_style, light_description_style = apply_palette("#ffffff", "#111111") + dark_background, dark_title_style, dark_description_style = apply_palette("#111111", "#f5f5f5") + finally: + app.setPalette(original_palette) + card.changeEvent(QEvent(QEvent.Type.PaletteChange)) + + assert light_background != dark_background + assert light_title_style != dark_title_style + assert light_description_style != dark_description_style + + +def test_welcome_action_card_uses_disabled_opacity_when_disabled(qtbot: QtBot) -> None: + app = QApplication.instance() + assert app is not None + original_palette = QPalette(app.palette()) + + card = WelcomeActionCard("bi--wallet2.svg") + qtbot.addWidget(card) + card.set_content("Hot Single Signature Wallet", "Disabled on Mainnet") + + try: + card.setEnabled(True) + qtbot.wait(10) + effect = card.graphicsEffect() + assert isinstance(effect, QGraphicsOpacityEffect) + enabled_opacity = effect.opacity() + + card.setEnabled(False) + qtbot.wait(10) + disabled_opacity = effect.opacity() + finally: + app.setPalette(original_palette) + card.changeEvent(QEvent(QEvent.Type.PaletteChange)) + + assert enabled_opacity == 1.0 + assert disabled_opacity == WelcomeActionCard._disabled_opacity + + +def test_welcome_action_card_keeps_disabled_opacity_for_explicit_theme_palettes(qtbot: QtBot) -> None: + app = QApplication.instance() + assert app is not None + original_palette = QPalette(app.palette()) + + card = WelcomeActionCard("bi--wallet2.svg") + qtbot.addWidget(card) + card.set_content("Hot Single Signature Wallet", "Disabled on Mainnet") + card.setEnabled(False) + + try: + app.setPalette(create_light_palette(original_palette)) + card.changeEvent(QEvent(QEvent.Type.ApplicationPaletteChange)) + effect = card.graphicsEffect() + assert isinstance(effect, QGraphicsOpacityEffect) + light_disabled_opacity = effect.opacity() + + app.setPalette(create_dark_palette(original_palette)) + card.changeEvent(QEvent(QEvent.Type.ApplicationPaletteChange)) + dark_disabled_opacity = effect.opacity() + finally: + app.setPalette(original_palette) + card.changeEvent(QEvent(QEvent.Type.ApplicationPaletteChange)) + + assert light_disabled_opacity == WelcomeActionCard._disabled_opacity + assert dark_disabled_opacity == WelcomeActionCard._disabled_opacity + + +def test_network_choice_card_update_ui_refreshes_backgrounds(qtbot: QtBot) -> None: + card = NetworkChoiceCard("bitcoin-bitcoin.svg") + qtbot.addWidget(card) + + card.background_color = None + card.cta_panel.background_color = None + card.changeEvent(QEvent(QEvent.Type.PaletteChange)) + + surface_colors = get_neutral_surface_colors() + assert to_color_name(card.background_color) == to_color_name(surface_colors.content_background) + assert to_color_name(card.cta_panel.background_color) == to_color_name(surface_colors.panel_background) + + +def test_new_wallet_welcome_screen_refreshes_cards_on_palette_change(qtbot: QtBot) -> None: + app = QApplication.instance() + assert app is not None + original_palette = QPalette(app.palette()) + + screen = NewWalletWelcomeScreen(network=bdk.Network.TESTNET, signals=Signals()) + qtbot.addWidget(screen) + + def apply_palette(window: str, text: str) -> str: + palette = QPalette(original_palette) + palette.setColor(QPalette.ColorRole.Window, QColor(window)) + palette.setColor(QPalette.ColorRole.Base, QColor(window)) + palette.setColor(QPalette.ColorRole.WindowText, QColor(text)) + palette.setColor(QPalette.ColorRole.Text, QColor(text)) + palette.setColor(QPalette.ColorRole.ButtonText, QColor(text)) + app.setPalette(palette) + screen.hide() + QApplication.sendEvent(screen, QEvent(QEvent.Type.ApplicationPaletteChange)) + screen.show() + qtbot.waitExposed(screen) + qtbot.wait(10) + return screen.card_demo_wallet.styleSheet() + + try: + light_stylesheet = apply_palette("#ffffff", "#111111") + dark_stylesheet = apply_palette("#111111", "#f5f5f5") + finally: + app.setPalette(original_palette) + screen.hide() + QApplication.sendEvent(screen, QEvent(QEvent.Type.ApplicationPaletteChange)) + + assert light_stylesheet != dark_stylesheet + assert to_color_name(screen.card_demo_wallet.background_color) == to_color_name( + get_neutral_surface_colors().panel_background + ) + + +def test_network_choice_welcome_screen_refreshes_cards_on_palette_change(qtbot: QtBot) -> None: + app = QApplication.instance() + assert app is not None + original_palette = QPalette(app.palette()) + + screen = NetworkChoiceWelcomeScreen(signals=Signals()) + qtbot.addWidget(screen) + + def apply_palette(window: str, text: str) -> str: + palette = QPalette(original_palette) + palette.setColor(QPalette.ColorRole.Window, QColor(window)) + palette.setColor(QPalette.ColorRole.Base, QColor(window)) + palette.setColor(QPalette.ColorRole.WindowText, QColor(text)) + palette.setColor(QPalette.ColorRole.Text, QColor(text)) + palette.setColor(QPalette.ColorRole.ButtonText, QColor(text)) + app.setPalette(palette) + screen.hide() + QApplication.sendEvent(screen, QEvent(QEvent.Type.ApplicationPaletteChange)) + screen.show() + qtbot.waitExposed(screen) + qtbot.wait(10) + return screen.card_secure_wallet.styleSheet() + + try: + light_stylesheet = apply_palette("#ffffff", "#111111") + dark_stylesheet = apply_palette("#111111", "#f5f5f5") + finally: + app.setPalette(original_palette) + screen.hide() + QApplication.sendEvent(screen, QEvent(QEvent.Type.ApplicationPaletteChange)) + + assert light_stylesheet != dark_stylesheet + assert to_color_name(screen.card_secure_wallet.cta_panel.background_color) == to_color_name( + get_neutral_surface_colors().panel_background + ) diff --git a/tests/gui/qt/test_descriptor_ui_performance.py b/tests/gui/qt/test_descriptor_ui_performance.py index 6b84ee5fb..5ff004704 100644 --- a/tests/gui/qt/test_descriptor_ui_performance.py +++ b/tests/gui/qt/test_descriptor_ui_performance.py @@ -226,6 +226,27 @@ def test_descriptor_ui_protowallet_mode_keeps_wallet_definition_editable( loop_in_thread.stop() +@pytest.mark.marker_qt_1 +def test_descriptor_ui_can_reduce_to_singlesig_with_incomplete_signers( + qtbot: QtBot, + test_config: TestConfig, +) -> None: + descriptor_ui, loop_in_thread, wallet = _build_descriptor_ui(qtbot=qtbot, test_config=test_config) + try: + descriptor_ui.spin_req.setValue(1) + descriptor_ui.spin_signers.setValue(1) + + assert descriptor_ui.spin_req.value() == 1 + assert descriptor_ui.spin_signers.value() == 1 + assert descriptor_ui.keystore_uis.count() == 1 + assert descriptor_ui.protowallet.is_multisig() is False + finally: + descriptor_ui.close() + if wallet: + wallet.close() + loop_in_thread.stop() + + @pytest.mark.marker_qt_1 def test_descriptor_ui_existing_wallet_mode_locks_descriptor_changes( qtbot: QtBot, diff --git a/tests/gui/qt/test_icon_label.py b/tests/gui/qt/test_icon_label.py index e26d26696..d9cf23727 100644 --- a/tests/gui/qt/test_icon_label.py +++ b/tests/gui/qt/test_icon_label.py @@ -27,15 +27,20 @@ # SOFTWARE. # +from pathlib import Path + +from PyQt6.QtCore import QEvent +from PyQt6.QtGui import QColor, QPalette +from PyQt6.QtWidgets import QApplication from pytestqt.qtbot import QtBot from bitcoin_safe.gui.qt.icon_label import IconLabel -from bitcoin_safe.gui.qt.util import svg_tools +from bitcoin_safe.gui.qt.util import get_icon_path def test_icon_label_defaults_to_icon_left(qtbot: QtBot) -> None: label = IconLabel("Default") - label.set_icon(svg_tools.get_QIcon("checkmark.svg")) + label.set_icon(Path(get_icon_path("checkmark.svg"))) qtbot.addWidget(label) layout = label.layout() @@ -47,7 +52,7 @@ def test_icon_label_defaults_to_icon_left(qtbot: QtBot) -> None: def test_icon_label_can_place_icon_on_right(qtbot: QtBot) -> None: label = IconLabel("Status", icon_on_right=True) - label.set_icon(svg_tools.get_QIcon("checkmark.svg")) + label.set_icon(Path(get_icon_path("checkmark.svg"))) qtbot.addWidget(label) layout = label.layout() @@ -55,3 +60,84 @@ def test_icon_label_can_place_icon_on_right(qtbot: QtBot) -> None: assert layout is not None assert layout.itemAt(0).widget() == label.textLabel assert layout.itemAt(1).widget() == label.icon_label + + +def test_icon_label_re_renders_theme_icon_on_palette_change(qtbot: QtBot) -> None: + app = QApplication.instance() + assert app is not None + original_palette = QPalette(app.palette()) + + label = IconLabel("Status") + label.set_icon("bi--question-circle.svg") + qtbot.addWidget(label) + label.show() + qtbot.waitExposed(label) + + def render_with_palette(window: str, text: str): + palette = QPalette(original_palette) + palette.setColor(QPalette.ColorRole.Window, QColor(window)) + palette.setColor(QPalette.ColorRole.WindowText, QColor(text)) + palette.setColor(QPalette.ColorRole.Text, QColor(text)) + palette.setColor(QPalette.ColorRole.ButtonText, QColor(text)) + palette.setColor(QPalette.ColorRole.Dark, QColor(text)) + app.setPalette(palette) + QApplication.sendEvent(label, QEvent(QEvent.Type.ApplicationPaletteChange)) + qtbot.wait(10) + pixmap = label.icon_label.pixmap() + assert pixmap is not None and not pixmap.isNull() + return pixmap.toImage() + + try: + light_image = render_with_palette("#ffffff", "#101010") + dark_image = render_with_palette("#101010", "#f5f5f5") + finally: + app.setPalette(original_palette) + QApplication.sendEvent(label, QEvent(QEvent.Type.ApplicationPaletteChange)) + + assert light_image != dark_image + + +def test_icon_label_supports_absolute_icon_path_on_palette_change(qtbot: QtBot) -> None: + app = QApplication.instance() + assert app is not None + original_palette = QPalette(app.palette()) + + label = IconLabel("Status") + label.set_icon(Path(get_icon_path("bi--question-circle.svg"))) + qtbot.addWidget(label) + label.show() + qtbot.waitExposed(label) + + try: + dark_palette = QPalette(original_palette) + dark_palette.setColor(QPalette.ColorRole.Window, QColor("#101010")) + dark_palette.setColor(QPalette.ColorRole.WindowText, QColor("#f5f5f5")) + dark_palette.setColor(QPalette.ColorRole.Text, QColor("#f5f5f5")) + dark_palette.setColor(QPalette.ColorRole.ButtonText, QColor("#f5f5f5")) + dark_palette.setColor(QPalette.ColorRole.Dark, QColor("#f5f5f5")) + app.setPalette(dark_palette) + QApplication.sendEvent(label, QEvent(QEvent.Type.ApplicationPaletteChange)) + qtbot.wait(10) + finally: + app.setPalette(original_palette) + QApplication.sendEvent(label, QEvent(QEvent.Type.ApplicationPaletteChange)) + + pixmap = label.icon_label.pixmap() + assert pixmap is not None and not pixmap.isNull() + + +def test_icon_label_ignores_style_change_event(qtbot: QtBot, monkeypatch) -> None: + label = IconLabel("Status") + qtbot.addWidget(label) + + calls = 0 + + def track_apply_icon() -> None: + nonlocal calls + calls += 1 + + monkeypatch.setattr(label, "_apply_icon", track_apply_icon) + + label.changeEvent(QEvent(QEvent.Type.StyleChange)) + + assert calls == 0 diff --git a/tests/gui/qt/test_initial_cbf_sync_widget.py b/tests/gui/qt/test_initial_cbf_sync_widget.py index d7388252e..e1a5d0593 100644 --- a/tests/gui/qt/test_initial_cbf_sync_widget.py +++ b/tests/gui/qt/test_initial_cbf_sync_widget.py @@ -35,12 +35,15 @@ from datetime import timedelta from typing import Any -from PyQt6.QtCore import QRectF +from PyQt6.QtCore import QEvent, QRectF +from PyQt6.QtGui import QColor, QPalette +from PyQt6.QtWidgets import QApplication from pytestqt.qtbot import QtBot from bitcoin_safe.client import ProgressInfo, SyncStatus from bitcoin_safe.geoip_rough import RoughGeoIpDatabase from bitcoin_safe.gui.qt.initial_cbf_sync_widget import NetworkMapWidget, NetworkMapWidgetMode +from bitcoin_safe.gui.qt.util import get_neutral_surface_colors, to_color_name from bitcoin_safe.network_config import Peer from bitcoin_safe.pythonbdk_types import BlockchainType @@ -259,6 +262,41 @@ def test_network_map_widget_visibility_depends_on_network_mode( assert widget.server_legend_label.isHidden() == expected["server_legend_hidden"] +def test_network_map_sync_progress_card_updates_background_on_palette_change( + qtbot: QtBot, test_config +) -> None: + app = QApplication.instance() + assert app is not None + original_palette = QPalette(app.palette()) + + test_config.network_config.server_type = BlockchainType.CompactBlockFilter + widget = NetworkMapWidget(config=test_config, mode=NetworkMapWidgetMode.cbf_initial_sync) + qtbot.addWidget(widget) + widget.show() + qtbot.waitExposed(widget) + + def apply_palette(window: str, text: str) -> str: + palette = QPalette(original_palette) + palette.setColor(QPalette.ColorRole.Window, QColor(window)) + palette.setColor(QPalette.ColorRole.WindowText, QColor(text)) + app.setPalette(palette) + QApplication.sendEvent(widget, QEvent(QEvent.Type.ApplicationPaletteChange)) + qtbot.wait(10) + return to_color_name(get_neutral_surface_colors().panel_background) + + try: + light_background = apply_palette("#ffffff", "#111111") + assert f"background: {light_background};" in widget.local_progress_card.styleSheet() + + dark_background = apply_palette("#111111", "#f5f5f5") + assert f"background: {dark_background};" in widget.local_progress_card.styleSheet() + finally: + app.setPalette(original_palette) + QApplication.sendEvent(widget, QEvent(QEvent.Type.ApplicationPaletteChange)) + + assert light_background != dark_background + + def test_network_map_widget_renders_multiple_wallet_progress_rows(qtbot: QtBot, test_config) -> None: widget = NetworkMapWidget(config=test_config, mode=NetworkMapWidgetMode.tools_tab) qtbot.addWidget(widget) diff --git a/tests/gui/qt/test_keystore_ui.py b/tests/gui/qt/test_keystore_ui.py index 4c4195851..a55682c86 100644 --- a/tests/gui/qt/test_keystore_ui.py +++ b/tests/gui/qt/test_keystore_ui.py @@ -35,8 +35,11 @@ from bitcoin_usb.address_types import AddressType, AddressTypes from bitcoin_usb.dialogs import AutoScanMode from PyQt6.QtCore import Qt +from PyQt6.QtGui import QColor, QPalette +from PyQt6.QtWidgets import QApplication from pytestqt.qtbot import QtBot +from bitcoin_safe.gui.qt.custom_edits import AnalyzerState from bitcoin_safe.gui.qt.keystore_ui import KeyStoreUI, KeyStoreUiState from bitcoin_safe.gui.qt.util import ColorScheme, svg_tools_hardware_signer from bitcoin_safe.hardware_signers import HardwareSigners @@ -112,7 +115,7 @@ def test_keystore_ui_add_state(qtbot: QtBot, loop_in_thread: LoopInThread) -> No actual_pixmap = widget.header_icon.pixmap() assert actual_pixmap is not None expected_icon = svg_tools_hardware_signer.get_QIcon(HardwareSigners.generic.icon_name) - expected_pixmap = expected_icon.pixmap(actual_pixmap.size(), widget.devicePixelRatioF()) + expected_pixmap = expected_icon.pixmap(34, 34) assert actual_pixmap.toImage() == expected_pixmap.toImage() assert widget.sizePolicy().verticalPolicy() == widget.sizePolicy().Policy.Fixed assert widget.combo_brand.isVisible() @@ -387,6 +390,40 @@ def test_keystore_ui_read_only_state_can_hide_register_button( assert not widget.button_register.isVisible() +def test_keystore_ui_refreshes_detail_warning_colors_on_palette_change( + qtbot: QtBot, loop_in_thread: LoopInThread +) -> None: + widget = _make_widget(qtbot, loop_in_thread) + app = QApplication.instance() + assert app is not None + original_palette = QPalette(app.palette()) + + def apply_palette(window: str, text: str) -> tuple[str, str]: + palette = QPalette(original_palette) + palette.setColor(QPalette.ColorRole.Window, QColor(window)) + palette.setColor(QPalette.ColorRole.WindowText, QColor(text)) + palette.setColor(QPalette.ColorRole.Base, QColor(window)) + palette.setColor(QPalette.ColorRole.Text, QColor(text)) + palette.setColor(QPalette.ColorRole.Button, QColor(window)) + palette.setColor(QPalette.ColorRole.ButtonText, QColor(text)) + app.setPalette(palette) + qtbot.wait(10) + widget.edit_xpub.format_edit(AnalyzerState.Warning) + return widget.edit_xpub.input_field.styleSheet(), widget.background_color.name() + + try: + light_stylesheet, light_background = apply_palette("#ffffff", "#101010") + dark_stylesheet, dark_background = apply_palette("#111111", "#f5f5f5") + finally: + app.setPalette(original_palette) + qtbot.wait(10) + + assert "#ffd49a" in light_stylesheet + assert "#8a4b00" in dark_stylesheet + assert light_stylesheet != dark_stylesheet + assert light_background != dark_background + + def test_register_multisig_emit_request_signal(qtbot: QtBot, loop_in_thread: LoopInThread) -> None: widget = _make_widget(qtbot, loop_in_thread, read_only_mode=True) keystore = create_test_seed_keystores( diff --git a/tests/gui/qt/test_my_treeview.py b/tests/gui/qt/test_my_treeview.py index 9f31d6bc7..b8c4d03b9 100644 --- a/tests/gui/qt/test_my_treeview.py +++ b/tests/gui/qt/test_my_treeview.py @@ -33,14 +33,14 @@ from pathlib import Path import pytest -from PyQt6.QtCore import QModelIndex -from PyQt6.QtGui import QStandardItem +from PyQt6.QtCore import QEvent, QModelIndex +from PyQt6.QtGui import QColor, QPalette, QStandardItem from PyQt6.QtWidgets import QApplication, QStyleFactory from pytestqt.qtbot import QtBot from bitcoin_safe.config import UserConfig from bitcoin_safe.gui.qt.color_corrected_treeview import ColorCorrectedTreeView -from bitcoin_safe.gui.qt.my_treeview import MyItemDataRole, MyTreeView +from bitcoin_safe.gui.qt.my_treeview import MyItemDataRole, MyTreeView, TreeViewWithToolbar from bitcoin_safe.signals import Signals @@ -174,6 +174,74 @@ def test_colorcorrectedtreeview_detects_windows11_after_stylesheet_wrap(qtbot: Q app.setStyle(restored_style) +def test_colorcorrectedtreeview_refreshes_selection_override_on_palette_change( + qtbot: QtBot, monkeypatch +) -> None: + app = QApplication.instance() + assert app is not None + original_palette = QPalette(app.palette()) + + tree_view = ColorCorrectedTreeView() + qtbot.addWidget(tree_view) + monkeypatch.setattr(tree_view, "_needs_selection_text_override", lambda: True) + + def apply_palette(highlighted_text: str) -> str: + palette = QPalette(original_palette) + palette.setColor(QPalette.ColorRole.HighlightedText, QColor(highlighted_text)) + app.setPalette(palette) + QApplication.sendEvent(tree_view, QEvent(QEvent.Type.PaletteChange)) + qtbot.wait(10) + return tree_view.styleSheet() + + try: + light_stylesheet = apply_palette("#111111") + dark_stylesheet = apply_palette("#f5f5f5") + finally: + app.setPalette(original_palette) + QApplication.sendEvent(tree_view, QEvent(QEvent.Type.PaletteChange)) + + assert light_stylesheet != dark_stylesheet + assert "#f5f5f5" in dark_stylesheet + + +def test_treeview_with_toolbar_re_renders_theme_icon_on_palette_change( + qtbot: QtBot, test_config: UserConfig +) -> None: + app = QApplication.instance() + assert app is not None + original_palette = QPalette(app.palette()) + + tree_view = DummyTreeView(config=test_config) + widget = TreeViewWithToolbar(searchable_list=tree_view, config=test_config) + widget.create_layout() + qtbot.addWidget(widget) + widget.show() + qtbot.waitExposed(widget) + + def render_with_palette(window: str, text: str): + palette = QPalette(original_palette) + palette.setColor(QPalette.ColorRole.Window, QColor(window)) + palette.setColor(QPalette.ColorRole.WindowText, QColor(text)) + palette.setColor(QPalette.ColorRole.Text, QColor(text)) + palette.setColor(QPalette.ColorRole.ButtonText, QColor(text)) + palette.setColor(QPalette.ColorRole.Dark, QColor(text)) + app.setPalette(palette) + QApplication.sendEvent(widget, QEvent(QEvent.Type.ApplicationPaletteChange)) + qtbot.wait(10) + pixmap = widget.toolbar_button.icon().pixmap(widget.toolbar_button.iconSize()) + assert not pixmap.isNull() + return pixmap.toImage() + + try: + light_image = render_with_palette("#ffffff", "#101010") + dark_image = render_with_palette("#101010", "#f5f5f5") + finally: + app.setPalette(original_palette) + QApplication.sendEvent(widget, QEvent(QEvent.Type.ApplicationPaletteChange)) + + assert light_image != dark_image + + def test_mytreeview_csv_export_writes_utf8(tmp_path: Path, test_config: UserConfig) -> None: tree_view = DummyTreeView(config=test_config) tree_view.append_row(text="Sync\u2011label", key="sync") diff --git a/tests/gui/qt/test_notification_bar.py b/tests/gui/qt/test_notification_bar.py new file mode 100644 index 000000000..bcbb4544d --- /dev/null +++ b/tests/gui/qt/test_notification_bar.py @@ -0,0 +1,126 @@ +# +# Bitcoin Safe +# Copyright (C) 2026 Andreas Griffin +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of version 3 of the GNU General Public License as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see https://www.gnu.org/licenses/gpl-3.0.html +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# + +import bdkpython as bdk +from PyQt6.QtCore import QEvent +from PyQt6.QtGui import QColor, QPalette +from PyQt6.QtWidgets import QApplication, QLabel +from pytestqt.qtbot import QtBot + +from bitcoin_safe.gui.qt.notification_bar import NotificationBar +from bitcoin_safe.gui.qt.notification_bar_regtest import NotificationBarRegtest +from bitcoin_safe.gui.qt.util import adjust_brightness +from bitcoin_safe.signals import SignalsMin + + +def test_notification_bar_reapplies_base_background_on_palette_change(qtbot: QtBot) -> None: + app = QApplication.instance() + assert app is not None + original_palette = QPalette(app.palette()) + + bar = NotificationBar("Status") + extra_label = QLabel("extra") + bar.add_styled_widget(extra_label) + qtbot.addWidget(bar) + bar.show() + qtbot.waitExposed(bar) + + base_color = QColor("#FFDF00") + + def apply_palette(window: str, text: str) -> str: + palette = QPalette(original_palette) + palette.setColor(QPalette.ColorRole.Window, QColor(window)) + palette.setColor(QPalette.ColorRole.WindowText, QColor(text)) + app.setPalette(palette) + bar.set_background_base_color(base_color) + QApplication.sendEvent(bar, QEvent(QEvent.Type.ApplicationPaletteChange)) + qtbot.wait(10) + assert bar.color is not None + assert f"background-color: {bar.color.name()};" in extra_label.styleSheet() + return bar.color.name() + + try: + light_name = apply_palette("#ffffff", "#101010") + dark_name = apply_palette("#101010", "#f5f5f5") + finally: + app.setPalette(original_palette) + QApplication.sendEvent(bar, QEvent(QEvent.Type.ApplicationPaletteChange)) + + assert light_name == base_color.name() + assert dark_name == adjust_brightness(base_color, -0.4).name() + + +def test_regtest_notification_bar_reacts_to_application_palette_change(qtbot: QtBot) -> None: + app = QApplication.instance() + assert app is not None + original_palette = QPalette(app.palette()) + + bar = NotificationBarRegtest( + callback_open_network_setting=lambda: None, + network=bdk.Network.REGTEST, + signals_min=SignalsMin(), + ) + qtbot.addWidget(bar) + bar.show() + qtbot.waitExposed(bar) + + def apply_palette(window: str, text: str) -> str: + palette = QPalette(original_palette) + palette.setColor(QPalette.ColorRole.Window, QColor(window)) + palette.setColor(QPalette.ColorRole.WindowText, QColor(text)) + app.setPalette(palette) + qtbot.wait(10) + assert bar.color is not None + return bar.color.name() + + try: + light_name = apply_palette("#ffffff", "#101010") + dark_name = apply_palette("#101010", "#f5f5f5") + finally: + app.setPalette(original_palette) + + assert light_name == QColor("lightblue").name() + assert dark_name == adjust_brightness(QColor("lightblue"), -0.4).name() + + +def test_notification_bar_ignores_style_change_event(qtbot: QtBot, monkeypatch) -> None: + bar = NotificationBar("Status") + qtbot.addWidget(bar) + + calls = 0 + + def track_refresh() -> None: + nonlocal calls + calls += 1 + + monkeypatch.setattr(bar, "_refresh_theme_background", track_refresh) + + bar.changeEvent(QEvent(QEvent.Type.StyleChange)) + + assert calls == 0 diff --git a/tests/gui/qt/test_quick_receive.py b/tests/gui/qt/test_quick_receive.py index 4ddc3a3f7..db70506a9 100644 --- a/tests/gui/qt/test_quick_receive.py +++ b/tests/gui/qt/test_quick_receive.py @@ -41,7 +41,8 @@ from PyQt6.QtWidgets import QApplication from pytestqt.qtbot import QtBot -from bitcoin_safe.gui.qt.qr_components.quick_receive import ReceiveGroup +from bitcoin_safe.gui.qt.invisible_scroll_area import InvisibleScrollArea +from bitcoin_safe.gui.qt.qr_components.quick_receive import QuickReceive, ReceiveGroup from bitcoin_safe.gui.qt.qt_wallet import QTWallet from ...helpers import TestConfig @@ -55,6 +56,15 @@ def _strip_invisible(text: str) -> str: return text.replace("\u200b", "").replace("\ufeff", "") +def test_quick_receive_uses_invisible_scroll_area(qtbot: QtBot) -> None: + widget = QuickReceive() + qtbot.addWidget(widget) + + assert isinstance(widget.scroll_area, InvisibleScrollArea) + assert widget.content_widget is widget.scroll_area.content_widget + assert "border: none;" in widget.scroll_area.styleSheet() + + @pytest.mark.marker_qt_3 def test_quick_receive_copy_and_next_address( qapp: QApplication, diff --git a/tests/gui/qt/test_spinbox.py b/tests/gui/qt/test_spinbox.py new file mode 100644 index 000000000..6d3cbc4aa --- /dev/null +++ b/tests/gui/qt/test_spinbox.py @@ -0,0 +1,53 @@ +# +# Bitcoin Safe +# Copyright (C) 2026 Andreas Griffin +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of version 3 of the GNU General Public License as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see https://www.gnu.org/licenses/gpl-3.0.html +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# + +from PyQt6.QtCore import QEvent +from pytestqt.qtbot import QtBot + +from bitcoin_safe.gui.qt.ui_tx.spinbox import AnalyzerSpinBox + + +def test_analyzer_spinbox_ignores_style_change_event(qtbot: QtBot) -> None: + widget = AnalyzerSpinBox() + qtbot.addWidget(widget) + widget.setReadOnly(True) + + widget.changeEvent(QEvent(QEvent.Type.StyleChange)) + + assert "background: transparent;" in widget.styleSheet() + + +def test_analyzer_spinbox_handles_palette_change_event(qtbot: QtBot) -> None: + widget = AnalyzerSpinBox() + qtbot.addWidget(widget) + widget.setReadOnly(True) + + widget.changeEvent(QEvent(QEvent.Type.PaletteChange)) + + assert "background: transparent;" in widget.styleSheet() diff --git a/tests/gui/qt/test_theme_change_guard.py b/tests/gui/qt/test_theme_change_guard.py new file mode 100644 index 000000000..5b2824f3b --- /dev/null +++ b/tests/gui/qt/test_theme_change_guard.py @@ -0,0 +1,99 @@ +# +# Bitcoin Safe +# Copyright (C) 2026 Andreas Griffin +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of version 3 of the GNU General Public License as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see https://www.gnu.org/licenses/gpl-3.0.html +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# + +from PyQt6.QtCore import QEvent +from PyQt6.QtGui import QColor, QPalette +from PyQt6.QtWidgets import QApplication, QWidget +from pytestqt.qtbot import QtBot + +from bitcoin_safe.gui.qt.util import ( + propagate_theme_change_to_descendants, + remember_theme_state, + should_process_theme_change, +) + + +def test_should_process_theme_change_skips_duplicate_palette_event(qtbot: QtBot) -> None: + widget = QWidget() + qtbot.addWidget(widget) + remember_theme_state(widget) + + assert not should_process_theme_change(widget, QEvent(QEvent.Type.PaletteChange)) + + palette = QPalette(widget.palette()) + palette.setColor(QPalette.ColorRole.WindowText, QColor("#f5f5f5")) + widget.setPalette(palette) + + assert should_process_theme_change(widget, QEvent(QEvent.Type.PaletteChange)) + assert not should_process_theme_change(widget, QEvent(QEvent.Type.PaletteChange)) + + +def test_should_process_theme_change_keeps_enabled_change_events(qtbot: QtBot) -> None: + widget = QWidget() + qtbot.addWidget(widget) + remember_theme_state(widget) + + assert should_process_theme_change(widget, QEvent(QEvent.Type.EnabledChange), include_enabled_change=True) + assert should_process_theme_change(widget, QEvent(QEvent.Type.EnabledChange), include_enabled_change=True) + + +class _ThemePropagationProbe(QWidget): + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.palette_change_calls = 0 + + def changeEvent(self, event: QEvent | None) -> None: + super().changeEvent(event) + if should_process_theme_change(self, event): + self.palette_change_calls += 1 + + +def test_propagate_theme_change_to_descendants_forwards_palette_change(qtbot: QtBot) -> None: + app = QApplication.instance() + assert app is not None + original_palette = QPalette(app.palette()) + + parent = QWidget() + child = _ThemePropagationProbe(parent) + qtbot.addWidget(parent) + parent.show() + qtbot.waitExposed(parent) + + try: + dark_palette = QPalette(original_palette) + dark_palette.setColor(QPalette.ColorRole.Window, QColor("#111111")) + dark_palette.setColor(QPalette.ColorRole.WindowText, QColor("#f5f5f5")) + app.setPalette(dark_palette) + propagate_theme_change_to_descendants(parent) + qtbot.wait(10) + finally: + app.setPalette(original_palette) + + assert child.palette_change_calls == 1 + assert child.palette().color(QPalette.ColorRole.Window).name() == "#111111" diff --git a/tests/gui/qt/test_theme_switching.py b/tests/gui/qt/test_theme_switching.py new file mode 100644 index 000000000..d80284b1e --- /dev/null +++ b/tests/gui/qt/test_theme_switching.py @@ -0,0 +1,409 @@ +# +# Bitcoin Safe +# Copyright (C) 2026 Andreas Griffin +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of version 3 of the GNU General Public License as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see https://www.gnu.org/licenses/gpl-3.0.html +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# + +from __future__ import annotations + +import inspect +import shutil +import tempfile +from datetime import datetime +from pathlib import Path + +from PyQt6.QtGui import QColor, QImage, QPalette +from PyQt6.QtTest import QTest +from PyQt6.QtWidgets import QApplication +from pytestqt.qtbot import QtBot + +from bitcoin_safe.pythonbdk_types import Balance +from bitcoin_safe.signals import UpdateFilter, UpdateFilterReason +from bitcoin_safe.wallet import Wallet + +from ...helpers import TestConfig +from .helpers import Shutter, main_window_context + + +def _images_differ(left: QImage, right: QImage) -> bool: + if left.size() != right.size(): + return True + + for y in range(left.height()): + for x in range(left.width()): + if left.pixel(x, y) != right.pixel(x, y): + return True + return False + + +def _build_dark_palette(original_palette: QPalette) -> QPalette: + dark_palette = QPalette(original_palette) + dark_palette.setColor(QPalette.ColorRole.Window, QColor("#111111")) + dark_palette.setColor(QPalette.ColorRole.WindowText, QColor("#f5f5f5")) + dark_palette.setColor(QPalette.ColorRole.Base, QColor("#181818")) + dark_palette.setColor(QPalette.ColorRole.AlternateBase, QColor("#222222")) + dark_palette.setColor(QPalette.ColorRole.Text, QColor("#f5f5f5")) + dark_palette.setColor(QPalette.ColorRole.Button, QColor("#202020")) + dark_palette.setColor(QPalette.ColorRole.ButtonText, QColor("#f5f5f5")) + return dark_palette + + +def _build_light_palette() -> QPalette: + light_palette = QPalette() + light_palette.setColor(QPalette.ColorRole.Window, QColor("#ffffff")) + light_palette.setColor(QPalette.ColorRole.WindowText, QColor("#000000")) + light_palette.setColor(QPalette.ColorRole.Base, QColor("#ffffff")) + light_palette.setColor(QPalette.ColorRole.AlternateBase, QColor("#f2f2f2")) + light_palette.setColor(QPalette.ColorRole.Text, QColor("#000000")) + light_palette.setColor(QPalette.ColorRole.Button, QColor("#f0f0f0")) + light_palette.setColor(QPalette.ColorRole.ButtonText, QColor("#000000")) + return light_palette + + +def _stub_wallet_balance_queries(monkeypatch) -> None: + """Keep palette-switch tests focused on UI refresh instead of backend balance calls.""" + monkeypatch.setattr(Wallet, "get_balance", lambda self: Balance()) + + +def test_wallet_details_screen_rethemes_keystore_card_on_palette_change( + qapp: QApplication, + qtbot: QtBot, + monkeypatch, + mytest_start_time: datetime, + test_config_main_chain: TestConfig, + wallet_file: str = "bacon.wallet", +) -> None: + frame = inspect.currentframe() + assert frame + shutter = Shutter(qtbot, name=f"{mytest_start_time.timestamp()}_{inspect.getframeinfo(frame).function}") + + shutter.create_symlink(test_config=test_config_main_chain) + original_palette = QPalette(qapp.palette()) + _stub_wallet_balance_queries(monkeypatch) + + with main_window_context(test_config=test_config_main_chain) as main_window: + QTest.qWaitForWindowExposed(main_window, timeout=10000) # type: ignore[arg-type] + + temp_wallet = Path(tempfile.mkdtemp()) / wallet_file + shutil.copy(str(Path("tests") / "data" / wallet_file), str(temp_wallet)) + + qt_wallet = main_window.open_wallet(str(temp_wallet)) + assert qt_wallet is not None + qt_wallet.settings_node.select() + qtbot.wait(50) + + key_store_ui = next(iter(qt_wallet.wallet_descriptor_ui.keystore_uis.getAllTabData().values())) + + light_path = shutter.save_screenshot(main_window, qtbot, shutter.name) + light_image = QImage(str(light_path)) + light_background = key_store_ui.background_color.name() + + try: + qapp.setPalette(_build_dark_palette(original_palette)) + qtbot.waitUntil( + lambda: key_store_ui.background_color.name() != light_background, + timeout=5000, + ) + dark_background = key_store_ui.background_color.name() + dark_path = shutter.save_screenshot(main_window, qtbot, shutter.name) + dark_image = QImage(str(dark_path)) + finally: + qapp.setPalette(original_palette) + qtbot.wait(10) + + assert _images_differ(light_image, dark_image) + assert dark_background != light_background + + +def test_wallet_tabs_retheme_on_palette_change( + qapp: QApplication, + qtbot: QtBot, + monkeypatch, + mytest_start_time: datetime, + test_config_main_chain: TestConfig, + wallet_file: str = "bacon.wallet", +) -> None: + frame = inspect.currentframe() + assert frame + shutter = Shutter(qtbot, name=f"{mytest_start_time.timestamp()}_{inspect.getframeinfo(frame).function}") + + shutter.create_symlink(test_config=test_config_main_chain) + original_palette = QPalette(qapp.palette()) + _stub_wallet_balance_queries(monkeypatch) + + with main_window_context(test_config=test_config_main_chain) as main_window: + QTest.qWaitForWindowExposed(main_window, timeout=10000) # type: ignore[arg-type] + + temp_wallet = Path(tempfile.mkdtemp()) / wallet_file + shutil.copy(str(Path("tests") / "data" / wallet_file), str(temp_wallet)) + + qt_wallet = main_window.open_wallet(str(temp_wallet)) + assert qt_wallet is not None + qtbot.wait(50) + + def current_recipient_widget(): + return qt_wallet.uitx_creator.column_recipients.recipients.get_recipient_group_boxes()[ + 0 + ].recipient_widget + + descriptor_edit = qt_wallet.wallet_descriptor_ui.edit_descriptor.edit.input_field + tab_nodes = { + "history": qt_wallet.hist_node, + "addresses": qt_wallet.address_node, + "send": qt_wallet.send_node, + "details": qt_wallet.settings_node, + } + if plugins_node := qt_wallet.get_plugins_node(): + tab_nodes["plugins"] = plugins_node + + light_images: dict[str, QImage] = {} + light_descriptor_base = descriptor_edit.palette().color(QPalette.ColorRole.Base).name() + qt_wallet.send_node.select() + qtbot.wait(50) + light_recipient_widget = current_recipient_widget() + light_label_base = ( + light_recipient_widget.label_line_edit.label_edit.palette().color(QPalette.ColorRole.Base).name() + ) + light_category_base = ( + light_recipient_widget.label_line_edit.category_edit.palette() + .color(QPalette.ColorRole.Base) + .name() + ) + light_fiat_base = light_recipient_widget.fiat_spin_box.palette().color(QPalette.ColorRole.Base).name() + + for name, node in tab_nodes.items(): + node.select() + qtbot.wait(50) + light_path = shutter.save_screenshot(main_window, qtbot, shutter.name) + light_images[name] = QImage(str(light_path)) + + try: + qapp.setPalette(_build_dark_palette(original_palette)) + qtbot.waitUntil( + lambda: ( + descriptor_edit.palette().color(QPalette.ColorRole.Base).name() != light_descriptor_base + ), + timeout=5000, + ) + qt_wallet.send_node.select() + qtbot.wait(50) + qtbot.waitUntil( + lambda: ( + current_recipient_widget() + .label_line_edit.label_edit.palette() + .color(QPalette.ColorRole.Base) + .name() + != light_label_base + ), + timeout=5000, + ) + + dark_images: dict[str, QImage] = {} + for name, node in tab_nodes.items(): + node.select() + qtbot.wait(50) + dark_path = shutter.save_screenshot(main_window, qtbot, shutter.name) + dark_images[name] = QImage(str(dark_path)) + + dark_descriptor_base = descriptor_edit.palette().color(QPalette.ColorRole.Base).name() + dark_recipient_widget = current_recipient_widget() + dark_label_base = ( + dark_recipient_widget.label_line_edit.label_edit.palette() + .color(QPalette.ColorRole.Base) + .name() + ) + dark_category_base = ( + dark_recipient_widget.label_line_edit.category_edit.palette() + .color(QPalette.ColorRole.Base) + .name() + ) + dark_fiat_base = ( + dark_recipient_widget.fiat_spin_box.palette().color(QPalette.ColorRole.Base).name() + ) + finally: + qapp.setPalette(original_palette) + qtbot.wait(10) + + for name in tab_nodes: + assert _images_differ(light_images[name], dark_images[name]), name + + assert dark_descriptor_base != light_descriptor_base + assert dark_label_base != light_label_base + assert dark_category_base != light_category_base + assert dark_fiat_base != light_fiat_base + + +def test_welcome_and_wizard_retheme_on_palette_change( + qapp: QApplication, + qtbot: QtBot, + monkeypatch, + mytest_start_time: datetime, + test_config: TestConfig, +) -> None: + frame = inspect.currentframe() + assert frame + shutter = Shutter(qtbot, name=f"{mytest_start_time.timestamp()}_{inspect.getframeinfo(frame).function}") + + shutter.create_symlink(test_config=test_config) + original_palette = QPalette(qapp.palette()) + _stub_wallet_balance_queries(monkeypatch) + + with main_window_context(test_config=test_config) as main_window: + QTest.qWaitForWindowExposed(main_window, timeout=10000) # type: ignore[arg-type] + + qtbot.waitUntil( + lambda: bool(main_window.tab_wallets.root.findNodeByWidget(main_window.welcome_screen)), + timeout=10000, + ) # type: ignore[arg-type] + welcome_node = main_window.tab_wallets.root.findNodeByWidget(main_window.welcome_screen) + assert welcome_node is not None + welcome_node.select() + qtbot.wait(50) + welcome_light_path = shutter.save_screenshot(main_window, qtbot, shutter.name) + welcome_light_image = QImage(str(welcome_light_path)) + welcome_light_background = main_window.welcome_screen.card_connect_devices.background_color.name() + + qt_protowallet = main_window.open_qtprotowallet_setup( + m_of_n=(2, 3), + wallet_id="wizard_probe", + show_tutorial=True, + ) + assert qt_protowallet is not None + assert qt_protowallet.wizard is not None + wizard = qt_protowallet.wizard + wizard.node.select() + qtbot.wait(50) + wizard_light_path = shutter.save_screenshot(main_window, qtbot, shutter.name) + wizard_light_image = QImage(str(wizard_light_path)) + + try: + qapp.setPalette(_build_dark_palette(original_palette)) + qtbot.waitUntil( + lambda: ( + main_window.welcome_screen.card_connect_devices.background_color.name() + != welcome_light_background + ), + timeout=5000, + ) + + welcome_node.select() + qtbot.wait(50) + welcome_dark_path = shutter.save_screenshot(main_window, qtbot, shutter.name) + welcome_dark_image = QImage(str(welcome_dark_path)) + + wizard.node.select() + qtbot.wait(50) + wizard_dark_path = shutter.save_screenshot(main_window, qtbot, shutter.name) + wizard_dark_image = QImage(str(wizard_dark_path)) + finally: + qapp.setPalette(original_palette) + qtbot.wait(10) + + assert _images_differ(welcome_light_image, welcome_dark_image) + assert _images_differ(wizard_light_image, wizard_dark_image) + + +def test_hist_list_category_colors_retheme_on_palette_change( + qapp: QApplication, + qtbot: QtBot, + monkeypatch, + mytest_start_time: datetime, + test_config_main_chain: TestConfig, + wallet_file: str = "bacon.wallet", +) -> None: + frame = inspect.currentframe() + assert frame + shutter = Shutter(qtbot, name=f"{mytest_start_time.timestamp()}_{inspect.getframeinfo(frame).function}") + + shutter.create_symlink(test_config=test_config_main_chain) + original_palette = QPalette(qapp.palette()) + _stub_wallet_balance_queries(monkeypatch) + light_palette = _build_light_palette() + dark_palette = _build_dark_palette(light_palette) + + with main_window_context(test_config=test_config_main_chain) as main_window: + QTest.qWaitForWindowExposed(main_window, timeout=10000) # type: ignore[arg-type] + + temp_wallet = Path(tempfile.mkdtemp()) / wallet_file + shutil.copy(str(Path("tests") / "data" / wallet_file), str(temp_wallet)) + + qt_wallet = main_window.open_wallet(str(temp_wallet)) + assert qt_wallet is not None + qt_wallet.hist_node.select() + qtbot.wait(50) + + hist_list = qt_wallet.history_list_with_toolbar.hist_list + target_row: int | None = None + + qapp.setPalette(light_palette) + qtbot.wait(50) + + for row in range(hist_list._source_model.rowCount()): + txid_item = hist_list._source_model.item(row, hist_list.Columns.TXID) + assert txid_item is not None + wallet = hist_list.get_wallet(txid=txid_item.text()) + if not wallet: + continue + fulltxdetail = wallet.get_dict_fulltxdetail().get(txid_item.text()) + if not fulltxdetail: + continue + involved_addresses = list(fulltxdetail.involved_addresses()) + if not involved_addresses: + continue + + category = "ThemeProbe" + wallet.labels.set_addr_category(involved_addresses[0], category, timestamp="now") + hist_list.update_with_filter( + UpdateFilter( + addresses=[involved_addresses[0]], + categories=[category], + reason=UpdateFilterReason.CategoryChange, + ) + ) + target_row = row + break + + assert target_row is not None + + def current_category_background() -> str: + item = hist_list._source_model.item(target_row, hist_list.Columns.CATEGORIES) + assert item is not None + return item.background().color().name() + + light_category_background = current_category_background() + + try: + qapp.setPalette(dark_palette) + qtbot.waitUntil( + lambda: current_category_background() != light_category_background, + timeout=5000, + ) + dark_category_background = current_category_background() + finally: + qapp.setPalette(original_palette) + qtbot.wait(10) + + assert dark_category_background != light_category_background diff --git a/tests/gui/qt/test_tx_signing_steps_theme.py b/tests/gui/qt/test_tx_signing_steps_theme.py new file mode 100644 index 000000000..6a1f75dba --- /dev/null +++ b/tests/gui/qt/test_tx_signing_steps_theme.py @@ -0,0 +1,110 @@ +# +# Bitcoin Safe +# Copyright (C) 2026 Andreas Griffin +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of version 3 of the GNU General Public License as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see https://www.gnu.org/licenses/gpl-3.0.html +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# + +from __future__ import annotations + +from unittest.mock import Mock + +from PyQt6.QtCore import QEvent +from PyQt6.QtGui import QColor, QPalette +from PyQt6.QtWidgets import QApplication + +from bitcoin_safe.gui.qt.tx_signing_steps import SigningDevice, TxSigningDeviceCard +from bitcoin_safe.hardware_signers import HardwareSigners + + +def test_tx_signing_device_card_refreshes_theme_dependent_ui_on_palette_change(qtbot) -> None: + app = QApplication.instance() + assert isinstance(app, QApplication) + original_palette = QPalette(app.palette()) + light_palette = QPalette(original_palette) + light_palette.setColor(QPalette.ColorRole.Window, QColor("#efefef")) + light_palette.setColor(QPalette.ColorRole.WindowText, QColor("#101010")) + light_palette.setColor(QPalette.ColorRole.Text, QColor("#101010")) + light_palette.setColor(QPalette.ColorRole.ButtonText, QColor("#101010")) + light_palette.setColor(QPalette.ColorRole.Dark, QColor("#101010")) + dark_palette = QPalette(original_palette) + dark_palette.setColor(QPalette.ColorRole.Window, QColor("#111111")) + dark_palette.setColor(QPalette.ColorRole.WindowText, QColor("#f5f5f5")) + dark_palette.setColor(QPalette.ColorRole.Text, QColor("#f5f5f5")) + dark_palette.setColor(QPalette.ColorRole.ButtonText, QColor("#f5f5f5")) + dark_palette.setColor(QPalette.ColorRole.Dark, QColor("#f5f5f5")) + + app.setPalette(light_palette) + card = TxSigningDeviceCard( + device=SigningDevice( + fingerprint="44250C36", + label="Signer 1", + hardware_signer=HardwareSigners.generic, + wallet_ids=["demo-public-regtest"], + ), + signature_importers=[], + psbt=Mock(), + network=Mock(), + wallet_functions=Mock(), + loop_in_thread=Mock(), + ) + qtbot.addWidget(card) + card.show() + qtbot.waitExposed(card) + + try: + light_background = card.background_color + assert isinstance(light_background, QColor) + light_title_color = card.header_title.palette().color(card.header_title.foregroundRole()) + light_subtitle_color = card.header_subtitle.palette().color(card.header_subtitle.foregroundRole()) + light_icon = card.header_icon.pixmap() + assert light_icon is not None and not light_icon.isNull() + light_icon_image = light_icon.toImage() + + app.setPalette(dark_palette) + QApplication.sendEvent(card, QEvent(QEvent.Type.ApplicationPaletteChange)) + qtbot.waitUntil( + lambda: ( + isinstance(card.background_color, QColor) + and card.background_color.name() != light_background.name() + ), + timeout=5000, + ) + + assert isinstance(card.background_color, QColor) + assert card.background_color.name() == "#111111" + assert card.header_title.palette().color(card.header_title.foregroundRole()).name() == "#f5f5f5" + assert card.header_subtitle.palette().color(card.header_subtitle.foregroundRole()).name() != ( + light_subtitle_color.name() + ) + + dark_icon = card.header_icon.pixmap() + assert dark_icon is not None and not dark_icon.isNull() + assert dark_icon.toImage() != light_icon_image + assert card.header_title.palette().color(card.header_title.foregroundRole()) != light_title_color + finally: + app.setPalette(original_palette) + QApplication.sendEvent(card, QEvent(QEvent.Type.ApplicationPaletteChange)) + card.close() diff --git a/tests/gui/qt/test_wallet_send.py b/tests/gui/qt/test_wallet_send.py index 87af359cf..e8ff17c2a 100644 --- a/tests/gui/qt/test_wallet_send.py +++ b/tests/gui/qt/test_wallet_send.py @@ -106,8 +106,10 @@ def _assert_card_matches_hardware_signer(card: TxSigningDeviceCard, hardware_sig ) actual_pixmap = card.header_icon.pixmap() assert actual_pixmap is not None - expected_icon = svg_tools_hardware_signer.get_QIcon(hardware_signer.icon_name) - expected_pixmap = expected_icon.pixmap(actual_pixmap.size(), card.devicePixelRatioF()) + expected_pixmap = svg_tools_hardware_signer.get_pixmap( + hardware_signer.icon_name, + size=(actual_pixmap.width(), actual_pixmap.height()), + ) assert actual_pixmap.toImage() == expected_pixmap.toImage(), ( f"expected icon {hardware_signer.icon_name}, got {card.device.hardware_signer.icon_name}" ) diff --git a/tests/gui/qt/test_wizard_support.py b/tests/gui/qt/test_wizard_support.py new file mode 100644 index 000000000..589b3a19b --- /dev/null +++ b/tests/gui/qt/test_wizard_support.py @@ -0,0 +1,63 @@ +# +# Bitcoin Safe +# Copyright (C) 2026 Andreas Griffin +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of version 3 of the GNU General Public License as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see https://www.gnu.org/licenses/gpl-3.0.html +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# + +from PyQt6.QtCore import QEvent +from pytestqt.qtbot import QtBot + +from bitcoin_safe.gui.qt.wizard.wizard_support import ThemeAwareStepWidget + + +class DummyTab: + def __init__(self) -> None: + self.is_closed = False + self.update_calls = 0 + + def updateUi(self) -> None: + self.update_calls += 1 + + +def test_theme_aware_step_widget_updates_open_tab_on_palette_change(qtbot: QtBot) -> None: + tab = DummyTab() + widget = ThemeAwareStepWidget(tab=tab) + qtbot.addWidget(widget) + + widget.changeEvent(QEvent(QEvent.Type.ApplicationPaletteChange)) + + assert tab.update_calls == 1 + + +def test_theme_aware_step_widget_skips_closed_tab_on_palette_change(qtbot: QtBot) -> None: + tab = DummyTab() + tab.is_closed = True + widget = ThemeAwareStepWidget(tab=tab) + qtbot.addWidget(widget) + + widget.changeEvent(QEvent(QEvent.Type.ApplicationPaletteChange)) + + assert tab.update_calls == 0 diff --git a/tests/non_gui/test_external_plugins.py b/tests/non_gui/test_external_plugins.py index 4f7a5ffa0..c0152cdff 100644 --- a/tests/non_gui/test_external_plugins.py +++ b/tests/non_gui/test_external_plugins.py @@ -3646,6 +3646,102 @@ def test_delete_installed_source_plugin_by_bundle_id_preserves_pending_payload( manager.close() +def test_delete_installed_source_plugin_cancel_keeps_enabled_state( + qapp: QApplication, tmp_path: Path, monkeypatch +) -> None: + del qapp + + monkeypatch.setattr( + PluginManager, + "_refresh_external_state", + classmethod(lambda cls, context, external_registry: {}), + ) + monkeypatch.setattr( + "bitcoin_safe.plugin_framework.plugin_manager.question_dialog", lambda **kwargs: False + ) + + client = _LoadTrackingPluginClient(enabled=True) + client.set_plugin_identity( + plugin_source=PluginClientSource.EXTERNAL, + plugin_bundle_id="test-plugin", + ) + manager = PluginManager( + clients=[client], + plugin_permissions={client.plugin_id: {PluginPermission.LABELS}}, + parent=None, + **_plugin_manager_init_kwargs(tmp_path), + ) + + removed_bundle_ids: list[str] = [] + + try: + monkeypatch.setattr( + manager.external_registry, + "remove_installed_plugin", + lambda bundle_id: removed_bundle_ids.append(bundle_id), + ) + + manager.delete_installed_source_plugin(client) + + assert removed_bundle_ids == [] + assert client.enabled + assert client.load_calls == 0 + assert client.unload_calls == 0 + assert manager.clients == [client] + assert client.plugin_id in manager.plugin_permissions + finally: + manager.close() + + +def test_delete_installed_source_plugin_failed_remove_restores_enabled_state( + qapp: QApplication, tmp_path: Path, monkeypatch +) -> None: + del qapp + + monkeypatch.setattr( + PluginManager, + "_refresh_external_state", + classmethod(lambda cls, context, external_registry: {}), + ) + monkeypatch.setattr("bitcoin_safe.plugin_framework.plugin_manager.question_dialog", lambda **kwargs: True) + + client = _LoadTrackingPluginClient(enabled=True) + client.set_plugin_identity( + plugin_source=PluginClientSource.EXTERNAL, + plugin_bundle_id="test-plugin", + ) + manager = PluginManager( + clients=[client], + plugin_permissions={client.plugin_id: {PluginPermission.LABELS}}, + parent=None, + **_plugin_manager_init_kwargs(tmp_path), + ) + + messages: list[str] = [] + + try: + monkeypatch.setattr( + manager.external_registry, + "remove_installed_plugin", + lambda bundle_id: (_ for _ in ()).throw(ExternalPluginError(f"boom: {bundle_id}")), + ) + monkeypatch.setattr( + "bitcoin_safe.plugin_framework.plugin_manager.Message", + lambda text, **kwargs: messages.append(text), + ) + + manager.delete_installed_source_plugin(client) + + assert messages == ["boom: test-plugin"] + assert client.enabled + assert client.load_calls == 1 + assert client.unload_calls == 1 + assert manager.clients == [client] + assert client.plugin_id in manager.plugin_permissions + finally: + manager.close() + + def test_source_catalog_item_allows_delete_for_installed_plugin(qapp: QApplication) -> None: del qapp item = SourceCatalogItem( diff --git a/tests/test_main_startup_args.py b/tests/test_main_startup_args.py index 6d91c4766..b73659952 100644 --- a/tests/test_main_startup_args.py +++ b/tests/test_main_startup_args.py @@ -89,12 +89,16 @@ def test_main_uses_sanitized_network() -> None: with ( patch.object(app_main, "QApplication", return_value=app), patch.object(app_main, "check_compatibility"), - patch.object(app_main, "is_gnome_dark_mode", return_value=False), - patch.object(app_main, "set_dark_palette"), + patch.object(app_main.UserConfig, "exists", return_value=False), + patch.object(app_main, "apply_theme_mode"), patch.object(app_main, "MainWindow", return_value=window) as main_window_cls, ): app_main.main(startup_args) - main_window_cls.assert_called_once_with(network=None, open_files_at_startup=["wallet.psbt"]) + main_window_cls.assert_called_once_with( + network=None, + config=None, + open_files_at_startup=["wallet.psbt"], + ) window.show.assert_called_once() app.exec.assert_called_once() diff --git a/tests/test_theme.py b/tests/test_theme.py new file mode 100644 index 000000000..a48c5d40f --- /dev/null +++ b/tests/test_theme.py @@ -0,0 +1,75 @@ +# +# Bitcoin Safe +# Copyright (C) 2026 Andreas Griffin +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of version 3 of the GNU General Public License as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see https://www.gnu.org/licenses/gpl-3.0.html +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# + +from __future__ import annotations + +from PyQt6.QtGui import QColor, QPalette +from PyQt6.QtWidgets import QApplication + +import bitcoin_safe.theme as theme + + +def test_apply_theme_mode_system_clears_app_palette_override(qtbot) -> None: + del qtbot + app = QApplication.instance() + assert isinstance(app, QApplication) + original_palette = QPalette(app.palette()) + custom_palette = QPalette(original_palette) + custom_palette.setColor(QPalette.ColorRole.Window, QColor("#123456")) + app.setPalette(custom_palette) + + try: + theme.apply_theme_mode(app, theme.ThemeMode.SYSTEM) + assert app.palette().color(QPalette.ColorRole.Window) == original_palette.color( + QPalette.ColorRole.Window + ) + finally: + app.setPalette(original_palette) + + +def test_apply_theme_mode_light_overrides_dark_app_palette(qtbot) -> None: + del qtbot + app = QApplication.instance() + assert isinstance(app, QApplication) + original_palette = QPalette(app.palette()) + dark_palette = QPalette(original_palette) + dark_palette.setColor(QPalette.ColorRole.Window, QColor("#111111")) + dark_palette.setColor(QPalette.ColorRole.WindowText, QColor("#f5f5f5")) + dark_palette.setColor(QPalette.ColorRole.Base, QColor("#181818")) + dark_palette.setColor(QPalette.ColorRole.Text, QColor("#f5f5f5")) + app.setPalette(dark_palette) + + try: + theme.apply_theme_mode(app, theme.ThemeMode.LIGHT) + assert app.palette().color(QPalette.ColorRole.Window).name() == "#efefef" + assert app.palette().color(QPalette.ColorRole.WindowText).name() == "#000000" + assert app.palette().color(QPalette.ColorRole.Base).name() == "#ffffff" + assert app.palette().color(QPalette.ColorRole.Text).name() == "#000000" + finally: + app.setPalette(original_palette)