diff --git a/TODO.md b/TODO.md index 9418f5e0..befeb145 100644 --- a/TODO.md +++ b/TODO.md @@ -5,7 +5,7 @@ - Bug fixes - 100% CPU usage when hitting top edge to trigger messages loading - Sending `![A picture](https://picsum.photos/256/256)` → not clickable? - - Icons aren't reloaded + - Icons and images aren't reloaded - HStyle singleton isn't reloaded - `MessageDelegate.qml:63: TypeError: 'reloadPreviousItem' not a function` - Bug when resizing window being tiled (i3), can't figure it out diff --git a/harmonyqml/backend/model/items.py b/harmonyqml/backend/model/items.py index 116fd64d..a74fc83e 100644 --- a/harmonyqml/backend/model/items.py +++ b/harmonyqml/backend/model/items.py @@ -1,92 +1,38 @@ -from typing import Any, Callable, Optional, Sequence, Tuple, Union +from typing import Any, Dict, List, Optional -from PyQt5.QtCore import QObject, pyqtProperty, pyqtSignal +from PyQt5.QtCore import QDateTime - -class ListItem(QObject): - roles: Tuple[str, ...] = () - - def __init__(self, *args, no_update: Sequence[str] = (), **kwargs): - super().__init__() - self.no_update = no_update - - for role, value in zip(self.roles, args): - setattr(self, role, value) - - for role, value in kwargs.items(): - setattr(self, role, value) - - - def __repr__(self) -> str: - return "%s(no_update=%s, %s)" % ( - type(self).__name__, - self.no_update, - ", ".join((f"{r}={getattr(self, r)!r}" for r in self.roles)), - ) - - - @pyqtProperty(str, constant=True) - def _repr(self) -> str: - return repr(self) - - -def prop(qt_type: Union[str, Callable], - name: str, - signal: Optional[pyqtSignal] = None, - default_value: Any = None) -> pyqtProperty: - - def fget(self, name=name, default_value=default_value): - if not hasattr(self, f"_{name}"): - setattr(self, f"_{name}", default_value) - return getattr(self, f"_{name}") - - def fset(self, value, name=name, signal=signal): - setattr(self, f"_{name}", value) - if signal: - getattr(self, f"{name}Changed").emit(value) - - kws = {"notify": signal} if signal else {"constant": True} - - return pyqtProperty(qt_type, fget=fget, fset=fset, **kws) +from .list_item import ListItem class User(ListItem): - roles = ("userId", "displayName", "avatarUrl", "statusMessage") + _required_init_values = {"userId"} + _constant = {"userId"} - displayNameChanged = pyqtSignal("QVariant") - avatarUrlChanged = pyqtSignal("QVariant") - statusMessageChanged = pyqtSignal(str) - - userId = prop(str, "userId") - displayName = prop("QVariant", "displayName", displayNameChanged) - avatarUrl = prop(str, "avatarUrl", avatarUrlChanged) - statusMessage = prop(str, "statusMessage", statusMessageChanged, "") + userId: str = "" + displayName: Optional[str] = None + avatarUrl: Optional[str] = None + statusMessage: Optional[str] = None class Room(ListItem): - roles = ("roomId", "category", "displayName", "topic", "typingUsers", - "inviter", "leftEvent") + _required_init_values = {"roomId", "displayName"} + _constant = {"roomId"} - categoryChanged = pyqtSignal(str) - displayNameChanged = pyqtSignal("QVariant") - topicChanged = pyqtSignal(str) - typingUsersChanged = pyqtSignal("QVariantList") - inviterChanged = pyqtSignal("QVariant") - leftEventChanged = pyqtSignal("QVariant") - - roomId = prop(str, "roomId") - category = prop(str, "category", categoryChanged) - displayName = prop(str, "displayName", displayNameChanged) - topic = prop(str, "topic", topicChanged, "") - typingUsers = prop(list, "typingUsers", typingUsersChanged, []) - inviter = prop("QVariant", "inviter", inviterChanged) - leftEvent = prop("QVariant", "leftEvent", leftEventChanged) + roomId: str = "" + displayName: str = "" + category: str = "Rooms" + topic: Optional[str] = None + typingUsers: List[str] = [] + inviter: Optional[Dict[str, str]] = None + leftEvent: Optional[Dict[str, str]] = None class RoomEvent(ListItem): - roles = ("type", "dateTime", "dict", "isLocalEcho") + _required_init_values = {"type", "dict"} + _constant = {"type"} - type = prop(str, "type") - dateTime = prop("QVariant", "dateTime") - dict = prop("QVariantMap", "dict") - isLocalEcho = prop(bool, "isLocalEcho", None, False) + type: str = "" + dict: Dict[str, Any] = {} + dateTime: QDateTime = QDateTime.currentDateTime() + isLocalEcho: bool = False diff --git a/harmonyqml/backend/model/list_item.py b/harmonyqml/backend/model/list_item.py new file mode 100644 index 00000000..f5712e70 --- /dev/null +++ b/harmonyqml/backend/model/list_item.py @@ -0,0 +1,147 @@ +from typing import Any, Dict, List, Mapping, Optional, Tuple + +from PyQt5.QtCore import QObject, pyqtProperty, pyqtSignal, pyqtSlot + + +class _ListItemMeta(type(QObject)): # type: ignore + __slots__ = () + + def __new__(mcs, name: str, bases: Tuple[type], attrs: Dict[str, Any]): + def to_pyqt_type(type_): + try: + if issubclass(type_, (bool, int, float, str)): + return type_ + if issubclass(type_, Mapping): + return "QVariantMap" + return "QVariant" + except TypeError: # e.g. None passed + return to_pyqt_type(type(type_)) + + special = {"_main_key", "_required_init_values", "_constant"} + constant = set(attrs.get("_constant") or set()) + + props = { + name: (to_pyqt_type(attrs.get("__annotations__", {}).get(name)), + value) + for name, value in attrs.items() + + if not (name.startswith("__") or callable(value) or + name in special) + } + + signals = { + f"{name}Changed": pyqtSignal(type_) + for name, (type_, _) in props.items() if name not in constant + } + + pyqt_props_kwargs: Dict[str, Dict[str, Any]] = { + name: {"constant": True} if name in constant else + {"notify": signals[f"{name}Changed"], + "fset": lambda self, value, n=name: ( + setattr(self, f"_{n}", value) or # type: ignore + getattr(self, f"{n}Changed").emit(value), + ), + } + for name in props + } + + pyqt_props = { + name: pyqtProperty( + type_, + fget=lambda self, n=name: getattr(self, f"_{n}"), + **pyqt_props_kwargs.get(name, {}), + ) + for name, (type_, _) in props.items() + } + + attrs = { + **attrs, **signals, **pyqt_props, + "__slots__": tuple({f"_{prop}" for prop in props} & {"_main_key"}), + "_props": props, + "_main_key": attrs.get("_main_key") or + list(props.keys())[0] if props else None, + + "_required_init_values": attrs.get("_required_init_values") or (), + "_constant": constant, + } + return type.__new__(mcs, name, bases, attrs) + + +class ListItem(QObject, metaclass=_ListItemMeta): + def __init__(self, *args, **kwargs) -> None: + super().__init__() + + method = "%s.__init__()" % type(self).__name__ + already_set = set() + + required = set(self._required_init_values) + required_num = len(required) + 1 # + 1 = self + args_num = len(self._props) + 1 + from_to = str(args_num) if required_num == args_num else \ + f"from {required_num} to {args_num}" + + if len(args) > len(self._props): + raise TypeError( + f"{method} takes {from_to} positional arguments but " + f"{len(args) + 1} were given" + ) + + for prop, value in zip(self._props, args): + setattr(self, f"_{prop}", value) + already_set.add(prop) + + for prop, value in kwargs.items(): + if prop in already_set: + raise TypeError(f"{method} got multiple values for " + f"argument {prop!r}") + if prop not in self._props: + raise TypeError(f"{method} got an unexpected keyword " + f"argument {prop!r}") + setattr(self, f"_{prop}", value) + already_set.add(prop) + + missing = required - already_set + if missing: + raise TypeError("%s missing %d required argument: %s" % ( + method, len(missing), ", ".join((repr(m) for m in missing)))) + + for prop in set(self._props) - already_set: + # Set default values for properties not provided in arguments + setattr(self, f"_{prop}", self._props[prop][1]) + + + def __repr__(self) -> str: + return "%s(main_key=%r, required_init_values=%r, constant=%r, %s)" % ( + type(self).__name__, + self.mainKey, + self._required_init_values, + self._constant, + ", ".join((("%s=%r" % (p, getattr(self, p))) for p in self._props)) + ) + + + @pyqtSlot(result=str) + def repr(self) -> str: + return self.__repr() + + + @pyqtProperty(list) + def roles(self) -> List[str]: + return list(self._props.keys()) + + + @pyqtProperty(str) + def mainKey(self) -> str: + return self._main_key + + +class User(ListItem): + _required_init_values = {"name"} + _constant = {"name"} + + name: str = "" + age: int = 0 + likes: Tuple[str, ...] = () + knows: Dict[str, str] = {} + photo: Optional[str] = None + other = None diff --git a/harmonyqml/backend/model/list_model.py b/harmonyqml/backend/model/list_model.py index cb06f34a..aedb2867 100644 --- a/harmonyqml/backend/model/list_model.py +++ b/harmonyqml/backend/model/list_model.py @@ -9,12 +9,14 @@ from PyQt5.QtCore import ( pyqtSlot ) -from .items import ListItem +from .list_item import ListItem +Index = Union[int, str] NewItem = Union[ListItem, Mapping[str, Any], Sequence] class ListModel(QAbstractListModel): + rolesSet = pyqtSignal() changed = pyqtSignal() countChanged = pyqtSignal(int) @@ -34,7 +36,7 @@ class ListModel(QAbstractListModel): def __getitem__(self, index): - return self._data[index] + return self.get(index) def __setitem__(self, index, value) -> None: @@ -53,11 +55,17 @@ class ListModel(QAbstractListModel): return iter(self._data) - @pyqtProperty(list, constant=True) + @pyqtProperty("QVariant", notify=rolesSet) def roles(self) -> Tuple[str, ...]: return self._data[0].roles if self._data else () # type: ignore + @pyqtProperty("QVariant", notify=rolesSet) + def mainKey(self) -> Optional[str]: + return self._data[0].mainKey if self._data else None + + + def roleNames(self) -> Dict[int, bytes]: return {Qt.UserRole + i: bytes(f, "utf-8") for i, f in enumerate(self.roles, 1)} \ @@ -110,11 +118,6 @@ class ListModel(QAbstractListModel): return len(self) - @pyqtSlot(int, result="QVariant") - def get(self, index: int) -> ListItem: - return self._data[index] - - @pyqtSlot(str, "QVariant", result=int) def indexWhere(self, prop: str, is_value: Any) -> int: for i, item in enumerate(self._data): @@ -125,16 +128,26 @@ class ListModel(QAbstractListModel): f"property {prop!r} set to {is_value!r}.") - @pyqtSlot(str, "QVariant", result="QVariant") - def getWhere(self, prop: str, is_value: Any) -> ListItem: - return self.get(self.indexWhere(prop, is_value)) + @pyqtSlot(int, result="QVariant") + @pyqtSlot(str, result="QVariant") + def get(self, index: Index) -> ListItem: + if isinstance(index, str): + index = self.indexWhere(self.mainKey, index) + + return self._data[index] # type: ignore @pyqtSlot(int, "QVariantMap") def insert(self, index: int, value: NewItem) -> None: value = self._convert_new_value(value) + self.beginInsertRows(QModelIndex(), index, index) + + had_data = bool(self._data) self._data.insert(index, value) + if not had_data: + self.rolesSet.emit() + self.endInsertRows() self.countChanged.emit(len(self)) @@ -152,46 +165,68 @@ class ListModel(QAbstractListModel): self.append(val) - @pyqtSlot("QVariantMap") - def update(self, index: int, value: NewItem) -> None: + @pyqtSlot(int, "QVariantMap") + @pyqtSlot(int, "QVariantMap", "QVariant") + @pyqtSlot(str, "QVariantMap") + @pyqtSlot(str, "QVariantMap", "QVariant") + def update(self, + index: Index, + value: NewItem, + ignore_roles: Sequence[str] = ()) -> None: value = self._convert_new_value(value) - for role in self.roles: - if role in value.no_update: - continue + if isinstance(index, str): + index = self.indexWhere(self.mainKey or value.mainKey, index) - setattr(self._data[index], role, getattr(value, role)) + to_update = self._data[index] # type: ignore + + for role in self.roles: + if role not in ignore_roles: + try: + setattr(to_update, role, getattr(value, role)) + except AttributeError: # constant/not settable + pass qidx = QAbstractListModel.index(self, index, 0) self.dataChanged.emit(qidx, qidx, self.roleNames()) self.changed.emit() - @pyqtSlot(str, "QVariant", "QVariantMap") - def updateOrAppendWhere( - self, prop: str, is_value: Any, update_with: NewItem - ) -> None: + @pyqtSlot(str, "QVariantMap") + @pyqtSlot(str, "QVariantMap", int) + @pyqtSlot(str, "QVariantMap", int, list) + def upsert(self, + where_main_key_is_value: Any, + update_with: NewItem, + index_if_insert: Optional[int] = None, + ignore_roles: Sequence[str] = ()) -> None: try: - index = self.indexWhere(prop, is_value) - self.update(index, update_with) - except ValueError: - index = len(self) - self.append(update_with) + self.update(where_main_key_is_value, update_with, ignore_roles) + except (IndexError, ValueError): + self.insert(index_if_insert or len(self), update_with) @pyqtSlot(int, list) - def set(self, index: int, value: NewItem) -> None: + @pyqtSlot(str, list) + def set(self, index: Index, value: NewItem) -> None: + if isinstance(index, str): + index = self.indexWhere(self.mainKey, index) + qidx = QAbstractListModel.index(self, index, 0) value = self._convert_new_value(value) - self._data[index] = value + self._data[index] = value # type: ignore self.dataChanged.emit(qidx, qidx, self.roleNames()) self.changed.emit() @pyqtSlot(int, str, "QVariant") - def setProperty(self, index: int, prop: str, value: Any) -> None: - setattr(self._data[index], prop, value) + @pyqtSlot(str, str, "QVariant") + def setProperty(self, index: Index, prop: str, value: Any) -> None: + if isinstance(index, str): + index = self.indexWhere(self.mainKey, index) + + setattr(self._data[index], prop, value) # type: ignore qidx = QAbstractListModel.index(self, index, 0) self.dataChanged.emit(qidx, qidx, self.roleNames()) self.changed.emit() @@ -227,9 +262,13 @@ class ListModel(QAbstractListModel): @pyqtSlot(int) - def remove(self, index: int) -> None: + @pyqtSlot(str) + def remove(self, index: Index) -> None: + if isinstance(index, str): + index = self.indexWhere(self.mainKey, index) + self.beginRemoveRows(QModelIndex(), index, index) - del self._data[index] + del self._data[index] # type: ignore self.endRemoveRows() self.countChanged.emit(len(self)) diff --git a/harmonyqml/backend/signal_manager.py b/harmonyqml/backend/signal_manager.py index a98f7eb5..ae51774e 100644 --- a/harmonyqml/backend/signal_manager.py +++ b/harmonyqml/backend/signal_manager.py @@ -41,8 +41,7 @@ class SignalManager(QObject): def onClientDeleted(self, user_id: str) -> None: - accs = self.backend.models.accounts - del accs[accs.indexWhere("userId", user_id)] + del self.backend.models.accounts[user_id] def connectClient(self, client: Client) -> None: @@ -120,7 +119,7 @@ class SignalManager(QObject): client: Client, room_id: str, room: MatrixRoom, - category: str, + category: str = "Rooms", inviter: Inviter = None, left_event: LeftEvent = None) -> None: @@ -144,15 +143,14 @@ class SignalManager(QObject): item = Room( roomId = room_id, - category = category, displayName = get_displayname(), - topic = room.topic if room else "", + category = category, + topic = room.topic if room else None, inviter = inviter, leftEvent = left_event, - no_update = no_update, ) - model.updateOrAppendWhere("roomId", room_id, item) + model.upsert(room_id, item, ignore_roles=no_update) with self._lock: self._move_room(client.userId, room_id) @@ -242,9 +240,7 @@ class SignalManager(QObject): client: Client, room_id: str, users: List[str]) -> None: - - rooms = self.backend.models.rooms[client.userId] - rooms[rooms.indexWhere("roomId", room_id)].typingUsers = users + self.backend.models.rooms[client.userId][room_id].typingUsers = users def onMessageAboutToBeSent(self, @@ -253,17 +249,15 @@ class SignalManager(QObject): content: Dict[str, str]) -> None: with self._lock: - timestamp = QDateTime.currentMSecsSinceEpoch() model = self.backend.models.roomEvents[room_id] nio_event = nio.events.RoomMessage.parse_event({ "event_id": "", "sender": client.userId, - "origin_server_ts": timestamp, + "origin_server_ts": QDateTime.currentMSecsSinceEpoch(), "content": content, }) event = RoomEvent( type = type(nio_event).__name__, - dateTime = QDateTime.fromMSecsSinceEpoch(timestamp), dict = nio_event.__dict__, isLocalEcho = True, ) @@ -275,7 +269,5 @@ class SignalManager(QObject): def onRoomAboutToBeForgotten(self, client: Client, room_id: str) -> None: with self._lock: - rooms = self.backend.models.rooms[client.userId] - del rooms[rooms.indexWhere("roomId", room_id)] - + del self.backend.models.rooms[client.userId][room_id] self.backend.models.roomEvents[room_id].clear() diff --git a/harmonyqml/components/Chat/Chat.qml b/harmonyqml/components/Chat/Chat.qml index 919d9a10..2a2f0650 100644 --- a/harmonyqml/components/Chat/Chat.qml +++ b/harmonyqml/components/Chat/Chat.qml @@ -8,7 +8,7 @@ HColumnLayout { property string roomId: "" readonly property var roomInfo: - Backend.models.rooms.get(userId).getWhere("roomId", roomId) + Backend.models.rooms.get(userId).get(roomId) property bool canLoadPastEvents: true @@ -17,7 +17,7 @@ HColumnLayout { RoomHeader { displayName: roomInfo.displayName - topic: roomInfo.topic + topic: roomInfo.topic || "" } RoomEventList {} diff --git a/harmonyqml/components/Pages/SignIn.qml b/harmonyqml/components/Pages/SignIn.qml index 61e7d439..b43c5132 100644 --- a/harmonyqml/components/Pages/SignIn.qml +++ b/harmonyqml/components/Pages/SignIn.qml @@ -6,9 +6,6 @@ Item { property string loginWith: "username" onFocusChanged: identifierField.forceActiveFocus() - property int wi: x - onWiChanged: console.log("loginI", wi) - HInterfaceBox { id: signInBox title: "Sign in"