From ceec1f95b997d795ebdd01f2749c8967e5ac5a63 Mon Sep 17 00:00:00 2001 From: pmagnier-slimani Date: Mon, 12 May 2025 10:46:10 -0400 Subject: [PATCH] spellcheck: windows and macos Implement the hunspell spellchecker for Windows and MacOS. It also changes the base implementation for Linux. The system dictionaries (if any) are aggregated with those installed from the LibreOffice repository via Jami's dictionary management interface. This commit implements a major refactoring of the spellcheck system to improve UI responsiveness and user experience: Core Changes: - Used QAbstractListModel to represent the list of dictionaries - Added new QML components: - DictionaryInstallView.qml - ManageDictionariesDialog.qml - SpellCheckLanguageComboBox.qml - Updated property names for clarity - Fixed a bug in the settings combo box custom component that caused out-of-range errors for filtered models GitLab: #1997 Change-Id: Ibd0879f957f27f4c7c5720762ace553ca84e2bc3 --- 3rdparty/hunspell | 2 +- CMakeLists.txt | 16 +- resources/misc/available_dictionaries.json | 382 +++++++++++++++ src/app/MainApplicationWindow.qml | 4 +- src/app/appsettingsmanager.h | 2 +- src/app/commoncomponents/BaseModalDialog.qml | 4 +- .../DictionaryInstallView.qml | 370 ++++++++++++++ .../commoncomponents/LineEditContextMenu.qml | 40 +- .../ManageDictionariesDialog.qml} | 22 +- src/app/commoncomponents/Scaffold.qml | 2 +- .../commoncomponents/SettingParaCombobox.qml | 21 +- .../SpellLanguageContextMenu.qml | 21 +- .../contextmenu/GeneralMenuItem.qml | 2 + src/app/mainapplication.cpp | 8 +- src/app/mainapplication.h | 2 - .../components/MessageBarTextArea.qml | 103 ++-- src/app/messagesadapter.cpp | 59 --- src/app/messagesadapter.h | 11 - src/app/net/jami/Constants/JamiStrings.qml | 16 +- src/app/qmlregister.cpp | 14 +- src/app/qmlregister.h | 2 - .../components/ChatSettingsPage.qml | 59 +++ .../components/SettingsComboBox.qml | 2 + .../components/SpellCheckLanguageComboBox.qml | 96 ++++ .../components/SystemSettingsPage.qml | 159 ------ src/app/spellcheckadapter.cpp | 139 ++++++ src/app/spellcheckadapter.h | 72 +++ src/app/spellcheckdictionarylistmodel.cpp | 460 ++++++++++++++++++ src/app/spellcheckdictionarylistmodel.h | 128 +++++ src/app/spellcheckdictionarymanager.cpp | 149 ------ src/app/spellcheckdictionarymanager.h | 39 -- src/app/spellchecker.cpp | 28 +- src/app/spellchecker.h | 19 +- src/app/utils.cpp | 11 + src/app/utils.h | 1 + tests/qml/main.cpp | 16 +- 36 files changed, 1886 insertions(+), 595 deletions(-) create mode 100644 resources/misc/available_dictionaries.json create mode 100644 src/app/commoncomponents/DictionaryInstallView.qml rename src/app/{mainview/components/CachedFile.qml => commoncomponents/ManageDictionariesDialog.qml} (71%) create mode 100644 src/app/settingsview/components/SpellCheckLanguageComboBox.qml create mode 100644 src/app/spellcheckadapter.cpp create mode 100644 src/app/spellcheckadapter.h create mode 100644 src/app/spellcheckdictionarylistmodel.cpp create mode 100644 src/app/spellcheckdictionarylistmodel.h delete mode 100644 src/app/spellcheckdictionarymanager.cpp delete mode 100644 src/app/spellcheckdictionarymanager.h 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_;