kcms/keyboard: Refactor and fix LayoutDialog

The list had a few problems, notably an erratic sorting that randomly change during and after searching for a layout.
While at it, refactor some code and port the models to C++.
This commit is contained in:
Tobias Fella 2026-10-07 21:52:21 +02:00
parent c7cf9cec98
commit dc7b9473f7
11 changed files with 232 additions and 152 deletions

View file

@ -19,6 +19,7 @@ set( kcm_keyboard_PART_SRCS
xkboptionsmodel.cpp
userlayoutmodel.cpp
layoutsearchmodel.cpp
variantsmodel.cpp
)
ecm_qt_declare_logging_category(kcm_keyboard_PART_SRCS

View file

@ -38,7 +38,7 @@ QVariant LayoutModel::data(const QModelIndex &index, int role) const
const auto &layout = m_data.at(index.row());
if (role == Roles::DescripionRole || role == Qt::DisplayRole) {
if (role == Roles::DescriptionRole || role == Qt::DisplayRole) {
return layout.description;
} else if (role == Roles::ShortNameRole) {
return layout.name;
@ -54,7 +54,7 @@ QHash<int, QByteArray> LayoutModel::roleNames() const
return {
{Qt::DisplayRole, QByteArrayLiteral("display")},
{Roles::ShortNameRole, QByteArrayLiteral("shortName")},
{Roles::DescripionRole, QByteArrayLiteral("description")},
{Roles::DescriptionRole, QByteArrayLiteral("description")},
{Roles::VariantNameRole, QByteArrayLiteral("variantName")},
};
}

View file

@ -35,7 +35,7 @@ public:
QHash<int, QByteArray> roleNames() const override;
enum Roles {
ShortNameRole = Qt::UserRole + 1,
DescripionRole,
DescriptionRole,
VariantNameRole,
};

View file

@ -1,16 +1,11 @@
/*
SPDX-FileCopyrightText: 2025 Bharadwaj Raju <bharadwaj.raju777@protonmail.com>
SPDX-FileCopyrightText: 2026 Tobias Fella <tobias.fella@kde.org>
SPDX-License-Identifier: GPL-2.0-or-later
*/
#include "layoutsearchmodel.h"
#include <algorithm>
#include <QDebug>
#include <QStringLiteral>
#include <KFuzzyMatcher>
#include "layoutmodel.h"
@ -20,53 +15,78 @@ using namespace Qt::Literals::StringLiterals;
LayoutSearchModel::LayoutSearchModel(QObject *parent)
: QSortFilterProxyModel(parent)
{
connect(this, &QSortFilterProxyModel::sourceModelChanged, this, [this]() {
const auto originalRoles = sourceModel()->roleNames().keys();
m_searchScoreRole = *std::max_element(originalRoles.begin(), originalRoles.end()) + 1;
});
setSortRole(LayoutModel::Roles::DescriptionRole);
sort(0);
}
void LayoutSearchModel::setSearchString(QStringView searchString)
QString LayoutSearchModel::searchString() const
{
beginResetModel();
m_searchString = searchString.toString();
endResetModel();
return m_searchString;
}
void LayoutSearchModel::setSearchString(const QString &searchString)
{
if (searchString == m_searchString) {
return;
}
beginFilterChange();
m_searchString = searchString;
endFilterChange();
sort(-1);
sort(0);
Q_EMIT searchStringChanged();
}
QString LayoutSearchModel::getFullName(const QModelIndex &idx) const
{
const auto shortName = sourceModel()->data(idx, LayoutModel::Roles::ShortNameRole).toString();
const auto description = sourceModel()->data(idx, LayoutModel::Roles::DescripionRole).toString();
const auto variantName = sourceModel()->data(idx, LayoutModel::Roles::VariantNameRole).toString();
const auto shortName = idx.data(LayoutModel::Roles::ShortNameRole).toString();
const auto description = idx.data(LayoutModel::Roles::DescriptionRole).toString();
const auto variantName = idx.data(LayoutModel::Roles::VariantNameRole).toString();
return shortName + " - "_L1 + description + " - "_L1 + variantName;
}
QVariant LayoutSearchModel::data(const QModelIndex &idx, int role) const
{
if (!idx.isValid())
return QVariant();
if (role == m_searchScoreRole)
return QVariant(KFuzzyMatcher::match(m_searchString, getFullName(idx)).score);
return sourceModel()->data(idx, role);
}
bool LayoutSearchModel::filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const
{
const auto index = sourceModel()->index(sourceRow, 0, sourceParent);
if (!index.data(LayoutModel::Roles::VariantNameRole).toString().trimmed().isEmpty()) {
return false;
}
if (m_searchString.isEmpty()) {
return true;
const auto idx = sourceModel()->index(sourceRow, 0, sourceParent);
const auto res = KFuzzyMatcher::match(m_searchString, getFullName(idx));
return res.matched;
}
if (getFullName(index).contains(m_searchString, Qt::CaseInsensitive)) {
return true;
}
const auto shortName = index.data(LayoutModel::ShortNameRole).toString();
for (auto i = 0; i < sourceModel()->rowCount(); i++) {
const auto sourceIndex = sourceModel()->index(i, 0, sourceParent);
const auto sourceShortName = sourceIndex.data(LayoutModel::ShortNameRole).toString();
if (sourceShortName == shortName && getFullName(sourceIndex).contains(m_searchString, Qt::CaseInsensitive)) {
return true;
}
}
return false;
}
QHash<int, QByteArray> LayoutSearchModel::roleNames() const
bool LayoutSearchModel::lessThan(const QModelIndex &left, const QModelIndex &right) const
{
QHash<int, QByteArray> newRoles(sourceModel()->roleNames());
newRoles.insert(m_searchScoreRole, "searchScore"_ba);
return newRoles;
auto leftScore = KFuzzyMatcher::match(m_searchString, getFullName(left)).score;
auto rightScore = KFuzzyMatcher::match(m_searchString, getFullName(right)).score;
if (leftScore == rightScore) {
return QSortFilterProxyModel::lessThan(left, right);
}
return leftScore > rightScore;
}
#include "moc_layoutsearchmodel.cpp"

View file

@ -1,6 +1,6 @@
/*
SPDX-FileCopyrightText: 2025 Bharadwaj Raju <bharadwaj.raju777@protonmail.com>
SPDX-FileCopyrightText: 2026 Tobias Fella <tobias.fella@kde.org>
SPDX-License-Identifier: GPL-2.0-or-later
*/
@ -12,27 +12,23 @@ class LayoutSearchModel : public QSortFilterProxyModel
{
Q_OBJECT
public:
LayoutSearchModel(QObject *parent = nullptr);
Q_INVOKABLE QString searchString() const
{
return m_searchString;
};
Q_INVOKABLE void setSearchString(QStringView searchString);
Q_PROPERTY(QString searchString READ searchString WRITE setSearchString NOTIFY searchStringChanged)
signals:
public:
explicit LayoutSearchModel(QObject *parent = nullptr);
QString searchString() const;
void setSearchString(const QString &searchString);
bool lessThan(const QModelIndex &left, const QModelIndex &right) const override;
Q_SIGNALS:
void searchStringChanged();
protected:
bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override;
QVariant data(const QModelIndex &idx, int role) const override;
QHash<int, QByteArray> roleNames() const override;
private:
QString m_searchString;
QString m_searchString = QStringLiteral("");
QString getFullName(const QModelIndex &idx) const;
int m_searchScoreRole;
};

View file

@ -5,6 +5,7 @@
#include "flags.h"
#include "layoutsearchmodel.h"
#include "variantsmodel.h"
struct FlagsForeign {
Q_GADGET
@ -18,3 +19,9 @@ struct LayoutSearchModelForeign {
QML_NAMED_ELEMENT(LayoutSearchModel)
QML_FOREIGN(LayoutSearchModel)
};
struct VariantsModelForeign {
Q_GADGET
QML_NAMED_ELEMENT(VariantsModel)
QML_FOREIGN(VariantsModel)
};

View file

@ -1,47 +0,0 @@
/*
SPDX-FileCopyrightText: 2024 Evgeny Chesnokov <echesnokov@astralinux.ru>
SPDX-License-Identifier: GPL-2.0-or-later
*/
import QtQuick
import org.kde.kitemmodels as KItemModels
KItemModels.KSortFilterProxyModel {
id: layoutsProxy
sourceModel: layoutSearchProxy
sortRoleName: "searchScore"
sortOrder: Qt.DescendingOrder
filterRowCallback: function (row, parent): bool {
const modelIndex = sourceModel.index(row, 0, parent);
const currentVariantName = sourceModel.data(modelIndex, KItemModels.KRoleNames.role("variantName"));
const description = sourceModel.data(modelIndex, KItemModels.KRoleNames.role("description"));
if (currentVariantName !== '') {
return false;
}
if (searchField.text.length > 0) {
const score = sourceModel.data(modelIndex, KItemModels.KRoleNames.role("searchScore"));
if (score !== 0) {
return true;
}
const shortNameRole = KItemModels.KRoleNames.role("shortName");
const currentName = sourceModel.data(modelIndex, shortNameRole);
const searchScoreRole = KItemModels.KRoleNames.role("searchScore");
for (let i = 0; i < sourceModel.rowCount(); i++) {
const index = sourceModel.index(i, 0, parent);
const name = sourceModel.data(index, shortNameRole);
const variantSearchScore = sourceModel.data(index, searchScoreRole);
if (name === currentName && variantSearchScore > 100) {
return true;
}
}
return false;
}
return true;
}
}

View file

@ -1,37 +0,0 @@
/*
SPDX-FileCopyrightText: 2024 Evgeny Chesnokov <echesnokov@astralinux.ru>
SPDX-License-Identifier: GPL-2.0-or-later
*/
import QtQuick
import org.kde.kitemmodels as KItemModels
KItemModels.KSortFilterProxyModel {
id: variantProxy
sourceModel: layoutSearchProxy
sortRoleName: "searchScore"
sortOrder: Qt.DescendingOrder
filterRowCallback: function (row, parent): bool {
if (!layoutsView.currentItem) {
return false;
}
const modelIndex = sourceModel.index(row, 0, parent);
const currentName = sourceModel.data(modelIndex, KItemModels.KRoleNames.role("shortName"));
const selectedName = layoutsView.currentItem.shortName;
if (currentName !== selectedName) {
return false;
}
if (searchField.text.length > 0) {
const searchScore = sourceModel.data(modelIndex, KItemModels.KRoleNames.role("searchScore"));
return searchScore > 100;
}
return true;
}
}

View file

@ -47,18 +47,15 @@ Kirigami.Dialog {
}
}
KCMKeyboard.LayoutSearchModel {
id: layoutSearchProxy
sourceModel: KCMKeyboard.LayoutModel {}
searchString: ""
KCMKeyboard.LayoutModel {
id: layoutModel
}
KeyboardLayoutsModel {
id: layoutsProxy
}
KeyboardVariantsModel {
KCMKeyboard.VariantsModel {
id: variantProxy
searchString: searchField.text
shortName: layoutsView.currentItem?.shortName ?? ""
sourceModel: layoutModel
}
contentItem: ColumnLayout {
@ -67,8 +64,10 @@ Kirigami.Dialog {
Kirigami.SearchField {
id: searchField
Layout.fillWidth: true
onAccepted: {
layoutSearchProxy.searchString = searchField.text.trim();
onTextChanged: {
layoutsView.currentIndex = 0;
variantView.currentIndex = 0;
}
}
@ -84,12 +83,13 @@ Kirigami.Dialog {
contentItem: ListView {
id: layoutsView
currentIndex: 0
model: layoutsProxy
model: KCMKeyboard.LayoutSearchModel {
sourceModel: layoutModel
searchString: searchField.text
}
delegate: LayoutDelegate {}
keyNavigationEnabled: true
activeFocusOnTab: true
onCurrentItemChanged: variantProxy.invalidateFilter()
}
}

View file

@ -0,0 +1,103 @@
// SPDX-FileCopyrightText: 2026 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: GPL-2.0-or-later
#include "variantsmodel.h"
#include <KFuzzyMatcher>
#include "layoutmodel.h"
using namespace Qt::Literals::StringLiterals;
VariantsModel::VariantsModel(QObject *parent)
: QSortFilterProxyModel(parent)
{
setSortRole(LayoutModel::DescriptionRole);
sort(0);
}
QString VariantsModel::searchString() const
{
return m_searchString;
}
void VariantsModel::setSearchString(const QString &searchString)
{
if (searchString == m_searchString) {
return;
}
beginFilterChange();
m_searchString = searchString;
endFilterChange();
sort(-1);
sort(0);
Q_EMIT searchStringChanged();
}
QString VariantsModel::shortName() const
{
return m_shortName;
}
void VariantsModel::setShortName(const QString &shortName)
{
if (shortName == m_shortName) {
return;
}
beginFilterChange();
m_shortName = shortName;
endFilterChange();
Q_EMIT shortNameChanged();
}
QString VariantsModel::getFullName(const QModelIndex &idx) const
{
const auto shortName = idx.data(LayoutModel::Roles::ShortNameRole).toString();
const auto description = idx.data(LayoutModel::Roles::DescriptionRole).toString();
const auto variantName = idx.data(LayoutModel::Roles::VariantNameRole).toString();
return shortName + " - "_L1 + description + " - "_L1 + variantName;
}
bool VariantsModel::filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const
{
if (m_shortName.isEmpty()) {
return false;
}
const auto index = sourceModel()->index(sourceRow, 0, sourceParent);
if (index.data(LayoutModel::ShortNameRole).toString() != m_shortName) {
return false;
}
if (!m_searchString.isEmpty()) {
return getFullName(index).contains(m_searchString, Qt::CaseInsensitive);
}
return true;
}
bool VariantsModel::lessThan(const QModelIndex &left, const QModelIndex &right) const
{
auto leftScore = KFuzzyMatcher::match(m_searchString, getFullName(left)).score;
auto rightScore = KFuzzyMatcher::match(m_searchString, getFullName(right)).score;
if (leftScore != rightScore) {
return leftScore > rightScore;
}
const auto leftIsBase = left.data(LayoutModel::VariantNameRole).toString().isEmpty();
const auto rightIsBase = right.data(LayoutModel::VariantNameRole).toString().isEmpty();
if (leftIsBase != rightIsBase) {
return leftIsBase;
}
return QSortFilterProxyModel::lessThan(left, right);
}
#include "moc_variantsmodel.cpp"

View file

@ -0,0 +1,37 @@
// SPDX-FileCopyrightText: 2026 Tobias Fella <tobias.fella@kde.org>
// SPDX-License-Identifier: GPL-2.0-or-later
#pragma once
#include <QSortFilterProxyModel>
class VariantsModel : public QSortFilterProxyModel
{
Q_OBJECT
Q_PROPERTY(QString searchString READ searchString WRITE setSearchString NOTIFY searchStringChanged)
Q_PROPERTY(QString shortName READ shortName WRITE setShortName NOTIFY shortNameChanged)
public:
explicit VariantsModel(QObject *parent = nullptr);
QString searchString() const;
void setSearchString(const QString &searchString);
QString shortName() const;
void setShortName(const QString &shortName);
bool lessThan(const QModelIndex &left, const QModelIndex &right) const override;
Q_SIGNALS:
void searchStringChanged();
void shortNameChanged();
protected:
bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override;
private:
QString m_searchString = QStringLiteral("");
QString getFullName(const QModelIndex &idx) const;
QString m_shortName;
};