Cockatrice/cockatrice/src/interface/widgets/utility/line_edit_completer.cpp
BruebachL 5d06bb903d
[Chat] Fix card completion after an @ mention (#7469)
An open [[ card link now takes precedence over a space-preceded @
mention, so typing an @ before a card no longer shadows the card
completer.

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-10-11 15:35:10 +02:00

272 lines
9.2 KiB
C++

#include "line_edit_completer.h"
#include <QAbstractItemModel>
#include <QAbstractItemView>
#include <QChar>
#include <QCompleter>
#include <QFocusEvent>
#include <QItemSelectionModel>
#include <QKeyEvent>
#include <QModelIndex>
#include <QVariant>
#include <QtGlobal>
#include <qnamespace.h>
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<const QString &>(&QCompleter::activated), this,
qOverload<const QString &>(&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;
}
}
}
}