1
0
mirror of https://github.com/ppy/osu.git synced 2025-02-08 11:42:55 +08:00

Merge pull request #31764 from peppy/beatmap-carousel-v2-grouping

Add group support to beatmap carousel v2
This commit is contained in:
Bartłomiej Dach 2025-02-04 08:13:54 +01:00 committed by GitHub
commit 6e59cab928
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 650 additions and 214 deletions

View File

@ -21,6 +21,7 @@ using osu.Game.Screens.SelectV2;
using osu.Game.Tests.Beatmaps; using osu.Game.Tests.Beatmaps;
using osu.Game.Tests.Resources; using osu.Game.Tests.Resources;
using osuTK.Graphics; using osuTK.Graphics;
using osuTK.Input;
using BeatmapCarousel = osu.Game.Screens.SelectV2.BeatmapCarousel; using BeatmapCarousel = osu.Game.Screens.SelectV2.BeatmapCarousel;
namespace osu.Game.Tests.Visual.SongSelect namespace osu.Game.Tests.Visual.SongSelect
@ -53,7 +54,7 @@ namespace osu.Game.Tests.Visual.SongSelect
} }
[SetUpSteps] [SetUpSteps]
public void SetUpSteps() public virtual void SetUpSteps()
{ {
RemoveAllBeatmaps(); RemoveAllBeatmaps();
@ -129,12 +130,59 @@ namespace osu.Game.Tests.Visual.SongSelect
}); });
} }
protected void SortBy(FilterCriteria criteria) => AddStep($"sort by {criteria.Sort}", () => Carousel.Filter(criteria)); protected void SortBy(FilterCriteria criteria) => AddStep($"sort {criteria.Sort} group {criteria.Group}", () => Carousel.Filter(criteria));
protected void WaitForDrawablePanels() => AddUntilStep("drawable panels loaded", () => Carousel.ChildrenOfType<ICarouselPanel>().Count(), () => Is.GreaterThan(0)); protected void WaitForDrawablePanels() => AddUntilStep("drawable panels loaded", () => Carousel.ChildrenOfType<ICarouselPanel>().Count(), () => Is.GreaterThan(0));
protected void WaitForSorting() => AddUntilStep("sorting finished", () => Carousel.IsFiltering, () => Is.False); protected void WaitForSorting() => AddUntilStep("sorting finished", () => Carousel.IsFiltering, () => Is.False);
protected void WaitForScrolling() => AddUntilStep("scroll finished", () => Scroll.Current, () => Is.EqualTo(Scroll.Target)); protected void WaitForScrolling() => AddUntilStep("scroll finished", () => Scroll.Current, () => Is.EqualTo(Scroll.Target));
protected void SelectNextPanel() => AddStep("select next panel", () => InputManager.Key(Key.Down));
protected void SelectPrevPanel() => AddStep("select prev panel", () => InputManager.Key(Key.Up));
protected void SelectNextGroup() => AddStep("select next group", () => InputManager.Key(Key.Right));
protected void SelectPrevGroup() => AddStep("select prev group", () => InputManager.Key(Key.Left));
protected void Select() => AddStep("select", () => InputManager.Key(Key.Enter));
protected void CheckNoSelection() => AddAssert("has no selection", () => Carousel.CurrentSelection, () => Is.Null);
protected void CheckHasSelection() => AddAssert("has selection", () => Carousel.CurrentSelection, () => Is.Not.Null);
protected void WaitForGroupSelection(int group, int panel)
{
AddUntilStep($"selected is group{group} panel{panel}", () =>
{
var groupingFilter = Carousel.Filters.OfType<BeatmapCarouselFilterGrouping>().Single();
GroupDefinition g = groupingFilter.GroupItems.Keys.ElementAt(group);
CarouselItem item = groupingFilter.GroupItems[g].ElementAt(panel);
return ReferenceEquals(Carousel.CurrentSelection, item.Model);
});
}
protected 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);
});
}
protected void ClickVisiblePanel<T>(int index)
where T : Drawable
{
AddStep($"click panel at index {index}", () =>
{
Carousel.ChildrenOfType<T>()
.Where(p => ((ICarouselPanel)p).Item?.IsVisible == true)
.Reverse()
.ElementAt(index)
.TriggerClick();
});
}
/// <summary> /// <summary>
/// Add requested beatmap sets count to list. /// Add requested beatmap sets count to list.
/// </summary> /// </summary>

View File

