diff --git a/CMakeLists.txt b/CMakeLists.txt
index 5683fb0d..020049e1 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -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
diff --git a/include/Config.h b/include/Config.h
index 54b2aa61..d7021d92 100644
--- a/include/Config.h
+++ b/include/Config.h
@@ -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;
}
diff --git a/include/SuggestionsPopup.hpp b/include/SuggestionsPopup.hpp
new file mode 100644
index 00000000..23549124
--- /dev/null
+++ b/include/SuggestionsPopup.hpp
@@ -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_;
+};
diff --git a/include/TextInputWidget.h b/include/TextInputWidget.h
index 872773f1..95262722 100644
--- a/include/TextInputWidget.h
+++ b/include/TextInputWidget.h
@@ -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_;
};
diff --git a/include/Utils.h b/include/Utils.h
index fba9bf67..cbecb4ac 100644
--- a/include/Utils.h
+++ b/include/Utils.h
@@ -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);
}
diff --git a/resources/styles/nheko-dark.qss b/resources/styles/nheko-dark.qss
index 034728f3..61643710 100644
--- a/resources/styles/nheko-dark.qss
+++ b/resources/styles/nheko-dark.qss
@@ -22,6 +22,11 @@ QuickSwitcher {
background-color: #202228;
}
+PopupItem {
+ background-color: #202228;
+ qproperty-hoverColor: rgba(45, 49, 57, 120);
+}
+
RoomList,
RoomList > * {
background-color: #2d3139;
diff --git a/resources/styles/nheko.qss b/resources/styles/nheko.qss
index e18704b5..b028c7d6 100644
--- a/resources/styles/nheko.qss
+++ b/resources/styles/nheko.qss
@@ -22,6 +22,11 @@ QuickSwitcher {
background-color: white;
}
+PopupItem {
+ background-color: white;
+ qproperty-hoverColor: rgba(192, 193, 195, 120);
+}
+
RoomList,
RoomList > * {
background-color: white;
diff --git a/resources/styles/system.qss b/resources/styles/system.qss
index 60b8865a..ce63f44e 100644
--- a/resources/styles/system.qss
+++ b/resources/styles/system.qss
@@ -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);
}
diff --git a/src/ChatPage.cc b/src/ChatPage.cc
index f2a3e269..b49fb6a2 100644
--- a/src/ChatPage.cc
+++ b/src/ChatPage.cc
@@ -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;
diff --git a/src/SuggestionsPopup.cpp b/src/SuggestionsPopup.cpp
new file mode 100644
index 00000000..3a7b3852
--- /dev/null
+++ b/src/SuggestionsPopup.cpp
@@ -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());
+}
diff --git a/src/TextInputWidget.cc b/src/TextInputWidget.cc
index 3f3d5cd9..e184d8b4 100644
--- a/src/TextInputWidget.cc
+++ b/src/TextInputWidget.cc
@@ -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,10 +72,44 @@ 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)
{
const bool isModifier = (event->modifiers() != Qt::NoModifier);
@@ -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);
diff --git a/src/Utils.cc b/src/Utils.cc
index 6f438c20..169be75e 100644
--- a/src/Utils.cc
+++ b/src/Utils.cc
@@ -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());
+}
|