1
0
mirror of https://github.com/ppy/osu.git synced 2025-03-14 05:47:20 +08:00

Refactor selection and activation handling

I had a bit of a struggle getting header traversal logic to work well.
The constraints I had in place were a bit weird:

- Group panels should toggle or potentially fall into the prev/next
  group
- Set panels should just traverse around them

The current method of using `CheckValidForGroupSelection` return type
for traversal did not mesh with the above two cases. Just trust me on
this one since it's quite hard to explain in words.

After some re-thinking, I've gone with a simpler approach with one
important change to UX: Now when group traversing with a beatmap set
header currently keyboard focused, the first operation will be to reset
keyboard selection to the selected beatmap, rather than traverse.

I find this non-offensive – at most it means a user will need to press
their group traversal key one extra time.

I've also changed group headers to always toggle expansion when doing
group traversal with them selected.

To make all this work, the meaning of `Activation` has changed somewhat.
It is now the primary path for carousel implementations to change
selection of an item. It is what the `Drawable` panels call when they
are clicked.

Selection changes are not performed implicitly by `Carousel` – an
implementation should decide when it actually wants to change the
selection, usually in `HandleItemActivated`.

Having less things mutating `CurrentSelection` is better in my eyes, as
we see this variable as only being mutated internally when utmost
required (ie the user has requested the change). With this change,
`CurrentSelection` can no longer become of a non-`T` type (in the
beatmap carousel implementation at least).

This might pave a path forward for making `CurrentSelection` typed, but
that comes with a few other concerns so I'll look at that as a
follow-up.
This commit is contained in:
Dean Herbert 2025-02-06 16:21:18 +09:00
parent 4026ca84f8
commit 024fbde0fd
No known key found for this signature in database
9 changed files with 98 additions and 83 deletions

View File

@ -110,8 +110,19 @@ namespace osu.Game.Tests.Visual.SongSelect
WaitForGroupSelection(0, 1);
SelectPrevPanel();
SelectPrevPanel();
AddAssert("keyboard selected panel is expanded", () => GetKeyboardSelectedPanel()?.Expanded.Value, () => Is.True);
SelectPrevGroup();
WaitForGroupSelection(4, 5);
WaitForGroupSelection(0, 1);
AddAssert("keyboard selected panel is contracted", () => GetKeyboardSelectedPanel()?.Expanded.Value, () => Is.False);
SelectPrevGroup();
WaitForGroupSelection(0, 1);
AddAssert("keyboard selected panel is expanded", () => GetKeyboardSelectedPanel()?.Expanded.Value, () => Is.True);
}
[Test]

View File

@ -100,8 +100,17 @@ namespace osu.Game.Tests.Visual.SongSelect
WaitForGroupSelection(0, 0);
SelectPrevPanel();
AddAssert("keyboard selected panel is expanded", () => GetKeyboardSelectedPanel()?.Expanded.Value, () => Is.True);
SelectPrevGroup();
WaitForGroupSelection(0, 0);
AddAssert("keyboard selected panel is contracted", () => GetKeyboardSelectedPanel()?.Expanded.Value, () => Is.False);
SelectPrevGroup();
WaitForGroupSelection(0, 0);
AddAssert("keyboard selected panel is expanded", () => GetKeyboardSelectedPanel()?.Expanded.Value, () => Is.True);
}
[Test]

View File

@ -147,7 +147,11 @@ namespace osu.Game.Tests.Visual.SongSelect
SelectPrevPanel();
SelectPrevGroup();
WaitForSelection(0, 0);
WaitForSelection(1, 0);
SelectPrevPanel();
SelectNextGroup();
WaitForSelection(1, 0);
}
[Test]

View File

@ -29,7 +29,7 @@ namespace osu.Game.Tests.Visual.SongSelect
{
Quad positionBefore = default;
AddStep("select middle beatmap", () => Carousel.CurrentSelection = BeatmapSets.ElementAt(BeatmapSets.Count - 2));
AddStep("select middle beatmap", () => Carousel.CurrentSelection = BeatmapSets.ElementAt(BeatmapSets.Count - 2).Beatmaps.First());
AddStep("scroll to selected item", () => Scroll.ScrollTo(Scroll.ChildrenOfType<BeatmapPanel>().Single(p => p.Selected.Value)));
WaitForScrolling();
@ -50,7 +50,7 @@ namespace osu.Game.Tests.Visual.SongSelect
AddStep("scroll to last item", () => Scroll.ScrollToEnd(false));
AddStep("select last beatmap", () => Carousel.CurrentSelection = BeatmapSets.Last());
AddStep("select last beatmap", () => Carousel.CurrentSelection = BeatmapSets.Last().Beatmaps.Last());
WaitForScrolling();

View File

@ -95,11 +95,9 @@ namespace osu.Game.Screens.SelectV2
private GroupDefinition? lastSelectedGroup;
private BeatmapInfo? lastSelectedBeatmap;
protected override bool HandleItemSelected(object? model)
protected override void HandleItemActivated(CarouselItem item)
{
base.HandleItemSelected(model);
switch (model)
switch (item.Model)
{
case GroupDefinition group:
// Special case collapsing an open group.
@ -107,16 +105,32 @@ namespace osu.Game.Screens.SelectV2
{
setExpansionStateOfGroup(lastSelectedGroup, false);
lastSelectedGroup = null;
return false;
return;
}
setExpandedGroup(group);
return false;
return;
case BeatmapSetInfo setInfo:
// Selecting a set isn't valid let's re-select the first difficulty.
CurrentSelection = setInfo.Beatmaps.First();
return false;
return;
case BeatmapInfo beatmapInfo:
CurrentSelection = beatmapInfo;
return;
}
}
protected override void HandleItemSelected(object? model)
{
base.HandleItemSelected(model);
switch (model)
{
case BeatmapSetInfo:
case GroupDefinition:
throw new InvalidOperationException("Groups should never become selected");
case BeatmapInfo beatmapInfo:
// Find any containing group. There should never be too many groups so iterating is efficient enough.
@ -125,11 +139,8 @@ namespace osu.Game.Screens.SelectV2
if (containingGroup != null)
setExpandedGroup(containingGroup);
setExpandedSet(beatmapInfo);
return true;
break;
}
return true;
}
protected override bool CheckValidForGroupSelection(CarouselItem item)

View File

@ -86,13 +86,7 @@ namespace osu.Game.Screens.SelectV2
protected override bool OnClick(ClickEvent e)
{
if (carousel.CurrentSelection != Item!.Model)
{
carousel.CurrentSelection = Item!.Model;
return true;
}
carousel.TryActivateSelection();
carousel.Activate(Item!);
return true;
}

View File

@ -1,7 +1,6 @@
// 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;
@ -83,7 +82,7 @@ namespace osu.Game.Screens.SelectV2
protected override bool OnClick(ClickEvent e)
{
carousel.CurrentSelection = Item!.Model;
carousel.Activate(Item!);
return true;
}
@ -98,8 +97,6 @@ namespace osu.Game.Screens.SelectV2
public void Activated()
{
// sets should never be activated.
throw new InvalidOperationException();
}
#endregion

View File

@ -94,26 +94,39 @@ namespace osu.Game.Screens.SelectV2
public object? CurrentSelection
{
get => currentSelection.Model;
set => setSelection(value);
set
{
if (currentSelection.Model != value)
{
HandleItemSelected(value);
if (currentSelection.Model != null)
HandleItemDeselected(currentSelection.Model);
currentKeyboardSelection = new Selection(value);
currentSelection = currentKeyboardSelection;
selectionValid.Invalidate();
}
else if (currentKeyboardSelection.Model != value)
{
// Even if the current selection matches, let's ensure the keyboard selection is reset
// to the newly selected object. This matches user expectations (for now).
currentKeyboardSelection = currentSelection;
selectionValid.Invalidate();
}
}
}
/// <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.
/// Activate the specified item.
/// </summary>
public void TryActivateSelection()
/// <param name="item"></param>
public void Activate(CarouselItem item)
{
if (currentSelection.CarouselItem != currentKeyboardSelection.CarouselItem)
{
CurrentSelection = currentKeyboardSelection.Model;
return;
}
(GetMaterialisedDrawableForItem(item) as ICarouselPanel)?.Activated();
HandleItemActivated(item);
if (currentSelection.CarouselItem != null)
{
(GetMaterialisedDrawableForItem(currentSelection.CarouselItem) as ICarouselPanel)?.Activated();
HandleItemActivated(currentSelection.CarouselItem);
}
selectionValid.Invalidate();
}
#endregion
@ -176,30 +189,28 @@ namespace osu.Game.Screens.SelectV2
protected virtual bool CheckValidForGroupSelection(CarouselItem item) => true;
/// <summary>
/// Called when an item is "selected".
/// Called after an item becomes the <see cref="CurrentSelection"/>.
/// Should be used to handle any group expansion, item visibility changes, etc.
/// </summary>
/// <returns>Whether the item should be selected.</returns>
protected virtual bool HandleItemSelected(object? model) => true;
protected virtual void HandleItemSelected(object? model) { }
/// <summary>
/// Called when an item is "deselected".
/// Called when the <see cref="CurrentSelection"/> changes to a new selection.
/// Should be used to handle any group expansion, item visibility changes, etc.
/// </summary>
protected virtual void HandleItemDeselected(object? model)
{
}
protected virtual void HandleItemDeselected(object? model) { }
/// <summary>
/// Called when an item is "activated".
/// Called when an item is activated via user input (keyboard traversal or a mouse click).
/// </summary>
/// <remarks>
/// An activated item should for instance:
/// - Open or close a folder
/// - Start gameplay on a beatmap difficulty.
/// An activated item should decide to perform an action, such as:
/// - Change its expanded state (and show / hide children items).
/// - Set the item to the <see cref="CurrentSelection"/>.
/// - Start gameplay on a beatmap difficulty if already selected.
/// </remarks>
/// <param name="item">The carousel item which was activated.</param>
protected virtual void HandleItemActivated(CarouselItem item)
{
}
protected virtual void HandleItemActivated(CarouselItem item) { }
#endregion
@ -305,7 +316,8 @@ namespace osu.Game.Screens.SelectV2
switch (e.Action)
{
case GlobalAction.Select:
TryActivateSelection();
if (currentKeyboardSelection.CarouselItem != null)
Activate(currentKeyboardSelection.CarouselItem);
return true;
case GlobalAction.SelectNext:
@ -374,13 +386,10 @@ namespace osu.Game.Screens.SelectV2
// 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 (currentKeyboardSelection.CarouselItem != null && currentSelection.CarouselItem != currentKeyboardSelection.CarouselItem)
{
TryActivateSelection();
// Is the selection actually changed, then we should not perform any further traversal.
if (currentSelection.CarouselItem == currentKeyboardSelection.CarouselItem)
return;
Activate(currentKeyboardSelection.CarouselItem);
return;
}
int originalIndex;
@ -413,7 +422,7 @@ namespace osu.Game.Screens.SelectV2
if (CheckValidForGroupSelection(newItem))
{
setSelection(newItem.Model);
HandleItemActivated(newItem);
return;
}
} while (newIndex != originalIndex);
@ -428,23 +437,6 @@ namespace osu.Game.Screens.SelectV2
private Selection currentKeyboardSelection = new Selection();
private Selection currentSelection = new Selection();
private void setSelection(object? model)
{
if (currentSelection.Model == model)
return;
if (HandleItemSelected(model))
{
if (currentSelection.Model != null)
HandleItemDeselected(currentSelection.Model);
currentKeyboardSelection = new Selection(model);
currentSelection = currentKeyboardSelection;
}
selectionValid.Invalidate();
}
private void setKeyboardSelection(object? model)
{
currentKeyboardSelection = new Selection(model);

View File

@ -1,7 +1,6 @@
// 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;
@ -96,7 +95,7 @@ namespace osu.Game.Screens.SelectV2
protected override bool OnClick(ClickEvent e)
{
carousel.CurrentSelection = Item!.Model;
carousel.Activate(Item!);
return true;
}
@ -111,8 +110,6 @@ namespace osu.Game.Screens.SelectV2
public void Activated()
{
// sets should never be activated.
throw new InvalidOperationException();
}
#endregion