@ -32,6 +32,13 @@ namespace osu.Game.Tests.Visual.SongSelect
RemoveAllBeatmaps(); RemoveAllBeatmaps();
} }
[Test]
public void TestOffScreenLoading()
{
AddStep("disable masking", () => Scroll.Masking = false);
AddStep("enable masking", () => Scroll.Masking = true);
}
[Test] [Test]
public void TestAddRemoveOneByOne() public void TestAddRemoveOneByOne()
{ {
@ -43,7 +50,7 @@ namespace osu.Game.Tests.Visual.SongSelect
public void TestSorting() public void TestSorting()
{ {
AddBeatmaps(10); AddBeatmaps(10);
SortBy(new FilterCriteria { Sort = SortMode.Difficulty }); SortBy(new FilterCriteria { Group = GroupMode.Difficulty, Sort = SortMode.Difficulty });
SortBy(new FilterCriteria { Sort = SortMode.Artist }); SortBy(new FilterCriteria { Sort = SortMode.Artist });
} }

View File

@ -0,0 +1,135 @@
// 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.Select;
using osu.Game.Screens.Select.Filter;
using osu.Game.Screens.SelectV2;
namespace osu.Game.Tests.Visual.SongSelect
{
[TestFixture]
public partial class TestSceneBeatmapCarouselV2GroupSelection : BeatmapCarouselV2TestScene
{
public override void SetUpSteps()
{
RemoveAllBeatmaps();
CreateCarousel();
SortBy(new FilterCriteria { Group = GroupMode.Difficulty, Sort = SortMode.Difficulty });
}
[Test]
public void TestOpenCloseGroupWithNoSelection()
{
AddBeatmaps(10, 5);
WaitForDrawablePanels();
AddAssert("no beatmaps visible", () => Carousel.ChildrenOfType<BeatmapPanel>().Count(p => p.Alpha > 0), () => Is.Zero);
CheckNoSelection();
ClickVisiblePanel<GroupPanel>(0);
AddUntilStep("some beatmaps visible", () => Carousel.ChildrenOfType<BeatmapPanel>().Count(p => p.Alpha > 0), () => Is.GreaterThan(0));
CheckNoSelection();
ClickVisiblePanel<GroupPanel>(0);
AddUntilStep("no beatmaps visible", () => Carousel.ChildrenOfType<BeatmapPanel>().Count(p => p.Alpha > 0), () => Is.Zero);
CheckNoSelection();
}
[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!));
AddAssert("selection matches original carousel selection", () => selection, () => Is.EqualTo(Carousel.CurrentSelection));
AddUntilStep("drawable selection restored", () => getSelectedPanel()?.Item?.Model, () => Is.EqualTo(selection));
AddAssert("carousel item is visible", () => getSelectedPanel()?.Item?.IsVisible, () => Is.True);
ClickVisiblePanel<GroupPanel>(0);
AddUntilStep("carousel item not visible", getSelectedPanel, () => Is.Null);
ClickVisiblePanel<GroupPanel>(0);
AddUntilStep("carousel item is visible", () => getSelectedPanel()?.Item?.IsVisible, () => Is.True);
BeatmapPanel? getSelectedPanel() => Carousel.ChildrenOfType<BeatmapPanel>().SingleOrDefault(p => p.Selected.Value);
}
[Test]
public void TestGroupSelectionOnHeader()
{
AddBeatmaps(10, 3);
WaitForDrawablePanels();
SelectNextGroup();
WaitForGroupSelection(0, 0);
SelectPrevPanel();
SelectPrevGroup();
WaitForGroupSelection(2, 9);
}
[Test]
public void TestKeyboardSelection()
{
AddBeatmaps(10, 3);
WaitForDrawablePanels();
SelectNextPanel();
SelectNextPanel();
SelectNextPanel();
SelectNextPanel();
CheckNoSelection();
// open first group
Select();
CheckNoSelection();
AddUntilStep("some beatmaps visible", () => Carousel.ChildrenOfType<BeatmapPanel>().Count(p => p.Alpha > 0), () => Is.GreaterThan(0));
SelectNextPanel();
Select();
WaitForGroupSelection(0, 0);
SelectNextGroup();
WaitForGroupSelection(0, 1);
SelectNextGroup();
WaitForGroupSelection(0, 2);
SelectPrevGroup();
WaitForGroupSelection(0, 1);
SelectPrevGroup();
WaitForGroupSelection(0, 0);
SelectPrevGroup();
WaitForGroupSelection(2, 9);
}
}
}

View File

