Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add event handler for user change of TabControl tab selection #6218

Merged
merged 12 commits into from Mar 21, 2024
129 changes: 129 additions & 0 deletions osu.Framework.Tests/Visual/UserInterface/TestSceneTabControlEvents.cs
@@ -0,0 +1,129 @@
// 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.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Graphics;
using osu.Framework.Graphics.UserInterface;
using osu.Framework.Input;
using osu.Framework.Testing;
using osuTK;
using osuTK.Input;

namespace osu.Framework.Tests.Visual.UserInterface
{
public partial class TestSceneTabControlEvents : ManualInputManagerTestScene
{
private EventQueuesTabControl tabControl = null!;

[SetUpSteps]
public void SetUpSteps()
{
AddStep("add tab control", () => Child = tabControl = new EventQueuesTabControl
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Size = new Vector2(150, 40),
Items = Enum.GetValues<TestEnum>(),
});

AddAssert("selected tab queue empty", () => tabControl.UserTabSelectionChangedQueue.Count == 0);
}

[Test]
public void TestClickSendsEvent()
{
AddStep("click second tab", () =>
{
InputManager.MoveMouseTo(this.ChildrenOfType<TabItem<TestEnum>>().ElementAt(1));
InputManager.Click(MouseButton.Left);
});

AddAssert("selected tab = second", () => tabControl.Current.Value == TestEnum.Second);
AddAssert("selected tab queue has \"second\"", () => tabControl.UserTabSelectionChangedQueue.Dequeue().Value == TestEnum.Second);
}

[Test]
public void TestClickSameTabDoesNotSendEvent()
{
AddAssert("first tab selected", () => tabControl.Current.Value == TestEnum.First);
AddStep("click first tab", () =>
{
InputManager.MoveMouseTo(this.ChildrenOfType<TabItem<TestEnum>>().First());
InputManager.Click(MouseButton.Left);
});

AddAssert("first tab still selected", () => tabControl.Current.Value == TestEnum.First);
AddAssert("selected tab queue empty", () => tabControl.UserTabSelectionChangedQueue.Count == 0);
}

[Test]
public void TestSelectItemMethodSendsEvent()
{
AddStep("call select item", () => tabControl.SelectItem(TestEnum.Second));
AddAssert("selected tab queue has \"second\"", () => tabControl.UserTabSelectionChangedQueue.Dequeue().Value == TestEnum.Second);
}

[Test]
public void TestSwitchTabMethodSendsEvent()
{
AddStep("set switchable", () => tabControl.IsSwitchable = true);
AddStep("call switch tab", () => tabControl.SwitchTab(1));
AddAssert("selected tab = second", () => tabControl.Current.Value == TestEnum.Second);
AddAssert("selected tab queue has \"second\"", () => tabControl.UserTabSelectionChangedQueue.Dequeue().Value == TestEnum.Second);
AddStep("call switch tab", () => tabControl.SwitchTab(-1));
AddAssert("selected tab = second", () => tabControl.Current.Value == TestEnum.First);
AddAssert("selected tab queue has \"second\"", () => tabControl.UserTabSelectionChangedQueue.Dequeue().Value == TestEnum.First);
}

[Test]
public void TestSwitchUsingKeyBindingSendsEvent()
{
AddStep("set switchable", () => tabControl.IsSwitchable = true);
AddStep("switch forward", () => InputManager.Keys(PlatformAction.DocumentNext));
AddAssert("selected tab = second", () => tabControl.Current.Value == TestEnum.Second);
AddAssert("selected tab queue has \"second\"", () => tabControl.UserTabSelectionChangedQueue.Dequeue().Value == TestEnum.Second);
AddStep("switch backward", () => InputManager.Keys(PlatformAction.DocumentPrevious));
AddAssert("selected tab = second", () => tabControl.Current.Value == TestEnum.First);
AddAssert("selected tab queue has \"second\"", () => tabControl.UserTabSelectionChangedQueue.Dequeue().Value == TestEnum.First);
}

[Test]
public void TestSwitchOnRemovalDoesNotSendEvent()
{
AddStep("set switchable", () => tabControl.IsSwitchable = true);
AddStep("remove first tab", () => tabControl.RemoveItem(TestEnum.First));

AddAssert("selected tab = second", () => tabControl.Current.Value == TestEnum.Second);
AddAssert("selected tab queue still empty", () => tabControl.UserTabSelectionChangedQueue.Count == 0);
}

[Test]
public void TestBindableChangeDoesNotSendEvent()
{
AddStep("set selected tab = second", () => tabControl.Current.Value = TestEnum.Second);
AddAssert("selected tab queue still empty", () => tabControl.UserTabSelectionChangedQueue.Count == 0);
}

[TearDownSteps]
public void TearDownSteps()
{
AddAssert("selected tab queue empty", () => tabControl.UserTabSelectionChangedQueue.Count == 0);
}

private partial class EventQueuesTabControl : BasicTabControl<TestEnum>
{
public readonly Queue<TabItem<TestEnum>> UserTabSelectionChangedQueue = new Queue<TabItem<TestEnum>>();

protected override void OnUserTabSelectionChanged(TabItem<TestEnum> item) => UserTabSelectionChangedQueue.Enqueue(item);
}

private enum TestEnum
{
First,
Second,
}
}
}
61 changes: 53 additions & 8 deletions osu.Framework/Graphics/UserInterface/TabControl.cs
Expand Up @@ -209,9 +209,9 @@ protected override void LoadComplete()
Current.BindValueChanged(v =>
{
if (v.NewValue != null && tabMap.TryGetValue(v.NewValue, out var found))
selectTab(found);
updateSelectedTab(found);
else
selectTab(null);
updateSelectedTab(null);
}, true);

