Skip to content
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

feat: Theme manager #587

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 13 additions & 6 deletions tagstudio/src/qt/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,20 @@

import logging
import typing
from PySide6.QtCore import (QCoreApplication, QMetaObject, QRect,QSize, Qt, QStringListModel)

from PySide6.QtCore import (QCoreApplication, QMetaObject, QRect, QSize,
QStringListModel, Qt)
from PySide6.QtGui import QFont
from PySide6.QtWidgets import (QComboBox, QFrame, QGridLayout,
QHBoxLayout, QVBoxLayout, QLayout, QLineEdit, QMainWindow,
QPushButton, QScrollArea, QSizePolicy,
QStatusBar, QWidget, QSplitter, QCheckBox,
QSpacerItem, QCompleter)
from PySide6.QtWidgets import (QApplication, QCheckBox, QComboBox, QCompleter,
QFrame, QGridLayout, QHBoxLayout, QLayout,
QLineEdit, QMainWindow, QPushButton,
QScrollArea, QSizePolicy, QSpacerItem,
QSplitter, QStatusBar, QVBoxLayout, QWidget)
from src.qt.pagination import Pagination
from src.qt.widgets.landing import LandingWidget

from src.qt import theme

# Only import for type checking/autocompletion, will not be imported at runtime.
if typing.TYPE_CHECKING:
from src.qt.ts_qt import QtDriver
Expand All @@ -37,6 +41,9 @@ class Ui_MainWindow(QMainWindow):
def __init__(self, driver: "QtDriver", parent=None) -> None:
super().__init__(parent)
self.driver: "QtDriver" = driver
# temporarily putting driver to application property
(QApplication.instance() or self.parent()).setProperty("driver", driver)
theme.update_palette() # update palette according to theme settings
VasigaranAndAngel marked this conversation as resolved.
Show resolved Hide resolved
self.setupUi(self)

# NOTE: These are old attempts to allow for a translucent/acrylic
Expand Down
175 changes: 175 additions & 0 deletions tagstudio/src/qt/theme.py
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
Copy link
Collaborator

Choose a reason for hiding this comment

The 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.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or should i just move this line into Ui_MainWindow?

91 changes: 91 additions & 0 deletions tagstudio/tests/qt/test_theme.py
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()}"
Loading