Add basic user autocompletion UI

This commit is contained in:
miruka 2020-08-20 12:21:47 -04:00
parent ec17d54923
commit 5ba669444d
11 changed files with 271 additions and 63 deletions

View File

@ -2,7 +2,7 @@
- refresh server list button - refresh server list button
- server list sorting method / explain what the % number is (stability) - 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 - cursor shape in HBox/HTabbedBox pages over fields
- login with account already added → infinite spinner in room list - login with account already added → infinite spinner in room list
- verify onKeyboardAccept/Cancel things respect button enabled state - verify onKeyboardAccept/Cancel things respect button enabled state

View File

@ -22,7 +22,7 @@ from .errors import MatrixError
from .matrix_client import MatrixClient from .matrix_client import MatrixClient
from .media_cache import MediaCache from .media_cache import MediaCache
from .models import SyncId from .models import SyncId
from .models.filters import FieldSubstringFilter from .models.filters import FieldStringFilter
from .models.items import Account, Event, Homeserver, PingStatus from .models.items import Account, Event, Homeserver, PingStatus
from .models.model import Model from .models.model import Model
from .models.model_store import ModelStore from .models.model_store import ModelStore
@ -458,8 +458,8 @@ class Backend:
return (settings, ui_state, history, theme) return (settings, ui_state, history, theme)
async def set_substring_filter(self, model_id: SyncId, value: str) -> None: async def set_string_filter(self, model_id: SyncId, value: str) -> None:
"""Set a FieldSubstringFilter model's filter property. """Set a FieldStringFilter (or derived class) model's filter property.
This should only be called from QML. This should only be called from QML.
""" """
@ -469,8 +469,8 @@ class Backend:
model = Model.proxies[model_id] model = Model.proxies[model_id]
if not isinstance(model, FieldSubstringFilter): if not isinstance(model, FieldStringFilter):
raise TypeError("model_id must point to a FieldSubstringFilter") raise TypeError("model_id must point to a FieldStringFilter")
model.filter = value model.filter = value

View File

@ -112,13 +112,12 @@ class ModelFilter(ModelProxy):
callback() callback()
class FieldSubstringFilter(ModelFilter): class FieldStringFilter(ModelFilter):
"""Filter source models based on if their fields matches a string. """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 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 items with a certain field (typically `display_name`) that starts with the
words of the text (can be partial, e.g. "red" matches "red" or "tired") entered text will be shown.
will be shown.
Matching is done using "smart case": insensitive if the filter text is Matching is done using "smart case": insensitive if the filter text is
all lowercase, sensitive otherwise. all lowercase, sensitive otherwise.
@ -128,6 +127,8 @@ class FieldSubstringFilter(ModelFilter):
self.fields: Collection[str] = fields self.fields: Collection[str] = fields
self._filter: str = "" self._filter: str = ""
self.no_filter_accept_all_items: bool = True
super().__init__(sync_id) super().__init__(sync_id)
@ -138,24 +139,48 @@ class FieldSubstringFilter(ModelFilter):
@filter.setter @filter.setter
def filter(self, value: str) -> None: def filter(self, value: str) -> None:
self._filter = value if value != self._filter:
self.refilter() self._filter = value
self.refilter()
def accept_item(self, item: "ModelItem") -> bool: def accept_item(self, item: "ModelItem") -> bool:
if not self.filter: if not self.filter:
return True return self.no_filter_accept_all_items
text = " ".join((getattr(item, f) for f in self.fields)) fields = {f: getattr(item, f) for f in self.fields}
filt = self.filter filtr = self.filter
filt_lower = filt.lower() lowercase = filtr.lower()
if filt_lower == filt: if lowercase == filtr:
# Consider case only if filter isn't all lowercase (smart case) # Consider case only if filter isn't all lowercase
filt = filt_lower filtr = lowercase
text = text.lower() 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: if word and word not in text:
return False return False

View File

