File indexing completed on 2024-12-01 04:37:01

0001 /*
0002    SPDX-FileCopyrightText: 2020-2024 Laurent Montel <montel@kde.org>
0003 
0004    SPDX-License-Identifier: LGPL-2.0-or-later
0005 */
0006 
0007 #include "messagetextedit.h"
0008 #include "common/commandcompletiondelegate.h"
0009 #include "common/completionlistview.h"
0010 #include "common/emojicompletiondelegate.h"
0011 #include "common/userandchannelcompletiondelegate.h"
0012 #include "model/inputcompletermodel.h"
0013 #include "rocketchataccount.h"
0014 #include "ruqola.h"
0015 #include <KLocalizedString>
0016 
0017 #include <KConfigGroup>
0018 #include <KSharedConfig>
0019 #include <QAbstractTextDocumentLayout>
0020 #include <QApplication>
0021 #include <QClipboard>
0022 #include <QKeyEvent>
0023 #include <QMenu>
0024 #include <QMimeData>
0025 #include <QTextCursor>
0026 #include <QTextDocument>
0027 
0028 #include "config-ruqola.h"
0029 
0030 #if HAVE_TEXT_AUTOCORRECTION_WIDGETS
0031 #include "textautocorrectioncore/textautocorrectionsettings.h"
0032 #include <TextAutoCorrectionCore/AutoCorrection>
0033 #endif
0034 
0035 MessageTextEdit::MessageTextEdit(QWidget *parent)
0036     : KTextEdit(parent)
0037     , mUserAndChannelCompletionListView(new CompletionListView)
0038     , mEmojiCompletionListView(new CompletionListView)
0039     , mCommandCompletionListView(new CompletionListView)
0040 {
0041     setAcceptRichText(false);
0042     enableFindReplace(false); // not needed here, let's instead make sure the Ctrl+F shortcut will search through channel history
0043 
0044     connect(document()->documentLayout(), &QAbstractTextDocumentLayout::documentSizeChanged, this, &QWidget::updateGeometry);
0045 
0046     mUserAndChannelCompletionDelegate = new UserAndChannelCompletionDelegate(mUserAndChannelCompletionListView);
0047     mUserAndChannelCompletionListView->setItemDelegate(mUserAndChannelCompletionDelegate);
0048     mUserAndChannelCompletionListView->setTextWidget(this);
0049     connect(mUserAndChannelCompletionListView, &CompletionListView::complete, this, &MessageTextEdit::slotComplete);
0050 
0051     mEmojiCompletionListView->setItemDelegate(new EmojiCompletionDelegate(mEmojiCompletionListView));
0052     mEmojiCompletionListView->setTextWidget(this);
0053     connect(mEmojiCompletionListView, &CompletionListView::complete, this, &MessageTextEdit::slotComplete);
0054 
0055     mCommandCompletionListView->setItemDelegate(new CommandCompletionDelegate(mCommandCompletionListView));
0056     mCommandCompletionListView->setTextWidget(this);
0057     connect(mCommandCompletionListView, &CompletionListView::complete, this, &MessageTextEdit::slotComplete);
0058     loadSpellCheckingSettings();
0059     connect(this, &MessageTextEdit::languageChanged, this, &MessageTextEdit::slotLanguageChanged);
0060     connect(this, &MessageTextEdit::checkSpellingChanged, this, &MessageTextEdit::slotSpellCheckingEnableChanged);
0061 }
0062 
0063 MessageTextEdit::~MessageTextEdit()
0064 {
0065     delete mUserAndChannelCompletionListView;
0066     delete mEmojiCompletionListView;
0067     delete mCommandCompletionListView;
0068 }
0069 
0070 void MessageTextEdit::slotSpellCheckingEnableChanged(bool b)
0071 {
0072     KSharedConfig::Ptr config = KSharedConfig::openConfig();
0073     KConfigGroup group(config, QStringLiteral("Spelling"));
0074     group.writeEntry("checkerEnabledByDefault", b);
0075 }
0076 
0077 void MessageTextEdit::slotLanguageChanged(const QString &lang)
0078 {
0079     KSharedConfig::Ptr config = KSharedConfig::openConfig();
0080     KConfigGroup group(config, QStringLiteral("Spelling"));
0081     group.writeEntry("Language", lang);
0082     switchAutoCorrectionLanguage(lang);
0083 }
0084 
0085 void MessageTextEdit::switchAutoCorrectionLanguage(const QString &lang)
0086 {
0087 #if HAVE_TEXT_AUTOCORRECTION_WIDGETS
0088     if (!lang.isEmpty()) {
0089         auto settings = Ruqola::self()->autoCorrection()->autoCorrectionSettings();
0090         settings->setLanguage(lang);
0091         Ruqola::self()->autoCorrection()->setAutoCorrectionSettings(settings);
0092     }
0093 #endif
0094     qDebug() << " MessageTextEdit::switchAutoCorrectionLanguage " << lang;
0095 }
0096 
0097 void MessageTextEdit::loadSpellCheckingSettings()
0098 {
0099     KSharedConfig::Ptr config = KSharedConfig::openConfig();
0100     if (config->hasGroup(QLatin1String("Spelling"))) {
0101         KConfigGroup group(config, QStringLiteral("Spelling"));
0102         setCheckSpellingEnabled(group.readEntry("checkerEnabledByDefault", false));
0103         const QString language = group.readEntry("Language", QString());
0104         setSpellCheckingLanguage(language);
0105         switchAutoCorrectionLanguage(language);
0106     }
0107 }
0108 
0109 void MessageTextEdit::setCurrentRocketChatAccount(RocketChatAccount *account, bool threadMessageDialog)
0110 {
0111     if (mCurrentInputTextManager) {
0112         disconnect(mCurrentInputTextManager, &InputTextManager::completionTypeChanged, this, &MessageTextEdit::slotCompletionTypeChanged);
0113         disconnect(mCurrentInputTextManager, &InputTextManager::selectFirstTextCompleter, this, &MessageTextEdit::slotSelectFirstTextCompleter);
0114         disconnect(mCurrentRocketChatAccount, &RocketChatAccount::loginStatusChanged, this, &MessageTextEdit::slotLoginChanged);
0115     }
0116     mCurrentRocketChatAccount = account;
0117     mCurrentInputTextManager = threadMessageDialog ? mCurrentRocketChatAccount->inputThreadMessageTextManager() : mCurrentRocketChatAccount->inputTextManager();
0118     mUserAndChannelCompletionListView->setModel(mCurrentInputTextManager->inputCompleterModel());
0119     mEmojiCompletionListView->setModel(mCurrentInputTextManager->emojiCompleterModel());
0120     mCommandCompletionListView->setModel(mCurrentInputTextManager->commandModel());
0121     mUserAndChannelCompletionDelegate->setRocketChatAccount(mCurrentRocketChatAccount);
0122     connect(mCurrentInputTextManager, &InputTextManager::completionTypeChanged, this, &MessageTextEdit::slotCompletionTypeChanged);
0123     connect(mCurrentInputTextManager, &InputTextManager::selectFirstTextCompleter, this, &MessageTextEdit::slotSelectFirstTextCompleter);
0124     connect(mCurrentRocketChatAccount, &RocketChatAccount::loginStatusChanged, this, &MessageTextEdit::slotLoginChanged);
0125     connect(mCurrentRocketChatAccount, &RocketChatAccount::updateMessageFailed, this, &MessageTextEdit::slotUpdateMessageFailed);
0126 }
0127 
0128 void MessageTextEdit::slotUpdateMessageFailed(const QString &str)
0129 {
0130     // Message Update failed => don't lose text!
0131     setText(str);
0132 }
0133 
0134 void MessageTextEdit::slotLoginChanged()
0135 {
0136     const auto loginStatus = mCurrentRocketChatAccount->loginStatus();
0137     if (loginStatus != DDPAuthenticationManager::LoggedIn) {
0138         mUserAndChannelCompletionListView->hide();
0139         mEmojiCompletionListView->hide();
0140         mCommandCompletionListView->hide();
0141     }
0142 }
0143 
0144 void MessageTextEdit::insertEmoji(const QString &text)
0145 {
0146     textCursor().insertText(text + QLatin1Char(' '));
0147     Q_EMIT textEditing(false);
0148 }
0149 
0150 QString MessageTextEdit::text() const
0151 {
0152     return toPlainText();
0153 }
0154 
0155 QSize MessageTextEdit::sizeHint() const
0156 {
0157     // The width of the QTextDocument is the current widget width, so this is somewhat circular logic.
0158     // But I don't really want to redo the layout with a different width like idealWidth(), seems slow.
0159     const QSize docSize = document()->size().toSize();
0160     const int margin = int(document()->documentMargin());
0161     return {docSize.width() + margin, qMin(300, docSize.height()) + margin};
0162 }
0163 
0164 QSize MessageTextEdit::minimumSizeHint() const
0165 {
0166     const int margin = int(document()->documentMargin());
0167     return {300, fontMetrics().height() + margin};
0168 }
0169 
0170 void MessageTextEdit::changeText(const QString &newText, int cursorPosition)
0171 {
0172     setPlainText(newText);
0173 
0174     QTextCursor cursor(document());
0175     cursor.setPosition(cursorPosition);
0176     setTextCursor(cursor);
0177 
0178     mCurrentInputTextManager->setInputTextChanged(roomId(), text(), cursorPosition);
0179 }
0180 
0181 QMenu *MessageTextEdit::mousePopupMenu()
0182 {
0183     QMenu *menu = KTextEdit::mousePopupMenu();
0184 
0185     QClipboard *clip = QApplication::clipboard();
0186     const QMimeData *mimeData = clip->mimeData();
0187     if (mimeData->hasImage()) {
0188         menu->addSeparator();
0189         menu->addAction(i18n("Paste Image"), this, [this, mimeData]() {
0190             Q_EMIT handleMimeData(mimeData);
0191         });
0192     }
0193     menu->addSeparator();
0194 
0195     auto formatMenu = new QMenu(menu);
0196     formatMenu->setTitle(i18n("Change Text Format"));
0197     menu->addMenu(formatMenu);
0198     formatMenu->addAction(QIcon::fromTheme(QStringLiteral("format-text-bold")), i18n("Bold"), this, &MessageTextEdit::slotSetAsBold);
0199     formatMenu->addAction(QIcon::fromTheme(QStringLiteral("format-text-italic")), i18n("Italic"), this, &MessageTextEdit::slotSetAsItalic);
0200     formatMenu->addAction(QIcon::fromTheme(QStringLiteral("format-text-strikethrough")), i18n("Strike-out"), this, &MessageTextEdit::slotSetAsStrikeOut);
0201     formatMenu->addSeparator();
0202     formatMenu->addAction(i18n("Code Block"), this, &MessageTextEdit::slotInsertCodeBlock);
0203     formatMenu->addSeparator();
0204     formatMenu->addAction(i18n("Markdown Url"), this, &MessageTextEdit::slotInsertMarkdownUrl);
0205     return menu;
0206 }
0207 
0208 void MessageTextEdit::setRoomId(const QString &roomId)
0209 {
0210     mRoomId = roomId;
0211 }
0212 
0213 QString MessageTextEdit::roomId() const
0214 {
0215     return mRoomId;
0216 }
0217 
0218 void MessageTextEdit::slotInsertMarkdownUrl()
0219 {
0220     QTextCursor cursor = textCursor();
0221     if (cursor.hasSelection()) {
0222         const QString mardownUrlStr{QStringLiteral("[text](%1)").arg(cursor.selectedText())};
0223         cursor.insertText(mardownUrlStr);
0224         cursor.setPosition(cursor.position() - mardownUrlStr.length() + 1);
0225     } else {
0226         const QString mardownUrlStr{QStringLiteral("[text](url)")};
0227         cursor.insertText(mardownUrlStr);
0228         cursor.setPosition(cursor.position() - mardownUrlStr.length() + 1);
0229     }
0230     setTextCursor(cursor);
0231 }
0232 
0233 void MessageTextEdit::slotInsertCodeBlock()
0234 {
0235     const QString textCodeBlock{QStringLiteral("```")};
0236     QTextCursor cursor = textCursor();
0237     if (cursor.hasSelection()) {
0238         const QString text = textCodeBlock + QLatin1Char('\n') + cursor.selectedText() + QLatin1Char('\n') + textCodeBlock;
0239         cursor.insertText(text);
0240     } else {
0241         cursor.insertText(QString(textCodeBlock + QStringLiteral("\n\n") + textCodeBlock));
0242     }
0243     cursor.setPosition(cursor.position() - 4);
0244     setTextCursor(cursor);
0245 }
0246 
0247 void MessageTextEdit::slotSetAsStrikeOut()
0248 {
0249     insertFormat(QLatin1Char('~'));
0250 }
0251 
0252 void MessageTextEdit::slotSetAsBold()
0253 {
0254     insertFormat(QLatin1Char('*'));
0255 }
0256 
0257 void MessageTextEdit::slotSetAsItalic()
0258 {
0259     insertFormat(QLatin1Char('_'));
0260 }
0261 
0262 void MessageTextEdit::insertFormat(QChar formatChar)
0263 {
0264     QTextCursor cursor = textCursor();
0265     if (cursor.hasSelection()) {
0266         const QString text = formatChar + cursor.selectedText() + formatChar;
0267         cursor.insertText(text);
0268     } else {
0269         cursor.insertText(QString(formatChar) + QString(formatChar));
0270     }
0271     cursor.setPosition(cursor.position() - 1);
0272     setTextCursor(cursor);
0273 }
0274 
0275 void MessageTextEdit::keyPressEvent(QKeyEvent *e)
0276 {
0277     const int key = e->key();
0278 
0279 #if HAVE_TEXT_AUTOCORRECTION_WIDGETS
0280     if (Ruqola::self()->autoCorrection()->autoCorrectionSettings()->isEnabledAutoCorrection()) {
0281         if ((key == Qt::Key_Space) || (key == Qt::Key_Enter) || (key == Qt::Key_Return)) {
0282             if (!textCursor().hasSelection()) {
0283                 int position = textCursor().position();
0284                 // no Html format in subject. => false
0285                 const bool addSpace = Ruqola::self()->autoCorrection()->autocorrect(false, *document(), position);
0286                 QTextCursor cur = textCursor();
0287                 cur.setPosition(position);
0288                 if (key == Qt::Key_Space) {
0289                     if (addSpace) {
0290                         cur.insertText(QStringLiteral(" "));
0291                         setTextCursor(cur);
0292                     }
0293                     return;
0294                 }
0295             }
0296         }
0297     }
0298 #endif
0299 
0300     if (key == Qt::Key_Return || key == Qt::Key_Enter) {
0301         if ((key == Qt::Key_Enter && (e->modifiers() == Qt::KeypadModifier)) || !e->modifiers()) {
0302             Q_EMIT sendMessage(text());
0303             // We send text => we will clear => we will send textEditing is empty => clear notification
0304             Q_EMIT textEditing(true);
0305             clear();
0306         } else {
0307             textCursor().insertBlock();
0308             ensureCursorVisible();
0309         }
0310         e->accept();
0311         return;
0312     } else if (key == Qt::Key_Up || key == Qt::Key_Down) {
0313         if (!(e->modifiers() & Qt::AltModifier)) {
0314             // document()->lineCount() is > 1 if the user used Shift+Enter
0315             // firstBlockLayout->lineCount() is > 1 if a single long line wrapped around
0316             const QTextLayout *firstBlockLayout = document()->firstBlock().layout();
0317             if (document()->lineCount() > 1 || firstBlockLayout->lineCount() > 1) {
0318                 KTextEdit::keyPressEvent(e);
0319                 return;
0320             }
0321         }
0322     }
0323     e->ignore();
0324     // Check if the listview or room widget want to handle the key (e.g Esc, PageUp)
0325     Q_EMIT keyPressed(e);
0326     if (e->isAccepted()) {
0327         return;
0328     }
0329     // Assign key to KTextEdit first otherwise text() doesn't return correct text
0330     KTextEdit::keyPressEvent(e);
0331     if (key == Qt::Key_Delete || key == Qt::Key_Backspace) {
0332         if (textCursor().hasSelection() && textCursor().selectedText() == text()) {
0333             // We will clear all text => we will send textEditing is empty => clear notification
0334             Q_EMIT textEditing(true);
0335         } else {
0336             mCurrentInputTextManager->setInputTextChanged(roomId(), text(), textCursor().position());
0337             Q_EMIT textEditing(document()->isEmpty());
0338         }
0339     } else {
0340         if (!e->text().isEmpty() || e->matches(QKeySequence::Paste) || e->matches(QKeySequence::Redo) || e->matches(QKeySequence::Undo)) {
0341             mCurrentInputTextManager->setInputTextChanged(roomId(), text(), textCursor().position());
0342             Q_EMIT textEditing(document()->isEmpty());
0343         }
0344     }
0345 }
0346 
0347 void MessageTextEdit::mousePressEvent(QMouseEvent *ev)
0348 {
0349     if (ev->buttons().testFlag(Qt::LeftButton)) {
0350         Q_EMIT textClicked();
0351     }
0352     KTextEdit::mousePressEvent(ev);
0353 }
0354 
0355 void MessageTextEdit::dragEnterEvent(QDragEnterEvent *event)
0356 {
0357     const QMimeData *mimeData = event->mimeData();
0358     if (mimeData->hasUrls()) {
0359         event->accept();
0360         // Don't let QTextEdit move the cursor around
0361         return;
0362     }
0363     KTextEdit::dragEnterEvent(event);
0364 }
0365 
0366 void MessageTextEdit::dragMoveEvent(QDragMoveEvent *event)
0367 {
0368     const QMimeData *mimeData = event->mimeData();
0369     if (mimeData->hasUrls()) {
0370         event->accept();
0371         // Don't let QTextEdit move the cursor around
0372         return;
0373     }
0374     KTextEdit::dragMoveEvent(event);
0375 }
0376 
0377 void MessageTextEdit::dropEvent(QDropEvent *event)
0378 {
0379     const QMimeData *mimeData = event->mimeData();
0380     if (mimeData->hasUrls()) {
0381         Q_EMIT handleMimeData(mimeData);
0382         event->accept();
0383         return;
0384     }
0385     KTextEdit::dropEvent(event);
0386 }
0387 
0388 void MessageTextEdit::slotCompletionTypeChanged(InputTextManager::CompletionForType type)
0389 {
0390     if (type == InputTextManager::Emoji) {
0391         // show emoji completion popup when typing ':'
0392         mEmojiCompletionListView->slotCompletionAvailable();
0393         mUserAndChannelCompletionListView->hide();
0394         mCommandCompletionListView->hide();
0395     } else if (type == InputTextManager::None) {
0396         mUserAndChannelCompletionListView->hide();
0397         mEmojiCompletionListView->hide();
0398         mCommandCompletionListView->hide();
0399     } else if (type == InputTextManager::Command) {
0400         mUserAndChannelCompletionListView->hide();
0401         mEmojiCompletionListView->hide();
0402         mCommandCompletionListView->slotCompletionAvailable();
0403     } else {
0404         // the user and channel completion inserts rows when typing '@' so it will trigger slotCompletionAvailable automatically
0405         mEmojiCompletionListView->hide();
0406         mCommandCompletionListView->hide();
0407     }
0408 }
0409 
0410 void MessageTextEdit::slotComplete(const QModelIndex &index)
0411 {
0412     const QString completerName = index.data(InputCompleterModel::CompleterName).toString();
0413     if (completerName.isEmpty()) {
0414         return;
0415     }
0416     int textPos = textCursor().position();
0417     const QString newText = mCurrentInputTextManager->applyCompletion(completerName + QLatin1Char(' '), text(), &textPos);
0418 
0419     mUserAndChannelCompletionListView->hide();
0420     mEmojiCompletionListView->hide();
0421     mCommandCompletionListView->hide();
0422 
0423     changeText(newText, textPos);
0424 }
0425 
0426 void MessageTextEdit::slotSelectFirstTextCompleter()
0427 {
0428     const QModelIndex idx = mUserAndChannelCompletionListView->model()->index(0, 0);
0429     // qDebug() << " idx " << idx;
0430     mUserAndChannelCompletionListView->setCurrentIndex(idx);
0431 }
0432 
0433 #include "moc_messagetextedit.cpp"