Skip to content

Commit 791fff0

Browse files
authored
Searchable context menu (#3669)
* Added `Menu.Searchable = true` to add a search text entry to any context menu * Made gameobject context menu searchable * Made SceneViewportWidget context menu searchable * Made assetbrowser context menu searchable https://files.facepunch.com/antopilo/1b2311b1/sbox-dev_Rg2lhrL0KY.mp4
1 parent 1dfd1de commit 791fff0

6 files changed

Lines changed: 248 additions & 51 deletions

File tree

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
using System;
2+
3+
namespace Editor
4+
{
5+
/// <summary>
6+
/// Identical to Menu except DeleteOnClose defaults to true.
7+
/// Can optionally be made searchable by setting <see cref="Searchable"/> to true before opening.
8+
/// </summary>
9+
/// <example>
10+
/// <code>
11+
/// var menu = new ContextMenu { Searchable = true };
12+
/// menu.AddOption( "Option 1", action: () => {} );
13+
/// menu.OpenAtCursor();
14+
/// </code>
15+
/// </example>
16+
public class ContextMenu : Menu
17+
{
18+
private record struct CachedItem( Option Option, string FullPath, int Index );
19+
20+
private List<CachedItem> _cachedOptions = [];
21+
private List<(Menu Menu, int Index)> _cachedMenus = [];
22+
private int _originalOptionCount;
23+
private bool _isCached;
24+
25+
private LineEdit _searchBox;
26+
27+
/// <summary>
28+
/// Adds a search bar in the context menu. Useful for big menus
29+
/// </summary>
30+
public bool Searchable { get; set; }
31+
32+
33+
public ContextMenu( Widget parent = null ) : base( parent )
34+
{
35+
DeleteOnClose = true;
36+
}
37+
38+
protected override void OnAboutToShow()
39+
{
40+
base.OnAboutToShow();
41+
42+
if ( Searchable && _searchBox == null )
43+
{
44+
_searchBox = new LineEdit( this )
45+
{
46+
PlaceholderText = "⌕ Search...",
47+
MinimumWidth = 200
48+
};
49+
50+
_searchBox.SetStyles( "font-size: 8pt; padding: 4px 16px; margin: 4px 2px 2px 4px;" );
51+
_searchBox.TextEdited += SearchMenu;
52+
53+
InsertWidgetAt( _searchBox, 0 );
54+
55+
_searchBox.Focus();
56+
}
57+
}
58+
59+
private void CacheMenu()
60+
{
61+
if ( _isCached ) return;
62+
63+
_originalOptionCount = Options.Count;
64+
_cachedOptions = [.. GetAllOptionsRecursive().Select( ( x, i ) => new CachedItem( x.Option, x.FullPath, i ) )];
65+
_cachedMenus = [.. Menus.Select( ( x, i ) => (x, i) )];
66+
_isCached = true;
67+
}
68+
69+
private void SearchMenu( string searchText )
70+
{
71+
CacheMenu();
72+
ClearItems();
73+
74+
if ( string.IsNullOrWhiteSpace( searchText ) )
75+
{
76+
// Restore original menu structure
77+
var directOptions = _cachedOptions.Take( _originalOptionCount );
78+
AddOptions( directOptions );
79+
AddMenus( _cachedMenus.OrderBy( x => x.Index ) );
80+
return;
81+
}
82+
83+
var matches = _cachedOptions
84+
.Where( x => x.FullPath.Contains( searchText, StringComparison.OrdinalIgnoreCase ) );
85+
86+
var matchingMenus = _cachedMenus
87+
.Where( x => x.Menu.Title?.Contains( searchText, StringComparison.OrdinalIgnoreCase ) ?? false );
88+
89+
AddOptions( matches );
90+
AddMenus( matchingMenus );
91+
}
92+
93+
// Add options and menus from cached menu
94+
private void AddOptions( IEnumerable<CachedItem> items )
95+
{
96+
foreach ( var item in items )
97+
{
98+
_menu.addAction( item.Option._action );
99+
Options.Add( item.Option );
100+
}
101+
}
102+
103+
private void AddMenus( IEnumerable<(Menu Menu, int Index)> menus )
104+
{
105+
foreach ( var (menu, _) in menus )
106+
{
107+
var action = menu.GetParentAction();
108+
if ( action.IsValid )
109+
{
110+
_menu.addAction( action );
111+
Menus.Add( menu );
112+
}
113+
}
114+
}
115+
116+
private void ClearItems()
117+
{
118+
var options = Options.ToArray();
119+
foreach ( var option in options )
120+
{
121+
RemoveOption( option );
122+
}
123+
124+
var menus = Menus.ToArray();
125+
foreach ( var menu in menus )
126+
{
127+
menu.RemoveFromParent();
128+
}
129+
}
130+
}
131+
}
132+

engine/Sandbox.Tools/Qt/Menu.cs

Lines changed: 90 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,6 @@ public string Icon
3030
}
3131
}
3232

33-
private bool _toolTipsVisible;
34-
3533
/// <summary>
3634
/// <para>
3735
/// This property holds whether tooltips of menu actions should be visible.
@@ -45,12 +43,14 @@ public string Icon
4543
/// </summary>
4644
public bool ToolTipsVisible
4745
{
48-
get => _toolTipsVisible;
49-
set => _menu.setToolTipsVisible( _toolTipsVisible = value );
46+
get => _menu.toolTipsVisible();
47+
set => _menu.setToolTipsVisible( value );
5048
}
5149

5250
private QAction _parentAction;
5351