@ -7,7 +7,8 @@ from typing import Dict
from . import SyncId from . import SyncId
from .model import Model from .model import Model
from .special_models import ( from .special_models import (
AllRooms, FilteredMembers, FilteredHomeservers, MatchingAccounts, AllRooms, AutoCompletedMembers, FilteredHomeservers, FilteredMembers,
MatchingAccounts,
) )
@ -42,6 +43,8 @@ class ModelStore(UserDict):
model = FilteredHomeservers() model = FilteredHomeservers()
elif is_tuple and len(key) == 3 and key[2] == "filtered_members": elif is_tuple and len(key) == 3 and key[2] == "filtered_members":
model = FilteredMembers(user_id=key[0], room_id=key[1]) 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: else:
model = Model(sync_id=key) # type: ignore model = Model(sync_id=key) # type: ignore

View File

@ -1,9 +1,9 @@
# SPDX-License-Identifier: LGPL-3.0-or-later # SPDX-License-Identifier: LGPL-3.0-or-later
from dataclasses import asdict 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 .items import Account, AccountOrRoom, Room
from .model import Model from .model import Model
from .model_item import ModelItem from .model_item import ModelItem
@ -117,6 +117,27 @@ class FilteredMembers(FieldSubstringFilter):
return source.sync_id == (self.user_id, self.room_id, "members") 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): class FilteredHomeservers(FieldSubstringFilter):
"""Filtered list of public Matrix homeservers.""" """Filtered list of public Matrix homeservers."""

View File

