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:
miruka 2020-10-05 03:06:07 -04:00
parent 00c468384a
commit 75fbf23b21
13 changed files with 249 additions and 230 deletions

View File

@ -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"

View File

@ -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:

View File

@ -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, {}),
) )

View File

@ -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,

View File

@ -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."""

View File

@ -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
if not self.create_missing and not self.path.exists():
continue
async with atomic_write(self.path) as (new, done): async with atomic_write(self.path) as (new, done):
await new.write(self._to_write) await new.write(self.serialized())
done() 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:
@ -190,11 +222,9 @@ class Accounts(JSONDataFile):
""" """
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())

View File

@ -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")

View File

@ -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]
) )

View File

@ -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)

View File

@ -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="") {

View File

@ -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", []) }

View File

@ -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: {

View File

@ -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)