diff --git a/osu.Framework.Tests/Visual/UserInterface/TestSceneDropdown.cs b/osu.Framework.Tests/Visual/UserInterface/TestSceneDropdown.cs index c1d247b409..c1827d8fb1 100644 --- a/osu.Framework.Tests/Visual/UserInterface/TestSceneDropdown.cs +++ b/osu.Framework.Tests/Visual/UserInterface/TestSceneDropdown.cs @@ -12,6 +12,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Primitives; +using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input; @@ -21,6 +22,7 @@ using osu.Framework.Testing; using osu.Framework.Testing.Input; using osuTK; +using osuTK.Graphics; using osuTK.Input; namespace osu.Framework.Tests.Visual.UserInterface @@ -29,16 +31,32 @@ public partial class TestSceneDropdown : ManualInputManagerTestScene { private const int items_to_add = 10; - [Test] - public void TestBasic() + [TestCase(false)] + [TestCase(true)] + public void TestToggleOnMouseDown(bool toggleOnMouseDown) { AddStep("setup dropdowns", () => { TestDropdown[] dropdowns = createDropdowns(2); + dropdowns.ForEach(dropdown => dropdown.ToggleOnMouseDown = toggleOnMouseDown); dropdowns[1].AlwaysShowSearchBar = true; }); } + [Test] + public void TestInsideScrollContainer() + { + TestDropdown[] dropdowns = []; + + AddStep("setup dropdowns", () => + { + dropdowns = createDropdownInScrollableParentScene(); + }); + + AddAssert("dropdown in scrollable has ToggleOnMouseDown set to false", () => dropdowns.ElementAt(0).ToggleOnMouseDown == false); + AddAssert("dropdown in non-scrollable has ToggleOnMouseDown set to true", () => dropdowns.ElementAt(1).ToggleOnMouseDown); + } + [Test] public void TestSelectByUserInteraction() { @@ -64,6 +82,64 @@ public void TestSelectByUserInteraction() .Item as DropdownMenuItem)?.Value?.Identifier == "test 2"); } + [Test] + public void TestSelectByUserPressAndRelease() + { + TestDropdown testDropdown = null!; + + AddStep("setup dropdown", () => + { + testDropdown = createDropdown(); + testDropdown.ToggleOnMouseDown = true; + }); + + toggleDropdownViaPress(() => testDropdown); + assertDropdownIsOpen(() => testDropdown); + + AddStep("release on item 2", () => + { + InputManager.MoveMouseTo(testDropdown.Menu.Children[2]); + InputManager.ReleaseButton(MouseButton.Left); + }); + + assertDropdownIsClosed(() => testDropdown); + + AddAssert("item 2 is selected", () => testDropdown.Current.Value?.Equals(testDropdown.Items.ElementAt(2)) == true); + AddAssert("item 2 is selected item", () => testDropdown.SelectedItem.Value?.Identifier == "test 2"); + AddAssert("item 2 is visually selected", () => (testDropdown.ChildrenOfType.DropdownMenu.DrawableDropdownMenuItem>() + .SingleOrDefault(i => i.IsSelected)? + .Item as DropdownMenuItem)?.Value?.Identifier == "test 2"); + } + + [Test] + public void TestUserPressAndReleaseOutsideMenu() + { + TestDropdown testDropdown = null!; + + AddStep("setup dropdown", () => + { + testDropdown = createDropdown(); + testDropdown.ToggleOnMouseDown = true; + }); + + toggleDropdownViaPress(() => testDropdown); + assertDropdownIsOpen(() => testDropdown); + + AddStep("preselect item 2", () => + InputManager.MoveMouseTo(testDropdown.Menu.Children[2]) + ); + AddStep("move outside the menu", () => + InputManager.MoveMouseTo(InputManager.ScreenSpaceDrawQuad.Centre) + ); + AddStep("release mouse buttons", () => + InputManager.ReleaseButton(MouseButton.Left) + ); + + assertDropdownIsClosed(() => testDropdown); + + AddAssert("item 2 is not selected", () => testDropdown.Current.Value?.Equals(testDropdown.Items.ElementAt(2)) == false); + } + [Test] public void TestSelectByCurrent() { @@ -709,8 +785,9 @@ public void TestMouseFromTouch() #endregion - [Test] - public void TestPaddedSearchBar() + [TestCase(false)] + [TestCase(true)] + public void TestPaddedSearchBar(bool toggleOnMouseDown) { SearchBarPaddedDropdown dropdown = null!; @@ -718,6 +795,7 @@ public void TestPaddedSearchBar() { Child = dropdown = new SearchBarPaddedDropdown { + ToggleOnMouseDown = toggleOnMouseDown, Position = new Vector2(50f, 50f), Width = 150f, Items = new TestModel("test").Yield(), @@ -788,6 +866,7 @@ private TDropdown[] createDropdowns(int count) Position = new Vector2(50f, 50f), Width = 150, Items = testItems, + ToggleOnMouseDown = false, }; } @@ -803,12 +882,74 @@ private TDropdown[] createDropdowns(int count) return dropdowns; } + private TDropdown[] createDropdownInScrollableParentScene() + where TDropdown : TestDropdown, new() + { + var dropdowns = new TDropdown[2]; + var testItems = new TestModel[10]; + for (int itemIndex = 0; itemIndex < items_to_add; itemIndex++) + testItems[itemIndex] = "test " + itemIndex; + + for (int i = 0; i < dropdowns.Length; i++) + { + dropdowns[i] = new TDropdown + { + Width = 150f, + Position = new Vector2(50f, 50f), + Items = testItems, + }; + } + + var scrollable = createPanel(Color4.DarkRed, new BasicScrollContainer + { + RelativeSizeAxes = Axes.Both, + Child = dropdowns[0], + }); + + var nonScrollable = createPanel(Color4.DarkBlue, dropdowns[1]); + + Child = new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Children = + [ + scrollable, + nonScrollable, + ] + }; + + return dropdowns; + + Container createPanel(Color4 colour, Drawable child) + => new Container + { + RelativeSizeAxes = Axes.Both, + Width = 0.5f, + Children = + [ + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colour, + }, + child, + ] + }; + } + private void toggleDropdownViaClick(Func dropdown, string? dropdownName = null) => AddStep($"click {dropdownName ?? "dropdown"}", () => { InputManager.MoveMouseTo(dropdown().Header); InputManager.Click(MouseButton.Left); }); + private void toggleDropdownViaPress(Func dropdown, string? dropdownName = null) => AddStep($"press {dropdownName ?? "dropdown"}", () => + { + InputManager.MoveMouseTo(dropdown().Header); + InputManager.PressButton(MouseButton.Left); + }); + private void assertDropdownIsOpen(Func dropdown) => AddAssert("dropdown is open", () => dropdown().Menu.State == MenuState.Open); private void assertDropdownIsClosed(Func dropdown) => AddAssert("dropdown is closed", () => dropdown().Menu.State == MenuState.Closed); diff --git a/osu.Framework/Graphics/UserInterface/Dropdown.cs b/osu.Framework/Graphics/UserInterface/Dropdown.cs index 5a17242c4c..7df4f2656a 100644 --- a/osu.Framework/Graphics/UserInterface/Dropdown.cs +++ b/osu.Framework/Graphics/UserInterface/Dropdown.cs @@ -44,6 +44,15 @@ public bool AlwaysShowSearchBar set => Header.AlwaysShowSearchBar = value; } + /// + /// Whether this should open/close on OnMouseDown event. + /// + public bool ToggleOnMouseDown + { + get => Header.ToggleOnMouseDown; + set => Header.ToggleOnMouseDown = value; + } + public bool AllowNonContiguousMatching { get => Menu.AllowNonContiguousMatching; @@ -202,7 +211,7 @@ protected virtual LocalisableString GenerateItemText(T item) /// /// Puts the state of this one level back: /// - If the dropdown search bar contains text, this method will reset it. - /// - If the dropdown is open, this method wil close it. + /// - If the dropdown is open, this method will close it. /// public bool Back() { @@ -340,6 +349,23 @@ protected override bool OnKeyDown(KeyDownEvent e) return false; } + protected override void OnMouseUp(MouseUpEvent e) + { + // Only proceed with the flag + if (!ToggleOnMouseDown) + return; + + // Do not close the menu if we are releasing on DropdownHeader + if (Header.IsHovered) + return; + + // Cursor is inside the menu and possibly selecting an item + if (Menu.IsHovered) + ((IDropdown)this).CommitPreselection(); + + Menu.Close(); + } + private void collectionChanged(object sender, NotifyCollectionChangedEventArgs e) { switch (e.Action) diff --git a/osu.Framework/Graphics/UserInterface/DropdownHeader.cs b/osu.Framework/Graphics/UserInterface/DropdownHeader.cs index 8a151c0910..fec2971a30 100644 --- a/osu.Framework/Graphics/UserInterface/DropdownHeader.cs +++ b/osu.Framework/Graphics/UserInterface/DropdownHeader.cs @@ -30,6 +30,22 @@ public bool AlwaysShowSearchBar set => SearchBar.AlwaysDisplayOnFocus = value; } + /// + /// Whether parent dropdown should open/close on OnMouseDown event. + /// + /// If not explicitly set, the value will be resolved to true + /// if is not found in the parent tree. + /// + public bool ToggleOnMouseDown + { + get => toggleOnMouseDownOverride ?? resolvedToggleOnMouseDown; + set => toggleOnMouseDownOverride = value; + } + + private bool? toggleOnMouseDownOverride; + + private bool resolvedToggleOnMouseDown; + protected internal DropdownSearchBar SearchBar { get; } public Bindable SearchTerm => SearchBar.SearchTerm; @@ -95,14 +111,14 @@ protected DropdownHeader() Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y + AutoSizeAxes = Axes.Y, }, SearchBar = CreateSearchBar(), - new ClickHandler + new UIEventHandler { RelativeSizeAxes = Axes.Both, - Click = onClick - } + UIEventHandle = handleUIEvent + }, }; } @@ -112,6 +128,10 @@ protected override void LoadComplete() { base.LoadComplete(); + // Make dropdown toggleable on MouseDown event when inside a non-scrollable container + if (toggleOnMouseDownOverride == null) + resolvedToggleOnMouseDown = this.FindClosestParent() == null; + Enabled.BindTo(dropdown.Enabled); Enabled.BindValueChanged(_ => updateState(), true); } @@ -135,19 +155,70 @@ private void updateState() } /// - /// Handles clicks on the header to open/close the menu. + /// Handles clicks and mouse events on the header to open/close the menu. /// - private bool onClick(ClickEvent e) + private bool handleUIEvent(UIEvent e) { // Allow input to fall through to the search bar (and its contained textbox) if there's any search text. if (SearchBar.State.Value == Visibility.Visible && !string.IsNullOrEmpty(SearchTerm.Value)) return false; + switch (e) + { + case MouseDownEvent mouseDown: + return onMouseDown(mouseDown); + + case ClickEvent click: + return onClick(click); + + default: + return false; + } + } + + /// + /// Handles clicks on the header to open/close the menu. + /// + private bool onClick(ClickEvent e) + { + // No need to handle dropdown as with this flag it has already been toggled by `onMouseDown` handler + if (ToggleOnMouseDown) + { + // UIEventHandler grows in the parent container, so there might be a situation + // when dropdown is opened by clicking outside `SearchBar.textBox`, + // which will lose focus and, therefore, close dropdown. + // To prevent that, restore focus manually. + if (dropdown.MenuState == MenuState.Open) + SearchBar.ObtainFocus(); + + return false; + } + // Otherwise, the header acts as a button to show/hide the menu. dropdown.ToggleMenu(); return true; } + /// + /// Handles mouse presses on the header to open/close the menu. + /// + private bool onMouseDown(MouseDownEvent e) + { + // Only proceed with the flag + if (!ToggleOnMouseDown) + return false; + + // Only allow dropdown to toggle when pressing primary mouse button + if (e.Button != MouseButton.Left) + return false; + + // Otherwise, the header acts as a button to show/hide the menu. + dropdown.ToggleMenu(); + + // And importantly, when the menu is closed as a result of the above toggle, block the search bar from receiving input. + return dropdown.MenuState == MenuState.Closed; + } + public override bool HandleNonPositionalInput => IsHovered; protected override bool OnKeyDown(KeyDownEvent e) @@ -204,10 +275,11 @@ public enum DropdownSelectionAction LastVisible } - private partial class ClickHandler : Drawable + private partial class UIEventHandler : Drawable { - public required Func Click { get; init; } - protected override bool OnClick(ClickEvent e) => Click(e); + public required Func UIEventHandle { get; init; } + + protected override bool Handle(UIEvent e) => UIEventHandle(e); } } } diff --git a/osu.Framework/Graphics/UserInterface/DropdownSearchBar.cs b/osu.Framework/Graphics/UserInterface/DropdownSearchBar.cs index a23df45f95..9ae9eccfbe 100644 --- a/osu.Framework/Graphics/UserInterface/DropdownSearchBar.cs +++ b/osu.Framework/Graphics/UserInterface/DropdownSearchBar.cs @@ -101,6 +101,25 @@ public bool Back() return true; } + /// + /// Focuses the textbox and opens parent as a result. + /// + public bool ObtainFocus() + { + return dropdown.ChangeFocus(textBox); + } + + /// + /// Clears current search and removes focus from the textbox, + /// closing parent as a result. + /// + public bool ReleaseFocus() + { + // Reset states when the menu is closed by any means. + SearchTerm.Value = string.Empty; + return textBox.HasFocus && dropdown.ChangeFocus(null); + } + /// /// Opens or closes the menu depending on whether the textbox is focused. /// @@ -136,17 +155,9 @@ private void onTextBoxCommit(TextBox sender, bool newText) private void onMenuStateChanged(MenuState state) { if (state == MenuState.Closed) - { - // Reset states when the menu is closed by any means. - SearchTerm.Value = string.Empty; - - if (textBox.HasFocus) - dropdown.ChangeFocus(null); - - dropdown.CloseMenu(); - } + ReleaseFocus(); else - dropdown.ChangeFocus(textBox); + ObtainFocus(); updateTextBoxVisibility(); }