diff --git a/edbee-lib/edbee/views/components/texteditorautocompletecomponent.cpp b/edbee-lib/edbee/views/components/texteditorautocompletecomponent.cpp index 3c23332..868c496 100644 --- a/edbee-lib/edbee/views/components/texteditorautocompletecomponent.cpp +++ b/edbee-lib/edbee/views/components/texteditorautocompletecomponent.cpp @@ -50,10 +50,15 @@ TextEditorAutoCompleteComponent::TextEditorAutoCompleteComponent(TextEditorContr this->setAttribute(Qt::WA_ShowWithoutActivating); menuRef_ = new QMenu(this); + menuRef_->setFocusPolicy(Qt::NoFocus); + menuRef_->setAttribute(Qt::WA_ShowWithoutActivating); menuRef_->setAccessibleName("Autocomplete"); listWidgetRef_ = new QListWidget(menuRef_); + listWidgetRef_->setFocusPolicy(Qt::NoFocus); + listWidgetRef_->setAttribute(Qt::WA_ShowWithoutActivating); + editorComponentRef_->installEventFilter(this); listWidgetRef_->installEventFilter(this); menuRef_->installEventFilter(this); @@ -327,6 +332,16 @@ void TextEditorAutoCompleteComponent::hideEvent(QHideEvent* event) } +void TextEditorAutoCompleteComponent::sendKeyEventTo(QWidget* target, QKeyEvent* sourceEvent) +{ + QKeyEvent event(sourceEvent->type(), sourceEvent->key(), sourceEvent->modifiers(), sourceEvent->text(), sourceEvent->isAutoRepeat(), sourceEvent->count()); + + eventBeingFiltered_ = true; + QApplication::sendEvent(target, &event); + eventBeingFiltered_ = false; +} + + /// we need to intercept keypresses if the widget is visible bool TextEditorAutoCompleteComponent::eventFilter(QObject *obj, QEvent *event) { @@ -336,14 +351,22 @@ bool TextEditorAutoCompleteComponent::eventFilter(QObject *obj, QEvent *event) return QObject::eventFilter(obj, event); } - if(obj == listWidgetRef_ && event->type() == QEvent::KeyPress) { + if (eventBeingFiltered_) { + return QObject::eventFilter(obj, event); + } + + if ((obj == editorComponentRef_ || obj == listWidgetRef_ || obj == menuRef_) && event->type() == QEvent::KeyPress && menuRef_->isVisible()) { QKeyEvent* key = static_cast(event); + const bool editorHasEvent = obj == editorComponentRef_; // text keys are allowed if (!key->text().isEmpty()) { QChar nextChar = key->text().at(0); if (nextChar.isLetterOrNumber()) { - QApplication::sendEvent(editorComponentRef_, event); + if (editorHasEvent) { + return false; + } + sendKeyEventTo(editorComponentRef_, key); return true; } } @@ -360,7 +383,10 @@ bool TextEditorAutoCompleteComponent::eventFilter(QObject *obj, QEvent *event) case Qt::Key_Tab: if (listWidgetRef_->currentItem() && currentWord_ == listWidgetRef_->currentItem()->text()) { // sends normal enter/return/tab if you've typed a full word menuRef_->close(); - QApplication::sendEvent(editorComponentRef_, event); + if (editorHasEvent) { + return false; + } + sendKeyEventTo(editorComponentRef_, key); return true; } else if (listWidgetRef_->currentItem()) { insertCurrentSelectedListItem(); @@ -371,11 +397,17 @@ bool TextEditorAutoCompleteComponent::eventFilter(QObject *obj, QEvent *event) break; case Qt::Key_Backspace: - QApplication::sendEvent(editorComponentRef_, event); + if (editorHasEvent) { + return false; + } + sendKeyEventTo(editorComponentRef_, key); return true; case Qt::Key_Shift: //ignore shift, don't hide - QApplication::sendEvent(editorComponentRef_, event); + if (editorHasEvent) { + return false; + } + sendKeyEventTo(editorComponentRef_, key); return true; // forward special keys to list @@ -383,12 +415,19 @@ bool TextEditorAutoCompleteComponent::eventFilter(QObject *obj, QEvent *event) case Qt::Key_Down: case Qt::Key_PageDown: case Qt::Key_PageUp: + if (editorHasEvent || obj == menuRef_) { + sendKeyEventTo(listWidgetRef_, key); + return true; + } return false; } // default operation is to hide and continue the event menuRef_->close(); - QApplication::sendEvent(editorComponentRef_, event); + if (editorHasEvent) { + return false; + } + sendKeyEventTo(editorComponentRef_, key); return true; } @@ -428,7 +467,6 @@ void TextEditorAutoCompleteComponent::updateList() // fills the autocomplete list with the curent word if (fillAutoCompleteList(doc, range, currentWord_)) { menuRef_->popup(menuRef_->pos()); - listWidgetRef_->setFocus(); // position the widget showInfoTip(); diff --git a/edbee-lib/edbee/views/components/texteditorautocompletecomponent.h b/edbee-lib/edbee/views/components/texteditorautocompletecomponent.h index c735f2e..8fc264f 100644 --- a/edbee-lib/edbee/views/components/texteditorautocompletecomponent.h +++ b/edbee-lib/edbee/views/components/texteditorautocompletecomponent.h @@ -18,6 +18,7 @@ class QListWidget; class QListWidgetItem; +class QKeyEvent; namespace edbee { @@ -76,6 +77,7 @@ class EDBEE_EXPORT TextEditorAutoCompleteComponent : public QWidget //void moveEvent(QMoveEvent *event); void insertCurrentSelectedListItem(); + void sendKeyEventTo(QWidget* target, QKeyEvent* sourceEvent); signals: public slots: diff --git a/edbee-test/CMakeLists.txt b/edbee-test/CMakeLists.txt index 536b63c..b0d5b7c 100644 --- a/edbee-test/CMakeLists.txt +++ b/edbee-test/CMakeLists.txt @@ -37,6 +37,7 @@ SET(SOURCES edbee/util/rangesetlineiteratortest.cpp edbee/models/dynamicvariablestest.cpp edbee/util/rangelineiteratortest.cpp + edbee/views/texteditorautocompletecomponenttest.cpp edbee/views/textthememanagertest.cpp ) @@ -67,6 +68,7 @@ SET(HEADERS edbee/util/rangesetlineiteratortest.h edbee/models/dynamicvariablestest.h edbee/util/rangelineiteratortest.h + edbee/views/texteditorautocompletecomponenttest.h edbee/views/textthememanagertest.h ) diff --git a/edbee-test/edbee-test.pro b/edbee-test/edbee-test.pro index e80b4bd..54307a7 100644 --- a/edbee-test/edbee-test.pro +++ b/edbee-test/edbee-test.pro @@ -50,6 +50,7 @@ SOURCES += \ edbee/util/rangesetlineiteratortest.cpp \ edbee/models/dynamicvariablestest.cpp \ edbee/util/rangelineiteratortest.cpp \ + edbee/views/texteditorautocompletecomponenttest.cpp \ edbee/views/textthememanagertest.cpp HEADERS += \ @@ -79,6 +80,7 @@ HEADERS += \ edbee/util/rangesetlineiteratortest.h \ edbee/models/dynamicvariablestest.h \ edbee/util/rangelineiteratortest.h \ + edbee/views/texteditorautocompletecomponenttest.h \ edbee/views/textthememanagertest.h ##OTHER_FILES += ../edbee-data/config/* diff --git a/edbee-test/edbee/views/texteditorautocompletecomponenttest.cpp b/edbee-test/edbee/views/texteditorautocompletecomponenttest.cpp new file mode 100644 index 0000000..3639987 --- /dev/null +++ b/edbee-test/edbee/views/texteditorautocompletecomponenttest.cpp @@ -0,0 +1,245 @@ +// edbee - Copyright (c) 2012-2025 by Rick Blommers and contributors +// SPDX-License-Identifier: MIT + +#include "texteditorautocompletecomponenttest.h" + +#include +#include +#include +#include +#include +#include + +#include "edbee/models/textautocompleteprovider.h" +#include "edbee/models/textdocument.h" +#include "edbee/texteditorcontroller.h" +#include "edbee/texteditorwidget.h" +#include "edbee/views/components/texteditorautocompletecomponent.h" +#include "edbee/views/components/texteditorcomponent.h" + +namespace edbee { + +namespace { + +class AutoCompleteFixture +{ +public: + AutoCompleteFixture() + : widget() + , provider(new StringTextAutoCompleteProvider()) + , editor(widget.textEditorComponent()) + , autocomplete(widget.autoCompleteComponent()) + , list(autocomplete->listWidget()) + { + widget.resize(640, 320); + widget.show(); + + provider->add("compare"); + provider->add("complete"); + provider->add("compose"); + widget.textDocument()->autoCompleteProviderList()->giveProvider(provider); + + processEvents(); + } + + void typePrefix(const QString& prefix) + { + editor->setFocus(); + widget.controller()->replaceSelection(prefix); + autocomplete->updateList(); + processEvents(); + } + + void sendEditorKey(int key, const QString& text = QString()) + { + QKeyEvent keyEvent(QEvent::KeyPress, key, Qt::NoModifier, text); + QApplication::sendEvent(editor, &keyEvent); + processEvents(); + } + + void sendListKey(int key, const QString& text = QString()) + { + QKeyEvent keyEvent(QEvent::KeyPress, key, Qt::NoModifier, text); + QApplication::sendEvent(list, &keyEvent); + processEvents(); + } + + void processEvents() + { + QApplication::processEvents(); + } + + TextEditorWidget widget; + StringTextAutoCompleteProvider* provider; + TextEditorComponent* editor; + TextEditorAutoCompleteComponent* autocomplete; + QListWidget* list; +}; + +} // namespace + +void TextEditorAutoCompleteComponentTest::openingAutocompleteKeepsEditorFocused() +{ + AutoCompleteFixture fixture; + fixture.typePrefix("com"); + + testEqual(fixture.widget.textDocument()->text(), "com"); + testEqual(fixture.list->count(), 3); + testTrue(fixture.list->isVisible()); + testEqual(fixture.list->focusPolicy(), Qt::NoFocus); + testTrue(fixture.list->testAttribute(Qt::WA_ShowWithoutActivating)); + testTrue(fixture.editor->hasFocus()); + testFalse(fixture.list->hasFocus()); +} + + +void TextEditorAutoCompleteComponentTest::typingContinuesThroughEditorWhenAutocompleteIsVisible() +{ + AutoCompleteFixture fixture; + fixture.typePrefix("com"); + + fixture.sendEditorKey(Qt::Key_P, "p"); + + testEqual(fixture.widget.textDocument()->text(), "comp"); + testTrue(fixture.editor->hasFocus()); + testFalse(fixture.list->hasFocus()); +} + + +void TextEditorAutoCompleteComponentTest::listKeyEventsForwardTextBackToEditor() +{ + AutoCompleteFixture fixture; + fixture.typePrefix("com"); + + fixture.sendListKey(Qt::Key_P, "p"); + + testEqual(fixture.widget.textDocument()->text(), "comp"); + testTrue(fixture.editor->hasFocus()); + testFalse(fixture.list->hasFocus()); +} + + +void TextEditorAutoCompleteComponentTest::navigationKeysMoveSelectionWithoutListFocus() +{ + AutoCompleteFixture fixture; + fixture.typePrefix("com"); + + testEqual(fixture.list->currentRow(), 0); + + fixture.sendEditorKey(Qt::Key_Down); + + testEqual(fixture.list->currentRow(), 1); + testEqual(fixture.widget.textDocument()->text(), "com"); + testTrue(fixture.editor->hasFocus()); + testFalse(fixture.list->hasFocus()); +} + + +void TextEditorAutoCompleteComponentTest::enterAcceptsSuggestionFromEditorFocus() +{ + AutoCompleteFixture fixture; + fixture.typePrefix("com"); + + fixture.sendEditorKey(Qt::Key_Down); + const QString selectedText = fixture.list->currentItem()->text(); + fixture.sendEditorKey(Qt::Key_Return); + + testEqual(fixture.widget.textDocument()->text(), selectedText); + testTrue(fixture.editor->hasFocus()); + testFalse(fixture.list->hasFocus()); +} + + +void TextEditorAutoCompleteComponentTest::fullWordEnterFallsThroughToEditor() +{ + AutoCompleteFixture fixture; + fixture.provider->add("compareTo"); + fixture.typePrefix("compare"); + + testTrue(fixture.list->isVisible()); + testEqual(fixture.list->currentItem()->text(), "compare"); + + fixture.sendEditorKey(Qt::Key_Return); + + testEqual(fixture.widget.textDocument()->text(), "compare\n"); + testTrue(fixture.editor->hasFocus()); + testFalse(fixture.list->hasFocus()); +} + + +void TextEditorAutoCompleteComponentTest::escapeCancelsAutocompleteUntilWordIsCleared() +{ + AutoCompleteFixture fixture; + fixture.typePrefix("com"); + + fixture.sendEditorKey(Qt::Key_Escape); + fixture.autocomplete->updateList(); + fixture.processEvents(); + + testFalse(fixture.list->isVisible()); + testTrue(fixture.editor->hasFocus()); + + fixture.sendEditorKey(Qt::Key_P, "p"); + fixture.autocomplete->updateList(); + fixture.processEvents(); + + testEqual(fixture.widget.textDocument()->text(), "comp"); + testFalse(fixture.list->isVisible()); + + fixture.sendEditorKey(Qt::Key_Backspace); + fixture.sendEditorKey(Qt::Key_Backspace); + fixture.sendEditorKey(Qt::Key_Backspace); + fixture.sendEditorKey(Qt::Key_Backspace); + fixture.autocomplete->updateList(); + fixture.processEvents(); + + testEqual(fixture.widget.textDocument()->text(), ""); + + fixture.sendEditorKey(Qt::Key_C, "c"); + fixture.sendEditorKey(Qt::Key_O, "o"); + fixture.sendEditorKey(Qt::Key_M, "m"); + + testEqual(fixture.widget.textDocument()->text(), "com"); + testTrue(fixture.list->isVisible()); +} + + +void TextEditorAutoCompleteComponentTest::hidingAutocompleteDoesNotStealFocusFromSiblingWidget() +{ + QWidget container; + QVBoxLayout layout(&container); + TextEditorWidget* widget = new TextEditorWidget(&container); + QLineEdit* sibling = new QLineEdit(&container); + + layout.addWidget(widget); + layout.addWidget(sibling); + container.resize(640, 360); + container.show(); + QApplication::processEvents(); + + StringTextAutoCompleteProvider* provider = new StringTextAutoCompleteProvider(); + provider->add("compare"); + widget->textDocument()->autoCompleteProviderList()->giveProvider(provider); + + TextEditorComponent* editor = widget->textEditorComponent(); + QListWidget* list = widget->autoCompleteComponent()->listWidget(); + + editor->setFocus(); + widget->controller()->replaceSelection("com"); + widget->autoCompleteComponent()->updateList(); + QApplication::processEvents(); + + testTrue(editor->hasFocus()); + testTrue(list->isVisible()); + + sibling->setFocus(); + QKeyEvent escapeKey(QEvent::KeyPress, Qt::Key_Escape, Qt::NoModifier); + QApplication::sendEvent(list, &escapeKey); + QApplication::processEvents(); + + testTrue(sibling->hasFocus()); + testFalse(editor->hasFocus()); + testFalse(list->hasFocus()); +} + +} // edbee diff --git a/edbee-test/edbee/views/texteditorautocompletecomponenttest.h b/edbee-test/edbee/views/texteditorautocompletecomponenttest.h new file mode 100644 index 0000000..986d107 --- /dev/null +++ b/edbee-test/edbee/views/texteditorautocompletecomponenttest.h @@ -0,0 +1,27 @@ +// edbee - Copyright (c) 2012-2025 by Rick Blommers and contributors +// SPDX-License-Identifier: MIT + +#pragma once + +#include "edbee/util/test.h" + +namespace edbee { + +class TextEditorAutoCompleteComponentTest : public edbee::test::TestCase +{ + Q_OBJECT + +private slots: + void openingAutocompleteKeepsEditorFocused(); + void typingContinuesThroughEditorWhenAutocompleteIsVisible(); + void listKeyEventsForwardTextBackToEditor(); + void navigationKeysMoveSelectionWithoutListFocus(); + void enterAcceptsSuggestionFromEditorFocus(); + void fullWordEnterFallsThroughToEditor(); + void escapeCancelsAutocompleteUntilWordIsCleared(); + void hidingAutocompleteDoesNotStealFocusFromSiblingWidget(); +}; + +DECLARE_TEST(edbee::TextEditorAutoCompleteComponentTest); + +} // edbee