-
-
Notifications
You must be signed in to change notification settings - Fork 400
feat: Theme manager #587
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
feat: Theme manager #587
Changes from all commits
64b6c5b
a2c4a56
a4912ea
748b11b
fc82847
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,175 @@ | ||
from collections.abc import Callable | ||
from typing import Literal | ||
|
||
import structlog | ||
from PySide6.QtCore import QSettings, Qt | ||
from PySide6.QtGui import QColor, QPalette | ||
from PySide6.QtWidgets import QApplication | ||
|
||
logger = structlog.get_logger("theme") | ||
|
||
theme_update_hooks: list[Callable[[], None]] = [] | ||
"List of callables that will be called when any theme is changed." | ||
|
||
|
||
def _update_theme_hooks() -> None: | ||
"""Update all theme hooks by calling each hook in the list.""" | ||
for hook in theme_update_hooks: | ||
try: | ||
hook() | ||
except Exception as e: | ||
logger.error(e) | ||
|
||
|
||
def _load_palette_from_file(file_path: str, default_palette: QPalette) -> QPalette: | ||
"""Load a palette from a file and update the default palette with the loaded colors. | ||
|
||
The file should be in the INI format and should have the following format: | ||
|
||
[ColorRoleName] | ||
ColorGroupName = Color | ||
|
||
ColorRoleName is the name of the color role (e.g. Window, Button, etc.) | ||
ColorGroupName is the name of the color group (e.g. Active, Inactive, Disabled, etc.) | ||
Color is the color value in the QColor supported format (e.g. #RRGGBB, blue, etc.) | ||
|
||
Args: | ||
file_path (str): The path to the file containing color information. | ||
default_palette (QPalette): The default palette to be updated with the colors. | ||
|
||
Returns: | ||
QPalette: The updated palette based on the colors specified in the file. | ||
""" | ||
theme = QSettings(file_path, QSettings.Format.IniFormat, QApplication.instance()) | ||
|
||
color_groups = ( | ||
QPalette.ColorGroup.Active, | ||
QPalette.ColorGroup.Inactive, | ||
QPalette.ColorGroup.Disabled, | ||
) | ||
|
||
pal = default_palette | ||
|
||
for role in list(QPalette.ColorRole)[:-1]: # remove last color role (NColorRoles) | ||
for group in color_groups: | ||
value: str | None = theme.value(f"{role.name}/{group.name}", None, str) # type: ignore | ||
if value is not None and QColor.isValidColorName(value): | ||
pal.setColor(group, role, QColor(value)) | ||
|
||
return pal | ||
|
||
|
||
def _save_palette_to_file(file_path: str, palette: QPalette) -> None: | ||
"""Save the given palette colors to a file in INI format, if the color is not default. | ||
|
||
If no color is changed, the file won't be created or changed. | ||
|
||
The file will be in the INI format and will have the following format: | ||
|
||
[ColorRoleName] | ||
ColorGroupName = Color | ||
|
||
ColorRoleName is the name of the color role (e.g. Window, Button, etc.) | ||
ColorGroupName is the name of the color group (e.g. Active, Inactive, Disabled, etc.) | ||
Color is the color value in the RgbHex (#RRGGBB) or ArgbHex (#AARRGGBB) format. | ||
|
||
Args: | ||
file_path (str): The path to the file where the palette will be saved. | ||
palette (QPalette): The palette to be saved. | ||
|
||
Returns: | ||
None | ||
""" | ||
theme = QSettings(file_path, QSettings.Format.IniFormat, QApplication.instance()) | ||
|
||
color_groups = ( | ||
QPalette.ColorGroup.Active, | ||
QPalette.ColorGroup.Inactive, | ||
QPalette.ColorGroup.Disabled, | ||
) | ||
default_pal = QPalette() | ||
|
||
for role in list(QPalette.ColorRole)[:-1]: # remove last color role (NColorRoles) | ||
theme.beginGroup(role.name) | ||
for group in color_groups: | ||
if default_pal.color(group, role) != palette.color(group, role): | ||
theme.setValue(group.name, palette.color(group, role).name()) | ||
theme.endGroup() | ||
|
||
theme.sync() | ||
|
||
|
||
def update_palette() -> None: | ||
"""Update the application palette based on the settings. | ||
|
||
This function retrieves the dark mode value and theme file paths from the settings. | ||
It then determines the dark mode status and loads the appropriate palette from the theme files. | ||
Finally, it sets the application palette and updates the theme hooks. | ||
|
||
Returns: | ||
None | ||
""" | ||
# region XXX: temporarily getting settings data from QApplication.property("driver") | ||
instance = QApplication.instance() | ||
if instance is None: | ||
return | ||
driver = instance.property("driver") | ||
if driver is None: | ||
return | ||
settings: QSettings = driver.settings | ||
|
||
settings.beginGroup("Appearance") | ||
dark_mode_value: str = settings.value("DarkMode", "auto") # type: ignore | ||
dark_theme_file: str | None = settings.value("DarkThemeFile", None) # type: ignore | ||
light_theme_file: str | None = settings.value("LightThemeFile", None) # type: ignore | ||
settings.endGroup() | ||
# endregion | ||
|
||
# TODO: get values of following from settings. | ||
# dark_mode: bool | Literal[-1] | ||
# "True: Dark mode. False: Light mode. auto: System mode." | ||
# dark_theme_file: str | None | ||
# "Path to the dark theme file." | ||
# light_theme_file: str | None | ||
# "Path to the light theme file." | ||
|
||
dark_mode: bool | Literal[-1] | ||
|
||
if dark_mode_value.lower() == "true": | ||
dark_mode = True | ||
elif dark_mode_value.lower() == "false": | ||
dark_mode = False | ||
elif dark_mode_value == "auto": | ||
dark_mode = -1 | ||
else: | ||
logger.warning( | ||
f"Invalid value for DarkMode: {dark_mode_value}. Defaulting to auto." | ||
+ 'possible values: "true", "false", "auto".' | ||
) | ||
dark_mode = -1 | ||
|
||
if dark_mode == -1: | ||
dark_mode = QApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark | ||
|
||
if dark_mode: | ||
if dark_theme_file is None: | ||
palette = QPalette() # default palette | ||
else: | ||
palette = _load_palette_from_file(dark_theme_file, QPalette()) | ||
else: | ||
if light_theme_file is None: | ||
palette = QPalette() # default palette | ||
else: | ||
palette = _load_palette_from_file(light_theme_file, QPalette()) | ||
|
||
QApplication.setPalette(palette) | ||
|
||
_update_theme_hooks() | ||
|
||
|
||
def save_current_palette(theme_file: str) -> None: | ||
_save_palette_to_file(theme_file, QApplication.palette()) | ||
|
||
|
||
# the following signal emits when system theme (Dark, Light) changes (Not accent color). | ||
QApplication.styleHints().colorSchemeChanged.connect(update_palette) | ||
Comment on lines
+174
to
+175
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd like to see this file moved into a ThemeManager class, rather than having this executed on import. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. or should i just move this line into |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,91 @@ | ||
from pathlib import Path | ||
|
||
from PySide6.QtCore import Qt | ||
from PySide6.QtGui import QColor, QPalette | ||
from src.qt.theme import _load_palette_from_file, _save_palette_to_file, update_palette | ||
|
||
|
||
def test_save_palette_to_file(tmp_path: Path): | ||
file = tmp_path / "test_tagstudio_theme.txt" | ||
|
||
pal = QPalette() | ||
pal.setColor(QPalette.ColorGroup.Active, QPalette.ColorRole.Button, QColor("#6E4BCE")) | ||
|
||
_save_palette_to_file(str(file), pal) | ||
|
||
with open(file) as f: | ||
data = f.read() | ||
assert data | ||
|
||
expacted_lines = ( | ||
"[Button]", | ||
"Active=#6e4bce", | ||
) | ||
|
||
for saved, expected in zip(data.splitlines(), expacted_lines): | ||
assert saved == expected | ||
|
||
|
||
def test_load_palette_from_file(tmp_path: Path): | ||
file = tmp_path / "test_tagstudio_theme_2.txt" | ||
|
||
file.write_text("[Button]\nActive=invalid color\n[Window]\nDisabled=#ff0000\nActive=blue") | ||
|
||
pal = _load_palette_from_file(str(file), QPalette()) | ||
|
||
# check if Active Button color is default | ||
active = QPalette.ColorGroup.Active | ||
button = QPalette.ColorRole.Button | ||
assert pal.color(active, button) == QPalette().color(active, button) | ||
|
||
# check if Disabled Window color is #ff0000 | ||
assert pal.color(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Window) == QColor("#ff0000") | ||
# check if Active Window color is #0000ff | ||
assert pal.color(QPalette.ColorGroup.Active, QPalette.ColorRole.Window) == QColor("#0000ff") | ||
|
||
|
||
def test_update_palette(tmp_path: Path) -> None: | ||
settings_file = tmp_path / "test_tagstudio_settings.ini" | ||
dark_theme_file = tmp_path / "test_tagstudio_dark_theme.txt" | ||
light_theme_file = tmp_path / "test_tagstudio_light_theme.txt" | ||
|
||
dark_theme_file.write_text("[Window]\nActive=#1f153a\n") | ||
light_theme_file.write_text("[Window]\nActive=#6e4bce\n") | ||
|
||
settings_file.write_text( | ||
"\n".join( | ||
( | ||
"[Appearance]", | ||
"DarkMode=true", | ||
f"DarkThemeFile={dark_theme_file}".replace("\\", "\\\\"), | ||
f"LightThemeFile={light_theme_file}".replace("\\", "\\\\"), | ||
) | ||
) | ||
) | ||
|
||
# region NOTE: temporary solution for test by making fake driver to use QSettings | ||
from PySide6.QtCore import QSettings | ||
from PySide6.QtWidgets import QApplication | ||
|
||
app = QApplication.instance() or QApplication([]) | ||
|
||
class Driver: | ||
settings = QSettings(str(settings_file), QSettings.Format.IniFormat, app) | ||
|
||
app.setProperty("driver", Driver) | ||
# endregion | ||
|
||
update_palette() | ||
|
||
value = QApplication.palette().color(QPalette.ColorGroup.Active, QPalette.ColorRole.Window) | ||
expected = QColor("#1f153a") | ||
assert value == expected, f"{value.name()} != {expected.name()}" | ||
|
||
Driver.settings.setValue("Appearance/DarkMode", "false") | ||
|
||
# emiting colorSchemeChanged just to make sure the palette updates by colorSchemeChanged signal | ||
QApplication.styleHints().colorSchemeChanged.emit(Qt.ColorScheme.Dark) | ||
|
||
value = QApplication.palette().color(QPalette.ColorGroup.Active, QPalette.ColorRole.Window) | ||
expected = QColor("#6e4bce") | ||
assert value == expected, f"{value.name()} != {expected.name()}" |
Uh oh!
There was an error while loading. Please reload this page.