terminalCode Example

s&box context menu with submenus and auto-positioning

calendar_today May 12, 2026 schedule ~1 min read person patrickjr verified 50

s&box Context Menu with Submenus and Auto-Positioning

This example shows a context menu system using s&box's Panel, Screen, and layout system with submenu builder pattern.

CSHARP
@using System
@using Sandbox.UI
@namespace Sandbox
@inherits Panel

<root>
    @if (!_isSubmenu)
    {
        <div class="background" @onmousedown=@Close @onclick=@Close></div>
    }

    <div class="inner @(_submenuOpensLeft && _options.Any(o => o.SubmenuBuilder != null) ? "opens-left" : "")">
        @foreach (var option in _options)
        {
            if (option.IsSpacer)
            {
                <div class="spacer"></div>
                continue;
            }

            var opt = option;

            <div class="option @(opt.SubmenuBuilder != null ? "has-submenu" : "")"
                 @onclick="@(() => OnOptionClick(opt))"
                 @onmouseover="@(() => OnOptionHover(opt))">
                <div class="icon">@opt.Icon</div>
                <div class="text">@opt.Text</div>
                @if (opt.SubmenuBuilder != null)
                {
                    <div class="submenu-arrow">@(_submenuOpensLeft ? "◀" : "▶")</div>
                }
            </div>
        }
    </div>
</root>

@code
{
    record MenuOption(string Icon, string Text, Action Action, Action<MenuPanel> SubmenuBuilder = null)
    {
        public bool IsSpacer => Icon == "__spacer__";
    }

    readonly List<MenuOption> _options = new();
    MenuPanel _openSubmenu;
    MenuOption? _activeSubmenuOption;
    bool _isSubmenu;
    bool _openLeft;
    float _parentLeft;
    bool _submenuOpensLeft;
    Panel _source;

    public static MenuPanel Open(Panel source)
    {
        var root = source.FindRootPanel();
        var menu = root.AddChild<MenuPanel>();
        menu._source = source;
        menu.Style.Left = root.MousePosition.x * root.ScaleFromScreen;
        menu.Style.Top = root.MousePosition.y * root.ScaleFromScreen;
        menu.PlaySound("sounds/kenney/ui/ui.navigate.forward.sound");
        return menu;
    }

    public override void Tick()
    {
        base.Tick();

        if (_isSubmenu) return;
        if (!_source.IsValid()) { Close(); return; }

        // Close menu if source panel becomes invisible
        if (_source.AncestorsAndSelf.Any(p => !p.IsVisible))
            Close();
    }

    public void AddOption(string icon, string text, Action action)
    {
        _options.Add(new MenuOption(icon, text, action));
    }

    public void AddSubmenu(string icon, string text, Action<MenuPanel> builder)
    {
        _options.Add(new MenuOption(icon, text, null, builder));
    }

    public void AddSpacer()
    {
        _options.Add(new MenuOption("__spacer__", "", null));
    }

    void OnOptionHover(MenuOption option)
    {
        if (_activeSubmenuOption == option) return;
        _activeSubmenuOption = option;

        CloseSubmenu();

        if (option.SubmenuBuilder == null) return;

        const int minWidth = 200;
        const int gap = 4;
        const int padding = 10;

        bool hasRoomRight = Box.Right + gap + minWidth < Screen.Width - padding;

        var root = FindRootPanel();
        var submenu = root.AddChild<MenuPanel>();
        submenu._isSubmenu = true;
        submenu._openLeft = !hasRoomRight;
        submenu._parentLeft = Box.Left;
        submenu.Style.Left = hasRoomRight ? Box.Right + gap : Box.Left - gap - minWidth;
        submenu.Style.Top = root.MousePosition.y * root.ScaleFromScreen - gap;

        option.SubmenuBuilder(submenu);
        submenu.StateHasChanged();
        _openSubmenu = submenu;
    }

    void SelectOption(MenuOption option)
    {
        CloseAll(this);
        option.Action?.Invoke();
    }

    public void Close()
    {
        CloseAll(this);
    }

    static void CloseAll(Panel source)
    {
        var root = source.FindRootPanel();
        foreach (var menu in root.Children.OfType<MenuPanel>().ToList())
            menu.Delete(false);
    }

    public override void OnLayout(ref Rect layoutRect)
    {
        const int padding = 10;
        const int gap = 4;

        if (_openLeft)
        {
            var w = layoutRect.Width;
            layoutRect.Right = _parentLeft - gap;
            layoutRect.Left = layoutRect.Right - w;
        }

        // Keep menu on screen using Screen.Width and Screen.Height
        if (layoutRect.Right > Screen.Width - padding)
        {
            layoutRect.Left -= layoutRect.Right - (Screen.Width - padding);
            layoutRect.Right = Screen.Width - padding;
        }

        if (layoutRect.Bottom > Screen.Height - padding)
        {
            layoutRect.Top -= layoutRect.Bottom - (Screen.Height - padding);
            layoutRect.Bottom = Screen.Height - padding;
        }

        _layoutRect = layoutRect;
        _submenuOpensLeft = !_isSubmenu && !(layoutRect.Right + 4 + 200 < Screen.Width - 10);
    }
}

s&box-Specific Features

  • Panel.FindRootPanel(): Gets the root panel for adding children
  • ScaleFromScreen: Converts screen coordinates to UI scale
  • Screen.Width/Screen.Height: Screen dimensions for layout clamping
  • OnLayout(ref Rect): s&box layout system override for positioning
  • Panel.AddChild<T>(): Adds child panels to the hierarchy
  • Panel.Delete(false): Removes panel without destroying children
  • Panel.PlaySound(): Plays UI sounds from asset paths
  • Panel.AncestorsAndSelf: Traverses panel hierarchy
Was this helpful?