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());
+}
|