diff --git a/.gitmodules b/.gitmodules index b06ae33b6a3015c44e12f68f1b05c41f41c350f0..44fd42b5366d744eeb57b24cd1d518d94cdb846d 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 0000000000000000000000000000000000000000..525f9f22766a28e0f81c435217fcf4528e01c013 --- /dev/null +++ b/3rdparty/hunspell @@ -0,0 +1 @@ +Subproject commit 525f9f22766a28e0f81c435217fcf4528e01c013 diff --git a/CMakeLists.txt b/CMakeLists.txt index e96fbf617fc8f3c859e12d12f8e480a48e8f3013..268c08a30e646465851d7055ae707fe72451699f 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 4a9abee958056e8f5e3ebdba518c9f9c66af42d2..20876783a02260faf25a32c1ee8ec38b673c6057 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 5c4f489cb68a10ec847a46637aab93b33acf68bd..211f8dd55b06a1f39c03630a903ba15c218befb2 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 d3b4e9e49d403507a2c46b6d6ed0c07c8d7c8f02..e7974683d4efa391c007c5462632f3eb1436899d 100644 --- a/src/app/commoncomponents/LineEditContextMenu.qml +++ b/src/app/commoncomponents/LineEditContextMenu.qml @@ -15,8 +15,11 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ 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<GeneralMenuItem> 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 0000000000000000000000000000000000000000..7d2aea216144ee33ff5333e22ef087b700de5594 --- /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 <https://www.gnu.org/licenses/>. + */ +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 d721626b29e88e49b7ee55ca377b67002c95795d..5eb2348528c077de504fd8c76ced73203543e266 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 baee2dfbc27eba83b5c1483c4bcdea056b0a65a6..a0850d0bab42a38b4591ddee1a4a37eca678e2ed 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 ba8ab60628fd4df7ff5fbc2e80127bffaa58ea80..02a9fb4510c4d0ce158961427a22529d295e7a7d 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 19318536b5cde2576f293bddca3b4a04c1d08e51..9b7ce833f1d85a74eb2241a0fee85890ba54d7ca 100644 --- a/src/app/imagedownloader.cpp +++ b/src/app/filedownloader.cpp @@ -15,32 +15,32 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -#include "imagedownloader.h" +#include "filedownloader.h" #include <QDir> #include <QLockFile> -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 32dac08d5f641bfcee3e13177d62c7b3816884c8..b92f24cb203b00d4cc5d97c1c904488a88408cb9 100644 --- a/src/app/imagedownloader.h +++ b/src/app/filedownloader.h @@ -24,7 +24,7 @@ #include <QQmlEngine> // QML registration #include <QApplication> // 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<ConnectivityMonitor*>()); } - 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 044879b59fe47396329b2bce60ee8a8a493c2b90..f2ba599b107439e93604095e81a19fd417dcdbcc 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 64a0f34689a1f9cffa934590f166fd4180263899..a9fc3999345df8e2a2a47a7bf2032ea148eab5c4 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 0000000000000000000000000000000000000000..cdd2b86215a07d4d2e5f6419feca2f12d194e96c --- /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 <https://www.gnu.org/licenses/>. + */ +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 8ce9cfb8009d90f3fd6b68e0bf30ea6c42ee0e7d..fdebe99a24339b179b064c6cf1fd0e123b3ae325 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 60e8e3b9ce7309f7ad5a053a10d1219c2a0e42d8..7546cbd692ab668c1a7025f6a63c240a7e3c0aac 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 5d5cf3787f5c890bdb09e2a57fbde410f976d1c2..04401cf7e665c1f3bf974529d749e5fb4fb08c33 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 ca90f74b1c06cf06b613709f7f5faa935c2a8e12..56dddba0f1dec7182082b8a31b5259b8126d5109 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 <api/datatransfermodel.h> #include <api/contact.h> @@ -39,17 +40,25 @@ #include <QtMath> #include <QRegExp> +#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<MessageListModel>(nullptr)) , timestampTimer_(new QTimer(this)) { + #if defined(Q_OS_LINUX) + // Initialize with make_shared + spellChecker_ = std::make_shared<SpellChecker>(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<MessageListModel*>(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 f5eb053c3294bf51f50c816f5e19c06a4bd67ac0..e7b94ae5509f6689a419ccb0ebd1a8c896c85b0c 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 <QObject> #include <QString> @@ -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<AppSettingsManager*>(), qApp->property("PreviewEngine").value<PreviewEngine*>(), + qApp->property("SpellCheckDictionaryManager") + .value<SpellCheckDictionaryManager*>(), qApp->property("LRCInstance").value<LRCInstance*>()); } 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<QString> conversationTypersUrlToName(const QSet<QString>& typersSet); AppSettingsManager* settingsManager_; + SpellCheckDictionaryManager* spellCheckDictionaryManager_; MessageParser* messageParser_; - FilteredMsgListModel* filteredMsgListModel_; - - static constexpr const int loadChunkSize_ {20}; - std::unique_ptr<MessageListModel> mediaInteractions_; - - QTimer* timestampTimer_ {nullptr}; + QTimer* timestampTimer_; + std::shared_ptr<SpellChecker> 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 942d5c6bfa5153976331d68b8613659789e3a336..70f67d4e530e96e06263e7e46bda94d478d12f5a 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 48f38724c84950eb44e070579377e44c03733c96..57b04ef5e18bf85e9637d65ef4de959b0bb25b2f 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 eacff9e22c8e28ebd632fe4c9b0cedd9924a9a44..615fef8e39baae98a2cf8e6b0b38b638f8edbeec 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 fe4f2a59232a37c40cc23cc5b3ebe4eee6c8b08b..090047ac9b80e296701a6855e9b279b30a2fd9a7 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 08951bc302a0d668bf6ed2a401b1c0e10c943c31..21e107d69e4c1ec13eacd3d20176251b0a382506 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 8abd146f9e0d9cc8635b5cd75d258ffd18a6a25c..f7223c089c6425c370388c054aa0e80164264f11 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 0000000000000000000000000000000000000000..1e7d12863178ba1bf860898e61c385db6adb75f6 --- /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 <http://www.gnu.org/licenses/>. + */ + +#include "spellcheckdictionarymanager.h" + +#include <QApplication> +#include <QBuffer> +#include <QClipboard> +#include <QFileInfo> +#include <QRegExp> +#include <QMimeData> +#include <QDir> +#include <QMimeDatabase> +#include <QUrl> +#include <QNetworkAccessManager> +#include <QNetworkReply> +#include <QEventLoop> +#include <QRegularExpression> + +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<QString> 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 0000000000000000000000000000000000000000..66755445223b080d1dc1eea12ca27390131a6c66 --- /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 <http://www.gnu.org/licenses/>. + */ + +#pragma once +#include "appsettingsmanager.h" + +#include <QObject> +#include <QApplication> +#include <QQmlEngine> + +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 0000000000000000000000000000000000000000..1b14235f20f422b5e9d7d128a679aaa9f672bd6b --- /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 <https://www.gnu.org/licenses/>. + * + * \file spellchecker.c + */ + +#include "spellchecker.h" + +#include <QString> +#include <QFile> +#include <QTextStream> +#include <QTextCodec> +#include <QStringList> +#include <QDebug> +#include <QRegExp> +#include <QRegularExpression> +#include <QRegularExpressionMatchIterator> + +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<std::string> 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<Hunspell>(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::WordInfo> +SpellChecker::findWords(const QString& text) +{ + // This is in the C++ part of the code because QML regex does not support unicode + QList<WordInfo> 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 0000000000000000000000000000000000000000..9ef6dd97f345cf0ba1da843da7f71e7f1815652f --- /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 <https://www.gnu.org/licenses/>. + * + * \file spellchecker.h + */ + +#pragma once + +#include "lrcinstance.h" +#include "qmladapterbase.h" +#include "previewengine.h" + +#include <QTextCodec> +#include <QString> +#include <QStringList> +#include <QDebug> +#include <QObject> +#include <string> + +#include <hunspell/hunspell.hxx> + +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<WordInfo> findWords(const QString& text); + +private: + void put_word(const QString& word); + std::shared_ptr<Hunspell> hunspell_; + QString encoding_; + QTextCodec* codec_; +}; diff --git a/src/app/utilsadapter.cpp b/src/app/utilsadapter.cpp index c050499255ad004714a89f6f7b68d1f09807e4d2..225ee7b1e80a20e912d669c6976b3c22ed330217 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 <api/datatransfermodel.h> #include <api/contact.h> @@ -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 445c397cb79ab2e6e14d3632077279bbbfc79cb7..aa0c853bc9b59791209277e9c8592cb7d8f9ccfa 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 a037b84867f54a3b77f947f3cf7c3060144b8446..3e4943ad19d8ed55b67e8314d91a3f80fb3276cc 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> connectivityMonitor_; QScopedPointer<AppSettingsManager> settingsManager_; + QScopedPointer<SpellCheckDictionaryManager> spellCheckDictionaryManager_; QScopedPointer<SystemTray> systemTray_; QScopedPointer<PreviewEngine> previewEngine_; ScreenInfo screenInfo_; diff --git a/tests/qml/src/tst_CachedImage.qml b/tests/qml/src/tst_CachedImage.qml index 6727e8871e6f5afd64d4795bdd15f3e66181a5f2..bd8b5f369c6aca76f77c6f94d49e72c9c2ebcbbc 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() {