1
0
Fork 0
mirror of https://git.jami.net/savoirfairelinux/jami-client-qt.git synced 2025-07-01 06:05:25 +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
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

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
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

View file

@ -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']

View file

@ -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) \

View file

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

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) {
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;

View file

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

View file

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

View file

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

View file

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

View file

@ -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_,

View file

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

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 {
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) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,

View file

@ -38,7 +38,6 @@ SettingsPageBase {
flickableContent: ColumnLayout {
id: manageAccountColumnLayout
width: contentFlickableWidth
spacing: JamiTheme.settingsBlockSpacing
anchors.left: parent.left
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.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);

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_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

View file

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

View file

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

View file

@ -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() {