From e114537c04383e1c0e0cc5e896be441f73ed3af5 Mon Sep 17 00:00:00 2001 From: wengkit218-pixel Date: Mon, 15 Jun 2026 09:04:56 +0800 Subject: [PATCH 1/2] fix autocomplete focus steal --- .../texteditorautocompletecomponent.cpp | 59 +++++- .../texteditorautocompletecomponent.h | 2 + edbee-test/CMakeLists.txt | 2 + edbee-test/edbee-test.pro | 2 + .../texteditorautocompletecomponenttest.cpp | 198 ++++++++++++++++++ .../texteditorautocompletecomponenttest.h | 24 +++ 6 files changed, 279 insertions(+), 8 deletions(-) create mode 100644 edbee-test/edbee/views/texteditorautocompletecomponenttest.cpp create mode 100644 edbee-test/edbee/views/texteditorautocompletecomponenttest.h diff --git a/edbee-lib/edbee/views/components/texteditorautocompletecomponent.cpp b/edbee-lib/edbee/views/components/texteditorautocompletecomponent.cpp index 3c23332d..6c78048f 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,21 @@ 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,15 +356,23 @@ 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); - return true; + if (editorHasEvent) { + return false; + } + sendKeyEventTo(editorComponentRef_, key); + return true; } } @@ -360,7 +388,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 +402,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 +420,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 +472,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 c735f2e3..019fd117 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 { @@ -69,6 +70,7 @@ class EDBEE_EXPORT TextEditorAutoCompleteComponent : public QWidget bool fillAutoCompleteList(TextDocument *document, const TextRange &range, const QString& word ); void positionWidgetForCaretOffset(size_t offset); + void sendKeyEventTo(QWidget* target, QKeyEvent* sourceEvent); bool eventFilter(QObject* obj, QEvent* event); void hideEvent(QHideEvent* event); diff --git a/edbee-test/CMakeLists.txt b/edbee-test/CMakeLists.txt index 536b63c7..b0d5b7c6 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 e80b4bdd..54307a70 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 00000000..9abb1d8b --- /dev/null +++ b/edbee-test/edbee/views/texteditorautocompletecomponenttest.cpp @@ -0,0 +1,198 @@ +// 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/edbee.h" +#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); + + QApplication::processEvents(); + editor->setFocus(); + QApplication::processEvents(); + } + + void typePrefix(const QString& prefix) + { + for (const QChar ch : prefix) { + sendEditorKey(ch.toLatin1(), QString(ch)); + } + } + + void sendEditorKey(int key, const QString& text = QString()) + { + QKeyEvent keyEvent(QEvent::KeyPress, key, Qt::NoModifier, text); + QApplication::sendEvent(editor, &keyEvent); + QApplication::processEvents(); + } + + void sendListKey(int key, const QString& text = QString()) + { + QKeyEvent keyEvent(QEvent::KeyPress, key, Qt::NoModifier, text); + QApplication::sendEvent(list, &keyEvent); + QApplication::processEvents(); + } + + void clearDocument() + { + while (!widget.textDocument()->text().isEmpty()) { + sendEditorKey(Qt::Key_Backspace); + } + } + + 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.editor->focusPolicy(), Qt::WheelFocus); + 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::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::escapeCancelsAutocompleteUntilWordIsCleared() +{ + AutoCompleteFixture fixture; + fixture.typePrefix("com"); + + fixture.sendEditorKey(Qt::Key_Escape); + + testFalse(fixture.list->isVisible()); + testTrue(fixture.editor->hasFocus()); + + fixture.sendEditorKey(Qt::Key_P, "p"); + testEqual(fixture.widget.textDocument()->text(), "comp"); + testFalse(fixture.list->isVisible()); + + fixture.clearDocument(); + testEqual(fixture.widget.textDocument()->text(), ""); + + fixture.typePrefix("com"); + 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(); + QApplication::processEvents(); + + for (const QChar ch : QStringLiteral("com")) { + QKeyEvent keyEvent(QEvent::KeyPress, ch.toLatin1(), Qt::NoModifier, QString(ch)); + QApplication::sendEvent(editor, &keyEvent); + } + QApplication::processEvents(); + + testTrue(list->isVisible()); + testTrue(editor->hasFocus()); + + sibling->setFocus(); + QApplication::processEvents(); + testTrue(sibling->hasFocus()); + + QKeyEvent escapeKey(QEvent::KeyPress, Qt::Key_Escape, Qt::NoModifier); + QApplication::sendEvent(list, &escapeKey); + QApplication::processEvents(); + + testTrue(sibling->hasFocus()); + testFalse(editor->hasFocus()); + testFalse(list->hasFocus()); +} + +} // namespace edbee diff --git a/edbee-test/edbee/views/texteditorautocompletecomponenttest.h b/edbee-test/edbee/views/texteditorautocompletecomponenttest.h new file mode 100644 index 00000000..e7d90337 --- /dev/null +++ b/edbee-test/edbee/views/texteditorautocompletecomponenttest.h @@ -0,0 +1,24 @@ +// 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 navigationKeysMoveSelectionWithoutListFocus(); + void escapeCancelsAutocompleteUntilWordIsCleared(); + void hidingAutocompleteDoesNotStealFocusFromSiblingWidget(); +}; + +DECLARE_TEST(edbee::TextEditorAutoCompleteComponentTest); + +} // namespace edbee From a2b4e4bc2741e31d0aa18df677f9900055bcb11b Mon Sep 17 00:00:00 2001 From: wengkit218-pixel Date: Mon, 15 Jun 2026 09:51:17 +0800 Subject: [PATCH 2/2] tighten autocomplete focus regression tests --- .../texteditorautocompletecomponent.cpp | 4 +-- .../texteditorautocompletecomponenttest.cpp | 32 +++++++++++++++++-- .../texteditorautocompletecomponenttest.h | 1 + 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/edbee-lib/edbee/views/components/texteditorautocompletecomponent.cpp b/edbee-lib/edbee/views/components/texteditorautocompletecomponent.cpp index 6c78048f..8d8c5daa 100644 --- a/edbee-lib/edbee/views/components/texteditorautocompletecomponent.cpp +++ b/edbee-lib/edbee/views/components/texteditorautocompletecomponent.cpp @@ -360,7 +360,7 @@ bool TextEditorAutoCompleteComponent::eventFilter(QObject *obj, QEvent *event) return QObject::eventFilter(obj, event); } - if ((obj == editorComponentRef_ || obj == listWidgetRef_ || obj == menuRef_) && event->type() == QEvent::KeyPress && menuRef_->isVisible()) { + if ((obj == editorComponentRef_ || obj == listWidgetRef_) && event->type() == QEvent::KeyPress && menuRef_->isVisible()) { QKeyEvent* key = static_cast(event); const bool editorHasEvent = obj == editorComponentRef_; @@ -420,7 +420,7 @@ bool TextEditorAutoCompleteComponent::eventFilter(QObject *obj, QEvent *event) case Qt::Key_Down: case Qt::Key_PageDown: case Qt::Key_PageUp: - if (editorHasEvent || obj == menuRef_) { + if (editorHasEvent) { sendKeyEventTo(listWidgetRef_, key); return true; } diff --git a/edbee-test/edbee/views/texteditorautocompletecomponenttest.cpp b/edbee-test/edbee/views/texteditorautocompletecomponenttest.cpp index 9abb1d8b..1867b243 100644 --- a/edbee-test/edbee/views/texteditorautocompletecomponenttest.cpp +++ b/edbee-test/edbee/views/texteditorautocompletecomponenttest.cpp @@ -59,10 +59,13 @@ class AutoCompleteFixture QApplication::processEvents(); } - void sendListKey(int key, const QString& text = QString()) + void sendFocusedWidgetKey(int key, const QString& text = QString()) { + QWidget* target = QApplication::focusWidget(); + Q_ASSERT(target != nullptr); + QKeyEvent keyEvent(QEvent::KeyPress, key, Qt::NoModifier, text); - QApplication::sendEvent(list, &keyEvent); + QApplication::sendEvent(target, &keyEvent); QApplication::processEvents(); } @@ -111,6 +114,25 @@ void TextEditorAutoCompleteComponentTest::typingContinuesThroughEditorWhenAutoco } +void TextEditorAutoCompleteComponentTest::typingStillRoutesThroughEditorWhenListGetsFocus() +{ + AutoCompleteFixture fixture; + fixture.typePrefix("com"); + + fixture.list->setFocusPolicy(Qt::StrongFocus); + fixture.list->setFocus(); + QApplication::processEvents(); + + testTrue(fixture.list->hasFocus()); + testTrue(QApplication::focusWidget() == fixture.list); + + fixture.sendFocusedWidgetKey(Qt::Key_P, "p"); + + testEqual(fixture.widget.textDocument()->text(), "comp"); + testTrue(fixture.list->hasFocus()); +} + + void TextEditorAutoCompleteComponentTest::navigationKeysMoveSelectionWithoutListFocus() { AutoCompleteFixture fixture; @@ -185,14 +207,18 @@ void TextEditorAutoCompleteComponentTest::hidingAutocompleteDoesNotStealFocusFro sibling->setFocus(); QApplication::processEvents(); testTrue(sibling->hasFocus()); + testTrue(QApplication::focusWidget() == sibling); QKeyEvent escapeKey(QEvent::KeyPress, Qt::Key_Escape, Qt::NoModifier); - QApplication::sendEvent(list, &escapeKey); + QWidget* focusWidget = QApplication::focusWidget(); + Q_ASSERT(focusWidget != nullptr); + QApplication::sendEvent(focusWidget, &escapeKey); QApplication::processEvents(); testTrue(sibling->hasFocus()); testFalse(editor->hasFocus()); testFalse(list->hasFocus()); + testFalse(list->isVisible()); } } // namespace edbee diff --git a/edbee-test/edbee/views/texteditorautocompletecomponenttest.h b/edbee-test/edbee/views/texteditorautocompletecomponenttest.h index e7d90337..c765d5a0 100644 --- a/edbee-test/edbee/views/texteditorautocompletecomponenttest.h +++ b/edbee-test/edbee/views/texteditorautocompletecomponenttest.h @@ -14,6 +14,7 @@ class TextEditorAutoCompleteComponentTest : public edbee::test::TestCase private slots: void openingAutocompleteKeepsEditorFocused(); void typingContinuesThroughEditorWhenAutocompleteIsVisible(); + void typingStillRoutesThroughEditorWhenListGetsFocus(); void navigationKeysMoveSelectionWithoutListFocus(); void escapeCancelsAutocompleteUntilWordIsCleared(); void hidingAutocompleteDoesNotStealFocusFromSiblingWidget();