1
0
mirror of https://github.com/ppy/osu.git synced 2025-02-07 19:43:20 +08:00

Merge pull request #31634 from peppy/beatmap-carousel-v2-selection

Add selection support to beatmap carousel v2
This commit is contained in:
Bartłomiej Dach 2025-01-24 11:43:17 +01:00 committed by GitHub
commit 2d46da1520
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1019 additions and 478 deletions

View 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);
});
}
}
}
}

View File

@ -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);
});
}
}
}
}

View File

@ -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));
}
}
}

View File

@ -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);
}
}
}
}

View File

@ -22,10 +22,10 @@ namespace osu.Game.Screens.SelectV2
{
private IBindableList<BeatmapSetInfo> detachedBeatmaps = null!;
private readonly DrawablePool<BeatmapCarouselPanel> carouselPanelPool = new DrawablePool<BeatmapCarouselPanel>(100);
private readonly LoadingLayer loading;
private readonly BeatmapCarouselFilterGrouping grouping;
public BeatmapCarousel()
{
DebounceDelay = 100;
@ -34,25 +34,27 @@ namespace osu.Game.Screens.SelectV2
Filters = new ICarouselFilter[]
{
new BeatmapCarouselFilterSorting(() => Criteria),
new BeatmapCarouselFilterGrouping(() => Criteria),
grouping = new BeatmapCarouselFilterGrouping(() => Criteria),
};
AddInternal(carouselPanelPool);
AddInternal(loading = new LoadingLayer(dimBackground: true));
}
[BackgroundDependencyLoader]
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.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)
{
// 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 void Filter(FilterCriteria criteria)
@ -94,5 +136,20 @@ namespace osu.Game.Screens.SelectV2
loading.Show();
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
}
}

View File

@ -13,6 +13,13 @@ namespace osu.Game.Screens.SelectV2
{
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;
public BeatmapCarouselFilterGrouping(Func<FilterCriteria> getCriteria)
@ -27,7 +34,10 @@ namespace osu.Game.Screens.SelectV2
if (criteria.SplitOutDifficulties)
{
foreach (var item in items)
((BeatmapCarouselItem)item).HasGroupHeader = false;
{
item.IsVisible = true;
item.IsGroupSelectionTarget = true;
}
return items;
}
@ -44,14 +54,25 @@ namespace osu.Game.Screens.SelectV2
{
// Add set header
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);
lastItem = item;
var beatmapCarouselItem = (BeatmapCarouselItem)item;
beatmapCarouselItem.HasGroupHeader = true;
item.IsGroupSelectionTarget = false;
item.IsVisible = false;
}
return newItems;

View File

@ -26,39 +26,34 @@ namespace osu.Game.Screens.SelectV2
{
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);
if (comparison == 0)
goto case SortMode.Title;
break;
case SortMode.Artist:
comparison = OrdinalSortByCaseStringComparer.DEFAULT.Compare(ab.BeatmapSet!.Metadata.Artist, bb.BeatmapSet!.Metadata.Artist);
if (comparison == 0)
goto case SortMode.Title;
break;
case SortMode.Difficulty:
comparison = ab.StarRating.CompareTo(bb.StarRating);
break;
case SortMode.Difficulty:
comparison = ab.StarRating.CompareTo(bb.StarRating);
break;
case SortMode.Title:
comparison = OrdinalSortByCaseStringComparer.DEFAULT.Compare(ab.BeatmapSet!.Metadata.Title, bb.BeatmapSet!.Metadata.Title);
break;
case SortMode.Title:
comparison = OrdinalSortByCaseStringComparer.DEFAULT.Compare(ab.BeatmapSet!.Metadata.Title, bb.BeatmapSet!.Metadata.Title);
break;
default:
throw new ArgumentOutOfRangeException();
}
default:
throw new ArgumentOutOfRangeException();
}
if (comparison != 0) return comparison;
if (a is BeatmapCarouselItem aItem && b is BeatmapCarouselItem bItem)
return aItem.ID.CompareTo(bItem.ID);
return 0;
return comparison;
}));
}, cancellationToken).ConfigureAwait(false);
}

View File

@ -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();
}
}
}

View File

@ -21,27 +21,41 @@ namespace osu.Game.Screens.SelectV2
[Resolved]
private BeatmapCarousel carousel { get; set; } = null!;
public CarouselItem? Item
{
get => item;
set
{
item = value;
selected.UnbindBindings();
if (item != null)
selected.BindTo(item.Selected);
}
}
private readonly BindableBool selected = new BindableBool();
private CarouselItem? item;
private Box activationFlash = null!;
private Box background = null!;
private OsuSpriteText text = null!;
[BackgroundDependencyLoader]
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)
{
@ -59,6 +73,8 @@ namespace osu.Game.Screens.SelectV2
{
base.FreeAfterUse();
Item = null;
Selected.Value = false;
KeyboardSelected.Value = false;
}
protected override void PrepareForUse()
@ -72,31 +88,44 @@ namespace osu.Game.Screens.SelectV2
Size = new Vector2(500, Item.DrawHeight);
Masking = true;
InternalChildren = new Drawable[]
{
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,
}
};
background.Colour = (Item.Model is BeatmapInfo ? Color4.Aqua : Color4.Yellow).Darken(5);
text.Text = getTextFor(Item.Model);
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)
{
carousel.CurrentSelection = Item!.Model;
if (carousel.CurrentSelection == Item!.Model)
carousel.TryActivateSelection();
else
carousel.CurrentSelection = Item!.Model;
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 void Activated()
{
activationFlash.FadeOutFromOne(500, Easing.OutQuint);
}
}
}

