Skip to content

Commit 64b6c5b

Browse files
New theme management system
main_window.py: put driver to application property and update theme palette. qt_logger.py: contains a logger which will be used in ui related codes. theme.py: New theme management system. test_theme.py: tests for theme management system.
1 parent a535ed1 commit 64b6c5b

File tree

4 files changed

+277
-6
lines changed

4 files changed

+277
-6
lines changed

tagstudio/src/qt/main_window.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,20 @@
1515

1616
import logging
1717
import typing
18-
from PySide6.QtCore import (QCoreApplication, QMetaObject, QRect,QSize, Qt, QStringListModel)
18+
19+
from PySide6.QtCore import (QCoreApplication, QMetaObject, QRect, QSize,
20+
QStringListModel, Qt)
1921
from PySide6.QtGui import QFont
20-
from PySide6.QtWidgets import (QComboBox, QFrame, QGridLayout,
21-
QHBoxLayout, QVBoxLayout, QLayout, QLineEdit, QMainWindow,
22-
QPushButton, QScrollArea, QSizePolicy,
23-
QStatusBar, QWidget, QSplitter, QCheckBox,
24-
QSpacerItem, QCompleter)
22+
from PySide6.QtWidgets import (QApplication, QCheckBox, QComboBox, QCompleter,
23+
QFrame, QGridLayout, QHBoxLayout, QLayout,
24+
QLineEdit, QMainWindow, QPushButton,
25+
QScrollArea, QSizePolicy, QSpacerItem,
26+
QSplitter, QStatusBar, QVBoxLayout, QWidget)
2527
from src.qt.pagination import Pagination
2628
from src.qt.widgets.landing import LandingWidget
2729

30+
from . import theme
31+
2832
# Only import for type checking/autocompletion, will not be imported at runtime.
2933
if typing.TYPE_CHECKING:
3034
from src.qt.ts_qt import QtDriver
@@ -37,6 +41,9 @@ class Ui_MainWindow(QMainWindow):
3741
def __init__(self, driver: "QtDriver", parent=None) -> None:
3842
super().__init__(parent)
3943
self.driver: "QtDriver" = driver
44+
# temporarily putting driver to application property
45+
(QApplication.instance() or self.parent()).setProperty("driver", driver)
46+
theme.update_palette() # update palette according to theme settings
4047
self.setupUi(self)
4148

4249
# NOTE: These are old attempts to allow for a translucent/acrylic

tagstudio/src/qt/qt_logger.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import structlog
2+
3+
logger = structlog.get_logger("qt_logger")

tagstudio/src/qt/theme.py

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
from collections.abc import Callable
2+
3+
from PySide6.QtCore import QSettings, Qt
4+
from PySide6.QtGui import QColor, QPalette
5+
from PySide6.QtWidgets import QApplication
6+
7+
from .qt_logger import logger
8+
9+
theme_update_hooks: list[Callable[[], None]] = []
10+
"List of callables that will be called when any theme is changed."
11+
12+
13+
def _update_theme_hooks() -> None:
14+
"""Update all theme hooks by calling each hook in the list."""
15+
for hook in theme_update_hooks:
16+
try:
17+
hook()
18+
except Exception as e:
19+
logger.error(e)
20+
21+
22+
def _load_palette_from_file(file_path: str, default_palette: QPalette) -> QPalette:
23+
"""Load a palette from a file and update the default palette with the loaded colors.
24+
25+
The file should be in the INI format and should have the following format:
26+
27+
[ColorRoleName]
28+
ColorGroupName = Color
29+
30+
ColorRoleName is the name of the color role (e.g. Window, Button, etc.)
31+
ColorGroupName is the name of the color group (e.g. Active, Inactive, Disabled, etc.)
32+
Color is the color value in the QColor supported format (e.g. #RRGGBB, blue, etc.)
33+
34+
Args:
35+
file_path (str): The path to the file containing color information.
36+
default_palette (QPalette): The default palette to be updated with the colors.
37+
38+
Returns:
39+
QPalette: The updated palette based on the colors specified in the file.
40+
"""
41+
theme = QSettings(file_path, QSettings.Format.IniFormat, QApplication.instance())
42+
43+
color_groups = (
44+
QPalette.ColorGroup.Active,
45+
QPalette.ColorGroup.Inactive,
46+
QPalette.ColorGroup.Disabled,
47+
)
48+
49+
pal = default_palette
50+
51+
for role in list(QPalette.ColorRole)[:-1]: # remove last color role (NColorRoles)
52+
for group in color_groups:
53+
value: str | None = theme.value(f"{role.name}/{group.name}", None, str) # type: ignore
54+
if value is not None and QColor.isValidColor(value):
55+
pal.setColor(group, role, QColor(value))
56+
57+
return pal
58+
59+
60+
def _save_palette_to_file(file_path: str, palette: QPalette) -> None:
61+
"""Save the given palette colors to a file in INI format, if the color is not default.
62+
63+
If no color is changed, the file won't be created or changed.
64+
65+
The file will be in the INI format and will have the following format:
66+
67+
[ColorRoleName]
68+
ColorGroupName = Color
69+
70+
ColorRoleName is the name of the color role (e.g. Window, Button, etc.)
71+
ColorGroupName is the name of the color group (e.g. Active, Inactive, Disabled, etc.)
72+
Color is the color value in the RgbHex (#RRGGBB) or ArgbHex (#AARRGGBB) format.
73+
74+
Args:
75+
file_path (str): The path to the file where the palette will be saved.
76+
palette (QPalette): The palette to be saved.
77+
78+
Returns:
79+
None
80+
"""
81+
theme = QSettings(file_path, QSettings.Format.IniFormat, QApplication.instance())
82+
83+
color_groups = (
84+
QPalette.ColorGroup.Active,
85+
QPalette.ColorGroup.Inactive,
86+
QPalette.ColorGroup.Disabled,
87+
)
88+
default_pal = QPalette()
89+
90+
for role in list(QPalette.ColorRole)[:-1]: # remove last color role (NColorRoles)
91+
theme.beginGroup(role.name)
92+
for group in color_groups:
93+
if default_pal.color(group, role) != palette.color(group, role):
94+
theme.setValue(group.name, palette.color(group, role).name())
95+
theme.endGroup()
96+
97+
98+
def update_palette() -> None:
99+
"""Update the application palette based on the settings.
100+
101+
This function retrieves the dark mode value and theme file paths from the settings.
102+
It then determines the dark mode status and loads the appropriate palette from the theme files.
103+
Finally, it sets the application palette and updates the theme hooks.
104+
105+
Returns:
106+
None
107+
"""
108+
# region XXX: temporarily getting settings data from QApplication.property("driver")
109+
instance = QApplication.instance()
110+
if instance is None:
111+
return
112+
driver = instance.property("driver")
113+
if driver is None:
114+
return
115+
settings: QSettings = driver.settings
116+
117+
settings.beginGroup("Appearance")
118+
dark_mode_value: str = settings.value("DarkMode", -1) # type: ignore
119+
dark_theme_file: str | None = settings.value("DarkThemeFile", None) # type: ignore
120+
light_theme_file: str | None = settings.value("LightThemeFile", None) # type: ignore
121+
settings.endGroup()
122+
# endregion
123+
124+
# TODO: get values of following from settings.
125+
# dark_mode: bool | Literal[-1]
126+
# "True: Dark mode. False: Light mode. -1: System mode."
127+
# dark_theme_file: str | None
128+
# "Path to the dark theme file."
129+
# light_theme_file: str | None
130+
# "Path to the light theme file."
131+
132+
true_values = ("1", "yes", "true", "on")
133+
false_values = ("0", "no", "false", "off")
134+
135+
if dark_mode_value.lower() in ("1", "yes", "true", "on"):
136+
dark_mode = True
137+
elif dark_mode_value.lower() in ("0", "no", "false", "off"):
138+
dark_mode = False
139+
elif dark_mode_value == "-1":
140+
dark_mode = -1
141+
else:
142+
logger.error(f"""Invalid value for DarkMode: {dark_mode_value}. Defaulting to -1.
143+
possible values: {true_values=}, {false_values=}, system=-1""")
144+
dark_mode = -1
145+
146+
if dark_mode == -1:
147+
dark_mode = QApplication.styleHints().colorScheme() is Qt.ColorScheme.Dark
148+
149+
if dark_mode:
150+
if dark_theme_file is None:
151+
palette = QPalette() # default palette
152+
else:
153+
palette = _load_palette_from_file(dark_theme_file, QPalette())
154+
else:
155+
if light_theme_file is None:
156+
palette = QPalette() # default palette
157+
else:
158+
palette = _load_palette_from_file(light_theme_file, QPalette())
159+
160+
QApplication.setPalette(palette)
161+
162+
_update_theme_hooks()
163+
164+
165+
def save_current_palette(theme_file: str) -> None:
166+
_save_palette_to_file(theme_file, QApplication.palette())
167+
168+
169+
# the following signal emits when system theme (Dark, Light) changes (Not accent color).
170+
QApplication.styleHints().colorSchemeChanged.connect(update_palette)

tagstudio/tests/qt/test_theme.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
from pathlib import Path
2+
3+
from PySide6.QtCore import Qt
4+
from PySide6.QtGui import QColor, QPalette
5+
from src.qt.theme import _load_palette_from_file, _save_palette_to_file, update_palette
6+
7+
8+
def test_save_palette_to_file(tmp_path: Path):
9+
file = tmp_path / "test_tagstudio_theme.txt"
10+
11+
pal = QPalette()
12+
pal.setColor(QPalette.ColorGroup.Active, QPalette.ColorRole.Button, QColor("#6E4BCE"))
13+
14+
_save_palette_to_file(str(file), pal)
15+
16+
with open(file) as f:
17+
data = f.read()
18+
assert data
19+
20+
expacted_lines = (
21+
"[Button]",
22+
"Active=#6e4bce",
23+
)
24+
25+
for saved, expected in zip(data.splitlines(), expacted_lines):
26+
assert saved == expected
27+
28+
29+
def test_load_palette_from_file(tmp_path: Path):
30+
file = tmp_path / "test_tagstudio_theme.txt"
31+
32+
file.write_text("[Button]\nActive=invalid color\n[Window]\nDisabled=#ff0000\nActive=blue")
33+
34+
pal = _load_palette_from_file(str(file), QPalette())
35+
36+
# check if Active Button color is default
37+
active = QPalette.ColorGroup.Active
38+
button = QPalette.ColorRole.Button
39+
assert pal.color(active, button) == QPalette().color(active, button)
40+
41+
# check if Disabled Window color is #ff0000
42+
assert pal.color(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Window) == QColor("#ff0000")
43+
# check if Active Window color is #0000ff
44+
assert pal.color(QPalette.ColorGroup.Active, QPalette.ColorRole.Window) == QColor("#0000ff")
45+
46+
47+
def test_update_palette(tmp_path: Path) -> None:
48+
settings_file = tmp_path / "test_tagstudio_settings.ini"
49+
dark_theme_file = tmp_path / "test_tagstudio_dark_theme.txt"
50+
light_theme_file = tmp_path / "test_tagstudio_light_theme.txt"
51+
52+
dark_theme_file.write_text("[Window]\nActive=#1f153a\n")
53+
light_theme_file.write_text("[Window]\nActive=#6e4bce\n")
54+
55+
settings_file.write_text(
56+
"\n".join(
57+
(
58+
"[Appearance]",
59+
"DarkMode=true",
60+
f"DarkThemeFile={dark_theme_file}".replace("\\", "\\\\"),
61+
f"LightThemeFile={light_theme_file}".replace("\\", "\\\\"),
62+
)
63+
)
64+
)
65+
66+
# region NOTE: temporary solution for test by making fake driver to use QSettings
67+
from PySide6.QtCore import QSettings
68+
from PySide6.QtWidgets import QApplication
69+
70+
app = QApplication.instance() or QApplication([])
71+
72+
class Driver:
73+
settings = QSettings(str(settings_file), QSettings.Format.IniFormat, app)
74+
75+
app.setProperty("driver", Driver)
76+
# endregion
77+
78+
update_palette()
79+
80+
value = QApplication.palette().color(QPalette.ColorGroup.Active, QPalette.ColorRole.Window)
81+
expected = QColor("#1f153a")
82+
assert value == expected, f"{value.name()} != {expected.name()}"
83+
84+
Driver.settings.setValue("Appearance/DarkMode", "false")
85+
86+
# emiting colorSchemeChanged just to make sure the palette updates by colorSchemeChanged signal
87+
QApplication.styleHints().colorSchemeChanged.emit(Qt.ColorScheme.Dark)
88+
89+
value = QApplication.palette().color(QPalette.ColorGroup.Active, QPalette.ColorRole.Window)
90+
expected = QColor("#6e4bce")
91+
assert value == expected, f"{value.name()} != {expected.name()}"

0 commit comments

Comments
 (0)