diff --git a/3rdparty/hunspell b/3rdparty/hunspell
index 525f9f22..749cd84a 160000
--- a/3rdparty/hunspell
+++ b/3rdparty/hunspell
@@ -1 +1 @@
-Subproject commit 525f9f22766a28e0f81c435217fcf4528e01c013
+Subproject commit 749cd84a0b7863fd8663b85c65abd8eca55d342d
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 475634fb..f9f27065 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -347,7 +347,8 @@ 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}/spellcheckdictionarylistmodel.cpp
+ ${APP_SRC_DIR}/spellcheckadapter.cpp
${APP_SRC_DIR}/filestosendlistmodel.cpp
${APP_SRC_DIR}/wizardviewstepmodel.cpp
${APP_SRC_DIR}/avatarregistry.cpp
@@ -420,7 +421,8 @@ 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}/spellcheckdictionarylistmodel.h
+ ${APP_SRC_DIR}/spellcheckadapter.h
${APP_SRC_DIR}/filestosendlistmodel.h
${APP_SRC_DIR}/wizardviewstepmodel.h
${APP_SRC_DIR}/avatarregistry.h
@@ -475,13 +477,6 @@ find_package(PkgConfig REQUIRED)
# hunspell
pkg_search_module(hunspell IMPORTED_TARGET 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")
set(HUNSPELL_LIBRARIES PkgConfig::hunspell)
@@ -734,7 +729,8 @@ qt_add_executable(
${COMMON_SOURCES}
${QML_RESOURCES}
${QML_RESOURCES_QML}
- ${SFPM_OBJECTS})
+ ${SFPM_OBJECTS}
+ src/app/spellcheckadapter.h src/app/spellcheckadapter.cpp)
#add_dependencies(${PROJECT_NAME} hunspell)
diff --git a/resources/misc/available_dictionaries.json b/resources/misc/available_dictionaries.json
new file mode 100644
index 00000000..772fb391
--- /dev/null
+++ b/resources/misc/available_dictionaries.json
@@ -0,0 +1,382 @@
+{
+ "af_ZA": {
+ "nativeName": "Afrikaans (Suid-Afrika)",
+ "path": "af_ZA/af_ZA"
+ },
+ "an_ES": {
+ "nativeName": "aragonés (España)",
+ "path": "an_ES/an_ES"
+ },
+ "ar": {
+ "nativeName": "العربية",
+ "path": "ar/ar"
+ },
+ "as_IN": {
+ "nativeName": "অসমীয়া (भारत)",
+ "path": "as_IN/as_IN"
+ },
+ "be-official": {
+ "nativeName": "беларуская",
+ "path": "be_BY/be-official"
+ },
+ "bg_BG": {
+ "nativeName": "български",
+ "path": "bg_BG/bg_BG"
+ },
+ "bn_BD": {
+ "nativeName": "বাঙ্গালি (বাংলাদেশ)",
+ "path": "bn_BD/bn_BD"
+ },
+ "bn_IN": {
+ "nativeName": "বাঙ্গালি (ভারত)",
+ "path": "bn_BD/bn_IN"
+ },
+ "bo": {
+ "nativeName": "བོད་སྐད་",
+ "path": "bo/bo"
+ },
+ "br_FR": {
+ "nativeName": "breton (France)",
+ "path": "br_FR/br_FR"
+ },
+ "bs_BA": {
+ "nativeName": "bosanski",
+ "path": "bs_BA/bs_BA"
+ },
+ "cs_CZ": {
+ "nativeName": "čeština",
+ "path": "cs_CZ/cs_CZ"
+ },
+ "da_DK": {
+ "nativeName": "dansk",
+ "path": "da_DK/da_DK"
+ },
+ "de_AT_frami": {
+ "nativeName": "Deutsch (Österreich)",
+ "path": "de/de_AT_frami"
+ },
+ "de_CH_frami": {
+ "nativeName": "Deutsch (Schweiz)",
+ "path": "de/de_CH_frami"
+ },
+ "de_DE_frami": {
+ "nativeName": "Deutsch (Deutschland)",
+ "path": "de/de_DE_frami"
+ },
+ "el_GR": {
+ "nativeName": "Ελληνικά",
+ "path": "el_GR/el_GR"
+ },
+ "en_AU": {
+ "nativeName": "English (Australia)",
+ "path": "en/en_AU"
+ },
+ "en_CA": {
+ "nativeName": "English (Canada)",
+ "path": "en/en_CA"
+ },
+ "en_GB": {
+ "nativeName": "English (British)",
+ "path": "en/en_GB"
+ },
+ "en_US": {
+ "nativeName": "English (American)",
+ "path": "en/en_US"
+ },
+ "en_ZA": {
+ "nativeName": "English (Suid-Afrika)",
+ "path": "en/en_ZA"
+ },
+ "eo": {
+ "nativeName": "esperanto",
+ "path": "eo/eo"
+ },
+ "es_AR": {
+ "nativeName": "español (Argentina)",
+ "path": "es/es_AR"
+ },
+ "es_BO": {
+ "nativeName": "español (Bolivia)",
+ "path": "es/es_BO"
+ },
+ "es_CL": {
+ "nativeName": "español (Chile)",
+ "path": "es/es_CL"
+ },
+ "es_CO": {
+ "nativeName": "español (Colombia)",
+ "path": "es/es_CO"
+ },
+ "es_CR": {
+ "nativeName": "español (Costa Rica)",
+ "path": "es/es_CR"
+ },
+ "es_CU": {
+ "nativeName": "español (Cuba)",
+ "path": "es/es_CU"
+ },
+ "es_DO": {
+ "nativeName": "español (República Dominicana)",
+ "path": "es/es_DO"
+ },
+ "es_EC": {
+ "nativeName": "español (Ecuador)",
+ "path": "es/es_EC"
+ },
+ "es_ES": {
+ "nativeName": "español (España)",
+ "path": "es/es_ES"
+ },
+ "es_GQ": {
+ "nativeName": "español (Guinea Ecuatorial)",
+ "path": "es/es_GQ"
+ },
+ "es_GT": {
+ "nativeName": "español (Guatemala)",
+ "path": "es/es_GT"
+ },
+ "es_HN": {
+ "nativeName": "español (Honduras)",
+ "path": "es/es_HN"
+ },
+ "es_MX": {
+ "nativeName": "español (México)",
+ "path": "es/es_MX"
+ },
+ "es_NI": {
+ "nativeName": "español (Nicaragua)",
+ "path": "es/es_NI"
+ },
+ "es_PA": {
+ "nativeName": "español (Panamá)",
+ "path": "es/es_PA"
+ },
+ "es_PE": {
+ "nativeName": "español (Perú)",
+ "path": "es/es_PE"
+ },
+ "es_PH": {
+ "nativeName": "español (Pilipinas)",
+ "path": "es/es_PH"
+ },
+ "es_PR": {
+ "nativeName": "español (Puerto Rico)",
+ "path": "es/es_PR"
+ },
+ "es_PY": {
+ "nativeName": "español (Paraguai)",
+ "path": "es/es_PY"
+ },
+ "es_SV": {
+ "nativeName": "español (El Salvador)",
+ "path": "es/es_SV"
+ },
+ "es_US": {
+ "nativeName": "español (United States)",
+ "path": "es/es_US"
+ },
+ "es_UY": {
+ "nativeName": "español (Uruguay)",
+ "path": "es/es_UY"
+ },
+ "es_VE": {
+ "nativeName": "español (Venezuela)",
+ "path": "es/es_VE"
+ },
+ "et_EE": {
+ "nativeName": "eesti",
+ "path": "et_EE/et_EE"
+ },
+ "fa-IR": {
+ "nativeName": "فارسی",
+ "path": "fa_IR/fa-IR"
+ },
+ "fr": {
+ "nativeName": "français",
+ "path": "fr_FR/fr"
+ },
+ "gd_GB": {
+ "nativeName": "Gàidhlig",
+ "path": "gd_GB/gd_GB"
+ },
+ "gl_ES": {
+ "nativeName": "galego",
+ "path": "gl/gl_ES"
+ },
+ "gu_IN": {
+ "nativeName": "ગુજરાતી",
+ "path": "gu_IN/gu_IN"
+ },
+ "he_IL": {
+ "nativeName": "עברית",
+ "path": "he_IL/he_IL"
+ },
+ "hi_IN": {
+ "nativeName": "हिन्दी",
+ "path": "hi_IN/hi_IN"
+ },
+ "hr_HR": {
+ "nativeName": "hrvatski",
+ "path": "hr_HR/hr_HR"
+ },
+ "hu_HU": {
+ "nativeName": "magyar",
+ "path": "hu_HU/hu_HU"
+ },
+ "id_ID": {
+ "nativeName": "Indonesian",
+ "path": "id/id_ID"
+ },
+ "is": {
+ "nativeName": "íslenska",
+ "path": "is/is"
+ },
+ "it_IT": {
+ "nativeName": "italiano",
+ "path": "it_IT/it_IT"
+ },
+ "kmr_Latn": {
+ "nativeName": "Northern Kurdish",
+ "path": "kmr_Latn/kmr_Latn"
+ },
+ "kn_IN": {
+ "nativeName": "ಕನ್ನಡ",
+ "path": "kn_IN/kn_IN"
+ },
+ "ko_KR": {
+ "nativeName": "한국어",
+ "path": "ko_KR/ko_KR"
+ },
+ "lo_LA": {
+ "nativeName": "ລາວ",
+ "path": "lo_LA/lo_LA"
+ },
+ "lt": {
+ "nativeName": "lietuvių",
+ "path": "lt_LT/lt"
+ },
+ "lv_LV": {
+ "nativeName": "latviešu",
+ "path": "lv_LV/lv_LV"
+ },
+ "mn_MN": {
+ "nativeName": "монгол",
+ "path": "mn_MN/mn_MN"
+ },
+ "mr_IN": {
+ "nativeName": "मराठी",
+ "path": "mr_IN/mr_IN"
+ },
+ "nb_NO": {
+ "nativeName": "norsk bokmål",
+ "path": "no/nb_NO"
+ },
+ "ne_NP": {
+ "nativeName": "नेपाली",
+ "path": "ne_NP/ne_NP"
+ },
+ "nl_NL": {
+ "nativeName": "Nederlands",
+ "path": "nl_NL/nl_NL"
+ },
+ "nn_NO": {
+ "nativeName": "norsk nynorsk",
+ "path": "no/nn_NO"
+ },
+ "oc_FR": {
+ "nativeName": "occitan",
+ "path": "oc_FR/oc_FR"
+ },
+ "or_IN": {
+ "nativeName": "ଓଡ଼ିଆ",
+ "path": "or_IN/or_IN"
+ },
+ "pa_IN": {
+ "nativeName": "ਪੰਜਾਬੀ",
+ "path": "pa_IN/pa_IN"
+ },
+ "pl_PL": {
+ "nativeName": "polski",
+ "path": "pl_PL/pl_PL"
+ },
+ "pt_BR": {
+ "nativeName": "português (Brasil)",
+ "path": "pt_BR/pt_BR"
+ },
+ "pt_PT": {
+ "nativeName": "português europeu (Portugal)",
+ "path": "pt_PT/pt_PT"
+ },
+ "ro_RO": {
+ "nativeName": "română",
+ "path": "ro/ro_RO"
+ },
+ "ru_RU": {
+ "nativeName": "русский",
+ "path": "ru_RU/ru_RU"
+ },
+ "sa_IN": {
+ "nativeName": "संस्कृत भाषा",
+ "path": "sa_IN/sa_IN"
+ },
+ "si_LK": {
+ "nativeName": "සිංහල",
+ "path": "si_LK/si_LK"
+ },
+ "sk_SK": {
+ "nativeName": "slovenčina",
+ "path": "sk_SK/sk_SK"
+ },
+ "sl_SI": {
+ "nativeName": "slovenščina",
+ "path": "sl_SI/sl_SI"
+ },
+ "sq_AL": {
+ "nativeName": "shqip",
+ "path": "sq_AL/sq_AL"
+ },
+ "sr": {
+ "nativeName": "српски",
+ "path": "sr/sr"
+ },
+ "sr-Latn": {
+ "nativeName": "srpski",
+ "path": "sr/sr-Latn"
+ },
+ "sv_FI": {
+ "nativeName": "svenska (Finland)",
+ "path": "sv_SE/sv_FI"
+ },
+ "sv_SE": {
+ "nativeName": "svenska (Sverige)",
+ "path": "sv_SE/sv_SE"
+ },
+ "sw_TZ": {
+ "nativeName": "Kiswahili",
+ "path": "sw_TZ/sw_TZ"
+ },
+ "ta_IN": {
+ "nativeName": "தமிழ்",
+ "path": "ta_IN/ta_IN"
+ },
+ "te_IN": {
+ "nativeName": "తెలుగు",
+ "path": "te_IN/te_IN"
+ },
+ "th_TH": {
+ "nativeName": "ไทย",
+ "path": "th_TH/th_TH"
+ },
+ "tr_TR": {
+ "nativeName": "Türkçe",
+ "path": "tr_TR/tr_TR"
+ },
+ "uk_UA": {
+ "nativeName": "українська",
+ "path": "uk_UA/uk_UA"
+ },
+ "vi_VN": {
+ "nativeName": "Tiếng Việt",
+ "path": "vi/vi_VN"
+ }
+}
diff --git a/src/app/MainApplicationWindow.qml b/src/app/MainApplicationWindow.qml
index 2ef2306a..c165bde3 100644
--- a/src/app/MainApplicationWindow.qml
+++ b/src/app/MainApplicationWindow.qml
@@ -292,9 +292,9 @@ ApplicationWindow {
}
}
- Connections {
+ Connections {
target: UtilsAdapter
- function onRaiseWhenCalled() {
+ function onRaiseWhenCalledChanged() {
raiseWhenCalled = AppSettingsManager.getValue(Settings.RaiseWhenCalled);
}
}
diff --git a/src/app/appsettingsmanager.h b/src/app/appsettingsmanager.h
index 17561df0..bac763ec 100644
--- a/src/app/appsettingsmanager.h
+++ b/src/app/appsettingsmanager.h
@@ -63,7 +63,7 @@ extern const QString defaultDownloadPath;
X(WindowState, QWindow::AutomaticVisibility) \
X(EnableExperimentalSwarm, false) \
X(LANG, "SYSTEM") \
- X(SpellLang, "NONE") \
+ X(SpellLang, {}) \
X(EnableSpellCheck, true) \
X(PluginStoreEndpoint, "https://plugins.jami.net") \
X(PositionShareDuration, 15) \
diff --git a/src/app/commoncomponents/BaseModalDialog.qml b/src/app/commoncomponents/BaseModalDialog.qml
index 4f1a5f7b..0cb6b7c7 100644
--- a/src/app/commoncomponents/BaseModalDialog.qml
+++ b/src/app/commoncomponents/BaseModalDialog.qml
@@ -78,7 +78,7 @@ Popup {
contentItem: ColumnLayout {
id: contentLayout
- JamiPushButton {
+ JamiPushButton { QWKSetParentHitTestVisible {}
id: closeButton
visible: closeButtonVisible
@@ -117,7 +117,7 @@ Popup {
Layout.fillHeight: true
Layout.preferredHeight: Math.min(contentHeight, root.height)
- Layout.preferredWidth: contentItem.childrenRect.width
+ Layout.preferredWidth: contentItem.childrenRect.width + ScrollBar.vertical.width
Layout.leftMargin: popupMargins
Layout.rightMargin: popupMargins
Layout.alignment: Qt.AlignCenter
diff --git a/src/app/commoncomponents/DictionaryInstallView.qml b/src/app/commoncomponents/DictionaryInstallView.qml
new file mode 100644
index 00000000..afe889bd
--- /dev/null
+++ b/src/app/commoncomponents/DictionaryInstallView.qml
@@ -0,0 +1,370 @@
+/*
+ * Copyright (C) 2025 Savoir-faire Linux Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import QtQuick
+import QtQuick.Controls
+import QtQuick.Layouts
+import Qt5Compat.GraphicalEffects
+import net.jami.Models 1.1
+import net.jami.Adapters 1.1
+import net.jami.Constants 1.1
+import "../mainview/components"
+import "../settingsview/components"
+import SortFilterProxyModel 0.2
+
+// Search bar for filtering dictionaries
+ColumnLayout {
+ id: root
+ spacing: 0
+ property int checkBoxWidth: 24
+
+ Component.onCompleted: Qt.callLater(dictionarySearchBar.setTextAreaFocus)
+
+ RowLayout {
+ id: headerLayout
+ width: parent.width
+ Layout.preferredHeight: childrenRect.height
+
+ // Header title
+ Searchbar {
+ id: dictionarySearchBar
+
+ focus: true
+ Layout.fillWidth: true
+ Layout.preferredHeight: 55
+
+ placeHolderText: JamiStrings.searchTextLanguages
+ Accessible.name: JamiStrings.searchTextLanguages
+ Accessible.role: Accessible.EditableText
+ Accessible.description: JamiStrings.searchAvailableTextLanguages
+
+ onSearchBarTextChanged: function (text) {
+ dictionaryProxyModel.combinedFilterPattern = text;
+ dictionaryProxyModel.invalidate();
+ }
+ }
+ Label {
+ text: JamiStrings.showInstalledDictionaries
+ color: JamiTheme.faddedLastInteractionFontColor
+ font.pixelSize: JamiTheme.settingsDescriptionPixelSize
+ Layout.rightMargin: 0
+ Layout.preferredHeight: 16
+ Layout.alignment: Qt.AlignVCenter
+ }
+
+ // Checkbox to filter installed dictionaries
+ CheckBox {
+ id: showInstalledOnlyCheckbox
+ Accessible.name: JamiStrings.showInstalledDictionaries
+ Accessible.role: Accessible.CheckBox
+ Accessible.description: JamiStrings.showInstalledDictionariesDescription
+ checked: false
+ indicator: Image {
+ anchors.centerIn: parent
+ layer {
+ enabled: true
+ effect: ColorOverlay {
+ color: JamiTheme.tintedBlue
+ }
+ mipmap: false
+ smooth: true
+ }
+ width: checkBoxWidth
+ height: checkBoxWidth
+ source: showInstalledOnlyCheckbox.checked ? JamiResources.check_box_24dp_svg : JamiResources.check_box_outline_blank_24dp_svg
+ }
+
+ Layout.preferredWidth: 55
+ Layout.preferredHeight: 55
+ Layout.rightMargin: 0
+ }
+ }
+
+ // Connect to listen for download failure and pop a simple dialog to inform the user
+ Connections {
+ target: SpellCheckAdapter
+ function onDownloadFailed(locale) {
+ viewCoordinator.presentDialog(appWindow, "commoncomponents/SimpleMessageDialog.qml", {
+ "title": JamiStrings.error,
+ "infoText": JamiStrings.spellCheckDownloadFailed.arg(locale),
+ "buttonTitles": [JamiStrings.optionOk],
+ "buttonStyles": [SimpleMessageDialog.ButtonStyle.TintedBlue],
+ "buttonRoles": [DialogButtonBox.AcceptRole]
+ });
+ }
+ }
+
+ JamiListView {
+ id: spellCheckDictionaryListView
+
+ width: parent.width
+ Layout.fillHeight: true
+
+ model: SortFilterProxyModel {
+ id: dictionaryProxyModel
+ sourceModel: SpellCheckAdapter.getDictionaryListModel()
+
+ property string combinedFilterPattern
+
+ filters: AllOf {
+ AnyOf {
+ // Filter by dictionary name
+ RegExpFilter {
+ roleName: "Locale"
+ pattern: dictionaryProxyModel.combinedFilterPattern
+ caseSensitivity: Qt.CaseInsensitive
+ }
+ // Filter by native name
+ RegExpFilter {
+ roleName: "NativeName"
+ pattern: dictionaryProxyModel.combinedFilterPattern
+ caseSensitivity: Qt.CaseInsensitive
+ }
+ }
+ ValueFilter {
+ roleName: "Installed"
+ value: true
+ enabled: showInstalledOnlyCheckbox.checked
+ }
+ }
+
+ sorters: [
+ // Sort by locale alphabetically
+ RoleSorter {
+ roleName: "Locale"
+ sortOrder: Qt.AscendingOrder
+ }
+ ]
+ }
+
+ readonly property int itemMargins: 20
+ topMargin: itemMargins / 2
+ bottomMargin: itemMargins / 2
+
+ spacing: 8
+ clip: true
+
+ delegate: ItemDelegate {
+ id: dictionaryDelegate
+ width: spellCheckDictionaryListView.width
+ height: Math.max(JamiTheme.preferredFieldHeight, contentLayout.implicitHeight + 32)
+
+ background: Rectangle {
+ anchors.horizontalCenter: parent.horizontalCenter
+ width: parent.width - spellCheckDictionaryListView.itemMargins
+ height: parent.height
+ color: JamiTheme.backgroundColor
+ radius: JamiTheme.primaryRadius
+ border.color: "transparent"
+ border.width: 1
+ }
+
+ RowLayout {
+ id: contentLayout
+ anchors.fill: parent
+ anchors.margins: 16
+ spacing: 16
+
+ // Dictionary info
+ ColumnLayout {
+ Layout.fillWidth: true
+ Layout.alignment: Qt.AlignVCenter
+ Layout.leftMargin: 16
+ spacing: 2
+
+ Text {
+ id: dictionaryName
+ Layout.fillWidth: true
+ text: model.NativeName || ""
+ color: JamiTheme.textColor
+ font.pixelSize: JamiTheme.settingsDescriptionPixelSize
+ font.weight: Font.Medium
+ elide: Text.ElideRight
+ verticalAlignment: Text.AlignVCenter
+ }
+
+ Text {
+ id: dictionaryLocale
+ Layout.fillWidth: true
+ text: model.Locale || ""
+ color: JamiTheme.faddedLastInteractionFontColor
+ font.pixelSize: JamiTheme.settingsDescriptionPixelSize - 2
+ elide: Text.ElideRight
+ visible: text !== ""
+ verticalAlignment: Text.AlignVCenter
+ }
+ }
+
+ // Installation status and action
+ Item {
+ Layout.preferredWidth: 100
+ Layout.preferredHeight: 32
+ Layout.alignment: Qt.AlignVCenter
+ Layout.rightMargin: 16
+
+ // Install button for available dictionaries
+ MaterialButton {
+ id: installButton
+ anchors.centerIn: parent
+ width: 100
+ height: 32
+ Accessible.name: dictionaryName.text + " " + JamiStrings.install
+ Accessible.role: Accessible.Button
+
+ text: JamiStrings.install
+
+ font.pixelSize: JamiTheme.settingsDescriptionPixelSize - 1
+ font.weight: Font.Medium
+
+ focusPolicy: Qt.StrongFocus
+ KeyNavigation.tab: {
+ try {
+ if (model.index < dictionaryProxyModel.count - 1) {
+ var nextItem = spellCheckDictionaryListView.itemAtIndex(model.index + 1);
+ if (nextItem) {
+ var nextButton = nextItem.findChild("installButton") || nextItem.findChild("uninstallButton");
+ return nextButton || null;
+ }
+ }
+ } catch (e) {
+ console.debug("KeyNavigation error handled:", e);
+ }
+ return null;
+ }
+
+ onFocusChanged: {
+ if (focus) {
+ spellCheckDictionaryListView.positionViewAtIndex(model.index, ListView.Contain);
+ }
+ }
+
+ onClicked: {
+ if (model.Locale) {
+ SpellCheckAdapter.installDictionary(model.Locale);
+ }
+ }
+
+ visible: !model.Downloading && !model.Installed && model.Locale !== undefined && model.Locale !== ""
+ }
+
+ // Uninstall button for installed dictionaries (not system dictionaries)
+ MaterialButton {
+ id: uninstallButton
+ anchors.centerIn: parent
+ width: 100
+ height: 32
+
+ Accessible.name: dictionaryName.text + " " + JamiStrings.uninstall
+ Accessible.role: Accessible.Button
+
+ text: JamiStrings.uninstall
+ color: "#ff6666"
+ hoveredColor: "#ff9999"
+
+ font.pixelSize: JamiTheme.settingsDescriptionPixelSize - 1
+ font.weight: Font.Medium
+
+ focusPolicy: Qt.StrongFocus
+ KeyNavigation.tab: {
+ try {
+ if (model.index < dictionaryProxyModel.count - 1) {
+ var nextItem = spellCheckDictionaryListView.itemAtIndex(model.index + 1);
+ if (nextItem) {
+ var nextButton = nextItem.findChild("installButton") || nextItem.findChild("uninstallButton");
+ return nextButton || null;
+ }
+ }
+ } catch (e) {
+ console.debug("KeyNavigation error handled:", e);
+ }
+ return null;
+ }
+
+ onFocusChanged: {
+ if (focus) {
+ spellCheckDictionaryListView.positionViewAtIndex(model.index, ListView.Contain);
+ }
+ }
+
+ onClicked: {
+ if (model.Locale) {
+ SpellCheckAdapter.uninstallDictionary(model.Locale);
+ }
+ }
+
+ visible: !model.Downloading && model.Installed && !model.IsSystem && model.Locale !== undefined && model.Locale !== ""
+ }
+
+ // System dictionary indicator
+ Text {
+ anchors.centerIn: parent
+ text: JamiStrings.systemDictionary
+ color: JamiTheme.faddedLastInteractionFontColor
+ font.pixelSize: JamiTheme.settingsDescriptionPixelSize - 2
+ visible: model.IsSystem
+ }
+
+ // Downloading status indicator
+ BusyIndicator {
+ anchors.centerIn: parent
+ visible: model.Downloading
+ running: model.Downloading
+ width: 24
+ height: 24
+
+ // Use a custom animation for better UX
+ Behavior on running {
+ NumberAnimation {
+ duration: 300
+ easing.type: Easing.InOutQuad
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Empty state for when no dictionaries are found
+ Item {
+ anchors.fill: parent
+ visible: dictionaryProxyModel.count === 0
+
+ ColumnLayout {
+ anchors.centerIn: parent
+ spacing: 16
+ width: parent.width * 0.8
+
+ // Big books emoji
+ Text {
+ Layout.alignment: Qt.AlignHCenter
+ text: "📚"
+ font.pixelSize: 48
+ opacity: 0.3
+ }
+
+ Text {
+ Layout.alignment: Qt.AlignHCenter
+ Layout.fillWidth: true
+ text: dictionarySearchBar.textContent.length > 0 ? JamiStrings.noDictionariesFoundFor.arg(dictionarySearchBar.textContent) : JamiStrings.noDictionariesAvailable
+ color: JamiTheme.faddedLastInteractionFontColor
+ font.pixelSize: JamiTheme.settingsDescriptionPixelSize
+ horizontalAlignment: Text.AlignHCenter
+ wrapMode: Text.WordWrap
+ }
+ }
+ }
+ }
+}
diff --git a/src/app/commoncomponents/LineEditContextMenu.qml b/src/app/commoncomponents/LineEditContextMenu.qml
index 73074255..489402fc 100644
--- a/src/app/commoncomponents/LineEditContextMenu.qml
+++ b/src/app/commoncomponents/LineEditContextMenu.qml
@@ -17,6 +17,8 @@
import QtQuick
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 "contextmenu"
import "../mainview"
import "../mainview/components"
@@ -30,15 +32,16 @@ ContextMenuAutoLoader {
property var selectionEnd
property bool customizePaste: false
property bool selectOnly: false
- property bool checkSpell: false
+ property bool spellCheckEnabled: false
property var suggestionList
property var menuItemsLength
property var language
signal contextMenuRequirePaste
+
SpellLanguageContextMenu {
id: spellLanguageContextMenu
- active: checkSpell
+ active: spellCheckEnabled
}
property list menuItems: [
@@ -49,8 +52,7 @@ ContextMenuAutoLoader {
isActif: lineEditObj.selectedText.length && !selectOnly
itemName: JamiStrings.cut
hasIcon: false
- onClicked:
- lineEditObj.cut();
+ onClicked: lineEditObj.cut()
},
GeneralMenuItem {
id: copy
@@ -59,8 +61,7 @@ ContextMenuAutoLoader {
isActif: lineEditObj.selectedText.length
itemName: JamiStrings.copy
hasIcon: false
- onClicked:
- lineEditObj.copy();
+ onClicked: lineEditObj.copy()
},
GeneralMenuItem {
id: paste
@@ -76,30 +77,39 @@ ContextMenuAutoLoader {
}
},
GeneralMenuItem {
- id: language
- visible: checkSpell
- canTrigger: checkSpell
- itemName: JamiStrings.language
+ id: textLanguage
+ canTrigger: spellCheckEnabled && SpellCheckAdapter.installedDictionaryCount > 0
+ itemName: JamiStrings.textLanguage
hasIcon: false
onClicked: {
spellLanguageContextMenu.openMenu();
}
+ },
+ GeneralMenuItem {
+ id: manageLanguages
+ itemName: JamiStrings.manageDictionaries
+ canTrigger: spellCheckEnabled
+ hasIcon: false
+ onClicked: {
+ viewCoordinator.presentDialog(appWindow, "commoncomponents/ManageDictionariesDialog.qml");
+ }
}
]
ListView {
model: ListModel {
- id: dynamicModel
+ id: suggestionListModel
}
Instantiator {
- model: dynamicModel
+ model: suggestionListModel
delegate: GeneralMenuItem {
id: suggestion
canTrigger: true
isActif: true
itemName: model.name
+ bold: true
hasIcon: false
onClicked: {
replaceWord(model.name);
@@ -117,7 +127,7 @@ ContextMenuAutoLoader {
}
function removeItems() {
- dynamicModel.remove(0, suggestionList.length);
+ suggestionListModel.clear();
suggestionList.length = 0;
}
@@ -125,7 +135,7 @@ ContextMenuAutoLoader {
menuItemsLength = menuItems.length; // Keep initial number of items for easier removal
suggestionList = wordList;
for (var i = 0; i < suggestionList.length; ++i) {
- dynamicModel.append({
+ suggestionListModel.append({
"name": suggestionList[i]
});
}
@@ -154,7 +164,7 @@ ContextMenuAutoLoader {
lineEditObj.select(selectionStart, selectionEnd);
}
function onClosed() {
- if (!suggestionList || suggestionList.length == 0) {
+ if (!suggestionList || suggestionList.length === 0) {
return;
}
removeItems();
diff --git a/src/app/mainview/components/CachedFile.qml b/src/app/commoncomponents/ManageDictionariesDialog.qml
similarity index 71%
rename from src/app/mainview/components/CachedFile.qml
rename to src/app/commoncomponents/ManageDictionariesDialog.qml
index cdd2b862..1102af41 100644
--- a/src/app/mainview/components/CachedFile.qml
+++ b/src/app/commoncomponents/ManageDictionariesDialog.qml
@@ -16,19 +16,21 @@
*/
import QtQuick
import QtQuick.Controls
-import QtQuick.Layouts
+import net.jami.Models 1.1
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"
+import "../commoncomponents/contextmenu"
-Item {
- id: cachedFile
- property string dictionaryPath: SpellCheckDictionaryManager.getDictionariesPath()
+BaseModalDialog {
+ id: root
+ objectName: "manageDictionariesDialog"
- function updateDictionnary(languagePath) {
- var file = dictionaryPath + languagePath;
- MessagesAdapter.updateDictionnary(file);
+ title: JamiStrings.manageDictionaries
+
+ popupContent: DictionaryInstallView {
+ Accessible.name: JamiStrings.manageDictionaries
+ Accessible.role: Accessible.PopupMenu
+ width: 400
+ height: 500
}
}
diff --git a/src/app/commoncomponents/Scaffold.qml b/src/app/commoncomponents/Scaffold.qml
index 519a028d..f36d5fb8 100644
--- a/src/app/commoncomponents/Scaffold.qml
+++ b/src/app/commoncomponents/Scaffold.qml
@@ -23,7 +23,7 @@ import QtQuick.Layouts
Rectangle {
property alias name: label.text
property bool stretchParent: false
- property string tag: parent.toString()
+ property string tag: parent.toString() + " (w:" + width + ", h: " + height + ")"
signal moveX(real dx)
signal moveY(real dy)
property real ox: 0
diff --git a/src/app/commoncomponents/SettingParaCombobox.qml b/src/app/commoncomponents/SettingParaCombobox.qml
index 5fbc3084..726b88f7 100644
--- a/src/app/commoncomponents/SettingParaCombobox.qml
+++ b/src/app/commoncomponents/SettingParaCombobox.qml
@@ -50,11 +50,14 @@ ComboBox {
contentItem: Text {
text: {
- if (index < 0)
+ if (index < 0 || !model)
return '';
- var currentItem = root.delegateModel.items.get(index);
- const value = currentItem.model[root.textRole];
- return value === undefined ? '' : value.toString();
+
+ if (root.textRole && model[root.textRole] !== undefined) {
+ return model[root.textRole].toString();
+ }
+
+ return model.display !== undefined ? model.display.toString() : '';
}
color: hovered ? JamiTheme.comboboxTextColorHovered : JamiTheme.textColor
@@ -80,7 +83,7 @@ ComboBox {
source: popup.visible ? JamiResources.expand_less_24dp_svg : JamiResources.expand_more_24dp_svg
- color: JamiTheme.comboboxIconColor
+ color: root.enabled ? JamiTheme.comboboxIconColor : "grey"
}
contentItem: Text {
@@ -92,7 +95,7 @@ ComboBox {
anchors.rightMargin: root.indicator.width * 2
font.pixelSize: JamiTheme.settingsDescriptionPixelSize
text: root.displayText
- color: JamiTheme.comboboxTextColor
+ color: root.enabled ? JamiTheme.comboboxTextColor : "grey"
font.weight: Font.Medium
verticalAlignment: Text.AlignVCenter
horizontalAlignment: Text.AlignLeft
@@ -104,7 +107,11 @@ ComboBox {
color: JamiTheme.transparentColor
implicitWidth: 120
implicitHeight: contentItem.implicitHeight + JamiTheme.buttontextHeightMargin
- border.color: popup.visible ? JamiTheme.comboboxBorderColorActive : JamiTheme.comboboxBorderColor
+ border.color: root.enabled ?
+ (popup.visible ?
+ JamiTheme.comboboxBorderColorActive :
+ JamiTheme.comboboxBorderColor) :
+ "grey"
border.width: root.visualFocus ? 2 : 1
radius: 5
}
diff --git a/src/app/commoncomponents/SpellLanguageContextMenu.qml b/src/app/commoncomponents/SpellLanguageContextMenu.qml
index 7d2aea21..e733c51e 100644
--- a/src/app/commoncomponents/SpellLanguageContextMenu.qml
+++ b/src/app/commoncomponents/SpellLanguageContextMenu.qml
@@ -22,15 +22,12 @@ import net.jami.Enums 1.1
import "contextmenu"
import "../mainview"
import "../mainview/components"
+import SortFilterProxyModel 0.2
ContextMenuAutoLoader {
id: root
- signal languageChanged()
-
- CachedFile {
- id: cachedFile
- }
+ signal languageChanged
function openMenuAt(mouseEvent) {
x = mouseEvent.x;
@@ -46,9 +43,11 @@ ContextMenuAutoLoader {
function generateMenuItems() {
var menuItems = [];
// Create new menu items
- var dictionaries = SpellCheckDictionaryManager.installedDictionaries();
+ var dictionaries = SpellCheckAdapter.getInstalledDictionaries();
var keys = Object.keys(dictionaries);
for (var i = 0; i < keys.length; ++i) {
+ const locale = keys[i];
+ const nativeName = dictionaries[keys[i]];
var menuItem = Qt.createComponent("qrc:/commoncomponents/contextmenu/GeneralMenuItem.qml", Component.PreferSynchronous);
if (menuItem.status !== Component.Ready) {
console.error("Error loading component:", menuItem.errorString());
@@ -58,17 +57,19 @@ ContextMenuAutoLoader {
"parent": root,
"canTrigger": true,
"isActif": true,
- "itemName": dictionaries[keys[i]],
+ "itemName": nativeName,
"hasIcon": false,
- "content": keys[i],
+ "content": locale,
+ "bold": UtilsAdapter.getAppValue(Settings.SpellLang) === locale
});
if (menuItemObject === null) {
console.error("Error creating menu item:", menuItem.errorString());
continue;
}
menuItemObject.clicked.connect(function () {
- UtilsAdapter.setAppValue(Settings.Key.SpellLang, menuItemObject.content);
- });
+ const locale = menuItemObject.content;
+ SpellCheckAdapter.setDictionary(locale);
+ });
// Log the object pointer
menuItems.push(menuItemObject);
}
diff --git a/src/app/commoncomponents/contextmenu/GeneralMenuItem.qml b/src/app/commoncomponents/contextmenu/GeneralMenuItem.qml
index 02a9fb45..45828264 100644
--- a/src/app/commoncomponents/contextmenu/GeneralMenuItem.qml
+++ b/src/app/commoncomponents/contextmenu/GeneralMenuItem.qml
@@ -28,6 +28,7 @@ MenuItem {
id: menuItem
property string itemName: ""
+ property bool bold: false
property string content: ""
property alias iconSource: contextMenuItemImage.source
property string iconColor: ""
@@ -99,6 +100,7 @@ MenuItem {
anchors.left: parent.left
height: parent.height
text: itemName
+ font.bold: bold
color: dangerous ? JamiTheme.redColor : isActif ? JamiTheme.textColor : JamiTheme.chatViewFooterImgColor
font.pointSize: JamiTheme.textFontSize
horizontalAlignment: Text.AlignLeft
diff --git a/src/app/mainapplication.cpp b/src/app/mainapplication.cpp
index f2ba599b..419868e1 100644
--- a/src/app/mainapplication.cpp
+++ b/src/app/mainapplication.cpp
@@ -20,7 +20,6 @@
#include "global.h"
#include "qmlregister.h"
#include "appsettingsmanager.h"
-#include "spellcheckdictionarymanager.h"
#include "connectivitymonitor.h"
#include "systemtray.h"
#include "previewengine.h"
@@ -160,6 +159,7 @@ MainApplication::MainApplication(int& argc, char** argv)
"qml.debug=false\n"
"default.debug=false\n"
"client.debug=false\n"
+ "spellcheck.debug=false\n"
"\n");
// These can be set in the environment as well.
// e.g. QT_LOGGING_RULES="*.debug=false;qml.debug=true"
@@ -191,7 +191,6 @@ 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
@@ -349,8 +348,8 @@ MainApplication::parseArguments()
parser_.addOption(muteDaemonOption);
#ifdef QT_DEBUG
- // In debug mode, add an option to test a specific QML component via its name.
- // e.g. ./jami --test AccountComboBox
+ // In debug mode, add an option to test a specific QML component via its name.
+ // e.g. ./jami --test AccountComboBox
parser_.addOption(QCommandLineOption("test", "Test a QML component via its name.", "uri"));
// We may need to force the test window dimensions in the case that the component to test
// does not specify its own dimensions and is dependent on parent/sibling dimensions.
@@ -425,7 +424,6 @@ MainApplication::initQmlLayer()
lrcInstance_.get(),
systemTray_,
settingsManager_,
- spellCheckDictionaryManager_,
connectivityMonitor_,
previewEngine_,
&screenInfo_,
diff --git a/src/app/mainapplication.h b/src/app/mainapplication.h
index a9fc3999..64a0f346 100644
--- a/src/app/mainapplication.h
+++ b/src/app/mainapplication.h
@@ -31,7 +31,6 @@
class ConnectivityMonitor;
class SystemTray;
class AppSettingsManager;
-class SpellCheckDictionaryManager;
class CrashReporter;
class PreviewEngine;
@@ -119,7 +118,6 @@ private:
ConnectivityMonitor* connectivityMonitor_;
SystemTray* systemTray_;
AppSettingsManager* settingsManager_;
- SpellCheckDictionaryManager* spellCheckDictionaryManager_;
PreviewEngine* previewEngine_;
CrashReporter* crashReporter_;
diff --git a/src/app/mainview/components/MessageBarTextArea.qml b/src/app/mainview/components/MessageBarTextArea.qml
index 04401cf7..001ceea7 100644
--- a/src/app/mainview/components/MessageBarTextArea.qml
+++ b/src/app/mainview/components/MessageBarTextArea.qml
@@ -37,7 +37,6 @@ 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
@@ -73,7 +72,7 @@ JamiFlickable {
lineEditObj: textArea
customizePaste: true
- checkSpell: (Qt.platform.os.toString() === "linux") ? true : false
+ spellCheckEnabled: root.spellCheckEnabled
onContextMenuRequirePaste: {
// Intercept paste event to use C++ QMimeData
@@ -114,73 +113,43 @@ JamiFlickable {
}
}
+ readonly property bool spellCheckEnabled:
+ AppSettingsManager.getValue(Settings.EnableSpellCheck) &&
+ AppSettingsManager.getValue(Settings.SpellLang) !== ""
+
+ // Spell check is active under the following conditions:
+ // 1. Spell check is enabled in settings
+ // 2. The selected spell language is not ""
+ // 3. We are not in preview mode
+ readonly property bool spellCheckActive: spellCheckEnabled && !showPreview
+
+ onSpellCheckActiveChanged: textArea.updateSpellCorrection()
+
TextArea.flickable: TextArea {
id: textArea
- CachedFile {
- id: cachedFile
+ Connections {
+ target: SpellCheckAdapter
+
+ function onDictionaryChanged() {
+ textArea.updateSpellCorrection();
+ }
}
- function updateCorrection(language) {
- cachedFile.updateDictionnary(language);
- textArea.updateUnderlineText();
- }
-
- // Listen to settings changes and apply it to this widget
+ // Listen to settings changes to apply it to the text area
Connections {
target: UtilsAdapter
function onChangeLanguage() {
- textArea.updateUnderlineText();
+ textArea.updateSpellCorrection();
}
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();
- }
+ textArea.updateSpellCorrection();
}
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();
+ textArea.updateSpellCorrection();
}
}
@@ -223,13 +192,15 @@ JamiFlickable {
onReleased: function (event) {
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);
+ if (spellCheckActive) {
+ var position = textArea.positionAt(event.x, event.y);
+ textArea.moveCursorSelection(position, TextInput.SelectWords);
+ textArea.selectWord();
+ if (!SpellCheckAdapter.spell(textArea.selectedText)) {
+ var wordList = SpellCheckAdapter.spellSuggestionsRequest(textArea.selectedText);
+ if (wordList.length !== 0) {
+ textAreaContextMenu.addMenuItem(wordList);
+ }
}
}
textAreaContextMenu.openMenuAt(event);
@@ -237,7 +208,7 @@ JamiFlickable {
}
onTextChanged: {
- updateUnderlineText();
+ updateSpellCorrection();
if (text !== debounceText && !showPreview) {
debounceText = text;
MessagesAdapter.userIsComposing(text ? true : false);
@@ -250,7 +221,7 @@ JamiFlickable {
// Shift + Enter -> Next Line
Keys.onPressed: function (keyEvent) {
// Update underline on each input to take into account deleted text and sent ones
- updateUnderlineText();
+ updateSpellCorrection();
if (keyEvent.matches(StandardKey.Paste)) {
MessagesAdapter.onPaste();
keyEvent.accepted = true;
@@ -280,17 +251,17 @@ JamiFlickable {
}
}
- function updateUnderlineText() {
+ function updateSpellCorrection() {
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);
+ var words = SpellCheckAdapter.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)) {
+ if (wordInfo && wordInfo.word && !SpellCheckAdapter.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;
diff --git a/src/app/messagesadapter.cpp b/src/app/messagesadapter.cpp
index 56dddba0..ca90f74b 100644
--- a/src/app/messagesadapter.cpp
+++ b/src/app/messagesadapter.cpp
@@ -21,7 +21,6 @@
#include "qtutils.h"
#include "messageparser.h"
#include "previewengine.h"
-#include "spellchecker.h"
#include
#include
@@ -40,25 +39,17 @@
#include
#include
-#define SUGGESTIONS_MAX_SIZE 3 // limit the number of spelling suggestions
-
MessagesAdapter::MessagesAdapter(AppSettingsManager* settingsManager,
PreviewEngine* previewEngine,
- SpellCheckDictionaryManager* spellCheckDictionaryManager,
LRCInstance* instance,
QObject* parent)
: QmlAdapterBase(instance, parent)
, settingsManager_(settingsManager)
- , spellCheckDictionaryManager_(spellCheckDictionaryManager)
, messageParser_(new MessageParser(previewEngine, this))
, filteredMsgListModel_(new FilteredMsgListModel(this))
, mediaInteractions_(std::make_unique(nullptr))
, timestampTimer_(new QTimer(this))
{
- #if defined(Q_OS_LINUX)
- // Initialize with make_shared
- spellChecker_ = std::make_shared(spellCheckDictionaryManager_->getDictionaryPath());
- #endif
setObjectName(typeid(*this).name());
set_messageListModel(QVariant::fromValue(filteredMsgListModel_));
@@ -736,53 +727,3 @@ MessagesAdapter::getMsgListSourceModel() const
// However it may be a nullptr if not yet set.
return static_cast(filteredMsgListModel_->sourceModel());
}
-
-bool
-MessagesAdapter::spell(const QString& word)
-{
- return spellChecker_->spell(word);
-}
-
-QVariantList
-MessagesAdapter::spellSuggestionsRequest(const QString& word)
-{
- QStringList suggestionsList;
- QVariantList variantList;
- if (spellChecker_ == nullptr || spellChecker_->spell(word)) {
- return variantList;
- }
-
- suggestionsList = spellChecker_->suggest(word);
- for (const auto& suggestion : suggestionsList) {
- if (variantList.size() >= SUGGESTIONS_MAX_SIZE) {
- break;
- }
- variantList.append(QVariant(suggestion));
- }
-
- return variantList;
-}
-
-QVariantList
-MessagesAdapter::findWords(const QString& text)
-{
- QVariantList result;
- if (!spellChecker_)
- return result;
-
- auto words = spellChecker_->findWords(text);
- for (const auto& word : words) {
- QVariantMap wordInfo;
- wordInfo["word"] = word.word;
- wordInfo["position"] = word.position;
- wordInfo["length"] = word.length;
- result.append(wordInfo);
- }
- return result;
-}
-
-void
-MessagesAdapter::updateDictionnary(const QString& path)
-{
- return spellChecker_->replaceDictionary(path);
-}
diff --git a/src/app/messagesadapter.h b/src/app/messagesadapter.h
index e7b94ae5..a18a6fb6 100644
--- a/src/app/messagesadapter.h
+++ b/src/app/messagesadapter.h
@@ -23,8 +23,6 @@
#include "previewengine.h"
#include "messageparser.h"
#include "appsettingsmanager.h"
-#include "spellchecker.h"
-#include "spellcheckdictionarymanager.h"
#include
#include
@@ -102,14 +100,11 @@ public:
{
return new MessagesAdapter(qApp->property("AppSettingsManager").value(),
qApp->property("PreviewEngine").value(),
- qApp->property("SpellCheckDictionaryManager")
- .value(),
qApp->property("LRCInstance").value());
}
explicit MessagesAdapter(AppSettingsManager* settingsManager,
PreviewEngine* previewEngine,
- SpellCheckDictionaryManager* spellCheckDictionaryManager,
LRCInstance* instance,
QObject* parent = nullptr);
~MessagesAdapter() = default;
@@ -168,10 +163,6 @@ 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);
@@ -206,12 +197,10 @@ private:
QList conversationTypersUrlToName(const QSet& typersSet);
AppSettingsManager* settingsManager_;
- SpellCheckDictionaryManager* spellCheckDictionaryManager_;
MessageParser* messageParser_;
FilteredMsgListModel* filteredMsgListModel_;
std::unique_ptr mediaInteractions_;
QTimer* timestampTimer_;
- std::shared_ptr spellChecker_;
static constexpr const int loadChunkSize_ {20};
static constexpr const int timestampUpdateIntervalMs_ {1000};
};
diff --git a/src/app/net/jami/Constants/JamiStrings.qml b/src/app/net/jami/Constants/JamiStrings.qml
index e3657c63..200472d5 100644
--- a/src/app/net/jami/Constants/JamiStrings.qml
+++ b/src/app/net/jami/Constants/JamiStrings.qml
@@ -273,9 +273,9 @@ Item {
property string hideSpectators: qsTr("Hide spectators")
// LineEditContextMenu
- property string copy: qsTr("Copy")
property string share: qsTr("Share")
property string cut: qsTr("Cut")
+ property string copy: qsTr("Copy")
property string paste: qsTr("Paste")
property string language: qsTr("Language")
@@ -913,9 +913,15 @@ Item {
// Spell checker
property string checkSpelling: qsTr("Check spelling while typing")
+ property string systemDictionary: qsTr("System")
property string textLanguage: qsTr("Text language")
- property string textLanguageDescription: qsTr("To install new dictionaries, use the system package manager.")
- property string spellChecker: qsTr("Spell checker")
- property string refresh: qsTr("Refresh")
- property string refreshInstalledDictionaries: qsTr("Refresh installed dictionaries")
+ property string spellchecking: qsTr("Spell checker")
+ property string searchTextLanguages: qsTr("Search text languages")
+ property string searchAvailableTextLanguages: qsTr("Search for available text languages")
+ property string noDictionariesFoundFor: qsTr("No dictionaries found for '%1'")
+ property string noDictionariesAvailable: qsTr("No dictionaries available")
+ property string manageDictionaries: qsTr("Manage Dictionaries")
+ property string spellCheckDownloadFailed: qsTr("Download failed for dictionary '%1'")
+ property string showInstalledDictionaries: qsTr("Show installed")
+ property string showInstalledDictionariesDescription: qsTr("Only show dictionaries that are currently installed")
}
diff --git a/src/app/qmlregister.cpp b/src/app/qmlregister.cpp
index 615fef8e..320f8b4c 100644
--- a/src/app/qmlregister.cpp
+++ b/src/app/qmlregister.cpp
@@ -36,7 +36,8 @@
#include "currentaccounttomigrate.h"
#include "pttlistener.h"
#include "calloverlaymodel.h"
-#include "spellcheckdictionarymanager.h"
+#include "spellcheckdictionarylistmodel.h"
+#include "spellcheckadapter.h"
#include "accountlistmodel.h"
#include "mediacodeclistmodel.h"
#include "audiodevicemodel.h"
@@ -65,7 +66,6 @@
#include "wizardviewstepmodel.h"
#include "linkdevicemodel.h"
#include "qrcodescannermodel.h"
-#include "spellchecker.h"
#include "api/peerdiscoverymodel.h"
#include "api/codecmodel.h"
@@ -119,7 +119,6 @@ registerTypes(QQmlEngine* engine,
LRCInstance* lrcInstance,
SystemTray* systemTray,
AppSettingsManager* settingsManager,
- SpellCheckDictionaryManager* spellCheckDictionaryManager,
ConnectivityMonitor* connectivityMonitor,
PreviewEngine* previewEngine,
ScreenInfo* screenInfo,
@@ -196,6 +195,10 @@ registerTypes(QQmlEngine* engine,
QQmlEngine::setObjectOwnership(linkdevicemodel, QQmlEngine::CppOwnership);
REG_QML_SINGLETON(REG_MODEL, "LinkDeviceModel", CREATE(linkdevicemodel));
+ // SpellCheckDictionaryListModel (available through SpellCheckAdapter)
+ auto spellCheckDictionaryListModel = new SpellCheckDictionaryListModel(settingsManager, connectivityMonitor, app);
+ qApp->setProperty("SpellCheckDictionaryListModel", QVariant::fromValue(spellCheckDictionaryListModel));
+
// Register app-level objects that are used by QML created objects.
// These MUST be set prior to loading the initial QML file, in order to
// be available to the QML adapter class factory creation methods.
@@ -204,7 +207,6 @@ 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);
@@ -225,6 +227,7 @@ registerTypes(QQmlEngine* engine,
QML_REGISTERSINGLETON_TYPE(NS_ADAPTERS, VideoDevices);
QML_REGISTERSINGLETON_TYPE(NS_ADAPTERS, CurrentAccountToMigrate);
QML_REGISTERSINGLETON_TYPE(NS_HELPERS, FileDownloader);
+ QML_REGISTERSINGLETON_TYPE(NS_ADAPTERS, SpellCheckAdapter);
// TODO: remove these
QML_REGISTERSINGLETONTYPE_CUSTOM(NS_MODELS, AVModel, &lrcInstance->avModel())
@@ -241,7 +244,6 @@ 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,12 +252,12 @@ registerTypes(QQmlEngine* engine,
QML_REGISTERNAMESPACE(NS_MODELS, FilesToSend::staticMetaObject, "FilesToSend");
QML_REGISTERNAMESPACE(NS_MODELS, MessageList::staticMetaObject, "MessageList");
QML_REGISTERNAMESPACE(NS_MODELS, PluginStatus::staticMetaObject, "PluginStatus");
+ // QML_REGISTERNAMESPACE(NS_MODELS, SpellCheckDictionaryList::staticMetaObject, "SpellCheckDictionaryList");
QML_REGISTERSINGLETONTYPE_POBJECT(NS_CONSTANTS, app, "MainApplication")
QML_REGISTERSINGLETONTYPE_POBJECT(NS_CONSTANTS, screenInfo, "CurrentScreenInfo")
QML_REGISTERSINGLETONTYPE_POBJECT(NS_CONSTANTS, lrcInstance, "LRCInstance")
QML_REGISTERSINGLETONTYPE_POBJECT(NS_CONSTANTS, settingsManager, "AppSettingsManager")
- QML_REGISTERSINGLETONTYPE_POBJECT(NS_CONSTANTS, spellCheckDictionaryManager, "SpellCheckDictionaryManager")
// Lrc namespaces, models, and singletons
QML_REGISTERNAMESPACE(NS_MODELS, lrc::api::staticMetaObject, "Lrc");
diff --git a/src/app/qmlregister.h b/src/app/qmlregister.h
index 090047ac..fe4f2a59 100644
--- a/src/app/qmlregister.h
+++ b/src/app/qmlregister.h
@@ -32,7 +32,6 @@
class SystemTray;
class LRCInstance;
class AppSettingsManager;
-class SpellCheckDictionaryManager;
class PreviewEngine;
class ScreenInfo;
class MainApplication;
@@ -62,7 +61,6 @@ void registerTypes(QQmlEngine* engine,
LRCInstance* lrcInstance,
SystemTray* systemTray,
AppSettingsManager* appSettingsManager,
- SpellCheckDictionaryManager* spellCheckDictionaryManager,
ConnectivityMonitor* connectivityMonitor,
PreviewEngine* previewEngine,
ScreenInfo* screenInfo,
diff --git a/src/app/settingsview/components/ChatSettingsPage.qml b/src/app/settingsview/components/ChatSettingsPage.qml
index 1c1ef8a9..dc72cd4c 100644
--- a/src/app/settingsview/components/ChatSettingsPage.qml
+++ b/src/app/settingsview/components/ChatSettingsPage.qml
@@ -17,11 +17,13 @@
import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
+import Qt5Compat.GraphicalEffects
import net.jami.Models 1.1
import net.jami.Adapters 1.1
import net.jami.Enums 1.1
import net.jami.Constants 1.1
import net.jami.Helpers 1.1
+import SortFilterProxyModel 0.2
import "../../commoncomponents"
import "../../mainview/components"
import "../../mainview/js/contactpickercreation.js" as ContactPickerCreation
@@ -41,6 +43,63 @@ SettingsPageBase {
anchors.left: parent.left
anchors.leftMargin: JamiTheme.preferredSettingsMarginSize
+ ColumnLayout {
+
+ width: parent.width
+ spacing: JamiTheme.settingsCategorySpacing
+
+ 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
+ tooltipText: JamiStrings.checkSpelling
+ onSwitchToggled: {
+ UtilsAdapter.setAppValue(Settings.Key.EnableSpellCheck, checked);
+ }
+ }
+
+ SpellCheckLanguageComboBox {
+ id: spellCheckLangComboBoxSetting
+ Layout.fillWidth: true
+ widthOfComboBox: itemWidth
+ }
+
+ // A button to open the dictionary install view as a popup
+ MaterialButton {
+ id: dictionaryInstallButton
+
+ secondary: true
+
+ preferredWidth: itemWidth
+ height: spellCheckLangComboBoxSetting.comboBox.height
+ Layout.alignment: Qt.AlignRight
+
+ text: JamiStrings.manageDictionaries
+ onClicked: {
+ viewCoordinator.presentDialog(appWindow, "commoncomponents/ManageDictionariesDialog.qml");
+ }
+ }
+ }
+
ColumnLayout {
id: generalSettings
diff --git a/src/app/settingsview/components/SettingsComboBox.qml b/src/app/settingsview/components/SettingsComboBox.qml
index bda0cb8c..66355a31 100644
--- a/src/app/settingsview/components/SettingsComboBox.qml
+++ b/src/app/settingsview/components/SettingsComboBox.qml
@@ -32,6 +32,7 @@ RowLayout {
property alias fontPointSize: comboBoxOfLayout.font.pointSize
property alias modelIndex: comboBoxOfLayout.currentIndex
property alias modelSize: comboBoxOfLayout.count
+ property alias comboBox: comboBoxOfLayout
property int widthOfComboBox: 50
@@ -53,6 +54,7 @@ RowLayout {
SettingParaCombobox {
id: comboBoxOfLayout
+ enabled: root.enabled
Layout.preferredWidth: widthOfComboBox
font.pointSize: JamiTheme.buttonFontSize
diff --git a/src/app/settingsview/components/SpellCheckLanguageComboBox.qml b/src/app/settingsview/components/SpellCheckLanguageComboBox.qml
new file mode 100644
index 00000000..3debbd3d
--- /dev/null
+++ b/src/app/settingsview/components/SpellCheckLanguageComboBox.qml
@@ -0,0 +1,96 @@
+/*
+ * Copyright (C) 2025 Savoir-faire Linux Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+import QtQuick
+import QtQuick.Layouts
+import QtQuick.Controls
+import Qt5Compat.GraphicalEffects
+import net.jami.Models 1.1
+import net.jami.Adapters 1.1
+import net.jami.Enums 1.1
+import net.jami.Constants 1.1
+import net.jami.Helpers 1.1
+import SortFilterProxyModel 0.2
+import "../../commoncomponents"
+import "../../mainview/components"
+
+SettingsComboBox {
+ id: root
+ height: JamiTheme.preferredFieldHeight
+ labelText: JamiStrings.textLanguage
+ tipText: JamiStrings.textLanguage
+ comboModel: SortFilterProxyModel {
+ id: installedDictionariesModel
+ sourceModel: SpellCheckAdapter.getDictionaryListModel()
+
+ // Filter to show only installed dictionaries
+ filters: ValueFilter {
+ roleName: "Installed"
+ value: true
+ }
+
+ // Sort alphabetically by native name
+ sorters: RoleSorter {
+ roleName: "NativeName"
+ sortOrder: Qt.AscendingOrder
+ }
+ Component.onCompleted: {
+ // Ensure the model is updated with the latest dictionaries
+ root.enabled = Qt.binding(function () {
+ return installedDictionariesModel.count > 0;
+ });
+ }
+ }
+ role: "NativeName"
+
+ // Show placeholder when disabled
+ placeholderText: JamiStrings.none
+
+ function getCurrentLocaleIndex() {
+ var currentLang = UtilsAdapter.getAppValue(Settings.Key.SpellLang);
+ for (var i = 0; i < comboModel.count; i++) {
+ var item = comboModel.get(i);
+ if (item.Locale === currentLang)
+ return i;
+ }
+ return -1;
+ }
+
+ // Set initial selection based on current spell language setting
+ Component.onCompleted: modelIndex = getCurrentLocaleIndex()
+
+ property string locale
+ function setForIndex(index) {
+ var selectedDict = comboModel.get(index);
+ if (selectedDict && selectedDict.Locale && selectedDict.Installed) {
+ locale = selectedDict.Locale;
+ }
+ }
+ onLocaleChanged: SpellCheckAdapter.setDictionary(locale)
+
+ // When the count changes, we might need to update the model index
+ readonly property int count: installedDictionariesModel.count
+ onCountChanged: {
+ modelIndex = getCurrentLocaleIndex();
+ // If the new index is -1 and we still have dictionaries, use the first one
+ if (modelIndex === -1 && installedDictionariesModel.count > 0) {
+ modelIndex = 0;
+ }
+ }
+
+ // If the model index changes programmatically, we need to update the dictionary path
+ onModelIndexChanged: setForIndex(modelIndex)
+}
diff --git a/src/app/settingsview/components/SystemSettingsPage.qml b/src/app/settingsview/components/SystemSettingsPage.qml
index b1969ff9..1c3e9d4d 100644
--- a/src/app/settingsview/components/SystemSettingsPage.qml
+++ b/src/app/settingsview/components/SystemSettingsPage.qml
@@ -185,165 +185,6 @@ SettingsPageBase {
}
}
}
- ColumnLayout {
-
- width: parent.width
- spacing: JamiTheme.settingsCategorySpacing
- visible: (Qt.platform.os.toString() !== "linux") ? false : true
-
- Text {
- id: spellCheckerTitle
-
- Layout.alignment: Qt.AlignLeft
- Layout.preferredWidth: parent.width
-
- text: JamiStrings.spellChecker
- 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
-
- function onChangeLanguage() {
- var langIdx = langComboBoxSetting.modelIndex;
- langModel.clear();
- var supported = UtilsAdapter.supportedLang();
- var keys = Object.keys(supported);
- for (var i = 0; i < keys.length; ++i) {
- langModel.append({
- "textDisplay": supported[keys[i]],
- "id": keys[i]
- });
- }
- 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;
- }
- }
- }
ColumnLayout {
diff --git a/src/app/spellcheckadapter.cpp b/src/app/spellcheckadapter.cpp
new file mode 100644
index 00000000..f1f875c4
--- /dev/null
+++ b/src/app/spellcheckadapter.cpp
@@ -0,0 +1,139 @@
+/*
+ * Copyright (C) 2025 Savoir-faire Linux Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#include "spellcheckadapter.h"
+
+#include "spellcheckdictionarylistmodel.h"
+
+#include
+
+#define SUGGESTIONS_MAX_SIZE 10 // limit the number of spelling suggestions
+
+SpellCheckAdapter::SpellCheckAdapter(SpellCheckDictionaryListModel* dictionaryListModel,
+ AppSettingsManager* settingsManager,
+ QObject* parent)
+ : QObject(parent)
+ , dictionaryListModel_(dictionaryListModel)
+ , settingsManager_(settingsManager)
+{
+ // Connect to update the selected dictionary if no dictionary is set on start
+ connect(dictionaryListModel_,
+ &SpellCheckDictionaryListModel::newDictionaryAvailable,
+ this,
+ &SpellCheckAdapter::setDictionary);
+
+ // Load the current dictionary if available
+ auto currentLocale = settingsManager_->getValue(Settings::Key::SpellLang).toString();
+ if (!currentLocale.isEmpty()) {
+ setDictionary(currentLocale);
+ }
+
+ // Listen for data changes to the dictionaryListModel_ to determine
+ // our installedDictionaryCount
+ connect(dictionaryListModel_,
+ &QAbstractItemModel::dataChanged,
+ this,
+ [this](const QModelIndex&, const QModelIndex&, const QList& roles) {
+ if (roles.contains(SpellCheckDictionaryList::Installed)) {
+ set_installedDictionaryCount(
+ dictionaryListModel_->getInstalledDictionaries().size());
+ }
+ });
+
+ // Initialize the installedDictionaryCount
+ set_installedDictionaryCount(dictionaryListModel_->getInstalledDictionaries().size());
+
+ // Listen for download failure
+ connect(dictionaryListModel_,
+ &SpellCheckDictionaryListModel::downloadFailed,
+ this,
+ &SpellCheckAdapter::downloadFailed);
+}
+
+void
+SpellCheckAdapter::installDictionary(const QString& locale)
+{
+ dictionaryListModel_->installDictionary(locale);
+}
+
+void
+SpellCheckAdapter::uninstallDictionary(const QString& locale)
+{
+ dictionaryListModel_->uninstallDictionary(locale);
+}
+
+QVariant
+SpellCheckAdapter::getDictionaryListModel() const
+{
+ return QVariant::fromValue(dictionaryListModel_);
+}
+
+QVariantMap
+SpellCheckAdapter::getInstalledDictionaries() const
+{
+ return dictionaryListModel_->getInstalledDictionaries();
+}
+
+bool
+SpellCheckAdapter::spell(const QString& word)
+{
+ return spellChecker_.spell(word);
+}
+
+QVariantList
+SpellCheckAdapter::spellSuggestionsRequest(const QString& word)
+{
+ QStringList suggestionsList;
+ QVariantList variantList;
+ if (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
+SpellCheckAdapter::findWords(const QString& text)
+{
+ QVariantList 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
+SpellCheckAdapter::setDictionary(const QString& locale)
+{
+ auto localPath = dictionaryListModel_->pathForLocale(locale);
+ if (spellChecker_.replaceDictionary(localPath)) {
+ settingsManager_->setValue(Settings::Key::SpellLang, locale);
+ Q_EMIT dictionaryChanged();
+ }
+}
diff --git a/src/app/spellcheckadapter.h b/src/app/spellcheckadapter.h
new file mode 100644
index 00000000..cfc21aaa
--- /dev/null
+++ b/src/app/spellcheckadapter.h
@@ -0,0 +1,72 @@
+/*
+ * Copyright (C) 2025 Savoir-faire Linux Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#pragma once
+
+#include "spellchecker.h"
+#include "qtutils.h"
+
+#include
+#include // QML registration
+#include // QML registration
+
+class SpellCheckDictionaryListModel;
+class AppSettingsManager;
+
+class SpellCheckAdapter final : public QObject
+{
+ Q_OBJECT
+ QML_SINGLETON
+
+ QML_RO_PROPERTY(int, installedDictionaryCount)
+
+public:
+ static SpellCheckAdapter* create(QQmlEngine* engine, QJSEngine*)
+ {
+ return new SpellCheckAdapter(
+ qApp->property("SpellCheckDictionaryListModel").value(),
+ qApp->property("AppSettingsManager").value(),
+ engine);
+ }
+
+ explicit SpellCheckAdapter(SpellCheckDictionaryListModel* dictionaryListModel,
+ AppSettingsManager* settingsManager,
+ QObject* parent = nullptr);
+ ~SpellCheckAdapter() = default;
+
+ Q_INVOKABLE QVariant getDictionaryListModel() const;
+ Q_INVOKABLE QVariantMap getInstalledDictionaries() const;
+
+ Q_INVOKABLE void installDictionary(const QString& locale);
+ Q_INVOKABLE void uninstallDictionary(const QString& locale);
+
+ Q_INVOKABLE QVariantList spellSuggestionsRequest(const QString& word);
+ Q_INVOKABLE bool spell(const QString& word);
+ Q_INVOKABLE QVariantList findWords(const QString& text);
+
+public Q_SLOTS:
+ Q_INVOKABLE void setDictionary(const QString& locale);
+
+Q_SIGNALS:
+ void dictionaryChanged();
+ void downloadFailed(const QString& locale);
+
+private:
+ SpellChecker spellChecker_;
+ SpellCheckDictionaryListModel* dictionaryListModel_ {nullptr};
+ AppSettingsManager* settingsManager_ {nullptr};
+};
diff --git a/src/app/spellcheckdictionarylistmodel.cpp b/src/app/spellcheckdictionarylistmodel.cpp
new file mode 100644
index 00000000..e158ac41
--- /dev/null
+++ b/src/app/spellcheckdictionarylistmodel.cpp
@@ -0,0 +1,460 @@
+/*
+ * Copyright (C) 2025 Savoir-faire Linux Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#include "spellcheckdictionarylistmodel.h"
+
+#include "global.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+SpellCheckDictionaryListModel::SpellCheckDictionaryListModel(AppSettingsManager* settingsManager,
+ ConnectivityMonitor* cm,
+ QObject* parent)
+ : QAbstractListModel(parent)
+ , spellCheckFileDownloader_(new FileDownloader(cm, this))
+ , settingsManager_(settingsManager)
+{
+ // Connect FileDownloader signals
+ connect(spellCheckFileDownloader_,
+ &FileDownloader::downloadFileSuccessful,
+ this,
+ &SpellCheckDictionaryListModel::onDownloadFileFinished);
+ connect(spellCheckFileDownloader_,
+ &FileDownloader::downloadFileFailed,
+ this,
+ &SpellCheckDictionaryListModel::onDownloadFileFailed);
+
+ // Initialize the model with available dictionaries and check if dictionaries are available
+ // This will determine whether we need to notify the UI about a new available dictionary,
+ // which is important because we want SpellCheckAdapter to be able to set the dictionary path
+ // but only when dictionaries are available after initialization, and not on every download.
+ dictionariesAvailable_ = populateDictionaries() > 0;
+
+ // First, correct/migrate a bad setting that may have been set in the past
+ auto spellLangLocale = settingsManager_->getValue(Settings::Key::SpellLang).toString();
+ auto currentLocale = settingsManager_->getLanguage();
+ if (spellLangLocale.isEmpty() || !isLocaleInstalled(spellLangLocale)) {
+ C_WARN << "Spell check language setting is empty or invalid, resetting to current locale";
+ settingsManager_->setValue(Settings::Key::SpellLang, currentLocale);
+ installDictionary(currentLocale);
+ }
+}
+
+QString
+SpellCheckDictionaryListModel::pathForLocale(const QString& locale) const
+{
+ // Find the dictionary in the model and construct the path based on the locale and the
+ // dictionary type (Jami-install or system)
+ const auto index = getDictionaryIndex(locale);
+ if (!index.isValid()) {
+ return {};
+ }
+ const auto& dictObj = dictionaries_.at(index.row()).toObject();
+ if (dictObj.value("isSystem").toBool()) {
+ return systemDictionariesPath_ + locale;
+ }
+ return dictionariesPath_ + locale;
+}
+
+int
+SpellCheckDictionaryListModel::rowCount(const QModelIndex& parent) const
+{
+ return dictionaries_.size();
+}
+
+QVariant
+SpellCheckDictionaryListModel::data(const QModelIndex& index, int role) const
+{
+ if (!index.isValid())
+ return {};
+ // Try to find the item at the index
+ const auto& item = dictionaries_.at(index.row());
+ if (!item.isObject())
+ return {};
+ // Try to convert the item to a QJsonObject
+ const auto& itemObject = item.toObject().toVariantMap();
+ switch (role) {
+ case Role::NativeName:
+ return itemObject.value("nativeName");
+ case Role::Path:
+ return itemObject.value("path");
+ case Role::Locale:
+ return itemObject.value("locale");
+ case Role::Installed:
+ return itemObject.value("installed").toBool();
+ case Role::Downloading:
+ return pendingDownloads_.contains(itemObject.value("locale").toString());
+ case Role::IsSystem:
+ return itemObject.value("isSystem").toBool();
+ default:
+ return {};
+ }
+}
+
+QHash
+SpellCheckDictionaryListModel::roleNames() const
+{
+ using namespace SpellCheckDictionaryList;
+ QHash roles;
+#define X(role) roles[role] = #role;
+ SPELL_CHECK_DICTIONARY_MODEL_ROLES
+#undef X
+ return roles;
+}
+
+int
+SpellCheckDictionaryListModel::populateDictionaries()
+{
+ // First, we need to get the list of available dictionaries.
+ QFile availableDictionariesFile(":/misc/available_dictionaries.json");
+ if (!availableDictionariesFile.open(QIODevice::ReadOnly | QIODevice::Text)) {
+ C_WARN << "Available Dictionaries file failed to load";
+ return 0;
+ }
+ const auto availableDictionaries = QString(availableDictionariesFile.readAll());
+ QJsonDocument doc = QJsonDocument::fromJson(availableDictionaries.toUtf8());
+ /*
+ The file is a JSON object with the following structure:
+ {
+ "af_ZA": {
+ "nativeName": "Afrikaans (Suid-Afrika)",
+ "path": "af_ZA/af_ZA"
+ },
+ ...
+ }
+ We want to convert it to a QJsonArray of QJsonObjects, each containing the locale,
+ nativeName, path, and installed status.
+ */
+ if (doc.isNull() || !doc.isObject()) {
+ C_WARN.noquote() << "Available Dictionaries file is not a valid JSON object";
+ return 0;
+ }
+
+ beginResetModel();
+ dictionaries_ = QJsonArray();
+
+ const auto object = doc.object();
+
+ // Get installed dictionaries to check status
+ QString hunspellDataDir = QStandardPaths::writableLocation(QStandardPaths::CacheLocation)
+ + "/dictionaries/";
+ QDir dictionariesDir(hunspellDataDir);
+ QRegExp regex("(.*).dic");
+ QStringList installedLocales;
+
+ // 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) {
+ installedLocales << captured[1];
+ }
+ }
+
+ // Check for dictionary files in the system directory
+ QStringList systemDicFiles = QDir(systemDictionariesPath_)
+ .entryList(QStringList() << "*.dic", QDir::Files);
+ for (const auto& dicFile : systemDicFiles) {
+ regex.indexIn(dicFile);
+ auto captured = regex.capturedTexts();
+ if (captured.size() == 2) {
+ installedLocales << captured[1];
+ }
+ }
+
+ // Add the dictionaries to the model with the appropriate flags
+ for (const auto& key : object.keys()) {
+ const auto valueObject = object.value(key).toObject();
+ bool isSystem = systemDicFiles.contains(key + ".dic");
+ bool isInstalled = installedLocales.contains(key);
+ dictionaries_.append(QJsonObject {{"locale", key},
+ {"nativeName", valueObject.value("nativeName").toString()},
+ {"path", valueObject.value("path").toString()},
+ {"installed", isInstalled},
+ {"isSystem", isSystem}});
+ }
+ endResetModel();
+ return installedLocales.size();
+}
+
+Q_INVOKABLE void
+SpellCheckDictionaryListModel::installDictionary(const QString& locale)
+{
+ // Check if dictionary is already installed
+ const auto index = getDictionaryIndex(locale);
+ if (!index.isValid()) {
+ C_WARN << "Dictionary not found for locale:" << locale;
+ return;
+ }
+
+ // Check if already installed
+ auto dictObj = dictionaries_.at(index.row()).toObject();
+ if (dictObj.value("installed").toBool()) {
+ C_WARN << "Dictionary already installed for locale:" << locale;
+ return;
+ }
+
+ // Check if download is already in progress
+ if (pendingDownloads_.contains(locale)) {
+ C_WARN << "Download already in progress for locale:" << locale;
+ return;
+ }
+
+ // Start the download process
+ downloadDictionaryFiles(locale);
+}
+
+Q_INVOKABLE void
+SpellCheckDictionaryListModel::uninstallDictionary(const QString& locale)
+{
+ const auto index = getDictionaryIndex(locale);
+ if (!index.isValid()) {
+ C_WARN << "Dictionary not found for locale:" << locale;
+ return;
+ }
+
+ // Check if dictionary is actually installed
+ auto dictObj = dictionaries_.at(index.row()).toObject();
+ if (!dictObj.value("installed").toBool()) {
+ C_WARN << "Dictionary not installed for locale:" << locale;
+ return;
+ }
+
+ // Check if the dictionary is a system dictionary (cannot be uninstalled here)
+ if (dictObj.value("isSystem").toBool()) {
+ C_WARN << "Cannot uninstall system dictionary:" << locale;
+ return;
+ }
+
+ // Delete the dictionary files
+ QString affFile = dictionariesPath_ + locale + ".aff";
+ QString dicFile = dictionariesPath_ + locale + ".dic";
+
+ bool affDeleted = true;
+ bool dicDeleted = true;
+
+ if (QFile::exists(affFile)) {
+ affDeleted = QFile::remove(affFile);
+ if (!affDeleted) {
+ C_WARN << "Failed to delete .aff file:" << affFile;
+ }
+ }
+
+ if (QFile::exists(dicFile)) {
+ dicDeleted = QFile::remove(dicFile);
+ if (!dicDeleted) {
+ C_WARN << "Failed to delete .dic file:" << dicFile;
+ }
+ }
+
+ // Update the installation status regardless of file deletion success
+ // Note: This ensures the UI reflects the uninstall attempt only
+ updateDictionaryInstallationStatus(locale, false);
+
+ if (affDeleted && dicDeleted) {
+ C_DBG << "Dictionary uninstalled successfully for locale:" << locale;
+ Q_EMIT uninstallFinished(locale);
+ } else {
+ C_WARN << "Dictionary uninstall completed with errors for locale:" << locale;
+ Q_EMIT uninstallFailed(locale);
+ }
+}
+
+QVariantMap
+SpellCheckDictionaryListModel::getInstalledDictionaries() const
+{
+ QVariantMap installedDictionaries;
+ for (const auto& dict : dictionaries_) {
+ const auto dictObj = dict.toObject();
+ if (dictObj.value("installed").toBool()) {
+ installedDictionaries.insert(dictObj.value("locale").toString(),
+ dictObj.value("nativeName").toString());
+ }
+ }
+ return installedDictionaries;
+}
+
+QModelIndex
+SpellCheckDictionaryListModel::getDictionaryIndex(const QString& locale) const
+{
+ for (int i = 0; i < dictionaries_.size(); ++i) {
+ if (dictionaries_.at(i).toObject().value("locale") == locale)
+ return createIndex(i, 0);
+ }
+ return {}; // Not found
+}
+
+bool
+SpellCheckDictionaryListModel::isLocaleInstalled(const QString& locale) const
+{
+ // Iterate through the dictionaries to check if the locale is installed
+ for (const auto& dict : dictionaries_) {
+ if (dict.toObject().value("locale").toString() == locale) {
+ return dict.toObject().value("installed").toBool();
+ }
+ }
+ return false; // Locale not found
+}
+
+void
+SpellCheckDictionaryListModel::downloadDictionaryFiles(const QString& locale)
+{
+ C_DBG << "Downloading dictionary:" << locale;
+
+ // Find the dictionary info
+ const auto index = getDictionaryIndex(locale);
+ if (!index.isValid()) {
+ C_WARN << "Cannot download: dictionary not found for locale:" << locale;
+ Q_EMIT downloadFailed(locale);
+ return;
+ }
+
+ auto dictObj = dictionaries_.at(index.row()).toObject();
+ QString basePath = dictObj.value("path").toString();
+
+ if (basePath.isEmpty()) {
+ C_WARN << "Cannot download: invalid path for dictionary" << locale;
+ Q_EMIT downloadFailed(locale);
+ return;
+ }
+
+ // Add to pending downloads
+ pendingDownloads_.append(locale);
+ Q_EMIT dataChanged(index, index, {Role::Downloading});
+
+ // Create target directory if it doesn't exist
+ QDir().mkpath(dictionariesPath_);
+
+ QString targetFile = dictionariesPath_ + locale;
+
+ // Construct URLs using the stored path
+ QString baseUrl = downloadUrl_.toString();
+ QUrl urlAff = baseUrl + "/" + basePath + ".aff";
+ QUrl urlDic = baseUrl + "/" + basePath + ".dic";
+
+ C_DBG << "Downloading dictionary files for" << locale;
+
+ // Start downloads
+ spellCheckFileDownloader_->downloadFile(urlAff, targetFile + ".aff");
+ spellCheckFileDownloader_->downloadFile(urlDic, targetFile + ".dic");
+}
+
+void
+SpellCheckDictionaryListModel::updateDictionaryInstallationStatus(const QString& locale,
+ bool installed)
+{
+ const auto index = getDictionaryIndex(locale);
+ if (!index.isValid()) {
+ return;
+ }
+
+ // Update the dictionary object
+ auto dictObj = dictionaries_.at(index.row()).toObject();
+ dictObj["installed"] = installed;
+ dictionaries_[index.row()] = dictObj;
+
+ // Emit data changed signal
+ Q_EMIT dataChanged(index, index, {SpellCheckDictionaryList::Installed});
+}
+
+void
+SpellCheckDictionaryListModel::notifyDownloadStateChanged(const QString& locale)
+{
+ auto index = getDictionaryIndex(locale);
+ if (index.isValid()) {
+ Q_EMIT dataChanged(index, index, {Role::Downloading});
+ }
+}
+
+void
+SpellCheckDictionaryListModel::onDownloadFileFinished(const QString& localPath)
+{
+ C_DBG << "Download finished:" << localPath;
+
+ // Extract locale from file path
+ QFileInfo fileInfo(localPath);
+ QString locale = fileInfo.baseName();
+
+ static auto handleDownloadComplete = [this, &locale](const QString& localPath) {
+ // Both files are now available, mark as installed
+ updateDictionaryInstallationStatus(locale, true);
+ pendingDownloads_.removeAll(locale);
+ notifyDownloadStateChanged(locale);
+ Q_EMIT downloadFinished(locale);
+ C_DBG << "Dictionary installation completed for:" << locale;
+
+ // If we want to prevent installed dictionaries from being automatically set as the current
+ // dictionary, then place the newDictionaryAvailable signal within the if block.
+ Q_EMIT newDictionaryAvailable(locale);
+ if (!dictionariesAvailable_) {
+ dictionariesAvailable_ = true;
+ }
+ };
+
+ // Check if this is a .dic file and if the corresponding .aff file exists
+ if (localPath.endsWith(".dic")) {
+ QString affFilePath = localPath;
+ affFilePath.chop(4); // Remove ".dic"
+ affFilePath += ".aff";
+
+ if (QFile::exists(affFilePath)) {
+ handleDownloadComplete(affFilePath);
+ } else {
+ C_DBG << "Waiting for .aff file for:" << locale;
+ }
+ } else if (localPath.endsWith(".aff")) {
+ QString dicFilePath = localPath;
+ dicFilePath.chop(4); // Remove ".aff"
+ dicFilePath += ".dic";
+
+ if (QFile::exists(dicFilePath)) {
+ handleDownloadComplete(dicFilePath);
+ } else {
+ C_DBG << "Waiting for .dic file for:" << locale;
+ }
+ }
+}
+
+void
+SpellCheckDictionaryListModel::onDownloadFileFailed(const QString& localPath)
+{
+ C_WARN << "Download failed for file:" << localPath;
+
+ // Extract locale from file path
+ QFileInfo fileInfo(localPath);
+ QString locale = fileInfo.baseName();
+
+ // Remove from pending downloads and emit failure signal
+ pendingDownloads_.removeAll(locale);
+ notifyDownloadStateChanged(locale);
+ Q_EMIT downloadFailed(locale);
+}
diff --git a/src/app/spellcheckdictionarylistmodel.h b/src/app/spellcheckdictionarylistmodel.h
new file mode 100644
index 00000000..917d295b
--- /dev/null
+++ b/src/app/spellcheckdictionarylistmodel.h
@@ -0,0 +1,128 @@
+/*
+ * Copyright (C) 2025 Savoir-faire Linux Inc.
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+#pragma once
+#include "appsettingsmanager.h"
+#include "connectivitymonitor.h"
+#include "filedownloader.h"
+#include "systemtray.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#define SPELL_CHECK_DICTIONARY_MODEL_ROLES \
+ X(NativeName) \
+ X(Path) \
+ X(Locale) \
+ X(Installed) \
+ X(Downloading) \
+ X(IsSystem)
+
+namespace SpellCheckDictionaryList {
+Q_NAMESPACE
+enum Role {
+ DummyRole = Qt::UserRole + 1,
+#define X(role) role,
+ SPELL_CHECK_DICTIONARY_MODEL_ROLES
+#undef X
+};
+Q_ENUM_NS(Role)
+} // namespace SpellCheckDictionaryList
+
+class SpellCheckDictionaryListModel : public QAbstractListModel
+{
+ Q_OBJECT
+
+public:
+ explicit SpellCheckDictionaryListModel(AppSettingsManager* settingsManager,
+ ConnectivityMonitor* cm,
+ QObject* parent = nullptr);
+ ~SpellCheckDictionaryListModel() override = default;
+
+ // Construct the final path for a given locale. This could be either
+ // a Jami-install or a system dictionary.
+ QString pathForLocale(const QString& locale) const;
+
+ // QAbstractListModel interface
+ int rowCount(const QModelIndex& parent = QModelIndex()) const override;
+ QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override;
+ QHash roleNames() const override;
+
+ // SpellCheckAdapter needs access to the dictionary management methods
+ friend class SpellCheckAdapter;
+
+Q_SIGNALS:
+ void downloadFinished(const QString& locale);
+ void downloadFailed(const QString& locale);
+ void uninstallFinished(const QString& locale);
+ void uninstallFailed(const QString& locale);
+
+ // When a new dictionary is available, emit the signal so we can use
+ // it to set the current dictionary from the SpellCheckAdapter
+ void newDictionaryAvailable(const QString& locale);
+
+protected:
+ using Role = SpellCheckDictionaryList::Role;
+
+ void installDictionary(const QString& locale);
+ void uninstallDictionary(const QString& locale);
+
+ QVariantMap getInstalledDictionaries() const;
+
+private Q_SLOTS:
+ void onDownloadFileFinished(const QString& localPath);
+ void onDownloadFileFailed(const QString& localPath);
+
+private:
+ QJsonArray dictionaries_; // Principal underlying data structure
+ QStringList pendingDownloads_; // Used to track pending downloads and status
+ bool dictionariesAvailable_ {false}; // Flag to indicate if dictionaries are available
+
+ int populateDictionaries(); // Returns number of installed dictionaries
+
+ // Utility to get the index of a dictionary
+ QModelIndex getDictionaryIndex(const QString& locale) const;
+ bool isLocaleInstalled(const QString& locale) const;
+
+ // Helper methods for download functionality
+ void downloadDictionaryFiles(const QString& locale);
+ void updateDictionaryInstallationStatus(const QString& locale, bool installed);
+ void notifyDownloadStateChanged(const QString& locale);
+
+ // Dictionary file management and downloading
+ FileDownloader* spellCheckFileDownloader_;
+ const QUrl downloadUrl_ {"https://raw.githubusercontent.com/LibreOffice/dictionaries/master"};
+ const QString dictionariesPath_ = QStandardPaths::writableLocation(QStandardPaths::CacheLocation)
+ + "/dictionaries/";
+#if defined(Q_OS_LINUX)
+ const QString systemDictionariesPath_ = "/usr/share/hunspell/";
+#elif defined(Q_OS_MACOS)
+ const QString systemDictionariesPath_ = "/Library/Spelling/";
+#else
+ const QString systemDictionariesPath_ = "";
+#endif
+
+ AppSettingsManager* settingsManager_;
+};
diff --git a/src/app/spellcheckdictionarymanager.cpp b/src/app/spellcheckdictionarymanager.cpp
deleted file mode 100644
index 1e7d1286..00000000
--- a/src/app/spellcheckdictionarymanager.cpp
+++ /dev/null
@@ -1,149 +0,0 @@
-/*
- * Copyright (C) 2025 Savoir-faire Linux Inc.
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-
-#include "spellcheckdictionarymanager.h"
-
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-#include
-
-SpellCheckDictionaryManager::SpellCheckDictionaryManager(AppSettingsManager* settingsManager,
- QObject* parent)
- : QObject {parent}
- , settingsManager_ {settingsManager}
-{}
-
-QVariantMap
-SpellCheckDictionaryManager::installedDictionaries()
-{
- // If we already have a cache of the installed dictionaries, return it
- if (cachedInstalledDictionaries_.size() > 0) {
- return cachedInstalledDictionaries_;
-
- // If not, we need to check the dictionaries directory
- } else {
- QString hunspellDataDir = getDictionariesPath();
-
- auto dictionariesDir = QDir(hunspellDataDir);
- QRegExp regex("(.*).dic");
- QSet nativeNames;
-
- QVariantMap result;
- result["NONE"] = tr("None");
- QStringList folders = dictionariesDir.entryList(QDir::Dirs | QDir::NoDotAndDotDot);
- // Check for dictionary files in the base directory
- QStringList rootDicFiles = dictionariesDir.entryList(QStringList() << "*.dic", QDir::Files);
- for (const auto& dicFile : rootDicFiles) {
- regex.indexIn(dicFile);
- auto captured = regex.capturedTexts();
- if (captured.size() == 2) {
- auto nativeName = QLocale(captured[1]).nativeLanguageName();
- if (!nativeName.isEmpty() && !nativeNames.contains(nativeName)) {
- result[captured[1]] = nativeName;
- nativeNames.insert(nativeName);
- }
- }
- }
- // Check for dictionary files in subdirectories
- for (const auto& folder : folders) {
- QDir subDir = dictionariesDir.absoluteFilePath(folder);
- QStringList dicFiles = subDir.entryList(QStringList() << "*.dic", QDir::Files);
- subDir.setFilter(QDir::Files | QDir::AllDirs | QDir::NoDotAndDotDot);
- subDir.setSorting(QDir::DirsFirst);
- QFileInfoList list = subDir.entryInfoList();
- for (const auto& fileInfo : list) {
- if (fileInfo.isDir()) {
- QDir recursiveDir(fileInfo.absoluteFilePath());
- QStringList recursiveDicFiles = recursiveDir.entryList(QStringList() << "*.dic",
- QDir::Files);
- if (!recursiveDicFiles.isEmpty()) {
- dicFiles.append(recursiveDicFiles);
- }
- }
- }
-
- // Extract the locale from the dictionary file names
- for (const auto& dicFile : dicFiles) {
- regex.indexIn(dicFile);
- auto captured = regex.capturedTexts();
-
- if (captured.size() == 2) {
- auto nativeName = QLocale(captured[1]).nativeLanguageName();
-
- if (nativeName.isEmpty()) {
- continue;
- }
-
- if (!nativeNames.contains(nativeName)) {
- result[folder + QDir::separator() + captured[1]] = nativeName;
- nativeNames.insert(nativeName);
- } else {
- qWarning() << "Duplicate native name found, skipping:" << nativeName;
- }
- }
- }
- }
- cachedInstalledDictionaries_ = result;
- return result;
- }
-}
-
-QString
-SpellCheckDictionaryManager::getDictionariesPath()
-{
-#if defined(Q_OS_LINUX)
- QString hunDir = "/usr/share/hunspell/";
- ;
-
-#elif defined(Q_OS_MACOS)
- QString hunDir = "/Library/Spelling/";
-#else
- QString hunDir = "";
-#endif
- return hunDir;
-}
-
-
-void
-SpellCheckDictionaryManager::refreshDictionaries()
-{
- cachedInstalledDictionaries_.clear();
-}
-
-QString
-SpellCheckDictionaryManager::getSpellLanguage()
-{
- auto pref = settingsManager_->getValue(Settings::Key::SpellLang).toString();
- return pref ;
-}
-
-// Is only used at application boot time
-QString
-SpellCheckDictionaryManager::getDictionaryPath()
-{
- return "/usr/share/hunspell/" + getSpellLanguage();
-}
diff --git a/src/app/spellcheckdictionarymanager.h b/src/app/spellcheckdictionarymanager.h
deleted file mode 100644
index 66755445..00000000
--- a/src/app/spellcheckdictionarymanager.h
+++ /dev/null
@@ -1,39 +0,0 @@
-/*
- * Copyright (C) 2025 Savoir-faire Linux Inc.
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-
-#pragma once
-#include "appsettingsmanager.h"
-
-#include
-#include
-#include
-
-class SpellCheckDictionaryManager : public QObject
-{
- Q_OBJECT
- QVariantMap cachedInstalledDictionaries_;
- AppSettingsManager* settingsManager_;
-public:
- explicit SpellCheckDictionaryManager(AppSettingsManager* settingsManager,
- QObject* parent = nullptr);
-
- Q_INVOKABLE QVariantMap installedDictionaries();
- Q_INVOKABLE QString getDictionariesPath();
- Q_INVOKABLE void refreshDictionaries();
- Q_INVOKABLE QString getDictionaryPath();
- Q_INVOKABLE QString getSpellLanguage();
-};
diff --git a/src/app/spellchecker.cpp b/src/app/spellchecker.cpp
index 1b14235f..8e894921 100644
--- a/src/app/spellchecker.cpp
+++ b/src/app/spellchecker.cpp
@@ -29,10 +29,9 @@
#include
#include
-SpellChecker::SpellChecker(const QString& dictionaryPath)
-{
- replaceDictionary(dictionaryPath);
-}
+SpellChecker::SpellChecker()
+ : hunspell_(new Hunspell("", ""))
+{}
bool
SpellChecker::spell(const QString& word)
@@ -66,18 +65,18 @@ SpellChecker::put_word(const QString& word)
hunspell_->add(codec_->fromUnicode(word).constData());
}
-void
+bool
SpellChecker::replaceDictionary(const QString& dictionaryPath)
{
+ if (dictionaryPath == currentDictionaryPath_) {
+ return false;
+ }
+
QString dictFile = dictionaryPath + ".dic";
QString affixFile = dictionaryPath + ".aff";
- QByteArray dictFilePathBA = dictFile.toLocal8Bit();
- QByteArray affixFilePathBA = affixFile.toLocal8Bit();
- if (hunspell_) {
- hunspell_.reset();
- }
- hunspell_ = std::make_shared(affixFilePathBA.constData(), dictFilePathBA.constData());
-
+ QByteArray dictFilePath = dictFile.toLocal8Bit();
+ QByteArray affixFilePath = affixFile.toLocal8Bit();
+ hunspell_.reset(new Hunspell(affixFilePath.constData(), dictFilePath.constData()));
// detect encoding analyzing the SET option in the affix file
encoding_ = "ISO8859-1";
QFile _affixFile(affixFile);
@@ -94,6 +93,9 @@ SpellChecker::replaceDictionary(const QString& dictionaryPath)
}
codec_ = QTextCodec::codecForName(this->encoding_.toLatin1().constData());
+
+ currentDictionaryPath_ = dictionaryPath;
+ return true;
}
QList
@@ -101,7 +103,7 @@ SpellChecker::findWords(const QString& text)
{
// This is in the C++ part of the code because QML regex does not support unicode
QList results;
- QRegularExpression regex("\\p{L}+|\\p{N}+");
+ QRegularExpression regex("\\p{L}+");
QRegularExpressionMatchIterator iter = regex.globalMatch(text);
while (iter.hasNext()) {
diff --git a/src/app/spellchecker.h b/src/app/spellchecker.h
index 9ef6dd97..b57757f9 100644
--- a/src/app/spellchecker.h
+++ b/src/app/spellchecker.h
@@ -19,16 +19,11 @@
#pragma once
-#include "lrcinstance.h"
-#include "qmladapterbase.h"
-#include "previewengine.h"
-
#include
#include
#include
#include
#include
-#include
#include
@@ -38,16 +33,17 @@ class SpellChecker : public QObject
{
Q_OBJECT
public:
- explicit SpellChecker(const QString&);
- ~SpellChecker() = default;
- void replaceDictionary(const QString& dictionaryPath);
+ explicit SpellChecker();
+
+ bool 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 {
+ struct WordInfo
+ {
QString word;
int position;
int length;
@@ -57,7 +53,10 @@ public:
private:
void put_word(const QString& word);
- std::shared_ptr hunspell_;
+
+ std::unique_ptr hunspell_;
+
+ QString currentDictionaryPath_;
QString encoding_;
QTextCodec* codec_;
};
diff --git a/src/app/utils.cpp b/src/app/utils.cpp
index 52cdb786..fb054098 100644
--- a/src/app/utils.cpp
+++ b/src/app/utils.cpp
@@ -627,6 +627,17 @@ Utils::getProjectCredits()
"who want to be added to the list should contact us."));
}
+QString
+Utils::getAvailableDictionariesJson()
+{
+ QFile availableDictionariesFile(":/misc/available_dictionaries.json");
+ if (!availableDictionariesFile.open(QIODevice::ReadOnly | QIODevice::Text)) {
+ qDebug().noquote() << "Available Dictionaries file failed to load";
+ return {};
+ }
+ return QString(availableDictionariesFile.readAll());
+}
+
inline QString
removeEndlines(const QString& str)
{
diff --git a/src/app/utils.h b/src/app/utils.h
index 7124a1fd..cfb30dc2 100644
--- a/src/app/utils.h
+++ b/src/app/utils.h
@@ -68,6 +68,7 @@ QString GetISODate();
QSize getRealSize(QScreen* screen);
void forceDeleteAsync(const QString& path);
QString getProjectCredits();
+QString getAvailableDictionariesJson();
void removeOldVersions();
// LRC helpers
diff --git a/tests/qml/main.cpp b/tests/qml/main.cpp
index 3e4943ad..4ea4096b 100644
--- a/tests/qml/main.cpp
+++ b/tests/qml/main.cpp
@@ -16,7 +16,6 @@
*/
#include "appsettingsmanager.h"
-#include "spellcheckdictionarymanager.h"
#include "connectivitymonitor.h"
#include "mainapplication.h"
#include "previewengine.h"
@@ -46,13 +45,13 @@
#endif
#ifdef Q_OS_WIN
-#define DATA_DIR "JAMI_DATA_HOME"
-#define CONFIG_DIR "JAMI_CONFIG_HOME"
-#define CACHE_DIR "JAMI_CACHE_HOME"
+#define DATA_DIR "JAMI_DATA_HOME"
+#define CONFIG_DIR "JAMI_CONFIG_HOME"
+#define CACHE_DIR "JAMI_CACHE_HOME"
#else
-#define DATA_DIR "XDG_DATA_HOME"
-#define CONFIG_DIR "XDG_CONFIG_HOME"
-#define CACHE_DIR "XDG_CACHE_HOME"
+#define DATA_DIR "XDG_DATA_HOME"
+#define CONFIG_DIR "XDG_CONFIG_HOME"
+#define CACHE_DIR "XDG_CACHE_HOME"
#endif
#include
@@ -95,7 +94,6 @@ 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");
@@ -154,7 +152,6 @@ public Q_SLOTS:
lrcInstance_.get(),
systemTray_.get(),
settingsManager_.get(),
- spellCheckDictionaryManager_.get(),
connectivityMonitor_.get(),
previewEngine_.get(),
&screenInfo_,
@@ -172,7 +169,6 @@ private:
QScopedPointer connectivityMonitor_;
QScopedPointer settingsManager_;
- QScopedPointer spellCheckDictionaryManager_;
QScopedPointer systemTray_;
QScopedPointer previewEngine_;
ScreenInfo screenInfo_;