From e69979bf7f6d229187ae13dced15b049205319fb Mon Sep 17 00:00:00 2001 From: kynguyen Date: Fri, 1 Aug 2025 04:01:18 -0400 Subject: [PATCH 01/13] Add qt's stylehints to theme.py --- picard/ui/theme.py | 95 ++++++--- test/test_theme.py | 31 +-- test/test_theme_stylehints.py | 358 ++++++++++++++++++++++++++++++++++ 3 files changed, 448 insertions(+), 36 deletions(-) create mode 100644 test/test_theme_stylehints.py diff --git a/picard/ui/theme.py b/picard/ui/theme.py index f528db9d7f..69293db880 100644 --- a/picard/ui/theme.py +++ b/picard/ui/theme.py @@ -96,11 +96,18 @@ def _missing_(cls, value): return cls.DEFAULT -AVAILABLE_UI_THEMES = [UiTheme.DEFAULT] -if IS_WIN or IS_MACOS: - AVAILABLE_UI_THEMES.extend([UiTheme.LIGHT, UiTheme.DARK]) -elif not IS_HAIKU: - AVAILABLE_UI_THEMES.extend([UiTheme.SYSTEM]) +# Theme availability based on platform capabilities +if IS_HAIKU: + # Haiku doesn't support themes - UI is hidden anyway, but keep empty for consistency + # Platform Detection: `IS_HAIKU` is detected via sys.platform == 'haiku1' + # Feature Flag: `OS_SUPPORTS_THEMES` is set to False for Haiku + # Empty Options: `AVAILABLE_UI_THEMES`` is set to an empty list [] for Haiku + # UI Hiding: The ui_theme_container widget is hidden when `OS_SUPPORTS_THEMES` is False (see `interface.py`) + AVAILABLE_UI_THEMES = [] +else: + # All other platforms: consistent structure + # It is now safe to add these to linux since `QtGui.QGuiApplication.styleHints().setColorScheme()` is available + AVAILABLE_UI_THEMES = [UiTheme.DEFAULT, UiTheme.LIGHT, UiTheme.DARK] class MacOverrideStyle(QtWidgets.QProxyStyle): @@ -118,11 +125,51 @@ def styleHint(self, hint, option, widget, returnData): class BaseTheme: def __init__(self): - self._dark_theme = False self._loaded_config_theme = UiTheme.DEFAULT + self._dark_theme = False + self._accent_color = None # Registry of dark mode detection strategies for Linux DEs self._dark_mode_strategies = get_linux_dark_mode_strategies() + def _apply_dark_palette_colors(self, palette): + """Apply dark palette colors to the given palette.""" + for key, value in DARK_PALETTE_COLORS.items(): + if isinstance(key, tuple): + group, role = key + palette.setColor(group, role, value) + else: + palette.setColor(key, value) + + def _get_style_hints(self) -> QtGui.QStyleHints | None: + """Get style hints from QGuiApplication, returning None if unavailable.""" + return QtGui.QGuiApplication.styleHints() + + def _set_color_scheme(self, color_scheme: QtCore.Qt.ColorScheme): + """Set the color scheme using style hints if available. + + Args: + color_scheme: The Qt color scheme to set + """ + style_hints = self._get_style_hints() + if style_hints is not None: + style_hints.setColorScheme(color_scheme) + + def _apply_dark_theme_to_palette(self, palette: QtGui.QPalette): + """Apply dark theme colors to the given palette using Qt's color scheme or manual fallback. + + This method tries to use Qt's built-in color scheme first, and falls back to + manually applying dark colors if style hints are unavailable. + + Args: + palette: The palette to apply dark colors to + """ + style_hints = self._get_style_hints() + if style_hints is not None: + style_hints.setColorScheme(QtCore.Qt.ColorScheme.Dark) + else: + # Fall back to manually applying dark colors + self._apply_dark_palette_colors(palette) + def _detect_linux_dark_mode(self) -> bool: # Iterate through all registered strategies for strategy in self._dark_mode_strategies: @@ -137,8 +184,8 @@ def setup(self, app): self._loaded_config_theme = UiTheme(config.setting['ui_theme']) # Use the new fusion style from PyQt6 for a modern and consistent look - # across all OSes. - if not IS_MACOS and not IS_HAIKU and self._loaded_config_theme != UiTheme.SYSTEM: + # across all OSes, except when using system default theme on Linux. + if not IS_MACOS and not IS_HAIKU and not (not IS_WIN and self._loaded_config_theme == UiTheme.DEFAULT): app.setStyle('Fusion') elif IS_MACOS: app.setStyle(MacOverrideStyle(app.style())) @@ -147,6 +194,17 @@ def setup(self, app): 'QGroupBox::title { /* PICARD-1206, Qt bug workaround */ }', ) + # Set color scheme based on theme configuration + style_hints = self._get_style_hints() + if style_hints is not None: + if self._loaded_config_theme == UiTheme.DARK: + self._set_color_scheme(QtCore.Qt.ColorScheme.Dark) + elif self._loaded_config_theme == UiTheme.LIGHT: + self._set_color_scheme(QtCore.Qt.ColorScheme.Light) + else: + # For DEFAULT and SYSTEM themes, let Qt follow system settings + self._set_color_scheme(QtCore.Qt.ColorScheme.Unknown) + palette = QtGui.QPalette(app.palette()) base_color = palette.color(QtGui.QPalette.ColorGroup.Active, QtGui.QPalette.ColorRole.Base) self._dark_theme = base_color.lightness() < 128 @@ -154,7 +212,7 @@ def setup(self, app): if self._dark_theme: self._accent_color = palette.color(QtGui.QPalette.ColorGroup.Active, QtGui.QPalette.ColorRole.Highlight) - # Linux-specific: If SYSTEM theme, try to detect system dark mode + # Linux-specific: If DEFAULT theme, try to detect system dark mode # Do not apply override if already dark theme is_dark_theme = self.is_dark_theme if ( @@ -162,17 +220,12 @@ def setup(self, app): and not IS_WIN and not IS_MACOS and not IS_HAIKU - and self._loaded_config_theme == UiTheme.SYSTEM + and self._loaded_config_theme == UiTheme.DEFAULT ): is_dark_theme = self._detect_linux_dark_mode() if is_dark_theme: - # Apply a dark palette centrally defined - for key, value in DARK_PALETTE_COLORS.items(): - if isinstance(key, tuple): - group, role = key - palette.setColor(group, role, value) - else: - palette.setColor(key, value) + # Apply dark theme to palette using Qt's color scheme or manual fallback + self._apply_dark_theme_to_palette(palette) self._dark_theme = True self._accent_color = palette.color(QtGui.QPalette.ColorGroup.Active, QtGui.QPalette.ColorRole.Highlight) else: @@ -264,12 +317,8 @@ def update_palette(self, palette, dark_theme, accent_color): # Adapt to Windows 10 color scheme (dark / light theme and accent color) super().update_palette(palette, dark_theme, accent_color) if dark_theme: - for key, value in DARK_PALETTE_COLORS.items(): - if isinstance(key, tuple): - group, role = key - palette.setColor(group, role, value) - else: - palette.setColor(key, value) + # Apply dark theme to palette using Qt's color scheme or manual fallback + self._apply_dark_theme_to_palette(palette) if IS_WIN: diff --git a/test/test_theme.py b/test/test_theme.py index 552260c381..0a717d0211 100644 --- a/test/test_theme.py +++ b/test/test_theme.py @@ -396,9 +396,9 @@ def assert_palette_matches_expected(palette, expected_colors): ) if isinstance(expected, QtGui.QColor): # Compare by value, not object identity - assert actual.getRgb() == expected.getRgb(), ( - f"Color for {key} should be {expected.getRgb()}, got {actual.getRgb()}" - ) + assert ( + actual.getRgb() == expected.getRgb() + ), f"Color for {key} should be {expected.getRgb()}, got {actual.getRgb()}" else: assert actual == QtGui.QColor(expected), f"Color for {key} should be {expected}, got {actual}" @@ -421,13 +421,13 @@ def assert_palette_not_dark(palette, expected_colors): continue actual = palette.color(QtGui.QPalette.ColorGroup.Active, key) if isinstance(expected, QtGui.QColor): - assert actual.getRgb() != expected.getRgb(), ( - f"Color for {key} should differ from dark mode in light mode; got {actual.getRgb()}" - ) + assert ( + actual.getRgb() != expected.getRgb() + ), f"Color for {key} should differ from dark mode in light mode; got {actual.getRgb()}" else: - assert actual != QtGui.QColor(expected), ( - f"Color for {key} should differ from dark mode in light mode; got {actual}" - ) + assert actual != QtGui.QColor( + expected + ), f"Color for {key} should differ from dark mode in light mode; got {actual}" @pytest.mark.parametrize( @@ -446,12 +446,15 @@ def test_linux_dark_theme_palette(monkeypatch, already_dark_theme, dark_mode, ex monkeypatch.setattr(theme_mod, "IS_HAIKU", False) # Set config to SYSTEM config_mock = MagicMock() - config_mock.setting = {"ui_theme": "system"} + config_mock.setting = {"ui_theme": "default"} monkeypatch.setattr(theme_mod, "get_config", lambda: config_mock) # Patch _detect_linux_dark_mode to return dark_mode theme = theme_mod.BaseTheme() theme._detect_linux_dark_mode = lambda: dark_mode + # Mock QGuiApplication.styleHints() to return None to force manual fallback + monkeypatch.setattr(QtGui.QGuiApplication, "styleHints", lambda: None) + # Mock app and palette app = DummyApp(already_dark_theme) theme.setup(app) @@ -511,7 +514,8 @@ def queryvalueex_side_effect(key, value): monkeypatch.setattr(theme_mod, "get_config", lambda: config_mock) # Instantiate WindowsTheme and run setup theme = theme_mod.WindowsTheme() - + # Force manual fallback for palette changes + monkeypatch.setattr(QtGui.QGuiApplication, "styleHints", lambda: None) app = DummyApp() theme.setup(app) palette = app._palette @@ -622,9 +626,10 @@ def test_linux_dark_palette_override_only_if_not_already_dark( monkeypatch.setattr(theme_mod, "IS_MACOS", False) monkeypatch.setattr(theme_mod, "IS_HAIKU", False) config_mock = MagicMock() - config_mock.setting = {"ui_theme": "system"} + config_mock.setting = {"ui_theme": "default"} monkeypatch.setattr(theme_mod, "get_config", lambda: config_mock) - + # Force manual fallback for palette changes + monkeypatch.setattr(QtGui.QGuiApplication, "styleHints", lambda: None) app = DummyApp(already_dark_theme) theme = theme_mod.BaseTheme() theme._detect_linux_dark_mode = lambda: linux_dark_mode_detected diff --git a/test/test_theme_stylehints.py b/test/test_theme_stylehints.py new file mode 100644 index 0000000000..6fe1b50c47 --- /dev/null +++ b/test/test_theme_stylehints.py @@ -0,0 +1,358 @@ +# -*- coding: utf-8 -*- +# +# Picard, the next-generation MusicBrainz tagger +# +# Copyright (C) 2019-2022, 2024-2025 Philipp Wolfer +# Copyright (C) 2020-2021 Gabriel Ferreira +# Copyright (C) 2021-2024 Laurent Monin +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the GNU General Public License version 2 +# of the License, or (at your option) any later version. +# +# 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, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +from unittest.mock import MagicMock, patch + +from PyQt6 import QtCore, QtGui + +import pytest + +import picard.ui.theme as theme_mod + + +@pytest.fixture +def base_theme(): + """Create a BaseTheme instance for testing.""" + return theme_mod.BaseTheme() + + +@pytest.fixture +def mock_style_hints(): + """Create a mock QStyleHints object.""" + mock_hints = MagicMock() + mock_hints.setColorScheme = MagicMock() + return mock_hints + + +@pytest.fixture +def mock_palette(): + """Create a mock QPalette object.""" + palette = QtGui.QPalette() + # Set some initial colors to verify changes + palette.setColor(QtGui.QPalette.ColorRole.Window, QtGui.QColor(255, 255, 255)) + palette.setColor(QtGui.QPalette.ColorRole.WindowText, QtGui.QColor(0, 0, 0)) + return palette + + +@pytest.fixture +def mock_app(): + """Create a mock app for testing.""" + app = MagicMock() + app.palette.return_value = QtGui.QPalette() + return app + + +class TestStyleHintsMethods: + """Test the new style hints related methods.""" + + def test_get_style_hints_returns_style_hints(self, base_theme, mock_style_hints): + """Test _get_style_hints returns QGuiApplication.styleHints().""" + with patch('picard.ui.theme.QtGui.QGuiApplication.styleHints', return_value=mock_style_hints): + result = base_theme._get_style_hints() + assert result is mock_style_hints + + def test_get_style_hints_returns_none_when_unavailable(self, base_theme): + """Test _get_style_hints returns None when styleHints() is unavailable.""" + with patch('picard.ui.theme.QtGui.QGuiApplication.styleHints', return_value=None): + result = base_theme._get_style_hints() + assert result is None + + def test_set_color_scheme_with_style_hints(self, base_theme, mock_style_hints): + """Test _set_color_scheme calls setColorScheme when style hints available.""" + with patch.object(base_theme, '_get_style_hints', return_value=mock_style_hints): + base_theme._set_color_scheme(QtCore.Qt.ColorScheme.Dark) + mock_style_hints.setColorScheme.assert_called_once_with(QtCore.Qt.ColorScheme.Dark) + + def test_set_color_scheme_without_style_hints(self, base_theme): + """Test _set_color_scheme does nothing when style hints unavailable.""" + with patch.object(base_theme, '_get_style_hints', return_value=None): + # Should not raise any exception + base_theme._set_color_scheme(QtCore.Qt.ColorScheme.Dark) + + @pytest.mark.parametrize( + "color_scheme", + [ + QtCore.Qt.ColorScheme.Dark, + QtCore.Qt.ColorScheme.Light, + QtCore.Qt.ColorScheme.Unknown, + ], + ) + def test_set_color_scheme_with_different_schemes(self, base_theme, mock_style_hints, color_scheme): + """Test _set_color_scheme works with different color schemes.""" + with patch.object(base_theme, '_get_style_hints', return_value=mock_style_hints): + base_theme._set_color_scheme(color_scheme) + mock_style_hints.setColorScheme.assert_called_once_with(color_scheme) + + +class TestApplyDarkPaletteColors: + """Test the _apply_dark_palette_colors method.""" + + def test_apply_dark_palette_colors_applies_all_colors(self, base_theme, mock_palette): + """Test _apply_dark_palette_colors applies all expected colors.""" + base_theme._apply_dark_palette_colors(mock_palette) + + # Check that the palette colors were changed to dark theme colors + window_color = mock_palette.color(QtGui.QPalette.ColorRole.Window) + assert window_color.getRgb() == (51, 51, 51, 255) + + window_text_color = mock_palette.color(QtGui.QPalette.ColorRole.WindowText) + assert window_text_color == QtCore.Qt.GlobalColor.white + + base_color = mock_palette.color(QtGui.QPalette.ColorRole.Base) + assert base_color.getRgb() == (31, 31, 31, 255) + + def test_apply_dark_palette_colors_applies_tuple_keys(self, base_theme, mock_palette): + """Test _apply_dark_palette_colors correctly handles tuple keys (group, role).""" + base_theme._apply_dark_palette_colors(mock_palette) + + # Check disabled text color (tuple key) + disabled_text_color = mock_palette.color(QtGui.QPalette.ColorGroup.Disabled, QtGui.QPalette.ColorRole.Text) + assert disabled_text_color == QtCore.Qt.GlobalColor.darkGray + + # Check disabled base color (tuple key) + disabled_base_color = mock_palette.color(QtGui.QPalette.ColorGroup.Disabled, QtGui.QPalette.ColorRole.Base) + assert disabled_base_color.getRgb() == (60, 60, 60, 255) + + +class TestApplyDarkThemeToPalette: + """Test the _apply_dark_theme_to_palette method.""" + + def test_apply_dark_theme_to_palette_with_style_hints(self, base_theme, mock_palette, mock_style_hints): + """Test _apply_dark_theme_to_palette uses style hints when available.""" + with patch.object(base_theme, '_get_style_hints', return_value=mock_style_hints): + base_theme._apply_dark_theme_to_palette(mock_palette) + mock_style_hints.setColorScheme.assert_called_once_with(QtCore.Qt.ColorScheme.Dark) + + def test_apply_dark_theme_to_palette_without_style_hints(self, base_theme, mock_palette): + """Test _apply_dark_theme_to_palette falls back to manual colors when style hints unavailable.""" + with patch.object(base_theme, '_get_style_hints', return_value=None): + base_theme._apply_dark_theme_to_palette(mock_palette) + + # Should have applied manual dark colors + window_color = mock_palette.color(QtGui.QPalette.ColorRole.Window) + assert window_color.getRgb() == (51, 51, 51, 255) + + def test_apply_dark_theme_to_palette_calls_manual_fallback(self, base_theme, mock_palette): + """Test _apply_dark_theme_to_palette calls _apply_dark_palette_colors when style hints unavailable.""" + with ( + patch.object(base_theme, '_get_style_hints', return_value=None), + patch.object(base_theme, '_apply_dark_palette_colors') as mock_apply, + ): + base_theme._apply_dark_theme_to_palette(mock_palette) + mock_apply.assert_called_once_with(mock_palette) + + +class TestThemeAvailability: + """Test the updated theme availability logic.""" + + def test_available_ui_themes_includes_all_themes_except_haiku(self): + """Test AVAILABLE_UI_THEMES includes DEFAULT, LIGHT, and DARK themes.""" + # The current implementation includes all themes for all platforms except Haiku + expected_themes = [theme_mod.UiTheme.DEFAULT, theme_mod.UiTheme.LIGHT, theme_mod.UiTheme.DARK] + + # Check that all expected themes are present + for theme in expected_themes: + assert theme in theme_mod.AVAILABLE_UI_THEMES + + # Check that we have exactly the expected themes (for non-Haiku platforms) + if not theme_mod.IS_HAIKU: + assert len(theme_mod.AVAILABLE_UI_THEMES) == len(expected_themes) + for theme in theme_mod.AVAILABLE_UI_THEMES: + assert theme in expected_themes + + +class TestSetupColorScheme: + """Test the color scheme setting logic in setup method.""" + + @pytest.mark.parametrize( + "theme_value,expected_color_scheme", + [ + ("dark", QtCore.Qt.ColorScheme.Dark), + ("light", QtCore.Qt.ColorScheme.Light), + ("default", QtCore.Qt.ColorScheme.Unknown), + ("system", QtCore.Qt.ColorScheme.Unknown), + ], + ) + def test_setup_sets_color_scheme_based_on_theme( + self, base_theme, mock_app, mock_style_hints, theme_value, expected_color_scheme + ): + """Test setup method sets color scheme based on theme configuration.""" + # Mock config + config_mock = MagicMock() + config_mock.setting = {"ui_theme": theme_value} + + with ( + patch.object(theme_mod, "get_config", return_value=config_mock), + patch.object(base_theme, '_get_style_hints', return_value=mock_style_hints), + ): + base_theme.setup(mock_app) + mock_style_hints.setColorScheme.assert_called_once_with(expected_color_scheme) + + def test_setup_handles_no_style_hints(self, base_theme, mock_app): + """Test setup method handles case when style hints are unavailable.""" + # Mock config + config_mock = MagicMock() + config_mock.setting = {"ui_theme": "dark"} + + with ( + patch.object(theme_mod, "get_config", return_value=config_mock), + patch.object(base_theme, '_get_style_hints', return_value=None), + ): + # Should not raise any exception + base_theme.setup(mock_app) + + +class TestWindowsTheme: + """Test the WindowsTheme class updates.""" + + def test_windows_theme_apply_dark_theme_to_palette(self, mock_palette): + """Test WindowsTheme uses _apply_dark_theme_to_palette in update_palette.""" + theme = theme_mod.WindowsTheme() + + with patch.object(theme, '_apply_dark_theme_to_palette') as mock_apply: + theme.update_palette(mock_palette, True, None) + mock_apply.assert_called_once_with(mock_palette) + + def test_windows_theme_does_not_apply_dark_theme_when_not_dark(self, mock_palette): + """Test WindowsTheme does not apply dark theme when dark_theme is False.""" + theme = theme_mod.WindowsTheme() + + with patch.object(theme, '_apply_dark_theme_to_palette') as mock_apply: + theme.update_palette(mock_palette, False, None) + mock_apply.assert_not_called() + + +class TestLinuxDarkModeDetection: + """Test the Linux dark mode detection logic.""" + + @pytest.fixture + def linux_theme(self, monkeypatch): + """Create a BaseTheme instance with Linux platform settings.""" + monkeypatch.setattr(theme_mod, "IS_WIN", False) + monkeypatch.setattr(theme_mod, "IS_MACOS", False) + monkeypatch.setattr(theme_mod, "IS_HAIKU", False) + return theme_mod.BaseTheme() + + @pytest.mark.parametrize( + "config_theme,detect_result,expected_apply_called", + [ + ("default", True, True), # Should apply dark theme + ("default", False, False), # Should not apply dark theme + ("dark", True, False), # Should not apply (already dark) + ("light", True, False), # Should not apply (explicit light) + ("system", True, False), # Should not apply (system theme) + ], + ) + def test_linux_dark_mode_detection_logic( + self, linux_theme, mock_app, config_theme, detect_result, expected_apply_called + ): + """Test Linux dark mode detection logic in setup method.""" + # Mock config + config_mock = MagicMock() + config_mock.setting = {"ui_theme": config_theme} + + # Mock palette with light base color (not already dark) + palette = QtGui.QPalette() + palette.setColor(QtGui.QPalette.ColorRole.Base, QtGui.QColor(255, 255, 255)) + mock_app.palette.return_value = palette + + with ( + patch.object(theme_mod, "get_config", return_value=config_mock), + patch.object(linux_theme, '_detect_linux_dark_mode', return_value=detect_result), + patch.object(linux_theme, '_apply_dark_theme_to_palette') as mock_apply, + patch.object(linux_theme, '_get_style_hints', return_value=None), + ): + linux_theme.setup(mock_app) + + if expected_apply_called: + mock_apply.assert_called_once() + else: + mock_apply.assert_not_called() + + def test_linux_dark_mode_not_applied_when_already_dark(self, linux_theme, mock_app): + """Test Linux dark mode is not applied when palette is already dark.""" + # Mock config + config_mock = MagicMock() + config_mock.setting = {"ui_theme": "default"} + + # Mock palette with dark base color (already dark) + palette = QtGui.QPalette() + palette.setColor(QtGui.QPalette.ColorRole.Base, QtGui.QColor(0, 0, 0)) + mock_app.palette.return_value = palette + + with ( + patch.object(theme_mod, "get_config", return_value=config_mock), + patch.object(linux_theme, '_detect_linux_dark_mode', return_value=True), + patch.object(linux_theme, '_apply_dark_theme_to_palette') as mock_apply, + patch.object(linux_theme, '_get_style_hints', return_value=None), + ): + linux_theme.setup(mock_app) + mock_apply.assert_not_called() + + +class TestIntegration: + """Integration tests for the new functionality.""" + + def test_style_hints_integration_with_real_palette(self, base_theme): + """Test integration of style hints with real palette objects.""" + palette = QtGui.QPalette() + original_window_color = palette.color(QtGui.QPalette.ColorRole.Window) + + # Test with style hints available + mock_hints = MagicMock() + with patch.object(base_theme, '_get_style_hints', return_value=mock_hints): + base_theme._apply_dark_theme_to_palette(palette) + mock_hints.setColorScheme.assert_called_once_with(QtCore.Qt.ColorScheme.Dark) + # Palette should not be modified when using style hints + assert palette.color(QtGui.QPalette.ColorRole.Window) == original_window_color + + def test_manual_fallback_integration(self, base_theme): + """Test integration of manual fallback with real palette objects.""" + palette = QtGui.QPalette() + original_window_color = palette.color(QtGui.QPalette.ColorRole.Window) + + # Test without style hints (manual fallback) + with patch.object(base_theme, '_get_style_hints', return_value=None): + base_theme._apply_dark_theme_to_palette(palette) + # Palette should be modified with dark colors + new_window_color = palette.color(QtGui.QPalette.ColorRole.Window) + assert new_window_color.getRgb() == (51, 51, 51, 255) + assert new_window_color != original_window_color + + def test_theme_setup_integration(self, mock_app): + """Test complete theme setup integration.""" + theme = theme_mod.BaseTheme() + + # Mock all dependencies + config_mock = MagicMock() + config_mock.setting = {"ui_theme": "dark"} + + mock_hints = MagicMock() + + with ( + patch.object(theme_mod, "get_config", return_value=config_mock), + patch.object(theme, '_get_style_hints', return_value=mock_hints), + ): + theme.setup(mock_app) + + # Verify style hints were used + mock_hints.setColorScheme.assert_called_once_with(QtCore.Qt.ColorScheme.Dark) From 89a93bb41191c41afb68ee6cd58a633b2335b8db Mon Sep 17 00:00:00 2001 From: kynguyen Date: Fri, 1 Aug 2025 04:10:00 -0400 Subject: [PATCH 02/13] Clean up formatting --- test/test_theme_stylehints.py | 48 +++++++++++++++++------------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/test/test_theme_stylehints.py b/test/test_theme_stylehints.py index 6fe1b50c47..13b214e135 100644 --- a/test/test_theme_stylehints.py +++ b/test/test_theme_stylehints.py @@ -66,25 +66,25 @@ class TestStyleHintsMethods: def test_get_style_hints_returns_style_hints(self, base_theme, mock_style_hints): """Test _get_style_hints returns QGuiApplication.styleHints().""" - with patch('picard.ui.theme.QtGui.QGuiApplication.styleHints', return_value=mock_style_hints): + with patch("picard.ui.theme.QtGui.QGuiApplication.styleHints", return_value=mock_style_hints): result = base_theme._get_style_hints() assert result is mock_style_hints def test_get_style_hints_returns_none_when_unavailable(self, base_theme): """Test _get_style_hints returns None when styleHints() is unavailable.""" - with patch('picard.ui.theme.QtGui.QGuiApplication.styleHints', return_value=None): + with patch("picard.ui.theme.QtGui.QGuiApplication.styleHints", return_value=None): result = base_theme._get_style_hints() assert result is None def test_set_color_scheme_with_style_hints(self, base_theme, mock_style_hints): """Test _set_color_scheme calls setColorScheme when style hints available.""" - with patch.object(base_theme, '_get_style_hints', return_value=mock_style_hints): + with patch.object(base_theme, "_get_style_hints", return_value=mock_style_hints): base_theme._set_color_scheme(QtCore.Qt.ColorScheme.Dark) mock_style_hints.setColorScheme.assert_called_once_with(QtCore.Qt.ColorScheme.Dark) def test_set_color_scheme_without_style_hints(self, base_theme): """Test _set_color_scheme does nothing when style hints unavailable.""" - with patch.object(base_theme, '_get_style_hints', return_value=None): + with patch.object(base_theme, "_get_style_hints", return_value=None): # Should not raise any exception base_theme._set_color_scheme(QtCore.Qt.ColorScheme.Dark) @@ -98,7 +98,7 @@ def test_set_color_scheme_without_style_hints(self, base_theme): ) def test_set_color_scheme_with_different_schemes(self, base_theme, mock_style_hints, color_scheme): """Test _set_color_scheme works with different color schemes.""" - with patch.object(base_theme, '_get_style_hints', return_value=mock_style_hints): + with patch.object(base_theme, "_get_style_hints", return_value=mock_style_hints): base_theme._set_color_scheme(color_scheme) mock_style_hints.setColorScheme.assert_called_once_with(color_scheme) @@ -138,13 +138,13 @@ class TestApplyDarkThemeToPalette: def test_apply_dark_theme_to_palette_with_style_hints(self, base_theme, mock_palette, mock_style_hints): """Test _apply_dark_theme_to_palette uses style hints when available.""" - with patch.object(base_theme, '_get_style_hints', return_value=mock_style_hints): + with patch.object(base_theme, "_get_style_hints", return_value=mock_style_hints): base_theme._apply_dark_theme_to_palette(mock_palette) mock_style_hints.setColorScheme.assert_called_once_with(QtCore.Qt.ColorScheme.Dark) def test_apply_dark_theme_to_palette_without_style_hints(self, base_theme, mock_palette): """Test _apply_dark_theme_to_palette falls back to manual colors when style hints unavailable.""" - with patch.object(base_theme, '_get_style_hints', return_value=None): + with patch.object(base_theme, "_get_style_hints", return_value=None): base_theme._apply_dark_theme_to_palette(mock_palette) # Should have applied manual dark colors @@ -154,8 +154,8 @@ def test_apply_dark_theme_to_palette_without_style_hints(self, base_theme, mock_ def test_apply_dark_theme_to_palette_calls_manual_fallback(self, base_theme, mock_palette): """Test _apply_dark_theme_to_palette calls _apply_dark_palette_colors when style hints unavailable.""" with ( - patch.object(base_theme, '_get_style_hints', return_value=None), - patch.object(base_theme, '_apply_dark_palette_colors') as mock_apply, + patch.object(base_theme, "_get_style_hints", return_value=None), + patch.object(base_theme, "_apply_dark_palette_colors") as mock_apply, ): base_theme._apply_dark_theme_to_palette(mock_palette) mock_apply.assert_called_once_with(mock_palette) @@ -184,7 +184,7 @@ class TestSetupColorScheme: """Test the color scheme setting logic in setup method.""" @pytest.mark.parametrize( - "theme_value,expected_color_scheme", + ("theme_value", "expected_color_scheme"), [ ("dark", QtCore.Qt.ColorScheme.Dark), ("light", QtCore.Qt.ColorScheme.Light), @@ -202,7 +202,7 @@ def test_setup_sets_color_scheme_based_on_theme( with ( patch.object(theme_mod, "get_config", return_value=config_mock), - patch.object(base_theme, '_get_style_hints', return_value=mock_style_hints), + patch.object(base_theme, "_get_style_hints", return_value=mock_style_hints), ): base_theme.setup(mock_app) mock_style_hints.setColorScheme.assert_called_once_with(expected_color_scheme) @@ -215,7 +215,7 @@ def test_setup_handles_no_style_hints(self, base_theme, mock_app): with ( patch.object(theme_mod, "get_config", return_value=config_mock), - patch.object(base_theme, '_get_style_hints', return_value=None), + patch.object(base_theme, "_get_style_hints", return_value=None), ): # Should not raise any exception base_theme.setup(mock_app) @@ -228,7 +228,7 @@ def test_windows_theme_apply_dark_theme_to_palette(self, mock_palette): """Test WindowsTheme uses _apply_dark_theme_to_palette in update_palette.""" theme = theme_mod.WindowsTheme() - with patch.object(theme, '_apply_dark_theme_to_palette') as mock_apply: + with patch.object(theme, "_apply_dark_theme_to_palette") as mock_apply: theme.update_palette(mock_palette, True, None) mock_apply.assert_called_once_with(mock_palette) @@ -236,7 +236,7 @@ def test_windows_theme_does_not_apply_dark_theme_when_not_dark(self, mock_palett """Test WindowsTheme does not apply dark theme when dark_theme is False.""" theme = theme_mod.WindowsTheme() - with patch.object(theme, '_apply_dark_theme_to_palette') as mock_apply: + with patch.object(theme, "_apply_dark_theme_to_palette") as mock_apply: theme.update_palette(mock_palette, False, None) mock_apply.assert_not_called() @@ -253,7 +253,7 @@ def linux_theme(self, monkeypatch): return theme_mod.BaseTheme() @pytest.mark.parametrize( - "config_theme,detect_result,expected_apply_called", + ("config_theme", "detect_result", "expected_apply_called"), [ ("default", True, True), # Should apply dark theme ("default", False, False), # Should not apply dark theme @@ -277,9 +277,9 @@ def test_linux_dark_mode_detection_logic( with ( patch.object(theme_mod, "get_config", return_value=config_mock), - patch.object(linux_theme, '_detect_linux_dark_mode', return_value=detect_result), - patch.object(linux_theme, '_apply_dark_theme_to_palette') as mock_apply, - patch.object(linux_theme, '_get_style_hints', return_value=None), + patch.object(linux_theme, "_detect_linux_dark_mode", return_value=detect_result), + patch.object(linux_theme, "_apply_dark_theme_to_palette") as mock_apply, + patch.object(linux_theme, "_get_style_hints", return_value=None), ): linux_theme.setup(mock_app) @@ -301,9 +301,9 @@ def test_linux_dark_mode_not_applied_when_already_dark(self, linux_theme, mock_a with ( patch.object(theme_mod, "get_config", return_value=config_mock), - patch.object(linux_theme, '_detect_linux_dark_mode', return_value=True), - patch.object(linux_theme, '_apply_dark_theme_to_palette') as mock_apply, - patch.object(linux_theme, '_get_style_hints', return_value=None), + patch.object(linux_theme, "_detect_linux_dark_mode", return_value=True), + patch.object(linux_theme, "_apply_dark_theme_to_palette") as mock_apply, + patch.object(linux_theme, "_get_style_hints", return_value=None), ): linux_theme.setup(mock_app) mock_apply.assert_not_called() @@ -319,7 +319,7 @@ def test_style_hints_integration_with_real_palette(self, base_theme): # Test with style hints available mock_hints = MagicMock() - with patch.object(base_theme, '_get_style_hints', return_value=mock_hints): + with patch.object(base_theme, "_get_style_hints", return_value=mock_hints): base_theme._apply_dark_theme_to_palette(palette) mock_hints.setColorScheme.assert_called_once_with(QtCore.Qt.ColorScheme.Dark) # Palette should not be modified when using style hints @@ -331,7 +331,7 @@ def test_manual_fallback_integration(self, base_theme): original_window_color = palette.color(QtGui.QPalette.ColorRole.Window) # Test without style hints (manual fallback) - with patch.object(base_theme, '_get_style_hints', return_value=None): + with patch.object(base_theme, "_get_style_hints", return_value=None): base_theme._apply_dark_theme_to_palette(palette) # Palette should be modified with dark colors new_window_color = palette.color(QtGui.QPalette.ColorRole.Window) @@ -350,7 +350,7 @@ def test_theme_setup_integration(self, mock_app): with ( patch.object(theme_mod, "get_config", return_value=config_mock), - patch.object(theme, '_get_style_hints', return_value=mock_hints), + patch.object(theme, "_get_style_hints", return_value=mock_hints), ): theme.setup(mock_app) From 7679e9335302e60a64d28d4dd8f2e97c5da45acf Mon Sep 17 00:00:00 2001 From: Khoa Nguyen Date: Fri, 1 Aug 2025 04:22:54 -0400 Subject: [PATCH 03/13] Fix failing mac tests --- test/test_theme_stylehints.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/test_theme_stylehints.py b/test/test_theme_stylehints.py index 13b214e135..028cd28c48 100644 --- a/test/test_theme_stylehints.py +++ b/test/test_theme_stylehints.py @@ -203,6 +203,7 @@ def test_setup_sets_color_scheme_based_on_theme( with ( patch.object(theme_mod, "get_config", return_value=config_mock), patch.object(base_theme, "_get_style_hints", return_value=mock_style_hints), + patch.object(theme_mod, "MacOverrideStyle") as _, ): base_theme.setup(mock_app) mock_style_hints.setColorScheme.assert_called_once_with(expected_color_scheme) @@ -216,6 +217,7 @@ def test_setup_handles_no_style_hints(self, base_theme, mock_app): with ( patch.object(theme_mod, "get_config", return_value=config_mock), patch.object(base_theme, "_get_style_hints", return_value=None), + patch.object(theme_mod, "MacOverrideStyle"), ): # Should not raise any exception base_theme.setup(mock_app) @@ -351,6 +353,7 @@ def test_theme_setup_integration(self, mock_app): with ( patch.object(theme_mod, "get_config", return_value=config_mock), patch.object(theme, "_get_style_hints", return_value=mock_hints), + patch.object(theme_mod, "MacOverrideStyle"), ): theme.setup(mock_app) From 9d5ef6d38a73731fe136e0156fec4bb805978078 Mon Sep 17 00:00:00 2001 From: kyle nguyen Date: Fri, 1 Aug 2025 07:53:06 -0400 Subject: [PATCH 04/13] fix all themes available only when stylehints available --- picard/ui/theme.py | 111 +++++++++------- test/test_theme_stylehints.py | 239 ++++++++++++++++++++-------------- 2 files changed, 202 insertions(+), 148 deletions(-) diff --git a/picard/ui/theme.py b/picard/ui/theme.py index 69293db880..e61d17866f 100644 --- a/picard/ui/theme.py +++ b/picard/ui/theme.py @@ -96,17 +96,33 @@ def _missing_(cls, value): return cls.DEFAULT +def get_style_hints() -> QtGui.QStyleHints | None: + """Get style hints from QGuiApplication, returning None if unavailable.""" + return QtGui.QGuiApplication.styleHints() + + +def _style_hints_available() -> bool: + """Check if style hints are available on the current system.""" + return get_style_hints() is not None + + # Theme availability based on platform capabilities if IS_HAIKU: # Haiku doesn't support themes - UI is hidden anyway, but keep empty for consistency # Platform Detection: `IS_HAIKU` is detected via sys.platform == 'haiku1' # Feature Flag: `OS_SUPPORTS_THEMES` is set to False for Haiku - # Empty Options: `AVAILABLE_UI_THEMES`` is set to an empty list [] for Haiku + # Empty Options: `AVAILABLE_UI_THEMES` is set to an empty list [] for Haiku # UI Hiding: The ui_theme_container widget is hidden when `OS_SUPPORTS_THEMES` is False (see `interface.py`) AVAILABLE_UI_THEMES = [] +elif not IS_WIN and not IS_MACOS: # Linux + if _style_hints_available(): + # All themes available when style hints are supported + AVAILABLE_UI_THEMES = [UiTheme.DEFAULT, UiTheme.LIGHT, UiTheme.DARK] + else: + # Only DEFAULT theme available when style hints are not available + AVAILABLE_UI_THEMES = [UiTheme.DEFAULT] else: - # All other platforms: consistent structure - # It is now safe to add these to linux since `QtGui.QGuiApplication.styleHints().setColorScheme()` is available + # Windows and macOS: consistent structure AVAILABLE_UI_THEMES = [UiTheme.DEFAULT, UiTheme.LIGHT, UiTheme.DARK] @@ -123,52 +139,51 @@ def styleHint(self, hint, option, widget, returnData): return super().styleHint(hint, option, widget, returnData) -class BaseTheme: - def __init__(self): - self._loaded_config_theme = UiTheme.DEFAULT - self._dark_theme = False - self._accent_color = None - # Registry of dark mode detection strategies for Linux DEs - self._dark_mode_strategies = get_linux_dark_mode_strategies() +def apply_dark_palette_colors(palette): + """Apply dark palette colors to the given palette.""" + for key, value in DARK_PALETTE_COLORS.items(): + if isinstance(key, tuple): + group, role = key + palette.setColor(group, role, value) + else: + palette.setColor(key, value) - def _apply_dark_palette_colors(self, palette): - """Apply dark palette colors to the given palette.""" - for key, value in DARK_PALETTE_COLORS.items(): - if isinstance(key, tuple): - group, role = key - palette.setColor(group, role, value) - else: - palette.setColor(key, value) - def _get_style_hints(self) -> QtGui.QStyleHints | None: - """Get style hints from QGuiApplication, returning None if unavailable.""" - return QtGui.QGuiApplication.styleHints() +def set_color_scheme(color_scheme: QtCore.Qt.ColorScheme): + """Set the color scheme using style hints if available. - def _set_color_scheme(self, color_scheme: QtCore.Qt.ColorScheme): - """Set the color scheme using style hints if available. + Args: + color_scheme: The Qt color scheme to set + """ + style_hints = get_style_hints() + if style_hints is not None: + style_hints.setColorScheme(color_scheme) - Args: - color_scheme: The Qt color scheme to set - """ - style_hints = self._get_style_hints() - if style_hints is not None: - style_hints.setColorScheme(color_scheme) - def _apply_dark_theme_to_palette(self, palette: QtGui.QPalette): - """Apply dark theme colors to the given palette using Qt's color scheme or manual fallback. +def apply_dark_theme_to_palette(palette: QtGui.QPalette): + """Apply dark theme colors to the given palette using Qt's color scheme or manual fallback. - This method tries to use Qt's built-in color scheme first, and falls back to - manually applying dark colors if style hints are unavailable. + This method tries to use Qt's built-in color scheme first, and falls back to + manually applying dark colors if style hints are unavailable. - Args: - palette: The palette to apply dark colors to - """ - style_hints = self._get_style_hints() - if style_hints is not None: - style_hints.setColorScheme(QtCore.Qt.ColorScheme.Dark) - else: - # Fall back to manually applying dark colors - self._apply_dark_palette_colors(palette) + Args: + palette: The palette to apply dark colors to + """ + style_hints = get_style_hints() + if style_hints is not None: + style_hints.setColorScheme(QtCore.Qt.ColorScheme.Dark) + else: + # Fall back to manually applying dark colors + apply_dark_palette_colors(palette) + + +class BaseTheme: + def __init__(self): + self._loaded_config_theme = UiTheme.DEFAULT + self._dark_theme = False + self._accent_color = None + # Registry of dark mode detection strategies for Linux DEs + self._dark_mode_strategies = get_linux_dark_mode_strategies() def _detect_linux_dark_mode(self) -> bool: # Iterate through all registered strategies @@ -195,15 +210,15 @@ def setup(self, app): ) # Set color scheme based on theme configuration - style_hints = self._get_style_hints() + style_hints = get_style_hints() if style_hints is not None: if self._loaded_config_theme == UiTheme.DARK: - self._set_color_scheme(QtCore.Qt.ColorScheme.Dark) + set_color_scheme(QtCore.Qt.ColorScheme.Dark) elif self._loaded_config_theme == UiTheme.LIGHT: - self._set_color_scheme(QtCore.Qt.ColorScheme.Light) + set_color_scheme(QtCore.Qt.ColorScheme.Light) else: # For DEFAULT and SYSTEM themes, let Qt follow system settings - self._set_color_scheme(QtCore.Qt.ColorScheme.Unknown) + set_color_scheme(QtCore.Qt.ColorScheme.Unknown) palette = QtGui.QPalette(app.palette()) base_color = palette.color(QtGui.QPalette.ColorGroup.Active, QtGui.QPalette.ColorRole.Base) @@ -225,7 +240,7 @@ def setup(self, app): is_dark_theme = self._detect_linux_dark_mode() if is_dark_theme: # Apply dark theme to palette using Qt's color scheme or manual fallback - self._apply_dark_theme_to_palette(palette) + apply_dark_theme_to_palette(palette) self._dark_theme = True self._accent_color = palette.color(QtGui.QPalette.ColorGroup.Active, QtGui.QPalette.ColorRole.Highlight) else: @@ -318,7 +333,7 @@ def update_palette(self, palette, dark_theme, accent_color): super().update_palette(palette, dark_theme, accent_color) if dark_theme: # Apply dark theme to palette using Qt's color scheme or manual fallback - self._apply_dark_theme_to_palette(palette) + apply_dark_theme_to_palette(palette) if IS_WIN: diff --git a/test/test_theme_stylehints.py b/test/test_theme_stylehints.py index 028cd28c48..cc3fae5d04 100644 --- a/test/test_theme_stylehints.py +++ b/test/test_theme_stylehints.py @@ -64,29 +64,29 @@ def mock_app(): class TestStyleHintsMethods: """Test the new style hints related methods.""" - def test_get_style_hints_returns_style_hints(self, base_theme, mock_style_hints): - """Test _get_style_hints returns QGuiApplication.styleHints().""" + def test_get_style_hints_returns_style_hints(self, mock_style_hints): + """Test get_style_hints returns QGuiApplication.styleHints().""" with patch("picard.ui.theme.QtGui.QGuiApplication.styleHints", return_value=mock_style_hints): - result = base_theme._get_style_hints() + result = theme_mod.get_style_hints() assert result is mock_style_hints - def test_get_style_hints_returns_none_when_unavailable(self, base_theme): - """Test _get_style_hints returns None when styleHints() is unavailable.""" + def test_get_style_hints_returns_none_when_unavailable(self): + """Test get_style_hints returns None when styleHints() is unavailable.""" with patch("picard.ui.theme.QtGui.QGuiApplication.styleHints", return_value=None): - result = base_theme._get_style_hints() + result = theme_mod.get_style_hints() assert result is None - def test_set_color_scheme_with_style_hints(self, base_theme, mock_style_hints): - """Test _set_color_scheme calls setColorScheme when style hints available.""" - with patch.object(base_theme, "_get_style_hints", return_value=mock_style_hints): - base_theme._set_color_scheme(QtCore.Qt.ColorScheme.Dark) + def test_set_color_scheme_with_style_hints(self, mock_style_hints): + """Test set_color_scheme calls setColorScheme when style hints available.""" + with patch("picard.ui.theme.get_style_hints", return_value=mock_style_hints): + theme_mod.set_color_scheme(QtCore.Qt.ColorScheme.Dark) mock_style_hints.setColorScheme.assert_called_once_with(QtCore.Qt.ColorScheme.Dark) - def test_set_color_scheme_without_style_hints(self, base_theme): - """Test _set_color_scheme does nothing when style hints unavailable.""" - with patch.object(base_theme, "_get_style_hints", return_value=None): + def test_set_color_scheme_without_style_hints(self): + """Test set_color_scheme does nothing when style hints unavailable.""" + with patch("picard.ui.theme.get_style_hints", return_value=None): # Should not raise any exception - base_theme._set_color_scheme(QtCore.Qt.ColorScheme.Dark) + theme_mod.set_color_scheme(QtCore.Qt.ColorScheme.Dark) @pytest.mark.parametrize( "color_scheme", @@ -96,92 +96,136 @@ def test_set_color_scheme_without_style_hints(self, base_theme): QtCore.Qt.ColorScheme.Unknown, ], ) - def test_set_color_scheme_with_different_schemes(self, base_theme, mock_style_hints, color_scheme): - """Test _set_color_scheme works with different color schemes.""" - with patch.object(base_theme, "_get_style_hints", return_value=mock_style_hints): - base_theme._set_color_scheme(color_scheme) + def test_set_color_scheme_with_different_schemes(self, mock_style_hints, color_scheme): + """Test set_color_scheme works with different color schemes.""" + with patch("picard.ui.theme.get_style_hints", return_value=mock_style_hints): + theme_mod.set_color_scheme(color_scheme) mock_style_hints.setColorScheme.assert_called_once_with(color_scheme) class TestApplyDarkPaletteColors: - """Test the _apply_dark_palette_colors method.""" + """Test the apply_dark_palette_colors method.""" - def test_apply_dark_palette_colors_applies_all_colors(self, base_theme, mock_palette): - """Test _apply_dark_palette_colors applies all expected colors.""" - base_theme._apply_dark_palette_colors(mock_palette) + def test_apply_dark_palette_colors_applies_all_colors(self, mock_palette): + """Test apply_dark_palette_colors applies all dark colors to palette.""" + theme_mod.apply_dark_palette_colors(mock_palette) - # Check that the palette colors were changed to dark theme colors - window_color = mock_palette.color(QtGui.QPalette.ColorRole.Window) - assert window_color.getRgb() == (51, 51, 51, 255) + # Verify colors have been changed to expected dark theme colors + new_window_color = mock_palette.color(QtGui.QPalette.ColorRole.Window) + new_text_color = mock_palette.color(QtGui.QPalette.ColorRole.Text) - window_text_color = mock_palette.color(QtGui.QPalette.ColorRole.WindowText) - assert window_text_color == QtCore.Qt.GlobalColor.white + # Check that window color is now the dark background color + assert new_window_color == theme_mod.DARK_BG_COLOR + # Check that text color is now white + assert new_text_color == QtCore.Qt.GlobalColor.white - base_color = mock_palette.color(QtGui.QPalette.ColorRole.Base) - assert base_color.getRgb() == (31, 31, 31, 255) + def test_apply_dark_palette_colors_applies_tuple_keys(self, mock_palette): + """Test apply_dark_palette_colors correctly handles tuple keys (group, role).""" + # Test that disabled text color is applied correctly + original_disabled_text = mock_palette.color(QtGui.QPalette.ColorGroup.Disabled, QtGui.QPalette.ColorRole.Text) - def test_apply_dark_palette_colors_applies_tuple_keys(self, base_theme, mock_palette): - """Test _apply_dark_palette_colors correctly handles tuple keys (group, role).""" - base_theme._apply_dark_palette_colors(mock_palette) + theme_mod.apply_dark_palette_colors(mock_palette) - # Check disabled text color (tuple key) - disabled_text_color = mock_palette.color(QtGui.QPalette.ColorGroup.Disabled, QtGui.QPalette.ColorRole.Text) - assert disabled_text_color == QtCore.Qt.GlobalColor.darkGray + new_disabled_text = mock_palette.color(QtGui.QPalette.ColorGroup.Disabled, QtGui.QPalette.ColorRole.Text) - # Check disabled base color (tuple key) - disabled_base_color = mock_palette.color(QtGui.QPalette.ColorGroup.Disabled, QtGui.QPalette.ColorRole.Base) - assert disabled_base_color.getRgb() == (60, 60, 60, 255) + assert new_disabled_text != original_disabled_text + assert new_disabled_text == QtCore.Qt.GlobalColor.darkGray class TestApplyDarkThemeToPalette: - """Test the _apply_dark_theme_to_palette method.""" + """Test the apply_dark_theme_to_palette method.""" - def test_apply_dark_theme_to_palette_with_style_hints(self, base_theme, mock_palette, mock_style_hints): - """Test _apply_dark_theme_to_palette uses style hints when available.""" - with patch.object(base_theme, "_get_style_hints", return_value=mock_style_hints): - base_theme._apply_dark_theme_to_palette(mock_palette) + def test_apply_dark_theme_to_palette_with_style_hints(self, mock_palette, mock_style_hints): + """Test apply_dark_theme_to_palette uses style hints when available.""" + with patch("picard.ui.theme.get_style_hints", return_value=mock_style_hints): + theme_mod.apply_dark_theme_to_palette(mock_palette) mock_style_hints.setColorScheme.assert_called_once_with(QtCore.Qt.ColorScheme.Dark) - def test_apply_dark_theme_to_palette_without_style_hints(self, base_theme, mock_palette): - """Test _apply_dark_theme_to_palette falls back to manual colors when style hints unavailable.""" - with patch.object(base_theme, "_get_style_hints", return_value=None): - base_theme._apply_dark_theme_to_palette(mock_palette) + def test_apply_dark_theme_to_palette_without_style_hints(self, mock_palette): + """Test apply_dark_theme_to_palette falls back to manual colors when no style hints.""" + with patch("picard.ui.theme.get_style_hints", return_value=None): + with patch("picard.ui.theme.apply_dark_palette_colors") as mock_apply_colors: + theme_mod.apply_dark_theme_to_palette(mock_palette) + mock_apply_colors.assert_called_once_with(mock_palette) - # Should have applied manual dark colors - window_color = mock_palette.color(QtGui.QPalette.ColorRole.Window) - assert window_color.getRgb() == (51, 51, 51, 255) - - def test_apply_dark_theme_to_palette_calls_manual_fallback(self, base_theme, mock_palette): - """Test _apply_dark_theme_to_palette calls _apply_dark_palette_colors when style hints unavailable.""" - with ( - patch.object(base_theme, "_get_style_hints", return_value=None), - patch.object(base_theme, "_apply_dark_palette_colors") as mock_apply, - ): - base_theme._apply_dark_theme_to_palette(mock_palette) - mock_apply.assert_called_once_with(mock_palette) + def test_apply_dark_theme_to_palette_calls_manual_fallback(self, mock_palette): + """Test apply_dark_theme_to_palette calls manual fallback when style hints unavailable.""" + with patch("picard.ui.theme.get_style_hints", return_value=None): + with patch("picard.ui.theme.apply_dark_palette_colors") as mock_apply_colors: + theme_mod.apply_dark_theme_to_palette(mock_palette) + mock_apply_colors.assert_called_once_with(mock_palette) class TestThemeAvailability: - """Test the updated theme availability logic.""" + """Test theme availability across platforms.""" def test_available_ui_themes_includes_all_themes_except_haiku(self): - """Test AVAILABLE_UI_THEMES includes DEFAULT, LIGHT, and DARK themes.""" - # The current implementation includes all themes for all platforms except Haiku - expected_themes = [theme_mod.UiTheme.DEFAULT, theme_mod.UiTheme.LIGHT, theme_mod.UiTheme.DARK] + """Test AVAILABLE_UI_THEMES includes appropriate themes based on platform and capabilities.""" + # This test assumes we're not on Haiku + # On Haiku, AVAILABLE_UI_THEMES would be empty + if theme_mod.IS_HAIKU: + assert len(theme_mod.AVAILABLE_UI_THEMES) == 0 + elif not theme_mod.IS_WIN and not theme_mod.IS_MACOS: + # Linux: Check if style hints are available + if theme_mod._style_hints_available(): + # All themes available when style hints are supported + assert len(theme_mod.AVAILABLE_UI_THEMES) == 3 + assert theme_mod.UiTheme.DEFAULT in theme_mod.AVAILABLE_UI_THEMES + assert theme_mod.UiTheme.LIGHT in theme_mod.AVAILABLE_UI_THEMES + assert theme_mod.UiTheme.DARK in theme_mod.AVAILABLE_UI_THEMES + else: + # Only DEFAULT theme available when style hints are not available + assert len(theme_mod.AVAILABLE_UI_THEMES) == 1 + assert theme_mod.UiTheme.DEFAULT in theme_mod.AVAILABLE_UI_THEMES + assert theme_mod.UiTheme.LIGHT not in theme_mod.AVAILABLE_UI_THEMES + assert theme_mod.UiTheme.DARK not in theme_mod.AVAILABLE_UI_THEMES + else: + # Windows and macOS: all themes available + assert len(theme_mod.AVAILABLE_UI_THEMES) > 0 + assert theme_mod.UiTheme.DEFAULT in theme_mod.AVAILABLE_UI_THEMES + assert theme_mod.UiTheme.LIGHT in theme_mod.AVAILABLE_UI_THEMES + assert theme_mod.UiTheme.DARK in theme_mod.AVAILABLE_UI_THEMES + + def test_linux_style_hints_detection(self, monkeypatch): + """Test Linux style hints detection affects available themes.""" + # Mock Linux platform + monkeypatch.setattr(theme_mod, "IS_WIN", False) + monkeypatch.setattr(theme_mod, "IS_MACOS", False) + monkeypatch.setattr(theme_mod, "IS_HAIKU", False) - # Check that all expected themes are present - for theme in expected_themes: - assert theme in theme_mod.AVAILABLE_UI_THEMES + # Test the _style_hints_available function directly + with patch("picard.ui.theme.get_style_hints", return_value=MagicMock()): + assert theme_mod._style_hints_available() is True - # Check that we have exactly the expected themes (for non-Haiku platforms) - if not theme_mod.IS_HAIKU: - assert len(theme_mod.AVAILABLE_UI_THEMES) == len(expected_themes) - for theme in theme_mod.AVAILABLE_UI_THEMES: - assert theme in expected_themes + with patch("picard.ui.theme.get_style_hints", return_value=None): + assert theme_mod._style_hints_available() is False + + # Test the logic that determines available themes + # We can't easily test the module-level AVAILABLE_UI_THEMES since it's evaluated at import time + # Instead, test that the logic works correctly by checking the function behavior + def test_available_themes_logic(style_hints_available): + if style_hints_available: + return [theme_mod.UiTheme.DEFAULT, theme_mod.UiTheme.LIGHT, theme_mod.UiTheme.DARK] + else: + return [theme_mod.UiTheme.DEFAULT] + + # Test with style hints available + available_themes = test_available_themes_logic(True) + assert len(available_themes) == 3 + assert theme_mod.UiTheme.DEFAULT in available_themes + assert theme_mod.UiTheme.LIGHT in available_themes + assert theme_mod.UiTheme.DARK in available_themes + + # Test with style hints not available + available_themes = test_available_themes_logic(False) + assert len(available_themes) == 1 + assert theme_mod.UiTheme.DEFAULT in available_themes + assert theme_mod.UiTheme.LIGHT not in available_themes + assert theme_mod.UiTheme.DARK not in available_themes class TestSetupColorScheme: - """Test the color scheme setting logic in setup method.""" + """Test color scheme setup in theme setup method.""" @pytest.mark.parametrize( ("theme_value", "expected_color_scheme"), @@ -202,7 +246,7 @@ def test_setup_sets_color_scheme_based_on_theme( with ( patch.object(theme_mod, "get_config", return_value=config_mock), - patch.object(base_theme, "_get_style_hints", return_value=mock_style_hints), + patch("picard.ui.theme.get_style_hints", return_value=mock_style_hints), patch.object(theme_mod, "MacOverrideStyle") as _, ): base_theme.setup(mock_app) @@ -216,7 +260,7 @@ def test_setup_handles_no_style_hints(self, base_theme, mock_app): with ( patch.object(theme_mod, "get_config", return_value=config_mock), - patch.object(base_theme, "_get_style_hints", return_value=None), + patch("picard.ui.theme.get_style_hints", return_value=None), patch.object(theme_mod, "MacOverrideStyle"), ): # Should not raise any exception @@ -224,13 +268,13 @@ def test_setup_handles_no_style_hints(self, base_theme, mock_app): class TestWindowsTheme: - """Test the WindowsTheme class updates.""" + """Test Windows-specific theme behavior.""" def test_windows_theme_apply_dark_theme_to_palette(self, mock_palette): - """Test WindowsTheme uses _apply_dark_theme_to_palette in update_palette.""" + """Test WindowsTheme uses apply_dark_theme_to_palette in update_palette.""" theme = theme_mod.WindowsTheme() - with patch.object(theme, "_apply_dark_theme_to_palette") as mock_apply: + with patch("picard.ui.theme.apply_dark_theme_to_palette") as mock_apply: theme.update_palette(mock_palette, True, None) mock_apply.assert_called_once_with(mock_palette) @@ -238,17 +282,18 @@ def test_windows_theme_does_not_apply_dark_theme_when_not_dark(self, mock_palett """Test WindowsTheme does not apply dark theme when dark_theme is False.""" theme = theme_mod.WindowsTheme() - with patch.object(theme, "_apply_dark_theme_to_palette") as mock_apply: + with patch("picard.ui.theme.apply_dark_theme_to_palette") as mock_apply: theme.update_palette(mock_palette, False, None) mock_apply.assert_not_called() class TestLinuxDarkModeDetection: - """Test the Linux dark mode detection logic.""" + """Test Linux dark mode detection logic.""" @pytest.fixture def linux_theme(self, monkeypatch): - """Create a BaseTheme instance with Linux platform settings.""" + """Create a Linux theme instance for testing.""" + # Mock platform detection to simulate Linux monkeypatch.setattr(theme_mod, "IS_WIN", False) monkeypatch.setattr(theme_mod, "IS_MACOS", False) monkeypatch.setattr(theme_mod, "IS_HAIKU", False) @@ -280,11 +325,10 @@ def test_linux_dark_mode_detection_logic( with ( patch.object(theme_mod, "get_config", return_value=config_mock), patch.object(linux_theme, "_detect_linux_dark_mode", return_value=detect_result), - patch.object(linux_theme, "_apply_dark_theme_to_palette") as mock_apply, - patch.object(linux_theme, "_get_style_hints", return_value=None), + patch("picard.ui.theme.apply_dark_theme_to_palette") as mock_apply, + patch("picard.ui.theme.get_style_hints", return_value=None), ): linux_theme.setup(mock_app) - if expected_apply_called: mock_apply.assert_called_once() else: @@ -304,40 +348,37 @@ def test_linux_dark_mode_not_applied_when_already_dark(self, linux_theme, mock_a with ( patch.object(theme_mod, "get_config", return_value=config_mock), patch.object(linux_theme, "_detect_linux_dark_mode", return_value=True), - patch.object(linux_theme, "_apply_dark_theme_to_palette") as mock_apply, - patch.object(linux_theme, "_get_style_hints", return_value=None), + patch("picard.ui.theme.apply_dark_theme_to_palette") as mock_apply, + patch("picard.ui.theme.get_style_hints", return_value=None), ): linux_theme.setup(mock_app) + # Should not apply dark theme when already dark mock_apply.assert_not_called() class TestIntegration: - """Integration tests for the new functionality.""" + """Test integration scenarios.""" - def test_style_hints_integration_with_real_palette(self, base_theme): + def test_style_hints_integration_with_real_palette(self): """Test integration of style hints with real palette objects.""" palette = QtGui.QPalette() - original_window_color = palette.color(QtGui.QPalette.ColorRole.Window) # Test with style hints available mock_hints = MagicMock() - with patch.object(base_theme, "_get_style_hints", return_value=mock_hints): - base_theme._apply_dark_theme_to_palette(palette) + with patch("picard.ui.theme.get_style_hints", return_value=mock_hints): + theme_mod.apply_dark_theme_to_palette(palette) mock_hints.setColorScheme.assert_called_once_with(QtCore.Qt.ColorScheme.Dark) - # Palette should not be modified when using style hints - assert palette.color(QtGui.QPalette.ColorRole.Window) == original_window_color - def test_manual_fallback_integration(self, base_theme): + def test_manual_fallback_integration(self): """Test integration of manual fallback with real palette objects.""" palette = QtGui.QPalette() original_window_color = palette.color(QtGui.QPalette.ColorRole.Window) # Test without style hints (manual fallback) - with patch.object(base_theme, "_get_style_hints", return_value=None): - base_theme._apply_dark_theme_to_palette(palette) - # Palette should be modified with dark colors + with patch("picard.ui.theme.get_style_hints", return_value=None): + theme_mod.apply_dark_theme_to_palette(palette) + # Verify that manual colors were applied new_window_color = palette.color(QtGui.QPalette.ColorRole.Window) - assert new_window_color.getRgb() == (51, 51, 51, 255) assert new_window_color != original_window_color def test_theme_setup_integration(self, mock_app): @@ -352,10 +393,8 @@ def test_theme_setup_integration(self, mock_app): with ( patch.object(theme_mod, "get_config", return_value=config_mock), - patch.object(theme, "_get_style_hints", return_value=mock_hints), + patch("picard.ui.theme.get_style_hints", return_value=mock_hints), patch.object(theme_mod, "MacOverrideStyle"), ): theme.setup(mock_app) - - # Verify style hints were used mock_hints.setColorScheme.assert_called_once_with(QtCore.Qt.ColorScheme.Dark) From 43a1b915a169def2c49cae6d6a16bd55684aae23 Mon Sep 17 00:00:00 2001 From: kyle nguyen Date: Mon, 4 Aug 2025 22:02:00 -0400 Subject: [PATCH 05/13] Remove/clarify some confusing comments --- picard/ui/theme.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/picard/ui/theme.py b/picard/ui/theme.py index e61d17866f..99b6451308 100644 --- a/picard/ui/theme.py +++ b/picard/ui/theme.py @@ -109,12 +109,8 @@ def _style_hints_available() -> bool: # Theme availability based on platform capabilities if IS_HAIKU: # Haiku doesn't support themes - UI is hidden anyway, but keep empty for consistency - # Platform Detection: `IS_HAIKU` is detected via sys.platform == 'haiku1' - # Feature Flag: `OS_SUPPORTS_THEMES` is set to False for Haiku - # Empty Options: `AVAILABLE_UI_THEMES` is set to an empty list [] for Haiku - # UI Hiding: The ui_theme_container widget is hidden when `OS_SUPPORTS_THEMES` is False (see `interface.py`) AVAILABLE_UI_THEMES = [] -elif not IS_WIN and not IS_MACOS: # Linux +elif not IS_WIN and not IS_MACOS: # Non-Windows and non-macOS platforms if _style_hints_available(): # All themes available when style hints are supported AVAILABLE_UI_THEMES = [UiTheme.DEFAULT, UiTheme.LIGHT, UiTheme.DARK] @@ -122,7 +118,7 @@ def _style_hints_available() -> bool: # Only DEFAULT theme available when style hints are not available AVAILABLE_UI_THEMES = [UiTheme.DEFAULT] else: - # Windows and macOS: consistent structure + # All others, like Windows and macOS: consistent structure AVAILABLE_UI_THEMES = [UiTheme.DEFAULT, UiTheme.LIGHT, UiTheme.DARK] From 113bf43383c4732199da1e792616d651d92ae68d Mon Sep 17 00:00:00 2001 From: kyle nguyen Date: Tue, 12 Aug 2025 00:31:25 -0400 Subject: [PATCH 06/13] Run `ruff format` --- test/test_theme.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/test/test_theme.py b/test/test_theme.py index 0a717d0211..045dac27ea 100644 --- a/test/test_theme.py +++ b/test/test_theme.py @@ -396,9 +396,9 @@ def assert_palette_matches_expected(palette, expected_colors): ) if isinstance(expected, QtGui.QColor): # Compare by value, not object identity - assert ( - actual.getRgb() == expected.getRgb() - ), f"Color for {key} should be {expected.getRgb()}, got {actual.getRgb()}" + assert actual.getRgb() == expected.getRgb(), ( + f"Color for {key} should be {expected.getRgb()}, got {actual.getRgb()}" + ) else: assert actual == QtGui.QColor(expected), f"Color for {key} should be {expected}, got {actual}" @@ -421,13 +421,13 @@ def assert_palette_not_dark(palette, expected_colors): continue actual = palette.color(QtGui.QPalette.ColorGroup.Active, key) if isinstance(expected, QtGui.QColor): - assert ( - actual.getRgb() != expected.getRgb() - ), f"Color for {key} should differ from dark mode in light mode; got {actual.getRgb()}" + assert actual.getRgb() != expected.getRgb(), ( + f"Color for {key} should differ from dark mode in light mode; got {actual.getRgb()}" + ) else: - assert actual != QtGui.QColor( - expected - ), f"Color for {key} should differ from dark mode in light mode; got {actual}" + assert actual != QtGui.QColor(expected), ( + f"Color for {key} should differ from dark mode in light mode; got {actual}" + ) @pytest.mark.parametrize( From f702b1e281982a34ebcf132b3e68c2c5bc86def9 Mon Sep 17 00:00:00 2001 From: ripstream Date: Tue, 12 Aug 2025 10:36:12 -0400 Subject: [PATCH 07/13] Fix failing linux test --- picard/ui/theme.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/picard/ui/theme.py b/picard/ui/theme.py index 99b6451308..c07f1cf33d 100644 --- a/picard/ui/theme.py +++ b/picard/ui/theme.py @@ -224,10 +224,12 @@ def setup(self, app): self._accent_color = palette.color(QtGui.QPalette.ColorGroup.Active, QtGui.QPalette.ColorRole.Highlight) # Linux-specific: If DEFAULT theme, try to detect system dark mode - # Do not apply override if already dark theme + # Do not apply override if already dark theme, or if a subclass already + # determined a dark theme state (e.g., WindowsTheme via registry on tests). is_dark_theme = self.is_dark_theme if ( not self._dark_theme + and not is_dark_theme and not IS_WIN and not IS_MACOS and not IS_HAIKU From 2902ff8e9e1828490ec66113b879ab959fe6d7b5 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Sat, 16 Aug 2025 15:02:13 +0200 Subject: [PATCH 08/13] If changing colorScheme fails fall back to the custom dark color palette --- picard/ui/theme.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/picard/ui/theme.py b/picard/ui/theme.py index c07f1cf33d..9769b5577f 100644 --- a/picard/ui/theme.py +++ b/picard/ui/theme.py @@ -168,9 +168,11 @@ def apply_dark_theme_to_palette(palette: QtGui.QPalette): style_hints = get_style_hints() if style_hints is not None: style_hints.setColorScheme(QtCore.Qt.ColorScheme.Dark) - else: - # Fall back to manually applying dark colors - apply_dark_palette_colors(palette) + # Test whether the change was successful + if style_hints.colorScheme() == QtCore.Qt.ColorScheme.Dark: + return + # Fall back to manually applying dark colors + apply_dark_palette_colors(palette) class BaseTheme: From 4a29c0f9b564a19515e2a9280a7a557867b34984 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Sat, 16 Aug 2025 15:12:38 +0200 Subject: [PATCH 09/13] Config migration for ui_theme "system" setting --- picard/__init__.py | 2 +- picard/config_upgrade.py | 8 ++++++++ test/test_config_upgrade.py | 8 ++++++++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/picard/__init__.py b/picard/__init__.py index 7ec485f442..331e0ecc72 100644 --- a/picard/__init__.py +++ b/picard/__init__.py @@ -42,7 +42,7 @@ PICARD_DISPLAY_NAME = "MusicBrainz Picard" PICARD_APP_ID = "org.musicbrainz.Picard" PICARD_DESKTOP_NAME = PICARD_APP_ID + ".desktop" -PICARD_VERSION = Version(3, 0, 0, 'dev', 6) +PICARD_VERSION = Version(3, 0, 0, 'dev', 7) # optional build version diff --git a/picard/config_upgrade.py b/picard/config_upgrade.py index fc1d54bc78..d469033a0c 100644 --- a/picard/config_upgrade.py +++ b/picard/config_upgrade.py @@ -62,6 +62,8 @@ VersionError, ) +from picard.ui.theme import UiTheme + # All upgrade functions have to start with following prefix UPGRADE_FUNCTION_PREFIX = 'upgrade_to_v' @@ -575,6 +577,12 @@ def upgrade_to_v3_0_0dev6(config): config.setting['standardize_vocals'] = standardize_instruments_and_vocals +def upgrade_to_v3_0_0dev7(config): + """Change theme option SYSTEM to DEFAULT""" + if config.setting['ui_theme'] == "system": + config.setting['ui_theme'] = UiTheme.DEFAULT + + def rename_option(config, old_opt, new_opt, option_type, default): _s = config.setting if old_opt in _s: diff --git a/test/test_config_upgrade.py b/test/test_config_upgrade.py index cfd0b2f244..62b25cfd43 100644 --- a/test/test_config_upgrade.py +++ b/test/test_config_upgrade.py @@ -69,11 +69,13 @@ upgrade_to_v3_0_0dev3, upgrade_to_v3_0_0dev4, upgrade_to_v3_0_0dev5, + upgrade_to_v3_0_0dev7, ) from picard.const.defaults import ( DEFAULT_FILE_NAMING_FORMAT, DEFAULT_REPLACEMENT, DEFAULT_SCRIPT_NAME, + DEFAULT_THEME_NAME, ) from picard.util import unique_numbered_title from picard.version import Version @@ -561,3 +563,9 @@ def test_upgrade_to_v3_0_0dev5(self): self.config.setting['replace_dir_separator'] = os.altsep upgrade_to_v3_0_0dev5(self.config) self.assertEqual(DEFAULT_REPLACEMENT, self.config.setting['replace_dir_separator']) + + def test_upgrade_to_v3_0_0dev7(self): + TextOption('setting', 'ui_theme', DEFAULT_THEME_NAME) + self.config.setting['ui_theme'] = 'system' + upgrade_to_v3_0_0dev7(self.config) + self.assertEqual(DEFAULT_THEME_NAME, self.config.setting['ui_theme']) From 49aa598fda502f23ec7b12646af8d4239c5769dc Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Sat, 16 Aug 2025 15:15:21 +0200 Subject: [PATCH 10/13] Remove UiTheme.SYSTEM --- picard/config_upgrade.py | 4 +--- picard/ui/options/interface.py | 4 ---- picard/ui/theme.py | 7 +++---- test/test_config_upgrade.py | 2 +- test/test_theme_stylehints.py | 1 - 5 files changed, 5 insertions(+), 13 deletions(-) diff --git a/picard/config_upgrade.py b/picard/config_upgrade.py index d469033a0c..c84e8d9908 100644 --- a/picard/config_upgrade.py +++ b/picard/config_upgrade.py @@ -385,11 +385,9 @@ def upgrade_to_v2_6_0beta2(config): def upgrade_to_v2_6_0beta3(config): """Replace use_system_theme with ui_theme options""" - from picard.ui.theme import UiTheme - _s = config.setting if _s.value('use_system_theme', BoolOption): - _s['ui_theme'] = str(UiTheme.SYSTEM) + _s['ui_theme'] = 'system' _s.remove('use_system_theme') diff --git a/picard/ui/options/interface.py b/picard/ui/options/interface.py index e938706045..ef34ceb3fa 100644 --- a/picard/ui/options/interface.py +++ b/picard/ui/options/interface.py @@ -97,10 +97,6 @@ class InterfaceOptionsPage(OptionsPage): 'label': N_("Light"), 'desc': N_("A light display theme"), }, - UiTheme.SYSTEM: { - 'label': N_("System"), - 'desc': N_("The Qt6 theme configured in the desktop environment"), - }, } def __init__(self, parent=None): diff --git a/picard/ui/theme.py b/picard/ui/theme.py index 9769b5577f..b6bff82fdf 100644 --- a/picard/ui/theme.py +++ b/picard/ui/theme.py @@ -86,7 +86,6 @@ class UiTheme(Enum): DEFAULT = 'default' DARK = 'dark' LIGHT = 'light' - SYSTEM = 'system' def __str__(self): return self.value @@ -197,8 +196,8 @@ def setup(self, app): self._loaded_config_theme = UiTheme(config.setting['ui_theme']) # Use the new fusion style from PyQt6 for a modern and consistent look - # across all OSes, except when using system default theme on Linux. - if not IS_MACOS and not IS_HAIKU and not (not IS_WIN and self._loaded_config_theme == UiTheme.DEFAULT): + # across all OSes, except for macOS and Haiku. + if not IS_MACOS and not IS_HAIKU: app.setStyle('Fusion') elif IS_MACOS: app.setStyle(MacOverrideStyle(app.style())) @@ -215,7 +214,7 @@ def setup(self, app): elif self._loaded_config_theme == UiTheme.LIGHT: set_color_scheme(QtCore.Qt.ColorScheme.Light) else: - # For DEFAULT and SYSTEM themes, let Qt follow system settings + # For DEFAULT theme, let Qt follow system settings set_color_scheme(QtCore.Qt.ColorScheme.Unknown) palette = QtGui.QPalette(app.palette()) diff --git a/test/test_config_upgrade.py b/test/test_config_upgrade.py index 62b25cfd43..79bab160c3 100644 --- a/test/test_config_upgrade.py +++ b/test/test_config_upgrade.py @@ -446,7 +446,7 @@ def test_upgrade_to_v2_6_0beta3(self): upgrade_to_v2_6_0beta3(self.config) self.assertNotIn('use_system_theme', self.config.setting) self.assertIn('ui_theme', self.config.setting) - self.assertEqual(str(UiTheme.SYSTEM), self.config.setting['ui_theme']) + self.assertEqual('system', self.config.setting['ui_theme']) def test_upgrade_to_v2_7_0dev3(self): # Legacy settings diff --git a/test/test_theme_stylehints.py b/test/test_theme_stylehints.py index cc3fae5d04..d37c18d5c3 100644 --- a/test/test_theme_stylehints.py +++ b/test/test_theme_stylehints.py @@ -306,7 +306,6 @@ def linux_theme(self, monkeypatch): ("default", False, False), # Should not apply dark theme ("dark", True, False), # Should not apply (already dark) ("light", True, False), # Should not apply (explicit light) - ("system", True, False), # Should not apply (system theme) ], ) def test_linux_dark_mode_detection_logic( From 1fe89ade2d550cae90bc241208c42c0bde5cef5a Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Sat, 16 Aug 2025 16:06:45 +0200 Subject: [PATCH 11/13] Fix test_windows_dark_theme_palette actually assuming IS_WIN --- test/test_theme.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/test_theme.py b/test/test_theme.py index 045dac27ea..370ac0b145 100644 --- a/test/test_theme.py +++ b/test/test_theme.py @@ -479,6 +479,10 @@ def test_linux_dark_theme_palette(monkeypatch, already_dark_theme, dark_mode, ex def test_windows_dark_theme_palette(monkeypatch, apps_use_light_theme, expected_dark): import picard.ui.theme as theme_mod + monkeypatch.setattr(theme_mod, "IS_WIN", True) + monkeypatch.setattr(theme_mod, "IS_MACOS", False) + monkeypatch.setattr(theme_mod, "IS_HAIKU", False) + # Patch winreg winreg_mock = types.SimpleNamespace() monkeypatch.setattr(theme_mod, "winreg", winreg_mock) From c1f24e86ece0137795a39134afcfc795f816dd14 Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Sat, 16 Aug 2025 16:31:13 +0200 Subject: [PATCH 12/13] Removed system theme UI warning --- picard/ui/options/interface.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/picard/ui/options/interface.py b/picard/ui/options/interface.py index ef34ceb3fa..17f00bbb16 100644 --- a/picard/ui/options/interface.py +++ b/picard/ui/options/interface.py @@ -177,13 +177,6 @@ def save(self): notes = [] if new_theme_setting != config.setting['ui_theme']: warnings.append(_("You have changed the application theme.")) - if new_theme_setting == str(UiTheme.SYSTEM): - notes.append( - _( - 'Please note that using the system theme might cause the user interface to be not shown correctly. ' - 'If this is the case select the "Default" theme option to use Picard\'s default theme again.' - ) - ) config.setting['ui_theme'] = new_theme_setting if new_language != config.setting['ui_language']: config.setting['ui_language'] = new_language From 604940dcbaa359eecd5f867d2045182dbb9138bc Mon Sep 17 00:00:00 2001 From: Philipp Wolfer Date: Thu, 28 Aug 2025 23:40:39 +0200 Subject: [PATCH 13/13] Simplify code for setting AVAILABLE_UI_THEMES --- picard/ui/theme.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/picard/ui/theme.py b/picard/ui/theme.py index b6bff82fdf..eb9f3136b6 100644 --- a/picard/ui/theme.py +++ b/picard/ui/theme.py @@ -109,16 +109,11 @@ def _style_hints_available() -> bool: if IS_HAIKU: # Haiku doesn't support themes - UI is hidden anyway, but keep empty for consistency AVAILABLE_UI_THEMES = [] -elif not IS_WIN and not IS_MACOS: # Non-Windows and non-macOS platforms - if _style_hints_available(): - # All themes available when style hints are supported - AVAILABLE_UI_THEMES = [UiTheme.DEFAULT, UiTheme.LIGHT, UiTheme.DARK] - else: - # Only DEFAULT theme available when style hints are not available - AVAILABLE_UI_THEMES = [UiTheme.DEFAULT] -else: - # All others, like Windows and macOS: consistent structure +elif IS_WIN or IS_MACOS or _style_hints_available(): AVAILABLE_UI_THEMES = [UiTheme.DEFAULT, UiTheme.LIGHT, UiTheme.DARK] +else: + # Use only default theme on platforms without style hints + AVAILABLE_UI_THEMES = [UiTheme.DEFAULT] class MacOverrideStyle(QtWidgets.QProxyStyle):