Add spellchecking suggestions

This commit is contained in:
Carl Schwan
2021-06-10 11:29:59 +00:00
parent 5cb8424a83
commit 0ff9425fee
14 changed files with 965 additions and 36 deletions

View File

@@ -44,7 +44,7 @@ target_sources(neochat PRIVATE ${NEOCHAT_ICON})
if(NOT ANDROID)
target_sources(neochat PRIVATE trayicon.cpp colorschemer.cpp spellcheckhighlighter.cpp)
target_link_libraries(neochat PRIVATE KF5::ConfigWidgets KF5::SonnetCore KF5::WindowSystem)
target_link_libraries(neochat PRIVATE KF5::ConfigWidgets KF5::WindowSystem KF5::SonnetCore)
target_compile_definitions(neochat PRIVATE -DHAVE_COLORSCHEME)
target_compile_definitions(neochat PRIVATE -DHAVE_WINDOWSYSTEM)
endif()

View File

@@ -11,9 +11,6 @@
#include <QTextDocument>
#include "neochatroom.h"
#ifndef Q_OS_ANDROID
#include "spellcheckhighlighter.h"
#endif
ChatDocumentHandler::ChatDocumentHandler(QObject *parent)
: QObject(parent)
@@ -37,9 +34,6 @@ void ChatDocumentHandler::setDocument(QQuickTextDocument *document)
m_document->textDocument()->disconnect(this);
}
m_document = document;
#ifndef Q_OS_ANDROID
new SpellcheckHighlighter(m_document->textDocument());
#endif
Q_EMIT documentChanged();
}

View File

@@ -57,6 +57,7 @@
#include "userdirectorylistmodel.h"
#include "userlistmodel.h"
#include "webshortcutmodel.h"
#include "spellcheckhighlighter.h"
#ifdef HAVE_COLORSCHEME
#include "colorschemer.h"
#endif
@@ -158,6 +159,7 @@ int main(int argc, char *argv[])
qmlRegisterType<AccountListModel>("org.kde.neochat", 1, 0, "AccountListModel");
qmlRegisterType<ActionsHandler>("org.kde.neochat", 1, 0, "ActionsHandler");
qmlRegisterType<ChatDocumentHandler>("org.kde.neochat", 1, 0, "ChatDocumentHandler");
qmlRegisterType<SpellcheckHighlighter>("org.kde.neochat", 1, 0, "SpellcheckHighlighter");
qmlRegisterType<RoomListModel>("org.kde.neochat", 1, 0, "RoomListModel");
qmlRegisterType<KWebShortcutModel>("org.kde.neochat", 1, 0, "WebShortcutModel");
qmlRegisterType<UserListModel>("org.kde.neochat", 1, 0, "UserListModel");

View File

@@ -1,11 +1,51 @@
// Copyright (c) 2020 Christian Mollekopf <mollekopf@kolabsystems.com>
// SPDX-License-Identifier: LGPL-2.0-or-later
// SPDX-FileCopyrightText: 2013 Aurélien Gâteau <agateau@kde.org>
// SPDX-FileCopyrightText: 2020 Christian Mollekopf <mollekopf@kolabsystems.com>
// SPDX-FileCopyrightText: 2021 Carl Schwan <carlschwan@kde.org>
// SPDX-License-Identifier: LGPL-2.1-or-later
#include "spellcheckhighlighter.h"
#include <QDebug>
#include <QHash>
#include <QTextBoundaryFinder>
// Cache of previously-determined languages (when using AutoDetectLanguage)
// There is one such cache per block (paragraph)
class LanguageCache : public QTextBlockUserData
{
public:
// Key: QPair<start, length>
// Value: language name
QMap<QPair<int, int>, QString> languages;
// Remove all cached language information after @p pos
void invalidate(int pos)
{
QMutableMapIterator<QPair<int, int>, QString> it(languages);
it.toBack();
while (it.hasPrevious()) {
it.previous();
if (it.key().first + it.key().second >= pos) {
it.remove();
} else {
break;
}
}
}
QString languageAtPos(int pos) const
{
// The data structure isn't really great for such lookups...
QMapIterator<QPair<int, int>, QString> it(languages);
while (it.hasNext()) {
it.next();
if (it.key().first <= pos && it.key().first + it.key().second >= pos) {
return it.value();
}
}
return QString();
}
};
QVector<QStringRef> split(QTextBoundaryFinder::BoundaryType boundary, const QString &text, int reasonMask = 0)
{
QVector<QStringRef> parts;
@@ -14,7 +54,7 @@ QVector<QStringRef> split(QTextBoundaryFinder::BoundaryType boundary, const QStr
while (boundaryFinder.position() < text.length()) {
const int start = boundaryFinder.position();
//Advance until we find a break that matches the mask or are at the end
// Advance until we find a break that matches the mask or are at the end
for (;;) {
if (boundaryFinder.toNextBoundary() == -1) {
boundaryFinder.toEnd();
@@ -35,31 +75,36 @@ QVector<QStringRef> split(QTextBoundaryFinder::BoundaryType boundary, const QStr
return parts;
}
SpellcheckHighlighter::SpellcheckHighlighter(QTextDocument *parent)
: QSyntaxHighlighter(parent),
mSpellchecker{new Sonnet::Speller()},
mLanguageGuesser{new Sonnet::GuessLanguage()}
SpellcheckHighlighter::SpellcheckHighlighter(QObject *parent)
: QSyntaxHighlighter(parent)
#ifndef Q_OS_ANDROID
, mSpellchecker{new Sonnet::Speller()}
, mLanguageGuesser{new Sonnet::GuessLanguage()}
#endif
, m_document(nullptr)
, m_cursorPosition(-1)
{
//Danger red from our color scheme
// Danger red from our color scheme
mErrorFormat.setForeground(QColor{"#ed1515"});
mErrorFormat.setUnderlineColor(QColor{"#ed1515"});
mErrorFormat.setUnderlineStyle(QTextCharFormat::SingleUnderline);
mQuoteFormat.setForeground(QColor{"#7f8c8d"});
#ifndef Q_OS_ANDROID
if (!mSpellchecker->isValid()) {
qWarning() << "Spellchecker is invalid";
}
qDebug() << "Available dictionaries: " << mSpellchecker->availableDictionaries();
#endif
}
void SpellcheckHighlighter::autodetectLanguage(const QString &sentence)
{
#ifndef Q_OS_ANDROID
const auto lang = mLanguageGuesser->identify(sentence, mSpellchecker->availableLanguages());
if (lang.isEmpty()) {
return;
}
mSpellchecker->setLanguage(lang);
#endif
}
static bool isSpellcheckable(const QStringRef &token)
@@ -70,20 +115,21 @@ static bool isSpellcheckable(const QStringRef &token)
if (!token.at(0).isLetter() || token.at(0).isUpper() || token.startsWith(QStringLiteral("http"))) {
return false;
}
//TODO ignore urls and uppercase?
// TODO ignore urls and uppercase?
return true;
}
void SpellcheckHighlighter::highlightBlock(const QString &text)
{
//Avoid spellchecking quotes
if (text.isEmpty() || text.at(0) == QChar{'>'}) {
// Avoid spellchecking quotes
if (text.isEmpty() || text.at(0) == QLatin1Char('>')) {
setFormat(0, text.length(), mQuoteFormat);
return;
}
#ifndef Q_OS_ANDROID
for (const auto &sentenceRef : split(QTextBoundaryFinder::Sentence, text)) {
//Avoid spellchecking quotes
if (sentenceRef.isEmpty() || sentenceRef.at(0) == QChar{'>'}) {
// Avoid spellchecking quotes
if (sentenceRef.isEmpty() || sentenceRef.at(0) == QLatin1Char('>')) {
continue;
}
@@ -93,8 +139,8 @@ void SpellcheckHighlighter::highlightBlock(const QString &text)
const int offset = sentenceRef.position();
for (const auto &wordRef : split(QTextBoundaryFinder::Word, sentence)) {
//Avoid spellchecking words in progress
//FIXME this will also prevent spellchecking a single word on a line.
// Avoid spellchecking words in progress
// FIXME this will also prevent spellchecking a single word on a line.
if (offset + wordRef.position() + wordRef.length() >= text.length()) {
continue;
}
@@ -105,4 +151,202 @@ void SpellcheckHighlighter::highlightBlock(const QString &text)
}
}
}
#endif
}
QStringList SpellcheckHighlighter::suggestions(int mousePosition, int max)
{
#ifndef Q_OS_ANDROID
QTextCursor cursor = textCursor();
QTextCursor cursorAtMouse(textDocument());
cursorAtMouse.setPosition(mousePosition);
// Check if the user clicked a selected word
/* clang-format off */
const bool selectedWordClicked = cursor.hasSelection()
&& mousePosition >= cursor.selectionStart()
&& mousePosition <= cursor.selectionEnd();
/* clang-format on */
// Get the word under the (mouse-)cursor and see if it is misspelled.
// Don't include apostrophes at the start/end of the word in the selection.
QTextCursor wordSelectCursor(cursorAtMouse);
wordSelectCursor.clearSelection();
wordSelectCursor.select(QTextCursor::WordUnderCursor);
m_selectedWord = wordSelectCursor.selectedText();
// Clear the selection again, we re-select it below (without the apostrophes).
wordSelectCursor.setPosition(wordSelectCursor.position() - m_selectedWord.size());
if (m_selectedWord.startsWith(QLatin1Char('\'')) || m_selectedWord.startsWith(QLatin1Char('\"'))) {
m_selectedWord = m_selectedWord.right(m_selectedWord.size() - 1);
wordSelectCursor.movePosition(QTextCursor::NextCharacter, QTextCursor::MoveAnchor);
}
if (m_selectedWord.endsWith(QLatin1Char('\'')) || m_selectedWord.endsWith(QLatin1Char('\"'))) {
m_selectedWord.chop(1);
}
wordSelectCursor.movePosition(QTextCursor::NextCharacter, QTextCursor::KeepAnchor, m_selectedWord.size());
int endSelection = wordSelectCursor.selectionEnd();
Q_EMIT wordUnderMouseChanged();
bool isMouseCursorInsideWord = true;
if ((mousePosition < wordSelectCursor.selectionStart() || mousePosition >= wordSelectCursor.selectionEnd()) //
&& (m_selectedWord.length() > 1)) {
isMouseCursorInsideWord = false;
}
wordSelectCursor.movePosition(QTextCursor::NextCharacter, QTextCursor::KeepAnchor, m_selectedWord.size());
m_wordIsMisspelled = isMouseCursorInsideWord && !m_selectedWord.isEmpty() && mSpellchecker->isMisspelled(m_selectedWord);
Q_EMIT wordIsMisspelledChanged();
if (!m_wordIsMisspelled || selectedWordClicked) {
return QStringList{};
}
if (!selectedWordClicked) {
Q_EMIT changeCursorPosition(wordSelectCursor.selectionStart(), endSelection);
}
LanguageCache *cache = dynamic_cast<LanguageCache *>(cursor.block().userData());
if (cache) {
const QString cachedLanguage = cache->languageAtPos(cursor.positionInBlock());
if (!cachedLanguage.isEmpty()) {
mSpellchecker->setLanguage(cachedLanguage);
}
}
QStringList suggestions = mSpellchecker->suggest(m_selectedWord);
if (max >= 0 && suggestions.count() > max) {
suggestions = suggestions.mid(0, max);
}
return suggestions;
#else
return QStringList();
#endif
}
void SpellcheckHighlighter::addWordToDictionary(const QString &word)
{
#ifndef Q_OS_ANDROID
mSpellchecker->addToPersonal(word);
rehighlight();
#endif
}
void SpellcheckHighlighter::ignoreWord(const QString &word)
{
#ifndef Q_OS_ANDROID
mSpellchecker->addToSession(word);
rehighlight();
#endif
}
void SpellcheckHighlighter::replaceWord(const QString &replacement)
{
#ifndef Q_OS_ANDROID
textCursor().insertText(replacement);
#endif
}
QQuickTextDocument *SpellcheckHighlighter::quickDocument() const
{
return m_document;
}
void SpellcheckHighlighter::setQuickDocument(QQuickTextDocument *document)
{
if (document == m_document) {
return;
}
if (m_document) {
m_document->textDocument()->disconnect(this);
}
m_document = document;
setDocument(document->textDocument());
Q_EMIT documentChanged();
}
int SpellcheckHighlighter::cursorPosition() const
{
return m_cursorPosition;
}
void SpellcheckHighlighter::setCursorPosition(int position)
{
if (position == m_cursorPosition) {
return;
}
m_cursorPosition = position;
Q_EMIT cursorPositionChanged();
}
int SpellcheckHighlighter::selectionStart() const
{
return m_selectionStart;
}
void SpellcheckHighlighter::setSelectionStart(int position)
{
if (position == m_selectionStart) {
return;
}
m_selectionStart = position;
Q_EMIT selectionStartChanged();
}
int SpellcheckHighlighter::selectionEnd() const
{
return m_selectionEnd;
}
void SpellcheckHighlighter::setSelectionEnd(int position)
{
if (position == m_selectionEnd) {
return;
}
m_selectionEnd = position;
Q_EMIT selectionEndChanged();
}
QTextCursor SpellcheckHighlighter::textCursor() const
{
QTextDocument *doc = textDocument();
if (!doc) {
return QTextCursor();
}
QTextCursor cursor(doc);
if (m_selectionStart != m_selectionEnd) {
cursor.setPosition(m_selectionStart);
cursor.setPosition(m_selectionEnd, QTextCursor::KeepAnchor);
} else {
cursor.setPosition(m_cursorPosition);
}
return cursor;
}
QTextDocument *SpellcheckHighlighter::textDocument() const
{
if (!m_document) {
return nullptr;
}
return m_document->textDocument();
}
bool SpellcheckHighlighter::wordIsMisspelled() const
{
return m_wordIsMisspelled;
}
QString SpellcheckHighlighter::wordUnderMouse() const
{
return m_selectedWord;
}

View File

@@ -1,26 +1,81 @@
// Copyright (c) 2020 Christian Mollekopf <mollekopf@kolabsystems.com>
// SPDX-License-Identifier: LGPL-2.0-or-later
// SPDX-FileCopyrightText: 2013 Aurélien Gâteau <agateau@kde.org>
// SPDX-FileCopyrightText: 2020 Christian Mollekopf <mollekopf@kolabsystems.com>
// SPDX-FileCopyrightText: 2021 Carl Schwan <carlschwan@kde.org>
// SPDX-License-Identifier: LGPL-2.1-or-later
#pragma once
#include <QTextDocument>
#include <QQuickTextDocument>
#include <QSyntaxHighlighter>
#include <Sonnet/Speller>
#include <QTextDocument>
#ifndef Q_OS_ANDROID
#include <Sonnet/GuessLanguage>
#include <Sonnet/Speller>
#endif
class SpellcheckHighlighter: public QSyntaxHighlighter
class SpellcheckHighlighter : public QSyntaxHighlighter
{
Q_OBJECT
Q_PROPERTY(QQuickTextDocument *document READ quickDocument WRITE setQuickDocument NOTIFY documentChanged)
Q_PROPERTY(int cursorPosition READ cursorPosition WRITE setCursorPosition NOTIFY cursorPositionChanged)
Q_PROPERTY(int selectionStart READ selectionStart WRITE setSelectionStart NOTIFY selectionStartChanged)
Q_PROPERTY(int selectionEnd READ selectionEnd WRITE setSelectionEnd NOTIFY selectionEndChanged)
Q_PROPERTY(bool wordIsMisspelled READ wordIsMisspelled NOTIFY wordIsMisspelledChanged)
Q_PROPERTY(QString wordUnderMouse READ wordUnderMouse NOTIFY wordUnderMouseChanged)
public:
SpellcheckHighlighter(QTextDocument *parent);
SpellcheckHighlighter(QObject *parent = nullptr);
Q_INVOKABLE QStringList suggestions(int position, int max = 5);
Q_INVOKABLE void ignoreWord(const QString &word);
Q_INVOKABLE void addWordToDictionary(const QString &word);
Q_INVOKABLE void replaceWord(const QString &word);
[[nodiscard]] QQuickTextDocument *quickDocument() const;
void setQuickDocument(QQuickTextDocument *document);
[[nodiscard]] int cursorPosition() const;
void setCursorPosition(int position);
[[nodiscard]] int selectionStart() const;
void setSelectionStart(int position);
[[nodiscard]] int selectionEnd() const;
void setSelectionEnd(int position);
[[nodiscard]] bool wordIsMisspelled() const;
[[nodiscard]] QString wordUnderMouse() const;
protected:
void highlightBlock(const QString &text) override;
Q_SIGNALS:
void documentChanged();
void cursorPositionChanged();
void selectionStartChanged();
void selectionEndChanged();
void wordIsMisspelledChanged();
void wordUnderMouseChanged();
void changeCursorPosition(int start, int end);
private:
[[nodiscard]] QTextCursor textCursor() const;
[[nodiscard]] QTextDocument *textDocument() const;
void autodetectLanguage(const QString &sentence);
QTextCharFormat mErrorFormat;
QTextCharFormat mQuoteFormat;
#ifndef Q_OS_ANDROID
QScopedPointer<Sonnet::Speller> mSpellchecker;
QScopedPointer<Sonnet::GuessLanguage> mLanguageGuesser;
};
#endif
QString m_selectedWord;
QQuickTextDocument *m_document;
int m_cursorPosition;
int m_selectionStart;
int m_selectionEnd;
int m_autoCompleteBeginPosition = -1;
int m_autoCompleteEndPosition = -1;
int m_wordIsMisspelled = false;
};