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