diff --git a/TODO.md b/TODO.md index cfa3aea8..cb7c7f36 100644 --- a/TODO.md +++ b/TODO.md @@ -2,6 +2,7 @@ - `QCoreApplication::setAttribute(Qt::AA_EnableHighDpiScaling)` - Refactoring + - Sendbox - Use .mjs modules - SignIn/RememberAccount screens - SignIn must be in a flickable @@ -14,8 +15,7 @@ - When qml syntax highlighting supports string interpolation, use them - Fixes - - Wrong typing notification sending before alias completed - - Message after daybreak delegate + - Message position after daybreak delegate - Keyboard flicking against top/bottom edge - Don't strip user spacing in html - Past events loading (limit 100) freezes the GUI - need to move upsert func diff --git a/src/python/backend.py b/src/python/backend.py index 4bc2dc9c..db4cd675 100644 --- a/src/python/backend.py +++ b/src/python/backend.py @@ -3,7 +3,7 @@ import asyncio import random -from typing import Any, Dict, Optional, Set, Tuple +from typing import Dict, Optional, Set, Tuple from .app import App from .events import users @@ -104,8 +104,13 @@ class Backend: # General functions - async def load_settings(self) -> Tuple[Dict[str, Any], ...]: - return (await self.ui_settings.read(), await self.ui_state.read()) + async def load_settings(self) -> tuple: + from .config_files import Theme + settings = await self.ui_settings.read() + ui_state = await self.ui_state.read() + theme = await Theme(self, settings["theme"]).read() + + return (settings, ui_state, theme) async def request_user_update_event(self, user_id: str) -> None: diff --git a/src/python/config_files.py b/src/python/config_files.py index 76ba650e..0fad5c43 100644 --- a/src/python/config_files.py +++ b/src/python/config_files.py @@ -10,6 +10,7 @@ import aiofiles from dataclasses import dataclass, field from .backend import Backend +from .theme_parser import convert_to_qml JsonData = Dict[str, Any] @@ -18,16 +19,32 @@ WRITE_LOCK = asyncio.Lock() @dataclass class ConfigFile: - backend: Backend = field(repr=False) - filename: str = field() - use_data_dir: bool = False + backend: Backend = field(repr=False) + filename: str = field() @property def path(self) -> Path: # pylint: disable=no-member - dirs = self.backend.app.appdirs - to = dirs.user_data_dir if self.use_data_dir else dirs.user_config_dir - return Path(to) / self.filename + return Path(self.backend.app.appdirs.user_config_dir) / self.filename + + + async def default_data(self): + return "" + + + async def read(self): + try: + return self.path.read_text() + except FileNotFoundError: + return await self.default_data() + + + async def write(self, data) -> None: + async with WRITE_LOCK: + self.path.parent.mkdir(parents=True, exist_ok=True) + + async with aiofiles.open(self.path, "w") as new: + await new.write(data) @dataclass @@ -38,8 +55,8 @@ class JSONConfigFile(ConfigFile): async def read(self) -> JsonData: try: - data = json.loads(self.path.read_text()) - except (json.JSONDecodeError, FileNotFoundError): + data = json.loads(await super().read()) + except json.JSONDecodeError: data = {} return {**await self.default_data(), **data} @@ -47,12 +64,7 @@ class JSONConfigFile(ConfigFile): async def write(self, data: JsonData) -> None: js = json.dumps(data, indent=4, ensure_ascii=False, sort_keys=True) - - async with WRITE_LOCK: - self.path.parent.mkdir(parents=True, exist_ok=True) - - async with aiofiles.open(self.path, "w") as new: - await new.write(js) + await super().write(js) @dataclass @@ -90,14 +102,20 @@ class UISettings(JSONConfigFile): async def default_data(self) -> JsonData: return { - "writeAliases": {} + "theme": "Default.qpl", + "writeAliases": {}, } @dataclass class UIState(JSONConfigFile): - filename: str = "ui-state.json" - use_data_dir: bool = True + filename: str = "ui-state.json" + + @property + def path(self) -> Path: + # pylint: disable=no-member + return Path(self.backend.app.appdirs.user_data_dir) / self.filename + async def default_data(self) -> JsonData: return { @@ -107,3 +125,30 @@ class UIState(JSONConfigFile): "pageProperties": {}, "sidePaneManualWidth": None, } + + +@dataclass +class Theme(ConfigFile): + @property + def path(self) -> Path: + # pylint: disable=no-member + data_dir = Path(self.backend.app.appdirs.user_data_dir) + user_file = data_dir / "themes" / self.filename + + if user_file.exists(): + return user_file + + return Path("src") / "themes" / self.filename + + + async def default_data(self) -> str: + async with aiofiles.open("src/themes/Default.qpl", "r") as file: + return file.read() + + + async def read(self) -> str: + return convert_to_qml(await super().read()) + + + async def write(self, data: str) -> None: + raise NotImplementedError() diff --git a/src/python/theme_parser.py b/src/python/theme_parser.py new file mode 100644 index 00000000..4f80d8ca --- /dev/null +++ b/src/python/theme_parser.py @@ -0,0 +1,69 @@ +# Copyright 2019 miruka +# This file is part of harmonyqml, licensed under LGPLv3. + +import re +from typing import Generator + +PROPERTY_TYPES = {"bool", "double", "int", "list", "real", "string", "url", + "var", "date", "point", "rect", "size", "color"} + + +def _add_property(line: str) -> str: + if re.match(r"^\s*[a-zA-Z0-9_]+\s*:$", line): + return re.sub(r"^(\s*)(\S*\s*):$", + r"\1readonly property QtObject \2: QtObject", + line) + + types = "|".join(PROPERTY_TYPES) + if re.match(fr"^\s*({types}) [a-zA-Z\d_]+\s*:", line): + return re.sub(r"^(\s*)(\S*)", r"\1readonly property \2", line) + + return line + + +def _process_lines(content: str) -> Generator[str, None, None]: + skip = False + indent = " " * 4 + current_indent = 0 + + for line in content.split("\n"): + line = line.rstrip() + + if not line.strip() or line.strip().startswith("//"): + continue + + start_space_list = re.findall(r"^ +", line) + start_space = start_space_list[0] if start_space_list else "" + + line_indents = len(re.findall(indent, start_space)) + + if not skip: + if line_indents > current_indent: + yield "%s{" % (indent * current_indent) + current_indent = line_indents + + while line_indents < current_indent: + current_indent -= 1 + yield "%s}" % (indent * current_indent) + + line = _add_property(line) + + yield line + + skip = any((line.endswith(e) for e in "([{+\\,?:")) + + while current_indent: + current_indent -= 1 + yield "%s}" % (indent * current_indent) + + +def convert_to_qml(theme_content: str) -> str: + lines = [ + "import QtQuick 2.12", + 'import "utils.js" as Ut', + "QtObject {", + " id: theme", + ] + lines += [f" {line}" for line in _process_lines(theme_content)] + lines += ["}"] + return "\n".join(lines) diff --git a/src/qml/Chat/SendBox.qml b/src/qml/Chat/SendBox.qml index bd42a440..9f8fce72 100644 --- a/src/qml/Chat/SendBox.qml +++ b/src/qml/Chat/SendBox.qml @@ -105,7 +105,7 @@ HRectangle { vals.reduce((a, b) => a.length > b.length ? a: b) let textNotStartsWithAnyAlias = - ! vals.some(a => text.startsWith(a)) + ! vals.some(a => a.startsWith(text)) let textContainsCharNotInAnyAlias = vals.every(a => text.split("").some(c => ! a.includes(c))) diff --git a/src/qml/Python.qml b/src/qml/Python.qml index 2d76bd7d..f2e082f1 100644 --- a/src/qml/Python.qml +++ b/src/qml/Python.qml @@ -53,9 +53,10 @@ Python { call("APP.is_debug_on", [Qt.application.arguments], on => { window.debug = on - callCoro("load_settings", [], ([settings, uiState]) => { + callCoro("load_settings", [], ([settings, uiState, theme]) => { window.settings = settings window.uiState = uiState + window.theme = Qt.createQmlObject(theme, window, "theme") callCoro("saved_accounts.any_saved", [], any => { py.ready = true diff --git a/src/qml/Theme.qml b/src/qml/Theme.qml deleted file mode 100644 index 47d959f5..00000000 --- a/src/qml/Theme.qml +++ /dev/null @@ -1,194 +0,0 @@ -// Copyright 2019 miruka -// This file is part of harmonyqml, licensed under LGPLv3. - -import QtQuick 2.12 -import "utils.js" as Ut - -QtObject { - id: theme - - property int minimumSupportedWidth: 240 - property int minimumSupportedHeight: 120 - property int contentIsWideAbove: 439 - - property int minimumSupportedWidthPlusSpacing: 240 + spacing * 2 - property int minimumSupportedHeightPlusSpacing: 120 + spacing * 2 - - property int baseElementsHeight: 36 - property int spacing: 8 - property int animationDuration: 100 - - property QtObject fontSize: QtObject { - property int smallest: 6 - property int smaller: 8 - property int small: 13 - property int normal: 16 - property int big: 22 - property int bigger: 32 - property int biggest: 48 - } - - property QtObject fontFamily: QtObject { - property string sans: "SFNS Display" - property string serif: "Roboto Slab" - property string mono: "Hack" - } - - property int radius: 5 - - property QtObject colors: QtObject { - property color background0: Ut.hsla(0, 0, 90, 0.5) - property color background1: Ut.hsla(0, 0, 90, 0.6) - property color background2: Ut.hsla(0, 0, 90, 0.7) - property color foreground: "black" - property color foregroundDim: Ut.hsl(0, 0, 20) - property color foregroundDim2: Ut.hsl(0, 0, 30) - property color foregroundError: Ut.hsl(342, 64, 32) - property color textBorder: Ut.hsla(0, 0, 0, 0.07) - property color accent: Ut.hsl(25, 60, 50) - property color accentDarker: Ut.hsl(25, 60, 35) - } - - property QtObject controls: QtObject { - property QtObject button: QtObject { - property color background: colors.background2 - } - - property QtObject interactiveRectangle: QtObject { - property color background: "transparent" - property color hoveredBackground: Ut.hsla(0, 0, 0, 0.2) - property color pressedBackground: Ut.hsla(0, 0, 0, 0.4) - property color checkedBackground: Ut.hsla(0, 0, 0, 0.4) - } - - property QtObject textField: QtObject { - property color background: colors.background2 - property color border: "transparent" - property color focusedBackground: background - property color focusedBorder: colors.accent - property int borderWidth: 1 - } - - property QtObject textArea: QtObject { - property color background: colors.background2 - } - } - - property QtObject sidePane: QtObject { - property real autoWidthRatio: 0.33 - property int maximumAutoWidth: 320 - - property int autoCollapseBelowWidth: 128 - property int collapsedWidth: avatar.size - - property int autoReduceBelowWindowWidth: - minimumSupportedWidthPlusSpacing + collapsedWidth - - property color background: colors.background2 - - property QtObject account: QtObject { - property color background: Qt.lighter(colors.background2, 1.05) - } - - property QtObject settingsButton: QtObject { - property color background: colors.background2 - } - - property QtObject filterRooms: QtObject { - property color background: colors.background2 - } - } - - property QtObject chat: QtObject { - property QtObject selectViewBar: QtObject { - property color background: colors.background2 - } - - property QtObject roomHeader: QtObject { - property color background: colors.background2 - } - - property QtObject eventList: QtObject { - property int ownEventsOnRightUnderWidth: 768 - property color background: "transparent" - } - - property QtObject message: QtObject { - property color ownBackground: Ut.hsla(25, 40, 82, 0.7) - property color background: colors.background2 - property color body: colors.foreground - property color date: colors.foregroundDim - - property color link: colors.accentDarker - // property color code: Ut.hsl(0, 0, 80) - // property color codeBackground: Ut.hsl(0, 0, 10) - property color code: Ut.hsl(265, 60, 35) - property color greenText: Ut.hsl(80, 60, 25) - - property string styleSheet: - "a { color: " + link + " }" + - - "code { font-family: " + fontFamily.mono + "; " + - "color: " + code + " }" + - - "h1, h2 { font-weight: normal }" + - "h6 { font-size: small }" + - - ".greentext { color: " + greenText + " }" - - property string styleInclude: - '\n' - } - - property QtObject daybreak: QtObject { - property color background: colors.background2 - property color foreground: colors.foreground - property int radius: theme.radius - } - - property QtObject inviteBanner: QtObject { - property color background: colors.background2 - } - - property QtObject leftBanner: QtObject { - property color background: colors.background2 - } - - property QtObject unknownDevices: QtObject { - property color background: colors.background2 - } - - property QtObject typingMembers: QtObject { - property color background: colors.background1 - } - - property QtObject sendBox: QtObject { - property color background: colors.background2 - } - } - - property color pageHeadersBackground: colors.background2 - - property QtObject box: QtObject { - property color background: colors.background0 - property int radius: theme.radius - } - - property QtObject avatar: QtObject { - property int size: baseElementsHeight - property int radius: theme.radius - property color letter: "white" - - property QtObject background: QtObject { - property real saturation: 0.22 - property real lightness: 0.5 - property real alpha: 1 - property color unknown: Ut.hsl(0, 0, 22) - } - } - - property QtObject displayName: QtObject { - property real saturation: 0.32 - property real lightness: 0.3 - } -} diff --git a/src/qml/Window.qml b/src/qml/Window.qml index 5c89fa84..8d8b6d68 100644 --- a/src/qml/Window.qml +++ b/src/qml/Window.qml @@ -8,8 +8,8 @@ import "Models" ApplicationWindow { id: window - minimumWidth: theme.minimumSupportedWidth - minimumHeight: theme.minimumSupportedHeight + minimumWidth: theme ? theme.minimumSupportedWidth : 240 + minimumHeight: theme ? theme.minimumSupportedHeight : 120 width: 640 height: 480 visible: true @@ -34,7 +34,8 @@ ApplicationWindow { property var uiState: ({}) onUiStateChanged: py.saveConfig("ui_state", uiState) - Theme { id: theme } + property var theme: null + Shortcuts { id: shortcuts} Python { id: py } diff --git a/src/themes/Default.qpl b/src/themes/Default.qpl new file mode 100644 index 00000000..75f5cd53 --- /dev/null +++ b/src/themes/Default.qpl @@ -0,0 +1,160 @@ +// Copyright 2019 miruka +// This file is part of harmonyqml, licensed under LGPLv3. +// vim: syntax=qml + +int minimumSupportedWidth: 240 +int minimumSupportedHeight: 120 +int contentIsWideAbove: 439 + +int minimumSupportedWidthPlusSpacing: 240 + spacing * 2 +int minimumSupportedHeightPlusSpacing: 120 + spacing * 2 + +int baseElementsHeight: 36 +int spacing: 8 +int radius: 5 +int animationDuration: 100 + +color pageHeadersBackground: colors.background2 + +fontSize: + int smallest: 6 + int smaller: 8 + int small: 13 + int normal: 16 + int big: 22 + int bigger: 32 + int biggest: 48 + +fontFamily: + string sans: "SFNS Display" + string serif: "Roboto Slab" + string mono: "Hack" + +colors: + color background0: Ut.hsla(0, 0, 90, 0.5) + color background1: Ut.hsla(0, 0, 90, 0.6) + color background2: Ut.hsla(0, 0, 90, 0.7) + color foreground: "black" + color foregroundDim: Ut.hsl(0, 0, 20) + color foregroundDim2: Ut.hsl(0, 0, 30) + color foregroundError: Ut.hsl(342, 64, 32) + color textBorder: Ut.hsla(0, 0, 0, 0.07) + color accent: Ut.hsl(25, 60, 50) + color accentDarker: Ut.hsl(25, 60, 35) + +controls: + button: + color background: colors.background2 + + interactiveRectangle: + color background: "transparent" + color hoveredBackground: Ut.hsla(0, 0, 0, 0.2) + color pressedBackground: Ut.hsla(0, 0, 0, 0.4) + color checkedBackground: Ut.hsla(0, 0, 0, 0.4) + + textField: + color background: colors.background2 + color border: "transparent" + color focusedBackground: background + color focusedBorder: colors.accent + int borderWidth: 1 + + textArea: + color background: colors.background2 + +sidePane: + real autoWidthRatio: 0.33 + int maximumAutoWidth: 320 + + int autoCollapseBelowWidth: 128 + int collapsedWidth: avatar.size + + int autoReduceBelowWindowWidth: + minimumSupportedWidthPlusSpacing + collapsedWidth + + color background: colors.background2 + + account: + color background: Qt.lighter(colors.background2, 1.05) + + settingsButton: + color background: colors.background2 + + filterRooms: + color background: colors.background2 + +chat: + selectViewBar: + color background: colors.background2 + + roomHeader: + color background: colors.background2 + + eventList: + int ownEventsOnRightUnderWidth: 768 + color background: "transparent" + + message: + color ownBackground: Ut.hsla(25, 40, 82, 0.7) + color background: colors.background2 + color body: colors.foreground + color date: colors.foregroundDim + + color link: colors.accentDarker + // color code: Ut.hsl(0, 0, 80) + // color codeBackground: Ut.hsl(0, 0, 10) + color code: Ut.hsl(265, 60, 35) + color greenText: Ut.hsl(80, 60, 25) + + string styleSheet: + "a { color: " + link + " }" + + + "code { font-family: " + fontFamily.mono + "; " + + "color: " + code + " }" + + + "h1, h2 { font-weight: normal }" + + "h6 { font-size: small }" + + + ".greentext { color: " + greenText + " }" + + string styleInclude: + '\n' + + daybreak: + color background: colors.background2 + color foreground: colors.foreground + int radius: theme.radius + + inviteBanner: + color background: colors.background2 + + leftBanner: + color background: colors.background2 + + unknownDevices: + color background: colors.background2 + + typingMembers: + color background: colors.background1 + + sendBox: + color background: colors.background2 + +box: + color background: colors.background0 + int radius: theme.radius + +avatar: + int size: baseElementsHeight + int radius: theme.radius + color letter: "white" + + background: + real saturation: 0.22 + real lightness: 0.5 + real alpha: 1 + color unknown: Ut.hsl(0, 0, 22) + +displayName: + real saturation: 0.32 + real lightness: 0.3