Add spellchecking suggestions

This commit is contained in:
Carl Schwan
2021-06-10 11:29:59 +00:00
parent 5cb8424a83
commit 0ff9425fee
14 changed files with 965 additions and 36 deletions

View File

@@ -68,7 +68,7 @@ if(ANDROID)
) )
else() else()
find_package(Qt5 ${QT_MIN_VERSION} COMPONENTS Widgets) 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 set_package_properties(KF5QQC2DesktopStyle PROPERTIES
TYPE RUNTIME TYPE RUNTIME
) )

View File

@@ -5,7 +5,9 @@
import QtQuick 2.15 import QtQuick 2.15
import QtQuick.Layouts 1.15 import QtQuick.Layouts 1.15
import QtQuick.Controls 2.15 import QtQuick.Controls 2.15
import QtQuick.Templates 2.15 as T
import Qt.labs.platform 1.1 as Platform import Qt.labs.platform 1.1 as Platform
import QtQuick.Window 2.15
import org.kde.kirigami 2.15 as Kirigami import org.kde.kirigami 2.15 as Kirigami
@@ -66,7 +68,8 @@ ToolBar {
id: fontMetrics id: fontMetrics
font: inputField.font font: inputField.font
} }
TextArea {
T.TextArea {
id: inputField id: inputField
focus: true focus: true
/* Some QQC2 styles will have their own predefined backgrounds for TextAreas. /* Some QQC2 styles will have their own predefined backgrounds for TextAreas.
@@ -81,6 +84,7 @@ ToolBar {
cursorShape: Qt.IBeamCursor cursorShape: Qt.IBeamCursor
z: 1 z: 1
} }
leftPadding: mirrored ? 0 : Kirigami.Units.largeSpacing leftPadding: mirrored ? 0 : Kirigami.Units.largeSpacing
rightPadding: !mirrored ? 0 : Kirigami.Units.largeSpacing rightPadding: !mirrored ? 0 : Kirigami.Units.largeSpacing
topPadding: 0 topPadding: 0
@@ -97,6 +101,86 @@ ToolBar {
wrapMode: Text.Wrap wrapMode: Text.Wrap
readOnly: currentRoom.usesEncryption 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 { ChatDocumentHandler {
id: documentHandler id: documentHandler
@@ -107,6 +191,18 @@ ToolBar {
room: currentRoom ?? null 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 { Timer {
id: timeoutTimer id: timeoutTimer
repeat: false repeat: false
@@ -147,6 +243,9 @@ ToolBar {
} }
Keys.onPressed: { Keys.onPressed: {
// trigger if context menu button is pressed
TextFieldContextMenu.targetKeyPressed(event, inputField)
if (event.key === Qt.Key_PageDown) { if (event.key === Qt.Key_PageDown) {
switchRoomDown(); switchRoomDown();
} else if (event.key === Qt.Key_PageUp) { } else if (event.key === Qt.Key_PageUp) {
@@ -211,7 +310,10 @@ ToolBar {
chatBar.complete(); chatBar.complete();
} }
onPressed: MobileTextActionsToolBar.shouldBeVisible = true;
onTextChanged: { onTextChanged: {
MobileTextActionsToolBar.shouldBeVisible = false;
timeoutTimer.restart() timeoutTimer.restart()
repeatTimer.start() repeatTimer.start()
currentRoom.cachedInput = text currentRoom.cachedInput = text

View File

@@ -0,0 +1,65 @@
/* SPDX-FileCopyrightText: 2018 Marco Martin <mart@kde.org>
* SPDX-FileCopyrightText: 2021 Noah Davis <noahadvs@gmail.com>
* 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()
}
}
}

View File

@@ -0,0 +1,98 @@
/* SPDX-FileCopyrightText: 2018 Marco Martin <mart@kde.org>
* SPDX-FileCopyrightText: 2021 Noah Davis <noahadvs@gmail.com>
* 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;
}
}
}
}
}

View File

@@ -0,0 +1,77 @@
/*
SPDX-FileCopyrightText: 2018 Marco Martin <mart@kde.org>
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();
}
}
}
}

View File

@@ -0,0 +1,259 @@
/*
SPDX-FileCopyrightText: 2020 Devin Lin <espidev@gmail.com>
SPDX-FileCopyrightText: 2021 Carl Schwan <carlschwan@kde.org>
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()};
}
}
}

View File

@@ -5,3 +5,7 @@ ReplyPane 1.0 ReplyPane.qml
AttachmentPane 1.0 AttachmentPane.qml AttachmentPane 1.0 AttachmentPane.qml
CompletionMenu 1.0 CompletionMenu.qml CompletionMenu 1.0 CompletionMenu.qml
EmojiPickerPane 1.0 EmojiPickerPane.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

View File

@@ -0,0 +1,25 @@
// SPDX-FileCopyrightText: 2021 Carl Schwan <carl@carlschwan.eu>
// 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()
}

View File

@@ -25,6 +25,10 @@
<file>imports/NeoChat/Component/ChatBox/AttachmentPane.qml</file> <file>imports/NeoChat/Component/ChatBox/AttachmentPane.qml</file>
<file>imports/NeoChat/Component/ChatBox/ReplyPane.qml</file> <file>imports/NeoChat/Component/ChatBox/ReplyPane.qml</file>
<file>imports/NeoChat/Component/ChatBox/CompletionMenu.qml</file> <file>imports/NeoChat/Component/ChatBox/CompletionMenu.qml</file>
<file>imports/NeoChat/Component/ChatBox/CursorHandle.qml</file>
<file>imports/NeoChat/Component/ChatBox/CursorDelegate.qml</file>
<file>imports/NeoChat/Component/ChatBox/MobileTextActionsToolBar.qml</file>
<file>imports/NeoChat/Component/ChatBox/TextFieldContextMenu.qml</file>
<file>imports/NeoChat/Component/ChatBox/qmldir</file> <file>imports/NeoChat/Component/ChatBox/qmldir</file>
<file>imports/NeoChat/Component/Emoji/EmojiPicker.qml</file> <file>imports/NeoChat/Component/Emoji/EmojiPicker.qml</file>
<file>imports/NeoChat/Component/Emoji/qmldir</file> <file>imports/NeoChat/Component/Emoji/qmldir</file>

View File

@@ -44,7 +44,7 @@ target_sources(neochat PRIVATE ${NEOCHAT_ICON})
if(NOT ANDROID) if(NOT ANDROID)
target_sources(neochat PRIVATE trayicon.cpp colorschemer.cpp spellcheckhighlighter.cpp) 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_COLORSCHEME)
target_compile_definitions(neochat PRIVATE -DHAVE_WINDOWSYSTEM) target_compile_definitions(neochat PRIVATE -DHAVE_WINDOWSYSTEM)
endif() endif()

View File

@@ -11,9 +11,6 @@
#include <QTextDocument> #include <QTextDocument>
#include "neochatroom.h" #include "neochatroom.h"
#ifndef Q_OS_ANDROID
#include "spellcheckhighlighter.h"
#endif
ChatDocumentHandler::ChatDocumentHandler(QObject *parent) ChatDocumentHandler::ChatDocumentHandler(QObject *parent)
: QObject(parent) : QObject(parent)
@@ -37,9 +34,6 @@ void ChatDocumentHandler::setDocument(QQuickTextDocument *document)
m_document->textDocument()->disconnect(this); m_document->textDocument()->disconnect(this);
} }
m_document = document; m_document = document;
#ifndef Q_OS_ANDROID
new SpellcheckHighlighter(m_document->textDocument());
#endif
Q_EMIT documentChanged(); Q_EMIT documentChanged();
} }

View File

@@ -57,6 +57,7 @@
#include "userdirectorylistmodel.h" #include "userdirectorylistmodel.h"
#include "userlistmodel.h" #include "userlistmodel.h"
#include "webshortcutmodel.h" #include "webshortcutmodel.h"
#include "spellcheckhighlighter.h"
#ifdef HAVE_COLORSCHEME #ifdef HAVE_COLORSCHEME
#include "colorschemer.h" #include "colorschemer.h"
#endif #endif
@@ -158,6 +159,7 @@ int main(int argc, char *argv[])
qmlRegisterType<AccountListModel>("org.kde.neochat", 1, 0, "AccountListModel"); qmlRegisterType<AccountListModel>("org.kde.neochat", 1, 0, "AccountListModel");
qmlRegisterType<ActionsHandler>("org.kde.neochat", 1, 0, "ActionsHandler"); qmlRegisterType<ActionsHandler>("org.kde.neochat", 1, 0, "ActionsHandler");
qmlRegisterType<ChatDocumentHandler>("org.kde.neochat", 1, 0, "ChatDocumentHandler"); qmlRegisterType<ChatDocumentHandler>("org.kde.neochat", 1, 0, "ChatDocumentHandler");
qmlRegisterType<SpellcheckHighlighter>("org.kde.neochat", 1, 0, "SpellcheckHighlighter");
qmlRegisterType<RoomListModel>("org.kde.neochat", 1, 0, "RoomListModel"); qmlRegisterType<RoomListModel>("org.kde.neochat", 1, 0, "RoomListModel");
qmlRegisterType<KWebShortcutModel>("org.kde.neochat", 1, 0, "WebShortcutModel"); qmlRegisterType<KWebShortcutModel>("org.kde.neochat", 1, 0, "WebShortcutModel");
qmlRegisterType<UserListModel>("org.kde.neochat", 1, 0, "UserListModel"); qmlRegisterType<UserListModel>("org.kde.neochat", 1, 0, "UserListModel");

View File

@@ -1,11 +1,51 @@
// Copyright (c) 2020 Christian Mollekopf <mollekopf@kolabsystems.com> // SPDX-FileCopyrightText: 2013 Aurélien Gâteau <agateau@kde.org>
// SPDX-License-Identifier: LGPL-2.0-or-later // SPDX-FileCopyrightText: 2020 Christian Mollekopf <mollekopf@kolabsystems.com>
// SPDX-FileCopyrightText: 2021 Carl Schwan <carlschwan@kde.org>
// SPDX-License-Identifier: LGPL-2.1-or-later
#include "spellcheckhighlighter.h" #include "spellcheckhighlighter.h"
#include <QDebug> #include <QHash>
#include <QTextBoundaryFinder> #include <QTextBoundaryFinder>
// Cache of previously-determined languages (when using AutoDetectLanguage)
// There is one such cache per block (paragraph)
class LanguageCache : public QTextBlockUserData
{
public:
// Key: QPair<start, length>
// Value: language name
QMap<QPair<int, int>, QString> languages;
// Remove all cached language information after @p pos
void invalidate(int pos)
{
QMutableMapIterator<QPair<int, int>, 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<QPair<int, int>, 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<QStringRef> split(QTextBoundaryFinder::BoundaryType boundary, const QString &text, int reasonMask = 0) QVector<QStringRef> split(QTextBoundaryFinder::BoundaryType boundary, const QString &text, int reasonMask = 0)
{ {
QVector<QStringRef> parts; QVector<QStringRef> parts;
@@ -14,7 +54,7 @@ QVector<QStringRef> split(QTextBoundaryFinder::BoundaryType boundary, const QStr
while (boundaryFinder.position() < text.length()) { while (boundaryFinder.position() < text.length()) {
const int start = boundaryFinder.position(); 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 (;;) { for (;;) {
if (boundaryFinder.toNextBoundary() == -1) { if (boundaryFinder.toNextBoundary() == -1) {
boundaryFinder.toEnd(); boundaryFinder.toEnd();
@@ -35,31 +75,36 @@ QVector<QStringRef> split(QTextBoundaryFinder::BoundaryType boundary, const QStr
return parts; return parts;
} }
SpellcheckHighlighter::SpellcheckHighlighter(QObject *parent)
SpellcheckHighlighter::SpellcheckHighlighter(QTextDocument *parent) : QSyntaxHighlighter(parent)
: QSyntaxHighlighter(parent), #ifndef Q_OS_ANDROID
mSpellchecker{new Sonnet::Speller()}, , mSpellchecker{new Sonnet::Speller()}
mLanguageGuesser{new Sonnet::GuessLanguage()} , 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.setUnderlineColor(QColor{"#ed1515"});
mErrorFormat.setUnderlineStyle(QTextCharFormat::SingleUnderline); mErrorFormat.setUnderlineStyle(QTextCharFormat::SingleUnderline);
mQuoteFormat.setForeground(QColor{"#7f8c8d"}); mQuoteFormat.setForeground(QColor{"#7f8c8d"});
#ifndef Q_OS_ANDROID
if (!mSpellchecker->isValid()) { if (!mSpellchecker->isValid()) {
qWarning() << "Spellchecker is invalid"; qWarning() << "Spellchecker is invalid";
} }
qDebug() << "Available dictionaries: " << mSpellchecker->availableDictionaries(); #endif
} }
void SpellcheckHighlighter::autodetectLanguage(const QString &sentence) void SpellcheckHighlighter::autodetectLanguage(const QString &sentence)
{ {
#ifndef Q_OS_ANDROID
const auto lang = mLanguageGuesser->identify(sentence, mSpellchecker->availableLanguages()); const auto lang = mLanguageGuesser->identify(sentence, mSpellchecker->availableLanguages());
if (lang.isEmpty()) { if (lang.isEmpty()) {
return; return;
} }
mSpellchecker->setLanguage(lang); mSpellchecker->setLanguage(lang);
#endif
} }
static bool isSpellcheckable(const QStringRef &token) 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"))) { if (!token.at(0).isLetter() || token.at(0).isUpper() || token.startsWith(QStringLiteral("http"))) {
return false; return false;
} }
//TODO ignore urls and uppercase? // TODO ignore urls and uppercase?
return true; return true;
} }
void SpellcheckHighlighter::highlightBlock(const QString &text) void SpellcheckHighlighter::highlightBlock(const QString &text)
{ {
//Avoid spellchecking quotes // Avoid spellchecking quotes
if (text.isEmpty() || text.at(0) == QChar{'>'}) { if (text.isEmpty() || text.at(0) == QLatin1Char('>')) {
setFormat(0, text.length(), mQuoteFormat); setFormat(0, text.length(), mQuoteFormat);
return; return;
} }
#ifndef Q_OS_ANDROID
for (const auto &sentenceRef : split(QTextBoundaryFinder::Sentence, text)) { for (const auto &sentenceRef : split(QTextBoundaryFinder::Sentence, text)) {
//Avoid spellchecking quotes // Avoid spellchecking quotes
if (sentenceRef.isEmpty() || sentenceRef.at(0) == QChar{'>'}) { if (sentenceRef.isEmpty() || sentenceRef.at(0) == QLatin1Char('>')) {
continue; continue;
} }
@@ -93,8 +139,8 @@ void SpellcheckHighlighter::highlightBlock(const QString &text)
const int offset = sentenceRef.position(); const int offset = sentenceRef.position();
for (const auto &wordRef : split(QTextBoundaryFinder::Word, sentence)) { for (const auto &wordRef : split(QTextBoundaryFinder::Word, sentence)) {
//Avoid spellchecking words in progress // Avoid spellchecking words in progress
//FIXME this will also prevent spellchecking a single word on a line. // FIXME this will also prevent spellchecking a single word on a line.
if (offset + wordRef.position() + wordRef.length() >= text.length()) { if (offset + wordRef.position() + wordRef.length() >= text.length()) {
continue; 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<LanguageCache *>(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;
} }

View File

@@ -1,26 +1,81 @@
// Copyright (c) 2020 Christian Mollekopf <mollekopf@kolabsystems.com> // SPDX-FileCopyrightText: 2013 Aurélien Gâteau <agateau@kde.org>
// SPDX-License-Identifier: LGPL-2.0-or-later // SPDX-FileCopyrightText: 2020 Christian Mollekopf <mollekopf@kolabsystems.com>
// SPDX-FileCopyrightText: 2021 Carl Schwan <carlschwan@kde.org>
// SPDX-License-Identifier: LGPL-2.1-or-later
#pragma once #pragma once
#include <QTextDocument> #include <QQuickTextDocument>
#include <QSyntaxHighlighter> #include <QSyntaxHighlighter>
#include <Sonnet/Speller> #include <QTextDocument>
#ifndef Q_OS_ANDROID
#include <Sonnet/GuessLanguage> #include <Sonnet/GuessLanguage>
#include <Sonnet/Speller>
#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: 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: protected:
void highlightBlock(const QString &text) override; 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: private:
[[nodiscard]] QTextCursor textCursor() const;
[[nodiscard]] QTextDocument *textDocument() const;
void autodetectLanguage(const QString &sentence); void autodetectLanguage(const QString &sentence);
QTextCharFormat mErrorFormat; QTextCharFormat mErrorFormat;
QTextCharFormat mQuoteFormat; QTextCharFormat mQuoteFormat;
#ifndef Q_OS_ANDROID
QScopedPointer<Sonnet::Speller> mSpellchecker; QScopedPointer<Sonnet::Speller> mSpellchecker;
QScopedPointer<Sonnet::GuessLanguage> mLanguageGuesser; QScopedPointer<Sonnet::GuessLanguage> 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;
};