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:
parent
1af1d30c48
commit
42f04b013e
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -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."""
|
||||
|
|
|
@ -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__
|
||||
|
||||
|
|
|
@ -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
8
src/gui/Base/Class.qml
Normal 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
|
||||
}
|
|
@ -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
25
src/gui/Base/Theme.qml
Normal 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
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -21,12 +21,13 @@ PythonBridge {
|
|||
addImportPath("qrc:/src")
|
||||
|
||||
importNames("backend.qml_bridge", ["BRIDGE"], () => {
|
||||
callCoro("get_settings", [], ([settings, state, hist, 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", []) }
|
||||
|
|
|
@ -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("") + "$")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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: ({})
|
||||
|
|
Loading…
Reference in New Issue
Block a user