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

spellcheck: 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
This commit is contained in:
pmagnier-slimani 2025-05-12 10:46:10 -04:00 committed by Andreas Traczyk
parent 1ac3db4f33
commit ceec1f95b9
36 changed files with 1886 additions and 595 deletions

2
3rdparty/hunspell vendored

@ -1 +1 @@
Subproject commit 525f9f22766a28e0f81c435217fcf4528e01c013 Subproject commit 749cd84a0b7863fd8663b85c65abd8eca55d342d

View file

@ -347,7 +347,8 @@ set(COMMON_SOURCES
${APP_SRC_DIR}/conversationlistmodel.cpp ${APP_SRC_DIR}/conversationlistmodel.cpp
${APP_SRC_DIR}/searchresultslistmodel.cpp ${APP_SRC_DIR}/searchresultslistmodel.cpp
${APP_SRC_DIR}/calloverlaymodel.cpp ${APP_SRC_DIR}/calloverlaymodel.cpp
${APP_SRC_DIR}/spellcheckdictionarymanager.cpp ${APP_SRC_DIR}/spellcheckdictionarylistmodel.cpp
${APP_SRC_DIR}/spellcheckadapter.cpp
${APP_SRC_DIR}/filestosendlistmodel.cpp ${APP_SRC_DIR}/filestosendlistmodel.cpp
${APP_SRC_DIR}/wizardviewstepmodel.cpp ${APP_SRC_DIR}/wizardviewstepmodel.cpp
${APP_SRC_DIR}/avatarregistry.cpp ${APP_SRC_DIR}/avatarregistry.cpp
@ -420,7 +421,8 @@ set(COMMON_HEADERS
${APP_SRC_DIR}/conversationlistmodel.h ${APP_SRC_DIR}/conversationlistmodel.h
${APP_SRC_DIR}/searchresultslistmodel.h ${APP_SRC_DIR}/searchresultslistmodel.h
${APP_SRC_DIR}/calloverlaymodel.h ${APP_SRC_DIR}/calloverlaymodel.h
${APP_SRC_DIR}/spellcheckdictionarymanager.h ${APP_SRC_DIR}/spellcheckdictionarylistmodel.h
${APP_SRC_DIR}/spellcheckadapter.h
${APP_SRC_DIR}/filestosendlistmodel.h ${APP_SRC_DIR}/filestosendlistmodel.h
${APP_SRC_DIR}/wizardviewstepmodel.h ${APP_SRC_DIR}/wizardviewstepmodel.h
${APP_SRC_DIR}/avatarregistry.h ${APP_SRC_DIR}/avatarregistry.h
@ -475,13 +477,6 @@ find_package(PkgConfig REQUIRED)
# hunspell # hunspell
pkg_search_module(hunspell IMPORTED_TARGET 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) if(hunspell_FOUND)
message(STATUS "hunspell found") message(STATUS "hunspell found")
set(HUNSPELL_LIBRARIES PkgConfig::hunspell) set(HUNSPELL_LIBRARIES PkgConfig::hunspell)
@ -734,7 +729,8 @@ qt_add_executable(
${COMMON_SOURCES} ${COMMON_SOURCES}
${QML_RESOURCES} ${QML_RESOURCES}
${QML_RESOURCES_QML} ${QML_RESOURCES_QML}
${SFPM_OBJECTS}) ${SFPM_OBJECTS}
src/app/spellcheckadapter.h src/app/spellcheckadapter.cpp)
#add_dependencies(${PROJECT_NAME} hunspell) #add_dependencies(${PROJECT_NAME} hunspell)

View file

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

View file

@ -294,7 +294,7 @@ ApplicationWindow {
Connections { Connections {
target: UtilsAdapter target: UtilsAdapter
function onRaiseWhenCalled() { function onRaiseWhenCalledChanged() {
raiseWhenCalled = AppSettingsManager.getValue(Settings.RaiseWhenCalled); raiseWhenCalled = AppSettingsManager.getValue(Settings.RaiseWhenCalled);
} }
} }

View file

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

View file

@ -78,7 +78,7 @@ Popup {
contentItem: ColumnLayout { contentItem: ColumnLayout {
id: contentLayout id: contentLayout
JamiPushButton { JamiPushButton { QWKSetParentHitTestVisible {}
id: closeButton id: closeButton
visible: closeButtonVisible visible: closeButtonVisible
@ -117,7 +117,7 @@ Popup {
Layout.fillHeight: true Layout.fillHeight: true
Layout.preferredHeight: Math.min(contentHeight, root.height) Layout.preferredHeight: Math.min(contentHeight, root.height)
Layout.preferredWidth: contentItem.childrenRect.width Layout.preferredWidth: contentItem.childrenRect.width + ScrollBar.vertical.width
Layout.leftMargin: popupMargins Layout.leftMargin: popupMargins
Layout.rightMargin: popupMargins Layout.rightMargin: popupMargins
Layout.alignment: Qt.AlignCenter Layout.alignment: Qt.AlignCenter

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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
}
}
}
}
}

View file

@ -17,6 +17,8 @@
import QtQuick import QtQuick
import net.jami.Adapters 1.1 import net.jami.Adapters 1.1
import net.jami.Constants 1.1 import net.jami.Constants 1.1
import net.jami.Enums 1.1
import net.jami.Models 1.1
import "contextmenu" import "contextmenu"
import "../mainview" import "../mainview"
import "../mainview/components" import "../mainview/components"
@ -30,15 +32,16 @@ ContextMenuAutoLoader {
property var selectionEnd property var selectionEnd
property bool customizePaste: false property bool customizePaste: false
property bool selectOnly: false property bool selectOnly: false
property bool checkSpell: false property bool spellCheckEnabled: false
property var suggestionList property var suggestionList
property var menuItemsLength property var menuItemsLength
property var language property var language
signal contextMenuRequirePaste signal contextMenuRequirePaste
SpellLanguageContextMenu { SpellLanguageContextMenu {
id: spellLanguageContextMenu id: spellLanguageContextMenu
active: checkSpell active: spellCheckEnabled
} }
property list<GeneralMenuItem> menuItems: [ property list<GeneralMenuItem> menuItems: [
@ -49,8 +52,7 @@ ContextMenuAutoLoader {
isActif: lineEditObj.selectedText.length && !selectOnly isActif: lineEditObj.selectedText.length && !selectOnly
itemName: JamiStrings.cut itemName: JamiStrings.cut
hasIcon: false hasIcon: false
onClicked: onClicked: lineEditObj.cut()
lineEditObj.cut();
}, },
GeneralMenuItem { GeneralMenuItem {
id: copy id: copy
@ -59,8 +61,7 @@ ContextMenuAutoLoader {
isActif: lineEditObj.selectedText.length isActif: lineEditObj.selectedText.length
itemName: JamiStrings.copy itemName: JamiStrings.copy
hasIcon: false hasIcon: false
onClicked: onClicked: lineEditObj.copy()
lineEditObj.copy();
}, },
GeneralMenuItem { GeneralMenuItem {
id: paste id: paste
@ -76,30 +77,39 @@ ContextMenuAutoLoader {
} }
}, },
GeneralMenuItem { GeneralMenuItem {
id: language id: textLanguage
visible: checkSpell canTrigger: spellCheckEnabled && SpellCheckAdapter.installedDictionaryCount > 0
canTrigger: checkSpell itemName: JamiStrings.textLanguage
itemName: JamiStrings.language
hasIcon: false hasIcon: false
onClicked: { onClicked: {
spellLanguageContextMenu.openMenu(); spellLanguageContextMenu.openMenu();
} }
},
GeneralMenuItem {
id: manageLanguages
itemName: JamiStrings.manageDictionaries
canTrigger: spellCheckEnabled
hasIcon: false
onClicked: {
viewCoordinator.presentDialog(appWindow, "commoncomponents/ManageDictionariesDialog.qml");
}
} }
] ]
ListView { ListView {
model: ListModel { model: ListModel {
id: dynamicModel id: suggestionListModel
} }
Instantiator { Instantiator {
model: dynamicModel model: suggestionListModel
delegate: GeneralMenuItem { delegate: GeneralMenuItem {
id: suggestion id: suggestion
canTrigger: true canTrigger: true
isActif: true isActif: true
itemName: model.name itemName: model.name
bold: true
hasIcon: false hasIcon: false
onClicked: { onClicked: {
replaceWord(model.name); replaceWord(model.name);
@ -117,7 +127,7 @@ ContextMenuAutoLoader {
} }
function removeItems() { function removeItems() {
dynamicModel.remove(0, suggestionList.length); suggestionListModel.clear();
suggestionList.length = 0; suggestionList.length = 0;
} }
@ -125,7 +135,7 @@ ContextMenuAutoLoader {
menuItemsLength = menuItems.length; // Keep initial number of items for easier removal menuItemsLength = menuItems.length; // Keep initial number of items for easier removal
suggestionList = wordList; suggestionList = wordList;
for (var i = 0; i < suggestionList.length; ++i) { for (var i = 0; i < suggestionList.length; ++i) {
dynamicModel.append({ suggestionListModel.append({
"name": suggestionList[i] "name": suggestionList[i]
}); });
} }
@ -154,7 +164,7 @@ ContextMenuAutoLoader {
lineEditObj.select(selectionStart, selectionEnd); lineEditObj.select(selectionStart, selectionEnd);
} }
function onClosed() { function onClosed() {
if (!suggestionList || suggestionList.length == 0) { if (!suggestionList || suggestionList.length === 0) {
return; return;
} }
removeItems(); removeItems();

View file

@ -16,19 +16,21 @@
*/ */
import QtQuick import QtQuick
import QtQuick.Controls import QtQuick.Controls
import QtQuick.Layouts import net.jami.Models 1.1
import net.jami.Adapters 1.1 import net.jami.Adapters 1.1
import net.jami.Constants 1.1 import net.jami.Constants 1.1
import net.jami.Helpers 1.1 import "../commoncomponents/contextmenu"
import net.jami.Models 1.1
import "../../commoncomponents"
Item { BaseModalDialog {
id: cachedFile id: root
property string dictionaryPath: SpellCheckDictionaryManager.getDictionariesPath() objectName: "manageDictionariesDialog"
function updateDictionnary(languagePath) { title: JamiStrings.manageDictionaries
var file = dictionaryPath + languagePath;
MessagesAdapter.updateDictionnary(file); popupContent: DictionaryInstallView {
Accessible.name: JamiStrings.manageDictionaries
Accessible.role: Accessible.PopupMenu
width: 400
height: 500
} }
} }

View file

@ -23,7 +23,7 @@ import QtQuick.Layouts
Rectangle { Rectangle {
property alias name: label.text property alias name: label.text
property bool stretchParent: false property bool stretchParent: false
property string tag: parent.toString() property string tag: parent.toString() + " (w:" + width + ", h: " + height + ")"
signal moveX(real dx) signal moveX(real dx)
signal moveY(real dy) signal moveY(real dy)
property real ox: 0 property real ox: 0

View file

@ -50,11 +50,14 @@ ComboBox {
contentItem: Text { contentItem: Text {
text: { text: {
if (index < 0) if (index < 0 || !model)
return ''; return '';
var currentItem = root.delegateModel.items.get(index);
const value = currentItem.model[root.textRole]; if (root.textRole && model[root.textRole] !== undefined) {
return value === undefined ? '' : value.toString(); return model[root.textRole].toString();
}
return model.display !== undefined ? model.display.toString() : '';
} }
color: hovered ? JamiTheme.comboboxTextColorHovered : JamiTheme.textColor color: hovered ? JamiTheme.comboboxTextColorHovered : JamiTheme.textColor
@ -80,7 +83,7 @@ ComboBox {
source: popup.visible ? JamiResources.expand_less_24dp_svg : JamiResources.expand_more_24dp_svg source: popup.visible ? JamiResources.expand_less_24dp_svg : JamiResources.expand_more_24dp_svg
color: JamiTheme.comboboxIconColor color: root.enabled ? JamiTheme.comboboxIconColor : "grey"
} }
contentItem: Text { contentItem: Text {
@ -92,7 +95,7 @@ ComboBox {
anchors.rightMargin: root.indicator.width * 2 anchors.rightMargin: root.indicator.width * 2
font.pixelSize: JamiTheme.settingsDescriptionPixelSize font.pixelSize: JamiTheme.settingsDescriptionPixelSize
text: root.displayText text: root.displayText
color: JamiTheme.comboboxTextColor color: root.enabled ? JamiTheme.comboboxTextColor : "grey"
font.weight: Font.Medium font.weight: Font.Medium
verticalAlignment: Text.AlignVCenter verticalAlignment: Text.AlignVCenter
horizontalAlignment: Text.AlignLeft horizontalAlignment: Text.AlignLeft
@ -104,7 +107,11 @@ ComboBox {
color: JamiTheme.transparentColor color: JamiTheme.transparentColor
implicitWidth: 120 implicitWidth: 120
implicitHeight: contentItem.implicitHeight + JamiTheme.buttontextHeightMargin 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 border.width: root.visualFocus ? 2 : 1
radius: 5 radius: 5
} }

View file

@ -22,15 +22,12 @@ import net.jami.Enums 1.1
import "contextmenu" import "contextmenu"
import "../mainview" import "../mainview"
import "../mainview/components" import "../mainview/components"
import SortFilterProxyModel 0.2
ContextMenuAutoLoader { ContextMenuAutoLoader {
id: root id: root
signal languageChanged() signal languageChanged
CachedFile {
id: cachedFile
}
function openMenuAt(mouseEvent) { function openMenuAt(mouseEvent) {
x = mouseEvent.x; x = mouseEvent.x;
@ -46,9 +43,11 @@ ContextMenuAutoLoader {
function generateMenuItems() { function generateMenuItems() {
var menuItems = []; var menuItems = [];
// Create new menu items // Create new menu items
var dictionaries = SpellCheckDictionaryManager.installedDictionaries(); var dictionaries = SpellCheckAdapter.getInstalledDictionaries();
var keys = Object.keys(dictionaries); var keys = Object.keys(dictionaries);
for (var i = 0; i < keys.length; ++i) { 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); var menuItem = Qt.createComponent("qrc:/commoncomponents/contextmenu/GeneralMenuItem.qml", Component.PreferSynchronous);
if (menuItem.status !== Component.Ready) { if (menuItem.status !== Component.Ready) {
console.error("Error loading component:", menuItem.errorString()); console.error("Error loading component:", menuItem.errorString());
@ -58,16 +57,18 @@ ContextMenuAutoLoader {
"parent": root, "parent": root,
"canTrigger": true, "canTrigger": true,
"isActif": true, "isActif": true,
"itemName": dictionaries[keys[i]], "itemName": nativeName,
"hasIcon": false, "hasIcon": false,
"content": keys[i], "content": locale,
"bold": UtilsAdapter.getAppValue(Settings.SpellLang) === locale
}); });
if (menuItemObject === null) { if (menuItemObject === null) {
console.error("Error creating menu item:", menuItem.errorString()); console.error("Error creating menu item:", menuItem.errorString());
continue; continue;
} }
menuItemObject.clicked.connect(function () { menuItemObject.clicked.connect(function () {
UtilsAdapter.setAppValue(Settings.Key.SpellLang, menuItemObject.content); const locale = menuItemObject.content;
SpellCheckAdapter.setDictionary(locale);
}); });
// Log the object pointer // Log the object pointer
menuItems.push(menuItemObject); menuItems.push(menuItemObject);

View file

@ -28,6 +28,7 @@ MenuItem {
id: menuItem id: menuItem
property string itemName: "" property string itemName: ""
property bool bold: false
property string content: "" property string content: ""
property alias iconSource: contextMenuItemImage.source property alias iconSource: contextMenuItemImage.source
property string iconColor: "" property string iconColor: ""
@ -99,6 +100,7 @@ MenuItem {
anchors.left: parent.left anchors.left: parent.left
height: parent.height height: parent.height
text: itemName text: itemName
font.bold: bold
color: dangerous ? JamiTheme.redColor : isActif ? JamiTheme.textColor : JamiTheme.chatViewFooterImgColor color: dangerous ? JamiTheme.redColor : isActif ? JamiTheme.textColor : JamiTheme.chatViewFooterImgColor
font.pointSize: JamiTheme.textFontSize font.pointSize: JamiTheme.textFontSize
horizontalAlignment: Text.AlignLeft horizontalAlignment: Text.AlignLeft

View file

@ -20,7 +20,6 @@
#include "global.h" #include "global.h"
#include "qmlregister.h" #include "qmlregister.h"
#include "appsettingsmanager.h" #include "appsettingsmanager.h"
#include "spellcheckdictionarymanager.h"
#include "connectivitymonitor.h" #include "connectivitymonitor.h"
#include "systemtray.h" #include "systemtray.h"
#include "previewengine.h" #include "previewengine.h"
@ -160,6 +159,7 @@ MainApplication::MainApplication(int& argc, char** argv)
"qml.debug=false\n" "qml.debug=false\n"
"default.debug=false\n" "default.debug=false\n"
"client.debug=false\n" "client.debug=false\n"
"spellcheck.debug=false\n"
"\n"); "\n");
// These can be set in the environment as well. // These can be set in the environment as well.
// e.g. QT_LOGGING_RULES="*.debug=false;qml.debug=true" // 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 // to any other initialization. This won't do anything if crashpad isn't
// enabled. // enabled.
settingsManager_ = new AppSettingsManager(this); settingsManager_ = new AppSettingsManager(this);
spellCheckDictionaryManager_ = new SpellCheckDictionaryManager(settingsManager_, this);
crashReporter_ = new CrashReporter(settingsManager_, this); crashReporter_ = new CrashReporter(settingsManager_, this);
// This 2-phase initialisation prevents ephemeral instances from // This 2-phase initialisation prevents ephemeral instances from
@ -425,7 +424,6 @@ MainApplication::initQmlLayer()
lrcInstance_.get(), lrcInstance_.get(),
systemTray_, systemTray_,
settingsManager_, settingsManager_,
spellCheckDictionaryManager_,
connectivityMonitor_, connectivityMonitor_,
previewEngine_, previewEngine_,
&screenInfo_, &screenInfo_,

View file

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

View file

@ -37,7 +37,6 @@ JamiFlickable {
property bool showPreview: false property bool showPreview: false
property bool isShowTypo: UtilsAdapter.getAppValue(Settings.Key.ShowMardownOption) property bool isShowTypo: UtilsAdapter.getAppValue(Settings.Key.ShowMardownOption)
property int textWidth: textArea.contentWidth property int textWidth: textArea.contentWidth
property var spellCheckActive: AppSettingsManager.getValue(Settings.EnableSpellCheck)
property var language: AppSettingsManager.getValue(Settings.SpellLang) property var language: AppSettingsManager.getValue(Settings.SpellLang)
// Used to cache the editable text when showing the preview message // Used to cache the editable text when showing the preview message
@ -73,7 +72,7 @@ JamiFlickable {
lineEditObj: textArea lineEditObj: textArea
customizePaste: true customizePaste: true
checkSpell: (Qt.platform.os.toString() === "linux") ? true : false spellCheckEnabled: root.spellCheckEnabled
onContextMenuRequirePaste: { onContextMenuRequirePaste: {
// Intercept paste event to use C++ QMimeData // 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 { TextArea.flickable: TextArea {
id: textArea id: textArea
CachedFile { Connections {
id: cachedFile target: SpellCheckAdapter
function onDictionaryChanged() {
textArea.updateSpellCorrection();
}
} }
function updateCorrection(language) { // Listen to settings changes to apply it to the text area
cachedFile.updateDictionnary(language);
textArea.updateUnderlineText();
}
// Listen to settings changes and apply it to this widget
Connections { Connections {
target: UtilsAdapter target: UtilsAdapter
function onChangeLanguage() { function onChangeLanguage() {
textArea.updateUnderlineText(); textArea.updateSpellCorrection();
} }
function onChangeFontSize() { function onChangeFontSize() {
textArea.updateUnderlineText(); textArea.updateSpellCorrection();
}
function onSpellLanguageChanged() {
root.language = SpellCheckDictionaryManager.getSpellLanguage();
if ((Qt.platform.os.toString() !== "linux") || (AppSettingsManager.getValue(Settings.SpellLang) === "NONE")) {
spellCheckActive = false;
} else {
spellCheckActive = AppSettingsManager.getValue(Settings.EnableSpellCheck);
}
if (spellCheckActive === true) {
root.language = SpellCheckDictionaryManager.getSpellLanguage();
textArea.updateCorrection(root.language);
} else {
textArea.clearUnderlines();
}
} }
function onEnableSpellCheckChanged() { function onEnableSpellCheckChanged() {
// Disable spell check on non-linux platforms yet textArea.updateSpellCorrection();
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();
} }
} }
@ -223,21 +192,23 @@ JamiFlickable {
onReleased: function (event) { onReleased: function (event) {
if (event.button === Qt.RightButton) { if (event.button === Qt.RightButton) {
if (spellCheckActive) {
var position = textArea.positionAt(event.x, event.y); var position = textArea.positionAt(event.x, event.y);
textArea.moveCursorSelection(position, TextInput.SelectWords); textArea.moveCursorSelection(position, TextInput.SelectWords);
textArea.selectWord(); textArea.selectWord();
if (!MessagesAdapter.spell(textArea.selectedText)) { if (!SpellCheckAdapter.spell(textArea.selectedText)) {
var wordList = MessagesAdapter.spellSuggestionsRequest(textArea.selectedText); var wordList = SpellCheckAdapter.spellSuggestionsRequest(textArea.selectedText);
if (wordList.length !== 0) { if (wordList.length !== 0) {
textAreaContextMenu.addMenuItem(wordList); textAreaContextMenu.addMenuItem(wordList);
} }
} }
}
textAreaContextMenu.openMenuAt(event); textAreaContextMenu.openMenuAt(event);
} }
} }
onTextChanged: { onTextChanged: {
updateUnderlineText(); updateSpellCorrection();
if (text !== debounceText && !showPreview) { if (text !== debounceText && !showPreview) {
debounceText = text; debounceText = text;
MessagesAdapter.userIsComposing(text ? true : false); MessagesAdapter.userIsComposing(text ? true : false);
@ -250,7 +221,7 @@ JamiFlickable {
// Shift + Enter -> Next Line // Shift + Enter -> Next Line
Keys.onPressed: function (keyEvent) { Keys.onPressed: function (keyEvent) {
// Update underline on each input to take into account deleted text and sent ones // Update underline on each input to take into account deleted text and sent ones
updateUnderlineText(); updateSpellCorrection();
if (keyEvent.matches(StandardKey.Paste)) { if (keyEvent.matches(StandardKey.Paste)) {
MessagesAdapter.onPaste(); MessagesAdapter.onPaste();
keyEvent.accepted = true; keyEvent.accepted = true;
@ -280,17 +251,17 @@ JamiFlickable {
} }
} }
function updateUnderlineText() { function updateSpellCorrection() {
clearUnderlines(); clearUnderlines();
// We iterate over the whole text to find words to check and underline them if needed // We iterate over the whole text to find words to check and underline them if needed
if (spellCheckActive) { if (spellCheckActive) {
var text = textArea.text; var text = textArea.text;
var words = MessagesAdapter.findWords(text); var words = SpellCheckAdapter.findWords(text);
if (!words) if (!words)
return; return;
for (var i = 0; i < words.length; i++) { for (var i = 0; i < words.length; i++) {
var wordInfo = words[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; textMetrics.text = wordInfo.word;
var xPos = textArea.positionToRectangle(wordInfo.position).x; var xPos = textArea.positionToRectangle(wordInfo.position).x;
var yPos = textArea.positionToRectangle(wordInfo.position).y + textArea.positionToRectangle(wordInfo.position).height; var yPos = textArea.positionToRectangle(wordInfo.position).y + textArea.positionToRectangle(wordInfo.position).height;

View file

@ -21,7 +21,6 @@
#include "qtutils.h" #include "qtutils.h"
#include "messageparser.h" #include "messageparser.h"
#include "previewengine.h" #include "previewengine.h"
#include "spellchecker.h"
#include <api/datatransfermodel.h> #include <api/datatransfermodel.h>
#include <api/contact.h> #include <api/contact.h>
@ -40,25 +39,17 @@
#include <QtMath> #include <QtMath>
#include <QRegExp> #include <QRegExp>
#define SUGGESTIONS_MAX_SIZE 3 // limit the number of spelling suggestions
MessagesAdapter::MessagesAdapter(AppSettingsManager* settingsManager, MessagesAdapter::MessagesAdapter(AppSettingsManager* settingsManager,
PreviewEngine* previewEngine, PreviewEngine* previewEngine,
SpellCheckDictionaryManager* spellCheckDictionaryManager,
LRCInstance* instance, LRCInstance* instance,
QObject* parent) QObject* parent)
: QmlAdapterBase(instance, parent) : QmlAdapterBase(instance, parent)
, settingsManager_(settingsManager) , settingsManager_(settingsManager)
, spellCheckDictionaryManager_(spellCheckDictionaryManager)
, messageParser_(new MessageParser(previewEngine, this)) , messageParser_(new MessageParser(previewEngine, this))
, filteredMsgListModel_(new FilteredMsgListModel(this)) , filteredMsgListModel_(new FilteredMsgListModel(this))
, mediaInteractions_(std::make_unique<MessageListModel>(nullptr)) , mediaInteractions_(std::make_unique<MessageListModel>(nullptr))
, timestampTimer_(new QTimer(this)) , timestampTimer_(new QTimer(this))
{ {
#if defined(Q_OS_LINUX)
// Initialize with make_shared
spellChecker_ = std::make_shared<SpellChecker>(spellCheckDictionaryManager_->getDictionaryPath());
#endif
setObjectName(typeid(*this).name()); setObjectName(typeid(*this).name());
set_messageListModel(QVariant::fromValue(filteredMsgListModel_)); set_messageListModel(QVariant::fromValue(filteredMsgListModel_));
@ -736,53 +727,3 @@ MessagesAdapter::getMsgListSourceModel() const
// However it may be a nullptr if not yet set. // However it may be a nullptr if not yet set.
return static_cast<MessageListModel*>(filteredMsgListModel_->sourceModel()); return static_cast<MessageListModel*>(filteredMsgListModel_->sourceModel());
} }
bool
MessagesAdapter::spell(const QString& word)
{
return spellChecker_->spell(word);
}
QVariantList
MessagesAdapter::spellSuggestionsRequest(const QString& word)
{
QStringList suggestionsList;
QVariantList variantList;
if (spellChecker_ == nullptr || spellChecker_->spell(word)) {
return variantList;
}
suggestionsList = spellChecker_->suggest(word);
for (const auto& suggestion : suggestionsList) {
if (variantList.size() >= SUGGESTIONS_MAX_SIZE) {
break;
}
variantList.append(QVariant(suggestion));
}
return variantList;
}
QVariantList
MessagesAdapter::findWords(const QString& text)
{
QVariantList result;
if (!spellChecker_)
return result;
auto words = spellChecker_->findWords(text);
for (const auto& word : words) {
QVariantMap wordInfo;
wordInfo["word"] = word.word;
wordInfo["position"] = word.position;
wordInfo["length"] = word.length;
result.append(wordInfo);
}
return result;
}
void
MessagesAdapter::updateDictionnary(const QString& path)
{
return spellChecker_->replaceDictionary(path);
}

View file

@ -23,8 +23,6 @@
#include "previewengine.h" #include "previewengine.h"
#include "messageparser.h" #include "messageparser.h"
#include "appsettingsmanager.h" #include "appsettingsmanager.h"
#include "spellchecker.h"
#include "spellcheckdictionarymanager.h"
#include <QObject> #include <QObject>
#include <QString> #include <QString>
@ -102,14 +100,11 @@ public:
{ {
return new MessagesAdapter(qApp->property("AppSettingsManager").value<AppSettingsManager*>(), return new MessagesAdapter(qApp->property("AppSettingsManager").value<AppSettingsManager*>(),
qApp->property("PreviewEngine").value<PreviewEngine*>(), qApp->property("PreviewEngine").value<PreviewEngine*>(),
qApp->property("SpellCheckDictionaryManager")
.value<SpellCheckDictionaryManager*>(),
qApp->property("LRCInstance").value<LRCInstance*>()); qApp->property("LRCInstance").value<LRCInstance*>());
} }
explicit MessagesAdapter(AppSettingsManager* settingsManager, explicit MessagesAdapter(AppSettingsManager* settingsManager,
PreviewEngine* previewEngine, PreviewEngine* previewEngine,
SpellCheckDictionaryManager* spellCheckDictionaryManager,
LRCInstance* instance, LRCInstance* instance,
QObject* parent = nullptr); QObject* parent = nullptr);
~MessagesAdapter() = default; ~MessagesAdapter() = default;
@ -168,10 +163,6 @@ public:
Q_INVOKABLE QVariant dataForInteraction(const QString& interactionId, Q_INVOKABLE QVariant dataForInteraction(const QString& interactionId,
int role = Qt::DisplayRole) const; int role = Qt::DisplayRole) const;
Q_INVOKABLE void startSearch(const QString& text, bool isMedia); Q_INVOKABLE void startSearch(const QString& text, bool isMedia);
Q_INVOKABLE QVariantList spellSuggestionsRequest(const QString& word);
Q_INVOKABLE bool spell(const QString& word);
Q_INVOKABLE void updateDictionnary(const QString& path);
Q_INVOKABLE QVariantList findWords(const QString& text);
// Run corrsponding js functions, c++ to qml. // Run corrsponding js functions, c++ to qml.
void setMessagesImageContent(const QString& path, bool isBased64 = false); void setMessagesImageContent(const QString& path, bool isBased64 = false);
@ -206,12 +197,10 @@ private:
QList<QString> conversationTypersUrlToName(const QSet<QString>& typersSet); QList<QString> conversationTypersUrlToName(const QSet<QString>& typersSet);
AppSettingsManager* settingsManager_; AppSettingsManager* settingsManager_;
SpellCheckDictionaryManager* spellCheckDictionaryManager_;
MessageParser* messageParser_; MessageParser* messageParser_;
FilteredMsgListModel* filteredMsgListModel_; FilteredMsgListModel* filteredMsgListModel_;
std::unique_ptr<MessageListModel> mediaInteractions_; std::unique_ptr<MessageListModel> mediaInteractions_;
QTimer* timestampTimer_; QTimer* timestampTimer_;
std::shared_ptr<SpellChecker> spellChecker_;
static constexpr const int loadChunkSize_ {20}; static constexpr const int loadChunkSize_ {20};
static constexpr const int timestampUpdateIntervalMs_ {1000}; static constexpr const int timestampUpdateIntervalMs_ {1000};
}; };

View file

@ -273,9 +273,9 @@ Item {
property string hideSpectators: qsTr("Hide spectators") property string hideSpectators: qsTr("Hide spectators")
// LineEditContextMenu // LineEditContextMenu
property string copy: qsTr("Copy")
property string share: qsTr("Share") property string share: qsTr("Share")
property string cut: qsTr("Cut") property string cut: qsTr("Cut")
property string copy: qsTr("Copy")
property string paste: qsTr("Paste") property string paste: qsTr("Paste")
property string language: qsTr("Language") property string language: qsTr("Language")
@ -913,9 +913,15 @@ Item {
// Spell checker // Spell checker
property string checkSpelling: qsTr("Check spelling while typing") property string checkSpelling: qsTr("Check spelling while typing")
property string systemDictionary: qsTr("System")
property string textLanguage: qsTr("Text language") property string textLanguage: qsTr("Text language")
property string textLanguageDescription: qsTr("To install new dictionaries, use the system package manager.") property string spellchecking: qsTr("Spell checker")
property string spellChecker: qsTr("Spell checker") property string searchTextLanguages: qsTr("Search text languages")
property string refresh: qsTr("Refresh") property string searchAvailableTextLanguages: qsTr("Search for available text languages")
property string refreshInstalledDictionaries: qsTr("Refresh installed dictionaries") 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")
} }

View file

@ -36,7 +36,8 @@
#include "currentaccounttomigrate.h" #include "currentaccounttomigrate.h"
#include "pttlistener.h" #include "pttlistener.h"
#include "calloverlaymodel.h" #include "calloverlaymodel.h"
#include "spellcheckdictionarymanager.h" #include "spellcheckdictionarylistmodel.h"
#include "spellcheckadapter.h"
#include "accountlistmodel.h" #include "accountlistmodel.h"
#include "mediacodeclistmodel.h" #include "mediacodeclistmodel.h"
#include "audiodevicemodel.h" #include "audiodevicemodel.h"
@ -65,7 +66,6 @@
#include "wizardviewstepmodel.h" #include "wizardviewstepmodel.h"
#include "linkdevicemodel.h" #include "linkdevicemodel.h"
#include "qrcodescannermodel.h" #include "qrcodescannermodel.h"
#include "spellchecker.h"
#include "api/peerdiscoverymodel.h" #include "api/peerdiscoverymodel.h"
#include "api/codecmodel.h" #include "api/codecmodel.h"
@ -119,7 +119,6 @@ registerTypes(QQmlEngine* engine,
LRCInstance* lrcInstance, LRCInstance* lrcInstance,
SystemTray* systemTray, SystemTray* systemTray,
AppSettingsManager* settingsManager, AppSettingsManager* settingsManager,
SpellCheckDictionaryManager* spellCheckDictionaryManager,
ConnectivityMonitor* connectivityMonitor, ConnectivityMonitor* connectivityMonitor,
PreviewEngine* previewEngine, PreviewEngine* previewEngine,
ScreenInfo* screenInfo, ScreenInfo* screenInfo,
@ -196,6 +195,10 @@ registerTypes(QQmlEngine* engine,
QQmlEngine::setObjectOwnership(linkdevicemodel, QQmlEngine::CppOwnership); QQmlEngine::setObjectOwnership(linkdevicemodel, QQmlEngine::CppOwnership);
REG_QML_SINGLETON<LinkDeviceModel>(REG_MODEL, "LinkDeviceModel", CREATE(linkdevicemodel)); REG_QML_SINGLETON<LinkDeviceModel>(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. // 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 // These MUST be set prior to loading the initial QML file, in order to
// be available to the QML adapter class factory creation methods. // 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("AppSettingsManager", QVariant::fromValue(settingsManager));
qApp->setProperty("ConnectivityMonitor", QVariant::fromValue(connectivityMonitor)); qApp->setProperty("ConnectivityMonitor", QVariant::fromValue(connectivityMonitor));
qApp->setProperty("PreviewEngine", QVariant::fromValue(previewEngine)); qApp->setProperty("PreviewEngine", QVariant::fromValue(previewEngine));
qApp->setProperty("SpellCheckDictionaryManager", QVariant::fromValue(spellCheckDictionaryManager));
// qml adapter registration // qml adapter registration
QML_REGISTERSINGLETON_TYPE(NS_HELPERS, QRCodeScannerModel); QML_REGISTERSINGLETON_TYPE(NS_HELPERS, QRCodeScannerModel);
@ -225,6 +227,7 @@ registerTypes(QQmlEngine* engine,
QML_REGISTERSINGLETON_TYPE(NS_ADAPTERS, VideoDevices); QML_REGISTERSINGLETON_TYPE(NS_ADAPTERS, VideoDevices);
QML_REGISTERSINGLETON_TYPE(NS_ADAPTERS, CurrentAccountToMigrate); QML_REGISTERSINGLETON_TYPE(NS_ADAPTERS, CurrentAccountToMigrate);
QML_REGISTERSINGLETON_TYPE(NS_HELPERS, FileDownloader); QML_REGISTERSINGLETON_TYPE(NS_HELPERS, FileDownloader);
QML_REGISTERSINGLETON_TYPE(NS_ADAPTERS, SpellCheckAdapter);
// TODO: remove these // TODO: remove these
QML_REGISTERSINGLETONTYPE_CUSTOM(NS_MODELS, AVModel, &lrcInstance->avModel()) QML_REGISTERSINGLETONTYPE_CUSTOM(NS_MODELS, AVModel, &lrcInstance->avModel())
@ -241,7 +244,6 @@ registerTypes(QQmlEngine* engine,
QML_REGISTERTYPE(NS_MODELS, PluginListPreferenceModel); QML_REGISTERTYPE(NS_MODELS, PluginListPreferenceModel);
QML_REGISTERTYPE(NS_MODELS, FilesToSendListModel); QML_REGISTERTYPE(NS_MODELS, FilesToSendListModel);
QML_REGISTERTYPE(NS_MODELS, CallInformationListModel); QML_REGISTERTYPE(NS_MODELS, CallInformationListModel);
QML_REGISTERTYPE(NS_MODELS, SpellChecker);
// Roles & type enums for models // Roles & type enums for models
QML_REGISTERNAMESPACE(NS_MODELS, AccountList::staticMetaObject, "AccountList"); QML_REGISTERNAMESPACE(NS_MODELS, AccountList::staticMetaObject, "AccountList");
@ -250,12 +252,12 @@ registerTypes(QQmlEngine* engine,
QML_REGISTERNAMESPACE(NS_MODELS, FilesToSend::staticMetaObject, "FilesToSend"); QML_REGISTERNAMESPACE(NS_MODELS, FilesToSend::staticMetaObject, "FilesToSend");
QML_REGISTERNAMESPACE(NS_MODELS, MessageList::staticMetaObject, "MessageList"); QML_REGISTERNAMESPACE(NS_MODELS, MessageList::staticMetaObject, "MessageList");
QML_REGISTERNAMESPACE(NS_MODELS, PluginStatus::staticMetaObject, "PluginStatus"); 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, app, "MainApplication")
QML_REGISTERSINGLETONTYPE_POBJECT(NS_CONSTANTS, screenInfo, "CurrentScreenInfo") QML_REGISTERSINGLETONTYPE_POBJECT(NS_CONSTANTS, screenInfo, "CurrentScreenInfo")
QML_REGISTERSINGLETONTYPE_POBJECT(NS_CONSTANTS, lrcInstance, "LRCInstance") QML_REGISTERSINGLETONTYPE_POBJECT(NS_CONSTANTS, lrcInstance, "LRCInstance")
QML_REGISTERSINGLETONTYPE_POBJECT(NS_CONSTANTS, settingsManager, "AppSettingsManager") QML_REGISTERSINGLETONTYPE_POBJECT(NS_CONSTANTS, settingsManager, "AppSettingsManager")
QML_REGISTERSINGLETONTYPE_POBJECT(NS_CONSTANTS, spellCheckDictionaryManager, "SpellCheckDictionaryManager")
// Lrc namespaces, models, and singletons // Lrc namespaces, models, and singletons
QML_REGISTERNAMESPACE(NS_MODELS, lrc::api::staticMetaObject, "Lrc"); QML_REGISTERNAMESPACE(NS_MODELS, lrc::api::staticMetaObject, "Lrc");

View file

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

View file

@ -17,11 +17,13 @@
import QtQuick import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import QtQuick.Controls import QtQuick.Controls
import Qt5Compat.GraphicalEffects
import net.jami.Models 1.1 import net.jami.Models 1.1
import net.jami.Adapters 1.1 import net.jami.Adapters 1.1
import net.jami.Enums 1.1 import net.jami.Enums 1.1
import net.jami.Constants 1.1 import net.jami.Constants 1.1
import net.jami.Helpers 1.1 import net.jami.Helpers 1.1
import SortFilterProxyModel 0.2
import "../../commoncomponents" import "../../commoncomponents"
import "../../mainview/components" import "../../mainview/components"
import "../../mainview/js/contactpickercreation.js" as ContactPickerCreation import "../../mainview/js/contactpickercreation.js" as ContactPickerCreation
@ -41,6 +43,63 @@ SettingsPageBase {
anchors.left: parent.left anchors.left: parent.left
anchors.leftMargin: JamiTheme.preferredSettingsMarginSize 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 { ColumnLayout {
id: generalSettings id: generalSettings

View file

@ -32,6 +32,7 @@ RowLayout {
property alias fontPointSize: comboBoxOfLayout.font.pointSize property alias fontPointSize: comboBoxOfLayout.font.pointSize
property alias modelIndex: comboBoxOfLayout.currentIndex property alias modelIndex: comboBoxOfLayout.currentIndex
property alias modelSize: comboBoxOfLayout.count property alias modelSize: comboBoxOfLayout.count
property alias comboBox: comboBoxOfLayout
property int widthOfComboBox: 50 property int widthOfComboBox: 50
@ -53,6 +54,7 @@ RowLayout {
SettingParaCombobox { SettingParaCombobox {
id: comboBoxOfLayout id: comboBoxOfLayout
enabled: root.enabled
Layout.preferredWidth: widthOfComboBox Layout.preferredWidth: widthOfComboBox
font.pointSize: JamiTheme.buttonFontSize font.pointSize: JamiTheme.buttonFontSize

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}

View file

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

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
#include "spellcheckadapter.h"
#include "spellcheckdictionarylistmodel.h"
#include <QApplication>
#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<int>& 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();
}
}

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include "spellchecker.h"
#include "qtutils.h"
#include <QObject>
#include <QQmlEngine> // QML registration
#include <QApplication> // 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<SpellCheckDictionaryListModel*>(),
qApp->property("AppSettingsManager").value<AppSettingsManager*>(),
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};
};

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
#include "spellcheckdictionarylistmodel.h"
#include "global.h"
#include <QApplication>
#include <QBuffer>
#include <QClipboard>
#include <QFileInfo>
#include <QRegExp>
#include <QMimeData>
#include <QDir>
#include <QMimeDatabase>
#include <QUrl>
#include <QRegularExpression>
#include <QJsonDocument>
#include <QJsonArray>
#include <QJsonObject>
#include <QStandardPaths>
#include <QFile>
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<int, QByteArray>
SpellCheckDictionaryListModel::roleNames() const
{
using namespace SpellCheckDictionaryList;
QHash<int, QByteArray> 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);
}

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include "appsettingsmanager.h"
#include "connectivitymonitor.h"
#include "filedownloader.h"
#include "systemtray.h"
#include <QObject>
#include <QApplication>
#include <QQmlEngine>
#include <QUrl>
#include <QJsonObject>
#include <QJsonArray>
#include <QSystemTrayIcon>
#include <QtCore/QLoggingCategory>
#include <QAbstractListModel>
#include <QJsonDocument>
#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<int, QByteArray> 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_;
};

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
#include "spellcheckdictionarymanager.h"
#include <QApplication>
#include <QBuffer>
#include <QClipboard>
#include <QFileInfo>
#include <QRegExp>
#include <QMimeData>
#include <QDir>
#include <QMimeDatabase>
#include <QUrl>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QEventLoop>
#include <QRegularExpression>
SpellCheckDictionaryManager::SpellCheckDictionaryManager(AppSettingsManager* settingsManager,
QObject* parent)
: QObject {parent}
, settingsManager_ {settingsManager}
{}
QVariantMap
SpellCheckDictionaryManager::installedDictionaries()
{
// If we already have a cache of the installed dictionaries, return it
if (cachedInstalledDictionaries_.size() > 0) {
return cachedInstalledDictionaries_;
// If not, we need to check the dictionaries directory
} else {
QString hunspellDataDir = getDictionariesPath();
auto dictionariesDir = QDir(hunspellDataDir);
QRegExp regex("(.*).dic");
QSet<QString> nativeNames;
QVariantMap result;
result["NONE"] = tr("None");
QStringList folders = dictionariesDir.entryList(QDir::Dirs | QDir::NoDotAndDotDot);
// Check for dictionary files in the base directory
QStringList rootDicFiles = dictionariesDir.entryList(QStringList() << "*.dic", QDir::Files);
for (const auto& dicFile : rootDicFiles) {
regex.indexIn(dicFile);
auto captured = regex.capturedTexts();
if (captured.size() == 2) {
auto nativeName = QLocale(captured[1]).nativeLanguageName();
if (!nativeName.isEmpty() && !nativeNames.contains(nativeName)) {
result[captured[1]] = nativeName;
nativeNames.insert(nativeName);
}
}
}
// Check for dictionary files in subdirectories
for (const auto& folder : folders) {
QDir subDir = dictionariesDir.absoluteFilePath(folder);
QStringList dicFiles = subDir.entryList(QStringList() << "*.dic", QDir::Files);
subDir.setFilter(QDir::Files | QDir::AllDirs | QDir::NoDotAndDotDot);
subDir.setSorting(QDir::DirsFirst);
QFileInfoList list = subDir.entryInfoList();
for (const auto& fileInfo : list) {
if (fileInfo.isDir()) {
QDir recursiveDir(fileInfo.absoluteFilePath());
QStringList recursiveDicFiles = recursiveDir.entryList(QStringList() << "*.dic",
QDir::Files);
if (!recursiveDicFiles.isEmpty()) {
dicFiles.append(recursiveDicFiles);
}
}
}
// Extract the locale from the dictionary file names
for (const auto& dicFile : dicFiles) {
regex.indexIn(dicFile);
auto captured = regex.capturedTexts();
if (captured.size() == 2) {
auto nativeName = QLocale(captured[1]).nativeLanguageName();
if (nativeName.isEmpty()) {
continue;
}
if (!nativeNames.contains(nativeName)) {
result[folder + QDir::separator() + captured[1]] = nativeName;
nativeNames.insert(nativeName);
} else {
qWarning() << "Duplicate native name found, skipping:" << nativeName;
}
}
}
}
cachedInstalledDictionaries_ = result;
return result;
}
}
QString
SpellCheckDictionaryManager::getDictionariesPath()
{
#if defined(Q_OS_LINUX)
QString hunDir = "/usr/share/hunspell/";
;
#elif defined(Q_OS_MACOS)
QString hunDir = "/Library/Spelling/";
#else
QString hunDir = "";
#endif
return hunDir;
}
void
SpellCheckDictionaryManager::refreshDictionaries()
{
cachedInstalledDictionaries_.clear();
}
QString
SpellCheckDictionaryManager::getSpellLanguage()
{
auto pref = settingsManager_->getValue(Settings::Key::SpellLang).toString();
return pref ;
}
// Is only used at application boot time
QString
SpellCheckDictionaryManager::getDictionaryPath()
{
return "/usr/share/hunspell/" + getSpellLanguage();
}

View file

@ -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 <http://www.gnu.org/licenses/>.
*/
#pragma once
#include "appsettingsmanager.h"
#include <QObject>
#include <QApplication>
#include <QQmlEngine>
class SpellCheckDictionaryManager : public QObject
{
Q_OBJECT
QVariantMap cachedInstalledDictionaries_;
AppSettingsManager* settingsManager_;
public:
explicit SpellCheckDictionaryManager(AppSettingsManager* settingsManager,
QObject* parent = nullptr);
Q_INVOKABLE QVariantMap installedDictionaries();
Q_INVOKABLE QString getDictionariesPath();
Q_INVOKABLE void refreshDictionaries();
Q_INVOKABLE QString getDictionaryPath();
Q_INVOKABLE QString getSpellLanguage();
};

View file

@ -29,10 +29,9 @@
#include <QRegularExpression> #include <QRegularExpression>
#include <QRegularExpressionMatchIterator> #include <QRegularExpressionMatchIterator>
SpellChecker::SpellChecker(const QString& dictionaryPath) SpellChecker::SpellChecker()
{ : hunspell_(new Hunspell("", ""))
replaceDictionary(dictionaryPath); {}
}
bool bool
SpellChecker::spell(const QString& word) SpellChecker::spell(const QString& word)
@ -66,18 +65,18 @@ SpellChecker::put_word(const QString& word)
hunspell_->add(codec_->fromUnicode(word).constData()); hunspell_->add(codec_->fromUnicode(word).constData());
} }
void bool
SpellChecker::replaceDictionary(const QString& dictionaryPath) SpellChecker::replaceDictionary(const QString& dictionaryPath)
{ {
if (dictionaryPath == currentDictionaryPath_) {
return false;
}
QString dictFile = dictionaryPath + ".dic"; QString dictFile = dictionaryPath + ".dic";
QString affixFile = dictionaryPath + ".aff"; QString affixFile = dictionaryPath + ".aff";
QByteArray dictFilePathBA = dictFile.toLocal8Bit(); QByteArray dictFilePath = dictFile.toLocal8Bit();
QByteArray affixFilePathBA = affixFile.toLocal8Bit(); QByteArray affixFilePath = affixFile.toLocal8Bit();
if (hunspell_) { hunspell_.reset(new Hunspell(affixFilePath.constData(), dictFilePath.constData()));
hunspell_.reset();
}
hunspell_ = std::make_shared<Hunspell>(affixFilePathBA.constData(), dictFilePathBA.constData());
// detect encoding analyzing the SET option in the affix file // detect encoding analyzing the SET option in the affix file
encoding_ = "ISO8859-1"; encoding_ = "ISO8859-1";
QFile _affixFile(affixFile); QFile _affixFile(affixFile);
@ -94,6 +93,9 @@ SpellChecker::replaceDictionary(const QString& dictionaryPath)
} }
codec_ = QTextCodec::codecForName(this->encoding_.toLatin1().constData()); codec_ = QTextCodec::codecForName(this->encoding_.toLatin1().constData());
currentDictionaryPath_ = dictionaryPath;
return true;
} }
QList<SpellChecker::WordInfo> QList<SpellChecker::WordInfo>
@ -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 // This is in the C++ part of the code because QML regex does not support unicode
QList<WordInfo> results; QList<WordInfo> results;
QRegularExpression regex("\\p{L}+|\\p{N}+"); QRegularExpression regex("\\p{L}+");
QRegularExpressionMatchIterator iter = regex.globalMatch(text); QRegularExpressionMatchIterator iter = regex.globalMatch(text);
while (iter.hasNext()) { while (iter.hasNext()) {

View file

@ -19,16 +19,11 @@
#pragma once #pragma once
#include "lrcinstance.h"
#include "qmladapterbase.h"
#include "previewengine.h"
#include <QTextCodec> #include <QTextCodec>
#include <QString> #include <QString>
#include <QStringList> #include <QStringList>
#include <QDebug> #include <QDebug>
#include <QObject> #include <QObject>
#include <string>
#include <hunspell/hunspell.hxx> #include <hunspell/hunspell.hxx>
@ -38,16 +33,17 @@ class SpellChecker : public QObject
{ {
Q_OBJECT Q_OBJECT
public: public:
explicit SpellChecker(const QString&); explicit SpellChecker();
~SpellChecker() = default;
void replaceDictionary(const QString& dictionaryPath); bool replaceDictionary(const QString& dictionaryPath);
Q_INVOKABLE bool spell(const QString& word); Q_INVOKABLE bool spell(const QString& word);
Q_INVOKABLE QStringList suggest(const QString& word); Q_INVOKABLE QStringList suggest(const QString& word);
Q_INVOKABLE void ignoreWord(const QString& word); Q_INVOKABLE void ignoreWord(const QString& word);
// Used to find words and their position in a text // Used to find words and their position in a text
struct WordInfo { struct WordInfo
{
QString word; QString word;
int position; int position;
int length; int length;
@ -57,7 +53,10 @@ public:
private: private:
void put_word(const QString& word); void put_word(const QString& word);
std::shared_ptr<Hunspell> hunspell_;
std::unique_ptr<Hunspell> hunspell_;
QString currentDictionaryPath_;
QString encoding_; QString encoding_;
QTextCodec* codec_; QTextCodec* codec_;
}; };

View file

@ -627,6 +627,17 @@ Utils::getProjectCredits()
"who want to be added to the list should contact us.")); "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 inline QString
removeEndlines(const QString& str) removeEndlines(const QString& str)
{ {

View file

@ -68,6 +68,7 @@ QString GetISODate();
QSize getRealSize(QScreen* screen); QSize getRealSize(QScreen* screen);
void forceDeleteAsync(const QString& path); void forceDeleteAsync(const QString& path);
QString getProjectCredits(); QString getProjectCredits();
QString getAvailableDictionariesJson();
void removeOldVersions(); void removeOldVersions();
// LRC helpers // LRC helpers

View file

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