parent
2054aad975
commit
553a97c8bb
@ -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
|
||||
|
@ -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;
|
||||
}
|
||||
|
63
include/SuggestionsPopup.hpp
Normal file
63
include/SuggestionsPopup.hpp
Normal 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_;
|
||||
};
|
@ -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_;
|
||||
};
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -22,6 +22,11 @@ QuickSwitcher {
|
||||
background-color: #202228;
|
||||
}
|
||||
|
||||
PopupItem {
|
||||
background-color: #202228;
|
||||
qproperty-hoverColor: rgba(45, 49, 57, 120);
|
||||
}
|
||||
|
||||
RoomList,
|
||||
RoomList > * {
|
||||
background-color: #2d3139;
|
||||
|
@ -22,6 +22,11 @@ QuickSwitcher {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
PopupItem {
|
||||
background-color: white;
|
||||
qproperty-hoverColor: rgba(192, 193, 195, 120);
|
||||
}
|
||||
|
||||
RoomList,
|
||||
RoomList > * {
|
||||
background-color: white;
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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
105
src/SuggestionsPopup.cpp
Normal 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());
|
||||
}
|
@ -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);
|
||||
|
||||
|
28
src/Utils.cc
28
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());
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user