Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Implement dropdown searching #6072

Merged
merged 23 commits into from
Dec 13, 2023
Merged

Implement dropdown searching #6072

merged 23 commits into from
Dec 13, 2023

Conversation

frenzibyte
Copy link
Member

RFC. The implementation is a bit heavy, commit-by-commit reviewing is advised.

There are a few hacks with this implementation, mainly the use of a local input manager to allow the search text box to be focused while the menu is focused as well. I tried not to pay too much attention to the details of this implementation to not waste time on it.

CleanShot.2023-12-02.at.22.31.18.mp4

@frenzibyte frenzibyte requested a review from a team December 2, 2023 19:36
Comment on lines 775 to 783
/// <summary>
/// A fixed size for the text displayed in this <see cref="TextBox"/>. If left unset, text size will be computed based on the dimensions of the <see cref="TextBox"/>.
/// </summary>
public float TextSize
{
init => customTextSize = value;
}

protected float DisplayedTextSize => customTextSize ?? (TextFlow.DrawSize.Y - (TextFlow.Padding.Top + TextFlow.Padding.Bottom));
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is there any reason this is this weird? Why is the public member init-only when it looks like it would work when set at any time? Can the "displayed" text size just be plainly publically gettable? And can we name this FontSize?

Copy link
Member Author

Choose a reason for hiding this comment

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

Making it non-init means adding extra logic to handle updating font size of existing characters, so better keep it init-only.

@@ -912,7 +942,7 @@ protected override bool OnClick(ClickEvent e)

#endregion

private partial class ItemsFlow : FillFlowContainer<DrawableMenuItem>
private partial class ItemsFlow : SearchContainer<DrawableMenuItem>
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is somewhat far-reaching...? Every single menu out there is now searchable? Not sure what the performance implications of this are, have you checked?

Copy link
Member Author

Choose a reason for hiding this comment

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

Moved locally to DropdownMenu.

Comment on lines 74 to 93
/// <summary>
/// Search terms to filter items displayed in this menu.
/// </summary>
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;
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

I don't know about this either... Very far-reaching API surface for this singular feature.

Copy link
Member Author

Choose a reason for hiding this comment

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

Moved locally to DropdownMenu.

protected override bool OnKeyDown(KeyDownEvent e)
{
if (e.Key == Key.Escape && !TopLevelMenu)
if (e.Key == Key.Escape && CloseOnEscape)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can we not add this virtual? Can this be handled more locally to the dropdowns somehow?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, but it felt odd to handle locally in a general component like Dropdown, as opposed to modify Menu. Here's the diff:

diff --git a/osu.Framework/Graphics/UserInterface/Dropdown.cs b/osu.Framework/Graphics/UserInterface/Dropdown.cs
index 585c97bbf..d93c1af9c 100644
--- a/osu.Framework/Graphics/UserInterface/Dropdown.cs
+++ b/osu.Framework/Graphics/UserInterface/Dropdown.cs
@@ -609,55 +609,56 @@ protected override bool OnHover(HoverEvent e)
 
             #endregion
 
-            // 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.
-            protected override bool CloseOnEscape => false;
-
             protected override bool OnKeyDown(KeyDownEvent e)
             {
                 var visibleMenuItemsList = VisibleMenuItems.ToList();
-                if (!visibleMenuItemsList.Any())
-                    return base.OnKeyDown(e);
-
-                var currentPreselected = PreselectedItem;
-                int targetPreselectionIndex = visibleMenuItemsList.IndexOf(currentPreselected);
 
-                switch (e.Key)
+                if (visibleMenuItemsList.Any())
                 {
-                    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();
+                    var currentPreselected = PreselectedItem;
+                    int targetPreselectionIndex = visibleMenuItemsList.IndexOf(currentPreselected);
 
-                        if (currentPreselected == lastVisibleItem)
-                            PreselectItem(targetPreselectionIndex + VisibleMenuItems.Count());
-                        else
-                            PreselectItem(visibleMenuItemsList.IndexOf(lastVisibleItem));
-                        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<T>)preselectedItem.Item);
+                            return true;
+                    }
+                }
 
