Add basic support for username auto-completion

fixes #40
This commit is contained in:
Konstantinos Sideris 2018-03-24 23:16:15 +02:00
parent 2054aad975
commit 553a97c8bb
12 changed files with 412 additions and 0 deletions

View File

@ -200,6 +200,7 @@ set(SRC_FILES
src/RunGuard.cc
src/SideBarActions.cc
src/Splitter.cc
src/SuggestionsPopup.cpp
src/TextInputWidget.cc
src/TopRoomBar.cc
src/TrayIcon.cc
@ -296,6 +297,7 @@ qt5_wrap_cpp(MOC_HEADERS
include/RoomList.h
include/SideBarActions.h
include/Splitter.h
include/SuggestionsPopup.hpp
include/TextInputWidget.h
include/TopRoomBar.h
include/TrayIcon.h

View File

@ -15,6 +15,11 @@ static constexpr int emojiSize = 14;
static constexpr int headerFontSize = 21;
static constexpr int typingNotificationFontSize = 11;
namespace popup {
static constexpr int font = fontSize;
static constexpr int avatar = 28;
}
namespace receipts {
static constexpr int font = 12;
}

View File

@ -0,0 +1,63 @@
#pragma once
#include <QHBoxLayout>
#include <QLabel>
#include <QPoint>
#include <QWidget>
class Avatar;
struct SearchResult
{
QString user_id;
QString display_name;
};
Q_DECLARE_METATYPE(SearchResult)
Q_DECLARE_METATYPE(QVector<SearchResult>)
class PopupItem : public QWidget
{
Q_OBJECT
Q_PROPERTY(QColor hoverColor READ hoverColor WRITE setHoverColor)
public:
PopupItem(QWidget *parent, const QString &user_id);
QColor hoverColor() const { return hoverColor_; }
void setHoverColor(QColor &color) { hoverColor_ = color; }
protected:
void paintEvent(QPaintEvent *event) override;
void mousePressEvent(QMouseEvent *event) override;
signals:
void clicked(const QString &display_name);
private:
QHBoxLayout *topLayout_;
Avatar *avatar_;
QLabel *userName_;
QString user_id_;
QColor hoverColor_;
};
class SuggestionsPopup : public QWidget
{
Q_OBJECT
public:
explicit SuggestionsPopup(QWidget *parent = nullptr);
public slots:
void addUsers(const QVector<SearchResult> &users);
signals:
void itemSelected(const QString &user);
private:
QVBoxLayout *layout_;
};

View File

@ -18,7 +18,11 @@
#pragma once
#include <deque>
#include <iterator>
#include <map>
#include <QApplication>
#include <QDebug>
#include <QHBoxLayout>
#include <QPaintEvent>
#include <QTextEdit>
@ -26,15 +30,20 @@
#include "FlatButton.h"
#include "LoadingIndicator.h"
#include "SuggestionsPopup.hpp"
#include "dialogs/PreviewUploadOverlay.h"
#include "emoji/PickButton.h"
class RoomState;
namespace dialogs {
class PreviewUploadOverlay;
}
struct SearchResult;
class FilteredTextEdit : public QTextEdit
{
Q_OBJECT
@ -61,18 +70,45 @@ signals:
void video(QSharedPointer<QIODevice> data, const QString &filename);
void file(QSharedPointer<QIODevice> data, const QString &filename);
//! Trigger the suggestion popup.
void showSuggestions(const QString &query);
void resultsRetrieved(const QVector<SearchResult> &results);
public slots:
void showResults(const QVector<SearchResult> &results);
protected:
void keyPressEvent(QKeyEvent *event) override;
bool canInsertFromMimeData(const QMimeData *source) const override;
void insertFromMimeData(const QMimeData *source) override;
void focusOutEvent(QFocusEvent *event) override
{
popup_.hide();
QWidget::focusOutEvent(event);
}
private:
std::deque<QString> true_history_, working_history_;
size_t history_index_;
QTimer *typingTimer_;
SuggestionsPopup popup_;
void closeSuggestions() { popup_.hide(); }
void resetAnchor() { atTriggerPosition_ = -1; }
QString query()
{
auto cursor = textCursor();
cursor.movePosition(QTextCursor::StartOfWord, QTextCursor::KeepAnchor);
return cursor.selectedText();
}
dialogs::PreviewUploadOverlay previewDialog_;
//! Latest position of the '@' character that triggers the username completer.
int atTriggerPosition_ = -1;
void textChanged();
void uploadData(const QByteArray data, const QString &media, const QString &filename);
void afterCompletion(int);
@ -97,6 +133,7 @@ public slots:
void openFileSelection();
void hideUploadSpinner();
void focusLineEdit() { input_->setFocus(); }
void setRoomState(QSharedPointer<RoomState> state) { currState_ = state; }
private slots:
void addSelectedEmoji(const QString &emoji);
@ -132,5 +169,8 @@ private:
FlatButton *sendMessageBtn_;
emoji::PickButton *emojiBtn_;
//! State of the current room.
QSharedPointer<RoomState> currState_;
QColor borderColor_;
};

View File

@ -54,4 +54,8 @@ scaleDown(uint64_t max_width, uint64_t max_height, const ImageType &source)
return source.scaled(
final_width, final_height, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
}
//! Calculate the Levenshtein distance between two strings with character skipping.
int
levenshtein_distance(const std::string &s1, const std::string &s2);
}

View File

@ -22,6 +22,11 @@ QuickSwitcher {
background-color: #202228;
}
PopupItem {
background-color: #202228;
qproperty-hoverColor: rgba(45, 49, 57, 120);
}
RoomList,
RoomList > * {
background-color: #2d3139;

View File

@ -22,6 +22,11 @@ QuickSwitcher {
background-color: white;
}
PopupItem {
background-color: white;
qproperty-hoverColor: rgba(192, 193, 195, 120);
}
RoomList,
RoomList > * {
background-color: white;

View File

@ -25,6 +25,11 @@ QuickSwitcher {
background-color: palette(window);
}
PopupItem {
background-color: palette(window);
qproperty-hoverColor: rgba(192, 193, 195, 120);
}
FlatButton {
qproperty-foregroundColor: palette(text);
}

View File

@ -158,6 +158,12 @@ ChatPage::ChatPage(QSharedPointer<MatrixClient> client,
typingDisplay_->setUsers(users);
});
connect(room_list_, &RoomList::roomChanged, text_input_, &TextInputWidget::stopTyping);
connect(room_list_, &RoomList::roomChanged, text_input_, [this](const QString &room_id) {
if (roomStates_.find(room_id) != roomStates_.end())
text_input_->setRoomState(roomStates_[room_id]);
else
qWarning() << "no state found for room_id" << room_id;
});
connect(room_list_, &RoomList::roomChanged, this, &ChatPage::changeTopRoomInfo);
connect(room_list_, &RoomList::roomChanged, text_input_, &TextInputWidget::focusLineEdit);
@ -781,6 +787,11 @@ ChatPage::updateTypingUsers(const QString &roomid, const std::vector<std::string
if (!userSettings_->isTypingNotificationsEnabled())
return;
if (user_ids.empty()) {
typingUsers_[roomid] = {};
return;
}
QStringList users;
QSettings settings;

105
src/SuggestionsPopup.cpp Normal file
View File

@ -0,0 +1,105 @@
#include "Avatar.h"
#include "AvatarProvider.h"
#include "Config.h"
#include "DropShadow.h"
#include "SuggestionsPopup.hpp"
#include "Utils.h"
#include "timeline/TimelineViewManager.h"
#include <QDebug>
#include <QPaintEvent>
#include <QPainter>
#include <QStyleOption>
constexpr int PopupHMargin = 5;
constexpr int PopupItemMargin = 4;
PopupItem::PopupItem(QWidget *parent, const QString &user_id)
: QWidget(parent)
, avatar_{new Avatar(this)}
, user_id_{user_id}
{
setMouseTracking(true);
setAttribute(Qt::WA_Hover);
topLayout_ = new QHBoxLayout(this);
topLayout_->setContentsMargins(
PopupHMargin, PopupItemMargin, PopupHMargin, PopupItemMargin);
QFont font;
font.setPixelSize(conf::popup::font);
auto displayName = TimelineViewManager::displayName(user_id);
avatar_->setSize(conf::popup::avatar);
avatar_->setLetter(utils::firstChar(displayName));
// If it's a matrix id we use the second letter.
if (displayName.size() > 1 && displayName.at(0) == '@')
avatar_->setLetter(QChar(displayName.at(1)));
userName_ = new QLabel(displayName, this);
userName_->setFont(font);
topLayout_->addWidget(avatar_);
topLayout_->addWidget(userName_, 1);
/* AvatarProvider::resolve(user_id, [this](const QImage &img) { avatar_->setImage(img); });
*/
}
void
PopupItem::paintEvent(QPaintEvent *)
{
QStyleOption opt;
opt.init(this);
QPainter p(this);
style()->drawPrimitive(QStyle::PE_Widget, &opt, &p, this);
if (underMouse())
p.fillRect(rect(), hoverColor_);
}
void
PopupItem::mousePressEvent(QMouseEvent *event)
{
if (event->buttons() != Qt::RightButton)
emit clicked(TimelineViewManager::displayName(user_id_));
QWidget::mousePressEvent(event);
}
SuggestionsPopup::SuggestionsPopup(QWidget *parent)
: QWidget(parent)
{
setAttribute(Qt::WA_ShowWithoutActivating, true);
setWindowFlags(Qt::ToolTip | Qt::NoDropShadowWindowHint);
layout_ = new QVBoxLayout(this);
layout_->setMargin(0);
layout_->setSpacing(0);
}
void
SuggestionsPopup::addUsers(const QVector<SearchResult> &users)
{
// Remove all items from the layout.
QLayoutItem *item;
while ((item = layout_->takeAt(0)) != 0) {
delete item->widget();
delete item;
}
if (users.isEmpty()) {
hide();
return;
}
for (const auto &u : users) {
auto user = new PopupItem(this, u.user_id);
layout_->addWidget(user);
connect(user, &PopupItem::clicked, this, &SuggestionsPopup::itemSelected);
}
resize(geometry().width(), 40 * users.size());
}

View File

@ -15,6 +15,8 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
#include <thread>
#include <QAbstractTextDocumentLayout>
#include <QApplication>
#include <QBuffer>
@ -28,17 +30,23 @@
#include <QPainter>
#include <QStyleOption>
#include <variant.hpp>
#include "Config.h"
#include "RoomState.h"
#include "TextInputWidget.h"
#include "Utils.h"
static constexpr size_t INPUT_HISTORY_SIZE = 127;
static constexpr int MAX_TEXTINPUT_HEIGHT = 120;
static constexpr int InputHeight = 26;
static constexpr int ButtonHeight = 24;
static constexpr int MaxPopupItems = 5;
FilteredTextEdit::FilteredTextEdit(QWidget *parent)
: QTextEdit{parent}
, history_index_{0}
, popup_{parent}
, previewDialog_{parent}
{
setFrameStyle(QFrame::NoFrame);
@ -64,9 +72,43 @@ FilteredTextEdit::FilteredTextEdit(QWidget *parent)
this,
&FilteredTextEdit::uploadData);
qRegisterMetaType<SearchResult>();
qRegisterMetaType<QVector<SearchResult>>();
connect(this, &FilteredTextEdit::resultsRetrieved, this, &FilteredTextEdit::showResults);
connect(&popup_, &SuggestionsPopup::itemSelected, this, [this](const QString &text) {
popup_.hide();
auto cursor = textCursor();
const int end = cursor.position();
cursor.setPosition(atTriggerPosition_, QTextCursor::MoveAnchor);
cursor.setPosition(end, QTextCursor::KeepAnchor);
cursor.removeSelectedText();
cursor.insertText(text);
});
previewDialog_.hide();
}
void
FilteredTextEdit::showResults(const QVector<SearchResult> &results)
{
QPoint pos;
if (atTriggerPosition_ != -1) {
auto cursor = textCursor();
cursor.setPosition(atTriggerPosition_);
pos = viewport()->mapToGlobal(cursorRect(cursor).topLeft());
} else {
auto rect = cursorRect();
pos = viewport()->mapToGlobal(rect.topLeft());
}
popup_.addUsers(results);
popup_.move(pos.x(), pos.y() - popup_.height() - 10);
popup_.show();
}
void
FilteredTextEdit::keyPressEvent(QKeyEvent *event)
{
@ -79,7 +121,34 @@ FilteredTextEdit::keyPressEvent(QKeyEvent *event)
typingTimer_->start();
}
// calculate the new query
if (textCursor().position() < atTriggerPosition_ || atTriggerPosition_ == -1) {
resetAnchor();
closeSuggestions();
}
if (popup_.isVisible()) {
switch (event->key()) {
case Qt::Key_Enter:
case Qt::Key_Return:
case Qt::Key_Escape:
case Qt::Key_Tab:
case Qt::Key_Space:
case Qt::Key_Backtab: {
closeSuggestions();
break;
}
default:
break;
}
}
switch (event->key()) {
case Qt::Key_At:
atTriggerPosition_ = textCursor().position();
QTextEdit::keyPressEvent(event);
break;
case Qt::Key_Return:
case Qt::Key_Enter:
if (!(event->modifiers() & Qt::ShiftModifier)) {
@ -124,6 +193,30 @@ FilteredTextEdit::keyPressEvent(QKeyEvent *event)
}
default:
QTextEdit::keyPressEvent(event);
// Check if the current word should be autocompleted.
auto cursor = textCursor();
cursor.movePosition(QTextCursor::StartOfWord, QTextCursor::KeepAnchor);
auto word = cursor.selectedText();
if (cursor.position() == 0) {
closeSuggestions();
return;
}
if (cursor.position() == atTriggerPosition_ + 1) {
const auto q = query();
if (q.isEmpty()) {
closeSuggestions();
return;
}
emit showSuggestions(query());
} else {
closeSuggestions();
}
break;
}
}
@ -340,6 +433,52 @@ TextInputWidget::TextInputWidget(QWidget *parent)
setFixedHeight(widgetHeight);
input_->setFixedHeight(textInputHeight);
});
connect(input_, &FilteredTextEdit::showSuggestions, this, [this](const QString &q) {
if (q.isEmpty() || currState_.isNull())
return;
std::thread worker([this, q = q.toLower().toStdString()]() {
std::multimap<int, std::pair<std::string, std::string>> items;
auto get_name = [](auto membership) {
auto name = membership.second.content.display_name;
auto key = membership.first;
// Remove the leading '@' character.
if (name.empty()) {
key.erase(0, 1);
name = key;
}
return std::make_pair(key, name);
};
for (const auto &m : currState_->memberships) {
const auto user = get_name(m);
const int score = utils::levenshtein_distance(q, user.second);
items.emplace(score, user);
}
QVector<SearchResult> results;
auto end = items.begin();
if (items.size() >= MaxPopupItems)
std::advance(end, MaxPopupItems);
for (auto it = items.begin(); it != end; it++) {
const auto user = it->second;
results.push_back(
SearchResult{QString::fromStdString(user.first),
QString::fromStdString(user.second)});
}
emit input_->resultsRetrieved(results);
});
worker.detach();
});
sendMessageBtn_ = new FlatButton(this);

View File

@ -149,3 +149,31 @@ utils::humanReadableFileSize(uint64_t bytes)
return QString::number(size, 'g', 4) + ' ' + units[u];
}
int
utils::levenshtein_distance(const std::string &s1, const std::string &s2)
{
const int nlen = s1.size();
const int hlen = s2.size();
if (hlen == 0)
return -1;
if (nlen == 1)
return s2.find(s1);
std::vector<int> row1(hlen + 1, 0);
for (int i = 0; i < nlen; ++i) {
std::vector<int> row2(1, i + 1);
for (int j = 0; j < hlen; ++j) {
const int cost = s1[i] != s2[j];
row2.push_back(
std::min(row1[j + 1] + 1, std::min(row2[j] + 1, row1[j] + cost)));
}
row1.swap(row2);
}
return *std::min_element(row1.begin(), row1.end());
}