View File

@ -28,7 +28,8 @@ namespace osu.Game.Screens.SelectV2
/// 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.
/// </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
@ -80,25 +81,37 @@ namespace osu.Game.Screens.SelectV2
public int VisibleItems => scroll.Panels.Count;
/// <summary>
/// The currently selected model.
/// The currently selected model. Generally of type T.
/// </summary>
/// <remarks>
/// Setting this will ensure <see cref="CarouselItem.Selected"/> is set to <c>true</c> only on the matching <see cref="CarouselItem"/>.
/// Of note, if no matching item exists all items will be deselected while waiting for potential new item which matches.
/// A carousel may create panels for non-T types.
/// 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>
public virtual object? CurrentSelection
public object? CurrentSelection
{
get => currentSelection;
set
get => currentSelection.Model;
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)
currentSelectionCarouselItem.Selected.Value = false;
CurrentSelection = currentKeyboardSelection.Model;
return;
}
currentSelection = value;
currentSelectionCarouselItem = null;
currentSelectionYPosition = null;
updateSelection();
if (currentSelection.CarouselItem != null)
{
(GetMaterialisedDrawableForItem(currentSelection.CarouselItem) as ICarouselPanel)?.Activated();
HandleItemActivated(currentSelection.CarouselItem);
}
}
@ -144,11 +157,42 @@ namespace osu.Game.Screens.SelectV2
protected abstract Drawable GetDrawableForDisplay(CarouselItem item);
/// <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>
/// <param name="model">The model.</param>
/// <returns>A <see cref="CarouselItem"/> representing the model.</returns>
protected abstract CarouselItem CreateCarouselItemForModel(T model);
/// <remarks>
/// This will only return a drawable if it is "on-screen".
/// </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
@ -197,7 +241,7 @@ namespace osu.Game.Screens.SelectV2
// Copy must be performed on update thread for now (see ConfigureAwait above).
// 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 () =>
{
@ -210,7 +254,7 @@ namespace osu.Game.Screens.SelectV2
}
log("Updating Y positions");
await updateYPositions(items, cts.Token).ConfigureAwait(false);
updateYPositions(items, visibleHalfHeight, SpacingBetweenPanels);
}
catch (OperationCanceledException)
{
@ -225,58 +269,231 @@ namespace osu.Game.Screens.SelectV2
carouselItems = items.ToList();
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}");
}
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)
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;
yPos += item.DrawHeight + SpacingBetweenPanels;
case GlobalAction.Select:
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
#region Selection handling
private object? currentSelection;
private CarouselItem? currentSelectionCarouselItem;
private double? currentSelectionYPosition;
private Selection currentKeyboardSelection = new Selection();
private Selection currentSelection = new Selection();
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;
if (isSelected)
{
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;
currentKeyboardSelection = new Selection();
currentSelection = new Selection();
return;
}
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
@ -285,7 +502,7 @@ namespace osu.Game.Screens.SelectV2
private DisplayRange? displayedRange;
private readonly CarouselItem carouselBoundsItem = new BoundsCarouselItem();
private readonly CarouselItem carouselBoundsItem = new CarouselItem(new object());
/// <summary>
/// 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);
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>()
: 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.
foreach (var panel in scroll.Panels)
{
@ -434,6 +656,15 @@ namespace osu.Game.Screens.SelectV2
#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);
/// <summary>
@ -573,16 +804,6 @@ namespace osu.Game.Screens.SelectV2
#endregion
}
private class BoundsCarouselItem : CarouselItem
{
public override float DrawHeight => 0;
public BoundsCarouselItem()
: base(new object())
{
}
}
#endregion
}
}

View File

@ -2,7 +2,6 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Bindables;
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}"/>.
/// This is used to house information related to the attached model that helps with display and tracking.
/// </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>
/// The model this item is representing.
@ -20,16 +19,27 @@ namespace osu.Game.Screens.SelectV2
public readonly object Model;
/// <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>
public double CarouselYPosition { get; set; }
/// <summary>
/// The height this item will take when displayed.
/// The height this item will take when displayed. Defaults to <see cref="DEFAULT_HEIGHT"/>.
/// </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;
}

View File

@ -1,22 +1,40 @@
// 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 osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Pooling;
namespace osu.Game.Screens.SelectV2
{
/// <summary>
/// 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>
public interface ICarouselPanel
{
/// <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>
double DrawYPosition { get; set; }
/// <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>
CarouselItem? Item { get; set; }
}