Begin yet another model refactor

Use native ListModel which require a lot of changes, but should be
much faster than the old way which exponentially slowed down to a crawl.
Also fix some popup bugs (leave/forget).

Not working yet: side pane keyboard controls, proper highlight,
room & member filtering, local echo replacement
This commit is contained in:
miruka
2019-12-02 16:29:29 -04:00
parent 2ce5e20efa
commit 9990fecc74
49 changed files with 826 additions and 781 deletions

View File

@@ -2,9 +2,6 @@
"""Provide classes related to data models shared between Python and QML."""
from typing import Tuple, Type, Union
from typing import Tuple, Union
from .model_item import ModelItem
# last one: Tuple[Union[Type[ModelItem], Tuple[Type[ModelItem]]], str...]
SyncId = Union[Type[ModelItem], Tuple[Type[ModelItem]], tuple]
SyncId = Union[str, Tuple[str]]

View File

@@ -11,31 +11,39 @@ from typing import Any, Dict, List, Optional, Tuple, Type, Union
from uuid import UUID
import lxml # nosec
import nio
from ..html_markdown import HTML_PROCESSOR
from ..utils import AutoStrEnum, auto
from .model_item import ModelItem
ZeroDate = datetime.fromtimestamp(0)
OptionalExceptionType = Union[Type[None], Type[Exception]]
class TypeSpecifier(AutoStrEnum):
"""Enum providing clarification of purpose for some matrix events."""
Unset = auto()
ProfileChange = auto()
MembershipChange = auto()
@dataclass
class Account(ModelItem):
"""A logged in matrix account."""
user_id: str = field()
display_name: str = ""
avatar_url: str = ""
first_sync_done: bool = False
profile_updated: Optional[datetime] = None
id: str = field()
display_name: str = ""
avatar_url: str = ""
first_sync_done: bool = False
profile_updated: datetime = ZeroDate
def __lt__(self, other: "Account") -> bool:
"""Sort by display name or user ID."""
name = self.display_name or self.user_id[1:]
other_name = other.display_name or other.user_id[1:]
return name < other_name
name = self.display_name or self.id[1:]
other_name = other.display_name or other.id[1:]
return name.lower() < other_name.lower()
@property
def filter_string(self) -> str:
@@ -47,18 +55,21 @@ class Account(ModelItem):
class Room(ModelItem):
"""A matrix room we are invited to, are or were member of."""
room_id: str = field()
given_name: str = ""
display_name: str = ""
avatar_url: str = ""
plain_topic: str = ""
topic: str = ""
inviter_id: str = ""
inviter_name: str = ""
inviter_avatar: str = ""
left: bool = False
id: str = field()
given_name: str = ""
display_name: str = ""
main_alias: str = ""
avatar_url: str = ""
plain_topic: str = ""
topic: str = ""
inviter_id: str = ""
inviter_name: str = ""
inviter_avatar: str = ""
left: bool = False
typing_members: List[str] = field(default_factory=list)
federated: bool = True
encrypted: bool = False
invite_required: bool = True
guests_allowed: bool = True
@@ -72,7 +83,7 @@ class Room(ModelItem):
can_set_join_rules: bool = False
can_set_guest_access: bool = False
last_event: Optional["Event"] = field(default=None, repr=False)
last_event_date: datetime = ZeroDate
def __lt__(self, other: "Room") -> bool:
"""Sort by join state, then descending last event date, then name.
@@ -85,68 +96,45 @@ class Room(ModelItem):
# Left rooms may still have an inviter_id, so check left first.
return (
self.left,
other.inviter_id,
other.last_event.date if other.last_event else
datetime.fromtimestamp(0),
self.display_name.lower() or self.room_id,
other.last_event_date,
(self.display_name or self.id).lower(),
) < (
other.left,
self.inviter_id,
self.last_event.date if self.last_event else
datetime.fromtimestamp(0),
other.display_name.lower() or other.room_id,
self.last_event_date,
(other.display_name or other.id).lower(),
)
@property
def filter_string(self) -> str:
"""Filter based on room display name, topic, and last event content."""
return " ".join((
self.display_name,
self.topic,
re.sub(r"<.*?>", "", self.last_event.inline_content)
if self.last_event else "",
))
@property
def serialized(self) -> Dict[str, Any]:
dct = super().serialized
if self.last_event is not None:
dct["last_event"] = self.last_event.serialized
return dct
return " ".join((self.display_name, self.topic))
@dataclass
class Member(ModelItem):
"""A member in a matrix room."""
user_id: str = field()
display_name: str = ""
avatar_url: str = ""
typing: bool = False
power_level: int = 0
invited: bool = False
id: str = field()
display_name: str = ""
avatar_url: str = ""
typing: bool = False
power_level: int = 0
invited: bool = False
profile_updated: datetime = ZeroDate
def __lt__(self, other: "Member") -> bool:
"""Sort by power level, then by display name/user ID."""
name = (self.display_name or self.user_id[1:]).lower()
other_name = (other.display_name or other.user_id[1:]).lower()
name = self.display_name or self.id[1:]
other_name = other.display_name or other.id[1:]
return (
self.invited, other.power_level, name,
self.invited, other.power_level, name.lower(),
) < (
other.invited, self.power_level, other_name,
other.invited, self.power_level, other_name.lower(),
)
@@ -168,15 +156,15 @@ class UploadStatus(AutoStrEnum):
class Upload(ModelItem):
"""Represent a running or failed file upload operation."""
uuid: UUID = field()
id: UUID = field()
task: asyncio.Task = field()
monitor: nio.TransferMonitor = field()
filepath: Path = field()
total_size: int = 0
uploaded: int = 0
speed: float = 0
time_left: Optional[timedelta] = None
total_size: int = 0
uploaded: int = 0
speed: float = 0
time_left: timedelta = timedelta(0)
status: UploadStatus = UploadStatus.Uploading
error: OptionalExceptionType = type(None)
@@ -191,21 +179,13 @@ class Upload(ModelItem):
return self.start_date > other.start_date
class TypeSpecifier(AutoStrEnum):
"""Enum providing clarification of purpose for some matrix events."""
none = auto()
profile_change = auto()
membership_change = auto()
@dataclass
class Event(ModelItem):
"""A matrix state event or message."""
source: Optional[nio.Event] = field()
client_id: str = field()
id: str = field()
event_id: str = field()
source: Optional[nio.Event] = field()
date: datetime = field()
sender_id: str = field()
sender_name: str = field()
@@ -213,8 +193,9 @@ class Event(ModelItem):
content: str = ""
inline_content: str = ""
reason: str = ""
type_specifier: TypeSpecifier = TypeSpecifier.none
type_specifier: TypeSpecifier = TypeSpecifier.Unset
target_id: str = ""
target_name: str = ""
@@ -271,12 +252,19 @@ class Event(ModelItem):
return urls
@property
def serialized(self) -> Dict[str, Any]:
dct = super().serialized
del dct["source"]
del dct["local_event_type"]
return dct
@dataclass
class Device(ModelItem):
"""A matrix user's device. This class is currently unused."""
device_id: str = field()
id: str = field()
ed25519_key: str = field()
trusted: bool = False
blacklisted: bool = False

