From 2a72da564e746a0422c36a1654e90d3defc4419b Mon Sep 17 00:00:00 2001 From: Jerome Lamy Date: Mon, 15 Apr 2024 13:58:17 -0400 Subject: [PATCH] spellcheck: for linux system dicts Implement a first version of the spellcheck for linux that use the systemwide installed dictionaries. GitLab: #1997 Change-Id: I7158e6c61061e7d0a7fe651069247227bbe399a4 --- .gitmodules | 4 + 3rdparty/hunspell | 1 + CMakeLists.txt | 72 ++++++++- build.py | 8 +- src/app/appsettingsmanager.h | 2 + .../commoncomponents/LineEditContextMenu.qml | 82 +++++++++- .../SpellLanguageContextMenu.qml | 77 +++++++++ .../contextmenu/BaseContextMenu.qml | 4 + .../contextmenu/ContextMenuAutoLoader.qml | 3 + .../contextmenu/GeneralMenuItem.qml | 1 + ...imagedownloader.cpp => filedownloader.cpp} | 24 +-- .../{imagedownloader.h => filedownloader.h} | 20 +-- src/app/mainapplication.cpp | 3 + src/app/mainapplication.h | 2 + src/app/mainview/components/CachedFile.qml | 34 ++++ src/app/mainview/components/CachedImage.qml | 6 +- .../mainview/components/ChatViewHeader.qml | 28 ++-- .../components/MessageBarTextArea.qml | 143 ++++++++++++++++- src/app/messagesadapter.cpp | 59 +++++++ src/app/messagesadapter.h | 20 ++- src/app/net/jami/Constants/JamiStrings.qml | 11 +- src/app/net/jami/Constants/JamiTheme.qml | 1 + src/app/qmlregister.cpp | 10 +- src/app/qmlregister.h | 2 + .../components/ManageAccountPage.qml | 1 - .../components/SystemSettingsPage.qml | 147 ++++++++++++++++- src/app/spellcheckdictionarymanager.cpp | 149 ++++++++++++++++++ src/app/spellcheckdictionarymanager.h | 39 +++++ src/app/spellchecker.cpp | 117 ++++++++++++++ src/app/spellchecker.h | 63 ++++++++ src/app/utilsadapter.cpp | 6 +- src/app/utilsadapter.h | 2 + tests/qml/main.cpp | 4 + tests/qml/src/tst_CachedImage.qml | 8 +- 34 files changed, 1076 insertions(+), 77 deletions(-) create mode 160000 3rdparty/hunspell create mode 100644 src/app/commoncomponents/SpellLanguageContextMenu.qml rename src/app/{imagedownloader.cpp => filedownloader.cpp} (66%) rename src/app/{imagedownloader.h => filedownloader.h} (66%) create mode 100644 src/app/mainview/components/CachedFile.qml create mode 100644 src/app/spellcheckdictionarymanager.cpp create mode 100644 src/app/spellcheckdictionarymanager.h create mode 100644 src/app/spellchecker.cpp create mode 100644 src/app/spellchecker.h diff --git a/.gitmodules b/.gitmodules index b06ae33b..44fd42b5 100644 --- a/.gitmodules +++ b/.gitmodules @@ -31,3 +31,7 @@ path = 3rdparty/zxing-cpp url = https://github.com/nu-book/zxing-cpp.git ignore = dirty +[submodule "3rdparty/hunspell"] + path = 3rdparty/hunspell + url = https://gitlab.savoirfairelinux.com/jami/hunspell.git + ignore = dirty diff --git a/3rdparty/hunspell b/3rdparty/hunspell new file mode 160000 index 00000000..525f9f22 --- /dev/null +++ b/3rdparty/hunspell @@ -0,0 +1 @@ +Subproject commit 525f9f22766a28e0f81c435217fcf4528e01c013 diff --git a/CMakeLists.txt b/CMakeLists.txt index e96fbf61..268c08a3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -255,7 +255,7 @@ set(PYTHON_EXEC ${Python3_EXECUTABLE}) # Versioning and build ID generation set(VERSION_FILE ${CMAKE_CURRENT_BINARY_DIR}/version_info.cpp) -# Touch the file to make sure it exists at configure time as +# Touch the file to ensure it exists at configure time as # we add it to the target_sources below. file(TOUCH ${VERSION_FILE}) add_custom_target( @@ -347,6 +347,7 @@ set(COMMON_SOURCES ${APP_SRC_DIR}/conversationlistmodel.cpp ${APP_SRC_DIR}/searchresultslistmodel.cpp ${APP_SRC_DIR}/calloverlaymodel.cpp + ${APP_SRC_DIR}/spellcheckdictionarymanager.cpp ${APP_SRC_DIR}/filestosendlistmodel.cpp ${APP_SRC_DIR}/wizardviewstepmodel.cpp ${APP_SRC_DIR}/avatarregistry.cpp @@ -361,13 +362,13 @@ set(COMMON_SOURCES ${APP_SRC_DIR}/currentcall.cpp ${APP_SRC_DIR}/messageparser.cpp ${APP_SRC_DIR}/previewengine.cpp - ${APP_SRC_DIR}/imagedownloader.cpp + ${APP_SRC_DIR}/filedownloader.cpp ${APP_SRC_DIR}/pluginversionmanager.cpp ${APP_SRC_DIR}/connectioninfolistmodel.cpp ${APP_SRC_DIR}/pluginversionmanager.cpp ${APP_SRC_DIR}/linkdevicemodel.cpp ${APP_SRC_DIR}/qrcodescannermodel.cpp -) + ${APP_SRC_DIR}/spellchecker.cpp) set(COMMON_HEADERS ${APP_SRC_DIR}/global.h @@ -419,6 +420,7 @@ set(COMMON_HEADERS ${APP_SRC_DIR}/conversationlistmodel.h ${APP_SRC_DIR}/searchresultslistmodel.h ${APP_SRC_DIR}/calloverlaymodel.h + ${APP_SRC_DIR}/spellcheckdictionarymanager.h ${APP_SRC_DIR}/filestosendlistmodel.h ${APP_SRC_DIR}/wizardviewstepmodel.h ${APP_SRC_DIR}/avatarregistry.h @@ -433,7 +435,7 @@ set(COMMON_HEADERS ${APP_SRC_DIR}/currentcall.h ${APP_SRC_DIR}/messageparser.h ${APP_SRC_DIR}/htmlparser.h - ${APP_SRC_DIR}/imagedownloader.h + ${APP_SRC_DIR}/filedownloader.h ${APP_SRC_DIR}/pluginversionmanager.h ${APP_SRC_DIR}/connectioninfolistmodel.h ${APP_SRC_DIR}/pttlistener.h @@ -441,7 +443,7 @@ set(COMMON_HEADERS ${APP_SRC_DIR}/crashreporter.h ${APP_SRC_DIR}/linkdevicemodel.h ${APP_SRC_DIR}/qrcodescannermodel.h -) + ${APP_SRC_DIR}/spellchecker.h) # For libavutil/avframe. set(LIBJAMI_CONTRIB_DIR "${DAEMON_DIR}/contrib") @@ -469,6 +471,48 @@ if(ENABLE_CRASHREPORTS) endif() endif() +find_package(PkgConfig REQUIRED) + +# hunspell +pkg_check_modules(HUNSPELL hunspell) +if(MSVC) +elseif (NOT APPLE) + set(HUNSPELL_DICT_DIR "/usr/share/hunspell/") +else() + set(HUNSPELL_DICT_DIR "/Library/Spelling/") +endif() + +if(HUNSPELL_FOUND) + message(STATUS "hunspell found") + include_directories(${HUNSPELL_INCLUDE_DIR}) + find_path(HUNSPELL_INCLUDE_DIRS + NAMES hunspell.hxx + PATH_SUFFIXES hunspell + HINTS ${HUNSPELL_INCLUDE_DIRS} + ) + + find_library(HUNSPELL_LIBRARIES + NAMES ${HUNSPELL_LIBRARIES} + hunspell + hunspell-1.7 + libhunspell + libhunspell-1.7 + libhunspell-devel + libhunspell-dev + HINTS ${HUNSPELL_LIBRARY_DIRS} + ) +else() + message(STATUS "hunspell not found - building hunspell") + + set(HUNSPELL_DIR ${PROJECT_SOURCE_DIR}/3rdparty/hunspell) + + # Build using the submodule and its CMakeLists.txt + add_subdirectory(${HUNSPELL_DIR} hunspell_build) + + set(HUNSPELL_INCLUDE_DIR ${HUNSPELL_DIR}/src) + set(HUNSPELL_LIBRARIES hunspell::hunspell) +endif() + if(MSVC) set(WINDOWS_SYS_LIBS windowsapp.lib @@ -531,8 +575,6 @@ elseif (NOT APPLE) ${APP_SRC_DIR}/screencastportal.h) list(APPEND QT_MODULES DBus) - find_package(PkgConfig REQUIRED) - pkg_check_modules(GLIB REQUIRED glib-2.0) if(GLIB_FOUND) add_definitions(${GLIB_CFLAGS_OTHER}) @@ -615,6 +657,13 @@ else() # APPLE endif() endif() +message(STATUS "Adding HUNSPELL_INCLUDE_DIR" ${HUNSPELL_INCLUDE_DIR}) +list(APPEND CLIENT_INCLUDE_DIRS ${HUNSPELL_INCLUDE_DIR} ${CMAKE_BINARY_DIR}/include +${CMAKE_CURRENT_SOURCE_DIR}/3rdparty/hunspell/src) + +message(STATUS "Adding HUNSPELL_LIBRARIES" ${HUNSPELL_INCLUDE_DIR}) +list(APPEND CLIENT_LIBS ${HUNSPELL_LIBRARIES}) + # Qt find package if(QT6_VER AND QT6_PATH) message(STATUS "Using custom Qt version") @@ -703,7 +752,9 @@ qt_add_executable( ${QML_RESOURCES_QML} ${SFPM_OBJECTS}) -# Make sure we can find the generated version file +add_dependencies(${PROJECT_NAME} hunspell) + +# Ensure the generated version file can be found. add_dependencies(${PROJECT_NAME} generate_version_info) foreach(MODULE ${QT_MODULES}) @@ -797,6 +848,11 @@ elseif (NOT APPLE) PRIVATE JAMI_INSTALL_PREFIX="${JAMI_DATA_PREFIX}") + target_compile_definitions( + ${PROJECT_NAME} + PRIVATE + HUNSPELL_INSTALL_DIR="${HUNSPELL_DICT_DIR}") + # Logos install( FILES resources/images/jami.svg diff --git a/build.py b/build.py index 4a9abee9..20876783 100755 --- a/build.py +++ b/build.py @@ -112,7 +112,7 @@ ZYPPER_CLIENT_DEPENDENCIES = [ 'qt6-svg-devel', 'qt6-multimedia-devel', 'qt6-multimedia-imports', 'qt6-declarative-devel', 'qt6-qmlcompiler-private-devel', 'qt6-quickcontrols2-devel', 'qt6-shadertools-devel', - 'qrencode-devel', 'NetworkManager-devel' + 'qrencode-devel', 'NetworkManager-devel', 'hunspell-devel', 'libhunspell-devel' ] ZYPPER_QT_WEBENGINE = [ @@ -139,7 +139,7 @@ DNF_CLIENT_DEPENDENCIES = [ 'libnotify-devel', 'qt6-qtbase-devel', 'qt6-qtsvg-devel', 'qt6-qtmultimedia-devel', 'qt6-qtdeclarative-devel', - 'qrencode-devel', 'NetworkManager-libnm-devel' + 'qrencode-devel', 'NetworkManager-libnm-devel', 'hunspell-devel', 'libhunspell-devel' ] DNF_QT_WEBENGINE = ['qt6-qtwebengine-devel'] @@ -171,7 +171,7 @@ APT_CLIENT_DEPENDENCIES = [ 'qml6-module-qtquick-dialogs', 'qml6-module-qtquick-layouts', 'qml6-module-qtquick-shapes', 'qml6-module-qtquick-window', 'qml6-module-qtquick-templates', 'qml6-module-qt-labs-platform', - 'libqrencode-dev', 'libnm-dev' + 'libqrencode-dev', 'libnm-dev', 'hunspell', 'libhunspell-dev' ] APT_QT_WEBENGINE = [ @@ -194,7 +194,7 @@ PACMAN_CLIENT_DEPENDENCIES = [ 'qt6-declarative', 'qt6-5compat', 'qt6-multimedia', 'qt6-networkauth', 'qt6-shadertools', 'qt6-svg', 'qt6-tools', - 'qrencode', 'libnm' + 'qrencode', 'libnm', 'hunspell' ] PACMAN_QT_WEBENGINE = ['qt6-webengine'] diff --git a/src/app/appsettingsmanager.h b/src/app/appsettingsmanager.h index 5c4f489c..211f8dd5 100644 --- a/src/app/appsettingsmanager.h +++ b/src/app/appsettingsmanager.h @@ -63,6 +63,8 @@ extern const QString defaultDownloadPath; X(WindowState, QWindow::AutomaticVisibility) \ X(EnableExperimentalSwarm, false) \ X(LANG, "SYSTEM") \ + X(SpellLang, "None") \ + X(EnableSpellCheck, true) \ X(PluginStoreEndpoint, "https://plugins.jami.net") \ X(PositionShareDuration, 15) \ X(PositionShareLimit, true) \ diff --git a/src/app/commoncomponents/LineEditContextMenu.qml b/src/app/commoncomponents/LineEditContextMenu.qml index d3b4e9e4..e7974683 100644 --- a/src/app/commoncomponents/LineEditContextMenu.qml +++ b/src/app/commoncomponents/LineEditContextMenu.qml @@ -15,8 +15,11 @@ * along with this program. If not, see . */ import QtQuick +import net.jami.Adapters 1.1 import net.jami.Constants 1.1 import "contextmenu" +import "../mainview" +import "../mainview/components" ContextMenuAutoLoader { id: root @@ -27,8 +30,16 @@ ContextMenuAutoLoader { property var selectionEnd property bool customizePaste: false property bool selectOnly: false + property bool checkSpell: false + property var suggestionList + property var menuItemsLength + property var language signal contextMenuRequirePaste + SpellLanguageContextMenu { + id: spellLanguageContextMenu + active: checkSpell + } property list menuItems: [ GeneralMenuItem { @@ -38,9 +49,8 @@ ContextMenuAutoLoader { isActif: lineEditObj.selectedText.length itemName: JamiStrings.copy hasIcon: false - onClicked: { + onClicked: lineEditObj.copy(); - } }, GeneralMenuItem { id: cut @@ -49,9 +59,8 @@ ContextMenuAutoLoader { isActif: lineEditObj.selectedText.length && !selectOnly itemName: JamiStrings.cut hasIcon: false - onClicked: { + onClicked: lineEditObj.cut(); - } }, GeneralMenuItem { id: paste @@ -65,9 +74,68 @@ ContextMenuAutoLoader { else lineEditObj.paste(); } + }, + GeneralMenuItem { + id: language + visible: checkSpell + canTrigger: checkSpell + itemName: JamiStrings.language + hasIcon: false + onClicked: { + spellLanguageContextMenu.openMenu(); + } } ] + ListView { + model: ListModel { + id: dynamicModel + } + + Instantiator { + model: dynamicModel + delegate: GeneralMenuItem { + id: suggestion + + canTrigger: true + isActif: true + itemName: model.name + hasIcon: false + onClicked: { + replaceWord(model.name); + } + } + + onObjectAdded: { + menuItems.push(object); + } + + onObjectRemoved: { + menuItems.splice(menuItemsLength, suggestionList.length); + } + } + } + + function removeItems() { + dynamicModel.remove(0, suggestionList.length); + suggestionList.length = 0; + } + + function addMenuItem(wordList) { + menuItemsLength = menuItems.length; // Keep initial number of items for easier removal + suggestionList = wordList; + for (var i = 0; i < suggestionList.length; ++i) { + dynamicModel.append({ + "name": suggestionList[i] + }); + } + } + + function replaceWord(word) { + lineEditObj.remove(selectionStart, selectionEnd); + lineEditObj.insert(lineEditObj.cursorPosition, word); + } + function openMenuAt(mouseEvent) { if (lineEditObj.selectedText.length === 0 && selectOnly) return; @@ -85,6 +153,12 @@ ContextMenuAutoLoader { function onOpened() { lineEditObj.select(selectionStart, selectionEnd); } + function onClosed() { + if (!suggestionList || suggestionList.length == 0) { + return; + } + removeItems(); + } } Component.onCompleted: menuItemsToLoad = menuItems diff --git a/src/app/commoncomponents/SpellLanguageContextMenu.qml b/src/app/commoncomponents/SpellLanguageContextMenu.qml new file mode 100644 index 00000000..7d2aea21 --- /dev/null +++ b/src/app/commoncomponents/SpellLanguageContextMenu.qml @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2020-2025 Savoir-faire Linux Inc. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +import QtQuick +import net.jami.Adapters 1.1 +import net.jami.Constants 1.1 +import net.jami.Models 1.1 +import net.jami.Enums 1.1 +import "contextmenu" +import "../mainview" +import "../mainview/components" + +ContextMenuAutoLoader { + id: root + + signal languageChanged() + + CachedFile { + id: cachedFile + } + + function openMenuAt(mouseEvent) { + x = mouseEvent.x; + y = mouseEvent.y; + root.openMenu(); + } + + onOpenRequested: { + // Create the menu items from the installed dictionaries + menuItemsToLoad = generateMenuItems(); + } + + function generateMenuItems() { + var menuItems = []; + // Create new menu items + var dictionaries = SpellCheckDictionaryManager.installedDictionaries(); + var keys = Object.keys(dictionaries); + for (var i = 0; i < keys.length; ++i) { + var menuItem = Qt.createComponent("qrc:/commoncomponents/contextmenu/GeneralMenuItem.qml", Component.PreferSynchronous); + if (menuItem.status !== Component.Ready) { + console.error("Error loading component:", menuItem.errorString()); + continue; + } + let menuItemObject = menuItem.createObject(root, { + "parent": root, + "canTrigger": true, + "isActif": true, + "itemName": dictionaries[keys[i]], + "hasIcon": false, + "content": keys[i], + }); + if (menuItemObject === null) { + console.error("Error creating menu item:", menuItem.errorString()); + continue; + } + menuItemObject.clicked.connect(function () { + UtilsAdapter.setAppValue(Settings.Key.SpellLang, menuItemObject.content); + }); + // Log the object pointer + menuItems.push(menuItemObject); + } + return menuItems; + } +} diff --git a/src/app/commoncomponents/contextmenu/BaseContextMenu.qml b/src/app/commoncomponents/contextmenu/BaseContextMenu.qml index d721626b..5eb23485 100644 --- a/src/app/commoncomponents/contextmenu/BaseContextMenu.qml +++ b/src/app/commoncomponents/contextmenu/BaseContextMenu.qml @@ -44,11 +44,15 @@ Menu { function loadMenuItems(menuItems) { root.addItem(menuTopBorder); + + // Establish the preferred width of the menu by taking the maximum width of the items for (var j = 0; j < menuItems.length; ++j) { var currentItemWidth = menuItems[j].itemPreferredWidth; if (currentItemWidth !== JamiTheme.menuItemsPreferredWidth && currentItemWidth > menuPreferredWidth && menuItems[j].canTrigger) menuPreferredWidth = currentItemWidth; } + + // Add the items to the menu for (var i = 0; i < menuItems.length; ++i) { if (menuItems[i].canTrigger) { menuItems[i].parentMenu = root; diff --git a/src/app/commoncomponents/contextmenu/ContextMenuAutoLoader.qml b/src/app/commoncomponents/contextmenu/ContextMenuAutoLoader.qml index baee2dfb..a0850d0b 100644 --- a/src/app/commoncomponents/contextmenu/ContextMenuAutoLoader.qml +++ b/src/app/commoncomponents/contextmenu/ContextMenuAutoLoader.qml @@ -27,11 +27,14 @@ Loader { property int contextMenuItemPreferredHeight: 0 property int contextMenuSeparatorPreferredHeight: 0 + signal openRequested + active: false visible: false function openMenu() { + openRequested(); root.active = true; root.sourceComponent = menuComponent; } diff --git a/src/app/commoncomponents/contextmenu/GeneralMenuItem.qml b/src/app/commoncomponents/contextmenu/GeneralMenuItem.qml index ba8ab606..02a9fb45 100644 --- a/src/app/commoncomponents/contextmenu/GeneralMenuItem.qml +++ b/src/app/commoncomponents/contextmenu/GeneralMenuItem.qml @@ -28,6 +28,7 @@ MenuItem { id: menuItem property string itemName: "" + property string content: "" property alias iconSource: contextMenuItemImage.source property string iconColor: "" property bool canTrigger: true diff --git a/src/app/imagedownloader.cpp b/src/app/filedownloader.cpp similarity index 66% rename from src/app/imagedownloader.cpp rename to src/app/filedownloader.cpp index 19318536..9b7ce833 100644 --- a/src/app/imagedownloader.cpp +++ b/src/app/filedownloader.cpp @@ -15,32 +15,32 @@ * along with this program. If not, see . */ -#include "imagedownloader.h" +#include "filedownloader.h" #include #include -ImageDownloader::ImageDownloader(ConnectivityMonitor* cm, QObject* parent) +FileDownloader::FileDownloader(ConnectivityMonitor* cm, QObject* parent) : NetworkManager(cm, parent) {} void -ImageDownloader::downloadImage(const QUrl& url, const QString& localPath) +FileDownloader::downloadFile(const QUrl& url, const QString& localPath) { Utils::oneShotConnect(this, &NetworkManager::errorOccurred, this, [this, localPath]() { - onDownloadImageFinished({}, localPath); + onDownloadFileFinished({}, localPath); }); - sendGetRequest(url, [this, localPath](const QByteArray& imageData) { - onDownloadImageFinished(imageData, localPath); + sendGetRequest(url, [this, localPath](const QByteArray& fileData) { + onDownloadFileFinished(fileData, localPath); }); } void -ImageDownloader::onDownloadImageFinished(const QByteArray& data, const QString& localPath) +FileDownloader::onDownloadFileFinished(const QByteArray& data, const QString& localPath) { if (data.isEmpty()) { - Q_EMIT downloadImageFailed(localPath); + Q_EMIT downloadFileFailed(localPath); return; } @@ -49,7 +49,7 @@ ImageDownloader::onDownloadImageFinished(const QByteArray& data, const QString& const QDir dir; if (!dir.mkpath(dirPath)) { qWarning() << Q_FUNC_INFO << "Failed to create directory" << dirPath; - Q_EMIT downloadImageFailed(localPath); + Q_EMIT downloadFileFailed(localPath); return; } @@ -58,10 +58,10 @@ ImageDownloader::onDownloadImageFinished(const QByteArray& data, const QString& if (lf.lock() && file.open(QIODevice::WriteOnly)) { file.write(data); file.close(); - Q_EMIT downloadImageSuccessful(localPath); + Q_EMIT downloadFileSuccessful(localPath); return; } - qWarning() << Q_FUNC_INFO << "Failed to write image to" << localPath; - Q_EMIT downloadImageFailed(localPath); + qWarning() << Q_FUNC_INFO << "Failed to write file to" << localPath; + Q_EMIT downloadFileFailed(localPath); } diff --git a/src/app/imagedownloader.h b/src/app/filedownloader.h similarity index 66% rename from src/app/imagedownloader.h rename to src/app/filedownloader.h index 32dac08d..b92f24cb 100644 --- a/src/app/imagedownloader.h +++ b/src/app/filedownloader.h @@ -24,7 +24,7 @@ #include // QML registration #include // QML registration -class ImageDownloader : public NetworkManager +class FileDownloader : public NetworkManager { Q_OBJECT QML_SINGLETON @@ -32,23 +32,23 @@ class ImageDownloader : public NetworkManager QML_PROPERTY(QString, cachePath) public: - static ImageDownloader* create(QQmlEngine*, QJSEngine*) + static FileDownloader* create(QQmlEngine*, QJSEngine*) { - return new ImageDownloader( + return new FileDownloader( qApp->property("ConnectivityMonitor").value()); } - explicit ImageDownloader(ConnectivityMonitor* cm, QObject* parent = nullptr); - ~ImageDownloader() = default; + explicit FileDownloader(ConnectivityMonitor* cm, QObject* parent = nullptr); + ~FileDownloader() = default; - // Download an image and call onDownloadImageFinished when done - Q_INVOKABLE void downloadImage(const QUrl& url, const QString& localPath); + // Download an image and call onDownloadFileFinished when done + Q_INVOKABLE void downloadFile(const QUrl& url, const QString& localPath); Q_SIGNALS: - void downloadImageSuccessful(const QString& localPath); - void downloadImageFailed(const QString& localPath); + void downloadFileSuccessful(const QString& localPath); + void downloadFileFailed(const QString& localPath); private Q_SLOTS: // Saves the image to the localPath and emits the appropriate signal - void onDownloadImageFinished(const QByteArray& reply, const QString& localPath); + void onDownloadFileFinished(const QByteArray& reply, const QString& localPath); }; diff --git a/src/app/mainapplication.cpp b/src/app/mainapplication.cpp index 044879b5..f2ba599b 100644 --- a/src/app/mainapplication.cpp +++ b/src/app/mainapplication.cpp @@ -20,6 +20,7 @@ #include "global.h" #include "qmlregister.h" #include "appsettingsmanager.h" +#include "spellcheckdictionarymanager.h" #include "connectivitymonitor.h" #include "systemtray.h" #include "previewengine.h" @@ -190,6 +191,7 @@ MainApplication::init() // to any other initialization. This won't do anything if crashpad isn't // enabled. settingsManager_ = new AppSettingsManager(this); + spellCheckDictionaryManager_ = new SpellCheckDictionaryManager(settingsManager_, this); crashReporter_ = new CrashReporter(settingsManager_, this); // This 2-phase initialisation prevents ephemeral instances from @@ -423,6 +425,7 @@ MainApplication::initQmlLayer() lrcInstance_.get(), systemTray_, settingsManager_, + spellCheckDictionaryManager_, connectivityMonitor_, previewEngine_, &screenInfo_, diff --git a/src/app/mainapplication.h b/src/app/mainapplication.h index 64a0f346..a9fc3999 100644 --- a/src/app/mainapplication.h +++ b/src/app/mainapplication.h @@ -31,6 +31,7 @@ class ConnectivityMonitor; class SystemTray; class AppSettingsManager; +class SpellCheckDictionaryManager; class CrashReporter; class PreviewEngine; @@ -118,6 +119,7 @@ private: ConnectivityMonitor* connectivityMonitor_; SystemTray* systemTray_; AppSettingsManager* settingsManager_; + SpellCheckDictionaryManager* spellCheckDictionaryManager_; PreviewEngine* previewEngine_; CrashReporter* crashReporter_; diff --git a/src/app/mainview/components/CachedFile.qml b/src/app/mainview/components/CachedFile.qml new file mode 100644 index 00000000..cdd2b862 --- /dev/null +++ b/src/app/mainview/components/CachedFile.qml @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2025 Savoir-faire Linux Inc. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import net.jami.Adapters 1.1 +import net.jami.Constants 1.1 +import net.jami.Helpers 1.1 +import net.jami.Models 1.1 +import "../../commoncomponents" + +Item { + id: cachedFile + property string dictionaryPath: SpellCheckDictionaryManager.getDictionariesPath() + + function updateDictionnary(languagePath) { + var file = dictionaryPath + languagePath; + MessagesAdapter.updateDictionnary(file); + } +} diff --git a/src/app/mainview/components/CachedImage.qml b/src/app/mainview/components/CachedImage.qml index 8ce9cfb8..fdebe99a 100644 --- a/src/app/mainview/components/CachedImage.qml +++ b/src/app/mainview/components/CachedImage.qml @@ -64,8 +64,8 @@ Item { } Connections { - target: ImageDownloader - function onDownloadImageSuccessful(localPath) { + target: FileDownloader + function onDownloadFileSuccessful(localPath) { if (localPath === cachedImage.localPath) { image.source = UtilsAdapter.urlFromLocalPath(localPath); } @@ -90,7 +90,7 @@ Item { } if (downloadUrl && downloadUrl !== "" && localPath !== "") { if (!UtilsAdapter.fileExists(localPath)) { - ImageDownloader.downloadImage(downloadUrl, localPath); + FileDownloader.downloadFile(downloadUrl, localPath); } else { image.source = UtilsAdapter.urlFromLocalPath(localPath); if (image.isGif) { diff --git a/src/app/mainview/components/ChatViewHeader.qml b/src/app/mainview/components/ChatViewHeader.qml index 60e8e3b9..7546cbd6 100644 --- a/src/app/mainview/components/ChatViewHeader.qml +++ b/src/app/mainview/components/ChatViewHeader.qml @@ -116,23 +116,23 @@ Rectangle { spacing: 0 - LineEditContextMenu { - id: displayNameContextMenu - lineEditObj: title - selectOnly: true - } - MouseArea { - anchors.fill: parent - acceptedButtons: Qt.RightButton - cursorShape: Qt.IBeamCursor - onClicked: function (mouse) { - displayNameContextMenu.openMenuAt(mouse); - } - } - ElidedTextLabel { id: title + LineEditContextMenu { + id: displayNameContextMenu + lineEditObj: title + selectOnly: true + } + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.RightButton + cursorShape: Qt.IBeamCursor + onClicked: function (mouse) { + displayNameContextMenu.openMenuAt(mouse); + } + } + Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft font.pointSize: JamiTheme.textFontSize + 2 diff --git a/src/app/mainview/components/MessageBarTextArea.qml b/src/app/mainview/components/MessageBarTextArea.qml index 5d5cf378..04401cf7 100644 --- a/src/app/mainview/components/MessageBarTextArea.qml +++ b/src/app/mainview/components/MessageBarTextArea.qml @@ -17,19 +17,17 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts - import net.jami.Adapters 1.1 import net.jami.Constants 1.1 import net.jami.Enums 1.1 import net.jami.Models 1.1 - import SortFilterProxyModel 0.2 - import "../../commoncomponents" JamiFlickable { id: root + property int underlineHeight: JamiTheme.messageUnderlineHeight property alias text: textArea.text property var textAreaObj: textArea property alias placeholderText: textArea.placeholderText @@ -39,9 +37,12 @@ JamiFlickable { property bool showPreview: false property bool isShowTypo: UtilsAdapter.getAppValue(Settings.Key.ShowMardownOption) property int textWidth: textArea.contentWidth + property var spellCheckActive: AppSettingsManager.getValue(Settings.EnableSpellCheck) + property var language: AppSettingsManager.getValue(Settings.SpellLang) // Used to cache the editable text when showing the preview message // and also to debounce the textChanged signal's effect on the composing status. + property var underlineList: [] property string cachedText property string debounceText @@ -72,6 +73,7 @@ JamiFlickable { lineEditObj: textArea customizePaste: true + checkSpell: (Qt.platform.os.toString() === "linux") ? true : false onContextMenuRequirePaste: { // Intercept paste event to use C++ QMimeData @@ -115,9 +117,79 @@ JamiFlickable { TextArea.flickable: TextArea { id: textArea + CachedFile { + id: cachedFile + } + + function updateCorrection(language) { + cachedFile.updateDictionnary(language); + textArea.updateUnderlineText(); + } + + // Listen to settings changes and apply it to this widget + Connections { + target: UtilsAdapter + + function onChangeLanguage() { + textArea.updateUnderlineText(); + } + + function onChangeFontSize() { + textArea.updateUnderlineText(); + } + + function onSpellLanguageChanged() { + root.language = SpellCheckDictionaryManager.getSpellLanguage(); + if ((Qt.platform.os.toString() !== "linux") || (AppSettingsManager.getValue(Settings.SpellLang) === "NONE")) { + spellCheckActive = false; + } else { + spellCheckActive = AppSettingsManager.getValue(Settings.EnableSpellCheck); + } + if (spellCheckActive === true) { + root.language = SpellCheckDictionaryManager.getSpellLanguage(); + textArea.updateCorrection(root.language); + } else { + textArea.clearUnderlines(); + } + } + + function onEnableSpellCheckChanged() { + // Disable spell check on non-linux platforms yet + if ((Qt.platform.os.toString() !== "linux") || (AppSettingsManager.getValue(Settings.SpellLang) === "NONE")) { + spellCheckActive = false; + } else { + spellCheckActive = AppSettingsManager.getValue(Settings.EnableSpellCheck); + } + if (spellCheckActive === true) { + root.language = SpellCheckDictionaryManager.getSpellLanguage(); + textArea.updateCorrection(root.language); + } else { + textArea.clearUnderlines(); + } + } + } + + // Initialize the settings if the component wasn't loaded when changing settings + Component.onCompleted: { + if ((Qt.platform.os.toString() !== "linux") || (AppSettingsManager.getValue(Settings.SpellLang) === "NONE")) { + spellCheckActive = false; + } else { + spellCheckActive = AppSettingsManager.getValue(Settings.EnableSpellCheck); + } + if (spellCheckActive === true) { + root.language = SpellCheckDictionaryManager.getSpellLanguage(); + textArea.updateCorrection(root.language); + } else { + textArea.clearUnderlines(); + } + } + readOnly: showPreview leftPadding: JamiTheme.scrollBarHandleSize rightPadding: JamiTheme.scrollBarHandleSize + topPadding: 0 + bottomPadding: underlineHeight + persistentSelection: true verticalAlignment: TextEdit.AlignVCenter font.pointSize: JamiTheme.textFontSize + 2 @@ -135,12 +207,37 @@ JamiFlickable { color: "transparent" } + TextMetrics { + id: textMetrics + elide: Text.ElideMiddle + font.family: textArea.font.family + font.pointSize: JamiTheme.textFontSize + 2 + } + + Text { + id: highlight + color: "black" + font.bold: true + visible: false + } + onReleased: function (event) { - if (event.button === Qt.RightButton) + if (event.button === Qt.RightButton) { + var position = textArea.positionAt(event.x, event.y); + textArea.moveCursorSelection(position, TextInput.SelectWords); + textArea.selectWord(); + if (!MessagesAdapter.spell(textArea.selectedText)) { + var wordList = MessagesAdapter.spellSuggestionsRequest(textArea.selectedText); + if (wordList.length !== 0) { + textAreaContextMenu.addMenuItem(wordList); + } + } textAreaContextMenu.openMenuAt(event); + } } onTextChanged: { + updateUnderlineText(); if (text !== debounceText && !showPreview) { debounceText = text; MessagesAdapter.userIsComposing(text ? true : false); @@ -152,6 +249,8 @@ JamiFlickable { // eg. Enter -> Send messages // Shift + Enter -> Next Line Keys.onPressed: function (keyEvent) { + // Update underline on each input to take into account deleted text and sent ones + updateUnderlineText(); if (keyEvent.matches(StandardKey.Paste)) { MessagesAdapter.onPaste(); keyEvent.accepted = true; @@ -180,5 +279,41 @@ JamiFlickable { keyEvent.accepted = true; } } + + function updateUnderlineText() { + clearUnderlines(); + // We iterate over the whole text to find words to check and underline them if needed + if (spellCheckActive) { + var text = textArea.text; + var words = MessagesAdapter.findWords(text); + if (!words) + return; + for (var i = 0; i < words.length; i++) { + var wordInfo = words[i]; + if (wordInfo && wordInfo.word && !MessagesAdapter.spell(wordInfo.word)) { + textMetrics.text = wordInfo.word; + var xPos = textArea.positionToRectangle(wordInfo.position).x; + var yPos = textArea.positionToRectangle(wordInfo.position).y + textArea.positionToRectangle(wordInfo.position).height; + var underlineObject = Qt.createQmlObject('import QtQuick; Rectangle {height: 2; color: "red";}', textArea); + underlineObject.x = xPos; + underlineObject.y = yPos; + underlineObject.width = textMetrics.width; + underlineList.push(underlineObject); + } + } + } + } + + function clearUnderlines() { + // Destroy all of the underline boxes + while (underlineList.length > 0) { + // Get the previous item + var underlineObject = underlineList[underlineList.length - 1]; + // Remove the last item + underlineList.pop(); + // Destroy the removed item + underlineObject.destroy(); + } + } } } diff --git a/src/app/messagesadapter.cpp b/src/app/messagesadapter.cpp index ca90f74b..56dddba0 100644 --- a/src/app/messagesadapter.cpp +++ b/src/app/messagesadapter.cpp @@ -21,6 +21,7 @@ #include "qtutils.h" #include "messageparser.h" #include "previewengine.h" +#include "spellchecker.h" #include #include @@ -39,17 +40,25 @@ #include #include +#define SUGGESTIONS_MAX_SIZE 3 // limit the number of spelling suggestions + MessagesAdapter::MessagesAdapter(AppSettingsManager* settingsManager, PreviewEngine* previewEngine, + SpellCheckDictionaryManager* spellCheckDictionaryManager, LRCInstance* instance, QObject* parent) : QmlAdapterBase(instance, parent) , settingsManager_(settingsManager) + , spellCheckDictionaryManager_(spellCheckDictionaryManager) , messageParser_(new MessageParser(previewEngine, this)) , filteredMsgListModel_(new FilteredMsgListModel(this)) , mediaInteractions_(std::make_unique(nullptr)) , timestampTimer_(new QTimer(this)) { + #if defined(Q_OS_LINUX) + // Initialize with make_shared + spellChecker_ = std::make_shared(spellCheckDictionaryManager_->getDictionaryPath()); + #endif setObjectName(typeid(*this).name()); set_messageListModel(QVariant::fromValue(filteredMsgListModel_)); @@ -727,3 +736,53 @@ MessagesAdapter::getMsgListSourceModel() const // However it may be a nullptr if not yet set. return static_cast(filteredMsgListModel_->sourceModel()); } + +bool +MessagesAdapter::spell(const QString& word) +{ + return spellChecker_->spell(word); +} + +QVariantList +MessagesAdapter::spellSuggestionsRequest(const QString& word) +{ + QStringList suggestionsList; + QVariantList variantList; + if (spellChecker_ == nullptr || spellChecker_->spell(word)) { + return variantList; + } + + suggestionsList = spellChecker_->suggest(word); + for (const auto& suggestion : suggestionsList) { + if (variantList.size() >= SUGGESTIONS_MAX_SIZE) { + break; + } + variantList.append(QVariant(suggestion)); + } + + return variantList; +} + +QVariantList +MessagesAdapter::findWords(const QString& text) +{ + QVariantList result; + if (!spellChecker_) + return result; + + auto words = spellChecker_->findWords(text); + for (const auto& word : words) { + QVariantMap wordInfo; + wordInfo["word"] = word.word; + wordInfo["position"] = word.position; + wordInfo["length"] = word.length; + result.append(wordInfo); + } + return result; +} + +void +MessagesAdapter::updateDictionnary(const QString& path) +{ + return spellChecker_->replaceDictionary(path); +} diff --git a/src/app/messagesadapter.h b/src/app/messagesadapter.h index f5eb053c..e7b94ae5 100644 --- a/src/app/messagesadapter.h +++ b/src/app/messagesadapter.h @@ -23,6 +23,8 @@ #include "previewengine.h" #include "messageparser.h" #include "appsettingsmanager.h" +#include "spellchecker.h" +#include "spellcheckdictionarymanager.h" #include #include @@ -46,7 +48,6 @@ public: connect(this, &QAbstractItemModel::rowsRemoved, this, &FilteredMsgListModel::countChanged); connect(this, &QAbstractItemModel::modelReset, this, &FilteredMsgListModel::countChanged); connect(this, &QAbstractItemModel::layoutChanged, this, &FilteredMsgListModel::countChanged); - } bool filterAcceptsRow(int sourceRow, const QModelIndex& sourceParent) const override { @@ -101,11 +102,14 @@ public: { return new MessagesAdapter(qApp->property("AppSettingsManager").value(), qApp->property("PreviewEngine").value(), + qApp->property("SpellCheckDictionaryManager") + .value(), qApp->property("LRCInstance").value()); } explicit MessagesAdapter(AppSettingsManager* settingsManager, PreviewEngine* previewEngine, + SpellCheckDictionaryManager* spellCheckDictionaryManager, LRCInstance* instance, QObject* parent = nullptr); ~MessagesAdapter() = default; @@ -164,6 +168,10 @@ public: Q_INVOKABLE QVariant dataForInteraction(const QString& interactionId, int role = Qt::DisplayRole) const; Q_INVOKABLE void startSearch(const QString& text, bool isMedia); + Q_INVOKABLE QVariantList spellSuggestionsRequest(const QString& word); + Q_INVOKABLE bool spell(const QString& word); + Q_INVOKABLE void updateDictionnary(const QString& path); + Q_INVOKABLE QVariantList findWords(const QString& text); // Run corrsponding js functions, c++ to qml. void setMessagesImageContent(const QString& path, bool isBased64 = false); @@ -198,14 +206,12 @@ private: QList conversationTypersUrlToName(const QSet& typersSet); AppSettingsManager* settingsManager_; + SpellCheckDictionaryManager* spellCheckDictionaryManager_; MessageParser* messageParser_; - FilteredMsgListModel* filteredMsgListModel_; - - static constexpr const int loadChunkSize_ {20}; - std::unique_ptr mediaInteractions_; - - QTimer* timestampTimer_ {nullptr}; + QTimer* timestampTimer_; + std::shared_ptr spellChecker_; + static constexpr const int loadChunkSize_ {20}; static constexpr const int timestampUpdateIntervalMs_ {1000}; }; diff --git a/src/app/net/jami/Constants/JamiStrings.qml b/src/app/net/jami/Constants/JamiStrings.qml index 942d5c6b..70f67d4e 100644 --- a/src/app/net/jami/Constants/JamiStrings.qml +++ b/src/app/net/jami/Constants/JamiStrings.qml @@ -275,6 +275,7 @@ Item { property string share: qsTr("Share") property string cut: qsTr("Cut") property string paste: qsTr("Paste") + property string language: qsTr("Language") // ConversationContextMenu property string startAudioCall: qsTr("Start audio call") @@ -508,7 +509,7 @@ Item { property string displayHyperlinkPreviews: qsTr("Web link previews") property string displayHyperlinkPreviewsDescription: qsTr("Preview requires downloading content from third-party servers.") - property string language: qsTr("User interface language") + property string userInterfaceLanguage: qsTr("User interface language") property string verticalViewOpt: qsTr("Vertical view") property string horizontalViewOpt: qsTr("Horizontal view") @@ -905,4 +906,12 @@ Item { property string copyAllData: qsTr("Copy all data") property string remote: qsTr("Remote: %1") property string view: qsTr("View") + + // Spellchecker + property string checkSpelling: qsTr("Check spelling while typing") + property string textLanguage: qsTr("Text language") + property string textLanguageDescription: qsTr("To install new dictionaries, use the system package manager.") + property string spellchecking: qsTr("Spellchecking") + property string refresh: qsTr("Refresh") + property string refreshInstalledDictionaries: qsTr("Refresh installed dictionaries") } diff --git a/src/app/net/jami/Constants/JamiTheme.qml b/src/app/net/jami/Constants/JamiTheme.qml index 48f38724..57b04ef5 100644 --- a/src/app/net/jami/Constants/JamiTheme.qml +++ b/src/app/net/jami/Constants/JamiTheme.qml @@ -516,6 +516,7 @@ Item { property int showTypoSecondToggleWidth: 540 property int messageBarMaximumHeight: 150 property int messageBarMinimumHeight: 36 + property int messageUnderlineHeight: 2 // InvitationView property real invitationViewAvatarSize: 112 diff --git a/src/app/qmlregister.cpp b/src/app/qmlregister.cpp index eacff9e2..615fef8e 100644 --- a/src/app/qmlregister.cpp +++ b/src/app/qmlregister.cpp @@ -26,7 +26,7 @@ #include "positionmanager.h" #include "tipsmodel.h" #include "connectivitymonitor.h" -#include "imagedownloader.h" +#include "filedownloader.h" #include "utilsadapter.h" #include "conversationsadapter.h" #include "currentcall.h" @@ -36,6 +36,7 @@ #include "currentaccounttomigrate.h" #include "pttlistener.h" #include "calloverlaymodel.h" +#include "spellcheckdictionarymanager.h" #include "accountlistmodel.h" #include "mediacodeclistmodel.h" #include "audiodevicemodel.h" @@ -64,6 +65,7 @@ #include "wizardviewstepmodel.h" #include "linkdevicemodel.h" #include "qrcodescannermodel.h" +#include "spellchecker.h" #include "api/peerdiscoverymodel.h" #include "api/codecmodel.h" @@ -117,6 +119,7 @@ registerTypes(QQmlEngine* engine, LRCInstance* lrcInstance, SystemTray* systemTray, AppSettingsManager* settingsManager, + SpellCheckDictionaryManager* spellCheckDictionaryManager, ConnectivityMonitor* connectivityMonitor, PreviewEngine* previewEngine, ScreenInfo* screenInfo, @@ -201,6 +204,7 @@ registerTypes(QQmlEngine* engine, qApp->setProperty("AppSettingsManager", QVariant::fromValue(settingsManager)); qApp->setProperty("ConnectivityMonitor", QVariant::fromValue(connectivityMonitor)); qApp->setProperty("PreviewEngine", QVariant::fromValue(previewEngine)); + qApp->setProperty("SpellCheckDictionaryManager", QVariant::fromValue(spellCheckDictionaryManager)); // qml adapter registration QML_REGISTERSINGLETON_TYPE(NS_HELPERS, QRCodeScannerModel); @@ -220,7 +224,7 @@ registerTypes(QQmlEngine* engine, QML_REGISTERSINGLETON_TYPE(NS_ADAPTERS, TipsModel); QML_REGISTERSINGLETON_TYPE(NS_ADAPTERS, VideoDevices); QML_REGISTERSINGLETON_TYPE(NS_ADAPTERS, CurrentAccountToMigrate); - QML_REGISTERSINGLETON_TYPE(NS_HELPERS, ImageDownloader); + QML_REGISTERSINGLETON_TYPE(NS_HELPERS, FileDownloader); // TODO: remove these QML_REGISTERSINGLETONTYPE_CUSTOM(NS_MODELS, AVModel, &lrcInstance->avModel()) @@ -237,6 +241,7 @@ registerTypes(QQmlEngine* engine, QML_REGISTERTYPE(NS_MODELS, PluginListPreferenceModel); QML_REGISTERTYPE(NS_MODELS, FilesToSendListModel); QML_REGISTERTYPE(NS_MODELS, CallInformationListModel); + QML_REGISTERTYPE(NS_MODELS, SpellChecker); // Roles & type enums for models QML_REGISTERNAMESPACE(NS_MODELS, AccountList::staticMetaObject, "AccountList"); @@ -250,6 +255,7 @@ registerTypes(QQmlEngine* engine, QML_REGISTERSINGLETONTYPE_POBJECT(NS_CONSTANTS, screenInfo, "CurrentScreenInfo") QML_REGISTERSINGLETONTYPE_POBJECT(NS_CONSTANTS, lrcInstance, "LRCInstance") QML_REGISTERSINGLETONTYPE_POBJECT(NS_CONSTANTS, settingsManager, "AppSettingsManager") + QML_REGISTERSINGLETONTYPE_POBJECT(NS_CONSTANTS, spellCheckDictionaryManager, "SpellCheckDictionaryManager") // Lrc namespaces, models, and singletons QML_REGISTERNAMESPACE(NS_MODELS, lrc::api::staticMetaObject, "Lrc"); diff --git a/src/app/qmlregister.h b/src/app/qmlregister.h index fe4f2a59..090047ac 100644 --- a/src/app/qmlregister.h +++ b/src/app/qmlregister.h @@ -32,6 +32,7 @@ class SystemTray; class LRCInstance; class AppSettingsManager; +class SpellCheckDictionaryManager; class PreviewEngine; class ScreenInfo; class MainApplication; @@ -61,6 +62,7 @@ void registerTypes(QQmlEngine* engine, LRCInstance* lrcInstance, SystemTray* systemTray, AppSettingsManager* appSettingsManager, + SpellCheckDictionaryManager* spellCheckDictionaryManager, ConnectivityMonitor* connectivityMonitor, PreviewEngine* previewEngine, ScreenInfo* screenInfo, diff --git a/src/app/settingsview/components/ManageAccountPage.qml b/src/app/settingsview/components/ManageAccountPage.qml index 08951bc3..21e107d6 100644 --- a/src/app/settingsview/components/ManageAccountPage.qml +++ b/src/app/settingsview/components/ManageAccountPage.qml @@ -38,7 +38,6 @@ SettingsPageBase { flickableContent: ColumnLayout { id: manageAccountColumnLayout - width: contentFlickableWidth spacing: JamiTheme.settingsBlockSpacing anchors.left: parent.left anchors.leftMargin: JamiTheme.preferredSettingsMarginSize diff --git a/src/app/settingsview/components/SystemSettingsPage.qml b/src/app/settingsview/components/SystemSettingsPage.qml index 8abd146f..f7223c08 100644 --- a/src/app/settingsview/components/SystemSettingsPage.qml +++ b/src/app/settingsview/components/SystemSettingsPage.qml @@ -23,6 +23,7 @@ import net.jami.Constants 1.1 import net.jami.Enums 1.1 import net.jami.Models 1.1 import "../../commoncomponents" +import SortFilterProxyModel 0.2 SettingsPageBase { id: root @@ -157,8 +158,8 @@ SettingsPageBase { Layout.fillWidth: true height: JamiTheme.preferredFieldHeight - labelText: JamiStrings.language - tipText: JamiStrings.language + labelText: JamiStrings.userInterfaceLanguage + tipText: JamiStrings.userInterfaceLanguage comboModel: ListModel { id: langModel Component.onCompleted: { @@ -183,6 +184,132 @@ SettingsPageBase { UtilsAdapter.setAppValue(Settings.Key.LANG, comboModel.get(modelIndex).id); } } + } + ColumnLayout { + + width: parent.width + spacing: JamiTheme.settingsCategorySpacing + visible: (Qt.platform.os.toString() !== "linux") ? false : true + + Text { + id: spellcheckingTitle + + Layout.alignment: Qt.AlignLeft + Layout.preferredWidth: parent.width + + text: JamiStrings.spellchecking + color: JamiTheme.textColor + horizontalAlignment: Text.AlignLeft + verticalAlignment: Text.AlignVCenter + wrapMode: Text.WordWrap + + font.pixelSize: JamiTheme.settingsTitlePixelSize + font.kerning: true + } + + ToggleSwitch { + id: enableSpellCheckToggleSwitch + Layout.fillWidth: true + visible: true + + checked: UtilsAdapter.getAppValue(Settings.Key.EnableSpellCheck) + labelText: JamiStrings.checkSpelling + descText: JamiStrings.textLanguageDescription + tooltipText: JamiStrings.checkSpelling + onSwitchToggled: { + UtilsAdapter.setAppValue(Settings.Key.EnableSpellCheck, checked); + } + } + + SettingsComboBox { + id: spellCheckLangComboBoxSetting + + Layout.fillWidth: true + height: JamiTheme.preferredFieldHeight + + labelText: JamiStrings.textLanguage + tipText: JamiStrings.textLanguage + comboModel: ListModel { + id: installedSpellCheckLangModel + Component.onCompleted: { + var supported = SpellCheckDictionaryManager.installedDictionaries(); + var keys = Object.keys(supported); + var currentKey = UtilsAdapter.getAppValue(Settings.Key.SpellLang); + for (var i = 0; i < keys.length; ++i) { + append({ + "textDisplay": supported[keys[i]], + "id": keys[i] + }); + if (keys[i] === currentKey) + spellCheckLangComboBoxSetting.modelIndex = i; + } + } + } + + widthOfComboBox: itemWidth + role: "textDisplay" + + onActivated: { + UtilsAdapter.setAppValue(Settings.Key.SpellLang, comboModel.get(modelIndex).id); + } + } + + RowLayout { + Layout.fillWidth: true + Layout.minimumHeight: JamiTheme.preferredFieldHeight + + Text { + Layout.fillWidth: true + Layout.fillHeight: true + Layout.rightMargin: JamiTheme.preferredMarginSize + + color: JamiTheme.textColor + wrapMode: Text.WordWrap + text: JamiStrings.refreshInstalledDictionaries + font.pointSize: JamiTheme.settingsFontSize + font.kerning: true + + horizontalAlignment: Text.AlignLeft + verticalAlignment: Text.AlignVCenter + } + + MaterialButton { + id: refreshInstalledDictionariesPushButton + + Layout.alignment: Qt.AlignCenter + + preferredWidth: textSizeRefresh.width + 2 * JamiTheme.buttontextWizzardPadding + buttontextHeightMargin: JamiTheme.buttontextHeightMargin + + primary: true + toolTipText: JamiStrings.refresh + + text: JamiStrings.refresh + + onClicked: { + SpellCheckDictionaryManager.refreshDictionaries(); + var langIdx = spellCheckLangComboBoxSetting.modelIndex; + installedSpellCheckLangModel.clear(); + var supported = SpellCheckDictionaryManager.installedDictionaries(); + var keys = Object.keys(supported); + for (var i = 0; i < keys.length; ++i) { + installedSpellCheckLangModel.append({ + "textDisplay": supported[keys[i]], + "id": keys[i] + }); + } + spellCheckLangComboBoxSetting.modelIndex = langIdx; + } + + TextMetrics { + id: textSizeRefresh + font.weight: Font.Bold + font.pixelSize: JamiTheme.wizardViewButtonFontPixelSize + font.capitalization: Font.AllUppercase + text: refreshInstalledDictionariesPushButton.text + } + } + } Connections { target: UtilsAdapter @@ -200,6 +327,21 @@ SettingsPageBase { } langComboBoxSetting.modelIndex = langIdx; } + + // Repopulate the spell check language list + function onSpellLanguageChanged() { + var langIdx = spellCheckLangComboBoxSetting.modelIndex; + installedSpellCheckLangModel.clear(); + var supported = SpellCheckDictionaryManager.installedDictionaries(); + var keys = Object.keys(supported); + for (var i = 0; i < keys.length; ++i) { + installedSpellCheckLangModel.append({ + "textDisplay": supported[keys[i]], + "id": keys[i] + }); + } + spellCheckLangComboBoxSetting.modelIndex = langIdx; + } } } @@ -257,6 +399,7 @@ SettingsPageBase { closeOrMinCheckBox.checked = UtilsAdapter.getDefault(Settings.Key.MinimizeOnClose); checkboxCallSwarm.checked = UtilsAdapter.getDefault(Settings.Key.EnableExperimentalSwarm); langComboBoxSetting.modelIndex = 0; + spellCheckLangComboBoxSetting.modelIndex = 0; UtilsAdapter.setToDefault(Settings.Key.EnableNotifications); UtilsAdapter.setToDefault(Settings.Key.MinimizeOnClose); UtilsAdapter.setToDefault(Settings.Key.LANG); diff --git a/src/app/spellcheckdictionarymanager.cpp b/src/app/spellcheckdictionarymanager.cpp new file mode 100644 index 00000000..1e7d1286 --- /dev/null +++ b/src/app/spellcheckdictionarymanager.cpp @@ -0,0 +1,149 @@ +/* + * Copyright (C) 2025 Savoir-faire Linux Inc. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#include "spellcheckdictionarymanager.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +SpellCheckDictionaryManager::SpellCheckDictionaryManager(AppSettingsManager* settingsManager, + QObject* parent) + : QObject {parent} + , settingsManager_ {settingsManager} +{} + +QVariantMap +SpellCheckDictionaryManager::installedDictionaries() +{ + // If we already have a cache of the installed dictionaries, return it + if (cachedInstalledDictionaries_.size() > 0) { + return cachedInstalledDictionaries_; + + // If not, we need to check the dictionaries directory + } else { + QString hunspellDataDir = getDictionariesPath(); + + auto dictionariesDir = QDir(hunspellDataDir); + QRegExp regex("(.*).dic"); + QSet nativeNames; + + QVariantMap result; + result["NONE"] = tr("None"); + QStringList folders = dictionariesDir.entryList(QDir::Dirs | QDir::NoDotAndDotDot); + // Check for dictionary files in the base directory + QStringList rootDicFiles = dictionariesDir.entryList(QStringList() << "*.dic", QDir::Files); + for (const auto& dicFile : rootDicFiles) { + regex.indexIn(dicFile); + auto captured = regex.capturedTexts(); + if (captured.size() == 2) { + auto nativeName = QLocale(captured[1]).nativeLanguageName(); + if (!nativeName.isEmpty() && !nativeNames.contains(nativeName)) { + result[captured[1]] = nativeName; + nativeNames.insert(nativeName); + } + } + } + // Check for dictionary files in subdirectories + for (const auto& folder : folders) { + QDir subDir = dictionariesDir.absoluteFilePath(folder); + QStringList dicFiles = subDir.entryList(QStringList() << "*.dic", QDir::Files); + subDir.setFilter(QDir::Files | QDir::AllDirs | QDir::NoDotAndDotDot); + subDir.setSorting(QDir::DirsFirst); + QFileInfoList list = subDir.entryInfoList(); + for (const auto& fileInfo : list) { + if (fileInfo.isDir()) { + QDir recursiveDir(fileInfo.absoluteFilePath()); + QStringList recursiveDicFiles = recursiveDir.entryList(QStringList() << "*.dic", + QDir::Files); + if (!recursiveDicFiles.isEmpty()) { + dicFiles.append(recursiveDicFiles); + } + } + } + + // Extract the locale from the dictionary file names + for (const auto& dicFile : dicFiles) { + regex.indexIn(dicFile); + auto captured = regex.capturedTexts(); + + if (captured.size() == 2) { + auto nativeName = QLocale(captured[1]).nativeLanguageName(); + + if (nativeName.isEmpty()) { + continue; + } + + if (!nativeNames.contains(nativeName)) { + result[folder + QDir::separator() + captured[1]] = nativeName; + nativeNames.insert(nativeName); + } else { + qWarning() << "Duplicate native name found, skipping:" << nativeName; + } + } + } + } + cachedInstalledDictionaries_ = result; + return result; + } +} + +QString +SpellCheckDictionaryManager::getDictionariesPath() +{ +#if defined(Q_OS_LINUX) + QString hunDir = "/usr/share/hunspell/"; + ; + +#elif defined(Q_OS_MACOS) + QString hunDir = "/Library/Spelling/"; +#else + QString hunDir = ""; +#endif + return hunDir; +} + + +void +SpellCheckDictionaryManager::refreshDictionaries() +{ + cachedInstalledDictionaries_.clear(); +} + +QString +SpellCheckDictionaryManager::getSpellLanguage() +{ + auto pref = settingsManager_->getValue(Settings::Key::SpellLang).toString(); + return pref ; +} + +// Is only used at application boot time +QString +SpellCheckDictionaryManager::getDictionaryPath() +{ + return "/usr/share/hunspell/" + getSpellLanguage(); +} diff --git a/src/app/spellcheckdictionarymanager.h b/src/app/spellcheckdictionarymanager.h new file mode 100644 index 00000000..66755445 --- /dev/null +++ b/src/app/spellcheckdictionarymanager.h @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2025 Savoir-faire Linux Inc. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +#pragma once +#include "appsettingsmanager.h" + +#include +#include +#include + +class SpellCheckDictionaryManager : public QObject +{ + Q_OBJECT + QVariantMap cachedInstalledDictionaries_; + AppSettingsManager* settingsManager_; +public: + explicit SpellCheckDictionaryManager(AppSettingsManager* settingsManager, + QObject* parent = nullptr); + + Q_INVOKABLE QVariantMap installedDictionaries(); + Q_INVOKABLE QString getDictionariesPath(); + Q_INVOKABLE void refreshDictionaries(); + Q_INVOKABLE QString getDictionaryPath(); + Q_INVOKABLE QString getSpellLanguage(); +}; diff --git a/src/app/spellchecker.cpp b/src/app/spellchecker.cpp new file mode 100644 index 00000000..1b14235f --- /dev/null +++ b/src/app/spellchecker.cpp @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2020-2025 Savoir-faire Linux Inc. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * \file spellchecker.c + */ + +#include "spellchecker.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +SpellChecker::SpellChecker(const QString& dictionaryPath) +{ + replaceDictionary(dictionaryPath); +} + +bool +SpellChecker::spell(const QString& word) +{ + // Encode from Unicode to the encoding used by current dictionary + return hunspell_->spell(word.toStdString()) != 0; +} + +QStringList +SpellChecker::suggest(const QString& word) +{ + // Encode from Unicode to the encoding used by current dictionary + std::vector numSuggestions = hunspell_->suggest(word.toStdString()); + QStringList suggestions; + + for (size_t i = 0; i < numSuggestions.size(); ++i) { + suggestions << QString::fromStdString(numSuggestions.at(i)); + } + return suggestions; +} + +void +SpellChecker::ignoreWord(const QString& word) +{ + put_word(word); +} + +void +SpellChecker::put_word(const QString& word) +{ + hunspell_->add(codec_->fromUnicode(word).constData()); +} + +void +SpellChecker::replaceDictionary(const QString& dictionaryPath) +{ + QString dictFile = dictionaryPath + ".dic"; + QString affixFile = dictionaryPath + ".aff"; + QByteArray dictFilePathBA = dictFile.toLocal8Bit(); + QByteArray affixFilePathBA = affixFile.toLocal8Bit(); + if (hunspell_) { + hunspell_.reset(); + } + hunspell_ = std::make_shared(affixFilePathBA.constData(), dictFilePathBA.constData()); + + // detect encoding analyzing the SET option in the affix file + encoding_ = "ISO8859-1"; + QFile _affixFile(affixFile); + if (_affixFile.open(QIODevice::ReadOnly)) { + QTextStream stream(&_affixFile); + QRegExp enc_detector("^\\s*SET\\s+([A-Z0-9\\-]+)\\s*", Qt::CaseInsensitive); + for (QString line = stream.readLine(); !line.isEmpty(); line = stream.readLine()) { + if (enc_detector.indexIn(line) > -1) { + encoding_ = enc_detector.cap(1); + break; + } + } + _affixFile.close(); + } + + codec_ = QTextCodec::codecForName(this->encoding_.toLatin1().constData()); +} + +QList +SpellChecker::findWords(const QString& text) +{ + // This is in the C++ part of the code because QML regex does not support unicode + QList results; + QRegularExpression regex("\\p{L}+|\\p{N}+"); + QRegularExpressionMatchIterator iter = regex.globalMatch(text); + + while (iter.hasNext()) { + QRegularExpressionMatch match = iter.next(); + WordInfo info; + info.word = match.captured(); + info.position = match.capturedStart(); + info.length = match.capturedLength(); + results.append(info); + } + + return results; +} diff --git a/src/app/spellchecker.h b/src/app/spellchecker.h new file mode 100644 index 00000000..9ef6dd97 --- /dev/null +++ b/src/app/spellchecker.h @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2020-2025 Savoir-faire Linux Inc. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * \file spellchecker.h + */ + +#pragma once + +#include "lrcinstance.h" +#include "qmladapterbase.h" +#include "previewengine.h" + +#include +#include +#include +#include +#include +#include + +#include + +class Hunspell; + +class SpellChecker : public QObject +{ + Q_OBJECT +public: + explicit SpellChecker(const QString&); + ~SpellChecker() = default; + void replaceDictionary(const QString& dictionaryPath); + + Q_INVOKABLE bool spell(const QString& word); + Q_INVOKABLE QStringList suggest(const QString& word); + Q_INVOKABLE void ignoreWord(const QString& word); + + // Used to find words and their position in a text + struct WordInfo { + QString word; + int position; + int length; + }; + + Q_INVOKABLE QList findWords(const QString& text); + +private: + void put_word(const QString& word); + std::shared_ptr hunspell_; + QString encoding_; + QTextCodec* codec_; +}; diff --git a/src/app/utilsadapter.cpp b/src/app/utilsadapter.cpp index c0504992..225ee7b1 100644 --- a/src/app/utilsadapter.cpp +++ b/src/app/utilsadapter.cpp @@ -23,7 +23,6 @@ #include "version.h" #include "version_info.h" #include "global.h" - #include #include @@ -93,6 +92,11 @@ UtilsAdapter::setAppValue(const Settings::Key key, const QVariant& value) Q_EMIT appThemeChanged(); else if (key == Settings::Key::UseFramelessWindow) Q_EMIT useFramelessWindowChanged(); + else if (key == Settings::Key::SpellLang) { + Q_EMIT spellLanguageChanged(); + } else if (key == Settings::Key::EnableSpellCheck) { + Q_EMIT enableSpellCheckChanged(); + } #if !APPSTORE // Any donation campaign-related keys can trigger a donation campaign check else if (key == Settings::Key::IsDonationVisible diff --git a/src/app/utilsadapter.h b/src/app/utilsadapter.h index 445c397c..aa0c853b 100644 --- a/src/app/utilsadapter.h +++ b/src/app/utilsadapter.h @@ -181,6 +181,8 @@ Q_SIGNALS: void changeLanguage(); void donationCampaignSettingsChanged(); void useFramelessWindowChanged(); + void spellLanguageChanged(); + void enableSpellCheckChanged(); private: QClipboard* clipboard_; diff --git a/tests/qml/main.cpp b/tests/qml/main.cpp index a037b848..3e4943ad 100644 --- a/tests/qml/main.cpp +++ b/tests/qml/main.cpp @@ -16,6 +16,7 @@ */ #include "appsettingsmanager.h" +#include "spellcheckdictionarymanager.h" #include "connectivitymonitor.h" #include "mainapplication.h" #include "previewengine.h" @@ -94,6 +95,7 @@ public Q_SLOTS: settingsManager_.reset(new AppSettingsManager(this)); systemTray_.reset(new SystemTray(settingsManager_.get(), this)); previewEngine_.reset(new PreviewEngine(connectivityMonitor_.get(), this)); + spellCheckDictionaryManager_.reset(new SpellCheckDictionaryManager(settingsManager_.get(), this)); QFontDatabase::addApplicationFont(":/images/FontAwesome.otf"); @@ -152,6 +154,7 @@ public Q_SLOTS: lrcInstance_.get(), systemTray_.get(), settingsManager_.get(), + spellCheckDictionaryManager_.get(), connectivityMonitor_.get(), previewEngine_.get(), &screenInfo_, @@ -169,6 +172,7 @@ private: QScopedPointer connectivityMonitor_; QScopedPointer settingsManager_; + QScopedPointer spellCheckDictionaryManager_; QScopedPointer systemTray_; QScopedPointer previewEngine_; ScreenInfo screenInfo_; diff --git a/tests/qml/src/tst_CachedImage.qml b/tests/qml/src/tst_CachedImage.qml index 6727e887..bd8b5f36 100644 --- a/tests/qml/src/tst_CachedImage.qml +++ b/tests/qml/src/tst_CachedImage.qml @@ -44,14 +44,14 @@ Item { SignalSpy { id: spyDownloadSuccessful - target: ImageDownloader - signalName: "onDownloadImageSuccessful" + target: FileDownloader + signalName: "onDownloadFileSuccessful" } SignalSpy { id: spyDownloadFailed - target: ImageDownloader - signalName: "onDownloadImageFailed" + target: FileDownloader + signalName: "onDownloadFileFailed" } function test_goodDownLoad() {