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:
@@ -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]]
|
||||
|
@@ -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
|
||||
|
@@ -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)
|
||||
|
@@ -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")
|
||||
|
Reference in New Issue
Block a user