current commit
261030.3 · gui-qt: replaces syntax dropdown with a searchable pop-up list
/***************************************************************************
                               syntaxpalette.cpp
                             -------------------
    begin                : 2026
    copyright            : (C) 2009-2026 by Andre Simon
    email                : a.simon@mailbox.org
 ***************************************************************************/

/*
This file is part of Highlight.

Highlight is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

Highlight is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with Highlight.  If not, see <http://www.gnu.org/licenses/>.
*/

#include "syntaxpalette.h"

#include <QCoreApplication>
#include <QKeyEvent>
#include <QLineEdit>
#include <QListView>
#include <QScreen>
#include <QVBoxLayout>

SyntaxFilterProxyModel::SyntaxFilterProxyModel(QObject *parent)
    : QSortFilterProxyModel(parent)
{
}

void SyntaxFilterProxyModel::setSearchText(const QString &text)
{
    searchText = text.trimmed();
    invalidate();
}

bool SyntaxFilterProxyModel::filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const
{
    if (searchText.isEmpty())
        return true;

    QModelIndex idx = sourceModel()->index(sourceRow, 0, sourceParent);
    return idx.data(Qt::DisplayRole).toString().contains(searchText, Qt::CaseInsensitive)
           || idx.data(Qt::UserRole).toString().contains(searchText, Qt::CaseInsensitive);
}

int SyntaxFilterProxyModel::rank(const QModelIndex &index) const
{
    if (searchText.isEmpty())
        return 0;

    if (index.data(Qt::DisplayRole).toString().startsWith(searchText, Qt::CaseInsensitive)
        || index.data(Qt::UserRole).toString().compare(searchText, Qt::CaseInsensitive) == 0)
        return 0;
    return 1;
}

bool SyntaxFilterProxyModel::lessThan(const QModelIndex &left, const QModelIndex &right) const
{
    int leftRank = rank(left);
    int rightRank = rank(right);
    if (leftRank != rightRank)
        return leftRank < rightRank;

    // keep the order of the source model within a rank
    return left.row() < right.row();
}

SyntaxPalette::SyntaxPalette(QAbstractItemModel *model, QWidget *parent)
    : QFrame(parent, Qt::Popup)
{
    setFrameStyle(QFrame::StyledPanel | QFrame::Raised);

    leSearch = new QLineEdit(this);
    leSearch->setPlaceholderText(tr("Search syntax or file extension"));
    leSearch->setClearButtonEnabled(true);
    leSearch->installEventFilter(this);

    proxy = new SyntaxFilterProxyModel(this);
    proxy->setSourceModel(model);
    proxy->sort(0);

    lvSyntaxes = new QListView(this);
    lvSyntaxes->setModel(proxy);
    lvSyntaxes->setEditTriggers(QAbstractItemView::NoEditTriggers);
    lvSyntaxes->setUniformItemSizes(true);
    lvSyntaxes->setFocusPolicy(Qt::NoFocus);

    QVBoxLayout *layout = new QVBoxLayout(this);
    layout->setContentsMargins(4, 4, 4, 4);
    layout->setSpacing(4);
    layout->addWidget(leSearch);
    layout->addWidget(lvSyntaxes);

    connect(leSearch, &QLineEdit::textChanged, this, &SyntaxPalette::onSearchTextChanged);
    connect(leSearch, &QLineEdit::returnPressed, this, &SyntaxPalette::acceptCurrent);
    connect(lvSyntaxes, &QListView::clicked, this, &SyntaxPalette::acceptCurrent);
}

void SyntaxPalette::popup(QWidget *anchor, int currentRow)
{
    leSearch->clear();

    QModelIndex current = proxy->mapFromSource(proxy->sourceModel()->index(currentRow, 0));
    lvSyntaxes->setCurrentIndex(current);
    lvSyntaxes->scrollTo(current, QAbstractItemView::PositionAtCenter);

    resize(qMax(anchor->width(), 280), 320);

    // open below the anchor, or above it if there is not enough space on the screen
    QPoint pos = anchor->mapToGlobal(QPoint(0, anchor->height()));
    QRect screenRect = anchor->screen()->availableGeometry();
    if (pos.y() + height() > screenRect.bottom())
        pos.setY(anchor->mapToGlobal(QPoint(0, 0)).y() - height());
    pos.setX(qBound(screenRect.left(), pos.x(), screenRect.right() - width()));
    move(pos);

    show();
    leSearch->setFocus();
}

bool SyntaxPalette::eventFilter(QObject *watched, QEvent *event)
{
    // let the list be navigated while the search field keeps the focus
    if (watched == leSearch && event->type() == QEvent::KeyPress) {
        switch (static_cast<QKeyEvent *>(event)->key()) {
        case Qt::Key_Up:
        case Qt::Key_Down:
        case Qt::Key_PageUp:
        case Qt::Key_PageDown:
            QCoreApplication::sendEvent(lvSyntaxes, event);
            return true;
        default:
            break;
        }
    }
    return QFrame::eventFilter(watched, event);
}

void SyntaxPalette::onSearchTextChanged(const QString &text)
{
    proxy->setSearchText(text);
    lvSyntaxes->setCurrentIndex(proxy->index(0, 0));
}

void SyntaxPalette::acceptCurrent()
{
    QModelIndex current = lvSyntaxes->currentIndex();
    if (!current.isValid())
        return;

    close();
    emit syntaxSelected(proxy->mapToSource(current).row());
}