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..c84e8d9908 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' @@ -383,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') @@ -575,6 +575,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/picard/ui/options/interface.py b/picard/ui/options/interface.py index e938706045..17f00bbb16 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): @@ -181,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 diff --git a/picard/ui/theme.py b/picard/ui/theme.py index f528db9d7f..eb9f3136b6 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 @@ -96,11 +95,25 @@ 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]) +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 + AVAILABLE_UI_THEMES = [] +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): @@ -116,10 +129,51 @@ def styleHint(self, hint, option, widget, returnData): return super().styleHint(hint, option, widget, returnData) +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 set_color_scheme(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) + + +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. + + 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) + # 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: 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() @@ -137,8 +191,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 for macOS and Haiku. + if not IS_MACOS and not IS_HAIKU: app.setStyle('Fusion') elif IS_MACOS: app.setStyle(MacOverrideStyle(app.style())) @@ -147,6 +201,17 @@ def setup(self, app): 'QGroupBox::title { /* PICARD-1206, Qt bug workaround */ }', ) + # Set color scheme based on theme configuration + style_hints = get_style_hints() + if style_hints is not None: + if self._loaded_config_theme == UiTheme.DARK: + set_color_scheme(QtCore.Qt.ColorScheme.Dark) + elif self._loaded_config_theme == UiTheme.LIGHT: + set_color_scheme(QtCore.Qt.ColorScheme.Light) + else: + # For DEFAULT theme, let Qt follow system settings + 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,25 +219,22 @@ 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 - # Do not apply override if already dark theme + # Linux-specific: If DEFAULT theme, try to detect system dark mode + # 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 - 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 + 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 +326,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 + apply_dark_theme_to_palette(palette) if IS_WIN: diff --git a/test/test_config_upgrade.py b/test/test_config_upgrade.py index cfd0b2f244..79bab160c3 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 @@ -444,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 @@ -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']) diff --git a/test/test_theme.py b/test/test_theme.py index 552260c381..370ac0b145 100644 --- a/test/test_theme.py +++ b/test/test_theme.py @@ -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) @@ -476,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) @@ -511,7 +518,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 +630,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..d37c18d5c3 --- /dev/null +++ b/test/test_theme_stylehints.py @@ -0,0 +1,399 @@ +# -*- 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, mock_style_hints): + """Test get_style_hints returns QGuiApplication.styleHints().""" + with patch("picard.ui.theme.QtGui.QGuiApplication.styleHints", return_value=mock_style_hints): + result = theme_mod.get_style_hints() + assert result is mock_style_hints + + 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 = theme_mod.get_style_hints() + assert result is None + + 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): + """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 + theme_mod.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, 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.""" + + 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) + + # 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) + + # 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 + + 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) + + theme_mod.apply_dark_palette_colors(mock_palette) + + new_disabled_text = mock_palette.color(QtGui.QPalette.ColorGroup.Disabled, QtGui.QPalette.ColorRole.Text) + + 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.""" + + 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, 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) + + 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 theme availability across platforms.""" + + def test_available_ui_themes_includes_all_themes_except_haiku(self): + """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) + + # 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 + + 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 color scheme setup in theme 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("picard.ui.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) + + 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("picard.ui.theme.get_style_hints", return_value=None), + patch.object(theme_mod, "MacOverrideStyle"), + ): + # Should not raise any exception + base_theme.setup(mock_app) + + +class TestWindowsTheme: + """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.""" + theme = theme_mod.WindowsTheme() + + 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) + + 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("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 Linux dark mode detection logic.""" + + @pytest.fixture + def linux_theme(self, monkeypatch): + """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) + 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) + ], + ) + 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("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: + 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("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: + """Test integration scenarios.""" + + def test_style_hints_integration_with_real_palette(self): + """Test integration of style hints with real palette objects.""" + palette = QtGui.QPalette() + + # Test with style hints available + mock_hints = MagicMock() + 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) + + 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("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 != 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("picard.ui.theme.get_style_hints", return_value=mock_hints), + patch.object(theme_mod, "MacOverrideStyle"), + ): + theme.setup(mock_app) + mock_hints.setColorScheme.assert_called_once_with(QtCore.Qt.ColorScheme.Dark)