Skip to content
Merged
2 changes: 1 addition & 1 deletion picard/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions picard/config_upgrade.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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')


Expand Down Expand Up @@ -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:
Expand Down
11 changes: 0 additions & 11 deletions picard/ui/options/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down
108 changes: 83 additions & 25 deletions picard/ui/theme.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,6 @@ class UiTheme(Enum):
DEFAULT = 'default'
DARK = 'dark'
LIGHT = 'light'
SYSTEM = 'system'

def __str__(self):
return self.value
Expand All @@ -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):
Expand All @@ -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()

Expand All @@ -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()))
Expand All @@ -147,32 +201,40 @@ 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
self._accent_color = None
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:
Expand Down Expand Up @@ -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:
Expand Down
10 changes: 9 additions & 1 deletion test/test_config_upgrade.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'])
17 changes: 13 additions & 4 deletions test/test_theme.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading