From 64b6c5b0c57dc8144d0d21eb8abfb820c7b0a15c Mon Sep 17 00:00:00 2001 From: VasigaranAndAngel <72515046+VasigaranAndAngel@users.noreply.github.com> Date: Wed, 18 Sep 2024 23:06:00 +0530 Subject: [PATCH 1/5] 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. --- tagstudio/src/qt/main_window.py | 19 ++-- tagstudio/src/qt/qt_logger.py | 3 + tagstudio/src/qt/theme.py | 170 +++++++++++++++++++++++++++++++ tagstudio/tests/qt/test_theme.py | 91 +++++++++++++++++ 4 files changed, 277 insertions(+), 6 deletions(-) create mode 100644 tagstudio/src/qt/qt_logger.py create mode 100644 tagstudio/src/qt/theme.py create mode 100644 tagstudio/tests/qt/test_theme.py diff --git a/tagstudio/src/qt/main_window.py b/tagstudio/src/qt/main_window.py index 3de0118d8..b7e312b95 100644 --- a/tagstudio/src/qt/main_window.py +++ b/tagstudio/src/qt/main_window.py @@ -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 . 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 @@ -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 self.setupUi(self) # NOTE: These are old attempts to allow for a translucent/acrylic diff --git a/tagstudio/src/qt/qt_logger.py b/tagstudio/src/qt/qt_logger.py new file mode 100644 index 000000000..42234556a --- /dev/null +++ b/tagstudio/src/qt/qt_logger.py @@ -0,0 +1,3 @@ +import structlog + +logger = structlog.get_logger("qt_logger") diff --git a/tagstudio/src/qt/theme.py b/tagstudio/src/qt/theme.py new file mode 100644 index 000000000..8b772bbbd --- /dev/null +++ b/tagstudio/src/qt/theme.py @@ -0,0 +1,170 @@ +from collections.abc import Callable + +from PySide6.QtCore import QSettings, Qt +from PySide6.QtGui import QColor, QPalette +from PySide6.QtWidgets import QApplication + +from .qt_logger import logger + +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.isValidColor(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() + + +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", -1) # 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. -1: System mode." + # dark_theme_file: str | None + # "Path to the dark theme file." + # light_theme_file: str | None + # "Path to the light theme file." + + true_values = ("1", "yes", "true", "on") + false_values = ("0", "no", "false", "off") + + if dark_mode_value.lower() in ("1", "yes", "true", "on"): + dark_mode = True + elif dark_mode_value.lower() in ("0", "no", "false", "off"): + dark_mode = False + elif dark_mode_value == "-1": + dark_mode = -1 + else: + logger.error(f"""Invalid value for DarkMode: {dark_mode_value}. Defaulting to -1. + possible values: {true_values=}, {false_values=}, system=-1""") + 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) diff --git a/tagstudio/tests/qt/test_theme.py b/tagstudio/tests/qt/test_theme.py new file mode 100644 index 000000000..4659b2c76 --- /dev/null +++ b/tagstudio/tests/qt/test_theme.py @@ -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.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()}" From a2c4a5641a24858901af9483411af2e25ee827f0 Mon Sep 17 00:00:00 2001 From: VasigaranAndAngel <72515046+VasigaranAndAngel@users.noreply.github.com> Date: Sat, 21 Sep 2024 00:30:26 +0530 Subject: [PATCH 2/5] Update tagstudio/src/qt/theme.py Co-authored-by: yed --- tagstudio/src/qt/theme.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tagstudio/src/qt/theme.py b/tagstudio/src/qt/theme.py index 8b772bbbd..54d66a693 100644 --- a/tagstudio/src/qt/theme.py +++ b/tagstudio/src/qt/theme.py @@ -132,9 +132,9 @@ def update_palette() -> None: true_values = ("1", "yes", "true", "on") false_values = ("0", "no", "false", "off") - if dark_mode_value.lower() in ("1", "yes", "true", "on"): + if dark_mode_value.lower() in true_values: dark_mode = True - elif dark_mode_value.lower() in ("0", "no", "false", "off"): + elif dark_mode_value.lower() in false_values: dark_mode = False elif dark_mode_value == "-1": dark_mode = -1 From a4912eac9845048d329a9c44c7b435f3af82679a Mon Sep 17 00:00:00 2001 From: VasigaranAndAngel <72515046+VasigaranAndAngel@users.noreply.github.com> Date: Sat, 16 Nov 2024 21:09:59 +0530 Subject: [PATCH 3/5] minor fixes --- tagstudio/src/qt/theme.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tagstudio/src/qt/theme.py b/tagstudio/src/qt/theme.py index 54d66a693..623c6e50b 100644 --- a/tagstudio/src/qt/theme.py +++ b/tagstudio/src/qt/theme.py @@ -1,4 +1,5 @@ from collections.abc import Callable +from typing import Literal from PySide6.QtCore import QSettings, Qt from PySide6.QtGui import QColor, QPalette @@ -115,7 +116,7 @@ def update_palette() -> None: settings: QSettings = driver.settings settings.beginGroup("Appearance") - dark_mode_value: str = settings.value("DarkMode", -1) # type: ignore + dark_mode_value: str = settings.value("DarkMode", "-1") # 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() @@ -129,6 +130,7 @@ def update_palette() -> None: # light_theme_file: str | None # "Path to the light theme file." + dark_mode: bool | Literal[-1] true_values = ("1", "yes", "true", "on") false_values = ("0", "no", "false", "off") From 748b11bb501fd3c2a2d74aa9df0546a71f76e425 Mon Sep 17 00:00:00 2001 From: VasigaranAndAngel <72515046+VasigaranAndAngel@users.noreply.github.com> Date: Sat, 16 Nov 2024 21:51:43 +0530 Subject: [PATCH 4/5] pytest fix --- tagstudio/tests/qt/test_theme.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tagstudio/tests/qt/test_theme.py b/tagstudio/tests/qt/test_theme.py index 4659b2c76..a62237cf8 100644 --- a/tagstudio/tests/qt/test_theme.py +++ b/tagstudio/tests/qt/test_theme.py @@ -27,7 +27,7 @@ def test_save_palette_to_file(tmp_path: Path): def test_load_palette_from_file(tmp_path: Path): - file = tmp_path / "test_tagstudio_theme.txt" + file = tmp_path / "test_tagstudio_theme_2.txt" file.write_text("[Button]\nActive=invalid color\n[Window]\nDisabled=#ff0000\nActive=blue") From fc82847c6541d0fc69d45b0f1c2fa2f7d64797c4 Mon Sep 17 00:00:00 2001 From: VasigaranAndAngel <72515046+VasigaranAndAngel@users.noreply.github.com> Date: Sun, 17 Nov 2024 12:59:31 +0530 Subject: [PATCH 5/5] addressed requested changes + minor fixes --- tagstudio/src/qt/main_window.py | 2 +- tagstudio/src/qt/qt_logger.py | 3 --- tagstudio/src/qt/theme.py | 25 ++++++++++++++----------- 3 files changed, 15 insertions(+), 15 deletions(-) delete mode 100644 tagstudio/src/qt/qt_logger.py diff --git a/tagstudio/src/qt/main_window.py b/tagstudio/src/qt/main_window.py index b7e312b95..8ede41ef5 100644 --- a/tagstudio/src/qt/main_window.py +++ b/tagstudio/src/qt/main_window.py @@ -27,7 +27,7 @@ from src.qt.pagination import Pagination from src.qt.widgets.landing import LandingWidget -from . import theme +from src.qt import theme # Only import for type checking/autocompletion, will not be imported at runtime. if typing.TYPE_CHECKING: diff --git a/tagstudio/src/qt/qt_logger.py b/tagstudio/src/qt/qt_logger.py deleted file mode 100644 index 42234556a..000000000 --- a/tagstudio/src/qt/qt_logger.py +++ /dev/null @@ -1,3 +0,0 @@ -import structlog - -logger = structlog.get_logger("qt_logger") diff --git a/tagstudio/src/qt/theme.py b/tagstudio/src/qt/theme.py index 623c6e50b..5a2a6b40c 100644 --- a/tagstudio/src/qt/theme.py +++ b/tagstudio/src/qt/theme.py @@ -1,11 +1,12 @@ 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 -from .qt_logger import logger +logger = structlog.get_logger("theme") theme_update_hooks: list[Callable[[], None]] = [] "List of callables that will be called when any theme is changed." @@ -52,7 +53,7 @@ def _load_palette_from_file(file_path: str, default_palette: QPalette) -> QPalet 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.isValidColor(value): + if value is not None and QColor.isValidColorName(value): pal.setColor(group, role, QColor(value)) return pal @@ -95,6 +96,8 @@ def _save_palette_to_file(file_path: str, palette: QPalette) -> None: 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. @@ -116,7 +119,7 @@ def update_palette() -> None: settings: QSettings = driver.settings settings.beginGroup("Appearance") - dark_mode_value: str = settings.value("DarkMode", "-1") # type: ignore + 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() @@ -124,25 +127,25 @@ def update_palette() -> None: # TODO: get values of following from settings. # dark_mode: bool | Literal[-1] - # "True: Dark mode. False: Light mode. -1: System mode." + # "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] - true_values = ("1", "yes", "true", "on") - false_values = ("0", "no", "false", "off") - if dark_mode_value.lower() in true_values: + if dark_mode_value.lower() == "true": dark_mode = True - elif dark_mode_value.lower() in false_values: + elif dark_mode_value.lower() == "false": dark_mode = False - elif dark_mode_value == "-1": + elif dark_mode_value == "auto": dark_mode = -1 else: - logger.error(f"""Invalid value for DarkMode: {dark_mode_value}. Defaulting to -1. - possible values: {true_values=}, {false_values=}, system=-1""") + 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: