Use new PCN format for settings config file
This commit is contained in:
parent
6ce3059322
commit
db12036372
1
TODO.md
1
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 `<image url>\n<image url>` messages
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -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, {}),
|
||||
)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
4
src/backend/pcn/__init__.py
Normal file
4
src/backend/pcn/__init__.py
Normal 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."""
|
37
src/backend/pcn/globals_dict.py
Normal file
37
src/backend/pcn/globals_dict.py
Normal 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)
|
52
src/backend/pcn/property.py
Normal file
52
src/backend/pcn/property.py
Normal 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
395
src/backend/pcn/section.py
Normal 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))
|
|
@ -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
|
||||
|
@ -36,18 +39,19 @@ class UserFile:
|
|||
|
||||
data: Any = field(init=False, default_factory=dict)
|
||||
_need_write: bool = field(init=False, default=False)
|
||||
_wrote: 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):
|
||||
try:
|
||||
ignored = 0
|
||||
|
||||
for change in changes:
|
||||
mtime = self.path.stat().st_mtime
|
||||
|
||||
if change[0] in (Change.added, Change.modified):
|
||||
if self._need_write or self._wrote:
|
||||
self._wrote = False
|
||||
if mtime == self._mtime:
|
||||
ignored += 1
|
||||
continue
|
||||
|
||||
async with aiofiles.open(self.path) as file:
|
||||
self.data, save = self.deserialized(await file.read())
|
||||
text = await file.read()
|
||||
self.data, save = self.deserialized(text)
|
||||
|
||||
if save:
|
||||
self.save()
|
||||
|
||||
elif change[0] == Change.deleted:
|
||||
self._wrote = False
|
||||
self.data = self.default_data
|
||||
self._need_write = self.create_missing
|
||||
|
||||
self._mtime = mtime
|
||||
|
||||
if changes and ignored < len(changes):
|
||||
UserFileChanged(type(self), self.data)
|
||||
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)
|
||||
|
||||
try:
|
||||
if self._need_write:
|
||||
async with atomic_write(self.path) as (new, done):
|
||||
async with atomic_write(self.write_path) as (new, done):
|
||||
await new.write(self.serialized())
|
||||
done()
|
||||
|
||||
self._need_write = False
|
||||
self._wrote = True
|
||||
self._mtime = self.write_path.stat().st_mtime
|
||||
|
||||
except Exception as err:
|
||||
self._need_write = False
|
||||
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
|
||||
|
|
|
@ -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."""
|
||||
|
||||
|
|
430
src/config/settings.py
Normal file
430
src/config/settings.py
Normal 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"]
|
|
@ -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) *
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@ Rectangle {
|
|||
|
||||
|
||||
implicitWidth:
|
||||
window.settings.compactMode ?
|
||||
window.settings.General.compact ?
|
||||
theme.controls.presence.radius * 2 :
|
||||
theme.controls.presence.radius * 2.5
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {} }
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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)) {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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 &&
|
||||
|
|
|
@ -123,7 +123,7 @@ HPopup {
|
|||
|
||||
Timer {
|
||||
id: autoHideTimer
|
||||
interval: window.settings.media.autoHideOSDAfterMsec
|
||||
interval: window.settings.Chat.Files.autohide_image_controls_after
|
||||
}
|
||||
|
||||
ViewerInfo {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
)
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
Loading…
Reference in New Issue
Block a user