@ -22,10 +22,10 @@ namespace osu.Game.Tests.Visual.SongSelect
{ {
AddBeatmaps(10); AddBeatmaps(10);
WaitForDrawablePanels(); WaitForDrawablePanels();
checkNoSelection(); CheckNoSelection();
select(); Select();
checkNoSelection(); CheckNoSelection();
AddStep("press down arrow", () => InputManager.PressKey(Key.Down)); AddStep("press down arrow", () => InputManager.PressKey(Key.Down));
checkSelectionIterating(false); checkSelectionIterating(false);
@ -39,8 +39,8 @@ namespace osu.Game.Tests.Visual.SongSelect
AddStep("release up arrow", () => InputManager.ReleaseKey(Key.Up)); AddStep("release up arrow", () => InputManager.ReleaseKey(Key.Up));
checkSelectionIterating(false); checkSelectionIterating(false);
select(); Select();
checkHasSelection(); CheckHasSelection();
} }
/// <summary> /// <summary>
@ -52,7 +52,7 @@ namespace osu.Game.Tests.Visual.SongSelect
{ {
AddBeatmaps(10); AddBeatmaps(10);
WaitForDrawablePanels(); WaitForDrawablePanels();
checkNoSelection(); CheckNoSelection();
AddStep("press right arrow", () => InputManager.PressKey(Key.Right)); AddStep("press right arrow", () => InputManager.PressKey(Key.Right));
checkSelectionIterating(true); checkSelectionIterating(true);
@ -73,13 +73,13 @@ namespace osu.Game.Tests.Visual.SongSelect
AddBeatmaps(10); AddBeatmaps(10);
WaitForDrawablePanels(); WaitForDrawablePanels();
selectNextGroup(); SelectNextGroup();
object? selection = null; object? selection = null;
AddStep("store drawable selection", () => selection = getSelectedPanel()?.Item?.Model); AddStep("store drawable selection", () => selection = getSelectedPanel()?.Item?.Model);
checkHasSelection(); CheckHasSelection();
AddAssert("drawable selection non-null", () => selection, () => Is.Not.Null); AddAssert("drawable selection non-null", () => selection, () => Is.Not.Null);
AddAssert("drawable selection matches carousel selection", () => selection, () => Is.EqualTo(Carousel.CurrentSelection)); AddAssert("drawable selection matches carousel selection", () => selection, () => Is.EqualTo(Carousel.CurrentSelection));
@ -89,13 +89,14 @@ namespace osu.Game.Tests.Visual.SongSelect
AddBeatmaps(10); AddBeatmaps(10);
WaitForDrawablePanels(); WaitForDrawablePanels();
checkHasSelection(); CheckHasSelection();
AddAssert("no drawable selection", getSelectedPanel, () => Is.Null); AddAssert("no drawable selection", getSelectedPanel, () => Is.Null);
AddStep("add previous selection", () => BeatmapSets.Add(((BeatmapInfo)selection!).BeatmapSet!)); AddStep("add previous selection", () => BeatmapSets.Add(((BeatmapInfo)selection!).BeatmapSet!));
AddAssert("selection matches original carousel selection", () => selection, () => Is.EqualTo(Carousel.CurrentSelection));
AddUntilStep("drawable selection restored", () => getSelectedPanel()?.Item?.Model, () => Is.EqualTo(selection)); AddUntilStep("drawable selection restored", () => getSelectedPanel()?.Item?.Model, () => Is.EqualTo(selection));
AddAssert("drawable selection matches carousel selection", () => selection, () => Is.EqualTo(Carousel.CurrentSelection)); AddAssert("carousel item is visible", () => getSelectedPanel()?.Item?.IsVisible, () => Is.True);
BeatmapPanel? getSelectedPanel() => Carousel.ChildrenOfType<BeatmapPanel>().SingleOrDefault(p => p.Selected.Value); BeatmapPanel? getSelectedPanel() => Carousel.ChildrenOfType<BeatmapPanel>().SingleOrDefault(p => p.Selected.Value);
} }
@ -108,10 +109,10 @@ namespace osu.Game.Tests.Visual.SongSelect
AddBeatmaps(total_set_count); AddBeatmaps(total_set_count);
WaitForDrawablePanels(); WaitForDrawablePanels();
selectNextGroup(); SelectNextGroup();
waitForSelection(0, 0); WaitForSelection(0, 0);
selectPrevGroup(); SelectPrevGroup();
waitForSelection(total_set_count - 1, 0); WaitForSelection(total_set_count - 1, 0);
} }
[Test] [Test]
@ -122,10 +123,25 @@ namespace osu.Game.Tests.Visual.SongSelect
AddBeatmaps(total_set_count); AddBeatmaps(total_set_count);
WaitForDrawablePanels(); WaitForDrawablePanels();
selectPrevGroup(); SelectPrevGroup();
waitForSelection(total_set_count - 1, 0); WaitForSelection(total_set_count - 1, 0);
selectNextGroup(); SelectNextGroup();
waitForSelection(0, 0); WaitForSelection(0, 0);
}
[Test]
public void TestGroupSelectionOnHeader()
{
AddBeatmaps(10, 3);
WaitForDrawablePanels();
SelectNextGroup();
SelectNextGroup();
WaitForSelection(1, 0);
SelectPrevPanel();
SelectPrevGroup();
WaitForSelection(0, 0);
} }
[Test] [Test]
@ -134,71 +150,50 @@ namespace osu.Game.Tests.Visual.SongSelect
AddBeatmaps(10, 3); AddBeatmaps(10, 3);
WaitForDrawablePanels(); WaitForDrawablePanels();
selectNextPanel(); SelectNextPanel();
selectNextPanel(); SelectNextPanel();
selectNextPanel(); SelectNextPanel();
selectNextPanel(); SelectNextPanel();
checkNoSelection(); CheckNoSelection();
select(); Select();
waitForSelection(3, 0); WaitForSelection(3, 0);
selectNextPanel(); SelectNextPanel();
waitForSelection(3, 0); WaitForSelection(3, 0);
select(); Select();
waitForSelection(3, 1); WaitForSelection(3, 1);
selectNextPanel(); SelectNextPanel();
waitForSelection(3, 1); WaitForSelection(3, 1);
select(); Select();
waitForSelection(3, 2); WaitForSelection(3, 2);
selectNextPanel(); SelectNextPanel();
waitForSelection(3, 2); WaitForSelection(3, 2);
select(); Select();
waitForSelection(4, 0); WaitForSelection(4, 0);
} }
[Test] [Test]
public void TestEmptyTraversal() public void TestEmptyTraversal()
{ {
selectNextPanel(); SelectNextPanel();
checkNoSelection(); CheckNoSelection();
selectNextGroup(); SelectNextGroup();
checkNoSelection(); CheckNoSelection();
selectPrevPanel(); SelectPrevPanel();
checkNoSelection(); CheckNoSelection();
selectPrevGroup(); SelectPrevGroup();
checkNoSelection(); 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) private void checkSelectionIterating(bool isIterating)
{ {
object? selection = null; object? selection = null;

View File

@ -92,34 +92,102 @@ namespace osu.Game.Screens.SelectV2
#region Selection handling #region Selection handling
protected override void HandleItemSelected(object? model) private GroupDefinition? lastSelectedGroup;
private BeatmapInfo? lastSelectedBeatmap;
protected override bool HandleItemSelected(object? model)
{ {
base.HandleItemSelected(model); base.HandleItemSelected(model);
// Selecting a set isn't valid let's re-select the first difficulty. switch (model)
if (model is BeatmapSetInfo setInfo)
{ {
CurrentSelection = setInfo.Beatmaps.First(); case GroupDefinition group:
return; // Special case collapsing an open group.
if (lastSelectedGroup == group)
{
setVisibilityOfGroupItems(lastSelectedGroup, false);
lastSelectedGroup = null;
return false;
}
setVisibleGroup(group);
return false;
case BeatmapSetInfo setInfo:
// Selecting a set isn't valid let's re-select the first difficulty.
CurrentSelection = setInfo.Beatmaps.First();
return false;
case BeatmapInfo beatmapInfo:
// If we have groups, we need to account for them.
if (Criteria.SplitOutDifficulties)
{
// Find the containing group. There should never be too many groups so iterating is efficient enough.
GroupDefinition? group = grouping.GroupItems.SingleOrDefault(kvp => kvp.Value.Any(i => ReferenceEquals(i.Model, beatmapInfo))).Key;
if (group != null)
setVisibleGroup(group);
}
else
{
setVisibleSet(beatmapInfo);
}
return true;
} }
if (model is BeatmapInfo beatmapInfo) return true;
setVisibilityOfSetItems(beatmapInfo.BeatmapSet!, true);
} }
protected override void HandleItemDeselected(object? model) protected override bool CheckValidForGroupSelection(CarouselItem item)
{ {
base.HandleItemDeselected(model); switch (item.Model)
{
case BeatmapSetInfo:
return true;
if (model is BeatmapInfo beatmapInfo) case BeatmapInfo:
setVisibilityOfSetItems(beatmapInfo.BeatmapSet!, false); return Criteria.SplitOutDifficulties;
case GroupDefinition:
return false;
default:
throw new ArgumentException($"Unsupported model type {item.Model}");
}
}
private void setVisibleGroup(GroupDefinition group)
{
if (lastSelectedGroup != null)
setVisibilityOfGroupItems(lastSelectedGroup, false);
lastSelectedGroup = group;
setVisibilityOfGroupItems(group, true);
}
private void setVisibilityOfGroupItems(GroupDefinition group, bool visible)
{
if (grouping.GroupItems.TryGetValue(group, out var items))
{
foreach (var i in items)
i.IsVisible = visible;
}
}
private void setVisibleSet(BeatmapInfo beatmapInfo)
{
if (lastSelectedBeatmap != null)
setVisibilityOfSetItems(lastSelectedBeatmap.BeatmapSet!, false);
lastSelectedBeatmap = beatmapInfo;
setVisibilityOfSetItems(beatmapInfo.BeatmapSet!, true);
} }
private void setVisibilityOfSetItems(BeatmapSetInfo set, bool visible) private void setVisibilityOfSetItems(BeatmapSetInfo set, bool visible)
{ {
if (grouping.SetItems.TryGetValue(set, out var group)) if (grouping.SetItems.TryGetValue(set, out var items))
{ {
foreach (var i in group) foreach (var i in items)
i.IsVisible = visible; i.IsVisible = visible;
} }
} }
@ -143,9 +211,11 @@ namespace osu.Game.Screens.SelectV2
private readonly DrawablePool<BeatmapPanel> beatmapPanelPool = new DrawablePool<BeatmapPanel>(100); private readonly DrawablePool<BeatmapPanel> beatmapPanelPool = new DrawablePool<BeatmapPanel>(100);
private readonly DrawablePool<BeatmapSetPanel> setPanelPool = new DrawablePool<BeatmapSetPanel>(100); private readonly DrawablePool<BeatmapSetPanel> setPanelPool = new DrawablePool<BeatmapSetPanel>(100);
private readonly DrawablePool<GroupPanel> groupPanelPool = new DrawablePool<GroupPanel>(100);
private void setupPools() private void setupPools()
{ {
AddInternal(groupPanelPool);
AddInternal(beatmapPanelPool); AddInternal(beatmapPanelPool);
AddInternal(setPanelPool); AddInternal(setPanelPool);
} }
@ -154,7 +224,12 @@ namespace osu.Game.Screens.SelectV2
{ {
switch (item.Model) switch (item.Model)
{ {
case GroupDefinition:
return groupPanelPool.Get();
case BeatmapInfo: case BeatmapInfo:
// TODO: if beatmap is a group selection target, it needs to be a different drawable
// with more information attached.
return beatmapPanelPool.Get(); return beatmapPanelPool.Get();
case BeatmapSetInfo: case BeatmapSetInfo:
@ -166,4 +241,6 @@ namespace osu.Game.Screens.SelectV2
#endregion #endregion
} }
public record GroupDefinition(string Title);
} }

View File

@ -8,6 +8,7 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Screens.Select; using osu.Game.Screens.Select;
using osu.Game.Screens.Select.Filter;
namespace osu.Game.Screens.SelectV2 namespace osu.Game.Screens.SelectV2
{ {
@ -18,7 +19,13 @@ namespace osu.Game.Screens.SelectV2
/// </summary> /// </summary>
public IDictionary<BeatmapSetInfo, HashSet<CarouselItem>> SetItems => setItems; public IDictionary<BeatmapSetInfo, HashSet<CarouselItem>> SetItems => setItems;
/// <summary>
/// Groups contain children which are group-selectable. This dictionary holds the relationships between groups-panels to allow expanding them on selection.
/// </summary>
public IDictionary<GroupDefinition, HashSet<CarouselItem>> GroupItems => groupItems;
private readonly Dictionary<BeatmapSetInfo, HashSet<CarouselItem>> setItems = new Dictionary<BeatmapSetInfo, HashSet<CarouselItem>>(); private readonly Dictionary<BeatmapSetInfo, HashSet<CarouselItem>> setItems = new Dictionary<BeatmapSetInfo, HashSet<CarouselItem>>();
private readonly Dictionary<GroupDefinition, HashSet<CarouselItem>> groupItems = new Dictionary<GroupDefinition, HashSet<CarouselItem>>();
private readonly Func<FilterCriteria> getCriteria; private readonly Func<FilterCriteria> getCriteria;
@ -29,50 +36,97 @@ namespace osu.Game.Screens.SelectV2
public async Task<IEnumerable<CarouselItem>> Run(IEnumerable<CarouselItem> items, CancellationToken cancellationToken) => await Task.Run(() => public async Task<IEnumerable<CarouselItem>> Run(IEnumerable<CarouselItem> items, CancellationToken cancellationToken) => await Task.Run(() =>
{ {
bool groupSetsTogether;
setItems.Clear();
groupItems.Clear();
var criteria = getCriteria(); var criteria = getCriteria();
if (criteria.SplitOutDifficulties)
{
foreach (var item in items)
{
item.IsVisible = true;
item.IsGroupSelectionTarget = true;
}
return items;
}
CarouselItem? lastItem = null;
var newItems = new List<CarouselItem>(items.Count()); var newItems = new List<CarouselItem>(items.Count());
foreach (var item in items) // Add criteria groups.
switch (criteria.Group)
{
default:
groupSetsTogether = true;
newItems.AddRange(items);
break;
case GroupMode.Difficulty:
groupSetsTogether = false;
int starGroup = int.MinValue;
foreach (var item in items)
{
cancellationToken.ThrowIfCancellationRequested();
var b = (BeatmapInfo)item.Model;
if (b.StarRating > starGroup)
{
starGroup = (int)Math.Floor(b.StarRating);
newItems.Add(new CarouselItem(new GroupDefinition($"{starGroup} - {++starGroup} *")) { DrawHeight = GroupPanel.HEIGHT });
}
newItems.Add(item);
}
break;
}
// Add set headers wherever required.
CarouselItem? lastItem = null;
if (groupSetsTogether)
{
for (int i = 0; i < newItems.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var item = newItems[i];
if (item.Model is BeatmapInfo beatmap)
{
bool newBeatmapSet = lastItem == null || (lastItem.Model is BeatmapInfo lastBeatmap && lastBeatmap.BeatmapSet!.ID != beatmap.BeatmapSet!.ID);
if (newBeatmapSet)
{
newItems.Insert(i, new CarouselItem(beatmap.BeatmapSet!) { DrawHeight = BeatmapSetPanel.HEIGHT });
i++;
}
if (!setItems.TryGetValue(beatmap.BeatmapSet!, out var related))
setItems[beatmap.BeatmapSet!] = related = new HashSet<CarouselItem>();
related.Add(item);
item.IsVisible = false;
}
lastItem = item;
}
}
// Link group items to their headers.
GroupDefinition? lastGroup = null;
foreach (var item in newItems)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
if (item.Model is BeatmapInfo b) if (item.Model is GroupDefinition group)
{ {
// Add set header lastGroup = group;
if (lastItem == null || (lastItem.Model is BeatmapInfo b2 && b2.BeatmapSet!.OnlineID != b.BeatmapSet!.OnlineID)) continue;
{
newItems.Add(new CarouselItem(b.BeatmapSet!)
{
DrawHeight = BeatmapSetPanel.HEIGHT,
IsGroupSelectionTarget = true
});
}
if (!setItems.TryGetValue(b.BeatmapSet!, out var related))
setItems[b.BeatmapSet!] = related = new HashSet<CarouselItem>();
related.Add(item);
} }
newItems.Add(item); if (lastGroup != null)
lastItem = item; {
if (!groupItems.TryGetValue(lastGroup, out var groupRelated))
groupItems[lastGroup] = groupRelated = new HashSet<CarouselItem>();
groupRelated.Add(item);
item.IsGroupSelectionTarget = false; item.IsVisible = false;
item.IsVisible = false; }
} }
return newItems; return newItems;

View File

@ -67,7 +67,6 @@ namespace osu.Game.Screens.SelectV2
base.PrepareForUse(); base.PrepareForUse();
Debug.Assert(Item != null); Debug.Assert(Item != null);
Debug.Assert(Item.IsGroupSelectionTarget);
var beatmapSetInfo = (BeatmapSetInfo)Item.Model; var beatmapSetInfo = (BeatmapSetInfo)Item.Model;

View File

@ -8,6 +8,7 @@ using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Framework.Caching;
using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Extensions.TypeExtensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
@ -130,7 +131,7 @@ namespace osu.Game.Screens.SelectV2
/// ///
/// A filter may add, mutate or remove items. /// A filter may add, mutate or remove items.
/// </remarks> /// </remarks>
protected IEnumerable<ICarouselFilter> Filters { get; init; } = Enumerable.Empty<ICarouselFilter>(); public IEnumerable<ICarouselFilter> Filters { get; init; } = Enumerable.Empty<ICarouselFilter>();
/// <summary> /// <summary>
/// All items which are to be considered for display in this carousel. /// All items which are to be considered for display in this carousel.
@ -167,12 +168,18 @@ namespace osu.Game.Screens.SelectV2
protected Drawable? GetMaterialisedDrawableForItem(CarouselItem item) => protected Drawable? GetMaterialisedDrawableForItem(CarouselItem item) =>
scroll.Panels.SingleOrDefault(p => ((ICarouselPanel)p).Item == item); scroll.Panels.SingleOrDefault(p => ((ICarouselPanel)p).Item == item);
/// <summary>
/// When a user is traversing the carousel via group selection keys, assert whether the item provided is a valid target.
/// </summary>
/// <param name="item">The candidate item.</param>
/// <returns>Whether the provided item is a valid group target. If <c>false</c>, more panels will be checked in the user's requested direction until a valid target is found.</returns>
protected virtual bool CheckValidForGroupSelection(CarouselItem item) => true;
/// <summary> /// <summary>
/// Called when an item is "selected". /// Called when an item is "selected".
/// </summary> /// </summary>
protected virtual void HandleItemSelected(object? model) /// <returns>Whether the item should be selected.</returns>
{ protected virtual bool HandleItemSelected(object? model) => true;
}
/// <summary> /// <summary>
/// Called when an item is "deselected". /// Called when an item is "deselected".
@ -205,7 +212,6 @@ namespace osu.Game.Screens.SelectV2
InternalChild = scroll = new CarouselScrollContainer InternalChild = scroll = new CarouselScrollContainer
{ {
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
Masking = false,
}; };
Items.BindCollectionChanged((_, _) => FilterAsync()); Items.BindCollectionChanged((_, _) => FilterAsync());
@ -303,19 +309,19 @@ namespace osu.Game.Screens.SelectV2
return true; return true;
case GlobalAction.SelectNext: case GlobalAction.SelectNext:
selectNext(1, isGroupSelection: false); traverseKeyboardSelection(1);
return true;
case GlobalAction.SelectNextGroup:
selectNext(1, isGroupSelection: true);
return true; return true;
case GlobalAction.SelectPrevious: case GlobalAction.SelectPrevious:
selectNext(-1, isGroupSelection: false); traverseKeyboardSelection(-1);
return true;
case GlobalAction.SelectNextGroup:
traverseGroupSelection(1);
return true; return true;
case GlobalAction.SelectPreviousGroup: case GlobalAction.SelectPreviousGroup:
selectNext(-1, isGroupSelection: true); traverseGroupSelection(-1);
return true; return true;
} }
@ -326,91 +332,99 @@ namespace osu.Game.Screens.SelectV2
{ {
} }
/// <summary> private void traverseKeyboardSelection(int direction)
/// 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 if (carouselItems == null || carouselItems.Count == 0) return;
Debug.Assert(direction != 0);
direction = direction > 0 ? 1 : -1;
if (carouselItems == null || carouselItems.Count == 0) int originalIndex;
return false;
// If the user has a different keyboard selection and requests if (currentKeyboardSelection.Index != null)
// group selection, first transfer the keyboard selection to actual selection. originalIndex = currentKeyboardSelection.Index.Value;
if (isGroupSelection && currentSelection.CarouselItem != currentKeyboardSelection.CarouselItem) else if (direction > 0)
{ originalIndex = carouselItems.Count - 1;
TryActivateSelection(); else
return true; originalIndex = 0;
}
CarouselItem? selectionItem = currentKeyboardSelection.CarouselItem; int newIndex = originalIndex;
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. // 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. // The fail condition is when we reach the selection after a cyclic loop over every item.
do do
{ {
selectionIndex += direction; newIndex = (newIndex + direction + carouselItems.Count) % carouselItems.Count;
newItem = carouselItems[(selectionIndex + carouselItems.Count) % carouselItems.Count]; var newItem = carouselItems[newIndex];
if (attemptSelection(newItem)) if (newItem.IsVisible)
return true; {
} while (newItem != selectionItem); setKeyboardSelection(newItem.Model);
return;
}
} while (newIndex != originalIndex);
}
return false; /// <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>
/// <returns>Whether selection was possible.</returns>
private void traverseGroupSelection(int direction)
{
if (carouselItems == null || carouselItems.Count == 0) return;
bool attemptSelection(CarouselItem item) // If the user has a different keyboard selection and requests
// group selection, first transfer the keyboard selection to actual selection.
if (currentSelection.CarouselItem != currentKeyboardSelection.CarouselItem)
{ {
if (!item.IsVisible || (isGroupSelection && !item.IsGroupSelectionTarget)) TryActivateSelection();
return false;
if (isGroupSelection) // There's a chance this couldn't resolve, at which point continue with standard traversal.
setSelection(item.Model); if (currentSelection.CarouselItem == currentKeyboardSelection.CarouselItem)
else return;
setKeyboardSelection(item.Model);
return true;
} }
int originalIndex;
int newIndex;
if (currentSelection.Index == null)
{
// If there's no current selection, start from either end of the full list.
newIndex = originalIndex = direction > 0 ? carouselItems.Count - 1 : 0;
}
else
{
newIndex = originalIndex = currentSelection.Index.Value;
// 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 (direction < 0)
{
while (!CheckValidForGroupSelection(carouselItems[newIndex]))
newIndex--;
}
}
// 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
{
newIndex = (newIndex + direction + carouselItems.Count) % carouselItems.Count;
var newItem = carouselItems[newIndex];
if (CheckValidForGroupSelection(newItem))
{
setSelection(newItem.Model);
return;
}
} while (newIndex != originalIndex);
} }
#endregion #endregion
#region Selection handling #region Selection handling
private readonly Cached selectionValid = new Cached();
private Selection currentKeyboardSelection = new Selection(); private Selection currentKeyboardSelection = new Selection();
private Selection currentSelection = new Selection(); private Selection currentSelection = new Selection();
@ -419,29 +433,22 @@ namespace osu.Game.Screens.SelectV2
if (currentSelection.Model == model) if (currentSelection.Model == model)
return; return;
var previousSelection = currentSelection; if (HandleItemSelected(model))
{
if (currentSelection.Model != null)
HandleItemDeselected(currentSelection.Model);
if (previousSelection.Model != null) currentKeyboardSelection = new Selection(model);
HandleItemDeselected(previousSelection.Model); currentSelection = currentKeyboardSelection;
}
currentSelection = currentKeyboardSelection = new Selection(model); selectionValid.Invalidate();
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) private void setKeyboardSelection(object? model)
{ {
currentKeyboardSelection = new Selection(model); currentKeyboardSelection = new Selection(model);
selectionValid.Invalidate();
refreshAfterSelection();
scrollToSelection();
} }
/// <summary> /// <summary>
@ -526,6 +533,13 @@ namespace osu.Game.Screens.SelectV2
if (carouselItems == null) if (carouselItems == null)
return; return;
if (!selectionValid.IsValid)
{
refreshAfterSelection();
scrollToSelection();
selectionValid.Validate();
}
var range = getDisplayRange(); var range = getDisplayRange();
if (range != displayedRange) if (range != displayedRange)

View File

@ -29,11 +29,6 @@ namespace osu.Game.Screens.SelectV2
/// </summary> /// </summary>
public float DrawHeight { get; set; } = DEFAULT_HEIGHT; public float DrawHeight { get; set; } = DEFAULT_HEIGHT;
/// <summary>
/// Whether this item should be a valid target for user group selection hotkeys.
/// </summary>
public bool IsGroupSelectionTarget { get; set; }
/// <summary> /// <summary>
/// Whether this item is visible or collapsed (hidden). /// Whether this item is visible or collapsed (hidden).
/// </summary> /// </summary>

View File

@ -0,0 +1,112 @@
// 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 System.Diagnostics;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Pooling;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Input.Events;
using osu.Game.Graphics.Sprites;
using osuTK;
using osuTK.Graphics;
namespace osu.Game.Screens.SelectV2
{
public partial class GroupPanel : PoolableDrawable, ICarouselPanel
{
public const float HEIGHT = CarouselItem.DEFAULT_HEIGHT * 2;
[Resolved]
private BeatmapCarousel carousel { get; set; } = null!;
private Box activationFlash = null!;
private OsuSpriteText text = null!;
[BackgroundDependencyLoader]
private void load()
{
Size = new Vector2(500, HEIGHT);
Masking = true;
InternalChildren = new Drawable[]
{
new Box
{
Colour = Color4.DarkBlue.Darken(5),
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)
{
BorderThickness = 5;
BorderColour = Color4.Pink;
}
else
{
BorderThickness = 0;
}
});
}
protected override void PrepareForUse()
{
base.PrepareForUse();
Debug.Assert(Item != null);
GroupDefinition group = (GroupDefinition)Item.Model;
text.Text = group.Title;
this.FadeInFromZero(500, Easing.OutQuint);
}
protected override bool OnClick(ClickEvent e)
{
carousel.CurrentSelection = Item!.Model;
return true;
}
#region ICarouselPanel
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()
{
// sets should never be activated.
throw new InvalidOperationException();
}
#endregion
}
}