diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs index a4feffddfb..938ab1e9f4 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneFreeModSelectOverlay.cs @@ -58,7 +58,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("select difficulty adjust", () => freeModSelectOverlay.SelectedMods.Value = new[] { new OsuModDifficultyAdjust() }); AddWaitStep("wait some", 3); - AddAssert("customisation area not expanded", () => this.ChildrenOfType().Single().Height == 0); + AddAssert("customisation area not expanded", () => !this.ChildrenOfType().Single().Expanded.Value); } [Test] diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModCustomisationPanel.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModCustomisationPanel.cs new file mode 100644 index 0000000000..9c0d185892 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModCustomisationPanel.cs @@ -0,0 +1,66 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Overlays; +using osu.Game.Overlays.Mods; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Mods; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public partial class TestSceneModCustomisationPanel : OsuManualInputManagerTestScene + { + [Cached] + private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + + private ModCustomisationPanel panel = null!; + + [SetUp] + public void SetUp() => Schedule(() => + { + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding(20f), + Child = panel = new ModCustomisationPanel + { + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre, + Width = 400f, + State = { Value = Visibility.Visible }, + SelectedMods = { BindTarget = SelectedMods }, + } + }; + }); + + [Test] + public void TestDisplay() + { + AddStep("set DT", () => + { + SelectedMods.Value = new[] { new OsuModDoubleTime() }; + panel.Enabled.Value = panel.Expanded.Value = true; + }); + AddStep("set DA", () => + { + SelectedMods.Value = new Mod[] { new OsuModDifficultyAdjust() }; + panel.Enabled.Value = panel.Expanded.Value = true; + }); + AddStep("set FL+WU+DA+AD", () => + { + SelectedMods.Value = new Mod[] { new OsuModFlashlight(), new ModWindUp(), new OsuModDifficultyAdjust(), new OsuModApproachDifferent() }; + panel.Enabled.Value = panel.Expanded.Value = true; + }); + AddStep("set empty", () => + { + SelectedMods.Value = Array.Empty(); + panel.Enabled.Value = panel.Expanded.Value = false; + }); + } + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs index a1452ddb31..21a5e3082b 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs @@ -7,7 +7,6 @@ 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.Input; @@ -56,6 +55,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("clear contents", Clear); AddStep("reset ruleset", () => Ruleset.Value = rulesetStore.GetRuleset(0)); AddStep("reset mods", () => SelectedMods.SetDefault()); + AddStep("reset config", () => configManager.SetValue(OsuSetting.ModSelectTextSearchStartsActive, true)); AddStep("set beatmap", () => Beatmap.Value = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo)); AddStep("set up presets", () => { @@ -225,7 +225,7 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("dismiss mod customisation via toggle", () => { - InputManager.MoveMouseTo(modSelectOverlay.CustomisationButton.AsNonNull()); + InputManager.MoveMouseTo(modSelectOverlay.ChildrenOfType().Single()); InputManager.Click(MouseButton.Left); }); assertCustomisationToggleState(disabled: false, active: false); @@ -258,7 +258,7 @@ namespace osu.Game.Tests.Visual.UserInterface } [Test] - public void TestDismissCustomisationViaDimmedArea() + public void TestDismissCustomisationViaClickingAway() { createScreen(); assertCustomisationToggleState(disabled: true, active: false); @@ -266,18 +266,23 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("select difficulty adjust via panel", () => getPanelForMod(typeof(OsuModDifficultyAdjust)).TriggerClick()); assertCustomisationToggleState(disabled: false, active: true); - AddStep("move mouse to settings area", () => InputManager.MoveMouseTo(this.ChildrenOfType().Single())); - AddStep("move mouse to dimmed area", () => - { - InputManager.MoveMouseTo(new Vector2( - modSelectOverlay.ScreenSpaceDrawQuad.TopLeft.X, - (modSelectOverlay.ScreenSpaceDrawQuad.TopLeft.Y + modSelectOverlay.ScreenSpaceDrawQuad.BottomLeft.Y) / 2)); - }); + AddStep("move mouse to search bar", () => InputManager.MoveMouseTo(modSelectOverlay.ChildrenOfType().Single())); AddStep("click", () => InputManager.Click(MouseButton.Left)); assertCustomisationToggleState(disabled: false, active: false); + } - AddStep("move mouse to first mod panel", () => InputManager.MoveMouseTo(modSelectOverlay.ChildrenOfType().First())); - AddAssert("first mod panel is hovered", () => modSelectOverlay.ChildrenOfType().First().IsHovered); + [Test] + public void TestDismissCustomisationWhenHidingOverlay() + { + createScreen(); + assertCustomisationToggleState(disabled: true, active: false); + + AddStep("select difficulty adjust via panel", () => getPanelForMod(typeof(OsuModDifficultyAdjust)).TriggerClick()); + assertCustomisationToggleState(disabled: false, active: true); + + AddStep("hide overlay", () => modSelectOverlay.Hide()); + AddStep("show overlay again", () => modSelectOverlay.Show()); + assertCustomisationToggleState(disabled: false, active: false); } /// @@ -339,7 +344,7 @@ namespace osu.Game.Tests.Visual.UserInterface createScreen(); changeRuleset(0); - AddStep("Select all fun mods", () => + AddStep("Select all difficulty-increase mods", () => { modSelectOverlay.ChildrenOfType() .Single(c => c.ModType == ModType.DifficultyIncrease) @@ -641,13 +646,15 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("select DT", () => SelectedMods.Value = new Mod[] { new OsuModDoubleTime() }); AddAssert("DT selected", () => modSelectOverlay.ChildrenOfType().Count(panel => panel.Active.Value), () => Is.EqualTo(1)); - AddStep("open customisation area", () => modSelectOverlay.CustomisationButton!.TriggerClick()); - assertCustomisationToggleState(false, true); + AddStep("open customisation area", () => modSelectOverlay.ChildrenOfType().Single().TriggerClick()); + assertCustomisationToggleState(disabled: false, active: true); + AddStep("hover over mod settings slider", () => { - var slider = modSelectOverlay.ChildrenOfType().Single().ChildrenOfType>().First(); + var slider = modSelectOverlay.ChildrenOfType().Single().ChildrenOfType>().First(); InputManager.MoveMouseTo(slider); }); + AddStep("press right arrow", () => InputManager.PressKey(Key.Right)); AddAssert("DT speed changed", () => !SelectedMods.Value.OfType().Single().SpeedChange.IsDefault); @@ -744,9 +751,10 @@ namespace osu.Game.Tests.Visual.UserInterface AddStep("select difficulty adjust via panel", () => getPanelForMod(typeof(OsuModDifficultyAdjust)).TriggerClick()); assertCustomisationToggleState(disabled: false, active: true); - AddAssert("back button disabled", () => !modSelectOverlay.BackButton.Enabled.Value); AddStep("dismiss customisation area", () => InputManager.Key(Key.Escape)); + AddAssert("mod select still visible", () => modSelectOverlay.State.Value == Visibility.Visible); + AddStep("click back button", () => { InputManager.MoveMouseTo(modSelectOverlay.BackButton); @@ -755,6 +763,19 @@ namespace osu.Game.Tests.Visual.UserInterface AddAssert("mod select hidden", () => modSelectOverlay.State.Value == Visibility.Hidden); } + [Test] + public void TestCloseViaToggleModSelectionBinding() + { + createScreen(); + changeRuleset(0); + + AddStep("select difficulty adjust via panel", () => getPanelForMod(typeof(OsuModDifficultyAdjust)).TriggerClick()); + assertCustomisationToggleState(disabled: false, active: true); + + AddStep("press F1", () => InputManager.Key(Key.F1)); + AddAssert("mod select hidden", () => modSelectOverlay.State.Value == Visibility.Hidden); + } + /// /// Covers columns hiding/unhiding on changes of . /// @@ -870,8 +891,8 @@ namespace osu.Game.Tests.Visual.UserInterface // it is instrumental in the reproduction of the failure scenario that this test is supposed to cover. AddStep("force collection", GC.Collect); - AddStep("open customisation area", () => modSelectOverlay.CustomisationButton!.TriggerClick()); - AddStep("reset half time speed to default", () => modSelectOverlay.ChildrenOfType().Single() + AddStep("open customisation area", () => modSelectOverlay.ChildrenOfType().Single().TriggerClick()); + AddStep("reset half time speed to default", () => modSelectOverlay.ChildrenOfType().Single() .ChildrenOfType>().Single().TriggerClick()); AddUntilStep("difficulty multiplier display shows correct value", () => modSelectOverlay.ChildrenOfType().Single().ModMultiplier.Value, () => Is.EqualTo(0.3).Within(Precision.DOUBLE_EPSILON)); @@ -883,24 +904,91 @@ namespace osu.Game.Tests.Visual.UserInterface createScreen(); AddStep("select DT + HD + DF", () => SelectedMods.Value = new Mod[] { new OsuModDoubleTime(), new OsuModHidden(), new OsuModDeflate() }); + AddStep("open customisation panel", () => this.ChildrenOfType().Single().TriggerClick()); AddAssert("mod settings order: DT, HD, DF", () => { - var columns = this.ChildrenOfType().Single().ChildrenOfType(); + var columns = this.ChildrenOfType(); return columns.ElementAt(0).Mod is OsuModDoubleTime && columns.ElementAt(1).Mod is OsuModHidden && columns.ElementAt(2).Mod is OsuModDeflate; }); - AddStep("replace DT with NC", () => SelectedMods.Value = SelectedMods.Value.Where(m => m is not ModDoubleTime).Append(new OsuModNightcore()).ToList()); + AddStep("replace DT with NC", () => + { + SelectedMods.Value = SelectedMods.Value.Where(m => m is not ModDoubleTime).Append(new OsuModNightcore()).ToList(); + this.ChildrenOfType().Single().TriggerClick(); + }); AddAssert("mod settings order: NC, HD, DF", () => { - var columns = this.ChildrenOfType().Single().ChildrenOfType(); + var columns = this.ChildrenOfType(); return columns.ElementAt(0).Mod is OsuModNightcore && columns.ElementAt(1).Mod is OsuModHidden && columns.ElementAt(2).Mod is OsuModDeflate; }); } + [Test] + public void TestOpeningCustomisationHidesPresetPopover() + { + createScreen(); + + AddStep("select DT", () => SelectedMods.Value = new Mod[] { new OsuModDoubleTime() }); + AddStep("click new preset", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("preset popover shown", () => this.ChildrenOfType().SingleOrDefault()?.IsPresent, () => Is.True); + + AddStep("click customisation header", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("preset popover hidden", () => this.ChildrenOfType().SingleOrDefault()?.IsPresent, () => Is.Not.True); + AddAssert("customisation panel shown", () => this.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Visible)); + } + + [Test] + public void TestCustomisationPanelAbsorbsInput([Values] bool textSearchStartsActive) + { + AddStep($"text search starts active = {textSearchStartsActive}", () => configManager.SetValue(OsuSetting.ModSelectTextSearchStartsActive, textSearchStartsActive)); + createScreen(); + + AddStep("select DT", () => SelectedMods.Value = new Mod[] { new OsuModDoubleTime() }); + AddStep("open customisation panel", () => this.ChildrenOfType().Single().TriggerClick()); + AddAssert("search lost focus", () => !this.ChildrenOfType().Single().HasFocus); + + AddStep("press tab", () => InputManager.Key(Key.Tab)); + AddAssert("search still not focused", () => !this.ChildrenOfType().Single().HasFocus); + + AddStep("press q", () => InputManager.Key(Key.Q)); + AddAssert("easy not selected", () => SelectedMods.Value.Single() is OsuModDoubleTime); + + // the "deselect all mods" action is intentionally disabled when customisation panel is open to not conflict with pressing backspace to delete characters in a textbox. + // this is supposed to be handled by the textbox itself especially since it's focused and thus prioritised in input queue, + // but it's not for some reason, and figuring out why is probably not going to be a pleasant experience (read TextBox.OnKeyDown for a head start). + AddStep("press backspace", () => InputManager.Key(Key.BackSpace)); + AddAssert("mods not deselected", () => SelectedMods.Value.Single() is OsuModDoubleTime); + + AddStep("move mouse to scroll bar", () => InputManager.MoveMouseTo(modSelectOverlay.ChildrenOfType().Single().ScreenSpaceDrawQuad.BottomLeft + new Vector2(10f, -5f))); + + AddStep("scroll down", () => InputManager.ScrollVerticalBy(-10f)); + AddAssert("column not scrolled", () => modSelectOverlay.ChildrenOfType().Single().IsScrolledToStart()); + + AddStep("press mouse", () => InputManager.PressButton(MouseButton.Left)); + AddAssert("search still not focused", () => !this.ChildrenOfType().Single().HasFocus); + AddStep("release mouse", () => InputManager.ReleaseButton(MouseButton.Left)); + AddAssert("customisation panel closed by click", () => !this.ChildrenOfType().Single().Expanded.Value); + + if (textSearchStartsActive) + AddAssert("search focused", () => this.ChildrenOfType().Single().HasFocus); + else + AddAssert("search still not focused", () => !this.ChildrenOfType().Single().HasFocus); + } + private void waitForColumnLoad() => AddUntilStep("all column content loaded", () => modSelectOverlay.ChildrenOfType().Any() && modSelectOverlay.ChildrenOfType().All(column => column.IsLoaded && column.ItemsLoaded) @@ -915,8 +1003,8 @@ namespace osu.Game.Tests.Visual.UserInterface private void assertCustomisationToggleState(bool disabled, bool active) { - AddAssert($"customisation toggle is {(disabled ? "" : "not ")}disabled", () => modSelectOverlay.CustomisationButton.AsNonNull().Active.Disabled == disabled); - AddAssert($"customisation toggle is {(active ? "" : "not ")}active", () => modSelectOverlay.CustomisationButton.AsNonNull().Active.Value == active); + AddUntilStep($"customisation panel is {(disabled ? "" : "not ")}disabled", () => modSelectOverlay.ChildrenOfType().Single().Enabled.Value == !disabled); + AddAssert($"customisation panel is {(active ? "" : "not ")}active", () => modSelectOverlay.ChildrenOfType().Single().Expanded.Value == active); } private T getSelectedMod() where T : Mod => SelectedMods.Value.OfType().Single(); @@ -929,7 +1017,6 @@ namespace osu.Game.Tests.Visual.UserInterface protected override bool ShowPresets => true; public new ShearedButton BackButton => base.BackButton; - public new ShearedToggleButton? CustomisationButton => base.CustomisationButton; } private class TestUnimplementedMod : Mod diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModSettingsArea.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModSettingsArea.cs deleted file mode 100644 index dac1f94c28..0000000000 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModSettingsArea.cs +++ /dev/null @@ -1,42 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#nullable disable - -using System; -using NUnit.Framework; -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Game.Overlays; -using osu.Game.Overlays.Mods; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Osu.Mods; - -namespace osu.Game.Tests.Visual.UserInterface -{ - [TestFixture] - public partial class TestSceneModSettingsArea : OsuTestScene - { - [Cached] - private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green); - - [Test] - public void TestModToggleArea() - { - ModSettingsArea modSettingsArea = null; - - AddStep("create content", () => Child = new Container - { - RelativeSizeAxes = Axes.Both, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - Child = modSettingsArea = new ModSettingsArea() - }); - AddStep("set DT", () => modSettingsArea.SelectedMods.Value = new[] { new OsuModDoubleTime() }); - AddStep("set DA", () => modSettingsArea.SelectedMods.Value = new Mod[] { new OsuModDifficultyAdjust() }); - AddStep("set FL+WU+DA+AD", () => modSettingsArea.SelectedMods.Value = new Mod[] { new OsuModFlashlight(), new ModWindUp(), new OsuModDifficultyAdjust(), new OsuModApproachDifferent() }); - AddStep("set empty", () => modSettingsArea.SelectedMods.Value = Array.Empty()); - } - } -} diff --git a/osu.Game/Localisation/ModSelectOverlayStrings.cs b/osu.Game/Localisation/ModSelectOverlayStrings.cs index cf01081772..10037d30c3 100644 --- a/osu.Game/Localisation/ModSelectOverlayStrings.cs +++ b/osu.Game/Localisation/ModSelectOverlayStrings.cs @@ -75,6 +75,16 @@ namespace osu.Game.Localisation /// public static LocalisableString UnrankedExplanation => new TranslatableString(getKey(@"unranked_explanation"), @"Performance points will not be granted due to active mods."); + /// + /// "Customise" + /// + public static LocalisableString CustomisationPanelHeader => new TranslatableString(getKey(@"customisation_panel_header"), @"Customise"); + + /// + /// "No mod selected which can be customised." + /// + public static LocalisableString CustomisationPanelDisabledReason => new TranslatableString(getKey(@"customisation_panel_disabled_reason"), @"No mod selected which can be customised."); + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Overlays/Mods/ModCustomisationHeader.cs b/osu.Game/Overlays/Mods/ModCustomisationHeader.cs new file mode 100644 index 0000000000..bf10e13515 --- /dev/null +++ b/osu.Game/Overlays/Mods/ModCustomisationHeader.cs @@ -0,0 +1,95 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osuTK; +using osu.Game.Localisation; + +namespace osu.Game.Overlays.Mods +{ + public partial class ModCustomisationHeader : OsuHoverContainer + { + private Box background = null!; + private SpriteIcon icon = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + protected override IEnumerable EffectTargets => new[] { background }; + + public readonly BindableBool Expanded = new BindableBool(); + + public ModCustomisationHeader() + { + Action = Expanded.Toggle; + Enabled.Value = false; + } + + [BackgroundDependencyLoader] + private void load() + { + CornerRadius = 10f; + Masking = true; + + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + }, + new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Text = ModSelectOverlayStrings.CustomisationPanelHeader, + UseFullGlyphHeight = false, + Font = OsuFont.Torus.With(size: 20f, weight: FontWeight.SemiBold), + Margin = new MarginPadding { Left = 20f }, + }, + new Container + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Size = new Vector2(16f), + Margin = new MarginPadding { Right = 20f }, + Child = icon = new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Icon = FontAwesome.Solid.ChevronDown, + RelativeSizeAxes = Axes.Both, + } + } + }; + + IdleColour = colourProvider.Dark3; + HoverColour = colourProvider.Light4; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Enabled.BindValueChanged(e => + { + TooltipText = e.NewValue + ? string.Empty + : ModSelectOverlayStrings.CustomisationPanelDisabledReason; + }, true); + + Expanded.BindValueChanged(v => + { + icon.ScaleTo(v.NewValue ? new Vector2(1, -1) : Vector2.One, 300, Easing.OutQuint); + }, true); + } + } +} diff --git a/osu.Game/Overlays/Mods/ModCustomisationPanel.cs b/osu.Game/Overlays/Mods/ModCustomisationPanel.cs new file mode 100644 index 0000000000..a1e64e8c49 --- /dev/null +++ b/osu.Game/Overlays/Mods/ModCustomisationPanel.cs @@ -0,0 +1,211 @@ +// Copyright (c) ppy Pty Ltd . 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 osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Bindings; +using osu.Framework.Input.Events; +using osu.Game.Configuration; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Input.Bindings; +using osu.Game.Rulesets.Mods; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Overlays.Mods +{ + public partial class ModCustomisationPanel : OverlayContainer, IKeyBindingHandler + { + private const float header_height = 42f; + private const float content_vertical_padding = 20f; + private const float content_border_thickness = 2f; + + private Container content = null!; + private OsuScrollContainer scrollContainer = null!; + private FillFlowContainer sectionsFlow = null!; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + public readonly BindableBool Enabled = new BindableBool(); + + public readonly BindableBool Expanded = new BindableBool(); + + public Bindable> SelectedMods { get; } = new Bindable>(Array.Empty()); + + public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; + + // Handle{Non}PositionalInput controls whether the panel should act as a blocking layer on the screen. only block when the panel is expanded. + // These properties are used because they correctly handle blocking/unblocking hover when mouse is pointing at a drawable outside + // (returning Expanded.Value to OnHover or overriding Block{Non}PositionalInput doesn't work). + public override bool HandlePositionalInput => Expanded.Value; + public override bool HandleNonPositionalInput => Expanded.Value; + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.Y; + + InternalChildren = new Drawable[] + { + new ModCustomisationHeader + { + Depth = float.MinValue, + RelativeSizeAxes = Axes.X, + Height = header_height, + Enabled = { BindTarget = Enabled }, + Expanded = { BindTarget = Expanded }, + }, + content = new FocusGrabbingContainer + { + RelativeSizeAxes = Axes.X, + BorderColour = colourProvider.Dark3, + BorderThickness = content_border_thickness, + CornerRadius = 10f, + Masking = true, + EdgeEffect = new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Offset = new Vector2(0f, 5f), + Radius = 20f, + Roundness = 5f, + Colour = Color4.Black.Opacity(0.25f), + }, + Expanded = { BindTarget = Expanded }, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Dark4, + }, + scrollContainer = new OsuScrollContainer(Direction.Vertical) + { + RelativeSizeAxes = Axes.X, + Margin = new MarginPadding + { + Top = header_height + content_border_thickness, + Bottom = content_border_thickness + }, + Child = sectionsFlow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(0f, 40f), + Margin = new MarginPadding + { + Top = content_vertical_padding, + Bottom = 5f + content_vertical_padding + }, + } + } + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Enabled.BindValueChanged(e => + { + this.FadeColour(OsuColour.Gray(e.NewValue ? 1f : 0.6f), 300, Easing.OutQuint); + }, true); + + Expanded.BindValueChanged(_ => updateDisplay(), true); + SelectedMods.BindValueChanged(_ => updateMods(), true); + + FinishTransforms(true); + } + + protected override void PopIn() => this.FadeIn(300, Easing.OutQuint); + + protected override void PopOut() => this.FadeOut(300, Easing.OutQuint); + + protected override bool OnClick(ClickEvent e) + { + Expanded.Value = false; + return base.OnClick(e); + } + + protected override bool OnKeyDown(KeyDownEvent e) => true; + + protected override bool OnScroll(ScrollEvent e) => true; + + public bool OnPressed(KeyBindingPressEvent e) + { + switch (e.Action) + { + case GlobalAction.Back: + Expanded.Value = false; + return true; + } + + return false; + } + + public void OnReleased(KeyBindingReleaseEvent e) + { + } + + private void updateDisplay() + { + content.ClearTransforms(); + + if (Expanded.Value) + { + content.AutoSizeDuration = 400; + content.AutoSizeEasing = Easing.OutQuint; + content.AutoSizeAxes = Axes.Y; + content.FadeIn(120, Easing.OutQuint); + } + else + { + content.AutoSizeAxes = Axes.None; + content.ResizeHeightTo(header_height, 400, Easing.OutQuint); + content.FadeOut(400, Easing.OutSine); + } + } + + private void updateMods() + { + Expanded.Value = false; + sectionsFlow.Clear(); + + // Importantly, the selected mods bindable is already ordered by the mod select overlay (following the order of mod columns and panels). + // Using AsOrdered produces a slightly different order (e.g. DT and NC no longer becoming adjacent), + // which breaks user expectations when interacting with the overlay. + foreach (var mod in SelectedMods.Value) + { + var settings = mod.CreateSettingsControls().ToList(); + + if (settings.Count > 0) + sectionsFlow.Add(new ModCustomisationSection(mod, settings)); + } + } + + protected override void Update() + { + base.Update(); + scrollContainer.Height = Math.Min(scrollContainer.AvailableContent, DrawHeight - header_height); + } + + private partial class FocusGrabbingContainer : InputBlockingContainer + { + public IBindable Expanded { get; } = new BindableBool(); + + public override bool RequestsFocus => Expanded.Value; + public override bool AcceptsFocus => Expanded.Value; + } + } +} diff --git a/osu.Game/Overlays/Mods/ModCustomisationSection.cs b/osu.Game/Overlays/Mods/ModCustomisationSection.cs new file mode 100644 index 0000000000..1dc97a8b0b --- /dev/null +++ b/osu.Game/Overlays/Mods/ModCustomisationSection.cs @@ -0,0 +1,82 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.UI; +using osuTK; + +namespace osu.Game.Overlays.Mods +{ + public partial class ModCustomisationSection : CompositeDrawable + { + public readonly Mod Mod; + + private readonly IReadOnlyList settings; + + public ModCustomisationSection(Mod mod, IReadOnlyList settings) + { + Mod = mod; + + this.settings = settings; + } + + [BackgroundDependencyLoader] + private void load() + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + FillFlowContainer flow; + + InternalChild = flow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0f, 8f), + Padding = new MarginPadding { Left = 7f }, + Children = new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Left = 20f, Right = 27f }, + Margin = new MarginPadding { Bottom = 4f }, + Children = new Drawable[] + { + new OsuSpriteText + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Text = Mod.Name, + Font = OsuFont.TorusAlternate.With(size: 20, weight: FontWeight.SemiBold), + }, + new ModSwitchTiny(Mod) + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + Active = { Value = true }, + Scale = new Vector2(0.5f), + } + } + }, + } + }; + + flow.AddRange(settings); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + FinishTransforms(true); + } + } +} diff --git a/osu.Game/Overlays/Mods/ModSelectOverlay.cs b/osu.Game/Overlays/Mods/ModSelectOverlay.cs index 145f58fb55..d5a4d27237 100644 --- a/osu.Game/Overlays/Mods/ModSelectOverlay.cs +++ b/osu.Game/Overlays/Mods/ModSelectOverlay.cs @@ -29,6 +29,7 @@ using osu.Game.Localisation; using osu.Game.Rulesets.Mods; using osu.Game.Utils; using osuTK; +using osuTK.Graphics; using osuTK.Input; namespace osu.Game.Overlays.Mods @@ -109,15 +110,6 @@ namespace osu.Game.Overlays.Mods protected virtual IEnumerable CreateFooterButtons() { - if (AllowCustomisation) - { - yield return CustomisationButton = new ShearedToggleButton(BUTTON_WIDTH) - { - Text = ModSelectOverlayStrings.ModCustomisation, - Active = { BindTarget = customisationVisible } - }; - } - yield return deselectAllModsButton = new DeselectAllModsButton(this); } @@ -125,10 +117,8 @@ namespace osu.Game.Overlays.Mods public IEnumerable AllAvailableMods => AvailableMods.Value.SelectMany(pair => pair.Value); - private readonly BindableBool customisationVisible = new BindableBool(); private Bindable textSearchStartsActive = null!; - private ModSettingsArea modSettingsArea = null!; private ColumnScrollContainer columnScroll = null!; private ColumnFlowContainer columnFlow = null!; private FillFlowContainer footerButtonFlow = null!; @@ -138,9 +128,9 @@ namespace osu.Game.Overlays.Mods private Container aboveColumnsContent = null!; private RankingInformationDisplay? rankingInformationDisplay; private BeatmapAttributesDisplay? beatmapAttributesDisplay; + private ModCustomisationPanel customisationPanel = null!; protected ShearedButton BackButton { get; private set; } = null!; - protected ShearedToggleButton? CustomisationButton { get; private set; } protected SelectAllModsButton? SelectAllModsButton { get; set; } private Sample? columnAppearSample; @@ -173,70 +163,67 @@ namespace osu.Game.Overlays.Mods columnAppearSample = audio.Samples.Get(@"SongSelect/mod-column-pop-in"); - AddRange(new Drawable[] + MainAreaContent.Add(new OsuContextMenuContainer { - new ClickToReturnContainer + RelativeSizeAxes = Axes.Both, + Child = new PopoverContainer { RelativeSizeAxes = Axes.Both, - HandleMouse = { BindTarget = customisationVisible }, - OnClicked = () => customisationVisible.Value = false - }, - modSettingsArea = new ModSettingsArea - { - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - Height = 0 - }, - }); - - MainAreaContent.AddRange(new Drawable[] - { - aboveColumnsContent = new Container - { - RelativeSizeAxes = Axes.X, - Height = RankingInformationDisplay.HEIGHT, - Padding = new MarginPadding { Horizontal = 100 }, - Child = SearchTextBox = new ShearedSearchTextBox + Children = new Drawable[] { - HoldFocus = false, - Width = 300 - } - }, - new OsuContextMenuContainer - { - RelativeSizeAxes = Axes.Both, - Child = new PopoverContainer - { - Padding = new MarginPadding + new Container { - Top = RankingInformationDisplay.HEIGHT + PADDING, - Bottom = PADDING - }, - RelativeSizeAxes = Axes.Both, - RelativePositionAxes = Axes.Both, - Children = new Drawable[] - { - columnScroll = new ColumnScrollContainer + Padding = new MarginPadding { - RelativeSizeAxes = Axes.Both, - Masking = false, - ClampExtension = 100, - ScrollbarOverlapsContent = false, - Child = columnFlow = new ColumnFlowContainer + Top = RankingInformationDisplay.HEIGHT + PADDING, + Bottom = PADDING + }, + RelativeSizeAxes = Axes.Both, + RelativePositionAxes = Axes.Both, + Children = new Drawable[] + { + columnScroll = new ColumnScrollContainer { - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Direction = FillDirection.Horizontal, - Shear = new Vector2(OsuGame.SHEAR, 0), - RelativeSizeAxes = Axes.Y, - AutoSizeAxes = Axes.X, - Margin = new MarginPadding { Horizontal = 70 }, - Padding = new MarginPadding { Bottom = 10 }, - ChildrenEnumerable = createColumns() + RelativeSizeAxes = Axes.Both, + Masking = false, + ClampExtension = 100, + ScrollbarOverlapsContent = false, + Child = columnFlow = new ColumnFlowContainer + { + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Direction = FillDirection.Horizontal, + Shear = new Vector2(OsuGame.SHEAR, 0), + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Margin = new MarginPadding { Horizontal = 70 }, + Padding = new MarginPadding { Bottom = 10 }, + ChildrenEnumerable = createColumns() + } + } + } + }, + aboveColumnsContent = new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Horizontal = 100, Bottom = 15f }, + Children = new Drawable[] + { + SearchTextBox = new ShearedSearchTextBox + { + HoldFocus = false, + Width = 300, + }, + customisationPanel = new ModCustomisationPanel + { + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Width = 400, + State = { Value = Visibility.Visible }, } } } - } + }, } }); @@ -320,7 +307,7 @@ namespace osu.Game.Overlays.Mods // This is an optimisation to prevent refreshing the available settings controls when it can be // reasonably assumed that the settings panel is never to be displayed (e.g. FreeModSelectOverlay). if (AllowCustomisation) - ((IBindable>)modSettingsArea.SelectedMods).BindTo(SelectedMods); + ((IBindable>)customisationPanel.SelectedMods).BindTo(SelectedMods); SelectedMods.BindValueChanged(_ => { @@ -347,7 +334,7 @@ namespace osu.Game.Overlays.Mods } }, true); - customisationVisible.BindValueChanged(_ => updateCustomisationVisualState(), true); + customisationPanel.Expanded.BindValueChanged(_ => updateCustomisationVisualState(), true); SearchTextBox.Current.BindValueChanged(query => { @@ -390,6 +377,7 @@ namespace osu.Game.Overlays.Mods footerContentFlow.LayoutDuration = 200; footerContentFlow.LayoutEasing = Easing.OutQuint; footerContentFlow.Direction = screenIsntWideEnough ? FillDirection.Vertical : FillDirection.Horizontal; + aboveColumnsContent.Padding = aboveColumnsContent.Padding with { Bottom = screenIsntWideEnough ? 70f : 15f }; } } @@ -491,7 +479,7 @@ namespace osu.Game.Overlays.Mods private void updateCustomisation() { - if (CustomisationButton == null) + if (!AllowCustomisation) return; bool anyCustomisableModActive = false; @@ -506,41 +494,32 @@ namespace osu.Game.Overlays.Mods if (anyCustomisableModActive) { - customisationVisible.Disabled = false; + customisationPanel.Enabled.Value = true; - if (anyModPendingConfiguration && !customisationVisible.Value) - customisationVisible.Value = true; + if (anyModPendingConfiguration) + customisationPanel.Expanded.Value = true; } else { - if (customisationVisible.Value) - customisationVisible.Value = false; - - customisationVisible.Disabled = true; + customisationPanel.Expanded.Value = false; + customisationPanel.Enabled.Value = false; } } private void updateCustomisationVisualState() { - const double transition_duration = 300; - - MainAreaContent.FadeColour(customisationVisible.Value ? Colour4.Gray : Colour4.White, transition_duration, Easing.InOutCubic); - - foreach (var button in footerButtonFlow) + if (customisationPanel.Expanded.Value) { - if (button != CustomisationButton) - button.Enabled.Value = !customisationVisible.Value; - } - - float modAreaHeight = customisationVisible.Value ? ModSettingsArea.HEIGHT : 0; - - modSettingsArea.ResizeHeightTo(modAreaHeight, transition_duration, Easing.InOutCubic); - TopLevelContent.MoveToY(-modAreaHeight, transition_duration, Easing.InOutCubic); - - if (customisationVisible.Value) + columnScroll.FadeColour(OsuColour.Gray(0.5f), 400, Easing.OutQuint); + SearchTextBox.FadeColour(OsuColour.Gray(0.5f), 400, Easing.OutQuint); SearchTextBox.KillFocus(); + } else + { + columnScroll.FadeColour(Color4.White, 400, Easing.OutQuint); + SearchTextBox.FadeColour(Color4.White, 400, Easing.OutQuint); setTextBoxFocus(textSearchStartsActive.Value); + } } /// @@ -693,6 +672,8 @@ namespace osu.Game.Overlays.Mods if (!allFiltered) nonFilteredColumnCount += 1; } + + customisationPanel.Expanded.Value = false; } #endregion @@ -706,16 +687,12 @@ namespace osu.Game.Overlays.Mods switch (e.Action) { + // If the customisation panel is expanded, the back action will be handled by it first. case GlobalAction.Back: - // Pressing the back binding should only go back one step at a time. - hideOverlay(false); - return true; - // This is handled locally here because this overlay is being registered at the game level // and therefore takes away keyboard focus from the screen stack. case GlobalAction.ToggleModSelection: - // Pressing toggle should completely hide the overlay in one shot. - hideOverlay(true); + hideOverlay(); return true; // This is handled locally here due to conflicts in input handling between the search text box and the deselect all mods button. @@ -723,7 +700,7 @@ namespace osu.Game.Overlays.Mods // wherein activating the binding will both change the contents of the search text box and deselect all mods. case GlobalAction.DeselectAllMods: { - if (!SearchTextBox.HasFocus) + if (!SearchTextBox.HasFocus && !customisationPanel.Expanded.Value) { deselectAllModsButton.TriggerClick(); return true; @@ -738,7 +715,7 @@ namespace osu.Game.Overlays.Mods // If there is no search in progress, it should exit the dialog (a bit weird, but this is the expectation from stable). if (string.IsNullOrEmpty(SearchTerm)) { - hideOverlay(true); + hideOverlay(); return true; } @@ -756,19 +733,7 @@ namespace osu.Game.Overlays.Mods return base.OnPressed(e); - void hideOverlay(bool immediate) - { - if (customisationVisible.Value) - { - Debug.Assert(CustomisationButton != null); - CustomisationButton.TriggerClick(); - - if (!immediate) - return; - } - - BackButton.TriggerClick(); - } + void hideOverlay() => BackButton.TriggerClick(); } /// @@ -795,6 +760,9 @@ namespace osu.Game.Overlays.Mods if (e.Repeat || e.Key != Key.Tab) return false; + if (customisationPanel.Expanded.Value) + return true; + // TODO: should probably eventually support typical platform search shortcuts (`Ctrl-F`, `/`) setTextBoxFocus(!SearchTextBox.HasFocus); return true; @@ -967,38 +935,5 @@ namespace osu.Game.Overlays.Mods updateState(); } } - - /// - /// A container which blocks and handles input, managing the "return from customisation" state change. - /// - private partial class ClickToReturnContainer : Container - { - public BindableBool HandleMouse { get; } = new BindableBool(); - - public Action? OnClicked { get; set; } - - public override bool HandlePositionalInput => base.HandlePositionalInput && HandleMouse.Value; - - protected override bool Handle(UIEvent e) - { - if (!HandleMouse.Value) - return base.Handle(e); - - switch (e) - { - case ClickEvent: - OnClicked?.Invoke(); - return true; - - case HoverEvent: - return false; - - case MouseEvent: - return true; - } - - return base.Handle(e); - } - } } } diff --git a/osu.Game/Overlays/Mods/ModSettingsArea.cs b/osu.Game/Overlays/Mods/ModSettingsArea.cs deleted file mode 100644 index d0e0f7e648..0000000000 --- a/osu.Game/Overlays/Mods/ModSettingsArea.cs +++ /dev/null @@ -1,189 +0,0 @@ -// Copyright (c) ppy Pty Ltd . 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 osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Input.Events; -using osu.Game.Configuration; -using osu.Game.Graphics; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.Sprites; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.UI; -using osuTK; - -namespace osu.Game.Overlays.Mods -{ - public partial class ModSettingsArea : CompositeDrawable - { - public Bindable> SelectedMods { get; } = new Bindable>(Array.Empty()); - - public const float HEIGHT = 250; - - private readonly Box background; - private readonly FillFlowContainer modSettingsFlow; - - [Resolved] - private OverlayColourProvider colourProvider { get; set; } = null!; - - public override bool AcceptsFocus => true; - - public ModSettingsArea() - { - RelativeSizeAxes = Axes.X; - Height = HEIGHT; - - Anchor = Anchor.BottomRight; - Origin = Anchor.BottomRight; - - InternalChild = new Container - { - RelativeSizeAxes = Axes.Both, - Masking = true, - Children = new Drawable[] - { - background = new Box - { - RelativeSizeAxes = Axes.Both - }, - new OsuScrollContainer(Direction.Horizontal) - { - RelativeSizeAxes = Axes.Both, - ScrollbarOverlapsContent = false, - ClampExtension = 100, - Child = modSettingsFlow = new FillFlowContainer - { - AutoSizeAxes = Axes.X, - RelativeSizeAxes = Axes.Y, - Padding = new MarginPadding { Vertical = 7, Horizontal = 70 }, - Spacing = new Vector2(7), - Direction = FillDirection.Horizontal - } - } - } - }; - } - - [BackgroundDependencyLoader] - private void load() - { - background.Colour = colourProvider.Dark3; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - SelectedMods.BindValueChanged(_ => updateMods(), true); - } - - private void updateMods() - { - modSettingsFlow.Clear(); - - // Importantly, the selected mods bindable is already ordered by the mod select overlay (following the order of mod columns and panels). - // Using AsOrdered produces a slightly different order (e.g. DT and NC no longer becoming adjacent), - // which breaks user expectations when interacting with the overlay. - foreach (var mod in SelectedMods.Value) - { - var settings = mod.CreateSettingsControls().ToList(); - - if (settings.Count > 0) - { - if (modSettingsFlow.Any()) - { - modSettingsFlow.Add(new Box - { - RelativeSizeAxes = Axes.Y, - Width = 2, - Colour = colourProvider.Dark4, - }); - } - - modSettingsFlow.Add(new ModSettingsColumn(mod, settings)); - } - } - } - - protected override bool OnMouseDown(MouseDownEvent e) => true; - protected override bool OnHover(HoverEvent e) => true; - - public partial class ModSettingsColumn : CompositeDrawable - { - public readonly Mod Mod; - - public ModSettingsColumn(Mod mod, IEnumerable settingsControls) - { - Mod = mod; - - Width = 250; - RelativeSizeAxes = Axes.Y; - Padding = new MarginPadding { Bottom = 7 }; - - InternalChild = new GridContainer - { - RelativeSizeAxes = Axes.Both, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize), - new Dimension(GridSizeMode.Absolute, 10), - new Dimension() - }, - Content = new[] - { - new Drawable[] - { - new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(7), - Children = new Drawable[] - { - new ModSwitchTiny(mod) - { - Active = { Value = true }, - Scale = new Vector2(0.6f), - Origin = Anchor.CentreLeft, - Anchor = Anchor.CentreLeft - }, - new OsuSpriteText - { - Text = mod.Name, - Font = OsuFont.Default.With(size: 16, weight: FontWeight.SemiBold), - Origin = Anchor.CentreLeft, - Anchor = Anchor.CentreLeft, - Margin = new MarginPadding { Bottom = 2 } - } - } - } - }, - new[] { Empty() }, - new Drawable[] - { - new OsuScrollContainer(Direction.Vertical) - { - RelativeSizeAxes = Axes.Both, - ClampExtension = 100, - Child = new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Padding = new MarginPadding { Right = 7 }, - ChildrenEnumerable = settingsControls, - Spacing = new Vector2(0, 7) - } - } - } - } - }; - } - } - } -}