Editor/SearchFilter.cs
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using System.Text.Json;
using Sandbox;
using Sandbox.Internal;
using System.Threading;
using System.Threading.Tasks;
namespace Editor;
/// <summary>
/// Search Filter – dockable inspector search panel that lets you scan and edit
/// properties across the selected GameObject and its children.
///
/// Live mode updates as you type and when selection changes.
/// Safe mode only updates when the user explicitly confirms a search.
/// </summary>
[Dock( "Editor", "Search Filter", "search" )]
public sealed class InspectorSearchTool : Widget
{
private LineEdit searchBox;
private IconButton clearButton;
private IconButton progressIcon;
private Label matchLabel;
private Label safeBaseLabel;
private Label scanStatusLabel;
private Label scanTimeLabel;
private Widget listContainer;
private Label manualModeHintLabel;
private Label selectedPrefixLabel;
private Label selectedNameLabel;
private Label selectedCountsLabel;
private Label typeBoolLabel;
private Label typeFloatLabel;
private Label typeStringLabel;
private Label typeEnumLabel;
private Label typeComplexLabel;
private Label typeErrorsLabel;
// Top progress visuals removed (legacy) — keep references null and unused.
private Widget noResultsContainer;
private Label noResultsLabel;
// When true, deselecting the current object clears the panel and shows the
// "no selection" hint. This can be turned off from the cog menu.
private bool clearOnDeselect = false;
// When true, Safe mode keeps existing results when you switch selection;
// when false, selecting a new object shows only the selection hint.
private bool keepResultsOnSelectionChange = true;
// When true, Safe mode will still auto-populate the list when selection changes
// (but keeps manual search behaviour). When false, selection does not rebuild.
private bool autoPopulateOnSelectionInSafe;
// When true, component properties are only reflected and materialised when
// the user explicitly loads a component (lazy mode). When false, all
// properties are built eagerly on each rebuild.
private bool lazyLoadProperties;
// When true, nested/hidden boolean members discovered inside a small set of
// known option structs (for example RenderOptions) are exposed as separate
// rows (e.g. Light Color.IsSdr). When false, those flattened helpers are
// omitted to keep results tidier and faster.
private bool showHiddenNestedBools = true;
private const int MaxSerializedChildRows = 12;
private const int MaxSerializedChildDepth = 1;
// When true, property type names (e.g. SoundEvent, Soundscape) are included
// in the "name" search scope so searching for "sound" will match these even
// if the property is just called "Event".
private bool includeTypeNamesInNameSearch = true;
// When true, simple list-like containers (e.g. hide body groups lists) are
// flattened into individual rows so their entries are searchable.
private bool flattenLists = true;
// Experimental deep nested-bool mode (all complex types) has been disabled
// for now – it was too slow in large scenes. Left here commented for
// potential future use.
// private bool showDeepNestedBools;
// When true, Modifinder writes simple timing/debug information to the log
// whenever rows are rebuilt or filters are applied.
private bool debugLogging;
// When debug logging is enabled, we keep track of which complex container
// types we have inspected for nested options so we can log them once per
// rebuild without flooding the console.
private readonly HashSet<string> debugNestedTypeNames = new();
// When true, only matching property rows are of interest and hierarchy
// styling is suppressed (folders/components are not emphasised). This is
// a compact "field only" mode for power searching.
private bool leanMode = false;
// Guard to prevent the clear (X) button from re-triggering live search logic
// when we are only interested in clearing the text field.
private bool suppressSearchChanged;
private GameObject target;
// Filters
private bool includeChildren = true;
private bool showBools = true;
private bool showNumeric = true;
private bool showText = true;
private bool showEnums = true;
private bool showComplex = true;
private bool filterErrorsOnly;
private enum MatchTargetMode
{
ComponentsOnly,
NamesOnly,
ComponentsAndNames
}
private MatchTargetMode matchTargetMode = MatchTargetMode.NamesOnly;
private enum ValueSearchMode
{
NamesOnly,
ValuesOnly,
NamesAndValues
}
private ValueSearchMode valueSearchMode = ValueSearchMode.NamesAndValues;
private enum SearchTriggerMode
{
Live,
Safe
}
private SearchTriggerMode searchTriggerMode = SearchTriggerMode.Safe;
private enum ValueFilterMode
{
All,
SetOnly,
UnsetOnly
}
private ValueFilterMode valueFilterMode = ValueFilterMode.All;
private readonly List<PropertyRow> rows = new();
private readonly List<RowModel> rowModels = new();
private readonly Dictionary<int, RowModel> rowModelLookup = new();
private readonly Dictionary<int, PropertyRow> rowLookup = new();
private readonly List<ComponentGroup> componentGroups = new();
private readonly List<GameObjectGroup> gameObjectGroups = new();
private readonly ComponentBuildQueue componentBuildQueue = new();
private readonly PropertyMetadataCache propertyMetadataCache = new();
private CancellationTokenSource rebuildCancellation;
private long rebuildSequence;
private readonly Dictionary<SelectionSignature, ScanJobResult> scanCache = new();
private readonly Queue<SelectionSignature> scanCacheOrder = new();
private const int MaxScanCacheEntries = 4;
private readonly Dictionary<string, RowFilterState> filterSnapshotCache = new();
private readonly Queue<string> filterSnapshotOrder = new();
private const int MaxFilterSnapshots = 6;
private readonly Dictionary<string, HashSet<int>> nameIndex = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, HashSet<int>> typeIndex = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, HashSet<int>> valueIndex = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, HashSet<int>> componentIndex = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, HashSet<int>> objectIndex = new(StringComparer.OrdinalIgnoreCase);
private bool rowsDirty;
private int nextRowId;
private string lastSearch = string.Empty;
private string pendingFilterText = string.Empty;
private bool filterDebouncePending;
private readonly Stopwatch filterDebounceStopwatch = new();
private const int FilterDebounceMs = 180;
private const int SafeBaseFilterDebounceMs = 320;
private bool filterInProgress;
private const int HeavyRowCountThreshold = 1200;
private bool safeBaseActive;
private string safeBaseSearchText;
private readonly HashSet<int> safeBaseRowIds = new();
private string[] activeIncludeTerms = Array.Empty<string>();
private string[] activeExcludeTerms = Array.Empty<string>();
private QueryFilters activeQueryFilters = new(
Array.Empty<string>(),
Array.Empty<string>(),
Array.Empty<string>(),
Array.Empty<string>(),
Array.Empty<string>() );
private bool pendingRebuild;
private bool isDisposed;
private int totalComponentCount;
private int builtComponentCount;
private float lastProgress01;
private bool gradualResults = false;
private bool showHeaderStats = true;
private bool showModeMessages = true;
private bool shorthandLabels;
private bool safeQueryDirty;
private CancellationTokenSource filterCancellation;
private long filterSequence;
private string BuildSearchFilterSummary()
{
string scope = includeChildren ? "scope:children" : "scope:selected";
string match = matchTargetMode switch
{
MatchTargetMode.ComponentsOnly => "match:comps",
MatchTargetMode.NamesOnly => "match:names",
MatchTargetMode.ComponentsAndNames => "match:comps+names",
_ => "match:comps+names"
};
string searchScope = valueSearchMode switch
{
ValueSearchMode.NamesOnly => "search:names",
ValueSearchMode.ValuesOnly => "search:values",
ValueSearchMode.NamesAndValues => "search:names+values",
_ => "search:names+values"
};
string values = valueFilterMode switch
{
ValueFilterMode.All => "values:all",
ValueFilterMode.SetOnly => "values:set",
ValueFilterMode.UnsetOnly => "values:unset",
_ => "values:all"
};
string types = "";
if ( showBools ) types += "bool,";
if ( showNumeric ) types += "float,";
if ( showText ) types += "string,";
if ( showEnums ) types += "enum,";
if ( showComplex ) types += "cx,";
if ( filterErrorsOnly ) types += "errors,";
var allTypesOn = showBools && showNumeric && showText && showEnums && showComplex;
if ( allTypesOn )
{
types = filterErrorsOnly ? "all,error," : "all,";
}
if ( types.EndsWith( "," ) ) types = types.Substring( 0, types.Length - 1 );
return $"{scope}, {match}, {searchScope}, {values}, types:{types}";
}
private static class SearchTokens
{
private static readonly char[] SplitChars = { ' ', '\t', '\r', '\n', '.', ',', ';', ':', '/', '\\', '-', '_', '+', '`', '(', ')', '[', ']', '{', '}', '<', '>', '@', '#', '!', '?', '"', '\'' };
public static string[] Tokenize( string value )
{
if ( string.IsNullOrEmpty( value ) )
return Array.Empty<string>();
var tokens = new List<string>(16);
var split = value.Split( SplitChars, StringSplitOptions.RemoveEmptyEntries );
foreach ( var s in split )
{
var lowered = s.ToLowerInvariant();
if ( !string.IsNullOrEmpty( lowered ) )
tokens.Add( lowered );
}
return tokens.Count == 0 ? Array.Empty<string>() : tokens.ToArray();
}
public static bool Contains( string[] tokens, string term )
{
if ( tokens == null || tokens.Length == 0 || string.IsNullOrEmpty( term ) )
return false;
var t = term.ToLowerInvariant();
return tokens.Any( tok => FuzzyContains( tok, t ) );
}
public static bool FuzzyContainsRaw( string haystack, string term )
{
if ( string.IsNullOrEmpty( haystack ) || string.IsNullOrEmpty( term ) )
return false;
return FuzzyContains( haystack, term );
}
// Match terms without getting tripped by punctuation (see sbox-public NodeQuery).
private static bool FuzzyContains( string str, string value )
{
return CultureInfo.InvariantCulture.CompareInfo.IndexOf(
str, value, CompareOptions.IgnoreCase | CompareOptions.IgnoreSymbols ) >= 0;
}
}
private static string HighlightMatches( string text, string[] terms )
{
if ( string.IsNullOrEmpty( text ) || terms == null || terms.Length == 0 )
return text ?? string.Empty;
var matches = new List<(int Start, int Length)>();
foreach ( var term in terms.OrderByDescending( t => t?.Length ?? 0 ) )
{
if ( string.IsNullOrEmpty( term ) )
continue;
var pattern = Regex.Escape( term );
foreach ( Match match in Regex.Matches( text, pattern, RegexOptions.IgnoreCase ) )
{
if ( match.Length == 0 )
continue;
var overlaps = matches.Any( m =>
match.Index < m.Start + m.Length && m.Start < match.Index + match.Length );
if ( overlaps )
continue;
matches.Add( (match.Index, match.Length) );
}
}
if ( matches.Count == 0 )
return text;
matches.Sort( (a, b) => a.Start.CompareTo( b.Start ) );
var sb = new StringBuilder( text.Length + matches.Count * 8 );
var lastIndex = 0;
foreach ( var match in matches )
{
if ( match.Start > lastIndex )
{
sb.Append( text.Substring( lastIndex, match.Start - lastIndex ) );
}
var matchedText = text.Substring( match.Start, match.Length );
sb.Append( "[" );
sb.Append( matchedText );
sb.Append( "]" );
lastIndex = match.Start + match.Length;
}
if ( lastIndex < text.Length )
{
sb.Append( text.Substring( lastIndex ) );
}
return sb.ToString();
}
private enum FilterModifier
{
None,
Not
}
private readonly record struct FilterPart( FilterModifier Modifier, string Value )
{
private static readonly Regex Pattern =
new( @"(?<modifier>-?)(?<value>(?:[^\s.,;\\\/""_-]|\\[\\""]|""(?:(?:[^\\""]|\\[\\""])+)"")+)" );
private static FilterModifier ParseModifier( string value )
{
return value switch
{
"-" => FilterModifier.Not,
_ => FilterModifier.None
};
}
private static readonly Regex EscapedChars = new( @"\\[\\""]|""" );
private static string FormatValue( string value )
{
return EscapedChars.Replace( value, x => x.Value switch
{
"\\\\" => "\\",
"\\\"" => "\"",
_ => ""
} ).Trim();
}
public static IReadOnlyList<FilterPart> Parse( string value )
{
if ( string.IsNullOrEmpty( value ) )
{
return Array.Empty<FilterPart>();
}
return Pattern.Matches( value )
.Select( x => new FilterPart(
ParseModifier( x.Groups["modifier"].Value ),
FormatValue( x.Groups["value"].Value ) ) )
.Where( x => !string.IsNullOrEmpty( x.Value ) )
.ToArray();
}
}
private readonly record struct QueryFilters(
string[] TypeTerms,
string[] ComponentTerms,
string[] ObjectTerms,
string[] NameTerms,
string[] ValueTerms );
private static readonly Regex KeyValuePattern = new( @"\b(?<key>\w+):(?<value>\S+)" );
private static void ParseSearchTerms( string rawText, out string[] includeTerms, out string[] excludeTerms, out QueryFilters filters )
{
rawText ??= string.Empty;
var typeTerms = new List<string>();
var componentTerms = new List<string>();
var objectTerms = new List<string>();
var nameTerms = new List<string>();
var valueTerms = new List<string>();
var kvMatches = KeyValuePattern.Matches( rawText );
if ( kvMatches.Count > 0 )
{
foreach ( Match match in kvMatches )
{
var key = match.Groups["key"].Value.ToLowerInvariant();
var value = match.Groups["value"].Value.Trim().Trim( '"' ).ToLowerInvariant();
if ( string.IsNullOrEmpty( value ) )
continue;
switch ( key )
{
case "type":
typeTerms.Add( value );
break;
case "component":
case "comp":
componentTerms.Add( value );
break;
case "object":
case "obj":
objectTerms.Add( value );
break;
case "name":
nameTerms.Add( value );
break;
case "value":
valueTerms.Add( value );
break;
}
}
rawText = KeyValuePattern.Replace( rawText, string.Empty ).Trim();
}
var parts = FilterPart.Parse( rawText );
if ( parts.Count == 0 )
{
includeTerms = Array.Empty<string>();
excludeTerms = Array.Empty<string>();
filters = new QueryFilters(
typeTerms.ToArray(),
componentTerms.ToArray(),
objectTerms.ToArray(),
nameTerms.ToArray(),
valueTerms.ToArray() );
return;
}
var include = new List<string>( parts.Count );
var exclude = new List<string>();
foreach ( var part in parts )
{
var term = part.Value?.Trim();
if ( string.IsNullOrEmpty( term ) )
continue;
term = term.ToLowerInvariant();
if ( part.Modifier == FilterModifier.Not )
exclude.Add( term );
else
include.Add( term );
}
includeTerms = include.Count > 0 ? include.ToArray() : Array.Empty<string>();
excludeTerms = exclude.Count > 0 ? exclude.ToArray() : Array.Empty<string>();
filters = new QueryFilters(
typeTerms.ToArray(),
componentTerms.ToArray(),
objectTerms.ToArray(),
nameTerms.ToArray(),
valueTerms.ToArray() );
}
private static readonly Lazy<Dictionary<string, string[]>> ApiTypeTokenMap =
new( BuildApiTypeTokenMap );
private static Dictionary<string, string[]> BuildApiTypeTokenMap()
{
try
{
var path = Path.Combine( Directory.GetCurrentDirectory(), "sbox_api.json" );
if ( !File.Exists( path ) )
return new Dictionary<string, string[]>( StringComparer.OrdinalIgnoreCase );
using var stream = File.OpenRead( path );
using var doc = JsonDocument.Parse( stream );
if ( !doc.RootElement.TryGetProperty( "Types", out var types ) || types.ValueKind != JsonValueKind.Array )
return new Dictionary<string, string[]>( StringComparer.OrdinalIgnoreCase );
var map = new Dictionary<string, string[]>( StringComparer.OrdinalIgnoreCase );
foreach ( var entry in types.EnumerateArray() )
{
if ( !entry.TryGetProperty( "Name", out var nameProp ) )
continue;
var name = nameProp.GetString();
if ( string.IsNullOrEmpty( name ) )
continue;
if ( !map.ContainsKey( name ) )
map[name] = SplitPascalCaseTokens( name );
if ( entry.TryGetProperty( "FullName", out var fullProp ) )
{
var fullName = fullProp.GetString();
if ( !string.IsNullOrEmpty( fullName ) && !map.ContainsKey( fullName ) )
map[fullName] = SplitPascalCaseTokens( fullName );
}
}
return map;
}
catch
{
return new Dictionary<string, string[]>( StringComparer.OrdinalIgnoreCase );
}
}
private static string[] SplitPascalCaseTokens( string value )
{
if ( string.IsNullOrEmpty( value ) )
return Array.Empty<string>();
var matches = Regex.Matches( value, @"([A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z]+|[A-Z]+|[0-9]+)" );
if ( matches.Count == 0 )
return Array.Empty<string>();
var list = new List<string>( matches.Count + 1 );
foreach ( Match match in matches )
{
var token = match.Value.ToLowerInvariant();
if ( !string.IsNullOrEmpty( token ) )
list.Add( token );
}
var full = value.ToLowerInvariant();
if ( !string.IsNullOrEmpty( full ) )
list.Add( full );
return list.Distinct().ToArray();
}
private static string[] MergeTokens( string[] baseTokens, string[] extraTokens )
{
if ( (baseTokens == null || baseTokens.Length == 0) && (extraTokens == null || extraTokens.Length == 0) )
return Array.Empty<string>();
if ( baseTokens == null || baseTokens.Length == 0 )
return extraTokens ?? Array.Empty<string>();
if ( extraTokens == null || extraTokens.Length == 0 )
return baseTokens;
var set = new HashSet<string>( baseTokens );
foreach ( var token in extraTokens )
set.Add( token );
return set.ToArray();
}
private void UpdateSearchPlaceholder()
{
if ( searchBox == null )
return;
var summary = BuildSearchFilterSummary();
if ( safeBaseActive && string.IsNullOrEmpty( searchBox.Text ) )
{
searchBox.PlaceholderText = $"Find in results… ({summary})";
}
else
{
searchBox.PlaceholderText = $"Search… ({summary})";
}
}
private string BuildParameterAccessorSummary( SkinnedModelRenderer.ParameterAccessor accessor, AnimationGraph graph, out List<(string Name, string Value)> entries )
{
entries = new List<(string Name, string Value)>();
if ( accessor == null )
return string.Empty;
if ( graph == null || !graph.IsValid || graph.ParamCount <= 0 )
return string.Empty;
for ( int i = 0; i < graph.ParamCount; i++ )
{
var name = graph.GetParameterName( i );
if ( string.IsNullOrEmpty( name ) )
continue;
var type = graph.GetParameterType( i );
string valueText;
if ( type == typeof( bool ) )
{
valueText = accessor.GetBool( name ).ToString();
}
else if ( type == typeof( float ) )
{
valueText = accessor.GetFloat( name ).ToString();
}
else if ( type == typeof( int ) )
{
valueText = accessor.GetInt( name ).ToString();
}
else if ( type == typeof( Vector3 ) )
{
valueText = accessor.GetVector( name ).ToString();
}
else if ( type == typeof( Rotation ) )
{
valueText = accessor.GetRotation( name ).ToString();
}
else
{
// Fallback for enums/other types – we can't read the raw enum
// value here, but we can at least surface whether it is set.
valueText = accessor.Contains( name ) ? "set" : "unset";
}
entries.Add( (name, valueText) );
}
return entries.Count > 0 ? string.Join( "; ", entries.Select( e => $"{e.Name}={e.Value}" ) ) : string.Empty;
}
/// <summary>
/// Build the dock UI: selection summary, search controls and scrollable results.
/// </summary>
public InspectorSearchTool( Widget parent ) : base( parent )
{
MinimumHeight = 200;
var rootLayout = Layout = Layout.Column();
rootLayout.Margin = 4;
rootLayout.Spacing = 4;
// Selection summary row – always visible, even when nothing is selected.
var selectionRow = rootLayout.AddRow();
selectionRow.Spacing = 4;
selectedPrefixLabel = new Label( "" )
{
Alignment = TextFlag.LeftCenter
};
selectedPrefixLabel.SetStyles( "color: rgba(255,255,255,0.55);" );
selectionRow.Add( selectedPrefixLabel, 0 );
selectedNameLabel = new Label( "(none)" )
{
Alignment = TextFlag.LeftCenter
};
selectedNameLabel.SetStyles( "font-weight: 700; color: rgba(255,255,255,0.85);" );
selectedNameLabel.ToolTip = "Current selection name.";
selectionRow.Add( selectedNameLabel, 0 );
// Text status for the current scan/index – reports how many
// fields/components have been indexed so far, next to the object name.
scanStatusLabel = new Label( "" )
{
Alignment = TextFlag.LeftCenter
};
scanStatusLabel.SetStyles( "color: rgba(255,255,255,0.45); font-size: 11px; margin-left: 6px;" );
scanStatusLabel.ToolTip = "Scan/filter progress (built/total components or rows).";
selectionRow.Add( scanStatusLabel, 0 );
scanTimeLabel = new Label( "" )
{
Alignment = TextFlag.LeftCenter
};
scanTimeLabel.SetStyles( "color: rgba(255,255,255,0.55); font-size: 11px; margin-left: 6px;" );
scanTimeLabel.ToolTip = "Time taken for the last filter (ms).";
selectionRow.Add( scanTimeLabel, 0 );
selectionRow.AddStretchCell();
selectedCountsLabel = new Label( "0 descendants · 0 components" )
{
Alignment = TextFlag.RightCenter
};
selectedCountsLabel.SetStyles( "color: rgba(255,255,255,0.55);" );
selectedCountsLabel.ToolTip = "Descendants and component counts for the current selection.";
selectionRow.Add( selectedCountsLabel, 0 );
// Search + filters row
var searchRow = rootLayout.AddRow();
searchRow.Spacing = 4;
searchBox = new LineEdit( this )
{
PlaceholderText = "Search…",
FixedHeight = Theme.RowHeight
};
searchBox.ToolTip = "Search: filter component properties by name and/or value.\nSafe mode: press Enter to apply; leave empty and press Enter to list all fields.";
// Search trigger mode: live vs manual
IconButton searchTriggerButton = null;
void UpdateSearchTriggerButton()
{
switch ( searchTriggerMode )
{
case SearchTriggerMode.Live:
searchTriggerButton.Icon = "flash_on";
searchTriggerButton.ToolTip = "Mode: Live – filters instantly as you type and when selection changes.\nClick to switch to Safe mode.";
break;
case SearchTriggerMode.Safe:
searchTriggerButton.Icon = "security";
searchTriggerButton.ToolTip = "Mode: Safe – updates only when you press Enter or click refresh.\nClick to switch to Live mode.";
break;
}
}
searchTriggerButton = new IconButton( "flash_on", () =>
{
searchTriggerMode = searchTriggerMode == SearchTriggerMode.Live
? SearchTriggerMode.Safe
: SearchTriggerMode.Live;
UpdateSearchTriggerButton();
UpdateSearchPlaceholder();
if ( searchTriggerMode == SearchTriggerMode.Live && pendingRebuild )
{
RebuildRows();
}
UpdateManualModeHint();
} )
{
FixedHeight = Theme.RowHeight,
FixedWidth = Theme.RowHeight
};
UpdateSearchTriggerButton();
searchRow.Add( searchTriggerButton, 0 );
safeBaseLabel = new Label( "" )
{
Alignment = TextFlag.LeftCenter,
Visible = false
};
safeBaseLabel.SetStyles( "color: rgba(200,220,255,0.9); font-size: 11px; margin-left: 4px; padding: 2px 6px; background-color: rgba(40,60,110,0.7); border-radius: 6px;" );
searchRow.Add( safeBaseLabel, 0 );
searchRow.Add( searchBox, 1 );
// Initial placeholder reflects default filter settings
UpdateSearchPlaceholder();
// In live mode, filter as the user types; in safe mode, filter only on Enter
// (via ReturnPressed) or the refresh button, so focus changes (e.g. clicking
// the console) never trigger a search.
searchBox.TextEdited += OnSearchChanged;
searchBox.ReturnPressed += () =>
{
if ( searchTriggerMode == SearchTriggerMode.Safe )
{
safeQueryDirty = false;
ApplyFilterSafe( searchBox.Text );
}
};
clearButton = new IconButton( "close", ClearSearch )
{
ToolTip = "Action: clear or interrupt search.\nSafe mode: first click clears text, second click clears results.",
FixedHeight = Theme.RowHeight,
FixedWidth = Theme.RowHeight
};
searchRow.Add( clearButton, 0 );
matchLabel = new Label( "0" ) { Alignment = TextFlag.LeftCenter };
matchLabel.SetStyles( "color: rgba(255,255,255,0.4);" );
matchLabel.ToolTip = "Visible matches for the current search.";
searchRow.Add( matchLabel, 0 );
// We no longer show a dedicated cancel button; the clear (X) button
// doubles as the interrupt. Keep a null spinner reference so
// UpdateProgressIcon can safely no-op when asked to update it.
progressIcon = null;
// Scope toggle: Selected vs Children
IconButton scopeButton = null;
scopeButton = new IconButton( includeChildren ? "account_tree" : "person", () =>
{
includeChildren = !includeChildren;
scopeButton.Icon = includeChildren ? "account_tree" : "person";
scopeButton.ToolTip = includeChildren
? "Scope: selection + children – include the selected object and its descendants."
: "Scope: selection only – search just the selected object.";
UpdateSelectionSummary();
if ( searchTriggerMode == SearchTriggerMode.Live )
{
RebuildRows();
}
else
{
pendingRebuild = true;
}
UpdateSearchPlaceholder();
UpdateManualModeHint();
} )
{
ToolTip = includeChildren
? "Scope: selection + children – include the selected object and its descendants."
: "Scope: selection only – search just the selected object.",
FixedHeight = Theme.RowHeight,
FixedWidth = Theme.RowHeight
};
searchRow.Add( scopeButton, 0 );
// Match target toggle: components vs property names vs both (3-way)
IconButton matchButton = null;
void UpdateMatchButton()
{
switch ( matchTargetMode )
{
case MatchTargetMode.ComponentsOnly:
matchButton.Icon = "view_module";
matchButton.ToolTip = "Show properties in matching components.";
break;
case MatchTargetMode.NamesOnly:
matchButton.Icon = "title";
matchButton.ToolTip = "Scope: properties only – show properties that match.";
break;
case MatchTargetMode.ComponentsAndNames:
matchButton.Icon = "public";
matchButton.ToolTip = "Show components and properties that match.";
break;
}
}
matchButton = new IconButton( "public", () =>
{
matchTargetMode = matchTargetMode switch
{
MatchTargetMode.ComponentsAndNames => MatchTargetMode.ComponentsOnly,
MatchTargetMode.ComponentsOnly => MatchTargetMode.NamesOnly,
MatchTargetMode.NamesOnly => MatchTargetMode.ComponentsAndNames,
_ => MatchTargetMode.ComponentsAndNames
};
UpdateMatchButton();
UpdateSearchPlaceholder();
if ( searchTriggerMode == SearchTriggerMode.Live )
{
ApplyFilter( lastSearch );
}
else
{
UpdateManualModeHint();
}
} )
{
FixedHeight = Theme.RowHeight,
FixedWidth = Theme.RowHeight
};
UpdateMatchButton();
searchRow.Add( matchButton, 0 );
// Value/name search mode toggle: Names, Values, Names & Values (3-way)
IconButton valueTextButton = null;
void UpdateValueSearchButton()
{
switch ( valueSearchMode )
{
case ValueSearchMode.NamesOnly:
valueTextButton.Icon = "title";
valueTextButton.ToolTip = "Scope: names only – look only at property names.";
break;
case ValueSearchMode.ValuesOnly:
valueTextButton.Icon = "tag";
valueTextButton.ToolTip = "Scope: values only – look only at property values.";
break;
case ValueSearchMode.NamesAndValues:
valueTextButton.Icon = "public";
valueTextButton.ToolTip = "Scope: names + values – look at both property names and values.";
break;
}
}
valueTextButton = new IconButton( "title", () =>
{
valueSearchMode = valueSearchMode switch
{
ValueSearchMode.NamesAndValues => ValueSearchMode.NamesOnly,
ValueSearchMode.NamesOnly => ValueSearchMode.ValuesOnly,
ValueSearchMode.ValuesOnly => ValueSearchMode.NamesAndValues,
_ => ValueSearchMode.NamesAndValues
};
UpdateValueSearchButton();
UpdateSearchPlaceholder();
if ( searchTriggerMode == SearchTriggerMode.Live )
{
ApplyFilter( lastSearch );
}
else
{
UpdateManualModeHint();
}
} )
{
FixedHeight = Theme.RowHeight,
FixedWidth = Theme.RowHeight
};
UpdateValueSearchButton();
searchRow.Add( valueTextButton, 0 );
// Value filter toggle: All / Set / Unset (single icon button cycling three modes)
IconButton valueFilterButton = null;
void UpdateValueFilterButton()
{
switch ( valueFilterMode )
{
case ValueFilterMode.All:
valueFilterButton.Icon = "filter_alt_off";
valueFilterButton.ToolTip = "Value state: all – show both set and unset values.";
break;
case ValueFilterMode.SetOnly:
valueFilterButton.Icon = "filter_alt";
valueFilterButton.ToolTip = "Value state: set only – show only values that currently have a value.";
break;
case ValueFilterMode.UnsetOnly:
valueFilterButton.Icon = "filter_list";
valueFilterButton.ToolTip = "Value state: unset only – show only values that are currently empty.";
break;
}
}
valueFilterButton = new IconButton( "filter_alt_off", () =>
{
valueFilterMode = valueFilterMode switch
{
ValueFilterMode.All => ValueFilterMode.SetOnly,
ValueFilterMode.SetOnly => ValueFilterMode.UnsetOnly,
ValueFilterMode.UnsetOnly => ValueFilterMode.All,
_ => ValueFilterMode.All
};
UpdateValueFilterButton();
UpdateSearchPlaceholder();
if ( searchTriggerMode == SearchTriggerMode.Live )
{
ApplyFilter( lastSearch );
}
else
{
UpdateManualModeHint();
}
} )
{
FixedHeight = Theme.RowHeight,
FixedWidth = Theme.RowHeight
};
UpdateValueFilterButton();
searchRow.Add( valueFilterButton, 0 );
var selectionCog = new IconButton( "settings", OpenOptionsMenu )
{
ToolTip = "Options: selection behaviour, Safe mode, loading and search helpers.",
FixedWidth = Theme.RowHeight,
FixedHeight = Theme.RowHeight
};
searchRow.Add( selectionCog, 0 );
// Type and extra filter toggles – shown on a second row as [checkbox icon] + colored label
var filterRow = rootLayout.AddRow();
filterRow.Spacing = 4;
IconButton AddFilterToggle( string iconOn, Func<bool> getter, Action<bool> setter, string tooltip )
{
IconButton btn = null;
btn = new IconButton( getter() ? iconOn : "check_box_outline_blank", () =>
{
var newVal = !getter();
setter( newVal );
btn.Icon = newVal ? iconOn : "check_box_outline_blank";
UpdateSearchPlaceholder();
if ( searchTriggerMode == SearchTriggerMode.Live )
{
ApplyFilter( lastSearch );
}
else
{
UpdateManualModeHint();
}
} )
{
ToolTip = tooltip,
FixedHeight = Theme.RowHeight,
FixedWidth = Theme.RowHeight
};
return btn;
}
Label AddFilterLabel( string text, string colorCss, out Label store )
{
var label = new Label( text )
{
Alignment = TextFlag.LeftCenter
};
label.SetStyles( $"color: {colorCss};" );
store = label;
return label;
}
// Bool (orange), Float (lime), String (pink), Enum (teal), Complex (yellow)
filterRow.Add( AddFilterToggle( "check_box", () => showBools, v => showBools = v, "Type filter: bool – include true/false flags." ), 0 );
filterRow.Add( AddFilterLabel( "Bool", "rgba(255,190,120,0.95)", out typeBoolLabel ), 0 );
filterRow.Add( AddFilterToggle( "check_box", () => showNumeric, v => showNumeric = v, "Type filter: numeric – include int/float-like values." ), 0 );
filterRow.Add( AddFilterLabel( "Float", "rgba(120,255,120,0.9)", out typeFloatLabel ), 0 );
filterRow.Add( AddFilterToggle( "check_box", () => showText, v => showText = v, "Type filter: text – include string fields (names, tags)." ), 0 );
filterRow.Add( AddFilterLabel( "String", "rgba(255,160,220,0.9)", out typeStringLabel ), 0 );
filterRow.Add( AddFilterToggle( "check_box", () => showEnums, v => showEnums = v, "Type filter: enum – include enum/flags values." ), 0 );
filterRow.Add( AddFilterLabel( "Enum", "rgba(120,240,240,0.9)", out typeEnumLabel ), 0 );
filterRow.Add( AddFilterToggle( "check_box", () => showComplex, v => showComplex = v, "Type filter: complex – include vectors, colours, transforms." ), 0 );
filterRow.Add( AddFilterLabel( "Complex", "rgba(255,235,140,0.9)", out typeComplexLabel ), 0 );
// Extra filter: errors-only (label in red)
filterRow.Add( AddFilterToggle( "check_box", () => filterErrorsOnly, v => filterErrorsOnly = v,"Extra filter: errors only – show only fields with '<error>'." ), 0 );
filterRow.Add( AddFilterLabel( "Errors", "rgba(255,120,120,0.95)", out typeErrorsLabel ), 0 );
var safeRow = rootLayout.AddRow();
safeRow.Spacing = 0;
safeRow.AddStretchCell();
manualModeHintLabel = new Label( this )
{
Text = "Safe mode is on. Press Enter or click refresh to update results.",
Visible = false
};
manualModeHintLabel.SetStyles( "color: rgba(200,220,255,0.85); background-color: rgba(20,60,120,0.55); font-weight: 600; font-size: 12px; margin: 8px 0; padding: 8px 12px; border-radius: 6px; text-align:center; min-width: 260px;" );
safeRow.Add( manualModeHintLabel, 0 );
safeRow.AddStretchCell();
// List area (scrollable)
var scrollArea = new ScrollArea( this );
scrollArea.Canvas = new Widget( scrollArea );
scrollArea.Canvas.VerticalSizeMode = SizeMode.CanGrow;
scrollArea.Canvas.Layout = Layout.Column();
scrollArea.Canvas.Layout.Margin = 0;
rootLayout.Add( scrollArea, 1 );
listContainer = scrollArea.Canvas;
// Initial build to show hint when nothing is selected
UpdateSelectionSummary();
RebuildRows( force: true );
UpdateManualModeHint();
ApplyHeaderStatsVisibility();
ApplyShorthandLabels();
}
private void ApplyHeaderStatsVisibility()
{
var currentMargin = showHeaderStats ? 6 : 0;
if ( selectedPrefixLabel != null && selectedPrefixLabel.IsValid() )
{
selectedPrefixLabel.Visible = showHeaderStats;
}
if ( selectedNameLabel != null && selectedNameLabel.IsValid() )
{
selectedNameLabel.Visible = showHeaderStats;
}
if ( selectedCountsLabel != null && selectedCountsLabel.IsValid() )
{
selectedCountsLabel.Visible = showHeaderStats;
}
if ( scanStatusLabel != null && scanStatusLabel.IsValid() )
{
scanStatusLabel.Visible = showHeaderStats;
}
if ( scanTimeLabel != null && scanTimeLabel.IsValid() )
{
scanTimeLabel.Visible = showHeaderStats;
if ( !showHeaderStats )
{
scanTimeLabel.Text = string.Empty;
}
// When stats are hidden, trim extra gap on the timing label; when
// shown, give it a small breathing space.
scanTimeLabel.SetStyles( $"color: rgba(255,255,255,0.55); font-size: 11px; margin-left: {currentMargin}px;" );
}
}
private void ApplyShorthandLabels()
{
// Selected prefix
if ( selectedPrefixLabel != null && selectedPrefixLabel.IsValid() )
{
selectedPrefixLabel.Text = shorthandLabels ? "" : "Selected:";
}
// Type filter labels
if ( shorthandLabels )
{
// Shorten only the ones that benefit most.
if ( typeBoolLabel != null && typeBoolLabel.IsValid() ) typeBoolLabel.Text = "B";
if ( typeFloatLabel != null && typeFloatLabel.IsValid() ) typeFloatLabel.Text = "F";
if ( typeStringLabel != null && typeStringLabel.IsValid() ) typeStringLabel.Text = "S";
if ( typeEnumLabel != null && typeEnumLabel.IsValid() ) typeEnumLabel.Text = "En";
if ( typeComplexLabel != null && typeComplexLabel.IsValid() ) typeComplexLabel.Text = "Cx";
if ( typeErrorsLabel != null && typeErrorsLabel.IsValid() ) typeErrorsLabel.Text = "E";
}
else
{
if ( typeBoolLabel != null && typeBoolLabel.IsValid() ) typeBoolLabel.Text = "Bool";
if ( typeFloatLabel != null && typeFloatLabel.IsValid() ) typeFloatLabel.Text = "Float";
if ( typeStringLabel != null && typeStringLabel.IsValid() ) typeStringLabel.Text = "String";
if ( typeEnumLabel != null && typeEnumLabel.IsValid() ) typeEnumLabel.Text = "Enum";
if ( typeComplexLabel != null && typeComplexLabel.IsValid() ) typeComplexLabel.Text = "Complex";
if ( typeErrorsLabel != null && typeErrorsLabel.IsValid() ) typeErrorsLabel.Text = "Errors";
}
// Selection counts are updated by UpdateSelectionSummary, which already
// respects shorthandLabels.
}
public void SetTarget( GameObject obj )
{
if ( isDisposed || !Visible )
return;
if ( target == obj )
return;
if ( debugLogging )
{
var name = obj != null && obj.IsValid() ? obj.Name ?? "(unnamed)" : "(null)";
Log.Info( $"[SearchFilter] SetTarget -> {name}" );
}
target = obj;
if ( target == null || !target.IsValid() )
{
pendingRebuild = false;
UpdateSelectionSummary();
// Respect the \"Deselect clears results\" option. When disabled,
// keep the current results visible even if nothing is selected.
if ( clearOnDeselect )
{
ShowSelectedHint();
}
UpdateManualModeHint();
return;
}
pendingRebuild = true;
UpdateSelectionSummary();
if ( searchTriggerMode == SearchTriggerMode.Live ||
(searchTriggerMode == SearchTriggerMode.Safe && autoPopulateOnSelectionInSafe) )
{
// Live mode (or Safe with override): selecting updates immediately.
RebuildRows();
}
else if ( searchTriggerMode == SearchTriggerMode.Safe )
{
// In Safe mode, we normally keep existing results. If configured not
// to keep results, or if we have no rows yet (fresh open), replace
// the plonker text with the object-selected hint.
if ( !keepResultsOnSelectionChange || (rows.Count == 0 && string.IsNullOrEmpty( lastSearch )) )
{
ShowSelectedHint();
}
}
UpdateManualModeHint();
}
private void ShowSelectedHint()
{
if ( listContainer is null )
return;
listContainer.Layout.Clear( true );
noResultsContainer = null;
noResultsLabel = null;
if ( matchLabel != null && matchLabel.IsValid() )
{
matchLabel.Text = "0";
matchLabel.SetStyles( "color: rgba(255,255,255,0.4);" );
}
// Reset progress/status when we are back to the empty selection hint.
totalComponentCount = 0;
builtComponentCount = 0;
lastProgress01 = 0f;
safeQueryDirty = false;
UpdateSearchProgress( 0f );
var emptyContainer = new Widget( listContainer );
var emptyLayout = emptyContainer.Layout = Layout.Column();
emptyLayout.AddStretchCell();
var hasSelection = target != null && target.IsValid();
var hintText = hasSelection
? string.Format( "Selected: {0}\n\nPress Enter on empty search to list all fields.", target?.Name ?? "(unnamed)" )
: "You haven't selected anything, you plonker.";
var hint = new Label( hintText )
{
Alignment = TextFlag.Center
};
hint.SetStyles( "color: rgba(255,255,255,0.35); font-style: italic; margin-top: 10px; text-align:center; white-space: pre-wrap;" );
emptyLayout.Add( hint, 0 );
emptyLayout.AddStretchCell();
emptyContainer.SetStyles( "min-height: 220px;" );
listContainer.Layout.Add( emptyContainer, 0 );
}
private void ShowResultsClearedMessage()
{
if ( listContainer is null )
return;
listContainer.Layout.Clear( true );
// This message replaces any previous "no results" banner, so drop
// references to those widgets to avoid talking to disposed labels.
noResultsContainer = null;
noResultsLabel = null;
if ( matchLabel != null && matchLabel.IsValid() )
{
matchLabel.Text = "0";
matchLabel.SetStyles( "color: rgba(255,255,255,0.4);" );
}
if ( scanStatusLabel != null && scanStatusLabel.IsValid() )
{
scanStatusLabel.Text = string.Empty;
}
var container = new Widget( listContainer );
var layout = container.Layout = Layout.Column();
layout.AddStretchCell();
var label = new Label( "Results cleared. Enter a search to repopulate." )
{
Alignment = TextFlag.Center
};
label.SetStyles( "color: rgba(255,255,255,0.35); font-style: italic; margin-top: 10px; text-align:center;" );
layout.Add( label, 0 );
layout.AddStretchCell();
container.SetStyles( "min-height: 220px;" );
listContainer.Layout.Add( container, 0 );
totalComponentCount = 0;
builtComponentCount = 0;
lastProgress01 = 0f;
UpdateSearchProgress( 0f );
}
protected override void OnKeyPress( KeyEvent e )
{
base.OnKeyPress( e );
// When the search box is empty and a base term is active, a Backspace
// press should clear only the base tag without reordering or
// refiltering the stack.
if ( safeBaseActive && searchBox != null )
{
// KeyEvent doesn't expose Backspace directly everywhere, but we can
// use the button code name which is already used in KeyBind.
var keyName = e.GetButtonCodeName();
if ( !string.IsNullOrEmpty( keyName ) &&
keyName.Equals( "Backspace", StringComparison.OrdinalIgnoreCase ) &&
string.IsNullOrEmpty( searchBox.Text ) )
{
ClearSafeBase();
e.Accepted = true;
}
}
}
/// <summary>
/// Open a small context menu with toggles for behaviour that is handy to
/// tweak while iterating on the tool (Safe mode selection, clearing rules).
/// </summary>
private void OpenOptionsMenu()
{
var menu = new ContextMenu();
// Selection behaviour
var keepResultsLabel = keepResultsOnSelectionChange
? "✓ Keep results when changing object"
: "Keep results when changing object";
menu.AddOption( keepResultsLabel, "visibility", () =>
{
keepResultsOnSelectionChange = !keepResultsOnSelectionChange;
} );
var clearOnDeselectLabel = clearOnDeselect
? "✓ Deselect clears results"
: "Deselect clears results";
menu.AddOption( clearOnDeselectLabel, "close", () =>
{
clearOnDeselect = !clearOnDeselect;
} );
var autoPopulateLabel = autoPopulateOnSelectionInSafe
? "✓ Safe mode: selection auto-populates list"
: "Safe mode: selection auto-populates list";
menu.AddOption( autoPopulateLabel, "playlist_add_check", () =>
{
autoPopulateOnSelectionInSafe = !autoPopulateOnSelectionInSafe;
} );
var includeTypesLabel = includeTypeNamesInNameSearch
? "✓ Include type names in name search"
: "Include type names in name search";
menu.AddOption( includeTypesLabel, "category", () =>
{
includeTypeNamesInNameSearch = !includeTypeNamesInNameSearch;
// Clear cached name tokens so the next filter pass rebuilds them
// with the updated setting.
foreach ( var model in rowModels )
{
if ( model != null )
{
model.NameTokens = null;
}
}
if ( !string.IsNullOrEmpty( lastSearch ) )
{
ApplyFilter( lastSearch );
}
else
{
UpdateManualModeHint();
}
} );
var flattenListsLabel = flattenLists
? "✓ Flatten simple lists (e.g. hide body groups)"
: "Flatten simple lists (e.g. hide body groups)";
menu.AddOption( flattenListsLabel, "format_list_bulleted", () =>
{
flattenLists = !flattenLists;
// Force a rebuild so flattened list rows are rebuilt/removed.
pendingRebuild = true;
if ( searchTriggerMode == SearchTriggerMode.Live )
{
RebuildRows();
}
else
{
UpdateManualModeHint();
}
} );
// Loading / view
var lazyLabel = lazyLoadProperties
? "✓ Lazy-load component properties"
: "Lazy-load component properties";
menu.AddOption( lazyLabel, "schedule", () =>
{
lazyLoadProperties = !lazyLoadProperties;
pendingRebuild = true;
if ( searchTriggerMode == SearchTriggerMode.Live )
{
ApplyFilter( lastSearch );
}
else
{
UpdateManualModeHint();
}
} );
// Field-only mode has been removed; keeping logic internal-only.
var headerStatsLabel = showHeaderStats
? "✓ Show header stats"
: "Show header stats";
menu.AddOption( headerStatsLabel, "insights", () =>
{
showHeaderStats = !showHeaderStats;
ApplyHeaderStatsVisibility();
} );
var shorthandLabel = shorthandLabels
? "✓ Shorthand labels"
: "Shorthand labels";
menu.AddOption( shorthandLabel, "short_text", () =>
{
shorthandLabels = !shorthandLabels;
UpdateSelectionSummary();
ApplyShorthandLabels();
} );
// Search helpers
var nestedLabel = showHiddenNestedBools
? "✓ Nested RenderOptions flags"
: "Nested RenderOptions flags";
menu.AddOption( nestedLabel, "filter_alt", () =>
{
showHiddenNestedBools = !showHiddenNestedBools;
// In Safe mode, simply mark that a rebuild is needed and let the
// blue banner remind the user to press Enter. In Live mode we can
// safely refresh immediately.
pendingRebuild = true;
if ( searchTriggerMode == SearchTriggerMode.Live )
{
ApplyFilter( lastSearch );
}
else
{
UpdateManualModeHint();
}
} );
// Deep nested-bool mode (all complex types) has been disabled for now;
// it is kept here commented as an experimental option that proved too
// slow in large scenes.
// var deepNestedLabel = showDeepNestedBools
// ? "✓ Search: deep nested option flags (all, slower)"
// : "Search: deep nested option flags (all, slower)";
// menu.AddOption( deepNestedLabel, "filter_list", () =>
// {
// showDeepNestedBools = !showDeepNestedBools;
// // Same Safe/Live behaviour as the shallow nested option flags.
// pendingRebuild = true;
// if ( searchTriggerMode == SearchTriggerMode.Live )
// {
// ApplyFilter( lastSearch );
// }
// else
// {
// UpdateManualModeHint();
// }
// } );
// Debug
var debugLabel = debugLogging
? "✓ Debug: log timings to console"
: "Debug: log timings to console";
menu.AddOption( debugLabel, "bug_report", () =>
{
debugLogging = !debugLogging;
} );
var modeMsgLabel = showModeMessages
? "✓ Show mode messages"
: "Show mode messages";
menu.AddOption( modeMsgLabel, "info", () =>
{
showModeMessages = !showModeMessages;
UpdateManualModeHint();
} );
menu.OpenAt( Editor.Application.CursorPosition );
}
private void UpdateManualModeHint()
{
if ( manualModeHintLabel is null )
return;
if ( !showModeMessages )
{
manualModeHintLabel.Visible = false;
return;
}
// Live mode uses a persistent yellow banner so it is obvious that the
// list is updating as you type / select.
if ( searchTriggerMode == SearchTriggerMode.Live )
{
manualModeHintLabel.Text = "Live mode is on. Results update as you type and when selection changes.";
manualModeHintLabel.SetStyles( "color: rgba(255,220,220,0.95); background-color: rgba(150,40,40,0.75); font-weight: 600; font-size: 12px; margin: 8px 0; padding: 8px 12px; border-radius: 6px; text-align:center; min-width: 260px;" );
manualModeHintLabel.Visible = true;
return;
}
// Safe base active acts like a mini live mode for refinements.
if ( searchTriggerMode == SearchTriggerMode.Safe && safeBaseActive )
{
var term = string.IsNullOrEmpty( safeBaseSearchText ) ? "base term" : $"\"{safeBaseSearchText}\"";
manualModeHintLabel.Text = $"Searching within results for {term}. Refining updates per keystroke.";
manualModeHintLabel.SetStyles( "color: rgba(255,240,200,0.95); background-color: rgba(140,110,30,0.7); font-weight: 600; font-size: 12px; margin: 8px 0; padding: 8px 12px; border-radius: 6px; text-align:center; min-width: 260px;" );
manualModeHintLabel.Visible = true;
return;
}
// Safe mode banner appears when either a structural change is pending
// (new scan required) or the current query text has changed and needs
// to be applied.
var showBanner = searchTriggerMode == SearchTriggerMode.Safe && (pendingRebuild || safeQueryDirty);
if ( showBanner )
{
manualModeHintLabel.Text = "Safe mode is on. Press Enter or click refresh to update results.";
manualModeHintLabel.SetStyles( "color: rgba(200,220,255,0.85); background-color: rgba(20,60,120,0.55); font-weight: 600; font-size: 12px; margin: 8px 0; padding: 8px 12px; border-radius: 6px; text-align:center; min-width: 260px;" );
manualModeHintLabel.Visible = true;
}
else
{
manualModeHintLabel.Visible = false;
}
}
private void UpdateSelectionSummary()
{
if ( selectedNameLabel is null || selectedCountsLabel is null )
return;
if ( target == null || !target.IsValid() )
{
selectedNameLabel.Text = "(none)";
selectedCountsLabel.Text = "0 descendants · 0 components";
return;
}
selectedNameLabel.Text = target.Name ?? "(unnamed)";
var descendants = 0;
var components = 0;
if ( includeChildren )
{
foreach ( var go in EnumerateWithChildren( target ) )
{
if ( go == null || !go.IsValid() )
continue;
if ( go != target )
descendants++;
components += go.Components.GetAll().Count( c => c != null && c.IsValid() );
}
}
else
{
components = target.Components.GetAll().Count( c => c != null && c.IsValid() );
}
if ( shorthandLabels )
{
selectedCountsLabel.Text = $"{descendants} d · {components} c";
}
else
{
selectedCountsLabel.Text = $"{descendants} descendants · {components} components";
}
}
public void EnsureTarget( GameObject obj )
{
if ( isDisposed || !Visible )
return;
if ( target == obj )
return;
SetTarget( obj );
}
/// <summary>
/// Keep in sync with the active scene selection.
/// </summary>
[EditorEvent.Frame]
public void Frame()
{
if ( isDisposed )
return;
if ( !Visible )
return;
var session = SceneEditorSession.Active;
if ( session is null )
return;
var go = session.Selection.OfType<GameObject>().FirstOrDefault();
EnsureTarget( go );
ProcessPendingComponentBuilds();
if ( filterDebouncePending &&
!componentBuildQueue.HasPending )
{
var debounceMs = FilterDebounceMs;
if ( searchTriggerMode == SearchTriggerMode.Safe && safeBaseActive )
{
debounceMs = SafeBaseFilterDebounceMs;
}
if ( filterDebounceStopwatch.ElapsedMilliseconds >= debounceMs )
{
filterDebouncePending = false;
if ( searchTriggerMode == SearchTriggerMode.Live )
{
ApplyFilter( pendingFilterText );
}
else if ( searchTriggerMode == SearchTriggerMode.Safe && safeBaseActive )
{
ApplyFilterSafe( pendingFilterText );
}
}
}
UpdateProgressIcon();
}
private void RebuildRows( bool applyFilter = true, bool force = false )
{
if ( isDisposed || ( !Visible && !force ) )
return;
// Any new rebuild makes previous filter work obsolete.
filterInProgress = false;
filterSnapshotCache.Clear();
filterSnapshotOrder.Clear();
pendingRebuild = false;
UpdateManualModeHint();
CancelPendingScan();
debugNestedTypeNames.Clear();
if ( target == null || !target.IsValid() )
{
ShowSelectedHint();
return;
}
PrepareForScan();
var signature = SelectionSignature.Create( target, includeChildren, valueSearchMode, searchTriggerMode, lazyLoadProperties );
if ( !force && scanCache.TryGetValue( signature, out var cachedResult ) && cachedResult != null)
{
ApplyScanResult( cachedResult, applyFilter );
return;
}
var request = new ScanJobRequest
{
Target = target,
IncludeChildren = includeChildren,
LazyLoad = lazyLoadProperties,
Signature = signature
};
StartBackgroundScan( request, applyFilter && !string.IsNullOrEmpty( lastSearch ) );
}
private void PrepareForScan()
{
rows.Clear();
rowModels.Clear();
rowModelLookup.Clear();
rowLookup.Clear();
nextRowId = 0;
rowsDirty = true;
componentGroups.Clear();
gameObjectGroups.Clear();
componentBuildQueue.Clear();
ClearTokenIndexes();
if ( listContainer == null )
return;
listContainer.Layout.Clear( true );
noResultsContainer = null;
noResultsLabel = null;
matchLabel.Text = "…";
matchLabel.SetStyles( "color: rgba(255,255,255,0.4);" );
var placeholder = new Widget( listContainer );
var placeholderLayout = placeholder.Layout = Layout.Column();
placeholderLayout.AddStretchCell();
var hint = new Label( "Scanning selection…" )
{
Alignment = TextFlag.Center
};
hint.SetStyles( "color: rgba(255,255,255,0.35); font-style: italic; margin-top: 10px; text-align:center;" );
placeholderLayout.Add( hint, 0 );
placeholderLayout.AddStretchCell();
placeholder.SetStyles( "min-height: 220px;" );
listContainer.Layout.Add( placeholder );
// Reset progress at the start of a scan.
totalComponentCount = 0;
builtComponentCount = 0;
lastProgress01 = 0f;
UpdateSearchProgress( 0f );
lastProgress01 = 0f;
UpdateProgressIcon();
}
private void CancelPendingScan()
{
if ( rebuildCancellation != null )
{
rebuildCancellation.Cancel();
rebuildCancellation.Dispose();
rebuildCancellation = null;
}
componentBuildQueue.Clear();
filterDebouncePending = false;
filterInProgress = false;
UpdateProgressIcon();
}
private void StartBackgroundScan( ScanJobRequest request, bool applyFilter )
{
if ( request == null )
return;
var cts = new CancellationTokenSource();
rebuildCancellation = cts;
var token = cts.Token;
var sequence = ++rebuildSequence;
var stopwatch = debugLogging ? Stopwatch.StartNew() : null;
Task.Run( () =>
{
ScanJobResult result = null;
try
{
result = BuildScanResult( request, token );
}
catch ( Exception e )
{
Log.Warning( $"[SearchFilter] Background scan failed: {e.Message}" );
}
if ( result == null )
return;
if ( stopwatch != null )
{
stopwatch.Stop();
result.ScanMilliseconds = stopwatch.ElapsedMilliseconds;
}
MainThread.Queue( () =>
{
if ( token.IsCancellationRequested || sequence != rebuildSequence )
return;
if ( rebuildCancellation != null )
{
rebuildCancellation.Dispose();
rebuildCancellation = null;
}
// Cache the scan result for this selection signature so that
// revisiting recent objects does not require a full rescan.
if ( result != null )
{
if ( !scanCacheOrder.Contains( request.Signature ) )
{
scanCacheOrder.Enqueue( request.Signature );
}
while ( scanCacheOrder.Count > MaxScanCacheEntries )
{
var oldest = scanCacheOrder.Dequeue();
scanCache.Remove( oldest );
}
scanCache[request.Signature] = result;
}
ApplyScanResult( result, applyFilter );
} );
} );
}
private ScanJobResult BuildScanResult( ScanJobRequest request, CancellationToken token )
{
var result = new ScanJobResult
{
LazyLoad = request.LazyLoad
};
if ( request.Target == null || !request.Target.IsValid() )
return result;
foreach ( var go in EnumerateTargets( request.Target, request.IncludeChildren ) )
{
if ( token.IsCancellationRequested )
return null;
if ( go == null || !go.IsValid() )
continue;
var goData = new ScanGameObjectResult
{
GameObject = go,
Depth = GetDepth( go ),
Path = GetHierarchyPath( go )
};
foreach ( var component in go.Components.GetAll() )
{
if ( token.IsCancellationRequested )
return null;
if ( component == null || !component.IsValid() )
continue;
var type = component.GetType();
PropertyInfo[] props;
try
{
props = propertyMetadataCache.GetPropertiesForComponentType( type );
}
catch
{
continue;
}
var comp = new ComponentScanResult
{
Component = component,
DisplayName = type.Name,
Properties = props
};
goData.Components.Add( comp );
}
result.GameObjects.Add( goData );
}
return result;
}
private void ApplyScanResult( ScanJobResult result, bool applyFilter )
{
if ( listContainer == null )
return;
rows.Clear();
rowModels.Clear();
rowModelLookup.Clear();
rowLookup.Clear();
nextRowId = 0;
componentGroups.Clear();
gameObjectGroups.Clear();
componentBuildQueue.Clear();
ClearTokenIndexes();
listContainer.Layout.Clear( true );
noResultsContainer = null;
noResultsLabel = null;
// Capture total component count for the current scan so we can render
// a simple progress bar as properties are materialised.
totalComponentCount = 0;
builtComponentCount = 0;
var useLazy = result?.LazyLoad ?? lazyLoadProperties;
if ( string.IsNullOrWhiteSpace( lastSearch ) )
{
useLazy = true;
}
if ( result == null || result.GameObjects.Count == 0 )
{
ShowSelectedHint();
return;
}
foreach ( var goData in result.GameObjects )
{
var go = goData.GameObject;
if ( go == null || !go.IsValid() )
continue;
var depth = goData.Depth;
var goRow = new Widget( listContainer );
var goRowLayout = goRow.Layout = Layout.Row();
goRowLayout.Spacing = 0;
var goIndent = new Widget( goRow );
var goIndentLayout = goIndent.Layout = Layout.Row();
goIndentLayout.Spacing = 0;
var folderHitLabel = new Label( "" )
{
Alignment = TextFlag.Center
};
folderHitLabel.SetStyles( "color: rgba(130,220,185,0.9); font-size: 12px; font-weight: 600; margin:0; padding:0;" );
folderHitLabel.Visible = false;
goIndentLayout.Add( folderHitLabel, 0 );
goIndent.FixedWidth = 20 + 14 * depth;
goRowLayout.Add( goIndent, 0 );
var goShade = depth % 2 == 0
? "rgba(255,255,255,0.02)"
: "rgba(255,255,255,0.05)";
goRow.SetStyles( $"background-color: {goShade}; border-radius: 4px; margin: 2px 0; padding: 2px 2px 2px 0;" );
var goGroup = new ExpandGroup( goRow )
{
Title = go.Name,
Icon = "folder"
};
goGroup.SetOpenState( false );
goGroup.StateCookieName = $"SpudInspectorSearcher.GameObject.{go.Id}";
var goBody = new Widget( goGroup );
goBody.Layout = Layout.Column();
goGroup.SetWidget( goBody );
goRowLayout.Add( goGroup, 1 );
listContainer.Layout.Add( goRow );
// When a search term is active, defer showing the hierarchy rows
// themselves until after the first filtered ApplyFilter pass so we
// never flash an unfiltered tree while components are still
// streaming in.
if ( !string.IsNullOrEmpty( lastSearch ) )
{
goRow.Visible = false;
}
var goKey = go.Id.ToString();
var goInfo = new GameObjectGroup
{
Group = goGroup,
Name = goGroup.Title,
NameLower = (goGroup.Title ?? string.Empty).ToLowerInvariant(),
HitLabel = folderHitLabel,
Row = goRow,
Key = goKey
};
gameObjectGroups.Add( goInfo );
var pathRow = new Widget( goBody );
var pathLayout = pathRow.Layout = Layout.Row();
pathLayout.Spacing = 4;
var capturedGo = go;
var pathButton = new IconButton( "arrow_forward", () =>
{
if ( capturedGo != null && capturedGo.IsValid() )
{
var session = SceneEditorSession.Active;
try
{
session?.Selection?.Set( capturedGo );
}
catch
{
}
}
} )
{
FixedWidth = Theme.RowHeight,
FixedHeight = Theme.RowHeight,
ToolTip = "Select this object in the scene"
};
pathLayout.Add( pathButton, 0 );
var pathLabel = new Label( goData.Path )
{
Alignment = TextFlag.LeftCenter
};
pathLabel.SetStyles( "color: rgba(200,220,255,0.8);" );
pathLayout.Add( pathLabel, 1 );
pathRow.SetStyles( "padding: 2px 4px; margin: 2px 0 4px 0; background-color: rgba(255,255,255,0.02); border-radius: 4px;" );
goBody.Layout.Add( pathRow );
foreach ( var compData in goData.Components )
{
var component = compData.Component;
if ( component == null || !component.IsValid() )
continue;
var compRow = new Widget( goBody );
var compRowLayout = compRow.Layout = Layout.Row();
compRowLayout.Spacing = 0;
var compIndent = new Widget( compRow );
compIndent.FixedWidth = 14;
compRowLayout.Add( compIndent, 0 );
var group = new ExpandGroup( compRow )
{
Title = compData.DisplayName,
Icon = "tune"
};
group.SetOpenState( false );
group.StateCookieName = $"SpudInspectorSearcher.{go.Id}.{compData.DisplayName}";
var groupBody = new Widget( group );
groupBody.Layout = Layout.Column();
group.SetWidget( groupBody );
compRowLayout.Add( group, 1 );
goBody.Layout.Add( compRow );
var groupInfo = new ComponentGroup
{
Group = group,
Name = group.Title,
OwnerKey = goKey,
MatchKey = $"{goKey}:{group.Title}:{component.GetHashCode()}",
OwnerName = go.Name,
Component = component,
Properties = compData.Properties ?? Array.Empty<PropertyInfo>(),
Body = groupBody,
PropertiesBuilt = false
};
componentGroups.Add( groupInfo );
totalComponentCount++;
if ( useLazy )
{
var placeholder = new Widget( groupBody );
var phLayout = placeholder.Layout = Layout.Row();
phLayout.Spacing = 4;
var loadButton = new IconButton( "unfold_more", () =>
{
groupBody.Layout.Clear( true );
BuildComponentRows( groupInfo );
groupInfo.PropertiesBuilt = true;
if ( !string.IsNullOrEmpty( lastSearch ) || pendingRebuild )
{
ApplyFilter( lastSearch );
}
} )
{
FixedWidth = Theme.RowHeight,
FixedHeight = Theme.RowHeight,
ToolTip = "Load properties for this component (lazy mode)."
};
phLayout.Add( loadButton, 0 );
var phLabel = new Label( "Load properties for this component (lazy mode)" )
{
Alignment = TextFlag.LeftCenter
};
phLabel.SetStyles( "color: rgba(255,255,255,0.6);" );
phLayout.Add( phLabel, 1 );
groupBody.Layout.Add( placeholder );
}
else
{
componentBuildQueue.Enqueue( groupInfo );
}
}
}
var stateContainer = new Widget( listContainer );
var stateLayout = stateContainer.Layout = Layout.Column();
stateLayout.Spacing = 4;
stateLayout.AddStretchCell();
var stateLabel = new Label( "" )
{
Alignment = TextFlag.Center
};
stateLabel.SetStyles( "color: rgba(255,255,255,0.35); font-style: italic; margin-top: 10px; text-align:center;" );
stateLayout.Add( stateLabel, 0 );
stateLayout.AddStretchCell();
stateContainer.SetStyles( "min-height: 220px;" );
listContainer.Layout.Add( stateContainer, 0 );
noResultsContainer = stateContainer;
noResultsLabel = stateLabel;
var hasPendingRows = componentBuildQueue.HasPending;
if ( rows.Count == 0 )
{
noResultsContainer.Visible = true;
// When a search term is active and rows are still being built,
// signal that we are searching rather than claiming "No results".
if ( !string.IsNullOrEmpty( lastSearch ) && hasPendingRows )
{
var searchingTerm = lastSearch
.ToLowerInvariant()
.Split( ' ', StringSplitOptions.RemoveEmptyEntries )
.FirstOrDefault() ?? lastSearch;
var selectedName = target != null && target.IsValid()
? target.Name ?? "(unnamed)"
: "(no selection)";
noResultsLabel.Text = $"Searching \"{searchingTerm}\"\n\nin {selectedName} ({BuildSearchFilterSummary()})...";
}
else if ( string.IsNullOrEmpty( lastSearch ) )
{
noResultsLabel.Text = "Object selected. Enter a search to populate the results.";
}
else
{
noResultsLabel.Text = $"No results for \"{lastSearch}\" :(";
}
}
else
{
noResultsContainer.Visible = false;
}
UpdateSearchProgress( totalComponentCount > 0 ? (float)builtComponentCount / totalComponentCount : 0f );
listContainer.Layout.AddStretchCell();
if ( debugLogging && result != null )
{
Log.Info( $"[SearchFilter] RebuildRows async lazy={lazyLoadProperties} lean={leanMode} nestedBools={showHiddenNestedBools} objects={gameObjectGroups.Count} components={componentGroups.Count} rows={rows.Count} scanMs={result.ScanMilliseconds}" );
}
if ( applyFilter )
{
ApplyFilter( lastSearch );
}
UpdateProgressIcon();
}
private void ProcessPendingComponentBuilds()
{
var builtAny = componentBuildQueue.ProcessNextBatch( BuildComponentRows );
if ( builtAny )
{
// Track how many component groups have had their properties built
// so we can update the progress bar even while results are still
// streaming in.
builtComponentCount = componentGroups.Count( g => g.PropertiesBuilt );
var progress01 = totalComponentCount > 0 ? (float)builtComponentCount / totalComponentCount : 0f;
UpdateSearchProgress( progress01 );
UpdateProgressIcon();
// When a safe-mode base term is active, new rows can start
// matching it. Refresh the base set so refinements stay on the
// correct subset.
var safeModeActive = searchTriggerMode == SearchTriggerMode.Safe && safeBaseActive && !string.IsNullOrEmpty( safeBaseSearchText );
if ( safeModeActive )
{
RefreshSafeBaseMatches();
}
if ( gradualResults || !componentBuildQueue.HasPending )
{
if ( safeModeActive && !string.IsNullOrEmpty( lastSearch ) )
{
var refineWithinBase = lastSearch != safeBaseSearchText;
ApplyFilter( refineWithinBase ? lastSearch : safeBaseSearchText, refineWithinBase );
}
else
{
ApplyFilter( lastSearch );
}
// When the queue has finished and the final filter has run,
// mark progress as complete so the bar reaches 100% only once
// results are actually ready.
if ( !componentBuildQueue.HasPending )
{
UpdateSearchProgress( 1f );
}
}
}
}
private void UpdateSearchProgress( float progress01 )
{
progress01 = Math.Clamp( progress01, 0f, 1f );
if ( progress01 < lastProgress01 )
{
progress01 = lastProgress01;
}
lastProgress01 = progress01;
if ( scanStatusLabel != null && scanStatusLabel.IsValid() )
{
if ( totalComponentCount > 0 )
{
scanStatusLabel.Text = $"{builtComponentCount}/{totalComponentCount}";
}
else
{
scanStatusLabel.Text = rows.Count > 0
? $"{rows.Count}"
: string.Empty;
}
}
}
private void OnSearchChanged( string text )
{
if ( suppressSearchChanged )
return;
// Live mode and Safe-mode with an active base both behave like
// debounced "live" search; plain Safe mode requires Enter.
if ( searchTriggerMode == SearchTriggerMode.Live ||
(searchTriggerMode == SearchTriggerMode.Safe && safeBaseActive) )
{
pendingFilterText = text ?? string.Empty;
filterDebouncePending = true;
filterDebounceStopwatch.Restart();
if ( searchTriggerMode == SearchTriggerMode.Safe )
{
safeQueryDirty = false;
}
UpdateSearchPlaceholder();
}
else
{
// Safe mode: typing indicates that results are stale for the current
// query; the user needs to press Enter to re-apply the filter, but
// we do not need a new scan of the hierarchy.
safeQueryDirty = true;
UpdateManualModeHint();
UpdateSearchPlaceholder();
}
}
private void ClearSearch()
{
if ( isDisposed )
return;
// If a search/scan/filter is currently in progress, treat this as an
// interrupt instead of a full clear: cancel work but keep the current
// stack and counts visible.
var isSearching = rebuildCancellation != null || componentBuildQueue.HasPending || filterDebouncePending || filterInProgress;
if ( isSearching )
{
filterDebouncePending = false;
filterInProgress = false;
CancelPendingScan();
// After an interrupt, keep whatever rows we have so far and apply
// the current filter to them so the user can see partial results.
if ( rows.Count > 0 && !string.IsNullOrEmpty( lastSearch ) )
{
ApplyFilter( lastSearch, refineWithinBase: safeBaseActive );
}
// Do not clear the base term here; the user can choose to clear it
// explicitly with another action.
if ( debugLogging )
{
Log.Info( "[SearchFilter] Interrupt: cancelled search, kept partial results." );
}
UpdateManualModeHint();
UpdateProgressIcon();
return;
}
// Safe mode: first click clears only the text and marks a pending rebuild.
// Second click (with an active search but empty text) clears the results.
if ( searchTriggerMode == SearchTriggerMode.Safe )
{
if ( !string.IsNullOrEmpty( searchBox.Text ) )
{
// First clear: empty the field and show the Safe-mode banner, but
// keep current results until the user explicitly refreshes. Also
// drop any active base term.
suppressSearchChanged = true;
searchBox.Text = string.Empty;
suppressSearchChanged = false;
pendingRebuild = true;
ClearSafeBase();
UpdateManualModeHint();
}
else if ( !string.IsNullOrEmpty( lastSearch ) || rows.Count > 0 )
{
// Second clear: drop the active search term and clear the results.
CancelPendingScan();
lastSearch = string.Empty;
totalComponentCount = 0;
builtComponentCount = 0;
lastProgress01 = 0f;
safeQueryDirty = false;
UpdateSearchProgress( 0f );
rows.Clear();
rowModels.Clear();
rowLookup.Clear();
rowModelLookup.Clear();
nextRowId = 0;
rowsDirty = true;
componentGroups.Clear();
gameObjectGroups.Clear();
ClearTokenIndexes();
ShowResultsClearedMessage();
// Mark that the next search needs a fresh rebuild.
pendingRebuild = true;
safeQueryDirty = false;
ClearSafeBase();
UpdateSelectionSummary();
if ( scanTimeLabel != null && scanTimeLabel.IsValid() )
{
scanTimeLabel.Text = string.Empty;
}
UpdateManualModeHint();
}
searchBox.Focus();
UpdateProgressIcon();
return;
}
// Live mode:
// - First click: clear text and debounce filter (keep results visible).
// - Second click (with empty text but an active search): clear results.
if ( searchTriggerMode == SearchTriggerMode.Live )
{
if ( !string.IsNullOrEmpty( searchBox.Text ) || string.IsNullOrEmpty( lastSearch ) )
{
// First clear: empty the field and debounce the filter so we
// don't immediately trigger a heavy pass if the user keeps typing.
suppressSearchChanged = true;
searchBox.Text = string.Empty;
suppressSearchChanged = false;
pendingFilterText = string.Empty;
filterDebouncePending = true;
filterDebounceStopwatch.Restart();
CancelPendingScan();
ClearSafeBase();
UpdateManualModeHint();
searchBox.Focus();
UpdateProgressIcon();
return;
}
if ( string.IsNullOrEmpty( searchBox.Text ) && (rows.Count > 0 || !string.IsNullOrEmpty( lastSearch )) )
{
// Second clear: drop the active search term and clear results.
CancelPendingScan();
lastSearch = string.Empty;
totalComponentCount = 0;
builtComponentCount = 0;
lastProgress01 = 0f;
UpdateSearchProgress( 0f );
rows.Clear();
rowModels.Clear();
rowLookup.Clear();
rowModelLookup.Clear();
nextRowId = 0;
rowsDirty = true;
componentGroups.Clear();
gameObjectGroups.Clear();
ClearTokenIndexes();
ShowResultsClearedMessage();
// Mark that the next search needs a fresh rebuild.
pendingRebuild = true;
ClearSafeBase();
UpdateSelectionSummary();
if ( scanTimeLabel != null && scanTimeLabel.IsValid() )
{
scanTimeLabel.Text = string.Empty;
}
UpdateManualModeHint();
searchBox.Focus();
UpdateProgressIcon();
return;
}
}
}
private void ApplyFilterSafe( string rawText )
{
// Safe mode helper – decide whether this Enter press should define a
// new base search or refine within the existing base stack.
if ( searchTriggerMode != SearchTriggerMode.Safe )
{
ApplyFilter( rawText );
return;
}
rawText ??= string.Empty;
ParseSearchTerms( rawText, out var includeTerms, out _, out _ );
var term = includeTerms.Length > 0 ? includeTerms[0] : string.Empty;
var hasBase = safeBaseActive && !string.IsNullOrEmpty( safeBaseSearchText );
var refineWithinBase = false;
// First non-empty Safe-mode Enter sets the base term immediately so the
// chip appears before the search finishes populating results.
if ( !hasBase && !string.IsNullOrEmpty( term ) )
{
safeBaseActive = true;
safeBaseSearchText = rawText;
safeBaseRowIds.Clear();
UpdateSafeBaseLabel();
UpdateSearchPlaceholder();
refineWithinBase = false;
// Move the base term into the chip and clear the text field so we
// don't duplicate the word visually.
if ( searchBox != null )
{
suppressSearchChanged = true;
searchBox.Text = string.Empty;
suppressSearchChanged = false;
}
}
else if ( hasBase && string.IsNullOrEmpty( term ) )
{
// Empty term with an active base should re-apply the base search
// rather than clearing it or treating it as a global empty search.
ApplyFilter( safeBaseSearchText, refineWithinBase: false );
UpdateSearchPlaceholder();
return;
}
else
{
refineWithinBase = hasBase && !string.IsNullOrEmpty( term );
}
ApplyFilter( rawText, refineWithinBase );
}
private void UpdateSafeBaseLabel()
{
if ( safeBaseLabel == null )
return;
if ( searchTriggerMode == SearchTriggerMode.Safe && safeBaseActive && !string.IsNullOrEmpty( safeBaseSearchText ) )
{
safeBaseLabel.Visible = true;
safeBaseLabel.Text = $"\"{safeBaseSearchText}\":";
}
else
{
safeBaseLabel.Visible = false;
safeBaseLabel.Text = string.Empty;
}
}
private RowFilterState RefreshSafeBaseMatches()
{
if ( !safeBaseActive || string.IsNullOrEmpty( safeBaseSearchText ) )
return null;
ParseSearchTerms( safeBaseSearchText, out var includeTerms, out var excludeTerms, out var filters );
var term = includeTerms.Length > 0 ? includeTerms[0] : string.Empty;
var termEmpty = string.IsNullOrEmpty( term );
var filterEngine = new FilterEngine( this );
var filterResult = filterEngine.Run( rowModels, includeTerms, excludeTerms, filters, termEmpty, CancellationToken.None );
safeBaseRowIds.Clear();
foreach ( var model in rowModels )
{
if ( model == null )
continue;
if ( filterResult.RowStates.TryGetValue( model.Id, out var state ) && state.Match )
{
safeBaseRowIds.Add( model.Id );
}
}
safeBaseActive = safeBaseRowIds.Count > 0;
UpdateSafeBaseLabel();
return filterResult;
}
private void ClearSafeBase()
{
if ( !safeBaseActive )
return;
safeBaseActive = false;
safeBaseSearchText = string.Empty;
safeBaseRowIds.Clear();
UpdateSafeBaseLabel();
UpdateSearchPlaceholder();
// When the base is dropped while results are visible, keep the stack
// unchanged. If there are no results at all, fall back to the standard
// selection hint so the user knows how to search again.
if ( rows.Count == 0 && string.IsNullOrEmpty( lastSearch ) )
{
ShowSelectedHint();
}
}
private void ApplyFilter( string rawText, bool refineWithinBase = false )
{
if ( isDisposed )
return;
if ( rebuildCancellation != null && componentBuildQueue.HasPending )
return;
filterInProgress = true;
if ( progressIcon != null )
{
progressIcon.Visible = true;
progressIcon.Enabled = true;
progressIcon.ToolTip = "Applying filter…";
}
var filterStopwatch = Stopwatch.StartNew();
rawText ??= string.Empty;
if ( rawText == lastSearch && !pendingRebuild && !rowsDirty && !string.IsNullOrEmpty( rawText ) )
{
filterInProgress = false;
UpdateProgressIcon();
return;
}
lastSearch = rawText;
if ( pendingRebuild )
{
pendingRebuild = false;
RebuildRows( applyFilter: false );
}
ParseSearchTerms( lastSearch, out var includeTerms, out var excludeTerms, out var filters );
activeIncludeTerms = includeTerms ?? Array.Empty<string>();
activeExcludeTerms = excludeTerms ?? Array.Empty<string>();
activeQueryFilters = filters;
var termEmpty = includeTerms.Length == 0 || string.IsNullOrEmpty( includeTerms[0] );
if ( !termEmpty && noResultsContainer != null && noResultsLabel != null )
{
var searchingTerm = lastSearch
.ToLowerInvariant()
.Split( ' ', StringSplitOptions.RemoveEmptyEntries )
.FirstOrDefault() ?? lastSearch;
noResultsLabel.Text = $"Searching \"{searchingTerm}\"...";
noResultsContainer.Visible = true;
}
foreach ( var cg in componentGroups )
{
cg.MatchCount = 0;
cg.Group.Title = cg.Name;
}
foreach ( var og in gameObjectGroups )
{
og.MatchCount = 0;
og.Group.Title = og.Name;
og.NameMatch = false;
}
if ( termEmpty && noResultsContainer != null )
{
noResultsContainer.Visible = false;
}
var snapshotKey = lastSearch ?? string.Empty;
RowFilterState cachedState = null;
var canUseSnapshot = !pendingRebuild && !refineWithinBase && filterSnapshotCache.TryGetValue( snapshotKey, out cachedState );
var candidates = GetFilterCandidates( includeTerms, filters, termEmpty, refineWithinBase ).ToList();
if ( canUseSnapshot )
{
ApplyFilterResultToUi( cachedState, refineWithinBase, termEmpty, snapshotKey, includeTerms, filterStopwatch );
return;
}
StartFilterTask( candidates, includeTerms, excludeTerms, filters, termEmpty, filterStopwatch, refineWithinBase, snapshotKey );
}
private void StartFilterTask( List<RowModel> candidates, string[] includeTerms, string[] excludeTerms, QueryFilters filters, bool termEmpty, Stopwatch filterStopwatch, bool refineWithinBase, string snapshotKey )
{
filterCancellation?.Cancel();
filterCancellation?.Dispose();
var cts = new CancellationTokenSource();
filterCancellation = cts;
var token = cts.Token;
var sequence = ++filterSequence;
var filterEngine = new FilterEngine( this );
Task.Run( () =>
{
try
{
return filterEngine.Run( candidates, includeTerms, excludeTerms, filters, termEmpty, token );
}
catch ( OperationCanceledException )
{
return null;
}
catch ( Exception e )
{
Log.Warning( $"[SearchFilter] Filter task failed: {e.Message}" );
return null;
}
}, token ).ContinueWith( t =>
{
if ( token.IsCancellationRequested || sequence != filterSequence || isDisposed )
return;
var filterResult = t.Result;
if ( filterResult == null )
return;
MainThread.Queue( () =>
{
if ( token.IsCancellationRequested || sequence != filterSequence || isDisposed )
return;
ApplyFilterResultToUi( filterResult, refineWithinBase, termEmpty, snapshotKey, includeTerms, filterStopwatch );
} );
}, TaskScheduler.Default );
}
private void ApplyFilterResultToUi( RowFilterState filterResult, bool refineWithinBase, bool termEmpty, string snapshotKey, string[] includeTerms, Stopwatch filterStopwatch )
{
if ( filterResult == null )
return;
var term = includeTerms.Length > 0 ? includeTerms[0] : string.Empty;
var objectsWithPropertyMatches = new HashSet<string>();
foreach ( var model in rowModels )
{
if ( model == null || string.IsNullOrEmpty( model.GameObjectKey ) )
continue;
if ( filterResult.RowStates.TryGetValue( model.Id, out var state ) && state.Match )
{
objectsWithPropertyMatches.Add( model.GameObjectKey );
}
}
int visibleMatchCount = 0;
bool anyVisibleRows = false;
foreach ( var row in rows )
{
var inBase = !refineWithinBase || (safeBaseActive && safeBaseRowIds.Contains( row.RowId ));
RowMatchResult state;
if ( inBase && filterResult.RowStates.TryGetValue( row.RowId, out var s ) )
{
state = s;
}
else if ( refineWithinBase )
{
state = new RowMatchResult { PassesFilters = false };
}
else
{
state = new RowMatchResult { PassesFilters = true };
}
var passes = state.PassesFilters;
var match = !termEmpty && state.Match;
var nameMatch = !termEmpty && state.NameMatch;
var valueMatch = !termEmpty && state.ValueMatch;
var objectNameMatch = !termEmpty && state.ObjectNameMatch;
bool visible;
if ( !passes )
{
visible = false;
}
else if ( termEmpty )
{
if ( refineWithinBase && safeBaseActive && safeBaseRowIds.Count > 0 )
{
visible = inBase;
}
else
{
visible = true;
}
}
else if ( leanMode )
{
visible = match;
}
else
{
visible = match;
}
if ( row.DisplayName != null )
{
if ( termEmpty )
{
row.NameLabel.Text = row.DisplayName;
}
else if ( nameMatch )
{
row.NameLabel.Text = HighlightMatches( row.DisplayName, includeTerms );
}
else
{
row.NameLabel.Text = row.DisplayName;
}
}
if ( row.Widget != null && visible != row.IsVisible )
{
row.Widget.Visible = visible;
row.IsVisible = visible;
}
var nameHighlight = !termEmpty && nameMatch;
if ( nameHighlight != row.NameHighlighted )
{
row.SetHighlighted( nameHighlight );
row.NameHighlighted = nameHighlight;
}
if ( row.NameMatchDot != null && nameMatch != row.NameDotOn )
{
row.NameMatchDot.Text = nameMatch ? "●" : string.Empty;
row.NameMatchDot.Visible = nameMatch;
row.NameDotOn = nameMatch;
}
if ( row.ValueMatchDot != null && valueMatch != row.ValueDotOn )
{
row.ValueMatchDot.Text = valueMatch ? "●" : string.Empty;
row.ValueMatchDot.Visible = valueMatch;
row.ValueDotOn = valueMatch;
}
if ( row.HitLabel != null )
{
row.HitLabel.Text = match ? "●" : string.Empty;
row.HitLabel.Visible = match;
}
if ( row.EditorWidget != null )
{
if ( valueMatch != row.ValueHighlighted )
{
var baseStyle = row.EditorBaseStyle ?? string.Empty;
if ( valueMatch )
{
row.EditorWidget.SetStyles( $"{baseStyle} color: rgba(140,220,185,1.0);" );
}
else
{
row.EditorWidget.SetStyles( baseStyle );
}
row.ValueHighlighted = valueMatch;
}
if ( row.EditorWidget is Label valueLabel )
{
if ( termEmpty )
{
valueLabel.Text = row.ValuePreview ?? string.Empty;
}
else if ( valueMatch )
{
valueLabel.Text = HighlightMatches( row.ValuePreview ?? string.Empty, includeTerms );
}
else
{
valueLabel.Text = row.ValuePreview ?? string.Empty;
}
}
}
if ( visible )
{
anyVisibleRows = true;
if ( match )
{
visibleMatchCount++;
}
}
}
if ( noResultsContainer != null && noResultsLabel != null )
{
if ( termEmpty )
{
noResultsContainer.Visible = false;
}
else if ( !anyVisibleRows )
{
var searchingTerm = lastSearch
.ToLowerInvariant()
.Split( ' ', StringSplitOptions.RemoveEmptyEntries )
.FirstOrDefault() ?? lastSearch;
if ( filterInProgress || componentBuildQueue.HasPending || filterDebouncePending )
{
noResultsLabel.Text = $"Searching \"{searchingTerm}\"...";
}
else
{
noResultsLabel.Text = $"No results for \"{searchingTerm}\" :(";
}
noResultsContainer.Visible = true;
}
else
{
noResultsContainer.Visible = false;
}
}
if ( !(searchTriggerMode == SearchTriggerMode.Live && rows.Count > HeavyRowCountThreshold) )
{
var zebraIndex = 0;
foreach ( var row in rows.Where( r => r.Widget != null && r.Widget.Visible ) )
{
var baseShade = zebraIndex % 2 == 0 ? "rgba(255,255,255,0.02)" : "rgba(255,255,255,0.04)";
var highlightShade = (row.ValueHighlighted || row.NameHighlighted) ? "rgba(110,210,175,0.10)" : baseShade;
row.Widget.SetStyles( $"background-color: {highlightShade}; padding: 2px 4px;" );
zebraIndex++;
}
}
if ( !leanMode )
{
foreach ( var cg in componentGroups )
{
cg.MatchCount = filterResult.ComponentMatches.TryGetValue( cg.MatchKey ?? cg.OwnerKey, out var cCount ) ? cCount : 0;
if ( cg.MatchCount > 0 )
{
cg.Group.Title = $"{cg.Name} ({cg.MatchCount})";
cg.Group.SetStyles( "color: rgba(255,255,255,1.0);" );
cg.Group.Visible = true;
if ( termEmpty )
{
cg.Group.SetOpenState( true );
}
else
{
cg.Group.SetOpenState( false );
}
cg.Group.SetHeight();
}
else
{
cg.Group.Visible = termEmpty;
}
}
var isSearchingTerm = !termEmpty && (rebuildCancellation != null || componentBuildQueue.HasPending || filterDebouncePending || filterInProgress);
var selectedKey = target != null && target.IsValid() ? target.Id.ToString() : null;
foreach ( var og in gameObjectGroups )
{
og.MatchCount = filterResult.ObjectMatches.TryGetValue( og.Key, out var gCount ) ? gCount : 0;
og.NameMatch = !termEmpty && og.MatchCount > 0;
if ( og.MatchCount > 0 || termEmpty )
{
var titleBase = og.MatchCount > 0
? $"{og.Name} ({og.MatchCount})"
: og.Name;
og.Group.Title = titleBase;
var color = og.NameMatch ? "color: rgba(130,220,185,1.0);" : "color: rgba(255,255,255,1.0);";
var weight = og.NameMatch ? "font-weight: 600;" : "font-weight: 400;";
og.Group.SetStyles( $"{color} {weight}" );
if ( og.HitLabel != null )
{
if ( isSearchingTerm && !string.IsNullOrEmpty( selectedKey ) && og.Key == selectedKey )
{
og.HitLabel.Text = "⟳";
og.HitLabel.Visible = true;
}
else if ( og.NameMatch )
{
og.HitLabel.Text = "●";
og.HitLabel.Visible = true;
}
else
{
og.HitLabel.Text = string.Empty;
og.HitLabel.Visible = false;
}
}
if ( og.Row != null && og.NameMatch )
{
og.Row.SetStyles( "background-color: rgba(110,210,175,0.08); border-radius: 4px; margin: 2px 0; padding: 2px 2px 2px 0;" );
}
if ( og.Row != null )
og.Row.Visible = true;
if ( termEmpty )
{
og.Group.SetOpenState( true );
}
else
{
og.Group.SetOpenState( false );
}
og.Group.SetHeight();
}
else
{
og.Group.Title = $"{og.Name} (0)";
og.Group.SetStyles( "color: rgba(255,255,255,0.4);" );
if ( og.HitLabel != null )
{
og.HitLabel.Text = string.Empty;
og.HitLabel.Visible = false;
}
if ( og.Row != null )
og.Row.Visible = false;
}
}
}
else
{
foreach ( var cg in componentGroups )
{
var hasVisibleRows = cg.Rows.Any( r => r.Widget != null && r.Widget.Visible );
if ( cg.Group != null )
{
cg.Group.Visible = hasVisibleRows;
if ( hasVisibleRows )
{
cg.Group.Title = cg.Name;
cg.Group.Icon = string.Empty;
cg.Group.SetOpenState( true );
cg.Group.SetStyles( "color: rgba(255,255,255,0.9); font-weight: 500; font-size: 11px; margin: 0; padding: 0;" );
cg.Group.SetHeight();
}
}
}
foreach ( var og in gameObjectGroups )
{
var hasVisibleRows = componentGroups
.Where( cg => cg.OwnerKey == og.Key )
.Any( cg => cg.Rows.Any( r => r.Widget != null && r.Widget.Visible ) );
if ( og.Row != null )
og.Row.Visible = hasVisibleRows;
if ( og.Group != null )
{
og.Group.Visible = hasVisibleRows;
if ( hasVisibleRows )
{
og.Group.Title = og.Name;
og.Group.Icon = string.Empty;
og.Group.SetOpenState( true );
og.Group.SetStyles( "color: rgba(255,255,255,0.9); font-weight: 600; font-size: 11px; margin: 2px 0 0 0; padding: 0;" );
og.Group.SetHeight();
}
}
if ( og.HitLabel != null )
{
og.HitLabel.Text = string.Empty;
og.HitLabel.Visible = false;
}
}
}
if ( string.IsNullOrEmpty( term ) )
{
matchLabel.Text = rows.Count.ToString();
matchLabel.SetStyles( "color: rgba(255,255,255,0.4);" );
}
else
{
matchLabel.Text = visibleMatchCount.ToString();
matchLabel.SetStyles( visibleMatchCount > 0
? "color: rgba(150,230,190,0.95); font-weight: 700;"
: "color: rgba(255,255,255,0.3);" );
}
if ( filterStopwatch != null )
{
filterStopwatch.Stop();
if ( scanTimeLabel != null )
{
scanTimeLabel.Text = $"{filterStopwatch.ElapsedMilliseconds} ms";
}
}
if ( !termEmpty )
{
if ( !anyVisibleRows && noResultsContainer != null )
{
noResultsContainer.Visible = true;
noResultsLabel.Text = "No results :(";
}
else if ( noResultsContainer != null )
{
noResultsContainer.Visible = false;
}
}
else
{
if ( !anyVisibleRows && noResultsContainer != null )
{
noResultsContainer.Visible = true;
noResultsLabel.Text = "Object selected. Enter a search to populate results.";
}
else if ( noResultsContainer != null )
{
noResultsContainer.Visible = false;
}
}
if ( searchTriggerMode == SearchTriggerMode.Safe && !refineWithinBase )
{
if ( !string.IsNullOrEmpty( lastSearch ) && anyVisibleRows )
{
safeBaseRowIds.Clear();
foreach ( var row in rows )
{
if ( row.Widget != null && row.Widget.Visible )
{
safeBaseRowIds.Add( row.RowId );
}
}
safeBaseActive = safeBaseRowIds.Count > 0;
safeBaseSearchText = safeBaseActive ? lastSearch : string.Empty;
}
else
{
safeBaseActive = false;
safeBaseSearchText = string.Empty;
safeBaseRowIds.Clear();
}
UpdateSafeBaseLabel();
}
if ( !refineWithinBase && !termEmpty && rows.Count > 0 )
{
if ( !filterSnapshotOrder.Contains( snapshotKey ) )
{
filterSnapshotOrder.Enqueue( snapshotKey );
}
while ( filterSnapshotOrder.Count > MaxFilterSnapshots )
{
var oldest = filterSnapshotOrder.Dequeue();
filterSnapshotCache.Remove( oldest );
}
filterSnapshotCache[snapshotKey] = filterResult;
}
filterInProgress = false;
UpdateProgressIcon();
rowsDirty = false;
UpdateManualModeHint();
}
private void UpdateProgressIcon()
{
var scanning = rebuildCancellation != null || componentBuildQueue.HasPending || filterDebouncePending || filterInProgress;
// Clear button doubles as interrupt; change icon/tooltip while search
// is running.
if ( clearButton != null )
{
clearButton.Icon = scanning ? "cancel" : "close";
clearButton.ToolTip = scanning
? "Action: interrupt search."
: "Action: clear or interrupt search.\nSafe mode: first click clears text, second click clears results.";
}
}
/// <summary>
/// Enumerate target GameObjects to inspect: selected only, or selected + all descendants.
/// </summary>
private IEnumerable<GameObject> EnumerateTargets()
{
foreach ( var go in EnumerateTargets( target, includeChildren ) )
yield return go;
}
private IEnumerable<GameObject> EnumerateTargets( GameObject root, bool includeDescendants )
{
if ( root == null || !root.IsValid() )
yield break;
if ( !includeDescendants )
{
yield return root;
yield break;
}
foreach ( var go in EnumerateWithChildren( root ) )
{
yield return go;
}
}
private IEnumerable<GameObject> EnumerateWithChildren( GameObject root )
{
if ( root == null || !root.IsValid() )
yield break;
yield return root;
foreach ( var child in root.Children )
{
foreach ( var c in EnumerateWithChildren( child ) )
yield return c;
}
}
/// <summary>
/// Get the depth of a GameObject in its parent/child hierarchy (0 = root).
/// </summary>
private int GetDepth( GameObject go )
{
var depth = 0;
var current = go?.Parent;
while ( current != null && current.IsValid() )
{
depth++;
current = current.Parent;
}
return depth;
}
private string GetHierarchyPath( GameObject go )
{
if ( go == null || !go.IsValid() )
return string.Empty;
var stack = new Stack<string>();
var current = go;
while ( current != null && current.IsValid() )
{
stack.Push( current.Name );
current = current.Parent;
}
var segments = stack.ToArray();
if ( segments.Length == 0 )
return string.Empty;
return string.Join( " / ", segments );
}
private enum PropertyKind
{
Bool,
Numeric,
Text,
Enum,
Complex
}
private static PropertyKind GetPropertyKind( Type t )
{
if ( t == null ) return PropertyKind.Complex;
// Treat Nullable<T> like its underlying type for kind checks so that
// bool? aligns with bool and numeric? aligns with numeric.
var underlying = Nullable.GetUnderlyingType( t ) ?? t;
if ( underlying == typeof( bool ) ) return PropertyKind.Bool;
if ( underlying == typeof( string ) ) return PropertyKind.Text;
if ( underlying.IsEnum ) return PropertyKind.Enum;
if ( underlying == typeof( int ) || underlying == typeof( float ) || underlying == typeof( double ) ||
underlying == typeof( long ) || underlying == typeof( short ) ||
underlying == typeof( uint ) || underlying == typeof( ulong ) )
return PropertyKind.Numeric;
return PropertyKind.Complex;
}
private bool ShouldIncludeKind( PropertyKind kind )
{
return kind switch
{
PropertyKind.Bool => showBools,
PropertyKind.Numeric => showNumeric,
PropertyKind.Text => showText,
PropertyKind.Enum => showEnums,
PropertyKind.Complex => showComplex,
_ => true
};
}
/// <summary>
/// Surface a small set of key option flags (currently Bloom/BloomLayer) as
/// first-class rows so they are searchable even when the more expensive
/// nested-bool flattening is disabled.
/// </summary>
private void TryAddBloomOptionRow( object container, string displayPrefix, ComponentGroup groupInfo, Component component, Widget groupBody, string componentTypeName )
{
if ( container == null || groupInfo == null || groupBody == null )
return;
if ( string.IsNullOrWhiteSpace( lastSearch ) )
return;
var nestedType = container.GetType();
if ( debugLogging )
{
var key = nestedType.FullName ?? nestedType.Name ?? "UnknownNestedType";
if ( debugNestedTypeNames.Add( key ) )
{
Log.Info( $"[SearchFilter] Nested options type seen: {key} (showHidden={showHiddenNestedBools})" );
}
}
// Look for a public bool called BloomLayer (as serialized in prefabs).
var bloomProp = nestedType.GetProperty( "BloomLayer", BindingFlags.Instance | BindingFlags.Public );
if ( bloomProp == null || bloomProp.PropertyType != typeof( bool ) )
return;
bool bloomValue;
try
{
bloomValue = (bool)(bloomProp.GetValue( container ) ?? false);
}
catch
{
return;
}
var memberName = "Bloom";
var nestedDisplayName = string.IsNullOrEmpty( displayPrefix )
? memberName
: $"{displayPrefix}.{memberName}";
var nestedValueText = bloomValue ? "true" : "false";
if ( !ShouldIncludeRowForSearch( nestedDisplayName, "bool", nestedValueText ) )
return;
var nestedNameLabel = new Label( nestedDisplayName )
{
Alignment = TextFlag.LeftCenter
};
var nestedRowWidget = new Widget( groupBody );
var nestedRowLayout = nestedRowWidget.Layout = Layout.Row();
nestedRowLayout.Spacing = 2;
var nestedHitLabel = AddMatchIndicator( nestedRowWidget, nestedRowLayout );
var nestedNameContainer = new Widget( nestedRowWidget );
var nestedNameLayout = nestedNameContainer.Layout = Layout.Row();
nestedNameLayout.Spacing = 0;
nestedNameLabel.SetStyles( "margin:0; padding:0;" );
nestedNameLayout.Add( nestedNameLabel, 0 );
nestedNameContainer.FixedWidth = 180;
nestedRowLayout.Add( nestedNameContainer, 0 );
var nestedValueLabel = new Label( nestedValueText )
{
FixedHeight = Theme.RowHeight * 0.9f,
Alignment = TextFlag.LeftCenter
};
nestedValueLabel.SetStyles( "background-color: rgba(255,190,120,0.08);" );
nestedRowLayout.Add( nestedValueLabel, 1 );
var nestedTooltip =
$"Type: bool\nComponent: {componentTypeName}\nProperty: {nestedDisplayName}\nValue: {nestedValueText}";
nestedValueLabel.ToolTip = nestedTooltip;
nestedNameLabel.ToolTip = nestedTooltip;
nestedRowWidget.SetStyles( "margin: 2px 0; padding: 2px 4px; border-radius: 3px;" );
groupBody.Layout.Add( nestedRowWidget );
var nestedRow = new PropertyRow
{
Widget = nestedRowWidget,
NameLabel = nestedNameLabel,
EditorWidget = nestedValueLabel,
Component = component,
PropertyInfo = null, // synthetic, read-only row
Name = memberName,
DisplayName = nestedDisplayName,
ComponentName = groupInfo.Name,
GameObjectName = groupInfo.OwnerName,
ValuePreview = nestedValueText,
HitLabel = nestedHitLabel,
IsSynthetic = true,
IsVisible = nestedRowWidget.Visible
};
RegisterRowModel( nestedRow, groupInfo, memberName, nestedDisplayName, nestedValueText, PropertyKind.Bool, isSynthetic: true );
rows.Add( nestedRow );
rowsDirty = true;
groupInfo.Rows.Add( nestedRow );
}
/// <summary>
/// Recursively flatten simple boolean members inside a complex container so that
/// nested options like RenderOptions.Bloom or RenderOptions.BloomLayer can be
/// searched directly. This only runs for a small allowlisted set of option
/// structs (e.g. RenderOptions) and walks public members with a shallow depth
/// limit to avoid runaway recursion and huge result counts.
/// </summary>
private void FlattenNestedBoolMembers( object container, string displayPrefix, ComponentGroup groupInfo, Component component, Widget groupBody, string componentTypeName, int depth = 0 )
{
if ( container == null || groupInfo == null || groupBody == null )
return;
if ( string.IsNullOrWhiteSpace( lastSearch ) )
return;
if ( !showHiddenNestedBools )
return;
// Keep recursion shallow; most engine option structs are only 1–2 levels deep.
if ( depth > 3 )
return;
var nestedType = container.GetType();
var nestedTypeName = nestedType.Name;
var nestedFullName = nestedType.FullName ?? nestedTypeName ?? string.Empty;
// Only flatten for a small set of known option/helper types so
// we avoid exploding the row count on arbitrary complex fields.
var allowNested =
nestedFullName.Contains( "RenderOptions", StringComparison.OrdinalIgnoreCase ) ||
nestedFullName.Contains( "SequenceAccessor", StringComparison.OrdinalIgnoreCase );
if ( !allowNested )
return;
// Skip primitives and strings – nothing nested to walk.
if ( nestedType.IsPrimitive || nestedType == typeof( string ) )
return;
void AddNestedBoolRow( string memberName, bool value )
{
var nestedDisplayName = string.IsNullOrEmpty( displayPrefix )
? memberName
: $"{displayPrefix}.{memberName}";
var nestedValueText = value ? "true" : "false";
if ( !ShouldIncludeRowForSearch( nestedDisplayName, "bool", nestedValueText ) )
return;
var nestedNameLabel = new Label( nestedDisplayName )
{
Alignment = TextFlag.LeftCenter
};
var nestedRowWidget = new Widget( groupBody );
var nestedRowLayout = nestedRowWidget.Layout = Layout.Row();
nestedRowLayout.Spacing = 2;
var nestedHitLabel = AddMatchIndicator( nestedRowWidget, nestedRowLayout );
var nestedNameContainer = new Widget( nestedRowWidget );
var nestedNameLayout = nestedNameContainer.Layout = Layout.Row();
nestedNameLayout.Spacing = 0;
nestedNameLabel.SetStyles( "margin:0; padding:0;" );
nestedNameLayout.Add( nestedNameLabel, 0 );
nestedNameContainer.FixedWidth = 180;
nestedRowLayout.Add( nestedNameContainer, 0 );
var nestedValueLabel = new Label( nestedValueText )
{
FixedHeight = Theme.RowHeight * 0.8f,
Alignment = TextFlag.LeftCenter
};
var nestedBaseStyle = "background-color: rgba(255,255,255,0.08); padding: 0 3px;";
nestedValueLabel.SetStyles( nestedBaseStyle );
nestedRowLayout.Add( nestedValueLabel, 1 );
var nestedTooltip =
$"Type: bool\nComponent: {componentTypeName}\nProperty: {nestedDisplayName}\nValue: {nestedValueText}";
nestedValueLabel.ToolTip = nestedTooltip;
nestedNameLabel.ToolTip = nestedTooltip;
nestedRowWidget.SetStyles( "margin: 2px 0; padding: 2px 4px; border-radius: 3px;" );
groupBody.Layout.Add( nestedRowWidget );
var nestedRow = new PropertyRow
{
Widget = nestedRowWidget,
NameLabel = nestedNameLabel,
EditorWidget = nestedValueLabel,
Component = component,
PropertyInfo = null, // nested bools are read-only here
Name = memberName,
DisplayName = nestedDisplayName,
ComponentName = groupInfo.Name,
GameObjectName = groupInfo.OwnerName,
ValuePreview = nestedValueText,
HitLabel = nestedHitLabel
};
nestedRow.EditorBaseStyle = nestedBaseStyle;
RegisterRowModel( nestedRow, groupInfo, memberName, nestedDisplayName, nestedValueText, PropertyKind.Bool, isSynthetic: true );
rows.Add( nestedRow );
rowsDirty = true;
groupInfo.Rows.Add( nestedRow );
}
// Properties (public only – the prefab serializer works from these).
foreach ( var nestedProp in nestedType.GetProperties( BindingFlags.Instance | BindingFlags.Public ) )
{
var memberType = nestedProp.PropertyType;
object nestedObj;
try
{
nestedObj = nestedProp.GetValue( container );
}
catch
{
continue;
}
if ( memberType == typeof( bool ) )
{
var boolValue = nestedObj is bool b && b;
AddNestedBoolRow( nestedProp.Name, boolValue );
}
else if ( !memberType.IsPrimitive && memberType != typeof( string ) && nestedObj != null )
{
FlattenNestedBoolMembers( nestedObj, string.IsNullOrEmpty( displayPrefix ) ? nestedProp.Name : $"{displayPrefix}.{nestedProp.Name}", groupInfo, component, groupBody, componentTypeName, depth + 1 );
}
}
// Fields (public only – the prefab serializer works from these).
foreach ( var nestedField in nestedType.GetFields( BindingFlags.Instance | BindingFlags.Public ) )
{
var memberType = nestedField.FieldType;
object nestedObj;
try
{
nestedObj = nestedField.GetValue( container );
}
catch
{
continue;
}
if ( memberType == typeof( bool ) )
{
var boolValue = nestedObj is bool b && b;
AddNestedBoolRow( nestedField.Name, boolValue );
}
else if ( !memberType.IsPrimitive && memberType != typeof( string ) && nestedObj != null )
{
FlattenNestedBoolMembers( nestedObj, string.IsNullOrEmpty( displayPrefix ) ? nestedField.Name: $"{displayPrefix}.{nestedField.Name}", groupInfo, component, groupBody, componentTypeName, depth + 1 );
}
}
}
/// <summary>
/// Flatten simple list-like containers (e.g. List<string> or List<enum>) into
/// individual rows so common patterns like \"Hide body groups\" can be searched
/// by value (Head, Torso, ArmUpper, etc.).
/// </summary>
private void FlattenListEntries( object container, string displayPrefix, ComponentGroup groupInfo, Component component, Widget groupBody, string componentTypeName )
{
if ( string.IsNullOrWhiteSpace( lastSearch ) )
return;
if ( !flattenLists )
return;
if ( container is not IList list || list.Count == 0 || groupInfo == null || groupBody == null )
return;
// Avoid exploding the UI on very large lists.
const int MaxListEntries = 64;
var count = Math.Min( list.Count, MaxListEntries );
var listType = container.GetType();
var elementType = listType.IsGenericType ? listType.GetGenericArguments()[0] : typeof( object );
// Only flatten simple value-like entries (strings, enums, primitives and a couple of small structs).
bool IsSimpleElementType( Type t )
{
if ( t == null ) return false;
if ( t.IsEnum ) return true;
if ( t == typeof( string ) ) return true;
if ( t.IsPrimitive ) return true;
if ( t == typeof( Vector3 ) || t == typeof( Rotation ) ) return true;
return false;
}
if ( !IsSimpleElementType( elementType ) )
return;
for ( int i = 0; i < count; i++ )
{
var item = list[i];
if ( item == null )
continue;
var name = item.ToString() ?? string.Empty;
if ( string.IsNullOrEmpty( name ) )
continue;
var entryDisplay = string.IsNullOrEmpty( displayPrefix )
? name
: $"{displayPrefix}.{name}";
var entryValue = name;
if ( !ShouldIncludeRowForSearch( entryDisplay, elementType?.Name ?? "List", entryValue ) )
continue;
var entryRowWidget = new Widget( groupBody );
var entryLayout = entryRowWidget.Layout = Layout.Row();
entryLayout.Spacing = 2;
var entryHitLabel = AddMatchIndicator( entryRowWidget, entryLayout );
var entryNameContainer = new Widget( entryRowWidget );
var entryNameLayout = entryNameContainer.Layout = Layout.Row();
entryNameLayout.Spacing = 0;
var entryNameLabel = new Label( entryDisplay )
{
Alignment = TextFlag.LeftCenter
};
entryNameLabel.SetStyles( "margin:0; padding:0;" );
entryNameLayout.Add( entryNameLabel, 0 );
entryNameContainer.FixedWidth = 180;
entryLayout.Add( entryNameContainer, 0 );
var entryWidget = new Label( entryValue )
{
FixedHeight = Theme.RowHeight * 0.8f,
Alignment = TextFlag.LeftCenter
};
var entryBaseStyle = "background-color: rgba(255,255,255,0.08); padding: 0 3px;";
entryWidget.SetStyles( entryBaseStyle );
entryLayout.Add( entryWidget, 1 );
var entryTooltip =
$"Type: List item\nComponent: {componentTypeName}\nProperty: {entryDisplay}\nValue: {entryValue}";
entryWidget.ToolTip = entryTooltip;
entryNameLabel.ToolTip = entryTooltip;
entryRowWidget.SetStyles( "margin: 2px 0; padding: 2px 4px; border-radius: 3px;" );
groupBody.Layout.Add( entryRowWidget );
var row = new PropertyRow
{
Widget = entryRowWidget,
NameLabel = entryNameLabel,
EditorWidget = entryWidget,
Component = component,
PropertyInfo = null,
Name = entryDisplay,
DisplayName = entryDisplay,
ComponentName = groupInfo.Name,
GameObjectName = groupInfo.OwnerName,
ValuePreview = entryValue,
HitLabel = entryHitLabel,
EditorBaseStyle = entryBaseStyle,
IsSynthetic = true,
IsVisible = entryRowWidget.Visible
};
RegisterRowModel( row, groupInfo, row.Name, entryDisplay, entryValue, PropertyKind.Complex, isSynthetic: true );
rows.Add( row );
rowsDirty = true;
groupInfo.Rows.Add( row );
}
}
/// <summary>
/// Flatten simple option fields on small helper structs such as PhysicsLock so
/// values like Pitch/Yaw can be searched directly without exploding row counts.
/// </summary>
private void FlattenPhysicsLockOptions( PhysicsLock physicsLock, string displayPrefix, ComponentGroup groupInfo, Component component, Widget groupBody, string componentTypeName )
{
if ( groupInfo == null || groupBody == null )
return;
var type = physicsLock.GetType();
bool IsSimple( Type t )
{
if ( t == null ) return false;
if ( t.IsEnum ) return true;
if ( t == typeof( string ) ) return true;
if ( t.IsPrimitive ) return true;
if ( t == typeof( float ) || t == typeof( double ) ) return true;
return false;
}
void AddOptionRow( string memberName, string valueText )
{
if ( string.IsNullOrEmpty( memberName ) )
return;
var entryDisplay = string.IsNullOrEmpty( displayPrefix )
? memberName
: $"{displayPrefix}.{memberName}";
if ( !ShouldIncludeRowForSearch( entryDisplay, "PhysicsLock", valueText ) )
return;
var entryRowWidget = new Widget( groupBody );
var entryLayout = entryRowWidget.Layout = Layout.Row();
entryLayout.Spacing = 2;
var entryHitLabel = AddMatchIndicator( entryRowWidget, entryLayout );
var entryNameContainer = new Widget( entryRowWidget );
var entryNameLayout = entryNameContainer.Layout = Layout.Row();
entryNameLayout.Spacing = 0;
var entryNameLabel = new Label( entryDisplay )
{
Alignment = TextFlag.LeftCenter
};
entryNameLabel.SetStyles( "margin:0; padding:0;" );
entryNameLayout.Add( entryNameLabel, 0 );
entryNameContainer.FixedWidth = 180;
entryLayout.Add( entryNameContainer, 0 );
var entryWidget = new Label( valueText )
{
FixedHeight = Theme.RowHeight * 0.8f,
Alignment = TextFlag.LeftCenter
};
var entryBaseStyle = "background-color: rgba(255,255,255,0.08); padding: 0 3px;";
entryWidget.SetStyles( entryBaseStyle );
entryLayout.Add( entryWidget, 1 );
var entryTooltip =
$"Type: PhysicsLock\nComponent: {componentTypeName}\nProperty: {entryDisplay}\nValue: {valueText}";
entryWidget.ToolTip = entryTooltip;
entryNameLabel.ToolTip = entryTooltip;
entryRowWidget.SetStyles( "margin: 2px 0; padding: 2px 4px; border-radius: 3px;" );
groupBody.Layout.Add( entryRowWidget );
var row = new PropertyRow
{
Widget = entryRowWidget,
NameLabel = entryNameLabel,
EditorWidget = entryWidget,
Component = component,
PropertyInfo = null,
Name = entryDisplay,
DisplayName = entryDisplay,
ComponentName = groupInfo.Name,
GameObjectName = groupInfo.OwnerName,
ValuePreview = valueText,
HitLabel = entryHitLabel,
EditorBaseStyle = entryBaseStyle,
IsSynthetic = true,
IsVisible = entryRowWidget.Visible
};
RegisterRowModel( row, groupInfo, row.Name, entryDisplay, valueText, PropertyKind.Complex, isSynthetic: true );
rows.Add( row );
rowsDirty = true;
groupInfo.Rows.Add( row );
}
foreach ( var prop in type.GetProperties( BindingFlags.Instance | BindingFlags.Public ) )
{
var t = prop.PropertyType;
if ( !IsSimple( t ) )
continue;
object v;
try
{
v = prop.GetValue( physicsLock );
}
catch
{
continue;
}
var valueText = v?.ToString() ?? string.Empty;
AddOptionRow( prop.Name, valueText );
}
foreach ( var field in type.GetFields( BindingFlags.Instance | BindingFlags.Public ) )
{
var t = field.FieldType;
if ( !IsSimple( t ) )
continue;
object v;
try
{
v = field.GetValue( physicsLock );
}
catch
{
continue;
}
var valueText = v?.ToString() ?? string.Empty;
AddOptionRow( field.Name, valueText );
}
}
private bool ShouldIncludeRowForSearch( string name, string typeName, string value )
{
if ( string.IsNullOrWhiteSpace( lastSearch ) )
return true;
name ??= string.Empty;
typeName ??= string.Empty;
value ??= string.Empty;
if ( activeExcludeTerms != null && activeExcludeTerms.Length > 0 )
{
var blob = $"{name} {typeName} {value}";
for ( int i = 0; i < activeExcludeTerms.Length; i++ )
{
var term = activeExcludeTerms[i];
if ( string.IsNullOrEmpty( term ) )
continue;
if ( SearchTokens.FuzzyContainsRaw( blob, term ) )
return false;
}
}
if ( activeQueryFilters.NameTerms.Length > 0 &&
!activeQueryFilters.NameTerms.All( t => SearchTokens.FuzzyContainsRaw( name, t ) ) )
return false;
if ( activeQueryFilters.TypeTerms.Length > 0 &&
!activeQueryFilters.TypeTerms.All( t => SearchTokens.FuzzyContainsRaw( typeName, t ) ) )
return false;
if ( activeQueryFilters.ValueTerms.Length > 0 &&
!activeQueryFilters.ValueTerms.All( t => SearchTokens.FuzzyContainsRaw( value, t ) ) )
return false;
if ( activeIncludeTerms == null || activeIncludeTerms.Length == 0 )
return true;
var includeBlob = $"{name} {typeName} {value}";
return activeIncludeTerms.All( t => SearchTokens.FuzzyContainsRaw( includeBlob, t ) );
}
private void FlattenSerializedPropertyChildren( SerializedProperty property, string displayPrefix, ComponentGroup groupInfo, Component component, Widget groupBody, string componentTypeName, int depth = 0 )
{
if ( !showHiddenNestedBools || property == null || groupInfo == null || groupBody == null )
return;
if ( string.IsNullOrWhiteSpace( lastSearch ) )
return;
if ( depth > MaxSerializedChildDepth )
return;
SerializedObject nestedObject;
try
{
if ( !property.TryGetAsObject( out nestedObject ) )
return;
}
catch
{
return;
}
var added = 0;
foreach ( var childProp in nestedObject )
{
if ( added >= MaxSerializedChildRows )
return;
if ( childProp.HasAttribute<HideAttribute>() )
continue;
var childType = childProp.PropertyType;
var childKind = GetPropertyKind( childType );
var childName = childProp.DisplayName ?? childProp.Name;
var childDisplay = string.IsNullOrEmpty( displayPrefix )
? childName
: $"{displayPrefix}.{childName}";
if ( childKind == PropertyKind.Complex )
{
if ( depth < MaxSerializedChildDepth )
{
FlattenSerializedPropertyChildren( childProp, childDisplay, groupInfo, component, groupBody, componentTypeName, depth + 1 );
}
continue;
}
string childValue = string.Empty;
try
{
var valueObj = childProp.GetValue<object>();
childValue = valueObj?.ToString() ?? string.Empty;
}
catch
{
childValue = string.Empty;
}
if ( !ShouldIncludeRowForSearch( childDisplay, childType?.Name ?? string.Empty, childValue ) )
continue;
var childRowWidget = new Widget( groupBody );
var childLayout = childRowWidget.Layout = Layout.Row();
childLayout.Spacing = 2;
var childHitLabel = AddMatchIndicator( childRowWidget, childLayout );
var childNameContainer = new Widget( childRowWidget );
var childNameLayout = childNameContainer.Layout = Layout.Row();
childNameLayout.Spacing = 0;
var childNameLabel = new Label( childDisplay )
{
Alignment = TextFlag.LeftCenter
};
childNameLabel.SetStyles( "margin:0; padding:0;" );
childNameLayout.Add( childNameLabel, 0 );
childNameContainer.FixedWidth = 180;
childLayout.Add( childNameContainer, 0 );
var childValueLabel = new Label( childValue )
{
FixedHeight = Theme.RowHeight * 0.8f,
Alignment = TextFlag.LeftCenter
};
var childBaseStyle = "background-color: rgba(255,255,255,0.08); padding: 0 3px;";
childValueLabel.SetStyles( childBaseStyle );
childLayout.Add( childValueLabel, 1 );
var childTooltip =
$"Type: {childType?.Name ?? "Unknown"}\nComponent: {componentTypeName}\nProperty: {childDisplay}\nValue: {childValue}";
childValueLabel.ToolTip = childTooltip;
childNameLabel.ToolTip = childTooltip;
childRowWidget.SetStyles( "margin: 2px 0; padding: 2px 4px; border-radius: 3px;" );
groupBody.Layout.Add( childRowWidget );
var row = new PropertyRow
{
Widget = childRowWidget,
NameLabel = childNameLabel,
EditorWidget = childValueLabel,
Component = component,
PropertyInfo = null,
Name = childDisplay,
DisplayName = childDisplay,
ComponentName = groupInfo.Name,
GameObjectName = groupInfo.OwnerName,
ValuePreview = childValue,
HitLabel = childHitLabel,
EditorBaseStyle = childBaseStyle,
IsSynthetic = true,
IsVisible = childRowWidget.Visible
};
RegisterRowModel( row, groupInfo, row.Name, childDisplay, childValue, childKind, isSynthetic: true );
rows.Add( row );
rowsDirty = true;
groupInfo.Rows.Add( row );
added++;
}
}
private static string GetSimpleTypeName( Type type )
{
if ( type == null )
return null;
var name = type.Name;
var tick = name.IndexOf( '`' );
if ( tick > 0 )
{
name = name.Substring( 0, tick );
}
return name;
}
private static string[] BuildTypeTokens( Type type )
{
if ( type == null )
return Array.Empty<string>();
var tokens = new HashSet<string>( StringComparer.OrdinalIgnoreCase );
void AddTokens( string value )
{
if ( string.IsNullOrEmpty( value ) )
return;
foreach ( var token in SearchTokens.Tokenize( value ) )
{
if ( string.IsNullOrEmpty( token ) )
continue;
tokens.Add( token );
}
}
void AddType( Type t )
{
if ( t == null )
return;
AddTokens( GetSimpleTypeName( t ) );
AddTokens( t.FullName );
}
AddType( type );
var nullableType = Nullable.GetUnderlyingType( type );
if ( nullableType != null && nullableType != type )
AddType( nullableType );
if ( type.IsArray )
{
var element = type.GetElementType();
if ( element != null )
AddType( element );
}
if ( type.IsGenericType )
{
foreach ( var arg in type.GetGenericArguments() )
{
AddType( arg );
}
}
return tokens.Count == 0 ? Array.Empty<string>() : tokens.ToArray();
}
private void RegisterRowModel( PropertyRow row, ComponentGroup groupInfo, string propertyName, string displayName, string valueText, PropertyKind kind, bool isSynthetic )
{
if ( row == null || groupInfo == null )
return;
var propertyType = row.PropertyInfo?.PropertyType;
var propertyTypeName = GetSimpleTypeName( propertyType );
var propertyTypeFullName = propertyType?.FullName ?? string.Empty;
var propertyTypeFullNameLower = string.IsNullOrEmpty( propertyTypeFullName )
? string.Empty
: propertyTypeFullName.ToLowerInvariant();
var id = nextRowId++;
row.RowId = id;
var model = new RowModel
{
Id = id,
GameObjectKey = groupInfo.OwnerKey,
OwnerKey = groupInfo.MatchKey ?? groupInfo.OwnerKey,
OwnerName = groupInfo.OwnerName,
GameObjectName = groupInfo.OwnerName,
ComponentName = groupInfo.Name,
PropertyName = propertyName,
DisplayName = displayName,
ValuePreview = valueText,
PropertyTypeName = propertyTypeName,
Kind = kind,
IsSynthetic = isSynthetic,
IsError = valueText == "<error>"
};
row.GameObjectKey = model.GameObjectKey;
rowModels.Add( model );
rowModelLookup[id] = model;
rowLookup[id] = row;
model.NameTokens = SearchTokens.Tokenize( $"{model.PropertyNameLower} {model.DisplayNameLower}" );
model.TypeTokens = BuildTypeTokens( propertyType );
if ( !string.IsNullOrEmpty( propertyTypeName ) && ApiTypeTokenMap.Value.TryGetValue( propertyTypeName, out var apiTokens ) )
{
model.TypeTokens = MergeTokens( model.TypeTokens, apiTokens );
}
else if ( !string.IsNullOrEmpty( propertyTypeFullName ) && ApiTypeTokenMap.Value.TryGetValue( propertyTypeFullName, out var apiTokensFull ) )
{
model.TypeTokens = MergeTokens( model.TypeTokens, apiTokensFull );
}
model.ComponentTokens = SearchTokens.Tokenize( model.ComponentNameLower );
model.ObjectTokens = SearchTokens.Tokenize( model.GameObjectNameLower );
model.ValueTokens = SearchTokens.Tokenize( model.ValuePreviewLower );
model.SearchBlobLower = string.Join( " ", new[]
{
model.PropertyNameLower,
model.DisplayNameLower,
model.PropertyTypeNameLower,
propertyTypeFullNameLower,
model.ComponentNameLower,
model.GameObjectNameLower,
model.ValuePreviewLower
} );
AddTokensToIndex( nameIndex, model.NameTokens, id );
AddTokensToIndex( typeIndex, model.TypeTokens, id );
AddTokensToIndex( componentIndex, model.ComponentTokens, id );
AddTokensToIndex( objectIndex, model.ObjectTokens, id );
AddTokensToIndex( valueIndex, model.ValueTokens, id );
rowsDirty = true;
}
private void AddTokensToIndex( Dictionary<string, HashSet<int>> index, string[] tokens, int rowId )
{
if ( tokens == null || tokens.Length == 0 )
return;
foreach ( var token in tokens )
{
if ( string.IsNullOrEmpty( token ) )
continue;
if ( !index.TryGetValue( token, out var set ) )
{
set = new HashSet<int>();
index[token] = set;
}
set.Add( rowId );
}
}
private void ClearTokenIndexes()
{
nameIndex.Clear();
typeIndex.Clear();
valueIndex.Clear();
componentIndex.Clear();
objectIndex.Clear();
}
private HashSet<int> IntersectTerms( Dictionary<string, HashSet<int>> index, string[] terms )
{
HashSet<int> current = null;
foreach ( var term in terms )
{
if ( !index.TryGetValue( term, out var set ) || set == null || set.Count == 0 )
{
return new HashSet<int>();
}
if ( current == null )
{
current = new HashSet<int>( set );
}
else
{
current.IntersectWith( set );
}
}
return current ?? new HashSet<int>();
}
private IEnumerable<RowModel> GetFilterCandidates( string[] includeTerms, QueryFilters filters, bool termEmpty, bool refineWithinBase )
{
IEnumerable<RowModel> baseSet = refineWithinBase && safeBaseActive && safeBaseRowIds.Count > 0
? rowModels.Where( m => m != null && safeBaseRowIds.Contains( m.Id ) )
: rowModels;
HashSet<int> filterCandidates = null;
void IntersectFilter( Dictionary<string, HashSet<int>> index, string[] terms )
{
if ( terms == null || terms.Length == 0 )
return;
var termSet = IntersectTerms( index, terms );
if ( filterCandidates == null )
filterCandidates = termSet;
else
filterCandidates.IntersectWith( termSet );
}
IntersectFilter( typeIndex, filters.TypeTerms );
IntersectFilter( componentIndex, filters.ComponentTerms );
IntersectFilter( objectIndex, filters.ObjectTerms );
IntersectFilter( nameIndex, filters.NameTerms );
IntersectFilter( valueIndex, filters.ValueTerms );
if ( refineWithinBase && safeBaseActive && safeBaseRowIds.Count > 0 && filterCandidates != null )
{
filterCandidates.IntersectWith( safeBaseRowIds );
}
if ( termEmpty )
{
if ( filterCandidates == null )
return baseSet;
return baseSet.Where( m => m != null && filterCandidates.Contains( m.Id ) );
}
var includeCandidateIds = new HashSet<int>();
void Union( HashSet<int> source )
{
if ( source == null || source.Count == 0 )
return;
includeCandidateIds.UnionWith( source );
}
var nameCandidates = IntersectTerms( nameIndex, includeTerms );
Union( nameCandidates );
if ( includeTypeNamesInNameSearch )
{
var typeCandidates = IntersectTerms( typeIndex, includeTerms );
Union( typeCandidates );
}
if ( valueSearchMode != ValueSearchMode.NamesOnly )
{
var valueCandidates = IntersectTerms( valueIndex, includeTerms );
Union( valueCandidates );
}
// Object/component matches still influence visibility/highlighting.
Union( IntersectTerms( componentIndex, includeTerms ) );
Union( IntersectTerms( objectIndex, includeTerms ) );
// If no index hits, fall back to the full (or base) set to avoid missing rows.
if ( includeCandidateIds.Count == 0 )
{
if ( includeTerms == null || includeTerms.Length == 0 )
return baseSet;
if ( filterCandidates != null )
{
return baseSet.Where( m =>
m != null
&& filterCandidates.Contains( m.Id )
&& includeTerms.All( t => SearchTokens.FuzzyContainsRaw( m.SearchBlobLower, t ) ) );
}
return baseSet.Where( m =>
m != null && includeTerms.All( t => SearchTokens.FuzzyContainsRaw( m.SearchBlobLower, t ) ) );
}
if ( filterCandidates != null )
{
includeCandidateIds.IntersectWith( filterCandidates );
}
if ( refineWithinBase && safeBaseActive && safeBaseRowIds.Count > 0 )
{
includeCandidateIds.IntersectWith( safeBaseRowIds );
}
var list = new List<RowModel>( includeCandidateIds.Count );
foreach ( var id in includeCandidateIds )
{
if ( rowModelLookup.TryGetValue( id, out var model ) && model != null )
{
list.Add( model );
}
}
return list;
}
/// <summary>
/// Build all property rows for a given component group. This is used both in
/// the default eager mode and when a lazy-loaded component is explicitly expanded.
/// </summary>
private void BuildComponentRows( ComponentGroup groupInfo )
{
if ( groupInfo == null || groupInfo.Component == null || !groupInfo.Component.IsValid() )
return;
groupInfo.Rows.Clear();
var component = groupInfo.Component;
var groupBody = groupInfo.Body;
var props = groupInfo.Properties ?? Array.Empty<PropertyInfo>();
// Try to get a SerializedObject for this component so we can
// use ControlWidget editors for complex/serialized fields.
SerializedObject componentSO = null;
try
{
componentSO = EditorTypeLibrary.GetSerializedObject( component );
}
catch
{
componentSO = null;
}
var type = component.GetType();
// Additionally inspect public instance fields so complex option structs that
// are exposed as fields (for example, some RenderOptions layouts) can still
// contribute nested boolean search rows even when they are not surfaced as
// [Property]-decorated properties.
foreach ( var field in type.GetFields( BindingFlags.Instance | BindingFlags.Public ) )
{
var fieldType = field.FieldType;
// Skip obvious non-container types
if ( fieldType.IsPrimitive || fieldType == typeof( string ) )
continue;
object fieldValue;
try
{
fieldValue = field.GetValue( component );
}
catch
{
continue;
}
if ( fieldValue == null )
continue;
var fieldDisplay = DisplayInfo.ForMember( field ).Name ?? field.Name;
FlattenNestedBoolMembers( fieldValue, fieldDisplay, groupInfo, component, groupBody, type.Name);
}
foreach ( var prop in props )
{
// Use pretty display name like the built-in inspector ("Cast Shadows" instead of "CastShadows")
var displayInfo = DisplayInfo.ForMember( prop );
var displayName = displayInfo.Name ?? prop.Name;
object valueObj = null;
string valueText = string.Empty;
try
{
valueObj = prop.GetValue( component );
// Treat null as empty so it's easy to type a fresh value
valueText = valueObj?.ToString() ?? string.Empty;
}
catch
{
valueText = "<error>";
}
var includeMainRow = ShouldIncludeRowForSearch( displayName, prop.PropertyType?.Name ?? string.Empty, valueText );
Widget editorWidget = null;
var propType = prop.PropertyType;
var underlyingType = Nullable.GetUnderlyingType( propType ) ?? propType;
var compactSearchUi = !string.IsNullOrWhiteSpace( lastSearch );
var allowControlInCompact = propType == typeof( Model );
SerializedProperty serializedProp = null;
if ( componentSO != null )
{
try
{
serializedProp = componentSO.GetProperty( prop.Name );
}
catch
{
serializedProp = null;
}
}
// Resolve type/component names once per property so both the main
// row and any flattened nested rows can share them in their tooltips.
var typeName = propType != null ? propType.Name : "Unknown";
var componentTypeName = component != null ? component.GetType().Name : "Unknown";
List<(string Name, string Value)> parameterEntries = null;
if ( propType == typeof( SkinnedModelRenderer.ParameterAccessor ) )
{
try
{
var accessor = valueObj as SkinnedModelRenderer.ParameterAccessor;
if ( accessor == null && serializedProp != null && serializedProp.IsValid )
{
accessor = serializedProp.GetValue<SkinnedModelRenderer.ParameterAccessor>();
}
var graph = accessor?.Graph;
if ( accessor != null && graph != null )
{
var summary = BuildParameterAccessorSummary( accessor, graph, out parameterEntries );
if ( !string.IsNullOrEmpty( summary ) )
{
valueText = summary;
}
valueObj = accessor;
}
}
catch
{
// Best-effort only; fall back to default value text.
}
}
var kind = GetPropertyKind( propType );
var includeKind = ShouldIncludeKind( kind );
// For complex container types, we still want to be able to flatten and
// expose simple nested fields (like RenderOptions.BloomLayer) even when
// the complex type itself is filtered out.
var allowComplexForNested = kind == PropertyKind.Complex && !includeKind;
if ( !includeKind && !allowComplexForNested )
continue;
var skipMainRow = false;
if ( allowComplexForNested )
{
skipMainRow = true;
}
var isSimpleType = underlyingType == typeof( string )
|| underlyingType.IsEnum
|| underlyingType == typeof( int )
|| underlyingType == typeof( float )
|| underlyingType == typeof( double )
|| underlyingType == typeof( long )
|| underlyingType == typeof( short )
|| underlyingType == typeof( uint )
|| underlyingType == typeof( ulong )
|| underlyingType == typeof( bool );
if ( !includeMainRow && isSimpleType )
continue;
if ( !includeMainRow )
skipMainRow = true;
// Basic editable types: string, numeric, bool, enum
if ( underlyingType == typeof( string ) ||
underlyingType.IsEnum ||
underlyingType == typeof( int ) ||
underlyingType == typeof( float ) ||
underlyingType == typeof( double ) ||
underlyingType == typeof( long ) ||
underlyingType == typeof( short ) ||
underlyingType == typeof( uint ) ||
underlyingType == typeof( ulong ) )
{
var line = new LineEdit( groupBody )
{
Text = valueText,
FixedHeight = Theme.RowHeight * 0.8f
};
line.SetStyles( "margin:0; padding:0 3px;" );
// Commit on finish edit
var capturedComponent = component;
var capturedRowIndex = rows.Count;
line.EditingFinished += () =>
{
if ( capturedComponent is null || !capturedComponent.IsValid() )
return;
if ( capturedRowIndex < 0 || capturedRowIndex >= rows.Count )
return;
var row = rows[capturedRowIndex];
TryCommitValue( row, line.Text );
};
editorWidget = line;
}
else if ( underlyingType == typeof( bool ) )
{
// Toggle via checkbox-style icon button, left-aligned in the value column
IconButton boolToggle = null;
var capturedComponent = component;
var capturedProp = prop;
var capturedRowIndex = rows.Count;
bool GetCurrent()
{
try
{
return (bool)(capturedProp.GetValue( capturedComponent ) ?? false);
}
catch
{
return false;
}
}
void UpdateBoolIcon()
{
var current = GetCurrent();
boolToggle.Icon = current ? "check_box" : "check_box_outline_blank";
}
boolToggle = new IconButton( "check_box_outline_blank", () =>
{
if ( capturedComponent is null || !capturedComponent.IsValid() )
return;
if ( capturedRowIndex < 0 || capturedRowIndex >= rows.Count )
return;
var current = GetCurrent();
var newVal = !current;
try
{
capturedProp.SetValue( capturedComponent, newVal );
var row = rows[capturedRowIndex];
row.ValuePreview = newVal ? "true" : "false";
UpdateBoolIcon();
}
catch { }
} )
{
FixedHeight = Theme.RowHeight * 0.8f,
FixedWidth = Theme.RowHeight,
ToolTip = "Toggle the bool value"
};
UpdateBoolIcon();
var boolContainer = new Widget( groupBody );
var boolLayout = boolContainer.Layout = Layout.Row();
boolLayout.Spacing = 0;
boolLayout.Add( boolToggle, 0 );
editorWidget = boolContainer;
}
else
{
// Complex type – prefer search-friendly representations over
// embedding heavy custom editors. For most types we still use
// ControlWidget when available; ParameterAccessor is handled
// specially via summary + flattened rows.
if ( includeMainRow && serializedProp != null && serializedProp.IsValid && includeKind )
{
try
{
var spVal = serializedProp.GetValue<object>();
if ( spVal != null )
{
valueObj ??= spVal;
if ( string.IsNullOrEmpty( valueText ) || valueText == prop.PropertyType?.Name )
{
valueText = spVal.ToString();
}
}
}
catch { }
}
if ( includeMainRow && includeKind )
{
if ( (compactSearchUi && !allowControlInCompact) || serializedProp == null || !serializedProp.IsValid )
{
editorWidget = new Label( valueText )
{
FixedHeight = Theme.RowHeight * 0.8f,
Alignment = TextFlag.LeftCenter
};
}
else if ( propType == typeof( SkinnedModelRenderer.ParameterAccessor ) )
{
editorWidget = new Label( valueText )
{
FixedHeight = Theme.RowHeight * 0.8f,
Alignment = TextFlag.LeftCenter
};
}
else
{
var control = ControlWidget.Create( serializedProp );
if ( control.IsValid() )
{
editorWidget = control;
}
else
{
editorWidget = new Label( valueText )
{
FixedHeight = Theme.RowHeight * 0.8f,
Alignment = TextFlag.LeftCenter
};
}
}
}
// Always make key option flags like Bloom/BloomLayer directly searchable,
// even when the broader nested-bool flattening is disabled.
if ( valueObj != null )
{
TryAddBloomOptionRow( valueObj, displayName, groupInfo, component, groupBody, componentTypeName );
// Flatten simple nested boolean fields inside complex structs so they
// can be searched directly (e.g. RenderOptions.Bloom or RenderOptions.BloomLayer),
// even when they live a couple of levels deep in an "Advanced" sub-struct.
FlattenNestedBoolMembers( valueObj, displayName, groupInfo, component, groupBody, componentTypeName );
// Flatten simple list-like containers (e.g. hide-body-groups lists) so
// their entries can be searched by name.
FlattenListEntries( valueObj, displayName, groupInfo, component, groupBody, componentTypeName );
// Flatten simple PhysicsLock fields (e.g. Pitch/Yaw) for targeted
// option structs without resorting to blanket struct flattening.
if ( valueObj is PhysicsLock physicsLock )
{
FlattenPhysicsLockOptions( physicsLock, displayName, groupInfo, component, groupBody, componentTypeName );
}
}
if ( serializedProp != null && serializedProp.IsValid )
{
FlattenSerializedPropertyChildren( serializedProp, displayName, groupInfo, component, groupBody, componentTypeName );
}
// If this complex property itself isn't included by the current type
// filters, skip creating a main row for it – we only keep the nested
// flattened rows above.
if ( skipMainRow )
continue;
}
// Name label for the property column
var nameLabel = new Label( displayName )
{
Alignment = TextFlag.LeftCenter
};
var rowWidget = new Widget( groupBody );
var rowLayout = rowWidget.Layout = Layout.Row();
rowLayout.Spacing = 2;
// When a search term is active, defer showing new rows until the
// next ApplyFilter pass so we never flash unfiltered content while
// components are still streaming in.
if ( !string.IsNullOrEmpty( lastSearch ) )
{
rowWidget.Visible = false;
}
// Match indicator cells to the left of the name and value fields.
var nameDotContainer = new Widget( rowWidget );
nameDotContainer.FixedWidth = 12;
var nameDotLayout = nameDotContainer.Layout = Layout.Row();
nameDotLayout.Spacing = 0;
var nameDotLabel = new Label( "" )
{
Alignment = TextFlag.Center
};
nameDotLabel.SetStyles( "color: rgba(110,210,175,0.95); font-size: 12px; font-weight: 700; margin:0; padding:0;" );
nameDotLabel.Visible = false;
nameDotLayout.Add( nameDotLabel, 1 );
rowLayout.Add( nameDotContainer, 0 );
// Fixed-width property column, flexible editor column (single label for name)
var nameContainer = new Widget( rowWidget );
var nameLayout = nameContainer.Layout = Layout.Row();
nameLayout.Spacing = 0;
nameLabel.SetStyles( "margin:0; padding:0;" );
nameLayout.Add( nameLabel, 0 );
nameContainer.FixedWidth = 180;
rowLayout.Add( nameContainer, 0 );
var valueDotContainer = new Widget( rowWidget );
valueDotContainer.FixedWidth = 12;
var valueDotLayout = valueDotContainer.Layout = Layout.Row();
valueDotLayout.Spacing = 0;
var valueDotLabel = new Label( "" )
{
Alignment = TextFlag.Center
};
valueDotLabel.SetStyles( "color: rgba(110,210,175,0.95); font-size: 12px; font-weight: 700; margin:0; padding:0;" );
valueDotLabel.Visible = false;
valueDotLayout.Add( valueDotLabel, 1 );
rowLayout.Add( valueDotContainer, 0 );
rowLayout.Add( editorWidget, 1 );
// Subtle background accent on the editor cell – neutral, off-white
// plastic so type differences are not overly colorful.
var kindForRow = kind;
var editorKindStyle = "background-color: rgba(255,255,255,0.08); padding: 0 3px;";
if ( !string.IsNullOrEmpty( editorKindStyle ) )
{
editorWidget.SetStyles( editorKindStyle + " padding: 0 3px;" );
}
// Per-row tooltip describing type/component/property/value (with simple emphasis)
var tooltipText =
$"Type: {typeName}\nComponent: {componentTypeName}\nProperty: {displayName}\nValue: {valueText}";
editorWidget.ToolTip = tooltipText;
nameLabel.ToolTip = tooltipText;
// Vertical spacing and subtle rounding for the entire row; zebra colors applied after filtering
rowWidget.SetStyles( "margin: 2px 0; padding: 2px 4px; border-radius: 3px;" );
groupBody.Layout.Add( rowWidget );
var row = new PropertyRow
{
Widget = rowWidget,
NameLabel = nameLabel,
EditorWidget = editorWidget,
Component = component,
PropertyInfo = prop,
Name = prop.Name,
DisplayName = displayName,
ComponentName = groupInfo.Name,
GameObjectName = groupInfo.OwnerName,
ValuePreview = valueText,
HitLabel = null,
NameMatchDot = nameDotLabel,
ValueMatchDot = valueDotLabel,
EditorBaseStyle = editorKindStyle,
IsVisible = rowWidget.Visible
};
RegisterRowModel( row, groupInfo, prop.Name, displayName, valueText, kindForRow, isSynthetic: false );
rows.Add( row );
rowsDirty = true;
groupInfo.Rows.Add( row );
// Expose ParameterAccessor entries as individual searchable rows.
if ( parameterEntries != null && parameterEntries.Count > 0 )
{
foreach ( var (entryName, entryValue) in parameterEntries )
{
var entryDisplay = $"{displayName}.{entryName}";
var entryWidget = new Label( entryValue )
{
FixedHeight = Theme.RowHeight * 0.8f,
Alignment = TextFlag.LeftCenter
};
var entryBaseStyle = "background-color: rgba(255,255,255,0.08); padding: 0 3px;";
entryWidget.SetStyles( entryBaseStyle );
var entryRowWidget = new Widget( groupBody );
var entryLayout = entryRowWidget.Layout = Layout.Row();
entryLayout.Spacing = 2;
var entryHitLabel = AddMatchIndicator( entryRowWidget, entryLayout );
var entryNameContainer = new Widget( entryRowWidget );
var entryNameLayout = entryNameContainer.Layout = Layout.Row();
entryNameLayout.Spacing = 0;
var entryNameLabel = new Label( entryDisplay )
{
Alignment = TextFlag.LeftCenter
};
entryNameLabel.SetStyles( "margin:0; padding:0;" );
entryNameLayout.Add( entryNameLabel, 0 );
entryNameContainer.FixedWidth = 180;
entryLayout.Add( entryNameContainer, 0 );
entryLayout.Add( entryWidget, 1 );
var entryTooltip =
$"Type: Parameter\nComponent: {componentTypeName}\nProperty: {entryDisplay}\nValue: {entryValue}";
entryWidget.ToolTip = entryTooltip;
entryNameLabel.ToolTip = entryTooltip;
entryRowWidget.SetStyles( "margin: 2px 0; padding: 2px 4px; border-radius: 3px;" );
groupBody.Layout.Add( entryRowWidget );
var paramRow = new PropertyRow
{
Widget = entryRowWidget,
NameLabel = entryNameLabel,
EditorWidget = entryWidget,
Component = component,
PropertyInfo = null,
Name = $"{prop.Name}.{entryName}",
DisplayName = entryDisplay,
ComponentName = groupInfo.Name,
GameObjectName = groupInfo.OwnerName,
ValuePreview = entryValue,
HitLabel = entryHitLabel,
EditorBaseStyle = entryBaseStyle,
IsSynthetic = true,
IsVisible = entryRowWidget.Visible
};
RegisterRowModel( paramRow, groupInfo, paramRow.Name, entryDisplay, entryValue, PropertyKind.Complex, isSynthetic: true );
rows.Add( paramRow );
rowsDirty = true;
groupInfo.Rows.Add( paramRow );
}
}
}
}
private Label AddMatchIndicator( Widget rowWidget, Layout rowLayout )
{
var hitContainer = new Widget( rowWidget );
hitContainer.FixedWidth = 20;
var hitLayout = hitContainer.Layout = Layout.Row();
hitLayout.Spacing = 0;
var hitLabel = new Label( "" )
{
Alignment = TextFlag.Center
};
hitLabel.SetStyles( "color: rgba(110,210,175,0.95); font-size: 12px; font-weight: 700; margin:0; padding:0;" );
hitLabel.Visible = false;
hitLayout.Add( hitLabel, 1 );
rowLayout.Add( hitContainer, 0 );
return hitLabel;
}
private void TryCommitValue( PropertyRow row, string text )
{
if ( isDisposed )
return;
if ( row == null || row.Component is null || !row.Component.IsValid() || row.PropertyInfo is null )
return;
var pType = row.PropertyInfo.PropertyType;
object converted = null;
try
{
if ( pType == typeof( string ) )
{
converted = text;
}
else if ( pType.IsEnum )
{
converted = Enum.Parse( pType, text, ignoreCase: true );
}
else if ( pType == typeof( int ) )
{
if ( int.TryParse( text, out var v ) ) converted = v;
}
else if ( pType == typeof( float ) )
{
if ( float.TryParse( text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var v ) )
converted = v;
}
else if ( pType == typeof( double ) )
{
if ( double.TryParse( text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var v ) )
converted = v;
}
else if ( pType == typeof( long ) )
{
if ( long.TryParse( text, out var v ) ) converted = v;
}
else if ( pType == typeof( short ) )
{
if ( short.TryParse( text, out var v ) ) converted = v;
}
else if ( pType == typeof( uint ) )
{
if ( uint.TryParse( text, out var v ) ) converted = v;
}
else if ( pType == typeof( ulong ) )
{
if ( ulong.TryParse( text, out var v ) ) converted = v;
}
}
catch
{
converted = null;
}
if ( converted is null && pType != typeof( string ) )
return;
try
{
row.PropertyInfo.SetValue( row.Component, converted );
row.ValuePreview = text;
}
catch
{
// Ignore bad writes; we don't want to throw in the UI.
}
}
private sealed class ScanJobRequest
{
public GameObject Target;
public bool IncludeChildren;
public bool LazyLoad;
public SelectionSignature Signature;
}
private sealed class ScanJobResult
{
public bool LazyLoad;
public long ScanMilliseconds;
public List<ScanGameObjectResult> GameObjects { get; } = new();
}
private sealed class ScanGameObjectResult
{
public GameObject GameObject;
public int Depth;
public string Path;
public List<ComponentScanResult> Components { get; } = new();
}
private sealed class ComponentScanResult
{
public Component Component;
public string DisplayName;
public PropertyInfo[] Properties;
}
private sealed class RowModel
{
public int Id;
public string GameObjectKey;
public string OwnerKey;
public string OwnerName;
public string GameObjectName;
public string ComponentName;
public string PropertyName;
public string DisplayName;
public string ValuePreview;
public string PropertyTypeName;
public PropertyKind Kind;
public bool IsSynthetic;
public bool IsError;
public string[] NameTokens;
public string[] TypeTokens;
public string[] ComponentTokens;
public string[] ObjectTokens;
public string[] ValueTokens;
public string SearchBlobLower;
public string GameObjectNameLower => GameObjectName?.ToLowerInvariant() ?? string.Empty;
public string ComponentNameLower => ComponentName?.ToLowerInvariant() ?? string.Empty;
public string PropertyNameLower => PropertyName?.ToLowerInvariant() ?? string.Empty;
public string DisplayNameLower => DisplayName?.ToLowerInvariant() ?? string.Empty;
public string ValuePreviewLower => ValuePreview?.ToLowerInvariant() ?? string.Empty;
public string PropertyTypeNameLower => PropertyTypeName?.ToLowerInvariant() ?? string.Empty;
}
private sealed class PropertyRow
{
public Widget Widget;
public Label NameLabel;
public Label HitLabel;
public Label NameMatchDot;
public Label ValueMatchDot;
public Widget EditorWidget;
public Component Component;
public PropertyInfo PropertyInfo;
public int RowId;
public string Name;
public string DisplayName;
public string ComponentName;
public string GameObjectKey;
public string GameObjectName;
public string ValuePreview;
public string EditorBaseStyle;
public bool IsSynthetic;
// Cached UI state so we can avoid redundant property and style updates
// during filtering.
public bool IsVisible;
public bool NameHighlighted;
public bool ValueHighlighted;
public bool NameDotOn;
public bool ValueDotOn;
public string NameLower => Name?.ToLowerInvariant() ?? string.Empty;
public string DisplayNameLower => DisplayName?.ToLowerInvariant() ?? string.Empty;
public string ComponentNameLower => ComponentName?.ToLowerInvariant() ?? string.Empty;
public string GameObjectNameLower => GameObjectName?.ToLowerInvariant() ?? string.Empty;
public string ValuePreviewLower => ValuePreview?.ToLowerInvariant() ?? string.Empty;
public void SetHighlighted( bool on )
{
var style = on
? "color: rgb(140,220,185);"
: "color: rgba(255,255,255,0.9);";
NameLabel?.SetStyles( style );
}
}
private sealed class ComponentGroup
{
public ExpandGroup Group;
public string Name;
public string OwnerKey;
public string MatchKey;
public string OwnerName;
public List<PropertyRow> Rows { get; } = new();
public int MatchCount;
public Component Component;
public PropertyInfo[] Properties;
public Widget Body;
public bool PropertiesBuilt;
}
private sealed class GameObjectGroup
{
public ExpandGroup Group;
public string Name;
public string NameLower;
public string Key;
public Widget Row;
public int MatchCount;
public bool NameMatch;
public Label HitLabel;
}
private readonly struct SelectionSignature : IEquatable<SelectionSignature>
{
private readonly long targetId;
private readonly bool includeChildren;
private readonly ValueSearchMode valueMode;
private readonly SearchTriggerMode triggerMode;
private readonly bool lazy;
private SelectionSignature( long targetId, bool includeChildren, ValueSearchMode valueMode, SearchTriggerMode triggerMode, bool lazy )
{
this.targetId = targetId;
this.includeChildren = includeChildren;
this.valueMode = valueMode;
this.triggerMode = triggerMode;
this.lazy = lazy;
}
public static SelectionSignature Create( GameObject target, bool includeChildren, ValueSearchMode valueMode, SearchTriggerMode triggerMode, bool lazy )
{
long id = 0;
if ( target != null && target.IsValid() )
{
// GameObject.Id is a Guid; use its hash as a stable-ish identifier for caching.
id = target.Id.GetHashCode();
}
return new SelectionSignature( id, includeChildren, valueMode, triggerMode, lazy );
}
public bool Equals( SelectionSignature other )
{
return targetId == other.targetId &&
includeChildren == other.includeChildren &&
valueMode == other.valueMode &&
triggerMode == other.triggerMode &&
lazy == other.lazy;
}
public override bool Equals( object obj )
{
return obj is SelectionSignature other && Equals( other );
}
public override int GetHashCode()
{
var hashCode = targetId.GetHashCode();
hashCode = (hashCode * 397) ^ includeChildren.GetHashCode();
hashCode = (hashCode * 397) ^ (int)valueMode;
hashCode = (hashCode * 397) ^ (int)triggerMode;
hashCode = (hashCode * 397) ^ lazy.GetHashCode();
return hashCode;
}
}
private sealed class RowMatchResult
{
public bool PassesFilters;
public bool Match;
public bool ValueMatch;
public bool NameMatch;
public bool ObjectNameMatch;
}
private sealed class RowFilterState
{
public Dictionary<int, RowMatchResult> RowStates { get; } = new();
public Dictionary<string, int> ComponentMatches { get; } = new();
public Dictionary<string, int> ObjectMatches { get; } = new();
public bool AnyObjectNameMatch;
public int MatchCount;
}
private sealed class FilterEngine
{
private readonly InspectorSearchTool owner;
public FilterEngine( InspectorSearchTool owner )
{
this.owner = owner;
}
private const int ParallelThreshold = 2000;
public RowFilterState Run( IEnumerable<RowModel> sourceRows, string[] includeTerms, string[] excludeTerms, QueryFilters filters, bool termEmpty, CancellationToken cancellationToken )
{
var result = new RowFilterState();
if ( sourceRows is null )
return result;
if ( sourceRows is ICollection<RowModel> list && list.Count >= ParallelThreshold )
{
RunParallel( list, includeTerms, excludeTerms, filters, termEmpty, cancellationToken, result );
return result;
}
foreach ( var row in sourceRows )
{
cancellationToken.ThrowIfCancellationRequested();
if ( row == null )
continue;
if ( !TryEvaluateRow( row, includeTerms, excludeTerms, filters, termEmpty, out var state, out var componentMatch ) )
continue;
UpdateAggregates( row, state, componentMatch, owner.matchTargetMode == MatchTargetMode.ComponentsOnly, result );
}
return result;
}
private void RunParallel(
ICollection<RowModel> rows,
string[] includeTerms,
string[] excludeTerms,
QueryFilters filters,
bool termEmpty,
CancellationToken token,
RowFilterState result )
{
var rowStates = new System.Collections.Concurrent.ConcurrentDictionary<int, RowMatchResult>();
var componentMatches = new System.Collections.Concurrent.ConcurrentDictionary<string, int>();
var objectMatches = new System.Collections.Concurrent.ConcurrentDictionary<string, int>();
int matchCount = 0;
int anyObjectNameMatch = 0;
Parallel.ForEach( rows, ( row, state ) =>
{
if ( token.IsCancellationRequested || row == null )
return;
if ( !TryEvaluateRow( row, includeTerms, excludeTerms, filters, termEmpty, out var rowState, out var componentMatch ) )
return;
rowStates[row.Id] = rowState;
if ( rowState.Match )
{
System.Threading.Interlocked.Increment( ref matchCount );
if ( !string.IsNullOrEmpty( row.OwnerKey ) )
componentMatches.AddOrUpdate( row.OwnerKey, 1, (_, v) => v + 1 );
if ( !string.IsNullOrEmpty( row.GameObjectKey ) )
objectMatches.AddOrUpdate( row.GameObjectKey, 1, (_, v) => v + 1 );
}
if ( owner.matchTargetMode == MatchTargetMode.ComponentsOnly && componentMatch )
{
if ( !string.IsNullOrEmpty( row.OwnerKey ) )
componentMatches.AddOrUpdate( row.OwnerKey, 1, (_, v) => Math.Max( 1, v ) );
if ( !string.IsNullOrEmpty( row.GameObjectKey ) )
objectMatches.AddOrUpdate( row.GameObjectKey, 1, (_, v) => Math.Max( 1, v ) );
}
if ( rowState.ObjectNameMatch )
{
System.Threading.Interlocked.Exchange( ref anyObjectNameMatch, 1 );
if ( !string.IsNullOrEmpty( row.GameObjectKey ) )
objectMatches.AddOrUpdate( row.GameObjectKey, 1, (_, v) => Math.Max( 1, v ) );
}
} );
result.MatchCount = matchCount;
result.AnyObjectNameMatch = anyObjectNameMatch == 1;
foreach ( var pair in rowStates )
result.RowStates[pair.Key] = pair.Value;
foreach ( var pair in componentMatches )
result.ComponentMatches[pair.Key] = pair.Value;
foreach ( var pair in objectMatches )
result.ObjectMatches[pair.Key] = pair.Value;
}
private bool TryEvaluateRow(
RowModel row,
string[] includeTerms,
string[] excludeTerms,
QueryFilters filters,
bool termEmpty,
out RowMatchResult state,
out bool componentMatch )
{
state = new RowMatchResult();
componentMatch = false;
// Type filter always applies
if ( !owner.ShouldIncludeKind( row.Kind ) )
{
state.PassesFilters = false;
return true;
}
// Value-state filters
if ( owner.valueFilterMode == ValueFilterMode.SetOnly && string.IsNullOrEmpty( row.ValuePreview ) )
{
state.PassesFilters = false;
return true;
}
if ( owner.valueFilterMode == ValueFilterMode.UnsetOnly && !string.IsNullOrEmpty( row.ValuePreview ) )
{
state.PassesFilters = false;
return true;
}
if ( owner.filterErrorsOnly && !row.IsError )
{
state.PassesFilters = false;
return true;
}
state.PassesFilters = true;
bool TokenContains( string[] tokens, string term ) => SearchTokens.Contains( tokens, term );
var includeTypeTokens = owner.includeTypeNamesInNameSearch && row.TypeTokens != null && row.TypeTokens.Length > 0;
if ( excludeTerms != null && excludeTerms.Length > 0 )
{
for ( int i = 0; i < excludeTerms.Length; i++ )
{
var term = excludeTerms[i];
if ( string.IsNullOrEmpty( term ) )
continue;
if ( TokenContains( row.ComponentTokens, term )
|| TokenContains( row.NameTokens, term )
|| (includeTypeTokens && TokenContains( row.TypeTokens, term ))
|| (row.ObjectTokens != null && TokenContains( row.ObjectTokens, term ))
|| (row.ValueTokens != null && TokenContains( row.ValueTokens, term )) )
{
state.PassesFilters = false;
return true;
}
}
}
if ( filters.TypeTerms.Length > 0 && !filters.TypeTerms.All( t => TokenContains( row.TypeTokens, t ) ) )
{
state.PassesFilters = false;
return true;
}
if ( filters.ComponentTerms.Length > 0 && !filters.ComponentTerms.All( t => TokenContains( row.ComponentTokens, t ) ) )
{
state.PassesFilters = false;
return true;
}
if ( filters.ObjectTerms.Length > 0 && !filters.ObjectTerms.All( t => TokenContains( row.ObjectTokens, t ) ) )
{
state.PassesFilters = false;
return true;
}
if ( filters.NameTerms.Length > 0 && !filters.NameTerms.All( t => TokenContains( row.NameTokens, t ) ) )
{
state.PassesFilters = false;
return true;
}
if ( filters.ValueTerms.Length > 0 && !filters.ValueTerms.All( t => TokenContains( row.ValueTokens, t ) ) )
{
state.PassesFilters = false;
return true;
}
if ( termEmpty )
{
return true;
}
componentMatch = includeTerms.All( t => TokenContains( row.ComponentTokens, t ) );
var nameMatch = includeTerms.All( t => TokenContains( row.NameTokens, t ) || (includeTypeTokens && TokenContains( row.TypeTokens, t )) );
var objectMatch = row.ObjectTokens != null && row.ObjectTokens.Length > 0 && includeTerms.All( t => TokenContains( row.ObjectTokens, t ) );
var valMatch = row.ValueTokens != null && row.ValueTokens.Length > 0 && includeTerms.All( t => TokenContains( row.ValueTokens, t ) );
state.ObjectNameMatch = objectMatch;
var propertyNameMatch = nameMatch;
var valueMatch = owner.valueSearchMode != ValueSearchMode.NamesOnly && valMatch;
if ( owner.valueSearchMode == ValueSearchMode.ValuesOnly )
propertyNameMatch = false;
if ( owner.valueSearchMode == ValueSearchMode.NamesOnly )
valueMatch = false;
if ( owner.matchTargetMode == MatchTargetMode.ComponentsOnly )
{
propertyNameMatch = false;
valueMatch = false;
}
var rowMatch = propertyNameMatch || valueMatch;
if ( owner.matchTargetMode == MatchTargetMode.ComponentsOnly )
{
rowMatch = componentMatch;
}
state.ValueMatch = valueMatch;
state.NameMatch = propertyNameMatch;
state.Match = rowMatch;
return true;
}
private static void UpdateAggregates( RowModel row, RowMatchResult state, bool componentMatch, bool componentsOnly, RowFilterState result )
{
if ( state.Match )
{
result.MatchCount++;
if ( !string.IsNullOrEmpty( row.OwnerKey ) )
{
result.ComponentMatches.TryGetValue( row.OwnerKey, out var cCount );
result.ComponentMatches[row.OwnerKey] = cCount + 1;
}
if ( !string.IsNullOrEmpty( row.GameObjectKey ) )
{
result.ObjectMatches.TryGetValue( row.GameObjectKey, out var gCount );
result.ObjectMatches[row.GameObjectKey] = gCount + 1;
}
}
if ( componentsOnly && componentMatch )
{
if ( !string.IsNullOrEmpty( row.OwnerKey ) )
{
result.ComponentMatches.TryGetValue( row.OwnerKey, out var cCount );
result.ComponentMatches[row.OwnerKey] = Math.Max( 1, cCount );
}
if ( !string.IsNullOrEmpty( row.GameObjectKey ) )
{
result.ObjectMatches.TryGetValue( row.GameObjectKey, out var gCount );
result.ObjectMatches[row.GameObjectKey] = Math.Max( 1, gCount );
}
}
if ( state.ObjectNameMatch )
{
result.AnyObjectNameMatch = true;
if ( !string.IsNullOrEmpty( row.GameObjectKey ) )
{
result.ObjectMatches.TryGetValue( row.GameObjectKey, out var gCount );
result.ObjectMatches[row.GameObjectKey] = Math.Max( 1, gCount );
}
}
result.RowStates[row.Id] = state;
}
}
private sealed class ComponentBuildQueue
{
private readonly Queue<ComponentGroup> queue = new();
private const int MaxComponentsPerFrame = 2;
public void Enqueue( ComponentGroup group )
{
if ( group == null )
return;
queue.Enqueue( group );
}
public bool ProcessNextBatch( Action<ComponentGroup> builder )
{
if ( builder == null || queue.Count == 0 )
return false;
var builtAny = false;
var processed = 0;
while ( queue.Count > 0 && processed < MaxComponentsPerFrame )
{
var group = queue.Dequeue();
if ( group == null || group.Body == null )
continue;
group.Body.Layout?.Clear( true );
builder( group );
group.PropertiesBuilt = true;
builtAny = true;
processed++;
}
return builtAny;
}
public void Clear()
{
queue.Clear();
}
public bool HasPending => queue.Count > 0;
}
private sealed class PropertyMetadataCache
{
private readonly System.Collections.Concurrent.ConcurrentDictionary<Type, PropertyInfo[]> propertyCache = new();
public PropertyInfo[] GetPropertiesForComponentType( Type type )
{
if ( type == null )
return Array.Empty<PropertyInfo>();
return propertyCache.GetOrAdd( type, t =>
t.GetProperties( BindingFlags.Instance | BindingFlags.Public )
.Where( p => p.IsDefined( typeof( PropertyAttribute ), inherit: true ) )
.ToArray() );
}
}
public override void OnDestroyed()
{
var destroyStopwatch = debugLogging ? Stopwatch.StartNew() : null;
Visible = false;
isDisposed = true;
pendingRebuild = false;
CancelPendingScan();
// Keep OnDestroyed as light as possible – the dock manager owns child
// widgets and will dispose them. We only clear our managed lists so
// subsequent callbacks become no-ops.
var rowCount = rows.Count;
var goCount = gameObjectGroups.Count;
var compCount = componentGroups.Count;
rows.Clear();
rowModels.Clear();
rowModelLookup.Clear();
rowLookup.Clear();
nextRowId = 0;
componentGroups.Clear();
gameObjectGroups.Clear();
ClearTokenIndexes();
target = null;
if ( destroyStopwatch != null )
{
destroyStopwatch.Stop();
Log.Info( $"[SearchFilter] OnDestroyed rows={rowCount} objects={goCount} components={compCount} ms={destroyStopwatch.ElapsedMilliseconds}" );
}
base.OnDestroyed();
}
}