From 3d123fb70d75d21605704bdb695150daede1c957 Mon Sep 17 00:00:00 2001 From: Gabriel Sartori Date: Wed, 9 Sep 2026 07:42:42 -0300 Subject: [PATCH 1/3] Add string helpers to handle case insensitive filters --- framework/interactive/CMakeLists.txt | 2 + .../internal/filedialogfilters.cpp | 116 ++++++++++++++++++ .../interactive/internal/filedialogfilters.h | 30 +++++ 3 files changed, 148 insertions(+) create mode 100644 framework/interactive/internal/filedialogfilters.cpp create mode 100644 framework/interactive/internal/filedialogfilters.h diff --git a/framework/interactive/CMakeLists.txt b/framework/interactive/CMakeLists.txt index 17ddd55647..7ef24391f1 100644 --- a/framework/interactive/CMakeLists.txt +++ b/framework/interactive/CMakeLists.txt @@ -35,6 +35,8 @@ target_sources(muse_interactive PRIVATE dev/testdialog.h dev/testdialog.ui + internal/filedialogfilters.cpp + internal/filedialogfilters.h internal/iinteractiveprovider.h internal/interactive.cpp internal/interactive.h diff --git a/framework/interactive/internal/filedialogfilters.cpp b/framework/interactive/internal/filedialogfilters.cpp new file mode 100644 index 0000000000..7655cd81e7 --- /dev/null +++ b/framework/interactive/internal/filedialogfilters.cpp @@ -0,0 +1,116 @@ +/* + * 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 "filedialogfilters.h" + +#include +#include +#include +#include +#include +#include + +#include "global/stringutils.h" + +using namespace muse::interactive; + +namespace { +struct NameFilterParts { + std::string description; + std::string globs; +}; + +std::optional splitNameFilter(const std::string& filter) +{ + const bool endsWithGroup = !filter.empty() && filter.back() == ')'; + const size_t open = endsWithGroup ? filter.rfind('(') : std::string::npos; + + if (open == std::string::npos) { + return std::nullopt; + } + + return NameFilterParts { filter.substr(0, open), filter.substr(open + 1, filter.size() - open - 2) }; +} + +std::vector splitGlobs(const std::string& globs) +{ + std::istringstream in(globs); + return { std::istream_iterator(in), std::istream_iterator() }; +} + +std::vector tokenizeGlob(const std::string& glob) +{ + std::vector tokens; + + for (size_t pos = 0; pos < glob.size();) { + const size_t close = glob[pos] == '[' ? glob.find(']', pos) : std::string::npos; + const size_t length = close == std::string::npos ? 1 : close - pos + 1; + tokens.push_back(glob.substr(pos, length)); + pos += length; + } + + return tokens; +} + +bool isPlainLetter(const std::string& token) +{ + return token.size() == 1 && std::isalpha(static_cast(token.front())); +} + +std::string bothCases(const std::string& letter) +{ + const unsigned char c = static_cast(letter.front()); + return { '[', static_cast(std::tolower(c)), static_cast(std::toupper(c)), ']' }; +} + +bool needsCaseInsensitiveRewrite(const std::string& glob) +{ + return std::ranges::any_of(tokenizeGlob(glob), isPlainLetter); +} + +std::string caseInsensitiveGlobIfNeeded(const std::string& glob) +{ + return needsCaseInsensitiveRewrite(glob) ? caseInsensitiveGlob(glob) : glob; +} +} + +std::string muse::interactive::caseInsensitiveGlob(const std::string& glob) +{ + std::string result; + for (const std::string& token : tokenizeGlob(glob)) { + result += isPlainLetter(token) ? bothCases(token) : token; + } + return result; +} + +std::string muse::interactive::caseInsensitiveNameFilter(const std::string& filter) +{ + const std::optional parts = splitNameFilter(filter); + if (!parts) { + return filter; + } + + std::vector globs = splitGlobs(parts->globs); + std::ranges::transform(globs, globs.begin(), caseInsensitiveGlobIfNeeded); + + return parts->description + '(' + muse::strings::join(globs, " ") + ')'; +} diff --git a/framework/interactive/internal/filedialogfilters.h b/framework/interactive/internal/filedialogfilters.h new file mode 100644 index 0000000000..12421ea418 --- /dev/null +++ b/framework/interactive/internal/filedialogfilters.h @@ -0,0 +1,30 @@ +/* + * 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 + +namespace muse::interactive { +std::string caseInsensitiveGlob(const std::string& glob); +std::string caseInsensitiveNameFilter(const std::string& filter); +} From e06c2bcbf6d635b3cfba61ee98c3b9aa7799833d Mon Sep 17 00:00:00 2001 From: Gabriel Sartori Date: Wed, 9 Sep 2026 20:56:55 -0300 Subject: [PATCH 2/3] File dialog filters unit tests --- framework/interactive/tests/CMakeLists.txt | 5 + .../tests/filedialogfilters_tests.cpp | 113 ++++++++++++++++++ 2 files changed, 118 insertions(+) create mode 100644 framework/interactive/tests/filedialogfilters_tests.cpp diff --git a/framework/interactive/tests/CMakeLists.txt b/framework/interactive/tests/CMakeLists.txt index ac3ff16bb2..30b8492c2e 100644 --- a/framework/interactive/tests/CMakeLists.txt +++ b/framework/interactive/tests/CMakeLists.txt @@ -21,7 +21,12 @@ set(MODULE_TEST muse_interactive_tests) set(MODULE_TEST_SRC + ${CMAKE_CURRENT_LIST_DIR}/filedialogfilters_tests.cpp ${CMAKE_CURRENT_LIST_DIR}/mocks/interactivemock.h ) +set(MODULE_TEST_LINK + muse_interactive +) + include(SetupGTest) diff --git a/framework/interactive/tests/filedialogfilters_tests.cpp b/framework/interactive/tests/filedialogfilters_tests.cpp new file mode 100644 index 0000000000..eaa5eb4458 --- /dev/null +++ b/framework/interactive/tests/filedialogfilters_tests.cpp @@ -0,0 +1,113 @@ +/* + * 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 + +#include + +#include "interactive/internal/filedialogfilters.h" + +using namespace muse::interactive; + +class Interactive_FileDialogFiltersTests : public ::testing::Test +{ +}; + +TEST_F(Interactive_FileDialogFiltersTests, CaseInsensitiveGlob_ExpandsLetters) +{ + EXPECT_EQ(caseInsensitiveGlob("*.mp3"), "*.[mM][pP]3"); + EXPECT_EQ(caseInsensitiveGlob("*.3gp"), "*.3[gG][pP]"); + EXPECT_EQ(caseInsensitiveGlob("*.film_cpk"), "*.[fF][iI][lL][mM]_[cC][pP][kK]"); +} + +TEST_F(Interactive_FileDialogFiltersTests, CaseInsensitiveGlob_NormalisesUpperCaseInput) +{ + EXPECT_EQ(caseInsensitiveGlob("*.MTV"), "*.[mM][tT][vV]"); +} + +TEST_F(Interactive_FileDialogFiltersTests, CaseInsensitiveGlob_LeavesNonLettersUntouched) +{ + EXPECT_EQ(caseInsensitiveGlob("*"), "*"); + EXPECT_EQ(caseInsensitiveGlob("*.302"), "*.302"); + EXPECT_EQ(caseInsensitiveGlob(""), ""); +} + +TEST_F(Interactive_FileDialogFiltersTests, CaseInsensitiveGlob_LeavesBracketExpressionsUntouched) +{ + EXPECT_EQ(caseInsensitiveGlob("*.[mM][pP]3"), "*.[mM][pP]3"); + EXPECT_EQ(caseInsensitiveGlob("*.[0-9]"), "*.[0-9]"); + EXPECT_EQ(caseInsensitiveGlob("[a-z]*"), "[a-z]*"); +} + +TEST_F(Interactive_FileDialogFiltersTests, CaseInsensitiveGlob_ExpandsLettersAroundBracketExpressions) +{ + EXPECT_EQ(caseInsensitiveGlob("*.m[34]a"), "*.[mM][34][aA]"); + EXPECT_EQ(caseInsensitiveGlob("*.[mM]p[34]"), "*.[mM][pP][34]"); +} + +TEST_F(Interactive_FileDialogFiltersTests, CaseInsensitiveNameFilter_SingleGroup) +{ + EXPECT_EQ(caseInsensitiveNameFilter("Audio files (*.mp3 *.wav)"), + "Audio files (*.[mM][pP]3 *.[wW][aA][vV])"); +} + +TEST_F(Interactive_FileDialogFiltersTests, CaseInsensitiveNameFilter_GlobWithBracketExpression) +{ + EXPECT_EQ(caseInsensitiveNameFilter("MPEG-4 audio (*.m4a *.m[34]a)"), + "MPEG-4 audio (*.[mM]4[aA] *.[mM][34][aA])"); +} + +TEST_F(Interactive_FileDialogFiltersTests, CaseInsensitiveNameFilter_RewritesOnlyTrailingGroup) +{ + EXPECT_EQ(caseInsensitiveNameFilter("All supported files (*.mp3,*.aac, ...) (*.aac *.ac3 *.mp2)"), + "All supported files (*.mp3,*.aac, ...) (*.[aA][aA][cC] *.[aA][cC]3 *.[mM][pP]2)"); +} + +TEST_F(Interactive_FileDialogFiltersTests, CaseInsensitiveNameFilter_AllFiles) +{ + EXPECT_EQ(caseInsensitiveNameFilter("All files (*)"), "All files (*)"); +} + +TEST_F(Interactive_FileDialogFiltersTests, CaseInsensitiveNameFilter_EmptyGroup) +{ + EXPECT_EQ(caseInsensitiveNameFilter("Name ()"), "Name ()"); +} + +TEST_F(Interactive_FileDialogFiltersTests, CaseInsensitiveNameFilter_CollapsesWhitespaceBetweenGlobs) +{ + EXPECT_EQ(caseInsensitiveNameFilter("Audio (*.mp3 *.wav )"), "Audio (*.[mM][pP]3 *.[wW][aA][vV])"); +} + +TEST_F(Interactive_FileDialogFiltersTests, CaseInsensitiveNameFilter_LeavesBareGlobListUntouched) +{ + EXPECT_EQ(caseInsensitiveNameFilter("*.mp3 *.wav"), "*.mp3 *.wav"); +} + +TEST_F(Interactive_FileDialogFiltersTests, CaseInsensitiveNameFilter_EmptyString) +{ + EXPECT_EQ(caseInsensitiveNameFilter(""), ""); +} + +TEST_F(Interactive_FileDialogFiltersTests, CaseInsensitiveNameFilter_IsIdempotent) +{ + const std::string once = caseInsensitiveNameFilter("Audio files (*.mp3 *.wav *.m[34]a)"); + EXPECT_EQ(caseInsensitiveNameFilter(once), once); +} From 0776ff67e9b3bf031da0d52699cf77c08d0bed95 Mon Sep 17 00:00:00 2001 From: Gabriel Sartori Date: Thu, 10 Sep 2026 06:42:02 -0300 Subject: [PATCH 3/3] Apply case insensitivity modification to open file dialogs --- framework/interactive/internal/interactive.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/framework/interactive/internal/interactive.cpp b/framework/interactive/internal/interactive.cpp index e52b83b531..62ed8c3cc8 100644 --- a/framework/interactive/internal/interactive.cpp +++ b/framework/interactive/internal/interactive.cpp @@ -39,6 +39,7 @@ #include "diagnostics/diagnosticutils.h" +#include "filedialogfilters.h" #include "widgetdialogadapter.h" #include "ui/view/widgetdialog.h" @@ -318,9 +319,13 @@ static UriQuery makeSelectFileQuery(FileDialogMode mode, const std::string& titl UriQuery q("muse://interactive/selectfile"); q.set("title", title); + const bool isOpenMode = mode == FileDialogMode::OpenFile || mode == FileDialogMode::OpenFiles; + const bool hidesFilterDetails = options & QFileDialog::HideNameFilterDetails; + const bool matchCaseInsensitively = isOpenMode && hidesFilterDetails; + ValList filterList; for (const std::string& f : filter) { - filterList.push_back(Val(f)); + filterList.push_back(Val(matchCaseInsensitively ? caseInsensitiveNameFilter(f) : f)); } q.set("nameFilters", filterList);