summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
authorKonstantinos Sideris <sideris.konstantin@gmail.com>2018-03-24 23:16:15 +0200
committerKonstantinos Sideris <sideris.konstantin@gmail.com>2018-03-24 23:16:15 +0200
commit553a97c8bb5042fbef0487255af52a4a6793d0fd (patch)
tree637a38c762fa6847c16cc9245a795c69314941f6 /src
parentAdjust version number for the windows build (diff)
downloadnheko-553a97c8bb5042fbef0487255af52a4a6793d0fd.tar.xz
Add basic support for username auto-completion
fixes #40
Diffstat (limited to 'src')
-rw-r--r--src/ChatPage.cc11
-rw-r--r--src/SuggestionsPopup.cpp105
-rw-r--r--src/TextInputWidget.cc139
-rw-r--r--src/Utils.cc28
4 files changed, 283 insertions, 0 deletions
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()); +}