From db120363728a660a888ce4c7472051cb2ff6d1a0 Mon Sep 17 00:00:00 2001 From: miruka Date: Wed, 7 Oct 2020 20:12:32 -0400 Subject: [PATCH] Use new PCN format for settings config file --- TODO.md | 1 + src/backend/backend.py | 12 +- src/backend/matrix_client.py | 28 +- src/backend/nio_callbacks.py | 6 +- src/backend/pcn/__init__.py | 4 + src/backend/pcn/globals_dict.py | 37 ++ src/backend/pcn/property.py | 52 +++ src/backend/pcn/section.py | 395 ++++++++++++++++ src/backend/user_files.py | 339 ++++++-------- src/backend/utils.py | 60 ++- src/config/settings.py | 430 ++++++++++++++++++ src/gui/Base/HDrawer.qml | 2 +- src/gui/Base/HFlickable.qml | 4 +- src/gui/Base/HGridView.qml | 4 +- src/gui/Base/HImage.qml | 2 +- src/gui/Base/HKineticScrollingDisabler.qml | 9 +- src/gui/Base/HListView.qml | 4 +- src/gui/Base/HTile/HTile.qml | 2 +- src/gui/Base/HToolTip.qml | 2 +- src/gui/Base/MediaPlayer/OSD.qml | 2 +- src/gui/Base/PresenceOrb.qml | 2 +- src/gui/DebugConsole.qml | 6 +- src/gui/IdleManager.qml | 4 +- src/gui/MainPane/AccountBar.qml | 4 +- src/gui/MainPane/AccountDelegate.qml | 16 +- src/gui/MainPane/BottomBar.qml | 10 +- src/gui/MainPane/MainPane.qml | 3 +- src/gui/MainPane/RoomList.qml | 29 +- src/gui/PageLoader.qml | 4 +- src/gui/Pages/AccountSettings/Account.qml | 8 +- src/gui/Pages/AccountSettings/Sessions.qml | 4 +- src/gui/Pages/Chat/Chat.qml | 4 +- src/gui/Pages/Chat/Composer/MessageArea.qml | 2 +- src/gui/Pages/Chat/Composer/UploadButton.qml | 4 +- src/gui/Pages/Chat/RoomHeader.qml | 2 +- .../Chat/RoomPane/MemberView/MemberView.qml | 6 +- src/gui/Pages/Chat/RoomPane/RoomPane.qml | 4 +- src/gui/Pages/Chat/Timeline/EventContent.qml | 4 +- src/gui/Pages/Chat/Timeline/EventDelegate.qml | 2 +- src/gui/Pages/Chat/Timeline/EventFile.qml | 7 +- src/gui/Pages/Chat/Timeline/EventImage.qml | 23 +- src/gui/Pages/Chat/Timeline/EventList.qml | 31 +- .../ImageViewerPopup/ImageViewerPopup.qml | 2 +- .../Popups/ImageViewerPopup/ViewerButtons.qml | 20 +- .../Popups/ImageViewerPopup/ViewerCanvas.qml | 14 +- src/gui/PythonBridge/EventHandlers.qml | 4 +- src/gui/ShortcutBundles/FlickShortcuts.qml | 12 +- src/gui/ShortcutBundles/TabShortcuts.qml | 4 +- src/gui/UI.qml | 28 +- src/gui/Window.qml | 26 +- src/themes/Glass.qpl | 15 +- src/themes/Midnight.qpl | 15 +- 52 files changed, 1305 insertions(+), 409 deletions(-) create mode 100644 src/backend/pcn/__init__.py create mode 100644 src/backend/pcn/globals_dict.py create mode 100644 src/backend/pcn/property.py create mode 100644 src/backend/pcn/section.py create mode 100644 src/config/settings.py diff --git a/TODO.md b/TODO.md index 8ec3daca..2a9e03d0 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,6 @@ # TODO +- login page password spinner - Encrypted rooms don't show invites in member list after Mirage restart - Room display name not updated when someone removes theirs - Fix right margin of own `\n` messages diff --git a/src/backend/backend.py b/src/backend/backend.py index b03ff994..12f95591 100644 --- a/src/backend/backend.py +++ b/src/backend/backend.py @@ -109,7 +109,7 @@ class Backend: self.settings = Settings(self) self.ui_state = UIState(self) self.history = History(self) - self.theme = Theme(self, self.settings["theme"]) + self.theme = Theme(self, self.settings.General.theme) self.clients: Dict[str, MatrixClient] = {} @@ -427,13 +427,13 @@ class Backend: return path - async def get_settings(self) -> Tuple[Settings, UIState, History, str]: + async def get_settings(self) -> Tuple[dict, UIState, History, str]: """Return parsed user config files for QML.""" return ( - self.settings.data, - self.ui_state.data, - self.history.data, - self.theme.data, + self.settings.qml_data, + self.ui_state.qml_data, + self.history.qml_data, + self.theme.qml_data, ) diff --git a/src/backend/matrix_client.py b/src/backend/matrix_client.py index 8c41510c..4c1baa55 100644 --- a/src/backend/matrix_client.py +++ b/src/backend/matrix_client.py @@ -493,7 +493,7 @@ class MatrixClient(nio.AsyncClient): utils.dict_update_recursive(first, self.low_limit_filter) - if self.backend.settings["hideUnknownEvents"]: + if not self.backend.settings.Chat.show_unknown_events: first["room"]["timeline"]["not_types"].extend( self.no_unknown_events_filter ["room"]["timeline"]["not_types"], @@ -1146,14 +1146,22 @@ class MatrixClient(nio.AsyncClient): room = self.models[self.user_id, "rooms"][room_id] room.bookmarked = not room.bookmarked - settings = self.backend.ui_settings - bookmarks = settings["roomBookmarkIDs"].setdefault(self.user_id, []) - if room.bookmarked and room_id not in bookmarks: - bookmarks.append(room_id) - elif not room.bookmarked and room_id in bookmarks: - bookmarks.remove(room_id) + settings = self.backend.settings + bookmarks = settings.RoomList.bookmarks + user_bookmarks = bookmarks.setdefault(self.user_id, []) - await self.backend.ui_settings.write(self.backend.ui_settings._data) + if room.bookmarked and room_id not in user_bookmarks: + user_bookmarks.append(room_id) + + while not room.bookmarked and room_id in user_bookmarks: + user_bookmarks.remove(room_id) + + # Changes inside dicts/lists aren't monitored, need to reassign + settings.RoomList.bookmarks = { + **bookmarks, self.user_id: user_bookmarks, + } + + self.backend.settings.save() async def room_forget(self, room_id: str) -> None: """Leave a joined room (or decline an invite) and forget its history. @@ -1866,7 +1874,7 @@ class MatrixClient(nio.AsyncClient): ) unverified_devices = registered.unverified_devices - bookmarks = self.backend.ui_settings["roomBookmarkIDs"] + bookmarks = self.backend.settings.RoomList.bookmarks room_item = Room( id = room.room_id, for_account = self.user_id, @@ -1912,7 +1920,7 @@ class MatrixClient(nio.AsyncClient): local_unreads = local_unreads, local_highlights = local_highlights, - lexical_sorting = self.backend.settings["lexicalRoomSorting"], + lexical_sorting = self.backend.settings.RoomList.lexical_sort, bookmarked = room.room_id in bookmarks.get(self.user_id, {}), ) diff --git a/src/backend/nio_callbacks.py b/src/backend/nio_callbacks.py index 394f02a4..d8f790e6 100644 --- a/src/backend/nio_callbacks.py +++ b/src/backend/nio_callbacks.py @@ -498,7 +498,7 @@ class NioCallbacks: # Membership changes if not prev or membership != prev_membership: - if self.client.backend.settings["hideMembershipEvents"]: + if not self.client.backend.settings.Chat.show_membership_events: return None reason = escape( @@ -564,7 +564,7 @@ class NioCallbacks: avatar_url = now.get("avatar_url") or "", ) - if self.client.backend.settings["hideProfileChangeEvents"]: + if not self.client.backend.settings.Chat.show_profile_changes: return None return ( @@ -682,7 +682,7 @@ class NioCallbacks: async def onUnknownEvent( self, room: nio.MatrixRoom, ev: nio.UnknownEvent, ) -> None: - if self.client.backend.settings["hideUnknownEvents"]: + if not self.client.backend.settings.Chat.show_unknown_events: await self.client.register_nio_room(room) return diff --git a/src/backend/pcn/__init__.py b/src/backend/pcn/__init__.py new file mode 100644 index 00000000..9decbe4b --- /dev/null +++ b/src/backend/pcn/__init__.py @@ -0,0 +1,4 @@ +# Copyright Mirage authors & contributors +# SPDX-License-Identifier: LGPL-3.0-or-later + +"""Parse and operate on PCN (Python Config Notation) files.""" diff --git a/src/backend/pcn/globals_dict.py b/src/backend/pcn/globals_dict.py new file mode 100644 index 00000000..5a8b0589 --- /dev/null +++ b/src/backend/pcn/globals_dict.py @@ -0,0 +1,37 @@ +from collections import UserDict +from typing import TYPE_CHECKING, Any, Dict, Iterator + +if TYPE_CHECKING: + from .section import Section + +PCN_GLOBALS: Dict[str, Any] = {} + + +class GlobalsDict(UserDict): + def __init__(self, section: "Section") -> None: + super().__init__() + self.section = section + + @property + def full_dict(self) -> Dict[str, Any]: + return { + **PCN_GLOBALS, + **(self.section.root if self.section.root else {}), + **(self.section.root.globals if self.section.root else {}), + "self": self.section, + "parent": self.section.parent, + "root": self.section.parent, + **self.data, + } + + def __getitem__(self, key: str) -> Any: + return self.full_dict[key] + + def __iter__(self) -> Iterator[str]: + return iter(self.full_dict) + + def __len__(self) -> int: + return len(self.full_dict) + + def __repr__(self) -> str: + return repr(self.full_dict) diff --git a/src/backend/pcn/property.py b/src/backend/pcn/property.py new file mode 100644 index 00000000..26c67657 --- /dev/null +++ b/src/backend/pcn/property.py @@ -0,0 +1,52 @@ +import re +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Callable, Dict, Type + +if TYPE_CHECKING: + from .section import Section + +TYPE_PROCESSORS: Dict[str, Callable[[Any], Any]] = { + "tuple": lambda v: tuple(v), + "set": lambda v: set(v), +} + + +class Unset: + pass + + +@dataclass +class Property: + name: str = field() + annotation: str = field() + expression: str = field() + section: "Section" = field() + value_override: Any = Unset + + def __get__(self, obj: "Section", objtype: Type["Section"]) -> Any: + if not obj: + return self + + if self.value_override is not Unset: + return self.value_override + + env = obj.globals + result = eval(self.expression, dict(env), env) # nosec + + return process_value(self.annotation, result) + + def __set__(self, obj: "Section", value: Any) -> None: + self.value_override = value + obj._edited[self.name] = value + + +def process_value(annotation: str, value: Any) -> Any: + annotation = re.sub(r"\[.*\]$", "", annotation) + + if annotation in TYPE_PROCESSORS: + return TYPE_PROCESSORS[annotation](value) + + if annotation.lower() in TYPE_PROCESSORS: + return TYPE_PROCESSORS[annotation.lower()](value) + + return value diff --git a/src/backend/pcn/section.py b/src/backend/pcn/section.py new file mode 100644 index 00000000..94374535 --- /dev/null +++ b/src/backend/pcn/section.py @@ -0,0 +1,395 @@ +import textwrap +from collections import OrderedDict +from collections.abc import MutableMapping +from dataclasses import dataclass, field +from operator import attrgetter +from pathlib import Path +from typing import ( + Any, Callable, ClassVar, Dict, Generator, List, Optional, Set, Tuple, Type, + Union, +) + +import redbaron as red + +from .globals_dict import GlobalsDict +from .property import Property, process_value + +# TODO: docstrings, error handling, support @property, non-section classes +BUILTINS_DIR: Path = Path(__file__).parent.parent.parent.resolve() # src dir + + +@dataclass(repr=False, eq=False) +class Section(MutableMapping): + sections: ClassVar[Set[str]] = set() + methods: ClassVar[Set[str]] = set() + properties: ClassVar[Set[str]] = set() + order: ClassVar[Dict[str, None]] = OrderedDict() + + source_path: Optional[Path] = None + root: Optional["Section"] = None + parent: Optional["Section"] = None + builtins_path: Path = BUILTINS_DIR + globals: GlobalsDict = field(init=False) + + _edited: Dict[str, Any] = field(init=False, default_factory=dict) + + def __init_subclass__(cls, **kwargs) -> None: + # Make these attributes not shared between Section and its subclasses + cls.sections = set() + cls.methods = set() + cls.properties = set() + cls.order = OrderedDict() + + for parent_class in cls.__bases__: + if not issubclass(parent_class, Section): + continue + + cls.sections |= parent_class.sections # union operator + cls.methods |= parent_class.methods + cls.properties |= parent_class.properties + cls.order.update(parent_class.order) + + super().__init_subclass__(**kwargs) # type: ignore + + + def __post_init__(self) -> None: + self.globals = GlobalsDict(self) + + + def __getattr__(self, name: str) -> Union["Section", Any]: + # This method signature tells mypy about the dynamic attribute types + # we can access. The body is run for attributes that aren't found. + + return super().__getattribute__(name) + + + def __setattr__(self, name: str, value: Any) -> None: + # This method tells mypy about the dynamic attribute types we can set. + # The body is also run when setting an existing or new attribute. + + if name in self.__dataclass_fields__: + super().__setattr__(name, value) + return + + if name in self.properties: + value = process_value(getattr(type(self), name).annotation, value) + + if self[name] == value: + return + + getattr(type(self), name).value_override = value + self._edited[name] = value + return + + if name in self.properties: + return + + if name in self.sections: + raise NotImplementedError(f"cannot overwrite section {name!r}") + + if name in self.methods: + raise NotImplementedError(f"cannot overwrite method {name!r}") + + raise NotImplementedError(f"cannot add new attribute {name!r}") + + + def __delattr__(self, name: str) -> None: + raise NotImplementedError(f"cannot delete existing attribute {name!r}") + + + def __getitem__(self, key: str) -> Any: + try: + return getattr(self, key) + except AttributeError as err: + raise KeyError(str(err)) + + + def __setitem__(self, key: str, value: Union["Section", str]) -> None: + setattr(self, key, value) + + + def __delitem__(self, key: str) -> None: + delattr(self, key) + + + def __iter__(self) -> Generator[str, None, None]: + for attr_name in self.order: + yield attr_name + + + def __len__(self) -> int: + return len(self.order) + + + def __eq__(self, obj: Any) -> bool: + if not isinstance(obj, Section): + return False + + if self.globals.data != obj.globals.data or self.order != obj.order: + return False + + return not any(self[attr] != obj[attr] for attr in self.order) + + + def __repr__(self) -> str: + name: str = type(self).__name__ + children: List[str] = [] + content: str = "" + newline: bool = False + + for attr_name in self.order: + value = getattr(self, attr_name) + + if attr_name in self.sections: + before = "\n" if children else "" + newline = True + + try: + children.append(f"{before}{value!r},") + except RecursionError as err: + name = type(value).__name__ + children.append(f"{before}{name}(\n {err!r}\n),") + pass + + elif attr_name in self.methods: + before = "\n" if children else "" + newline = True + children.append(f"{before}def {value.__name__}(…),") + + elif attr_name in self.properties: + before = "\n" if newline else "" + newline = False + + try: + children.append(f"{before}{attr_name} = {value!r},") + except RecursionError as err: + children.append(f"{before}{attr_name} = {err!r},") + + else: + newline = False + + if children: + content = "\n%s\n" % textwrap.indent("\n".join(children), " " * 4) + + return f"{name}({content})" + + + @classmethod + def _register_set_attr(cls, name: str, add_to_set_name: str) -> None: + cls.methods.discard(name) + cls.properties.discard(name) + cls.sections.discard(name) + getattr(cls, add_to_set_name).add(name) + cls.order[name] = None + + for subclass in cls.__subclasses__(): + subclass._register_set_attr(name, add_to_set_name) + + + def _set_section(self, section: "Section") -> None: + name = type(section).__name__ + + if hasattr(self, name) and name not in self.order: + raise AttributeError(f"{name!r}: forbidden name") + + if name in self.sections: + self[name].deep_merge(section) + return + + self._register_set_attr(name, "sections") + setattr(type(self), name, section) + + + def _set_method(self, name: str, method: Callable) -> None: + if hasattr(self, name) and name not in self.order: + raise AttributeError(f"{name!r}: forbidden name") + + self._register_set_attr(name, "methods") + setattr(type(self), name, method) + + + def _set_property( + self, name: str, annotation: str, expression: str, + ) -> None: + if hasattr(self, name) and name not in self.order: + raise AttributeError(f"{name!r}: forbidden name") + + prop = Property(name, annotation, expression, self) + self._register_set_attr(name, "properties") + setattr(type(self), name, prop) + + + def deep_merge(self, section2: "Section") -> None: + for key in section2: + if key in self.sections and key in section2.sections: + self.globals.data.update(section2.globals.data) + self[key].deep_merge(section2[key]) + + elif key in section2.sections: + self.globals.data.update(section2.globals.data) + new_type = type(key, (Section,), {}) + instance = new_type( + source_path = self.source_path, + root = self.root or self, + parent = self, + builtins_path = self.builtins_path, + ) + self._set_section(instance) + instance.deep_merge(section2[key]) + + elif key in section2.methods: + self._set_method(key, section2[key]) + + else: + prop2 = getattr(type(section2), key) + self._set_property(key, prop2.annotation, prop2.expression) + + + def include_file(self, path: Union[Path, str]) -> None: + if not Path(path).is_absolute() and self.source_path: + path = self.source_path.parent / path + + self.deep_merge(Section.from_file(path)) + + + def include_builtin(self, relative_path: Union[Path, str]) -> None: + self.deep_merge(Section.from_file(self.builtins_path / relative_path)) + + + def as_dict(self, _section: Optional["Section"] = None) -> Dict[str, Any]: + dct = {} + section = self if _section is None else _section + + for key, value in section.items(): + if isinstance(value, Section): + dct[key] = self.as_dict(value) + else: + dct[key] = value + + return dct + + + def edits_as_dict( + self, _section: Optional["Section"] = None, + ) -> Dict[str, Any]: + + warning = ( + "This file is generated when settings are changed from the GUI, " + "and properties in it override the ones in the corresponding " + "PCN user config file. " + "If a property is gets changed in the PCN file, any corresponding " + "property override here is removed." + ) + + if _section is None: + section = self + dct = {"__comment": warning, "set": section._edited.copy()} + add_to = dct["set"] + else: + section = _section + dct = { + prop_name: ( + getattr(type(section), prop_name).expression, + value_override, + ) + for prop_name, value_override in section._edited.items() + } + add_to = dct + + for name in section.sections: + edits = section.edits_as_dict(section[name]) + + if edits: + add_to[name] = edits # type: ignore + + return dct + + + def deep_merge_edits( + self, edits: Dict[str, Any], has_expressions: bool = True, + ) -> None: + if not self.parent: # this is Root + edits = edits.get("set", {}) + + for name, value in edits.items(): + if name not in self: + continue + + if isinstance(self[name], Section) and isinstance(value, dict): + self[name].deep_merge_edits(value, has_expressions) + + elif not has_expressions: + self[name] = value + + elif isinstance(value, (tuple, list)): + user_expression, gui_value = value + + if getattr(type(self), name).expression == user_expression: + self[name] = gui_value + + + @classmethod + def from_source_code( + cls, + code: str, + path: Optional[Path] = None, + builtins: Optional[Path] = None, + *, + inherit: Tuple[Type["Section"], ...] = (), + node: Union[None, red.RedBaron, red.ClassNode] = None, + name: str = "Root", + root: Optional["Section"] = None, + parent: Optional["Section"] = None, + ) -> "Section": + + builtins = builtins or BUILTINS_DIR + section: Type["Section"] = type(name, inherit or (Section,), {}) + instance: Section = section(path, root, parent, builtins) + + node = node or red.RedBaron(code) + + for child in node.node_list: + if isinstance(child, red.ClassNode): + root_arg = instance if root is None else root + child_inherit = [] + + for name in child.inherit_from.dumps().split(","): + name = name.strip() + + if root_arg is not None and name: + child_inherit.append(type(attrgetter(name)(root_arg))) + + instance._set_section(section.from_source_code( + code = code, + path = path, + builtins = builtins, + inherit = tuple(child_inherit), + node = child, + name = child.name, + root = root_arg, + parent = instance, + )) + + elif isinstance(child, red.AssignmentNode): + instance._set_property( + child.target.value, + child.annotation.dumps() if child.annotation else "", + child.value.dumps(), + ) + + else: + env = instance.globals + exec(child.dumps(), dict(env), env) # nosec + + if isinstance(child, red.DefNode): + instance._set_method(child.name, env[child.name]) + + return instance + + + @classmethod + def from_file( + cls, path: Union[str, Path], builtins: Union[str, Path] = BUILTINS_DIR, + ) -> "Section": + path = Path(path) + return Section.from_source_code(path.read_text(), path, Path(builtins)) diff --git a/src/backend/user_files.py b/src/backend/user_files.py index 5dcdeb47..c0844834 100644 --- a/src/backend/user_files.py +++ b/src/backend/user_files.py @@ -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 diff --git a/src/backend/utils.py b/src/backend/utils.py index d94c56b9..767363a3 100644 --- a/src/backend/utils.py +++ b/src/backend/utils.py @@ -12,14 +12,16 @@ import json import sys import xml.etree.cElementTree as xml_etree # FIXME: bandit warning from concurrent.futures import ProcessPoolExecutor -from datetime import datetime, timedelta +from contextlib import suppress +from datetime import date, datetime, time, timedelta from enum import Enum from enum import auto as autostr from pathlib import Path from tempfile import NamedTemporaryFile from types import ModuleType from typing import ( - Any, AsyncIterator, Callable, Dict, Mapping, Sequence, Tuple, Type, Union, + Any, AsyncIterator, Callable, Dict, Iterable, Mapping, Sequence, Tuple, + Type, Union, ) from uuid import UUID @@ -27,10 +29,9 @@ import aiofiles import filetype from aiofiles.threadpool.binary import AsyncBufferedReader from aiofiles.threadpool.text import AsyncTextIOWrapper -from PIL import Image as PILImage - from nio.crypto import AsyncDataT as File from nio.crypto import async_generator_from_data +from PIL import Image as PILImage if sys.version_info >= (3, 7): from contextlib import asynccontextmanager @@ -145,14 +146,17 @@ def plain2html(text: str) -> str: .replace("\t", " " * 4) -def serialize_value_for_qml(value: Any, json_list_dicts: bool = False) -> Any: +def serialize_value_for_qml( + value: Any, json_list_dicts: bool = False, reject_unknown: bool = False, +) -> Any: """Convert a value to make it easier to use from QML. Returns: - - For `int`, `float`, `bool`, `str` and `datetime`: the unchanged value + - For `bool`, `int`, `float`, `bytes`, `str`, `datetime`, `date`, `time`: + the unchanged value (PyOtherSide handles these) - - For `Sequence` and `Mapping` subclasses (includes `list` and `dict`): + - For `Iterable` objects (includes `list` and `dict`): a JSON dump if `json_list_dicts` is `True`, else the unchanged value - If the value is an instancied object and has a `serialized` attribute or @@ -168,10 +172,11 @@ def serialize_value_for_qml(value: Any, json_list_dicts: bool = False) -> Any: - For class types: the class `__name__` - - For anything else: the unchanged value + - For anything else: raise a `TypeError` if `reject_unknown` is `True`, + else return the unchanged value. """ - if isinstance(value, (int, float, bool, str, datetime)): + if isinstance(value, (bool, int, float, bytes, str, datetime, date, time)): return value if json_list_dicts and isinstance(value, (Sequence, Mapping)): @@ -180,6 +185,9 @@ def serialize_value_for_qml(value: Any, json_list_dicts: bool = False) -> Any: if not inspect.isclass(value) and hasattr(value, "serialized"): return value.serialized + if isinstance(value, Iterable): + return value + if hasattr(value, "__class__") and issubclass(value.__class__, Enum): return value.value @@ -195,9 +203,43 @@ def serialize_value_for_qml(value: Any, json_list_dicts: bool = False) -> Any: if inspect.isclass(value): return value.__name__ + if reject_unknown: + raise TypeError("Unknown type reject") + return value +def deep_serialize_for_qml(obj: Iterable) -> Union[list, dict]: + """Recursively serialize lists and dict values for QML.""" + + if isinstance(obj, Mapping): + dct = {} + + for key, value in obj.items(): + if isinstance(value, Iterable) and not isinstance(value, str): + # PyOtherSide only accept dicts with string keys + dct[str(key)] = deep_serialize_for_qml(value) + continue + + with suppress(TypeError): + dct[str(key)] = \ + serialize_value_for_qml(value, reject_unknown=True) + + return dct + + lst = [] + + for value in obj: + if isinstance(value, Iterable) and not isinstance(value, str): + lst.append(deep_serialize_for_qml(value)) + continue + + with suppress(TypeError): + lst.append(serialize_value_for_qml(value, reject_unknown=True)) + + return lst + + def classes_defined_in(module: ModuleType) -> Dict[str, Type]: """Return a `{name: class}` dict of all the classes a module defines.""" diff --git a/src/config/settings.py b/src/config/settings.py new file mode 100644 index 00000000..fea5348a --- /dev/null +++ b/src/config/settings.py @@ -0,0 +1,430 @@ +# pylint: skip-file +# flake8: noqa +# mypy: ignore-errors + +class General: + # When closing the window, minimize the application to system tray instead + # of quitting the application. + # A click on the tray icon reveals the window, middle click fully quits it + # and right click opens a menu with these options. + close_to_tray: bool = False + + # Show rooms, members and messages in way that takes less vertical space. + compact: bool = False + + # When the window width is less than this number of pixels, switch to a + # mobile-like mode where only the left main pane, center page/chat or + # right room pane is visible at a time. + hide_side_panes_under: int = 450 + + # How many seconds the cursor must hover on buttons and other elements + # to show tooltips. + tooltips_delay: float = 0.5 + + # Application theme to use. + # Can be the name of a built-in theme (Mirage.qpl or Glass.qpl), or + # the name (including extension) of a file in the user theme folder, which + # is "$XDG_DATA_HOME/mirage/themes" or "~/.local/share/mirage/themes". + # For Flatpak, it is + # "~/.var/app/io.github.mirukana.mirage/data/mirage/themes". + theme: str = "Midnight.qpl" + + # Interface scale multiplier, e.g. 0.5 makes everything half-size. + zoom: float = 1.0 + +class Presence: + # Automatically set your presence to unavailable after this number of + # seconds without any mouse or keyboard activity. + # This currently only works on Linux X11. + auto_away_after: int = 60 * 10 + +class Notifications: + # How long in seconds window alerts will last when a new message + # is posted in a room. On most desktops, this highlights or flashes the + # application in the taskbar or dock. + # Can be set to 0 for no alerts. + # Can be set to -1 for alerts that last until the window is focused. + alert_time: float = 0 + + # Same as alert_time for urgent messages, e.g. when you are mentioned, + # replied to, or the message contains a keyword. + urgent_alert_time: float = -1 + +class Scrolling: + # Use velocity-based kinetic scrolling. + # Can cause problems on laptop touchpads and some special mouse wheels. + kinetic: bool = True + + # Maximum allowed velocity when kinetic scrolling is used. + kinetic_max_speed: int = 2500 + + # When kinetic scrolling is used, how fast the view slows down when you + # stop physically scrolling. + kinetic_deceleration: int = 1500 + + # Multiplier for the scrolling speed when kinetic scrolling is + # disabled, e.g. 1.5 is 1.5x faster than the default speed. + non_kinetic_speed: float = 1.0 + +class RoomList: + # Prevent resizing the pane below this width in pixels. + min_width: int = 144 + + # Sort rooms in alphabetical order instead of recent activity. + # The application must be restarted to apply changes to this setting. + lexical_sort: bool = False + + # Mapping of account user ID to list of room ID to always keep on top. + # You can copy a room's ID by right clicking on it in the room list. + # Example: {"@alice:example.org": ["!aBc@example.org", "!123:example.org"]} + bookmarks: Dict[str, List[str]] = {} + + # When clicking on a room, recenter the room list on that room. + click_centers: bool = False + + # When pressing enter in the room filter field, clear the field's text, + # in addition to activating the keyboard-focused room. + enter_clears_filter: bool = True + + # When pressing escape in the room filter field, clear the field's text. + # in addition to focusing the current page or chat composer. + escape_clears_filter: bool = True + +class Chat: + # Center the chat header (room avatar, name and topic) even when sidepanes + # aren't hidden (see comment for the hide_sidepanes_under setting). + always_center_header: bool = False + + # When the chat timeline is larger than this pixel number, + # Align your own messages to the left of the timeline instead of right. + # Can be 0 to always show your messages on the left. + own_messages_on_left_above: int = 895 + + # Maximum number of characters in a message line before wrapping the text + # to a new line. Ignores messages containing code blocks or tables. + max_messages_line_length: int = 65 + + # Show membership events in the timeline: when someone is invited to the + # room, joins, leaves, is kicked, banned or unbanned. + show_membership_events: bool = True + + # Show room member display name and avatar change events in the timeline. + show_profile_changes: bool = False + + # Show a notice in the timeline for events types that aren't recognized. + show_unknown_events: bool = False + + # In a chat with unread messages, the messages will be marked as read + # after this number of seconds. + # Focusing another window or chat resets the timer. + mark_read_delay: float = 0.2 + + class Composer: + # Mapping of account user ID to alias. + # From any chat, start a message with an alias followed by a space + # to type and send as this associated account. + # The account must have permission to talk in the room. + # To ignore an alias when typing, prepend it with a space. + # Example: {"@alice:example.org": "al", "@bob:example.org": "b"} + aliases: Dict[str, str] = {} + + class Files: + # Minimum width of the file name/size box for files without previews. + min_file_width: int = 256 + + # Minimum (width, height) for image thumbnails. + min_thumbnail_size: Tuple[int, int] = (256, 256) + + # How much of the chat height image thumbnails can take at most, + # e.g. 0.4 for 40% of the chat or 1 for 100%. + max_thumbnail_height_ratio: float = 0.4 + + # Automatically play animated GIF images in the timeline. + auto_play_gif: bool = True + + # When clicking on a file in the timeline, open it in an external + # programing instead of displaying it using Mirage's interface. + # On Linux, the xdg-open command is used. + click_opens_externally: bool = False + + # In the full image viewer, if the image is large enough to cover the + # info bar or buttons, they will automatically hide after this number + # of seconds. + # Hovering on the top/bottom with a mouse or tapping on a touch screen + # reveals the hidden controls. + autohide_image_controls_after: float = 2.0 + + +class Keys: + # All keybind settings, unless their comment says otherwise, are list of + # the possible shortcuts for an action, e.g. ["Ctrl+A", "Alt+Shift+A"]. + # + # The available modifiers are Ctrl, Shift, Alt and Meta. + # On macOS, Ctrl corresponds to Cmd and Meta corresponds to Control. + # On other systems, Meta corresponds to the Windows/Super/mod4 key. + # + # https://doc.qt.io/qt-5/qt.html#Key-enum lists the names of special + # keys, e.g. for "Qt::Key_Space", you would use "Space" in this config. + # + # The Escape key by itself should not be bound, as it would conflict with + # closing popups and various other actions. + # + # Key chords can be defined by having up to four shortcuts + # separated by commas in a string, e.g. for ["Ctrl+A,B"], Ctrl+A then B + # would need to be pressed. + + # Helper functions + + import platform + + def os_ctrl(self) -> str: + # Return Meta on macOS, which corresponds to Ctrl, and Ctrl on others. + return "Meta" if platform.system() == "Darwin" else "Ctrl" + + def alt_or_cmd(self) -> str: + # Return Ctrl on macOS, which corresponds to Cmd, and Alt on others. + return "Ctrl" if platform.system() == "Darwin" else "Alt" + + # Toggle compact interface mode. See the compact setting comment. + compact = ["Alt+Ctrl+C"] + + # Control the interface scale. + zoom_in = ["Ctrl++"] + zoom_out = ["Ctrl+-"] + reset_zoom = ["Ctrl+="] + + # Switch to the previous/next tab in pages. In chats, this controls what + # the right room pane shows, e.g. member list or room settings. + previous_tab = ["Alt+Shift+Left", "Alt+Shift+H"] + next_tab = ["Alt+Shift+Right", "Alt+Shift+L"] + + # Switch to the last opened page/chat, similar to Alt+Tab on most desktops. + last_page = ["Ctrl+Tab"] + + # Toggle the QML developer console. Type ". help" in it for more info. + qml_console = ["F1"] + + # Start the Python backend debugger. Unless the "remote-pdb" pip package is + # installed, Mirage must be started from a terminal for this to work. + python_debugger = ["Shift+F1"] + + class Scrolling: + # Pages and chat timeline scrolling + up = ["Alt+Up", "Alt+K"] + down = ["Alt+Down", "Alt+J"] + page_up = ["Alt+Ctrl+Up", "Alt+Ctrl+K", "PgUp"] + page_down = ["Alt+Ctrl+Down", "Alt+Ctrl+J", "PgDown"] + top = ["Alt+Ctrl+Shift+Up", "Alt+Ctrl+Shift+K", "Home"] + bottom = ["Alt+Ctrl+Shift+Down", "Alt+Ctrl+Shift+J", "End"] + + class Accounts: + # The current account is the account under which a page or chat is + # opened, or the keyboard-focused one when using the room filter field. + + # Add a new account + add = ["Alt+Shift+A"] + + # Collapse the current account + collapse = ["Alt+O"] + + # Open the current account settings + settings = ["Alt+A"] + + # Open the current account context menu + menu = ["Alt+P"] + + # Toggle current account presence between this status and online + unavailable = ["Alt+Ctrl+U", "Alt+Ctrl+A"] + invisible = ["Alt+Ctrl+I"] + offline = ["Alt+Ctrl+O"] + + # Switch to first room of the previous/next account in the room list. + previous = ["Alt+Shift+N"] + next = ["Alt+N"] + + # Switch to the first room of the account number X in the list. + # This is a mapping of account number to keybind, e.g. + # {1: "Ctrl+1"} would bind Ctrl+1 to the switch to the first account. + at_index: Dict[int, str] = { + "1": f"{parent.os_ctrl()}+1", + "2": f"{parent.os_ctrl()}+2", + "3": f"{parent.os_ctrl()}+3", + "4": f"{parent.os_ctrl()}+4", + "5": f"{parent.os_ctrl()}+5", + "6": f"{parent.os_ctrl()}+6", + "7": f"{parent.os_ctrl()}+7", + "8": f"{parent.os_ctrl()}+8", + "9": f"{parent.os_ctrl()}+9", + "10": f"{parent.os_ctrl()}+0", + } + + class Rooms: + # Add a new room (direct chat, join or create a group). + add = ["Alt+C"] + + # Focus or clear the text of the left main pane's room filter field. + # When focusing the field, use Tab/Shift+Tab or the arrows to navigate + # the list, Enter to switch to focused account/room, Escape to cancel, + # Menu to open the context menu. + focus_filter = ["Alt+F"] + clear_filter = ["Alt+Shift+F"] + + # Switch to the previous/next room in the list. + previous = ["Alt+Shift+Up", "Alt+Shift+K"] + next = ["Alt+Shift+Down", "Alt+Shift+J"] + + # Switch to the previous/next room with unread messages in the list. + previous_unread = ["Alt+Shift+U"] + next_unread = ["Alt+U"] + + # Switch to the previous/next room with urgent messages in the list, + # e.g. messages mentioning your name, replies to you or keywords. + previous_urgent = ["Alt+Shift+M"] + next_urgent = ["Alt+M"] + + # Switch to room number X in the current account. + # This is a mapping of room number to keybind, e.g. + # {1: "Alt+1"} would bind Alt+1 to switch to the first room. + at_index: Dict[int, str] = { + "1": f"{parent.alt_or_cmd()}+1", + "2": f"{parent.alt_or_cmd()}+2", + "3": f"{parent.alt_or_cmd()}+3", + "4": f"{parent.alt_or_cmd()}+4", + "5": f"{parent.alt_or_cmd()}+5", + "6": f"{parent.alt_or_cmd()}+6", + "7": f"{parent.alt_or_cmd()}+7", + "8": f"{parent.alt_or_cmd()}+8", + "9": f"{parent.alt_or_cmd()}+9", + "10": f"{parent.alt_or_cmd()}+0", + } + + class Chat: + # Keybinds specific to the current chat page. + + # Focus the right room pane. If the pane is currently showing the + # room member list, the corresponding filter field is focused. + # When focusing the field, use Tab/Shift+Tab or the arrows to navigate + # the list, Enter to see the focused member's profile, Escape to cancel, + # Menu to open the context menu. + focus_room_pane = ["Alt+R"] + + # Toggle hiding the right pane. + # Can also be done by clicking on current tab button at the top right. + hide_room_pane = ["Alt+Ctrl+R"] + + + # Invite new members, leave or forget the current chat. + invite = ["Alt+I"] + leave = ["Alt+Escape"] + forget = ["Alt+Shift+Escape"] + + # Open the file picker to upload files in the current chat. + send_file = ["Alt+S"] + + # If your clipboard contains a file path, upload that file. + send_clipboard_path = ["Alt+Shift+S"] + + class Messages: + # Focus the previous/next message in the timeline. + # Keybinds defined below in this section affect the focused message. + # The Menu key can open the context menu for a focused message. + previous = ["Ctrl+Up", "Ctrl+K"] + next = ["Ctrl+Down", "Ctrl+J"] + + # Select the currently focused message, same as clicking on it. + # When there are selected messages, some right click menu options + # and keybinds defined below will affect these messages instead of + # the focused (for keybinds) or mouse-targeted (right click menu) one. + # The Menu key can open the context menu for selected messages. + select = ["Ctrl+Space"] + + # Select all messages from point A to point B. + # If used when no messages are already selected, all the messages + # from the most recent in the timeline to the focused one are selected. + # Otherwise, messages from the last selected to focused are selected. + select_until_here = ["Ctrl+Shift+Space"] + + # Clear the message keyboard focus. + # If no message is focused but some are selected, clear the selection. + unfocus_or_deselect = ["Ctrl+D"] + + # Remove the selected messages if any, else the focused message if any, + # else the last message you posted. + remove = ["Ctrl+R", "Alt+Del"] + + # Reply/cancel reply to the focused message if any, + # else the last message posted by someone else. + # Replying can also be cancelled by pressing Escape. + reply = ["Ctrl+Q"] + + # Open the QML developer console for the focused message if any, + # and display the event source. + debug = ["Ctrl+Shift+D"] + + # Open the files and links in selected messages if any, else the + # file/links of the focused message if any, else the last + # files/link in the timeline. + open_links_files = ["Ctrl+O"] + + # Like open_links_files, but files open in external programs instead. + # On Linux, this uses the xdg-open command. + open_links_files_externally = ["Ctrl+Shift+O"] + + # Copy the downloaded files path in selected messages if any, + # else the file path for the focused message if any, else the + # path for the last downloaded file in the timeline. + copy_files_path = ["Ctrl+Shift+C"] + + # Clear all messages from the chat. + # This does not remove anything for other users. + clear_all = ["Ctrl+L"] + + class ImageViewer: + # Close the image viewer + close = ["X", "Q"] + + # Toggle alternate image scaling mode: if the original image size is + # smaller than the window, upscale it to fit the window. + # If it is bigger than the window, show it as its real size. + expand = ["E"] + + # Toggle fullscreen mode. + fullscreen = ["F", "F11", "Alt+Return", "Alt+Enter"] + + # Pan/scroll the image. + pan_left = ["H", "Left", "Alt+H", "Alt+Left"] + pan_down = ["J", "Down", "Alt+J", "Alt+Down"] + pan_up = ["K", "Up", "Alt+K", "Alt+Up"] + pan_right = ["L", "Right", "Alt+L", "Alt+Right"] + + # Control the image's zoom. Ctrl+wheel can also be used. + zoom_in = ["Z", "+", "Ctrl++"] + zoom_out = ["Shift+Z", "-", "Ctrl+-"] + reset_zoom = ["Alt+Z", "=", "Ctrl+="] + + # Control the image's rotation. + rotate_right = ["R"] + rotate_left = ["Shift+R"] + reset_rotation = ["Alt+R"] + + # Control the speed for animated GIF images. + speed_up = ["S"] + slow_down = ["Shift+S"] + reset_speed = ["Alt+S"] + + # Toggle pausing for animated GIF images. + pause = ["Space"] + + class Sessions: + # These keybinds affect the session list in your account settings. + # + # Currently unchangable keys: + # Tab/Shift+Tab or the arrow keys to navigate the list, + # Space to check/uncheck focused session, + # Menu to open the focused session's context menu. + + # Refresh the list of sessions. + refresh = ["Alt+R", "F5"] + + # Sign out checked sessions if any, else sign out all sessions. + sign_out_checked_or_all = ["Alt+S", "Delete"] diff --git a/src/gui/Base/HDrawer.qml b/src/gui/Base/HDrawer.qml index 1070a884..71216a21 100644 --- a/src/gui/Base/HDrawer.qml +++ b/src/gui/Base/HDrawer.qml @@ -37,7 +37,7 @@ Drawer { property bool collapse: (horizontal ? window.width : window.height) < - window.settings.collapseSidePanesUnderWindowWidth * theme.uiScale + window.settings.General.hide_side_panes_under * theme.uiScale property int peekSizeWhileCollapsed: (horizontal ? referenceSizeParent.width : referenceSizeParent.height) * diff --git a/src/gui/Base/HFlickable.qml b/src/gui/Base/HFlickable.qml index 0f35c889..d99d2a49 100644 --- a/src/gui/Base/HFlickable.qml +++ b/src/gui/Base/HFlickable.qml @@ -6,8 +6,8 @@ import QtQuick.Controls 2.12 Flickable { id: flickable - maximumFlickVelocity: window.settings.kineticScrollingMaxSpeed - flickDeceleration: window.settings.kineticScrollingDeceleration + maximumFlickVelocity: window.settings.Scrolling.kinetic_max_speed + flickDeceleration: window.settings.Scrolling.kinetic_deceleration ScrollBar.vertical: HScrollBar { visible: parent.interactive diff --git a/src/gui/Base/HGridView.qml b/src/gui/Base/HGridView.qml index c9195eb3..7f4ff57c 100644 --- a/src/gui/Base/HGridView.qml +++ b/src/gui/Base/HGridView.qml @@ -81,8 +81,8 @@ GridView { preferredHighlightBegin: height / 2 - currentItemHeight / 2 preferredHighlightEnd: height / 2 + currentItemHeight / 2 - maximumFlickVelocity: window.settings.kineticScrollingMaxSpeed - flickDeceleration: window.settings.kineticScrollingDeceleration + maximumFlickVelocity: window.settings.Scrolling.kinetic_max_speed + flickDeceleration: window.settings.Scrolling.kinetic_deceleration highlight: Rectangle { diff --git a/src/gui/Base/HImage.qml b/src/gui/Base/HImage.qml index ea8e0c10..ceffc67d 100644 --- a/src/gui/Base/HImage.qml +++ b/src/gui/Base/HImage.qml @@ -18,7 +18,7 @@ Image { property alias radius: roundMask.radius property alias showProgressBar: progressBarLoader.active property bool showPauseButton: true - property bool pause: ! window.settings.media.autoPlayGIF + property bool pause: ! window.settings.Chat.Files.auto_play_gif property bool forcePause: false property real speed: 1 diff --git a/src/gui/Base/HKineticScrollingDisabler.qml b/src/gui/Base/HKineticScrollingDisabler.qml index 7bbe2a5a..540b3cf7 100644 --- a/src/gui/Base/HKineticScrollingDisabler.qml +++ b/src/gui/Base/HKineticScrollingDisabler.qml @@ -16,7 +16,7 @@ MouseArea { const speedMultiply = Qt.styleHints.wheelScrollLines * - window.settings.nonKineticScrollingSpeed + window.settings.Scrolling.non_kinetic_speed const pixelDelta = { x: wheel.pixelDelta.x || wheel.angleDelta.x / 8 * speedMultiply, @@ -64,7 +64,7 @@ MouseArea { } - enabled: ! window.settings.enableKineticScrolling + enabled: ! window.settings.Scrolling.kinetic propagateComposedEvents: true acceptedButtons: Qt.NoButton @@ -84,7 +84,8 @@ MouseArea { Binding { target: flickable property: "maximumFlickVelocity" - value: mouseArea.enabled ? 0 : window.settings.kineticScrollingMaxSpeed + value: + mouseArea.enabled ? 0 : window.settings.Scrolling.kinetic_max_speed } Binding { @@ -93,6 +94,6 @@ MouseArea { value: mouseArea.enabled ? 0 : - window.settings.kineticScrollingDeceleration + window.settings.Scrolling.kinetic_deceleration } } diff --git a/src/gui/Base/HListView.qml b/src/gui/Base/HListView.qml index 2dd7562f..6870b1ed 100644 --- a/src/gui/Base/HListView.qml +++ b/src/gui/Base/HListView.qml @@ -89,8 +89,8 @@ ListView { preferredHighlightBegin: height / 2 - currentItemHeight / 2 preferredHighlightEnd: height / 2 + currentItemHeight / 2 - maximumFlickVelocity: window.settings.kineticScrollingMaxSpeed - flickDeceleration: window.settings.kineticScrollingDeceleration + maximumFlickVelocity: window.settings.Scrolling.kinetic_max_speed + flickDeceleration: window.settings.Scrolling.kinetic_deceleration highlight: Rectangle { color: theme.controls.listView.highlight diff --git a/src/gui/Base/HTile/HTile.qml b/src/gui/Base/HTile/HTile.qml index 151c7c20..2f4718a0 100644 --- a/src/gui/Base/HTile/HTile.qml +++ b/src/gui/Base/HTile/HTile.qml @@ -7,7 +7,7 @@ import ".." HButton { id: tile - property bool compact: window.settings.compactMode + property bool compact: window.settings.General.compact property real contentOpacity: 1 property Component contextMenu: null property HMenu openedMenu: null diff --git a/src/gui/Base/HToolTip.qml b/src/gui/Base/HToolTip.qml index 801c3fbb..3c97b24c 100644 --- a/src/gui/Base/HToolTip.qml +++ b/src/gui/Base/HToolTip.qml @@ -26,7 +26,7 @@ ToolTip { } - delay: instant ? 0 : theme.controls.toolTip.delay + delay: instant ? 0 : window.settings.General.tooltips_delay * 1000 padding: background.border.width background: Rectangle { diff --git a/src/gui/Base/MediaPlayer/OSD.qml b/src/gui/Base/MediaPlayer/OSD.qml index 50220dbc..ad6c14d1 100644 --- a/src/gui/Base/MediaPlayer/OSD.qml +++ b/src/gui/Base/MediaPlayer/OSD.qml @@ -55,7 +55,7 @@ HColumnLayout { Timer { id: osdHideTimer - interval: window.settings.media.autoHideOSDAfterMsec + interval: window.settings.Chat.Files.autohide_image_controls_after onTriggered: osd.showup = false } diff --git a/src/gui/Base/PresenceOrb.qml b/src/gui/Base/PresenceOrb.qml index 349521fd..8374143d 100644 --- a/src/gui/Base/PresenceOrb.qml +++ b/src/gui/Base/PresenceOrb.qml @@ -8,7 +8,7 @@ Rectangle { implicitWidth: - window.settings.compactMode ? + window.settings.General.compact ? theme.controls.presence.radius * 2 : theme.controls.presence.radius * 2.5 diff --git a/src/gui/DebugConsole.qml b/src/gui/DebugConsole.qml index 25b9a59e..81d956e2 100644 --- a/src/gui/DebugConsole.qml +++ b/src/gui/DebugConsole.qml @@ -25,7 +25,7 @@ HDrawer { property string selectedOutputText: "" property string pythonDebugKeybind: - window.settings.keys.startPythonDebugger[0] + window.settings.Keys.python_debugger[0] property string help: qsTr( `Interact with the QML code using JavaScript ES6 syntax. @@ -71,7 +71,7 @@ HDrawer { if (addToHistory && history.slice(-1)[0] !== input) { history.push(input) while (history.length > maxHistoryLength) history.shift() - window.historyChanged() + window.saveHistory() } let output = "" @@ -154,7 +154,7 @@ HDrawer { } HShortcut { - sequences: settings.keys.toggleDebugConsole + sequences: settings.Keys.qml_console onActivated: debugConsole.toggle() } diff --git a/src/gui/IdleManager.qml b/src/gui/IdleManager.qml index 50d90ac2..77c70d10 100644 --- a/src/gui/IdleManager.qml +++ b/src/gui/IdleManager.qml @@ -17,7 +17,7 @@ Timer { interval: 1000 repeat: true running: - window.settings.beUnavailableAfterSecondsIdle > 0 && + window.settings.Presence.auto_away_after > 0 && CppUtils.idleMilliseconds() !== -1 onTriggered: { @@ -25,7 +25,7 @@ Timer { const beUnavailable = CppUtils.idleMilliseconds() / 1000 >= - window.settings.beUnavailableAfterSecondsIdle + window.settings.Presence.auto_away_after for (let i = 0; i < accounts.count; i++) { const account = accounts.get(i) diff --git a/src/gui/MainPane/AccountBar.qml b/src/gui/MainPane/AccountBar.qml index ebe41de7..4cbfc84b 100644 --- a/src/gui/MainPane/AccountBar.qml +++ b/src/gui/MainPane/AccountBar.qml @@ -81,7 +81,7 @@ Rectangle { HShortcut { - sequences: window.settings.keys.goToPreviousAccount + sequences: window.settings.Keys.Accounts.previous onActivated: { accountList.moveCurrentIndexLeft() accountList.currentItem.leftClicked() @@ -89,7 +89,7 @@ Rectangle { } HShortcut { - sequences: window.settings.keys.goToNextAccount + sequences: window.settings.Keys.Accounts.next onActivated: { accountList.moveCurrentIndexRight() accountList.currentItem.leftClicked() diff --git a/src/gui/MainPane/AccountDelegate.qml b/src/gui/MainPane/AccountDelegate.qml index 3b0e543f..7c3d60ed 100644 --- a/src/gui/MainPane/AccountDelegate.qml +++ b/src/gui/MainPane/AccountDelegate.qml @@ -26,7 +26,7 @@ HTile { function setCollapse(collapse) { window.uiState.collapseAccounts[model.id] = collapse - window.uiStateChanged() + window.saveUIState() py.callCoro("set_account_collapse", [model.id, collapse]) } @@ -162,7 +162,7 @@ HTile { HShortcut { enabled: enableKeybinds - sequences: window.settings.keys.addNewChat + sequences: window.settings.Keys.Rooms.add onActivated: addChat.clicked() } } @@ -210,37 +210,37 @@ HTile { HShortcut { enabled: enableKeybinds - sequences: window.settings.keys.accountSettings + sequences: window.settings.Keys.Accounts.settings onActivated: leftClicked() } HShortcut { enabled: enableKeybinds - sequences: window.settings.keys.toggleCollapseAccount + sequences: window.settings.Keys.Accounts.collapse onActivated: toggleCollapse() } HShortcut { enabled: enableKeybinds - sequences: window.settings.keys.openPresenceMenu + sequences: window.settings.Keys.Accounts.menu onActivated: account.doRightClick(false) } HShortcut { enabled: enableKeybinds - sequences: window.settings.keys.togglePresenceUnavailable + sequences: window.settings.Keys.Accounts.unavailable onActivated: account.togglePresence("unavailable") } HShortcut { enabled: enableKeybinds - sequences: window.settings.keys.togglePresenceInvisible + sequences: window.settings.Keys.Accounts.invisible onActivated: account.togglePresence("invisible") } HShortcut { enabled: enableKeybinds - sequences: window.settings.keys.togglePresenceOffline + sequences: window.settings.Keys.Accounts.offline onActivated: account.togglePresence("offline") } diff --git a/src/gui/MainPane/BottomBar.qml b/src/gui/MainPane/BottomBar.qml index 208afdf5..42ae2374 100644 --- a/src/gui/MainPane/BottomBar.qml +++ b/src/gui/MainPane/BottomBar.qml @@ -32,7 +32,7 @@ Rectangle { Layout.fillHeight: true HShortcut { - sequences: window.settings.keys.addNewAccount + sequences: window.settings.Keys.Accounts.add onActivated: addAccountButton.clicked() } } @@ -57,7 +57,7 @@ Rectangle { Keys.onEnterPressed: Keys.onReturnPressed(event) Keys.onReturnPressed: { roomList.showItemAtIndex() - if (window.settings.clearRoomFilterOnEnter) text = "" + if (window.settings.RoomList.enter_clears_filter) text = "" } Keys.onMenuPressed: @@ -66,19 +66,19 @@ Rectangle { Keys.onEscapePressed: { mainPane.toggleFocus() - if (window.settings.clearRoomFilterOnEscape) text = "" + if (window.settings.RoomList.escape_clears_filter) text = "" } Behavior on opacity { HNumberAnimation {} } HShortcut { - sequences: window.settings.keys.clearRoomFilter + sequences: window.settings.Keys.Rooms.clear_filter onActivated: filterField.text = "" } HShortcut { - sequences: window.settings.keys.toggleFocusMainPane + sequences: window.settings.Keys.Rooms.focus_filter onActivated: mainPane.toggleFocus() } } diff --git a/src/gui/MainPane/MainPane.qml b/src/gui/MainPane/MainPane.qml index 3658cbf8..5a2dfd05 100644 --- a/src/gui/MainPane/MainPane.qml +++ b/src/gui/MainPane/MainPane.qml @@ -25,8 +25,9 @@ HDrawer { saveName: "mainPane" background: Rectangle { color: theme.mainPane.background } - minimumSize: theme.mainPane.minimumSize requireDefaultSize: bottomBar.filterField.activeFocus + minimumSize: + window.settings.RoomList.min_width * window.settings.General.zoom Behavior on opacity { HNumberAnimation {} } diff --git a/src/gui/MainPane/RoomList.qml b/src/gui/MainPane/RoomList.qml index b353525b..8c8cd29e 100644 --- a/src/gui/MainPane/RoomList.qml +++ b/src/gui/MainPane/RoomList.qml @@ -73,12 +73,12 @@ HListView { ) : pageLoader.showRoom(item.for_account, item.id) - if (fromClick && ! window.settings.centerRoomListOnClick) + if (fromClick && ! window.settings.RoomList.click_centers) keepListCentered = false currentIndex = index - if (fromClick && ! window.settings.centerRoomListOnClick) + if (fromClick && ! window.settings.RoomList.click_centers) keepListCentered = true } @@ -245,54 +245,53 @@ HListView { } HShortcut { - sequences: window.settings.keys.goToPreviousRoom + sequences: window.settings.Keys.Rooms.previous onActivated: { decrementCurrentIndex(); showItemLimiter.restart() } } HShortcut { - sequences: window.settings.keys.goToNextRoom + sequences: window.settings.Keys.Rooms.next onActivated: { incrementCurrentIndex(); showItemLimiter.restart() } } HShortcut { - sequences: window.settings.keys.goToPreviousUnreadRoom + sequences: window.settings.Keys.Rooms.previous_unread onActivated: { cycleUnreadRooms(false) && showItemLimiter.restart() } } HShortcut { - sequences: window.settings.keys.goToNextUnreadRoom + sequences: window.settings.Keys.Rooms.next_unread onActivated: { cycleUnreadRooms(true) && showItemLimiter.restart() } } HShortcut { - sequences: window.settings.keys.goToPreviousMentionedRoom + sequences: window.settings.Keys.Rooms.previous_urgent onActivated: cycleUnreadRooms(false, true) && showItemLimiter.restart() } HShortcut { - sequences: window.settings.keys.goToNextMentionedRoom + sequences: window.settings.Keys.Rooms.next_urgent onActivated: cycleUnreadRooms(true, true) && showItemLimiter.restart() } Repeater { - model: Object.keys(window.settings.keys.focusAccountAtIndex) + model: Object.keys(window.settings.Keys.Accounts.at_index) Item { HShortcut { - sequence: window.settings.keys.focusAccountAtIndex[modelData] - onActivated: goToAccountNumber(parseInt(modelData - 1, 10)) + sequence: window.settings.Keys.Accounts.at_index[modelData] + onActivated: goToAccountNumber(parseInt(modelData, 10) - 1) } } } Repeater { - model: Object.keys(window.settings.keys.focusRoomAtIndex) + model: Object.keys(window.settings.Keys.Rooms.at_index) Item { HShortcut { - sequence: window.settings.keys.focusRoomAtIndex[modelData] - onActivated: - showAccountRoomAtIndex(parseInt(modelData - 1, 10)) + sequence: window.settings.Keys.Rooms.at_index[modelData] + onActivated: showAccountRoomAtIndex(parseInt(modelData,10) - 1) } } } diff --git a/src/gui/PageLoader.qml b/src/gui/PageLoader.qml index 4ea64340..400a6b3b 100644 --- a/src/gui/PageLoader.qml +++ b/src/gui/PageLoader.qml @@ -44,7 +44,7 @@ HLoader { } window.uiState.pageProperties = properties - window.uiStateChanged() + window.saveUIState() } function showRoom(userId, roomId) { @@ -92,7 +92,7 @@ HLoader { } HShortcut { - sequences: window.settings.keys.goToLastPage + sequences: window.settings.Keys.last_page onActivated: showPrevious() } } diff --git a/src/gui/Pages/AccountSettings/Account.qml b/src/gui/Pages/AccountSettings/Account.qml index f37b394f..ccce8d22 100644 --- a/src/gui/Pages/AccountSettings/Account.qml +++ b/src/gui/Pages/AccountSettings/Account.qml @@ -34,8 +34,10 @@ HFlickableColumnPage { } if (aliasFieldItem.changed) { - window.settings.writeAliases[userId] = aliasFieldItem.text - window.settingsChanged() + window.settings.Chat.Composer.aliases[userId] = + aliasFieldItem.text + + window.saveSettings() } if (avatar.changed) { @@ -249,7 +251,7 @@ HFlickableColumnPage { HLabeledItem { id: aliasField - readonly property var aliases: window.settings.writeAliases + readonly property var aliases: window.settings.Chat.Composer.aliases readonly property string currentAlias: aliases[userId] || "" readonly property bool hasWhiteSpace: /\s/.test(item.text) diff --git a/src/gui/Pages/AccountSettings/Sessions.qml b/src/gui/Pages/AccountSettings/Sessions.qml index 7b32b792..c79c87a3 100644 --- a/src/gui/Pages/AccountSettings/Sessions.qml +++ b/src/gui/Pages/AccountSettings/Sessions.qml @@ -164,12 +164,12 @@ HColumnPage { Keys.onMenuPressed: Keys.onEnterPressed(event) HShortcut { - sequences: window.settings.keys.refreshDevices + sequences: window.settings.Keys.Sessions.refresh onActivated: refreshButton.clicked() } HShortcut { - sequences: window.settings.keys.signOutCheckedOrAllDevices + sequences: window.settings.Keys.Sessions.sign_out_checked_or_all onActivated: signOutCheckedButton.clicked() } diff --git a/src/gui/Pages/Chat/Chat.qml b/src/gui/Pages/Chat/Chat.qml index 59feb7f1..f91927e9 100644 --- a/src/gui/Pages/Chat/Chat.qml +++ b/src/gui/Pages/Chat/Chat.qml @@ -51,7 +51,7 @@ Item { onReadyChanged: longLoading = false HShortcut { - sequences: window.settings.keys.leaveRoom + sequences: window.settings.Keys.Chat.leave active: userInfo && userInfo.presence !== "offline" onActivated: window.makePopup( "Popups/LeaveRoomPopup.qml", @@ -60,7 +60,7 @@ Item { } HShortcut { - sequences: window.settings.keys.forgetRoom + sequences: window.settings.Keys.Chat.forget active: userInfo && userInfo.presence !== "offline" onActivated: window.makePopup( "Popups/ForgetRoomPopup.qml", diff --git a/src/gui/Pages/Chat/Composer/MessageArea.qml b/src/gui/Pages/Chat/Composer/MessageArea.qml index 9d2a932b..9d936364 100644 --- a/src/gui/Pages/Chat/Composer/MessageArea.qml +++ b/src/gui/Pages/Chat/Composer/MessageArea.qml @@ -21,7 +21,7 @@ HTextArea { readonly property var usableAliases: { const obj = {} - const aliases = window.settings.writeAliases + const aliases = window.settings.Chat.Composer.aliases // Get accounts that are members of this room with permission to talk for (const [id, alias] of Object.entries(aliases)) { diff --git a/src/gui/Pages/Chat/Composer/UploadButton.qml b/src/gui/Pages/Chat/Composer/UploadButton.qml index bb4e81ea..881c0f30 100644 --- a/src/gui/Pages/Chat/Composer/UploadButton.qml +++ b/src/gui/Pages/Chat/Composer/UploadButton.qml @@ -21,7 +21,7 @@ HButton { onClicked: sendFilePicker.dialog.open() HShortcut { - sequences: window.settings.keys.sendFileFromPathInClipboard + sequences: window.settings.Keys.Chat.send_clipboard_path onActivated: window.makePopup( "Popups/ConfirmUploadPopup.qml", { @@ -43,7 +43,7 @@ HButton { onReplied: chat.clearReplyTo() HShortcut { - sequences: window.settings.keys.sendFile + sequences: window.settings.Keys.Chat.send_file onActivated: sendFilePicker.dialog.open() } } diff --git a/src/gui/Pages/Chat/RoomHeader.qml b/src/gui/Pages/Chat/RoomHeader.qml index a17fabe3..ed9ecfff 100644 --- a/src/gui/Pages/Chat/RoomHeader.qml +++ b/src/gui/Pages/Chat/RoomHeader.qml @@ -14,7 +14,7 @@ Rectangle { (chat.roomPane.collapse || chat.roomPane.forceCollapse) readonly property bool center: - showLeftButton || window.settings.alwaysCenterRoomHeader + showLeftButton || window.settings.Chat.always_center_header implicitHeight: theme.baseElementsHeight diff --git a/src/gui/Pages/Chat/RoomPane/MemberView/MemberView.qml b/src/gui/Pages/Chat/RoomPane/MemberView/MemberView.qml index a920d79a..62a571e0 100644 --- a/src/gui/Pages/Chat/RoomPane/MemberView/MemberView.qml +++ b/src/gui/Pages/Chat/RoomPane/MemberView/MemberView.qml @@ -139,7 +139,9 @@ HColumnLayout { stackView.currentItem.currentIndex = -1 roomPane.toggleFocus() - if (window.settings.clearMemberFilterOnEscape) text = "" + + if (window.settings.RoomList.escape_clears_filter) + text = "" } Behavior on opacity { HNumberAnimation {} } @@ -174,7 +176,7 @@ HColumnLayout { Layout.preferredHeight: filterField.implicitHeight HShortcut { - sequences: window.settings.keys.inviteToRoom + sequences: window.settings.Keys.Chat.invite onActivated: if (inviteButton.enabled) inviteButton.clicked() } diff --git a/src/gui/Pages/Chat/RoomPane/RoomPane.qml b/src/gui/Pages/Chat/RoomPane/RoomPane.qml index 59c771f6..ca14aa43 100644 --- a/src/gui/Pages/Chat/RoomPane/RoomPane.qml +++ b/src/gui/Pages/Chat/RoomPane/RoomPane.qml @@ -105,12 +105,12 @@ MultiviewPane { } HShortcut { - sequences: window.settings.keys.toggleFocusRoomPane + sequences: window.settings.Keys.Chat.focus_room_pane onActivated: roomPane.toggleFocus() } HShortcut { - sequences: window.settings.keys.toggleHideRoomPane + sequences: window.settings.Keys.Chat.hide_room_pane onActivated: roomPane.forceCollapse = ! roomPane.forceCollapse } } diff --git a/src/gui/Pages/Chat/Timeline/EventContent.qml b/src/gui/Pages/Chat/Timeline/EventContent.qml index 21af980e..e5dffb1d 100644 --- a/src/gui/Pages/Chat/Timeline/EventContent.qml +++ b/src/gui/Pages/Chat/Timeline/EventContent.qml @@ -75,11 +75,11 @@ HRowLayout { readonly property int maxMessageWidth: contentText.includes("
") || contentText.includes("") ?
         -1 :
