From 42f04b013ef6d1d52c32d2eeed07fadd83b1eca2 Mon Sep 17 00:00:00 2001 From: miruka Date: Tue, 17 Nov 2020 08:24:55 -0400 Subject: [PATCH] Add PCN theme system Coexist with the old theme system for now. QML components have to be updated to use the new system. --- src/backend/backend.py | 6 ++- src/backend/user_files.py | 33 +++++++++++++- src/backend/utils.py | 44 ++++++++++++++++++- src/config/settings.py | 1 + src/gui/Base/Class.qml | 8 ++++ src/gui/Base/HButton.qml | 7 ++- src/gui/Base/Theme.qml | 25 +++++++++++ src/gui/MainPane/BottomBar.qml | 1 - src/gui/Pages/Chat/Composer/Composer.qml | 7 +++ src/gui/Pages/Chat/Composer/UploadButton.qml | 3 +- .../Chat/RoomPane/MemberView/MemberView.qml | 2 - src/gui/PythonBridge/EventHandlers.qml | 1 + src/gui/PythonBridge/PythonRootBridge.qml | 13 +++--- src/gui/Utils.qml | 23 ++++++++++ src/gui/Window.qml | 1 + 15 files changed, 159 insertions(+), 16 deletions(-) create mode 100644 src/gui/Base/Class.qml create mode 100644 src/gui/Base/Theme.qml diff --git a/src/backend/backend.py b/src/backend/backend.py index 762aa3fe..8012685c 100644 --- a/src/backend/backend.py +++ b/src/backend/backend.py @@ -28,7 +28,7 @@ from .models.model import Model from .models.model_store import ModelStore from .presence import Presence from .sso_server import SSOServer -from .user_files import Accounts, History, Settings, Theme, UIState +from .user_files import Accounts, History, NewTheme, Settings, Theme, UIState # Logging configuration log.getLogger().setLevel(log.INFO) @@ -109,6 +109,7 @@ class Backend: self.ui_state = UIState(self) self.history = History(self) self.theme = Theme(self, self.settings.General.theme) + self.new_theme = NewTheme(self, self.settings.General.new_theme) self.clients: Dict[str, MatrixClient] = {} @@ -426,13 +427,14 @@ class Backend: return path - async def get_settings(self) -> Tuple[dict, UIState, History, str]: + async def get_settings(self) -> Tuple[dict, UIState, History, str, dict]: """Return parsed user config files for QML.""" return ( self.settings.qml_data, self.ui_state.qml_data, self.history.qml_data, self.theme.qml_data, + self.new_theme.qml_data, ) diff --git a/src/backend/user_files.py b/src/backend/user_files.py index 3a3932a2..734bd3c9 100644 --- a/src/backend/user_files.py +++ b/src/backend/user_files.py @@ -22,6 +22,7 @@ from .pyotherside_events import LoopException, UserFileChanged from .theme_parser import convert_to_qml from .utils import ( aiopen, atomic_write, deep_serialize_for_qml, dict_update_recursive, + flatten_dict_keys, ) if TYPE_CHECKING: @@ -269,6 +270,10 @@ class PCNFile(MappingFile): def qml_data(self) -> Dict[str, Any]: return deep_serialize_for_qml(self.data.as_dict()) # type: ignore + @property + def default_data(self) -> Section: + return Section() + def deserialized(self, data: str) -> Tuple[Section, bool]: root = Section.from_source_code(data, self.path) edits = "{}" @@ -379,11 +384,37 @@ class Settings(ConfigFile, PCNFile): self.backend.theme = Theme( self.backend, section.General.theme, # type: ignore ) - UserFileChanged(Theme, self.backend.theme.data) + UserFileChanged(Theme, self.backend.theme.qml_data) + + if self and self.General.new_theme != section.General.new_theme: + self.backend.new_theme.stop_watching() + self.backend.new_theme = NewTheme( + self.backend, section.General.new_theme, # type: ignore + ) + UserFileChanged(Theme, self.backend.new_theme.qml_data) return (section, save) +@dataclass +class NewTheme(UserDataFile, PCNFile): + """A theme file defining the look of QML components.""" + + create_missing = False + + @property + def path(self) -> Path: + data_dir = Path( + os.environ.get("MIRAGE_DATA_DIR") or + self.backend.appdirs.user_data_dir, + ) + return data_dir / "themes" / self.filename + + @property + def qml_data(self) -> Dict[str, Any]: + return flatten_dict_keys(super().qml_data, last_level=False) + + @dataclass class UIState(UserDataFile, JSONFile): """File used to save and restore the state of QML components.""" diff --git a/src/backend/utils.py b/src/backend/utils.py index cb4bb5fd..899676d1 100644 --- a/src/backend/utils.py +++ b/src/backend/utils.py @@ -20,8 +20,8 @@ from pathlib import Path from tempfile import NamedTemporaryFile from types import ModuleType from typing import ( - Any, AsyncIterator, Callable, Dict, Iterable, Mapping, Sequence, Tuple, - Type, Union, + Any, AsyncIterator, Callable, Dict, Iterable, Mapping, Optional, Sequence, + Tuple, Type, Union, ) from uuid import UUID @@ -32,6 +32,8 @@ from nio.crypto import AsyncDataT as File from nio.crypto import async_generator_from_data from PIL import Image as PILImage +from .color import Color + if sys.version_info >= (3, 7): from contextlib import asynccontextmanager else: @@ -70,6 +72,39 @@ def dict_update_recursive(dict1: dict, dict2: dict) -> None: dict1[k] = dict2[k] +def flatten_dict_keys( + source: Optional[Dict[str, Any]] = None, + separator: str = ".", + last_level: bool = True, + _flat: Optional[Dict[str, Any]] = None, + _prefix: str = "", +) -> Dict[str, Any]: + """Return a flattened version of the ``source`` dict. + + Example: + >>> dct + {"content": {"body": "foo"}, "m.test": {"key": {"bar": 1}}} + >>> flatten_dict_keys(dct) + {"content.body": "foo", "m.test.key.bar": 1} + >>> flatten_dict_keys(dct, last_level=False) + {"content": {"body": "foo"}, "m.test.key": {bar": 1}} + """ + + flat = {} if _flat is None else _flat + + for key, value in (source or {}).items(): + if isinstance(value, dict): + prefix = f"{_prefix}{key}{separator}" + flatten_dict_keys(value, separator, last_level, flat, prefix) + elif last_level: + flat[f"{_prefix}{key}"] = value + else: + prefix = _prefix[:-len(separator)] # remove trailing separator + flat.setdefault(prefix, {})[key] = value + + return flat + + async def is_svg(file: File) -> bool: """Return whether the file is a SVG (`lxml` is used for detection).""" @@ -168,6 +203,8 @@ def serialize_value_for_qml( - For `timedelta` objects: the delta as a number of milliseconds `int` + - For `Color` objects: the color's hexadecimal value + - For class types: the class `__name__` - For anything else: raise a `TypeError` if `reject_unknown` is `True`, @@ -198,6 +235,9 @@ def serialize_value_for_qml( if isinstance(value, timedelta): return value.total_seconds() * 1000 + if isinstance(value, Color): + return value.hex + if inspect.isclass(value): return value.__name__ diff --git a/src/config/settings.py b/src/config/settings.py index 44488d62..ca6354b0 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -28,6 +28,7 @@ class General: # For Flatpak, it is # "~/.var/app/io.github.mirukana.mirage/data/mirage/themes". theme: str = "Midnight.qpl" + new_theme: str = "test.py" # Interface scale multiplier, e.g. 0.5 makes everything half-size. zoom: float = 1.0 diff --git a/src/gui/Base/Class.qml b/src/gui/Base/Class.qml new file mode 100644 index 00000000..ea089350 --- /dev/null +++ b/src/gui/Base/Class.qml @@ -0,0 +1,8 @@ +// Copyright Mirage authors & contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +import QtQuick 2.12 + +QtObject { + property string name +} diff --git a/src/gui/Base/HButton.qml b/src/gui/Base/HButton.qml index ce0e1828..5afa82ab 100644 --- a/src/gui/Base/HButton.qml +++ b/src/gui/Base/HButton.qml @@ -8,10 +8,15 @@ import QtQuick.Layouts 1.12 Button { id: button + property Theme ntheme: Theme { + target: button + classes: Class { name: "Button" } + } + readonly property alias iconItem: contentItem.icon readonly property alias label: contentItem.label - property color backgroundColor: theme.controls.button.background + property color backgroundColor: ntheme.data.background property color focusLineColor: Qt.colorEqual(icon.color, theme.icons.colorize) ? theme.controls.button.focusedBorder : diff --git a/src/gui/Base/Theme.qml b/src/gui/Base/Theme.qml new file mode 100644 index 00000000..d5bc28b2 --- /dev/null +++ b/src/gui/Base/Theme.qml @@ -0,0 +1,25 @@ +// Copyright Mirage authors & contributors +// SPDX-License-Identifier: LGPL-3.0-or-later + +import QtQuick 2.12 + +QtObject { + id: root + + property Item target + default property list classes + + readonly property var matchablePathRegex: utils.getClassPathRegex(target) + readonly property var themeRules: window.themeRules + readonly property var data: { + const newData = {} + + for (const [path, section] of Object.entries(themeRules)) + if (matchablePathRegex.test(path)) + for (const [name, value] of Object.entries(section)) + if (! name.startsWith("_")) + newData[name] = value + + return newData + } +} diff --git a/src/gui/MainPane/BottomBar.qml b/src/gui/MainPane/BottomBar.qml index 0f866674..cb280023 100644 --- a/src/gui/MainPane/BottomBar.qml +++ b/src/gui/MainPane/BottomBar.qml @@ -22,7 +22,6 @@ Rectangle { id: addAccountButton icon.name: "add-account" toolTip.text: qsTr("Add another account") - backgroundColor: theme.mainPane.bottomBar.settingsButtonBackground onClicked: { pageLoader.show("Pages/AddAccount/AddAccount.qml") roomList.startCorrectItemSearch() diff --git a/src/gui/Pages/Chat/Composer/Composer.qml b/src/gui/Pages/Chat/Composer/Composer.qml index 5c96165d..d6183b52 100644 --- a/src/gui/Pages/Chat/Composer/Composer.qml +++ b/src/gui/Pages/Chat/Composer/Composer.qml @@ -8,6 +8,13 @@ import "../../../Base" import "../AutoCompletion" Rectangle { + id: composer + + property Theme ntheme: Theme { + target: composer + classes: Class { name: "Composer" } + } + property UserAutoCompletion userCompletion property alias eventList: messageArea.eventList diff --git a/src/gui/Pages/Chat/Composer/UploadButton.qml b/src/gui/Pages/Chat/Composer/UploadButton.qml index 881c0f30..accddd2f 100644 --- a/src/gui/Pages/Chat/Composer/UploadButton.qml +++ b/src/gui/Pages/Chat/Composer/UploadButton.qml @@ -8,9 +8,10 @@ import "../../../Base" import "../../../Dialogs" HButton { + ntheme.classes: Class { name: "UploadButton" } + enabled: chat.roomInfo.can_send_messages icon.name: "upload-file" - backgroundColor: theme.chat.composer.uploadButton.background toolTip.text: chat.userInfo.max_upload_size ? qsTr("Send files (%1 max)").arg( diff --git a/src/gui/Pages/Chat/RoomPane/MemberView/MemberView.qml b/src/gui/Pages/Chat/RoomPane/MemberView/MemberView.qml index e272735d..a5374f08 100644 --- a/src/gui/Pages/Chat/RoomPane/MemberView/MemberView.qml +++ b/src/gui/Pages/Chat/RoomPane/MemberView/MemberView.qml @@ -149,8 +149,6 @@ HColumnLayout { HButton { id: inviteButton icon.name: "room-send-invite" - backgroundColor: - theme.chat.roomPane.bottomBar.inviteButton.background enabled: chat.userInfo.presence !== "offline" && chat.roomInfo.can_invite diff --git a/src/gui/PythonBridge/EventHandlers.qml b/src/gui/PythonBridge/EventHandlers.qml index d15ce450..4686d4c2 100644 --- a/src/gui/PythonBridge/EventHandlers.qml +++ b/src/gui/PythonBridge/EventHandlers.qml @@ -73,6 +73,7 @@ QtObject { } type === "Settings" ? window.settings = newData : + type === "NewTheme" ? window.themeRules = newData : type === "UIState" ? window.uiState = newData : type === "History" ? window.history = newData : null diff --git a/src/gui/PythonBridge/PythonRootBridge.qml b/src/gui/PythonBridge/PythonRootBridge.qml index 7cbbc90b..b3ab9c62 100644 --- a/src/gui/PythonBridge/PythonRootBridge.qml +++ b/src/gui/PythonBridge/PythonRootBridge.qml @@ -21,12 +21,13 @@ PythonBridge { addImportPath("qrc:/src") importNames("backend.qml_bridge", ["BRIDGE"], () => { - callCoro("get_settings", [], ([settings, state, hist, theme]) => { - window.settings = settings - window.uiState = state - window.history = hist - window.theme = Qt.createQmlObject(theme, window, "theme") - utils.theme = window.theme + callCoro("get_settings", [], ([settings, state, hist, theme, themeRules]) => { + window.settings = settings + window.uiState = state + window.history = hist + window.theme = Qt.createQmlObject(theme, window, "theme") + utils.theme = window.theme + window.themeRules = themeRules callCoro("saved_accounts.any_saved", [], any => { if (any) { callCoro("load_saved_accounts", []) } diff --git a/src/gui/Utils.qml b/src/gui/Utils.qml index 1dbc3480..4ab34152 100644 --- a/src/gui/Utils.qml +++ b/src/gui/Utils.qml @@ -519,4 +519,27 @@ QtObject { return {word, start, end: seen} } + + function getClassPathRegex(obj) { + const regexParts = [] + let parent = obj + + while (parent) { + if (! parent.ntheme || ! parent.ntheme.classes.length) { + parent = parent.parent + continue + } + + const names = [] + const end = regexParts.length ? "\\.)?" : ")" + + for (let i = 0; i < parent.ntheme.classes.length; i++) + names.push(parent.ntheme.classes[i].name) + + regexParts.push("(" + names.join("|") + end) + parent = parent.parent + } + + return new RegExp("^" + regexParts.reverse().join("") + "$") + } } diff --git a/src/gui/Window.qml b/src/gui/Window.qml index 4e063243..9370d7b4 100644 --- a/src/gui/Window.qml +++ b/src/gui/Window.qml @@ -28,6 +28,7 @@ ApplicationWindow { property var uiState: ({}) property var history: ({}) property var theme: null + property var themeRules: null property string settingsFolder readonly property var visibleMenus: ({})