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