Editor/UI/ArchWorkflow.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Sandbox;
namespace Sunless.Architecture;
// A widget that prints its own heading when it stands loose in a panel, and drops it when its host already
// draws one - a workflow step being the host that does.
public interface IArchHeaded {
void DropHeading();
}
// A tool's choices as a sequence. The step being asked is open, and so is the one that led to it - a step is not
// closed until the NEXT one is answered, so the choice you just made stays readable beside the choice you are
// making. Every step is a row either way, so nothing is ever more than one click away.
//
// Steps are keyed by NAME, never by ordinal: a step comes and goes with the answer above it, so numbering the
// memory would swap the reach step and the dressing step the moment Subtract was chosen.
public sealed class ArchWorkflow : IDisposable {
// Where the sequence stands, and the step trailing it. Held off the cookies on purpose: the sequence starts
// again every time the tool is picked, so none of it should outlive the session.
static readonly Dictionary<string, string> standing = new();
static readonly Dictionary<string, string> trailing = new();
static readonly Dictionary<string, string> advancing = new();
readonly ToolSidebarWidget panel;
readonly string scope;
readonly Action refresh;
readonly List<Waiting> waiting = new();
ArchWorkflow( ToolSidebarWidget panel, string scope, Action refresh ) {
this.panel = panel;
this.scope = scope;
this.refresh = refresh;
}
public static ArchWorkflow In( ToolSidebarWidget panel, string scope, Action refresh ) {
return new ArchWorkflow( panel, scope, refresh );
}
// Picking the tool starts the sequence over, so the first step is open and asking.
public static void Restart( string scope ) {
standing.Remove( scope );
trailing.Remove( scope );
advancing.Remove( scope );
}
public void Step( string title, string answer, string icon, Action<ArchWorkflowStep> build ) {
waiting.Add( new Waiting { Title = title, Answer = answer, Icon = icon, Build = build } );
}
// The rows are laid HERE rather than as they are declared, because which one is open depends on the whole
// sequence: the step after the one just answered is only knowable once every step has named itself, and a
// cursor left on a step this build never laid down has to fall back to the first rather than open nothing.
public void Dispose() {
if ( waiting.Count == 0 ) {
return;
}
var at = Cursor();
var from = trailing.TryGetValue( scope, out var led ) ? led : null;
for ( var index = 0; index < waiting.Count; index++ ) {
var step = waiting[index];
var open = step.Title == at || step.Title == from;
var row = new ArchWorkflowRow( index + 1, step.Title, step.Answer, step.Icon, open, () => Aim( step.Title ) );
if ( open ) {
step.Build( new ArchWorkflowStep( row.Content, () => Advance( step.Title ) ) );
}
panel.Layout.Add( row );
}
}
string Cursor() {
var at = standing.TryGetValue( scope, out var held ) ? held : null;
if ( advancing.TryGetValue( scope, out var after ) ) {
advancing.Remove( scope );
var answered = waiting.FindIndex( step => step.Title == after );
if ( answered >= 0 && answered + 1 < waiting.Count ) {
at = waiting[answered + 1].Title;
}
}
if ( at is null || waiting.All( step => step.Title != at ) ) {
at = waiting[0].Title;
trailing.Remove( scope );
}
standing[scope] = at;
return at;
}
void Advance( string title ) {
advancing[scope] = title;
trailing[scope] = title;
refresh?.Invoke();
}
// A row clicked deliberately is the author jumping, so nothing trails it - and the row already open is where
// they already are, which is not a click that should leave the panel with nothing on it.
void Aim( string title ) {
standing[scope] = title;
trailing.Remove( scope );
advancing.Remove( scope );
refresh?.Invoke();
}
sealed class Waiting {
public string Title;
public string Answer;
public string Icon;
public Action<ArchWorkflowStep> Build;
}
}
// Handed to a step so its controls can say they have ANSWERED, which is what moves the author on. A form that
// is typed into rather than picked from never answers - a number is not a decision the panel can see the end of.
public sealed class ArchWorkflowStep {
readonly Action chose;
internal ArchWorkflowStep( Layout layout, Action chose ) {
Layout = layout;
this.chose = chose;
}
public Layout Layout { get; }
// A shelf carries its own caption for the panels it stands loose in. Inside a step the numbered row above
// already prints that word, so anything added here drops its heading rather than saying it twice.
public void Add( Widget widget ) {
if ( widget is IArchHeaded headed ) {
headed.DropHeading();
}
Layout.Add( widget );
}
public void Chose() => chose();
}
sealed class ArchWorkflowRow : Widget {
const float HeaderHeight = 26f;
const int AnswerLength = 24;
readonly int number;
readonly string title;
readonly string answer;
readonly string icon;
readonly bool open;
readonly Action toggle;
readonly Widget content;
bool hovered;
public ArchWorkflowRow( int number, string title, string answer, string icon, bool open, Action toggle ) {
this.number = number;
this.title = title;
this.answer = Short( answer );
this.icon = icon;
this.open = open;
this.toggle = toggle;
Layout = Layout.Column();
Layout.Spacing = 2;
// Tight sides on purpose: a step routinely holds a three-wide icon grid, and every inch of inset here is an
// inch that grid loses to a fourth column it cannot have.
Layout.Margin = new Sandbox.UI.Margin( 4, HeaderHeight, 4, open ? 6 : 1 );
content = new Widget( this ) { Layout = Layout.Column() };
content.Layout.Spacing = 3;
content.Visible = open;
Layout.Add( content );
HorizontalSizeMode = SizeMode.Expand | SizeMode.CanGrow;
VerticalSizeMode = SizeMode.CanShrink;
MouseTracking = true;
}
public Layout Content => content.Layout;
// A zero hint takes the height from the content's own minimum, which is what keeps a collapsed row one line.
protected override Vector2 SizeHint() => 0;
static string Short( string text ) {
if ( string.IsNullOrWhiteSpace( text ) ) {
return "";
}
return text.Length <= AnswerLength ? text : $"{text[..(AnswerLength - 1)]}…";
}
protected override void OnMouseMove( MouseEvent e ) {
var was = hovered;
hovered = !open && e.LocalPosition.y < HeaderHeight;
if ( was != hovered ) {
Update();
}
}
protected override void OnMouseLeave() {
if ( !hovered ) {
return;
}
hovered = false;
Update();
}
protected override void OnMouseClick( MouseEvent e ) {
if ( open || e.LocalPosition.y > HeaderHeight ) {
return;
}
toggle();
e.Accepted = true;
}
protected override void OnPaint() {
Paint.Antialiasing = true;
Paint.TextAntialiasing = true;
Cursor = open ? CursorShape.Arrow : CursorShape.Finger;
var body = Paint.LocalRect;
var header = new Rect( 0f, 2f, body.Width, HeaderHeight - 4f );
if ( open ) {
var frame = body.Shrink( 0f, HeaderHeight - 2f, 1f, 1f );
Paint.SetBrushAndPen( Theme.Text.WithAlpha( 0.012f ), Theme.Text.WithAlpha( 0.09f ) );
Paint.DrawRect( frame, 4 );
}
var badge = new Rect( header.Left + 2f, header.Top + 3f, 15f, 15f );
Paint.SetBrushAndPen( Theme.Blue.WithAlpha( open ? 0.85f : hovered ? 0.35f : 0.16f ) );
Paint.DrawRect( badge, 3 );
Paint.SetPen( open ? Theme.TabBackground : Theme.Text.WithAlpha( 0.7f ) );
Paint.SetDefaultFont( 6f, 700 );
Paint.DrawText( badge, number.ToString(), TextFlag.Center );
Paint.SetPen( Theme.TextControl.WithAlpha( open || hovered ? 1f : 0.6f ) );
Paint.SetDefaultFont( 7f, 600 );
Paint.DrawText( new Rect( header.Left + 23f, header.Top, header.Width - 23f, header.Height ), title.ToUpperInvariant(), TextFlag.LeftCenter );
if ( open || string.IsNullOrEmpty( answer ) ) {
return;
}
Paint.SetPen( Theme.Blue.WithAlpha( hovered ? 1f : 0.7f ) );
Paint.DrawIcon( new Rect( header.Right - 17f, header.Top + 4f, 14f, 14f ), icon, 14f );
Paint.SetPen( Theme.Text.WithAlpha( hovered ? 0.9f : 0.6f ) );
Paint.SetDefaultFont( 7f, 400 );
Paint.DrawText( new Rect( header.Left, header.Top, header.Width - 22f, header.Height ), answer, TextFlag.RightCenter );
}
}