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)