Refactor user_files module & add live reloading
- Cleaner design for the backend user_files classes and simplified interaction with QML - Config and theme files will now automatically reload when changed on disk - Removed manual reload keybind and button
This commit is contained in:
parent
00c468384a
commit
75fbf23b21
|
@ -10,6 +10,7 @@ mistune >= 0.8.4, < 0.9
|
|||
pymediainfo >= 4.2.1, < 5
|
||||
plyer >= 1.4.3, < 2
|
||||
sortedcontainers >= 2.2.2, < 3
|
||||
watchgod >= 0.6, < 0.7
|
||||
dbus-python >= 1.2.16, < 2; platform_system == "Linux"
|
||||
|
||||
async_generator >= 1.10, < 2; python_version < "3.7"
|
||||
|
|
|
@ -29,7 +29,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, Theme, UISettings, UIState
|
||||
from .user_files import Accounts, History, Theme, Settings, UIState
|
||||
|
||||
# Logging configuration
|
||||
log.getLogger().setLevel(log.INFO)
|
||||
|
@ -43,7 +43,7 @@ class Backend:
|
|||
Attributes:
|
||||
saved_accounts: User config file for saved matrix account.
|
||||
|
||||
ui_settings: User config file for QML interface settings.
|
||||
settings: User config file for general UI and backend settings.
|
||||
|
||||
ui_state: User data file for saving/restoring QML UI state.
|
||||
|
||||
|
@ -103,12 +103,13 @@ class Backend:
|
|||
def __init__(self) -> None:
|
||||
self.appdirs = AppDirs(appname=__app_name__, roaming=True)
|
||||
|
||||
self.models = ModelStore()
|
||||
|
||||
self.saved_accounts = Accounts(self)
|
||||
self.ui_settings = UISettings(self)
|
||||
self.settings = Settings(self)
|
||||
self.ui_state = UIState(self)
|
||||
self.history = History(self)
|
||||
|
||||
self.models = ModelStore()
|
||||
self.theme = Theme(self, self.settings["theme"])
|
||||
|
||||
self.clients: Dict[str, MatrixClient] = {}
|
||||
|
||||
|
@ -296,8 +297,8 @@ class Backend:
|
|||
return user_id
|
||||
|
||||
return await asyncio.gather(*(
|
||||
resume(uid, info)
|
||||
for uid, info in (await self.saved_accounts.read()).items()
|
||||
resume(user_id, info)
|
||||
for user_id, info in self.saved_accounts.items()
|
||||
if info.get("enabled", True)
|
||||
))
|
||||
|
||||
|
@ -325,7 +326,7 @@ class Backend:
|
|||
|
||||
self.models[user_id, "rooms"].clear()
|
||||
|
||||
await self.saved_accounts.delete(user_id)
|
||||
await self.saved_accounts.forget(user_id)
|
||||
|
||||
|
||||
async def terminate_clients(self) -> None:
|
||||
|
@ -426,20 +427,14 @@ class Backend:
|
|||
return path
|
||||
|
||||
|
||||
async def load_settings(self) -> tuple:
|
||||
"""Return parsed user config files."""
|
||||
|
||||
settings = await self.ui_settings.read()
|
||||
ui_state = await self.ui_state.read()
|
||||
history = await self.history.read()
|
||||
theme = await Theme(self, settings["theme"]).read()
|
||||
|
||||
state_data = self.ui_state._data
|
||||
if state_data:
|
||||
for user, collapse in state_data["collapseAccounts"].items():
|
||||
self.models["all_rooms"].set_account_collapse(user, collapse)
|
||||
|
||||
return (settings, ui_state, history, theme)
|
||||
async def get_settings(self) -> Tuple[Settings, UIState, History, str]:
|
||||
"""Return parsed user config files for QML."""
|
||||
return (
|
||||
self.settings.data,
|
||||
self.ui_state.data,
|
||||
self.history.data,
|
||||
self.theme.data,
|
||||
)
|
||||
|
||||
|
||||
async def set_string_filter(self, model_id: SyncId, value: str) -> None:
|
||||
|
|
|
@ -285,7 +285,7 @@ class MatrixClient(nio.AsyncClient):
|
|||
await super().login(password, self.default_device_name, token)
|
||||
|
||||
order = 0
|
||||
saved_accounts = await self.backend.saved_accounts.read()
|
||||
saved_accounts = self.backend.saved_accounts
|
||||
|
||||
if saved_accounts:
|
||||
order = max(
|
||||
|
@ -493,7 +493,7 @@ class MatrixClient(nio.AsyncClient):
|
|||
|
||||
utils.dict_update_recursive(first, self.low_limit_filter)
|
||||
|
||||
if self.backend.ui_settings["hideUnknownEvents"]:
|
||||
if self.backend.settings["hideUnknownEvents"]:
|
||||
first["room"]["timeline"]["not_types"].extend(
|
||||
self.no_unknown_events_filter
|
||||
["room"]["timeline"]["not_types"],
|
||||
|
@ -1553,7 +1553,7 @@ class MatrixClient(nio.AsyncClient):
|
|||
|
||||
if save:
|
||||
account.save_presence = True
|
||||
await self.backend.saved_accounts.update(
|
||||
await self.backend.saved_accounts.set(
|
||||
self.user_id, presence=presence, status_msg=status_msg,
|
||||
)
|
||||
else:
|
||||
|
@ -1912,7 +1912,7 @@ class MatrixClient(nio.AsyncClient):
|
|||
local_unreads = local_unreads,
|
||||
local_highlights = local_highlights,
|
||||
|
||||
lexical_sorting = self.backend.ui_settings["lexicalRoomSorting"],
|
||||
lexical_sorting = self.backend.settings["lexicalRoomSorting"],
|
||||
bookmarked = room.room_id in bookmarks.get(self.user_id, {}),
|
||||
)
|
||||
|
||||
|
|
|
@ -498,7 +498,7 @@ class NioCallbacks:
|
|||
|
||||
# Membership changes
|
||||
if not prev or membership != prev_membership:
|
||||
if self.client.backend.ui_settings["hideMembershipEvents"]:
|
||||
if self.client.backend.settings["hideMembershipEvents"]:
|
||||
return None
|
||||
|
||||
reason = escape(
|
||||
|
@ -564,7 +564,7 @@ class NioCallbacks:
|
|||
avatar_url = now.get("avatar_url") or "",
|
||||
)
|
||||
|
||||
if self.client.backend.ui_settings["hideProfileChangeEvents"]:
|
||||
if self.client.backend.settings["hideProfileChangeEvents"]:
|
||||
return None
|
||||
|
||||
return (
|
||||
|
@ -682,7 +682,7 @@ class NioCallbacks:
|
|||
async def onUnknownEvent(
|
||||
self, room: nio.MatrixRoom, ev: nio.UnknownEvent,
|
||||
) -> None:
|
||||
if self.client.backend.ui_settings["hideUnknownEvents"]:
|
||||
if self.client.backend.settings["hideUnknownEvents"]:
|
||||
await self.client.register_nio_room(room)
|
||||
return
|
||||
|
||||
|
@ -846,7 +846,7 @@ class NioCallbacks:
|
|||
status_msg = account.status_msg
|
||||
state = Presence.State.invisible
|
||||
|
||||
await self.client.backend.saved_accounts.update(
|
||||
await self.client.backend.saved_accounts.set(
|
||||
user_id = ev.user_id,
|
||||
status_msg = status_msg,
|
||||
presence = state.value,
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Dict, Optional, Sequence, Union
|
||||
from typing import TYPE_CHECKING, Any, Dict, Optional, Sequence, Type, Union
|
||||
|
||||
import pyotherside
|
||||
|
||||
|
@ -11,6 +11,7 @@ from .utils import serialize_value_for_qml
|
|||
|
||||
if TYPE_CHECKING:
|
||||
from .models import SyncId
|
||||
from .user_files import UserFile
|
||||
|
||||
|
||||
@dataclass
|
||||
|
@ -69,6 +70,14 @@ class LoopException(PyOtherSideEvent):
|
|||
traceback: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserFileChanged(PyOtherSideEvent):
|
||||
"""Indicate that a config or data file changed on disk."""
|
||||
|
||||
type: Type["UserFile"] = field()
|
||||
new_data: Any = field()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelEvent(PyOtherSideEvent):
|
||||
"""Base class for model change events."""
|
||||
|
|
|
@ -7,179 +7,211 @@ import asyncio
|
|||
import json
|
||||
import os
|
||||
import platform
|
||||
from collections.abc import MutableMapping
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Dict, Optional
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Iterator, Optional, Tuple
|
||||
|
||||
import aiofiles
|
||||
from watchgod import Change, awatch
|
||||
|
||||
import pyotherside
|
||||
|
||||
from .pyotherside_events import UserFileChanged
|
||||
from .theme_parser import convert_to_qml
|
||||
from .utils import atomic_write, dict_update_recursive
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .backend import Backend
|
||||
|
||||
JsonData = Dict[str, Any]
|
||||
|
||||
WRITE_LOCK = asyncio.Lock()
|
||||
|
||||
|
||||
@dataclass
|
||||
class DataFile:
|
||||
"""Base class representing a user data file."""
|
||||
class UserFile:
|
||||
"""Base class representing a user config or data file."""
|
||||
|
||||
is_config: ClassVar[bool] = False
|
||||
create_missing: ClassVar[bool] = True
|
||||
|
||||
backend: "Backend" = field(repr=False)
|
||||
filename: str = field()
|
||||
|
||||
_to_write: Optional[str] = field(init=False, default=None)
|
||||
data: Any = field(init=False, default_factory=dict)
|
||||
_need_write: bool = field(init=False, default=False)
|
||||
_wrote: bool = field(init=False, default=False)
|
||||
|
||||
_reader: Optional[asyncio.Future] = field(init=False, default=None)
|
||||
_writer: Optional[asyncio.Future] = field(init=False, default=None)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
asyncio.ensure_future(self._write_loop())
|
||||
try:
|
||||
self.data, save = self.deserialized(self.path.read_text())
|
||||
except FileNotFoundError:
|
||||
self.data = self.default_data
|
||||
self._need_write = self.create_missing
|
||||
else:
|
||||
if save:
|
||||
self.save()
|
||||
|
||||
self._reader = asyncio.ensure_future(self._start_reader())
|
||||
self._writer = asyncio.ensure_future(self._start_writer())
|
||||
|
||||
@property
|
||||
def path(self) -> Path:
|
||||
"""Full path of the file, even if it doesn't exist yet."""
|
||||
"""Full path of the file, can exist or not exist."""
|
||||
raise NotImplementedError()
|
||||
|
||||
if self.is_config:
|
||||
return Path(
|
||||
os.environ.get("MIRAGE_CONFIG_DIR") or
|
||||
self.backend.appdirs.user_config_dir,
|
||||
) / self.filename
|
||||
@property
|
||||
def default_data(self) -> Any:
|
||||
"""Default deserialized content to use if the file doesn't exist."""
|
||||
raise NotImplementedError()
|
||||
|
||||
return Path(
|
||||
os.environ.get("MIRAGE_DATA_DIR") or
|
||||
self.backend.appdirs.user_data_dir,
|
||||
) / self.filename
|
||||
def deserialized(self, data: str) -> Tuple[Any, bool]:
|
||||
"""Return parsed data from file text and whether to call `save()`."""
|
||||
return (data, False)
|
||||
|
||||
def serialized(self) -> str:
|
||||
"""Return text from `UserFile.data` that can be written to disk."""
|
||||
raise NotImplementedError()
|
||||
|
||||
def save(self) -> None:
|
||||
"""Inform the disk writer coroutine that the data has changed."""
|
||||
self._need_write = True
|
||||
|
||||
async def set_data(self, data: Any) -> None:
|
||||
"""Set `data` and call `save()`, conveniance method for QML."""
|
||||
self.data = data
|
||||
self.save()
|
||||
|
||||
async def _start_reader(self) -> None:
|
||||
"""Disk reader coroutine, watches for file changes to update `data`."""
|
||||
|
||||
while not self.path.exists():
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async for changes in awatch(self.path):
|
||||
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
|
||||
|
||||
async with aiofiles.open(self.path) as file:
|
||||
self.data, save = self.deserialized(await file.read())
|
||||
|
||||
if save:
|
||||
self.save()
|
||||
|
||||
elif change[0] == Change.deleted:
|
||||
self._wrote = False
|
||||
self.data = self.default_data
|
||||
self._need_write = self.create_missing
|
||||
|
||||
if changes and ignored < len(changes):
|
||||
UserFileChanged(type(self), self.data)
|
||||
|
||||
|
||||
async def default_data(self):
|
||||
"""Default content if the file doesn't exist."""
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
async def read(self):
|
||||
"""Return future, existing or default content for the file on disk."""
|
||||
|
||||
if self._to_write is not None:
|
||||
return self._to_write
|
||||
|
||||
try:
|
||||
return self.path.read_text()
|
||||
except FileNotFoundError:
|
||||
default = await self.default_data()
|
||||
|
||||
if self.create_missing:
|
||||
await self.write(default)
|
||||
|
||||
return default
|
||||
|
||||
|
||||
async def write(self, data) -> None:
|
||||
"""Request for the file to be written/updated with data."""
|
||||
|
||||
self._to_write = data
|
||||
|
||||
|
||||
async def _write_loop(self) -> None:
|
||||
"""Write/update file on disk with a 1 second cooldown."""
|
||||
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)
|
||||
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
if self._to_write is None:
|
||||
continue
|
||||
|
||||
if not self.create_missing and not self.path.exists():
|
||||
continue
|
||||
|
||||
async with atomic_write(self.path) as (new, done):
|
||||
await new.write(self._to_write)
|
||||
done()
|
||||
|
||||
self._to_write = None
|
||||
if self._need_write:
|
||||
async with atomic_write(self.path) as (new, done):
|
||||
await new.write(self.serialized())
|
||||
done()
|
||||
|
||||
self._need_write = False
|
||||
self._wrote = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class JSONDataFile(DataFile):
|
||||
"""Represent a user data file in the JSON format."""
|
||||
class ConfigFile(UserFile):
|
||||
"""A file that goes in the configuration directory, e.g. ~/.config/app."""
|
||||
|
||||
_data: Optional[Dict[str, Any]] = field(init=False, default=None)
|
||||
@property
|
||||
def path(self) -> Path:
|
||||
return Path(
|
||||
os.environ.get("MIRAGE_CONFIG_DIR") or
|
||||
self.backend.appdirs.user_config_dir,
|
||||
) / self.filename
|
||||
|
||||
|
||||
def __getitem__(self, key: str) -> Any:
|
||||
if self._data is None:
|
||||
raise RuntimeError(f"{self}: read() hasn't been called yet")
|
||||
@dataclass
|
||||
class UserDataFile(UserFile):
|
||||
"""A file that goes in the user data directory, e.g. ~/.local/share/app."""
|
||||
|
||||
return self._data[key]
|
||||
@property
|
||||
def path(self) -> Path:
|
||||
return Path(
|
||||
os.environ.get("MIRAGE_DATA_DIR") or
|
||||
self.backend.appdirs.user_data_dir,
|
||||
) / self.filename
|
||||
|
||||
|
||||
async def default_data(self) -> JsonData:
|
||||
@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]
|
||||
|
||||
def __setitem__(self, key: Any, value: Any) -> None:
|
||||
self.data[key] = value
|
||||
|
||||
def __delitem__(self, key: Any) -> None:
|
||||
del self.data[key]
|
||||
|
||||
def __iter__(self) -> Iterator:
|
||||
return iter(self.data)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.data)
|
||||
|
||||
|
||||
@dataclass
|
||||
class JSONFile(MappingFile):
|
||||
"""A file stored on disk in the JSON format."""
|
||||
|
||||
@property
|
||||
def default_data(self) -> dict:
|
||||
return {}
|
||||
|
||||
|
||||
async def read(self) -> JsonData:
|
||||
"""Return future, existing or default content for the file on disk.
|
||||
def deserialized(self, data: str) -> Tuple[dict, bool]:
|
||||
"""Return parsed data from file text and whether to call `save()`.
|
||||
|
||||
If the file has missing keys, the missing data will be merged and
|
||||
written to disk before returning.
|
||||
|
||||
If `create_missing` is `True` and the file doesn't exist, it will be
|
||||
created.
|
||||
If the file has missing keys, the missing data will be merged to the
|
||||
returned dict and the second tuple item will be `True`.
|
||||
"""
|
||||
|
||||
if self._to_write is not None:
|
||||
return json.loads(self._to_write)
|
||||
|
||||
try:
|
||||
data = json.loads(self.path.read_text())
|
||||
except FileNotFoundError:
|
||||
if not self.create_missing:
|
||||
data = await self.default_data()
|
||||
self._data = data
|
||||
return data
|
||||
|
||||
data = {}
|
||||
loaded = json.loads(data)
|
||||
except json.JSONDecodeError:
|
||||
data = {}
|
||||
loaded = {}
|
||||
|
||||
all_data = await self.default_data()
|
||||
dict_update_recursive(all_data, data)
|
||||
|
||||
if data != all_data:
|
||||
await self.write(all_data)
|
||||
|
||||
self._data = all_data
|
||||
return all_data
|
||||
all_data = self.default_data.copy()
|
||||
dict_update_recursive(all_data, loaded)
|
||||
return (all_data, loaded != all_data)
|
||||
|
||||
|
||||
async def write(self, data: JsonData) -> None:
|
||||
js = json.dumps(data, indent=4, ensure_ascii=False, sort_keys=True)
|
||||
await super().write(js)
|
||||
def serialized(self) -> str:
|
||||
data = self.data
|
||||
return json.dumps(data, indent=4, ensure_ascii=False, sort_keys=True)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Accounts(JSONDataFile):
|
||||
"""Config file for saved matrix accounts: user ID, access tokens, etc."""
|
||||
|
||||
is_config = True
|
||||
class Accounts(ConfigFile, JSONFile):
|
||||
"""Config file for saved matrix accounts: user ID, access tokens, etc"""
|
||||
|
||||
filename: str = "accounts.json"
|
||||
|
||||
|
||||
async def any_saved(self) -> bool:
|
||||
"""Return whether there are any accounts saved on disk."""
|
||||
return bool(await self.read())
|
||||
"""Return for QML whether there are any accounts saved on disk."""
|
||||
return bool(self.data)
|
||||
|
||||
|
||||
async def add(self, user_id: str) -> None:
|
||||
|
@ -189,12 +221,10 @@ class Accounts(JSONDataFile):
|
|||
the corresponding `MatrixClient` in `backend.clients`.
|
||||
"""
|
||||
|
||||
client = self.backend.clients[user_id]
|
||||
saved = await self.read()
|
||||
account = self.backend.models["accounts"][user_id]
|
||||
client = self.backend.clients[user_id]
|
||||
account = self.backend.models["accounts"][user_id]
|
||||
|
||||
await self.write({
|
||||
**saved,
|
||||
self.update({
|
||||
client.user_id: {
|
||||
"homeserver": client.homeserver,
|
||||
"token": client.access_token,
|
||||
|
@ -205,9 +235,10 @@ class Accounts(JSONDataFile):
|
|||
"order": account.order,
|
||||
},
|
||||
})
|
||||
self.save()
|
||||
|
||||
|
||||
async def update(
|
||||
async def set(
|
||||
self,
|
||||
user_id: str,
|
||||
enabled: Optional[str] = None,
|
||||
|
@ -217,45 +248,39 @@ class Accounts(JSONDataFile):
|
|||
) -> None:
|
||||
"""Update an account if found in the config file and write to disk."""
|
||||
|
||||
saved = await self.read()
|
||||
|
||||
if user_id not in saved:
|
||||
if user_id not in self:
|
||||
return
|
||||
|
||||
if enabled is not None:
|
||||
saved[user_id]["enabled"] = enabled
|
||||
self[user_id]["enabled"] = enabled
|
||||
|
||||
if presence is not None:
|
||||
saved[user_id]["presence"] = presence
|
||||
self[user_id]["presence"] = presence
|
||||
|
||||
if order is not None:
|
||||
saved[user_id]["order"] = order
|
||||
self[user_id]["order"] = order
|
||||
|
||||
if status_msg is not None:
|
||||
saved[user_id]["status_msg"] = status_msg
|
||||
self[user_id]["status_msg"] = status_msg
|
||||
|
||||
await self.write({**saved})
|
||||
self.save()
|
||||
|
||||
|
||||
async def delete(self, user_id: str) -> None:
|
||||
async def forget(self, user_id: str) -> None:
|
||||
"""Delete an account from the config and write it on disk."""
|
||||
|
||||
await self.write({
|
||||
uid: info
|
||||
for uid, info in (await self.read()).items() if uid != user_id
|
||||
})
|
||||
self.pop(user_id, None)
|
||||
self.save()
|
||||
|
||||
|
||||
@dataclass
|
||||
class UISettings(JSONDataFile):
|
||||
"""Config file for QML interface settings and keybindings."""
|
||||
|
||||
is_config = True
|
||||
class Settings(ConfigFile, JSONFile):
|
||||
"""General config file for UI and backend settings"""
|
||||
|
||||
filename: str = "settings.json"
|
||||
|
||||
|
||||
async def default_data(self) -> JsonData:
|
||||
@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"
|
||||
|
@ -305,7 +330,6 @@ class UISettings(JSONDataFile):
|
|||
"keys": {
|
||||
"startPythonDebugger": ["Alt+Shift+D"],
|
||||
"toggleDebugConsole": ["Alt+Shift+C", "F1"],
|
||||
"reloadConfig": ["Alt+Shift+R"],
|
||||
|
||||
"zoomIn": ["Ctrl++"],
|
||||
"zoomOut": ["Ctrl+-"],
|
||||
|
@ -423,42 +447,58 @@ class UISettings(JSONDataFile):
|
|||
},
|
||||
}
|
||||
|
||||
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"])
|
||||
UserFileChanged(Theme, self.backend.theme.data)
|
||||
|
||||
return (dict_data, save)
|
||||
|
||||
|
||||
@dataclass
|
||||
class UIState(JSONDataFile):
|
||||
"""File to save and restore the state of the QML interface."""
|
||||
class UIState(UserDataFile, JSONFile):
|
||||
"""File used to save and restore the state of QML components."""
|
||||
|
||||
filename: str = "state.json"
|
||||
|
||||
|
||||
async def default_data(self) -> JsonData:
|
||||
@property
|
||||
def default_data(self) -> dict:
|
||||
return {
|
||||
"collapseAccounts": {},
|
||||
"page": "Pages/Default.qml",
|
||||
"pageProperties": {},
|
||||
}
|
||||
|
||||
def deserialized(self, data: str) -> Tuple[dict, bool]:
|
||||
dict_data, save = super().deserialized(data)
|
||||
|
||||
for user_id, do in dict_data["collapseAccounts"].items():
|
||||
self.backend.models["all_rooms"].set_account_collapse(user_id, do)
|
||||
|
||||
return (dict_data, save)
|
||||
|
||||
|
||||
@dataclass
|
||||
class History(JSONDataFile):
|
||||
class History(UserDataFile, JSONFile):
|
||||
"""File to save and restore lines typed by the user in QML components."""
|
||||
|
||||
filename: str = "history.json"
|
||||
|
||||
|
||||
async def default_data(self) -> JsonData:
|
||||
@property
|
||||
def default_data(self) -> dict:
|
||||
return {"console": []}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Theme(DataFile):
|
||||
class Theme(UserDataFile):
|
||||
"""A theme file defining the look of QML components."""
|
||||
|
||||
# Since it currently breaks at every update and the file format will be
|
||||
# changed later, don't copy the theme to user data dir if it doesn't exist.
|
||||
create_missing = False
|
||||
|
||||
|
||||
@property
|
||||
def path(self) -> Path:
|
||||
data_dir = Path(
|
||||
|
@ -467,19 +507,17 @@ class Theme(DataFile):
|
|||
)
|
||||
return data_dir / "themes" / self.filename
|
||||
|
||||
|
||||
async def default_data(self) -> str:
|
||||
@property
|
||||
def default_data(self) -> str:
|
||||
path = f"src/themes/{self.filename}"
|
||||
|
||||
try:
|
||||
byte_content = pyotherside.qrc_get_file_contents(path)
|
||||
except ValueError:
|
||||
# App was compiled without QRC
|
||||
async with aiofiles.open(path) as file:
|
||||
return await file.read()
|
||||
return convert_to_qml(Path(path).read_text())
|
||||
else:
|
||||
return byte_content.decode()
|
||||
return convert_to_qml(byte_content.decode())
|
||||
|
||||
|
||||
async def read(self) -> str:
|
||||
return convert_to_qml(await super().read())
|
||||
def deserialized(self, data: str) -> Tuple[str, bool]:
|
||||
return (convert_to_qml(data), False)
|
||||
|
|
|
@ -40,12 +40,6 @@ Rectangle {
|
|||
py.callCoro("get_theme_dir", [], Qt.openUrlExternally)
|
||||
}
|
||||
|
||||
HMenuItem {
|
||||
icon.name: "reload-config-files"
|
||||
text: qsTr("Reload config & theme")
|
||||
onTriggered: mainUI.reloadSettings()
|
||||
}
|
||||
|
||||
HMenuItem {
|
||||
icon.name: "debug"
|
||||
text: qsTr("Developer console")
|
||||
|
|
|
@ -41,7 +41,7 @@ HFlickableColumnPage {
|
|||
py.callCoro(
|
||||
rememberAccount.checked ?
|
||||
"saved_accounts.add":
|
||||
"saved_accounts.delete",
|
||||
"saved_accounts.forget",
|
||||
|
||||
[receivedUserId]
|
||||
)
|
||||
|
|
|
@ -65,6 +65,19 @@ QtObject {
|
|||
py.showError(type, traceback, "", message)
|
||||
}
|
||||
|
||||
function onUserFileChanged(type, newData) {
|
||||
if (type === "Theme") {
|
||||
window.theme = Qt.createQmlObject(newData, window, "theme")
|
||||
utils.theme = window.theme
|
||||
return
|
||||
}
|
||||
|
||||
type === "Settings" ? window.settings = newData :
|
||||
type === "UIState" ? window.uiState = newData :
|
||||
type === "History" ? window.history = newData :
|
||||
null
|
||||
}
|
||||
|
||||
function onModelItemSet(syncId, indexThen, indexNow, changedFields) {
|
||||
const model = ModelStore.get(syncId)
|
||||
|
||||
|
|
|
@ -47,23 +47,9 @@ Python {
|
|||
call("BRIDGE.cancel_coro", [uuid])
|
||||
}
|
||||
|
||||
function saveConfig(backend_attribute, data, callback=null) {
|
||||
if (! py.ready) { return } // config not loaded yet
|
||||
return callCoro(backend_attribute + ".write", [data], callback)
|
||||
}
|
||||
|
||||
function loadSettings(callback=null) {
|
||||
const func = "load_settings"
|
||||
|
||||
return callCoro(func, [], ([settings, uiState, history, theme]) => {
|
||||
window.settings = settings
|
||||
window.uiState = uiState
|
||||
window.history = history
|
||||
window.theme = Qt.createQmlObject(theme, window, "theme")
|
||||
utils.theme = window.theme
|
||||
|
||||
if (callback) { callback(settings, uiState, theme) }
|
||||
})
|
||||
function saveConfig(backend_attribute, data) {
|
||||
if (! py.ready) { return } // config not done loading yet
|
||||
callCoro(backend_attribute + ".set_data", [data])
|
||||
}
|
||||
|
||||
function showError(type, traceback, sourceIndication="", message="") {
|
||||
|
|
|
@ -22,7 +22,13 @@ PythonBridge {
|
|||
addImportPath("qrc:/src")
|
||||
|
||||
importNames("backend.qml_bridge", ["BRIDGE"], () => {
|
||||
loadSettings(() => {
|
||||
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("saved_accounts.any_saved", [], any => {
|
||||
if (any) { callCoro("load_saved_accounts", []) }
|
||||
|
||||
|
|
|
@ -28,41 +28,18 @@ Item {
|
|||
readonly property alias debugConsole: debugConsole
|
||||
readonly property alias mainPane: mainPane
|
||||
readonly property alias pageLoader: pageLoader
|
||||
readonly property alias pressAnimation: pressAnimation
|
||||
readonly property alias fontMetrics: fontMetrics
|
||||
readonly property alias idleManager: idleManager
|
||||
|
||||
function reloadSettings() {
|
||||
py.loadSettings(() => {
|
||||
image.reload()
|
||||
mainUI.pressAnimation.start()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
focus: true
|
||||
Component.onCompleted: window.mainUI = mainUI
|
||||
|
||||
SequentialAnimation {
|
||||
id: pressAnimation
|
||||
HNumberAnimation {
|
||||
target: mainUI; property: "scale"; from: 1.0; to: 0.9
|
||||
}
|
||||
HNumberAnimation {
|
||||
target: mainUI; property: "scale"; from: 0.9; to: 1.0
|
||||
}
|
||||
}
|
||||
|
||||
HShortcut {
|
||||
sequences: window.settings.keys.startPythonDebugger
|
||||
onActivated: py.call("BRIDGE.pdb")
|
||||
}
|
||||
|
||||
HShortcut {
|
||||
sequences: window.settings.keys.reloadConfig
|
||||
onActivated: reloadSettings()
|
||||
}
|
||||
|
||||
HShortcut {
|
||||
sequences: window.settings.keys.zoomIn
|
||||
onActivated: {
|
||||
|
|
|
@ -88,7 +88,7 @@ ApplicationWindow {
|
|||
|
||||
// NOTE: For JS object variables, the corresponding method to notify
|
||||
// key/value changes must be called manually, e.g. settingsChanged().
|
||||
onSettingsChanged: py.saveConfig("ui_settings", settings)
|
||||
onSettingsChanged: py.saveConfig("settings", settings)
|
||||
onUiStateChanged: py.saveConfig("ui_state", uiState)
|
||||
onHistoryChanged: py.saveConfig("history", history)
|
||||
|
||||
|
|
Loading…
Reference in New Issue
Block a user