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
- 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

View File

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

View File

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

View File

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

View File

@ -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."""

View File

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

View File

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

View File

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

View File

@ -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
// )

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: {
stackView.pop(stackView.initialItem)
py.callCoro("set_substring_filter", [modelSyncId, text])
py.callCoro("set_string_filter", [modelSyncId, text])
}
onActiveFocusChanged: {