Editor/PropertyFinderWindow.cs
using System;
using Editor;
using Sandbox;
using System.Collections.Generic;
using System.Linq;
namespace PropertyFinder;
/// <summary>
/// Searches the selected objects' component properties by name and shows only the matches,
/// editable in place. The stock inspector's filter is private, so this is its own window:
/// frameless, opened with CTRL+F, hidden when focus or a click goes elsewhere in the editor.
/// </summary>
public sealed class PropertyFinderWindow : Widget
{
private const int MaxObjects = 16;
private const float Grip = 14;
private const string PositionCookie = "PropertyFinder.Position";
private const string SizeCookie = "PropertyFinder.Size";
private static PropertyFinderWindow instance;
// Frames left in which to (re)claim keyboard focus after opening.
private static int pendingFocus;
private readonly LineEdit search;
private readonly Label status;
private readonly ScrollArea scroll;
private int lastHash;
// Hiding on focus loss only arms once focus has actually reached the window, otherwise
// the viewport still holding focus in the frames after CTRL+F would hide it at once.
private bool armed;
private bool mouseWasDown;
private bool dragging;
private bool resizing;
private Vector2 grabOffset;
// Stored whole-component, like the stock inspector, so undo survives a later delete.
private IDisposable undoScope;
private PropertyFinderWindow( Widget parent ) : base( parent )
{
WindowFlags = WindowFlags.Tool | WindowFlags.FramelessWindowHint;
NoSystemBackground = true;
TranslucentBackground = true;
MinimumSize = new Vector2( 260, 120 );
Layout = Layout.Column();
Layout.Margin = 10;
Layout.Spacing = 6;
search = Layout.Add( new LineEdit( this ) );
search.PlaceholderText = "Search properties on the selection…";
search.TextEdited += _ => Rebuild();
status = Layout.Add( new Label( "", this ) );
// Let presses fall through so the status line doubles as a drag handle.
status.TransparentForMouseEvents = true;
scroll = Layout.Add( new ScrollArea( this ), 1 );
scroll.Canvas = new Widget( scroll );
scroll.Canvas.Layout = Layout.Column();
scroll.Canvas.Layout.Spacing = 4;
Size = EditorCookie.Get( SizeCookie, new Vector2( 380, 460 ) );
var fallback = EditorWindow.ScreenRect.Center - Size * 0.5f;
Position = EditorCookie.Get( PositionCookie, fallback );
Rebuild();
}
// The mesh Edge/Vertex tools also bind CTRL+F (weld UVs) inside the scene view.
[Menu( "Editor", "Edit/Property Finder" )]
[Shortcut( "propertyfinder.open", "CTRL+F" )]
public static void Open()
{
if ( instance is null || !instance.IsValid() )
instance = new PropertyFinderWindow( EditorWindow );
instance.ApplyStyle();
instance.armed = false;
instance.mouseWasDown = true; // ignore the click that may have opened it (menu)
instance.Show();
instance.Raise();
// Focusing inside the key press doesn't stick: activation lands afterwards and takes
// focus back. Claim it over the next few frames instead.
pendingFocus = 5;
instance.Rebuild();
}
private void Dismiss()
{
dragging = resizing = false;
StoreGeometry();
Hide();
}
private void StoreGeometry()
{
EditorCookie.Set( PositionCookie, Position );
EditorCookie.Set( SizeCookie, Size );
}
[EditorEvent.Frame]
private void Frame()
{
if ( !ReferenceEquals( instance, this ) || !Visible ) return;
if ( pendingFocus > 0 )
{
pendingFocus--;
// Select only when (re)gaining focus, so text typed during the retries is never selected.
if ( !search.IsFocused )
{
search.Focus( true );
search.SelectAll();
}
}
var focus = Editor.Application.FocusWidget;
var focusInside = Contains( focus );
if ( focusInside ) armed = true;
// Focus moved to another part of the editor (inspector, hierarchy, 3D view).
// Focus in a separate window (a colour picker or asset picker opened from a row) is ignored.
if ( armed && focus.IsValid() && !focusInside && IsInMainWindow( focus ) )
{
Dismiss();
return;
}
// Clicking the 3D view doesn't always take keyboard focus, so a fresh press outside
// the window counts too, unless one of the editor's sticky popups is open.
var mouseDown = Editor.Application.MouseButtons != MouseButtons.None;
var pressed = mouseDown && !mouseWasDown;
mouseWasDown = mouseDown;
// A row's right-click menu can hang past the window edge; clicking it is not "outside".
if ( pressed && !ScreenRect.IsInside( Editor.Application.CursorPosition ) && StickyPopup.All.Count == 0 && !OverOwnPopup() )
{
Dismiss();
return;
}
ExtendContextMenus();
// Rebuild only when the query, selection or component lists change. Rebuilding every
// frame would destroy the control being edited and drop its focus mid-drag.
if ( BuildHash() != lastHash ) Rebuild();
}
// The window lives for the whole editor session (hidden, never destroyed), so anything set
// only in the constructor misses hot-reloaded changes. Styling is re-applied here instead.
private void ApplyStyle()
{
search.SetStyles( $"background-color: {Theme.ControlBackground.Darken( 0.4f ).Hex};" );
}
[EditorEvent.Hotload]
private void Hotload()
{
ApplyStyle();
Rebuild();
}
private bool Contains( Widget widget )
{
// Walk parents by hand: popups parented to a row are separate windows, and still ours.
for ( var w = widget; w is not null; w = w.Parent )
if ( ReferenceEquals( w, this ) ) return true;
return false;
}
private static bool IsInMainWindow( Widget widget )
{
var root = widget;
while ( root.Parent is not null ) root = root.Parent;
return ReferenceEquals( root, EditorWindow );
}
protected override void OnKeyPress( KeyEvent e )
{
if ( e.Key == KeyCode.Escape )
{
Dismiss();
e.Accepted = true;
return;
}
base.OnKeyPress( e );
}
// No title bar: drag from the margins or status line, resize from the bottom-right corner.
private bool InGrip( Vector2 local ) => local.x > Width - Grip && local.y > Height - Grip;
// Controls like dropdowns let their press fall through to this window. Only the bare
// margins and the status line may start a drag, never the search box or results.
private bool OnFreeArea( Vector2 local )
{
foreach ( var child in new Widget[] { search, scroll } )
if ( new Rect( child.Position, child.Size ).IsInside( local ) ) return false;
return true;
}
private static bool LeftHeld => Editor.Application.MouseButtons.HasFlag( MouseButtons.Left );
protected override void OnMousePress( MouseEvent e )
{
if ( !e.LeftMouseButton ) return;
resizing = InGrip( e.LocalPosition );
dragging = !resizing && OnFreeArea( e.LocalPosition );
if ( !dragging && !resizing ) return;
grabOffset = resizing ? Size - e.LocalPosition : e.LocalPosition;
e.Accepted = true;
}
protected override void OnMouseMove( MouseEvent e )
{
// A popup that grabs the mouse swallows the release; never keep following a button
// that is no longer held.
if ( (dragging || resizing) && !LeftHeld )
{
StoreGeometry();
dragging = resizing = false;
return;
}
if ( resizing )
Size = new Vector2( MathF.Max( MinimumSize.x, e.LocalPosition.x + grabOffset.x ), MathF.Max( MinimumSize.y, e.LocalPosition.y + grabOffset.y ) );
else if ( dragging )
Position = e.ScreenPosition - grabOffset;
}
protected override void OnMouseReleased( MouseEvent e )
{
if ( dragging || resizing ) StoreGeometry();
dragging = resizing = false;
}
protected override void OnPaint()
{
Paint.Antialiasing = true;
Paint.SetPen( Theme.ControlBackground.Lighten( 0.3f ) );
Paint.SetBrush( Theme.WindowBackground );
Paint.DrawRect( LocalRect.Shrink( 0.5f ), 6 );
// Resize grip: two short diagonals in the corner.
Paint.SetPen( Theme.Text.WithAlpha( 0.3f ), 1 );
var c = LocalRect.BottomRight - 4;
Paint.DrawLine( c - new Vector2( 8, 0 ), c - new Vector2( 0, 8 ) );
Paint.DrawLine( c - new Vector2( 4, 0 ), c - new Vector2( 0, 4 ) );
}
private static GameObject[] Selected()
{
var session = SceneEditorSession.Active;
if ( session is null ) return [];
return session.Selection.OfType<GameObject>().Where( go => go.IsValid() ).Take( MaxObjects ).ToArray();
}
private int BuildHash()
{
var hc = new HashCode();
hc.Add( search.Text );
foreach ( var go in Selected() )
{
hc.Add( go.Id );
foreach ( var component in go.Components.GetAll() )
if ( component.IsValid() ) hc.Add( component.Id );
}
return hc.ToHashCode();
}
private void Rebuild()
{
lastHash = BuildHash();
var canvas = scroll.Canvas;
using var _ = SuspendUpdates.For( canvas );
canvas.Layout.Clear( true );
rows.Clear();
var terms = (search.Text ?? "").Split( ' ', StringSplitOptions.RemoveEmptyEntries );
var objects = Selected();
if ( objects.Length == 0 )
{
status.Text = "Select an object in the hierarchy.";
canvas.Layout.AddStretchCell();
return;
}
if ( terms.Length == 0 )
{
status.Text = "Type part of a property name. Spaces narrow the search.";
canvas.Layout.AddStretchCell();
return;
}
var matches = 0;
foreach ( var go in objects )
{
foreach ( var component in go.Components.GetAll() )
{
if ( !component.IsValid() || component.Flags.HasFlag( ComponentFlags.Hidden ) ) continue;
var so = component.GetSerialized();
var hits = so.Where( p => Matches( p, terms ) ).ToArray();
if ( hits.Length == 0 ) continue;
matches += hits.Length;
var header = canvas.Layout.Add( new Label( $"{go.Name} › {so.TypeTitle}", canvas ) );
header.SetStyles( "font-weight: 600; padding-top: 6px;" );
so.OnPropertyStartEdit += p => StartEdit( p, component );
so.OnPropertyChanged += p => Changed( p, component );
so.OnPropertyFinishEdit += p => FinishEdit( p, component );
// Rows are added one by one instead of via AddObject, which would rebuild [Feature]
// tabs and hide matches behind unselected tabs. Tab and group names become sections.
foreach ( var section in hits.GroupBy( SectionName ) )
{
if ( !string.IsNullOrEmpty( section.Key ) )
{
var sub = canvas.Layout.Add( new Label( section.Key, canvas ) );
sub.SetStyles( $"color: {Theme.Text.WithAlpha( 0.55f ).Hex}; padding-top: 2px;" );
}
var sheet = new ControlSheet();
sheet.IncludePropertyNames = true;
foreach ( var property in section )
{
var control = sheet.AddRow( property );
if ( control.IsValid() ) rows[control] = (component, property.Name);
}
canvas.Layout.Add( sheet );
}
}
}
status.Text = matches == 0
? "No matching properties."
: $"{matches} match{(matches == 1 ? "" : "es")}" + (objects.Length == MaxObjects ? $" (first {MaxObjects} objects)" : "");
canvas.Layout.AddStretchCell();
}
// Same visibility rules as the stock ComponentSheet, minus the Advanced toggle:
// finding an advanced property is a reason to search.
private static bool Matches( SerializedProperty p, string[] terms )
{
if ( p.PropertyType is null ) return false;
if ( p.PropertyType.IsAssignableTo( typeof( Delegate ) ) && p.Name.StartsWith( "OnComponent" ) ) return false;
if ( !p.IsMethod && !p.HasAttribute<PropertyAttribute>() ) return false;
var haystack = $"{p.Name} {p.DisplayName} {SectionName( p )}";
return terms.All( t => haystack.Contains( t, StringComparison.OrdinalIgnoreCase ) );
}
// "Feature tab / Group", either part optional.
private static string SectionName( SerializedProperty p )
{
var feature = p.TryGetAttribute<FeatureAttribute>( out var f ) ? f.Title : null;
var parts = new[] { feature, p.GroupName }.Where( s => !string.IsNullOrWhiteSpace( s ) ).Distinct();
return string.Join( " / ", parts );
}
// --- "Show in Inspector" -----------------------------------------------------------------
// The stock row builds its context menu internally with no extension hook, so the option is
// appended to that menu once it has opened: same Copy/Paste/Reset/Jump to code, plus ours.
private readonly Dictionary<ControlWidget, (Component Component, string Property)> rows = new();
private readonly HashSet<ContextMenu> extendedMenus = new();
private int menuScanFrames;
private bool rightWasDown;
private bool OverOwnPopup()
{
// Open menus aren't reachable by walking the widget tree (measured), so ask what's hovered.
for ( var w = Editor.Application.HoveredWidget; w is not null; w = w.Parent )
if ( w is Menu ) return true;
return false;
}
private void ExtendContextMenus()
{
var rightDown = Editor.Application.MouseButtons.HasFlag( MouseButtons.Right );
if ( rightDown && !rightWasDown )
{
// Remember which row was right-clicked. On Windows the menu opens on RELEASE,
// so keep looking for it for about a second.
pressedRow = RowUnder( Editor.Application.HoveredWidget );
menuScanFrames = 60;
}
rightWasDown = rightDown;
if ( menuScanFrames <= 0 ) return;
menuScanFrames--;
extendedMenus.RemoveWhere( m => !m.IsValid() );
// The open menu isn't reachable by walking the widget tree (measured: 0 found), but
// it pops up under the cursor, so the hovered widget leads to it.
ContextMenu menu = null;
for ( var w = Editor.Application.HoveredWidget; w is not null && menu is null; w = w.Parent )
menu = w as ContextMenu;
if ( menu is null || pressedRow is null || extendedMenus.Contains( menu ) ) return;
extendedMenus.Add( menu );
menuScanFrames = 0;
var target = pressedRow.Value;
menu.AddSeparator();
menu.AddOption( "Show in Inspector", "manage_search", () => ShowInInspector( target.Component, target.Property ) );
}
private (Component Component, string Property)? pressedRow;
// Climb from the widget under the cursor to the nearest ancestor holding a tracked control,
// never past the results canvas (which holds every row).
private (Component Component, string Property)? RowUnder( Widget w )
{
for ( ; w is not null && !ReferenceEquals( w, scroll.Canvas ) && !ReferenceEquals( w, this ); w = w.Parent )
{
if ( w is ControlWidget c && Lookup( c ) is { } direct ) return direct;
var controls = w.GetDescendants<ControlWidget>().Select( Lookup ).Where( x => x is not null ).Distinct().ToArray();
if ( controls.Length == 1 ) return controls[0];
if ( controls.Length > 1 ) return null;
}
return null;
}
// Match by the control's property, not by widget identity: a widget found by walking the
// tree may be a different managed wrapper than the one AddRow returned.
private (Component Component, string Property)? Lookup( ControlWidget control )
{
if ( control is null ) return null;
if ( rows.TryGetValue( control, out var direct ) ) return direct;
var p = control.SerializedProperty;
if ( p is null ) return null;
foreach ( var entry in rows.Values )
if ( Targets( p, entry.Component, entry.Property ) ) return entry;
return null;
}
private static (Component Component, string Property) reveal;
private static int revealFrames;
private void ShowInInspector( Component component, string property )
{
if ( !component.IsValid() ) return;
var session = SceneEditorSession.Resolve( component );
session?.Selection.Set( component.GameObject );
Dismiss();
EditorWindow.DockManager.RaiseDock( "Inspector" );
// The inspector rebuilds for the new selection over the next frames; keep looking.
reveal = (component, property);
revealFrames = 60;
}
private static bool Targets( SerializedProperty p, Component component, string name )
=> p is not null && p.Name == name && (p.Parent?.Targets?.Contains( component ) ?? false);
[EditorEvent.Frame]
private static void RevealFrame()
{
if ( revealFrames <= 0 ) return;
revealFrames--;
var (component, name) = reveal;
var inspector = EditorWindow.GetDescendants<Inspector>().FirstOrDefault( i => i.IsValid() && i.Visible );
if ( !component.IsValid() || inspector is null ) return;
// A collapsed component builds no rows. Expanding goes through the stock SetExpanded, which
// is internal (the header's own OnExpandChanged is protected), so reflection it is; if a
// future editor renames it, the reveal just stops at the component.
var header = inspector.GetDescendants<ComponentSheetHeader>().FirstOrDefault( h => ReferenceEquals( h.GetComponent(), component ) );
if ( header is not null && !header.IsExpanded )
{
var sheet = header.Parent as ComponentSheet;
var setExpanded = typeof( ComponentSheet ).GetMethod( "SetExpanded", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public );
if ( sheet is not null && setExpanded is not null )
{
header.IsExpanded = true;
setExpanded.Invoke( sheet, [true] );
header.Update();
return; // rows build this frame; look for them next frame
}
}
// A property on an unselected [Feature] tab has no row yet: select its tab first.
foreach ( var tab in inspector.GetDescendants<FeatureTabOption>() )
{
if ( tab.IsSelected || tab.Feature.Properties is null ) continue;
if ( !tab.Feature.Properties.Any( p => Targets( p, component, name ) ) ) continue;
inspector.GetDescendants<FeatureTabWidget>().FirstOrDefault( w => w.GetDescendants<FeatureTabOption>().Contains( tab ) )?.Select( tab );
return; // the page builds this frame; find the row next frame
}
var control = inspector.GetDescendants<ControlWidget>()
.FirstOrDefault( c => c.IsValid() && c.Visible && Targets( c.SerializedProperty, component, name ) );
if ( control is null ) return;
revealFrames = 0;
var row = control.Parent ?? control;
for ( var w = row.Parent; w is not null; w = w.Parent )
{
if ( w is ScrollArea area )
{
area.MakeVisible( row );
break;
}
}
_ = new FlashOverlay( row );
}
private void StartEdit( SerializedProperty property, Component component )
{
var session = SceneEditorSession.Resolve( component );
if ( session is null ) return;
using var scene = session.Scene.Push();
undoScope?.Dispose();
undoScope = session.UndoScope( $"Edit {property.Name} on {component.GetType().Name}" ).WithComponentChanges( component ).Push();
property.DispatchPreEdited();
}
private static void Changed( SerializedProperty property, Component component )
{
using var scene = component.Scene?.Push();
property.DispatchEdited();
}
private void FinishEdit( SerializedProperty property, Component component )
{
using var scene = component.Scene?.Push();
property.DispatchEdited();
undoScope?.Dispose();
undoScope = null;
}
}
/// <summary>Briefly tints a widget so the eye lands on it after a jump.</summary>
file sealed class FlashOverlay : Widget
{
private const float Duration = 1.2f;
private readonly RealTimeSince age = 0;
public FlashOverlay( Widget target ) : base( target )
{
TransparentForMouseEvents = true;
Position = 0;
Size = target.Size;
Show();
}
[EditorEvent.Frame]
private void Frame()
{
if ( age > Duration ) { Destroy(); return; }
if ( Parent is not null ) Size = Parent.Size;
Update();
}
protected override void OnPaint()
{
var fade = 1 - MathF.Min( 1, age / Duration );
Paint.ClearPen();
Paint.SetBrush( Theme.Primary.WithAlpha( 0.35f * fade ) );
Paint.DrawRect( LocalRect, 3 );
}
}