-        window.settings.maxMessageCharactersPerLine < 0 ?
+        window.settings.Chat.max_messages_line_length < 0 ?
         -1 :
         Math.ceil(
             mainUI.fontMetrics.averageCharacterWidth *
-            window.settings.maxMessageCharactersPerLine
+            window.settings.Chat.max_messages_line_length
         )
 
     readonly property alias selectedText: contentLabel.selectedPlainText
diff --git a/src/gui/Pages/Chat/Timeline/EventDelegate.qml b/src/gui/Pages/Chat/Timeline/EventDelegate.qml
index 0249dc3b..8ec7d0ef 100644
--- a/src/gui/Pages/Chat/Timeline/EventDelegate.qml
+++ b/src/gui/Pages/Chat/Timeline/EventDelegate.qml
@@ -17,7 +17,7 @@ HColumnLayout {
     readonly property var nextModel: eventList.model.get(model.index - 1)
     readonly property QtObject currentModel: model
 
-    readonly property bool compact: window.settings.compactMode
+    readonly property bool compact: window.settings.General.compact
     readonly property bool checked: model.id in eventList.checked
     readonly property bool isOwn: chat.userId === model.sender_id
     readonly property bool isRedacted: model.event_type === "RedactedEvent"
diff --git a/src/gui/Pages/Chat/Timeline/EventFile.qml b/src/gui/Pages/Chat/Timeline/EventFile.qml
index a320a5d1..7feb99b0 100644
--- a/src/gui/Pages/Chat/Timeline/EventFile.qml
+++ b/src/gui/Pages/Chat/Timeline/EventFile.qml
@@ -17,7 +17,12 @@ HTile {
     width: Math.min(
         eventDelegate.width,
         eventContent.maxMessageWidth,
-        Math.max(theme.chat.message.fileMinWidth, implicitWidth),
+        Math.max(
+            window.settings.Chat.Files.min_file_width *
+            window.settings.General.zoom,
+
+            implicitWidth,
+        ),
     )
     height: Math.max(theme.chat.message.avatarSize, implicitHeight)
 
diff --git a/src/gui/Pages/Chat/Timeline/EventImage.qml b/src/gui/Pages/Chat/Timeline/EventImage.qml
index 883dd464..5b972443 100644
--- a/src/gui/Pages/Chat/Timeline/EventImage.qml
+++ b/src/gui/Pages/Chat/Timeline/EventImage.qml
@@ -10,13 +10,16 @@ HMxcImage {
 
     property EventMediaLoader loader
 
+    readonly property real zoom: window.settings.General.zoom
+
     readonly property real maxHeight:
-        eventList.height * theme.chat.message.thumbnailMaxHeightRatio
+        eventList.height *
+        window.settings.Chat.Files.max_thumbnail_height_ratio * zoom
 
     readonly property size fitSize: utils.fitSize(
         // Minimum display size
-        theme.chat.message.thumbnailMinSize.width,
-        theme.chat.message.thumbnailMinSize.height,
+        window.settings.Chat.Files.min_thumbnail_size[0] * zoom,
+        window.settings.Chat.Files.min_thumbnail_size[1] * zoom,
 
         // Real size
         (
@@ -35,12 +38,18 @@ HMxcImage {
 
         // Maximum display size
         Math.min(
-            Math.max(maxHeight, theme.chat.message.thumbnailMinSize.width),
+            Math.max(
+                maxHeight,
+                window.settings.Chat.Files.min_thumbnail_size[0] * zoom,
+            ),
             pureMedia ? Infinity : eventContent.maxMessageWidth,
             eventDelegate.width - eventContent.spacing - avatarWrapper.width -
             eventContent.spacing * 2,  // padding
         ),
-        Math.max(maxHeight, theme.chat.message.thumbnailMinSize.height),
+        Math.max(
+            maxHeight,
+            window.settings.Chat.Files.min_thumbnail_size[1] * zoom,
+        ),
     )
 
     readonly property bool hovered: hover.hovered
@@ -92,7 +101,7 @@ HMxcImage {
                 return
             }
 
-            window.settings.media.openExternallyOnClick ?
+            window.settings.Chat.Files.click_opens_externally ?
             image.openExternally() :
             image.openInternally()
         }
@@ -103,7 +112,7 @@ HMxcImage {
         acceptedModifiers: Qt.NoModifier
         gesturePolicy: TapHandler.ReleaseWithinBounds
         onTapped:
-            window.settings.media.openExternallyOnClick ?
+            window.settings.Chat.Files.click_opens_externally ?
             image.openInternally() :
             image.openExternally()
     }
diff --git a/src/gui/Pages/Chat/Timeline/EventList.qml b/src/gui/Pages/Chat/Timeline/EventList.qml
index 7963dc5d..4f179dd8 100644
--- a/src/gui/Pages/Chat/Timeline/EventList.qml
+++ b/src/gui/Pages/Chat/Timeline/EventList.qml
@@ -21,7 +21,7 @@ Rectangle {
     color: theme.chat.eventList.background
 
     HShortcut {
-        sequences: window.settings.keys.unfocusOrDeselectAllMessages
+        sequences: window.settings.Keys.Messages.unfocus_or_deselect
         onActivated: {
             eventList.selectedCount ?
             eventList.checked = {} :
@@ -30,24 +30,24 @@ Rectangle {
     }
 
     HShortcut {
-        sequences: window.settings.keys.focusPreviousMessage
+        sequences: window.settings.Keys.Messages.previous
         onActivated: eventList.focusPreviousMessage()
     }
 
     HShortcut {
-        sequences: window.settings.keys.focusNextMessage
+        sequences: window.settings.Keys.Messages.next
         onActivated: eventList.focusNextMessage()
     }
 
     HShortcut {
         active: eventList.currentItem
-        sequences: window.settings.keys.toggleSelectMessage
+        sequences: window.settings.Keys.Messages.select
         onActivated: eventList.toggleCheck(eventList.currentIndex)
     }
 
     HShortcut {
         active: eventList.currentItem
-        sequences: window.settings.keys.selectMessagesUntilHere
+        sequences: window.settings.Keys.Messages.select_until_here
         onActivated:
             eventList.checkFromLastToHere(eventList.currentIndex)
     }
@@ -75,7 +75,7 @@ Rectangle {
         }
 
         enabled: (events && events.length > 0) || events === null
-        sequences: window.settings.keys.removeFocusedOrSelectedMessages
+        sequences: window.settings.Keys.Messages.remove
         onActivated: window.makePopup(
             "Popups/RedactPopup.qml",
             {
@@ -98,7 +98,7 @@ Rectangle {
     }
 
     HShortcut {
-        sequences: window.settings.keys.replyToFocusedOrLastMessage
+        sequences: window.settings.Keys.Messages.reply
         onActivated: {
             let event = eventList.model.get(0)
 
@@ -132,7 +132,7 @@ Rectangle {
     }
 
     HShortcut {
-        sequences: window.settings.keys.openMessagesLinksOrFiles
+        sequences: window.settings.Keys.Messages.open_links_files
         onActivated: {
             const indice =
                 eventList.getFocusedOrSelectedOrLastMediaEvents(true)
@@ -158,7 +158,7 @@ Rectangle {
     }
 
     HShortcut {
-        sequences: window.settings.keys.openMessagesLinksOrFilesExternally
+        sequences: window.settings.Keys.Messages.open_links_files_externally
         onActivated: {
             const indice =
                 eventList.getFocusedOrSelectedOrLastMediaEvents(true)
@@ -178,7 +178,7 @@ Rectangle {
     }
 
     HShortcut {
-        sequences: window.settings.keys.copyFilesLocalPath
+        sequences: window.settings.Keys.Messages.copy_files_path
         onActivated: {
             const paths  = []
             const indice =
@@ -199,14 +199,14 @@ Rectangle {
 
     HShortcut {
         active: eventList.currentItem
-        sequences: window.settings.keys.debugFocusedMessage
+        sequences: window.settings.Keys.Messages.debug
         onActivated: mainUI.debugConsole.toggle(
             eventList.currentItem.eventContent, "t.parent.json()",
         )
     }
 
     HShortcut {
-        sequences: window.settings.keys.clearRoomMessages
+        sequences: window.settings.Keys.Messages.clear_all
         onActivated: window.makePopup(
             "Popups/ClearMessagesPopup.qml",
             {
@@ -231,9 +231,10 @@ Rectangle {
         property bool moreToLoad: true
 
         property bool ownEventsOnLeft:
-            window.settings.ownMessagesOnLeftAboveWidth < 0 ?
+            window.settings.Chat.own_messages_on_left_above < 0 ?
             false :
-            width > window.settings.ownMessagesOnLeftAboveWidth * theme.uiScale
+            width >
+            window.settings.Chat.own_messages_on_left_above * theme.uiScale
 
         property string delegateWithSelectedText: ""
         property string selectedText: ""
@@ -616,7 +617,7 @@ Rectangle {
     }
 
     Timer {
-        interval: Math.max(100, window.settings.markRoomReadMsecDelay)
+        interval: Math.max(100, window.settings.Chat.mark_read_delay * 1000)
 
         running:
             ! eventList.updateMarkerFutureId &&
diff --git a/src/gui/Popups/ImageViewerPopup/ImageViewerPopup.qml b/src/gui/Popups/ImageViewerPopup/ImageViewerPopup.qml
index 03d6dd26..5b71802d 100644
--- a/src/gui/Popups/ImageViewerPopup/ImageViewerPopup.qml
+++ b/src/gui/Popups/ImageViewerPopup/ImageViewerPopup.qml
@@ -123,7 +123,7 @@ HPopup {
 
         Timer {
             id: autoHideTimer
-            interval: window.settings.media.autoHideOSDAfterMsec
+            interval: window.settings.Chat.Files.autohide_image_controls_after
         }
 
         ViewerInfo {
diff --git a/src/gui/Popups/ImageViewerPopup/ViewerButtons.qml b/src/gui/Popups/ImageViewerPopup/ViewerButtons.qml
index 4c0a2959..078a1d52 100644
--- a/src/gui/Popups/ImageViewerPopup/ViewerButtons.qml
+++ b/src/gui/Popups/ImageViewerPopup/ViewerButtons.qml
@@ -29,7 +29,7 @@ HFlow {
         visible: viewer.isAnimated
 
         HPopupShortcut {
-            sequences: window.settings.keys.imageViewer.pause
+            sequences: window.settings.Keys.ImageViewer.pause
             onActivated: pause.clicked()
         }
     }
@@ -46,7 +46,7 @@ HFlow {
         visible: viewer.isAnimated
 
         HPopupShortcut {
-            sequences: window.settings.keys.imageViewer.previousSpeed
+            sequences: window.settings.Keys.ImageViewer.slow_down
             onActivated: viewer.imagesSpeed = viewer.availableSpeeds[Math.min(
                 viewer.availableSpeeds.indexOf(viewer.imagesSpeed) + 1,
                 viewer.availableSpeeds.length - 1,
@@ -54,14 +54,14 @@ HFlow {
         }
 
         HPopupShortcut {
-            sequences: window.settings.keys.imageViewer.nextSpeed
+            sequences: window.settings.Keys.ImageViewer.speed_up
             onActivated: viewer.imagesSpeed = viewer.availableSpeeds[Math.max(
                 viewer.availableSpeeds.indexOf(viewer.imagesSpeed) - 1, 0,
             )]
         }
 
         HPopupShortcut {
-            sequences: window.settings.keys.imageViewer.resetSpeed
+            sequences: window.settings.Keys.ImageViewer.reset_speed
             onActivated: viewer.imagesSpeed = 1
         }
     }
@@ -77,7 +77,7 @@ HFlow {
         onPressed: viewer.animatedRotationTarget -= 45
 
         HPopupShortcut {
-            sequences: window.settings.keys.imageViewer.rotateLeft
+            sequences: window.settings.Keys.ImageViewer.rotate_left
             onActivated: viewer.animatedRotationTarget -= 45
         }
     }
@@ -93,13 +93,13 @@ HFlow {
         onPressed: viewer.animatedRotationTarget += 45
 
         HPopupShortcut {
-            sequences: window.settings.keys.imageViewer.rotateRight
+            sequences: window.settings.Keys.ImageViewer.rotate_right
             onActivated: viewer.animatedRotationTarget += 45
         }
     }
 
     HPopupShortcut {
-        sequences: window.settings.keys.imageViewer.rotateReset
+        sequences: window.settings.Keys.ImageViewer.reset_rotation
         onActivated: viewer.animatedRotationTarget = 0
     }
 
@@ -116,7 +116,7 @@ HFlow {
         onClicked: viewer.alternateScaling = ! viewer.alternateScaling
 
         HPopupShortcut {
-            sequences: window.settings.keys.imageViewer.expand
+            sequences: window.settings.Keys.ImageViewer.expand
             onActivated: expand.clicked()
         }
     }
@@ -131,7 +131,7 @@ HFlow {
         visible: Qt.application.supportsMultipleWindows
 
         HPopupShortcut {
-            sequences: window.settings.keys.imageViewer.fullScreen
+            sequences: window.settings.Keys.ImageViewer.fullscreen
             onActivated: fullScreen.clicked()
         }
     }
@@ -144,7 +144,7 @@ HFlow {
         onClicked: viewer.close()
 
         HPopupShortcut {
-            sequences: window.settings.keys.imageViewer.close
+            sequences: window.settings.Keys.ImageViewer.close
             onActivated: close.clicked()
         }
     }
diff --git a/src/gui/Popups/ImageViewerPopup/ViewerCanvas.qml b/src/gui/Popups/ImageViewerPopup/ViewerCanvas.qml
index e64e32ce..d898442f 100644
--- a/src/gui/Popups/ImageViewerPopup/ViewerCanvas.qml
+++ b/src/gui/Popups/ImageViewerPopup/ViewerCanvas.qml
@@ -53,37 +53,37 @@ HFlickable {
     }
 
     HPopupShortcut {
-        sequences: window.settings.keys.imageViewer.panLeft
+        sequences: window.settings.Keys.ImageViewer.pan_left
         onActivated: utils.flickPages(flickable, -0.2, true, 5)
     }
 
     HPopupShortcut {
-        sequences: window.settings.keys.imageViewer.panRight
+        sequences: window.settings.Keys.ImageViewer.pan_right
         onActivated: utils.flickPages(flickable, 0.2, true, 5)
     }
 
     HPopupShortcut {
-        sequences: window.settings.keys.imageViewer.panUp
+        sequences: window.settings.Keys.ImageViewer.pan_up
         onActivated: utils.flickPages(flickable, -0.2, false, 5)
     }
 
     HPopupShortcut {
-        sequences: window.settings.keys.imageViewer.panDown
+        sequences: window.settings.Keys.ImageViewer.pan_down
         onActivated: utils.flickPages(flickable, 0.2, false, 5)
     }
 
     HPopupShortcut {
-        sequences: window.settings.keys.imageViewer.zoomOut
+        sequences: window.settings.Keys.ImageViewer.zoom_out
         onActivated: thumbnail.scale = Math.max(0.1, thumbnail.scale - 0.2)
     }
 
     HPopupShortcut {
-        sequences: window.settings.keys.imageViewer.zoomIn
+        sequences: window.settings.Keys.ImageViewer.zoom_in
         onActivated: thumbnail.scale = Math.min(10, thumbnail.scale + 0.2)
     }
 
     HPopupShortcut {
-        sequences: window.settings.keys.imageViewer.zoomReset
+        sequences: window.settings.Keys.ImageViewer.reset_zoom
         onActivated: resetScaleAnimation.start()
     }
 
diff --git a/src/gui/PythonBridge/EventHandlers.qml b/src/gui/PythonBridge/EventHandlers.qml
index 15835157..d15ce450 100644
--- a/src/gui/PythonBridge/EventHandlers.qml
+++ b/src/gui/PythonBridge/EventHandlers.qml
@@ -28,8 +28,8 @@ QtObject {
 
         const msec =
             highImportance ?
-            window.settings.alertOnMentionForMsec :
-            window.settings.alertOnMessageForMsec
+            window.settings.Notifications.urgent_alert_time * 1000 :
+            window.settings.Notifications.alert_time * 1000
 
         if (msec) window.alert(msec === -1 ? 0 : msec)  // -1 → 0 = no time out
     }
diff --git a/src/gui/ShortcutBundles/FlickShortcuts.qml b/src/gui/ShortcutBundles/FlickShortcuts.qml
index 0da70060..dcebe492 100644
--- a/src/gui/ShortcutBundles/FlickShortcuts.qml
+++ b/src/gui/ShortcutBundles/FlickShortcuts.qml
@@ -13,37 +13,37 @@ HQtObject {
 
     HShortcut {
         active: root.active
-        sequences: window.settings.keys.scrollUp
+        sequences: window.settings.Keys.Scrolling.up
         onActivated: utils.flickPages(flickable, -1 / 10)
     }
 
     HShortcut {
         active: root.active
-        sequences: window.settings.keys.scrollDown
+        sequences: window.settings.Keys.Scrolling.down
         onActivated: utils.flickPages(flickable, 1 / 10)
     }
 
     HShortcut {
         active: root.active
-        sequences: window.settings.keys.scrollPageUp
+        sequences: window.settings.Keys.Scrolling.page_up
         onActivated: utils.flickPages(flickable, -1)
     }
 
     HShortcut {
         active: root.active
-        sequences: window.settings.keys.scrollPageDown
+        sequences: window.settings.Keys.Scrolling.page_down
         onActivated: utils.flickPages(flickable, 1)
     }
 
     HShortcut {
         active: root.active
-        sequences: window.settings.keys.scrollToTop
+        sequences: window.settings.Keys.Scrolling.top
         onActivated: utils.flickToTop(flickable)
     }
 
     HShortcut {
         active: root.active
-        sequences: window.settings.keys.scrollToBottom
+        sequences: window.settings.Keys.Scrolling.bottom
         onActivated: utils.flickToBottom(flickable)
     }
 }
diff --git a/src/gui/ShortcutBundles/TabShortcuts.qml b/src/gui/ShortcutBundles/TabShortcuts.qml
index aed42a9c..1b3f4375 100644
--- a/src/gui/ShortcutBundles/TabShortcuts.qml
+++ b/src/gui/ShortcutBundles/TabShortcuts.qml
@@ -13,7 +13,7 @@ HQtObject {
 
     HShortcut {
         active: root.active
-        sequences: window.settings.keys.previousTab
+        sequences: window.settings.Keys.previous_tab
         onActivated: container.setCurrentIndex(
             utils.numberWrapAt(container.currentIndex - 1, container.count),
         )
@@ -21,7 +21,7 @@ HQtObject {
 
     HShortcut {
         active: root.active
-        sequences: window.settings.keys.nextTab
+        sequences: window.settings.Keys.next_tab
         onActivated: container.setCurrentIndex(
             utils.numberWrapAt(container.currentIndex + 1, container.count),
         )
diff --git a/src/gui/UI.qml b/src/gui/UI.qml
index f518ca7b..880ad87f 100644
--- a/src/gui/UI.qml
+++ b/src/gui/UI.qml
@@ -36,39 +36,41 @@ Item {
     Component.onCompleted: window.mainUI = mainUI
 
     HShortcut {
-        sequences: window.settings.keys.startPythonDebugger
+        sequences: window.settings.Keys.python_debugger
         onActivated: py.call("BRIDGE.pdb")
     }
 
     HShortcut {
-        sequences: window.settings.keys.zoomIn
+        sequences: window.settings.Keys.zoom_in
         onActivated: {
-            window.settings.zoom += 0.1
-            window.settingsChanged()
+            window.settings.General.zoom += 0.1
+            window.saveSettings()
         }
     }
 
     HShortcut {
-        sequences: window.settings.keys.zoomOut
+        sequences: window.settings.Keys.zoom_out
         onActivated: {
-            window.settings.zoom = Math.max(0.1, window.settings.zoom - 0.1)
-            window.settingsChanged()
+            window.settings.General.zoom =
+                Math.max(0.1, window.settings.General.zoom - 0.1)
+
+            window.saveSettings()
         }
     }
 
     HShortcut {
-        sequences: window.settings.keys.zoomReset
+        sequences: window.settings.Keys.reset_zoom
         onActivated: {
-            window.settings.zoom = 1
-            window.settingsChanged()
+            window.settings.General.zoom = 1
+            window.saveSettings()
         }
     }
 
     HShortcut {
-        sequences: window.settings.keys.toggleCompactMode
+        sequences: window.settings.Keys.compact
         onActivated: {
-            settings.compactMode = ! settings.compactMode
-            settingsChanged()
+            window.settings.General.compact = ! window.settings.General.compact
+            windowsaveSettings()
         }
     }
 
diff --git a/src/gui/Window.qml b/src/gui/Window.qml
index eb1ea7bd..ff73e7d5 100644
--- a/src/gui/Window.qml
+++ b/src/gui/Window.qml
@@ -36,6 +36,20 @@ ApplicationWindow {
     readonly property bool anyPopup: Object.keys(visiblePopups).length > 0
     readonly property bool anyPopupOrMenu: anyMenu || anyPopup
 
+    function saveSettings() {
+        settingsChanged()
+        py.saveConfig("settings", settings)
+    }
+
+    function saveUIState() {
+        uiStateChanged()
+        py.saveConfig("ui_state", uiState)
+    }
+
+    function saveHistory() {
+        historyChanged()
+        py.saveConfig("history", history)
+    }
 
     function saveState(obj) {
         if (! obj.saveName || ! obj.saveProperties ||
@@ -51,7 +65,7 @@ ApplicationWindow {
             [obj.saveName]: { [obj.saveId || "ALL"]: propertyValues },
         })
 
-        uiStateChanged()
+        saveUIState()
     }
 
     function getState(obj, property, defaultValue=undefined) {
@@ -86,15 +100,9 @@ ApplicationWindow {
     visible: true
     color: "transparent"
 
-    // NOTE: For JS object variables, the corresponding method to notify
-    // key/value changes must be called manually, e.g. settingsChanged().
-    onSettingsChanged: py.saveConfig("settings", settings)
-    onUiStateChanged: py.saveConfig("ui_state", uiState)
-    onHistoryChanged: py.saveConfig("history", history)
-
     onClosing: {
-        close.accepted = ! settings.closeMinimizesToTray
-        settings.closeMinimizesToTray ? hide() : Qt.quit()
+        close.accepted = ! settings.General.close_to_tray
+        settings.General.close_to_tray ? hide() : Qt.quit()
     }
 
     PythonRootBridge { id: py }
diff --git a/src/themes/Glass.qpl b/src/themes/Glass.qpl
index c868ef5a..08a450ee 100644
--- a/src/themes/Glass.qpl
+++ b/src/themes/Glass.qpl
@@ -2,7 +2,7 @@
 
 // Base variables
 
-real uiScale: window.settings.zoom
+real uiScale: window.settings.General.zoom
 
 int minimumSupportedWidth:  240 * uiScale
 int minimumSupportedHeight: 120 * uiScale
@@ -211,7 +211,6 @@ controls:
         color placeholderText: controls.textField.placeholderText
 
     toolTip:
-        int delay:        500
         color background: colors.strongBackground
         color text:       colors.text
         color border:     "black"
@@ -296,7 +295,6 @@ ui:
 
 
 mainPane:
-    int minimumSize: 144 * uiScale
     color background: "transparent"
 
     topBar:
@@ -465,17 +463,6 @@ chat:
         string styleInclude:
             '\n'
 
-        // Prefered minimum width of file messages
-        int fileMinWidth: 256 * uiScale
-
-        // Don't scale down thumbnails below this size in pixels, if
-        // the becomes too small to show it at normal size.
-        size thumbnailMinSize: Qt.size(256 * uiScale, 256 * uiScale)
-
-        // How much of the chat height thumbnails can take at most,
-        // by default 0.4 for 40%.
-        real thumbnailMaxHeightRatio: 0.4 * Math.min(1, uiScale)
-
         real thumbnailCheckedOverlayOpacity: 0.4
 
     daybreak:
diff --git a/src/themes/Midnight.qpl b/src/themes/Midnight.qpl
index 8da0f6aa..dec0041b 100644
--- a/src/themes/Midnight.qpl
+++ b/src/themes/Midnight.qpl
@@ -2,7 +2,7 @@
 
 // Base variables
 
-real uiScale: window.settings.zoom
+real uiScale: window.settings.General.zoom
 
 int minimumSupportedWidth:  240 * uiScale
 int minimumSupportedHeight: 120 * uiScale
@@ -217,7 +217,6 @@ controls:
         color placeholderText: controls.textField.placeholderText
 
     toolTip:
-        int delay:        500
         color background: colors.strongBackground
         color text:       colors.text
         color border:     "black"
@@ -309,7 +308,6 @@ ui:
 
 
 mainPane:
-    int minimumSize: 144 * uiScale
     color background: "transparent"
 
     topBar:
@@ -474,17 +472,6 @@ chat:
         string styleInclude:
             '\n'
 
-        // Prefered minimum width of file messages
-        int fileMinWidth: 256 * uiScale
-
-        // Don't scale down thumbnails below this size in pixels, if
-        // the becomes too small to show it at normal size.
-        size thumbnailMinSize: Qt.size(256 * uiScale, 256 * uiScale)
-
-        // How much of the chat height thumbnails can take at most,
-        // by default 0.4 for 40%.
-        real thumbnailMaxHeightRatio: 0.4 * Math.min(1, uiScale)
-
         real thumbnailCheckedOverlayOpacity: 0.4
 
     daybreak: