diff --git a/osu.Desktop/osu.Desktop.csproj b/osu.Desktop/osu.Desktop.csproj
index 538aaf2d7a..2461351110 100644
--- a/osu.Desktop/osu.Desktop.csproj
+++ b/osu.Desktop/osu.Desktop.csproj
@@ -23,10 +23,10 @@
-
+
-
+
diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs
new file mode 100644
index 0000000000..30686cb947
--- /dev/null
+++ b/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs
@@ -0,0 +1,201 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using NUnit.Framework;
+using osu.Game.Beatmaps;
+using osu.Game.Rulesets;
+using osu.Game.Screens.Select;
+using osu.Game.Screens.Select.Carousel;
+
+namespace osu.Game.Tests.NonVisual.Filtering
+{
+ [TestFixture]
+ public class FilterMatchingTest
+ {
+ private BeatmapInfo getExampleBeatmap() => new BeatmapInfo
+ {
+ Ruleset = new RulesetInfo { ID = 5 },
+ StarDifficulty = 4.0d,
+ BaseDifficulty = new BeatmapDifficulty
+ {
+ ApproachRate = 5.0f,
+ DrainRate = 3.0f,
+ CircleSize = 2.0f,
+ },
+ Metadata = new BeatmapMetadata
+ {
+ Artist = "The Artist",
+ ArtistUnicode = "check unicode too",
+ Title = "Title goes here",
+ TitleUnicode = "Title goes here",
+ AuthorString = "The Author",
+ Source = "unit tests",
+ Tags = "look for tags too",
+ },
+ Version = "version as well",
+ Length = 2500,
+ BPM = 160,
+ BeatDivisor = 12,
+ Status = BeatmapSetOnlineStatus.Loved
+ };
+
+ [Test]
+ public void TestCriteriaMatchingNoRuleset()
+ {
+ var exampleBeatmapInfo = getExampleBeatmap();
+ var criteria = new FilterCriteria();
+ var carouselItem = new CarouselBeatmap(exampleBeatmapInfo);
+ carouselItem.Filter(criteria);
+ Assert.IsFalse(carouselItem.Filtered.Value);
+ }
+
+ [Test]
+ public void TestCriteriaMatchingSpecificRuleset()
+ {
+ var exampleBeatmapInfo = getExampleBeatmap();
+ var criteria = new FilterCriteria
+ {
+ Ruleset = new RulesetInfo { ID = 6 }
+ };
+ var carouselItem = new CarouselBeatmap(exampleBeatmapInfo);
+ carouselItem.Filter(criteria);
+ Assert.IsTrue(carouselItem.Filtered.Value);
+ }
+
+ [Test]
+ public void TestCriteriaMatchingConvertedBeatmaps()
+ {
+ var exampleBeatmapInfo = getExampleBeatmap();
+ var criteria = new FilterCriteria
+ {
+ Ruleset = new RulesetInfo { ID = 6 },
+ AllowConvertedBeatmaps = true
+ };
+ var carouselItem = new CarouselBeatmap(exampleBeatmapInfo);
+ carouselItem.Filter(criteria);
+ Assert.IsFalse(carouselItem.Filtered.Value);
+ }
+
+ [Test]
+ [TestCase(true)]
+ [TestCase(false)]
+ public void TestCriteriaMatchingRangeMin(bool inclusive)
+ {
+ var exampleBeatmapInfo = getExampleBeatmap();
+ var criteria = new FilterCriteria
+ {
+ Ruleset = new RulesetInfo { ID = 6 },
+ AllowConvertedBeatmaps = true,
+ ApproachRate = new FilterCriteria.OptionalRange
+ {
+ IsLowerInclusive = inclusive,
+ Min = 5.0f
+ }
+ };
+ var carouselItem = new CarouselBeatmap(exampleBeatmapInfo);
+ carouselItem.Filter(criteria);
+ Assert.AreEqual(!inclusive, carouselItem.Filtered.Value);
+ }
+
+ [Test]
+ [TestCase(true)]
+ [TestCase(false)]
+ public void TestCriteriaMatchingRangeMax(bool inclusive)
+ {
+ var exampleBeatmapInfo = getExampleBeatmap();
+ var criteria = new FilterCriteria
+ {
+ Ruleset = new RulesetInfo { ID = 6 },
+ AllowConvertedBeatmaps = true,
+ BPM = new FilterCriteria.OptionalRange
+ {
+ IsUpperInclusive = inclusive,
+ Max = 160d
+ }
+ };
+ var carouselItem = new CarouselBeatmap(exampleBeatmapInfo);
+ carouselItem.Filter(criteria);
+ Assert.AreEqual(!inclusive, carouselItem.Filtered.Value);
+ }
+
+ [Test]
+ [TestCase("artist", false)]
+ [TestCase("artist title author", false)]
+ [TestCase("an artist", true)]
+ [TestCase("tags too", false)]
+ [TestCase("version", false)]
+ [TestCase("an auteur", true)]
+ public void TestCriteriaMatchingTerms(string terms, bool filtered)
+ {
+ var exampleBeatmapInfo = getExampleBeatmap();
+ var criteria = new FilterCriteria
+ {
+ Ruleset = new RulesetInfo { ID = 6 },
+ AllowConvertedBeatmaps = true,
+ SearchText = terms
+ };
+ var carouselItem = new CarouselBeatmap(exampleBeatmapInfo);
+ carouselItem.Filter(criteria);
+ Assert.AreEqual(filtered, carouselItem.Filtered.Value);
+ }
+
+ [Test]
+ [TestCase("", false)]
+ [TestCase("The", false)]
+ [TestCase("THE", false)]
+ [TestCase("author", false)]
+ [TestCase("the author", false)]
+ [TestCase("the author AND then something else", true)]
+ [TestCase("unknown", true)]
+ public void TestCriteriaMatchingCreator(string creatorName, bool filtered)
+ {
+ var exampleBeatmapInfo = getExampleBeatmap();
+ var criteria = new FilterCriteria
+ {
+ Creator = new FilterCriteria.OptionalTextFilter { SearchTerm = creatorName }
+ };
+ var carouselItem = new CarouselBeatmap(exampleBeatmapInfo);
+ carouselItem.Filter(criteria);
+ Assert.AreEqual(filtered, carouselItem.Filtered.Value);
+ }
+
+ [Test]
+ [TestCase("", false)]
+ [TestCase("The", false)]
+ [TestCase("THE", false)]
+ [TestCase("artist", false)]
+ [TestCase("the artist", false)]
+ [TestCase("the artist AND then something else", true)]
+ [TestCase("unicode too", false)]
+ [TestCase("unknown", true)]
+ public void TestCriteriaMatchingArtist(string artistName, bool filtered)
+ {
+ var exampleBeatmapInfo = getExampleBeatmap();
+ var criteria = new FilterCriteria
+ {
+ Artist = new FilterCriteria.OptionalTextFilter { SearchTerm = artistName }
+ };
+ var carouselItem = new CarouselBeatmap(exampleBeatmapInfo);
+ carouselItem.Filter(criteria);
+ Assert.AreEqual(filtered, carouselItem.Filtered.Value);
+ }
+
+ [Test]
+ [TestCase("", false)]
+ [TestCase("artist", false)]
+ [TestCase("unknown", true)]
+ public void TestCriteriaMatchingArtistWithNullUnicodeName(string artistName, bool filtered)
+ {
+ var exampleBeatmapInfo = getExampleBeatmap();
+ exampleBeatmapInfo.Metadata.ArtistUnicode = null;
+
+ var criteria = new FilterCriteria
+ {
+ Artist = new FilterCriteria.OptionalTextFilter { SearchTerm = artistName }
+ };
+ var carouselItem = new CarouselBeatmap(exampleBeatmapInfo);
+ carouselItem.Filter(criteria);
+ Assert.AreEqual(filtered, carouselItem.Filtered.Value);
+ }
+ }
+}
diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs
new file mode 100644
index 0000000000..9869ddde41
--- /dev/null
+++ b/osu.Game.Tests/NonVisual/Filtering/FilterQueryParserTest.cs
@@ -0,0 +1,184 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using NUnit.Framework;
+using osu.Game.Beatmaps;
+using osu.Game.Screens.Select;
+
+namespace osu.Game.Tests.NonVisual.Filtering
+{
+ [TestFixture]
+ public class FilterQueryParserTest
+ {
+ [Test]
+ public void TestApplyQueriesBareWords()
+ {
+ const string query = "looking for a beatmap";
+ var filterCriteria = new FilterCriteria();
+ FilterQueryParser.ApplyQueries(filterCriteria, query);
+ Assert.AreEqual("looking for a beatmap", filterCriteria.SearchText);
+ Assert.AreEqual(4, filterCriteria.SearchTerms.Length);
+ }
+
+ /*
+ * The following tests have been written a bit strangely (they don't check exact
+ * bound equality with what the filter says).
+ * This is to account for floating-point arithmetic issues.
+ * For example, specifying a bpm<140 filter would previously match beatmaps with BPM
+ * of 139.99999, which would be displayed in the UI as 140.
+ * Due to this the tests check the last tick inside the range and the first tick
+ * outside of the range.
+ */
+
+ [Test]
+ public void TestApplyStarQueries()
+ {
+ const string query = "stars<4 easy";
+ var filterCriteria = new FilterCriteria();
+ FilterQueryParser.ApplyQueries(filterCriteria, query);
+ Assert.AreEqual("easy", filterCriteria.SearchText.Trim());
+ Assert.AreEqual(1, filterCriteria.SearchTerms.Length);
+ Assert.IsNotNull(filterCriteria.StarDifficulty.Max);
+ Assert.Greater(filterCriteria.StarDifficulty.Max, 3.99d);
+ Assert.Less(filterCriteria.StarDifficulty.Max, 4.00d);
+ Assert.IsNull(filterCriteria.StarDifficulty.Min);
+ }
+
+ [Test]
+ public void TestApplyApproachRateQueries()
+ {
+ const string query = "ar>=9 difficult";
+ var filterCriteria = new FilterCriteria();
+ FilterQueryParser.ApplyQueries(filterCriteria, query);
+ Assert.AreEqual("difficult", filterCriteria.SearchText.Trim());
+ Assert.AreEqual(1, filterCriteria.SearchTerms.Length);
+ Assert.IsNotNull(filterCriteria.ApproachRate.Min);
+ Assert.Greater(filterCriteria.ApproachRate.Min, 8.9f);
+ Assert.Less(filterCriteria.ApproachRate.Min, 9.0f);
+ Assert.IsNull(filterCriteria.ApproachRate.Max);
+ }
+
+ [Test]
+ public void TestApplyDrainRateQueries()
+ {
+ const string query = "dr>2 quite specific dr<:6";
+ var filterCriteria = new FilterCriteria();
+ FilterQueryParser.ApplyQueries(filterCriteria, query);
+ Assert.AreEqual("quite specific", filterCriteria.SearchText.Trim());
+ Assert.AreEqual(2, filterCriteria.SearchTerms.Length);
+ Assert.Greater(filterCriteria.DrainRate.Min, 2.0f);
+ Assert.Less(filterCriteria.DrainRate.Min, 2.1f);
+ Assert.Greater(filterCriteria.DrainRate.Max, 6.0f);
+ Assert.Less(filterCriteria.DrainRate.Min, 6.1f);
+ }
+
+ [Test]
+ public void TestApplyBPMQueries()
+ {
+ const string query = "bpm>:200 gotta go fast";
+ var filterCriteria = new FilterCriteria();
+ FilterQueryParser.ApplyQueries(filterCriteria, query);
+ Assert.AreEqual("gotta go fast", filterCriteria.SearchText.Trim());
+ Assert.AreEqual(3, filterCriteria.SearchTerms.Length);
+ Assert.IsNotNull(filterCriteria.BPM.Min);
+ Assert.Greater(filterCriteria.BPM.Min, 199.99d);
+ Assert.Less(filterCriteria.BPM.Min, 200.00d);
+ Assert.IsNull(filterCriteria.BPM.Max);
+ }
+
+ private static object[] lengthQueryExamples =
+ {
+ new object[] { "6ms", TimeSpan.FromMilliseconds(6), TimeSpan.FromMilliseconds(1) },
+ new object[] { "23s", TimeSpan.FromSeconds(23), TimeSpan.FromSeconds(1) },
+ new object[] { "9m", TimeSpan.FromMinutes(9), TimeSpan.FromMinutes(1) },
+ new object[] { "0.25h", TimeSpan.FromHours(0.25), TimeSpan.FromHours(1) },
+ new object[] { "70", TimeSpan.FromSeconds(70), TimeSpan.FromSeconds(1) },
+ };
+
+ [Test]
+ [TestCaseSource(nameof(lengthQueryExamples))]
+ public void TestApplyLengthQueries(string lengthQuery, TimeSpan expectedLength, TimeSpan scale)
+ {
+ string query = $"length={lengthQuery} time";
+ var filterCriteria = new FilterCriteria();
+ FilterQueryParser.ApplyQueries(filterCriteria, query);
+ Assert.AreEqual("time", filterCriteria.SearchText.Trim());
+ Assert.AreEqual(1, filterCriteria.SearchTerms.Length);
+ Assert.AreEqual(expectedLength.TotalMilliseconds - scale.TotalMilliseconds / 2.0, filterCriteria.Length.Min);
+ Assert.AreEqual(expectedLength.TotalMilliseconds + scale.TotalMilliseconds / 2.0, filterCriteria.Length.Max);
+ }
+
+ [Test]
+ public void TestApplyDivisorQueries()
+ {
+ const string query = "that's a time signature alright! divisor:12";
+ var filterCriteria = new FilterCriteria();
+ FilterQueryParser.ApplyQueries(filterCriteria, query);
+ Assert.AreEqual("that's a time signature alright!", filterCriteria.SearchText.Trim());
+ Assert.AreEqual(5, filterCriteria.SearchTerms.Length);
+ Assert.AreEqual(12, filterCriteria.BeatDivisor.Min);
+ Assert.IsTrue(filterCriteria.BeatDivisor.IsLowerInclusive);
+ Assert.AreEqual(12, filterCriteria.BeatDivisor.Max);
+ Assert.IsTrue(filterCriteria.BeatDivisor.IsUpperInclusive);
+ }
+
+ [Test]
+ public void TestApplyStatusQueries()
+ {
+ const string query = "I want the pp status=ranked";
+ var filterCriteria = new FilterCriteria();
+ FilterQueryParser.ApplyQueries(filterCriteria, query);
+ Assert.AreEqual("I want the pp", filterCriteria.SearchText.Trim());
+ Assert.AreEqual(4, filterCriteria.SearchTerms.Length);
+ Assert.AreEqual(BeatmapSetOnlineStatus.Ranked, filterCriteria.OnlineStatus.Min);
+ Assert.IsTrue(filterCriteria.OnlineStatus.IsLowerInclusive);
+ Assert.AreEqual(BeatmapSetOnlineStatus.Ranked, filterCriteria.OnlineStatus.Max);
+ Assert.IsTrue(filterCriteria.OnlineStatus.IsUpperInclusive);
+ }
+
+ [Test]
+ public void TestApplyCreatorQueries()
+ {
+ const string query = "beatmap specifically by creator=my_fav";
+ var filterCriteria = new FilterCriteria();
+ FilterQueryParser.ApplyQueries(filterCriteria, query);
+ Assert.AreEqual("beatmap specifically by", filterCriteria.SearchText.Trim());
+ Assert.AreEqual(3, filterCriteria.SearchTerms.Length);
+ Assert.AreEqual("my_fav", filterCriteria.Creator.SearchTerm);
+ }
+
+ [Test]
+ public void TestApplyArtistQueries()
+ {
+ const string query = "find me songs by artist=singer please";
+ var filterCriteria = new FilterCriteria();
+ FilterQueryParser.ApplyQueries(filterCriteria, query);
+ Assert.AreEqual("find me songs by please", filterCriteria.SearchText.Trim());
+ Assert.AreEqual(5, filterCriteria.SearchTerms.Length);
+ Assert.AreEqual("singer", filterCriteria.Artist.SearchTerm);
+ }
+
+ [Test]
+ public void TestApplyArtistQueriesWithSpaces()
+ {
+ const string query = "really like artist=\"name with space\" yes";
+ var filterCriteria = new FilterCriteria();
+ FilterQueryParser.ApplyQueries(filterCriteria, query);
+ Assert.AreEqual("really like yes", filterCriteria.SearchText.Trim());
+ Assert.AreEqual(3, filterCriteria.SearchTerms.Length);
+ Assert.AreEqual("name with space", filterCriteria.Artist.SearchTerm);
+ }
+
+ [Test]
+ public void TestApplyArtistQueriesOneDoubleQuote()
+ {
+ const string query = "weird artist=double\"quote";
+ var filterCriteria = new FilterCriteria();
+ FilterQueryParser.ApplyQueries(filterCriteria, query);
+ Assert.AreEqual("weird", filterCriteria.SearchText.Trim());
+ Assert.AreEqual(1, filterCriteria.SearchTerms.Length);
+ Assert.AreEqual("double\"quote", filterCriteria.Artist.SearchTerm);
+ }
+ }
+}
diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs
index 6669ec7da3..71399106f4 100644
--- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs
+++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapCarousel.cs
@@ -242,6 +242,21 @@ namespace osu.Game.Tests.Visual.SongSelect
AddStep("Un-filter", () => carousel.Filter(new FilterCriteria(), false));
AddAssert("Selection is non-null", () => currentSelection != null);
+
+ setSelected(1, 3);
+ AddStep("Apply a range filter", () => carousel.Filter(new FilterCriteria
+ {
+ SearchText = "#3",
+ StarDifficulty = new FilterCriteria.OptionalRange
+ {
+ Min = 2,
+ Max = 5.5,
+ IsLowerInclusive = true
+ }
+ }, false));
+ checkSelected(3, 2);
+
+ AddStep("Un-filter", () => carousel.Filter(new FilterCriteria(), false));
}
///
diff --git a/osu.Game.Tournament/osu.Game.Tournament.csproj b/osu.Game.Tournament/osu.Game.Tournament.csproj
index 4790fcbcde..bddaff0a80 100644
--- a/osu.Game.Tournament/osu.Game.Tournament.csproj
+++ b/osu.Game.Tournament/osu.Game.Tournament.csproj
@@ -11,6 +11,6 @@
-
+
\ No newline at end of file
diff --git a/osu.Game/Overlays/Changelog/ChangelogBuild.cs b/osu.Game/Overlays/Changelog/ChangelogBuild.cs
index 11dc2049fd..bce1be5941 100644
--- a/osu.Game/Overlays/Changelog/ChangelogBuild.cs
+++ b/osu.Game/Overlays/Changelog/ChangelogBuild.cs
@@ -15,6 +15,7 @@ using osu.Game.Users;
using osuTK.Graphics;
using osu.Framework.Allocation;
using System.Net;
+using osuTK;
namespace osu.Game.Overlays.Changelog
{
@@ -67,22 +68,34 @@ namespace osu.Game.Overlays.Changelog
foreach (APIChangelogEntry entry in categoryEntries)
{
- LinkFlowContainer title = new LinkFlowContainer
- {
- Direction = FillDirection.Full,
- RelativeSizeAxes = Axes.X,
- AutoSizeAxes = Axes.Y,
- Margin = new MarginPadding { Vertical = 5 },
- };
-
var entryColour = entry.Major ? colours.YellowLight : Color4.White;
- title.AddIcon(entry.Type == ChangelogEntryType.Fix ? FontAwesome.Solid.Check : FontAwesome.Solid.Plus, t =>
+ LinkFlowContainer title;
+
+ Container titleContainer = new Container
{
- t.Font = fontSmall;
- t.Colour = entryColour;
- t.Padding = new MarginPadding { Left = -17, Right = 5 };
- });
+ AutoSizeAxes = Axes.Y,
+ RelativeSizeAxes = Axes.X,
+ Margin = new MarginPadding { Vertical = 5 },
+ Children = new Drawable[]
+ {
+ new SpriteIcon
+ {
+ Anchor = Anchor.CentreLeft,
+ Origin = Anchor.CentreRight,
+ Size = new Vector2(fontSmall.Size),
+ Icon = entry.Type == ChangelogEntryType.Fix ? FontAwesome.Solid.Check : FontAwesome.Solid.Plus,
+ Colour = entryColour,
+ Margin = new MarginPadding { Right = 5 },
+ },
+ title = new LinkFlowContainer
+ {
+ Direction = FillDirection.Full,
+ RelativeSizeAxes = Axes.X,
+ AutoSizeAxes = Axes.Y,
+ }
+ }
+ };
title.AddText(entry.Title, t =>
{
@@ -139,7 +152,7 @@ namespace osu.Game.Overlays.Changelog
t.Colour = entryColour;
});
- ChangelogEntries.Add(title);
+ ChangelogEntries.Add(titleContainer);
if (!string.IsNullOrEmpty(entry.MessageHtml))
{
diff --git a/osu.Game/Screens/Menu/Button.cs b/osu.Game/Screens/Menu/Button.cs
index 1bf25a2504..ffeadb96c7 100644
--- a/osu.Game/Screens/Menu/Button.cs
+++ b/osu.Game/Screens/Menu/Button.cs
@@ -31,6 +31,8 @@ namespace osu.Game.Screens.Menu
{
public event Action StateChanged;
+ public readonly Key TriggerKey;
+
private readonly Container iconText;
private readonly Container box;
private readonly Box boxHoverLayer;
@@ -43,7 +45,6 @@ namespace osu.Game.Screens.Menu
public ButtonSystemState VisibleState = ButtonSystemState.TopLevel;
private readonly Action clickAction;
- private readonly Key triggerKey;
private SampleChannel sampleClick;
private SampleChannel sampleHover;
@@ -53,7 +54,7 @@ namespace osu.Game.Screens.Menu
{
this.sampleName = sampleName;
this.clickAction = clickAction;
- this.triggerKey = triggerKey;
+ TriggerKey = triggerKey;
AutoSizeAxes = Axes.Both;
Alpha = 0;
@@ -210,7 +211,7 @@ namespace osu.Game.Screens.Menu
if (e.Repeat || e.ControlPressed || e.ShiftPressed || e.AltPressed)
return false;
- if (triggerKey == e.Key && triggerKey != Key.Unknown)
+ if (TriggerKey == e.Key && TriggerKey != Key.Unknown)
{
trigger();
return true;
diff --git a/osu.Game/Screens/Menu/ButtonSystem.cs b/osu.Game/Screens/Menu/ButtonSystem.cs
index 1a3e1213b4..ed8e4c70f9 100644
--- a/osu.Game/Screens/Menu/ButtonSystem.cs
+++ b/osu.Game/Screens/Menu/ButtonSystem.cs
@@ -14,6 +14,7 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites;
using osu.Framework.Input.Bindings;
+using osu.Framework.Input.Events;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Framework.Threading;
@@ -180,6 +181,20 @@ namespace osu.Game.Screens.Menu
State = ButtonSystemState.Initial;
}
+ protected override bool OnKeyDown(KeyDownEvent e)
+ {
+ if (State == ButtonSystemState.Initial)
+ {
+ if (buttonsTopLevel.Any(b => e.Key == b.TriggerKey))
+ {
+ logo?.Click();
+ return true;
+ }
+ }
+
+ return base.OnKeyDown(e);
+ }
+
public bool OnPressed(GlobalAction action)
{
switch (action)
diff --git a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs
index 9cc84c8bdd..6c3c9d20f3 100644
--- a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs
+++ b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs
@@ -39,6 +39,10 @@ namespace osu.Game.Screens.Select.Carousel
match &= criteria.BeatDivisor.IsInRange(Beatmap.BeatDivisor);
match &= criteria.OnlineStatus.IsInRange(Beatmap.Status);
+ match &= criteria.Creator.Matches(Beatmap.Metadata.AuthorString);
+ match &= criteria.Artist.Matches(Beatmap.Metadata.Artist) ||
+ criteria.Artist.Matches(Beatmap.Metadata.ArtistUnicode);
+
if (match)
foreach (var criteriaTerm in criteria.SearchTerms)
match &=
diff --git a/osu.Game/Screens/Select/FilterControl.cs b/osu.Game/Screens/Select/FilterControl.cs
index e3c23f7e22..91f1ca0307 100644
--- a/osu.Game/Screens/Select/FilterControl.cs
+++ b/osu.Game/Screens/Select/FilterControl.cs
@@ -16,8 +16,6 @@ using Container = osu.Framework.Graphics.Containers.Container;
using osu.Framework.Graphics.Shapes;
using osu.Game.Configuration;
using osu.Game.Rulesets;
-using System.Text.RegularExpressions;
-using osu.Game.Beatmaps;
namespace osu.Game.Screens.Select
{
@@ -47,10 +45,7 @@ namespace osu.Game.Screens.Select
Ruleset = ruleset.Value
};
- applyQueries(criteria, ref query);
-
- criteria.SearchText = query;
-
+ FilterQueryParser.ApplyQueries(criteria, query);
return criteria;
}
@@ -181,129 +176,5 @@ namespace osu.Game.Screens.Select
}
private void updateCriteria() => FilterChanged?.Invoke(CreateCriteria());
-
- private static readonly Regex query_syntax_regex = new Regex(
- @"\b(?stars|ar|dr|cs|divisor|length|objects|bpm|status)(?[=:><]+)(?\S*)",
- RegexOptions.Compiled | RegexOptions.IgnoreCase);
-
- private void applyQueries(FilterCriteria criteria, ref string query)
- {
- foreach (Match match in query_syntax_regex.Matches(query))
- {
- var key = match.Groups["key"].Value.ToLower();
- var op = match.Groups["op"].Value;
- var value = match.Groups["value"].Value;
-
- switch (key)
- {
- case "stars" when float.TryParse(value, out var stars):
- updateCriteriaRange(ref criteria.StarDifficulty, op, stars);
- break;
-
- case "ar" when float.TryParse(value, out var ar):
- updateCriteriaRange(ref criteria.ApproachRate, op, ar);
- break;
-
- case "dr" when float.TryParse(value, out var dr):
- updateCriteriaRange(ref criteria.DrainRate, op, dr);
- break;
-
- case "cs" when float.TryParse(value, out var cs):
- updateCriteriaRange(ref criteria.CircleSize, op, cs);
- break;
-
- case "bpm" when double.TryParse(value, out var bpm):
- updateCriteriaRange(ref criteria.BPM, op, bpm);
- break;
-
- case "length" when double.TryParse(value.TrimEnd('m', 's', 'h'), out var length):
- var scale =
- value.EndsWith("ms") ? 1 :
- value.EndsWith("s") ? 1000 :
- value.EndsWith("m") ? 60000 :
- value.EndsWith("h") ? 3600000 : 1000;
-
- updateCriteriaRange(ref criteria.Length, op, length * scale, scale / 2.0);
- break;
-
- case "divisor" when int.TryParse(value, out var divisor):
- updateCriteriaRange(ref criteria.BeatDivisor, op, divisor);
- break;
-
- case "status" when Enum.TryParse(value, true, out var statusValue):
- updateCriteriaRange(ref criteria.OnlineStatus, op, statusValue);
- break;
- }
-
- query = query.Replace(match.ToString(), "");
- }
- }
-
- private void updateCriteriaRange(ref FilterCriteria.OptionalRange range, string op, float value, float tolerance = 0.05f)
- {
- updateCriteriaRange(ref range, op, value);
-
- switch (op)
- {
- case "=":
- case ":":
- range.Min = value - tolerance;
- range.Max = value + tolerance;
- break;
- }
- }
-
- private void updateCriteriaRange(ref FilterCriteria.OptionalRange range, string op, double value, double tolerance = 0.05)
- {
- updateCriteriaRange(ref range, op, value);
-
- switch (op)
- {
- case "=":
- case ":":
- range.Min = value - tolerance;
- range.Max = value + tolerance;
- break;
- }
- }
-
- private void updateCriteriaRange(ref FilterCriteria.OptionalRange range, string op, T value)
- where T : struct, IComparable
- {
- switch (op)
- {
- default:
- return;
-
- case "=":
- case ":":
- range.IsInclusive = true;
- range.Min = value;
- range.Max = value;
- break;
-
- case ">":
- range.IsInclusive = false;
- range.Min = value;
- break;
-
- case ">=":
- case ">:":
- range.IsInclusive = true;
- range.Min = value;
- break;
-
- case "<":
- range.IsInclusive = false;
- range.Max = value;
- break;
-
- case "<=":
- case "<:":
- range.IsInclusive = true;
- range.Max = value;
- break;
- }
- }
}
}
diff --git a/osu.Game/Screens/Select/FilterCriteria.cs b/osu.Game/Screens/Select/FilterCriteria.cs
index a3fa1b10ca..c2cbac905e 100644
--- a/osu.Game/Screens/Select/FilterCriteria.cs
+++ b/osu.Game/Screens/Select/FilterCriteria.cs
@@ -23,6 +23,8 @@ namespace osu.Game.Screens.Select
public OptionalRange BPM;
public OptionalRange BeatDivisor;
public OptionalRange OnlineStatus;
+ public OptionalTextFilter Creator;
+ public OptionalTextFilter Artist;
public string[] SearchTerms = Array.Empty();
@@ -53,7 +55,7 @@ namespace osu.Game.Screens.Select
if (comparison < 0)
return false;
- if (comparison == 0 && !IsInclusive)
+ if (comparison == 0 && !IsLowerInclusive)
return false;
}
@@ -64,7 +66,7 @@ namespace osu.Game.Screens.Select
if (comparison > 0)
return false;
- if (comparison == 0 && !IsInclusive)
+ if (comparison == 0 && !IsUpperInclusive)
return false;
}
@@ -73,12 +75,33 @@ namespace osu.Game.Screens.Select
public T? Min;
public T? Max;
- public bool IsInclusive;
+ public bool IsLowerInclusive;
+ public bool IsUpperInclusive;
public bool Equals(OptionalRange other)
=> Min.Equals(other.Min)
&& Max.Equals(other.Max)
- && IsInclusive.Equals(other.IsInclusive);
+ && IsLowerInclusive.Equals(other.IsLowerInclusive)
+ && IsUpperInclusive.Equals(other.IsUpperInclusive);
+ }
+
+ public struct OptionalTextFilter : IEquatable
+ {
+ public bool Matches(string value)
+ {
+ if (string.IsNullOrEmpty(SearchTerm))
+ return true;
+
+ // search term is guaranteed to be non-empty, so if the string we're comparing is empty, it's not matching
+ if (string.IsNullOrEmpty(value))
+ return false;
+
+ return value.IndexOf(SearchTerm, StringComparison.InvariantCultureIgnoreCase) >= 0;
+ }
+
+ public string SearchTerm;
+
+ public bool Equals(OptionalTextFilter other) => SearchTerm?.Equals(other.SearchTerm) ?? true;
}
}
}
diff --git a/osu.Game/Screens/Select/FilterQueryParser.cs b/osu.Game/Screens/Select/FilterQueryParser.cs
new file mode 100644
index 0000000000..ffe1258168
--- /dev/null
+++ b/osu.Game/Screens/Select/FilterQueryParser.cs
@@ -0,0 +1,211 @@
+// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence.
+// See the LICENCE file in the repository root for full licence text.
+
+using System;
+using System.Globalization;
+using System.Text.RegularExpressions;
+using osu.Game.Beatmaps;
+
+namespace osu.Game.Screens.Select
+{
+ internal static class FilterQueryParser
+ {
+ private static readonly Regex query_syntax_regex = new Regex(
+ @"\b(?stars|ar|dr|cs|divisor|length|objects|bpm|status|creator|artist)(?[=:><]+)(?("".*"")|(\S*))",
+ RegexOptions.Compiled | RegexOptions.IgnoreCase);
+
+ internal static void ApplyQueries(FilterCriteria criteria, string query)
+ {
+ foreach (Match match in query_syntax_regex.Matches(query))
+ {
+ var key = match.Groups["key"].Value.ToLower();
+ var op = match.Groups["op"].Value;
+ var value = match.Groups["value"].Value;
+
+ parseKeywordCriteria(criteria, key, value, op);
+
+ query = query.Replace(match.ToString(), "");
+ }
+
+ criteria.SearchText = query;
+ }
+
+ private static void parseKeywordCriteria(FilterCriteria criteria, string key, string value, string op)
+ {
+ switch (key)
+ {
+ case "stars" when parseFloatWithPoint(value, out var stars):
+ updateCriteriaRange(ref criteria.StarDifficulty, op, stars, 0.01f / 2);
+ break;
+
+ case "ar" when parseFloatWithPoint(value, out var ar):
+ updateCriteriaRange(ref criteria.ApproachRate, op, ar, 0.1f / 2);
+ break;
+
+ case "dr" when parseFloatWithPoint(value, out var dr):
+ updateCriteriaRange(ref criteria.DrainRate, op, dr, 0.1f / 2);
+ break;
+
+ case "cs" when parseFloatWithPoint(value, out var cs):
+ updateCriteriaRange(ref criteria.CircleSize, op, cs, 0.1f / 2);
+ break;
+
+ case "bpm" when parseDoubleWithPoint(value, out var bpm):
+ updateCriteriaRange(ref criteria.BPM, op, bpm, 0.01d / 2);
+ break;
+
+ case "length" when parseDoubleWithPoint(value.TrimEnd('m', 's', 'h'), out var length):
+ var scale = getLengthScale(value);
+ updateCriteriaRange(ref criteria.Length, op, length * scale, scale / 2.0);
+ break;
+
+ case "divisor" when parseInt(value, out var divisor):
+ updateCriteriaRange(ref criteria.BeatDivisor, op, divisor);
+ break;
+
+ case "status" when Enum.TryParse(value, true, out var statusValue):
+ updateCriteriaRange(ref criteria.OnlineStatus, op, statusValue);
+ break;
+
+ case "creator":
+ updateCriteriaText(ref criteria.Creator, op, value);
+ break;
+
+ case "artist":
+ updateCriteriaText(ref criteria.Artist, op, value);
+ break;
+ }
+ }
+
+ private static int getLengthScale(string value) =>
+ value.EndsWith("ms") ? 1 :
+ value.EndsWith("s") ? 1000 :
+ value.EndsWith("m") ? 60000 :
+ value.EndsWith("h") ? 3600000 : 1000;
+
+ private static bool parseFloatWithPoint(string value, out float result) =>
+ float.TryParse(value, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out result);
+
+ private static bool parseDoubleWithPoint(string value, out double result) =>
+ double.TryParse(value, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out result);
+
+ private static bool parseInt(string value, out int result) =>
+ int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out result);
+
+ private static void updateCriteriaText(ref FilterCriteria.OptionalTextFilter textFilter, string op, string value)
+ {
+ switch (op)
+ {
+ case "=":
+ case ":":
+ textFilter.SearchTerm = value.Trim('"');
+ break;
+ }
+ }
+
+ private static void updateCriteriaRange(ref FilterCriteria.OptionalRange range, string op, float value, float tolerance = 0.05f)
+ {
+ switch (op)
+ {
+ default:
+ return;
+
+ case "=":
+ case ":":
+ range.Min = value - tolerance;
+ range.Max = value + tolerance;
+ break;
+
+ case ">":
+ range.Min = value + tolerance;
+ break;
+
+ case ">=":
+ case ">:":
+ range.Min = value - tolerance;
+ break;
+
+ case "<":
+ range.Max = value - tolerance;
+ break;
+
+ case "<=":
+ case "<:":
+ range.Max = value + tolerance;
+ break;
+ }
+ }
+
+ private static void updateCriteriaRange(ref FilterCriteria.OptionalRange range, string op, double value, double tolerance = 0.05)
+ {
+ switch (op)
+ {
+ default:
+ return;
+
+ case "=":
+ case ":":
+ range.Min = value - tolerance;
+ range.Max = value + tolerance;
+ break;
+
+ case ">":
+ range.Min = value + tolerance;
+ break;
+
+ case ">=":
+ case ">:":
+ range.Min = value - tolerance;
+ break;
+
+ case "<":
+ range.Max = value - tolerance;
+ break;
+
+ case "<=":
+ case "<:":
+ range.Max = value + tolerance;
+ break;
+ }
+ }
+
+ private static void updateCriteriaRange(ref FilterCriteria.OptionalRange range, string op, T value)
+ where T : struct, IComparable
+ {
+ switch (op)
+ {
+ default:
+ return;
+
+ case "=":
+ case ":":
+ range.IsLowerInclusive = range.IsUpperInclusive = true;
+ range.Min = value;
+ range.Max = value;
+ break;
+
+ case ">":
+ range.IsLowerInclusive = false;
+ range.Min = value;
+ break;
+
+ case ">=":
+ case ">:":
+ range.IsLowerInclusive = true;
+ range.Min = value;
+ break;
+
+ case "<":
+ range.IsUpperInclusive = false;
+ range.Max = value;
+ break;
+
+ case "<=":
+ case "<:":
+ range.IsUpperInclusive = true;
+ range.Max = value;
+ break;
+ }
+ }
+ }
+}
diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj
index a27a94b8f9..a699217503 100644
--- a/osu.Game/osu.Game.csproj
+++ b/osu.Game/osu.Game.csproj
@@ -30,6 +30,6 @@
-
+
diff --git a/osu.iOS.props b/osu.iOS.props
index a6516e6d1b..7803ea1e49 100644
--- a/osu.iOS.props
+++ b/osu.iOS.props
@@ -123,7 +123,7 @@
-
+