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 +#include +#include +#include + +class Avatar; + +struct SearchResult +{ + QString user_id; + QString display_name; +}; + +Q_DECLARE_METATYPE(SearchResult) +Q_DECLARE_METATYPE(QVector) + +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 &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 +#include +#include +#include +#include #include #include #include @@ -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 data, const QString &filename); void file(QSharedPointer data, const QString &filename); + //! Trigger the suggestion popup. + void showSuggestions(const QString &query); + void resultsRetrieved(const QVector &results); + +public slots: + void showResults(const QVector &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 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 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 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 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::vectorisTypingNotificationsEnabled()) 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 +#include +#include +#include + +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 &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 . */ +#include + #include #include #include @@ -28,17 +30,23 @@ #include #include +#include + #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(); + qRegisterMetaType>(); + 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 &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> 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 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 row1(hlen + 1, 0); + + for (int i = 0; i < nlen; ++i) { + std::vector 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()); +}