Editor/UI/ArchSidebarChrome.cs
using System;
using Editor;
using Sandbox;
namespace Sunless.Architecture;
// The stable landmarks every creation sidebar opens with: what tool this is, what gesture it wants, and
// the sections that come and go beneath them.
public static class ArchSidebarLayout {
public static void Header( ToolSidebarWidget panel, string title, string icon, string shortcut, string advice, Action guide ) {
panel.Layout.Add( new ArchSidebarHeader( title, icon, shortcut, guide ) );
if ( string.IsNullOrWhiteSpace( advice ) ) {
return;
}
panel.Layout.Add( Advice( advice ) );
}
// WHAT IS PICKED, and the one press that lets go of it. A placement tool edits the layer it just placed, so
// without this line an author tuning the seed for the next drag is silently re-cutting the last one - and a pick
// this tool does not author holds the viewport just as hard, because its widgets take the drag before any
// placement can. Either way the only way out was to click empty space and hope.
public static void Editing( ToolSidebarWidget panel, string label, string action, Action stop ) {
if ( string.IsNullOrWhiteSpace( label ) ) {
return;
}
panel.Layout.Add( new ArchEditingBar( label, action, stop ) );
}
// The shared *Ui forms lay themselves out through AddGroup, which needs a sidebar rather than a layout, so a
// disclosure holds one by nesting it. Here once, or every caller folding a form away repeats the dance.
public static void Form( Layout group, Action<ToolSidebarWidget> build ) {
var form = new ToolSidebarWidget();
build( form );
group.Add( form );
}
// Qt puts a wrappable label's minimum at one line however much text it holds, so the line count is estimated.
public static Widget Advice( string text ) {
var label = new Label( text );
label.WordWrap = true;
label.FixedWidth = ArchPartUi.PanelWidth;
label.MinimumHeight = MathF.Ceiling( text.Length / 36f ) * 14f + 4f;
label.SetStyles( "font-size: 10px; color: rgba(255,255,255,0.48);" );
return label;
}
}
// The line that says the form is aimed at a layer rather than at the next placement, and the one press back to
// seeding. Nothing is deleted or moved by it - only the form stops writing to what is standing.
public sealed class ArchEditingBar : Widget {
public ArchEditingBar( string label, string action, Action stop ) {
FixedHeight = 24f;
Layout = Layout.Row();
Layout.Spacing = 4;
var caption = new Label( label ) {
ToolTip = "A picked layer holds its widgets over the viewport, and every control below writes to it."
};
caption.SetStyles( "font-size: 10px; color: rgba(255,140,30,0.95);" );
Layout.Add( caption, 1 );
Layout.Add( new Button( action, "close" ) {
FixedHeight = 20f,
ToolTip = "Let go of this layer and draw a new one",
Clicked = () => stop?.Invoke()
} );
}
}
// The HOW TO chip stands only where a guide is written for this tool, so its presence is itself the promise
// that there is more to read than the one line under it.
public sealed class ArchSidebarHeader : Widget {
const float ChipWidth = 56f;
readonly string title;
readonly string icon;
readonly string shortcut;
readonly Action guide;
bool hovered;
public ArchSidebarHeader( string title, string icon, string shortcut, Action guide ) {
this.title = title;
this.icon = icon;
this.shortcut = shortcut;
this.guide = guide;
FixedHeight = 36f;
MouseTracking = guide is not null;
}
Rect Chip => new( Width - ChipWidth, 9f, ChipWidth, 18f );
protected override void OnMouseMove( MouseEvent e ) {
var was = hovered;
hovered = guide is not null && Chip.IsInside( e.LocalPosition );
if ( was != hovered ) {
Cursor = hovered ? CursorShape.Finger : CursorShape.Arrow;
Update();
}
}
protected override void OnMouseLeave() {
if ( !hovered ) {
return;
}
hovered = false;
Cursor = CursorShape.Arrow;
Update();
}
protected override void OnMouseClick( MouseEvent e ) {
if ( guide is null || !Chip.IsInside( e.LocalPosition ) ) {
return;
}
guide();
e.Accepted = true;
}
protected override void OnPaint() {
Paint.Antialiasing = true;
Paint.TextAntialiasing = true;
var body = LocalRect;
var reserved = guide is null ? 0f : ChipWidth + 6f;
Paint.SetPen( Theme.Blue );
Paint.DrawIcon( new Rect( body.Left, body.Top + 9f, 18f, 18f ), icon, 18f );
Paint.SetDefaultFont( 10f, 600 );
Paint.SetPen( Theme.Text );
Paint.DrawText( body.Shrink( 25f, 0f, reserved, 0f ), title, TextFlag.LeftCenter );
if ( !string.IsNullOrWhiteSpace( shortcut ) ) {
Paint.SetDefaultFont( 7f, 500 );
Paint.SetPen( Theme.TextControl.WithAlpha( 0.4f ) );
Paint.DrawText( body.Shrink( 0f, 0f, reserved, 0f ), shortcut, TextFlag.RightCenter );
}
if ( guide is not null ) {
PaintChip();
}
Paint.ClearBrush();
Paint.SetPen( Theme.Text.WithAlpha( 0.09f ) );
Paint.DrawLine( new Vector2( body.Left, body.Bottom - 1f ), new Vector2( body.Right, body.Bottom - 1f ) );
}
void PaintChip() {
var chip = Chip;
Paint.SetBrushAndPen( Theme.Blue.WithAlpha( hovered ? 0.28f : 0.12f ), Theme.Blue.WithAlpha( hovered ? 0.8f : 0.35f ) );
Paint.DrawRect( chip, 9 );
Paint.ClearBrush();
Paint.SetPen( Theme.Blue.WithAlpha( hovered ? 1f : 0.75f ) );
Paint.DrawIcon( new Rect( chip.Left + 5f, chip.Top + 3f, 12f, 12f ), "help_outline", 12f );
Paint.SetDefaultFont( 6.5f, 700 );
Paint.DrawText( new Rect( chip.Left + 18f, chip.Top, chip.Width - 21f, chip.Height ), "HOW TO", TextFlag.LeftCenter );
}
}
// A disclosure that keeps its own open state per subtool, and that is simply absent when the choice above
// it cannot use it - a greyed control still asks the reader to work out why it cannot be used.
public sealed class ArchSidebarSection : Widget {
public static void Show( ToolSidebarWidget panel, string key, string title, Action<Layout> build ) {
Show( panel, key, title, true, true, false, build );
}
public static void Show( ToolSidebarWidget panel, string key, string title, bool visible, Action<Layout> build ) {
Show( panel, key, title, visible, true, false, build );
}
public static void Show( ToolSidebarWidget panel, string key, string title, bool visible, bool expanded, bool collapsible, Action<Layout> build ) {
if ( !visible ) {
return;
}
var section = new ArchSidebarSection( key, title, expanded, collapsible );
build( section.content.Layout );
panel.Layout.Add( section );
}
public static void Disclosure( ToolSidebarWidget panel, string key, string title, bool visible, Action<Layout> build ) {
Show( panel, key, title, visible, false, true, build );
}
readonly string key;
readonly string title;
readonly bool collapsible;
readonly Widget content;
bool open;
bool hovered;
ArchSidebarSection( string key, string title, bool expanded, bool collapsible ) {
this.key = key;
this.title = title;
this.collapsible = collapsible;
open = !collapsible || ArchSidebarState.Expanded( key, expanded );
Layout = Layout.Column();
Layout.Spacing = 2;
content = new Widget( this ) { Layout = Layout.Column() };
content.Layout.Spacing = 3;
content.Visible = open;
Layout.Add( content );
Margins();
HorizontalSizeMode = SizeMode.Expand | SizeMode.CanGrow;
VerticalSizeMode = SizeMode.CanShrink;
MouseTracking = true;
}
// A zero hint takes the height from the content's minimum, which is what keeps a collapsed section thin.
protected override Vector2 SizeHint() => 0;
void Margins() {
Layout.Margin = new Sandbox.UI.Margin( 6, 15, 6, open ? 6 : 0 );
}
void Toggle() {
open = !open;
content.Visible = open;
Margins();
ArchSidebarState.Expand( key, open );
Update();
}
protected override void OnMouseMove( MouseEvent e ) {
var was = hovered;
hovered = collapsible && e.LocalPosition.y < 14f;
if ( was != hovered ) {
Update();
}
}
protected override void OnMouseLeave() {
if ( !hovered ) {
return;
}
hovered = false;
Update();
}
protected override void OnMouseClick( MouseEvent e ) {
if ( !collapsible || e.LocalPosition.y > 14f ) {
return;
}
Toggle();
e.Accepted = true;
}
protected override void OnPaint() {
Paint.Antialiasing = true;
Paint.TextAntialiasing = true;
if ( open ) {
var frame = Paint.LocalRect;
frame.Top += 6f;
frame = frame.Shrink( 0, 0, 1, 1 );
Paint.SetBrushAndPen( Theme.Text.WithAlpha( 0.012f ), Theme.Text.WithAlpha( 0.09f ) );
Paint.DrawRect( frame, 4 );
}
Paint.SetPen( Theme.TextControl.WithAlpha( hovered ? 1f : 0.55f ) );
Paint.SetDefaultFont( 7f, 600 );
if ( collapsible ) {
Paint.DrawIcon( new Rect( 1, -2, 12, 14 ), open ? "expand_more" : "chevron_right", 10 );
Paint.DrawText( new Vector2( 15, 0 ), title.ToUpperInvariant() );
Cursor = CursorShape.Finger;
} else {
Paint.DrawText( new Vector2( 10, 0 ), title.ToUpperInvariant() );
}
}
}
public readonly record struct ArchRunState( bool Active, string Progress );
// Finish and Cancel exist only while a chained operation is live. It rides the tool FOOTER, outside the
// sidebar's scroll, so a long form cannot push the way out of a run off the bottom.
public sealed class ArchRunBar : Widget {
readonly Func<ArchRunState> read;
readonly Label progress;
public ArchRunBar( Func<ArchRunState> read, Action finish, Action cancel ) {
this.read = read;
Layout = Layout.Row();
Layout.Margin = new Sandbox.UI.Margin( 8, 6, 8, 6 );
Layout.Spacing = 6;
progress = new Label( "" );
progress.SetStyles( "font-size: 10px; color: rgba(255,255,255,0.7);" );
Layout.Add( progress );
Layout.AddStretchCell();
Layout.Add( new Button( "Cancel", "close" ) { Clicked = () => cancel?.Invoke() } );
Layout.Add( new Button.Primary( "Finish", "check" ) { Clicked = () => finish?.Invoke() } );
Visible = false;
}
[EditorEvent.Frame]
public void Poll() {
var state = read();
if ( Visible != state.Active ) {
Visible = state.Active;
}
if ( !state.Active ) {
return;
}
var text = state.Progress ?? "";
if ( progress.Text != text ) {
progress.Text = text;
}
}
protected override void OnPaint() {
Paint.ClearPen();
Paint.SetBrush( Theme.TabBackground );
Paint.DrawRect( LocalRect );
Paint.SetPen( Theme.Blue.WithAlpha( 0.5f ) );
Paint.DrawLine( new Vector2( 0, 0 ), new Vector2( Width, 0 ) );
}
}