// TabContainer doesn't have valid layout yet, so TabItems all have y=0 and selectTab() didn't call performTabSort() so we call it here instead
Expand Down Expand Up @@ -323,7 +323,10 @@ protected virtual void RemoveTabItem(TabItem<T> tab, bool removeFromDropdown = t
{
// check all tabs as to include self (in correct iteration order)
bool anySwitchableTabsToRight = AllTabs.SkipWhile(t => t != tab).Skip(1).Any(t => t.IsSwitchable);
SwitchTab(anySwitchableTabsToRight ? 1 : -1);

// switching tab on removal is not directly caused by the user.
// call the private method to not trigger a user change event.
switchTab(anySwitchableTabsToRight ? 1 : -1);
}
}

Expand All @@ -349,16 +352,37 @@ private void updateDropdown(TabItem<T> tab, bool isVisible)
}

/// <summary>
/// Selects a <see cref="TabItem{T}"/>.
/// Selects a <see cref="TabItem{T}"/> and signals an event that the user selected the given tab value via <see cref="OnUserTabSelectionChanged"/>.
Copy link
Sponsor Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dunno about all these public events triggering the OnUserTabSelectionChanged method, but let's see how it works.

/// </summary>
/// <param name="item">The item to select.</param>
public void SelectItem(T item)
{
if (!tabMap.TryGetValue(item, out var tab))
throw new InvalidOperationException($"Item {item} cannot be selected as it does not exist in this {nameof(TabControl<T>)}");

SelectTab(tab);
}

/// <summary>
/// Selects a <see cref="TabItem{T}"/> and signals an event that the user selected the given tab via <see cref="OnUserTabSelectionChanged"/>.
/// </summary>
/// <param name="tab">The tab to select.</param>
protected virtual void SelectTab(TabItem<T> tab)
protected void SelectTab(TabItem<T> tab)
peppy marked this conversation as resolved.
Show resolved Hide resolved
{
if (selectTab(tab))
OnUserTabSelectionChanged(tab);
}

private bool selectTab(TabItem<T> tab)
{
selectTab(tab);
var lastTab = SelectedTab;

updateSelectedTab(tab);
Current.Value = SelectedTab != null ? SelectedTab.Value : default;
return SelectedTab != lastTab;
}

private void selectTab(TabItem<T> tab)
private void updateSelectedTab(TabItem<T> tab)
{
// Only reorder if not pinned and not showing
if (AutoSort && tab != null && !tab.IsPresent && !tab.Pinned)
Expand All @@ -379,9 +403,19 @@ private void selectTab(TabItem<T> tab)
/// <param name="direction">Pass 1 to move to the next tab, or -1 to move to the previous tab.</param>
/// <param name="wrap">If <c>true</c>, moving past the start or the end of the tab list will wrap to the opposite end.</param>
public virtual void SwitchTab(int direction, bool wrap = true)
{
bool changed = switchTab(direction, wrap);

if (changed)
OnUserTabSelectionChanged(SelectedTab);
}

private bool switchTab(int direction, bool wrap = true)
{
if (Math.Abs(direction) != 1) throw new ArgumentException("value must be -1 or 1", nameof(direction));

var lastTab = SelectedTab;

// the current selected tab may be an non-switchable tab, so search all tabs for a candidate.
// this is done to ensure ordering (ie. if an non-switchable tab is in the middle).
var allTabs = TabContainer.AllTabItems.Where(t => t.IsSwitchable || t == SelectedTab);
Expand All @@ -395,7 +429,9 @@ public virtual void SwitchTab(int direction, bool wrap = true)
found = allTabs.FirstOrDefault(t => t != SelectedTab);

if (found != null)
SelectTab(found);
selectTab(found);

return SelectedTab != lastTab;
}

private void activationRequested(TabItem<T> tab)
Expand Down Expand Up @@ -440,6 +476,15 @@ public void OnReleased(KeyBindingReleaseEvent<PlatformAction> e)
{
}

/// <summary>
/// Invoked when the user directly changed tab selection, either by clicking on another tab or switching to the tab using <see cref="PlatformAction"/> key bindings.
/// Note that this does not get invoked when tab selection is changed as a result of a user closing the currently selected tab (see <see cref="SwitchTabOnRemove"/>).
/// </summary>
/// <param name="item">The new tab item.</param>
protected virtual void OnUserTabSelectionChanged(TabItem<T> item)
{
}

/// <summary>
/// Creates the <see cref="TabFillFlowContainer"/> to contain the <see cref="TabItem{T}"/>s.
/// </summary>
Expand Down