From 5ba669444d76070ce48a61c42207927860f70777 Mon Sep 17 00:00:00 2001 From: miruka Date: Thu, 20 Aug 2020 12:21:47 -0400 Subject: [PATCH] Add basic user autocompletion UI --- TODO.md | 2 +- src/backend/backend.py | 10 +- src/backend/models/filters.py | 55 ++++++--- src/backend/models/model_store.py | 5 +- src/backend/models/special_models.py | 25 ++++- src/gui/MainPane/RoomList.qml | 2 +- src/gui/Pages/AddAccount/ServerBrowser.qml | 2 +- src/gui/Pages/Chat/Composer/Composer.qml | 71 +++++++----- src/gui/Pages/Chat/Composer/MessageArea.qml | 55 +++++++-- .../Chat/Composer/UserAutoCompletion.qml | 105 ++++++++++++++++++ .../Chat/RoomPane/MemberView/MemberView.qml | 2 +- 11 files changed, 271 insertions(+), 63 deletions(-) create mode 100644 src/gui/Pages/Chat/Composer/UserAutoCompletion.qml diff --git a/TODO.md b/TODO.md index 481d498d..f59f2389 100644 --- a/TODO.md +++ b/TODO.md @@ -2,7 +2,7 @@ - refresh server list button - server list sorting method / explain what the % number is (stability) -- spam alt+shift+a when starting app on server browser → segfault +- spam alt+shift+a/going to room when starting app on server browser → segfault - cursor shape in HBox/HTabbedBox pages over fields - login with account already added → infinite spinner in room list - verify onKeyboardAccept/Cancel things respect button enabled state diff --git a/src/backend/backend.py b/src/backend/backend.py index fe7dd3f6..be3c8ee5 100644 --- a/src/backend/backend.py +++ b/src/backend/backend.py @@ -22,7 +22,7 @@ from .errors import MatrixError from .matrix_client import MatrixClient from .media_cache import MediaCache from .models import SyncId -from .models.filters import FieldSubstringFilter +from .models.filters import FieldStringFilter from .models.items import Account, Event, Homeserver, PingStatus from .models.model import Model from .models.model_store import ModelStore @@ -458,8 +458,8 @@ class Backend: return (settings, ui_state, history, theme) - async def set_substring_filter(self, model_id: SyncId, value: str) -> None: - """Set a FieldSubstringFilter model's filter property. + async def set_string_filter(self, model_id: SyncId, value: str) -> None: + """Set a FieldStringFilter (or derived class) model's filter property. This should only be called from QML. """ @@ -469,8 +469,8 @@ class Backend: model = Model.proxies[model_id] - if not isinstance(model, FieldSubstringFilter): - raise TypeError("model_id must point to a FieldSubstringFilter") + if not isinstance(model, FieldStringFilter): + raise TypeError("model_id must point to a FieldStringFilter") model.filter = value diff --git a/src/backend/models/filters.py b/src/backend/models/filters.py index 4cb519e3..acb00798 100644 --- a/src/backend/models/filters.py +++ b/src/backend/models/filters.py @@ -112,13 +112,12 @@ class ModelFilter(ModelProxy): callback() -class FieldSubstringFilter(ModelFilter): +class FieldStringFilter(ModelFilter): """Filter source models based on if their fields matches a string. This is used for filter fields in QML: the user enters some text and only - items with a certain field (typically `display_name`) that contain the - words of the text (can be partial, e.g. "red" matches "red" or "tired") - will be shown. + items with a certain field (typically `display_name`) that starts with the + entered text will be shown. Matching is done using "smart case": insensitive if the filter text is all lowercase, sensitive otherwise. @@ -128,6 +127,8 @@ class FieldSubstringFilter(ModelFilter): self.fields: Collection[str] = fields self._filter: str = "" + self.no_filter_accept_all_items: bool = True + super().__init__(sync_id) @@ -138,24 +139,48 @@ class FieldSubstringFilter(ModelFilter): @filter.setter def filter(self, value: str) -> None: - self._filter = value - self.refilter() + if value != self._filter: + self._filter = value + self.refilter() def accept_item(self, item: "ModelItem") -> bool: if not self.filter: - return True + return self.no_filter_accept_all_items - text = " ".join((getattr(item, f) for f in self.fields)) - filt = self.filter - filt_lower = filt.lower() + fields = {f: getattr(item, f) for f in self.fields} + filtr = self.filter + lowercase = filtr.lower() - if filt_lower == filt: - # Consider case only if filter isn't all lowercase (smart case) - filt = filt_lower - text = text.lower() + if lowercase == filtr: + # Consider case only if filter isn't all lowercase + filtr = lowercase + fields = {name: value.lower() for name, value in fields.items()} - for word in filt.split(): + return self.match(fields, filtr) + + + def match(self, fields: Dict[str, str], filtr: str) -> bool: + for value in fields.values(): + if value.startswith(filtr): + return True + + return False + + +class FieldSubstringFilter(FieldStringFilter): + """Fuzzy-like alternative to `FieldStringFilter`. + + All words in the filter string must fully or partially match words in the + item field values, e.g. "red l" can match "red light", + "tired legs", "light red" (order of the filter words doesn't matter), + but not just "red" or "light" by themselves. + """ + + def match(self, fields: Dict[str, str], filtr: str) -> bool: + text = " ".join(fields.values()) + + for word in filtr.split(): if word and word not in text: return False diff --git a/src/backend/models/model_store.py b/src/backend/models/model_store.py index 4996af94..82f94c47 100644 --- a/src/backend/models/model_store.py +++ b/src/backend/models/model_store.py @@ -7,7 +7,8 @@ from typing import Dict from . import SyncId from .model import Model from .special_models import ( - AllRooms, FilteredMembers, FilteredHomeservers, MatchingAccounts, + AllRooms, AutoCompletedMembers, FilteredHomeservers, FilteredMembers, + MatchingAccounts, ) @@ -42,6 +43,8 @@ class ModelStore(UserDict): model = FilteredHomeservers() elif is_tuple and len(key) == 3 and key[2] == "filtered_members": model = FilteredMembers(user_id=key[0], room_id=key[1]) + elif is_tuple and len(key) == 3 and key[2] == "autocompleted_members": + model = AutoCompletedMembers(user_id=key[0], room_id=key[1]) else: model = Model(sync_id=key) # type: ignore diff --git a/src/backend/models/special_models.py b/src/backend/models/special_models.py index bfb8dda4..6b44916b 100644 --- a/src/backend/models/special_models.py +++ b/src/backend/models/special_models.py @@ -1,9 +1,9 @@ # SPDX-License-Identifier: LGPL-3.0-or-later from dataclasses import asdict -from typing import Set +from typing import Dict, Set -from .filters import FieldSubstringFilter, ModelFilter +from .filters import FieldStringFilter, FieldSubstringFilter, ModelFilter from .items import Account, AccountOrRoom, Room from .model import Model from .model_item import ModelItem @@ -117,6 +117,27 @@ class FilteredMembers(FieldSubstringFilter): return source.sync_id == (self.user_id, self.room_id, "members") +class AutoCompletedMembers(FieldStringFilter): + """Filtered list of mentionable members for tab-completion.""" + + def __init__(self, user_id: str, room_id: str) -> None: + self.user_id = user_id + self.room_id = room_id + sync_id = (user_id, room_id, "autocompleted_members") + + super().__init__(sync_id=sync_id, fields=("display_name", "id")) + self.no_filter_accept_all_items = False + + + def accept_source(self, source: Model) -> bool: + return source.sync_id == (self.user_id, self.room_id, "members") + + + def match(self, fields: Dict[str, str], filtr: str) -> bool: + fields["id"] = fields["id"][1:] # remove leading @ + return super().match(fields, filtr) + + class FilteredHomeservers(FieldSubstringFilter): """Filtered list of public Matrix homeservers.""" diff --git a/src/gui/MainPane/RoomList.qml b/src/gui/MainPane/RoomList.qml index 5f97dc69..602d522b 100644 --- a/src/gui/MainPane/RoomList.qml +++ b/src/gui/MainPane/RoomList.qml @@ -175,7 +175,7 @@ HListView { } onFilterChanged: { - py.callCoro("set_substring_filter", ["all_rooms", filter], () => { + py.callCoro("set_string_filter", ["all_rooms", filter], () => { if (filter) { currentIndex = 1 // highlight the first matching room return diff --git a/src/gui/Pages/AddAccount/ServerBrowser.qml b/src/gui/Pages/AddAccount/ServerBrowser.qml index 4e9fdc79..71cc180c 100644 --- a/src/gui/Pages/AddAccount/ServerBrowser.qml +++ b/src/gui/Pages/AddAccount/ServerBrowser.qml @@ -134,7 +134,7 @@ HBox { onTextEdited: { py.callCoro( - "set_substring_filter", ["filtered_homeservers", text], + "set_string_filter", ["filtered_homeservers", text], ) serverList.currentIndex = -1 } diff --git a/src/gui/Pages/Chat/Composer/Composer.qml b/src/gui/Pages/Chat/Composer/Composer.qml index 4fb5b52a..84c437f6 100644 --- a/src/gui/Pages/Chat/Composer/Composer.qml +++ b/src/gui/Pages/Chat/Composer/Composer.qml @@ -11,39 +11,60 @@ Rectangle { function takeFocus() { messageArea.forceActiveFocus() } - implicitHeight: - Math.max(theme.baseElementsHeight, messageArea.implicitHeight) - + implicitHeight: Math.max(theme.baseElementsHeight, column.implicitHeight) color: theme.chat.composer.background - HRowLayout { + HColumnLayout { + id: column anchors.fill: parent - HUserAvatar { - id: avatar - radius: 0 - userId: messageArea.writingUserId + UserAutoCompletion { + id: userCompletion + textArea: messageArea - mxc: - messageArea.writingUserInfo ? - messageArea.writingUserInfo.avatar_url : - "" - - displayName: - messageArea.writingUserInfo ? - messageArea.writingUserInfo.display_name : - "" - } - - HScrollView { - Layout.fillHeight: true Layout.fillWidth: true - - MessageArea { id: messageArea } + Layout.maximumHeight: chat.height / 3 } - UploadButton { - Layout.fillHeight: true + HRowLayout { + HUserAvatar { + id: avatar + radius: 0 + userId: messageArea.writingUserId + + mxc: + messageArea.writingUserInfo ? + messageArea.writingUserInfo.avatar_url : + "" + + displayName: + messageArea.writingUserInfo ? + messageArea.writingUserInfo.display_name : + "" + } + + HScrollView { + Layout.fillHeight: true + Layout.fillWidth: true + + MessageArea { + id: messageArea + autoCompletionOpen: userCompletion.open + + onAutoCompletePrevious: userCompletion.previous() + onAutoCompleteNext: userCompletion.next() + onCancelAutoCompletion: userCompletion.cancel() + onExtraCharacterCloseAutoCompletion: + ! userCompletion.autoOpen || + userCompletion.autoOpenCompleted ? + userCompletion.open = false : + null + } + } + + UploadButton { + Layout.fillHeight: true + } } } } diff --git a/src/gui/Pages/Chat/Composer/MessageArea.qml b/src/gui/Pages/Chat/Composer/MessageArea.qml index 39675241..d27f65fd 100644 --- a/src/gui/Pages/Chat/Composer/MessageArea.qml +++ b/src/gui/Pages/Chat/Composer/MessageArea.qml @@ -6,9 +6,10 @@ import "../../.." import "../../../Base" HTextArea { - id: textArea + id: area property HListView eventList + property bool autoCompletionOpen property string indent: " " @@ -20,12 +21,12 @@ HTextArea { ModelStore.get("accounts").find(writingUserId) readonly property int cursorY: - textArea.text.substring(0, cursorPosition).split("\n").length - 1 + area.text.substring(0, cursorPosition).split("\n").length - 1 readonly property int cursorX: cursorPosition - lines.slice(0, cursorY).join("").length - cursorY - readonly property var lines: textArea.text.split("\n") + readonly property var lines: area.text.split("\n") readonly property string lineText: lines[cursorY] || "" readonly property string lineTextUntilCursor: @@ -50,8 +51,13 @@ HTextArea { return obj } + signal autoCompletePrevious() + signal autoCompleteNext() + signal extraCharacterCloseAutoCompletion() + signal cancelAutoCompletion() + function setTyping(typing) { - if (! textArea.enabled) return + if (! area.enabled) return py.callClientCoro( writingUserId, "room_typing", [chat.roomId, typing, 5000], @@ -76,7 +82,7 @@ HTextArea { } const add = indent.repeat(indents) - textArea.insertAtCursor("\n" + add) + area.insertAtCursor("\n" + add) } function sendText() { @@ -85,7 +91,7 @@ HTextArea { const args = [chat.roomId, toSend, chat.replyToEventId] py.callClientCoro(writingUserId, "send_text", args) - textArea.clear() + area.clear() clearReplyTo() } @@ -174,9 +180,11 @@ HTextArea { }, ) - Keys.onEscapePressed: clearReplyTo() + Keys.onEscapePressed: + autoCompletionOpen ? cancelAutoCompletion() : clearReplyTo() Keys.onReturnPressed: ev => { + extraCharacterCloseAutoCompletion() ev.accepted = true ev.modifiers & Qt.ShiftModifier || @@ -189,18 +197,43 @@ HTextArea { Keys.onEnterPressed: ev => Keys.returnPressed(ev) Keys.onMenuPressed: ev => { + extraCharacterCloseAutoCompletion() + if (eventList && eventList.currentItem) eventList.currentItem.openContextMenu() } + Keys.onBacktabPressed: ev => { + ev.accepted = true + autoCompletePrevious() + } + Keys.onTabPressed: ev => { ev.accepted = true - textArea.insertAtCursor(indent) + + if (text.slice(-1).trim()) { // previous char isn't a space/tab + autoCompleteNext() + return + } + + area.insertAtCursor(indent) + } + + Keys.onUpPressed: ev => { + ev.accepted = autoCompletionOpen + if (autoCompletionOpen) autoCompletePrevious() + } + + Keys.onDownPressed: ev => { + ev.accepted = autoCompletionOpen + if (autoCompletionOpen) autoCompleteNext() } Keys.onPressed: ev => { + if (ev.text) extraCharacterCloseAutoCompletion() + if (ev.matches(StandardKey.Copy) && - ! textArea.selectedText && + ! area.selectedText && eventList && (eventList.selectedCount || eventList.currentIndex !== -1)) { @@ -212,10 +245,10 @@ HTextArea { // FIXME: buggy // if (ev.modifiers === Qt.NoModifier && // ev.key === Qt.Key_Backspace && - // ! textArea.selectedText) + // ! area.selectedText) // { // ev.accepted = true - // textArea.remove( + // area.remove( // cursorPosition - deleteCharsOnBackspace, // cursorPosition // ) diff --git a/src/gui/Pages/Chat/Composer/UserAutoCompletion.qml b/src/gui/Pages/Chat/Composer/UserAutoCompletion.qml new file mode 100644 index 00000000..a9aadb80 --- /dev/null +++ b/src/gui/Pages/Chat/Composer/UserAutoCompletion.qml @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: LGPL-3.0-or-later + +import QtQuick 2.12 +import QtQuick.Layouts 1.12 +import "../../.." +import "../../../Base" +import "../../../Base/HTile" + +HListView { + id: listView + + property HTextArea textArea + property bool open: false + + property string originalText: "" + property bool autoOpenCompleted: false + + readonly property bool autoOpen: + autoOpenCompleted || textArea.text.match(/.*(^|\W)@[^\s]+$/) + + readonly property string wordToComplete: + open ? + (originalText || textArea.text).split(/\s/).slice(-1)[0].replace( + autoOpen ? /^@/ : "", "", + ) : + "" + + function replaceLastWord(withText) { + const lastWordStart = /(?:^|\s)[^\s]+$/.exec(textArea.text).index + const isTextStart = + lastWordStart === 0 && ! textArea.text[0].match(/\s/) + + textArea.remove(lastWordStart + (isTextStart ? 0 : 1), textArea.length) + textArea.insertAtCursor(withText) + } + + function previous() { + if (open) { + decrementCurrentIndex() + return + } + + open = true + const args = [model.modelId, wordToComplete] + py.callCoro("set_string_filter", args, decrementCurrentIndex) + } + + function next() { + if (open) { + incrementCurrentIndex() + return + } + + open = true + const args = [model.modelId, wordToComplete] + py.callCoro("set_string_filter", args, incrementCurrentIndex) + } + + function cancel() { + if (originalText) + replaceLastWord(originalText.split(/\s/).splice(-1)[0]) + + open = false + } + + + visible: opacity > 0 + opacity: open && count ? 1 : 0 + implicitHeight: open && count ? Math.min(window.height, contentHeight) : 0 + model: ModelStore.get(chat.userId, chat.roomId, "autocompleted_members") + + delegate: HTile { + width: listView.width + contentItem: HLabel { text: model.display_name + " (" + model.id + ")"} + onClicked: { + currentIndex = model.index + listView.open = false + } + } + + onCountChanged: if (! count && open) open = false + onAutoOpenChanged: open = autoOpen + onOpenChanged: if (! open) { + originalText = "" + currentIndex = -1 + autoOpenCompleted = false + py.callCoro("set_string_filter", [model.modelId, ""]) + } + + onWordToCompleteChanged: { + if (! open) return + py.callCoro("set_string_filter", [model.modelId, wordToComplete]) + } + + onCurrentIndexChanged: { + if (currentIndex === -1) return + if (! originalText) originalText = textArea.text + if (autoOpen) autoOpenCompleted = true + + replaceLastWord(model.get(currentIndex).display_name) + } + + Behavior on opacity { HNumberAnimation {} } + Behavior on implicitHeight { HNumberAnimation {} } +} diff --git a/src/gui/Pages/Chat/RoomPane/MemberView/MemberView.qml b/src/gui/Pages/Chat/RoomPane/MemberView/MemberView.qml index d5db6b1f..c88334af 100644 --- a/src/gui/Pages/Chat/RoomPane/MemberView/MemberView.qml +++ b/src/gui/Pages/Chat/RoomPane/MemberView/MemberView.qml @@ -94,7 +94,7 @@ HColumnLayout { onTextChanged: { stackView.pop(stackView.initialItem) - py.callCoro("set_substring_filter", [modelSyncId, text]) + py.callCoro("set_string_filter", [modelSyncId, text]) } onActiveFocusChanged: {