/* * nheko Copyright (C) 2017 Konstantinos Sideris * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ #include #include #include #include #include #include #include #include #include #include #include #include #include "Cache.h" #include "ChatPage.h" #include "CompletionModelRoles.h" #include "CompletionProxyModel.h" #include "Logging.h" #include "TextInputWidget.h" #include "Utils.h" #include "emoji/EmojiSearchModel.h" #include "emoji/Provider.h" #include "ui/FlatButton.h" #include "ui/LoadingIndicator.h" #if defined(Q_OS_MAC) #include "emoji/MacHelper.h" #endif static constexpr size_t INPUT_HISTORY_SIZE = 127; static constexpr int MAX_TEXTINPUT_HEIGHT = 120; static constexpr int ButtonHeight = 22; FilteredTextEdit::FilteredTextEdit(QWidget *parent) : QTextEdit{parent} , history_index_{0} , suggestionsPopup_{parent} , previewDialog_{parent} { setFrameStyle(QFrame::NoFrame); connect(document()->documentLayout(), &QAbstractTextDocumentLayout::documentSizeChanged, this, &FilteredTextEdit::updateGeometry); connect(document()->documentLayout(), &QAbstractTextDocumentLayout::documentSizeChanged, this, [this]() { emit heightChanged(document()->size().toSize().height()); }); working_history_.push_back(""); connect(this, &QTextEdit::textChanged, this, &FilteredTextEdit::textChanged); setAcceptRichText(false); completer_ = new QCompleter(this); completer_->setWidget(this); auto model = new emoji::EmojiSearchModel(this); model->sort(0, Qt::AscendingOrder); completer_->setModel((emoji_completion_model_ = new CompletionProxyModel(model, this))); emoji_completion_model_->setFilterRole(CompletionModel::SearchRole); completer_->setModelSorting(QCompleter::UnsortedModel); completer_->popup()->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); completer_->popup()->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); connect(completer_, QOverload::of(&QCompleter::activated), [this](auto &index) { emoji_popup_open_ = false; auto text = index.data(CompletionModel::CompletionRole).toString(); insertCompletion(text); }); connect(this, &FilteredTextEdit::resultsRetrieved, this, &FilteredTextEdit::showResults); connect( &suggestionsPopup_, &SuggestionsPopup::itemSelected, this, [this](const QString &text) { suggestionsPopup_.hide(); auto cursor = textCursor(); const int end = cursor.position(); cursor.setPosition(atTriggerPosition_, QTextCursor::MoveAnchor); cursor.setPosition(end, QTextCursor::KeepAnchor); cursor.removeSelectedText(); cursor.insertText(text); }); // For cycling through the suggestions by hitting tab. connect(this, &FilteredTextEdit::selectNextSuggestion, &suggestionsPopup_, &SuggestionsPopup::selectNextSuggestion); connect(this, &FilteredTextEdit::selectPreviousSuggestion, &suggestionsPopup_, &SuggestionsPopup::selectPreviousSuggestion); connect(this, &FilteredTextEdit::selectHoveredSuggestion, this, [this]() { suggestionsPopup_.selectHoveredSuggestion(); }); previewDialog_.hide(); } void FilteredTextEdit::insertCompletion(QString completion) { // Paint the current word and replace it with 'completion' auto cur_text = textAfterPosition(trigger_pos_); auto tc = textCursor(); tc.movePosition(QTextCursor::Left, QTextCursor::MoveAnchor, cur_text.length()); tc.movePosition(QTextCursor::Right, QTextCursor::KeepAnchor, cur_text.length()); tc.insertText(completion); setTextCursor(tc); } void FilteredTextEdit::showResults(const std::vector &results) { QPoint pos; if (isAnchorValid()) { auto cursor = textCursor(); cursor.setPosition(atTriggerPosition_); pos = viewport()->mapToGlobal(cursorRect(cursor).topLeft()); } else { auto rect = cursorRect(); pos = viewport()->mapToGlobal(rect.topLeft()); } suggestionsPopup_.addUsers(results); suggestionsPopup_.move(pos.x(), pos.y() - suggestionsPopup_.height() - 10); suggestionsPopup_.show(); } void FilteredTextEdit::keyPressEvent(QKeyEvent *event) { const bool isModifier = (event->modifiers() != Qt::NoModifier); #if defined(Q_OS_MAC) if (event->modifiers() == (Qt::ControlModifier | Qt::MetaModifier) && event->key() == Qt::Key_Space) MacHelper::showEmojiWindow(); #endif if (event->modifiers() == Qt::ControlModifier && event->key() == Qt::Key_U) QTextEdit::setText(""); // calculate the new query if (textCursor().position() < atTriggerPosition_ || !isAnchorValid()) { resetAnchor(); closeSuggestions(); } if (suggestionsPopup_.isVisible()) { switch (event->key()) { case Qt::Key_Down: case Qt::Key_Tab: emit selectNextSuggestion(); return; case Qt::Key_Enter: case Qt::Key_Return: emit selectHoveredSuggestion(); return; case Qt::Key_Escape: closeSuggestions(); return; case Qt::Key_Up: case Qt::Key_Backtab: { emit selectPreviousSuggestion(); return; } default: break; } } if (emoji_popup_open_) { auto fake_key = (event->key() == Qt::Key_Backtab) ? Qt::Key_Up : Qt::Key_Down; switch (event->key()) { case Qt::Key_Backtab: case Qt::Key_Tab: { // Simulate up/down arrow press auto ev = new QKeyEvent(QEvent::KeyPress, fake_key, Qt::NoModifier); QCoreApplication::postEvent(completer_->popup(), ev); return; } default: break; } } switch (event->key()) { case Qt::Key_At: atTriggerPosition_ = textCursor().position(); anchorType_ = AnchorType::Sigil; QTextEdit::keyPressEvent(event); break; case Qt::Key_Tab: { auto cursor = textCursor(); const int initialPos = cursor.position(); cursor.movePosition(QTextCursor::StartOfWord, QTextCursor::KeepAnchor); auto word = cursor.selectedText(); const int startOfWord = cursor.position(); // There is a word to complete. if (initialPos != startOfWord) { atTriggerPosition_ = startOfWord; anchorType_ = AnchorType::Tab; emit showSuggestions(word); } else { QTextEdit::keyPressEvent(event); } break; } case Qt::Key_Colon: { QTextEdit::keyPressEvent(event); trigger_pos_ = textCursor().position() - 1; emoji_completion_model_->setFilterRegExp(""); emoji_popup_open_ = true; break; } case Qt::Key_Return: case Qt::Key_Enter: if (emoji_popup_open_) { if (!completer_->popup()->currentIndex().isValid()) { // No completion to select, do normal behavior completer_->popup()->hide(); emoji_popup_open_ = false; } else { event->ignore(); return; } } if (!(event->modifiers() & Qt::ShiftModifier)) { submit(); } else { QTextEdit::keyPressEvent(event); } break; case Qt::Key_Up: { auto initial_cursor = textCursor(); QTextEdit::keyPressEvent(event); if (textCursor() == initial_cursor && textCursor().atStart() && history_index_ + 1 < working_history_.size()) { ++history_index_; setPlainText(working_history_[history_index_]); moveCursor(QTextCursor::End); } else if (textCursor() == initial_cursor) { // Move to the start of the text if there aren't any lines to move up to. initial_cursor.movePosition(QTextCursor::Start, QTextCursor::MoveAnchor, 1); setTextCursor(initial_cursor); } break; } case Qt::Key_Down: { auto initial_cursor = textCursor(); QTextEdit::keyPressEvent(event); if (textCursor() == initial_cursor && textCursor().atEnd() && history_index_ > 0) { --history_index_; setPlainText(working_history_[history_index_]); moveCursor(QTextCursor::End); } else if (textCursor() == initial_cursor) { // Move to the end of the text if there aren't any lines to move down to. initial_cursor.movePosition(QTextCursor::End, QTextCursor::MoveAnchor, 1); setTextCursor(initial_cursor); } break; } default: QTextEdit::keyPressEvent(event); if (isModifier) return; if (emoji_popup_open_ && textAfterPosition(trigger_pos_).length() > 2) { // Update completion // Don't include the trigger token in the search emoji_completion_model_->setFilterWildcard( textAfterPosition(trigger_pos_).remove(0, 1)); completer_->complete(completerRect()); } if (emoji_popup_open_ && (completer_->completionCount() < 1 || !textAfterPosition(trigger_pos_) .contains(QRegularExpression(":[^\r\n\t\f\v :]+$")))) { // No completions for this word or another word than the completer was // started with emoji_popup_open_ = false; completer_->popup()->hide(); } if (textCursor().position() == 0) { resetAnchor(); closeSuggestions(); return; } // Check if the current word should be autocompleted. auto cursor = textCursor(); cursor.movePosition(QTextCursor::StartOfWord, QTextCursor::KeepAnchor); auto word = cursor.selectedText(); if (hasAnchor(cursor.position(), anchorType_) && isAnchorValid()) { if (word.isEmpty()) { closeSuggestions(); return; } emit showSuggestions(word); } else { resetAnchor(); closeSuggestions(); } break; } } QRect FilteredTextEdit::completerRect() { // Move left edge to the beginning of the word auto cursor = textCursor(); auto rect = cursorRect(); cursor.movePosition( QTextCursor::Left, QTextCursor::MoveAnchor, textAfterPosition(trigger_pos_).length()); auto cursor_global_x = viewport()->mapToGlobal(cursorRect(cursor).topLeft()).x(); auto rect_global_left = viewport()->mapToGlobal(rect.bottomLeft()).x(); auto dx = qAbs(rect_global_left - cursor_global_x); rect.moveLeft(rect.left() - dx); auto item_height = completer_->popup()->sizeHintForRow(0); auto max_height = item_height * completer_->maxVisibleItems(); auto height = (completer_->completionCount() > completer_->maxVisibleItems()) ? max_height : completer_->completionCount() * item_height; rect.setWidth(completer_->popup()->sizeHintForColumn(0)); rect.moveBottom(-height); return rect; } QSize FilteredTextEdit::sizeHint() const { ensurePolished(); auto margins = viewportMargins(); margins += document()->documentMargin(); QSize size = document()->size().toSize(); size.rwidth() += margins.left() + margins.right(); size.rheight() += margins.top() + margins.bottom(); return size; } QSize FilteredTextEdit::minimumSizeHint() const { ensurePolished(); auto margins = viewportMargins(); margins += document()->documentMargin(); margins += contentsMargins(); QSize size(fontMetrics().averageCharWidth() * 10, fontMetrics().lineSpacing() + margins.top() + margins.bottom()); return size; } void FilteredTextEdit::submit() {} void FilteredTextEdit::textChanged() { working_history_[history_index_] = toPlainText(); } TextInputWidget::TextInputWidget(QWidget *parent) : QWidget(parent) { QFont f; f.setPointSizeF(f.pointSizeF()); const int fontHeight = QFontMetrics(f).height(); const int contentHeight = static_cast(fontHeight * 2.5); const int InputHeight = static_cast(fontHeight * 1.5); setFixedHeight(contentHeight); setCursor(Qt::ArrowCursor); topLayout_ = new QHBoxLayout(); topLayout_->setSpacing(0); topLayout_->setContentsMargins(13, 1, 13, 0); QIcon send_file_icon; send_file_icon.addFile(":/icons/icons/ui/paper-clip-outline.png"); sendFileBtn_ = new FlatButton(this); sendFileBtn_->setToolTip(tr("Send a file")); sendFileBtn_->setIcon(send_file_icon); sendFileBtn_->setIconSize(QSize(ButtonHeight, ButtonHeight)); spinner_ = new LoadingIndicator(this); spinner_->setFixedHeight(InputHeight); spinner_->setFixedWidth(InputHeight); spinner_->setObjectName("FileUploadSpinner"); spinner_->hide(); input_ = new FilteredTextEdit(this); input_->setFixedHeight(InputHeight); input_->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); input_->setPlaceholderText(tr("Write a message...")); connect(input_, &FilteredTextEdit::heightChanged, this, [this, InputHeight, contentHeight](int height) { int widgetHeight = std::min(MAX_TEXTINPUT_HEIGHT, std::max(height, contentHeight)); int textInputHeight = std::min(widgetHeight - 1, std::max(height, InputHeight)); setFixedHeight(widgetHeight); input_->setFixedHeight(textInputHeight); emit heightChanged(widgetHeight); }); connect(input_, &FilteredTextEdit::showSuggestions, this, [this](const QString &q) { if (q.isEmpty()) return; QtConcurrent::run([this, q = q.toLower().toStdString()]() { try { emit input_->resultsRetrieved(cache::searchUsers( ChatPage::instance()->currentRoom().toStdString(), q)); } catch (const lmdb::error &e) { nhlog::db()->error("Suggestion retrieval failed: {}", e.what()); } }); }); sendMessageBtn_ = new FlatButton(this); sendMessageBtn_->setToolTip(tr("Send a message")); QIcon send_message_icon; send_message_icon.addFile(":/icons/icons/ui/cursor.png"); sendMessageBtn_->setIcon(send_message_icon); sendMessageBtn_->setIconSize(QSize(ButtonHeight, ButtonHeight)); topLayout_->addWidget(sendFileBtn_); topLayout_->addWidget(input_); topLayout_->addWidget(sendMessageBtn_); setLayout(topLayout_); connect(sendMessageBtn_, &FlatButton::clicked, input_, &FilteredTextEdit::submit); connect(sendFileBtn_, SIGNAL(clicked()), this, SLOT(openFileSelection())); } void TextInputWidget::focusInEvent(QFocusEvent *event) { input_->setFocus(event->reason()); } void TextInputWidget::paintEvent(QPaintEvent *) { QStyleOption opt; opt.init(this); QPainter p(this); style()->drawPrimitive(QStyle::PE_Widget, &opt, &p, this); }