-
-
Notifications
You must be signed in to change notification settings - Fork 396
feat: Theme manager #587
New issue
Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? # to your account
Open
VasigaranAndAngel
wants to merge
5
commits into
TagStudioDev:main
Choose a base branch
from
VasigaranAndAngel:theme-management
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
feat: Theme manager #587
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
64b6c5b
New theme management system
VasigaranAndAngel a2c4a56
Update tagstudio/src/qt/theme.py
VasigaranAndAngel a4912ea
minor fixes
VasigaranAndAngel 748b11b
pytest fix
VasigaranAndAngel fc82847
addressed requested changes + minor fixes
VasigaranAndAngel File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()}" |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.