feat: linux-only system dictionary spell checking (#6703)
Reviewed-by: Nerixyz <nerixdev@outlook.de>
This commit is contained in:
+1
-1
@@ -74,7 +74,7 @@
|
|||||||
- Dev: Unwrapped `LimitedQueueSnapshot` to `std::vector`. (#6606)
|
- Dev: Unwrapped `LimitedQueueSnapshot` to `std::vector`. (#6606)
|
||||||
- Dev: Simplified uses of `getMessageSnapshot`. (#6607)
|
- Dev: Simplified uses of `getMessageSnapshot`. (#6607)
|
||||||
- Dev: Disabled `llvm-prefer-static-over-anonymous-namespace` in clang-tidy. (#6610)
|
- Dev: Disabled `llvm-prefer-static-over-anonymous-namespace` in clang-tidy. (#6610)
|
||||||
- Dev: Started work on spell checking. (#6446)
|
- Dev: Added experimental spell checker support. (#6446, #6703)
|
||||||
- Dev: Added Clazy linting in CI. (#6623)
|
- Dev: Added Clazy linting in CI. (#6623)
|
||||||
- Dev: Added custom clang-tidy module linting in CI. (#6626)
|
- Dev: Added custom clang-tidy module linting in CI. (#6626)
|
||||||
- Dev: CMake option `USE_ALTERNATE_LINKER` now errors if the given linker can't be found. (#6692)
|
- Dev: CMake option `USE_ALTERNATE_LINKER` now errors if the given linker can't be found. (#6692)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
#include "common/Env.hpp"
|
#include "common/Env.hpp"
|
||||||
#include "controllers/commands/CommandContext.hpp"
|
#include "controllers/commands/CommandContext.hpp"
|
||||||
#include "controllers/notifications/NotificationController.hpp"
|
#include "controllers/notifications/NotificationController.hpp"
|
||||||
|
#include "controllers/spellcheck/SpellChecker.hpp"
|
||||||
#include "messages/Image.hpp"
|
#include "messages/Image.hpp"
|
||||||
#include "messages/Message.hpp"
|
#include "messages/Message.hpp"
|
||||||
#include "messages/MessageBuilder.hpp"
|
#include "messages/MessageBuilder.hpp"
|
||||||
@@ -202,6 +203,18 @@ QString debugTest(const CommandContext &ctx)
|
|||||||
getApp()->getUpdates().checkForUpdates();
|
getApp()->getUpdates().checkForUpdates();
|
||||||
ctx.channel->addSystemMessage(QString("checking for updates"));
|
ctx.channel->addSystemMessage(QString("checking for updates"));
|
||||||
}
|
}
|
||||||
|
else if (command == "spellcheck-get-system-dictionaries")
|
||||||
|
{
|
||||||
|
#ifdef CHATTERINO_WITH_SPELLCHECK
|
||||||
|
auto dicts = getApp()->getSpellChecker()->getSystemDictionaries();
|
||||||
|
for (const auto &dict : dicts)
|
||||||
|
{
|
||||||
|
ctx.channel->addSystemMessage(QString("system dictionary: %1 at %2")
|
||||||
|
.arg(dict.name)
|
||||||
|
.arg(dict.path));
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
else if (command == "save-settings")
|
else if (command == "save-settings")
|
||||||
{
|
{
|
||||||
ctx.channel->addSystemMessage(u"requesting settings save"_s);
|
ctx.channel->addSystemMessage(u"requesting settings save"_s);
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
#include "Application.hpp"
|
#include "Application.hpp"
|
||||||
#include "common/QLogging.hpp"
|
#include "common/QLogging.hpp"
|
||||||
#include "singletons/Paths.hpp"
|
#include "singletons/Paths.hpp"
|
||||||
|
#include "singletons/Settings.hpp"
|
||||||
|
#include "util/CombinePath.hpp"
|
||||||
#include "util/FilesystemHelpers.hpp"
|
#include "util/FilesystemHelpers.hpp"
|
||||||
|
#include "util/XDGDirectory.hpp"
|
||||||
|
|
||||||
#ifdef CHATTERINO_WITH_SPELLCHECK
|
#ifdef CHATTERINO_WITH_SPELLCHECK
|
||||||
# include <hunspell/hunspell.hxx>
|
# include <hunspell/hunspell.hxx>
|
||||||
@@ -12,22 +15,77 @@
|
|||||||
namespace chatterino {
|
namespace chatterino {
|
||||||
|
|
||||||
#ifdef CHATTERINO_WITH_SPELLCHECK
|
#ifdef CHATTERINO_WITH_SPELLCHECK
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
/// Returns a list of available dictionaries in the given directory
|
||||||
|
std::vector<DictionaryInfo> loadDictionariesFromDirectory(
|
||||||
|
const QDir &searchDirectory)
|
||||||
|
{
|
||||||
|
std::vector<DictionaryInfo> dictionaries;
|
||||||
|
|
||||||
|
for (const auto &affInfo : searchDirectory.entryInfoList(
|
||||||
|
{"*.aff"}, QDir::Files | QDir::NoDotAndDotDot, QDir::Name))
|
||||||
|
{
|
||||||
|
if (!affInfo.isFile())
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto dictName = affInfo.baseName();
|
||||||
|
|
||||||
|
auto dicInfo = QFileInfo(searchDirectory, dictName % ".dic");
|
||||||
|
if (!dicInfo.isFile())
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto isSymbolicLink =
|
||||||
|
affInfo.isSymbolicLink() || dicInfo.isSymbolicLink();
|
||||||
|
|
||||||
|
dictionaries.push_back(DictionaryInfo{
|
||||||
|
.name = dictName,
|
||||||
|
.path = searchDirectory.absoluteFilePath(dictName),
|
||||||
|
.isSymbolicLink = isSymbolicLink,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return dictionaries;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
class SpellCheckerPrivate
|
class SpellCheckerPrivate
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
static std::unique_ptr<SpellCheckerPrivate> tryLoad();
|
static std::unique_ptr<SpellCheckerPrivate> tryLoad(
|
||||||
|
const QString &path = {});
|
||||||
|
|
||||||
|
/// NOTE: To support multiple dictionaries at the same time, it seems like we need to store a list of Hunspell instances, each supporting a single dictionary, and then during the spell checking process check each hunspell instance.
|
||||||
Hunspell hunspell;
|
Hunspell hunspell;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
SpellCheckerPrivate(const char *affpath, const char *dpath);
|
SpellCheckerPrivate(const char *affpath, const char *dpath);
|
||||||
};
|
};
|
||||||
|
|
||||||
std::unique_ptr<SpellCheckerPrivate> SpellCheckerPrivate::tryLoad()
|
std::unique_ptr<SpellCheckerPrivate> SpellCheckerPrivate::tryLoad(
|
||||||
|
const QString &path)
|
||||||
{
|
{
|
||||||
auto stdPath = qStringToStdPath(getApp()->getPaths().dictionariesDirectory);
|
std::filesystem::path aff;
|
||||||
auto aff = stdPath / "index.aff";
|
std::filesystem::path dic;
|
||||||
auto dic = stdPath / "index.dic";
|
if (path.isNull())
|
||||||
|
{
|
||||||
|
auto stdPath =
|
||||||
|
qStringToStdPath(getApp()->getPaths().dictionariesDirectory);
|
||||||
|
aff = stdPath / "index.aff";
|
||||||
|
dic = stdPath / "index.dic";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
aff = qStringToStdPath(path % ".aff");
|
||||||
|
dic = qStringToStdPath(path % ".dic");
|
||||||
|
}
|
||||||
|
|
||||||
if (!std::filesystem::exists(aff) || !std::filesystem::exists(dic))
|
if (!std::filesystem::exists(aff) || !std::filesystem::exists(dic))
|
||||||
{
|
{
|
||||||
qCInfo(chatterinoSpellcheck)
|
qCInfo(chatterinoSpellcheck)
|
||||||
@@ -64,6 +122,18 @@ SpellCheckerPrivate::SpellCheckerPrivate(const char *affpath, const char *dpath)
|
|||||||
SpellChecker::SpellChecker()
|
SpellChecker::SpellChecker()
|
||||||
: private_(SpellCheckerPrivate::tryLoad())
|
: private_(SpellCheckerPrivate::tryLoad())
|
||||||
{
|
{
|
||||||
|
// The method we load dictionaries by is bound to change, so it's OK for this to be a bit ugly as we test things.
|
||||||
|
|
||||||
|
if (!this->private_)
|
||||||
|
{
|
||||||
|
// The default dictionary was not found, try the fallback if it's set
|
||||||
|
auto fallbackDictionary =
|
||||||
|
getSettings()->spellCheckingFallback.getValue();
|
||||||
|
if (!fallbackDictionary.isEmpty())
|
||||||
|
{
|
||||||
|
this->private_ = SpellCheckerPrivate::tryLoad(fallbackDictionary);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
class SpellCheckerPrivate
|
class SpellCheckerPrivate
|
||||||
@@ -117,4 +187,74 @@ std::vector<std::string> SpellChecker::suggestions(const QString &word)
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NOLINTNEXTLINE(readability-convert-member-functions-to-static)
|
||||||
|
std::vector<DictionaryInfo> SpellChecker::getSystemDictionaries() const
|
||||||
|
{
|
||||||
|
#ifdef CHATTERINO_WITH_SPELLCHECK
|
||||||
|
# if defined(Q_OS_UNIX) and !defined(Q_OS_DARWIN)
|
||||||
|
// For each XDG data directory, search in hunspell, myspell, and myspell/dicts.
|
||||||
|
// This somewhat matches where dictionaries are stored on Ubuntu & Fedora
|
||||||
|
// if we want to support defaulting to your LC_ALL language.
|
||||||
|
|
||||||
|
QStringList searchDirectories;
|
||||||
|
auto dataDirs = getXDGBaseDirectories(XDGDirectoryType::Data);
|
||||||
|
for (const auto &dataDir : dataDirs)
|
||||||
|
{
|
||||||
|
searchDirectories.push_back(combinePath(dataDir, "hunspell"));
|
||||||
|
searchDirectories.push_back(combinePath(dataDir, "myspell"));
|
||||||
|
searchDirectories.push_back(combinePath(dataDir, "myspell/dicts"));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<DictionaryInfo> dictionaries;
|
||||||
|
|
||||||
|
for (const auto &searchDirectory : searchDirectories)
|
||||||
|
{
|
||||||
|
qCDebug(chatterinoSpellcheck)
|
||||||
|
<< "Looking for system dictionaries in" << searchDirectory;
|
||||||
|
for (const auto &dict : loadDictionariesFromDirectory(searchDirectory))
|
||||||
|
{
|
||||||
|
if (dict.isSymbolicLink)
|
||||||
|
{
|
||||||
|
// NOTE: We currently filter out symbolic links from system-loaded dictionaries.
|
||||||
|
// Without this, the list of dictionaries we "support" would be too high on Linux distros.
|
||||||
|
// As an example, this is the symlinks the installation of `hunspell-en-gb` creates on Arch Linux:
|
||||||
|
// - en_AG.aff -> en_GB-large.aff
|
||||||
|
// - en_BS.aff -> en_GB-large.aff
|
||||||
|
// - en_BW.aff -> en_GB-large.aff
|
||||||
|
// - en_BZ.aff -> en_GB-large.aff
|
||||||
|
// - en_DK.aff -> en_GB-large.aff
|
||||||
|
// - en_GB.aff -> en_GB-large.aff
|
||||||
|
// - en_GB-large.aff
|
||||||
|
// - en_GH.aff -> en_GB-large.aff
|
||||||
|
// - en_HK.aff -> en_GB-large.aff
|
||||||
|
// - en_IE.aff -> en_GB-large.aff
|
||||||
|
// - en_IN.aff -> en_GB-large.aff
|
||||||
|
// - en_JM.aff -> en_GB-large.aff
|
||||||
|
// - en_NA.aff -> en_GB-large.aff
|
||||||
|
// - en_NG.aff -> en_GB-large.aff
|
||||||
|
// - en_NZ.aff -> en_GB-large.aff
|
||||||
|
// - en_SG.aff -> en_GB-large.aff
|
||||||
|
// - en_TT.aff -> en_GB-large.aff
|
||||||
|
// - en_ZA.aff -> en_GB-large.aff
|
||||||
|
// - en_ZW.aff -> en_GB-large.aff
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
dictionaries.push_back(DictionaryInfo{
|
||||||
|
.name = dict.name % " (System)",
|
||||||
|
.path = dict.path,
|
||||||
|
.isSymbolicLink = false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dictionaries;
|
||||||
|
# else
|
||||||
|
return {};
|
||||||
|
# endif
|
||||||
|
#else
|
||||||
|
return {};
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace chatterino
|
} // namespace chatterino
|
||||||
|
|||||||
@@ -11,6 +11,16 @@ namespace chatterino {
|
|||||||
class Channel;
|
class Channel;
|
||||||
class TwitchChannel;
|
class TwitchChannel;
|
||||||
|
|
||||||
|
struct DictionaryInfo {
|
||||||
|
/// The name of the dictionary to be shown to users (e.g. "en_GB (System)")
|
||||||
|
QString name;
|
||||||
|
|
||||||
|
/// The absolute path to the dictionary without the .aff or .dic suffix (e.g. "/foo/bar/en_GB")
|
||||||
|
QString path;
|
||||||
|
|
||||||
|
bool isSymbolicLink;
|
||||||
|
};
|
||||||
|
|
||||||
class SpellCheckerPrivate;
|
class SpellCheckerPrivate;
|
||||||
class SpellChecker
|
class SpellChecker
|
||||||
{
|
{
|
||||||
@@ -23,6 +33,11 @@ public:
|
|||||||
bool check(const QString &word);
|
bool check(const QString &word);
|
||||||
std::vector<std::string> suggestions(const QString &word);
|
std::vector<std::string> suggestions(const QString &word);
|
||||||
|
|
||||||
|
/// Return a list of system-installed dictionaries.
|
||||||
|
///
|
||||||
|
/// Currently only implemented on Linux.
|
||||||
|
std::vector<DictionaryInfo> getSystemDictionaries() const;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
std::unique_ptr<SpellCheckerPrivate> private_;
|
std::unique_ptr<SpellCheckerPrivate> private_;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -353,8 +353,14 @@ public:
|
|||||||
false,
|
false,
|
||||||
};
|
};
|
||||||
|
|
||||||
BoolSetting enableSpellChecking = {"/behaviour/spellChecking/enabled",
|
BoolSetting enableSpellChecking = {
|
||||||
false};
|
"/behaviour/spellChecking/enabled",
|
||||||
|
false,
|
||||||
|
};
|
||||||
|
QStringSetting spellCheckingFallback = {
|
||||||
|
"/behaviour/spellChecking/systemFallback",
|
||||||
|
"",
|
||||||
|
};
|
||||||
|
|
||||||
FloatSetting pauseOnHoverDuration = {"/behaviour/pauseOnHoverDuration", 0};
|
FloatSetting pauseOnHoverDuration = {"/behaviour/pauseOnHoverDuration", 0};
|
||||||
EnumSetting<Qt::KeyboardModifier> pauseChatModifier = {
|
EnumSetting<Qt::KeyboardModifier> pauseChatModifier = {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
#include "widgets/settingspages/ExternalToolsPage.hpp"
|
#include "widgets/settingspages/ExternalToolsPage.hpp"
|
||||||
|
|
||||||
|
#include "controllers/spellcheck/SpellChecker.hpp"
|
||||||
|
#include "singletons/Paths.hpp"
|
||||||
#include "singletons/Settings.hpp"
|
#include "singletons/Settings.hpp"
|
||||||
#include "util/Clipboard.hpp"
|
#include "util/Clipboard.hpp"
|
||||||
#include "util/Helpers.hpp"
|
#include "util/Helpers.hpp"
|
||||||
@@ -16,6 +18,8 @@
|
|||||||
#include <QMessageBox>
|
#include <QMessageBox>
|
||||||
#include <QPushButton>
|
#include <QPushButton>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
namespace chatterino {
|
namespace chatterino {
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
@@ -227,6 +231,52 @@ void ExternalToolsPage::initLayout(GeneralPageView &layout)
|
|||||||
layout.addLayout(buttonLayout);
|
layout.addLayout(buttonLayout);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#ifdef CHATTERINO_WITH_SPELLCHECK
|
||||||
|
{
|
||||||
|
// auto *form = new QFormLayout;
|
||||||
|
layout.addTitle("Spell checker (experimental)");
|
||||||
|
|
||||||
|
layout.addDescription(
|
||||||
|
u"Check the spelling of words in the input box of splits."
|
||||||
|
" Chatterino does not include dictionaries - they have to "
|
||||||
|
"be downloaded or created manually. Chatterino expects "
|
||||||
|
"Hunspell "
|
||||||
|
"dictionaries in " %
|
||||||
|
formatRichNamedLink(getApp()->getPaths().dictionariesDirectory,
|
||||||
|
getApp()->getPaths().dictionariesDirectory) %
|
||||||
|
u". The file index.aff has to contain the affixes and "
|
||||||
|
u"index.dic "
|
||||||
|
u"must contain the dictionary (subject to change).");
|
||||||
|
|
||||||
|
SettingWidget::checkbox("Check spelling by default",
|
||||||
|
s.enableSpellChecking)
|
||||||
|
->setTooltip("Check the spelling of words in the input box of all "
|
||||||
|
"splits by default.")
|
||||||
|
->addTo(layout);
|
||||||
|
|
||||||
|
auto toItem =
|
||||||
|
[](const DictionaryInfo &dict) -> std::pair<QString, QVariant> {
|
||||||
|
return {
|
||||||
|
dict.name,
|
||||||
|
dict.path,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
std::vector<std::pair<QString, QVariant>> dictList{{"None", ""}};
|
||||||
|
|
||||||
|
std::ranges::transform(
|
||||||
|
getApp()->getSpellChecker()->getSystemDictionaries(),
|
||||||
|
std::back_inserter(dictList), toItem);
|
||||||
|
|
||||||
|
if (dictList.size() > 1)
|
||||||
|
{
|
||||||
|
SettingWidget::dropdown(
|
||||||
|
"Fallback spellchecking dictionary (requires restart)",
|
||||||
|
s.spellCheckingFallback, dictList)
|
||||||
|
->addTo(layout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
layout.addStretch();
|
layout.addStretch();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -481,21 +481,6 @@ void GeneralPage::initLayout(GeneralPageView &layout)
|
|||||||
"yours is successfully sent in the matching channel.")
|
"yours is successfully sent in the matching channel.")
|
||||||
->addTo(layout);
|
->addTo(layout);
|
||||||
|
|
||||||
#ifdef CHATTERINO_WITH_SPELLCHECK
|
|
||||||
SettingWidget::checkbox("Check spelling by default (experimental)",
|
|
||||||
s.enableSpellChecking)
|
|
||||||
->setTooltip(
|
|
||||||
u"Check the spelling of words in the input box of all splits by "
|
|
||||||
"default. This can be overwritten per split in the context menu."
|
|
||||||
" Chatterino does not include dictionaries - they have to "
|
|
||||||
"be downloaded or created manually. Chatterino expects Hunspell "
|
|
||||||
"dictionaries in '" %
|
|
||||||
getApp()->getPaths().dictionariesDirectory %
|
|
||||||
u"'. The file index.aff has to contain the affixes and index.dic "
|
|
||||||
u"must contain the dictionary (subject to change).")
|
|
||||||
->addTo(layout);
|
|
||||||
#endif
|
|
||||||
|
|
||||||
layout.addTitle("Messages");
|
layout.addTitle("Messages");
|
||||||
|
|
||||||
SettingWidget::checkbox("Separate with lines", s.separateMessages)
|
SettingWidget::checkbox("Separate with lines", s.separateMessages)
|
||||||
|
|||||||
Reference in New Issue
Block a user