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