Use new PCN format for settings config file

This commit is contained in:
miruka
2020-10-07 20:12:32 -04:00
parent 6ce3059322
commit db12036372
52 changed files with 1305 additions and 409 deletions

View File

@@ -6,20 +6,23 @@
import asyncio
import json
import os
import platform
import traceback
from collections.abc import MutableMapping
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar, Iterator, Optional, Tuple
from typing import (
TYPE_CHECKING, Any, ClassVar, Dict, Iterator, Optional, Tuple,
)
import aiofiles
from watchgod import Change, awatch
import pyotherside
from .pyotherside_events import UserFileChanged
from .pcn.section import Section
from .pyotherside_events import LoopException, UserFileChanged
from .theme_parser import convert_to_qml
from .utils import atomic_write, dict_update_recursive
from .utils import atomic_write, deep_serialize_for_qml, dict_update_recursive
if TYPE_CHECKING:
from .backend import Backend
@@ -34,20 +37,21 @@ class UserFile:
backend: "Backend" = field(repr=False)
filename: str = field()
data: Any = field(init=False, default_factory=dict)
_need_write: bool = field(init=False, default=False)
_wrote: bool = field(init=False, default=False)
data: Any = field(init=False, default_factory=dict)
_need_write: bool = field(init=False, default=False)
_mtime: Optional[float] = field(init=False, default=None)
_reader: Optional[asyncio.Future] = field(init=False, default=None)
_writer: Optional[asyncio.Future] = field(init=False, default=None)
def __post_init__(self) -> None:
try:
self.data, save = self.deserialized(self.path.read_text())
text_data = self.path.read_text()
except FileNotFoundError:
self.data = self.default_data
self._need_write = self.create_missing
else:
self.data, save = self.deserialized(text_data)
if save:
self.save()
@@ -56,14 +60,24 @@ class UserFile:
@property
def path(self) -> Path:
"""Full path of the file, can exist or not exist."""
"""Full path of the file to read, can exist or not exist."""
raise NotImplementedError()
@property
def write_path(self) -> Path:
"""Full path of the file to write, can exist or not exist."""
return self.path
@property
def default_data(self) -> Any:
"""Default deserialized content to use if the file doesn't exist."""
raise NotImplementedError()
@property
def qml_data(self) -> Any:
"""Data converted for usage in QML."""
return self.data
def deserialized(self, data: str) -> Tuple[Any, bool]:
"""Return parsed data from file text and whether to call `save()`."""
return (data, False)
@@ -76,6 +90,15 @@ class UserFile:
"""Inform the disk writer coroutine that the data has changed."""
self._need_write = True
def stop_watching(self) -> None:
"""Stop watching the on-disk file for changes."""
if self._reader:
self._reader.cancel()
if self._writer:
self._writer.cancel()
async def set_data(self, data: Any) -> None:
"""Set `data` and call `save()`, conveniance method for QML."""
self.data = data
@@ -88,45 +111,57 @@ class UserFile:
await asyncio.sleep(1)
async for changes in awatch(self.path):
ignored = 0
try:
ignored = 0
for change in changes:
if change[0] in (Change.added, Change.modified):
if self._need_write or self._wrote:
self._wrote = False
ignored += 1
continue
for change in changes:
mtime = self.path.stat().st_mtime
async with aiofiles.open(self.path) as file:
self.data, save = self.deserialized(await file.read())
if change[0] in (Change.added, Change.modified):
if mtime == self._mtime:
ignored += 1
continue
if save:
self.save()
async with aiofiles.open(self.path) as file:
text = await file.read()
self.data, save = self.deserialized(text)
elif change[0] == Change.deleted:
self._wrote = False
self.data = self.default_data
self._need_write = self.create_missing
if save:
self.save()
if changes and ignored < len(changes):
UserFileChanged(type(self), self.data)
elif change[0] == Change.deleted:
self.data = self.default_data
self._need_write = self.create_missing
self._mtime = mtime
if changes and ignored < len(changes):
UserFileChanged(type(self), self.qml_data)
except Exception as err:
LoopException(str(err), err, traceback.format_exc().rstrip())
async def _start_writer(self) -> None:
"""Disk writer coroutine, update the file with a 1 second cooldown."""
self.path.parent.mkdir(parents=True, exist_ok=True)
self.write_path.parent.mkdir(parents=True, exist_ok=True)
while True:
await asyncio.sleep(1)
if self._need_write:
async with atomic_write(self.path) as (new, done):
await new.write(self.serialized())
done()
try:
if self._need_write:
async with atomic_write(self.write_path) as (new, done):
await new.write(self.serialized())
done()
self._need_write = False
self._mtime = self.write_path.stat().st_mtime
except Exception as err:
self._need_write = False
self._wrote = True
LoopException(str(err), err, traceback.format_exc().rstrip())
@dataclass
@@ -156,6 +191,7 @@ class UserDataFile(UserFile):
@dataclass
class MappingFile(MutableMapping, UserFile):
"""A file manipulable like a dict. `data` must be a mutable mapping."""
def __getitem__(self, key: Any) -> Any:
return self.data[key]
@@ -171,6 +207,22 @@ class MappingFile(MutableMapping, UserFile):
def __len__(self) -> int:
return len(self.data)
def __getattr__(self, key: Any) -> Any:
try:
return self.data[key]
except KeyError:
return super().__getattribute__(key)
def __setattr__(self, key: Any, value: Any) -> None:
if key in self.__dataclass_fields__:
super().__setattr__(key, value)
return
self.data[key] = value
def __delattr__(self, key: Any) -> None:
del self.data[key]
@dataclass
class JSONFile(MappingFile):
@@ -180,7 +232,6 @@ class JSONFile(MappingFile):
def default_data(self) -> dict:
return {}
def deserialized(self, data: str) -> Tuple[dict, bool]:
"""Return parsed data from file text and whether to call `save()`.
@@ -193,12 +244,41 @@ class JSONFile(MappingFile):
dict_update_recursive(all_data, loaded)
return (all_data, loaded != all_data)
def serialized(self) -> str:
data = self.data
return json.dumps(data, indent=4, ensure_ascii=False, sort_keys=True)
@dataclass
class PCNFile(MappingFile):
"""File stored in the PCN format, with machine edits in a separate JSON."""
create_missing = False
@property
def write_path(self) -> Path:
"""Full path of file where programatically-done edits are stored."""
return self.path.with_suffix(".gui.json")
@property
def qml_data(self) -> Dict[str, Any]:
return deep_serialize_for_qml(self.data.as_dict()) # type: ignore
def deserialized(self, data: str) -> Tuple[Section, bool]:
root = Section.from_source_code(data, self.path)
edits = self.write_path.read_text() if self.write_path.exists() else ""
root.deep_merge_edits(json.loads(edits))
return (root, False)
def serialized(self) -> str:
edits = self.data.edits_as_dict()
return json.dumps(edits, indent=4, ensure_ascii=False)
async def set_data(self, data: Dict[str, Any]) -> None:
self.data.deep_merge_edits({"set": data}, has_expressions=False)
self.save()
@dataclass
class Accounts(ConfigFile, JSONFile):
"""Config file for saved matrix accounts: user ID, access tokens, etc"""
@@ -209,7 +289,6 @@ class Accounts(ConfigFile, JSONFile):
"""Return for QML whether there are any accounts saved on disk."""
return bool(self.data)
async def add(self, user_id: str) -> None:
"""Add an account to the config and write it on disk.
@@ -233,7 +312,6 @@ class Accounts(ConfigFile, JSONFile):
})
self.save()
async def set(
self,
user_id: str,
@@ -261,7 +339,6 @@ class Accounts(ConfigFile, JSONFile):
self.save()
async def forget(self, user_id: str) -> None:
"""Delete an account from the config and write it on disk."""
@@ -270,187 +347,33 @@ class Accounts(ConfigFile, JSONFile):
@dataclass
class Settings(ConfigFile, JSONFile):
class Settings(ConfigFile, PCNFile):
"""General config file for UI and backend settings"""
filename: str = "settings.json"
filename: str = "settings.py"
@property
def default_data(self) -> dict:
def ctrl_or_osx_ctrl() -> str:
# Meta in Qt corresponds to Ctrl on OSX
return "Meta" if platform.system() == "Darwin" else "Ctrl"
def default_data(self) -> Section:
root = Section.from_file("src/config/settings.py")
edits = "{}"
def alt_or_cmd() -> str:
# Ctrl in Qt corresponds to Cmd on OSX
return "Ctrl" if platform.system() == "Darwin" else "Alt"
if self.write_path.exists():
edits = self.write_path.read_text()
return {
"alertOnMentionForMsec": -1,
"alertOnMessageForMsec": 0,
"alwaysCenterRoomHeader": False,
# "autoHideScrollBarsAfterMsec": 2000,
"beUnavailableAfterSecondsIdle": 60 * 10,
"centerRoomListOnClick": False,
"compactMode": False,
"clearRoomFilterOnEnter": True,
"clearRoomFilterOnEscape": True,
"clearMemberFilterOnEscape": True,
"closeMinimizesToTray": False,
"collapseSidePanesUnderWindowWidth": 450,
"enableKineticScrolling": True,
"hideProfileChangeEvents": True,
"hideMembershipEvents": False,
"hideUnknownEvents": True,
"kineticScrollingMaxSpeed": 2500,
"kineticScrollingDeceleration": 1500,
"lexicalRoomSorting": False,
"markRoomReadMsecDelay": 200,
"maxMessageCharactersPerLine": 65,
"nonKineticScrollingSpeed": 1.0,
"ownMessagesOnLeftAboveWidth": 895,
"theme": "Midnight.qpl",
"writeAliases": {},
"zoom": 1.0,
"roomBookmarkIDs": {},
root.deep_merge_edits(json.loads(edits))
return root
"media": {
"autoLoad": True,
"autoPlay": False,
"autoPlayGIF": True,
"autoHideOSDAfterMsec": 3000,
"defaultVolume": 100,
"openExternallyOnClick": False,
"startMuted": False,
},
"keys": {
"startPythonDebugger": ["Alt+Shift+D"],
"toggleDebugConsole": ["Alt+Shift+C", "F1"],
def deserialized(self, data: str) -> Tuple[Section, bool]:
section, save = super().deserialized(data)
"zoomIn": ["Ctrl++"],
"zoomOut": ["Ctrl+-"],
"zoomReset": ["Ctrl+="],
"toggleCompactMode": ["Ctrl+Alt+C"],
"toggleHideRoomPane": ["Ctrl+Alt+R"],
"scrollUp": ["Alt+Up", "Alt+K"],
"scrollDown": ["Alt+Down", "Alt+J"],
"scrollPageUp": ["Alt+Ctrl+Up", "Alt+Ctrl+K", "PgUp"],
"scrollPageDown": ["Alt+Ctrl+Down", "Alt+Ctrl+J", "PgDown"],
"scrollToTop":
["Alt+Ctrl+Shift+Up", "Alt+Ctrl+Shift+K", "Home"],
"scrollToBottom":
["Alt+Ctrl+Shift+Down", "Alt+Ctrl+Shift+J", "End"],
"previousTab": ["Alt+Shift+Left", "Alt+Shift+H"],
"nextTab": ["Alt+Shift+Right", "Alt+Shift+L"],
"addNewAccount": ["Alt+Shift+A"],
"accountSettings": ["Alt+A"],
"addNewChat": ["Alt+C"],
"toggleFocusMainPane": ["Alt+F"],
"clearRoomFilter": ["Alt+Shift+F"],
"toggleCollapseAccount": ["Alt+O"],
"openPresenceMenu": ["Alt+P"],
"togglePresenceUnavailable": ["Alt+Ctrl+U", "Alt+Ctrl+A"],
"togglePresenceInvisible": ["Alt+Ctrl+I"],
"togglePresenceOffline": ["Alt+Ctrl+O"],
"goToLastPage": ["Ctrl+Tab"],
"goToPreviousAccount": ["Alt+Shift+N"],
"goToNextAccount": ["Alt+N"],
"goToPreviousRoom": ["Alt+Shift+Up", "Alt+Shift+K"],
"goToNextRoom": ["Alt+Shift+Down", "Alt+Shift+J"],
"goToPreviousUnreadRoom": ["Alt+Shift+U"],
"goToNextUnreadRoom": ["Alt+U"],
"goToPreviousMentionedRoom": ["Alt+Shift+M"],
"goToNextMentionedRoom": ["Alt+M"],
"focusAccountAtIndex": {
"01": f"{ctrl_or_osx_ctrl()}+1",
"02": f"{ctrl_or_osx_ctrl()}+2",
"03": f"{ctrl_or_osx_ctrl()}+3",
"04": f"{ctrl_or_osx_ctrl()}+4",
"05": f"{ctrl_or_osx_ctrl()}+5",
"06": f"{ctrl_or_osx_ctrl()}+6",
"07": f"{ctrl_or_osx_ctrl()}+7",
"08": f"{ctrl_or_osx_ctrl()}+8",
"09": f"{ctrl_or_osx_ctrl()}+9",
"10": f"{ctrl_or_osx_ctrl()}+0",
},
# On OSX, alt+numbers if used for symbols, use cmd instead
"focusRoomAtIndex": {
"01": f"{alt_or_cmd()}+1",
"02": f"{alt_or_cmd()}+2",
"03": f"{alt_or_cmd()}+3",
"04": f"{alt_or_cmd()}+4",
"05": f"{alt_or_cmd()}+5",
"06": f"{alt_or_cmd()}+6",
"07": f"{alt_or_cmd()}+7",
"08": f"{alt_or_cmd()}+8",
"09": f"{alt_or_cmd()}+9",
"10": f"{alt_or_cmd()}+0",
},
"unfocusOrDeselectAllMessages": ["Ctrl+D"],
"focusPreviousMessage": ["Ctrl+Up", "Ctrl+K"],
"focusNextMessage": ["Ctrl+Down", "Ctrl+J"],
"toggleSelectMessage": ["Ctrl+Space"],
"selectMessagesUntilHere": ["Ctrl+Shift+Space"],
"removeFocusedOrSelectedMessages": ["Ctrl+R", "Alt+Del"],
"replyToFocusedOrLastMessage": ["Ctrl+Q"], # Q → Quote
"debugFocusedMessage": ["Ctrl+Shift+D"],
"openMessagesLinksOrFiles": ["Ctrl+O"],
"openMessagesLinksOrFilesExternally": ["Ctrl+Shift+O"],
"copyFilesLocalPath": ["Ctrl+Shift+C"],
"clearRoomMessages": ["Ctrl+L"],
"sendFile": ["Alt+S"],
"sendFileFromPathInClipboard": ["Alt+Shift+S"],
"inviteToRoom": ["Alt+I"],
"leaveRoom": ["Alt+Escape"],
"forgetRoom": ["Alt+Shift+Escape"],
"toggleFocusRoomPane": ["Alt+R"],
"refreshDevices": ["Alt+R", "F5"],
"signOutCheckedOrAllDevices": ["Alt+S", "Delete"],
"imageViewer": {
"panLeft": ["H", "Left", "Alt+H", "Alt+Left"],
"panDown": ["J", "Down", "Alt+J", "Alt+Down"],
"panUp": ["K", "Up", "Alt+K", "Alt+Up"],
"panRight": ["L", "Right", "Alt+L", "Alt+Right"],
"zoomReset": ["Alt+Z", "=", "Ctrl+="],
"zoomOut": ["Shift+Z", "-", "Ctrl+-"],
"zoomIn": ["Z", "+", "Ctrl++"],
"rotateReset": ["Alt+R"],
"rotateLeft": ["Shift+R"],
"rotateRight": ["R"],
"resetSpeed": ["Alt+S"],
"previousSpeed": ["Shift+S"],
"nextSpeed": ["S"],
"pause": ["Space"],
"expand": ["E"],
"fullScreen": ["F", "F11", "Alt+Return", "Alt+Enter"],
"close": ["X", "Q"],
},
},
}
def deserialized(self, data: str) -> Tuple[dict, bool]:
dict_data, save = super().deserialized(data)
if "theme" in self and self["theme"] != dict_data["theme"]:
self.backend.theme = Theme(self.backend, dict_data["theme"])
if self and self.General.theme != section.General.theme:
self.backend.theme.stop_watching()
self.backend.theme = Theme(
self.backend, section.General.theme, # type: ignore
)
UserFileChanged(Theme, self.backend.theme.data)
return (dict_data, save)
return (section, save)
@dataclass