mirror of
https://github.com/ppy/osu.git
synced 2025-02-12 15:52:55 +08:00
Merge pull request #31634 from peppy/beatmap-carousel-v2-selection
Add selection support to beatmap carousel v2
This commit is contained in:
commit
2d46da1520
189
osu.Game.Tests/Visual/SongSelect/BeatmapCarouselV2TestScene.cs
Normal file
189
osu.Game.Tests/Visual/SongSelect/BeatmapCarouselV2TestScene.cs
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||||
|
// See the LICENCE file in the repository root for full licence text.
|
||||||
|
|
||||||
|
using System.Linq;
|
||||||
|
using NUnit.Framework;
|
||||||
|
using osu.Framework.Allocation;
|
||||||
|
using osu.Framework.Bindables;
|
||||||
|
using osu.Framework.Extensions.ObjectExtensions;
|
||||||
|
using osu.Framework.Graphics;
|
||||||
|
using osu.Framework.Graphics.Containers;
|
||||||
|
using osu.Framework.Graphics.Shapes;
|
||||||
|
using osu.Framework.Testing;
|
||||||
|
using osu.Framework.Utils;
|
||||||
|
using osu.Game.Beatmaps;
|
||||||
|
using osu.Game.Database;
|
||||||
|
using osu.Game.Graphics;
|
||||||
|
using osu.Game.Graphics.Containers;
|
||||||
|
using osu.Game.Screens.Select;
|
||||||
|
using osu.Game.Screens.Select.Filter;
|
||||||
|
using osu.Game.Screens.SelectV2;
|
||||||
|
using osu.Game.Tests.Beatmaps;
|
||||||
|
using osu.Game.Tests.Resources;
|
||||||
|
using osuTK.Graphics;
|
||||||
|
using BeatmapCarousel = osu.Game.Screens.SelectV2.BeatmapCarousel;
|
||||||
|
|
||||||
|
namespace osu.Game.Tests.Visual.SongSelect
|
||||||
|
{
|
||||||
|
public abstract partial class BeatmapCarouselV2TestScene : OsuManualInputManagerTestScene
|
||||||
|
{
|
||||||
|
protected readonly BindableList<BeatmapSetInfo> BeatmapSets = new BindableList<BeatmapSetInfo>();
|
||||||
|
|
||||||
|
protected BeatmapCarousel Carousel = null!;
|
||||||
|
|
||||||
|
protected OsuScrollContainer<Drawable> Scroll => Carousel.ChildrenOfType<OsuScrollContainer<Drawable>>().Single();
|
||||||
|
|
||||||
|
[Cached(typeof(BeatmapStore))]
|
||||||
|
private BeatmapStore store;
|
||||||
|
|
||||||
|
private OsuTextFlowContainer stats = null!;
|
||||||
|
|
||||||
|
private int beatmapCount;
|
||||||
|
|
||||||
|
protected BeatmapCarouselV2TestScene()
|
||||||
|
{
|
||||||
|
store = new TestBeatmapStore
|
||||||
|
{
|
||||||
|
BeatmapSets = { BindTarget = BeatmapSets }
|
||||||
|
};
|
||||||
|
|
||||||
|
BeatmapSets.BindCollectionChanged((_, _) => beatmapCount = BeatmapSets.Sum(s => s.Beatmaps.Count));
|
||||||
|
|
||||||
|
Scheduler.AddDelayed(updateStats, 100, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
[SetUpSteps]
|
||||||
|
public void SetUpSteps()
|
||||||
|
{
|
||||||
|
RemoveAllBeatmaps();
|
||||||
|
|
||||||
|
CreateCarousel();
|
||||||
|
|
||||||
|
SortBy(new FilterCriteria { Sort = SortMode.Title });
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void CreateCarousel()
|
||||||
|
{
|
||||||
|
AddStep("create components", () =>
|
||||||
|
{
|
||||||
|
Box topBox;
|
||||||
|
Children = new Drawable[]
|
||||||
|
{
|
||||||
|
new GridContainer
|
||||||
|
{
|
||||||
|
RelativeSizeAxes = Axes.Both,
|
||||||
|
ColumnDimensions = new[]
|
||||||
|
{
|
||||||
|
new Dimension(GridSizeMode.Relative, 1),
|
||||||
|
},
|
||||||
|
RowDimensions = new[]
|
||||||
|
{
|
||||||
|
new Dimension(GridSizeMode.Absolute, 200),
|
||||||
|
new Dimension(),
|
||||||
|
new Dimension(GridSizeMode.Absolute, 200),
|
||||||
|
},
|
||||||
|
Content = new[]
|
||||||
|
{
|
||||||
|
new Drawable[]
|
||||||
|
{
|
||||||
|
topBox = new Box
|
||||||
|
{
|
||||||
|
Anchor = Anchor.Centre,
|
||||||
|
Origin = Anchor.Centre,
|
||||||
|
Colour = Color4.Cyan,
|
||||||
|
RelativeSizeAxes = Axes.Both,
|
||||||
|
Alpha = 0.4f,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
new Drawable[]
|
||||||
|
{
|
||||||
|
Carousel = new BeatmapCarousel
|
||||||
|
{
|
||||||
|
Anchor = Anchor.Centre,
|
||||||
|
Origin = Anchor.Centre,
|
||||||
|
Width = 500,
|
||||||
|
RelativeSizeAxes = Axes.Y,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new Box
|
||||||
|
{
|
||||||
|
Anchor = Anchor.Centre,
|
||||||
|
Origin = Anchor.Centre,
|
||||||
|
Colour = Color4.Cyan,
|
||||||
|
RelativeSizeAxes = Axes.Both,
|
||||||
|
Alpha = 0.4f,
|
||||||
|
},
|
||||||
|
topBox.CreateProxy(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stats = new OsuTextFlowContainer
|
||||||
|
{
|
||||||
|
AutoSizeAxes = Axes.Both,
|
||||||
|
Padding = new MarginPadding(10),
|
||||||
|
TextAnchor = Anchor.CentreLeft,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected void SortBy(FilterCriteria criteria) => AddStep($"sort by {criteria.Sort}", () => Carousel.Filter(criteria));
|
||||||
|
|
||||||
|
protected void WaitForDrawablePanels() => AddUntilStep("drawable panels loaded", () => Carousel.ChildrenOfType<BeatmapCarouselPanel>().Count(), () => Is.GreaterThan(0));
|
||||||
|
protected void WaitForSorting() => AddUntilStep("sorting finished", () => Carousel.IsFiltering, () => Is.False);
|
||||||
|
protected void WaitForScrolling() => AddUntilStep("scroll finished", () => Scroll.Current, () => Is.EqualTo(Scroll.Target));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Add requested beatmap sets count to list.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="count">The count of beatmap sets to add.</param>
|
||||||
|
/// <param name="fixedDifficultiesPerSet">If not null, the number of difficulties per set. If null, randomised difficulty count will be used.</param>
|
||||||
|
protected void AddBeatmaps(int count, int? fixedDifficultiesPerSet = null) => AddStep($"add {count} beatmaps", () =>
|
||||||
|
{
|
||||||
|
for (int i = 0; i < count; i++)
|
||||||
|
BeatmapSets.Add(TestResources.CreateTestBeatmapSetInfo(fixedDifficultiesPerSet ?? RNG.Next(1, 4)));
|
||||||
|
});
|
||||||
|
|
||||||
|
protected void RemoveAllBeatmaps() => AddStep("clear all beatmaps", () => BeatmapSets.Clear());
|
||||||
|
|
||||||
|
protected void RemoveFirstBeatmap() =>
|
||||||
|
AddStep("remove first beatmap", () =>
|
||||||
|
{
|
||||||
|
if (BeatmapSets.Count == 0) return;
|
||||||
|
|
||||||
|
BeatmapSets.Remove(BeatmapSets.First());
|
||||||
|
});
|
||||||
|
|
||||||
|
private void updateStats()
|
||||||
|
{
|
||||||
|
if (Carousel.IsNull())
|
||||||
|
return;
|
||||||
|
|
||||||
|
stats.Clear();
|
||||||
|
createHeader("beatmap store");
|
||||||
|
stats.AddParagraph($"""
|
||||||
|
sets: {BeatmapSets.Count}
|
||||||
|
beatmaps: {beatmapCount}
|
||||||
|
""");
|
||||||
|
createHeader("carousel");
|
||||||
|
stats.AddParagraph($"""
|
||||||
|
sorting: {Carousel.IsFiltering}
|
||||||
|
tracked: {Carousel.ItemsTracked}
|
||||||
|
displayable: {Carousel.DisplayableItems}
|
||||||
|
displayed: {Carousel.VisibleItems}
|
||||||
|
selected: {Carousel.CurrentSelection}
|
||||||
|
""");
|
||||||
|
|
||||||
|
void createHeader(string text)
|
||||||
|
{
|
||||||
|
stats.AddParagraph(string.Empty);
|
||||||
|
stats.AddParagraph(text, cp =>
|
||||||
|
{
|
||||||
|
cp.Font = cp.Font.With(size: 18, weight: FontWeight.Bold);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,286 +0,0 @@
|
|||||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
|
||||||
// See the LICENCE file in the repository root for full licence text.
|
|
||||||
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using NUnit.Framework;
|
|
||||||
using osu.Framework.Allocation;
|
|
||||||
using osu.Framework.Bindables;
|
|
||||||
using osu.Framework.Extensions.ObjectExtensions;
|
|
||||||
using osu.Framework.Graphics;
|
|
||||||
using osu.Framework.Graphics.Containers;
|
|
||||||
using osu.Framework.Graphics.Primitives;
|
|
||||||
using osu.Framework.Graphics.Shapes;
|
|
||||||
using osu.Framework.Testing;
|
|
||||||
using osu.Framework.Utils;
|
|
||||||
using osu.Game.Beatmaps;
|
|
||||||
using osu.Game.Database;
|
|
||||||
using osu.Game.Graphics;
|
|
||||||
using osu.Game.Graphics.Containers;
|
|
||||||
using osu.Game.Screens.Select;
|
|
||||||
using osu.Game.Screens.Select.Filter;
|
|
||||||
using osu.Game.Screens.SelectV2;
|
|
||||||
using osu.Game.Tests.Beatmaps;
|
|
||||||
using osu.Game.Tests.Resources;
|
|
||||||
using osuTK.Graphics;
|
|
||||||
using BeatmapCarousel = osu.Game.Screens.SelectV2.BeatmapCarousel;
|
|
||||||
|
|
||||||
namespace osu.Game.Tests.Visual.SongSelect
|
|
||||||
{
|
|
||||||
[TestFixture]
|
|
||||||
public partial class TestSceneBeatmapCarouselV2 : OsuManualInputManagerTestScene
|
|
||||||
{
|
|
||||||
private readonly BindableList<BeatmapSetInfo> beatmapSets = new BindableList<BeatmapSetInfo>();
|
|
||||||
|
|
||||||
[Cached(typeof(BeatmapStore))]
|
|
||||||
private BeatmapStore store;
|
|
||||||
|
|
||||||
private OsuTextFlowContainer stats = null!;
|
|
||||||
private BeatmapCarousel carousel = null!;
|
|
||||||
|
|
||||||
private OsuScrollContainer<Drawable> scroll => carousel.ChildrenOfType<OsuScrollContainer<Drawable>>().Single();
|
|
||||||
|
|
||||||
private int beatmapCount;
|
|
||||||
|
|
||||||
public TestSceneBeatmapCarouselV2()
|
|
||||||
{
|
|
||||||
store = new TestBeatmapStore
|
|
||||||
{
|
|
||||||
BeatmapSets = { BindTarget = beatmapSets }
|
|
||||||
};
|
|
||||||
|
|
||||||
beatmapSets.BindCollectionChanged((_, _) =>
|
|
||||||
{
|
|
||||||
beatmapCount = beatmapSets.Sum(s => s.Beatmaps.Count);
|
|
||||||
});
|
|
||||||
|
|
||||||
Scheduler.AddDelayed(updateStats, 100, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
[SetUpSteps]
|
|
||||||
public void SetUpSteps()
|
|
||||||
{
|
|
||||||
AddStep("create components", () =>
|
|
||||||
{
|
|
||||||
beatmapSets.Clear();
|
|
||||||
|
|
||||||
Box topBox;
|
|
||||||
Children = new Drawable[]
|
|
||||||
{
|
|
||||||
new GridContainer
|
|
||||||
{
|
|
||||||
RelativeSizeAxes = Axes.Both,
|
|
||||||
ColumnDimensions = new[]
|
|
||||||
{
|
|
||||||
new Dimension(GridSizeMode.Relative, 1),
|
|
||||||
},
|
|
||||||
RowDimensions = new[]
|
|
||||||
{
|
|
||||||
new Dimension(GridSizeMode.Absolute, 200),
|
|
||||||
new Dimension(),
|
|
||||||
new Dimension(GridSizeMode.Absolute, 200),
|
|
||||||
},
|
|
||||||
Content = new[]
|
|
||||||
{
|
|
||||||
new Drawable[]
|
|
||||||
{
|
|
||||||
topBox = new Box
|
|
||||||
{
|
|
||||||
Anchor = Anchor.Centre,
|
|
||||||
Origin = Anchor.Centre,
|
|
||||||
Colour = Color4.Cyan,
|
|
||||||
RelativeSizeAxes = Axes.Both,
|
|
||||||
Alpha = 0.4f,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
new Drawable[]
|
|
||||||
{
|
|
||||||
carousel = new BeatmapCarousel
|
|
||||||
{
|
|
||||||
Anchor = Anchor.Centre,
|
|
||||||
Origin = Anchor.Centre,
|
|
||||||
Width = 500,
|
|
||||||
RelativeSizeAxes = Axes.Y,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
new[]
|
|
||||||
{
|
|
||||||
new Box
|
|
||||||
{
|
|
||||||
Anchor = Anchor.Centre,
|
|
||||||
Origin = Anchor.Centre,
|
|
||||||
Colour = Color4.Cyan,
|
|
||||||
RelativeSizeAxes = Axes.Both,
|
|
||||||
Alpha = 0.4f,
|
|
||||||
},
|
|
||||||
topBox.CreateProxy(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
stats = new OsuTextFlowContainer
|
|
||||||
{
|
|
||||||
AutoSizeAxes = Axes.Both,
|
|
||||||
Padding = new MarginPadding(10),
|
|
||||||
TextAnchor = Anchor.CentreLeft,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
AddStep("sort by title", () =>
|
|
||||||
{
|
|
||||||
carousel.Filter(new FilterCriteria { Sort = SortMode.Title });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void TestBasic()
|
|
||||||
{
|
|
||||||
AddStep("add 10 beatmaps", () =>
|
|
||||||
{
|
|
||||||
for (int i = 0; i < 10; i++)
|
|
||||||
beatmapSets.Add(TestResources.CreateTestBeatmapSetInfo(RNG.Next(1, 4)));
|
|
||||||
});
|
|
||||||
|
|
||||||
AddStep("add 1 beatmap", () => beatmapSets.Add(TestResources.CreateTestBeatmapSetInfo(RNG.Next(1, 4))));
|
|
||||||
|
|
||||||
AddStep("remove all beatmaps", () => beatmapSets.Clear());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void TestSorting()
|
|
||||||
{
|
|
||||||
AddStep("add 10 beatmaps", () =>
|
|
||||||
{
|
|
||||||
for (int i = 0; i < 10; i++)
|
|
||||||
beatmapSets.Add(TestResources.CreateTestBeatmapSetInfo(RNG.Next(1, 4)));
|
|
||||||
});
|
|
||||||
|
|
||||||
AddStep("sort by difficulty", () =>
|
|
||||||
{
|
|
||||||
carousel.Filter(new FilterCriteria { Sort = SortMode.Difficulty });
|
|
||||||
});
|
|
||||||
|
|
||||||
AddStep("sort by artist", () =>
|
|
||||||
{
|
|
||||||
carousel.Filter(new FilterCriteria { Sort = SortMode.Artist });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void TestScrollPositionMaintainedOnAddSecondSelected()
|
|
||||||
{
|
|
||||||
Quad positionBefore = default;
|
|
||||||
|
|
||||||
AddStep("add 10 beatmaps", () =>
|
|
||||||
{
|
|
||||||
for (int i = 0; i < 10; i++)
|
|
||||||
beatmapSets.Add(TestResources.CreateTestBeatmapSetInfo(RNG.Next(1, 4)));
|
|
||||||
});
|
|
||||||
|
|
||||||
AddUntilStep("visual item added", () => carousel.ChildrenOfType<BeatmapCarouselPanel>().Count(), () => Is.GreaterThan(0));
|
|
||||||
|
|
||||||
AddStep("select middle beatmap", () => carousel.CurrentSelection = beatmapSets.ElementAt(beatmapSets.Count - 2));
|
|
||||||
AddStep("scroll to selected item", () => scroll.ScrollTo(scroll.ChildrenOfType<BeatmapCarouselPanel>().Single(p => p.Item!.Selected.Value)));
|
|
||||||
|
|
||||||
AddUntilStep("wait for scroll finished", () => scroll.Current, () => Is.EqualTo(scroll.Target));
|
|
||||||
|
|
||||||
AddStep("save selected screen position", () => positionBefore = carousel.ChildrenOfType<BeatmapCarouselPanel>().FirstOrDefault(p => p.Item!.Selected.Value)!.ScreenSpaceDrawQuad);
|
|
||||||
|
|
||||||
AddStep("remove first beatmap", () => beatmapSets.Remove(beatmapSets.Last()));
|
|
||||||
AddUntilStep("sorting finished", () => carousel.IsFiltering, () => Is.False);
|
|
||||||
AddAssert("select screen position unchanged", () => carousel.ChildrenOfType<BeatmapCarouselPanel>().Single(p => p.Item!.Selected.Value).ScreenSpaceDrawQuad,
|
|
||||||
() => Is.EqualTo(positionBefore));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void TestScrollPositionMaintainedOnAddLastSelected()
|
|
||||||
{
|
|
||||||
Quad positionBefore = default;
|
|
||||||
|
|
||||||
AddStep("add 10 beatmaps", () =>
|
|
||||||
{
|
|
||||||
for (int i = 0; i < 10; i++)
|
|
||||||
beatmapSets.Add(TestResources.CreateTestBeatmapSetInfo(RNG.Next(1, 4)));
|
|
||||||
});
|
|
||||||
|
|
||||||
AddUntilStep("visual item added", () => carousel.ChildrenOfType<BeatmapCarouselPanel>().Count(), () => Is.GreaterThan(0));
|
|
||||||
|
|
||||||
AddStep("scroll to last item", () => scroll.ScrollToEnd(false));
|
|
||||||
|
|
||||||
AddStep("select last beatmap", () => carousel.CurrentSelection = beatmapSets.First());
|
|
||||||
|
|
||||||
AddUntilStep("wait for scroll finished", () => scroll.Current, () => Is.EqualTo(scroll.Target));
|
|
||||||
|
|
||||||
AddStep("save selected screen position", () => positionBefore = carousel.ChildrenOfType<BeatmapCarouselPanel>().FirstOrDefault(p => p.Item!.Selected.Value)!.ScreenSpaceDrawQuad);
|
|
||||||
|
|
||||||
AddStep("remove first beatmap", () => beatmapSets.Remove(beatmapSets.Last()));
|
|
||||||
AddUntilStep("sorting finished", () => carousel.IsFiltering, () => Is.False);
|
|
||||||
AddAssert("select screen position unchanged", () => carousel.ChildrenOfType<BeatmapCarouselPanel>().Single(p => p.Item!.Selected.Value).ScreenSpaceDrawQuad,
|
|
||||||
() => Is.EqualTo(positionBefore));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void TestAddRemoveOneByOne()
|
|
||||||
{
|
|
||||||
AddRepeatStep("add beatmaps", () => beatmapSets.Add(TestResources.CreateTestBeatmapSetInfo(RNG.Next(1, 4))), 20);
|
|
||||||
|
|
||||||
AddRepeatStep("remove beatmaps", () => beatmapSets.RemoveAt(RNG.Next(0, beatmapSets.Count)), 20);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
[Explicit]
|
|
||||||
public void TestInsane()
|
|
||||||
{
|
|
||||||
const int count = 200000;
|
|
||||||
|
|
||||||
List<BeatmapSetInfo> generated = new List<BeatmapSetInfo>();
|
|
||||||
|
|
||||||
AddStep($"populate {count} test beatmaps", () =>
|
|
||||||
{
|
|
||||||
generated.Clear();
|
|
||||||
Task.Run(() =>
|
|
||||||
{
|
|
||||||
for (int j = 0; j < count; j++)
|
|
||||||
generated.Add(TestResources.CreateTestBeatmapSetInfo(RNG.Next(1, 4)));
|
|
||||||
}).ConfigureAwait(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
AddUntilStep("wait for beatmaps populated", () => generated.Count, () => Is.GreaterThan(count / 3));
|
|
||||||
AddUntilStep("this takes a while", () => generated.Count, () => Is.GreaterThan(count / 3 * 2));
|
|
||||||
AddUntilStep("maybe they are done now", () => generated.Count, () => Is.EqualTo(count));
|
|
||||||
|
|
||||||
AddStep("add all beatmaps", () => beatmapSets.AddRange(generated));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void updateStats()
|
|
||||||
{
|
|
||||||
if (carousel.IsNull())
|
|
||||||
return;
|
|
||||||
|
|
||||||
stats.Clear();
|
|
||||||
createHeader("beatmap store");
|
|
||||||
stats.AddParagraph($"""
|
|
||||||
sets: {beatmapSets.Count}
|
|
||||||
beatmaps: {beatmapCount}
|
|
||||||
""");
|
|
||||||
createHeader("carousel");
|
|
||||||
stats.AddParagraph($"""
|
|
||||||
sorting: {carousel.IsFiltering}
|
|
||||||
tracked: {carousel.ItemsTracked}
|
|
||||||
displayable: {carousel.DisplayableItems}
|
|
||||||
displayed: {carousel.VisibleItems}
|
|
||||||
selected: {carousel.CurrentSelection}
|
|
||||||
""");
|
|
||||||
|
|
||||||
void createHeader(string text)
|
|
||||||
{
|
|
||||||
stats.AddParagraph(string.Empty);
|
|
||||||
stats.AddParagraph(text, cp =>
|
|
||||||
{
|
|
||||||
cp.Font = cp.Font.With(size: 18, weight: FontWeight.Bold);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -0,0 +1,119 @@
|
|||||||
|
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||||
|
// See the LICENCE file in the repository root for full licence text.
|
||||||
|
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using NUnit.Framework;
|
||||||
|
using osu.Framework.Graphics.Primitives;
|
||||||
|
using osu.Framework.Testing;
|
||||||
|
using osu.Framework.Utils;
|
||||||
|
using osu.Game.Beatmaps;
|
||||||
|
using osu.Game.Screens.Select;
|
||||||
|
using osu.Game.Screens.Select.Filter;
|
||||||
|
using osu.Game.Screens.SelectV2;
|
||||||
|
using osu.Game.Tests.Resources;
|
||||||
|
|
||||||
|
namespace osu.Game.Tests.Visual.SongSelect
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Currently covers adding and removing of items and scrolling.
|
||||||
|
/// If we add more tests here, these two categories can likely be split out into separate scenes.
|
||||||
|
/// </summary>
|
||||||
|
[TestFixture]
|
||||||
|
public partial class TestSceneBeatmapCarouselV2Basics : BeatmapCarouselV2TestScene
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public void TestBasics()
|
||||||
|
{
|
||||||
|
AddBeatmaps(1);
|
||||||
|
AddBeatmaps(10);
|
||||||
|
RemoveFirstBeatmap();
|
||||||
|
RemoveAllBeatmaps();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void TestAddRemoveOneByOne()
|
||||||
|
{
|
||||||
|
AddRepeatStep("add beatmaps", () => BeatmapSets.Add(TestResources.CreateTestBeatmapSetInfo(RNG.Next(1, 4))), 20);
|
||||||
|
AddRepeatStep("remove beatmaps", () => BeatmapSets.RemoveAt(RNG.Next(0, BeatmapSets.Count)), 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void TestSorting()
|
||||||
|
{
|
||||||
|
AddBeatmaps(10);
|
||||||
|
SortBy(new FilterCriteria { Sort = SortMode.Difficulty });
|
||||||
|
SortBy(new FilterCriteria { Sort = SortMode.Artist });
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void TestScrollPositionMaintainedOnAddSecondSelected()
|
||||||
|
{
|
||||||
|
Quad positionBefore = default;
|
||||||
|
|
||||||
|
AddBeatmaps(10);
|
||||||
|
WaitForDrawablePanels();
|
||||||
|
|
||||||
|
AddStep("select middle beatmap", () => Carousel.CurrentSelection = BeatmapSets.ElementAt(BeatmapSets.Count - 2));
|
||||||
|
AddStep("scroll to selected item", () => Scroll.ScrollTo(Scroll.ChildrenOfType<BeatmapCarouselPanel>().Single(p => p.Selected.Value)));
|
||||||
|
|
||||||
|
WaitForScrolling();
|
||||||
|
|
||||||
|
AddStep("save selected screen position", () => positionBefore = Carousel.ChildrenOfType<BeatmapCarouselPanel>().FirstOrDefault(p => p.Selected.Value)!.ScreenSpaceDrawQuad);
|
||||||
|
|
||||||
|
RemoveFirstBeatmap();
|
||||||
|
WaitForSorting();
|
||||||
|
|
||||||
|
AddAssert("select screen position unchanged", () => Carousel.ChildrenOfType<BeatmapCarouselPanel>().Single(p => p.Selected.Value).ScreenSpaceDrawQuad,
|
||||||
|
() => Is.EqualTo(positionBefore));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void TestScrollPositionMaintainedOnAddLastSelected()
|
||||||
|
{
|
||||||
|
Quad positionBefore = default;
|
||||||
|
|
||||||
|
AddBeatmaps(10);
|
||||||
|
WaitForDrawablePanels();
|
||||||
|
|
||||||
|
AddStep("scroll to last item", () => Scroll.ScrollToEnd(false));
|
||||||
|
|
||||||
|
AddStep("select last beatmap", () => Carousel.CurrentSelection = BeatmapSets.Last());
|
||||||
|
|
||||||
|
WaitForScrolling();
|
||||||
|
|
||||||
|
AddStep("save selected screen position", () => positionBefore = Carousel.ChildrenOfType<BeatmapCarouselPanel>().FirstOrDefault(p => p.Selected.Value)!.ScreenSpaceDrawQuad);
|
||||||
|
|
||||||
|
RemoveFirstBeatmap();
|
||||||
|
WaitForSorting();
|
||||||
|
AddAssert("select screen position unchanged", () => Carousel.ChildrenOfType<BeatmapCarouselPanel>().Single(p => p.Selected.Value).ScreenSpaceDrawQuad,
|
||||||
|
() => Is.EqualTo(positionBefore));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
[Explicit]
|
||||||
|
public void TestPerformanceWithManyBeatmaps()
|
||||||
|
{
|
||||||
|
const int count = 200000;
|
||||||
|
|
||||||
|
List<BeatmapSetInfo> generated = new List<BeatmapSetInfo>();
|
||||||
|
|
||||||
|
AddStep($"populate {count} test beatmaps", () =>
|
||||||
|
{
|
||||||
|
generated.Clear();
|
||||||
|
Task.Run(() =>
|
||||||
|
{
|
||||||
|
for (int j = 0; j < count; j++)
|
||||||
|
generated.Add(TestResources.CreateTestBeatmapSetInfo(RNG.Next(1, 4)));
|
||||||
|
}).ConfigureAwait(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
AddUntilStep("wait for beatmaps populated", () => generated.Count, () => Is.GreaterThan(count / 3));
|
||||||
|
AddUntilStep("this takes a while", () => generated.Count, () => Is.GreaterThan(count / 3 * 2));
|
||||||
|
AddUntilStep("maybe they are done now", () => generated.Count, () => Is.EqualTo(count));
|
||||||
|
|
||||||
|
AddStep("add all beatmaps", () => BeatmapSets.AddRange(generated));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,216 @@
|
|||||||
|
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||||
|
// See the LICENCE file in the repository root for full licence text.
|
||||||
|
|
||||||
|
using System.Linq;
|
||||||
|
using NUnit.Framework;
|
||||||
|
using osu.Framework.Testing;
|
||||||
|
using osu.Game.Beatmaps;
|
||||||
|
using osu.Game.Screens.SelectV2;
|
||||||
|
using osuTK.Input;
|
||||||
|
|
||||||
|
namespace osu.Game.Tests.Visual.SongSelect
|
||||||
|
{
|
||||||
|
[TestFixture]
|
||||||
|
public partial class TestSceneBeatmapCarouselV2Selection : BeatmapCarouselV2TestScene
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Keyboard selection via up and down arrows doesn't actually change the selection until
|
||||||
|
/// the select key is pressed.
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public void TestKeyboardSelectionKeyRepeat()
|
||||||
|
{
|
||||||
|
AddBeatmaps(10);
|
||||||
|
WaitForDrawablePanels();
|
||||||
|
checkNoSelection();
|
||||||
|
|
||||||
|
select();
|
||||||
|
checkNoSelection();
|
||||||
|
|
||||||
|
AddStep("press down arrow", () => InputManager.PressKey(Key.Down));
|
||||||
|
checkSelectionIterating(false);
|
||||||
|
|
||||||
|
AddStep("press up arrow", () => InputManager.PressKey(Key.Up));
|
||||||
|
checkSelectionIterating(false);
|
||||||
|
|
||||||
|
AddStep("release down arrow", () => InputManager.ReleaseKey(Key.Down));
|
||||||
|
checkSelectionIterating(false);
|
||||||
|
|
||||||
|
AddStep("release up arrow", () => InputManager.ReleaseKey(Key.Up));
|
||||||
|
checkSelectionIterating(false);
|
||||||
|
|
||||||
|
select();
|
||||||
|
checkHasSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Keyboard selection via left and right arrows moves between groups, updating the selection
|
||||||
|
/// immediately.
|
||||||
|
/// </summary>
|
||||||
|
[Test]
|
||||||
|
public void TestGroupSelectionKeyRepeat()
|
||||||
|
{
|
||||||
|
AddBeatmaps(10);
|
||||||
|
WaitForDrawablePanels();
|
||||||
|
checkNoSelection();
|
||||||
|
|
||||||
|
AddStep("press right arrow", () => InputManager.PressKey(Key.Right));
|
||||||
|
checkSelectionIterating(true);
|
||||||
|
|
||||||
|
AddStep("press left arrow", () => InputManager.PressKey(Key.Left));
|
||||||
|
checkSelectionIterating(true);
|
||||||
|
|
||||||
|
AddStep("release right arrow", () => InputManager.ReleaseKey(Key.Right));
|
||||||
|
checkSelectionIterating(true);
|
||||||
|
|
||||||
|
AddStep("release left arrow", () => InputManager.ReleaseKey(Key.Left));
|
||||||
|
checkSelectionIterating(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void TestCarouselRemembersSelection()
|
||||||
|
{
|
||||||
|
AddBeatmaps(10);
|
||||||
|
WaitForDrawablePanels();
|
||||||
|
|
||||||
|
selectNextGroup();
|
||||||
|
|
||||||
|
object? selection = null;
|
||||||
|
|
||||||
|
AddStep("store drawable selection", () => selection = getSelectedPanel()?.Item?.Model);
|
||||||
|
|
||||||
|
checkHasSelection();
|
||||||
|
AddAssert("drawable selection non-null", () => selection, () => Is.Not.Null);
|
||||||
|
AddAssert("drawable selection matches carousel selection", () => selection, () => Is.EqualTo(Carousel.CurrentSelection));
|
||||||
|
|
||||||
|
RemoveAllBeatmaps();
|
||||||
|
AddUntilStep("no drawable selection", getSelectedPanel, () => Is.Null);
|
||||||
|
|
||||||
|
AddBeatmaps(10);
|
||||||
|
WaitForDrawablePanels();
|
||||||
|
|
||||||
|
checkHasSelection();
|
||||||
|
AddAssert("no drawable selection", getSelectedPanel, () => Is.Null);
|
||||||
|
|
||||||
|
AddStep("add previous selection", () => BeatmapSets.Add(((BeatmapInfo)selection!).BeatmapSet!));
|
||||||
|
|
||||||
|
AddUntilStep("drawable selection restored", () => getSelectedPanel()?.Item?.Model, () => Is.EqualTo(selection));
|
||||||
|
AddAssert("drawable selection matches carousel selection", () => selection, () => Is.EqualTo(Carousel.CurrentSelection));
|
||||||
|
|
||||||
|
BeatmapCarouselPanel? getSelectedPanel() => Carousel.ChildrenOfType<BeatmapCarouselPanel>().SingleOrDefault(p => p.Selected.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void TestTraversalBeyondStart()
|
||||||
|
{
|
||||||
|
const int total_set_count = 200;
|
||||||
|
|
||||||
|
AddBeatmaps(total_set_count);
|
||||||
|
WaitForDrawablePanels();
|
||||||
|
|
||||||
|
selectNextGroup();
|
||||||
|
waitForSelection(0, 0);
|
||||||
|
selectPrevGroup();
|
||||||
|
waitForSelection(total_set_count - 1, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void TestTraversalBeyondEnd()
|
||||||
|
{
|
||||||
|
const int total_set_count = 200;
|
||||||
|
|
||||||
|
AddBeatmaps(total_set_count);
|
||||||
|
WaitForDrawablePanels();
|
||||||
|
|
||||||
|
selectPrevGroup();
|
||||||
|
waitForSelection(total_set_count - 1, 0);
|
||||||
|
selectNextGroup();
|
||||||
|
waitForSelection(0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void TestKeyboardSelection()
|
||||||
|
{
|
||||||
|
AddBeatmaps(10, 3);
|
||||||
|
WaitForDrawablePanels();
|
||||||
|
|
||||||
|
selectNextPanel();
|
||||||
|
selectNextPanel();
|
||||||
|
selectNextPanel();
|
||||||
|
selectNextPanel();
|
||||||
|
checkNoSelection();
|
||||||
|
|
||||||
|
select();
|
||||||
|
waitForSelection(3, 0);
|
||||||
|
|
||||||
|
selectNextPanel();
|
||||||
|
waitForSelection(3, 0);
|
||||||
|
|
||||||
|
select();
|
||||||
|
waitForSelection(3, 1);
|
||||||
|
|
||||||
|
selectNextPanel();
|
||||||
|
waitForSelection(3, 1);
|
||||||
|
|
||||||
|
select();
|
||||||
|
waitForSelection(3, 2);
|
||||||
|
|
||||||
|
selectNextPanel();
|
||||||
|
waitForSelection(3, 2);
|
||||||
|
|
||||||
|
select();
|
||||||
|
waitForSelection(4, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void TestEmptyTraversal()
|
||||||
|
{
|
||||||
|
selectNextPanel();
|
||||||
|
checkNoSelection();
|
||||||
|
|
||||||
|
selectNextGroup();
|
||||||
|
checkNoSelection();
|
||||||
|
|
||||||
|
selectPrevPanel();
|
||||||
|
checkNoSelection();
|
||||||
|
|
||||||
|
selectPrevGroup();
|
||||||
|
checkNoSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void waitForSelection(int set, int? diff = null)
|
||||||
|
{
|
||||||
|
AddUntilStep($"selected is set{set}{(diff.HasValue ? $" diff{diff.Value}" : "")}", () =>
|
||||||
|
{
|
||||||
|
if (diff != null)
|
||||||
|
return ReferenceEquals(Carousel.CurrentSelection, BeatmapSets[set].Beatmaps[diff.Value]);
|
||||||
|
|
||||||
|
return BeatmapSets[set].Beatmaps.Contains(Carousel.CurrentSelection);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void selectNextPanel() => AddStep("select next panel", () => InputManager.Key(Key.Down));
|
||||||
|
private void selectPrevPanel() => AddStep("select prev panel", () => InputManager.Key(Key.Up));
|
||||||
|
private void selectNextGroup() => AddStep("select next group", () => InputManager.Key(Key.Right));
|
||||||
|
private void selectPrevGroup() => AddStep("select prev group", () => InputManager.Key(Key.Left));
|
||||||
|
|
||||||
|
private void select() => AddStep("select", () => InputManager.Key(Key.Enter));
|
||||||
|
|
||||||
|
private void checkNoSelection() => AddAssert("has no selection", () => Carousel.CurrentSelection, () => Is.Null);
|
||||||
|
private void checkHasSelection() => AddAssert("has selection", () => Carousel.CurrentSelection, () => Is.Not.Null);
|
||||||
|
|
||||||
|
private void checkSelectionIterating(bool isIterating)
|
||||||
|
{
|
||||||
|
object? selection = null;
|
||||||
|
|
||||||
|
for (int i = 0; i < 3; i++)
|
||||||
|
{
|
||||||
|
AddStep("store selection", () => selection = Carousel.CurrentSelection);
|
||||||
|
if (isIterating)
|
||||||
|
AddUntilStep("selection changed", () => Carousel.CurrentSelection != selection);
|
||||||
|
else
|
||||||
|
AddUntilStep("selection not changed", () => Carousel.CurrentSelection == selection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -22,10 +22,10 @@ namespace osu.Game.Screens.SelectV2
|
|||||||
{
|
{
|
||||||
private IBindableList<BeatmapSetInfo> detachedBeatmaps = null!;
|
private IBindableList<BeatmapSetInfo> detachedBeatmaps = null!;
|
||||||
|
|
||||||
private readonly DrawablePool<BeatmapCarouselPanel> carouselPanelPool = new DrawablePool<BeatmapCarouselPanel>(100);
|
|
||||||
|
|
||||||
private readonly LoadingLayer loading;
|
private readonly LoadingLayer loading;
|
||||||
|
|
||||||
|
private readonly BeatmapCarouselFilterGrouping grouping;
|
||||||
|
|
||||||
public BeatmapCarousel()
|
public BeatmapCarousel()
|
||||||
{
|
{
|
||||||
DebounceDelay = 100;
|
DebounceDelay = 100;
|
||||||
@ -34,25 +34,27 @@ namespace osu.Game.Screens.SelectV2
|
|||||||
Filters = new ICarouselFilter[]
|
Filters = new ICarouselFilter[]
|
||||||
{
|
{
|
||||||
new BeatmapCarouselFilterSorting(() => Criteria),
|
new BeatmapCarouselFilterSorting(() => Criteria),
|
||||||
new BeatmapCarouselFilterGrouping(() => Criteria),
|
grouping = new BeatmapCarouselFilterGrouping(() => Criteria),
|
||||||
};
|
};
|
||||||
|
|
||||||
AddInternal(carouselPanelPool);
|
|
||||||
|
|
||||||
AddInternal(loading = new LoadingLayer(dimBackground: true));
|
AddInternal(loading = new LoadingLayer(dimBackground: true));
|
||||||
}
|
}
|
||||||
|
|
||||||
[BackgroundDependencyLoader]
|
[BackgroundDependencyLoader]
|
||||||
private void load(BeatmapStore beatmapStore, CancellationToken? cancellationToken)
|
private void load(BeatmapStore beatmapStore, CancellationToken? cancellationToken)
|
||||||
|
{
|
||||||
|
setupPools();
|
||||||
|
setupBeatmaps(beatmapStore, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
#region Beatmap source hookup
|
||||||
|
|
||||||
|
private void setupBeatmaps(BeatmapStore beatmapStore, CancellationToken? cancellationToken)
|
||||||
{
|
{
|
||||||
detachedBeatmaps = beatmapStore.GetBeatmapSets(cancellationToken);
|
detachedBeatmaps = beatmapStore.GetBeatmapSets(cancellationToken);
|
||||||
detachedBeatmaps.BindCollectionChanged(beatmapSetsChanged, true);
|
detachedBeatmaps.BindCollectionChanged(beatmapSetsChanged, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override Drawable GetDrawableForDisplay(CarouselItem item) => carouselPanelPool.Get();
|
|
||||||
|
|
||||||
protected override CarouselItem CreateCarouselItemForModel(BeatmapInfo model) => new BeatmapCarouselItem(model);
|
|
||||||
|
|
||||||
private void beatmapSetsChanged(object? beatmaps, NotifyCollectionChangedEventArgs changed)
|
private void beatmapSetsChanged(object? beatmaps, NotifyCollectionChangedEventArgs changed)
|
||||||
{
|
{
|
||||||
// TODO: moving management of BeatmapInfo tracking to BeatmapStore might be something we want to consider.
|
// TODO: moving management of BeatmapInfo tracking to BeatmapStore might be something we want to consider.
|
||||||
@ -86,6 +88,46 @@ namespace osu.Game.Screens.SelectV2
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Selection handling
|
||||||
|
|
||||||
|
protected override void HandleItemSelected(object? model)
|
||||||
|
{
|
||||||
|
base.HandleItemSelected(model);
|
||||||
|
|
||||||
|
// Selecting a set isn't valid – let's re-select the first difficulty.
|
||||||
|
if (model is BeatmapSetInfo setInfo)
|
||||||
|
{
|
||||||
|
CurrentSelection = setInfo.Beatmaps.First();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (model is BeatmapInfo beatmapInfo)
|
||||||
|
setVisibilityOfSetItems(beatmapInfo.BeatmapSet!, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void HandleItemDeselected(object? model)
|
||||||
|
{
|
||||||
|
base.HandleItemDeselected(model);
|
||||||
|
|
||||||
|
if (model is BeatmapInfo beatmapInfo)
|
||||||
|
setVisibilityOfSetItems(beatmapInfo.BeatmapSet!, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setVisibilityOfSetItems(BeatmapSetInfo set, bool visible)
|
||||||
|
{
|
||||||
|
if (grouping.SetItems.TryGetValue(set, out var group))
|
||||||
|
{
|
||||||
|
foreach (var i in group)
|
||||||
|
i.IsVisible = visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Filtering
|
||||||
|
|
||||||
public FilterCriteria Criteria { get; private set; } = new FilterCriteria();
|
public FilterCriteria Criteria { get; private set; } = new FilterCriteria();
|
||||||
|
|
||||||
public void Filter(FilterCriteria criteria)
|
public void Filter(FilterCriteria criteria)
|
||||||
@ -94,5 +136,20 @@ namespace osu.Game.Screens.SelectV2
|
|||||||
loading.Show();
|
loading.Show();
|
||||||
FilterAsync().ContinueWith(_ => Schedule(() => loading.Hide()));
|
FilterAsync().ContinueWith(_ => Schedule(() => loading.Hide()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Drawable pooling
|
||||||
|
|
||||||
|
private readonly DrawablePool<BeatmapCarouselPanel> carouselPanelPool = new DrawablePool<BeatmapCarouselPanel>(100);
|
||||||
|
|
||||||
|
private void setupPools()
|
||||||
|
{
|
||||||
|
AddInternal(carouselPanelPool);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Drawable GetDrawableForDisplay(CarouselItem item) => carouselPanelPool.Get();
|
||||||
|
|
||||||
|
#endregion
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -13,6 +13,13 @@ namespace osu.Game.Screens.SelectV2
|
|||||||
{
|
{
|
||||||
public class BeatmapCarouselFilterGrouping : ICarouselFilter
|
public class BeatmapCarouselFilterGrouping : ICarouselFilter
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Beatmap sets contain difficulties as related panels. This dictionary holds the relationships between set-difficulties to allow expanding them on selection.
|
||||||
|
/// </summary>
|
||||||
|
public IDictionary<BeatmapSetInfo, HashSet<CarouselItem>> SetItems => setItems;
|
||||||
|
|
||||||
|
private readonly Dictionary<BeatmapSetInfo, HashSet<CarouselItem>> setItems = new Dictionary<BeatmapSetInfo, HashSet<CarouselItem>>();
|
||||||
|
|
||||||
private readonly Func<FilterCriteria> getCriteria;
|
private readonly Func<FilterCriteria> getCriteria;
|
||||||
|
|
||||||
public BeatmapCarouselFilterGrouping(Func<FilterCriteria> getCriteria)
|
public BeatmapCarouselFilterGrouping(Func<FilterCriteria> getCriteria)
|
||||||
@ -27,7 +34,10 @@ namespace osu.Game.Screens.SelectV2
|
|||||||
if (criteria.SplitOutDifficulties)
|
if (criteria.SplitOutDifficulties)
|
||||||
{
|
{
|
||||||
foreach (var item in items)
|
foreach (var item in items)
|
||||||
((BeatmapCarouselItem)item).HasGroupHeader = false;
|
{
|
||||||
|
item.IsVisible = true;
|
||||||
|
item.IsGroupSelectionTarget = true;
|
||||||
|
}
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
@ -44,14 +54,25 @@ namespace osu.Game.Screens.SelectV2
|
|||||||
{
|
{
|
||||||
// Add set header
|
// Add set header
|
||||||
if (lastItem == null || (lastItem.Model is BeatmapInfo b2 && b2.BeatmapSet!.OnlineID != b.BeatmapSet!.OnlineID))
|
if (lastItem == null || (lastItem.Model is BeatmapInfo b2 && b2.BeatmapSet!.OnlineID != b.BeatmapSet!.OnlineID))
|
||||||
newItems.Add(new BeatmapCarouselItem(b.BeatmapSet!) { IsGroupHeader = true });
|
{
|
||||||
|
newItems.Add(new CarouselItem(b.BeatmapSet!)
|
||||||
|
{
|
||||||
|
DrawHeight = 80,
|
||||||
|
IsGroupSelectionTarget = true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!setItems.TryGetValue(b.BeatmapSet!, out var related))
|
||||||
|
setItems[b.BeatmapSet!] = related = new HashSet<CarouselItem>();
|
||||||
|
|
||||||
|
related.Add(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
newItems.Add(item);
|
newItems.Add(item);
|
||||||
lastItem = item;
|
lastItem = item;
|
||||||
|
|
||||||
var beatmapCarouselItem = (BeatmapCarouselItem)item;
|
item.IsGroupSelectionTarget = false;
|
||||||
beatmapCarouselItem.HasGroupHeader = true;
|
item.IsVisible = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return newItems;
|
return newItems;
|
||||||
|
@ -26,39 +26,34 @@ namespace osu.Game.Screens.SelectV2
|
|||||||
{
|
{
|
||||||
var criteria = getCriteria();
|
var criteria = getCriteria();
|
||||||
|
|
||||||
return items.OrderDescending(Comparer<CarouselItem>.Create((a, b) =>
|
return items.Order(Comparer<CarouselItem>.Create((a, b) =>
|
||||||
{
|
{
|
||||||
int comparison = 0;
|
int comparison;
|
||||||
|
|
||||||
if (a.Model is BeatmapInfo ab && b.Model is BeatmapInfo bb)
|
var ab = (BeatmapInfo)a.Model;
|
||||||
|
var bb = (BeatmapInfo)b.Model;
|
||||||
|
|
||||||
|
switch (criteria.Sort)
|
||||||
{
|
{
|
||||||
switch (criteria.Sort)
|
case SortMode.Artist:
|
||||||
{
|
comparison = OrdinalSortByCaseStringComparer.DEFAULT.Compare(ab.BeatmapSet!.Metadata.Artist, bb.BeatmapSet!.Metadata.Artist);
|
||||||
case SortMode.Artist:
|
if (comparison == 0)
|
||||||
comparison = OrdinalSortByCaseStringComparer.DEFAULT.Compare(ab.BeatmapSet!.Metadata.Artist, bb.BeatmapSet!.Metadata.Artist);
|
goto case SortMode.Title;
|
||||||
if (comparison == 0)
|
break;
|
||||||
goto case SortMode.Title;
|
|
||||||
break;
|
|
||||||
|
|
||||||
case SortMode.Difficulty:
|
case SortMode.Difficulty:
|
||||||
comparison = ab.StarRating.CompareTo(bb.StarRating);
|
comparison = ab.StarRating.CompareTo(bb.StarRating);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case SortMode.Title:
|
case SortMode.Title:
|
||||||
comparison = OrdinalSortByCaseStringComparer.DEFAULT.Compare(ab.BeatmapSet!.Metadata.Title, bb.BeatmapSet!.Metadata.Title);
|
comparison = OrdinalSortByCaseStringComparer.DEFAULT.Compare(ab.BeatmapSet!.Metadata.Title, bb.BeatmapSet!.Metadata.Title);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
throw new ArgumentOutOfRangeException();
|
throw new ArgumentOutOfRangeException();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (comparison != 0) return comparison;
|
return comparison;
|
||||||
|
|
||||||
if (a is BeatmapCarouselItem aItem && b is BeatmapCarouselItem bItem)
|
|
||||||
return aItem.ID.CompareTo(bItem.ID);
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}));
|
}));
|
||||||
}, cancellationToken).ConfigureAwait(false);
|
}, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
@ -1,48 +0,0 @@
|
|||||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
|
||||||
// See the LICENCE file in the repository root for full licence text.
|
|
||||||
|
|
||||||
using System;
|
|
||||||
using osu.Game.Beatmaps;
|
|
||||||
using osu.Game.Database;
|
|
||||||
|
|
||||||
namespace osu.Game.Screens.SelectV2
|
|
||||||
{
|
|
||||||
public class BeatmapCarouselItem : CarouselItem
|
|
||||||
{
|
|
||||||
public readonly Guid ID;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether this item has a header providing extra information for it.
|
|
||||||
/// When displaying items which don't have header, we should make sure enough information is included inline.
|
|
||||||
/// </summary>
|
|
||||||
public bool HasGroupHeader { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether this item is a group header.
|
|
||||||
/// Group headers are generally larger in display. Setting this will account for the size difference.
|
|
||||||
/// </summary>
|
|
||||||
public bool IsGroupHeader { get; set; }
|
|
||||||
|
|
||||||
public override float DrawHeight => IsGroupHeader ? 80 : 40;
|
|
||||||
|
|
||||||
public BeatmapCarouselItem(object model)
|
|
||||||
: base(model)
|
|
||||||
{
|
|
||||||
ID = (Model as IHasGuidPrimaryKey)?.ID ?? Guid.NewGuid();
|
|
||||||
}
|
|
||||||
|
|
||||||
public override string? ToString()
|
|
||||||
{
|
|
||||||
switch (Model)
|
|
||||||
{
|
|
||||||
case BeatmapInfo bi:
|
|
||||||
return $"Difficulty: {bi.DifficultyName} ({bi.StarRating:N1}*)";
|
|
||||||
|
|
||||||
case BeatmapSetInfo si:
|
|
||||||
return $"{si.Metadata}";
|
|
||||||
}
|
|
||||||
|
|
||||||
return Model.ToString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -21,27 +21,41 @@ namespace osu.Game.Screens.SelectV2
|
|||||||
[Resolved]
|
[Resolved]
|
||||||
private BeatmapCarousel carousel { get; set; } = null!;
|
private BeatmapCarousel carousel { get; set; } = null!;
|
||||||
|
|
||||||
public CarouselItem? Item
|
private Box activationFlash = null!;
|
||||||
{
|
private Box background = null!;
|
||||||
get => item;
|
private OsuSpriteText text = null!;
|
||||||
set
|
|
||||||
{
|
|
||||||
item = value;
|
|
||||||
|
|
||||||
selected.UnbindBindings();
|
|
||||||
|
|
||||||
if (item != null)
|
|
||||||
selected.BindTo(item.Selected);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private readonly BindableBool selected = new BindableBool();
|
|
||||||
private CarouselItem? item;
|
|
||||||
|
|
||||||
[BackgroundDependencyLoader]
|
[BackgroundDependencyLoader]
|
||||||
private void load()
|
private void load()
|
||||||
{
|
{
|
||||||
selected.BindValueChanged(value =>
|
InternalChildren = new Drawable[]
|
||||||
|
{
|
||||||
|
background = new Box
|
||||||
|
{
|
||||||
|
Alpha = 0.8f,
|
||||||
|
RelativeSizeAxes = Axes.Both,
|
||||||
|
},
|
||||||
|
activationFlash = new Box
|
||||||
|
{
|
||||||
|
Colour = Color4.White,
|
||||||
|
Blending = BlendingParameters.Additive,
|
||||||
|
Alpha = 0,
|
||||||
|
RelativeSizeAxes = Axes.Both,
|
||||||
|
},
|
||||||
|
text = new OsuSpriteText
|
||||||
|
{
|
||||||
|
Padding = new MarginPadding(5),
|
||||||
|
Anchor = Anchor.CentreLeft,
|
||||||
|
Origin = Anchor.CentreLeft,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Selected.BindValueChanged(value =>
|
||||||
|
{
|
||||||
|
activationFlash.FadeTo(value.NewValue ? 0.2f : 0, 500, Easing.OutQuint);
|
||||||
|
});
|
||||||
|
|
||||||
|
KeyboardSelected.BindValueChanged(value =>
|
||||||
{
|
{
|
||||||
if (value.NewValue)
|
if (value.NewValue)
|
||||||
{
|
{
|
||||||
@ -59,6 +73,8 @@ namespace osu.Game.Screens.SelectV2
|
|||||||
{
|
{
|
||||||
base.FreeAfterUse();
|
base.FreeAfterUse();
|
||||||
Item = null;
|
Item = null;
|
||||||
|
Selected.Value = false;
|
||||||
|
KeyboardSelected.Value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void PrepareForUse()
|
protected override void PrepareForUse()
|
||||||
@ -72,31 +88,44 @@ namespace osu.Game.Screens.SelectV2
|
|||||||
Size = new Vector2(500, Item.DrawHeight);
|
Size = new Vector2(500, Item.DrawHeight);
|
||||||
Masking = true;
|
Masking = true;
|
||||||
|
|
||||||
InternalChildren = new Drawable[]
|
background.Colour = (Item.Model is BeatmapInfo ? Color4.Aqua : Color4.Yellow).Darken(5);
|
||||||
{
|
text.Text = getTextFor(Item.Model);
|
||||||
new Box
|
|
||||||
{
|
|
||||||
Colour = (Item.Model is BeatmapInfo ? Color4.Aqua : Color4.Yellow).Darken(5),
|
|
||||||
RelativeSizeAxes = Axes.Both,
|
|
||||||
},
|
|
||||||
new OsuSpriteText
|
|
||||||
{
|
|
||||||
Text = Item.ToString() ?? string.Empty,
|
|
||||||
Padding = new MarginPadding(5),
|
|
||||||
Anchor = Anchor.CentreLeft,
|
|
||||||
Origin = Anchor.CentreLeft,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
this.FadeInFromZero(500, Easing.OutQuint);
|
this.FadeInFromZero(500, Easing.OutQuint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string getTextFor(object item)
|
||||||
|
{
|
||||||
|
switch (item)
|
||||||
|
{
|
||||||
|
case BeatmapInfo bi:
|
||||||
|
return $"Difficulty: {bi.DifficultyName} ({bi.StarRating:N1}*)";
|
||||||
|
|
||||||
|
case BeatmapSetInfo si:
|
||||||
|
return $"{si.Metadata}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
protected override bool OnClick(ClickEvent e)
|
protected override bool OnClick(ClickEvent e)
|
||||||
{
|
{
|
||||||
carousel.CurrentSelection = Item!.Model;
|
if (carousel.CurrentSelection == Item!.Model)
|
||||||
|
carousel.TryActivateSelection();
|
||||||
|
else
|
||||||
|
carousel.CurrentSelection = Item!.Model;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public CarouselItem? Item { get; set; }
|
||||||
|
public BindableBool Selected { get; } = new BindableBool();
|
||||||
|
public BindableBool KeyboardSelected { get; } = new BindableBool();
|
||||||
|
|
||||||
public double DrawYPosition { get; set; }
|
public double DrawYPosition { get; set; }
|
||||||
|
|
||||||
|
public void Activated()
|
||||||
|
{
|
||||||
|
activationFlash.FadeOutFromOne(500, Easing.OutQuint);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -28,7 +28,8 @@ namespace osu.Game.Screens.SelectV2
|
|||||||
/// A highly efficient vertical list display that is used primarily for the song select screen,
|
/// A highly efficient vertical list display that is used primarily for the song select screen,
|
||||||
/// but flexible enough to be used for other use cases.
|
/// but flexible enough to be used for other use cases.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public abstract partial class Carousel<T> : CompositeDrawable
|
public abstract partial class Carousel<T> : CompositeDrawable, IKeyBindingHandler<GlobalAction>
|
||||||
|
where T : notnull
|
||||||
{
|
{
|
||||||
#region Properties and methods for external usage
|
#region Properties and methods for external usage
|
||||||
|
|
||||||
@ -80,25 +81,37 @@ namespace osu.Game.Screens.SelectV2
|
|||||||
public int VisibleItems => scroll.Panels.Count;
|
public int VisibleItems => scroll.Panels.Count;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The currently selected model.
|
/// The currently selected model. Generally of type T.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Setting this will ensure <see cref="CarouselItem.Selected"/> is set to <c>true</c> only on the matching <see cref="CarouselItem"/>.
|
/// A carousel may create panels for non-T types.
|
||||||
/// Of note, if no matching item exists all items will be deselected while waiting for potential new item which matches.
|
/// To keep things simple, we therefore avoid generic constraints on the current selection.
|
||||||
|
///
|
||||||
|
/// The selection is never reset due to not existing. It can be set to anything.
|
||||||
|
/// If no matching carousel item exists, there will be no visually selected item while waiting for potential new item which matches.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public virtual object? CurrentSelection
|
public object? CurrentSelection
|
||||||
{
|
{
|
||||||
get => currentSelection;
|
get => currentSelection.Model;
|
||||||
set
|
set => setSelection(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Activate the current selection, if a selection exists and matches keyboard selection.
|
||||||
|
/// If keyboard selection does not match selection, this will transfer the selection on first invocation.
|
||||||
|
/// </summary>
|
||||||
|
public void TryActivateSelection()
|
||||||
|
{
|
||||||
|
if (currentSelection.CarouselItem != currentKeyboardSelection.CarouselItem)
|
||||||
{
|
{
|
||||||
if (currentSelectionCarouselItem != null)
|
CurrentSelection = currentKeyboardSelection.Model;
|
||||||
currentSelectionCarouselItem.Selected.Value = false;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
currentSelection = value;
|
if (currentSelection.CarouselItem != null)
|
||||||
|
{
|
||||||
currentSelectionCarouselItem = null;
|
(GetMaterialisedDrawableForItem(currentSelection.CarouselItem) as ICarouselPanel)?.Activated();
|
||||||
currentSelectionYPosition = null;
|
HandleItemActivated(currentSelection.CarouselItem);
|
||||||
updateSelection();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -144,11 +157,42 @@ namespace osu.Game.Screens.SelectV2
|
|||||||
protected abstract Drawable GetDrawableForDisplay(CarouselItem item);
|
protected abstract Drawable GetDrawableForDisplay(CarouselItem item);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Create an internal carousel representation for the provided model object.
|
/// Given a <see cref="CarouselItem"/>, find a drawable representation if it is currently displayed in the carousel.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="model">The model.</param>
|
/// <remarks>
|
||||||
/// <returns>A <see cref="CarouselItem"/> representing the model.</returns>
|
/// This will only return a drawable if it is "on-screen".
|
||||||
protected abstract CarouselItem CreateCarouselItemForModel(T model);
|
/// </remarks>
|
||||||
|
/// <param name="item">The item to find a related drawable representation.</param>
|
||||||
|
/// <returns>The drawable representation if it exists.</returns>
|
||||||
|
protected Drawable? GetMaterialisedDrawableForItem(CarouselItem item) =>
|
||||||
|
scroll.Panels.SingleOrDefault(p => ((ICarouselPanel)p).Item == item);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called when an item is "selected".
|
||||||
|
/// </summary>
|
||||||
|
protected virtual void HandleItemSelected(object? model)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called when an item is "deselected".
|
||||||
|
/// </summary>
|
||||||
|
protected virtual void HandleItemDeselected(object? model)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called when an item is "activated".
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// An activated item should for instance:
|
||||||
|
/// - Open or close a folder
|
||||||
|
/// - Start gameplay on a beatmap difficulty.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="item">The carousel item which was activated.</param>
|
||||||
|
protected virtual void HandleItemActivated(CarouselItem item)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
@ -197,7 +241,7 @@ namespace osu.Game.Screens.SelectV2
|
|||||||
|
|
||||||
// Copy must be performed on update thread for now (see ConfigureAwait above).
|
// Copy must be performed on update thread for now (see ConfigureAwait above).
|
||||||
// Could potentially be optimised in the future if it becomes an issue.
|
// Could potentially be optimised in the future if it becomes an issue.
|
||||||
IEnumerable<CarouselItem> items = new List<CarouselItem>(Items.Select(CreateCarouselItemForModel));
|
IEnumerable<CarouselItem> items = new List<CarouselItem>(Items.Select(m => new CarouselItem(m)));
|
||||||
|
|
||||||
await Task.Run(async () =>
|
await Task.Run(async () =>
|
||||||
{
|
{
|
||||||
@ -210,7 +254,7 @@ namespace osu.Game.Screens.SelectV2
|
|||||||
}
|
}
|
||||||
|
|
||||||
log("Updating Y positions");
|
log("Updating Y positions");
|
||||||
await updateYPositions(items, cts.Token).ConfigureAwait(false);
|
updateYPositions(items, visibleHalfHeight, SpacingBetweenPanels);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
@ -225,58 +269,231 @@ namespace osu.Game.Screens.SelectV2
|
|||||||
carouselItems = items.ToList();
|
carouselItems = items.ToList();
|
||||||
displayedRange = null;
|
displayedRange = null;
|
||||||
|
|
||||||
updateSelection();
|
// Need to call this to ensure correct post-selection logic is handled on the new items list.
|
||||||
|
HandleItemSelected(currentSelection.Model);
|
||||||
|
|
||||||
|
refreshAfterSelection();
|
||||||
|
|
||||||
void log(string text) => Logger.Log($"Carousel[op {cts.GetHashCode().ToString()}] {stopwatch.ElapsedMilliseconds} ms: {text}");
|
void log(string text) => Logger.Log($"Carousel[op {cts.GetHashCode().ToString()}] {stopwatch.ElapsedMilliseconds} ms: {text}");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task updateYPositions(IEnumerable<CarouselItem> carouselItems, CancellationToken cancellationToken) => await Task.Run(() =>
|
private static void updateYPositions(IEnumerable<CarouselItem> carouselItems, float offset, float spacing)
|
||||||
{
|
{
|
||||||
float yPos = visibleHalfHeight;
|
|
||||||
|
|
||||||
foreach (var item in carouselItems)
|
foreach (var item in carouselItems)
|
||||||
|
updateItemYPosition(item, ref offset, spacing);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void updateItemYPosition(CarouselItem item, ref float offset, float spacing)
|
||||||
|
{
|
||||||
|
item.CarouselYPosition = offset;
|
||||||
|
if (item.IsVisible)
|
||||||
|
offset += item.DrawHeight + spacing;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Input handling
|
||||||
|
|
||||||
|
public bool OnPressed(KeyBindingPressEvent<GlobalAction> e)
|
||||||
|
{
|
||||||
|
switch (e.Action)
|
||||||
{
|
{
|
||||||
item.CarouselYPosition = yPos;
|
case GlobalAction.Select:
|
||||||
yPos += item.DrawHeight + SpacingBetweenPanels;
|
TryActivateSelection();
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case GlobalAction.SelectNext:
|
||||||
|
selectNext(1, isGroupSelection: false);
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case GlobalAction.SelectNextGroup:
|
||||||
|
selectNext(1, isGroupSelection: true);
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case GlobalAction.SelectPrevious:
|
||||||
|
selectNext(-1, isGroupSelection: false);
|
||||||
|
return true;
|
||||||
|
|
||||||
|
case GlobalAction.SelectPreviousGroup:
|
||||||
|
selectNext(-1, isGroupSelection: true);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}, cancellationToken).ConfigureAwait(false);
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnReleased(KeyBindingReleaseEvent<GlobalAction> e)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Select the next valid selection relative to a current selection.
|
||||||
|
/// This is generally for keyboard based traversal.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="direction">Positive for downwards, negative for upwards.</param>
|
||||||
|
/// <param name="isGroupSelection">Whether the selection should traverse groups. Group selection updates the actual selection immediately, while non-group selection will only prepare a future keyboard selection.</param>
|
||||||
|
/// <returns>Whether selection was possible.</returns>
|
||||||
|
private bool selectNext(int direction, bool isGroupSelection)
|
||||||
|
{
|
||||||
|
// Ensure sanity
|
||||||
|
Debug.Assert(direction != 0);
|
||||||
|
direction = direction > 0 ? 1 : -1;
|
||||||
|
|
||||||
|
if (carouselItems == null || carouselItems.Count == 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// If the user has a different keyboard selection and requests
|
||||||
|
// group selection, first transfer the keyboard selection to actual selection.
|
||||||
|
if (isGroupSelection && currentSelection.CarouselItem != currentKeyboardSelection.CarouselItem)
|
||||||
|
{
|
||||||
|
TryActivateSelection();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
CarouselItem? selectionItem = currentKeyboardSelection.CarouselItem;
|
||||||
|
int selectionIndex = currentKeyboardSelection.Index ?? -1;
|
||||||
|
|
||||||
|
// To keep things simple, let's first handle the cases where there's no selection yet.
|
||||||
|
if (selectionItem == null || selectionIndex < 0)
|
||||||
|
{
|
||||||
|
// Start by selecting the first item.
|
||||||
|
selectionItem = carouselItems.First();
|
||||||
|
selectionIndex = 0;
|
||||||
|
|
||||||
|
// In the forwards case, immediately attempt selection of this panel.
|
||||||
|
// If selection fails, continue with standard logic to find the next valid selection.
|
||||||
|
if (direction > 0 && attemptSelection(selectionItem))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// In the backwards direction we can just allow the selection logic to go ahead and loop around to the last valid.
|
||||||
|
}
|
||||||
|
|
||||||
|
Debug.Assert(selectionItem != null);
|
||||||
|
|
||||||
|
// As a second special case, if we're group selecting backwards and the current selection isn't a group,
|
||||||
|
// make sure to go back to the group header this item belongs to, so that the block below doesn't find it and stop too early.
|
||||||
|
if (isGroupSelection && direction < 0)
|
||||||
|
{
|
||||||
|
while (!carouselItems[selectionIndex].IsGroupSelectionTarget)
|
||||||
|
selectionIndex--;
|
||||||
|
}
|
||||||
|
|
||||||
|
CarouselItem? newItem;
|
||||||
|
|
||||||
|
// Iterate over every item back to the current selection, finding the first valid item.
|
||||||
|
// The fail condition is when we reach the selection after a cyclic loop over every item.
|
||||||
|
do
|
||||||
|
{
|
||||||
|
selectionIndex += direction;
|
||||||
|
newItem = carouselItems[(selectionIndex + carouselItems.Count) % carouselItems.Count];
|
||||||
|
|
||||||
|
if (attemptSelection(newItem))
|
||||||
|
return true;
|
||||||
|
} while (newItem != selectionItem);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
|
||||||
|
bool attemptSelection(CarouselItem item)
|
||||||
|
{
|
||||||
|
if (!item.IsVisible || (isGroupSelection && !item.IsGroupSelectionTarget))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (isGroupSelection)
|
||||||
|
setSelection(item.Model);
|
||||||
|
else
|
||||||
|
setKeyboardSelection(item.Model);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Selection handling
|
#region Selection handling
|
||||||
|
|
||||||
private object? currentSelection;
|
private Selection currentKeyboardSelection = new Selection();
|
||||||
private CarouselItem? currentSelectionCarouselItem;
|
private Selection currentSelection = new Selection();
|
||||||
private double? currentSelectionYPosition;
|
|
||||||
|
|
||||||
private void updateSelection()
|
private void setSelection(object? model)
|
||||||
{
|
{
|
||||||
currentSelectionCarouselItem = null;
|
if (currentSelection.Model == model)
|
||||||
|
return;
|
||||||
|
|
||||||
if (carouselItems == null) return;
|
var previousSelection = currentSelection;
|
||||||
|
|
||||||
foreach (var item in carouselItems)
|
if (previousSelection.Model != null)
|
||||||
|
HandleItemDeselected(previousSelection.Model);
|
||||||
|
|
||||||
|
currentSelection = currentKeyboardSelection = new Selection(model);
|
||||||
|
HandleItemSelected(currentSelection.Model);
|
||||||
|
|
||||||
|
// `HandleItemSelected` can alter `CurrentSelection`, which will recursively call `setSelection()` again.
|
||||||
|
// if that happens, the rest of this method should be a no-op.
|
||||||
|
if (currentSelection.Model != model)
|
||||||
|
return;
|
||||||
|
|
||||||
|
refreshAfterSelection();
|
||||||
|
scrollToSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setKeyboardSelection(object? model)
|
||||||
|
{
|
||||||
|
currentKeyboardSelection = new Selection(model);
|
||||||
|
|
||||||
|
refreshAfterSelection();
|
||||||
|
scrollToSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Call after a selection of items change to re-attach <see cref="CarouselItem"/>s to current <see cref="Selection"/>s.
|
||||||
|
/// </summary>
|
||||||
|
private void refreshAfterSelection()
|
||||||
|
{
|
||||||
|
float yPos = visibleHalfHeight;
|
||||||
|
|
||||||
|
// Invalidate display range as panel positions and visible status may have changed.
|
||||||
|
// Position transfer won't happen unless we invalidate this.
|
||||||
|
displayedRange = null;
|
||||||
|
|
||||||
|
// The case where no items are available for display yet.
|
||||||
|
if (carouselItems == null)
|
||||||
{
|
{
|
||||||
bool isSelected = item.Model == currentSelection;
|
currentKeyboardSelection = new Selection();
|
||||||
|
currentSelection = new Selection();
|
||||||
if (isSelected)
|
return;
|
||||||
{
|
|
||||||
currentSelectionCarouselItem = item;
|
|
||||||
|
|
||||||
if (currentSelectionYPosition != item.CarouselYPosition)
|
|
||||||
{
|
|
||||||
if (currentSelectionYPosition != null)
|
|
||||||
{
|
|
||||||
float adjustment = (float)(item.CarouselYPosition - currentSelectionYPosition.Value);
|
|
||||||
scroll.OffsetScrollPosition(adjustment);
|
|
||||||
}
|
|
||||||
|
|
||||||
currentSelectionYPosition = item.CarouselYPosition;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
item.Selected.Value = isSelected;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
float spacing = SpacingBetweenPanels;
|
||||||
|
int count = carouselItems.Count;
|
||||||
|
|
||||||
|
Selection prevKeyboard = currentKeyboardSelection;
|
||||||
|
|
||||||
|
// We are performing two important operations here:
|
||||||
|
// - Update all Y positions. After a selection occurs, panels may have changed visibility state and therefore Y positions.
|
||||||
|
// - Link selected models to CarouselItems. If a selection changed, this is where we find the relevant CarouselItems for further use.
|
||||||
|
for (int i = 0; i < count; i++)
|
||||||
|
{
|
||||||
|
var item = carouselItems[i];
|
||||||
|
|
||||||
|
updateItemYPosition(item, ref yPos, spacing);
|
||||||
|
|
||||||
|
if (ReferenceEquals(item.Model, currentKeyboardSelection.Model))
|
||||||
|
currentKeyboardSelection = new Selection(item.Model, item, item.CarouselYPosition, i);
|
||||||
|
|
||||||
|
if (ReferenceEquals(item.Model, currentSelection.Model))
|
||||||
|
currentSelection = new Selection(item.Model, item, item.CarouselYPosition, i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a keyboard selection is currently made, we want to keep the view stable around the selection.
|
||||||
|
// That means that we should offset the immediate scroll position by any change in Y position for the selection.
|
||||||
|
if (prevKeyboard.YPosition != null && currentKeyboardSelection.YPosition != prevKeyboard.YPosition)
|
||||||
|
scroll.OffsetScrollPosition((float)(currentKeyboardSelection.YPosition!.Value - prevKeyboard.YPosition.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void scrollToSelection()
|
||||||
|
{
|
||||||
|
if (currentKeyboardSelection.CarouselItem != null)
|
||||||
|
scroll.ScrollTo(currentKeyboardSelection.CarouselItem.CarouselYPosition - visibleHalfHeight);
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
@ -285,7 +502,7 @@ namespace osu.Game.Screens.SelectV2
|
|||||||
|
|
||||||
private DisplayRange? displayedRange;
|
private DisplayRange? displayedRange;
|
||||||
|
|
||||||
private readonly CarouselItem carouselBoundsItem = new BoundsCarouselItem();
|
private readonly CarouselItem carouselBoundsItem = new CarouselItem(new object());
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The position of the lower visible bound with respect to the current scroll position.
|
/// The position of the lower visible bound with respect to the current scroll position.
|
||||||
@ -335,6 +552,9 @@ namespace osu.Game.Screens.SelectV2
|
|||||||
float dist = Math.Abs(1f - posInScroll.Y / visibleHalfHeight);
|
float dist = Math.Abs(1f - posInScroll.Y / visibleHalfHeight);
|
||||||
|
|
||||||
panel.X = offsetX(dist, visibleHalfHeight);
|
panel.X = offsetX(dist, visibleHalfHeight);
|
||||||
|
|
||||||
|
c.Selected.Value = c.Item == currentSelection?.CarouselItem;
|
||||||
|
c.KeyboardSelected.Value = c.Item == currentKeyboardSelection?.CarouselItem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -381,6 +601,8 @@ namespace osu.Game.Screens.SelectV2
|
|||||||
? new List<CarouselItem>()
|
? new List<CarouselItem>()
|
||||||
: carouselItems.GetRange(range.First, range.Last - range.First + 1);
|
: carouselItems.GetRange(range.First, range.Last - range.First + 1);
|
||||||
|
|
||||||
|
toDisplay.RemoveAll(i => !i.IsVisible);
|
||||||
|
|
||||||
// Iterate over all panels which are already displayed and figure which need to be displayed / removed.
|
// Iterate over all panels which are already displayed and figure which need to be displayed / removed.
|
||||||
foreach (var panel in scroll.Panels)
|
foreach (var panel in scroll.Panels)
|
||||||
{
|
{
|
||||||
@ -434,6 +656,15 @@ namespace osu.Game.Screens.SelectV2
|
|||||||
|
|
||||||
#region Internal helper classes
|
#region Internal helper classes
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bookkeeping for a current selection.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Model">The selected model. If <c>null</c>, there's no selection.</param>
|
||||||
|
/// <param name="CarouselItem">A related carousel item representation for the model. May be null if selection is not present as an item, or if <see cref="Carousel{T}.refreshAfterSelection"/> has not been run yet.</param>
|
||||||
|
/// <param name="YPosition">The Y position of the selection as of the last run of <see cref="Carousel{T}.refreshAfterSelection"/>. May be null if selection is not present as an item, or if <see cref="Carousel{T}.refreshAfterSelection"/> has not been run yet.</param>
|
||||||
|
/// <param name="Index">The index of the selection as of the last run of <see cref="Carousel{T}.refreshAfterSelection"/>. May be null if selection is not present as an item, or if <see cref="Carousel{T}.refreshAfterSelection"/> has not been run yet.</param>
|
||||||
|
private record Selection(object? Model = null, CarouselItem? CarouselItem = null, double? YPosition = null, int? Index = null);
|
||||||
|
|
||||||
private record DisplayRange(int First, int Last);
|
private record DisplayRange(int First, int Last);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@ -573,16 +804,6 @@ namespace osu.Game.Screens.SelectV2
|
|||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
|
|
||||||
private class BoundsCarouselItem : CarouselItem
|
|
||||||
{
|
|
||||||
public override float DrawHeight => 0;
|
|
||||||
|
|
||||||
public BoundsCarouselItem()
|
|
||||||
: base(new object())
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,7 +2,6 @@
|
|||||||
// See the LICENCE file in the repository root for full licence text.
|
// See the LICENCE file in the repository root for full licence text.
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
using osu.Framework.Bindables;
|
|
||||||
|
|
||||||
namespace osu.Game.Screens.SelectV2
|
namespace osu.Game.Screens.SelectV2
|
||||||
{
|
{
|
||||||
@ -10,9 +9,9 @@ namespace osu.Game.Screens.SelectV2
|
|||||||
/// Represents a single display item for display in a <see cref="Carousel{T}"/>.
|
/// Represents a single display item for display in a <see cref="Carousel{T}"/>.
|
||||||
/// This is used to house information related to the attached model that helps with display and tracking.
|
/// This is used to house information related to the attached model that helps with display and tracking.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public abstract class CarouselItem : IComparable<CarouselItem>
|
public sealed class CarouselItem : IComparable<CarouselItem>
|
||||||
{
|
{
|
||||||
public readonly BindableBool Selected = new BindableBool();
|
public const float DEFAULT_HEIGHT = 40;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The model this item is representing.
|
/// The model this item is representing.
|
||||||
@ -20,16 +19,27 @@ namespace osu.Game.Screens.SelectV2
|
|||||||
public readonly object Model;
|
public readonly object Model;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The current Y position in the carousel. This is managed by <see cref="Carousel{T}"/> and should not be set manually.
|
/// The current Y position in the carousel.
|
||||||
|
/// This is managed by <see cref="Carousel{T}"/> and should not be set manually.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public double CarouselYPosition { get; set; }
|
public double CarouselYPosition { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The height this item will take when displayed.
|
/// The height this item will take when displayed. Defaults to <see cref="DEFAULT_HEIGHT"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public abstract float DrawHeight { get; }
|
public float DrawHeight { get; set; } = DEFAULT_HEIGHT;
|
||||||
|
|
||||||
protected CarouselItem(object model)
|
/// <summary>
|
||||||
|
/// Whether this item should be a valid target for user group selection hotkeys.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsGroupSelectionTarget { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether this item is visible or collapsed (hidden).
|
||||||
|
/// </summary>
|
||||||
|
public bool IsVisible { get; set; } = true;
|
||||||
|
|
||||||
|
public CarouselItem(object model)
|
||||||
{
|
{
|
||||||
Model = model;
|
Model = model;
|
||||||
}
|
}
|
||||||
|
@ -1,22 +1,40 @@
|
|||||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
||||||
// See the LICENCE file in the repository root for full licence text.
|
// See the LICENCE file in the repository root for full licence text.
|
||||||
|
|
||||||
|
using osu.Framework.Bindables;
|
||||||
using osu.Framework.Graphics;
|
using osu.Framework.Graphics;
|
||||||
|
using osu.Framework.Graphics.Pooling;
|
||||||
|
|
||||||
namespace osu.Game.Screens.SelectV2
|
namespace osu.Game.Screens.SelectV2
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// An interface to be attached to any <see cref="Drawable"/>s which are used for display inside a <see cref="Carousel{T}"/>.
|
/// An interface to be attached to any <see cref="Drawable"/>s which are used for display inside a <see cref="Carousel{T}"/>.
|
||||||
|
/// Importantly, all properties in this interface are managed by <see cref="Carousel{T}"/> and should not be written to elsewhere.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public interface ICarouselPanel
|
public interface ICarouselPanel
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The Y position which should be used for displaying this item within the carousel. This is managed by <see cref="Carousel{T}"/> and should not be set manually.
|
/// Whether this item has selection. Should be read from to update the visual state.
|
||||||
|
/// </summary>
|
||||||
|
BindableBool Selected { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether this item has keyboard selection. Should be read from to update the visual state.
|
||||||
|
/// </summary>
|
||||||
|
BindableBool KeyboardSelected { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called when the panel is activated. Should be used to update the panel's visual state.
|
||||||
|
/// </summary>
|
||||||
|
void Activated();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Y position used internally for positioning in the carousel.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
double DrawYPosition { get; set; }
|
double DrawYPosition { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The carousel item this drawable is representing. This is managed by <see cref="Carousel{T}"/> and should not be set manually.
|
/// The carousel item this drawable is representing. Will be set before <see cref="PoolableDrawable.PrepareForUse"/> is called.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
CarouselItem? Item { get; set; }
|
CarouselItem? Item { get; set; }
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user