#include "line_edit_completer.h" #include #include #include #include #include #include #include #include #include #include #include class QWidget; LineEditCompleter::LineEditCompleter(QWidget *parent) : LineEditUnfocusable(parent) { } void LineEditCompleter::addCompleter(QCompleter *c, CompleterTrigger trigger) { c->setWidget(this); c->setCompletionMode(QCompleter::PopupCompletion); c->setCaseSensitivity(Qt::CaseInsensitive); connect(c, qOverload(&QCompleter::activated), this, qOverload(&LineEditCompleter::insertCompletion)); completers.append({c, trigger}); } bool LineEditCompleter::hasVisibleCompleterPopup() const { for (const auto &info : completers) { if (info.completer->popup()->isVisible()) { return true; } } return false; } void LineEditCompleter::hideCompleterPopups() { for (const auto &info : completers) { info.completer->popup()->hide(); } } void LineEditCompleter::focusOutEvent(QFocusEvent *e) { LineEditUnfocusable::focusOutEvent(e); // Commit completion when focus moves away via Tab if popup is visible. // Other focus losses (e.g. the unfocus shortcut / Escape) must simply close // the popup without inserting anything. bool popupWasVisible = false; for (auto &info : completers) { if (info.completer->popup()->isVisible()) { popupWasVisible = true; const QModelIndex currentIndex = info.completer->popup()->currentIndex(); QString completionText; if (currentIndex.isValid()) { completionText = currentIndex.data().toString(); } else { completionText = info.completer->currentCompletion(); } if (!completionText.isEmpty()) { insertCompletion(info.completer, completionText); } } } hideCompleterPopups(); if (popupWasVisible && e->reason() == Qt::TabFocusReason) { // Refocus the edit so Tab doesn't move focus away // Use a queued connection to ensure focus is restored after the focus change completes QMetaObject::invokeMethod(this, "setFocus", Qt::QueuedConnection); e->accept(); return; } if (popupWasVisible) { return; } } void LineEditCompleter::keyPressEvent(QKeyEvent *event) { if (event->key() == Qt::Key_Escape) { if (hasVisibleCompleterPopup()) { event->ignore(); hideCompleterPopups(); return; } LineEditUnfocusable::keyPressEvent(event); return; } if (event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter || event->key() == Qt::Key_Space) { for (auto &info : completers) { if (info.completer->popup()->isVisible()) { event->ignore(); const QModelIndex currentIndex = info.completer->popup()->currentIndex(); QString completionText; if (currentIndex.isValid()) { completionText = currentIndex.data().toString(); } else { completionText = info.completer->currentCompletion(); } if (!completionText.isEmpty()) { insertCompletion(info.completer, completionText); } hideCompleterPopups(); return; } } } if (event->key() == Qt::Key_Tab) { // Let Tab proceed normally; focusOutEvent will handle completion if popup is visible LineEditUnfocusable::keyPressEvent(event); return; } LineEditUnfocusable::keyPressEvent(event); QString textValue = text(); int cursorPos = cursorPosition(); CompleterInfo *active = nullptr; QString prefix; // An open "[[" card link takes precedence over a mention: while the cursor is // inside it, an earlier "@" must not shadow the card completer. const int openCardPos = textValue.lastIndexOf("[[", cursorPos - 1); const int closeCardPos = openCardPos == -1 ? -1 : textValue.indexOf("]]", openCardPos + 2); const bool insideCardLink = openCardPos != -1 && (closeCardPos == -1 || closeCardPos >= cursorPos); for (auto &info : completers) { bool triggered = false; switch (info.trigger) { case CompleterTrigger::Mention: { if (insideCardLink) { break; } int triggerPos = textValue.lastIndexOf("@", cursorPos - 1); if (triggerPos != -1 && (triggerPos == 0 || textValue[triggerPos - 1].isSpace())) { triggered = true; // Keep the "@" so the prefix matches the "@"-prefixed mention model entries. prefix = textValue.mid(triggerPos, cursorPos - triggerPos); } break; } case CompleterTrigger::Card: { int triggerPos = textValue.lastIndexOf("[[", cursorPos - 1); int closePos = textValue.indexOf("]]", triggerPos + 2); if (triggerPos != -1 && (closePos == -1 || closePos >= cursorPos)) { triggered = true; prefix = textValue.mid(triggerPos + 2, cursorPos - (triggerPos + 2)); } break; } } if (triggered) { active = &info; break; } } if (!active) { for (auto &info : completers) { info.completer->popup()->hide(); } return; } active->completer->setCompletionPrefix(prefix); switch (active->trigger) { case CompleterTrigger::Card: emit cardPartialChanged(prefix); return; case CompleterTrigger::Mention: break; } active->completer->complete(); // The popup opens without a current row, so nothing is highlighted and there // is no visual indication of what Enter will insert. Select the row that // currentCompletion() would use so the current mention stays visible. auto *completionModel = active->completer->completionModel(); const int currentRow = active->completer->currentRow(); if (currentRow >= 0 && currentRow < completionModel->rowCount()) { active->completer->popup()->selectionModel()->setCurrentIndex( completionModel->index(currentRow, active->completer->completionColumn()), QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows); } } void LineEditCompleter::insertCompletion(const QString &completion) { for (auto &info : completers) { if (info.completer == sender()) { insertCompletion(info.completer, completion); return; } } } void LineEditCompleter::insertCompletion(QCompleter *completer, const QString &completion) { QString t = text(); int pos = cursorPosition(); for (const auto &info : completers) { if (info.completer != completer) { continue; } switch (info.trigger) { case CompleterTrigger::Card: { int triggerPos = t.lastIndexOf("[[", pos - 1); if (triggerPos == -1) { return; } // If an earlier "[[" is still open it also encloses the cursor, so // replace from its start. Otherwise completing in text such as // "[[Opt[[Amok" would leave a stray "[[" behind. int startPos = triggerPos; for (int searchFrom = triggerPos; searchFrom > 0;) { const int earlier = t.lastIndexOf("[[", searchFrom - 1); if (earlier == -1) { break; } const int earlierClose = t.indexOf("]]", earlier + 2); if (earlierClose != -1 && earlierClose < pos) { break; } startPos = earlier; searchFrom = earlier; } // If the cursor sits inside an already-closed [[...]] pair, replace // the whole construct instead of leaving a duplicate closing bracket // behind. int insertEnd = pos; const int closePos = t.indexOf("]]", startPos + 2); if (closePos != -1 && closePos >= pos) { insertEnd = closePos + 2; } QString after = t.mid(insertEnd); QString replaced = t.left(startPos + 2) + completion + "]] "; setText(replaced + after); setCursorPosition(replaced.length()); return; } case CompleterTrigger::Mention: { int triggerPos = t.lastIndexOf("@", pos - 1); if (triggerPos == -1) { return; } setText(t.replace(triggerPos, pos - triggerPos, completion + " ")); setCursorPosition(triggerPos + completion.length() + 1); return; } } } }