52+
internal QAction GetParentAction() => _parentAction;
53+
5454
public override string ToolTip
5555
{
5656
get => _parentAction.IsValid ? _parentAction.toolTip() : base.ToolTip;
@@ -73,10 +73,6 @@ public Menu( Widget parent = null ) : base( false )
7373
{
7474
var ptr = Native.QMenu.Create( parent?._widget ?? default );
7575
NativeInit( ptr );
76-
77-
// This is 99% the wanted behaviour, and 99% forgotten about
78-
// meaning we're ending up with a shit load of unused menus
79-
// hidden, just existing.
8076
DeleteOnClose = true;
8177
}
8278

@@ -189,14 +185,63 @@ public T AddWidget<T>( T widget ) where T : Widget
189185
return widget;
190186
}
191187

188+
/// <summary>
189+
/// Insert a widget at a specific position in the menu
190+
/// </summary>
191+
internal T InsertWidgetAt<T>( T widget, int position ) where T : Widget
192+
{
193+
var widgetAction = Native.CQNoDeleteWidgetAction.Create( _object );
194+
widgetAction.setDefaultWidget( widget._widget );
195+
196+
if ( position == 0 )
197+
{
198+
// Insert at the very beginning - before the first item
199+
var firstAction = Options.FirstOrDefault()?._action ?? Menus.FirstOrDefault()?.GetParentAction();
200+
if ( firstAction.HasValue && !firstAction.Value.IsNull )
201+
{
202+
_menu.insertAction( firstAction.Value, widgetAction );
203+
}
204+
else
205+
{
206+
_menu.addAction( widgetAction );
207+
}
208+
}
209+
else
210+
{
211+
// Get all actions and insert at the specified position
212+
var actions = new List<QAction>();
213+
foreach ( var opt in Options )
214+
{
215+
actions.Add( opt._action );
216+
}
217+
foreach ( var menu in Menus )
218+
{
219+
var action = menu.GetParentAction();
220+
if ( action.IsValid )
221+
actions.Add( action );
222+
}
223+
224+
if ( position < actions.Count && actions[position].IsValid )
225+
{
226+
_menu.insertAction( actions[position], widgetAction );
227+
}
228+
else
229+
{
230+
_menu.addAction( widgetAction );
231+
}
232+
}
233+
234+
_widgets.Add( (widget, widgetAction) );
235+
return widget;
236+
}
237+
192238
private class Heading : Widget
193239
{
194240
public Label Label { get; }
195241

196242
public Heading( string title ) : base( null )
197243
{
198244
Layout = Layout.Row();
199-
200245
Layout.Margin = 6;
201246
Layout.Spacing = 4;
202247

@@ -250,10 +295,10 @@ public Menu FindOrCreateMenu( string name )
250295
return AddMenu( name );
251296
}
252297

253-
List<Menu> Menus = new();
254-
List<Option> Options = new();
298+
protected List<Menu> Menus = [];
299+
protected List<Option> Options = [];
255300

256-
private readonly List<(Widget Widget, QAction Action)> _widgets = new();
301+
private readonly List<(Widget Widget, QAction Action)> _widgets = [];
257302

258303
public bool HasOptions => Options.Count > 0;
259304
public bool HasMenus => Menus.Count > 0;
@@ -266,6 +311,39 @@ public Menu FindOrCreateMenu( string name )
266311
.Select( x => x.Widget )
267312
.ToArray();
268313

314+
/// <summary>
315+
/// Get all options in this menu and all submenus recursively
316+
/// </summary>
317+
internal List<(Option Option, string FullPath)> GetAllOptionsRecursive( string pathPrefix = "" )
318+
{
319+
var result = new List<(Option, string)>();
320+
321+
foreach ( var option in Options )
322+
{
323+
var fullPath = string.IsNullOrEmpty( pathPrefix ) ? option.Text : $"{pathPrefix} > {option.Text}";
324+
result.Add( (option, fullPath) );
325+
}
326+
327+
foreach ( var menu in Menus )
328+
{
329+
var newPath = string.IsNullOrEmpty( pathPrefix ) ? menu.Title : $"{pathPrefix} > {menu.Title}";
330+
result.AddRange( menu.GetAllOptionsRecursive( newPath ) );
331+
}
332+
333+
return result;
334+
}
335+
336+
/// <summary>
337+
/// Remove this menu from its parent safely
338+
/// </summary>
339+
internal void RemoveFromParent()
340+
{
341+
if ( ParentMenu != null && _parentAction.IsValid )
342+
{
343+
ParentMenu._menu.removeAction( _parentAction );
344+
}
345+
}
346+
269347
public Menu AddMenu( string name, string icon = null )
270348
{
271349
var menu = new Menu( name, this ) { ParentMenu = this };
@@ -413,15 +491,4 @@ public Option SelectedOption
413491
}
414492
}
415493
}
416-
417-
/// <summary>
418-
/// Identical to Menu except DeleteOnClose defaults to true
419-
/// </summary>
420-
public class ContextMenu : Menu
421-
{
422-
public ContextMenu( Widget parent = null ) : base( parent )
423-
{
424-
DeleteOnClose = true;
425-
}
426-
}
427494
}

game/addons/tools/Code/Editor/AssetBrowser/Nodes/FolderNode.cs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,6 @@ public override bool OnContextMenu()
9292
Context = this
9393
};
9494

95-
var menu = fcm.Menu.AddMenu( "New", "note_add" );
96-
CreateAsset.AddOptions( menu, Value );
97-
9895
EditorEvent.Run( "folder.contextmenu", fcm );
9996

10097
if ( !fcm.Menu.HasOptions )

0 commit comments

Comments
 (0)