UI/ToolPanel.razor

A Razor UI Panel that renders the tool settings column next to the Q menu. It builds rows from ToolSettings.For(Context) and draws toggles, option chips, colour swatches, text/number fields with nudges, and a scrollbar decision based on an estimated line count.

File Access
@using Sandbox;
@using Sandbox.UI;
@using System.Linq;
@using System.Collections.Generic;
@using NZombies;
@inherits Panel

@*
    TOOL SETTINGS — the column beside the Q menu.

    Shows the settings for whatever you are holding, or for the settings group
    you opened from the Settings tab. Rows come from ToolSettings, which reads
    and writes MapConfig / MapEditor directly, so this panel holds no state of
    its own and can never disagree with a value changed by a console command.

    ⚠️ A `Panel`, NOT a `PanelComponent`, and rendered as <ToolPanel /> inside
    DevMenu. A PanelComponent would need its own GameObject with its own
    ScreenPanel — and razor types are GENERATED, so no plain .cs file can create
    one (the reason LobbyState exists at all). As a child panel it needs no
    scene change whatsoever, and it lands beside the Q menu by construction.

    ⚠️ EVERY VALUE IS TYPEABLE. The first version was − / + steppers on every
    row, which is unusable for anything with a range: setting a health cap of
    60000 in steps of 1000 is sixty clicks. Steppers remain only as a nudge
    beside the field for numbers, where they are genuinely quicker than typing.
*@

@if ( Visible )
{
    @* `tall` gives the panel a DEFINITE height so its row list can scroll.
       Short lists stay content-sized — a two-row tool in a full-height frame
       is mostly empty box. See the scss for why a max-height alone does not
       work on an absolutely-positioned panel.

       ⛔ MEASURED FROM CONTENT, NOT FROM `Rows.Count`, AND THE OLD TEST WAS AN
       OFF-BY-ONE SITTING EXACTLY ON THE WALLBUY TOOL. That tool has SIX rows —
       Pack, Category, Weapon, Rarity, Price, Ammo — and the gate was
       `Rows.Count > 6`, so it never went tall, never got a definite height, and
       had nothing for `overflow-y: scroll` to act on. Reported as "the wallbuy
       tool does not let me scroll".

       ⚠️ AND ROW COUNT WAS NEVER THE RIGHT SIGNAL. A `RowKind.Options` row is
       one row holding every choice as a wrapped chip, so the Weapon row alone is
       dozens of lines tall — a two-row tool with a long picker overflows just as
       badly as a twelve-row one. Chips wrap about three to a line inside the
       240px `.options` box, so each option counts as a third of a line.

       ⛔ THE THRESHOLD IS 16, NOT 6, AND 6 MADE THE WHOLE MENU UNREADABLE. Going
       `tall` is not free: it gives the panel a DEFINITE height, and at 6 almost
       every tool in the game crossed it — so panels that used to size themselves
       to their content were suddenly full-height boxes whose rows were squeezed
       to fit. Reported as "everything is trying to fit on screen at once and
       nothing is understandable". The squeezing itself was a missing
       `flex-shrink: 0` and is fixed in the scss; this is the other half, which is
       that a bound should only be imposed when the content would actually need
       one.

       ⚠️ 16 IS ARITHMETIC, NOT TASTE. The panel spans 8%–92% of the screen, so
       about 900px at 1080p, and a row is ~46px (9px padding either side of a 28px
       control) — so roughly twenty fit before anything runs off the bottom.
       Sixteen leaves margin for the taller rows without tripping on ordinary
       ones: a four-row tool with a six-option picker scores 6 and stays exactly
       as it was, while the wallbuy tool's weapon list scores well over forty and
       still gets its scrollbar. *@
    <div class="toolpanel @(EstimatedLines > 16 ? "tall" : "")">

        <div class="titlebar">
            <div class="title">@ToolSettings.TitleFor( Context )</div>
            <div class="close" onclick=@( () => ToolPanelState.Context = "" )>✕</div>
        </div>

        <div class="rows">
            @foreach ( var row in Rows )
            {
                <div class="row">

                    <div class="head">
                        <div class="label">@row.Label</div>

                        @if ( row.Kind == ToolSettings.RowKind.Toggle )
                        {
                            <div class="toggle @(row.Value() == "on" ? "on" : "")"
                                 onclick=@( () => Nudge( row, 1 ) )>@row.Value()</div>
                        }
                        else if ( row.Kind == ToolSettings.RowKind.Info )
                        {
                            <div class="info">@row.Value()</div>
                        }
                        else if ( row.Kind == ToolSettings.RowKind.Colour )
                        {
                            @* ⚠️ EACH SWATCH IS PAINTED WITH ITS OWN VALUE, so the row shows
                               the colours rather than a list of hex codes nobody can read as
                               a colour. The current one is ringed rather than merely
                               highlighted — on a row of colours, a highlight IS a colour. *@
                            <div class="swatches">
                                @foreach ( var opt in row.Options?.Invoke() ?? System.Array.Empty<string>() )
                                {
                                    <div class="swatch @(opt == row.Value() ? "on" : "")"
                                         style="background-color: @opt;"
                                         onclick=@( () => SetText( row, opt ) )></div>
                                }
                            </div>
                        }
                        else if ( row.Kind == ToolSettings.RowKind.Options )
                        {
                            @* ⚠️ Options are resolved HERE, every draw — the weapon
                               list depends on the pack chosen in the row above, so a
                               value captured when the panel was built would be one
                               selection stale. *@
                            <div class="options">
                                @foreach ( var opt in row.Options?.Invoke() ?? System.Array.Empty<string>() )
                                {
                                    <div class="opt @(opt == row.Value() ? "on" : "")"
                                         onclick=@( () => SetText( row, opt ) )>@opt</div>
                                }
                            </div>
                        }
                        else
                        {
                            <div class="field">
                                <TextEntry class="entry" [email protected]()
                                           OnTextEdited=@( ( string v ) => SetText( row, v ) ) />

                                @if ( row.Kind == ToolSettings.RowKind.Number )
                                {
                                    <div class="nudge">
                                        <div class="step" onclick=@( () => Nudge( row, -1 ) )>−</div>
                                        <div class="step" onclick=@( () => Nudge( row, 1 ) )>+</div>
                                    </div>
                                }
                            </div>
                        }
                    </div>

                    @if ( !string.IsNullOrEmpty( row.Hint ) )
                    {
                        <div class="hint">@row.Hint</div>
                    }
                </div>
            }
        </div>

        <div class="foot">Unsaved — nz_save to keep</div>
    </div>
}

