Use new PCN format for settings config file

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

View File

@ -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 `<image url>\n<image url>` messages

View File

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

View File

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

View File

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

View File

@ -0,0 +1,4 @@
# Copyright Mirage authors & contributors <https://github.com/mirukana/mirage>
# SPDX-License-Identifier: LGPL-3.0-or-later
"""Parse and operate on PCN (Python Config Notation) files."""

View File

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

View File

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

395
src/backend/pcn/section.py Normal file
View File

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

View File

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

View File

@ -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", "&nbsp;" * 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."""

430
src/config/settings.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,7 +8,7 @@ Rectangle {
implicitWidth:
window.settings.compactMode ?
window.settings.General.compact ?
theme.controls.presence.radius * 2 :
theme.controls.presence.radius * 2.5

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -75,11 +75,11 @@ HRowLayout {
readonly property int maxMessageWidth:
contentText.includes("<pre>") || contentText.includes("<table>") ?
-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

View File

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

View File

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

View File

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

View File

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

View File

@ -123,7 +123,7 @@ HPopup {
Timer {
id: autoHideTimer
interval: window.settings.media.autoHideOSDAfterMsec
interval: window.settings.Chat.Files.autohide_image_controls_after
}
ViewerInfo {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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:
'<style type"text/css">\n' + styleSheet + '\n</style>\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:

View File

@ -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:
'<style type"text/css">\n' + styleSheet + '\n</style>\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: