diff --git a/CMakeLists.txt b/CMakeLists.txt index 870e238..4ce1eed 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -94,6 +94,7 @@ quotient_ispushruleenabledjob_wrapper.h quotient_redirecttoidpjob_wrapper.cpp pyquotient_module_wrapper.cpp quotient_getprotocolmetadatajob_wrapper.h +quotient_settingsgroup_wrapper.h quotient_requesttokentoresetpasswordemailjob_wrapper.h quotient_getroomeventsjob_wrapper.cpp quotient_getversionsjob_wrapper.cpp @@ -101,6 +102,7 @@ quotient_querypublicroomsjob_wrapper.cpp quotient_querykeysjob_deviceinformation_wrapper.cpp quotient_bind3pidjob_wrapper.cpp quotient_pushruleset_wrapper.cpp +quotient_accountsettings_wrapper.h quotient_user_wrapper.cpp quotient_searchjob_groupings_wrapper.h quotient_requestopenidtokenjob_wrapper.h @@ -171,9 +173,11 @@ quotient_setaccountdataperroomjob_wrapper.cpp quotient_getuserprofilejob_wrapper.cpp quotient_gettokenownerjob_wrapper.cpp quotient_getpublicroomsjob_wrapper.cpp +quotient_settingsgroup_wrapper.cpp quotient_geturlpreviewjob_wrapper.h quotient_getpushersjob_pusherdata_wrapper.h quotient_getpushruleactionsjob_wrapper.cpp +quotient_accountsettings_wrapper.cpp quotient_querykeysjob_unsigneddeviceinfo_wrapper.cpp quotient_getroomtagsjob_wrapper.cpp quotient_getmembersbyroomjob_wrapper.h @@ -429,8 +433,6 @@ quotient_roomevent_wrapper.cpp quotient_roomevent_wrapper.h quotient_roomeventptr_wrapper.cpp quotient_roomeventptr_wrapper.h -quotient_eventstatus_wrapper.cpp -quotient_eventstatus_wrapper.h quotient_eventitembase_wrapper.cpp quotient_eventitembase_wrapper.h quotient_timelineitem_wrapper.cpp diff --git a/PyQuotient/typesystems/typesystem_connection.xml b/PyQuotient/typesystems/typesystem_connection.xml index c1fb63f..e02e353 100644 --- a/PyQuotient/typesystems/typesystem_connection.xml +++ b/PyQuotient/typesystems/typesystem_connection.xml @@ -4,23 +4,6 @@ - - - - - - - + diff --git a/PyQuotient/typesystems/typesystem_eventitem.xml b/PyQuotient/typesystems/typesystem_eventitem.xml index 4ad94da..4b11137 100644 --- a/PyQuotient/typesystems/typesystem_eventitem.xml +++ b/PyQuotient/typesystems/typesystem_eventitem.xml @@ -3,9 +3,9 @@ - + - + diff --git a/PyQuotient/typesystems/typesystem_settings.xml b/PyQuotient/typesystems/typesystem_settings.xml index 5dced89..a0154d7 100644 --- a/PyQuotient/typesystems/typesystem_settings.xml +++ b/PyQuotient/typesystems/typesystem_settings.xml @@ -3,5 +3,11 @@ + + + + + + diff --git a/demo/accountregistry.py b/demo/accountregistry.py new file mode 100644 index 0000000..bf5fb3c --- /dev/null +++ b/demo/accountregistry.py @@ -0,0 +1,37 @@ +from typing import List +from PySide6 import QtCore +from PyQuotient import Quotient +from __feature__ import snake_case, true_property + + +class Account(Quotient.Connection): + ... + + +class AccountRegistry(QtCore.QObject): + addedAccount = QtCore.Signal(Account) + aboutToDropAccount = QtCore.Signal(Account) + + def __init__(self) -> None: + super().__init__() + self.accounts: List[Account] = [] + + def __len__(self): + return len(self.accounts) + + def __getitem__(self, position): + return self.accounts[position] + + def add(self, account: Account) -> None: + if (account in self.accounts): + return + + self.accounts.append(account) + self.addedAccount.emit(account) + + def drop(self, account: Account) -> None: + self.aboutToDropAccount.emit(account) + self.accounts.remove(account) + + def is_logged_in(self, user_id: str) -> bool: + return next((user for user in self.accounts if user.user_id == user_id), None) is not None diff --git a/demo/logindialog.py b/demo/logindialog.py index df33137..c3c9056 100644 --- a/demo/logindialog.py +++ b/demo/logindialog.py @@ -1,7 +1,7 @@ from PySide6 import QtCore, QtWidgets, QtGui +from PyQuotient import Quotient from __feature__ import snake_case, true_property -from PyQuotient import Quotient from .dialog import Dialog @@ -157,8 +157,9 @@ def on_user_edit_editing_finished(self): self.buttons.button(QtWidgets.QDialogButtonBox.Ok).enabled = False self.connection.resolve_server(user_id) - @QtCore.Slot(str) - def on_server_edit_editing_finished(self, server: str): + @QtCore.Slot() + def on_server_edit_editing_finished(self): + server = self.server_edit.text hs_url = QtCore.QUrl(server) if hs_url.is_valid(): self.connection.homerserver = server diff --git a/demo/mainwindow.py b/demo/mainwindow.py index 4803c22..10ac8bf 100644 --- a/demo/mainwindow.py +++ b/demo/mainwindow.py @@ -1,24 +1,72 @@ import math from PySide6 import QtCore, QtWidgets, QtGui -from __feature__ import snake_case, true_property - from PyQuotient import Quotient +from demo.accountregistry import AccountRegistry from demo.logindialog import LoginDialog +from demo.roomlistdock import RoomListDock +from demo.pyquaternionroom import PyquaternionRoom +from __feature__ import snake_case, true_property class MainWindow(QtWidgets.QMainWindow): def __init__(self): super().__init__() - self.text_label = QtWidgets.QLabel("Welcome to PyQuotient demo!") self.login_dialog = None self.connection_menu = None self.logout_menu = None + # FIXME: This will be a problem when we get ability to show + # several rooms at once. + self.current_room = None - self.set_central_widget(self.text_label) + self.account_registry = AccountRegistry() + self.room_list_dock = RoomListDock(self) + self.room_list_dock.roomSelected.connect(self.select_room) + self.add_dock_widget(QtCore.Qt.LeftDockWidgetArea, self.room_list_dock) self.create_menu() + # Only GUI, account settings will be loaded in invoke_login + self.load_settings() + + timer = QtCore.QTimer(self) + timer.single_shot = True + timer.timeout.connect(self.invoke_login) + timer.start(0) + + def __del__(self): + self.save_settings() + + def load_settings(self): + sg = Quotient.SettingsGroup("UI/MainWindow") + # TODO: fix rect value, is None + # if sg.contains("normal_geometry"): + # self.geometry = sg.value("normal_geometry") + if sg.value("maximized"): + + self.show_maximized() + # TODO: fix value, is None + # if sg.contains("window_parts_state"): + # self.restore_state(sg.value("window_parts_state")) + + def save_settings(self): + sg = Quotient.SettingsGroup("UI/MainWindow") + sg.set_value("normal_geometry", self.normal_geometry) + sg.set_value("maximized", self.maximized) + sg.set_value("window_parts_state", self.save_state()) + sg.sync() + + @QtCore.Slot() + def invoke_login(self): + accounts = Quotient.SettingsGroup("Accounts").child_groups() + auto_logged_in = False + for account_id in accounts: + account = Quotient.AccountSettings() + if account.homeserver: + access_token = self.load_access_token(account) + + def load_access_token(self, account: Quotient.AccountSettings): + ... @QtCore.Slot() def open_login_window(self): @@ -44,6 +92,8 @@ def quit(self): def add_connection(self, connection: Quotient.Connection, device_name: str): connection.lazy_loading = True + self.account_registry.add(connection) + self.room_list_dock.add_connection(connection) connection.syncLoop(30000) logout_action = self.logout_menu.add_action(connection.local_user_id, lambda: self.logout(connection)) @@ -148,6 +198,7 @@ def on_reconnection_timer_timeout(self, connection: Quotient.Connection): def on_logged_out(self, connection: Quotient.Connection): self.status_bar().show_message(f'Logged out as {connection.local_user_id}', 3000) + self.account_registry.drop(connection) self.drop_connection(connection) def create_menu(self): @@ -166,4 +217,31 @@ def show_millis_to_recon(self, connection: Quotient.Connection): """ self.status_bar().show_message('Couldn\'t connect to the server as {user}; will retry within {seconds} seconds'.format( user=connection.local_user_id, seconds=math.ceil(connection.millis_to_reconnect) - )) \ No newline at end of file + )) + + @QtCore.Slot(PyquaternionRoom) + def select_room(self, room: PyquaternionRoom) -> None: + if room is not None: + print(f'Opening room {room.object_name()}') + elif self.current_room is not None: + print(f'Closing room {self.current_room.object_name()}') + + if self.current_room is not None: + self.current_room.displaynameChanged.disconnect(self.current_room_displayname_changed) + + self.current_room = room + new_window_title = '' + if self.current_room: + new_window_title = self.current_room.display_name() + self.current_room.displaynameChanged.connect(self.current_room_displayname_changed) + + self.window_title = new_window_title + self.room_list_dock.set_selected_room(self.current_room) + + if room is not None and not self.is_active_window(): + self.show() + self.activate_window() + + @QtCore.Slot() + def current_room_displayname_changed(self): + self.window_title = self.current_room.displayName() diff --git a/demo/models/abstractroomordering.py b/demo/models/abstractroomordering.py new file mode 100644 index 0000000..9e77289 --- /dev/null +++ b/demo/models/abstractroomordering.py @@ -0,0 +1,43 @@ +from __future__ import annotations +from typing import List, Optional, TYPE_CHECKING + + +from PySide6 import QtCore +from PyQuotient import Quotient +if TYPE_CHECKING: + from demo.models.roomlistmodel import RoomListModel +from __feature__ import snake_case, true_property + + +class RoomGroup: + SystemPrefix = "im.quotient." + LegacyPrefix = "org.qmatrixclient." + + def __init__(self, key: str, rooms: Optional[List[Quotient.Room]] = None): + self.key = key + self.rooms: List[Quotient.Room] = [] + if rooms is not None: + self.rooms = rooms + + def __eq__(self, o: object) -> bool: + if isinstance(o, RoomGroup): + return self.key == o.key + return self.key == o + + def __repr__(self) -> str: + return f"RoomGroup(key='{self.key}', len(rooms)={len(self.rooms)})" + + +RoomGroups = List[RoomGroup] + + +class AbstractRoomOrdering(QtCore.QObject): + def __init__(self, model: RoomListModel) -> None: + super().__init__(model) + self.model = model + + def room_groups(self, room: Quotient.Room) -> RoomGroups: + return [] + + def update_groups(self, room: Quotient.Room) -> None: + self.model.update_groups(room) diff --git a/demo/models/orderbytag.py b/demo/models/orderbytag.py new file mode 100644 index 0000000..33a8ac9 --- /dev/null +++ b/demo/models/orderbytag.py @@ -0,0 +1,214 @@ +from typing import Any, List, Union +from demo.models.abstractroomordering import AbstractRoomOrdering, RoomGroup +from PyQuotient import Quotient +from __feature__ import snake_case, true_property + + +Invite = RoomGroup.SystemPrefix + "invite" +DirectChat = RoomGroup.SystemPrefix + "direct" +Untagged = RoomGroup.SystemPrefix + "none" +Left = RoomGroup.SystemPrefix + "left" + +InvitesLabel = "The caption for invitations" +FavouritesLabel = "Favourites" +LowPriorityLabel = "Low priority" +ServerNoticeLabel = "Server notices" +DirectChatsLabel = "The caption for direct chats" +UngroupedRoomsLabel = "Ungrouped rooms" +LeftLabel = "The caption for left rooms" + + +def tag_to_caption(tag: str) -> str: + if tag == Quotient.FavouriteTag: + return FavouritesLabel + elif tag == Quotient.LowPriorityTag: + return LowPriorityLabel + elif Quotient.ServerNoticeTag: + return ServerNoticeLabel + elif tag.startswith('u.'): + return tag[2:] + return tag + + +def caption_to_tag(caption: str): + if caption == FavouritesLabel: + return Quotient.FavouriteTag + elif caption == LowPriorityLabel: + return Quotient.LowPriorityTag + elif caption == ServerNoticeLabel: + return Quotient.ServerNoticeTag + elif caption.startswith('m.') or caption.startswith('u.'): + return caption + return f'u.{caption}' + + +def find_index(item_list: List[Any], value): + try: + return item_list[item_list.index(value)] + except ValueError: + return item_list[-1] + + +def find_index_with_wildcards(item_list: List[str], value: str): + if len(item_list) == 0 or len(value) == 0: + return len(item_list) + + index = item_list.index(value) + # Try namespace groupings (".*" in the list), from right to left + dot_pos = 0 + i = 0 + while not (i == len(item_list)): + i = find_index(item_list, value[:dot_pos + 1] + '*') + try: + dot_pos = value.rindex('.', dot_pos - 1) + except ValueError: + break; + return i + + +class OrderByTag(AbstractRoomOrdering): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.tags_order: List[str] = self.init_tags_order() + + def init_tags_order(self): + return [ + Invite, + Quotient.FavouriteTag, + "u.*", + DirectChat, + Untagged, + Quotient.LowPriorityTag, + Left + ] + + def connect_signals(self, obj: Union[Quotient.Connection, Quotient.Room]) -> None: + if isinstance(obj, Quotient.Connection): + obj.directChatsListChanged.connect(lambda additions, removals: self.on_conn_direct_chats_list_changed(obj, additions, removals)) + elif isinstance(obj, Quotient.Room): + obj.displaynameChanged.connect(lambda: self.update_groups(obj)) + obj.tagsChanged.connect(lambda: self.update_groups(obj)) + obj.joinStateChanged.connect(lambda: self.update_groups(obj)) + + def on_conn_direct_chats_list_changed(self, conn, additions, removals): + # The same room may show up in removals and in additions if it + # moves from one userid to another (pretty weird but encountered + # in the wild). Therefore process removals first. + for room_id in removals: + room = conn.room(room_id) + if room: + self.update_groups(room) + + for room_id in additions: + room = conn.room(room_id) + if room: + self.update_groups(room) + + def update_groups(self, room: Quotient.Room) -> None: + super().update_groups(room) + + # As the room may shadow predecessors, need to update their groups too. + pred_room = room.predecessor() # TODO: 'Quotient.JoinState.Join' error: predecessor takes no arguments + if pred_room: + self.update_groups(pred_room) + + def group_label(group: RoomGroup) -> str: + caption = tag_to_caption(group.key) + if group.key == Untagged: + caption = UngroupedRoomsLabel + elif group.key == Invite: + caption = InvitesLabel + elif group.key == DirectChat: + caption = DirectChatsLabel + elif group.key == Left: + caption = LeftLabel + + return f"{caption} ({len(group.rooms)} room(s))" + + def group_less_than(self, group1: RoomGroup, group2_key: str) -> bool: + lkey = group1.key + rkey = group2_key + li = find_index_with_wildcards(self.tags_order, lkey) + ri = find_index_with_wildcards(self.tags_order, rkey) + return li < ri or (li == ri and lkey < rkey) + + def room_groups(self, room: Quotient.Room): + if room.join_state == Quotient.JoinState.Invite: + return [Invite] + if room.join_state == Quotient.JoinState.Leave: + return [Left] + + tags = self.get_filtered_tags(room) + if len(tags) == 0: + tags.insert(0, Untagged) + # Check successors, reusing room as the current frame, and for each group + # shadow this room if there's already any of its successors in the group + successor_room = room.successor() # TODO: Quotient.JoinState.Join + while successor_room is not None: + successor_tags = self.get_filtered_tags(successor_room) + + if len(successor_tags) == 0: + tags.remove(Untagged) + else: + for tag in successor_tags: + if tag in tags: + tags.remove(tag) + + if len(tags) == 0: + return [] # No remaining groups, hide the room + + successor_room = room.successor() # TODO: Quotient.JoinState.Join + return tags + + def get_filtered_tags(self, room: Quotient.Room) -> List[str]: + all_tags = room.tag_names + if room.is_direct_chat(): + all_tags.append(DirectChat) + + result: List[str] = [] + for tag in all_tags: + if find_index_with_wildcards(self.tags_order, '-' + tag) == len(self.tags_order): + result.append(tag) # Only copy tags that are not disabled + return result + + def room_less_than(self, group_key: str, room1: Quotient.Room, room2: Quotient.Room): + if room1 == room2: + return False # 0. Short-circuit for coinciding room objects + + # 1. Compare tag order values + tag = group_key + order1 = room1.tag(tag).order + order2 = room2.tag(tag).order + if type(order2) != type(order1): + return not order2 == None + + if order1 and order2: + # Compare floats; fallthrough if neither is smaller + if order1 < order2: + return True + + if order1 > order2: + return False + + # 2. Neither tag order is less than the other; compare room display names + if room1.display_name != room2.display_name: + return room1.display_name < room2.display_name + + # 3. Within the same display name, order by room id + # (typically the case when both display names are completely empty) + if room1.id != room2.id: + return room1.id < room2.id + + # 4. Room ids are equal; order by connections (=userids) + connection1 = room1.connection + connection2 = room2.connection + if connection1 != connection2: + if connection1.user_id != connection2.user_id: + return connection1.user_id < connection2.user_id + + # 4a. Two logins under the same userid: pervert, but technically correct + return connection1.access_token < connection2.access_token + + # 5. Assume two incarnations of the room with the different join state + # (by design, join states are distinct within one connection+roomid) + return room1.join_state < room2.join_state diff --git a/demo/models/roomlistmodel.py b/demo/models/roomlistmodel.py new file mode 100644 index 0000000..7d39c86 --- /dev/null +++ b/demo/models/roomlistmodel.py @@ -0,0 +1,293 @@ +import functools +from enum import Enum, auto +from typing import Callable, Dict, List, Optional +from PySide6 import QtCore +from PyQuotient import Quotient +from demo.models.abstractroomordering import AbstractRoomOrdering, RoomGroup, RoomGroups +from __feature__ import snake_case, true_property + + +Visitor = Callable[[QtCore.QModelIndex], None] + + +class Roles(Enum): + HasUnreadRole = QtCore.Qt.UserRole + 1 + HighlightCountRole = auto() + JoinStateRole = auto() + ObjectRole = auto() + + +class RoomListModel(QtCore.QAbstractItemModel): + saveCurrentSelection = QtCore.Signal() + restoreCurrentSelection = QtCore.Signal() + groupAdded = QtCore.Signal(int) + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.room_groups: RoomGroups = [] + self.connections: List[Quotient.Connection] = [] + self.room_order: Optional[AbstractRoomOrdering] = None + self.room_indices: Dict[Quotient.Room, QtCore.QPersistentModelIndex] = {} + + self.modelAboutToBeReset.connect(self.saveCurrentSelection) + self.modelReset.connect(self.restoreCurrentSelection) + + def column_count(self, index: QtCore.QModelIndex) -> int: + return 1 + + def row_count(self, parent: QtCore.QModelIndex) -> int: + if not parent.is_valid(): + return len(self.room_groups) + + if self.is_valid_group_index(parent): + return len(self.room_groups[parent.row].rooms) + + return 0 + + def is_valid_group_index(self, index: QtCore.QModelIndex) -> bool: + return index.is_valid() and not index.parent().is_valid() and index.row < len(self.room_groups) + + def is_valid_room_index(self, index: QtCore.QModelIndex) -> bool: + return index.is_valid() and self.is_valid_group_index(index.parent()) and index.row < len(self.room_groups[index.parent.row].rooms) + + def add_connection(self, connection: Quotient.Connection) -> None: + self.connections.append(connection) + connection.loggedOut.connect(lambda: self.delete_connection(connection)) + connection.newRoom.connect(self.add_room) + self.room_order.connect_signals(connection) + + for room in connection.all_rooms(): + self.add_room(room) + + def delete_connection(self, connection: Quotient.Connection): + conn = next((conn for conn in self.connections if conn == connection), None) + if conn == None: + print('Connection is missing in the rooms model') + return + + for room in connection.all_rooms(): + self.delete_room(room) + self.connections.remove(connection) + + @QtCore.Slot(Quotient.Room) + def add_room(self, room: Quotient.Room) -> None: + self.add_room_to_groups(room) + self.connect_room_signals(room) + + def add_room_to_groups(self, room: Quotient.Room, groups_keys: List[str] = None) -> None: + if groups_keys is None: + groups_keys = [] + + if len(groups_keys) == 0: + groups_keys = self.room_order.room_groups(room) + + for group_key in groups_keys: + inserted_group = self.try_insert_group(group_key) + lower_bound_room = self.lower_bound_room(inserted_group, room) + if lower_bound_room == room: + print("RoomListModel: room is already listed under group") + continue + + group_index = self.index(len(self.room_groups), 0) + try: + room_index = inserted_group.rooms.index(room) + except ValueError: + room_index = 0 + + self.begin_insert_rows(group_index, room_index, room_index) + inserted_group.rooms.insert(room_index, room) + self.end_insert_rows() + self.room_indices[room] = self.index(room_index, 0, group_index) + print(f"RoomListModel: Added {room.object_name} to group {group_key}") + + def try_insert_group(self, group_key: str) -> RoomGroup: + lower_bound_group = self.lower_bound_group(group_key) + if lower_bound_group is None: + group_index = len(self.room_groups) + # TODO: const auto affectedIdxs = preparePersistentIndexChange(gPos, 1); + self.begin_insert_rows(QtCore.QModelIndex(), group_index, group_index) + room_group = RoomGroup(group_key) + self.room_groups.append(room_group) # TODO: insert on correct position + lower_bound_group = room_group + self.end_insert_rows() + # TODO: changePersistentIndexList(affectedIdxs.first, affectedIdxs.second); + self.groupAdded.emit(self.room_groups.index(room_group)) + # TODO + return lower_bound_group + + + def connect_room_signals(self, room: Quotient.Room) -> None: + room.beforeDestruction.connect(self.delete_room) + self.room_order.connect_signals(room) + room.displaynameChanged.connect(lambda: self.refresh(room)) + room.unreadMessagesChanged.connect(lambda: self.refresh(room)) + room.notificationCountChanged.connect(lambda: self.refresh(room)) + room.avatarChanged.connect(lambda: self.refresh(room, [QtCore.Qt.DecorationRole])) + + @QtCore.Slot(Quotient.Room) + def delete_room(self, room: Quotient.Room) -> None: + self.visit_room(room, self.do_remove_room) + + def do_remove_room(self, index: QtCore.QModelIndex) -> None: + if not self.is_valid_room_index(index): + print(f'Attempt to remove a room at invalid index {index}') + + group_position = index.parent.row + group = self.room_groups[group_position] + room = group.rooms[index.row] + print(f'RoomListModel: Removing room {room.object_name} from group {group.key}') + try: + del self.room_indices[room] + except KeyError: + print(f'Index {index} for room {room.object_name} not found in the index registry') + + self.begin_remove_rows(index.parent, index.row, index.row) + group.rooms.remove(room) + self.end_remove_rows() + + if len(group.rooms) == 0: + # Update persistent indices with parents after the deleted one + affected_indexes = self.prepare_persistent_index_change(group_position + 1, -1) + + self.begin_remove_rows([], group_position, group_position) + del self.room_groups[group_position] + self.end_remove_rows() + + self.change_persistent_index_list(affected_indexes[0], affected_indexes[1]) + + def refresh(self, room: Quotient.Room, roles: List[int] = None) -> None: + if roles is None: + roles = [] + + # The problem here is that the change might cause the room to change + # its groups. Assume for now that such changes are processed elsewhere + # where details about the change are available (e.g. in tagsChanged). + def refresh_visitor(index: QtCore.QModelIndex) -> None: + self.dataChanged.emit(index, index, roles) + self.dataChanged.emit(index.parent, index.parent, roles) + + self.visit_room(room, refresh_visitor) + + def visit_room(self, room: Quotient.Room, visitor: Visitor) -> None: + # Copy persistent indices because visitors may alter m_roomIndices + indices = self.room_indices.values() + for index in indices: + room_at_index = self.room_at(index) + if room_at_index == room: + visitor(index) + elif room_at_index is not None: + print(f'Room at {index} is {self.room_at(index).object_name} instead of {room.object_name}') + else: + print(f'Room at index {index} not found') + + def room_at(self, index: QtCore.QModelIndex) -> Optional[Quotient.Room]: + if self.is_valid_room_index(index): + return self.room_groups[index.parent.row].rooms[index.row] + return None + + def total_rooms(self) -> int: + return functools.reduce(lambda c1, c2: len(c1.all_rooms()) + len(c2.all_rooms()), self.connections) + + def set_order(self, order: AbstractRoomOrdering) -> None: + self.begin_reset_model() + self.room_groups.clear() + self.room_indices.clear() + self.room_order = order + self.end_reset_model() + + for connection in self.connections: + self.room_order.connect_signals(connection) + for room in connection.all_rooms(): + self.add_room_to_groups(room) + self.room_order.connect_signals(room) + + def update_groups(self, room: Quotient.Room) -> None: + groups = self.room_order.room_groups(room) + old_room_index = self.room_indices[room] # TODO: should be multiple? + + group_index = old_room_index.parent() + group = self.room_groups[group_index.row()] + try: + groups.remove(group.key) + # The room still in this group but may need to move around + # TODO: move rows if needed + + assert self.room_at(old_room_index) == room + except ValueError: + self.do_remove_room(old_room_index) + + if len(groups) > 0: + self.add_room_to_groups(room, groups) # Groups the room wasn't before + print(f"RoomListModel: groups for {room.object_name()} updated") + + def lower_bound_group(self, group_key: str, room = '') -> Optional[RoomGroup]: + found_group = None + for room_group in self.room_groups: + if not self.room_order.group_less_than(room_group, group_key): + found_group = room_group + break + if found_group is not None: + return found_group + else: + if len(self.room_groups) == 0: + return None + return self.room_groups[len(self.room_groups) - 1] + + + def lower_bound_room(self, group: RoomGroup, room: Quotient.Room): + found_room = None + for group_room in group.rooms: + if not self.room_order.room_less_than(group_room, group.key): + found_room = group_room + break + if found_room: + return found_room + else: + if len(group.rooms) == 0: + return None + return group.rooms[len(group.rooms) - 1] + + def index(self, row: int, column: int, parent: QtCore.QModelIndex = QtCore.QModelIndex()): + if not self.has_index(row, column, parent): + return QtCore.QModelIndex() + + # Groups get internalId() == -1, rooms get the group ordinal number + parent_row = -1 + if parent: + parent_row = parent.row + return self.create_index(row, column, parent_row) + + def parent(self, child: QtCore.QModelIndex): + parent_pos = int(child.internal_id()) + # TODO: fix OverflowError (point to unexisting data?) + # if child.is_valid() and parent_pos > -1: + # return self.index(parent_pos, 0) + return QtCore.QModelIndex() + + def room_group_at(self, index: QtCore.QModelIndex): + assert index.is_valid() # Root item shouldn't come here + # If we're on a room, find its group; otherwise just take the index + group_index = index + if index.parent().is_valid(): + group_index = index.parent() + try: + return self.room_groups[group_index.row()].key + except ValueError: + return '' + + def index_of(self, group_key, room = None): + if room is not None: + index = self.room_indices.get(room, None) + if group_key == '' and index != None: + return index; + + for room_index in self.room_indices.keys(): + if self.room_groups[room_index.parent().row()].key == group_key: + return room_index + return QtCore.QModelIndex() + else: + group = self.lower_bound_group(group_key) + if group not in self.room_groups: + # Group not found + return QtCore.QModelIndex() + return self.index(self.room_groups.index(group), 0) diff --git a/demo/roomlistdock.py b/demo/roomlistdock.py new file mode 100644 index 0000000..5295efe --- /dev/null +++ b/demo/roomlistdock.py @@ -0,0 +1,112 @@ +from demo.models.abstractroomordering import RoomGroup +from PySide6 import QtCore, QtWidgets, QtGui +from PyQuotient import Quotient +from demo.pyquaternionroom import PyquaternionRoom +from demo.models.roomlistmodel import RoomListModel, Roles +from demo.models.orderbytag import OrderByTag +from __feature__ import snake_case, true_property + + +class RoomListItemDelegate(QtWidgets.QStyledItemDelegate): + def paint(self, painter: QtGui.QPainter, option: QtWidgets.QStyleOptionViewItem, index: QtCore.QModelIndex): + new_option = QtWidgets.QStyleOptionViewItem(option) + if not index.parent().is_valid(): + # Group captions + new_option.display_alignment = QtCore.Qt.AlignHCenter + new_option.font.bold = True + + if index.data(Roles.HasUnreadRole) is not None: + new_option.font.bold = True + + if int(index.data(Roles.HighlightCountRole)) > 0: + highlight_color = QtGui.QColor("orange") + new_option.palette.set_color(QtGui.QPalette.Text, highlight_color) + # Highlighting the text may not work out on monochrome colour schemes, + # hence duplicating with italic font. + new_option.font.italic = True + + join_state = index.data(Roles.JoinStateRole) + if join_state == "invite": + new_option.font.italic = True + elif join_state == "leave" or join_state == "upgraded": + new_option.font.strike_out = True + + super().paint(painter, new_option, index) + +class RoomListDock(QtWidgets.QDockWidget): + roomSelected = QtCore.Signal(PyquaternionRoom) + + def __init__(self, parent=None) -> None: + super().__init__("Rooms", parent) + self.selected_group_cache = None + self.selected_room_cache = None + + self.object_name = "RoomsDock" + self.view = QtWidgets.QTreeView(self) + self.model = RoomListModel(self.view) + self.update_sorting_mode() + self.view.set_model(self.model) + self.view.set_item_delegate(RoomListItemDelegate(self)) + self.view.animated = True + self.view.uniform_row_heights = True + self.view.selection_behavior = QtWidgets.QTreeView.SelectRows + self.view.header_hidden = True + self.view.indentation = 0 + self.view.root_is_decorated = False + + self.view.activated.connect(self.row_selected) # See Quaternion #608 + self.view.clicked.connect(self.row_selected) + self.model.rowsInserted.connect(self.refresh_title) + self.model.rowsRemoved.connect(self.refresh_title) + self.model.saveCurrentSelection.connect(self.save_current_selection) + + self.set_widget(self.view) + + def add_connection(self, connection: Quotient.Connection): + self.model.add_connection(connection) + + @QtCore.Slot() + def set_selected_room(self, room: PyquaternionRoom): + if self.get_selected_room() == room: + return + + # First try the current group; if that fails, try the entire list + index = None + current_group = self.get_selected_group() + if current_group is not None: + index = self.model.index_of(current_group, room) + if not index.is_valid(): + index = self.model.index_of(RoomGroup(''), room) + if index.is_valid(): + self.view.current_index = index + self.view.scroll_to(index) + + @QtCore.Slot() + def update_sorting_mode(self): + self.model.set_order(OrderByTag(self.model)) + + @QtCore.Slot(QtCore.QModelIndex) + def row_selected(self, index: QtCore.QModelIndex): + if self.model.is_valid_room_index(index): + self.roomSelected.emit(self.model.room_at(index)) + + @QtCore.Slot() + def refresh_title(self): + self.window_title = f"Rooms ({self.model.total_rooms()})" + + @QtCore.Slot() + def save_current_selection(self): + self.selected_room_cache = self.get_selected_room() + self.selected_group_cache = self.get_selected_group() + + def get_selected_room(self): + index = self.view.current_index() + if not index.is_valid() or not index.parent().is_valid(): + return None + return self.model.room_at(index) + + def get_selected_group(self): + index = self.view.current_index() + if not index.is_valid(): + return None + return self.model.room_group_at(index) diff --git a/tests/test_room.py b/tests/test_room.py new file mode 100644 index 0000000..9761a6e --- /dev/null +++ b/tests/test_room.py @@ -0,0 +1,17 @@ +from PyQuotient import Quotient +from __feature__ import snake_case, true_property + + +def test_init(): + connection = Quotient.Connection() + room = Quotient.Room(connection, 'room1', Quotient.JoinState.Join) + assert isinstance(room, Quotient.Room) + + +def test_subclass(): + class PyRoom(Quotient.Room): + ... + + connection = Quotient.Connection() + room = PyRoom(connection, 'room1', Quotient.JoinState.Join) + assert isinstance(room, PyRoom) diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..94b48bb --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,20 @@ +from PyQuotient import Quotient +from __feature__ import snake_case, true_property + + +class TestSettings: + def test_init(self): + settings = Quotient.Settings() + assert isinstance(settings, Quotient.Settings) + + +class TestSettingsGroup: + def test_init(self): + settings_group = Quotient.SettingsGroup('group1') + assert isinstance(settings_group, Quotient.SettingsGroup) + + +class TestAccountSettings: + def test_init(self): + account_settings = Quotient.AccountSettings('account1') + assert isinstance(account_settings, Quotient.AccountSettings)