Some checks failed
Docker. / Ubuntu (push) Has been cancelled
User-agent updater. / User-agent (push) Failing after 15s
Lock Threads / lock (push) Failing after 10s
Waiting for answer. / waiting-for-answer (push) Failing after 22s
Close stale issues and PRs / stale (push) Successful in 13s
Needs user action. / needs-user-action (push) Failing after 8s
Can't reproduce. / cant-reproduce (push) Failing after 8s
948 lines
25 KiB
C++
948 lines
25 KiB
C++
// This file is part of Desktop App Toolkit,
|
||
// a set of libraries for developing nice desktop applications.
|
||
//
|
||
// For license and copyright information please follow this link:
|
||
// https://github.com/desktop-app/legal/blob/master/LEGAL
|
||
//
|
||
|
||
#include "spellcheck/spelling_highlighter.h"
|
||
|
||
#include "base/weak_qptr.h"
|
||
#include "spellcheck/spellcheck_value.h"
|
||
#include "spellcheck/spellcheck_utils.h"
|
||
#include "spellcheck/spelling_highlighter_helper.h"
|
||
#include "ui/widgets/menu/menu.h"
|
||
#include "ui/text/text_entity.h"
|
||
#include "ui/text/text_utilities.h"
|
||
#include "ui/widgets/popup_menu.h"
|
||
#include "ui/ui_utility.h"
|
||
#include "base/qt/qt_common_adapters.h"
|
||
#include "base/platform/base_platform_info.h"
|
||
#include "base/event_filter.h"
|
||
#include "styles/palette.h"
|
||
|
||
namespace Spellchecker {
|
||
namespace {
|
||
|
||
constexpr auto kTagProperty = QTextFormat::UserProperty + 4;
|
||
const auto kUnspellcheckableTags = {
|
||
&Ui::InputField::kTagCode,
|
||
&Ui::InputField::kTagPre,
|
||
&Ui::InputField::kTagUnderline
|
||
};
|
||
|
||
constexpr auto kColdSpellcheckingTimeout = crl::time(1000);
|
||
|
||
constexpr auto kMaxDeadKeys = 1;
|
||
|
||
constexpr auto kSkippableFlags = 0
|
||
| TextParseLinks
|
||
| TextParseMentions
|
||
| TextParseHashtags
|
||
| TextParseBotCommands;
|
||
|
||
const auto kKeysToCheck = {
|
||
Qt::Key_Up,
|
||
Qt::Key_Down,
|
||
Qt::Key_Left,
|
||
Qt::Key_Right,
|
||
Qt::Key_PageUp,
|
||
Qt::Key_PageDown,
|
||
Qt::Key_Home,
|
||
Qt::Key_End,
|
||
};
|
||
|
||
inline int EndOfWord(const MisspelledWord &range) {
|
||
return range.first + range.second;
|
||
}
|
||
|
||
inline bool IntersectsWordRanges(
|
||
const MisspelledWord &range,
|
||
int pos2,
|
||
int len2) {
|
||
const auto l1 = range.first;
|
||
const auto r1 = EndOfWord(range) - 1;
|
||
const auto l2 = pos2;
|
||
const auto r2 = pos2 + len2 - 1;
|
||
return !(l1 > r2 || l2 > r1);
|
||
}
|
||
|
||
inline bool IntersectsWordRanges(
|
||
const MisspelledWord &range,
|
||
const MisspelledWord &range2) {
|
||
const auto l1 = range.first;
|
||
const auto r1 = EndOfWord(range) - 1;
|
||
const auto l2 = range2.first;
|
||
const auto r2 = EndOfWord(range2) - 1;
|
||
return !(l1 > r2 || l2 > r1);
|
||
}
|
||
|
||
inline bool IntersectsWordRanges(const EntityInText &e, int pos2, int len2) {
|
||
return IntersectsWordRanges({ e.offset(), e.length() }, pos2, len2);
|
||
}
|
||
|
||
inline bool IsTagUnspellcheckable(const QString &tag) {
|
||
if (tag.isEmpty()) {
|
||
return false;
|
||
}
|
||
for (const auto &single : TextUtilities::SplitTags(tag)) {
|
||
const auto isCommonFormatting = ranges::any_of(
|
||
kUnspellcheckableTags,
|
||
[&](const auto *t) { return (*t) == single; });
|
||
|
||
if (isCommonFormatting) {
|
||
return true;
|
||
}
|
||
|
||
if (Ui::InputField::IsValidMarkdownLink(single)) {
|
||
return true;
|
||
}
|
||
|
||
if (TextUtilities::IsMentionLink(single)) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
inline auto FindEntities(const QString &text) {
|
||
return TextUtilities::ParseEntities(text, kSkippableFlags).entities;
|
||
}
|
||
|
||
inline auto IntersectsAnyOfEntities(
|
||
int pos,
|
||
int len,
|
||
EntitiesInText entities) {
|
||
return !entities.empty() && ranges::any_of(entities, [&](const auto &e) {
|
||
return IntersectsWordRanges(e, pos, len);
|
||
});
|
||
}
|
||
|
||
inline QChar AddedSymbol(QStringView text, int position, int added) {
|
||
if (added != 1 || position >= text.size()) {
|
||
return QChar();
|
||
}
|
||
return text.at(position);
|
||
}
|
||
|
||
inline MisspelledWord CorrectAccentValues(
|
||
const QString &oldText,
|
||
const QString &newText) {
|
||
auto diff = std::vector<int>();
|
||
const auto sizeOfDiff = newText.size() - oldText.size();
|
||
if (sizeOfDiff <= 0 || sizeOfDiff > kMaxDeadKeys) {
|
||
return MisspelledWord();
|
||
}
|
||
for (auto i = 0; i < oldText.size(); i++) {
|
||
if (oldText.at(i) != newText.at(i + diff.size())) {
|
||
diff.push_back(i);
|
||
if (diff.size() > kMaxDeadKeys) {
|
||
return MisspelledWord();
|
||
}
|
||
}
|
||
}
|
||
if (diff.size() == 0) {
|
||
return MisspelledWord(oldText.size(), sizeOfDiff);
|
||
}
|
||
return MisspelledWord(diff.front(), diff.size() > 1 ? diff.back() : 1);
|
||
}
|
||
|
||
inline MisspelledWord RangeFromCursorSelection(const QTextCursor &cursor) {
|
||
const auto start = cursor.selectionStart();
|
||
return MisspelledWord(start, cursor.selectionEnd() - start);
|
||
}
|
||
|
||
[[nodiscard]] bool SpellcheckSuggestEvent(not_null<QKeyEvent*> e) {
|
||
const auto modifier = Platform::IsMac()
|
||
? Qt::MetaModifier
|
||
: Qt::ControlModifier;
|
||
return (e->key() == Qt::Key_Space) && e->modifiers().testFlag(modifier);
|
||
}
|
||
|
||
} // namespace
|
||
|
||
SpellingHighlighter::SpellingHighlighter(
|
||
not_null<Ui::InputField*> field,
|
||
rpl::producer<bool> enabled,
|
||
std::optional<CustomContextMenuItem> customContextMenuItem)
|
||
: QSyntaxHighlighter(field->rawTextEdit()->document())
|
||
, _cursor(QTextCursor(document()))
|
||
, _coldSpellcheckingTimer([=] { checkChangedText(); })
|
||
, _field(field)
|
||
, _textEdit(field->rawTextEdit())
|
||
, _customContextMenuItem(customContextMenuItem) {
|
||
#ifdef Q_OS_WIN
|
||
Platform::Spellchecker::Init();
|
||
#endif // !Q_OS_WIN
|
||
|
||
_cachedRanges = MisspelledWords();
|
||
|
||
// Use the patched SpellCheckUnderline style.
|
||
_misspelledFormat.setUnderlineStyle(QTextCharFormat::SpellCheckUnderline);
|
||
style::PaletteChanged(
|
||
) | rpl::on_next([=] {
|
||
updatePalette();
|
||
rehighlight();
|
||
}, _lifetime);
|
||
updatePalette();
|
||
|
||
_field->documentContentsChanges(
|
||
) | rpl::on_next([=](const auto &value) {
|
||
const auto &[pos, removed, added] = value;
|
||
contentsChange(pos, removed, added);
|
||
}, _lifetime);
|
||
|
||
_field->markdownTagApplies(
|
||
) | rpl::on_next([=](auto markdownTag) {
|
||
if (!IsTagUnspellcheckable(markdownTag.tag)) {
|
||
return;
|
||
}
|
||
_cachedRanges = ranges::views::all(
|
||
_cachedRanges
|
||
) | ranges::views::filter([&](const auto &range) {
|
||
return !IntersectsWordRanges(
|
||
range,
|
||
markdownTag.internalStart,
|
||
markdownTag.internalLength);
|
||
}) | ranges::to_vector;
|
||
rehighlight();
|
||
}, _lifetime);
|
||
|
||
updateDocumentText();
|
||
|
||
std::move(
|
||
enabled
|
||
) | rpl::on_next([=](bool value) {
|
||
setEnabled(value);
|
||
if (_enabled) {
|
||
_field->installEventFilter(this);
|
||
_textEdit->installEventFilter(this);
|
||
_textEdit->viewport()->installEventFilter(this);
|
||
} else {
|
||
_field->installEventFilter(this);
|
||
_textEdit->removeEventFilter(this);
|
||
_textEdit->viewport()->removeEventFilter(this);
|
||
}
|
||
}, _lifetime);
|
||
|
||
Spellchecker::SupportedScriptsChanged(
|
||
) | rpl::on_next([=] {
|
||
checkCurrentText();
|
||
}, _lifetime);
|
||
}
|
||
|
||
void SpellingHighlighter::updatePalette() {
|
||
_misspelledFormat.setUnderlineColor(st::spellUnderline->c);
|
||
}
|
||
|
||
void SpellingHighlighter::contentsChange(int pos, int removed, int added) {
|
||
if (!_enabled) {
|
||
return;
|
||
}
|
||
if (document()->isEmpty()) {
|
||
updateDocumentText();
|
||
_cachedRanges.clear();
|
||
return;
|
||
}
|
||
|
||
{
|
||
const auto oldText = documentText().mid(
|
||
pos,
|
||
documentText().indexOf(QChar::ParagraphSeparator, pos));
|
||
updateDocumentText();
|
||
|
||
const auto b = findBlock(pos);
|
||
const auto bLen = (document()->blockCount() > 1)
|
||
? b.length()
|
||
: b.text().size();
|
||
|
||
// This is a workaround for typing accents.
|
||
// For example, when the user press the dead key (e.g. ` or ´),
|
||
// Qt sends wrong values. E.g. if a text length is 100,
|
||
// then values will be 0, 100, 100.
|
||
// This invokes to re-check the entire text.
|
||
// The Mac's accent menu has a pretty similar behavior.
|
||
if ((b.position() == pos) && (bLen == added)) {
|
||
const auto newText = b.text();
|
||
const auto diff = added - removed;
|
||
// The plain text of the document cannot contain dead keys.
|
||
if (!diff) {
|
||
if (!oldText.compare(newText, Qt::CaseSensitive)) {
|
||
const auto c = RangeFromCursorSelection(
|
||
_textEdit->textCursor());
|
||
// If the cursor has a selection for the entire text,
|
||
// we probably just changed its formatting.
|
||
// So if we find the unspellcheckable tag,
|
||
// we can clear cached ranges of misspelled words.
|
||
if (!c.first && c.second == bLen) {
|
||
if (hasUnspellcheckableTag(pos, added)) {
|
||
_cachedRanges.clear();
|
||
rehighlight();
|
||
} else {
|
||
checkCurrentText();
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
} else if (diff > 0 && diff <= kMaxDeadKeys) {
|
||
const auto [p, l] = CorrectAccentValues(oldText, newText);
|
||
if (l) {
|
||
pos = p + b.position();
|
||
added = l;
|
||
removed = 0;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const auto shift = [&](auto chars) {
|
||
ranges::for_each(_cachedRanges, [&](auto &range) {
|
||
if (range.first >= pos + removed) {
|
||
range.first += chars;
|
||
}
|
||
});
|
||
};
|
||
|
||
// Shift to the right all words after the cursor, when adding text.
|
||
if (added > 0) {
|
||
shift(added);
|
||
}
|
||
|
||
// Remove all words that are in the selection.
|
||
// Remove the word that is under the cursor.
|
||
const auto wordUnderPos = getWordUnderPosition(pos);
|
||
|
||
// If the cursor is between spaces,
|
||
// QTextCursor::WordUnderCursor highlights the word on the left
|
||
// even if the word is not under the cursor.
|
||
// Example: "super | test", where | is the cursor position.
|
||
// In this example QTextCursor::WordUnderCursor will select "super".
|
||
const auto isPosNotInWord = pos > EndOfWord(wordUnderPos);
|
||
|
||
_cachedRanges = (
|
||
_cachedRanges
|
||
) | ranges::views::filter([&](const auto &range) {
|
||
const auto isIntersected = IntersectsWordRanges(range, wordUnderPos);
|
||
if (isIntersected) {
|
||
return isPosNotInWord;
|
||
}
|
||
return !(isIntersected
|
||
|| (removed > 0 && IntersectsWordRanges(range, pos, removed)));
|
||
}) | ranges::to_vector;
|
||
|
||
// Shift to the left all words after the cursor, when deleting text.
|
||
if (removed > 0) {
|
||
shift(-removed);
|
||
}
|
||
|
||
// Normally we should to invoke rehighlighting to immediately apply
|
||
// shifting of ranges. But we don't have to do this because the call of
|
||
// contentsChange() is performed before the application's call of
|
||
// highlightBlock().
|
||
|
||
_addedSymbols += added;
|
||
_removedSymbols += removed;
|
||
|
||
// The typing of text character by character should produce
|
||
// the same _lastPosition, _addedSymbols and _removedSymbols values
|
||
// as removing and pasting several characters at a time.
|
||
if (!_lastPosition || (removed == 1)) {
|
||
_lastPosition = pos;
|
||
}
|
||
|
||
const auto addedSymbol = AddedSymbol(documentText(), pos, added);
|
||
|
||
if ((removed == 1) || addedSymbol.isLetterOrNumber()) {
|
||
if (_coldSpellcheckingTimer.isActive()) {
|
||
_coldSpellcheckingTimer.cancel();
|
||
}
|
||
_coldSpellcheckingTimer.callOnce(kColdSpellcheckingTimeout);
|
||
} else {
|
||
// We forcefully increase the range of check
|
||
// when inserting a non-char. This can help when the user inserts
|
||
// a non-char in the middle of a word.
|
||
if (!(addedSymbol.isNull()
|
||
|| addedSymbol.isSpace()
|
||
|| addedSymbol.isLetterOrNumber())) {
|
||
_lastPosition--;
|
||
_addedSymbols++;
|
||
}
|
||
if (_isLastKeyRepeat) {
|
||
return;
|
||
}
|
||
checkChangedText();
|
||
}
|
||
}
|
||
|
||
void SpellingHighlighter::checkChangedText() {
|
||
const auto pos = _lastPosition;
|
||
const auto added = _addedSymbols;
|
||
const auto removed = _removedSymbols;
|
||
|
||
_lastPosition = 0;
|
||
_removedSymbols = 0;
|
||
_addedSymbols = 0;
|
||
|
||
if (_coldSpellcheckingTimer.isActive()) {
|
||
_coldSpellcheckingTimer.cancel();
|
||
}
|
||
|
||
const auto wordUnderCursor = getWordUnderPosition(pos);
|
||
// If the length of the word is 0, there is no sense in checking it.
|
||
if (!wordUnderCursor.second) {
|
||
return;
|
||
}
|
||
|
||
const auto wordInCacheIt = [=] {
|
||
return ranges::find_if(_cachedRanges, [&](auto &&w) {
|
||
return w.first >= wordUnderCursor.first;
|
||
});
|
||
};
|
||
|
||
if (added > 0) {
|
||
const auto lastWordNewSelection = getWordUnderPosition(pos + added);
|
||
|
||
// This is the same word.
|
||
if (wordUnderCursor == lastWordNewSelection) {
|
||
checkSingleWord(wordUnderCursor);
|
||
return;
|
||
}
|
||
|
||
const auto beginNewSelection = wordUnderCursor.first;
|
||
const auto endNewSelection = EndOfWord(lastWordNewSelection);
|
||
|
||
auto callback = [=](MisspelledWords &&r) {
|
||
ranges::insert(_cachedRanges, wordInCacheIt(), std::move(r));
|
||
};
|
||
invokeCheckText(
|
||
beginNewSelection,
|
||
endNewSelection - beginNewSelection,
|
||
std::move(callback));
|
||
return;
|
||
}
|
||
|
||
if (removed > 0) {
|
||
checkSingleWord(wordUnderCursor);
|
||
}
|
||
}
|
||
|
||
MisspelledWords SpellingHighlighter::filterSkippableWords(
|
||
MisspelledWords &ranges) {
|
||
const auto text = documentText();
|
||
if (text.isEmpty()) {
|
||
return MisspelledWords();
|
||
}
|
||
return ranges | ranges::views::filter([&](const auto &range) {
|
||
return !isSkippableWord(range);
|
||
}) | ranges::to_vector;
|
||
}
|
||
|
||
bool SpellingHighlighter::isSkippableWord(const MisspelledWord &range) {
|
||
return isSkippableWord(range.first, range.second);
|
||
}
|
||
|
||
bool SpellingHighlighter::isSkippableWord(int position, int length) {
|
||
if (hasUnspellcheckableTag(position, length)) {
|
||
return true;
|
||
}
|
||
const auto text = documentText();
|
||
const auto ref = base::StringViewMid(text, position, length);
|
||
if (ref.isNull()) {
|
||
return true;
|
||
}
|
||
return IsWordSkippable(ref);
|
||
}
|
||
|
||
void SpellingHighlighter::checkCurrentText() {
|
||
if (document()->isEmpty()) {
|
||
_cachedRanges.clear();
|
||
return;
|
||
}
|
||
invokeCheckText(0, size(), [&](MisspelledWords &&ranges) {
|
||
_cachedRanges = std::move(ranges);
|
||
});
|
||
}
|
||
|
||
void SpellingHighlighter::invokeCheckText(
|
||
int textPosition,
|
||
int textLength,
|
||
Fn<void(MisspelledWords &&ranges)> callback) {
|
||
if (!_enabled) {
|
||
return;
|
||
}
|
||
|
||
const auto rangesOffset = textPosition;
|
||
const auto text = partDocumentText(textPosition, textLength);
|
||
const auto weak = base::make_weak(this);
|
||
_countOfCheckingTextAsync++;
|
||
crl::async([=,
|
||
text = std::move(text),
|
||
callback = std::move(callback)]() mutable {
|
||
MisspelledWords misspelledWordRanges;
|
||
Platform::Spellchecker::CheckSpellingText(
|
||
text,
|
||
&misspelledWordRanges);
|
||
if (rangesOffset) {
|
||
ranges::for_each(misspelledWordRanges, [&](auto &&range) {
|
||
range.first += rangesOffset;
|
||
});
|
||
}
|
||
crl::on_main(weak, [=,
|
||
text = std::move(text),
|
||
ranges = std::move(misspelledWordRanges),
|
||
callback = std::move(callback)]() mutable {
|
||
_countOfCheckingTextAsync--;
|
||
// Checking a large part of text can take an unknown amount of
|
||
// time. So we have to compare the text before and after async
|
||
// work.
|
||
// If the text has changed during async and we have more async,
|
||
// we don't perform further refreshing of cache and underlines.
|
||
// But if it was the last async, we should invoke a new one.
|
||
if (compareDocumentText(text, textPosition, textLength)) {
|
||
if (!_countOfCheckingTextAsync) {
|
||
checkCurrentText();
|
||
}
|
||
return;
|
||
}
|
||
auto filtered = filterSkippableWords(ranges);
|
||
|
||
// When we finish checking the text, the user can
|
||
// supplement the last word and there may be a situation where
|
||
// a part of the last word may not be underlined correctly.
|
||
// Example:
|
||
// 1. We insert a text with an incomplete last word.
|
||
// "Time in a bottl".
|
||
// 2. We don't wait for the check to be finished
|
||
// and end the last word with the letter "e".
|
||
// 3. invokeCheckText() will mark the last word "bottl" as
|
||
// misspelled.
|
||
// 4. checkSingleWord() will mark the "bottle" as correct and
|
||
// leave it as it is.
|
||
// 5. The first five letters of the "bottle" will be underlined
|
||
// and the sixth will not be underlined.
|
||
// We can fix it with a check of completeness of the last word.
|
||
if (filtered.size()) {
|
||
const auto lastWord = filtered.back();
|
||
if (const auto endOfText = textPosition + textLength;
|
||
EndOfWord(lastWord) == endOfText) {
|
||
const auto word = getWordUnderPosition(endOfText);
|
||
if (EndOfWord(word) != endOfText) {
|
||
filtered.pop_back();
|
||
checkSingleWord(word);
|
||
}
|
||
}
|
||
}
|
||
|
||
callback(std::move(filtered));
|
||
for (const auto &b : blocksFromRange(textPosition, textLength)) {
|
||
rehighlightBlock(b);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
void SpellingHighlighter::checkSingleWord(const MisspelledWord &singleWord) {
|
||
const auto weak = base::make_weak(this);
|
||
auto w = partDocumentText(singleWord.first, singleWord.second);
|
||
if (isSkippableWord(singleWord)) {
|
||
return;
|
||
}
|
||
crl::async([=,
|
||
w = std::move(w),
|
||
singleWord = std::move(singleWord)]() mutable {
|
||
if (Platform::Spellchecker::CheckSpelling(std::move(w))) {
|
||
return;
|
||
}
|
||
|
||
crl::on_main(weak, [=,
|
||
singleWord = std::move(singleWord)]() mutable {
|
||
const auto posOfWord = singleWord.first;
|
||
ranges::insert(
|
||
_cachedRanges,
|
||
ranges::find_if(_cachedRanges, [&](auto &&w) {
|
||
return w.first >= posOfWord;
|
||
}),
|
||
singleWord);
|
||
rehighlightBlock(findBlock(posOfWord));
|
||
});
|
||
});
|
||
}
|
||
|
||
bool SpellingHighlighter::hasUnspellcheckableTag(int begin, int length) {
|
||
// This method is called only in the context of separate words,
|
||
// so it is not supposed that the word can be in more than one block.
|
||
const auto block = findBlock(begin);
|
||
length = std::min(block.position() + block.length() - begin, length);
|
||
for (auto it = block.begin(); !(it.atEnd()); ++it) {
|
||
const auto fragment = it.fragment();
|
||
if (!fragment.isValid()) {
|
||
continue;
|
||
}
|
||
const auto frPos = fragment.position();
|
||
const auto frLen = fragment.length();
|
||
if (!IntersectsWordRanges({ frPos, frLen }, begin, length)) {
|
||
continue;
|
||
}
|
||
const auto format = fragment.charFormat();
|
||
if (!format.hasProperty(kTagProperty)) {
|
||
continue;
|
||
}
|
||
const auto tag = format.property(kTagProperty).toString();
|
||
if (IsTagUnspellcheckable(tag)) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
MisspelledWord SpellingHighlighter::getWordUnderPosition(int position) {
|
||
if (position < 0) {
|
||
position = 0;
|
||
}
|
||
_cursor.setPosition(std::min(position, size()));
|
||
_cursor.select(QTextCursor::WordUnderCursor);
|
||
return RangeFromCursorSelection(_cursor);
|
||
}
|
||
|
||
void SpellingHighlighter::highlightBlock(const QString &text) {
|
||
if (_cachedRanges.empty() || !_enabled || text.isEmpty()) {
|
||
return;
|
||
}
|
||
const auto entities = FindEntities(text);
|
||
const auto bPos = currentBlock().position();
|
||
const auto bLen = currentBlock().length();
|
||
ranges::for_each((
|
||
_cachedRanges
|
||
// Skip the all words outside the current block.
|
||
) | ranges::views::filter([&](const auto &range) {
|
||
return IntersectsWordRanges(range, bPos, bLen);
|
||
}), [&](const auto &range) {
|
||
const auto posInBlock = range.first - bPos;
|
||
if (IntersectsAnyOfEntities(posInBlock, range.second, entities)) {
|
||
return;
|
||
}
|
||
setFormat(posInBlock, range.second, _misspelledFormat);
|
||
});
|
||
|
||
setCurrentBlockState(0);
|
||
}
|
||
|
||
bool SpellingHighlighter::eventFilter(QObject *o, QEvent *e) {
|
||
if (!_enabled) {
|
||
return false;
|
||
} else if (o == _field) {
|
||
if (e->type() == QEvent::KeyPress) {
|
||
const auto k = static_cast<QKeyEvent*>(e);
|
||
if (SpellcheckSuggestEvent(k)) {
|
||
showSpellcheckerMenu();
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
if (e->type() == QEvent::ContextMenu) {
|
||
const auto c = static_cast<QContextMenuEvent*>(e);
|
||
const auto menu = _textEdit->createStandardContextMenu();
|
||
if (!menu || !c) {
|
||
return false;
|
||
}
|
||
// Copy of QContextMenuEvent.
|
||
auto copyEvent = std::make_shared<QContextMenuEvent>(
|
||
c->reason(),
|
||
c->pos(),
|
||
c->globalPos());
|
||
auto showMenu = [=, copyEvent = std::move(copyEvent)] {
|
||
_contextMenuCreated.fire({ menu, copyEvent });
|
||
};
|
||
addSpellcheckerActions(
|
||
std::move(menu),
|
||
_textEdit->cursorForPosition(c->pos()),
|
||
std::move(showMenu),
|
||
c->globalPos());
|
||
return true;
|
||
} else if (e->type() == QEvent::KeyPress) {
|
||
const auto k = static_cast<QKeyEvent*>(e);
|
||
if (ranges::contains(kKeysToCheck, k->key())) {
|
||
if (_addedSymbols + _removedSymbols + _lastPosition) {
|
||
checkCurrentText();
|
||
}
|
||
} else if ((o == _textEdit) && k->isAutoRepeat()) {
|
||
_isLastKeyRepeat = true;
|
||
}
|
||
} else if (_isLastKeyRepeat && (o == _textEdit)) {
|
||
if (e->type() == QEvent::FocusOut) {
|
||
_isLastKeyRepeat = false;
|
||
if (_addedSymbols + _removedSymbols + _lastPosition) {
|
||
checkCurrentText();
|
||
}
|
||
} else if (e->type() == QEvent::KeyRelease) {
|
||
const auto k = static_cast<QKeyEvent*>(e);
|
||
if (!k->isAutoRepeat()) {
|
||
_isLastKeyRepeat = false;
|
||
_coldSpellcheckingTimer.callOnce(kColdSpellcheckingTimeout);
|
||
}
|
||
}
|
||
} else if ((o == _textEdit->viewport())
|
||
&& (e->type() == QEvent::MouseButtonPress)) {
|
||
if (_addedSymbols + _removedSymbols + _lastPosition) {
|
||
checkCurrentText();
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
bool SpellingHighlighter::enabled() {
|
||
return _enabled;
|
||
}
|
||
|
||
void SpellingHighlighter::setEnabled(bool enabled) {
|
||
_enabled = enabled;
|
||
if (_enabled) {
|
||
updateDocumentText();
|
||
checkCurrentText();
|
||
} else {
|
||
_cachedRanges.clear();
|
||
rehighlight();
|
||
}
|
||
}
|
||
|
||
QString SpellingHighlighter::documentText() {
|
||
return _lastPlainText;
|
||
}
|
||
|
||
void SpellingHighlighter::updateDocumentText() {
|
||
_lastPlainText = document()->toRawText();
|
||
}
|
||
|
||
QString SpellingHighlighter::partDocumentText(int pos, int length) {
|
||
return _lastPlainText.mid(pos, length);
|
||
}
|
||
|
||
int SpellingHighlighter::size() {
|
||
return document()->characterCount() - 1;
|
||
}
|
||
|
||
QTextBlock SpellingHighlighter::findBlock(int pos) {
|
||
return document()->findBlock(pos);
|
||
}
|
||
|
||
std::vector<QTextBlock> SpellingHighlighter::blocksFromRange(
|
||
int pos,
|
||
int length) {
|
||
auto b = findBlock(pos);
|
||
auto blocks = std::vector<QTextBlock>{b};
|
||
const auto end = pos + length;
|
||
while (!b.contains(end) && (b != document()->end())) {
|
||
if ((b = b.next()).isValid()) {
|
||
blocks.push_back(b);
|
||
}
|
||
}
|
||
return blocks;
|
||
}
|
||
|
||
int SpellingHighlighter::compareDocumentText(
|
||
const QString &text,
|
||
int textPos,
|
||
int textLen) {
|
||
if (_lastPlainText.size() < textPos + textLen) {
|
||
return -1;
|
||
}
|
||
const auto p = base::StringViewMid(_lastPlainText, textPos, textLen);
|
||
if (p.isNull()) {
|
||
return -1;
|
||
}
|
||
return text.compare(p, Qt::CaseSensitive);
|
||
}
|
||
|
||
void SpellingHighlighter::addSpellcheckerActions(
|
||
not_null<QMenu*> parentMenu,
|
||
QTextCursor cursorForPosition,
|
||
Fn<void()> showMenuCallback,
|
||
QPoint mousePosition) {
|
||
const auto menu = new QMenu(
|
||
ph::lng_spellchecker_submenu(ph::now),
|
||
parentMenu);
|
||
|
||
auto addToParentAndShow = [=](int) {
|
||
if (!menu->isEmpty()) {
|
||
using namespace Spelling::Helper;
|
||
if (IsContextMenuTop(parentMenu, mousePosition)) {
|
||
parentMenu->addSeparator();
|
||
parentMenu->addMenu(menu);
|
||
} else {
|
||
const auto first = parentMenu->actions().first();
|
||
parentMenu->insertMenu(first, menu);
|
||
parentMenu->insertSeparator(first);
|
||
}
|
||
}
|
||
showMenuCallback();
|
||
};
|
||
fillSpellcheckerMenu(menu, cursorForPosition, addToParentAndShow);
|
||
}
|
||
|
||
void SpellingHighlighter::showSpellcheckerMenu() {
|
||
auto menu = std::make_unique<QMenu>();
|
||
const auto raw = menu.get();
|
||
|
||
const auto cursor = _textEdit->textCursor();
|
||
auto rect = _textEdit->cursorRect(cursor);
|
||
rect.setTopLeft(_textEdit->viewport()->mapToGlobal(rect.topLeft()));
|
||
auto show = [=, menu = std::move(menu)](int firstSuggestion) mutable {
|
||
if (!menu->isEmpty()) {
|
||
_menu = base::make_unique_q<Ui::PopupMenu>(
|
||
_textEdit,
|
||
menu.release(),
|
||
_field->st().menu);
|
||
_menu->setForcedVerticalOrigin(
|
||
Ui::PopupMenu::VerticalOrigin::Top);
|
||
base::install_event_filter(_menu.get(), [=](
|
||
not_null<QEvent*> e) {
|
||
if (e->type() == QEvent::KeyPress) {
|
||
const auto k = static_cast<QKeyEvent*>(e.get());
|
||
if (SpellcheckSuggestEvent(k)) {
|
||
auto event = QKeyEvent(
|
||
QEvent::KeyPress,
|
||
Qt::Key_Down,
|
||
Qt::KeyboardModifiers());
|
||
_menu->menu()->handleKeyPress(&event);
|
||
}
|
||
}
|
||
return base::EventFilterResult::Continue;
|
||
});
|
||
_menu->popup(rect.topLeft());
|
||
if (_menu && firstSuggestion >= 0) {
|
||
_menu->menu()->setSelected(firstSuggestion, false);
|
||
}
|
||
}
|
||
};
|
||
fillSpellcheckerMenu(raw, cursor, std::move(show));
|
||
}
|
||
|
||
void SpellingHighlighter::fillSpellcheckerMenu(
|
||
not_null<QMenu*> menu,
|
||
QTextCursor cursorForPosition,
|
||
FnMut<void(int firstSuggestionIndex)> show) {
|
||
const auto customItem = !Platform::Spellchecker::IsSystemSpellchecker()
|
||
&& _customContextMenuItem.has_value();
|
||
|
||
cursorForPosition.select(QTextCursor::WordUnderCursor);
|
||
|
||
// There is no reason to call async work if the word is skippable.
|
||
const auto skippable = [&] {
|
||
const auto &[p, l] = RangeFromCursorSelection(cursorForPosition);
|
||
const auto e = FindEntities(findBlock(p).text());
|
||
return (!l
|
||
|| isSkippableWord(p, l)
|
||
|| IntersectsAnyOfEntities(p, l, e));
|
||
}();
|
||
|
||
if (customItem) {
|
||
menu->addAction(
|
||
_customContextMenuItem->title,
|
||
_customContextMenuItem->callback);
|
||
}
|
||
|
||
if (skippable) {
|
||
show(-1);
|
||
return;
|
||
}
|
||
|
||
const auto word = cursorForPosition.selectedText();
|
||
|
||
auto fillMenu = [
|
||
=,
|
||
show = std::move(show),
|
||
menu = std::move(menu)
|
||
](
|
||
bool isCorrect,
|
||
const auto &suggestions,
|
||
const auto &newTextCursor) mutable {
|
||
auto firstSuggestionIndex = -1;
|
||
const auto guard = gsl::finally([&] {
|
||
show(firstSuggestionIndex);
|
||
});
|
||
|
||
const auto addSeparator = [&] {
|
||
if (!menu->isEmpty()) {
|
||
menu->addSeparator();
|
||
}
|
||
};
|
||
|
||
if (isCorrect) {
|
||
if (Platform::Spellchecker::IsWordInDictionary(word)) {
|
||
addSeparator();
|
||
auto remove = [=] {
|
||
Platform::Spellchecker::RemoveWord(word);
|
||
checkCurrentText();
|
||
};
|
||
menu->addAction(
|
||
ph::lng_spellchecker_remove(ph::now),
|
||
std::move(remove));
|
||
}
|
||
return;
|
||
}
|
||
|
||
addSeparator();
|
||
|
||
auto add = [=] {
|
||
Platform::Spellchecker::AddWord(word);
|
||
checkCurrentText();
|
||
};
|
||
menu->addAction(ph::lng_spellchecker_add(ph::now), std::move(add));
|
||
|
||
auto ignore = [=] {
|
||
Platform::Spellchecker::IgnoreWord(word);
|
||
checkCurrentText();
|
||
};
|
||
menu->addAction(
|
||
ph::lng_spellchecker_ignore(ph::now),
|
||
std::move(ignore));
|
||
|
||
if (suggestions.empty()) {
|
||
return;
|
||
}
|
||
|
||
addSeparator();
|
||
for (const auto &suggestion : suggestions) {
|
||
if (firstSuggestionIndex < 0) {
|
||
firstSuggestionIndex = menu->actions().size();
|
||
}
|
||
auto replaceWord = [=] {
|
||
const auto oldTextCursor = _textEdit->textCursor();
|
||
_textEdit->setTextCursor(newTextCursor);
|
||
_textEdit->textCursor().insertText(suggestion);
|
||
_textEdit->setTextCursor(oldTextCursor);
|
||
};
|
||
menu->addAction(suggestion, std::move(replaceWord));
|
||
}
|
||
};
|
||
|
||
const auto weak = base::make_weak(this);
|
||
crl::async([=,
|
||
newTextCursor = std::move(cursorForPosition),
|
||
fillMenu = std::move(fillMenu),
|
||
word = std::move(word)
|
||
]() mutable {
|
||
const auto isCorrect = Platform::Spellchecker::CheckSpelling(word);
|
||
auto suggestions = std::vector<QString>();
|
||
if (!isCorrect) {
|
||
Platform::Spellchecker::FillSuggestionList(word, &suggestions);
|
||
}
|
||
|
||
crl::on_main(weak, [=,
|
||
newTextCursor = std::move(newTextCursor),
|
||
suggestions = std::move(suggestions),
|
||
fillMenu = std::move(fillMenu)
|
||
]() mutable {
|
||
fillMenu(
|
||
isCorrect,
|
||
std::move(suggestions),
|
||
std::move(newTextCursor));
|
||
});
|
||
});
|
||
}
|
||
|
||
} // namespace Spellchecker
|