From 48332590248234814145570cb7162186d09964ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20B=C3=BChler?= Date: Sat, 1 Aug 2026 09:53:07 +0200 Subject: [PATCH] learn: Add fuzzy search to learn page Refactors the playlist models to a QAbstractListModel subclass to allow the use of our SortFilterProxyModel. --- framework/learn/qml/Muse/Learn/CMakeLists.txt | 2 + framework/learn/qml/Muse/Learn/LearnPage.qml | 24 +++- .../qml/Muse/Learn/internal/Playlist.qml | 25 ++-- .../qml/Muse/Learn/internal/playlistmodel.cpp | 117 ++++++++++++++++++ .../qml/Muse/Learn/internal/playlistmodel.h | 56 +++++++++ .../learn/qml/Muse/Learn/learnpagemodel.cpp | 111 +++-------------- .../learn/qml/Muse/Learn/learnpagemodel.h | 39 ++---- 7 files changed, 237 insertions(+), 137 deletions(-) create mode 100644 framework/learn/qml/Muse/Learn/internal/playlistmodel.cpp create mode 100644 framework/learn/qml/Muse/Learn/internal/playlistmodel.h diff --git a/framework/learn/qml/Muse/Learn/CMakeLists.txt b/framework/learn/qml/Muse/Learn/CMakeLists.txt index b0ac4db7ad..dcf4491f11 100644 --- a/framework/learn/qml/Muse/Learn/CMakeLists.txt +++ b/framework/learn/qml/Muse/Learn/CMakeLists.txt @@ -26,6 +26,8 @@ qt_add_qml_module(muse_learn_qml SOURCES learnpagemodel.cpp learnpagemodel.h + internal/playlistmodel.cpp + internal/playlistmodel.h QML_FILES LearnPage.qml internal/ClassesPage.qml diff --git a/framework/learn/qml/Muse/Learn/LearnPage.qml b/framework/learn/qml/Muse/Learn/LearnPage.qml index cc7f63d78d..61e8c527d4 100644 --- a/framework/learn/qml/Muse/Learn/LearnPage.qml +++ b/framework/learn/qml/Muse/Learn/LearnPage.qml @@ -115,10 +115,6 @@ FocusScope { navigation.order: 1 visible: tabBar.currentIndex !== 1 // Not visible for Classes tab - - onSearchTextChanged: { - pageModel.setSearchText(searchText) - } } } @@ -205,7 +201,25 @@ FocusScope { Playlist { id: getStartedComp - playlist: pageModel.startedPlaylist + playlist: SortFilterProxyModel { + sourceModel: pageModel.startedPlaylist + filters: [ + FuzzyFilter { + id: fuzzyFilter + enabled: Boolean(fuzzyPattern) + fuzzyPattern: searchField.searchText + roleName: "searchKey" + caseSensitivity: Qt.CaseInsensitive + } + ] + + sorters: [ + FuzzyScoreSorter { + enabled: fuzzyFilter.enabled + fuzzyFilter: fuzzyFilter + } + ] + } navigation.section: navSec navigation.order: 3 diff --git a/framework/learn/qml/Muse/Learn/internal/Playlist.qml b/framework/learn/qml/Muse/Learn/internal/Playlist.qml index 6cafb2e878..694bed2331 100644 --- a/framework/learn/qml/Muse/Learn/internal/Playlist.qml +++ b/framework/learn/qml/Muse/Learn/internal/Playlist.qml @@ -19,8 +19,10 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ + +pragma ComponentBehavior: Bound + import QtQuick -import QtQuick.Layouts import QtQuick.Controls import Muse.Ui @@ -87,6 +89,11 @@ FocusScope { } delegate: Item { + id: cellWrap + + required property var model + required property int index + height: view.cellHeight width: view.cellWidth @@ -98,21 +105,21 @@ FocusScope { anchors.horizontalCenter: parent.horizontalCenter navigation.panel: navPanel - navigation.row: view.columns === 0 ? 0 : Math.floor(model.index / view.columns) - navigation.column: model.index - (navigation.row * view.columns) + navigation.row: view.columns === 0 ? 0 : Math.floor(cellWrap.index / view.columns) + navigation.column: cellWrap.index - (navigation.row * view.columns) navigation.onActiveChanged: { if (navigation.active) { - view.positionViewAtIndex(index, ListView.Contain) + view.positionViewAtIndex(cellWrap.index, ListView.Contain) } } - title: modelData.title - author: modelData.author - duration: modelData.duration - thumbnail: modelData.thumbnailUrl + title: cellWrap.model.title + author: cellWrap.model.author + duration: cellWrap.model.duration + thumbnail: cellWrap.model.thumbnailUrl onClicked: { - api.launcher.openUrl(modelData.url) + api.launcher.openUrl(cellWrap.model.url) } } } diff --git a/framework/learn/qml/Muse/Learn/internal/playlistmodel.cpp b/framework/learn/qml/Muse/Learn/internal/playlistmodel.cpp new file mode 100644 index 0000000000..402f9eb9a0 --- /dev/null +++ b/framework/learn/qml/Muse/Learn/internal/playlistmodel.cpp @@ -0,0 +1,117 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program 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 this program. If not, see . + */ + +#include "playlistmodel.h" + +#include +#include + +#include "global/log.h" + +namespace muse::learn { +PlaylistModel::PlaylistModel(QObject* parent) + : QAbstractListModel(parent) +{ +} + +const Playlist& PlaylistModel::playlist() +{ + return m_playlist; +} + +void PlaylistModel::setPlaylist(const Playlist& playlist) +{ + beginResetModel(); + m_playlist = playlist; + endResetModel(); +} + +int PlaylistModel::rowCount(const QModelIndex& parent) const +{ + if (parent.isValid()) { + return 0; + } + + return m_playlist.size(); +} + +QVariant PlaylistModel::data(const QModelIndex& index, int roleId) const +{ + IF_ASSERT_FAILED(index.isValid() && index.row() < m_playlist.size()) { + return QVariant(); + } + + const PlaylistItem& item = m_playlist[index.row()]; + + switch (roleId) { + case RoleTitle: + return item.title; + case RoleAuthor: + return item.author; + case RoleDuration: { + // h:mm:ss for anything over an hour + // m:ss for anything under an hour + // 0:ss for anything under a minute + int seconds = item.durationSecs; + int minutes = seconds / 60; + seconds -= minutes * 60; + int hours = minutes / 60; + minutes -= hours * 60; + + return ((hours > 0) + ? (QString::number(hours) + ":" + QString::number(minutes).rightJustified(2, '0')) + : QString::number(minutes) + ) + ":" + + QString::number(seconds).rightJustified(2, '0'); + } + case RoleUrl: + return item.url; + case RoleThumbnailUrl: + return item.thumbnailUrl; + case RoleSearchKey: { + QStringList searchKeyItems; + searchKeyItems << item.title + << item.author; + + return searchKeyItems.join(u' '); + } + + default: + UNREACHABLE; + break; + } + + return QVariant(); +} + +QHash PlaylistModel::roleNames() const +{ + return QHash { + { RoleTitle, "title" }, + { RoleAuthor, "author" }, + { RoleDuration, "duration" }, + { RoleUrl, "url" }, + { RoleThumbnailUrl, "thumbnailUrl" }, + { RoleSearchKey, "searchKey" }, + }; +} +} diff --git a/framework/learn/qml/Muse/Learn/internal/playlistmodel.h b/framework/learn/qml/Muse/Learn/internal/playlistmodel.h new file mode 100644 index 0000000000..7faec24faa --- /dev/null +++ b/framework/learn/qml/Muse/Learn/internal/playlistmodel.h @@ -0,0 +1,56 @@ +/* + * SPDX-License-Identifier: GPL-3.0-only + * MuseScore-CLA-applies + * + * MuseScore Studio + * Music Composition & Notation + * + * Copyright (C) 2026 MuseScore Limited and others + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 3 as + * published by the Free Software Foundation. + * + * This program 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 this program. If not, see . + */ + +#pragma once + +#include + +#include "learn/learntypes.h" + +namespace muse::learn { +class PlaylistModel final : public QAbstractListModel +{ + Q_OBJECT + +public: + explicit PlaylistModel(QObject* parent = nullptr); + + const Playlist& playlist(); + void setPlaylist(const Playlist&); + + int rowCount(const QModelIndex& parent) const override; + QVariant data(const QModelIndex& index, int roleId) const override; + QHash roleNames() const override; + +private: + enum Roles { + RoleTitle = Qt::UserRole, + RoleAuthor, + RoleDuration, + RoleUrl, + RoleThumbnailUrl, + RoleSearchKey, + }; + + Playlist m_playlist; +}; +} diff --git a/framework/learn/qml/Muse/Learn/learnpagemodel.cpp b/framework/learn/qml/Muse/Learn/learnpagemodel.cpp index 373c5f8d5e..0ff34896d7 100644 --- a/framework/learn/qml/Muse/Learn/learnpagemodel.cpp +++ b/framework/learn/qml/Muse/Learn/learnpagemodel.cpp @@ -19,59 +19,45 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -#include "learnpagemodel.h" -#include -#include +#include "learnpagemodel.h" #include "translation.h" using namespace muse::learn; LearnPageModel::LearnPageModel(QObject* parent) - : QObject(parent), Contextable(muse::iocCtxForQmlObject(this)) + : QObject(parent), + m_gettingStartedModel{new PlaylistModel(this)}, + m_advancedModel{new PlaylistModel(this)} { } -QVariantList LearnPageModel::startedPlaylist() const +PlaylistModel* LearnPageModel::startedPlaylist() const { - Playlist filteredPlaylist = filterPlaylistBySearch(m_startedPlaylist); - return playlistToVariantList(filteredPlaylist); + return m_gettingStartedModel; } -QVariantList LearnPageModel::advancedPlaylist() const +PlaylistModel* LearnPageModel::advancedPlaylist() const { - Playlist filteredPlaylist = filterPlaylistBySearch(m_advancedPlaylist); - return playlistToVariantList(filteredPlaylist); + return m_advancedModel; } void LearnPageModel::load() { learnService()->refreshPlaylists(); - setStartedPlaylist(learnService()->startedPlaylist()); - learnService()->startedPlaylistChanged().onReceive(this, [this](const Playlist& playlist) { - setStartedPlaylist(playlist); + m_gettingStartedModel->setPlaylist(learnService()->startedPlaylist()); + learnService()->startedPlaylistChanged().onReceive(this, [this] (const Playlist& playlist) { + m_gettingStartedModel->setPlaylist(playlist); }); - setAdvancedPlaylist(learnService()->advancedPlaylist()); - learnService()->advancedPlaylistChanged().onReceive(this, [this](const Playlist& playlist) { - setAdvancedPlaylist(playlist); + m_advancedModel->setPlaylist(learnService()->advancedPlaylist()); + learnService()->advancedPlaylistChanged().onReceive(this, [this] (const Playlist& playlist) { + m_advancedModel->setPlaylist(playlist); }); } -void LearnPageModel::setSearchText(const QString& text) -{ - if (m_searchText == text) { - return; - } - - m_searchText = text; - - emit startedPlaylistChanged(); - emit advancedPlaylistChanged(); -} - QVariantMap LearnPageModel::classesAuthor() const { QVariantMap author; @@ -104,72 +90,3 @@ bool LearnPageModel::classesEnabled() { return learnConfiguration()->classesEnabled(); } - -void LearnPageModel::setStartedPlaylist(Playlist startedPlaylist) -{ - if (m_startedPlaylist == startedPlaylist) { - return; - } - - m_startedPlaylist = startedPlaylist; - emit startedPlaylistChanged(); -} - -void LearnPageModel::setAdvancedPlaylist(Playlist advancedPlaylist) -{ - if (m_advancedPlaylist == advancedPlaylist) { - return; - } - - m_advancedPlaylist = advancedPlaylist; - emit advancedPlaylistChanged(); -} - -QVariantList LearnPageModel::playlistToVariantList(const Playlist& playlist) const -{ - QVariantList result; - - // h:mm:ss for anything over an hour - // m:ss for anything under an hour - // 0:ss for anything under a minute - auto formatDuration = [](int durationSecs) { - int seconds = durationSecs; - int minutes = seconds / 60; - seconds -= minutes * 60; - int hours = minutes / 60; - minutes -= hours * 60; - - return ((hours > 0) - ? (QString::number(hours) + ":" + QString::number(minutes).rightJustified(2, '0')) - : QString::number(minutes) - ) + ":" - + QString::number(seconds).rightJustified(2, '0'); - }; - - for (const PlaylistItem& item : playlist) { - QVariantMap itemObj; - itemObj["title"] = item.title; - itemObj["author"] = item.author; - itemObj["url"] = item.url; - itemObj["thumbnailUrl"] = item.thumbnailUrl; - itemObj["duration"] = formatDuration(item.durationSecs); - - result << itemObj; - } - - return result; -} - -Playlist LearnPageModel::filterPlaylistBySearch(const Playlist& playlist) const -{ - Playlist result; - - for (const PlaylistItem& playlistItem : playlist) { - if (playlistItem.title.contains(m_searchText, Qt::CaseInsensitive) - || playlistItem.author.contains(m_searchText, Qt::CaseInsensitive)) { - result.push_back(playlistItem); - } - } - - return result; -} diff --git a/framework/learn/qml/Muse/Learn/learnpagemodel.h b/framework/learn/qml/Muse/Learn/learnpagemodel.h index 4d03541494..5af13d6b3a 100644 --- a/framework/learn/qml/Muse/Learn/learnpagemodel.h +++ b/framework/learn/qml/Muse/Learn/learnpagemodel.h @@ -27,20 +27,22 @@ #include #include -#include "async/asyncable.h" +#include "global/async/asyncable.h" +#include "global/modularity/ioc.h" -#include "modularity/ioc.h" -#include "ilearnservice.h" -#include "ilearnconfiguration.h" +#include "learn/ilearnconfiguration.h" +#include "learn/ilearnservice.h" + +#include "internal/playlistmodel.h" namespace muse::learn { -class LearnPageModel : public QObject, public Contextable, public async::Asyncable +class LearnPageModel : public QObject, public async::Asyncable { Q_OBJECT QML_ELEMENT; - Q_PROPERTY(QVariantList startedPlaylist READ startedPlaylist NOTIFY startedPlaylistChanged) - Q_PROPERTY(QVariantList advancedPlaylist READ advancedPlaylist NOTIFY advancedPlaylistChanged) + Q_PROPERTY(QAbstractItemModel * startedPlaylist READ startedPlaylist CONSTANT) + Q_PROPERTY(QAbstractItemModel * advancedPlaylist READ advancedPlaylist CONSTANT) GlobalInject learnConfiguration; GlobalInject learnService; @@ -48,30 +50,15 @@ class LearnPageModel : public QObject, public Contextable, public async::Asyncab public: explicit LearnPageModel(QObject* parent = nullptr); - QVariantList startedPlaylist() const; - QVariantList advancedPlaylist() const; + PlaylistModel* startedPlaylist() const; + PlaylistModel* advancedPlaylist() const; Q_INVOKABLE void load(); - Q_INVOKABLE void setSearchText(const QString& text); Q_INVOKABLE QVariantMap classesAuthor() const; Q_INVOKABLE bool classesEnabled(); -private slots: - void setStartedPlaylist(Playlist startedPlaylist); - void setAdvancedPlaylist(Playlist advancedPlaylist); - -signals: - void startedPlaylistChanged(); - void advancedPlaylistChanged(); - private: - QVariantList playlistToVariantList(const Playlist& playlist) const; - - Playlist filterPlaylistBySearch(const Playlist& playlist) const; - - Playlist m_startedPlaylist; - Playlist m_advancedPlaylist; - - QString m_searchText; + PlaylistModel* m_gettingStartedModel = nullptr; + PlaylistModel* m_advancedModel = nullptr; }; }