-                    case Key.Enter:
-                        var preselectedItem = VisibleMenuItems.ElementAt(targetPreselectionIndex);
-                        PreselectionConfirmed?.Invoke((DropdownMenuItem<T>)preselectedItem.Item);
-                        return true;
+                // 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.
+                if (e.Key == Key.Escape)
+                    return false;
 
-                    default:
-                        return base.OnKeyDown(e);
-                }
+                return base.OnKeyDown(e);
             }
 
             public bool OnPressed(KeyBindingPressEvent<PlatformAction> e)

If you find it more acceptable this way, I'll apply accordingly.

Copy link
Member Author

Choose a reason for hiding this comment

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

For the sake of moving forward, I applied this.

Comment on lines 727 to 729
public abstract bool MatchingFilter { get; set; }

public abstract bool FilteringActive { set; }
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is an API break. Consumer classes will have to implement this. Even if they won't / can't do anything with it.

I'd argue we should avoid that if possible.

Copy link
Member Author

@frenzibyte frenzibyte Dec 4, 2023

Choose a reason for hiding this comment

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

Moved to DropdownMenu and changed to a singular virtual method.

Comment on lines +26 to +30
public bool AlwaysShowSearchBar
{
get => SearchBar.AlwaysDisplayOnFocus;
set => SearchBar.AlwaysDisplayOnFocus = value;
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

AlwaysShowSearchBar, AlwaysDisplayOnFocus... can we pick one naming convention and stick with it?

Copy link
Member Author

Choose a reason for hiding this comment

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

AlwaysShowSearchBar is the general name for the flag, AlwaysDisplayOnFocus is local to the search bar component to clearly define what it would do. Should I just omit the OnFocus part, or do you have a better shared name to propose?

/// - If the dropdown search bar contains text, this method will reset it.
/// - If the dropdown is open, this method wil close it.
/// </summary>
public bool Back()
Copy link
Collaborator

Choose a reason for hiding this comment

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

This is a weird method and should probably just be inlined at its only usage.

Copy link
Member Author

Choose a reason for hiding this comment

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

This method is intentionally separated as it'll be called from OsuDropdown when it handles the Back action. Naming is open for suggestions, I thought of TryClose at first, maybe go fully explicit and name it ClearSearchOrClose.

Comment on lines 220 to 267

Header.Action = Menu.Toggle;
Header.ToggleMenu = Menu.Toggle;
Header.ChangeSelection += selectionKeyPressed;

Header.SearchTerm.ValueChanged += t => Menu.SearchTerm = t.NewValue;

Header.State.BindTo(State);

Menu.RelativeSizeAxes = Axes.X;
Menu.PreselectionConfirmed += preselectionConfirmed;
Menu.FilterCompleted += menuFilterCompleted;
Menu.StateChanged += state => State.Value = state;

State.ValueChanged += state => Menu.State = state.NewValue;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Oh jesus christmas. My brain just shut down at this number of callbacks. I'm not sure how I'm ever to review that this all coordinates correctly.

Can this be somehow simplified to not require this level of coordination?

Copy link
Member Author

Choose a reason for hiding this comment

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

I've removed the existence of State in Dropdown to keep things simpler, but beyond that, I'm not sure anything else can ever be changed without changing non-bindable properties to become bindables (like SearchTerm) and cause breaking changes.

@frenzibyte
Copy link
Member Author

Comparison before/after latest changes, to ensure visual correctness:

dropdown text box TestBrowserTextBox
before CleanShot 2023-12-11 at 06 46 23@2x CleanShot 2023-12-11 at 06 45 49@2x CleanShot 2023-12-11 at 15 47 51@2x
after CleanShot 2023-12-11 at 10 29 14@2x CleanShot 2023-12-11 at 06 48 03@2x CleanShot 2023-12-11 at 10 32 27@2x

Notice caret is placed and sized correctly on the dropdown search bar. Also notice caret got shorter in TestBrowserTextBox after changes, that is actually the correct size for it, since the text box was previously using a smaller font indirectly (by modifying the height of TextFlow), thus the caret was taller than the text height.

@peppy peppy merged commit ba569bf into ppy:master Dec 13, 2023
18 of 20 checks passed
@frenzibyte frenzibyte deleted the dropdown-search branch December 13, 2023 03:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants