diff --git a/CHANGELOG.md b/CHANGELOG.md index 5914fb33..9ba7d664 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,7 +74,7 @@ - Dev: Unwrapped `LimitedQueueSnapshot` to `std::vector`. (#6606) - Dev: Simplified uses of `getMessageSnapshot`. (#6607) - 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 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) diff --git a/src/controllers/commands/builtin/chatterino/Debugging.cpp b/src/controllers/commands/builtin/chatterino/Debugging.cpp index aaa40e06..0b707277 100644 --- a/src/controllers/commands/builtin/chatterino/Debugging.cpp +++ b/src/controllers/commands/builtin/chatterino/Debugging.cpp @@ -5,6 +5,7 @@ #include "common/Env.hpp" #include "controllers/commands/CommandContext.hpp" #include "controllers/notifications/NotificationController.hpp" +#include "controllers/spellcheck/SpellChecker.hpp" #include "messages/Image.hpp" #include "messages/Message.hpp" #include "messages/MessageBuilder.hpp" @@ -202,6 +203,18 @@ QString debugTest(const CommandContext &ctx) getApp()->getUpdates().checkForUpdates(); 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") { ctx.channel->addSystemMessage(u"requesting settings save"_s); diff --git a/src/controllers/spellcheck/SpellChecker.cpp b/src/controllers/spellcheck/SpellChecker.cpp index 549bb894..3e5ed43f 100644 --- a/src/controllers/spellcheck/SpellChecker.cpp +++ b/src/controllers/spellcheck/SpellChecker.cpp @@ -3,7 +3,10 @@ #include "Application.hpp" #include "common/QLogging.hpp" #include "singletons/Paths.hpp" +#include "singletons/Settings.hpp" +#include "util/CombinePath.hpp" #include "util/FilesystemHelpers.hpp" +#include "util/XDGDirectory.hpp" #ifdef CHATTERINO_WITH_SPELLCHECK # include @@ -12,22 +15,77 @@ namespace chatterino { #ifdef CHATTERINO_WITH_SPELLCHECK + +namespace { + +/// Returns a list of available dictionaries in the given directory +std::vector loadDictionariesFromDirectory( + const QDir &searchDirectory) +{ + std::vector 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 { public: - static std::unique_ptr tryLoad(); + static std::unique_ptr 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; private: SpellCheckerPrivate(const char *affpath, const char *dpath); }; -std::unique_ptr SpellCheckerPrivate::tryLoad() +std::unique_ptr SpellCheckerPrivate::tryLoad( + const QString &path) { - auto stdPath = qStringToStdPath(getApp()->getPaths().dictionariesDirectory); - auto aff = stdPath / "index.aff"; - auto dic = stdPath / "index.dic"; + std::filesystem::path aff; + std::filesystem::path 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)) { qCInfo(chatterinoSpellcheck) @@ -64,6 +122,18 @@ SpellCheckerPrivate::SpellCheckerPrivate(const char *affpath, const char *dpath) SpellChecker::SpellChecker() : 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 class SpellCheckerPrivate @@ -117,4 +187,74 @@ std::vector SpellChecker::suggestions(const QString &word) #endif } +// NOLINTNEXTLINE(readability-convert-member-functions-to-static) +std::vector 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 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 diff --git a/src/controllers/spellcheck/SpellChecker.hpp b/src/controllers/spellcheck/SpellChecker.hpp index 350ba143..f676362c 100644 --- a/src/controllers/spellcheck/SpellChecker.hpp +++ b/src/controllers/spellcheck/SpellChecker.hpp @@ -11,6 +11,16 @@ namespace chatterino { class Channel; 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 SpellChecker { @@ -23,6 +33,11 @@ public: bool check(const QString &word); std::vector suggestions(const QString &word); + /// Return a list of system-installed dictionaries. + /// + /// Currently only implemented on Linux. + std::vector getSystemDictionaries() const; + private: std::unique_ptr private_; }; diff --git a/src/singletons/Settings.hpp b/src/singletons/Settings.hpp index 15fa75a2..259c8272 100644 --- a/src/singletons/Settings.hpp +++ b/src/singletons/Settings.hpp @@ -353,8 +353,14 @@ public: false, }; - BoolSetting enableSpellChecking = {"/behaviour/spellChecking/enabled", - false}; + BoolSetting enableSpellChecking = { + "/behaviour/spellChecking/enabled", + false, + }; + QStringSetting spellCheckingFallback = { + "/behaviour/spellChecking/systemFallback", + "", + }; FloatSetting pauseOnHoverDuration = {"/behaviour/pauseOnHoverDuration", 0}; EnumSetting pauseChatModifier = { diff --git a/src/widgets/settingspages/ExternalToolsPage.cpp b/src/widgets/settingspages/ExternalToolsPage.cpp index 1cb9ff83..3ede517c 100644 --- a/src/widgets/settingspages/ExternalToolsPage.cpp +++ b/src/widgets/settingspages/ExternalToolsPage.cpp @@ -1,5 +1,7 @@ #include "widgets/settingspages/ExternalToolsPage.hpp" +#include "controllers/spellcheck/SpellChecker.hpp" +#include "singletons/Paths.hpp" #include "singletons/Settings.hpp" #include "util/Clipboard.hpp" #include "util/Helpers.hpp" @@ -16,6 +18,8 @@ #include #include +#include + namespace chatterino { namespace { @@ -227,6 +231,52 @@ void ExternalToolsPage::initLayout(GeneralPageView &layout) 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 { + return { + dict.name, + dict.path, + }; + }; + std::vector> 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(); } diff --git a/src/widgets/settingspages/GeneralPage.cpp b/src/widgets/settingspages/GeneralPage.cpp index b66542ae..68f2f30e 100644 --- a/src/widgets/settingspages/GeneralPage.cpp +++ b/src/widgets/settingspages/GeneralPage.cpp @@ -481,21 +481,6 @@ void GeneralPage::initLayout(GeneralPageView &layout) "yours is successfully sent in the matching channel.") ->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"); SettingWidget::checkbox("Separate with lines", s.separateMessages)