diff options
author | Jarkko Koivikko <jarkko.koivikko@code-q.fi> | 2018-08-07 12:01:39 +0300 |
---|---|---|
committer | Jarkko Koivikko <jarkko.koivikko@code-q.fi> | 2018-08-16 13:31:39 +0000 |
commit | e803aec1ea21fd00e13b9535a4b536cc43c26ee4 (patch) | |
tree | 7cdcfcebb4de0cf3651aeea56637e335b46f9a18 | |
parent | cf69f8603e3a1fee24f79d1b446b5ea717e2cf7d (diff) |
Add user dictionary and learning for Hunspell
This change adds user dictionary and learning function for Hunspell.
Learning happens when the user selects the first candidate from the
word candidate list (or presses the space key while the first candidate
is selected) and the word does not exist in the default dictionary.
User can remove a word from user dictionary by long pressing an item
on the word candidate list (and selecting from the menu). This also
allows the user to block words originating from the system dictionary.
The Hunspell user dictionary is hard limited to 100 words. However, when
user enters a word again (which exists in the user dictionary), it will
be prioritized in the dictionary, so the most frequent words stay in the
dictionary.
The dictionaries are language and locale specific and are stored in:
QStandardPaths::GenericConfigLocation + "/qtvirtualkeyboard/hunspell"
The dictionaries are UTF-8 encoded text files and contain one word per line.
The same directory also contains the blacklist files (which also exist per
language and locale). These files contain words which are blacklisted from
the default dictionary.
[ChangeLog] Added user dictionary and learning for Hunspell
Change-Id: Ib0fc70f7eaa14ec49b74000144a75c59313ac0fb
Reviewed-by: Mitch Curtis <mitch.curtis@qt.io>
20 files changed, 1343 insertions, 230 deletions
diff --git a/src/plugin/plugin.cpp b/src/plugin/plugin.cpp index 4841f53b..956bafbd 100644 --- a/src/plugin/plugin.cpp +++ b/src/plugin/plugin.cpp @@ -191,7 +191,7 @@ QPlatformInputContext *QVirtualKeyboardPlugin::create(const QString &system, con qmlRegisterType(QUrl(componentsPath + QLatin1String("TraceInputArea.qml")), pluginUri, 2, 0, "TraceInputArea"); qmlRegisterType(QUrl(componentsPath + QLatin1String("TraceInputKey.qml")), pluginUri, 2, 0, "TraceInputKey"); qmlRegisterType(QUrl(componentsPath + QLatin1String("WordCandidatePopupList.qml")), pluginUri, 2, 0, "WordCandidatePopupList"); - qmlRegisterType(QUrl(componentsPath + QLatin1String("LanguagePopupList.qml")), pluginUri, 2, 1, "LanguagePopupList"); + qmlRegisterType(QUrl(componentsPath + QLatin1String("PopupList.qml")), pluginUri, 2, 3, "PopupList"); qmlRegisterType(QUrl(componentsPath + QLatin1String("SelectionControl.qml")), pluginUri, 2, 1, "SelectionControl"); qmlRegisterType(QUrl(componentsPath + QLatin1String("InputModeKey.qml")), pluginUri, 2, 3, "InputModeKey"); diff --git a/src/plugins/hunspell/hunspellinputmethod/hunspellinputmethod.cpp b/src/plugins/hunspell/hunspellinputmethod/hunspellinputmethod.cpp index d437f8fe..ee8d31e3 100644 --- a/src/plugins/hunspell/hunspellinputmethod/hunspellinputmethod.cpp +++ b/src/plugins/hunspell/hunspellinputmethod/hunspellinputmethod.cpp @@ -108,16 +108,24 @@ bool HunspellInputMethod::keyEvent(Qt::Key key, const QString &text, Qt::Keyboar update(); break; case Qt::Key_Backspace: - if (!d->word.isEmpty()) { - d->word.remove(d->word.length() - 1, 1); - ic->setPreeditText(d->word); - if (d->updateSuggestions()) { - emit selectionListChanged(SelectionListModel::WordCandidateList); - emit selectionListActiveItemChanged(SelectionListModel::WordCandidateList, d->activeWordIndex); + { + QString word = d->wordCandidates.wordAt(0); + if (!word.isEmpty()) { + word.remove(word.length() - 1, 1); + ic->setPreeditText(word); + if (!word.isEmpty()) { + d->wordCandidates.updateWord(0, word); + if (d->updateSuggestions()) { + emit selectionListChanged(SelectionListModel::WordCandidateList); + emit selectionListActiveItemChanged(SelectionListModel::WordCandidateList, d->wordCandidates.index()); + } + } else { + d->reset(); } accept = true; } break; + } default: if (inputMethodHints.testFlag(Qt::ImhNoPredictiveText)) break; @@ -127,10 +135,11 @@ bool HunspellInputMethod::keyEvent(Qt::Key key, const QString &text, Qt::Keyboar } if (text.length() > 0) { QChar c = text.at(0); - bool addToWord = d->isValidInputChar(c) && (!d->word.isEmpty() || !d->isJoiner(c)); + QString word = d->wordCandidates.wordAt(0); + bool addToWord = d->isValidInputChar(c) && (!word.isEmpty() || !d->isJoiner(c)); if (addToWord) { /* Automatic space insertion. */ - if (d->word.isEmpty()) { + if (word.isEmpty()) { QString surroundingText = ic->surroundingText(); int cursorPosition = ic->cursorPosition(); /* Rules for automatic space insertion: @@ -144,7 +153,7 @@ bool HunspellInputMethod::keyEvent(Qt::Key key, const QString &text, Qt::Keyboar if (!lastChar.isSpace() && lastChar != Qt::Key_Minus && d->isAutoSpaceAllowed()) { - ic->commit(" "); + ic->commit(QLatin1String(" ")); } } } @@ -152,21 +161,22 @@ bool HunspellInputMethod::keyEvent(Qt::Key key, const QString &text, Qt::Keyboar pre-edit text. The update is triggered if the text editor has a selection which the pre-edit text will replace. */ - d->ignoreUpdate = d->word.isEmpty(); - d->word.append(text); - ic->setPreeditText(d->word); + d->ignoreUpdate = word.isEmpty(); + word.append(text); + d->wordCandidates.updateWord(0, word); + ic->setPreeditText(word); d->ignoreUpdate = false; if (d->updateSuggestions()) { emit selectionListChanged(SelectionListModel::WordCandidateList); - emit selectionListActiveItemChanged(SelectionListModel::WordCandidateList, d->activeWordIndex); + emit selectionListActiveItemChanged(SelectionListModel::WordCandidateList, d->wordCandidates.index()); } accept = true; } else if (text.length() > 1) { - bool addSpace = !d->word.isEmpty() || d->autoSpaceAllowed; + bool addSpace = !word.isEmpty() || d->autoSpaceAllowed; update(); d->autoSpaceAllowed = true; if (addSpace && d->isAutoSpaceAllowed()) - ic->commit(" "); + ic->commit(QLatin1String(" ")); ic->commit(text); d->autoSpaceAllowed = addSpace; accept = true; @@ -185,7 +195,10 @@ bool HunspellInputMethod::keyEvent(Qt::Key key, const QString &text, Qt::Keyboar QList<SelectionListModel::Type> HunspellInputMethod::selectionLists() { Q_D(const HunspellInputMethod); - Qt::InputMethodHints inputMethodHints = inputContext()->inputMethodHints(); + InputContext *ic = inputContext(); + if (!ic) + return QList<SelectionListModel::Type>(); + Qt::InputMethodHints inputMethodHints = ic->inputMethodHints(); if (d->dictionaryState == HunspellInputMethodPrivate::DictionaryNotLoaded || inputMethodHints.testFlag(Qt::ImhNoPredictiveText) || inputMethodHints.testFlag(Qt::ImhHiddenText)) return QList<SelectionListModel::Type>(); return QList<SelectionListModel::Type>() << SelectionListModel::WordCandidateList; @@ -195,7 +208,7 @@ int HunspellInputMethod::selectionListItemCount(SelectionListModel::Type type) { Q_UNUSED(type) Q_D(HunspellInputMethod); - return d->wordCandidates.count(); + return d->wordCandidates.size(); } QVariant HunspellInputMethod::selectionListData(SelectionListModel::Type type, int index, int role) @@ -204,15 +217,28 @@ QVariant HunspellInputMethod::selectionListData(SelectionListModel::Type type, i Q_D(HunspellInputMethod); switch (role) { case SelectionListModel::DisplayRole: - result = QVariant(d->wordCandidates.at(index)); + result = QVariant(d->wordCandidates.wordAt(index)); break; case SelectionListModel::WordCompletionLengthRole: { - const QString wordCandidate(d->wordCandidates.at(index)); - int wordCompletionLength = wordCandidate.length() - d->word.length(); - result.setValue((wordCompletionLength > 0 && wordCandidate.startsWith(d->word)) ? wordCompletionLength : 0); + const QString wordCandidate(d->wordCandidates.wordAt(index)); + const QString word(d->wordCandidates.wordAt(0)); + int wordCompletionLength = wordCandidate.length() - word.length(); + result.setValue((wordCompletionLength > 0 && wordCandidate.startsWith(word)) ? wordCompletionLength : 0); break; } + case SelectionListModel::DictionaryTypeRole: + { + const QString wordCandidate(d->wordCandidates.wordAt(index)); + SelectionListModel::DictionaryType dictionaryType = + d->userDictionaryWords && d->userDictionaryWords->contains(wordCandidate) ? + SelectionListModel::UserDictionary : SelectionListModel::DefaultDictionary; + result = QVariant(static_cast<int>(dictionaryType)); + break; + } + case SelectionListModel::CanRemoveSuggestionRole: + result.setValue(index > 0 && d->wordCandidates.wordFlagsAt(index).testFlag(HunspellWordList::SpellCheckOk)); + break; default: result = AbstractInputMethod::selectionListData(type, index, role); break; @@ -224,16 +250,33 @@ void HunspellInputMethod::selectionListItemSelected(SelectionListModel::Type typ { Q_UNUSED(type) Q_D(HunspellInputMethod); - QString finalWord = d->wordCandidates.at(index); + d->wordCandidates.setIndex(index); + d->addToDictionary(); + QString finalWord = d->wordCandidates.wordAt(index); reset(); inputContext()->commit(finalWord); d->autoSpaceAllowed = true; } +bool HunspellInputMethod::selectionListRemoveItem(SelectionListModel::Type type, int index) +{ + Q_D(HunspellInputMethod); + Q_UNUSED(type) + + if (index <= 0 || index >= d->wordCandidates.size()) + return false; + + QString word = d->wordCandidates.wordAt(index); + d->removeFromDictionary(word); + + return true; +} + bool HunspellInputMethod::reselect(int cursorPosition, const InputEngine::ReselectFlags &reselectFlags) { Q_D(HunspellInputMethod); - Q_ASSERT(d->word.isEmpty()); + QString word(d->wordCandidates.wordAt(0)); + Q_ASSERT(word.isEmpty()); if (d->dictionaryState == HunspellInputMethodPrivate::DictionaryNotLoaded) return false; @@ -250,61 +293,54 @@ bool HunspellInputMethod::reselect(int cursorPosition, const InputEngine::Resele QChar c = surroundingText.at(i); if (!d->isValidInputChar(c)) break; - d->word.insert(0, c); + word.insert(0, c); --replaceFrom; } - while (replaceFrom < 0 && d->isJoiner(d->word.at(0))) { - d->word.remove(0, 1); + while (replaceFrom < 0 && d->isJoiner(word.at(0))) { + word.remove(0, 1); ++replaceFrom; } } - if (reselectFlags.testFlag(InputEngine::WordAtCursor) && replaceFrom == 0) { - d->word.clear(); + if (reselectFlags.testFlag(InputEngine::WordAtCursor) && replaceFrom == 0) return false; - } if (reselectFlags.testFlag(InputEngine::WordAfterCursor)) { for (int i = cursorPosition; i < surroundingText.length(); ++i) { QChar c = surroundingText.at(i); if (!d->isValidInputChar(c)) break; - d->word.append(c); + word.append(c); } - while (replaceFrom > -d->word.length()) { - int lastPos = d->word.length() - 1; - if (!d->isJoiner(d->word.at(lastPos))) + while (replaceFrom > -word.length()) { + int lastPos = word.length() - 1; + if (!d->isJoiner(word.at(lastPos))) break; - d->word.remove(lastPos, 1); + word.remove(lastPos, 1); } } - if (d->word.isEmpty()) + if (word.isEmpty()) return false; - if (reselectFlags.testFlag(InputEngine::WordAtCursor) && replaceFrom == -d->word.length()) { - d->word.clear(); + if (reselectFlags.testFlag(InputEngine::WordAtCursor) && replaceFrom == -word.length()) return false; - } - if (d->isJoiner(d->word.at(0))) { - d->word.clear(); + if (d->isJoiner(word.at(0))) return false; - } - if (d->isJoiner(d->word.at(d->word.length() - 1))) { - d->word.clear(); + if (d->isJoiner(word.at(word.length() - 1))) return false; - } - ic->setPreeditText(d->word, QList<QInputMethodEvent::Attribute>(), replaceFrom, d->word.length()); + d->wordCandidates.updateWord(0, word); + ic->setPreeditText(word, QList<QInputMethodEvent::Attribute>(), replaceFrom, word.length()); d->autoSpaceAllowed = false; if (d->updateSuggestions()) { emit selectionListChanged(SelectionListModel::WordCandidateList); - emit selectionListActiveItemChanged(SelectionListModel::WordCandidateList, d->activeWordIndex); + emit selectionListActiveItemChanged(SelectionListModel::WordCandidateList, d->wordCandidates.index()); } return true; @@ -321,37 +357,50 @@ void HunspellInputMethod::update() Q_D(HunspellInputMethod); if (d->ignoreUpdate) return; - if (!d->word.isEmpty()) { - QString finalWord = d->hasSuggestions() ? d->wordCandidates.at(d->activeWordIndex) : d->word; - d->reset(); - inputContext()->commit(finalWord); + + QString finalWord; + if (!d->wordCandidates.isEmpty()) { + d->addToDictionary(); + finalWord = d->wordCandidates.wordAt(d->wordCandidates.index()); } + d->reset(); + inputContext()->commit(finalWord); d->autoSpaceAllowed = false; } -void HunspellInputMethod::updateSuggestions(const QStringList &wordList, int activeWordIndex) +void HunspellInputMethod::updateSuggestions(const QSharedPointer<HunspellWordList> &wordList, int tag) { Q_D(HunspellInputMethod); if (d->dictionaryState == HunspellInputMethodPrivate::DictionaryNotLoaded) { + qCDebug(lcHunspell) << "updateSuggestions: skip (dictionary not loaded)"; update(); return; } - d->wordCandidates.clear(); - d->wordCandidates.append(wordList); - // Make sure the exact match is up-to-date - if (!d->word.isEmpty() && !d->wordCandidates.isEmpty() && d->wordCandidates.at(0) != d->word) - d->wordCandidates.replace(0, d->word); - d->activeWordIndex = activeWordIndex; + if (d->wordCandidatesUpdateTag != tag) { + qCDebug(lcHunspell) << "updateSuggestions: skip tag" << tag << "current" << d->wordCandidatesUpdateTag; + return; + } + QString word(d->wordCandidates.wordAt(0)); + d->wordCandidates = *wordList; + if (d->wordCandidates.wordAt(0).compare(word) != 0) + d->wordCandidates.updateWord(0, word); emit selectionListChanged(SelectionListModel::WordCandidateList); - emit selectionListActiveItemChanged(SelectionListModel::WordCandidateList, d->activeWordIndex); + emit selectionListActiveItemChanged(SelectionListModel::WordCandidateList, d->wordCandidates.index()); } void HunspellInputMethod::dictionaryLoadCompleted(bool success) { Q_D(HunspellInputMethod); + InputContext *ic = inputContext(); + if (!ic) + return; + + QList<SelectionListModel::Type> oldSelectionLists = selectionLists(); d->dictionaryState = success ? HunspellInputMethodPrivate::DictionaryReady : HunspellInputMethodPrivate::DictionaryNotLoaded; - emit selectionListsChanged(); + QList<SelectionListModel::Type> newSelectionLists = selectionLists(); + if (oldSelectionLists != newSelectionLists) + emit selectionListsChanged(); } } // namespace QtVirtualKeyboard diff --git a/src/plugins/hunspell/hunspellinputmethod/hunspellinputmethod_p.cpp b/src/plugins/hunspell/hunspellinputmethod/hunspellinputmethod_p.cpp index 3a6777b2..2027a987 100644 --- a/src/plugins/hunspell/hunspellinputmethod/hunspellinputmethod_p.cpp +++ b/src/plugins/hunspell/hunspellinputmethod/hunspellinputmethod_p.cpp @@ -34,10 +34,13 @@ #include <QDir> #include <QTextCodec> #include <QtCore/QLibraryInfo> +#include <QStandardPaths> QT_BEGIN_NAMESPACE namespace QtVirtualKeyboard { +const int HunspellInputMethodPrivate::userDictionaryMaxSize = 100; + /*! \class QtVirtualKeyboard::HunspellInputMethodPrivate \internal @@ -47,13 +50,13 @@ HunspellInputMethodPrivate::HunspellInputMethodPrivate(HunspellInputMethod *q_pt q_ptr(q_ptr), hunspellWorker(new HunspellWorker()), locale(), - word(), - wordCandidates(), - activeWordIndex(-1), wordCompletionPoint(2), ignoreUpdate(false), autoSpaceAllowed(false), - dictionaryState(DictionaryNotLoaded) + dictionaryState(DictionaryNotLoaded), + userDictionaryWords(new HunspellWordList(userDictionaryMaxSize)), + blacklistedWords(new HunspellWordList(userDictionaryMaxSize)), + wordCandidatesUpdateTag(0) { if (hunspellWorker) hunspellWorker->start(); @@ -69,7 +72,8 @@ bool HunspellInputMethodPrivate::createHunspell(const QString &locale) if (!hunspellWorker) return false; if (this->locale != locale) { - hunspellWorker->removeAllTasks(); + clearSuggestionsRelatedTasks(); + hunspellWorker->waitForAllTasks(); QString hunspellDataPath(qEnvironmentVariable("QT_VIRTUALKEYBOARD_HUNSPELL_DATA_PATH")); const QString pathListSep( #if defined(Q_OS_WIN32) @@ -96,55 +100,65 @@ bool HunspellInputMethodPrivate::createHunspell(const QString &locale) emit q->selectionListsChanged(); hunspellWorker->addTask(loadDictionaryTask); this->locale = locale; + + loadCustomDictionary(userDictionaryWords, QLatin1String("userdictionary")); + addToHunspell(userDictionaryWords); + loadCustomDictionary(blacklistedWords, QLatin1String("blacklist")); + removeFromHunspell(blacklistedWords); } return true; } void HunspellInputMethodPrivate::reset() { - if (clearSuggestions()) { + if (clearSuggestions(true)) { Q_Q(HunspellInputMethod); emit q->selectionListChanged(SelectionListModel::WordCandidateList); - emit q->selectionListActiveItemChanged(SelectionListModel::WordCandidateList, activeWordIndex); + emit q->selectionListActiveItemChanged(SelectionListModel::WordCandidateList, wordCandidates.index()); } - word.clear(); autoSpaceAllowed = false; } bool HunspellInputMethodPrivate::updateSuggestions() { bool wordCandidateListChanged = false; + QString word = wordCandidates.wordAt(0); if (!word.isEmpty() && dictionaryState != HunspellInputMethodPrivate::DictionaryNotLoaded) { - if (hunspellWorker) - hunspellWorker->removeAllTasksExcept<HunspellLoadDictionaryTask>(); - if (wordCandidates.isEmpty()) { - wordCandidates.append(word); - activeWordIndex = 0; - wordCandidateListChanged = true; - } else if (wordCandidates.at(0) != word) { - wordCandidates.replace(0, word); - activeWordIndex = 0; - wordCandidateListChanged = true; - } + wordCandidateListChanged = true; if (word.length() >= wordCompletionPoint) { if (hunspellWorker) { - QSharedPointer<HunspellWordList> wordList(new HunspellWordList()); + QSharedPointer<HunspellWordList> wordList(new HunspellWordList(wordCandidates)); + + // Clear obsolete tasks from the worker queue + clearSuggestionsRelatedTasks(); + + // Build suggestions QSharedPointer<HunspellBuildSuggestionsTask> buildSuggestionsTask(new HunspellBuildSuggestionsTask()); - buildSuggestionsTask->word = word; buildSuggestionsTask->wordList = wordList; buildSuggestionsTask->autoCorrect = false; hunspellWorker->addTask(buildSuggestionsTask); + + // Filter out blacklisted word (sometimes Hunspell suggests, + // e.g. with different text case) + QSharedPointer<HunspellFilterWordTask> filterWordTask(new HunspellFilterWordTask()); + filterWordTask->wordList = wordList; + filterWordTask->filterList = blacklistedWords; + hunspellWorker->addTask(filterWordTask); + + // Boost words from user dictionary + QSharedPointer<HunspellBoostWordTask> boostWordTask(new HunspellBoostWordTask()); + boostWordTask->wordList = wordList; + boostWordTask->boostList = userDictionaryWords; + hunspellWorker->addTask(boostWordTask); + + // Update word candidate list QSharedPointer<HunspellUpdateSuggestionsTask> updateSuggestionsTask(new HunspellUpdateSuggestionsTask()); updateSuggestionsTask->wordList = wordList; + updateSuggestionsTask->tag = ++wordCandidatesUpdateTag; Q_Q(HunspellInputMethod); - q->connect(updateSuggestionsTask.data(), SIGNAL(updateSuggestions(QStringList, int)), SLOT(updateSuggestions(QStringList, int))); + QObject::connect(updateSuggestionsTask.data(), &HunspellUpdateSuggestionsTask::updateSuggestions, q, &HunspellInputMethod::updateSuggestions); hunspellWorker->addTask(updateSuggestionsTask); } - } else if (wordCandidates.length() > 1) { - wordCandidates.clear(); - wordCandidates.append(word); - activeWordIndex = 0; - wordCandidateListChanged = true; } } else { wordCandidateListChanged = clearSuggestions(); @@ -152,20 +166,20 @@ bool HunspellInputMethodPrivate::updateSuggestions() return wordCandidateListChanged; } -bool HunspellInputMethodPrivate::clearSuggestions() +bool HunspellInputMethodPrivate::clearSuggestions(bool clearInputWord) { - if (hunspellWorker) - hunspellWorker->removeAllTasksExcept<HunspellLoadDictionaryTask>(); - if (wordCandidates.isEmpty()) - return false; - wordCandidates.clear(); - activeWordIndex = -1; - return true; + clearSuggestionsRelatedTasks(); + return clearInputWord ? wordCandidates.clear() : wordCandidates.clearSuggestions(); } -bool HunspellInputMethodPrivate::hasSuggestions() const +void HunspellInputMethodPrivate::clearSuggestionsRelatedTasks() { - return !wordCandidates.isEmpty(); + if (hunspellWorker) { + hunspellWorker->removeAllTasksOfType<HunspellBuildSuggestionsTask>(); + hunspellWorker->removeAllTasksOfType<HunspellFilterWordTask>(); + hunspellWorker->removeAllTasksOfType<HunspellBoostWordTask>(); + hunspellWorker->removeAllTasksOfType<HunspellUpdateSuggestionsTask>(); + } } bool HunspellInputMethodPrivate::isAutoSpaceAllowed() const @@ -211,5 +225,114 @@ bool HunspellInputMethodPrivate::isJoiner(const QChar &c) const return false; } +QString HunspellInputMethodPrivate::customDictionaryLocation(const QString &dictionaryType) const +{ + if (dictionaryType.isEmpty() || locale.isEmpty()) + return QString(); + + QString location = QStandardPaths::writableLocation(QStandardPaths::GenericConfigLocation); + if (location.isEmpty()) + return QString(); + + return QStringLiteral("%1/qtvirtualkeyboard/hunspell/%2-%3.txt") + .arg(location) + .arg(dictionaryType) + .arg(locale); +} + +void HunspellInputMethodPrivate::loadCustomDictionary(const QSharedPointer<HunspellWordList> &wordList, + const QString &dictionaryType) const +{ + QSharedPointer<HunspellLoadWordListTask> loadWordsTask(new HunspellLoadWordListTask()); + loadWordsTask->filePath = customDictionaryLocation(dictionaryType); + loadWordsTask->wordList = wordList; + hunspellWorker->addTask(loadWordsTask); +} + +void HunspellInputMethodPrivate::saveCustomDictionary(const QSharedPointer<HunspellWordList> &wordList, + const QString &dictionaryType) const +{ + QSharedPointer<HunspellSaveWordListTask> saveWordsTask(new HunspellSaveWordListTask()); + saveWordsTask->filePath = customDictionaryLocation(dictionaryType); + saveWordsTask->wordList = wordList; + hunspellWorker->addTask(saveWordsTask); +} + +void HunspellInputMethodPrivate::addToHunspell(const QSharedPointer<HunspellWordList> &wordList) const +{ + QSharedPointer<HunspellAddWordTask> addWordTask(new HunspellAddWordTask()); + addWordTask->wordList = wordList; + hunspellWorker->addTask(addWordTask); +} + +void HunspellInputMethodPrivate::removeFromHunspell(const QSharedPointer<HunspellWordList> &wordList) const +{ + QSharedPointer<HunspellRemoveWordTask> removeWordTask(new HunspellRemoveWordTask()); + removeWordTask->wordList = wordList; + hunspellWorker->addTask(removeWordTask); +} + +void HunspellInputMethodPrivate::removeFromDictionary(const QString &word) +{ + if (userDictionaryWords->removeWord(word) > 0) { + saveCustomDictionary(userDictionaryWords, QLatin1String("userdictionary")); + } else if (!blacklistedWords->contains(word)) { + blacklistedWords->appendWord(word); + saveCustomDictionary(blacklistedWords, QLatin1String("blacklist")); + } + + QSharedPointer<HunspellWordList> wordList(new HunspellWordList()); + wordList->appendWord(word); + removeFromHunspell(wordList); + + updateSuggestions(); +} + +void HunspellInputMethodPrivate::addToDictionary() +{ + Q_Q(HunspellInputMethod); + // This feature is not allowed when dealing with sensitive information + const Qt::InputMethodHints inputMethodHints(q->inputContext()->inputMethodHints()); + const bool userDictionaryEnabled = + !inputMethodHints.testFlag(Qt::ImhHiddenText) && + !inputMethodHints.testFlag(Qt::ImhSensitiveData); + if (!userDictionaryEnabled) + return; + + if (wordCandidates.isEmpty()) + return; + + QString word; + HunspellWordList::Flags wordFlags; + const int activeWordIndex = wordCandidates.index(); + wordCandidates.wordAt(activeWordIndex, word, wordFlags); + if (activeWordIndex == 0) { + if (blacklistedWords->removeWord(word) > 0) { + saveCustomDictionary(blacklistedWords, QLatin1String("blacklist")); + } else if (word.length() > 1 && !wordFlags.testFlag(HunspellWordList::SpellCheckOk) && !userDictionaryWords->contains(word)) { + userDictionaryWords->appendWord(word); + saveCustomDictionary(userDictionaryWords, QLatin1String("userdictionary")); + } else { + // Avoid adding words to Hunspell which are too short or passed spell check + return; + } + + QSharedPointer<HunspellWordList> wordList(new HunspellWordList()); + wordList->appendWord(word); + addToHunspell(wordList); + } else { + // Check if found in the user dictionary and move as last in the list. + // This way the list is always ordered by use. + // If userDictionaryMaxSize is greater than zero the number of words in the + // list will be limited to that amount. By pushing last used items to end of + // list we can avoid (to certain extent) removing frequently used words. + int userDictionaryIndex = userDictionaryWords->indexOfWord(word); + if (userDictionaryIndex != -1) { + userDictionaryWords->moveWord(userDictionaryIndex, userDictionaryWords->size() - 1); + saveCustomDictionary(userDictionaryWords, QLatin1String("userdictionary")); + } + } +} + } // namespace QtVirtualKeyboard QT_END_NAMESPACE diff --git a/src/plugins/hunspell/hunspellinputmethod/hunspellinputmethod_p.h b/src/plugins/hunspell/hunspellinputmethod/hunspellinputmethod_p.h index 68a4e702..bb9548c0 100644 --- a/src/plugins/hunspell/hunspellinputmethod/hunspellinputmethod_p.h +++ b/src/plugins/hunspell/hunspellinputmethod/hunspellinputmethod_p.h @@ -47,6 +47,7 @@ QT_BEGIN_NAMESPACE namespace QtVirtualKeyboard { class HunspellInputMethodPrivate; +class HunspellWordList; class HunspellInputMethod : public AbstractInputMethod { @@ -68,6 +69,7 @@ public: int selectionListItemCount(SelectionListModel::Type type); QVariant selectionListData(SelectionListModel::Type type, int index, int role); void selectionListItemSelected(SelectionListModel::Type type, int index); + bool selectionListRemoveItem(SelectionListModel::Type type, int index); bool reselect(int cursorPosition, const InputEngine::ReselectFlags &reselectFlags); @@ -75,7 +77,7 @@ public: void update(); protected Q_SLOTS: - void updateSuggestions(const QStringList &wordList, int activeWordIndex); + void updateSuggestions(const QSharedPointer<HunspellWordList> &wordList, int tag); void dictionaryLoadCompleted(bool success); protected: diff --git a/src/plugins/hunspell/hunspellinputmethod/hunspellinputmethod_p_p.h b/src/plugins/hunspell/hunspellinputmethod/hunspellinputmethod_p_p.h index ebf202fd..8bb75f69 100644 --- a/src/plugins/hunspell/hunspellinputmethod/hunspellinputmethod_p_p.h +++ b/src/plugins/hunspell/hunspellinputmethod/hunspellinputmethod_p_p.h @@ -64,22 +64,31 @@ public: bool createHunspell(const QString &locale); void reset(); bool updateSuggestions(); - bool clearSuggestions(); - bool hasSuggestions() const; + bool clearSuggestions(bool clearInputWord = false); + void clearSuggestionsRelatedTasks(); bool isAutoSpaceAllowed() const; bool isValidInputChar(const QChar &c) const; bool isJoiner(const QChar &c) const; + QString customDictionaryLocation(const QString &dictionaryType) const; + void loadCustomDictionary(const QSharedPointer<HunspellWordList> &wordList, const QString &dictionaryType) const; + void saveCustomDictionary(const QSharedPointer<HunspellWordList> &wordList, const QString &dictionaryType) const; + void addToHunspell(const QSharedPointer<HunspellWordList> &wordList) const; + void removeFromHunspell(const QSharedPointer<HunspellWordList> &wordList) const; + void removeFromDictionary(const QString &word); + void addToDictionary(); HunspellInputMethod *q_ptr; QScopedPointer<HunspellWorker> hunspellWorker; QString locale; - QString word; - QStringList wordCandidates; - int activeWordIndex; + HunspellWordList wordCandidates; int wordCompletionPoint; bool ignoreUpdate; bool autoSpaceAllowed; DictionaryState dictionaryState; + QSharedPointer<HunspellWordList> userDictionaryWords; + QSharedPointer<HunspellWordList> blacklistedWords; + int wordCandidatesUpdateTag; + static const int userDictionaryMaxSize; }; } // namespace QtVirtualKeyboard diff --git a/src/plugins/hunspell/hunspellinputmethod/hunspellworker.cpp b/src/plugins/hunspell/hunspellinputmethod/hunspellworker.cpp index 46cbf49c..6387ee16 100644 --- a/src/plugins/hunspell/hunspellinputmethod/hunspellworker.cpp +++ b/src/plugins/hunspell/hunspellinputmethod/hunspellworker.cpp @@ -28,17 +28,308 @@ ****************************************************************************/ #include <QtHunspellInputMethod/private/hunspellworker_p.h> -#include <QLoggingCategory> #include <QVector> #include <QTextCodec> #include <QFileInfo> #include <QRegularExpression> #include <QTime> +#include <QFile> +#include <QDir> +#include <QtAlgorithms> QT_BEGIN_NAMESPACE namespace QtVirtualKeyboard { -Q_DECLARE_LOGGING_CATEGORY(lcHunspell) +HunspellWordList::HunspellWordList(int limit) : + _index(0), + _limit(limit) +{ +} + +HunspellWordList::HunspellWordList(HunspellWordList &other) +{ + *this = other; +} + +HunspellWordList &HunspellWordList::operator=(HunspellWordList &other) +{ + if (this != &other) { + QMutexLocker guard(&_lock); + QMutexLocker otherGuard(&other._lock); + _list = other._list; + _flags = other._flags; + _index = other._index; + _limit = other._limit; + _searchIndex = other._searchIndex; + } + return *this; +} + +int HunspellWordList::index() const +{ + return _index < _list.size() ? _index : -1; +} + +void HunspellWordList::setIndex(int index) +{ + QMutexLocker guard(&_lock); + _index = index; +} + +bool HunspellWordList::clear() +{ + QMutexLocker guard(&_lock); + bool result = !_list.isEmpty(); + _list.clear(); + _flags.clear(); + _index = 0; + _searchIndex.clear(); + return result; +} + +bool HunspellWordList::clearSuggestions() +{ + QMutexLocker guard(&_lock); + if (_list.isEmpty()) + return false; + + _searchIndex.clear(); + if (_list.size() > 1) { + QString word = _list.at(0); + Flags flags = _flags.at(0); + _list.clear(); + _flags.clear(); + if (!word.isEmpty()) { + _index = 0; + _list.append(word); + _flags.append(flags); + } + return true; + } else if (_list.at(0).isEmpty()) { + _list.clear(); + _flags.clear(); + _index = 0; + return true; + } + return false; +} + +bool HunspellWordList::hasSuggestions() const +{ + return _list.size() > 1; +} + +int HunspellWordList::size() const +{ + return _list.size(); +} + +int HunspellWordList::isEmpty() const +{ + return _list.isEmpty() || _list.at(0).isEmpty(); +} + +bool HunspellWordList::contains(const QString &word) +{ + QMutexLocker guard(&_lock); + + // Use index search when the search index is available. + // This provides a lot faster search than QList::contains(). + // Search index is available when it has been rebuilt using + // rebuildSearchIndex() method. Search index is automatically + // cleared when the word list is modified. + if (!_searchIndex.isEmpty()) { + Q_ASSERT(_searchIndex.size() == _list.size()); + + SearchContext searchContext(word, _list); + return std::binary_search(_searchIndex.begin(), _searchIndex.end(), -1, [searchContext](const int &a, const int &b) { + const QString &wordA = (a == -1) ? searchContext.word : searchContext.list[a]; + const QString &wordB = (b == -1) ? searchContext.word : searchContext.list[b]; + return wordA.compare(wordB, Qt::CaseInsensitive) < 0; + }); + } + + return _list.contains(word, Qt::CaseInsensitive); +} + +QString HunspellWordList::findWordCompletion(const QString &word) +{ + QMutexLocker guard(&_lock); + + if (!_searchIndex.isEmpty()) { + Q_ASSERT(_searchIndex.size() == _list.size()); + + SearchContext searchContext(word, _list); + auto match = std::lower_bound(_searchIndex.begin(), _searchIndex.end(), -1, [searchContext](const int &a, const int &b) { + const QString &wordA = (a == -1) ? searchContext.word : searchContext.list[a]; + const QString &wordB = (b == -1) ? searchContext.word : searchContext.list[b]; + return wordA.compare(wordB, Qt::CaseInsensitive) < 0; + }); + + if (match == _searchIndex.end()) + return QString(); + + if (!word.compare(_list[*match], Qt::CaseInsensitive)) { + match++; + if (match == _searchIndex.end()) + return QString(); + } + + return _list[*match].startsWith(word, Qt::CaseInsensitive) ? _list[*match] : QString(); + } + + QString bestMatch; + for (int i = 0, count = _list.size(); i < count; ++i) { + const QString &wordB(_list[i]); + if (wordB.length() > bestMatch.length() && + word.length() < wordB.length() && + wordB.startsWith(word, Qt::CaseInsensitive)) + bestMatch = wordB; + } + + return bestMatch; +} + +int HunspellWordList::indexOfWord(const QString &word) +{ + QMutexLocker guard(&_lock); + + if (!_searchIndex.isEmpty()) { + Q_ASSERT(_searchIndex.size() == _list.size()); + + SearchContext searchContext(word, _list); + auto match = std::lower_bound(_searchIndex.begin(), _searchIndex.end(), -1, [searchContext](int a, int b) { + const QString &wordA = (a == -1) ? searchContext.word : searchContext.list[a]; + const QString &wordB = (b == -1) ? searchContext.word : searchContext.list[b]; + return wordA.compare(wordB, Qt::CaseInsensitive) < 0; + }); + return (match != _searchIndex.end()) ? *match : -1; + } + + return _list.indexOf(word); +} + +QString HunspellWordList::wordAt(int index) +{ + QMutexLocker guard(&_lock); + + return index >= 0 && index < _list.size() ? _list.at(index) : QString(); +} + +void HunspellWordList::wordAt(int index, QString &word, Flags &flags) +{ + QMutexLocker guard(&_lock); + Q_ASSERT(index >= 0 && index < _list.size()); + + word = _list.at(index); + flags = _flags.at(index); +} + +const HunspellWordList::Flags &HunspellWordList::wordFlagsAt(int index) +{ + QMutexLocker guard(&_lock); + + return _flags[index]; +} + +void HunspellWordList::appendWord(const QString &word, const Flags &flags) +{ + QMutexLocker guard(&_lock); + + _searchIndex.clear(); + if (_limit > 0) { + while (_list.size() >= _limit) { + _list.removeAt(0); + _flags.removeAt(0); + } + } + _list.append(word); + _flags.append(flags); +} + +void HunspellWordList::insertWord(int index, const QString &word, const Flags &flags) +{ + QMutexLocker guard(&_lock); + Q_ASSERT(_limit == 0); + + _searchIndex.clear(); + _list.insert(index, word); + _flags.insert(index, flags); +} + +void HunspellWordList::updateWord(int index, const QString &word, const Flags &flags) +{ + Q_ASSERT(index >= 0); + QMutexLocker guard(&_lock); + + if (index < _list.size()) { + if (word != _list[index]) + _searchIndex.clear(); + _list[index] = word; + _flags[index] = flags; + } else { + _searchIndex.clear(); + _list.append(word); + _flags.append(flags); + } +} + +void HunspellWordList::moveWord(int from, int to) +{ + QMutexLocker guard(&_lock); + + if (from < 0 || from >= _list.size()) + return; + if (to < 0 || to >= _list.size()) + return; + if (from == to) + return; + + _searchIndex.clear(); + _list.move(from, to); + _flags.move(from, to); +} + +int HunspellWordList::removeWord(const QString &word) +{ + QMutexLocker guard(&_lock); + int removeCount = 0; + for (int i = 0, count = _list.size(); i < count;) { + if (!_list[i].compare(word, Qt::CaseInsensitive)) { + _list.removeAt(i); + _flags.removeAt(i); + --count; + ++removeCount; + } else { + ++i; + } + } + if (removeCount > 0) + _searchIndex.clear(); + return removeCount; +} + +void HunspellWordList::removeWordAt(int index) +{ + QMutexLocker guard(&_lock); + + _list.removeAt(index); +} + +void HunspellWordList::rebuildSearchIndex() +{ + QMutexLocker guard(&_lock); + _searchIndex.clear(); + + if (_list.isEmpty()) + return; + + _searchIndex.resize(_list.size()); + std::iota(_searchIndex.begin(), _searchIndex.end(), 0); + + const QStringList list(_list); + std::sort(_searchIndex.begin(), _searchIndex.end(), [list](int a, int b) { return list[a].compare(list[b], Qt::CaseInsensitive) < 0; }); +} /*! \class QtVirtualKeyboard::HunspellTask @@ -69,9 +360,6 @@ void HunspellLoadDictionaryTask::run() qCDebug(lcHunspell) << "HunspellLoadDictionaryTask::run(): locale:" << locale; - QTime perf; - perf.start(); - if (*hunspellPtr) { Hunspell_destroy(*hunspellPtr); *hunspellPtr = nullptr; @@ -97,13 +385,11 @@ void HunspellLoadDictionaryTask::run() by the QTextCodec. */ if (!QTextCodec::codecForName(Hunspell_get_dic_encoding(*hunspellPtr))) { - qCWarning(lcHunspell) << "The Hunspell dictionary" << dicPath << "cannot be used because it uses an unknown text codec" << QString(Hunspell_get_dic_encoding(*hunspellPtr)); + qCWarning(lcHunspell) << "The Hunspell dictionary" << dicPath << "cannot be used because it uses an unknown text codec" << QLatin1String(Hunspell_get_dic_encoding(*hunspellPtr)); Hunspell_destroy(*hunspellPtr); *hunspellPtr = nullptr; } } - - qCDebug(lcHunspell) << "HunspellLoadDictionaryTask::run(): time:" << perf.elapsed() << "ms"; } else { qCWarning(lcHunspell) << "Hunspell dictionary is missing for" << locale << ". Search paths" << searchPaths; } @@ -118,11 +404,11 @@ void HunspellLoadDictionaryTask::run() void HunspellBuildSuggestionsTask::run() { - QTime perf; - perf.start(); + if (wordList->isEmpty()) + return; - wordList->list.append(word); - wordList->index = 0; + wordList->clearSuggestions(); + QString word = wordList->wordAt(0); /* Select text codec based on the dictionary encoding. Hunspell_get_dic_encoding() should always return at least @@ -138,34 +424,45 @@ void HunspellBuildSuggestionsTask::run() /* Collect word candidates from the Hunspell suggestions. Insert word completions in the beginning of the list. */ - const int firstWordCompletionIndex = wordList->list.length(); + const int firstWordCompletionIndex = wordList->size(); int lastWordCompletionIndex = firstWordCompletionIndex; bool suggestCapitalization = false; for (int i = 0; i < n; i++) { QString wordCandidate(textCodec->toUnicode(slst[i])); - wordCandidate.replace(QChar(0x2019), '\''); - if (wordCandidate.compare(word) != 0) { - QString normalizedWordCandidate = removeAccentsAndDiacritics(wordCandidate); - /* Prioritize word Capitalization */ - if (!suggestCapitalization && !wordCandidate.compare(word, Qt::CaseInsensitive)) { - wordList->list.insert(1, wordCandidate); + wordCandidate.replace(QChar(0x2019), QLatin1Char('\'')); + QString normalizedWordCandidate = removeAccentsAndDiacritics(wordCandidate); + /* Prioritize word Capitalization */ + if (!wordCandidate.compare(word, Qt::CaseInsensitive)) { + if (suggestCapitalization) { + bool wordCandidateIsCapital = wordCandidate.at(0).isUpper(); + bool wordIsCapital = word.at(0).isUpper(); + if (wordCandidateIsCapital == wordIsCapital) { + if (wordCandidateIsCapital) + wordCandidate = wordCandidate.toLower(); + else + wordCandidate[0] = wordCandidate.at(0).toUpper(); + } + wordList->insertWord(1, wordCandidate); lastWordCompletionIndex++; suggestCapitalization = true; - /* Prioritize word completions, missing punctuation or missing accents */ - } else if (normalizedWordCandidate.startsWith(word) || - wordCandidate.contains(QChar('\''))) { - wordList->list.insert(lastWordCompletionIndex++, wordCandidate); - } else { - wordList->list.append(wordCandidate); } + /* Prioritize word completions, missing punctuation or missing accents */ + } else if ((normalizedWordCandidate.length() > word.length() && + normalizedWordCandidate.startsWith(word)) || + wordCandidate.contains(QLatin1Char('\''))) { + wordList->insertWord(lastWordCompletionIndex++, wordCandidate); + } else { + wordList->appendWord(wordCandidate); } } /* Prioritize words with missing spaces next to word completions. */ - for (int i = lastWordCompletionIndex; i < wordList->list.length(); i++) { - if (QString(wordList->list.at(i)).replace(" ", "").compare(word) == 0) { + for (int i = lastWordCompletionIndex; i < wordList->size(); i++) { + QString wordCandidate(wordList->wordAt(i)); + if (wordCandidate.contains(QLatin1String(" "))) { + wordList->updateWord(i, wordCandidate, wordList->wordFlagsAt(i) | HunspellWordList::CompoundWord); if (i != lastWordCompletionIndex) { - wordList->list.move(i, lastWordCompletionIndex); + wordList->moveWord(i, lastWordCompletionIndex); } lastWordCompletionIndex++; } @@ -178,21 +475,28 @@ void HunspellBuildSuggestionsTask::run() which may be suboptimal for the purpose, but gives some clue how much the suggested word differs from the given word. */ - if (autoCorrect && wordList->list.length() > 1 && (!spellCheck(word) || suggestCapitalization)) { - if (lastWordCompletionIndex > firstWordCompletionIndex || levenshteinDistance(word, wordList->list.at(firstWordCompletionIndex)) < 3) - wordList->index = firstWordCompletionIndex; + if (autoCorrect && wordList->size() > 1 && (!spellCheck(word) || suggestCapitalization)) { + if (lastWordCompletionIndex > firstWordCompletionIndex || levenshteinDistance(word, wordList->wordAt(firstWordCompletionIndex)) < 3) + wordList->setIndex(firstWordCompletionIndex); } } Hunspell_free_list(hunspell, &slst, n); - qCDebug(lcHunspell) << "HunspellBuildSuggestionsTask::run(): time:" << perf.elapsed() << "ms"; + for (int i = 0, count = wordList->size(); i < count; ++i) { + HunspellWordList::Flags flags; + wordList->wordAt(i, word, flags); + if (flags.testFlag(HunspellWordList::CompoundWord)) + continue; + if (Hunspell_spell(hunspell, textCodec->fromUnicode(word).constData()) != 0) + wordList->updateWord(i, word, wordList->wordFlagsAt(i) | HunspellWordList::SpellCheckOk); + } } bool HunspellBuildSuggestionsTask::spellCheck(const QString &word) { if (!hunspell) return false; - if (word.contains(QRegularExpression("[0-9]"))) + if (word.contains(QRegularExpression(QLatin1Literal("[0-9]")))) return true; return Hunspell_spell(hunspell, textCodec->fromUnicode(word).constData()) != 0; } @@ -243,7 +547,140 @@ QString HunspellBuildSuggestionsTask::removeAccentsAndDiacritics(const QString& void HunspellUpdateSuggestionsTask::run() { - emit updateSuggestions(wordList->list, wordList->index); + emit updateSuggestions(wordList, tag); +} + +void HunspellAddWordTask::run() +{ + const QTextCodec *textCodec; + textCodec = QTextCodec::codecForName(Hunspell_get_dic_encoding(hunspell)); + if (!textCodec) + return; + + QString tmpWord; + tmpWord.reserve(64); + for (int i = 0, count = wordList->size(); i < count; ++i) { + const QString word(wordList->wordAt(i)); + if (word.length() < 2) + continue; + Hunspell_add(hunspell, textCodec->fromUnicode(word).constData()); + if (HunspellAddWordTask::alternativeForm(word, tmpWord)) + Hunspell_add(hunspell, textCodec->fromUnicode(tmpWord).constData()); + } +} + +bool HunspellAddWordTask::alternativeForm(const QString &word, QString &alternativeForm) +{ + if (word.length() < 2) + return false; + if (!word.mid(1).isLower()) + return false; + + const QChar initial(word.at(0)); + const QChar newInitial = initial.isUpper() ? initial.toLower() : initial.toUpper(); + if (newInitial == initial) + return false; + + alternativeForm.truncate(0); + alternativeForm.append(word); + alternativeForm[0] = newInitial; + + return true; +} + +void HunspellRemoveWordTask::run() +{ + const QTextCodec *textCodec; + textCodec = QTextCodec::codecForName(Hunspell_get_dic_encoding(hunspell)); + if (!textCodec) + return; + + QString tmpWord; + tmpWord.reserve(64); + for (int i = 0, count = wordList->size(); i < count; ++i) { + const QString word(wordList->wordAt(i)); + if (word.isEmpty()) + continue; + Hunspell_remove(hunspell, textCodec->fromUnicode(word).constData()); + if (HunspellAddWordTask::alternativeForm(word, tmpWord)) + Hunspell_remove(hunspell, textCodec->fromUnicode(tmpWord).constData()); + } +} + +void HunspellLoadWordListTask::run() +{ + wordList->clear(); + + QFile inputFile(filePath); + if (inputFile.open(QIODevice::ReadOnly | QIODevice::Text)) { + QTextStream inStream(&inputFile); + inStream.setCodec(QTextCodec::codecForName("UTF-8")); + QString word; + word.reserve(64); + while (inStream.readLineInto(&word)) { + if (!word.isEmpty()) + wordList->appendWord(word); + } + inputFile.close(); + } +} + +void HunspellSaveWordListTask::run() +{ + QFile outputFile(filePath); + if (!QFileInfo::exists(filePath)) + QDir().mkpath(QFileInfo(filePath).absoluteDir().path()); + if (outputFile.open(QIODevice::WriteOnly | QIODevice::Text)) { + QTextStream outStream(&outputFile); + outStream.setCodec(QTextCodec::codecForName("UTF-8")); + for (int i = 0, count = wordList->size(); i < count; ++i) { + const QString word(wordList->wordAt(i)); + outStream << word.toUtf8() << '\n'; + } + outputFile.close(); + } +} + +void HunspellFilterWordTask::run() +{ + if (filterList->isEmpty()) + return; + + filterList->rebuildSearchIndex(); + + for (int i = startIndex, count = wordList->size(); i < count;) { + if (filterList->contains(wordList->wordAt(i))) { + wordList->removeWordAt(i); + --count; + } else { + ++i; + } + } +} + +void HunspellBoostWordTask::run() +{ + if (boostList->isEmpty()) + return; + + boostList->rebuildSearchIndex(); + + const QString word(wordList->wordAt(0)); + const QString wordCompletion(boostList->findWordCompletion(word)); + if (!wordCompletion.isEmpty()) { + int from = wordList->indexOfWord(wordCompletion); + if (from != 1) { + int to; + for (to = 1; to < wordList->size() && wordList->wordAt(to).startsWith(word); ++to) + ; + if (from != -1) { + if (to < from) + wordList->moveWord(from, to); + } else { + wordList->insertWord(to, wordCompletion, HunspellWordList::SpellCheckOk); + } + } + } } /*! @@ -253,11 +690,13 @@ void HunspellUpdateSuggestionsTask::run() HunspellWorker::HunspellWorker(QObject *parent) : QThread(parent), + idleSema(), taskSema(), taskLock(), hunspell(nullptr) { abort = false; + qRegisterMetaType<QSharedPointer<HunspellWordList>>("QSharedPointer<HunspellWordList>"); } HunspellWorker::~HunspellWorker() @@ -282,12 +721,30 @@ void HunspellWorker::removeAllTasks() taskList.clear(); } +void HunspellWorker::waitForAllTasks() +{ + qCDebug(lcHunspell) << "waitForAllTasks enter"; + while (isRunning()) { + idleSema.acquire(); + QMutexLocker guard(&taskLock); + if (taskList.isEmpty()) { + idleSema.release(); + break; + } + idleSema.release(); + } + qCDebug(lcHunspell) << "waitForAllTasks leave"; +} + void HunspellWorker::run() { + QTime perf; while (!abort) { + idleSema.release(); taskSema.acquire(); if (abort) break; + idleSema.acquire(); QSharedPointer<HunspellTask> currentTask; { QMutexLocker guard(&taskLock); @@ -304,7 +761,9 @@ void HunspellWorker::run() currentTask->hunspell = hunspell; else continue; + perf.start(); currentTask->run(); + qCDebug(lcHunspell) << QString(QLatin1String(currentTask->metaObject()->className()) + "::run(): time:").toLatin1().constData() << perf.elapsed() << "ms"; } } if (hunspell) { diff --git a/src/plugins/hunspell/hunspellinputmethod/hunspellworker_p.h b/src/plugins/hunspell/hunspellinputmethod/hunspellworker_p.h index 1fe61bba..5ffb34a9 100644 --- a/src/plugins/hunspell/hunspellinputmethod/hunspellworker_p.h +++ b/src/plugins/hunspell/hunspellinputmethod/hunspellworker_p.h @@ -46,6 +46,8 @@ #include <QMutex> #include <QStringList> #include <QSharedPointer> +#include <QVector> +#include <QLoggingCategory> #include <hunspell/hunspell.h> QT_BEGIN_NAMESPACE @@ -53,6 +55,65 @@ class QTextCodec; namespace QtVirtualKeyboard { +Q_DECLARE_LOGGING_CATEGORY(lcHunspell) + +class HunspellWordList +{ +public: + enum Flag + { + SpellCheckOk = 0x1, + CompoundWord = 0x2 + }; + Q_DECLARE_FLAGS(Flags, Flag) + + HunspellWordList(int limit = 0); + HunspellWordList(HunspellWordList &other); + + HunspellWordList &operator=(HunspellWordList &other); + + int index() const; + void setIndex(int index); + bool clear(); + bool clearSuggestions(); + bool hasSuggestions() const; + int size() const; + int isEmpty() const; + bool contains(const QString &word); + QString findWordCompletion(const QString &word); + int indexOfWord(const QString &word); + QString wordAt(int index); + void wordAt(int index, QString &word, Flags &flags); + const Flags &wordFlagsAt(int index); + void appendWord(const QString &word, const Flags &flags = Flags()); + void insertWord(int index, const QString &word, const Flags &flags = Flags()); + void updateWord(int index, const QString &word, const Flags &flags = Flags()); + void moveWord(int from, int to); + int removeWord(const QString &word); + void removeWordAt(int index); + void rebuildSearchIndex(); + +private: + class SearchContext { + public: + SearchContext(const QString &word, + const QStringList &list) : + word(word), + list(list) + {} + const QString &word; + const QStringList &list; + }; + +private: + QMutex _lock; + QStringList _list; + QVector<Flags> _flags; + QVector<int> _searchIndex; + int _index; + int _limit; +}; + class HunspellTask : public QObject { Q_OBJECT @@ -84,24 +145,11 @@ public: const QStringList searchPaths; }; -class HunspellWordList -{ -public: - HunspellWordList() : - list(), - index(-1) - {} - - QStringList list; - int index; -}; - class HunspellBuildSuggestionsTask : public HunspellTask { Q_OBJECT const QTextCodec *textCodec; public: - QString word; QSharedPointer<HunspellWordList> wordList; bool autoCorrect; @@ -120,7 +168,80 @@ public: void run(); signals: - void updateSuggestions(const QStringList &wordList, int activeWordIndex); + void updateSuggestions(const QSharedPointer<HunspellWordList> &wordList, int tag); + +public: + int tag; +}; + +class HunspellAddWordTask : public HunspellTask +{ + Q_OBJECT +public: + QSharedPointer<HunspellWordList> wordList; + + void run(); + + static bool alternativeForm(const QString &word, QString &alternativeForm); +}; + +class HunspellRemoveWordTask : public HunspellTask +{ + Q_OBJECT +public: + QSharedPointer<HunspellWordList> wordList; + + void run(); +}; + +class HunspellLoadWordListTask : public HunspellTask +{ + Q_OBJECT +public: + QSharedPointer<HunspellWordList> wordList; + QString filePath; + + void run(); +}; + +class HunspellSaveWordListTask : public HunspellTask +{ + Q_OBJECT +public: + QSharedPointer<HunspellWordList> wordList; + QString filePath; + + void run(); +}; + +class HunspellFilterWordTask : public HunspellTask +{ + Q_OBJECT +public: + HunspellFilterWordTask() : + HunspellTask(), + startIndex(1) + {} + + QSharedPointer<HunspellWordList> wordList; + QSharedPointer<HunspellWordList> filterList; + int startIndex; + + void run(); +}; + +class HunspellBoostWordTask : public HunspellTask +{ + Q_OBJECT +public: + HunspellBoostWordTask() : + HunspellTask() + {} + + QSharedPointer<HunspellWordList> wordList; + QSharedPointer<HunspellWordList> boostList; + + void run(); }; class HunspellWorker : public QThread @@ -132,16 +253,19 @@ public: void addTask(QSharedPointer<HunspellTask> task); void removeAllTasks(); + void waitForAllTasks(); template <class X> - void removeAllTasksExcept() { + void removeAllTasksOfType() { QMutexLocker guard(&taskLock); for (int i = 0; i < taskList.size();) { QSharedPointer<X> task(taskList[i].objectCast<X>()); - if (!task) + if (task) { + qCDebug(lcHunspell) << "Remove task" << QLatin1String(task->metaObject()->className()); taskList.removeAt(i); - else + } else { i++; + } } } @@ -154,6 +278,7 @@ private: private: friend class HunspellLoadDictionaryTask; QList<QSharedPointer<HunspellTask> > taskList; + QSemaphore idleSema; QSemaphore taskSema; QMutex taskLock; Hunhandle *hunspell; @@ -163,4 +288,6 @@ private: } // namespace QtVirtualKeyboard QT_END_NAMESPACE +Q_DECLARE_METATYPE(QSharedPointer<QT_PREPEND_NAMESPACE(QtVirtualKeyboard)::HunspellWordList>); + #endif // HUNSPELLWORKER_P_H diff --git a/src/plugins/lipi-toolkit/plugin/lipiinputmethod.cpp b/src/plugins/lipi-toolkit/plugin/lipiinputmethod.cpp index 49fadf24..86877623 100644 --- a/src/plugins/lipi-toolkit/plugin/lipiinputmethod.cpp +++ b/src/plugins/lipi-toolkit/plugin/lipiinputmethod.cpp @@ -274,6 +274,7 @@ public: // Double swipe: commit word, or insert space cancelRecognition(); #ifdef HAVE_HUNSPELL + int activeWordIndex = wordCandidates.index(); if (activeWordIndex != -1) { q->selectionListItemSelected(SelectionListModel::WordCandidateList, activeWordIndex); return; diff --git a/src/styles/SelectionListItem.qml b/src/styles/SelectionListItem.qml index f3c9fdc9..3419b7e2 100644 --- a/src/styles/SelectionListItem.qml +++ b/src/styles/SelectionListItem.qml @@ -27,7 +27,7 @@ ** ****************************************************************************/ -import QtQuick 2.0 +import QtQuick 2.11 /*! \qmltype SelectionListItem @@ -60,5 +60,10 @@ Item { selectionListItem.ListView.view.currentIndex = index selectionListItem.ListView.view.model.selectItem(index) } + onPressAndHold: { + if (index === -1) + return + selectionListItem.ListView.view.longPressItem(index) + } } } diff --git a/src/virtualkeyboard/abstractinputmethod.cpp b/src/virtualkeyboard/abstractinputmethod.cpp index 7c51f437..622e6333 100644 --- a/src/virtualkeyboard/abstractinputmethod.cpp +++ b/src/virtualkeyboard/abstractinputmethod.cpp @@ -165,6 +165,13 @@ void AbstractInputMethod::selectionListItemSelected(SelectionListModel::Type typ Q_UNUSED(index) } +bool AbstractInputMethod::selectionListRemoveItem(SelectionListModel::Type type, int index) +{ + Q_UNUSED(type) + Q_UNUSED(index) + return false; +} + /*! \since QtQuick.VirtualKeyboard 2.0 @@ -305,7 +312,7 @@ bool AbstractInputMethod::clickPreeditText(int cursorPosition) /*! \fn QVariant QtVirtualKeyboard::AbstractInputMethod::selectionListData(SelectionListModel::Type type, int index, int role) - Returns item data for the selection list identified by \a type. The \a role + Returns item data for the selection list identified by \a type. The \a \l {QtVirtualKeyboard::SelectionListModel::Role}{role} parameter specifies which data is requested. The \a index parameter is a zero based index into the list. */ @@ -318,6 +325,14 @@ bool AbstractInputMethod::clickPreeditText(int cursorPosition) */ /*! + \fn bool QtVirtualKeyboard::AbstractInputMethod::selectionListRemoveItem(SelectionListModel::Type type, int index) + + This method is called when an item at \a index must be removed from dictionary. + The selection list is identified by the \a type parameter. + The function returns \c true if the word was successfully removed. +*/ + +/*! \fn void QtVirtualKeyboard::AbstractInputMethod::selectionListChanged(int type) The input method emits this signal when the contents of the selection list diff --git a/src/virtualkeyboard/abstractinputmethod.h b/src/virtualkeyboard/abstractinputmethod.h index ed272cc9..8689d84d 100644 --- a/src/virtualkeyboard/abstractinputmethod.h +++ b/src/virtualkeyboard/abstractinputmethod.h @@ -60,6 +60,7 @@ public: virtual int selectionListItemCount(SelectionListModel::Type type); virtual QVariant selectionListData(SelectionListModel::Type type, int index, int role); virtual void selectionListItemSelected(SelectionListModel::Type type, int index); + virtual bool selectionListRemoveItem(SelectionListModel::Type type, int index); virtual QList<InputEngine::PatternRecognitionMode> patternRecognitionModes() const; virtual Trace *traceBegin(int traceId, InputEngine::PatternRecognitionMode patternRecognitionMode, diff --git a/src/virtualkeyboard/content/components/Keyboard.qml b/src/virtualkeyboard/content/components/Keyboard.qml index 8485d3c2..9bc04ad7 100644 --- a/src/virtualkeyboard/content/components/Keyboard.qml +++ b/src/virtualkeyboard/content/components/Keyboard.qml @@ -30,7 +30,7 @@ import QtQuick 2.0 import QtQuick.Layouts 1.0 import QtQuick.Window 2.2 -import QtQuick.VirtualKeyboard 2.2 +import QtQuick.VirtualKeyboard 2.3 import QtQuick.VirtualKeyboard.Styles 2.1 import QtQuick.VirtualKeyboard.Settings 2.2 import Qt.labs.folderlistmodel 2.0 @@ -184,6 +184,10 @@ Item { } break } + if (wordCandidateContextMenu.active) { + hideWordCandidateContextMenu() + break + } if (wordCandidateView.count) { if (wordCandidateView.currentIndex > 0) { wordCandidateView.decrementCurrentIndex() @@ -230,6 +234,14 @@ Item { alternativeKeys.close() keyboardInputArea.setActiveKey(null) keyboardInputArea.navigateToNextKey(0, 0, false) + } else if (wordCandidateContextMenu.active) { + if (wordCandidateContextMenuList.currentIndex > 0) { + wordCandidateContextMenuList.decrementCurrentIndex() + } else if (wordCandidateContextMenuList.keyNavigationWraps && wordCandidateContextMenuList.count > 1) { + wordCandidateContextMenuList.currentIndex = wordCandidateContextMenuList.count - 1 + } else { + hideWordCandidateContextMenu() + } } else if (keyboard.navigationModeActive && !keyboardInputArea.initialKey && wordCandidateView.count) { keyboardInputArea.navigateToNextKey(0, 0, false) initialKey = keyboardInputArea.initialKey @@ -262,6 +274,10 @@ Item { } break } + if (wordCandidateContextMenu.active) { + hideWordCandidateContextMenu() + break + } if (wordCandidateView.count) { if (wordCandidateView.currentIndex + 1 < wordCandidateView.count) { wordCandidateView.incrementCurrentIndex() @@ -308,6 +324,16 @@ Item { alternativeKeys.close() keyboardInputArea.setActiveKey(null) keyboardInputArea.navigateToNextKey(0, 0, false) + } else if (wordCandidateContextMenu.active) { + if (wordCandidateContextMenuList.currentIndex + 1 < wordCandidateContextMenuList.count) { + wordCandidateContextMenuList.incrementCurrentIndex() + } else if (wordCandidateContextMenuList.keyNavigationWraps && wordCandidateContextMenuList.count > 1) { + wordCandidateContextMenuList.currentIndex = 0 + } else { + hideWordCandidateContextMenu() + keyboardInputArea.setActiveKey(null) + keyboardInputArea.navigateToNextKey(0, 0, false) + } } else if (keyboard.navigationModeActive && !keyboardInputArea.initialKey && wordCandidateView.count) { keyboardInputArea.navigateToNextKey(0, 0, false) initialKey = keyboardInputArea.initialKey @@ -343,10 +369,10 @@ Item { keyboardInputArea.setActiveKey(keyboardInputArea.initialKey) keyboardInputArea.press(keyboardInputArea.initialKey, true) } - } else if (wordCandidateView.count > 0) { - wordCandidateView.model.selectItem(wordCandidateView.currentIndex) - if (!InputContext.preeditText.length) - keyboardInputArea.navigateToNextKey(0, 1, true) + } else if (!wordCandidateContextMenu.active && wordCandidateView.count > 0) { + if (!isAutoRepeat) { + pressAndHoldTimer.restart() + } } break default: @@ -361,13 +387,26 @@ Item { languagePopupList.model.selectItem(languagePopupList.currentIndex) break } - if (!languagePopupListActive && !alternativeKeys.active && keyboard.activeKey && !isAutoRepeat) { + if (isAutoRepeat) + break + if (!languagePopupListActive && !alternativeKeys.active && !wordCandidateContextMenu.active && keyboard.activeKey) { keyboardInputArea.release(keyboard.activeKey) pressAndHoldTimer.stop() alternativeKeys.close() keyboardInputArea.setActiveKey(null) if (!languagePopupListActive && keyboardInputArea.navigationCursor !== Qt.point(-1, -1)) keyboardInputArea.navigateToNextKey(0, 0, false) + } else if (wordCandidateContextMenu.active) { + if (!wordCandidateContextMenu.openedByNavigationKeyLongPress) { + wordCandidateContextMenu.selectCurrentItem() + keyboardInputArea.navigateToNextKey(0, 0, false) + } else { + wordCandidateContextMenu.openedByNavigationKeyLongPress = false + } + } else if (!wordCandidateContextMenu.active && wordCandidateView.count > 0) { + wordCandidateView.model.selectItem(wordCandidateView.currentIndex) + if (!InputContext.preeditText.length) + keyboardInputArea.navigateToNextKey(0, 1, true) } break default: @@ -444,6 +483,9 @@ Item { keyboardInputArea.initialKey = null if (keyboardInputArea.navigationCursor !== Qt.point(-1, -1)) keyboardInputArea.navigateToNextKey(0, 0, false) + } else if (!wordCandidateContextMenu.active) { + wordCandidateContextMenu.show(wordCandidateView.currentIndex) + wordCandidateContextMenu.openedByNavigationKeyLongPress = true } } } @@ -507,6 +549,8 @@ Item { return languagePopupList.highlightItem } else if (alternativeKeys.listView.count > 0) { return alternativeKeys.listView.highlightItem + } else if (wordCandidateContextMenu.active) { + return wordCandidateContextMenuList.highlightItem } else if (wordCandidateView.count > 0) { return wordCandidateView.highlightItem } @@ -620,6 +664,7 @@ Item { if (empty) wordCandidateViewAutoHideTimer.restart() wordCandidateView.empty = empty + keyboard.hideWordCandidateContextMenu() } } Connections { @@ -664,6 +709,10 @@ Item { } } } + + function longPressItem(index) { + return keyboard.showWordCandidateContextMenu(index) + } } Item { @@ -1012,6 +1061,7 @@ Item { } Item { + id: languagePopup z: 1 anchors.fill: parent @@ -1021,13 +1071,19 @@ Item { enabled: languagePopupList.enabled } - LanguagePopupList { + PopupList { id: languagePopupList + objectName: "languagePopupList" z: 2 anchors.left: parent.left anchors.top: parent.top enabled: false model: languageListModel + delegate: keyboard.style ? keyboard.style.languageListDelegate : null + highlight: keyboard.style ? keyboard.style.languageListHighlight : defaultHighlight + add: keyboard.style ? keyboard.style.languageListAdd : null + remove: keyboard.style ? keyboard.style.languageListRemove : null + background: keyboard.style ? keyboard.style.languageListBackground : null property rect previewRect: Qt.rect(keyboard.x + languagePopupList.x, keyboard.y + languagePopupList.y, languagePopupList.width, @@ -1058,42 +1114,165 @@ Item { } } } - } - function showLanguagePopup(parentItem, customLayoutsOnly) { - if (!languagePopupList.enabled) { - var locales = keyboard.listLocales(customLayoutsOnly, parent.externalLanguageSwitchEnabled) - if (parent.externalLanguageSwitchEnabled) { - var currentIndex = 0 + function show(locales, parentItem, customLayoutsOnly) { + if (!languagePopupList.enabled) { + languageListModel.clear() for (var i = 0; i < locales.length; i++) { - if (locales[i] === keyboard.locale) { - currentIndex = i - break - } + languageListModel.append({localeName: locales[i].name, displayName: locales[i].locale.nativeLanguageName, localeIndex: locales[i].index}) + if (locales[i].index === keyboard.localeIndex) + languagePopupList.currentIndex = i } - parent.externalLanguageSwitch(locales, currentIndex) - return + languagePopupList.positionViewAtIndex(languagePopupList.currentIndex, ListView.Center) + languagePopupList.anchors.leftMargin = Qt.binding(function() {return Math.round(keyboard.mapFromItem(parentItem, (parentItem.width - languagePopupList.width) / 2, 0).x)}) + languagePopupList.anchors.topMargin = Qt.binding(function() {return Math.round(keyboard.mapFromItem(parentItem, 0, -languagePopupList.height).y)}) + } + languagePopupList.enabled = true + } + + function hide() { + if (languagePopupList.enabled) { + languagePopupList.enabled = false + languagePopupList.anchors.leftMargin = undefined + languagePopupList.anchors.topMargin = undefined + languageListModel.clear() } - languageListModel.clear() - for (i = 0; i < locales.length; i++) { - languageListModel.append({localeName: locales[i].name, displayName: locales[i].locale.nativeLanguageName, localeIndex: locales[i].index}) - if (locales[i].index === keyboard.localeIndex) - languagePopupList.currentIndex = i + } + } + + function showLanguagePopup(parentItem, customLayoutsOnly) { + var locales = keyboard.listLocales(customLayoutsOnly, parent.externalLanguageSwitchEnabled) + if (parent.externalLanguageSwitchEnabled) { + var currentIndex = 0 + for (var i = 0; i < locales.length; i++) { + if (locales[i] === keyboard.locale) { + currentIndex = i + break + } } - languagePopupList.positionViewAtIndex(languagePopupList.currentIndex, ListView.Center) - languagePopupList.anchors.leftMargin = Qt.binding(function() {return Math.round(keyboard.mapFromItem(parentItem, (parentItem.width - languagePopupList.width) / 2, 0).x)}) - languagePopupList.anchors.topMargin = Qt.binding(function() {return Math.round(keyboard.mapFromItem(parentItem, 0, -languagePopupList.height).y)}) + parent.externalLanguageSwitch(locales, currentIndex) + return } - languagePopupList.enabled = true + languagePopup.show(locales, parentItem, customLayoutsOnly) } function hideLanguagePopup() { - if (languagePopupList.enabled) { - languagePopupList.enabled = false - languagePopupList.anchors.leftMargin = undefined - languagePopupList.anchors.topMargin = undefined - languageListModel.clear() + languagePopup.hide() + } + + MouseArea { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + height: keyboard.parent.parent ? keyboard.parent.parent.height : Screen.height + onPressed: keyboard.hideWordCandidateContextMenu() + enabled: wordCandidateContextMenuList.enabled + } + + Item { + id: wordCandidateContextMenu + objectName: "wordCandidateContextMenu" + z: 1 + anchors.fill: parent + property int previousWordCandidateIndex: -1 + readonly property bool active: wordCandidateContextMenuList.visible + property bool openedByNavigationKeyLongPress + + PopupList { + id: wordCandidateContextMenuList + objectName: "wordCandidateContextMenuList" + z: 2 + anchors.left: parent.left + anchors.top: parent.top + enabled: false + model: wordCandidateContextMenuListModel + property rect previewRect: Qt.rect(keyboard.x + wordCandidateContextMenuList.x, + keyboard.y + wordCandidateContextMenuList.y, + wordCandidateContextMenuList.width, + wordCandidateContextMenuList.height) } + + ListModel { + id: wordCandidateContextMenuListModel + + function selectItem(index) { + wordCandidateContextMenu.previousWordCandidateIndex = -1 + wordCandidateContextMenuList.currentIndex = index + keyboard.soundEffect.play(wordCandidateContextMenuList.currentItem.soundEffect) + switch (get(index).action) { + case "remove": + wordCandidateView.model.removeItem(wordCandidateView.currentIndex) + break + } + keyboard.hideWordCandidateContextMenu() + } + } + + function show(wordCandidateIndex) { + if (wordCandidateContextMenu.enabled) + wordCandidateContextMenu.hide() + + wordCandidateContextMenuListModel.clear() + + var canRemoveSuggestion = wordCandidateView.model.dataAt(wordCandidateIndex, SelectionListModel.CanRemoveSuggestionRole) + if (canRemoveSuggestion) { + var dictionaryType = wordCandidateView.model.dataAt(wordCandidateIndex, SelectionListModel.DictionaryTypeRole) + var removeItemText; + switch (dictionaryType) { + case SelectionListModel.UserDictionary: + //~ VirtualKeyboard Context menu for word suggestion if it can be removed from the user dictionary. + removeItemText = qsTr("Remove from dictionary") + break + case SelectionListModel.DefaultDictionary: + // Fallthrough + default: + //~ VirtualKeyboard Context menu for word suggestion if it can be removed from the default dictionary. + removeItemText = qsTr("Block word") + break + } + wordCandidateContextMenuListModel.append({action: "remove", display: removeItemText, wordCompletionLength: 0}) + } + + if (wordCandidateContextMenuListModel.count === 0) + return + + previousWordCandidateIndex = wordCandidateView.currentIndex + wordCandidateView.currentIndex = wordCandidateIndex + + wordCandidateContextMenuList.anchors.leftMargin = Qt.binding(function() { + var leftBorder = Math.round(wordCandidateView.mapFromItem(wordCandidateView.currentItem, (wordCandidateView.currentItem.width - wordCandidateContextMenuList.width) / 2, 0).x) + var rightBorder = Math.round(wordCandidateContextMenuList.parent.width - wordCandidateContextMenuList.width) + return Math.min(leftBorder, rightBorder) + }) + + wordCandidateContextMenuList.enabled = true + } + + function hide() { + if (wordCandidateContextMenuList.enabled) { + if (previousWordCandidateIndex !== -1) { + wordCandidateView.currentIndex = previousWordCandidateIndex + previousWordCandidateIndex = -1 + } + wordCandidateContextMenuList.enabled = false + wordCandidateContextMenuList.anchors.leftMargin = undefined + wordCandidateContextMenuListModel.clear() + } + openedByNavigationKeyLongPress = false + } + + function selectCurrentItem() { + if (active && wordCandidateContextMenuList.currentIndex !== -1) + wordCandidateContextMenuListModel.selectItem(wordCandidateContextMenuList.currentIndex) + } + } + + function showWordCandidateContextMenu(wordCandidateIndex) { + wordCandidateContextMenu.show(wordCandidateIndex) + } + + function hideWordCandidateContextMenu() { + wordCandidateContextMenu.hide() } function updateInputMethod() { diff --git a/src/virtualkeyboard/content/components/LanguagePopupList.qml b/src/virtualkeyboard/content/components/PopupList.qml index 2c8b8c99..dcd02ee1 100644 --- a/src/virtualkeyboard/content/components/LanguagePopupList.qml +++ b/src/virtualkeyboard/content/components/PopupList.qml @@ -28,15 +28,14 @@ ****************************************************************************/ import QtQuick 2.0 -import QtQuick.VirtualKeyboard 2.1 +import QtQuick.VirtualKeyboard 2.3 ListView { - id: languagePopupList - objectName: "languagePopupList" - property int maxVisibleItems: 5 readonly property int preferredVisibleItems: count < maxVisibleItems ? count : maxVisibleItems readonly property real contentWidth: contentItem.childrenRect.width + property alias background: popupListBackground.sourceComponent + property alias defaultHighlight: defaultHighlight clip: true visible: enabled && count > 0 @@ -44,12 +43,12 @@ ListView { height: currentItem ? currentItem.height * preferredVisibleItems + (spacing * preferredVisibleItems - 1) : 0 orientation: ListView.Vertical snapMode: ListView.SnapToItem - delegate: keyboard.style.languageListDelegate - highlight: keyboard.style.languageListHighlight ? keyboard.style.languageListHighlight : defaultHighlight + delegate: keyboard.style.popupListDelegate + highlight: keyboard.style.popupListHighlight ? keyboard.style.popupListHighlight : defaultHighlight highlightMoveDuration: 0 highlightResizeDuration: 0 - add: keyboard.style.languageListAdd - remove: keyboard.style.languageListRemove + add: keyboard.style.popupListAdd + remove: keyboard.style.popupListRemove keyNavigationWraps: true onCurrentItemChanged: if (currentItem) keyboard.soundEffect.register(currentItem.soundEffect) @@ -60,7 +59,8 @@ ListView { } Loader { - sourceComponent: keyboard.style.languageListBackground + id: popupListBackground + sourceComponent: keyboard.style.popupListBackground anchors.fill: parent z: -1 } diff --git a/src/virtualkeyboard/content/components/WordCandidatePopupList.qml b/src/virtualkeyboard/content/components/WordCandidatePopupList.qml index 7740cbf9..e255142a 100644 --- a/src/virtualkeyboard/content/components/WordCandidatePopupList.qml +++ b/src/virtualkeyboard/content/components/WordCandidatePopupList.qml @@ -28,12 +28,11 @@ ****************************************************************************/ import QtQuick 2.0 -import QtQuick.VirtualKeyboard 2.1 +import QtQuick.VirtualKeyboard 2.3 -ListView { +PopupList { id: wordCandidatePopupList - property int maxVisibleItems: 5 readonly property int preferredVisibleItems: { if (!currentItem) return 0 @@ -43,13 +42,10 @@ ListView { --result return result } - readonly property real contentWidth: contentItem.childrenRect.width readonly property bool flipVertical: currentItem && Qt.inputMethod.cursorRectangle.y + (Qt.inputMethod.cursorRectangle.height / 2) > (parent.height / 2) && Qt.inputMethod.cursorRectangle.y + Qt.inputMethod.cursorRectangle.height + (currentItem.height * 2) > parent.height - clip: true - visible: enabled && count > 0 height: currentItem ? currentItem.height * preferredVisibleItems + (spacing * preferredVisibleItems - 1) : 0 Binding { target: wordCandidatePopupList @@ -66,19 +62,9 @@ ListView { value: Math.round(wordCandidatePopupList.flipVertical ? Qt.inputMethod.cursorRectangle.y - wordCandidatePopupList.height : Qt.inputMethod.cursorRectangle.y + Qt.inputMethod.cursorRectangle.height) when: wordCandidatePopupList.visible } - orientation: ListView.Vertical - snapMode: ListView.SnapToItem - delegate: keyboard.style.popupListDelegate - highlight: keyboard.style.popupListHighlight ? keyboard.style.popupListHighlight : null - highlightMoveDuration: 0 - highlightResizeDuration: 0 - add: keyboard.style.popupListAdd - remove: keyboard.style.popupListRemove - keyNavigationWraps: true model: enabled ? InputContext.inputEngine.wordCandidateListModel : null onContentWidthChanged: viewResizeTimer.restart() - onCurrentItemChanged: if (currentItem) keyboard.soundEffect.register(currentItem.soundEffect) Timer { id: viewResizeTimer @@ -92,10 +78,4 @@ ListView { onActiveItemChanged: wordCandidatePopupList.currentIndex = index onItemSelected: if (wordCandidatePopupList.currentItem) keyboard.soundEffect.play(wordCandidatePopupList.currentItem.soundEffect) } - - Loader { - sourceComponent: keyboard.style.popupListBackground - anchors.fill: parent - z: -1 - } } diff --git a/src/virtualkeyboard/content/content.qrc b/src/virtualkeyboard/content/content.qrc index d29dbe33..1e6392f4 100644 --- a/src/virtualkeyboard/content/content.qrc +++ b/src/virtualkeyboard/content/content.qrc @@ -27,7 +27,7 @@ <file>components/TraceInputArea.qml</file> <file>components/HandwritingModeKey.qml</file> <file>components/WordCandidatePopupList.qml</file> - <file>components/LanguagePopupList.qml</file> + <file>components/PopupList.qml</file> <file>components/SelectionControl.qml</file> <file>components/ShadowInputControl.qml</file> <file>components/InputModeKey.qml</file> diff --git a/src/virtualkeyboard/selectionlistmodel.cpp b/src/virtualkeyboard/selectionlistmodel.cpp index 1f8f9db9..33ec8de9 100644 --- a/src/virtualkeyboard/selectionlistmodel.cpp +++ b/src/virtualkeyboard/selectionlistmodel.cpp @@ -118,6 +118,22 @@ public: the completion part expressed as the number of characters counted from the end of the string. + \value DictionaryTypeRole + An integer specifying \ l {QtVirtualKeyboard::SelectionListModel::DictionaryType}{dictionary type}. + \value CanRemoveSuggestionRole + A boolean value indicating if the word candidate + can be removed from dictionary. +*/ + +/*! + \enum QtVirtualKeyboard::SelectionListModel::DictionaryType + + This enum specifies the dictionary type of a word. + + \value DefaultDictionary + The word candidate is from the default dictionary. + \value UserDictionary + The word candidate is from the user dictionary. */ SelectionListModel::SelectionListModel(QObject *parent) : @@ -226,6 +242,14 @@ void SelectionListModel::selectItem(int index) } } +void SelectionListModel::removeItem(int index) +{ + Q_D(SelectionListModel); + if (index >= 0 && index < d->rowCount && d->dataSource) { + d->dataSource->selectionListRemoveItem(d->type, index); + } +} + /*! * \internal */ diff --git a/src/virtualkeyboard/selectionlistmodel.h b/src/virtualkeyboard/selectionlistmodel.h index ec9b84e3..22d0ed3d 100644 --- a/src/virtualkeyboard/selectionlistmodel.h +++ b/src/virtualkeyboard/selectionlistmodel.h @@ -56,11 +56,19 @@ public: enum Role { DisplayRole = Qt::DisplayRole, - WordCompletionLengthRole = Qt::UserRole + 1 + WordCompletionLengthRole = Qt::UserRole + 1, + DictionaryTypeRole, + CanRemoveSuggestionRole, + }; + enum DictionaryType + { + DefaultDictionary = 0, + UserDictionary }; Q_ENUM(Type) Q_ENUM(Role) + Q_ENUM(DictionaryType) ~SelectionListModel(); void setDataSource(AbstractInputMethod *dataSource, Type type); @@ -72,6 +80,7 @@ public: int count() const; Q_INVOKABLE void selectItem(int index); + Q_INVOKABLE void removeItem(int index); Q_INVOKABLE QVariant dataAt(int index, int role = Qt::DisplayRole) const; Q_SIGNALS: @@ -92,5 +101,6 @@ QT_END_NAMESPACE Q_DECLARE_METATYPE(QT_PREPEND_NAMESPACE(QtVirtualKeyboard)::SelectionListModel::Type) Q_DECLARE_METATYPE(QT_PREPEND_NAMESPACE(QtVirtualKeyboard)::SelectionListModel::Role) +Q_DECLARE_METATYPE(QT_PREPEND_NAMESPACE(QtVirtualKeyboard)::SelectionListModel::DictionaryType) #endif // SELECTIONLISTMODEL_H diff --git a/tests/auto/inputpanel/data/inputpanel/inputpanel.qml b/tests/auto/inputpanel/data/inputpanel/inputpanel.qml index a467fb15..c3682b8a 100644 --- a/tests/auto/inputpanel/data/inputpanel/inputpanel.qml +++ b/tests/auto/inputpanel/data/inputpanel/inputpanel.qml @@ -67,6 +67,7 @@ InputPanel { naviationHighlight.widthAnimation.running || naviationHighlight.heightAnimation.running readonly property var wordCandidateView: Utils.findChildByProperty(keyboard, "objectName", "wordCandidateView", null) + readonly property var wordCandidateContextMenu: Utils.findChildByProperty(keyboard, "objectName", "wordCandidateContextMenu", null) readonly property var shadowInputControl: Utils.findChildByProperty(keyboard, "objectName", "shadowInputControl", null) readonly property var shadowInput: Utils.findChildByProperty(keyboard, "objectName", "shadowInput", null) readonly property var selectionControl: Utils.findChildByProperty(inputPanel, "objectName", "selectionControl", null) @@ -178,6 +179,12 @@ InputPanel { } SignalSpy { + id: wordCandidateContextMenuActiveSpy + target: wordCandidateContextMenu + signalName: "onActiveChanged" + } + + SignalSpy { id: shiftStateSpy target: InputContext signalName: "onShiftChanged" @@ -530,6 +537,14 @@ InputPanel { InputContext.shiftHandler.toggleShift() } + function setShift(shift) { + InputContext.shift = shift + } + + function setCapsLock(capsLock) { + InputContext.capsLock = capsLock + } + function style() { return VirtualKeyboardSettings.styleName } @@ -583,6 +598,48 @@ InputPanel { return true } + function selectionListCurrentIndex() { + return inputPanel.wordCandidateView.currentIndex + } + + function selectionListSuggestionIsFromUserDictionary() { + if (!inputPanel.wordCandidateView.currentItem) + return false + var dictionaryType = inputPanel.wordCandidateView.model.dataAt(inputPanel.wordCandidateView.currentIndex, SelectionListModel.DictionaryTypeRole) + return dictionaryType !== undefined && dictionaryType === SelectionListModel.UserDictionary + } + + function openWordCandidateContextMenu() { + if (!inputPanel.wordCandidateView.currentItem) + return false + testcase.wait(200) + wordCandidateContextMenuActiveSpy.clear() + testcase.mousePress(inputPanel.wordCandidateView.currentItem) + wordCandidateContextMenuActiveSpy.wait() + testcase.mouseRelease(inputPanel.wordCandidateView.currentItem) + return wordCandidateContextMenu.active + } + + function selectItemFromWordCandidateContextMenu(index) { + if (!inputPanel.wordCandidateView.currentItem) + return false + if (!wordCandidateContextMenu.active) + return false + var wordCandidateContextMenuList = Utils.findChildByProperty(keyboard, "objectName", "wordCandidateContextMenuList", null) + if (wordCandidateContextMenuList.currentIndex !== index) { + wordCandidateContextMenuList.currentIndex = index + testcase.waitForRendering(inputPanel) + } + if (!wordCandidateContextMenuList.currentItem) + return false + var itemPos = inputPanel.mapFromItem(wordCandidateContextMenuList.currentItem, + wordCandidateContextMenuList.currentItem.width / 2, + wordCandidateContextMenuList.currentItem.height / 2) + testcase.mouseClick(inputPanel, itemPos.x, itemPos.y, Qt.LeftButton, 0, 20) + testcase.waitForRendering(inputPanel) + return true + } + function setHandwritingMode(enabled) { if (inputPanel.keyboard.handwritingMode !== enabled) { if (!enabled || inputPanel.keyboard.isHandwritingAvailable()) diff --git a/tests/auto/inputpanel/data/tst_inputpanel.qml b/tests/auto/inputpanel/data/tst_inputpanel.qml index 83c97593..e2efe8b0 100644 --- a/tests/auto/inputpanel/data/tst_inputpanel.qml +++ b/tests/auto/inputpanel/data/tst_inputpanel.qml @@ -1820,19 +1820,14 @@ Rectangle { skip("Prediction/spell correction not enabled") for (var len = 1; len <= 5; ++len) { - inputPanel.wordCandidateListChangedSpy.clear() inputPanel.virtualKeyClick("z") - waitForRendering(inputPanel) - if (len >= 3) { - if (data.wclAutoCommitWord) - tryVerify(function() { return inputPanel.wordCandidateView.model.count === 0 }, 500) - else - wait(500) + if (len >= 2) { + inputPanel.wordCandidateListChangedSpy.clear() + inputPanel.wordCandidateListChangedSpy.wait() if (inputPanel.wordCandidateView.model.count <= 1) break } } - waitForRendering(inputPanel) if (data.wclAutoCommitWord) compare(inputPanel.wordCandidateView.model.count, 0) @@ -2018,5 +2013,67 @@ Rectangle { compare(inputPanel.shadowInput.text, "") } + function test_userDictionary_data() { + return [ + { inputSequence: ['a','s','d','f'], initShift: false }, + { inputSequence: ['a','s','d'], initShift: false, expectedSuggestion: "asdf", suggestionIsFromUserDictionary: true }, + { inputSequence: ['a','s','d'], initShift: true, expectedSuggestion: "Asdf", suggestionIsFromUserDictionary: true }, + // + { inputSequence: ['s','d','f','a'], initShift: true }, + { inputSequence: ['s','d','f'], initShift: true, expectedSuggestion: "Sdfa", suggestionIsFromUserDictionary: true }, + { inputSequence: ['s','d','f'], initShift: false, expectedSuggestion: "sdfa", suggestionIsFromUserDictionary: true, removeSuggestion: true }, + // + { inputSequence: ['d','f','a','s'], initCapsLock: true }, + { inputSequence: ['d','f','a'], initCapsLock: true, expectedSuggestion: "DFAS", suggestionIsFromUserDictionary: true }, + { inputSequence: ['d','f','a'], initShift: false, unexpectedSuggestion: "dfas", suggestionIsFromUserDictionary: true }, + // + { inputSequence: ['f','a','s','d'], initShift: false, initInputMethodHints: Qt.ImhSensitiveData }, + { inputSequence: ['f','a','s'], initShift: false, unexpectedSuggestion: "fasd" }, + { inputSequence: ['f','a','s'], initShift: true, unexpectedSuggestion: "Fasd"}, + // + { initLocale: "en_GB", inputSequence: "windo", expectedSuggestion: "Window", suggestionIsFromUserDictionary: false, removeSuggestion: true }, + { initLocale: "en_GB", inputSequence: "window", }, + { initLocale: "en_GB", inputSequence: "windo", expectedSuggestion: "Window", suggestionIsFromUserDictionary: false }, + ] + } + + function test_userDictionary(data) { + prepareTest(data, true) + + if (!inputPanel.wordCandidateListVisibleHint) + skip("Prediction/spell correction not enabled") + + if (data.hasOwnProperty("initShift")) + inputPanel.setShift(data.initShift) + if (data.hasOwnProperty("initCapsLock")) + inputPanel.setCapsLock(data.initCapsLock) + + for (var inputIndex in data.inputSequence) + inputPanel.virtualKeyClick(data.inputSequence[inputIndex]) + + if (data.hasOwnProperty("expectedSuggestion")) { + tryVerify(function() {return inputPanel.selectionListSearchSuggestion(data.expectedSuggestion)}, 1000, "The expected spell correction suggestion \"%1\" was not found".arg(data.expectedSuggestion)) + verify(inputPanel.selectionListCurrentIndex() > 0) + if (data.hasOwnProperty("suggestionIsFromUserDictionary")) + compare(inputPanel.selectionListSuggestionIsFromUserDictionary(), data.suggestionIsFromUserDictionary) + if (data.hasOwnProperty("removeSuggestion") && data.removeSuggestion) { + verify(inputPanel.openWordCandidateContextMenu()) + inputPanel.wordCandidateListChangedSpy.clear() + verify(inputPanel.selectItemFromWordCandidateContextMenu(0)) + inputPanel.wordCandidateListChangedSpy.wait() + tryVerify(function() {return !inputPanel.selectionListSearchSuggestion(data.expectedSuggestion)}, 1000, "An unexpected spell correction suggestion \"%1\" was found".arg(data.unexpectedSuggestion)) + } else { + inputPanel.selectionListSelectCurrentItem() + } + } else if (data.hasOwnProperty("unexpectedSuggestion")) { + var oldIndex = inputPanel.selectionListCurrentIndex() + tryVerify(function() {return !inputPanel.selectionListSearchSuggestion(data.unexpectedSuggestion)}, 1000, "An unexpected spell correction suggestion \"%1\" was found".arg(data.unexpectedSuggestion)) + compare(inputPanel.selectionListCurrentIndex(), oldIndex) + } else { + inputPanel.selectionListSelectCurrentItem() + } + + Qt.inputMethod.reset() + } } } diff --git a/tests/auto/inputpanel/tst_inputpanel.cpp b/tests/auto/inputpanel/tst_inputpanel.cpp index 8b84f67b..409383c7 100644 --- a/tests/auto/inputpanel/tst_inputpanel.cpp +++ b/tests/auto/inputpanel/tst_inputpanel.cpp @@ -29,7 +29,22 @@ #include <QtQuickTest/quicktest.h> #include <QByteArray> +#include <QStandardPaths> +#include <QFileInfo> +#include <QDir> static bool s_configEnv = qputenv("QT_IM_MODULE", QByteArray("qtvirtualkeyboard")); +static bool initStandardPaths() { + QStandardPaths::setTestModeEnabled(true); + auto configLocations = QStringList() + << QStandardPaths::writableLocation(QStandardPaths::ConfigLocation) + "/qtvirtualkeyboard" + << QStandardPaths::writableLocation(QStandardPaths::GenericConfigLocation) + "/qtvirtualkeyboard"; + for (const QString &configLocation : configLocations) { + if (configLocation != "/qtvirtualkeyboard") + QDir(configLocation).removeRecursively(); + } + return true; +} +static bool s_initStandardPaths = initStandardPaths(); QUICK_TEST_MAIN(inputpanel) |