@ -175,7 +175,7 @@ HListView {
} }
onFilterChanged: { onFilterChanged: {
py.callCoro("set_substring_filter", ["all_rooms", filter], () => { py.callCoro("set_string_filter", ["all_rooms", filter], () => {
if (filter) { if (filter) {
currentIndex = 1 // highlight the first matching room currentIndex = 1 // highlight the first matching room
return return

View File

@ -134,7 +134,7 @@ HBox {
onTextEdited: { onTextEdited: {
py.callCoro( py.callCoro(
"set_substring_filter", ["filtered_homeservers", text], "set_string_filter", ["filtered_homeservers", text],
) )
serverList.currentIndex = -1 serverList.currentIndex = -1
} }

View File

@ -11,39 +11,60 @@ Rectangle {
function takeFocus() { messageArea.forceActiveFocus() } function takeFocus() { messageArea.forceActiveFocus() }
implicitHeight: implicitHeight: Math.max(theme.baseElementsHeight, column.implicitHeight)
Math.max(theme.baseElementsHeight, messageArea.implicitHeight)
color: theme.chat.composer.background color: theme.chat.composer.background
HRowLayout { HColumnLayout {
id: column
anchors.fill: parent anchors.fill: parent
HUserAvatar { UserAutoCompletion {
id: avatar id: userCompletion
radius: 0 textArea: messageArea
userId: messageArea.writingUserId
mxc:
messageArea.writingUserInfo ?
messageArea.writingUserInfo.avatar_url :
""
displayName:
messageArea.writingUserInfo ?
messageArea.writingUserInfo.display_name :
""
}
HScrollView {
Layout.fillHeight: true
Layout.fillWidth: true Layout.fillWidth: true
Layout.maximumHeight: chat.height / 3
MessageArea { id: messageArea }
} }
UploadButton { HRowLayout {
Layout.fillHeight: true 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
}
} }
} }
} }

View File

@ -6,9 +6,10 @@ import "../../.."
import "../../../Base" import "../../../Base"
HTextArea { HTextArea {
id: textArea id: area
property HListView eventList property HListView eventList
property bool autoCompletionOpen
property string indent: " " property string indent: " "
@ -20,12 +21,12 @@ HTextArea {
ModelStore.get("accounts").find(writingUserId) ModelStore.get("accounts").find(writingUserId)
readonly property int cursorY: 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: readonly property int cursorX:
cursorPosition - lines.slice(0, cursorY).join("").length - cursorY 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 lineText: lines[cursorY] || ""
readonly property string lineTextUntilCursor: readonly property string lineTextUntilCursor:
@ -50,8 +51,13 @@ HTextArea {
return obj return obj
} }
signal autoCompletePrevious()
signal autoCompleteNext()
signal extraCharacterCloseAutoCompletion()
signal cancelAutoCompletion()
function setTyping(typing) { function setTyping(typing) {
if (! textArea.enabled) return if (! area.enabled) return
py.callClientCoro( py.callClientCoro(
writingUserId, "room_typing", [chat.roomId, typing, 5000], writingUserId, "room_typing", [chat.roomId, typing, 5000],
@ -76,7 +82,7 @@ HTextArea {
} }
const add = indent.repeat(indents) const add = indent.repeat(indents)
textArea.insertAtCursor("\n" + add) area.insertAtCursor("\n" + add)
} }
function sendText() { function sendText() {
@ -85,7 +91,7 @@ HTextArea {
const args = [chat.roomId, toSend, chat.replyToEventId] const args = [chat.roomId, toSend, chat.replyToEventId]
py.callClientCoro(writingUserId, "send_text", args) py.callClientCoro(writingUserId, "send_text", args)
textArea.clear() area.clear()
clearReplyTo() clearReplyTo()
} }
@ -174,9 +180,11 @@ HTextArea {
}, },
) )
Keys.onEscapePressed: clearReplyTo() Keys.onEscapePressed:
autoCompletionOpen ? cancelAutoCompletion() : clearReplyTo()
Keys.onReturnPressed: ev => { Keys.onReturnPressed: ev => {
extraCharacterCloseAutoCompletion()
ev.accepted = true ev.accepted = true
ev.modifiers & Qt.ShiftModifier || ev.modifiers & Qt.ShiftModifier ||
@ -189,18 +197,43 @@ HTextArea {
Keys.onEnterPressed: ev => Keys.returnPressed(ev) Keys.onEnterPressed: ev => Keys.returnPressed(ev)
Keys.onMenuPressed: ev => { Keys.onMenuPressed: ev => {
extraCharacterCloseAutoCompletion()
if (eventList && eventList.currentItem) if (eventList && eventList.currentItem)
eventList.currentItem.openContextMenu() eventList.currentItem.openContextMenu()
} }
Keys.onBacktabPressed: ev => {
ev.accepted = true
autoCompletePrevious()
}
Keys.onTabPressed: ev => { Keys.onTabPressed: ev => {
ev.accepted = true 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 => { Keys.onPressed: ev => {
if (ev.text) extraCharacterCloseAutoCompletion()
if (ev.matches(StandardKey.Copy) && if (ev.matches(StandardKey.Copy) &&
! textArea.selectedText && ! area.selectedText &&
eventList && eventList &&
(eventList.selectedCount || eventList.currentIndex !== -1)) (eventList.selectedCount || eventList.currentIndex !== -1))
{ {
@ -212,10 +245,10 @@ HTextArea {
// FIXME: buggy // FIXME: buggy
// if (ev.modifiers === Qt.NoModifier && // if (ev.modifiers === Qt.NoModifier &&
// ev.key === Qt.Key_Backspace && // ev.key === Qt.Key_Backspace &&
// ! textArea.selectedText) // ! area.selectedText)
// { // {
// ev.accepted = true // ev.accepted = true
// textArea.remove( // area.remove(
// cursorPosition - deleteCharsOnBackspace, // cursorPosition - deleteCharsOnBackspace,
// cursorPosition // cursorPosition
// ) // )

View File

@ -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 {} }
}

View File

@ -94,7 +94,7 @@ HColumnLayout {
onTextChanged: { onTextChanged: {
stackView.pop(stackView.initialItem) stackView.pop(stackView.initialItem)
py.callCoro("set_substring_filter", [modelSyncId, text]) py.callCoro("set_string_filter", [modelSyncId, text])
} }
onActiveFocusChanged: { onActiveFocusChanged: {