diff --git a/osu.Framework.Tests/Visual/Layout/TestSceneFillFlowContainer.cs b/osu.Framework.Tests/Visual/Layout/TestSceneFillFlowContainer.cs index 659eb55abe..a536412ead 100644 --- a/osu.Framework.Tests/Visual/Layout/TestSceneFillFlowContainer.cs +++ b/osu.Framework.Tests/Visual/Layout/TestSceneFillFlowContainer.cs @@ -397,6 +397,17 @@ public TestSceneDropdownHeader() label = new SpriteText(), }; } + + protected override DropdownSearchBar CreateSearchBar() => new BasicDropdownSearchBar(); + + private partial class BasicDropdownSearchBar : DropdownSearchBar + { + protected override void PopIn() => this.FadeIn(); + + protected override void PopOut() => this.FadeOut(); + + protected override TextBox CreateTextBox() => new BasicTextBox(); + } } private partial class AnchorDropdown : BasicDropdown diff --git a/osu.Framework.Tests/Visual/UserInterface/TestSceneDropdown.cs b/osu.Framework.Tests/Visual/UserInterface/TestSceneDropdown.cs index d36281e1cd..1c7d75a780 100644 --- a/osu.Framework.Tests/Visual/UserInterface/TestSceneDropdown.cs +++ b/osu.Framework.Tests/Visual/UserInterface/TestSceneDropdown.cs @@ -15,6 +15,7 @@ using osu.Framework.Input; using osu.Framework.Localisation; using osu.Framework.Testing; +using osu.Framework.Testing.Input; using osuTK; using osuTK.Input; @@ -24,6 +25,16 @@ public partial class TestSceneDropdown : ManualInputManagerTestScene { private const int items_to_add = 10; + [Test] + public void TestBasic() + { + AddStep("setup dropdowns", () => + { + TestDropdown[] dropdowns = createDropdowns(2); + dropdowns[1].AlwaysShowSearchBar = true; + }); + } + [Test] public void TestSelectByUserInteraction() { @@ -143,10 +154,10 @@ public void TestKeyboardSelection(bool cleanSelection) AddAssert("previous item is selected", () => testDropdown.SelectedIndex == Math.Max(0, previousIndex - 1)); AddStep("select last item", () => InputManager.Keys(PlatformAction.MoveToListEnd)); - AddAssert("last item selected", () => testDropdown.SelectedItem == testDropdown.Menu.DrawableMenuItems.Last().Item); + AddAssert("last item selected", () => testDropdown.SelectedItem == testDropdown.Menu.VisibleMenuItems.Last().Item); AddStep("select last item", () => InputManager.Keys(PlatformAction.MoveToListStart)); - AddAssert("first item selected", () => testDropdown.SelectedItem == testDropdown.Menu.DrawableMenuItems.First().Item); + AddAssert("first item selected", () => testDropdown.SelectedItem == testDropdown.Menu.VisibleMenuItems.First().Item); AddStep("select next item when empty", () => InputManager.Key(Key.Up)); AddStep("select previous item when empty", () => InputManager.Key(Key.Down)); @@ -330,7 +341,7 @@ public void TestClearItemsInBindableWhileNotPresent() AddStep("hide dropdown", () => testDropdown.Hide()); AddStep("clear items", () => bindableList.Clear()); AddStep("show dropdown", () => testDropdown.Show()); - AddAssert("dropdown menu empty", () => !testDropdown.Menu.DrawableMenuItems.Any()); + AddAssert("dropdown menu empty", () => !testDropdown.Menu.Children.Any()); } /// @@ -355,7 +366,7 @@ public void TestAddItemBeforeDropdownLoad() }; }); - AddAssert("text is expected", () => dropdown.Menu.DrawableMenuItems.First().ChildrenOfType().First().Text.ToString(), () => Is.EqualTo("loaded: test")); + AddAssert("text is expected", () => dropdown.Menu.VisibleMenuItems.First().ChildrenOfType().First().Text.ToString(), () => Is.EqualTo("loaded: test")); } /// @@ -377,7 +388,7 @@ public void TestAddItemWhileDropdownIsInReadyState() dropdown.Items = new TestModel("test").Yield(); }); - AddAssert("text is expected", () => dropdown.Menu.DrawableMenuItems.First(d => d.IsSelected).ChildrenOfType().First().Text.ToString(), () => Is.EqualTo("loaded: test")); + AddAssert("text is expected", () => dropdown.Menu.VisibleMenuItems.First(d => d.IsSelected).ChildrenOfType().First().Text.ToString(), () => Is.EqualTo("loaded: test")); } /// @@ -418,11 +429,114 @@ public void TestSetNonExistentItem([Values] bool afterBdl) AddAssert("text is expected", () => dropdown.SelectedItem.Text.Value.ToString(), () => Is.EqualTo("loaded: non-existent item")); } + #region Searching + + [Test] + public void TestSearching() + { + ManualTextDropdown dropdown = null!; + + AddStep("setup dropdown", () => dropdown = createDropdowns(1)[0]); + AddAssert("search bar hidden", () => dropdown.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Hidden)); + + toggleDropdownViaClick(() => dropdown); + + AddAssert("search bar still hidden", () => dropdown.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Hidden)); + + AddStep("trigger text", () => dropdown.TextInput.Text("test 4")); + AddAssert("search bar visible", () => dropdown.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Visible)); + AddAssert("items filtered", () => + { + var drawableItem = dropdown.Menu.VisibleMenuItems.Single(i => i.IsPresent); + return drawableItem.Item.Text.Value == "test 4"; + }); + AddAssert("item preselected", () => dropdown.Menu.VisibleMenuItems.Single().IsPreSelected); + + AddStep("press enter", () => InputManager.Key(Key.Enter)); + AddAssert("item selected", () => dropdown.SelectedItem.Text.Value == "test 4"); + } + + [Test] + public void TestReleaseFocusAfterSearching() + { + ManualTextDropdown dropdown = null!; + + AddStep("setup dropdown", () => dropdown = createDropdowns(1)[0]); + toggleDropdownViaClick(() => dropdown); + + AddStep("trigger text", () => dropdown.TextInput.Text("test 4")); + AddAssert("search bar visible", () => dropdown.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Visible)); + + AddStep("press escape", () => InputManager.Key(Key.Escape)); + AddAssert("search bar hidden", () => dropdown.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Hidden)); + AddAssert("dropdown still open", () => dropdown.Menu.State == MenuState.Open); + + AddStep("press escape again", () => InputManager.Key(Key.Escape)); + AddAssert("dropdown closed", () => dropdown.Menu.State == MenuState.Closed); + + toggleDropdownViaClick(() => dropdown); + AddStep("trigger text", () => dropdown.TextInput.Text("test 4")); + AddAssert("search bar visible", () => dropdown.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Visible)); + + AddStep("click away", () => + { + InputManager.MoveMouseTo(Vector2.Zero); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("search bar hidden", () => dropdown.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Hidden)); + } + + [Test] + public void TestAlwaysShowSearchBar() + { + ManualTextDropdown dropdown = null!; + + AddStep("setup dropdown", () => + { + dropdown = createDropdowns(1)[0]; + dropdown.AlwaysShowSearchBar = true; + }); + + AddAssert("search bar hidden", () => dropdown.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Hidden)); + toggleDropdownViaClick(() => dropdown); + + AddAssert("search bar visible", () => dropdown.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Visible)); + + AddStep("trigger text", () => dropdown.TextInput.Text("test 4")); + AddAssert("search bar still visible", () => dropdown.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Visible)); + + AddStep("press escape", () => InputManager.Key(Key.Escape)); + AddAssert("search bar still visible", () => dropdown.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Visible)); + AddAssert("dropdown still open", () => dropdown.Menu.State == MenuState.Open); + + AddStep("press escape again", () => InputManager.Key(Key.Escape)); + AddAssert("dropdown closed", () => dropdown.Menu.State == MenuState.Closed); + AddAssert("search bar hidden", () => dropdown.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Hidden)); + + toggleDropdownViaClick(() => dropdown); + AddStep("trigger text", () => dropdown.TextInput.Text("test 4")); + AddAssert("search bar visible", () => dropdown.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Visible)); + + AddStep("click away", () => + { + InputManager.MoveMouseTo(Vector2.Zero); + InputManager.Click(MouseButton.Left); + }); + + AddAssert("search bar hidden", () => dropdown.ChildrenOfType().Single().State.Value, () => Is.EqualTo(Visibility.Hidden)); + } + + #endregion + private TestDropdown createDropdown() => createDropdowns(1).Single(); - private TestDropdown[] createDropdowns(int count) + private TestDropdown[] createDropdowns(int count) => createDropdowns(count); + + private TDropdown[] createDropdowns(int count) + where TDropdown : TestDropdown, new() { - TestDropdown[] dropdowns = new TestDropdown[count]; + TDropdown[] dropdowns = new TDropdown[count]; for (int dropdownIndex = 0; dropdownIndex < count; dropdownIndex++) { @@ -430,7 +544,7 @@ private TestDropdown[] createDropdowns(int count) for (int itemIndex = 0; itemIndex < items_to_add; itemIndex++) testItems[itemIndex] = "test " + itemIndex; - dropdowns[dropdownIndex] = new TestDropdown + dropdowns[dropdownIndex] = new TDropdown { Position = new Vector2(50f, 50f), Width = 150, @@ -488,8 +602,14 @@ private partial class TestDropdown : BasicDropdown { internal new DropdownMenuItem SelectedItem => base.SelectedItem; - public int SelectedIndex => Menu.DrawableMenuItems.Select(d => d.Item).ToList().IndexOf(SelectedItem); - public int PreselectedIndex => Menu.DrawableMenuItems.ToList().IndexOf(Menu.PreselectedItem); + public int SelectedIndex => Menu.VisibleMenuItems.Select(d => d.Item).ToList().IndexOf(SelectedItem); + public int PreselectedIndex => Menu.VisibleMenuItems.ToList().IndexOf(Menu.PreselectedItem); + } + + private partial class ManualTextDropdown : TestDropdown + { + [Cached(typeof(TextInputSource))] + public readonly ManualTextInputSource TextInput = new ManualTextInputSource(); } /// diff --git a/osu.Framework.Tests/Visual/UserInterface/TestSceneTabControl.cs b/osu.Framework.Tests/Visual/UserInterface/TestSceneTabControl.cs index a962c4a5e8..c83d51df50 100644 --- a/osu.Framework.Tests/Visual/UserInterface/TestSceneTabControl.cs +++ b/osu.Framework.Tests/Visual/UserInterface/TestSceneTabControl.cs @@ -505,6 +505,17 @@ public StyledDropdownHeader() new Box { Width = 20, Height = 20 } }; } + + protected override DropdownSearchBar CreateSearchBar() => new BasicDropdownSearchBar(); + + private partial class BasicDropdownSearchBar : DropdownSearchBar + { + protected override void PopIn() => this.FadeIn(); + + protected override void PopOut() => this.FadeOut(); + + protected override TextBox CreateTextBox() => new BasicTextBox(); + } } private partial class TabControlWithNoDropdown : BasicTabControl diff --git a/osu.Framework.Tests/Visual/UserInterface/TestSceneTextBox.cs b/osu.Framework.Tests/Visual/UserInterface/TestSceneTextBox.cs index f3f09e793e..1da11b240d 100644 --- a/osu.Framework.Tests/Visual/UserInterface/TestSceneTextBox.cs +++ b/osu.Framework.Tests/Visual/UserInterface/TestSceneTextBox.cs @@ -16,7 +16,6 @@ using osu.Framework.Testing; using osu.Framework.Utils; using osuTK; -using osuTK.Graphics; using osuTK.Input; namespace osu.Framework.Tests.Visual.UserInterface @@ -878,7 +877,7 @@ private partial class NumberTextBox : BasicTextBox private partial class CustomTextBox : BasicTextBox { - protected override Drawable GetDrawableCharacter(char c) => new ScalingText(c, CalculatedTextSize); + protected override Drawable GetDrawableCharacter(char c) => new ScalingText(c, FontSize); private partial class ScalingText : CompositeDrawable { @@ -923,16 +922,19 @@ private partial class BorderCaret : Caret public BorderCaret() { - RelativeSizeAxes = Axes.Y; - - Masking = true; - BorderColour = Color4.White; - BorderThickness = 3; - - InternalChild = new Box + InternalChild = new Container { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, RelativeSizeAxes = Axes.Both, - Colour = Color4.Transparent + Masking = true, + BorderColour = Colour4.White, + BorderThickness = 3f, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Colour4.Transparent, + }, }; } diff --git a/osu.Framework.Tests/Visual/UserInterface/TestSceneTextBoxEvents.cs b/osu.Framework.Tests/Visual/UserInterface/TestSceneTextBoxEvents.cs index 008df0226c..643924de69 100644 --- a/osu.Framework.Tests/Visual/UserInterface/TestSceneTextBoxEvents.cs +++ b/osu.Framework.Tests/Visual/UserInterface/TestSceneTextBoxEvents.cs @@ -12,6 +12,7 @@ using osu.Framework.Graphics.UserInterface; using osu.Framework.Input; using osu.Framework.Testing; +using osu.Framework.Testing.Input; using osuTK; using osuTK.Input; @@ -20,7 +21,7 @@ namespace osu.Framework.Tests.Visual.UserInterface public partial class TestSceneTextBoxEvents : ManualInputManagerTestScene { private EventQueuesTextBox textBox; - private ManualTextInput textInput; + private ManualTextInputSource textInput; private ManualTextInputContainer textInputContainer; private const string default_text = "some default text"; @@ -618,57 +619,12 @@ protected override void OnImeResult(string result, bool successful) => public partial class ManualTextInputContainer : Container { [Cached(typeof(TextInputSource))] - public readonly ManualTextInput TextInput; + public readonly ManualTextInputSource TextInput; public ManualTextInputContainer() { RelativeSizeAxes = Axes.Both; - TextInput = new ManualTextInput(); - } - } - - public class ManualTextInput : TextInputSource - { - public void Text(string text) => TriggerTextInput(text); - - public new void TriggerImeComposition(string text, int start, int length) - { - base.TriggerImeComposition(text, start, length); - } - - public new void TriggerImeResult(string text) - { - base.TriggerImeResult(text); - } - - public override void ResetIme() - { - base.ResetIme(); - - // this call will be somewhat delayed in a real world scenario, but let's run it immediately for simplicity. - base.TriggerImeComposition(string.Empty, 0, 0); - } - - public readonly Queue ActivationQueue = new Queue(); - public readonly Queue EnsureActivatedQueue = new Queue(); - public readonly Queue DeactivationQueue = new Queue(); - - protected override void ActivateTextInput(bool allowIme) - { - base.ActivateTextInput(allowIme); - ActivationQueue.Enqueue(allowIme); - } - - protected override void EnsureTextInputActivated(bool allowIme) - { - base.EnsureTextInputActivated(allowIme); - EnsureActivatedQueue.Enqueue(allowIme); - } - - protected override void DeactivateTextInput() - { - base.DeactivateTextInput(); - DeactivationQueue.Enqueue(true); + TextInput = new ManualTextInputSource(); } } diff --git a/osu.Framework.Tests/Visual/UserInterface/TestSceneTextBoxKeyEvents.cs b/osu.Framework.Tests/Visual/UserInterface/TestSceneTextBoxKeyEvents.cs index 0d8ae811e4..b1281b4a43 100644 --- a/osu.Framework.Tests/Visual/UserInterface/TestSceneTextBoxKeyEvents.cs +++ b/osu.Framework.Tests/Visual/UserInterface/TestSceneTextBoxKeyEvents.cs @@ -11,6 +11,7 @@ using osu.Framework.Input.Events; using osu.Framework.Platform; using osu.Framework.Testing; +using osu.Framework.Testing.Input; using osuTK; using osuTK.Input; @@ -20,7 +21,7 @@ public partial class TestSceneTextBoxKeyEvents : ManualInputManagerTestScene { private KeyEventQueuesTextBox textBox; - private TestSceneTextBoxEvents.ManualTextInput textInput; + private ManualTextInputSource textInput; [Resolved] private GameHost host { get; set; } diff --git a/osu.Framework/Graphics/UserInterface/BasicDropdown.cs b/osu.Framework/Graphics/UserInterface/BasicDropdown.cs index 651b475b74..843e1e57cb 100644 --- a/osu.Framework/Graphics/UserInterface/BasicDropdown.cs +++ b/osu.Framework/Graphics/UserInterface/BasicDropdown.cs @@ -15,6 +15,8 @@ public partial class BasicDropdown : Dropdown public partial class BasicDropdownHeader : DropdownHeader { + private static FontUsage font => FrameworkFont.Condensed; + private readonly SpriteText label; protected internal override LocalisableString Label @@ -25,8 +27,6 @@ protected internal override LocalisableString Label public BasicDropdownHeader() { - var font = FrameworkFont.Condensed; - Foreground.Padding = new MarginPadding(5); BackgroundColour = FrameworkColour.Green; BackgroundColourHover = FrameworkColour.YellowGreen; @@ -41,6 +41,21 @@ public BasicDropdownHeader() }, }; } + + protected override DropdownSearchBar CreateSearchBar() => new BasicDropdownSearchBar(); + + public partial class BasicDropdownSearchBar : DropdownSearchBar + { + protected override void PopIn() => this.FadeIn(); + + protected override void PopOut() => this.FadeOut(); + + protected override TextBox CreateTextBox() => new BasicTextBox + { + PlaceholderText = "type to search", + FontSize = font.Size, + }; + } } public partial class BasicDropdownMenu : DropdownMenu diff --git a/osu.Framework/Graphics/UserInterface/BasicTextBox.cs b/osu.Framework/Graphics/UserInterface/BasicTextBox.cs index 69609ee23b..46802d8cbb 100644 --- a/osu.Framework/Graphics/UserInterface/BasicTextBox.cs +++ b/osu.Framework/Graphics/UserInterface/BasicTextBox.cs @@ -96,7 +96,7 @@ protected override void OnFocus(FocusEvent e) protected override Drawable GetDrawableCharacter(char c) => new FallingDownContainer { AutoSizeAxes = Axes.Both, - Child = new SpriteText { Text = c.ToString(), Font = FrameworkFont.Condensed.With(size: CalculatedTextSize) } + Child = new SpriteText { Text = c.ToString(), Font = FrameworkFont.Condensed.With(size: FontSize) } }; protected override SpriteText CreatePlaceholder() => new FadingPlaceholderText @@ -140,20 +140,17 @@ public partial class BasicCaret : Caret { public BasicCaret() { - RelativeSizeAxes = Axes.Y; - Size = new Vector2(1, 0.9f); - Colour = Color4.Transparent; - Anchor = Anchor.CentreLeft; - Origin = Anchor.CentreLeft; - - Masking = true; - CornerRadius = 1; - InternalChild = new Box + InternalChild = new Container { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, RelativeSizeAxes = Axes.Both, - Colour = Color4.White, + Height = 0.9f, + CornerRadius = 1f, + Masking = true, + Child = new Box { RelativeSizeAxes = Axes.Both }, }; } diff --git a/osu.Framework/Graphics/UserInterface/Dropdown.cs b/osu.Framework/Graphics/UserInterface/Dropdown.cs index 515e3fbb46..830bcba12f 100644 --- a/osu.Framework/Graphics/UserInterface/Dropdown.cs +++ b/osu.Framework/Graphics/UserInterface/Dropdown.cs @@ -17,6 +17,7 @@ using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; +using osu.Framework.Layout; using osu.Framework.Localisation; using osuTK.Graphics; using osuTK.Input; @@ -32,6 +33,21 @@ public abstract partial class Dropdown : CompositeDrawable, IHasCurrentValue< protected internal DropdownHeader Header; protected internal DropdownMenu Menu; + /// + /// Whether this should always have a search bar displayed in the header when opened. + /// + public bool AlwaysShowSearchBar + { + get => Header.AlwaysShowSearchBar; + set => Header.AlwaysShowSearchBar = value; + } + + public bool AllowNonContiguousMatching + { + get => Menu.AllowNonContiguousMatching; + set => Menu.AllowNonContiguousMatching = value; + } + /// /// Creates the header part of the control. /// @@ -116,7 +132,7 @@ private void addDropdownItem(T value, int? position = null) if (!Current.Disabled) Current.Value = value; - Menu.State = MenuState.Closed; + Menu.Close(); }); // inheritors expect that `virtual GenerateItemText` is only called when this dropdown's BDL has run to completion. @@ -180,6 +196,25 @@ 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. + /// + public bool Back() + { + if (Header.SearchBar.Back()) + return true; + + if (Menu.State == MenuState.Open) + { + Menu.Close(); + return true; + } + + return false; + } + private readonly BindableWithCurrent current = new BindableWithCurrent(); public Bindable Current @@ -221,11 +256,21 @@ protected Dropdown() AutoSizeAxes = Axes.Y }; - Menu.RelativeSizeAxes = Axes.X; - - Header.Action = Menu.Toggle; + Header.ToggleMenu = Menu.Toggle; Header.ChangeSelection += selectionKeyPressed; + + Header.SearchTerm.ValueChanged += t => Menu.SearchTerm = t.NewValue; + + Menu.RelativeSizeAxes = Axes.X; Menu.PreselectionConfirmed += preselectionConfirmed; + Menu.FilterCompleted += filterCompleted; + + Menu.StateChanged += state => + { + Menu.State = state; + Header.UpdateSearchBarFocus(state); + }; + Current.ValueChanged += val => Scheduler.AddOnce(updateItemSelection, val.NewValue); Current.DisabledChanged += disabled => { @@ -237,12 +282,20 @@ protected Dropdown() ItemSource.CollectionChanged += collectionChanged; } - private void preselectionConfirmed(int selectedIndex) + private void preselectionConfirmed(DropdownMenuItem item) { - SelectedItem = MenuItems.ElementAtOrDefault(selectedIndex); + SelectedItem = item; Menu.State = MenuState.Closed; } + private void filterCompleted() + { + if (!string.IsNullOrEmpty(Menu.SearchTerm)) + Menu.PreselectItem(0); + else + Menu.PreselectItem(null); + } + private void selectionKeyPressed(DropdownHeader.DropdownSelectionAction action) { if (!MenuItems.Any()) @@ -291,6 +344,14 @@ protected override void LoadComplete() Header.Label = SelectedItem?.Text.Value ?? default; } + protected override bool OnKeyDown(KeyDownEvent e) + { + if (e.Key == Key.Escape) + return Back(); + + return false; + } + private void collectionChanged(object sender, NotifyCollectionChangedEventArgs e) { switch (e.Action) @@ -421,6 +482,29 @@ internal void ShowItem(T val) public abstract partial class DropdownMenu : Menu, IKeyBindingHandler { + private SearchContainer itemsFlow; + + /// + /// Search terms to filter items displayed in this menu. + /// + public string SearchTerm + { + get => itemsFlow.SearchTerm; + set => itemsFlow.SearchTerm = value; + } + + public bool AllowNonContiguousMatching + { + get => itemsFlow.AllowNonContiguousMatching; + set => itemsFlow.AllowNonContiguousMatching = value; + } + + public event Action FilterCompleted + { + add => itemsFlow.FilterCompleted += value; + remove => itemsFlow.FilterCompleted -= value; + } + protected DropdownMenu() : base(Direction.Vertical) { @@ -433,13 +517,13 @@ private void clearPreselection(MenuState obj) PreselectItem(null); } - protected internal IEnumerable DrawableMenuItems => Children.OfType(); - protected internal IEnumerable VisibleMenuItems => DrawableMenuItems.Where(item => !item.IsMaskedAway); + protected internal IEnumerable VisibleMenuItems => Children.OfType().Where(i => i.MatchingFilter); + protected internal IEnumerable MenuItemsInView => VisibleMenuItems.Where(item => !item.IsMaskedAway); - public DrawableDropdownMenuItem PreselectedItem => DrawableMenuItems.FirstOrDefault(c => c.IsPreSelected) - ?? DrawableMenuItems.FirstOrDefault(c => c.IsSelected); + public DrawableDropdownMenuItem PreselectedItem => VisibleMenuItems.FirstOrDefault(c => c.IsPreSelected) + ?? VisibleMenuItems.FirstOrDefault(c => c.IsSelected); - public event Action PreselectionConfirmed; + public event Action> PreselectionConfirmed; /// /// Selects an item from this . @@ -473,13 +557,18 @@ public void SelectItem(DropdownMenuItem item) /// public bool AnyPresent => Children.Any(c => c.IsPresent); - protected void PreselectItem(int index) => PreselectItem(Items[Math.Clamp(index, 0, DrawableMenuItems.Count() - 1)]); + protected internal void PreselectItem(int index) + { + PreselectItem(VisibleMenuItems.Any() + ? VisibleMenuItems.ElementAt(Math.Clamp(index, 0, VisibleMenuItems.Count() - 1)).Item + : null); + } /// /// Preselects an item from this . /// /// The item to select. - protected void PreselectItem(MenuItem item) + protected internal void PreselectItem(MenuItem item) { Children.OfType().ForEach(c => { @@ -509,10 +598,27 @@ private static bool compareItemEquality(MenuItem a, MenuItem b) #region DrawableDropdownMenuItem - public abstract partial class DrawableDropdownMenuItem : DrawableMenuItem + public abstract partial class DrawableDropdownMenuItem : DrawableMenuItem, IFilterable { public event Action> PreselectionRequested; + private bool matchingFilter = true; + + public bool MatchingFilter + { + get => matchingFilter; + set + { + matchingFilter = value; + UpdateFilteringState(value); + } + } + + public virtual bool FilteringActive + { + set { } + } + protected DrawableDropdownMenuItem(MenuItem item) : base(item) { @@ -594,6 +700,8 @@ protected override void UpdateForegroundColour() Foreground.FadeColour(IsPreSelected ? ForegroundColourHover : IsSelected ? ForegroundColourSelected : ForegroundColour); } + protected virtual void UpdateFilteringState(bool filtered) => this.FadeTo(filtered ? 1 : 0); + protected override bool OnHover(HoverEvent e) { PreselectionRequested?.Invoke(Item as DropdownMenuItem); @@ -605,52 +713,54 @@ protected override bool OnHover(HoverEvent e) protected override bool OnKeyDown(KeyDownEvent e) { - var drawableMenuItemsList = DrawableMenuItems.ToList(); - if (!drawableMenuItemsList.Any()) - return base.OnKeyDown(e); - - var currentPreselected = PreselectedItem; - int targetPreselectionIndex = drawableMenuItemsList.IndexOf(currentPreselected); + var visibleMenuItemsList = VisibleMenuItems.ToList(); - switch (e.Key) + if (visibleMenuItemsList.Any()) { - case Key.Up: - PreselectItem(targetPreselectionIndex - 1); - return true; - - case Key.Down: - PreselectItem(targetPreselectionIndex + 1); - return true; + var currentPreselected = PreselectedItem; + int targetPreselectionIndex = visibleMenuItemsList.IndexOf(currentPreselected); - case Key.PageUp: - var firstVisibleItem = VisibleMenuItems.First(); - - if (currentPreselected == firstVisibleItem) - PreselectItem(targetPreselectionIndex - VisibleMenuItems.Count()); - else - PreselectItem(drawableMenuItemsList.IndexOf(firstVisibleItem)); - return true; - - case Key.PageDown: - var lastVisibleItem = VisibleMenuItems.Last(); - - if (currentPreselected == lastVisibleItem) - PreselectItem(targetPreselectionIndex + VisibleMenuItems.Count()); - else - PreselectItem(drawableMenuItemsList.IndexOf(lastVisibleItem)); - return true; - - case Key.Enter: - PreselectionConfirmed?.Invoke(targetPreselectionIndex); - return true; + switch (e.Key) + { + case Key.Up: + PreselectItem(targetPreselectionIndex - 1); + return true; + + case Key.Down: + PreselectItem(targetPreselectionIndex + 1); + return true; + + case Key.PageUp: + var firstVisibleItem = VisibleMenuItems.First(); + + if (currentPreselected == firstVisibleItem) + PreselectItem(targetPreselectionIndex - VisibleMenuItems.Count()); + else + PreselectItem(visibleMenuItemsList.IndexOf(firstVisibleItem)); + return true; + + case Key.PageDown: + var lastVisibleItem = VisibleMenuItems.Last(); + + if (currentPreselected == lastVisibleItem) + PreselectItem(targetPreselectionIndex + VisibleMenuItems.Count()); + else + PreselectItem(visibleMenuItemsList.IndexOf(lastVisibleItem)); + return true; + + case Key.Enter: + var preselectedItem = VisibleMenuItems.ElementAt(targetPreselectionIndex); + PreselectionConfirmed?.Invoke((DropdownMenuItem)preselectedItem.Item); + return true; + } + } - case Key.Escape: - State = MenuState.Closed; - return true; + if (e.Key == Key.Escape) + // we'll handle closing the menu in Dropdown instead, + // since a search bar may be active and we want to reset it rather than closing the menu. + return false; - default: - return base.OnKeyDown(e); - } + return base.OnKeyDown(e); } public bool OnPressed(KeyBindingPressEvent e) @@ -673,6 +783,21 @@ public bool OnPressed(KeyBindingPressEvent e) public void OnReleased(KeyBindingReleaseEvent e) { } + + internal override IItemsFlow CreateItemsFlow(FillDirection direction) => (IItemsFlow)(itemsFlow = new SearchableItemsFlow + { + Direction = direction, + }); + + private partial class SearchableItemsFlow : SearchContainer, IItemsFlow + { + public LayoutValue SizeCache { get; } = new LayoutValue(Invalidation.RequiredParentSizeToFit, InvalidationSource.Self); + + public SearchableItemsFlow() + { + AddLayout(SizeCache); + } + } } #endregion diff --git a/osu.Framework/Graphics/UserInterface/DropdownHeader.cs b/osu.Framework/Graphics/UserInterface/DropdownHeader.cs index 1603fd1c3c..2981c8017c 100644 --- a/osu.Framework/Graphics/UserInterface/DropdownHeader.cs +++ b/osu.Framework/Graphics/UserInterface/DropdownHeader.cs @@ -5,6 +5,7 @@ using osuTK.Graphics; using System; +using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Input; @@ -15,13 +16,23 @@ namespace osu.Framework.Graphics.UserInterface { - public abstract partial class DropdownHeader : ClickableContainer, IKeyBindingHandler + public abstract partial class DropdownHeader : Container, IKeyBindingHandler { public event Action ChangeSelection; protected Container Background; protected Container Foreground; + public bool AlwaysShowSearchBar + { + get => SearchBar.AlwaysDisplayOnFocus; + set => SearchBar.AlwaysDisplayOnFocus = value; + } + + protected internal DropdownSearchBar SearchBar { get; } + + public Bindable SearchTerm => SearchBar.SearchTerm; + private Color4 backgroundColour = Color4.DarkGray; protected Color4 BackgroundColour @@ -52,12 +63,17 @@ protected Color4 DisabledColour protected internal abstract LocalisableString Label { get; set; } + public BindableBool Enabled { get; } = new BindableBool(true); + + public Action ToggleMenu; + protected DropdownHeader() { Masking = true; RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; Width = 1; + InternalChildren = new Drawable[] { Background = new Container @@ -79,15 +95,28 @@ protected DropdownHeader() RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y }, + SearchBar = CreateSearchBar(), }; } + protected abstract DropdownSearchBar CreateSearchBar(); + protected override void LoadComplete() { base.LoadComplete(); + Enabled.BindValueChanged(_ => updateState(), true); } + protected override bool OnClick(ClickEvent e) + { + if (!Enabled.Value) + return false; + + ToggleMenu?.Invoke(); + return false; + } + protected override bool OnHover(HoverEvent e) { updateState(); @@ -100,16 +129,25 @@ protected override void OnHoverLost(HoverLostEvent e) base.OnHoverLost(e); } + public void UpdateSearchBarFocus(MenuState state) + { + if (state == MenuState.Open) + SearchBar.ObtainFocus(); + else + SearchBar.ReleaseFocus(); + } + private void updateState() { Colour = Enabled.Value ? Color4.White : DisabledColour; Background.Colour = IsHovered && Enabled.Value ? BackgroundColourHover : BackgroundColour; } - public override bool HandleNonPositionalInput => IsHovered; - protected override bool OnKeyDown(KeyDownEvent e) { + if (!IsHovered) + return false; + if (!Enabled.Value) return true; diff --git a/osu.Framework/Graphics/UserInterface/DropdownSearchBar.cs b/osu.Framework/Graphics/UserInterface/DropdownSearchBar.cs new file mode 100644 index 0000000000..a95560da50 --- /dev/null +++ b/osu.Framework/Graphics/UserInterface/DropdownSearchBar.cs @@ -0,0 +1,107 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics.Containers; +using osu.Framework.Input; +using osuTK; + +namespace osu.Framework.Graphics.UserInterface +{ + public abstract partial class DropdownSearchBar : VisibilityContainer + { + private TextBox textBox = null!; + private PassThroughInputManager textBoxInputManager = null!; + + public Bindable SearchTerm { get; } = new Bindable(); + + // handling mouse input on dropdown header is not easy, since the menu would lose focus on release and automatically close + public override bool HandlePositionalInput => false; + public override bool PropagatePositionalInputSubTree => false; + + private bool obtainedFocus; + + private bool alwaysDisplayOnFocus; + + public bool AlwaysDisplayOnFocus + { + get => alwaysDisplayOnFocus; + set + { + alwaysDisplayOnFocus = value; + + if (IsLoaded) + updateVisibility(); + } + } + + [BackgroundDependencyLoader] + private void load() + { + AlwaysPresent = true; + RelativeSizeAxes = Axes.Both; + + // Dropdown menus rely on their focus state to determine when they should be closed. + // On the other hand, text boxes require to be focused in order for the user to interact with them. + // To handle that matter, we'll wrap the search text box inside a local input manager, and manage its focus state accordingly. + InternalChild = textBoxInputManager = new PassThroughInputManager + { + RelativeSizeAxes = Axes.Both, + Child = textBox = CreateTextBox().With(t => + { + t.ReleaseFocusOnCommit = false; + t.RelativeSizeAxes = Axes.Both; + t.Size = new Vector2(1f); + t.Current = SearchTerm; + }) + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + SearchTerm.BindValueChanged(v => updateVisibility()); + updateVisibility(); + } + + public void ObtainFocus() + { + textBoxInputManager.ChangeFocus(textBox); + obtainedFocus = true; + + updateVisibility(); + } + + public void ReleaseFocus() + { + textBoxInputManager.ChangeFocus(null); + SearchTerm.Value = string.Empty; + obtainedFocus = false; + + updateVisibility(); + } + + public bool Back() + { + // text box may have lost focus from pressing escape, retain it. + if (obtainedFocus && !textBox.HasFocus) + ObtainFocus(); + + if (!string.IsNullOrEmpty(SearchTerm.Value)) + { + SearchTerm.Value = string.Empty; + return true; + } + + return false; + } + + private void updateVisibility() => State.Value = obtainedFocus && (AlwaysDisplayOnFocus || !string.IsNullOrEmpty(SearchTerm.Value)) + ? Visibility.Visible + : Visibility.Hidden; + + protected abstract TextBox CreateTextBox(); + } +} diff --git a/osu.Framework/Graphics/UserInterface/Menu.cs b/osu.Framework/Graphics/UserInterface/Menu.cs index e35f98cf64..fb6502442c 100644 --- a/osu.Framework/Graphics/UserInterface/Menu.cs +++ b/osu.Framework/Graphics/UserInterface/Menu.cs @@ -14,6 +14,7 @@ using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; using osu.Framework.Layout; +using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Framework.Threading; using osuTK; @@ -51,6 +52,8 @@ public abstract partial class Menu : CompositeDrawable, IStateful // since we manage it ourselves to define a specific order for menu items and allow inserting ones between others. protected Container ItemsContainer => itemsFlow; + private FillFlowContainer itemsFlow; + /// /// The container that provides the masking effects for this . /// @@ -59,11 +62,10 @@ public abstract partial class Menu : CompositeDrawable, IStateful /// /// Gets the item representations contained by this . /// - protected internal IReadOnlyList Children => ItemsContainer.Children; + protected internal IReadOnlyList Children => itemsFlow.Children; protected readonly Direction Direction; - private ItemsFlow itemsFlow; private Menu parentMenu; private Menu submenu; @@ -103,7 +105,7 @@ protected Menu(Direction direction, bool topLevelMenu = false) { d.RelativeSizeAxes = Axes.Both; d.Masking = false; - d.Child = itemsFlow = new ItemsFlow { Direction = direction == Direction.Horizontal ? FillDirection.Horizontal : FillDirection.Vertical }; + d.Child = itemsFlow = (FillFlowContainer)CreateItemsFlow(direction == Direction.Horizontal ? FillDirection.Horizontal : FillDirection.Vertical); }) } }, @@ -117,16 +119,16 @@ protected Menu(Direction direction, bool topLevelMenu = false) switch (direction) { case Direction.Horizontal: - ItemsContainer.AutoSizeAxes = Axes.X; + itemsFlow.AutoSizeAxes = Axes.X; break; case Direction.Vertical: - ItemsContainer.AutoSizeAxes = Axes.Y; + itemsFlow.AutoSizeAxes = Axes.Y; break; } // The menu will provide a valid size for the items container based on our own size - ItemsContainer.RelativeSizeAxes = Axes.Both & ~ItemsContainer.AutoSizeAxes; + itemsFlow.RelativeSizeAxes = Axes.Both & ~itemsFlow.AutoSizeAxes; AddLayout(positionLayout); } @@ -142,7 +144,7 @@ protected override void LoadComplete() /// public IReadOnlyList Items { - get => ItemsContainer.Select(r => r.Item).ToList(); + get => itemsFlow.Select(r => r.Item).ToList(); set { Clear(); @@ -183,7 +185,7 @@ public float MaxWidth maxWidth = value; - itemsFlow.SizeCache.Invalidate(); + ((IItemsFlow)itemsFlow).SizeCache.Invalidate(); } } @@ -202,7 +204,7 @@ public float MaxHeight maxHeight = value; - itemsFlow.SizeCache.Invalidate(); + ((IItemsFlow)itemsFlow).SizeCache.Invalidate(); } } @@ -272,7 +274,7 @@ private void resetState() return; submenu?.Close(); - itemsFlow.SizeCache.Invalidate(); + ((IItemsFlow)itemsFlow).SizeCache.Invalidate(); } /// @@ -301,7 +303,7 @@ public void Insert(int position, MenuItem item) itemsFlow.SetLayoutPosition(items[i], i + 1); itemsFlow.Insert(position, drawableItem); - itemsFlow.SizeCache.Invalidate(); + ((IItemsFlow)itemsFlow).SizeCache.Invalidate(); } private void itemStateChanged(DrawableMenuItem item, MenuItemState state) @@ -338,7 +340,7 @@ public bool Remove(MenuItem item) } } - itemsFlow.SizeCache.Invalidate(); + ((IItemsFlow)itemsFlow).SizeCache.Invalidate(); return removed; } @@ -347,7 +349,7 @@ public bool Remove(MenuItem item) /// public void Clear() { - ItemsContainer.Clear(); + itemsFlow.Clear(); resetState(); } @@ -469,7 +471,7 @@ protected override void UpdateAfterChildren() { base.UpdateAfterChildren(); - if (!itemsFlow.SizeCache.IsValid) + if (!((IItemsFlow)itemsFlow).SizeCache.IsValid) { // Our children will be relatively-sized on the axis separate to the menu direction, so we need to compute // that size ourselves, based on the content size of our children, to give them a valid relative size @@ -483,11 +485,11 @@ protected override void UpdateAfterChildren() height = Math.Max(height, item.ContentDrawHeight); } - // When scrolling in one direction, ItemsContainer is auto-sized in that direction and relative-sized in the other + // When scrolling in one direction, itemsFlow is auto-sized in that direction and relative-sized in the other // In the case of the auto-sized direction, we want to use its size. In the case of the relative-sized direction, we want // to use the (above) computed size. - width = Direction == Direction.Horizontal ? ItemsContainer.Width : width; - height = Direction == Direction.Vertical ? ItemsContainer.Height : height; + width = Direction == Direction.Horizontal ? itemsFlow.Width : width; + height = Direction == Direction.Vertical ? itemsFlow.Height : height; width = Math.Min(MaxWidth, width); height = Math.Min(MaxHeight, height); @@ -503,7 +505,7 @@ protected override void UpdateAfterChildren() UpdateSize(new Vector2(width, height)); - itemsFlow.SizeCache.Validate(); + ((IItemsFlow)itemsFlow).SizeCache.Validate(); } } @@ -683,6 +685,8 @@ private void closeFromChild(MenuItem source) /// The . protected abstract ScrollContainer CreateScrollContainer(Direction direction); + internal virtual IItemsFlow CreateItemsFlow(FillDirection direction) => new ItemsFlow { Direction = direction }; + #region DrawableMenuItem // must be public due to mono bug(?) https://github.com/ppy/osu/issues/1204 @@ -728,6 +732,8 @@ public abstract partial class DrawableMenuItem : CompositeDrawable, IStateful public virtual bool CloseMenuOnClick => true; + public IEnumerable FilterTerms => Item.Text.Value.Yield(); + protected DrawableMenuItem(MenuItem item) { Item = item; @@ -942,9 +948,14 @@ protected override bool OnClick(ClickEvent e) #endregion - private partial class ItemsFlow : FillFlowContainer + internal interface IItemsFlow : IFillFlowContainer + { + LayoutValue SizeCache { get; } + } + + private partial class ItemsFlow : FillFlowContainer, IItemsFlow { - public readonly LayoutValue SizeCache = new LayoutValue(Invalidation.RequiredParentSizeToFit, InvalidationSource.Self); + public LayoutValue SizeCache { get; } = new LayoutValue(Invalidation.RequiredParentSizeToFit, InvalidationSource.Self); public ItemsFlow() { diff --git a/osu.Framework/Graphics/UserInterface/TextBox.cs b/osu.Framework/Graphics/UserInterface/TextBox.cs index d9a74aecab..d0331a5a24 100644 --- a/osu.Framework/Graphics/UserInterface/TextBox.cs +++ b/osu.Framework/Graphics/UserInterface/TextBox.cs @@ -169,15 +169,22 @@ protected TextBox() Position = new Vector2(LeftRightPadding, 0), Children = new Drawable[] { - Placeholder = CreatePlaceholder(), - caret = CreateCaret(), + Placeholder = CreatePlaceholder().With(p => + { + p.Anchor = Anchor.CentreLeft; + p.Origin = Anchor.CentreLeft; + }), + caret = CreateCaret().With(c => + { + c.Anchor = Anchor.CentreLeft; + c.Origin = Anchor.CentreLeft; + }), TextFlow = new FillFlowContainer { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, Direction = FillDirection.Horizontal, - AutoSizeAxes = Axes.X, - RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.Both, }, }, }, @@ -507,7 +514,8 @@ protected override void Dispose(bool isDisposing) private void updateCursorAndLayout() { - Placeholder.Font = Placeholder.Font.With(size: CalculatedTextSize); + caret.Height = FontSize; + Placeholder.Font = Placeholder.Font.With(size: FontSize); float cursorPos = 0; if (text.Length > 0) @@ -740,7 +748,7 @@ private string removeCharacters(int number = 1) /// /// The character that this should represent. /// A that represents the character - protected virtual Drawable GetDrawableCharacter(char c) => new SpriteText { Text = c.ToString(), Font = new FontUsage(size: CalculatedTextSize) }; + protected virtual Drawable GetDrawableCharacter(char c) => new SpriteText { Text = c.ToString(), Font = new FontUsage(size: FontSize) }; protected virtual Drawable AddCharacterToFlow(char c) { @@ -770,7 +778,16 @@ protected virtual Drawable AddCharacterToFlow(char c) private float getDepthForCharacterIndex(int index) => -index; - protected float CalculatedTextSize => TextFlow.DrawSize.Y - (TextFlow.Padding.Top + TextFlow.Padding.Bottom); + private readonly float? customFontSize; + + /// + /// A fixed size for the text displayed in this . If left unset, text size will be computed based on the dimensions of the . + /// + public float FontSize + { + get => customFontSize ?? TextContainer.DrawSize.Y; + init => customFontSize = value; + } protected void InsertString(string value) { diff --git a/osu.Framework/Testing/Input/ManualTextInputSource.cs b/osu.Framework/Testing/Input/ManualTextInputSource.cs new file mode 100644 index 0000000000..def56cb23b --- /dev/null +++ b/osu.Framework/Testing/Input/ManualTextInputSource.cs @@ -0,0 +1,53 @@ +// 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.Input; + +namespace osu.Framework.Testing.Input +{ + public class ManualTextInputSource : TextInputSource + { + public readonly Queue ActivationQueue = new Queue(); + public readonly Queue EnsureActivatedQueue = new Queue(); + public readonly Queue DeactivationQueue = new Queue(); + + public void Text(string text) => TriggerTextInput(text); + + public new void TriggerImeComposition(string text, int start, int length) + { + base.TriggerImeComposition(text, start, length); + } + + public new void TriggerImeResult(string text) + { + base.TriggerImeResult(text); + } + + public override void ResetIme() + { + base.ResetIme(); + + // this call will be somewhat delayed in a real world scenario, but let's run it immediately for simplicity. + base.TriggerImeComposition(string.Empty, 0, 0); + } + + protected override void ActivateTextInput(bool allowIme) + { + base.ActivateTextInput(allowIme); + ActivationQueue.Enqueue(allowIme); + } + + protected override void EnsureTextInputActivated(bool allowIme) + { + base.EnsureTextInputActivated(allowIme); + EnsureActivatedQueue.Enqueue(allowIme); + } + + protected override void DeactivateTextInput() + { + base.DeactivateTextInput(); + DeactivationQueue.Enqueue(true); + } + } +} diff --git a/osu.Framework/Testing/TestBrowser.cs b/osu.Framework/Testing/TestBrowser.cs index 4ed7863d36..8ba2883055 100644 --- a/osu.Framework/Testing/TestBrowser.cs +++ b/osu.Framework/Testing/TestBrowser.cs @@ -655,7 +655,7 @@ private partial class TestBrowserTextBox : BasicTextBox public TestBrowserTextBox() { - TextFlow.Height = 0.75f; + FontSize = 14f; } } }