From 0ff9425feee139ee907a46539a097faa7e77711a Mon Sep 17 00:00:00 2001 From: Carl Schwan Date: Thu, 10 Jun 2021 11:29:59 +0000 Subject: [PATCH] Add spellchecking suggestions --- CMakeLists.txt | 2 +- imports/NeoChat/Component/ChatBox/ChatBar.qml | 104 ++++++- .../Component/ChatBox/CursorDelegate.qml | 65 ++++ .../Component/ChatBox/CursorHandle.qml | 98 ++++++ .../ChatBox/MobileTextActionsToolBar.qml | 77 +++++ .../ChatBox/TextFieldContextMenu.qml | 259 ++++++++++++++++ imports/NeoChat/Component/ChatBox/qmldir | 4 + imports/NeoChat/Menu/TextContextMenu.qml | 25 ++ res.qrc | 4 + src/CMakeLists.txt | 2 +- src/chatdocumenthandler.cpp | 6 - src/main.cpp | 2 + src/spellcheckhighlighter.cpp | 284 ++++++++++++++++-- src/spellcheckhighlighter.h | 69 ++++- 14 files changed, 965 insertions(+), 36 deletions(-) create mode 100644 imports/NeoChat/Component/ChatBox/CursorDelegate.qml create mode 100644 imports/NeoChat/Component/ChatBox/CursorHandle.qml create mode 100644 imports/NeoChat/Component/ChatBox/MobileTextActionsToolBar.qml create mode 100644 imports/NeoChat/Component/ChatBox/TextFieldContextMenu.qml create mode 100644 imports/NeoChat/Menu/TextContextMenu.qml diff --git a/CMakeLists.txt b/CMakeLists.txt index 2b30999f7..b9064a4a2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -68,7 +68,7 @@ if(ANDROID) ) else() find_package(Qt5 ${QT_MIN_VERSION} COMPONENTS Widgets) - find_package(KF5 ${KF5_MIN_VERSION} REQUIRED COMPONENTS QQC2DesktopStyle ConfigWidgets KIO Sonnet WindowSystem) + find_package(KF5 ${KF5_MIN_VERSION} REQUIRED COMPONENTS QQC2DesktopStyle ConfigWidgets KIO WindowSystem Sonnet) set_package_properties(KF5QQC2DesktopStyle PROPERTIES TYPE RUNTIME ) diff --git a/imports/NeoChat/Component/ChatBox/ChatBar.qml b/imports/NeoChat/Component/ChatBox/ChatBar.qml index 77279cb3c..ce776eaf2 100644 --- a/imports/NeoChat/Component/ChatBox/ChatBar.qml +++ b/imports/NeoChat/Component/ChatBox/ChatBar.qml @@ -5,7 +5,9 @@ import QtQuick 2.15 import QtQuick.Layouts 1.15 import QtQuick.Controls 2.15 +import QtQuick.Templates 2.15 as T import Qt.labs.platform 1.1 as Platform +import QtQuick.Window 2.15 import org.kde.kirigami 2.15 as Kirigami @@ -66,7 +68,8 @@ ToolBar { id: fontMetrics font: inputField.font } - TextArea { + + T.TextArea { id: inputField focus: true /* Some QQC2 styles will have their own predefined backgrounds for TextAreas. @@ -81,6 +84,7 @@ ToolBar { cursorShape: Qt.IBeamCursor z: 1 } + leftPadding: mirrored ? 0 : Kirigami.Units.largeSpacing rightPadding: !mirrored ? 0 : Kirigami.Units.largeSpacing topPadding: 0 @@ -97,6 +101,86 @@ ToolBar { wrapMode: Text.Wrap readOnly: currentRoom.usesEncryption + palette: Kirigami.Theme.palette + Kirigami.Theme.colorSet: Kirigami.Theme.View + Kirigami.Theme.inherit: false + + implicitWidth: Math.max(contentWidth + leftPadding + rightPadding, + implicitBackgroundWidth + leftInset + rightInset, + placeholder.implicitWidth + leftPadding + rightPadding) + implicitHeight: Math.max(contentHeight + topPadding + bottomPadding, + implicitBackgroundHeight + topInset + bottomInset, + placeholder.implicitHeight + topPadding + bottomPadding) + + color: Kirigami.Theme.textColor + selectionColor: Kirigami.Theme.highlightColor + selectedTextColor: Kirigami.Theme.highlightedTextColor + hoverEnabled: !Kirigami.Settings.tabletMode + + // Work around Qt bug where NativeRendering breaks for non-integer scale factors + // https://bugreports.qt.io/browse/QTBUG-67007 + renderType: Screen.devicePixelRatio % 1 !== 0 ? Text.QtRendering : Text.NativeRendering + + selectByMouse: !Kirigami.Settings.tabletMode + + cursorDelegate: Loader { + visible: inputField.activeFocus && !inputField.readOnly && inputField.selectionStart === inputField.selectionEnd + active: visible + sourceComponent: CursorDelegate { target: inputField } + } + + CursorHandle { + id: selectionStartHandle + target: inputField + } + + CursorHandle { + id: selectionEndHandle + target: inputField + isSelectionEnd: true + } + + TapHandler { + acceptedDevices: PointerDevice.Mouse | PointerDevice.Stylus + acceptedButtons: Qt.LeftButton | Qt.RightButton + + // unfortunately, taphandler's pressed event only triggers when the press is lifted + // we need to use the longpress signal since it triggers when the button is first pressed + longPressThreshold: 0 + onLongPressed: TextFieldContextMenu.targetClick(point, inputField, spellcheckhighlighter, inputField.positionAt(point.position.x, point.position.y)); + } + + onPressAndHold: { + if (!Kirigami.Settings.tabletMode) { + return; + } + forceActiveFocus(); + cursorPosition = positionAt(event.x, event.y); + selectWord(); + } + + onFocusChanged: { + if (focus) { + MobileTextActionsToolBar.controlRoot = inputField; + } + } + + Label { + id: placeholder + x: inputField.leftPadding + y: inputField.topPadding + width: inputField.width - (inputField.leftPadding + inputField.rightPadding) + height: inputField.height - (inputField.topPadding + inputField.bottomPadding) + + text: inputField.placeholderText + font: inputField.font + color: Kirigami.Theme.disabledTextColor + horizontalAlignment: inputField.horizontalAlignment + verticalAlignment: inputField.verticalAlignment + visible: !inputField.length && !inputField.preeditText && (!inputField.activeFocus || inputField.horizontalAlignment !== Qt.AlignHCenter) + elide: Text.ElideRight + } + ChatDocumentHandler { id: documentHandler @@ -107,6 +191,18 @@ ToolBar { room: currentRoom ?? null } + SpellcheckHighlighter { + id: spellcheckhighlighter + document: inputField.textDocument + cursorPosition: inputField.cursorPosition + selectionStart: inputField.selectionStart + selectionEnd: inputField.selectionEnd + onChangeCursorPosition: { + inputField.cursorPosition = start; + inputField.moveCursorSelection(end, TextEdit.SelectCharacters); + } + } + Timer { id: timeoutTimer repeat: false @@ -147,6 +243,9 @@ ToolBar { } Keys.onPressed: { + // trigger if context menu button is pressed + TextFieldContextMenu.targetKeyPressed(event, inputField) + if (event.key === Qt.Key_PageDown) { switchRoomDown(); } else if (event.key === Qt.Key_PageUp) { @@ -211,7 +310,10 @@ ToolBar { chatBar.complete(); } + onPressed: MobileTextActionsToolBar.shouldBeVisible = true; + onTextChanged: { + MobileTextActionsToolBar.shouldBeVisible = false; timeoutTimer.restart() repeatTimer.start() currentRoom.cachedInput = text diff --git a/imports/NeoChat/Component/ChatBox/CursorDelegate.qml b/imports/NeoChat/Component/ChatBox/CursorDelegate.qml new file mode 100644 index 000000000..851daa052 --- /dev/null +++ b/imports/NeoChat/Component/ChatBox/CursorDelegate.qml @@ -0,0 +1,65 @@ +/* SPDX-FileCopyrightText: 2018 Marco Martin + * SPDX-FileCopyrightText: 2021 Noah Davis + * SPDX-License-Identifier: LGPL-2.0-or-later + */ + +import QtQuick 2.15 +import QtQuick.Window 2.15 +import QtQuick.Templates 2.15 +import org.kde.kirigami 2.14 as Kirigami + +Item { + id: root + property alias target: root.parent + + Rectangle { + id: cursorLine + property real previousX: 0 + property real previousY: 0 + parent: target + implicitWidth: target.cursorRectangle.width + implicitHeight: target.cursorRectangle.height + x: Math.floor(target.cursorRectangle.x) + y: Math.floor(target.cursorRectangle.y) + + color: target.color + SequentialAnimation { + id: blinkAnimation + running: root.visible && Qt.styleHints.cursorFlashTime != 0 && target.selectionStart === target.selectionEnd + PropertyAction { + target: cursorLine + property: "opacity" + value: 1 + } + PauseAnimation { + duration: Qt.styleHints.cursorFlashTime/2 + } + SequentialAnimation { + loops: Animation.Infinite + OpacityAnimator { + target: cursorLine + from: 1 + to: 0 + duration: Qt.styleHints.cursorFlashTime/2 + easing.type: Easing.OutCubic + } + OpacityAnimator { + target: cursorLine + from: 0 + to: 1 + duration: Qt.styleHints.cursorFlashTime/2 + easing.type: Easing.OutCubic + } + } + } + } + + Connections { + target: root.target + function onCursorPositionChanged() { + blinkAnimation.restart() + } + } +} + + diff --git a/imports/NeoChat/Component/ChatBox/CursorHandle.qml b/imports/NeoChat/Component/ChatBox/CursorHandle.qml new file mode 100644 index 000000000..a401f79af --- /dev/null +++ b/imports/NeoChat/Component/ChatBox/CursorHandle.qml @@ -0,0 +1,98 @@ +/* SPDX-FileCopyrightText: 2018 Marco Martin + * SPDX-FileCopyrightText: 2021 Noah Davis + * SPDX-License-Identifier: LGPL-2.0-or-later + */ + +import QtQuick 2.15 +import QtQuick.Window 2.15 +import QtQuick.Templates 2.15 +import org.kde.kirigami 2.14 as Kirigami + +Loader { + id: root + property Item target + property bool isSelectionEnd: false + visible: Kirigami.Settings.tabletMode && target.activeFocus && (isSelectionEnd ? target.selectionStart !== target.selectionEnd : true) + active: visible + sourceComponent: Kirigami.ShadowedRectangle { + id: handle + property real selectionStartX: Math.floor(Qt.inputMethod.anchorRectangle.x + (Qt.inputMethod.cursorRectangle.width - width)/2) + property real selectionStartY: Math.floor(Qt.inputMethod.anchorRectangle.y + Qt.inputMethod.cursorRectangle.height + pointyBitVerticalOffset) + property real selectionEndX: Math.floor(Qt.inputMethod.cursorRectangle.x + (Qt.inputMethod.cursorRectangle.width - width)/2) + property real selectionEndY: Math.floor(Qt.inputMethod.cursorRectangle.y + Qt.inputMethod.cursorRectangle.height + pointyBitVerticalOffset) + property real pointyBitVerticalOffset: Math.abs(pointyBit.y*2) + parent: Overlay.overlay + x: isSelectionEnd ? selectionEndX : selectionStartX + y: isSelectionEnd ? selectionEndY : selectionStartY + + // HACK: make it appear above most popups that show up in the + // overlay in case any of them use TextField or TextArea + z: 999 + + //opacity: target.activeFocus ? 1 : 0 + implicitHeight: { + let h = Kirigami.Units.gridUnit + return h - (h % 2 == 0 ? 1 : 0) + } + implicitWidth: implicitHeight + radius: width/2 + + color: target.selectionColor + + shadow { + color: Qt.rgba(0,0,0,0.2) + size: 3 + yOffset: 1 + } + + Rectangle { + id: pointyBit + x: (parent.width - width)/2 + y: -height/4 + 0.2 // magic number to get it to line up with the edge of the circle + implicitHeight: parent.implicitHeight/2 + implicitWidth: implicitHeight + antialiasing: true + rotation: 45 + color: parent.color + } + + Kirigami.ShadowedRectangle { + id: inner + visible: target.selectionStart !== target.selectionEnd && (handle.y < selectionStartY || handle.y < selectionEndY) + anchors.fill: parent + anchors.margins: Kirigami.Units.smallBorder + color: target.selectedTextColor + radius: height/2 + Rectangle { + id: innerPointyBit + x: (parent.width - width)/2 + y: -height/4 + 0.8 // magic number to get it to line up with the edge of the circle + implicitHeight: pointyBit.implicitHeight + implicitWidth: implicitHeight + antialiasing: true + rotation: 45 + color: parent.color + } + } + + MouseArea { + enabled: handle.visible + anchors.fill: parent + // preventStealing: true + onPositionChanged: { + let pos = mapToItem(root.target, mouse.x, mouse.y); + pos = root.target.positionAt(pos.x, pos.y - handle.height - handle.pointyBitVerticalOffset); + + if (target.selectionStart !== target.selectionEnd) { + if (!isSelectionEnd) { + root.target.select(Math.min(pos, root.target.selectionEnd - 1), root.target.selectionEnd); + } else { + root.target.select(root.target.selectionStart, Math.max(pos, root.target.selectionStart + 1)); + } + } else { + root.target.cursorPosition = pos; + } + } + } + } +} diff --git a/imports/NeoChat/Component/ChatBox/MobileTextActionsToolBar.qml b/imports/NeoChat/Component/ChatBox/MobileTextActionsToolBar.qml new file mode 100644 index 000000000..a159ca8eb --- /dev/null +++ b/imports/NeoChat/Component/ChatBox/MobileTextActionsToolBar.qml @@ -0,0 +1,77 @@ +/* + SPDX-FileCopyrightText: 2018 Marco Martin + + SPDX-License-Identifier: LGPL-2.0-or-later +*/ + +pragma Singleton + +import QtQuick 2.1 +import QtQuick.Layouts 1.2 +import QtQuick.Window 2.2 +import QtQuick.Controls 2.15 +import org.kde.kirigami 2.5 as Kirigami + +Popup { + id: root + + property Item controlRoot + parent: controlRoot ? controlRoot.Window.contentItem : undefined + modal: false + focus: false + closePolicy: Popup.NoAutoClose + property bool shouldBeVisible: false + + x: { + if (!controlRoot || !controlRoot.Window.contentItem) { + return 0; + } + return Math.min(Math.max(0, controlRoot.mapToItem(root.parent, controlRoot.positionToRectangle(controlRoot.selectionStart).x, 0).x - root.width/2), controlRoot.Window.contentItem.width - root.width); + } + + y: { + if (!controlRoot || !controlRoot.Window.contentItem) { + return 0; + } + var desiredY = controlRoot.mapToItem(root.parent, 0, controlRoot.positionToRectangle(controlRoot.selectionStart).y).y - root.height; + + if (desiredY >= 0) { + return Math.min(desiredY, controlRoot.Window.contentItem.height - root.height); + } else { + return Math.min(Math.max(0, controlRoot.mapToItem(root.parent, 0, controlRoot.positionToRectangle(controlRoot.selectionEnd).y + Math.round(Kirigami.Units.gridUnit*1.5)).y), controlRoot.Window.contentItem.height - root.height); + } + } + + + visible: controlRoot ? shouldBeVisible && Qt.platform.os !== "android" && Kirigami.Settings.tabletMode && (controlRoot.selectedText.length > 0 || controlRoot.canPaste) : false + + width: contentItem.implicitWidth + leftPadding + rightPadding + + contentItem: RowLayout { + ToolButton { + focusPolicy: Qt.NoFocus + icon.name: "edit-cut" + visible: controlRoot && controlRoot.selectedText.length > 0 && (!controlRoot.hasOwnProperty("echoMode") || controlRoot.echoMode === TextInput.Normal) + onClicked: { + controlRoot.cut(); + } + } + ToolButton { + focusPolicy: Qt.NoFocus + icon.name: "edit-copy" + visible: controlRoot && controlRoot.selectedText.length > 0 && (!controlRoot.hasOwnProperty("echoMode") || controlRoot.echoMode === TextInput.Normal) + onClicked: { + controlRoot.copy(); + } + } + ToolButton { + focusPolicy: Qt.NoFocus + icon.name: "edit-paste" + visible: controlRoot && controlRoot.canPaste + onClicked: { + controlRoot.paste(); + } + } + } +} + diff --git a/imports/NeoChat/Component/ChatBox/TextFieldContextMenu.qml b/imports/NeoChat/Component/ChatBox/TextFieldContextMenu.qml new file mode 100644 index 000000000..2c70f1e4f --- /dev/null +++ b/imports/NeoChat/Component/ChatBox/TextFieldContextMenu.qml @@ -0,0 +1,259 @@ +/* + SPDX-FileCopyrightText: 2020 Devin Lin + SPDX-FileCopyrightText: 2021 Carl Schwan + + SPDX-License-Identifier: LGPL-2.0-or-later +*/ + +pragma Singleton + +import QtQuick 2.6 +import QtQml 2.2 +import QtQuick.Controls 2.15 +import org.kde.kirigami 2.5 as Kirigami + +Menu { + id: contextMenu + + property Item target + property bool deselectWhenMenuClosed: true + property int restoredCursorPosition: 0 + property int restoredSelectionStart + property int restoredSelectionEnd + property bool persistentSelectionSetting + property var spellcheckhighlighter: null + property var suggestions: ([]) + Component.onCompleted: persistentSelectionSetting = persistentSelectionSetting // break binding + + property var runOnMenuClose + + parent: Overlay.overlay + + function storeCursorAndSelection() { + contextMenu.restoredCursorPosition = target.cursorPosition; + contextMenu.restoredSelectionStart = target.selectionStart; + contextMenu.restoredSelectionEnd = target.selectionEnd; + } + + // target is pressed with mouse + function targetClick(handlerPoint, newTarget, spellcheckhighlighter, mousePosition) { + if (handlerPoint.pressedButtons === Qt.RightButton) { // only accept just right click + if (contextMenu.visible) { + deselectWhenMenuClosed = false; // don't deselect text if menu closed by right click on textfield + dismiss(); + } else { + contextMenu.target = newTarget; + contextMenu.target.persistentSelection = true; // persist selection when menu is opened + contextMenu.spellcheckhighlighter = spellcheckhighlighter + contextMenu.suggestions = spellcheckhighlighter.suggestions(mousePosition); + storeCursorAndSelection(); + popup(contextMenu.target); + // slightly locate context menu away from mouse so no item is selected when menu is opened + x += 1 + y += 1 + } + } else { + dismiss(); + } + } + + // context menu keyboard key + function targetKeyPressed(event, newTarget) { + if (event.modifiers === Qt.NoModifier && event.key === Qt.Key_Menu) { + contextMenu.target = newTarget; + target.persistentSelection = true; // persist selection when menu is opened + storeCursorAndSelection(); + popup(contextMenu.target); + } + } + + readonly property bool targetIsPassword: target !== null && (target.echoMode === TextInput.PasswordEchoOnEdit || target.echoMode === TextInput.Password) + + onAboutToShow: { + if (Overlay.overlay) { + let tempZ = 0 + for (let i in Overlay.overlay.visibleChildren) { + tempZ = Math.max(tempZ, Overlay.overlay.visibleChildren[i].z) + } + z = tempZ + 1 + } + } + + // deal with whether or not text should be deselected + onClosed: { + // restore text field's original persistent selection setting + target.persistentSelection = persistentSelectionSetting + // deselect text field text if menu is closed not because of a right click on the text field + if (deselectWhenMenuClosed) { + target.deselect(); + } + deselectWhenMenuClosed = true; + + // restore cursor position + target.forceActiveFocus(); + target.cursorPosition = restoredCursorPosition; + target.select(restoredSelectionStart, restoredSelectionEnd); + + // run action + runOnMenuClose(); + } + + onOpened: { + runOnMenuClose = function() {}; + } + + Instantiator { + active: target !== null && !target.readOnly && spellcheckhighlighter !== null && spellcheckhighlighter.wordIsMisspelled + model: suggestions + delegate: MenuItem { + text: modelData + onClicked: { + deselectWhenMenuClosed = false; + runOnMenuClose = function() { + spellcheckhighlighter.replaceWord(modelData); + }; + } + } + onObjectAdded: contextMenu.insertItem(0, object) + onObjectRemoved: contextMenu.removeItem(0) + } + + MenuItem { + visible: target !== null && !target.readOnly && spellcheckhighlighter !== null && spellcheckhighlighter.wordIsMisspelled && suggestions.length === 0 + action: Action { + text: spellcheckhighlighter ? i18nc("@action:inmenu", "No suggestions for %1", spellcheckhighlighter.wordUnderMouse) : "" + enabled: false + } + } + + MenuSeparator { + visible: target !== null && !target.readOnly && spellcheckhighlighter !== null && spellcheckhighlighter.wordIsMisspelled + } + + MenuItem { + visible: target !== null && !target.readOnly && spellcheckhighlighter !== null && spellcheckhighlighter.wordIsMisspelled + action: Action { + text: i18n("Add to dictionary") + onTriggered: { + deselectWhenMenuClosed = false; + runOnMenuClose = function() { + spellcheckhighlighter.addWordToDictionary(spellcheckhighlighter.wordUnderMouse) + }; + } + } + } + + MenuItem { + visible: target !== null && !target.readOnly && spellcheckhighlighter !== null && spellcheckhighlighter.wordIsMisspelled + action: Action { + text: i18n("Ignore") + onTriggered: { + deselectWhenMenuClosed = false; + runOnMenuClose = function() { + spellcheckhighlighter.ignoreWord(spellcheckhighlighter.wordUnderMouse) + }; + } + } + } + + MenuSeparator { + visible: target !== null && !target.readOnly && spellcheckhighlighter !== null && spellcheckhighlighter.wordIsMisspelled + } + + MenuItem { + visible: target !== null && !target.readOnly + action: Action { + icon.name: "edit-undo-symbolic" + text: i18nc("@action:inmenu", "Undo") + shortcut: StandardKey.Undo + } + enabled: target !== null && target.canUndo + onTriggered: { + deselectWhenMenuClosed = false; + runOnMenuClose = function() {target.undo()}; + } + } + MenuItem { + visible: target !== null && !target.readOnly + action: Action { + icon.name: "edit-redo-symbolic" + text: i18nc("@action:inmenu", "Redo") + shortcut: StandardKey.Redo + } + enabled: target !== null && target.canRedo + onTriggered: { + deselectWhenMenuClosed = false; + runOnMenuClose = function() {target.redo()}; + } + } + MenuSeparator { + visible: target !== null && !target.readOnly + } + MenuItem { + visible: target !== null && !target.readOnly && !targetIsPassword + action: Action { + icon.name: "edit-cut-symbolic" + text: i18nc("@action:inmenu", "Cut") + shortcut: StandardKey.Cut + } + enabled: target !== null && target.selectedText + onTriggered: { + deselectWhenMenuClosed = false; + runOnMenuClose = function() {target.cut()} + } + } + MenuItem { + action: Action { + icon.name: "edit-copy-symbolic" + text: i18nc("@action:inmenu", "Copy") + shortcut: StandardKey.Copy + } + enabled: target !== null && target.selectedText + visible: !targetIsPassword + onTriggered: { + deselectWhenMenuClosed = false; + runOnMenuClose = function() {target.copy()} + } + } + MenuItem { + visible: target !== null && !target.readOnly + action: Action { + icon.name: "edit-paste-symbolic" + text: i18nc("@action:inmenu", "Paste") + shortcut: StandardKey.Paste + } + enabled: target !== null && target.canPaste + onTriggered: { + deselectWhenMenuClosed = false; + runOnMenuClose = function() {target.paste()}; + } + } + MenuItem { + visible: target !== null && !target.readOnly + action: Action { + icon.name: "edit-delete-symbolic" + text: i18nc("@action:inmenu", "Delete") + shortcut: StandardKey.Delete + } + enabled: target !== null && target.selectedText + onTriggered: { + deselectWhenMenuClosed = false; + runOnMenuClose = function() {target.remove(target.selectionStart, target.selectionEnd)}; + } + } + MenuSeparator { + visible: !targetIsPassword + } + MenuItem { + action: Action { + icon.name: "edit-select-all-symbolic" + text: i18nc("@action:inmenu", "Select All") + shortcut: StandardKey.SelectAll + } + visible: !targetIsPassword + onTriggered: { + deselectWhenMenuClosed = false; + runOnMenuClose = function() {target.selectAll()}; + } + } +} diff --git a/imports/NeoChat/Component/ChatBox/qmldir b/imports/NeoChat/Component/ChatBox/qmldir index 28b7a8a94..8f9b11f86 100644 --- a/imports/NeoChat/Component/ChatBox/qmldir +++ b/imports/NeoChat/Component/ChatBox/qmldir @@ -5,3 +5,7 @@ ReplyPane 1.0 ReplyPane.qml AttachmentPane 1.0 AttachmentPane.qml CompletionMenu 1.0 CompletionMenu.qml EmojiPickerPane 1.0 EmojiPickerPane.qml +singleton TextFieldContextMenu 1.0 TextFieldContextMenu.qml +CursorDelegate 1.0 CursorDelegate.qml +CursorHandle 1.0 CursorHandle.qml +singleton MobileTextActionsToolBar 1.0 MobileTextActionsToolBar.qml diff --git a/imports/NeoChat/Menu/TextContextMenu.qml b/imports/NeoChat/Menu/TextContextMenu.qml new file mode 100644 index 000000000..4c44d415c --- /dev/null +++ b/imports/NeoChat/Menu/TextContextMenu.qml @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2021 Carl Schwan +// SPDX-License-Identifier: LGPL-2.0-or-later + + +/** + * Context menu when clicking on a room in the room list + */ +Menu { + id: root + property var selectedText + + Repeater { + model: WebShortcutModel { + selectedText: root.selectedText + } + delegate: MenuItem { + text: model.display + icon.name: model.decoration + } + } + + MenuSeparator {} + + onClosed: destroy() +} diff --git a/res.qrc b/res.qrc index 24a0e0663..561f70f0e 100644 --- a/res.qrc +++ b/res.qrc @@ -25,6 +25,10 @@ imports/NeoChat/Component/ChatBox/AttachmentPane.qml imports/NeoChat/Component/ChatBox/ReplyPane.qml imports/NeoChat/Component/ChatBox/CompletionMenu.qml + imports/NeoChat/Component/ChatBox/CursorHandle.qml + imports/NeoChat/Component/ChatBox/CursorDelegate.qml + imports/NeoChat/Component/ChatBox/MobileTextActionsToolBar.qml + imports/NeoChat/Component/ChatBox/TextFieldContextMenu.qml imports/NeoChat/Component/ChatBox/qmldir imports/NeoChat/Component/Emoji/EmojiPicker.qml imports/NeoChat/Component/Emoji/qmldir diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 6d2b96d2c..9423a99ca 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -44,7 +44,7 @@ target_sources(neochat PRIVATE ${NEOCHAT_ICON}) if(NOT ANDROID) target_sources(neochat PRIVATE trayicon.cpp colorschemer.cpp spellcheckhighlighter.cpp) - target_link_libraries(neochat PRIVATE KF5::ConfigWidgets KF5::SonnetCore KF5::WindowSystem) + target_link_libraries(neochat PRIVATE KF5::ConfigWidgets KF5::WindowSystem KF5::SonnetCore) target_compile_definitions(neochat PRIVATE -DHAVE_COLORSCHEME) target_compile_definitions(neochat PRIVATE -DHAVE_WINDOWSYSTEM) endif() diff --git a/src/chatdocumenthandler.cpp b/src/chatdocumenthandler.cpp index b748d30fc..764dafc2e 100644 --- a/src/chatdocumenthandler.cpp +++ b/src/chatdocumenthandler.cpp @@ -11,9 +11,6 @@ #include #include "neochatroom.h" -#ifndef Q_OS_ANDROID -#include "spellcheckhighlighter.h" -#endif ChatDocumentHandler::ChatDocumentHandler(QObject *parent) : QObject(parent) @@ -37,9 +34,6 @@ void ChatDocumentHandler::setDocument(QQuickTextDocument *document) m_document->textDocument()->disconnect(this); } m_document = document; -#ifndef Q_OS_ANDROID - new SpellcheckHighlighter(m_document->textDocument()); -#endif Q_EMIT documentChanged(); } diff --git a/src/main.cpp b/src/main.cpp index 45108d75e..6cd684a3b 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -57,6 +57,7 @@ #include "userdirectorylistmodel.h" #include "userlistmodel.h" #include "webshortcutmodel.h" +#include "spellcheckhighlighter.h" #ifdef HAVE_COLORSCHEME #include "colorschemer.h" #endif @@ -158,6 +159,7 @@ int main(int argc, char *argv[]) qmlRegisterType("org.kde.neochat", 1, 0, "AccountListModel"); qmlRegisterType("org.kde.neochat", 1, 0, "ActionsHandler"); qmlRegisterType("org.kde.neochat", 1, 0, "ChatDocumentHandler"); + qmlRegisterType("org.kde.neochat", 1, 0, "SpellcheckHighlighter"); qmlRegisterType("org.kde.neochat", 1, 0, "RoomListModel"); qmlRegisterType("org.kde.neochat", 1, 0, "WebShortcutModel"); qmlRegisterType("org.kde.neochat", 1, 0, "UserListModel"); diff --git a/src/spellcheckhighlighter.cpp b/src/spellcheckhighlighter.cpp index c1f8fa7e8..bafde4a29 100644 --- a/src/spellcheckhighlighter.cpp +++ b/src/spellcheckhighlighter.cpp @@ -1,11 +1,51 @@ -// Copyright (c) 2020 Christian Mollekopf -// SPDX-License-Identifier: LGPL-2.0-or-later +// SPDX-FileCopyrightText: 2013 Aurélien Gâteau +// SPDX-FileCopyrightText: 2020 Christian Mollekopf +// SPDX-FileCopyrightText: 2021 Carl Schwan +// SPDX-License-Identifier: LGPL-2.1-or-later #include "spellcheckhighlighter.h" -#include +#include #include +// Cache of previously-determined languages (when using AutoDetectLanguage) +// There is one such cache per block (paragraph) +class LanguageCache : public QTextBlockUserData +{ +public: + // Key: QPair + // Value: language name + QMap, QString> languages; + + // Remove all cached language information after @p pos + void invalidate(int pos) + { + QMutableMapIterator, QString> it(languages); + it.toBack(); + while (it.hasPrevious()) { + it.previous(); + if (it.key().first + it.key().second >= pos) { + it.remove(); + } else { + break; + } + } + } + + QString languageAtPos(int pos) const + { + // The data structure isn't really great for such lookups... + QMapIterator, QString> it(languages); + while (it.hasNext()) { + it.next(); + if (it.key().first <= pos && it.key().first + it.key().second >= pos) { + return it.value(); + } + } + return QString(); + } +}; + QVector split(QTextBoundaryFinder::BoundaryType boundary, const QString &text, int reasonMask = 0) { QVector parts; @@ -14,7 +54,7 @@ QVector split(QTextBoundaryFinder::BoundaryType boundary, const QStr while (boundaryFinder.position() < text.length()) { const int start = boundaryFinder.position(); - //Advance until we find a break that matches the mask or are at the end + // Advance until we find a break that matches the mask or are at the end for (;;) { if (boundaryFinder.toNextBoundary() == -1) { boundaryFinder.toEnd(); @@ -35,31 +75,36 @@ QVector split(QTextBoundaryFinder::BoundaryType boundary, const QStr return parts; } - -SpellcheckHighlighter::SpellcheckHighlighter(QTextDocument *parent) - : QSyntaxHighlighter(parent), - mSpellchecker{new Sonnet::Speller()}, - mLanguageGuesser{new Sonnet::GuessLanguage()} +SpellcheckHighlighter::SpellcheckHighlighter(QObject *parent) + : QSyntaxHighlighter(parent) +#ifndef Q_OS_ANDROID + , mSpellchecker{new Sonnet::Speller()} + , mLanguageGuesser{new Sonnet::GuessLanguage()} +#endif + , m_document(nullptr) + , m_cursorPosition(-1) { - //Danger red from our color scheme + // Danger red from our color scheme + mErrorFormat.setForeground(QColor{"#ed1515"}); mErrorFormat.setUnderlineColor(QColor{"#ed1515"}); mErrorFormat.setUnderlineStyle(QTextCharFormat::SingleUnderline); mQuoteFormat.setForeground(QColor{"#7f8c8d"}); - - +#ifndef Q_OS_ANDROID if (!mSpellchecker->isValid()) { qWarning() << "Spellchecker is invalid"; } - qDebug() << "Available dictionaries: " << mSpellchecker->availableDictionaries(); +#endif } void SpellcheckHighlighter::autodetectLanguage(const QString &sentence) { +#ifndef Q_OS_ANDROID const auto lang = mLanguageGuesser->identify(sentence, mSpellchecker->availableLanguages()); if (lang.isEmpty()) { return; } mSpellchecker->setLanguage(lang); +#endif } static bool isSpellcheckable(const QStringRef &token) @@ -70,20 +115,21 @@ static bool isSpellcheckable(const QStringRef &token) if (!token.at(0).isLetter() || token.at(0).isUpper() || token.startsWith(QStringLiteral("http"))) { return false; } - //TODO ignore urls and uppercase? + // TODO ignore urls and uppercase? return true; } void SpellcheckHighlighter::highlightBlock(const QString &text) { - //Avoid spellchecking quotes - if (text.isEmpty() || text.at(0) == QChar{'>'}) { + // Avoid spellchecking quotes + if (text.isEmpty() || text.at(0) == QLatin1Char('>')) { setFormat(0, text.length(), mQuoteFormat); return; } +#ifndef Q_OS_ANDROID for (const auto &sentenceRef : split(QTextBoundaryFinder::Sentence, text)) { - //Avoid spellchecking quotes - if (sentenceRef.isEmpty() || sentenceRef.at(0) == QChar{'>'}) { + // Avoid spellchecking quotes + if (sentenceRef.isEmpty() || sentenceRef.at(0) == QLatin1Char('>')) { continue; } @@ -93,8 +139,8 @@ void SpellcheckHighlighter::highlightBlock(const QString &text) const int offset = sentenceRef.position(); for (const auto &wordRef : split(QTextBoundaryFinder::Word, sentence)) { - //Avoid spellchecking words in progress - //FIXME this will also prevent spellchecking a single word on a line. + // Avoid spellchecking words in progress + // FIXME this will also prevent spellchecking a single word on a line. if (offset + wordRef.position() + wordRef.length() >= text.length()) { continue; } @@ -105,4 +151,202 @@ void SpellcheckHighlighter::highlightBlock(const QString &text) } } } +#endif +} + +QStringList SpellcheckHighlighter::suggestions(int mousePosition, int max) +{ +#ifndef Q_OS_ANDROID + QTextCursor cursor = textCursor(); + + QTextCursor cursorAtMouse(textDocument()); + cursorAtMouse.setPosition(mousePosition); + + // Check if the user clicked a selected word + /* clang-format off */ + const bool selectedWordClicked = cursor.hasSelection() + && mousePosition >= cursor.selectionStart() + && mousePosition <= cursor.selectionEnd(); + /* clang-format on */ + + // Get the word under the (mouse-)cursor and see if it is misspelled. + // Don't include apostrophes at the start/end of the word in the selection. + QTextCursor wordSelectCursor(cursorAtMouse); + wordSelectCursor.clearSelection(); + wordSelectCursor.select(QTextCursor::WordUnderCursor); + m_selectedWord = wordSelectCursor.selectedText(); + + // Clear the selection again, we re-select it below (without the apostrophes). + wordSelectCursor.setPosition(wordSelectCursor.position() - m_selectedWord.size()); + if (m_selectedWord.startsWith(QLatin1Char('\'')) || m_selectedWord.startsWith(QLatin1Char('\"'))) { + m_selectedWord = m_selectedWord.right(m_selectedWord.size() - 1); + wordSelectCursor.movePosition(QTextCursor::NextCharacter, QTextCursor::MoveAnchor); + } + if (m_selectedWord.endsWith(QLatin1Char('\'')) || m_selectedWord.endsWith(QLatin1Char('\"'))) { + m_selectedWord.chop(1); + } + + wordSelectCursor.movePosition(QTextCursor::NextCharacter, QTextCursor::KeepAnchor, m_selectedWord.size()); + + int endSelection = wordSelectCursor.selectionEnd(); + Q_EMIT wordUnderMouseChanged(); + + bool isMouseCursorInsideWord = true; + if ((mousePosition < wordSelectCursor.selectionStart() || mousePosition >= wordSelectCursor.selectionEnd()) // + && (m_selectedWord.length() > 1)) { + isMouseCursorInsideWord = false; + } + + wordSelectCursor.movePosition(QTextCursor::NextCharacter, QTextCursor::KeepAnchor, m_selectedWord.size()); + + m_wordIsMisspelled = isMouseCursorInsideWord && !m_selectedWord.isEmpty() && mSpellchecker->isMisspelled(m_selectedWord); + Q_EMIT wordIsMisspelledChanged(); + + if (!m_wordIsMisspelled || selectedWordClicked) { + return QStringList{}; + } + + if (!selectedWordClicked) { + Q_EMIT changeCursorPosition(wordSelectCursor.selectionStart(), endSelection); + } + + LanguageCache *cache = dynamic_cast(cursor.block().userData()); + if (cache) { + const QString cachedLanguage = cache->languageAtPos(cursor.positionInBlock()); + if (!cachedLanguage.isEmpty()) { + mSpellchecker->setLanguage(cachedLanguage); + } + } + QStringList suggestions = mSpellchecker->suggest(m_selectedWord); + if (max >= 0 && suggestions.count() > max) { + suggestions = suggestions.mid(0, max); + } + + return suggestions; +#else + return QStringList(); +#endif +} + +void SpellcheckHighlighter::addWordToDictionary(const QString &word) +{ +#ifndef Q_OS_ANDROID + mSpellchecker->addToPersonal(word); + rehighlight(); +#endif +} + +void SpellcheckHighlighter::ignoreWord(const QString &word) +{ +#ifndef Q_OS_ANDROID + mSpellchecker->addToSession(word); + rehighlight(); +#endif +} + +void SpellcheckHighlighter::replaceWord(const QString &replacement) +{ +#ifndef Q_OS_ANDROID + textCursor().insertText(replacement); +#endif +} + +QQuickTextDocument *SpellcheckHighlighter::quickDocument() const +{ + return m_document; +} + +void SpellcheckHighlighter::setQuickDocument(QQuickTextDocument *document) +{ + if (document == m_document) { + return; + } + + if (m_document) { + m_document->textDocument()->disconnect(this); + } + m_document = document; + setDocument(document->textDocument()); + Q_EMIT documentChanged(); +} + +int SpellcheckHighlighter::cursorPosition() const +{ + return m_cursorPosition; +} + +void SpellcheckHighlighter::setCursorPosition(int position) +{ + if (position == m_cursorPosition) { + return; + } + + m_cursorPosition = position; + Q_EMIT cursorPositionChanged(); +} + +int SpellcheckHighlighter::selectionStart() const +{ + return m_selectionStart; +} + +void SpellcheckHighlighter::setSelectionStart(int position) +{ + if (position == m_selectionStart) { + return; + } + + m_selectionStart = position; + Q_EMIT selectionStartChanged(); +} + +int SpellcheckHighlighter::selectionEnd() const +{ + return m_selectionEnd; +} + +void SpellcheckHighlighter::setSelectionEnd(int position) +{ + if (position == m_selectionEnd) { + return; + } + + m_selectionEnd = position; + Q_EMIT selectionEndChanged(); +} + +QTextCursor SpellcheckHighlighter::textCursor() const +{ + QTextDocument *doc = textDocument(); + if (!doc) { + return QTextCursor(); + } + + QTextCursor cursor(doc); + if (m_selectionStart != m_selectionEnd) { + cursor.setPosition(m_selectionStart); + cursor.setPosition(m_selectionEnd, QTextCursor::KeepAnchor); + } else { + cursor.setPosition(m_cursorPosition); + } + return cursor; +} + +QTextDocument *SpellcheckHighlighter::textDocument() const +{ + if (!m_document) { + return nullptr; + } + + return m_document->textDocument(); +} + +bool SpellcheckHighlighter::wordIsMisspelled() const +{ + return m_wordIsMisspelled; +} + +QString SpellcheckHighlighter::wordUnderMouse() const +{ + return m_selectedWord; } diff --git a/src/spellcheckhighlighter.h b/src/spellcheckhighlighter.h index 5a03ae016..38a1f3bf4 100644 --- a/src/spellcheckhighlighter.h +++ b/src/spellcheckhighlighter.h @@ -1,26 +1,81 @@ -// Copyright (c) 2020 Christian Mollekopf -// SPDX-License-Identifier: LGPL-2.0-or-later +// SPDX-FileCopyrightText: 2013 Aurélien Gâteau +// SPDX-FileCopyrightText: 2020 Christian Mollekopf +// SPDX-FileCopyrightText: 2021 Carl Schwan +// SPDX-License-Identifier: LGPL-2.1-or-later #pragma once -#include +#include #include -#include +#include +#ifndef Q_OS_ANDROID #include +#include +#endif -class SpellcheckHighlighter: public QSyntaxHighlighter +class SpellcheckHighlighter : public QSyntaxHighlighter { + Q_OBJECT + Q_PROPERTY(QQuickTextDocument *document READ quickDocument WRITE setQuickDocument NOTIFY documentChanged) + Q_PROPERTY(int cursorPosition READ cursorPosition WRITE setCursorPosition NOTIFY cursorPositionChanged) + Q_PROPERTY(int selectionStart READ selectionStart WRITE setSelectionStart NOTIFY selectionStartChanged) + Q_PROPERTY(int selectionEnd READ selectionEnd WRITE setSelectionEnd NOTIFY selectionEndChanged) + Q_PROPERTY(bool wordIsMisspelled READ wordIsMisspelled NOTIFY wordIsMisspelledChanged) + Q_PROPERTY(QString wordUnderMouse READ wordUnderMouse NOTIFY wordUnderMouseChanged) + public: - SpellcheckHighlighter(QTextDocument *parent); + SpellcheckHighlighter(QObject *parent = nullptr); + + Q_INVOKABLE QStringList suggestions(int position, int max = 5); + Q_INVOKABLE void ignoreWord(const QString &word); + Q_INVOKABLE void addWordToDictionary(const QString &word); + Q_INVOKABLE void replaceWord(const QString &word); + + [[nodiscard]] QQuickTextDocument *quickDocument() const; + void setQuickDocument(QQuickTextDocument *document); + + [[nodiscard]] int cursorPosition() const; + void setCursorPosition(int position); + + [[nodiscard]] int selectionStart() const; + void setSelectionStart(int position); + + [[nodiscard]] int selectionEnd() const; + void setSelectionEnd(int position); + + [[nodiscard]] bool wordIsMisspelled() const; + [[nodiscard]] QString wordUnderMouse() const; protected: void highlightBlock(const QString &text) override; +Q_SIGNALS: + void documentChanged(); + void cursorPositionChanged(); + void selectionStartChanged(); + void selectionEndChanged(); + void wordIsMisspelledChanged(); + void wordUnderMouseChanged(); + void changeCursorPosition(int start, int end); + private: + [[nodiscard]] QTextCursor textCursor() const; + [[nodiscard]] QTextDocument *textDocument() const; + void autodetectLanguage(const QString &sentence); QTextCharFormat mErrorFormat; QTextCharFormat mQuoteFormat; +#ifndef Q_OS_ANDROID QScopedPointer mSpellchecker; QScopedPointer mLanguageGuesser; -}; +#endif + QString m_selectedWord; + QQuickTextDocument *m_document; + int m_cursorPosition; + int m_selectionStart; + int m_selectionEnd; + int m_autoCompleteBeginPosition = -1; + int m_autoCompleteEndPosition = -1; + int m_wordIsMisspelled = false; +};