View File

@@ -1,12 +1,12 @@
# SPDX-License-Identifier: LGPL-3.0-or-later
import logging as log
import time
from threading import Lock, Thread
from threading import Lock
from typing import Any, Dict, Iterator, List, MutableMapping
from ..pyotherside_events import (
ModelCleared, ModelItemDeleted, ModelItemInserted,
)
from . import SyncId
from ..pyotherside_events import ModelUpdated
from .model_item import ModelItem
@@ -30,13 +30,10 @@ class Model(MutableMapping):
"""
def __init__(self, sync_id: SyncId) -> None:
self.sync_id: SyncId = sync_id
self._data: Dict[Any, ModelItem] = {}
self._changed: bool = False
self._sync_lock: Lock = Lock()
self._sync_thread: Thread = Thread(target=self._sync_loop, daemon=True)
self._sync_thread.start()
self.sync_id: SyncId = sync_id
self._data: Dict[Any, ModelItem] = {}
self._sorted_data: List[ModelItem] = []
self._write_lock: Lock = Lock()
def __repr__(self) -> str:
@@ -47,27 +44,14 @@ class Model(MutableMapping):
except ImportError:
from pprint import pformat # type: ignore
if isinstance(self.sync_id, tuple):
sid = (self.sync_id[0].__name__, *self.sync_id[1:])
else:
sid = self.sync_id.__name__ # type: ignore
return "%s(sync_id=%s, %s)" % (
type(self).__name__, sid, pformat(self._data),
type(self).__name__, self.sync_id, pformat(self._data),
)
def __str__(self) -> str:
"""Provide a short "<sync_id>: <num> items" representation."""
if isinstance(self.sync_id, tuple):
reprs = tuple(repr(s) for s in self.sync_id[1:])
sid = ", ".join((self.sync_id[0].__name__, *reprs))
sid = f"({sid})"
else:
sid = self.sync_id.__name__
return f"{sid!s}: {len(self)} items"
return f"{self.sync_id}: {len(self)} items"
def __getitem__(self, key):
@@ -81,37 +65,36 @@ class Model(MutableMapping):
updated with the passed `ModelItem`'s fields.
In other cases, the item is simply added to the model.
This also sets the `ModelItem.parent_model` hidden attribute on the
passed item.
This also sets the `ModelItem.parent_model` hidden attributes on
the passed item.
"""
new = value
with self._write_lock:
existing = self._data.get(key)
new = value
if key in self:
existing = dict(self[key].serialized) # copy to not alter with pop
merged = {**existing, **value.serialized}
existing.pop("parent_model", None)
merged.pop("parent_model", None)
if merged == existing:
if existing:
for field in new.__dataclass_fields__: # type: ignore
# The same shared item is in _sorted_data, no need to find
# and modify it explicitely.
setattr(existing, field, getattr(new, field))
return
merged_init_kwargs = {**vars(self[key]), **vars(value)}
merged_init_kwargs.pop("parent_model", None)
new = type(value)(**merged_init_kwargs)
new.parent_model = self
new.parent_model = self
with self._sync_lock:
self._data[key] = new
self._changed = True
self._sorted_data.append(new)
self._sorted_data.sort()
ModelItemInserted(self.sync_id, self._sorted_data.index(new), new)
def __delitem__(self, key) -> None:
with self._sync_lock:
del self._data[key]
self._changed = True
with self._write_lock:
item = self._data.pop(key)
index = self._sorted_data.index(item)
del self._sorted_data[index]
ModelItemDeleted(self.sync_id, index)
def __iter__(self) -> Iterator:
@@ -122,31 +105,11 @@ class Model(MutableMapping):
return len(self._data)
def _sync_loop(self) -> None:
"""Loop to synchronize model when needed with a cooldown of 0.25s."""
while True:
time.sleep(0.25)
if self._changed:
with self._sync_lock:
log.debug("Syncing %s", self)
self.sync_now()
def sync_now(self) -> None:
"""Trigger a model synchronization right now. Use with precaution."""
ModelUpdated(self.sync_id, self.serialized())
self._changed = False
def serialized(self) -> List[Dict[str, Any]]:
"""Return serialized model content as a list of dict for QML."""
return [item.serialized for item in sorted(self._data.values())]
def __lt__(self, other: "Model") -> bool:
"""Sort `Model` objects lexically by `sync_id`."""
return str(self.sync_id) < str(other.sync_id)
def clear(self) -> None:
super().clear()
ModelCleared(self.sync_id)

View File

@@ -2,8 +2,6 @@
from typing import Any, Dict, Optional
from ..utils import serialize_value_for_qml
class ModelItem:
"""Base class for items stored inside a `Model`.
@@ -28,11 +26,23 @@ class ModelItem:
def __setattr__(self, name: str, value) -> None:
"""If this item is in a `Model`, alert it of attribute changes."""
if name == "parent_model" or self.parent_model is None:
super().__setattr__(name, value)
return
if getattr(self, name) == value:
return
super().__setattr__(name, value)
if name != "parent_model" and self.parent_model is not None:
with self.parent_model._sync_lock:
self.parent_model._changed = True
old_index = self.parent_model._sorted_data.index(self)
self.parent_model._sorted_data.sort()
new_index = self.parent_model._sorted_data.index(self)
from ..pyotherside_events import ModelItemFieldChanged
ModelItemFieldChanged(
self.parent_model.sync_id, old_index, new_index, name, value,
)
def __delattr__(self, name: str) -> None:
@@ -43,8 +53,10 @@ class ModelItem:
def serialized(self) -> Dict[str, Any]:
"""Return this item as a dict ready to be passed to QML."""
from ..utils import serialize_value_for_qml
return {
name: serialize_value_for_qml(getattr(self, name))
name: serialize_value_for_qml(getattr(self, name), json_lists=True)
for name in dir(self)
if not (
name.startswith("_") or name in ("parent_model", "serialized")