@code
{
    string Context => ToolPanelState.Context;

    List<ToolSettings.Row> Rows => ToolSettings.For( Context );

    /// <summary>
    /// Roughly how many lines tall the rows are, for the `tall` decision.
    ///
    /// ⚠️ OPTIONS ARE A `Func`, SO THIS COSTS A CALL PER OPTIONS ROW PER FRAME. That is
    /// the same call the markup below already makes to draw them — and it has to be live for
    /// the same reason: the weapon list depends on the pack chosen in the row above, so a
    /// count taken when the panel was built would be one selection stale.
    ///
    /// ⚠️ A THIRD OF A LINE PER CHIP, from `.options` being `max-width: 240px` with
    /// `flex-wrap: wrap` and chips running 60-80px. It only has to be right either side of
    /// six, not exact.
    /// </summary>
    int EstimatedLines
    {
        get
        {
            var lines = 0;
            foreach ( var row in Rows )
            {
                lines++;
                if ( row.Kind == ToolSettings.RowKind.Options )
                    lines += (row.Options?.Invoke()?.Length ?? 0) / 3;
            }
            return lines;
        }
    }

    /// <summary>
    /// Only when there is something to show.
    ///
    /// ⚠️ Gated on the ROW COUNT, not just on a context being set. An unknown
    /// tool id would otherwise open an empty frame, which looks like the panel
    /// is broken rather than like the tool having no settings — the tools that
    /// genuinely have none return an explicit Info row instead.
    /// </summary>
    bool Visible => !string.IsNullOrEmpty( Context ) && Rows.Count > 0;

    void Nudge( ToolSettings.Row row, int dir )
    {
        row.Nudge( dir );
        Log.Info( $"[nz-ui] {ToolSettings.TitleFor( Context )} · {row.Label} -> {row.Value()}" );
    }

    void SetText( ToolSettings.Row row, string value )
    {
        if ( row.Text is null ) return;

        row.Text( value );
        Log.Info( $"[nz-ui] {ToolSettings.TitleFor( Context )} · {row.Label} -> \"{row.Value()}\"" );
    }

    // ⚠️ The VALUES must be in the hash, not just the row count. They are read
    // through delegates every frame, so without them the panel would keep
    // showing whatever the setting was when it opened.
    protected override int BuildHash()
        => System.HashCode.Combine( Visible, Context, Rows.Count,
            string.Join( "|", Rows.Select( r => r.Value() ) ) );
}