diff --git a/autotests/CMakeLists.txt b/autotests/CMakeLists.txt index 1bce901..68019b9 100644 --- a/autotests/CMakeLists.txt +++ b/autotests/CMakeLists.txt @@ -1,21 +1,22 @@ include(ECMMarkAsTest) include(ECMAddTests) find_package(Qt5Test ${REQUIRED_QT_VERSION} REQUIRED NO_MODULE) ########### unittests ############### -macro(SONNET_UNIT_TESTS) - foreach(_testname ${ARGN}) - ecm_add_test(${_testname}.cpp TEST_NAME sonnet-${_testname} LINK_LIBRARIES KF5::SonnetCore Qt5::Test) - endforeach() -endmacro(SONNET_UNIT_TESTS) - -sonnet_unit_tests( +ecm_add_tests( test_filter test_core test_suggest test_settings + NAME_PREFIX "sonnet-" + LINK_LIBRARIES KF5::SonnetCore Qt5::Test ) +ecm_add_tests( + test_highlighter + NAME_PREFIX "sonnet-" + LINK_LIBRARIES KF5::SonnetUi KF5::SonnetCore Qt5::Test +) diff --git a/autotests/test_highlighter.cpp b/autotests/test_highlighter.cpp new file mode 100644 index 0000000..2895b5f --- /dev/null +++ b/autotests/test_highlighter.cpp @@ -0,0 +1,141 @@ +// krazy:excludeall=spelling +/** + * Copyright (C) 2017 David Faure + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 2.1 of the License, or (at your option) any later version. + * + * This library 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 + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public + * License along with this library; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA + * 02110-1301 USA + */ + +#include "highlighter.h" +#include "speller.h" + +#include +#include +#include +#include +#include + +using namespace Sonnet; + +class HighlighterTest : public QObject +{ + Q_OBJECT + +private Q_SLOTS: + void initTestCase(); + void testEnglish(); + void testFrench(); + void testMultipleLanguages(); +}; + +void HighlighterTest::initTestCase() +{ + QStandardPaths::setTestModeEnabled(true); + + Speller speller(QStringLiteral("en_US")); + if (!speller.availableBackends().contains(QLatin1String("ASpell"))) { + QSKIP("ASpell not available"); + } + // Doing this here affects all the highlighters created later on, due to the weird hidden "Settings" class saving stuff behind the API's back... + speller.setDefaultClient(QStringLiteral("ASpell")); + if (!speller.availableLanguages().contains(QLatin1String("en"))) { + QSKIP("'en' not available"); + } + + // How weird to have to do this here and not with the Highlighter API.... + speller.setAttribute(Speller::AutoDetectLanguage, true); +} + +static const char s_englishSentence[] = "Hello helo this is the highlighter test enviroment guvernment"; // words from test_suggest.cpp + +void HighlighterTest::testEnglish() +{ + // GIVEN + QPlainTextEdit textEdit; + textEdit.setPlainText(QString::fromLatin1(s_englishSentence)); + Sonnet::Highlighter highlighter(&textEdit); + highlighter.setCurrentLanguage(QStringLiteral("en")); + QVERIFY(highlighter.spellCheckerFound()); + highlighter.rehighlight(); + QTextCursor cursor(textEdit.document()); + + // WHEN + cursor.setPosition(6); + const QStringList suggestionsForHelo = highlighter.suggestionsForWord(QStringLiteral("helo"), cursor); + const QStringList unlimitedSuggestions = highlighter.suggestionsForWord(QStringLiteral("helo"), cursor, -1); + cursor.setPosition(40); + const QStringList suggestionsForEnviroment = highlighter.suggestionsForWord(QStringLiteral("enviroment"), cursor); + + // THEN + QCOMPARE(suggestionsForHelo.count(), 10); + QVERIFY2(suggestionsForHelo.contains(QStringLiteral("hello")), qPrintable(suggestionsForHelo.join(QLatin1Char(',')))); + QVERIFY2(suggestionsForEnviroment.contains(QStringLiteral("environment")), qPrintable(suggestionsForEnviroment.join(QLatin1Char(',')))); + QVERIFY(unlimitedSuggestions.count() > 10); +} + +static const char s_frenchSentence[] = "Bnjour est un bon mot pour tester le dictionnare."; + +void HighlighterTest::testFrench() +{ + // GIVEN + QPlainTextEdit textEdit; + textEdit.setPlainText(QString::fromLatin1(s_frenchSentence)); + Sonnet::Highlighter highlighter(&textEdit); + highlighter.setCurrentLanguage(QStringLiteral("fr_FR")); + QVERIFY(highlighter.spellCheckerFound()); + highlighter.rehighlight(); + QTextCursor cursor(textEdit.document()); + + // WHEN + cursor.setPosition(0); + const QStringList suggestionsForBnjour = highlighter.suggestionsForWord(QStringLiteral("Bnjour"), cursor); + cursor.setPosition(37); + const QStringList suggestionsForDict = highlighter.suggestionsForWord(QStringLiteral("dictionnare"), cursor); + + // THEN + QVERIFY2(suggestionsForBnjour.contains(QStringLiteral("Bonjour")), qPrintable(suggestionsForBnjour.join(QLatin1Char(',')))); + QVERIFY2(suggestionsForDict.contains(QStringLiteral("dictionnaire")), qPrintable(suggestionsForDict.join(QLatin1Char(',')))); +} + +void HighlighterTest::testMultipleLanguages() +{ + // GIVEN + QPlainTextEdit textEdit; + const QString englishSentence = QString::fromLatin1(s_englishSentence) + QLatin1Char('\n'); + textEdit.setPlainText(englishSentence + QString::fromLatin1(s_frenchSentence)); + Sonnet::Highlighter highlighter(&textEdit); + highlighter.rehighlight(); + QTextCursor cursor(textEdit.document()); + + // WHEN + cursor.setPosition(6); + const QStringList suggestionsForHelo = highlighter.suggestionsForWord(QStringLiteral("helo"), cursor); + cursor.setPosition(40); + const QStringList suggestionsForEnviroment = highlighter.suggestionsForWord(QStringLiteral("enviroment"), cursor); + cursor.setPosition(englishSentence.size()); + const QStringList suggestionsForBnjour = highlighter.suggestionsForWord(QStringLiteral("Bnjour"), cursor); + cursor.setPosition(englishSentence.size() + 37); + const QStringList suggestionsForDict = highlighter.suggestionsForWord(QStringLiteral("dictionnare"), cursor); + + // THEN + QVERIFY2(suggestionsForHelo.contains(QStringLiteral("hello")), qPrintable(suggestionsForHelo.join(QLatin1Char(',')))); + QVERIFY2(suggestionsForEnviroment.contains(QStringLiteral("environment")), qPrintable(suggestionsForEnviroment.join(QLatin1Char(',')))); + QVERIFY2(suggestionsForBnjour.contains(QStringLiteral("Bonjour")), qPrintable(suggestionsForBnjour.join(QLatin1Char(',')))); + QVERIFY2(suggestionsForDict.contains(QStringLiteral("dictionnaire")), qPrintable(suggestionsForDict.join(QLatin1Char(',')))); +} + +QTEST_MAIN(HighlighterTest) + +#include "test_highlighter.moc" diff --git a/src/ui/highlighter.cpp b/src/ui/highlighter.cpp index 89643e4..8b1ec21 100644 --- a/src/ui/highlighter.cpp +++ b/src/ui/highlighter.cpp @@ -1,482 +1,514 @@ /** * highlighter.cpp * * Copyright (C) 2004 Zack Rusin * Copyright (C) 2006 Laurent Montel * Copyright (C) 2013 Martin Sandsmark * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 2.1 of the License, or (at your option) any later version. * * This library 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 * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA * 02110-1301 USA */ #include "highlighter.h" #include "speller.h" #include "loader_p.h" #include "tokenizer_p.h" #include "settings_p.h" #include "languagefilter_p.h" #include "ui_debug.h" #include #include #include #include #include #include #include #include #include #include #include namespace Sonnet { +// Cache of previously-determined languages (when using AutoDetectLanguage) +// There is one such cache per block (paragraph) class LanguageCache : public QTextBlockUserData { public: + // Key: QPair + // Value: language name QMap, QString> languages; + + // Remove all cached language information after @p pos void invalidate(int pos) { QMutableMapIterator, QString> it(languages); it.toBack(); while (it.hasPrevious()) { it.previous(); if (it.key().first+it.key().second >=pos) it.remove(); else break; } } + + QString languageAtPos(int pos) const { + // The data structure isn't really great for such lookups... + QMapIterator, QString> it(languages); + while (it.hasNext()) { + it.next(); + if (it.key().first <= pos && it.key().first + it.key().second >= pos) { + return it.value(); + } + } + return QString(); + } }; class HighlighterPrivate { public: HighlighterPrivate(Highlighter *qq, const QColor &col) : textEdit(nullptr), plainTextEdit(nullptr), spellColor(col), q(qq) { tokenizer = new WordTokenizer(); active = true; automatic = false; connected = false; wordCount = 0; errorCount = 0; intraWordEditing = false; completeRehighlightRequired = false; spellColor = spellColor.isValid() ? spellColor : Qt::red; languageFilter = new LanguageFilter(new SentenceTokenizer()); loader = Loader::openLoader(); loader->settings()->restore(); spellchecker = new Sonnet::Speller(); spellCheckerFound = spellchecker->isValid(); rehighlightRequest = new QTimer(q); q->connect(rehighlightRequest, SIGNAL(timeout()), q, SLOT(slotRehighlight())); if (!spellCheckerFound) { return; } disablePercentage = loader->settings()->disablePercentageWordError(); disableWordCount = loader->settings()->disableWordErrorCount(); completeRehighlightRequired = true; rehighlightRequest->setInterval(0); rehighlightRequest->setSingleShot(true); rehighlightRequest->start(); } ~HighlighterPrivate(); WordTokenizer *tokenizer = nullptr; LanguageFilter *languageFilter = nullptr; Loader *loader = nullptr; Speller *spellchecker = nullptr; QTextEdit *textEdit = nullptr; QPlainTextEdit *plainTextEdit = nullptr; bool active; bool automatic; bool completeRehighlightRequired; bool intraWordEditing; bool spellCheckerFound; //cached d->dict->isValid() value bool connected; int disablePercentage = 0; int disableWordCount = 0; int wordCount, errorCount; QTimer *rehighlightRequest = nullptr; QColor spellColor; Highlighter *q; }; HighlighterPrivate::~HighlighterPrivate() { delete spellchecker; delete languageFilter; delete tokenizer; } Highlighter::Highlighter(QTextEdit *edit, const QColor &_col) : QSyntaxHighlighter(edit), d(new HighlighterPrivate(this, _col)) { d->textEdit = edit; d->textEdit->installEventFilter(this); d->textEdit->viewport()->installEventFilter(this); } Highlighter::Highlighter(QPlainTextEdit *edit, const QColor &col) : QSyntaxHighlighter(edit), d(new HighlighterPrivate(this, col)) { d->plainTextEdit = edit; setDocument(d->plainTextEdit->document()); d->plainTextEdit->installEventFilter(this); d->plainTextEdit->viewport()->installEventFilter(this); } Highlighter::~Highlighter() { delete d; } bool Highlighter::spellCheckerFound() const { return d->spellCheckerFound; } void Highlighter::slotRehighlight() { if (d->completeRehighlightRequired) { d->wordCount = 0; d->errorCount = 0; rehighlight(); } else { //rehighlight the current para only (undo/redo safe) QTextCursor cursor; if (d->textEdit) cursor = d->textEdit->textCursor(); else cursor = d->plainTextEdit->textCursor(); cursor.insertText(QString()); } //if (d->checksDone == d->checksRequested) //d->completeRehighlightRequired = false; QTimer::singleShot(0, this, SLOT(slotAutoDetection())); } bool Highlighter::automatic() const { return d->automatic; } bool Highlighter::intraWordEditing() const { return d->intraWordEditing; } void Highlighter::setIntraWordEditing(bool editing) { d->intraWordEditing = editing; } void Highlighter::setAutomatic(bool automatic) { if (automatic == d->automatic) { return; } d->automatic = automatic; if (d->automatic) { slotAutoDetection(); } } void Highlighter::slotAutoDetection() { bool savedActive = d->active; //don't disable just because 1 of 4 is misspelled. if (d->automatic && d->wordCount >= 10) { // tme = Too many errors bool tme = (d->errorCount >= d->disableWordCount) && ( d->errorCount * 100 >= d->disablePercentage * d->wordCount); if (d->active && tme) { d->active = false; } else if (!d->active && !tme) { d->active = true; } } if (d->active != savedActive) { if (d->active) { emit activeChanged(tr("As-you-type spell checking enabled.")); } else { qCDebug(SONNET_LOG_UI) << "Sonnet: Disabling spell checking, too many errors"; emit activeChanged(tr("Too many misspelled words. " "As-you-type spell checking disabled.")); } d->completeRehighlightRequired = true; d->rehighlightRequest->setInterval(100); d->rehighlightRequest->setSingleShot(true); } } void Highlighter::setActive(bool active) { if (active == d->active) { return; } d->active = active; rehighlight(); if (d->active) { emit activeChanged(tr("As-you-type spell checking enabled.")); } else { emit activeChanged(tr("As-you-type spell checking disabled.")); } } bool Highlighter::isActive() const { return d->active; } void Highlighter::contentsChange(int pos, int add, int rem) { // Invalidate the cache where the text has changed const QTextBlock &lastBlock = document()->findBlock(pos + add - rem); QTextBlock block = document()->findBlock(pos); do { LanguageCache* cache=dynamic_cast(block.userData()); if (cache) cache->invalidate(pos-block.position()); block = block.next(); } while (block.isValid() && block < lastBlock); } void Highlighter::highlightBlock(const QString &text) { if (text.isEmpty() || !d->active || !d->spellCheckerFound) { return; } if (!d->connected) { connect(document(), SIGNAL(contentsChange(int,int,int)), SLOT(contentsChange(int,int,int))); d->connected = true; } QTextCursor cursor; if (d->textEdit) { cursor = d->textEdit->textCursor(); } else { cursor = d->plainTextEdit->textCursor(); } int index = cursor.position(); const int lengthPosition = text.length() - 1; if ( index != lengthPosition || ( lengthPosition > 0 && !text[lengthPosition-1].isLetter() ) ) { d->languageFilter->setBuffer(text); LanguageCache* cache=dynamic_cast(currentBlockUserData()); if (!cache) { cache = new LanguageCache; setCurrentBlockUserData(cache); } const bool autodetectLanguage = d->spellchecker->testAttribute(Speller::AutoDetectLanguage); while (d->languageFilter->hasNext()) { QStringRef sentence=d->languageFilter->next(); if (autodetectLanguage) { QString lang; QPair spos=QPair(sentence.position(),sentence.length()); // try cache first if (cache->languages.contains(spos)) { lang=cache->languages.value(spos); } else { lang=d->languageFilter->language(); if (!d->languageFilter->isSpellcheckable()) lang.clear(); cache->languages[spos]=lang; } if (lang.isEmpty()) continue; d->spellchecker->setLanguage(lang); } d->tokenizer->setBuffer(sentence.toString()); int offset=sentence.position(); while (d->tokenizer->hasNext()) { QStringRef word=d->tokenizer->next(); if (!d->tokenizer->isSpellcheckable()) continue; ++d->wordCount; if (d->spellchecker->isMisspelled(word.toString())) { ++d->errorCount; setMisspelled(word.position()+offset, word.length()); } else { unsetMisspelled(word.position()+offset, word.length()); } } } } //QTimer::singleShot( 0, this, SLOT(checkWords()) ); setCurrentBlockState(0); } QString Highlighter::currentLanguage() const { return d->spellchecker->language(); } void Highlighter::setCurrentLanguage(const QString &lang) { QString prevLang=d->spellchecker->language(); d->spellchecker->setLanguage(lang); d->spellCheckerFound = d->spellchecker->isValid(); if (!d->spellCheckerFound) { qCDebug(SONNET_LOG_UI) << "No dictionary for \"" << lang << "\" staying with the current language."; d->spellchecker->setLanguage(prevLang); return; } d->wordCount = 0; d->errorCount = 0; if (d->automatic) { d->rehighlightRequest->start(0); } } void Highlighter::setMisspelled(int start, int count) { QTextCharFormat format; format.setFontUnderline(true); format.setUnderlineStyle(QTextCharFormat::SpellCheckUnderline); format.setUnderlineColor(d->spellColor); setFormat(start, count, format); } void Highlighter::unsetMisspelled(int start, int count) { setFormat(start, count, QTextCharFormat()); } bool Highlighter::eventFilter(QObject *o, QEvent *e) { if (!d->spellCheckerFound) { return false; } if ((o == d->textEdit || o == d->plainTextEdit) && (e->type() == QEvent::KeyPress)) { QKeyEvent *k = static_cast(e); //d->autoReady = true; if (d->rehighlightRequest->isActive()) { // try to stay out of the users way d->rehighlightRequest->start(500); } if (k->key() == Qt::Key_Enter || k->key() == Qt::Key_Return || k->key() == Qt::Key_Up || k->key() == Qt::Key_Down || k->key() == Qt::Key_Left || k->key() == Qt::Key_Right || k->key() == Qt::Key_PageUp || k->key() == Qt::Key_PageDown || k->key() == Qt::Key_Home || k->key() == Qt::Key_End || ((k->modifiers() == Qt::ControlModifier) && ((k->key() == Qt::Key_A) || (k->key() == Qt::Key_B) || (k->key() == Qt::Key_E) || (k->key() == Qt::Key_N) || (k->key() == Qt::Key_P)))) { if (intraWordEditing()) { setIntraWordEditing(false); d->completeRehighlightRequired = true; d->rehighlightRequest->setInterval(500); d->rehighlightRequest->setSingleShot(true); d->rehighlightRequest->start(); } } else { setIntraWordEditing(true); } if (k->key() == Qt::Key_Space || k->key() == Qt::Key_Enter || k->key() == Qt::Key_Return) { QTimer::singleShot(0, this, SLOT(slotAutoDetection())); } } else if ((( d->textEdit && ( o == d->textEdit->viewport())) || (d->plainTextEdit && (o == d->plainTextEdit->viewport()))) && (e->type() == QEvent::MouseButtonPress)) { //d->autoReady = true; if (intraWordEditing()) { setIntraWordEditing(false); d->completeRehighlightRequired = true; d->rehighlightRequest->setInterval(0); d->rehighlightRequest->setSingleShot(true); d->rehighlightRequest->start(); } } return false; } void Highlighter::addWordToDictionary(const QString &word) { d->spellchecker->addToPersonal(word); } void Highlighter::ignoreWord(const QString &word) { d->spellchecker->addToSession(word); } QStringList Highlighter::suggestionsForWord(const QString &word, int max) { QStringList suggestions = d->spellchecker->suggest(word); - if (max != -1) { - while (suggestions.count() > max) { - suggestions.removeLast(); + if (max >= 0 && suggestions.count() > max) { + suggestions = suggestions.mid(0, max); + } + return suggestions; +} + +QStringList Highlighter::suggestionsForWord(const QString &word, const QTextCursor &cursor, int max) +{ + LanguageCache* cache = dynamic_cast(cursor.block().userData()); + if (cache) { + const QString cachedLanguage = cache->languageAtPos(cursor.positionInBlock()); + if (!cachedLanguage.isEmpty()) { + d->spellchecker->setLanguage(cachedLanguage); } } + QStringList suggestions = d->spellchecker->suggest(word); + if (max >= 0 && suggestions.count() > max) { + suggestions = suggestions.mid(0, max); + } return suggestions; } bool Highlighter::isWordMisspelled(const QString &word) { return d->spellchecker->isMisspelled(word); } void Highlighter::setMisspelledColor(const QColor &color) { d->spellColor = color; } bool Highlighter::checkerEnabledByDefault() const { return d->loader->settings()->checkerEnabledByDefault(); } void Highlighter::setDocument(QTextDocument* document) { d->connected = false; QSyntaxHighlighter::setDocument(document); } } diff --git a/src/ui/highlighter.h b/src/ui/highlighter.h index ceed3ff..a316b01 100644 --- a/src/ui/highlighter.h +++ b/src/ui/highlighter.h @@ -1,216 +1,231 @@ /* * highlighter.h * * Copyright (C) 2004 Zack Rusin * Copyright (C) 2013 Martin Sandsmark * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 2.1 of the License, or (at your option) any later version. * * This library 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 * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA * 02110-1301 USA */ #ifndef SONNET_HIGHLIGHTER_H #define SONNET_HIGHLIGHTER_H #include #include #include "sonnetui_export.h" class QTextEdit; class QPlainTextEdit; namespace Sonnet { class HighlighterPrivate; /// The Sonnet Highlighter class, used for drawing pretty red lines in text fields class SONNETUI_EXPORT Highlighter : public QSyntaxHighlighter { Q_OBJECT public: explicit Highlighter(QTextEdit *textEdit, const QColor &col = QColor()); /** * @brief Highlighter * @param textEdit * @param col define spellchecking color. * @since 5.12 */ explicit Highlighter(QPlainTextEdit *textEdit, const QColor &col = QColor()); ~Highlighter(); /** * Returns whether a spell checking backend with support for the * @ref currentLanguage was found. * * @return true if spell checking is supported for the current language. */ bool spellCheckerFound() const; /** * Returns the current language used for spell checking. * * @return the language code for the current language. */ QString currentLanguage() const; /** * @short Enable/Disable spell checking. * * If @p active is true then spell checking is enabled; otherwise it * is disabled. Note that you have to disable automatic (de)activation * with @ref setAutomatic() before you change the state of spell * checking if you want to persistently enable/disable spell * checking. * * @param active if true, then spell checking is enabled * * @see isActive(), setAutomatic() */ void setActive(bool active); /** * Returns the state of spell checking. * * @return true if spell checking is active * * @see setActive() */ bool isActive() const; /** * Returns the state of the automatic disabling of spell checking. * * @return true if spell checking is automatically disabled if there's * too many errors */ bool automatic() const; /** * Sets whether to automatically disable spell checking if there's too * many errors. * * @param automatic if true, spell checking will be disabled if there's * a significant amount of errors. */ void setAutomatic(bool automatic); /** * Adds the given word permanently to the dictionary. It will never * be marked as misspelled again, even after restarting the application. * * @param word the word which will be added to the dictionary * @since 4.1 */ void addWordToDictionary(const QString &word); /** * Ignores the given word. This word will not be marked misspelled for * this session. It will again be marked as misspelled when creating * new highlighters. * * @param word the word which will be ignored * @since 4.1 */ void ignoreWord(const QString &word); /** * Returns a list of suggested replacements for the given misspelled word. * If the word is not misspelled, the list will be empty. * * @param word the misspelled word * @param max at most this many suggestions will be returned. If this is * -1, as many suggestions as the spell backend supports will * be returned. * @return a list of suggested replacements for the word * @since 4.1 */ QStringList suggestionsForWord(const QString &word, int max = 10); + /** + * Returns a list of suggested replacements for the given misspelled word. + * If the word is not misspelled, the list will be empty. + * + * @param word the misspelled word + * @param cursor the cursor pointing to the beginning of that word. This is used + * to determine the language to use, when AutoDetectLanguage is enabled. + * @param max at most this many suggestions will be returned. If this is + * -1, as many suggestions as the spell backend supports will + * be returned. + * @return a list of suggested replacements for the word + * @since 5.42 + */ + QStringList suggestionsForWord(const QString &word, const QTextCursor& cursor, int max = 10); + /** * Checks if a given word is marked as misspelled by the highlighter. * * @param word the word to be checked * @return true if the given word is misspelled. * @since 4.1 */ bool isWordMisspelled(const QString &word); /** * Sets the color in which the highlighter underlines misspelled words. * @since 4.2 */ void setMisspelledColor(const QColor &color); /** * Return true if checker is enabled by default * @since 4.5 */ bool checkerEnabledByDefault() const; /** * Set a new @ref QTextDocument for this highlighter to operate on. * * @param document the new document to operate on. */ void setDocument(QTextDocument *document); Q_SIGNALS: /** * Emitted when as-you-type spell checking is enabled or disabled. * * @param description is a i18n description of the new state, * with an optional reason */ void activeChanged(const QString &description); protected: void highlightBlock(const QString &text) Q_DECL_OVERRIDE; virtual void setMisspelled(int start, int count); virtual void unsetMisspelled(int start, int count); bool eventFilter(QObject *o, QEvent *e) Q_DECL_OVERRIDE; bool intraWordEditing() const; void setIntraWordEditing(bool editing); public Q_SLOTS: /** * Set language to use for spell checking. * * @param language the language code for the new language to use. */ void setCurrentLanguage(const QString &language); /** * Run auto detection, disabling spell checking if too many errors are found. */ void slotAutoDetection(); /** * Force a new highlighting. */ void slotRehighlight(); private Q_SLOTS: void contentsChange(int pos, int added, int removed); private: HighlighterPrivate *const d; Q_DISABLE_COPY(Highlighter) }; } #endif diff --git a/src/ui/spellcheckdecorator.cpp b/src/ui/spellcheckdecorator.cpp index 7e1fb38..a32c588 100644 --- a/src/ui/spellcheckdecorator.cpp +++ b/src/ui/spellcheckdecorator.cpp @@ -1,275 +1,275 @@ /* * spellcheckdecorator.h * * Copyright (C) 2013 Aurélien Gâteau * * This library is free software; you can redistribute it and/or * modify it under the terms of the GNU Lesser General Public * License as published by the Free Software Foundation; either * version 2.1 of the License, or (at your option) any later version. * * This library 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 * Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public * License along with this library; if not, write to the Free Software * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA * 02110-1301 USA */ #include "spellcheckdecorator.h" // Local #include // Qt #include #include #include #include namespace Sonnet { class Q_DECL_HIDDEN SpellCheckDecorator::Private { public: Private(SpellCheckDecorator *installer, QPlainTextEdit *textEdit) : q(installer) , m_textEdit(nullptr) , m_plainTextEdit(textEdit) , m_highlighter(nullptr) { createDefaultHighlighter(); // Catch pressing the "menu" key m_plainTextEdit->installEventFilter(q); // Catch right-click m_plainTextEdit->viewport()->installEventFilter(q); } Private(SpellCheckDecorator *installer, QTextEdit *textEdit) : q(installer) , m_textEdit(textEdit) , m_plainTextEdit(nullptr) , m_highlighter(nullptr) { createDefaultHighlighter(); // Catch pressing the "menu" key m_textEdit->installEventFilter(q); // Catch right-click m_textEdit->viewport()->installEventFilter(q); } bool onContextMenuEvent(QContextMenuEvent *event); void execSuggestionMenu(const QPoint &pos, const QString &word, const QTextCursor &cursor); void createDefaultHighlighter(); SpellCheckDecorator *q = nullptr; QTextEdit *m_textEdit = nullptr; QPlainTextEdit *m_plainTextEdit = nullptr; Highlighter *m_highlighter = nullptr; }; bool SpellCheckDecorator::Private::onContextMenuEvent(QContextMenuEvent *event) { if (!m_highlighter) { createDefaultHighlighter(); } // Obtain the cursor at the mouse position and the current cursor QTextCursor cursorAtMouse; if (m_textEdit) { cursorAtMouse = m_textEdit->cursorForPosition(event->pos()); } else { cursorAtMouse = m_plainTextEdit->cursorForPosition(event->pos()); } const int mousePos = cursorAtMouse.position(); QTextCursor cursor; if (m_textEdit) { cursor = m_textEdit->textCursor(); } else { cursor = m_plainTextEdit->textCursor(); } // Check if the user clicked a selected word const bool selectedWordClicked = cursor.hasSelection() && mousePos >= cursor.selectionStart() && mousePos <= cursor.selectionEnd(); // Get the word under the (mouse-)cursor and see if it is misspelled. // Don't include apostrophes at the start/end of the word in the selection. QTextCursor wordSelectCursor(cursorAtMouse); wordSelectCursor.clearSelection(); wordSelectCursor.select(QTextCursor::WordUnderCursor); QString selectedWord = wordSelectCursor.selectedText(); bool isMouseCursorInsideWord = true; if ((mousePos < wordSelectCursor.selectionStart() || mousePos >= wordSelectCursor.selectionEnd()) && (selectedWord.length() > 1)) { isMouseCursorInsideWord = false; } // Clear the selection again, we re-select it below (without the apostrophes). wordSelectCursor.setPosition(wordSelectCursor.position() - selectedWord.size()); if (selectedWord.startsWith(QLatin1Char('\'')) || selectedWord.startsWith(QLatin1Char('\"'))) { selectedWord = selectedWord.right(selectedWord.size() - 1); wordSelectCursor.movePosition(QTextCursor::NextCharacter, QTextCursor::MoveAnchor); } if (selectedWord.endsWith(QLatin1Char('\'')) || selectedWord.endsWith(QLatin1Char('\"'))) { selectedWord.chop(1); } wordSelectCursor.movePosition(QTextCursor::NextCharacter, QTextCursor::KeepAnchor, selectedWord.size()); const bool wordIsMisspelled = isMouseCursorInsideWord && m_highlighter && m_highlighter->isActive() && !selectedWord.isEmpty() && m_highlighter->isWordMisspelled(selectedWord); // If the user clicked a selected word, do nothing. // If the user clicked somewhere else, move the cursor there. // If the user clicked on a misspelled word, select that word. // Same behavior as in OpenOffice Writer. bool checkBlock = q->isSpellCheckingEnabledForBlock(cursorAtMouse.block().text()); if (!selectedWordClicked) { if (wordIsMisspelled && checkBlock) { if (m_textEdit) { m_textEdit->setTextCursor(wordSelectCursor); } else { m_plainTextEdit->setTextCursor(wordSelectCursor); } } else { if (m_textEdit) { m_textEdit->setTextCursor(cursorAtMouse); } else { m_plainTextEdit->setTextCursor(cursorAtMouse); } } if (m_textEdit) { cursor = m_textEdit->textCursor(); } else { cursor = m_plainTextEdit->textCursor(); } } // Use standard context menu for already selected words, correctly spelled // words and words inside quotes. if (!wordIsMisspelled || selectedWordClicked || !checkBlock) { return false; } execSuggestionMenu(event->globalPos(), selectedWord, cursor); return true; } void SpellCheckDecorator::Private::execSuggestionMenu(const QPoint &pos, const QString &selectedWord, const QTextCursor &_cursor) { QTextCursor cursor = _cursor; QMenu menu; //don't use KMenu here we don't want auto management accelerator //Add the suggestions to the menu - const QStringList reps = m_highlighter->suggestionsForWord(selectedWord); + const QStringList reps = m_highlighter->suggestionsForWord(selectedWord, cursor); if (reps.isEmpty()) { QAction *suggestionsAction = menu.addAction(tr("No suggestions for %1").arg(selectedWord)); suggestionsAction->setEnabled(false); } else { QStringList::const_iterator end(reps.constEnd()); for (QStringList::const_iterator it = reps.constBegin(); it != end; ++it) { menu.addAction(*it); } } menu.addSeparator(); QAction *ignoreAction = menu.addAction(tr("Ignore")); QAction *addToDictAction = menu.addAction(tr("Add to Dictionary")); //Execute the popup inline const QAction *selectedAction = menu.exec(pos); if (selectedAction) { Q_ASSERT(cursor.selectedText() == selectedWord); if (selectedAction == ignoreAction) { m_highlighter->ignoreWord(selectedWord); m_highlighter->rehighlight(); } else if (selectedAction == addToDictAction) { m_highlighter->addWordToDictionary(selectedWord); m_highlighter->rehighlight(); } // Other actions can only be one of the suggested words else { const QString replacement = selectedAction->text(); Q_ASSERT(reps.contains(replacement)); cursor.insertText(replacement); if (m_textEdit) { m_textEdit->setTextCursor(cursor); } else { m_plainTextEdit->setTextCursor(cursor); } } } } void SpellCheckDecorator::Private::createDefaultHighlighter() { if (m_textEdit) { m_highlighter = new Highlighter(m_textEdit); } else { m_highlighter = new Highlighter(m_plainTextEdit); } } SpellCheckDecorator::SpellCheckDecorator(QTextEdit *textEdit) : QObject(textEdit) , d(new Private(this, textEdit)) { } SpellCheckDecorator::SpellCheckDecorator(QPlainTextEdit *textEdit) : QObject(textEdit) , d(new Private(this, textEdit)) { } SpellCheckDecorator::~SpellCheckDecorator() { delete d; } void SpellCheckDecorator::setHighlighter(Highlighter *highlighter) { d->m_highlighter = highlighter; } Highlighter *SpellCheckDecorator::highlighter() const { if (!d->m_highlighter) { d->createDefaultHighlighter(); } return d->m_highlighter; } bool SpellCheckDecorator::eventFilter(QObject * /*obj*/, QEvent *event) { if (event->type() == QEvent::ContextMenu) { return d->onContextMenuEvent(static_cast(event)); } return false; } bool SpellCheckDecorator::isSpellCheckingEnabledForBlock(const QString &textBlock) const { Q_UNUSED(textBlock); if (d->m_textEdit) { return d->m_textEdit->isEnabled(); } else { return d->m_plainTextEdit->isEnabled(); } } } // namespace