Fix song select navigation with page up/down (#36293)

Resolves #36099 

This PR fixes keyboard navigation in the beatmap select carousel for
lazer by implementing page-wise traversal with the Page Up and Page Down
keys and changing it from only scrolling to actually selecting items.

**Changes:**
- Added handling for `TraversalType.Page` in the keyboard traversal
switch.
- Implemented `traverseKeyboardPage(int direction)` method to move the
selection by approximately one "page" of visible items, accounting for
partially obscured items like the search bar. Also it does not wrap
around (like the current PageUp/Down functionality).
- Added new key bindings:
    - `PageUp` → SelectPreviousPage
    - `PageDown` → SelectNextPage

The code may be very explicit for the scroll logic with the page keys,
so I would appreciate some feedback when the PR is reviewed.
The naming of the keybinds may need to be adjusted. `Next page` and
`previous page` may be somewhat misleading.

**Behavior after the change:**
- Pressing Page Up/Down now moves the selection by a page of items.
- After navigating, pressing Left/Right selects the navigated song
instead of moving relative to the previous position.

**See:**
https://www.youtube.com/watch?v=JXmKAhhKiCc

---------

Signed-off-by: Linus Genz <linuslinuxgenz@gmail.com>
Co-authored-by: Dean Herbert <pe@ppy.sh>
This commit is contained in:
Linus Genz
2026-03-01 18:08:19 +01:00
committed by GitHub
parent b88cba0829
commit 033e13cb3b
2 changed files with 94 additions and 15 deletions

View File

@@ -33,6 +33,9 @@ namespace osu.Game.Graphics.Carousel
/// </summary>
protected partial class ScrollContainer : UserTrackingScrollContainer, IKeyBindingHandler<GlobalAction>
{
public Action? OnPageUp { get; init; }
public Action? OnPageDown { get; init; }
public readonly Container Panels;
public void SetLayoutHeight(float height) => Panels.Height = height;
@@ -127,6 +130,22 @@ namespace osu.Game.Graphics.Carousel
protected override bool IsDragging => base.IsDragging || AbsoluteScrolling;
protected override bool OnKeyDown(KeyDownEvent e)
{
switch (e.Key)
{
case Key.PageUp:
OnPageUp?.Invoke();
return true;
case Key.PageDown:
OnPageDown?.Invoke();
return true;
}
return base.OnKeyDown(e);
}
public bool OnPressed(KeyBindingPressEvent<GlobalAction> e)
{
switch (e.Action)

View File

@@ -317,6 +317,8 @@ namespace osu.Game.Graphics.Carousel
{
Masking = false,
RelativeSizeAxes = Axes.Both,
OnPageUp = () => Scheduler.AddOnce(traverseFromKey, new TraversalOperation(TraversalType.Page, -1)),
OnPageDown = () => Scheduler.AddOnce(traverseFromKey, new TraversalOperation(TraversalType.Page, 1)),
};
Items.BindCollectionChanged((_, args) =>
@@ -538,26 +540,30 @@ namespace osu.Game.Graphics.Carousel
}
return false;
}
void traverseFromKey(TraversalOperation traversal)
private void traverseFromKey(TraversalOperation traversal)
{
switch (traversal.Type)
{
switch (traversal.Type)
{
case TraversalType.Keyboard:
traverseKeyboardSelection(traversal.Direction);
break;
case TraversalType.Keyboard:
traverseKeyboardSelection(traversal.Direction);
break;
case TraversalType.Set:
traverseSetSelection(traversal.Direction);
break;
case TraversalType.Page:
traverseKeyboardPage(traversal.Direction);
break;
case TraversalType.Group:
traverseGroupSelection(traversal.Direction);
break;
case TraversalType.Set:
traverseSetSelection(traversal.Direction);
break;
default:
throw new ArgumentOutOfRangeException();
}
case TraversalType.Group:
traverseGroupSelection(traversal.Direction);
break;
default:
throw new ArgumentOutOfRangeException();
}
}
@@ -565,6 +571,7 @@ namespace osu.Game.Graphics.Carousel
{
Keyboard,
Set,
Page,
Group
}
@@ -622,6 +629,59 @@ namespace osu.Game.Graphics.Carousel
} while (newIndex != originalIndex);
}
/// <summary>
/// Performs a page-wise keyboard traversal in the carousel, moving the selection by approximately one "page" of items.
/// </summary>
/// <param name="direction">Positive for downwards, negative for upwards.</param>
private void traverseKeyboardPage(int direction)
{
if (carouselItems == null || carouselItems.Count == 0)
return;
int startIndex = currentKeyboardSelection.Index ?? (direction > 0 ? carouselItems.Count - 1 : 0);
// Compute the number of visible panels to treat as one page.
// Reduced by 50% to account for the search bar covering the top items.
int visiblePanelsCount = Math.Max(1, Scroll.Panels.Count / 2);
int visibleCount = 0;
int i = startIndex;
while (i >= 0 && i < carouselItems.Count)
{
i += direction;
if (i < 0 || i >= carouselItems.Count)
break;
var item = carouselItems[i];
if (!item.IsVisible)
continue;
visibleCount++;
if (visibleCount >= visiblePanelsCount)
{
setKeyboardSelection(item.Model);
ScrollToSelection();
playTraversalSound();
return;
}
}
// If we are at the beginning or end and there are not enough items left to scroll through a complete page, then we go to the last or first item.
var fallback = direction > 0
? carouselItems.LastOrDefault(x => x.IsVisible)
: carouselItems.FirstOrDefault(x => x.IsVisible);
if (fallback != null && !CheckModelEquality(fallback.Model, currentKeyboardSelection.Model))
{
setKeyboardSelection(fallback.Model);
ScrollToSelection();
playTraversalSound();
}
}
/// <summary>
/// Select the next valid group selection relative to a current selection.
/// This is generally for keyboard based traversal.