Add PCN theme system

Coexist with the old theme system for now. QML components have to be
updated to use the new system.
This commit is contained in:
miruka 2020-11-17 08:24:55 -04:00
parent 1af1d30c48
commit 42f04b013e
15 changed files with 159 additions and 16 deletions

View File

@ -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,
)

View File

@ -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."""

View File

@ -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__

View File

@ -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

8
src/gui/Base/Class.qml Normal file
View File

@ -0,0 +1,8 @@
// Copyright Mirage authors & contributors <https://github.com/mirukana/mirage>
// SPDX-License-Identifier: LGPL-3.0-or-later
import QtQuick 2.12
QtObject {
property string name
}

View File

@ -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 :

25
src/gui/Base/Theme.qml Normal file
View File

@ -0,0 +1,25 @@
// Copyright Mirage authors & contributors <https://github.com/mirukana/mirage>
// SPDX-License-Identifier: LGPL-3.0-or-later
import QtQuick 2.12
QtObject {
id: root
property Item target
default property list<Class> 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
}
}

View File

@ -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()

View File

@ -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

View File

@ -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(

View File

@ -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

View File

@ -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

View File

@ -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", []) }

View File

@ -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("") + "$")
}
}

View File

@ -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: ({})