Editor/EffigyEditor/EffigySettingsWindow.cs
using Editor;
using Sandbox;
using System;
namespace Marionette.EditorTools;
/// <summary>
/// An on/off switch with a knob that slides between the two ends.
///
/// HAND-PAINTED BECAUSE S&BOX HAS NO SWITCH. It ships <c>Checkbox</c>, which is a tick in a square
/// — a different control that reads as "tick this to agree" rather than "this is on". Same reason
/// EffigyIcons draws its own glyphs: the widget wanted here does not exist in the library, so it
/// gets drawn, using the same Paint calls every other custom widget in this file already uses.
///
/// The slide advances a fixed step PER PAINT rather than per second, and that is deliberate. An
/// eased, time-based animation needs a trustworthy frame clock, and a wrong guess about one in the
/// editor gives a knob stuck half way — a visual bug that no compiler catches. Stepping per paint
/// cannot stall: it is bounded at roughly six frames, it always arrives, and OnPaint stops asking
/// for another frame the moment it lands.
/// </summary>
internal sealed class EffigyToggleSwitch : Widget
{
private const float TrackWidth = 42f;
private const float TrackHeight = 22f;
/// <summary>Knob inset from the track edge, so the track reads as a groove around it.</summary>
private const float KnobInset = 3f;
/// <summary>How much of the travel one repaint covers. 0.18 is about six frames end to end —
/// fast enough to feel like a switch, slow enough to read as movement.</summary>
private const float SlideStep = 0.18f;
public bool Value { get; private set; }
/// <summary>Fires only on a real change, and only when the user caused it — SetValue with
/// notify false is how the window seeds the control without echoing back.</summary>
public Action<bool> ValueChanged { get; set; }
private float _slide;
private bool _pressed;
public EffigyToggleSwitch( Widget parent, bool value ) : base( parent )
{
Value = value;
_slide = value ? 1f : 0f;
Cursor = CursorShape.Finger;
MouseTracking = true;
// Same reasoning as the tool bar's buttons: a plain Widget paints the system background, which
// is a pale square sitting behind a rounded control.
TranslucentBackground = true;
NoSystemBackground = true;
FixedSize = new Vector2( TrackWidth, TrackHeight );
}
public void SetValue( bool value, bool notify = true )
{
if ( Value == value )
return;
Value = value;
Update();
if ( notify )
ValueChanged?.Invoke( value );
}
protected override void OnPaint()
{
Paint.Antialiasing = true;
var target = Value ? 1f : 0f;
if ( MathF.Abs( _slide - target ) <= SlideStep )
{
_slide = target;
}
else
{
_slide += _slide < target ? SlideStep : -SlideStep;
// Still travelling, so ask for another frame. Once it lands this stops firing and the
// widget goes quiet.
Update();
}
var track = LocalRect;
// The track carries the state: dim when off, the theme's accent when on. Colour and
// position both say the same thing, which is what makes a switch readable at a glance.
Paint.ClearPen();
Paint.SetBrush( Color.Lerp( Theme.ControlBackground, Theme.Primary, _slide ) );
Paint.DrawRect( track, track.Height * 0.5f );
var diameter = track.Height - KnobInset * 2f;
var travel = track.Width - KnobInset * 2f - diameter;
var knob = new Rect( track.Left + KnobInset + travel * _slide, track.Top + KnobInset, diameter, diameter );
Paint.SetBrush( Theme.TextLight.WithAlpha( _pressed ? 0.75f : 1f ) );
Paint.DrawRect( knob, diameter * 0.5f );
}
protected override void OnMousePress( MouseEvent e )
{
if ( !e.LeftMouseButton )
return;
_pressed = true;
Update();
e.Accepted = true;
}
protected override void OnMouseReleased( MouseEvent e )
{
if ( !_pressed )
return;
_pressed = false;
// Only flips if released over the control — dragging off to cancel, the same as every
// other button in this editor.
if ( IsUnderMouse )
SetValue( !Value );
else
Update();
}
protected override void OnMouseLeave()
{
base.OnMouseLeave();
_pressed = false;
Update();
}
}
/// <summary>
/// Effigy's settings, in their own window off the Edit menu.
///
/// A WINDOW RATHER THAN MORE MENU. Both of these lived in the View menu as checkable options, which
/// is where a setting goes when there are two of them and nowhere else to put them. There are two
/// now and there will be more — this is where they land instead of the menu growing a tail.
///
/// It knows nothing about EffigyWindow. Values come in as plain arguments and changes go out as
/// callbacks, so the window that owns the viewport stays the one place that decides what a setting
/// actually does. Built the way FastTextureWindow builds: a Canvas, with the toolbar, menu and
/// status bars turned off, since none of the three has anything to show here.
/// </summary>
internal sealed class EffigySettingsWindow : Window
{
/// <summary>
/// Everything the window shows, in and out in one lump.
///
/// A struct rather than eight constructor arguments and eight callbacks. Every one of these is
/// read once when the window opens and written back the moment a control moves, so passing them
/// separately meant a parameter list that grew every time a setting was added — which was
/// already twice.
/// </summary>
internal struct Values
{
public bool ShowGrid;
public float GridSpacing;
public bool SnapToGrid;
public bool SnapToPoints;
public bool SnapToFaceEdges;
public int PaletteIndex;
public bool ShowSizeReference;
/// <summary>OUT ONLY — the height of the loaded stand-in, in world units, which only the
/// viewport can know because only it has the model. Whatever is set on the way in is
/// ignored; the applied values coming back carry the real number, and the caption under
/// the switch prints it.</summary>
public float SizeReferenceHeight;
}
/// <summary>The spacings the dropdown offers, in sketch units. Zero is Automatic — the adaptive
/// 1/2/5 step that keeps the grid about a constant size on screen at any zoom.</summary>
private static readonly float[] Spacings = { 0f, 0.1f, 0.25f, 0.5f, 1f, 2f, 5f, 10f, 25f };
private Values _values;
/// <summary>
/// Apply, and hand back what was actually applied.
///
/// A one-way callback was enough while every setting was a value this window already had.
/// The size reference is not: its height is a property of a model this window never loads, so
/// the only way to print it is to ask for it after the switch has been flipped and the viewport
/// has done the loading.
/// </summary>
private readonly Func<Values, Values> _changed;
/// <summary>The line under the size-reference switch, kept so a flip can rewrite it.</summary>
private Editor.Label _referenceNote;
/// <summary>The size-reference switch itself, kept for the one case where the answer comes back
/// different from the question: the citizen would not load, so the viewport is off and the
/// switch has to go back to off with it rather than sit on over an empty floor.</summary>
private EffigyToggleSwitch _referenceToggle;
public EffigySettingsWindow( Widget owner, Values values, Func<Values, Values> changed )
{
_values = values;
_changed = changed;
// PARENTED TO EFFIGY, NOT TO THE MAIN EDITOR WINDOW.
//
// ProjectSettingsWindow uses `Parent = EditorWindow` and that is right for it — it is the
// main editor's own dialog. Copying it here was not: Effigy is a separate top-level window,
// so owning this dialog to a DIFFERENT top-level window handed focus to the editor's window
// group and dropped Effigy behind it. Opening settings appeared to minimise the tool.
//
// Owned by the window whose settings these are, the dialog floats over Effigy and Effigy
// stays exactly where it was.
Parent = owner;
WindowFlags = WindowFlags.Dialog | WindowFlags.Customized | WindowFlags.CloseButton
| WindowFlags.WindowSystemMenuHint | WindowFlags.WindowTitle;
WindowTitle = "Effigy Settings";
Size = new Vector2( 400, 420 );
SetWindowIcon( "settings" );
Build();
}
/// <summary>
/// Built here rather than in an override of BuildDock.
///
/// BuildDock is not visible to override from this assembly — FastTextureWindow and
/// ProjectSettingsWindow both live inside the editor's own assembly, where it is. From out
/// here the constructor is the hook, which is fine: everything this window shows is known by
/// the time it is constructed.
/// </summary>
private void Build()
{
var canvas = new Widget( this );
canvas.Layout = Layout.Column();
canvas.Layout.Margin = 16;
canvas.Layout.Spacing = 12;
Heading( canvas, "Grid" );
AddSwitch( canvas, "Show plane grid",
"The lattice inside every plane's outline - the three reference planes and the one you "
+ "are sketching on.",
_values.ShowGrid,
value => { _values.ShowGrid = value; Changed(); } );
// --- spacing -------------------------------------------------------------------------
var spacingRow = canvas.Layout.AddRow();
spacingRow.Add( new Editor.Label( "Grid spacing" ) );
spacingRow.AddStretchCell();
var spacing = new ComboBox( canvas )
{
MinimumWidth = 150,
ToolTip = "How far apart the grid lines sit, in sketch units. This is the same step the "
+ "cursor snaps to - the lines are the intervals you land on.",
};
foreach ( var value in Spacings )
{
var step = value;
spacing.AddItem( Describe( step ),
onSelected: () => { _values.GridSpacing = step; Changed(); },
selected: MathF.Abs( step - _values.GridSpacing ) < 0.0001f );
}
spacingRow.Add( spacing );
// --- snapping ------------------------------------------------------------------------
Heading( canvas, "Snapping" );
AddSwitch( canvas, "Snap to grid",
"Round the cursor to the nearest grid intersection. Off draws freehand on the plane.",
_values.SnapToGrid,
value => { _values.SnapToGrid = value; Changed(); } );
AddSwitch( canvas, "Snap to points",
"Jump the cursor onto existing sketch points. This is what closes a chain - without it "
+ "two clicks in the same spot leave two points a hair apart and the profile will not "
+ "extrude.",
_values.SnapToPoints,
value => { _values.SnapToPoints = value; Changed(); } );
AddSwitch( canvas, "Snap to the face underneath",
"While sketching on the face of a part, jump the cursor onto that face's own corners "
+ "and slide it along its edges. Off leaves the outline drawn but inert - useful when "
+ "you want to draw across a face rather than measure from it.",
_values.SnapToFaceEdges,
value => { _values.SnapToFaceEdges = value; Changed(); } );
// --- the size reference ---------------------------------------------------------------
Heading( canvas, "Reference" );
_referenceToggle = AddSwitch( canvas, "Show citizen",
"Stand the base citizen at the origin, to build against. It is scenery only - it takes "
+ "no clicks, joins no feature and is never exported.",
_values.ShowSizeReference,
value => { _values.ShowSizeReference = value; Changed(); } );
_referenceNote = canvas.Layout.Add( new Editor.Label( ReferenceNote( _values ) ) );
// Dim and small, because it is a readout rather than a control - it sits under the switch
// the way a hint does, not in the column of things you can change.
_referenceNote.SetStyles( "color: #808080; font-size: 11px;" );
// --- the palette ---------------------------------------------------------------------
Heading( canvas, "Appearance" );
var paletteRow = canvas.Layout.AddRow();
paletteRow.Add( new Editor.Label( "Colour palette" ) );
paletteRow.AddStretchCell();
var combo = new ComboBox( canvas ) { MinimumWidth = 150 };
for ( var i = 0; i < EffigyPalette.All.Length; i++ )
{
var index = i;
combo.AddItem( EffigyPalette.All[index].Name,
onSelected: () => { _values.PaletteIndex = index; Changed(); },
selected: index == _values.PaletteIndex );
}
paletteRow.Add( combo );
canvas.Layout.AddStretchCell();
Canvas = canvas;
}
/// <summary>Apply, then take the applied values back - the viewport fills in what it alone
/// knows, and the caption is rewritten from that rather than from a guess made here.</summary>
private void Changed()
{
if ( _changed is null )
return;
_values = _changed( _values );
if ( _referenceNote.IsValid() )
_referenceNote.Text = ReferenceNote( _values );
// notify false: this is the applied value coming home, not a new request. Notifying would
// hand it straight back to Changed and round the loop again.
_referenceToggle?.SetValue( _values.ShowSizeReference, notify: false );
}
/// <summary>
/// What the stand-in is worth as a ruler: its height, in the units every other number in Effigy
/// is in.
///
/// A height of zero with the switch on means the model did not load - the citizen addon is not
/// mounted. Saying so here is the only place that failure is visible, since the viewport's own
/// answer to a missing model is an empty patch of floor.
/// </summary>
private static string ReferenceNote( Values values )
{
if ( !values.ShowSizeReference )
return "The citizen from the base addon, standing at the origin.";
return values.SizeReferenceHeight > 0f
? $"The citizen stands {values.SizeReferenceHeight:0.#} units tall."
: "The citizen could not be loaded - is the base citizen addon mounted?";
}
private static void Heading( Widget canvas, string text )
{
var label = canvas.Layout.Add( new Editor.Label( text ) );
label.SetStyles( "font-weight: 600;" );
}
/// <summary>A labelled row with the switch pushed out to the right edge, which is the shape
/// every one of these settings wants.</summary>
private static EffigyToggleSwitch AddSwitch( Widget canvas, string label, string tip, bool value, Action<bool> changed )
{
var row = canvas.Layout.AddRow();
row.Add( new Editor.Label( label ) { ToolTip = tip } );
row.AddStretchCell();
var toggle = new EffigyToggleSwitch( canvas, value ) { ToolTip = tip };
toggle.ValueChanged = changed;
row.Add( toggle );
return toggle;
}
/// <summary>Zero is the adaptive step rather than "no grid", so it has to say so — a dropdown
/// reading "0" next to a visible lattice is a puzzle.</summary>
private static string Describe( float step ) => step <= 0f ? "Automatic" : $"{step:0.###} u";
}