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 .models.model_store import ModelStore
|
||||||
from .presence import Presence
|
from .presence import Presence
|
||||||
from .sso_server import SSOServer
|
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
|
# Logging configuration
|
||||||
log.getLogger().setLevel(log.INFO)
|
log.getLogger().setLevel(log.INFO)
|
||||||
|
@ -109,6 +109,7 @@ class Backend:
|
||||||
self.ui_state = UIState(self)
|
self.ui_state = UIState(self)
|
||||||
self.history = History(self)
|
self.history = History(self)
|
||||||
self.theme = Theme(self, self.settings.General.theme)
|
self.theme = Theme(self, self.settings.General.theme)
|
||||||
|
self.new_theme = NewTheme(self, self.settings.General.new_theme)
|
||||||
|
|
||||||
self.clients: Dict[str, MatrixClient] = {}
|
self.clients: Dict[str, MatrixClient] = {}
|
||||||
|
|
||||||
|
@ -426,13 +427,14 @@ class Backend:
|
||||||
return path
|
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 parsed user config files for QML."""
|
||||||
return (
|
return (
|
||||||
self.settings.qml_data,
|
self.settings.qml_data,
|
||||||
self.ui_state.qml_data,
|
self.ui_state.qml_data,
|
||||||
self.history.qml_data,
|
self.history.qml_data,
|
||||||
self.theme.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 .theme_parser import convert_to_qml
|
||||||
from .utils import (
|
from .utils import (
|
||||||
aiopen, atomic_write, deep_serialize_for_qml, dict_update_recursive,
|
aiopen, atomic_write, deep_serialize_for_qml, dict_update_recursive,
|
||||||
|
flatten_dict_keys,
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
@ -269,6 +270,10 @@ class PCNFile(MappingFile):
|
||||||
def qml_data(self) -> Dict[str, Any]:
|
def qml_data(self) -> Dict[str, Any]:
|
||||||
return deep_serialize_for_qml(self.data.as_dict()) # type: ignore
|
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]:
|
def deserialized(self, data: str) -> Tuple[Section, bool]:
|
||||||
root = Section.from_source_code(data, self.path)
|
root = Section.from_source_code(data, self.path)
|
||||||
edits = "{}"
|
edits = "{}"
|
||||||
|
@ -379,11 +384,37 @@ class Settings(ConfigFile, PCNFile):
|
||||||
self.backend.theme = Theme(
|
self.backend.theme = Theme(
|
||||||
self.backend, section.General.theme, # type: ignore
|
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)
|
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
|
@dataclass
|
||||||
class UIState(UserDataFile, JSONFile):
|
class UIState(UserDataFile, JSONFile):
|
||||||
"""File used to save and restore the state of QML components."""
|
"""File used to save and restore the state of QML components."""
|
||||||
|
|
|
@ -20,8 +20,8 @@ from pathlib import Path
|
||||||
from tempfile import NamedTemporaryFile
|
from tempfile import NamedTemporaryFile
|
||||||
from types import ModuleType
|
from types import ModuleType
|
||||||
from typing import (
|
from typing import (
|
||||||
Any, AsyncIterator, Callable, Dict, Iterable, Mapping, Sequence, Tuple,
|
Any, AsyncIterator, Callable, Dict, Iterable, Mapping, Optional, Sequence,
|
||||||
Type, Union,
|
Tuple, Type, Union,
|
||||||
)
|
)
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
|
@ -32,6 +32,8 @@ from nio.crypto import AsyncDataT as File
|
||||||
from nio.crypto import async_generator_from_data
|
from nio.crypto import async_generator_from_data
|
||||||
from PIL import Image as PILImage
|
from PIL import Image as PILImage
|
||||||
|
|
||||||
|
from .color import Color
|
||||||
|
|
||||||
if sys.version_info >= (3, 7):
|
if sys.version_info >= (3, 7):
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
else:
|
else:
|
||||||
|
@ -70,6 +72,39 @@ def dict_update_recursive(dict1: dict, dict2: dict) -> None:
|
||||||
dict1[k] = dict2[k]
|
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:
|
async def is_svg(file: File) -> bool:
|
||||||
"""Return whether the file is a SVG (`lxml` is used for detection)."""
|
"""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 `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 class types: the class `__name__`
|
||||||
|
|
||||||
- For anything else: raise a `TypeError` if `reject_unknown` is `True`,
|
- For anything else: raise a `TypeError` if `reject_unknown` is `True`,
|
||||||
|
@ -198,6 +235,9 @@ def serialize_value_for_qml(
|
||||||
if isinstance(value, timedelta):
|
if isinstance(value, timedelta):
|
||||||
return value.total_seconds() * 1000
|
return value.total_seconds() * 1000
|
||||||
|
|
||||||
|
if isinstance(value, Color):
|
||||||
|
return value.hex
|
||||||
|
|
||||||
if inspect.isclass(value):
|
if inspect.isclass(value):
|
||||||
return value.__name__
|
return value.__name__
|
||||||
|
|
||||||
|
|
|
@ -28,6 +28,7 @@ class General:
|
||||||
# For Flatpak, it is
|
# For Flatpak, it is
|
||||||
# "~/.var/app/io.github.mirukana.mirage/data/mirage/themes".
|
# "~/.var/app/io.github.mirukana.mirage/data/mirage/themes".
|
||||||
theme: str = "Midnight.qpl"
|
theme: str = "Midnight.qpl"
|
||||||
|
new_theme: str = "test.py"
|
||||||
|
|
||||||
# Interface scale multiplier, e.g. 0.5 makes everything half-size.
|
# Interface scale multiplier, e.g. 0.5 makes everything half-size.
|
||||||
zoom: float = 1.0
|
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 {
|
Button {
|
||||||
id: button
|
id: button
|
||||||
|
|
||||||
|
property Theme ntheme: Theme {
|
||||||
|
target: button
|
||||||
|
classes: Class { name: "Button" }
|
||||||
|
}
|
||||||
|
|
||||||
readonly property alias iconItem: contentItem.icon
|
readonly property alias iconItem: contentItem.icon
|
||||||
readonly property alias label: contentItem.label
|
readonly property alias label: contentItem.label
|
||||||
|
|
||||||
property color backgroundColor: theme.controls.button.background
|
property color backgroundColor: ntheme.data.background
|
||||||
property color focusLineColor:
|
property color focusLineColor:
|
||||||
Qt.colorEqual(icon.color, theme.icons.colorize) ?
|
Qt.colorEqual(icon.color, theme.icons.colorize) ?
|
||||||
theme.controls.button.focusedBorder :
|
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
|
id: addAccountButton
|
||||||
icon.name: "add-account"
|
icon.name: "add-account"
|
||||||
toolTip.text: qsTr("Add another account")
|
toolTip.text: qsTr("Add another account")
|
||||||
backgroundColor: theme.mainPane.bottomBar.settingsButtonBackground
|
|
||||||
onClicked: {
|
onClicked: {
|
||||||
pageLoader.show("Pages/AddAccount/AddAccount.qml")
|
pageLoader.show("Pages/AddAccount/AddAccount.qml")
|
||||||
roomList.startCorrectItemSearch()
|
roomList.startCorrectItemSearch()
|
||||||
|
|
|
@ -8,6 +8,13 @@ import "../../../Base"
|
||||||
import "../AutoCompletion"
|
import "../AutoCompletion"
|
||||||
|
|
||||||
Rectangle {
|
Rectangle {
|
||||||
|
id: composer
|
||||||
|
|
||||||
|
property Theme ntheme: Theme {
|
||||||
|
target: composer
|
||||||
|
classes: Class { name: "Composer" }
|
||||||
|
}
|
||||||
|
|
||||||
property UserAutoCompletion userCompletion
|
property UserAutoCompletion userCompletion
|
||||||
property alias eventList: messageArea.eventList
|
property alias eventList: messageArea.eventList
|
||||||
|
|
||||||
|
|
|
@ -8,9 +8,10 @@ import "../../../Base"
|
||||||
import "../../../Dialogs"
|
import "../../../Dialogs"
|
||||||
|
|
||||||
HButton {
|
HButton {
|
||||||
|
ntheme.classes: Class { name: "UploadButton" }
|
||||||
|
|
||||||
enabled: chat.roomInfo.can_send_messages
|
enabled: chat.roomInfo.can_send_messages
|
||||||
icon.name: "upload-file"
|
icon.name: "upload-file"
|
||||||
backgroundColor: theme.chat.composer.uploadButton.background
|
|
||||||
toolTip.text:
|
toolTip.text:
|
||||||
chat.userInfo.max_upload_size ?
|
chat.userInfo.max_upload_size ?
|
||||||
qsTr("Send files (%1 max)").arg(
|
qsTr("Send files (%1 max)").arg(
|
||||||
|
|
|
@ -149,8 +149,6 @@ HColumnLayout {
|
||||||
HButton {
|
HButton {
|
||||||
id: inviteButton
|
id: inviteButton
|
||||||
icon.name: "room-send-invite"
|
icon.name: "room-send-invite"
|
||||||
backgroundColor:
|
|
||||||
theme.chat.roomPane.bottomBar.inviteButton.background
|
|
||||||
enabled:
|
enabled:
|
||||||
chat.userInfo.presence !== "offline" &&
|
chat.userInfo.presence !== "offline" &&
|
||||||
chat.roomInfo.can_invite
|
chat.roomInfo.can_invite
|
||||||
|
|
|
@ -73,6 +73,7 @@ QtObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
type === "Settings" ? window.settings = newData :
|
type === "Settings" ? window.settings = newData :
|
||||||
|
type === "NewTheme" ? window.themeRules = newData :
|
||||||
type === "UIState" ? window.uiState = newData :
|
type === "UIState" ? window.uiState = newData :
|
||||||
type === "History" ? window.history = newData :
|
type === "History" ? window.history = newData :
|
||||||
null
|
null
|
||||||
|
|
|
@ -21,12 +21,13 @@ PythonBridge {
|
||||||
addImportPath("qrc:/src")
|
addImportPath("qrc:/src")
|
||||||
|
|
||||||
importNames("backend.qml_bridge", ["BRIDGE"], () => {
|
importNames("backend.qml_bridge", ["BRIDGE"], () => {
|
||||||
callCoro("get_settings", [], ([settings, state, hist, theme]) => {
|
callCoro("get_settings", [], ([settings, state, hist, theme, themeRules]) => {
|
||||||
window.settings = settings
|
window.settings = settings
|
||||||
window.uiState = state
|
window.uiState = state
|
||||||
window.history = hist
|
window.history = hist
|
||||||
window.theme = Qt.createQmlObject(theme, window, "theme")
|
window.theme = Qt.createQmlObject(theme, window, "theme")
|
||||||
utils.theme = window.theme
|
utils.theme = window.theme
|
||||||
|
window.themeRules = themeRules
|
||||||
|
|
||||||
callCoro("saved_accounts.any_saved", [], any => {
|
callCoro("saved_accounts.any_saved", [], any => {
|
||||||
if (any) { callCoro("load_saved_accounts", []) }
|
if (any) { callCoro("load_saved_accounts", []) }
|
||||||
|
|
|
@ -519,4 +519,27 @@ QtObject {
|
||||||
|
|
||||||
return {word, start, end: seen}
|
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 uiState: ({})
|
||||||
property var history: ({})
|
property var history: ({})
|
||||||
property var theme: null
|
property var theme: null
|
||||||
|
property var themeRules: null
|
||||||
property string settingsFolder
|
property string settingsFolder
|
||||||
|
|
||||||
readonly property var visibleMenus: ({})
|
readonly property var visibleMenus: ({})
|
||||||
|
|
Loading…
Reference in New Issue
Block a user