1
0
Fork 0
mirror of https://git.jami.net/savoirfairelinux/jami-client-qt.git synced 2025-07-01 22:25:26 +02:00

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
This commit is contained in:
Jerome Lamy 2024-04-15 13:58:17 -04:00 committed by Page Magnier-Slimani
parent 88d0539085
commit 2a72da564e
34 changed files with 1076 additions and 77 deletions

4
.gitmodules vendored
View file

@ -31,3 +31,7 @@
path = 3rdparty/zxing-cpp path = 3rdparty/zxing-cpp
url = https://github.com/nu-book/zxing-cpp.git url = https://github.com/nu-book/zxing-cpp.git
ignore = dirty ignore = dirty
[submodule "3rdparty/hunspell"]
path = 3rdparty/hunspell
url = https://gitlab.savoirfairelinux.com/jami/hunspell.git
ignore = dirty

1
3rdparty/hunspell vendored Submodule

@ -0,0 +1 @@
Subproject commit 525f9f22766a28e0f81c435217fcf4528e01c013

View file

@ -255,7 +255,7 @@ set(PYTHON_EXEC ${Python3_EXECUTABLE})
# Versioning and build ID generation # Versioning and build ID generation
set(VERSION_FILE ${CMAKE_CURRENT_BINARY_DIR}/version_info.cpp) 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. # we add it to the target_sources below.
file(TOUCH ${VERSION_FILE}) file(TOUCH ${VERSION_FILE})
add_custom_target( add_custom_target(
@ -347,6 +347,7 @@ set(COMMON_SOURCES
${APP_SRC_DIR}/conversationlistmodel.cpp ${APP_SRC_DIR}/conversationlistmodel.cpp
${APP_SRC_DIR}/searchresultslistmodel.cpp ${APP_SRC_DIR}/searchresultslistmodel.cpp
${APP_SRC_DIR}/calloverlaymodel.cpp ${APP_SRC_DIR}/calloverlaymodel.cpp
${APP_SRC_DIR}/spellcheckdictionarymanager.cpp
${APP_SRC_DIR}/filestosendlistmodel.cpp ${APP_SRC_DIR}/filestosendlistmodel.cpp
${APP_SRC_DIR}/wizardviewstepmodel.cpp ${APP_SRC_DIR}/wizardviewstepmodel.cpp
${APP_SRC_DIR}/avatarregistry.cpp ${APP_SRC_DIR}/avatarregistry.cpp
@ -361,13 +362,13 @@ set(COMMON_SOURCES
${APP_SRC_DIR}/currentcall.cpp ${APP_SRC_DIR}/currentcall.cpp
${APP_SRC_DIR}/messageparser.cpp ${APP_SRC_DIR}/messageparser.cpp
${APP_SRC_DIR}/previewengine.cpp ${APP_SRC_DIR}/previewengine.cpp
${APP_SRC_DIR}/imagedownloader.cpp ${APP_SRC_DIR}/filedownloader.cpp
${APP_SRC_DIR}/pluginversionmanager.cpp ${APP_SRC_DIR}/pluginversionmanager.cpp
${APP_SRC_DIR}/connectioninfolistmodel.cpp ${APP_SRC_DIR}/connectioninfolistmodel.cpp
${APP_SRC_DIR}/pluginversionmanager.cpp ${APP_SRC_DIR}/pluginversionmanager.cpp
${APP_SRC_DIR}/linkdevicemodel.cpp ${APP_SRC_DIR}/linkdevicemodel.cpp
${APP_SRC_DIR}/qrcodescannermodel.cpp ${APP_SRC_DIR}/qrcodescannermodel.cpp
) ${APP_SRC_DIR}/spellchecker.cpp)
set(COMMON_HEADERS set(COMMON_HEADERS
${APP_SRC_DIR}/global.h ${APP_SRC_DIR}/global.h
@ -419,6 +420,7 @@ set(COMMON_HEADERS
${APP_SRC_DIR}/conversationlistmodel.h ${APP_SRC_DIR}/conversationlistmodel.h
${APP_SRC_DIR}/searchresultslistmodel.h ${APP_SRC_DIR}/searchresultslistmodel.h
${APP_SRC_DIR}/calloverlaymodel.h ${APP_SRC_DIR}/calloverlaymodel.h
${APP_SRC_DIR}/spellcheckdictionarymanager.h
${APP_SRC_DIR}/filestosendlistmodel.h ${APP_SRC_DIR}/filestosendlistmodel.h
${APP_SRC_DIR}/wizardviewstepmodel.h ${APP_SRC_DIR}/wizardviewstepmodel.h
${APP_SRC_DIR}/avatarregistry.h ${APP_SRC_DIR}/avatarregistry.h
@ -433,7 +435,7 @@ set(COMMON_HEADERS
${APP_SRC_DIR}/currentcall.h ${APP_SRC_DIR}/currentcall.h
${APP_SRC_DIR}/messageparser.h ${APP_SRC_DIR}/messageparser.h
${APP_SRC_DIR}/htmlparser.h ${APP_SRC_DIR}/htmlparser.h
${APP_SRC_DIR}/imagedownloader.h ${APP_SRC_DIR}/filedownloader.h
${APP_SRC_DIR}/pluginversionmanager.h ${APP_SRC_DIR}/pluginversionmanager.h
${APP_SRC_DIR}/connectioninfolistmodel.h ${APP_SRC_DIR}/connectioninfolistmodel.h
${APP_SRC_DIR}/pttlistener.h ${APP_SRC_DIR}/pttlistener.h
@ -441,7 +443,7 @@ set(COMMON_HEADERS
${APP_SRC_DIR}/crashreporter.h ${APP_SRC_DIR}/crashreporter.h
${APP_SRC_DIR}/linkdevicemodel.h ${APP_SRC_DIR}/linkdevicemodel.h
${APP_SRC_DIR}/qrcodescannermodel.h ${APP_SRC_DIR}/qrcodescannermodel.h
) ${APP_SRC_DIR}/spellchecker.h)
# For libavutil/avframe. # For libavutil/avframe.
set(LIBJAMI_CONTRIB_DIR "${DAEMON_DIR}/contrib") set(LIBJAMI_CONTRIB_DIR "${DAEMON_DIR}/contrib")
@ -469,6 +471,48 @@ if(ENABLE_CRASHREPORTS)
endif() endif()
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) if(MSVC)
set(WINDOWS_SYS_LIBS set(WINDOWS_SYS_LIBS
windowsapp.lib windowsapp.lib
@ -531,8 +575,6 @@ elseif (NOT APPLE)
${APP_SRC_DIR}/screencastportal.h) ${APP_SRC_DIR}/screencastportal.h)
list(APPEND QT_MODULES DBus) list(APPEND QT_MODULES DBus)
find_package(PkgConfig REQUIRED)
pkg_check_modules(GLIB REQUIRED glib-2.0) pkg_check_modules(GLIB REQUIRED glib-2.0)
if(GLIB_FOUND) if(GLIB_FOUND)
add_definitions(${GLIB_CFLAGS_OTHER}) add_definitions(${GLIB_CFLAGS_OTHER})
@ -615,6 +657,13 @@ else() # APPLE
endif() endif()
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 # Qt find package
if(QT6_VER AND QT6_PATH) if(QT6_VER AND QT6_PATH)
message(STATUS "Using custom Qt version") message(STATUS "Using custom Qt version")
@ -703,7 +752,9 @@ qt_add_executable(
${QML_RESOURCES_QML} ${QML_RESOURCES_QML}
${SFPM_OBJECTS}) ${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) add_dependencies(${PROJECT_NAME} generate_version_info)
foreach(MODULE ${QT_MODULES}) foreach(MODULE ${QT_MODULES})
@ -797,6 +848,11 @@ elseif (NOT APPLE)
PRIVATE PRIVATE
JAMI_INSTALL_PREFIX="${JAMI_DATA_PREFIX}") JAMI_INSTALL_PREFIX="${JAMI_DATA_PREFIX}")
target_compile_definitions(
${PROJECT_NAME}
PRIVATE
HUNSPELL_INSTALL_DIR="${HUNSPELL_DICT_DIR}")
# Logos # Logos
install( install(
FILES resources/images/jami.svg FILES resources/images/jami.svg

View file

@ -112,7 +112,7 @@ ZYPPER_CLIENT_DEPENDENCIES = [
'qt6-svg-devel', 'qt6-multimedia-devel', 'qt6-multimedia-imports', 'qt6-svg-devel', 'qt6-multimedia-devel', 'qt6-multimedia-imports',
'qt6-declarative-devel', 'qt6-qmlcompiler-private-devel', 'qt6-declarative-devel', 'qt6-qmlcompiler-private-devel',
'qt6-quickcontrols2-devel', 'qt6-shadertools-devel', 'qt6-quickcontrols2-devel', 'qt6-shadertools-devel',
'qrencode-devel', 'NetworkManager-devel' 'qrencode-devel', 'NetworkManager-devel', 'hunspell-devel', 'libhunspell-devel'
] ]
ZYPPER_QT_WEBENGINE = [ ZYPPER_QT_WEBENGINE = [
@ -139,7 +139,7 @@ DNF_CLIENT_DEPENDENCIES = [
'libnotify-devel', 'libnotify-devel',
'qt6-qtbase-devel', 'qt6-qtbase-devel',
'qt6-qtsvg-devel', 'qt6-qtmultimedia-devel', 'qt6-qtdeclarative-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'] DNF_QT_WEBENGINE = ['qt6-qtwebengine-devel']
@ -171,7 +171,7 @@ APT_CLIENT_DEPENDENCIES = [
'qml6-module-qtquick-dialogs', 'qml6-module-qtquick-layouts', 'qml6-module-qtquick-dialogs', 'qml6-module-qtquick-layouts',
'qml6-module-qtquick-shapes', 'qml6-module-qtquick-window', 'qml6-module-qtquick-shapes', 'qml6-module-qtquick-window',
'qml6-module-qtquick-templates', 'qml6-module-qt-labs-platform', 'qml6-module-qtquick-templates', 'qml6-module-qt-labs-platform',
'libqrencode-dev', 'libnm-dev' 'libqrencode-dev', 'libnm-dev', 'hunspell', 'libhunspell-dev'
] ]
APT_QT_WEBENGINE = [ APT_QT_WEBENGINE = [
@ -194,7 +194,7 @@ PACMAN_CLIENT_DEPENDENCIES = [
'qt6-declarative', 'qt6-5compat', 'qt6-multimedia', 'qt6-declarative', 'qt6-5compat', 'qt6-multimedia',
'qt6-networkauth', 'qt6-shadertools', 'qt6-networkauth', 'qt6-shadertools',
'qt6-svg', 'qt6-tools', 'qt6-svg', 'qt6-tools',
'qrencode', 'libnm' 'qrencode', 'libnm', 'hunspell'
] ]
PACMAN_QT_WEBENGINE = ['qt6-webengine'] PACMAN_QT_WEBENGINE = ['qt6-webengine']

View file

@ -63,6 +63,8 @@ extern const QString defaultDownloadPath;
X(WindowState, QWindow::AutomaticVisibility) \ X(WindowState, QWindow::AutomaticVisibility) \
X(EnableExperimentalSwarm, false) \ X(EnableExperimentalSwarm, false) \
X(LANG, "SYSTEM") \ X(LANG, "SYSTEM") \
X(SpellLang, "None") \
X(EnableSpellCheck, true) \
X(PluginStoreEndpoint, "https://plugins.jami.net") \ X(PluginStoreEndpoint, "https://plugins.jami.net") \
X(PositionShareDuration, 15) \ X(PositionShareDuration, 15) \
X(PositionShareLimit, true) \ X(PositionShareLimit, true) \

View file

@ -15,8 +15,11 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import QtQuick import QtQuick
import net.jami.Adapters 1.1
import net.jami.Constants 1.1 import net.jami.Constants 1.1
import "contextmenu" import "contextmenu"
import "../mainview"
import "../mainview/components"
ContextMenuAutoLoader { ContextMenuAutoLoader {
id: root id: root
@ -27,8 +30,16 @@ ContextMenuAutoLoader {
property var selectionEnd property var selectionEnd
property bool customizePaste: false property bool customizePaste: false
property bool selectOnly: false property bool selectOnly: false
property bool checkSpell: false
property var suggestionList
property var menuItemsLength
property var language
signal contextMenuRequirePaste signal contextMenuRequirePaste
SpellLanguageContextMenu {
id: spellLanguageContextMenu
active: checkSpell
}
property list<GeneralMenuItem> menuItems: [ property list<GeneralMenuItem> menuItems: [
GeneralMenuItem { GeneralMenuItem {
@ -38,9 +49,8 @@ ContextMenuAutoLoader {
isActif: lineEditObj.selectedText.length isActif: lineEditObj.selectedText.length
itemName: JamiStrings.copy itemName: JamiStrings.copy
hasIcon: false hasIcon: false
onClicked: { onClicked:
lineEditObj.copy(); lineEditObj.copy();
}
}, },
GeneralMenuItem { GeneralMenuItem {
id: cut id: cut
@ -49,9 +59,8 @@ ContextMenuAutoLoader {
isActif: lineEditObj.selectedText.length && !selectOnly isActif: lineEditObj.selectedText.length && !selectOnly
itemName: JamiStrings.cut itemName: JamiStrings.cut
hasIcon: false hasIcon: false
onClicked: { onClicked:
lineEditObj.cut(); lineEditObj.cut();
}
}, },
GeneralMenuItem { GeneralMenuItem {
id: paste id: paste
@ -65,9 +74,68 @@ ContextMenuAutoLoader {
else else
lineEditObj.paste(); 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) { function openMenuAt(mouseEvent) {
if (lineEditObj.selectedText.length === 0 && selectOnly) if (lineEditObj.selectedText.length === 0 && selectOnly)
return; return;
@ -85,6 +153,12 @@ ContextMenuAutoLoader {
function onOpened() { function onOpened() {
lineEditObj.select(selectionStart, selectionEnd); lineEditObj.select(selectionStart, selectionEnd);
} }
function onClosed() {
if (!suggestionList || suggestionList.length == 0) {
return;
}
removeItems();
}
} }
Component.onCompleted: menuItemsToLoad = menuItems Component.onCompleted: menuItemsToLoad = menuItems

View file

@ -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;
}
}

View file

@ -44,11 +44,15 @@ Menu {
function loadMenuItems(menuItems) { function loadMenuItems(menuItems) {
root.addItem(menuTopBorder); 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) { for (var j = 0; j < menuItems.length; ++j) {
var currentItemWidth = menuItems[j].itemPreferredWidth; var currentItemWidth = menuItems[j].itemPreferredWidth;
if (currentItemWidth !== JamiTheme.menuItemsPreferredWidth && currentItemWidth > menuPreferredWidth && menuItems[j].canTrigger) if (currentItemWidth !== JamiTheme.menuItemsPreferredWidth && currentItemWidth > menuPreferredWidth && menuItems[j].canTrigger)
menuPreferredWidth = currentItemWidth; menuPreferredWidth = currentItemWidth;
} }
// Add the items to the menu
for (var i = 0; i < menuItems.length; ++i) { for (var i = 0; i < menuItems.length; ++i) {
if (menuItems[i].canTrigger) { if (menuItems[i].canTrigger) {
menuItems[i].parentMenu = root; menuItems[i].parentMenu = root;

View file

@ -27,11 +27,14 @@ Loader {
property int contextMenuItemPreferredHeight: 0 property int contextMenuItemPreferredHeight: 0
property int contextMenuSeparatorPreferredHeight: 0 property int contextMenuSeparatorPreferredHeight: 0
signal openRequested
active: false active: false
visible: false visible: false
function openMenu() { function openMenu() {
openRequested();
root.active = true; root.active = true;
root.sourceComponent = menuComponent; root.sourceComponent = menuComponent;
} }

View file

@ -28,6 +28,7 @@ MenuItem {
id: menuItem id: menuItem
property string itemName: "" property string itemName: ""
property string content: ""
property alias iconSource: contextMenuItemImage.source property alias iconSource: contextMenuItemImage.source
property string iconColor: "" property string iconColor: ""
property bool canTrigger: true property bool canTrigger: true

View file

@ -15,32 +15,32 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
#include "imagedownloader.h" #include "filedownloader.h"
#include <QDir> #include <QDir>
#include <QLockFile> #include <QLockFile>
ImageDownloader::ImageDownloader(ConnectivityMonitor* cm, QObject* parent) FileDownloader::FileDownloader(ConnectivityMonitor* cm, QObject* parent)
: NetworkManager(cm, parent) : NetworkManager(cm, parent)
{} {}
void 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]() { Utils::oneShotConnect(this, &NetworkManager::errorOccurred, this, [this, localPath]() {
onDownloadImageFinished({}, localPath); onDownloadFileFinished({}, localPath);
}); });
sendGetRequest(url, [this, localPath](const QByteArray& imageData) { sendGetRequest(url, [this, localPath](const QByteArray& fileData) {
onDownloadImageFinished(imageData, localPath); onDownloadFileFinished(fileData, localPath);
}); });
} }
void void
ImageDownloader::onDownloadImageFinished(const QByteArray& data, const QString& localPath) FileDownloader::onDownloadFileFinished(const QByteArray& data, const QString& localPath)
{ {
if (data.isEmpty()) { if (data.isEmpty()) {
Q_EMIT downloadImageFailed(localPath); Q_EMIT downloadFileFailed(localPath);
return; return;
} }
@ -49,7 +49,7 @@ ImageDownloader::onDownloadImageFinished(const QByteArray& data, const QString&
const QDir dir; const QDir dir;
if (!dir.mkpath(dirPath)) { if (!dir.mkpath(dirPath)) {
qWarning() << Q_FUNC_INFO << "Failed to create directory" << dirPath; qWarning() << Q_FUNC_INFO << "Failed to create directory" << dirPath;
Q_EMIT downloadImageFailed(localPath); Q_EMIT downloadFileFailed(localPath);
return; return;
} }
@ -58,10 +58,10 @@ ImageDownloader::onDownloadImageFinished(const QByteArray& data, const QString&
if (lf.lock() && file.open(QIODevice::WriteOnly)) { if (lf.lock() && file.open(QIODevice::WriteOnly)) {
file.write(data); file.write(data);
file.close(); file.close();
Q_EMIT downloadImageSuccessful(localPath); Q_EMIT downloadFileSuccessful(localPath);
return; return;
} }
qWarning() << Q_FUNC_INFO << "Failed to write image to" << localPath; qWarning() << Q_FUNC_INFO << "Failed to write file to" << localPath;
Q_EMIT downloadImageFailed(localPath); Q_EMIT downloadFileFailed(localPath);
} }

View file

@ -24,7 +24,7 @@
#include <QQmlEngine> // QML registration #include <QQmlEngine> // QML registration
#include <QApplication> // QML registration #include <QApplication> // QML registration
class ImageDownloader : public NetworkManager class FileDownloader : public NetworkManager
{ {
Q_OBJECT Q_OBJECT
QML_SINGLETON QML_SINGLETON
@ -32,23 +32,23 @@ class ImageDownloader : public NetworkManager
QML_PROPERTY(QString, cachePath) QML_PROPERTY(QString, cachePath)
public: public:
static ImageDownloader* create(QQmlEngine*, QJSEngine*) static FileDownloader* create(QQmlEngine*, QJSEngine*)
{ {
return new ImageDownloader( return new FileDownloader(
qApp->property("ConnectivityMonitor").value<ConnectivityMonitor*>()); qApp->property("ConnectivityMonitor").value<ConnectivityMonitor*>());
} }
explicit ImageDownloader(ConnectivityMonitor* cm, QObject* parent = nullptr); explicit FileDownloader(ConnectivityMonitor* cm, QObject* parent = nullptr);
~ImageDownloader() = default; ~FileDownloader() = default;
// Download an image and call onDownloadImageFinished when done // Download an image and call onDownloadFileFinished when done
Q_INVOKABLE void downloadImage(const QUrl& url, const QString& localPath); Q_INVOKABLE void downloadFile(const QUrl& url, const QString& localPath);
Q_SIGNALS: Q_SIGNALS:
void downloadImageSuccessful(const QString& localPath); void downloadFileSuccessful(const QString& localPath);
void downloadImageFailed(const QString& localPath); void downloadFileFailed(const QString& localPath);
private Q_SLOTS: private Q_SLOTS:
// Saves the image to the localPath and emits the appropriate signal // 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);
}; };

View file

@ -20,6 +20,7 @@
#include "global.h" #include "global.h"
#include "qmlregister.h" #include "qmlregister.h"
#include "appsettingsmanager.h" #include "appsettingsmanager.h"
#include "spellcheckdictionarymanager.h"
#include "connectivitymonitor.h" #include "connectivitymonitor.h"
#include "systemtray.h" #include "systemtray.h"
#include "previewengine.h" #include "previewengine.h"
@ -190,6 +191,7 @@ MainApplication::init()
// to any other initialization. This won't do anything if crashpad isn't // to any other initialization. This won't do anything if crashpad isn't
// enabled. // enabled.
settingsManager_ = new AppSettingsManager(this); settingsManager_ = new AppSettingsManager(this);
spellCheckDictionaryManager_ = new SpellCheckDictionaryManager(settingsManager_, this);
crashReporter_ = new CrashReporter(settingsManager_, this); crashReporter_ = new CrashReporter(settingsManager_, this);
// This 2-phase initialisation prevents ephemeral instances from // This 2-phase initialisation prevents ephemeral instances from
@ -423,6 +425,7 @@ MainApplication::initQmlLayer()
lrcInstance_.get(), lrcInstance_.get(),
systemTray_, systemTray_,
settingsManager_, settingsManager_,
spellCheckDictionaryManager_,
connectivityMonitor_, connectivityMonitor_,
previewEngine_, previewEngine_,
&screenInfo_, &screenInfo_,

View file

@ -31,6 +31,7 @@
class ConnectivityMonitor; class ConnectivityMonitor;
class SystemTray; class SystemTray;
class AppSettingsManager; class AppSettingsManager;
class SpellCheckDictionaryManager;
class CrashReporter; class CrashReporter;
class PreviewEngine; class PreviewEngine;
@ -118,6 +119,7 @@ private:
ConnectivityMonitor* connectivityMonitor_; ConnectivityMonitor* connectivityMonitor_;
SystemTray* systemTray_; SystemTray* systemTray_;
AppSettingsManager* settingsManager_; AppSettingsManager* settingsManager_;
SpellCheckDictionaryManager* spellCheckDictionaryManager_;
PreviewEngine* previewEngine_; PreviewEngine* previewEngine_;
CrashReporter* crashReporter_; CrashReporter* crashReporter_;

View file

@ -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);
}
}

View file

@ -64,8 +64,8 @@ Item {
} }
Connections { Connections {
target: ImageDownloader target: FileDownloader
function onDownloadImageSuccessful(localPath) { function onDownloadFileSuccessful(localPath) {
if (localPath === cachedImage.localPath) { if (localPath === cachedImage.localPath) {
image.source = UtilsAdapter.urlFromLocalPath(localPath); image.source = UtilsAdapter.urlFromLocalPath(localPath);
} }
@ -90,7 +90,7 @@ Item {
} }
if (downloadUrl && downloadUrl !== "" && localPath !== "") { if (downloadUrl && downloadUrl !== "" && localPath !== "") {
if (!UtilsAdapter.fileExists(localPath)) { if (!UtilsAdapter.fileExists(localPath)) {
ImageDownloader.downloadImage(downloadUrl, localPath); FileDownloader.downloadFile(downloadUrl, localPath);
} else { } else {
image.source = UtilsAdapter.urlFromLocalPath(localPath); image.source = UtilsAdapter.urlFromLocalPath(localPath);
if (image.isGif) { if (image.isGif) {

View file

@ -116,6 +116,9 @@ Rectangle {
spacing: 0 spacing: 0
ElidedTextLabel {
id: title
LineEditContextMenu { LineEditContextMenu {
id: displayNameContextMenu id: displayNameContextMenu
lineEditObj: title lineEditObj: title
@ -130,9 +133,6 @@ Rectangle {
} }
} }
ElidedTextLabel {
id: title
Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft Layout.alignment: Qt.AlignVCenter | Qt.AlignLeft
font.pointSize: JamiTheme.textFontSize + 2 font.pointSize: JamiTheme.textFontSize + 2

View file

@ -17,19 +17,17 @@
import QtQuick import QtQuick
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Layouts import QtQuick.Layouts
import net.jami.Adapters 1.1 import net.jami.Adapters 1.1
import net.jami.Constants 1.1 import net.jami.Constants 1.1
import net.jami.Enums 1.1 import net.jami.Enums 1.1
import net.jami.Models 1.1 import net.jami.Models 1.1
import SortFilterProxyModel 0.2 import SortFilterProxyModel 0.2
import "../../commoncomponents" import "../../commoncomponents"
JamiFlickable { JamiFlickable {
id: root id: root
property int underlineHeight: JamiTheme.messageUnderlineHeight
property alias text: textArea.text property alias text: textArea.text
property var textAreaObj: textArea property var textAreaObj: textArea
property alias placeholderText: textArea.placeholderText property alias placeholderText: textArea.placeholderText
@ -39,9 +37,12 @@ JamiFlickable {
property bool showPreview: false property bool showPreview: false
property bool isShowTypo: UtilsAdapter.getAppValue(Settings.Key.ShowMardownOption) property bool isShowTypo: UtilsAdapter.getAppValue(Settings.Key.ShowMardownOption)
property int textWidth: textArea.contentWidth 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 // Used to cache the editable text when showing the preview message
// and also to debounce the textChanged signal's effect on the composing status. // and also to debounce the textChanged signal's effect on the composing status.
property var underlineList: []
property string cachedText property string cachedText
property string debounceText property string debounceText
@ -72,6 +73,7 @@ JamiFlickable {
lineEditObj: textArea lineEditObj: textArea
customizePaste: true customizePaste: true
checkSpell: (Qt.platform.os.toString() === "linux") ? true : false
onContextMenuRequirePaste: { onContextMenuRequirePaste: {
// Intercept paste event to use C++ QMimeData // Intercept paste event to use C++ QMimeData
@ -115,9 +117,79 @@ JamiFlickable {
TextArea.flickable: TextArea { TextArea.flickable: TextArea {
id: 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 readOnly: showPreview
leftPadding: JamiTheme.scrollBarHandleSize leftPadding: JamiTheme.scrollBarHandleSize
rightPadding: JamiTheme.scrollBarHandleSize rightPadding: JamiTheme.scrollBarHandleSize
topPadding: 0
bottomPadding: underlineHeight
persistentSelection: true persistentSelection: true
verticalAlignment: TextEdit.AlignVCenter verticalAlignment: TextEdit.AlignVCenter
font.pointSize: JamiTheme.textFontSize + 2 font.pointSize: JamiTheme.textFontSize + 2
@ -135,12 +207,37 @@ JamiFlickable {
color: "transparent" 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) { 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); textAreaContextMenu.openMenuAt(event);
} }
}
onTextChanged: { onTextChanged: {
updateUnderlineText();
if (text !== debounceText && !showPreview) { if (text !== debounceText && !showPreview) {
debounceText = text; debounceText = text;
MessagesAdapter.userIsComposing(text ? true : false); MessagesAdapter.userIsComposing(text ? true : false);
@ -152,6 +249,8 @@ JamiFlickable {
// eg. Enter -> Send messages // eg. Enter -> Send messages
// Shift + Enter -> Next Line // Shift + Enter -> Next Line
Keys.onPressed: function (keyEvent) { Keys.onPressed: function (keyEvent) {
// Update underline on each input to take into account deleted text and sent ones
updateUnderlineText();
if (keyEvent.matches(StandardKey.Paste)) { if (keyEvent.matches(StandardKey.Paste)) {
MessagesAdapter.onPaste(); MessagesAdapter.onPaste();
keyEvent.accepted = true; keyEvent.accepted = true;
@ -180,5 +279,41 @@ JamiFlickable {
keyEvent.accepted = true; 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();
}
}
} }
} }

View file

@ -21,6 +21,7 @@
#include "qtutils.h" #include "qtutils.h"
#include "messageparser.h" #include "messageparser.h"
#include "previewengine.h" #include "previewengine.h"
#include "spellchecker.h"
#include <api/datatransfermodel.h> #include <api/datatransfermodel.h>
#include <api/contact.h> #include <api/contact.h>
@ -39,17 +40,25 @@
#include <QtMath> #include <QtMath>
#include <QRegExp> #include <QRegExp>
#define SUGGESTIONS_MAX_SIZE 3 // limit the number of spelling suggestions
MessagesAdapter::MessagesAdapter(AppSettingsManager* settingsManager, MessagesAdapter::MessagesAdapter(AppSettingsManager* settingsManager,
PreviewEngine* previewEngine, PreviewEngine* previewEngine,
SpellCheckDictionaryManager* spellCheckDictionaryManager,
LRCInstance* instance, LRCInstance* instance,
QObject* parent) QObject* parent)
: QmlAdapterBase(instance, parent) : QmlAdapterBase(instance, parent)
, settingsManager_(settingsManager) , settingsManager_(settingsManager)
, spellCheckDictionaryManager_(spellCheckDictionaryManager)
, messageParser_(new MessageParser(previewEngine, this)) , messageParser_(new MessageParser(previewEngine, this))
, filteredMsgListModel_(new FilteredMsgListModel(this)) , filteredMsgListModel_(new FilteredMsgListModel(this))
, mediaInteractions_(std::make_unique<MessageListModel>(nullptr)) , mediaInteractions_(std::make_unique<MessageListModel>(nullptr))
, timestampTimer_(new QTimer(this)) , 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()); setObjectName(typeid(*this).name());
set_messageListModel(QVariant::fromValue(filteredMsgListModel_)); set_messageListModel(QVariant::fromValue(filteredMsgListModel_));
@ -727,3 +736,53 @@ MessagesAdapter::getMsgListSourceModel() const
// However it may be a nullptr if not yet set. // However it may be a nullptr if not yet set.
return static_cast<MessageListModel*>(filteredMsgListModel_->sourceModel()); 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);
}

View file

@ -23,6 +23,8 @@
#include "previewengine.h" #include "previewengine.h"
#include "messageparser.h" #include "messageparser.h"
#include "appsettingsmanager.h" #include "appsettingsmanager.h"
#include "spellchecker.h"
#include "spellcheckdictionarymanager.h"
#include <QObject> #include <QObject>
#include <QString> #include <QString>
@ -46,7 +48,6 @@ public:
connect(this, &QAbstractItemModel::rowsRemoved, this, &FilteredMsgListModel::countChanged); connect(this, &QAbstractItemModel::rowsRemoved, this, &FilteredMsgListModel::countChanged);
connect(this, &QAbstractItemModel::modelReset, this, &FilteredMsgListModel::countChanged); connect(this, &QAbstractItemModel::modelReset, this, &FilteredMsgListModel::countChanged);
connect(this, &QAbstractItemModel::layoutChanged, this, &FilteredMsgListModel::countChanged); connect(this, &QAbstractItemModel::layoutChanged, this, &FilteredMsgListModel::countChanged);
} }
bool filterAcceptsRow(int sourceRow, const QModelIndex& sourceParent) const override bool filterAcceptsRow(int sourceRow, const QModelIndex& sourceParent) const override
{ {
@ -101,11 +102,14 @@ public:
{ {
return new MessagesAdapter(qApp->property("AppSettingsManager").value<AppSettingsManager*>(), return new MessagesAdapter(qApp->property("AppSettingsManager").value<AppSettingsManager*>(),
qApp->property("PreviewEngine").value<PreviewEngine*>(), qApp->property("PreviewEngine").value<PreviewEngine*>(),
qApp->property("SpellCheckDictionaryManager")
.value<SpellCheckDictionaryManager*>(),
qApp->property("LRCInstance").value<LRCInstance*>()); qApp->property("LRCInstance").value<LRCInstance*>());
} }
explicit MessagesAdapter(AppSettingsManager* settingsManager, explicit MessagesAdapter(AppSettingsManager* settingsManager,
PreviewEngine* previewEngine, PreviewEngine* previewEngine,
SpellCheckDictionaryManager* spellCheckDictionaryManager,
LRCInstance* instance, LRCInstance* instance,
QObject* parent = nullptr); QObject* parent = nullptr);
~MessagesAdapter() = default; ~MessagesAdapter() = default;
@ -164,6 +168,10 @@ public:
Q_INVOKABLE QVariant dataForInteraction(const QString& interactionId, Q_INVOKABLE QVariant dataForInteraction(const QString& interactionId,
int role = Qt::DisplayRole) const; int role = Qt::DisplayRole) const;
Q_INVOKABLE void startSearch(const QString& text, bool isMedia); 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. // Run corrsponding js functions, c++ to qml.
void setMessagesImageContent(const QString& path, bool isBased64 = false); void setMessagesImageContent(const QString& path, bool isBased64 = false);
@ -198,14 +206,12 @@ private:
QList<QString> conversationTypersUrlToName(const QSet<QString>& typersSet); QList<QString> conversationTypersUrlToName(const QSet<QString>& typersSet);
AppSettingsManager* settingsManager_; AppSettingsManager* settingsManager_;
SpellCheckDictionaryManager* spellCheckDictionaryManager_;
MessageParser* messageParser_; MessageParser* messageParser_;
FilteredMsgListModel* filteredMsgListModel_; FilteredMsgListModel* filteredMsgListModel_;
static constexpr const int loadChunkSize_ {20};
std::unique_ptr<MessageListModel> mediaInteractions_; std::unique_ptr<MessageListModel> mediaInteractions_;
QTimer* timestampTimer_;
QTimer* timestampTimer_ {nullptr}; std::shared_ptr<SpellChecker> spellChecker_;
static constexpr const int loadChunkSize_ {20};
static constexpr const int timestampUpdateIntervalMs_ {1000}; static constexpr const int timestampUpdateIntervalMs_ {1000};
}; };

View file

@ -275,6 +275,7 @@ Item {
property string share: qsTr("Share") property string share: qsTr("Share")
property string cut: qsTr("Cut") property string cut: qsTr("Cut")
property string paste: qsTr("Paste") property string paste: qsTr("Paste")
property string language: qsTr("Language")
// ConversationContextMenu // ConversationContextMenu
property string startAudioCall: qsTr("Start audio call") property string startAudioCall: qsTr("Start audio call")
@ -508,7 +509,7 @@ Item {
property string displayHyperlinkPreviews: qsTr("Web link previews") property string displayHyperlinkPreviews: qsTr("Web link previews")
property string displayHyperlinkPreviewsDescription: qsTr("Preview requires downloading content from third-party servers.") 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 verticalViewOpt: qsTr("Vertical view")
property string horizontalViewOpt: qsTr("Horizontal view") property string horizontalViewOpt: qsTr("Horizontal view")
@ -905,4 +906,12 @@ Item {
property string copyAllData: qsTr("Copy all data") property string copyAllData: qsTr("Copy all data")
property string remote: qsTr("Remote: %1") property string remote: qsTr("Remote: %1")
property string view: qsTr("View") 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")
} }

View file

@ -516,6 +516,7 @@ Item {
property int showTypoSecondToggleWidth: 540 property int showTypoSecondToggleWidth: 540
property int messageBarMaximumHeight: 150 property int messageBarMaximumHeight: 150
property int messageBarMinimumHeight: 36 property int messageBarMinimumHeight: 36
property int messageUnderlineHeight: 2
// InvitationView // InvitationView
property real invitationViewAvatarSize: 112 property real invitationViewAvatarSize: 112

View file

@ -26,7 +26,7 @@
#include "positionmanager.h" #include "positionmanager.h"
#include "tipsmodel.h" #include "tipsmodel.h"
#include "connectivitymonitor.h" #include "connectivitymonitor.h"
#include "imagedownloader.h" #include "filedownloader.h"
#include "utilsadapter.h" #include "utilsadapter.h"
#include "conversationsadapter.h" #include "conversationsadapter.h"
#include "currentcall.h" #include "currentcall.h"
@ -36,6 +36,7 @@
#include "currentaccounttomigrate.h" #include "currentaccounttomigrate.h"
#include "pttlistener.h" #include "pttlistener.h"
#include "calloverlaymodel.h" #include "calloverlaymodel.h"
#include "spellcheckdictionarymanager.h"
#include "accountlistmodel.h" #include "accountlistmodel.h"
#include "mediacodeclistmodel.h" #include "mediacodeclistmodel.h"
#include "audiodevicemodel.h" #include "audiodevicemodel.h"
@ -64,6 +65,7 @@
#include "wizardviewstepmodel.h" #include "wizardviewstepmodel.h"
#include "linkdevicemodel.h" #include "linkdevicemodel.h"
#include "qrcodescannermodel.h" #include "qrcodescannermodel.h"
#include "spellchecker.h"
#include "api/peerdiscoverymodel.h" #include "api/peerdiscoverymodel.h"
#include "api/codecmodel.h" #include "api/codecmodel.h"
@ -117,6 +119,7 @@ registerTypes(QQmlEngine* engine,
LRCInstance* lrcInstance, LRCInstance* lrcInstance,
SystemTray* systemTray, SystemTray* systemTray,
AppSettingsManager* settingsManager, AppSettingsManager* settingsManager,
SpellCheckDictionaryManager* spellCheckDictionaryManager,
ConnectivityMonitor* connectivityMonitor, ConnectivityMonitor* connectivityMonitor,
PreviewEngine* previewEngine, PreviewEngine* previewEngine,
ScreenInfo* screenInfo, ScreenInfo* screenInfo,
@ -201,6 +204,7 @@ registerTypes(QQmlEngine* engine,
qApp->setProperty("AppSettingsManager", QVariant::fromValue(settingsManager)); qApp->setProperty("AppSettingsManager", QVariant::fromValue(settingsManager));
qApp->setProperty("ConnectivityMonitor", QVariant::fromValue(connectivityMonitor)); qApp->setProperty("ConnectivityMonitor", QVariant::fromValue(connectivityMonitor));
qApp->setProperty("PreviewEngine", QVariant::fromValue(previewEngine)); qApp->setProperty("PreviewEngine", QVariant::fromValue(previewEngine));
qApp->setProperty("SpellCheckDictionaryManager", QVariant::fromValue(spellCheckDictionaryManager));
// qml adapter registration // qml adapter registration
QML_REGISTERSINGLETON_TYPE(NS_HELPERS, QRCodeScannerModel); QML_REGISTERSINGLETON_TYPE(NS_HELPERS, QRCodeScannerModel);
@ -220,7 +224,7 @@ registerTypes(QQmlEngine* engine,
QML_REGISTERSINGLETON_TYPE(NS_ADAPTERS, TipsModel); QML_REGISTERSINGLETON_TYPE(NS_ADAPTERS, TipsModel);
QML_REGISTERSINGLETON_TYPE(NS_ADAPTERS, VideoDevices); QML_REGISTERSINGLETON_TYPE(NS_ADAPTERS, VideoDevices);
QML_REGISTERSINGLETON_TYPE(NS_ADAPTERS, CurrentAccountToMigrate); QML_REGISTERSINGLETON_TYPE(NS_ADAPTERS, CurrentAccountToMigrate);
QML_REGISTERSINGLETON_TYPE(NS_HELPERS, ImageDownloader); QML_REGISTERSINGLETON_TYPE(NS_HELPERS, FileDownloader);
// TODO: remove these // TODO: remove these
QML_REGISTERSINGLETONTYPE_CUSTOM(NS_MODELS, AVModel, &lrcInstance->avModel()) QML_REGISTERSINGLETONTYPE_CUSTOM(NS_MODELS, AVModel, &lrcInstance->avModel())
@ -237,6 +241,7 @@ registerTypes(QQmlEngine* engine,
QML_REGISTERTYPE(NS_MODELS, PluginListPreferenceModel); QML_REGISTERTYPE(NS_MODELS, PluginListPreferenceModel);
QML_REGISTERTYPE(NS_MODELS, FilesToSendListModel); QML_REGISTERTYPE(NS_MODELS, FilesToSendListModel);
QML_REGISTERTYPE(NS_MODELS, CallInformationListModel); QML_REGISTERTYPE(NS_MODELS, CallInformationListModel);
QML_REGISTERTYPE(NS_MODELS, SpellChecker);
// Roles & type enums for models // Roles & type enums for models
QML_REGISTERNAMESPACE(NS_MODELS, AccountList::staticMetaObject, "AccountList"); 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, screenInfo, "CurrentScreenInfo")
QML_REGISTERSINGLETONTYPE_POBJECT(NS_CONSTANTS, lrcInstance, "LRCInstance") QML_REGISTERSINGLETONTYPE_POBJECT(NS_CONSTANTS, lrcInstance, "LRCInstance")
QML_REGISTERSINGLETONTYPE_POBJECT(NS_CONSTANTS, settingsManager, "AppSettingsManager") QML_REGISTERSINGLETONTYPE_POBJECT(NS_CONSTANTS, settingsManager, "AppSettingsManager")
QML_REGISTERSINGLETONTYPE_POBJECT(NS_CONSTANTS, spellCheckDictionaryManager, "SpellCheckDictionaryManager")
// Lrc namespaces, models, and singletons // Lrc namespaces, models, and singletons
QML_REGISTERNAMESPACE(NS_MODELS, lrc::api::staticMetaObject, "Lrc"); QML_REGISTERNAMESPACE(NS_MODELS, lrc::api::staticMetaObject, "Lrc");

View file

@ -32,6 +32,7 @@
class SystemTray; class SystemTray;
class LRCInstance; class LRCInstance;
class AppSettingsManager; class AppSettingsManager;
class SpellCheckDictionaryManager;
class PreviewEngine; class PreviewEngine;
class ScreenInfo; class ScreenInfo;
class MainApplication; class MainApplication;
@ -61,6 +62,7 @@ void registerTypes(QQmlEngine* engine,
LRCInstance* lrcInstance, LRCInstance* lrcInstance,
SystemTray* systemTray, SystemTray* systemTray,
AppSettingsManager* appSettingsManager, AppSettingsManager* appSettingsManager,
SpellCheckDictionaryManager* spellCheckDictionaryManager,
ConnectivityMonitor* connectivityMonitor, ConnectivityMonitor* connectivityMonitor,
PreviewEngine* previewEngine, PreviewEngine* previewEngine,
ScreenInfo* screenInfo, ScreenInfo* screenInfo,

View file

@ -38,7 +38,6 @@ SettingsPageBase {
flickableContent: ColumnLayout { flickableContent: ColumnLayout {
id: manageAccountColumnLayout id: manageAccountColumnLayout
width: contentFlickableWidth
spacing: JamiTheme.settingsBlockSpacing spacing: JamiTheme.settingsBlockSpacing
anchors.left: parent.left anchors.left: parent.left
anchors.leftMargin: JamiTheme.preferredSettingsMarginSize anchors.leftMargin: JamiTheme.preferredSettingsMarginSize

View file

@ -23,6 +23,7 @@ import net.jami.Constants 1.1
import net.jami.Enums 1.1 import net.jami.Enums 1.1
import net.jami.Models 1.1 import net.jami.Models 1.1
import "../../commoncomponents" import "../../commoncomponents"
import SortFilterProxyModel 0.2
SettingsPageBase { SettingsPageBase {
id: root id: root
@ -157,8 +158,8 @@ SettingsPageBase {
Layout.fillWidth: true Layout.fillWidth: true
height: JamiTheme.preferredFieldHeight height: JamiTheme.preferredFieldHeight
labelText: JamiStrings.language labelText: JamiStrings.userInterfaceLanguage
tipText: JamiStrings.language tipText: JamiStrings.userInterfaceLanguage
comboModel: ListModel { comboModel: ListModel {
id: langModel id: langModel
Component.onCompleted: { Component.onCompleted: {
@ -183,6 +184,132 @@ SettingsPageBase {
UtilsAdapter.setAppValue(Settings.Key.LANG, comboModel.get(modelIndex).id); 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 { Connections {
target: UtilsAdapter target: UtilsAdapter
@ -200,6 +327,21 @@ SettingsPageBase {
} }
langComboBoxSetting.modelIndex = langIdx; 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); closeOrMinCheckBox.checked = UtilsAdapter.getDefault(Settings.Key.MinimizeOnClose);
checkboxCallSwarm.checked = UtilsAdapter.getDefault(Settings.Key.EnableExperimentalSwarm); checkboxCallSwarm.checked = UtilsAdapter.getDefault(Settings.Key.EnableExperimentalSwarm);
langComboBoxSetting.modelIndex = 0; langComboBoxSetting.modelIndex = 0;
spellCheckLangComboBoxSetting.modelIndex = 0;
UtilsAdapter.setToDefault(Settings.Key.EnableNotifications); UtilsAdapter.setToDefault(Settings.Key.EnableNotifications);
UtilsAdapter.setToDefault(Settings.Key.MinimizeOnClose); UtilsAdapter.setToDefault(Settings.Key.MinimizeOnClose);
UtilsAdapter.setToDefault(Settings.Key.LANG); UtilsAdapter.setToDefault(Settings.Key.LANG);

View file

@ -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();
}

View file

@ -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();
};

117
src/app/spellchecker.cpp Normal file
View file

@ -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;
}

63
src/app/spellchecker.h Normal file
View file

@ -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_;
};

View file

@ -23,7 +23,6 @@
#include "version.h" #include "version.h"
#include "version_info.h" #include "version_info.h"
#include "global.h" #include "global.h"
#include <api/datatransfermodel.h> #include <api/datatransfermodel.h>
#include <api/contact.h> #include <api/contact.h>
@ -93,6 +92,11 @@ UtilsAdapter::setAppValue(const Settings::Key key, const QVariant& value)
Q_EMIT appThemeChanged(); Q_EMIT appThemeChanged();
else if (key == Settings::Key::UseFramelessWindow) else if (key == Settings::Key::UseFramelessWindow)
Q_EMIT useFramelessWindowChanged(); Q_EMIT useFramelessWindowChanged();
else if (key == Settings::Key::SpellLang) {
Q_EMIT spellLanguageChanged();
} else if (key == Settings::Key::EnableSpellCheck) {
Q_EMIT enableSpellCheckChanged();
}
#if !APPSTORE #if !APPSTORE
// Any donation campaign-related keys can trigger a donation campaign check // Any donation campaign-related keys can trigger a donation campaign check
else if (key == Settings::Key::IsDonationVisible else if (key == Settings::Key::IsDonationVisible

View file

@ -181,6 +181,8 @@ Q_SIGNALS:
void changeLanguage(); void changeLanguage();
void donationCampaignSettingsChanged(); void donationCampaignSettingsChanged();
void useFramelessWindowChanged(); void useFramelessWindowChanged();
void spellLanguageChanged();
void enableSpellCheckChanged();
private: private:
QClipboard* clipboard_; QClipboard* clipboard_;

View file

@ -16,6 +16,7 @@
*/ */
#include "appsettingsmanager.h" #include "appsettingsmanager.h"
#include "spellcheckdictionarymanager.h"
#include "connectivitymonitor.h" #include "connectivitymonitor.h"
#include "mainapplication.h" #include "mainapplication.h"
#include "previewengine.h" #include "previewengine.h"
@ -94,6 +95,7 @@ public Q_SLOTS:
settingsManager_.reset(new AppSettingsManager(this)); settingsManager_.reset(new AppSettingsManager(this));
systemTray_.reset(new SystemTray(settingsManager_.get(), this)); systemTray_.reset(new SystemTray(settingsManager_.get(), this));
previewEngine_.reset(new PreviewEngine(connectivityMonitor_.get(), this)); previewEngine_.reset(new PreviewEngine(connectivityMonitor_.get(), this));
spellCheckDictionaryManager_.reset(new SpellCheckDictionaryManager(settingsManager_.get(), this));
QFontDatabase::addApplicationFont(":/images/FontAwesome.otf"); QFontDatabase::addApplicationFont(":/images/FontAwesome.otf");
@ -152,6 +154,7 @@ public Q_SLOTS:
lrcInstance_.get(), lrcInstance_.get(),
systemTray_.get(), systemTray_.get(),
settingsManager_.get(), settingsManager_.get(),
spellCheckDictionaryManager_.get(),
connectivityMonitor_.get(), connectivityMonitor_.get(),
previewEngine_.get(), previewEngine_.get(),
&screenInfo_, &screenInfo_,
@ -169,6 +172,7 @@ private:
QScopedPointer<ConnectivityMonitor> connectivityMonitor_; QScopedPointer<ConnectivityMonitor> connectivityMonitor_;
QScopedPointer<AppSettingsManager> settingsManager_; QScopedPointer<AppSettingsManager> settingsManager_;
QScopedPointer<SpellCheckDictionaryManager> spellCheckDictionaryManager_;
QScopedPointer<SystemTray> systemTray_; QScopedPointer<SystemTray> systemTray_;
QScopedPointer<PreviewEngine> previewEngine_; QScopedPointer<PreviewEngine> previewEngine_;
ScreenInfo screenInfo_; ScreenInfo screenInfo_;

View file

@ -44,14 +44,14 @@ Item {
SignalSpy { SignalSpy {
id: spyDownloadSuccessful id: spyDownloadSuccessful
target: ImageDownloader target: FileDownloader
signalName: "onDownloadImageSuccessful" signalName: "onDownloadFileSuccessful"
} }
SignalSpy { SignalSpy {
id: spyDownloadFailed id: spyDownloadFailed
target: ImageDownloader target: FileDownloader
signalName: "onDownloadImageFailed" signalName: "onDownloadFileFailed"
} }
function test_goodDownLoad() { function test_goodDownLoad() {