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.
@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() ) ) );
}