UI/ContextMenu/Inspector.razor

A UI inspector panel Razor component for the s&box game that shows context editor tabs, highlights hovered and selected GameObjects, and provides world mouse interactions for selecting, deleting, or breaking props. It discovers editor types via TypeLibrary attributes, manages editor visibility, draws outline highlights, and performs scene ray traces to update the cursor and hover state.

NetworkingFile Access
@using Sandbox;
@using Sandbox.UI;
@namespace Sandbox
@inherits Panel

<root>

	<div class="canvas" @ref=_canvas></div>

	<div class="inspector-panel @(_editors?.Any( e => e.WasVisible ) == true ? "" : "hidden")" @onmousedown="@WindowPress">
		<div class="tab-bar">
			@foreach ( var entry in _editors?.Where( e => e.WasVisible ) ?? [] )
			{
				var e = entry;
				<div class="tab @( _active == e ? "active" : "" )" @onclick="@(() => SetActive( e ))">
					@e.Editor.Title
				</div>
			}
		</div>
		<div class="window-stack" @ref=_windowStack></div>
	</div>

</root>

@code
{
    public GameObject Hovered => hovered;

    Panel _canvas = default;
    Panel _windowStack = default;

    record EditorEntry( IInspectorEditor Editor )
    {
        public bool WasVisible { get; set; }
    }
    List<EditorEntry> _editors;
    EditorEntry _active;

    void InitEditors()
    {
        if ( _editors != null || _windowStack == null ) return;
        _editors = new();

        var types = TypeLibrary.GetTypesWithAttribute<InspectorEditorAttribute>()
            .OrderByDescending( x => x.Type.GetAttribute<OrderAttribute>()?.Value ?? 0 )
            .ThenBy( t => t.Attribute.Type is null ? 1 : 0 );

        foreach ( var (typeDesc, attr) in types )
        {
            var editor = typeDesc.Create<IInspectorEditor>();
            if ( editor is not Panel editorPanel ) continue;

            editorPanel.AddClass( "window" );
            editorPanel.SetClass( "hidden", true );
            editorPanel.Parent = _windowStack;

            _editors.Add( new EditorEntry( editor ) );
        }
    }

    void SetActive( EditorEntry entry )
    {
        _active = entry;
        ApplyVisibility();
    }

    void ApplyVisibility()
    {
        foreach ( var e in _editors )
            (e.Editor as Panel)?.SetClass( "hidden", !( e.WasVisible && e == _active ) );
    }

    HashSet<GameObject> _lastSelected = new();

    void UpdateEditors()
    {
        if ( _editors == null ) return;

        bool changed = false;

        foreach ( var entry in _editors )
        {
            bool visible = entry.Editor.TrySetTarget( _selected );
            if ( visible != entry.WasVisible ) changed = true;
            entry.WasVisible = visible;
        }

        if ( !_lastSelected.SetEquals( _selected ) )
        {
            changed = true;
            _lastSelected = _selected.ToHashSet();
        }

        if ( changed )
        {
            var visible = _editors.Where( e => e.WasVisible ).ToList();
            if ( _active == null || !_active.WasVisible )
                _active = visible.LastOrDefault();

            ApplyVisibility();
        }
    }

    protected override int BuildHash() => HashCode.Combine( hovered, _selected.Count, _active );

    public override void Tick()
    {
        _selected.RemoveAll( x => !x.IsValid() );
        InitEditors();
        UpdateEditors();
        UpdateHighlights();
        UpdateCursor();
    }

    protected override void OnVisibilityChanged()
    {
        UpdateHighlights();
    }

    void UpdateHighlights()
    {
        var host = Ancestors.OfType<ContextMenuHost>().FirstOrDefault();
        if (host is null) return;

        if (host.SelectedOutline is null || host.HoveredOutline is null)
            return;

        host.SelectedOutline.Targets ??= new();
        host.SelectedOutline.Targets.Clear();
        host.SelectedOutline.Color = new Color(4.7f, 10.1f, 30.6f, 1);
        host.SelectedOutline.ObscuredColor = new Color(2.2f, 2.3f, 2.9f, 0.1f);
        host.SelectedOutline.Width = 0.2f;

        host.HoveredOutline.Targets ??= new();
        host.HoveredOutline.Targets.Clear();
        host.HoveredOutline.Color = new Color(2.6f, 2.0f, 0.2f, 1);
        host.HoveredOutline.ObscuredColor = new Color(2.6f, 2.0f, 0.2f, 0.1f);
        host.HoveredOutline.Width = 0.2f;

        if (!IsVisible)
            return;

        host.SelectedOutline.Targets.AddRange(_selected.SelectMany(x => GetRenderers( x ) ) ?? []);

        if ( !_selected.Contains( hovered  ) )
        {
            host.HoveredOutline.Targets = GetRenderers( Hovered ).ToList();
        }
        else
        {
            host.HoveredOutline.Targets = default;
        }
    }

    IEnumerable<Renderer> GetRenderers( GameObject o )
    {
        if ( o == null ) yield break;

        foreach ( var r in o.GetComponents<Renderer>() )
        {
            yield return r;
        }

        foreach( var c in o.Children )
        {
            if (c.NetworkMode == NetworkMode.Object) continue;

            foreach (var rr in GetRenderers( c ) )
            {
                yield return rr;
            }
        }
    }

    GameObject hovered;

    List<GameObject> _selected = new();

    void UpdateCursor()
    {
        var cursorPos = Mouse.Position;
        var screenRay = Scene.Camera.ScreenPixelToRay( cursorPos );
        var tr = Scene.Trace.Ray(screenRay, 4096 )
                            .IgnoreGameObjectHierarchy( Player.FindLocalPlayer()?.GameObject )
                            .Run();

        var go = tr.Collider?.GameObject ?? tr.GameObject;
        go = go.FindNetworkRoot();

        if (!_canvas.HasHovered) go = default;
        if (!CanSelect(go)) go = null;

        UpdateHovered(go);
    }

    bool CanSelect( GameObject o )
    {
        if (o == null) return false;
        if (o.Tags.Has("world")) return false;
        if (o.NetworkMode == NetworkMode.Never) return false;

        o = o?.FindNetworkRoot();

        return true;
    }

    void UpdateHovered( GameObject o )
    {
        o = o?.FindNetworkRoot();

        if (hovered == o) return;

        hovered = o;
        PlaySound("ui.button.over");
    }


    public void WorldMouseDown(MousePanelEvent e)
    {
        SelectObject(hovered);
    }

    public void WorldMouseRightDown(MousePanelEvent e)
    {
        if ( !hovered.IsValid() ) return;

        SelectObject( hovered );

        var target = hovered;
        var isPlayer = target.Tags.Has( "player" );
        var prop = target.GetComponent<Prop>();
        var isGibbable = prop.IsValid() && prop.Health > 0;

        var menu = MenuPanel.Open( this );
        if ( !isPlayer )
        {
            menu.AddOption( "🗑️", Game.Language.GetPhrase( "spawnmenu.inspect.delete" ), () => GameManager.DeleteInspectedObject( target ) );
        }

        if ( isGibbable )
        {
            menu.AddOption( "💥", Game.Language.GetPhrase( "spawnmenu.inspect.break" ), () => GameManager.BreakInspectedProp( prop ) );
        }
    }

    public void WorldMouseUp(MousePanelEvent e)
    {
        // nothing.
    }

    public void SelectObject( GameObject o )
    {
        if ( !o.IsValid() )
        {
            _selected.Clear();
        }
        else
        {
            if (!Input.Down("run"))
                _selected.Clear();

            _selected.Remove(o);
            _selected.Add(o);
        }

        _selected = _selected.Distinct().ToList();
    }

	void WindowPress( PanelEvent panelEvent )
	{
		panelEvent.StopPropagation();
	}
}