s&box Package Code Search

Search C# source code, UI razor templates, shaders, and configs across s&box packages.

Showing code results for query: * (1 total matches found)
yugi.propertyfinder / Editor/PropertyFinderWindow.cs
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"
        }
    ]
}