From e803aec1ea21fd00e13b9535a4b536cc43c26ee4 Mon Sep 17 00:00:00 2001 From: Jarkko Koivikko Date: Tue, 7 Aug 2018 12:01:39 +0300 Subject: 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 --- src/plugin/plugin.cpp | 2 +- .../hunspellinputmethod/hunspellinputmethod.cpp | 167 ++++--- .../hunspellinputmethod/hunspellinputmethod_p.cpp | 197 ++++++-- .../hunspellinputmethod/hunspellinputmethod_p.h | 4 +- .../hunspellinputmethod/hunspellinputmethod_p_p.h | 19 +- .../hunspellinputmethod/hunspellworker.cpp | 527 +++++++++++++++++++-- .../hunspellinputmethod/hunspellworker_p.h | 161 ++++++- .../lipi-toolkit/plugin/lipiinputmethod.cpp | 1 + src/styles/SelectionListItem.qml | 7 +- src/virtualkeyboard/abstractinputmethod.cpp | 17 +- src/virtualkeyboard/abstractinputmethod.h | 1 + .../content/components/Keyboard.qml | 245 ++++++++-- .../content/components/LanguagePopupList.qml | 67 --- .../content/components/PopupList.qml | 67 +++ .../content/components/WordCandidatePopupList.qml | 24 +- src/virtualkeyboard/content/content.qrc | 2 +- src/virtualkeyboard/selectionlistmodel.cpp | 24 + src/virtualkeyboard/selectionlistmodel.h | 12 +- .../auto/inputpanel/data/inputpanel/inputpanel.qml | 57 +++ tests/auto/inputpanel/data/tst_inputpanel.qml | 73 ++- tests/auto/inputpanel/tst_inputpanel.cpp | 15 + 21 files changed, 1401 insertions(+), 288 deletions(-) delete mode 100644 src/virtualkeyboard/content/components/LanguagePopupList.qml create mode 100644 src/virtualkeyboard/content/components/PopupList.qml 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 HunspellInputMethod::selectionLists() { Q_D(const HunspellInputMethod); - Qt::InputMethodHints inputMethodHints = inputContext()->inputMethodHints(); + InputContext *ic = inputContext(); + if (!ic) + return QList(); + Qt::InputMethodHints inputMethodHints = ic->inputMethodHints(); if (d->dictionaryState == HunspellInputMethodPrivate::DictionaryNotLoaded || inputMethodHints.testFlag(Qt::ImhNoPredictiveText) || inputMethodHints.testFlag(Qt::ImhHiddenText)) return QList(); return QList() << 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(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(), replaceFrom, d->word.length()); + d->wordCandidates.updateWord(0, word); + ic->setPreeditText(word, QList(), 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 &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 oldSelectionLists = selectionLists(); d->dictionaryState = success ? HunspellInputMethodPrivate::DictionaryReady : HunspellInputMethodPrivate::DictionaryNotLoaded; - emit selectionListsChanged(); + QList 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 #include #include +#include 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(); - 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 wordList(new HunspellWordList()); + QSharedPointer wordList(new HunspellWordList(wordCandidates)); + + // Clear obsolete tasks from the worker queue + clearSuggestionsRelatedTasks(); + + // Build suggestions QSharedPointer 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 filterWordTask(new HunspellFilterWordTask()); + filterWordTask->wordList = wordList; + filterWordTask->filterList = blacklistedWords; + hunspellWorker->addTask(filterWordTask); + + // Boost words from user dictionary + QSharedPointer boostWordTask(new HunspellBoostWordTask()); + boostWordTask->wordList = wordList; + boostWordTask->boostList = userDictionaryWords; + hunspellWorker->addTask(boostWordTask); + + // Update word candidate list QSharedPointer 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(); - 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(); + hunspellWorker->removeAllTasksOfType(); + hunspellWorker->removeAllTasksOfType(); + hunspellWorker->removeAllTasksOfType(); + } } 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 &wordList, + const QString &dictionaryType) const +{ + QSharedPointer loadWordsTask(new HunspellLoadWordListTask()); + loadWordsTask->filePath = customDictionaryLocation(dictionaryType); + loadWordsTask->wordList = wordList; + hunspellWorker->addTask(loadWordsTask); +} + +void HunspellInputMethodPrivate::saveCustomDictionary(const QSharedPointer &wordList, + const QString &dictionaryType) const +{ + QSharedPointer saveWordsTask(new HunspellSaveWordListTask()); + saveWordsTask->filePath = customDictionaryLocation(dictionaryType); + saveWordsTask->wordList = wordList; + hunspellWorker->addTask(saveWordsTask); +} + +void HunspellInputMethodPrivate::addToHunspell(const QSharedPointer &wordList) const +{ + QSharedPointer addWordTask(new HunspellAddWordTask()); + addWordTask->wordList = wordList; + hunspellWorker->addTask(addWordTask); +} + +void HunspellInputMethodPrivate::removeFromHunspell(const QSharedPointer &wordList) const +{ + QSharedPointer 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 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 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 &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 &wordList, const QString &dictionaryType) const; + void saveCustomDictionary(const QSharedPointer &wordList, const QString &dictionaryType) const; + void addToHunspell(const QSharedPointer &wordList) const; + void removeFromHunspell(const QSharedPointer &wordList) const; + void removeFromDictionary(const QString &word); + void addToDictionary(); HunspellInputMethod *q_ptr; QScopedPointer hunspellWorker; QString locale; - QString word; - QStringList wordCandidates; - int activeWordIndex; + HunspellWordList wordCandidates; int wordCompletionPoint; bool ignoreUpdate; bool autoSpaceAllowed; DictionaryState dictionaryState; + QSharedPointer userDictionaryWords; + QSharedPointer 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 -#include #include #include #include #include #include +#include +#include +#include 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"); } 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 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 #include #include +#include +#include #include 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; + QVector _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 wordList; bool autoCorrect; @@ -120,7 +168,80 @@ public: void run(); signals: - void updateSuggestions(const QStringList &wordList, int activeWordIndex); + void updateSuggestions(const QSharedPointer &wordList, int tag); + +public: + int tag; +}; + +class HunspellAddWordTask : public HunspellTask +{ + Q_OBJECT +public: + QSharedPointer wordList; + + void run(); + + static bool alternativeForm(const QString &word, QString &alternativeForm); +}; + +class HunspellRemoveWordTask : public HunspellTask +{ + Q_OBJECT +public: + QSharedPointer wordList; + + void run(); +}; + +class HunspellLoadWordListTask : public HunspellTask +{ + Q_OBJECT +public: + QSharedPointer wordList; + QString filePath; + + void run(); +}; + +class HunspellSaveWordListTask : public HunspellTask +{ + Q_OBJECT +public: + QSharedPointer wordList; + QString filePath; + + void run(); +}; + +class HunspellFilterWordTask : public HunspellTask +{ + Q_OBJECT +public: + HunspellFilterWordTask() : + HunspellTask(), + startIndex(1) + {} + + QSharedPointer wordList; + QSharedPointer filterList; + int startIndex; + + void run(); +}; + +class HunspellBoostWordTask : public HunspellTask +{ + Q_OBJECT +public: + HunspellBoostWordTask() : + HunspellTask() + {} + + QSharedPointer wordList; + QSharedPointer boostList; + + void run(); }; class HunspellWorker : public QThread @@ -132,16 +253,19 @@ public: void addTask(QSharedPointer task); void removeAllTasks(); + void waitForAllTasks(); template - void removeAllTasksExcept() { + void removeAllTasksOfType() { QMutexLocker guard(&taskLock); for (int i = 0; i < taskList.size();) { QSharedPointer task(taskList[i].objectCast()); - 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 > taskList; + QSemaphore idleSema; QSemaphore taskSema; QMutex taskLock; Hunhandle *hunspell; @@ -163,4 +288,6 @@ private: } // namespace QtVirtualKeyboard QT_END_NAMESPACE +Q_DECLARE_METATYPE(QSharedPointer); + #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. */ @@ -317,6 +324,14 @@ bool AbstractInputMethod::clickPreeditText(int cursorPosition) user. The selection list is identified by the \a type parameter. */ +/*! + \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) 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 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/LanguagePopupList.qml deleted file mode 100644 index 2c8b8c99..00000000 --- a/src/virtualkeyboard/content/components/LanguagePopupList.qml +++ /dev/null @@ -1,67 +0,0 @@ -/**************************************************************************** -** -** Copyright (C) 2016 The Qt Company Ltd. -** Contact: https://www.qt.io/licensing/ -** -** This file is part of the Qt Virtual Keyboard module of the Qt Toolkit. -** -** $QT_BEGIN_LICENSE:GPL$ -** Commercial License Usage -** Licensees holding valid commercial Qt licenses may use this file in -** accordance with the commercial license agreement provided with the -** Software or, alternatively, in accordance with the terms contained in -** a written agreement between you and The Qt Company. For licensing terms -** and conditions see https://www.qt.io/terms-conditions. For further -** information use the contact form at https://www.qt.io/contact-us. -** -** GNU General Public License Usage -** Alternatively, this file may be used under the terms of the GNU -** General Public License version 3 or (at your option) any later version -** approved by the KDE Free Qt Foundation. The licenses are as published by -** the Free Software Foundation and appearing in the file LICENSE.GPL3 -** included in the packaging of this file. Please review the following -** information to ensure the GNU General Public License requirements will -** be met: https://www.gnu.org/licenses/gpl-3.0.html. -** -** $QT_END_LICENSE$ -** -****************************************************************************/ - -import QtQuick 2.0 -import QtQuick.VirtualKeyboard 2.1 - -ListView { - id: languagePopupList - objectName: "languagePopupList" - - property int maxVisibleItems: 5 - readonly property int preferredVisibleItems: count < maxVisibleItems ? count : maxVisibleItems - readonly property real contentWidth: contentItem.childrenRect.width - - clip: true - visible: enabled && count > 0 - width: contentWidth - 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 - highlightMoveDuration: 0 - highlightResizeDuration: 0 - add: keyboard.style.languageListAdd - remove: keyboard.style.languageListRemove - keyNavigationWraps: true - - onCurrentItemChanged: if (currentItem) keyboard.soundEffect.register(currentItem.soundEffect) - - Component { - id: defaultHighlight - Item {} - } - - Loader { - sourceComponent: keyboard.style.languageListBackground - anchors.fill: parent - z: -1 - } -} diff --git a/src/virtualkeyboard/content/components/PopupList.qml b/src/virtualkeyboard/content/components/PopupList.qml new file mode 100644 index 00000000..dcd02ee1 --- /dev/null +++ b/src/virtualkeyboard/content/components/PopupList.qml @@ -0,0 +1,67 @@ +/**************************************************************************** +** +** Copyright (C) 2016 The Qt Company Ltd. +** Contact: https://www.qt.io/licensing/ +** +** This file is part of the Qt Virtual Keyboard module of the Qt Toolkit. +** +** $QT_BEGIN_LICENSE:GPL$ +** Commercial License Usage +** Licensees holding valid commercial Qt licenses may use this file in +** accordance with the commercial license agreement provided with the +** Software or, alternatively, in accordance with the terms contained in +** a written agreement between you and The Qt Company. For licensing terms +** and conditions see https://www.qt.io/terms-conditions. For further +** information use the contact form at https://www.qt.io/contact-us. +** +** GNU General Public License Usage +** Alternatively, this file may be used under the terms of the GNU +** General Public License version 3 or (at your option) any later version +** approved by the KDE Free Qt Foundation. The licenses are as published by +** the Free Software Foundation and appearing in the file LICENSE.GPL3 +** included in the packaging of this file. Please review the following +** information to ensure the GNU General Public License requirements will +** be met: https://www.gnu.org/licenses/gpl-3.0.html. +** +** $QT_END_LICENSE$ +** +****************************************************************************/ + +import QtQuick 2.0 +import QtQuick.VirtualKeyboard 2.3 + +ListView { + 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 + width: contentWidth + height: currentItem ? currentItem.height * preferredVisibleItems + (spacing * preferredVisibleItems - 1) : 0 + orientation: ListView.Vertical + snapMode: ListView.SnapToItem + delegate: keyboard.style.popupListDelegate + highlight: keyboard.style.popupListHighlight ? keyboard.style.popupListHighlight : defaultHighlight + highlightMoveDuration: 0 + highlightResizeDuration: 0 + add: keyboard.style.popupListAdd + remove: keyboard.style.popupListRemove + keyNavigationWraps: true + + onCurrentItemChanged: if (currentItem) keyboard.soundEffect.register(currentItem.soundEffect) + + Component { + id: defaultHighlight + Item {} + } + + Loader { + 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 @@ components/TraceInputArea.qml components/HandwritingModeKey.qml components/WordCandidatePopupList.qml - components/LanguagePopupList.qml + components/PopupList.qml components/SelectionControl.qml components/ShadowInputControl.qml components/InputModeKey.qml 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) @@ -177,6 +178,12 @@ InputPanel { signalName: "onVisibleConditionChanged" } + SignalSpy { + id: wordCandidateContextMenuActiveSpy + target: wordCandidateContextMenu + signalName: "onActiveChanged" + } + SignalSpy { id: shiftStateSpy target: InputContext @@ -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 #include +#include +#include +#include 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) -- cgit v1.2.3