Editor/EffigyEditor/EffigyStageBar.cs
using Editor;
using Sandbox;
using System;
using System.Collections.Generic;
namespace Marionette.EditorTools;
// ============================================================================
// The stage bar — the tool chrome that replaced three floating icon strips.
//
// WHAT WAS WRONG WITH THE STRIPS. There were fifty buttons across the feature,
// sketch and sculpt strips, every one a 54px monochrome square, every one in
// the SAME top-left spot on the canvas, and the three swapped through that
// spot without ever saying which was showing. One button in fifty carried a
// text label, so finding a tool meant parking the cursor on each square in
// turn and waiting for a tooltip. The strip also could not decline to paint
// (see the old EffigyToolStrip.OnPaint), so it was not floating chrome at all
// — it was an opaque band covering the top-left of the part.
//
// WHAT THIS DOES INSTEAD. Two docked rows above the viewport:
//
// Sketch 2 | Solid 4 | Detail 5 | Repeat 4 | Finish 4 SKETCH 1 ✔ Finish
// ▣ Primitive ⬆ Extrude ⟳ Revolve ↝ Sweep ⌂ Loft
//
// A stage is a named handful of tools. Only one stage's tools are on screen,
// so the widest row is six buttons rather than nineteen, which buys enough
// room to put the NAME on every button. Mode stops being a thing you infer
// from which glyphs appeared: entering a sketch swaps the whole stage set and
// writes the mode on the right of the tab row, next to the one control that
// leaves it.
//
// BOTH ROWS ARE ONE PAINTED WIDGET EACH, not a layout full of button widgets.
// That is what lets a button size itself to its own label: Paint.MeasureText
// only works inside a paint pass, so each row measures its items while drawing
// and caches the rects it drew them in, and the mouse handlers hit-test that
// cache. Same pattern EffigyResultStrip uses for its segments. The old strips
// had to guess (EffigyToolStrip carried a hand-counted _contentWidth and the
// one labelled button was hardcoded to 132px) and a guess that came out short
// simply cut the last buttons off.
// ============================================================================
/// <summary>Which set of stages the bar is showing. The modes are exclusive by construction now —
/// one bar holding one list — where three strips with independent Visible flags could and did show
/// two at once.</summary>
internal enum EffigyBarMode
{
Part,
Sketch,
Sculpt,
}
/// <summary>
/// The chrome constants and edge-decoration painting shared by everything that draws a tool
/// button, kept in one place so the bar, the result strip and the sculpt bar agree.
///
/// This is what is left of EffigyToolStrip. That class was a widget carrying a pile of statics
/// that had nothing to do with being a widget; the widget half is gone and the statics are all
/// that anything else was ever reaching for.
/// </summary>
internal static class EffigyToolChrome
{
/// <summary>Height of the tab row — the strip of stage names.</summary>
public const float TabRowHeight = 30f;
/// <summary>Height of the tool row under it. 42 leaves a 30px button with 6px of air above and
/// below, which is enough for the hover glow to read as hugging the button rather than as a
/// line between rows.</summary>
public const float ToolRowHeight = 42f;
/// <summary>The bar as a whole. Named because the viewport prices its overlays against it.</summary>
public const float BarHeight = TabRowHeight + ToolRowHeight;
/// <summary>Height of a tool button inside the tool row.</summary>
public const float ButtonHeight = 30f;
/// <summary>
/// Scale for the hand-painted glyphs, which are authored in a nominal 18x18 box.
///
/// 1.05 puts a 19px glyph next to an 11.5pt label. The old strip ran at 1.8 for a 32px glyph,
/// and the reason that number was right is gone with the strip it was measured for: at 54px
/// square with no label, the glyph WAS the button and had to carry it alone. Here the label
/// carries the naming and the glyph is the thing your eye returns to once you know the name,
/// so it wants to sit at text weight rather than shout over it.
/// </summary>
public const float IconScale = 1.05f;
/// <summary>Point size of a tool button's label.</summary>
public const float LabelFontSize = 11.5f;
/// <summary>Point size of a stage tab's name.</summary>
public const float TabFontSize = 11.5f;
/// <summary>
/// The colour of every CONFIRM action in this editor - accept a feature, finish a sketch,
/// validate a binding.
///
/// A tick drawn in the same grey as everything else is a shape you have to go looking for, and
/// the two on screen at once - accept the feature, finish the sketch - are the two most
/// consequential buttons in the tool. Green for commit is one of the few colour conventions
/// everyone already reads without being taught. Anything new that commits something should use
/// this rather than picking its own green.
/// </summary>
public static Color ConfirmColor => Theme.Green;
/// <summary>
/// The colour the sketcher paints the outline of the face being sketched on, so a button that
/// acts on that outline wears it too.
///
/// NOT ConfirmColor, even though both are green. That one means "this commits what you have
/// been doing" and is spent carefully - two on screen at once is already the most it should be.
/// This one means "this is about the part underneath", and it matches SketchReferenceColor in
/// the viewport rather than the theme.
/// </summary>
public static Color ReferenceColor => new( 0.45f, 1f, 0.6f, 1f );
/// <summary>
/// Hover and press: a faint halo hugging the item's outer edge, and nothing else.
///
/// Drawn as concentric rounded rects fading INWARD from the edge. On the old floating strip
/// that was forced — a widget clips its own painting, so a halo outside its rect was simply
/// cut off — and it is kept here because it is also the right answer: the rows sit against
/// chrome now, and a filled hover box behind every button would turn a row of five names into
/// a row of five boxes.
/// </summary>
public static void PaintEdgeGlow( Rect rect, float strength )
{
const int Rings = 4;
Paint.ClearBrush();
for ( var i = 0; i < Rings; i++ )
{
var falloff = 1f - i / (float)Rings;
Paint.SetPen( Theme.Text.WithAlpha( strength * falloff * falloff * 0.5f ), 1f );
Paint.DrawRect( rect.Shrink( 0.5f + i ), 4f );
}
}
/// <summary>A crisp ring at the edge, for a mode that is armed and has to stay visibly armed
/// with the cursor somewhere else. A ring rather than a tint - same no-colour-change rule as
/// PaintEdgeGlow, so armed reads as a different SHAPE, not a different colour.</summary>
public static void PaintEdgeRing( Rect rect )
{
Paint.ClearBrush();
Paint.SetPen( Theme.Text.WithAlpha( 0.75f ), 1.5f );
Paint.DrawRect( rect.Shrink( 1f ), 4f );
}
/// <summary>
/// "The tutorial means this one." A filled wash plus a solid ring, in the same yellow the
/// tutorial panel uses for its current step and its bullets.
///
/// LOUDER THAN THE ARMED RING ON PURPOSE. Armed is a state you put the tool in and already
/// know about; this is aimed at someone who has been told to press a button they have never
/// seen. It matters less than it did — the buttons carry their names now, so the reader has
/// something to search for besides a shape — but a tutorial that says "press Extrude" should
/// still be able to point at Extrude.
/// </summary>
public static void PaintAttentionRing( Rect rect )
{
Paint.ClearPen();
Paint.SetBrush( Theme.Yellow.WithAlpha( 0.22f ) );
Paint.DrawRect( rect, 4f );
Paint.ClearBrush();
Paint.SetPen( Theme.Yellow.WithAlpha( 0.9f ), 2f );
Paint.DrawRect( rect.Shrink( 1f ), 4f );
}
}
/// <summary>One entry behind a tool's chevron: the same idea, done a different way. A corner
/// rectangle and a centre rectangle are one button with two variants, not two buttons — the same
/// arrangement Onshape uses, and the reason the sketch row is nineteen buttons for
/// twenty-four tools.</summary>
internal sealed class EffigyStageVariant
{
public EffigyIcon Icon;
public string Label;
public string Tip;
/// <summary>What arming this variant does. Carried per variant rather than the button holding
/// an index into somebody's table, so the bar never needs to know what a tool IS.</summary>
public Action Chosen;
}
/// <summary>
/// One tool on the bar.
///
/// A DATA OBJECT THE CALLER OWNS AND KEEPS, not a widget. The rows are repainted wholesale on
/// every stage change, so a button held from before a switch would belong to nothing; state that
/// has to survive — which variant is on the face, whether the tool is armed — lives here instead.
/// That is also what lets the window arm a tool that is sitting on a stage nobody is looking at.
/// </summary>
internal sealed class EffigyStageTool
{
public EffigyIcon Icon;
public string Label;
public string Tip;
/// <summary>A mode you turn on and leave on, rather than a command that happens once. Armed
/// tools wear the edge ring.</summary>
public bool Checkable;
/// <summary>Overrides the glyph colour for a button that means something in particular — the
/// finish tick is green like every other confirm, Use is the reference green. Null leaves it
/// as ordinary chrome.</summary>
public Color? IconColor;
/// <summary>What a plain click does. Ignored when there are variants: those carry their own.</summary>
public Action Clicked;
public EffigyStageVariant[] Variants;
// --- live state, owned here so a stage switch cannot lose it ---------------------------
public bool Checked;
public bool Attention;
/// <summary>Which variant is on the face of the button. Onshape keeps the last one you picked
/// there, so the second use of a centre rectangle is a single click.</summary>
public int Current;
public bool HasVariants => Variants is { Length: > 1 };
private EffigyStageVariant Face =>
Variants is { Length: > 0 } ? Variants[Math.Clamp( Current, 0, Variants.Length - 1 )] : null;
public EffigyIcon FaceIcon => Face?.Icon ?? Icon;
public string FaceLabel => Face?.Label ?? Label;
public string FaceTip => Face?.Tip ?? Tip;
/// <summary>Run whatever this button does right now — the current variant, or the plain
/// action.</summary>
public void Run()
{
if ( Face is { } variant )
variant.Chosen?.Invoke();
else
Clicked?.Invoke();
}
}
/// <summary>A named handful of tools, and whether they can be used yet.</summary>
internal sealed class EffigyStage
{
public string Name;
/// <summary>
/// Why this stage cannot be entered, or null when it can.
///
/// A REASON RATHER THAN A BOOL, and shown rather than hidden. The strip this replaced dropped
/// seventeen of nineteen buttons on an empty studio so nobody could add an extrude with
/// nothing to extrude — correct, and it meant the toolbar silently changed shape halfway
/// through step one of the tutorial, and never said why it had been holding things back. A
/// dimmed tab that says "draw a sketch first" teaches the same rule and stays still.
/// </summary>
public string LockedReason;
public readonly List<EffigyStageTool> Tools = new();
public bool Locked => !string.IsNullOrEmpty( LockedReason );
public EffigyStage Add( EffigyStageTool tool )
{
Tools.Add( tool );
return this;
}
}
/// <summary>
/// The bar itself: a tab row over a tool row, docked above the viewport rather than floating on
/// it. Docked because it no longer has to hide — five or six named buttons is a piece of chrome
/// you read, where nineteen anonymous squares was a wall you wanted off your model.
/// </summary>
internal sealed class EffigyStageBar : Widget
{
private readonly EffigyStageTabRow _tabs;
private readonly EffigyStageToolRow _tools;
private readonly List<EffigyStage> _stages = new();
private int _selected;
/// <summary>Raised after the visible stage changes, so the owner can re-apply anything that
/// lives on the buttons — armed states, the tutorial's highlight.</summary>
public Action StageChanged { get; set; }
/// <summary>The mode this bar is in - "SKETCH 1", "SCULPT" - or null in the ordinary part
/// studio. Painted at the right of the tab row next to the control that leaves it.</summary>
public string Mode
{
get => _tabs.Mode;
set { _tabs.Mode = value; _tabs.Update(); }
}
/// <summary>The green "leave this mode" control, or null for no mode. Label and action.</summary>
public void SetFinish( string label, Action clicked )
{
_tabs.FinishLabel = label;
_tabs.FinishClicked = clicked;
_tabs.Update();
}
/// <summary>The bar's own background, kept in step with the palette by ApplyPalette. The bar
/// is chrome, so it takes the chrome colour rather than the viewport's.</summary>
public Color ChromeColor
{
get => _tabs.ChromeColor;
set
{
_tabs.ChromeColor = value;
_tools.ChromeColor = value;
Refresh();
}
}
public EffigyStageBar( Widget parent ) : base( parent )
{
Layout = Layout.Column();
Layout.Spacing = 0;
Layout.Margin = new Sandbox.UI.Margin( 0 );
_tabs = new EffigyStageTabRow( this ) { Bar = this };
_tools = new EffigyStageToolRow( this ) { Bar = this };
Layout.Add( _tabs );
Layout.Add( _tools );
FixedHeight = EffigyToolChrome.BarHeight;
}
public IReadOnlyList<EffigyStage> Stages => _stages;
public EffigyStage Current =>
_selected >= 0 && _selected < _stages.Count ? _stages[_selected] : null;
public int SelectedIndex => _selected;
/// <summary>
/// Replace the whole stage set — which is what entering a sketch or a sculpt does.
///
/// The selection is re-derived rather than kept: the incoming stages are a different set of
/// things, so an index into the old one means nothing. Landing on the first stage that is not
/// locked is what stops a fresh studio opening on a tab whose tools all refuse to run.
/// </summary>
public void SetStages( IEnumerable<EffigyStage> stages, int select = -1 )
{
_stages.Clear();
_stages.AddRange( stages );
_selected = select >= 0 && select < _stages.Count && !_stages[select].Locked
? select
: FirstUsable();
Refresh();
StageChanged?.Invoke();
}
private int FirstUsable()
{
for ( var i = 0; i < _stages.Count; i++ )
{
if ( !_stages[i].Locked )
return i;
}
return _stages.Count > 0 ? 0 : -1;
}
/// <summary>
/// Point the bar at a stage. A locked one is refused rather than shown empty-handed — there is
/// nothing on it that would run, and a tab that opens onto dead buttons teaches less than one
/// that says why it will not open.
/// </summary>
public void Select( int index )
{
if ( index < 0 || index >= _stages.Count || index == _selected )
return;
if ( _stages[index].Locked )
return;
_selected = index;
Refresh();
StageChanged?.Invoke();
}
/// <summary>
/// Bring the stage holding this tool to the front, and say whether it worked.
///
/// THE PIECE THAT MAKES STAGES SURVIVABLE. A tool armed from somewhere else — the L and C
/// shortcuts, Escape falling back to Select, the tutorial pointing at Extrude — may live on a
/// stage nobody is looking at, and a bar that showed one thing while the viewport did another
/// would be worse than the strip it replaced.
/// </summary>
public bool Reveal( EffigyStageTool tool )
{
if ( tool is null )
return false;
// THE STAGE IN FRONT OF THE READER WINS. A tool can appear on more than one stage - Select
// is on all four sketch stages, because falling back to it should never cost a tab change -
// and without this the first stage holding it would drag the bar away from wherever the
// reader actually is every time Escape dropped the tool back to Select.
if ( Current is { } current && current.Tools.Contains( tool ) )
return true;
for ( var i = 0; i < _stages.Count; i++ )
{
if ( !_stages[i].Tools.Contains( tool ) || _stages[i].Locked )
continue;
_selected = i;
Refresh();
StageChanged?.Invoke();
return true;
}
return false;
}
/// <summary>Which stage this tool sits on, or -1. Used to decide whether a repaint is enough
/// or the bar has to move.</summary>
public int StageOf( EffigyStageTool tool )
{
for ( var i = 0; i < _stages.Count; i++ )
{
if ( _stages[i].Tools.Contains( tool ) )
return i;
}
return -1;
}
/// <summary>Repaint both rows. Cheap — each is one widget — and the only way to push a change
/// made to a tool's data onto what is on screen.</summary>
public void Refresh()
{
_tabs.Update();
_tools.Update();
}
}
/// <summary>
/// The stage names, the mode, and the way out of it.
///
/// One painted widget for every tab, hit-tested against the rects it drew last frame. See the
/// header of this file for why the rows are painted rather than composed of button widgets.
/// </summary>
internal sealed class EffigyStageTabRow : Widget
{
public EffigyStageBar Bar { get; set; }
public string Mode { get; set; }
public string FinishLabel { get; set; }
public Action FinishClicked { get; set; }
public Color ChromeColor { get; set; } = Theme.ControlBackground;
/// <summary>Where each tab was drawn, by stage index. Filled during OnPaint and read by the
/// mouse handlers - a click before the first paint finds an empty list and does nothing, which
/// is the correct answer for a bar nobody has seen yet.</summary>
private readonly List<Rect> _tabRects = new();
private Rect _finishRect;
private int _hovered = -1;
private bool _hoveredFinish;
private const float Pad = 14f;
private const float Gap = 2f;
public EffigyStageTabRow( Widget parent ) : base( parent )
{
Cursor = CursorShape.Finger;
MouseTracking = true;
TranslucentBackground = true;
NoSystemBackground = true;
FixedHeight = EffigyToolChrome.TabRowHeight;
}
protected override void OnPaint()
{
Paint.Antialiasing = true;
// The row is opaque chrome and says so. This is the one place in the tool where painting a
// solid band is right rather than a bug to work around: the bar is docked above the
// viewport now, so there is no 3D view behind it to show through.
Paint.ClearPen();
Paint.SetBrush( ChromeColor );
Paint.DrawRect( new Rect( 0f, 0f, Width, Height ) );
// A hairline under the row, so the bar has a bottom edge against the viewport instead of
// bleeding into it.
Paint.SetPen( Theme.Text.WithAlpha( 0.10f ), 1f );
Paint.DrawLine( new Vector2( 0f, Height - 0.5f ), new Vector2( Width, Height - 0.5f ) );
PaintTabs();
PaintMode();
}
private void PaintTabs()
{
_tabRects.Clear();
if ( Bar is null )
return;
var x = Pad * 0.5f;
for ( var i = 0; i < Bar.Stages.Count; i++ )
{
var stage = Bar.Stages[i];
var selected = i == Bar.SelectedIndex;
Paint.SetDefaultFont( EffigyToolChrome.TabFontSize, selected ? 600 : 450 );
var nameWidth = Paint.MeasureText( stage.Name ).x;
var count = stage.Tools.Count.ToString();
Paint.SetDefaultFont( EffigyToolChrome.TabFontSize - 2f, 500 );
var countWidth = stage.Locked ? 0f : Paint.MeasureText( count ).x + 5f;
var rect = new Rect( x, 0f, nameWidth + countWidth + Pad, Height );
_tabRects.Add( rect );
// The selected tab is an accent underline rather than a filled box. A fill would need
// the row to be tall enough to carry it, and the underline is the one tab idiom that
// reads at this height without stealing attention from the tools underneath.
if ( selected )
{
Paint.ClearPen();
Paint.SetBrush( Theme.Blue );
Paint.DrawRect( new Rect( rect.Position.x, Height - 2f, rect.Size.x, 2f ) );
}
else if ( _hovered == i && !stage.Locked )
{
Paint.ClearPen();
Paint.SetBrush( Theme.Text.WithAlpha( 0.06f ) );
Paint.DrawRect( rect );
}
var textColor = stage.Locked
? Theme.TextControl.WithAlpha( 0.35f )
: selected ? Theme.Text : Theme.TextControl.WithAlpha( 0.8f );
Paint.SetDefaultFont( EffigyToolChrome.TabFontSize, selected ? 600 : 450 );
Paint.SetPen( textColor );
Paint.DrawText( new Rect( rect.Position.x + Pad * 0.5f, 0f, nameWidth, Height ),
stage.Name, TextFlag.LeftCenter );
// The count is what makes the tabs worth having a number on: it says how much is behind
// each one, so "where is Shell" has five candidates to check rather than nineteen.
if ( !stage.Locked )
{
Paint.SetDefaultFont( EffigyToolChrome.TabFontSize - 2f, 500 );
Paint.SetPen( selected ? Theme.Blue : Theme.TextControl.WithAlpha( 0.45f ) );
Paint.DrawText( new Rect( rect.Position.x + Pad * 0.5f + nameWidth + 5f, 0f, countWidth, Height ),
count, TextFlag.LeftCenter );
}
x += rect.Size.x + Gap;
}
}
/// <summary>
/// The mode, and the way out of it, at the right-hand end.
///
/// TOGETHER, AND ON PURPOSE. "Which mode am I in" and "how do I leave it" are one question
/// asked twice, and the old strips answered neither: sketch mode was a different set of
/// glyphs in the same rectangle, and the way out was a green tick at the end of nineteen
/// squares.
/// </summary>
private void PaintMode()
{
_finishRect = default;
if ( string.IsNullOrEmpty( Mode ) )
return;
var x = Width - Pad * 0.5f;
if ( !string.IsNullOrEmpty( FinishLabel ) )
{
Paint.SetDefaultFont( EffigyToolChrome.TabFontSize, 600 );
var textWidth = Paint.MeasureText( FinishLabel ).x;
var width = textWidth + 34f;
_finishRect = new Rect( x - width, 4f, width, Height - 9f );
Paint.ClearPen();
Paint.SetBrush( EffigyToolChrome.ConfirmColor.WithAlpha( _hoveredFinish ? 0.30f : 0.18f ) );
Paint.DrawRect( _finishRect, 3f );
Paint.SetPen( EffigyToolChrome.ConfirmColor );
EffigyIcons.Draw( EffigyIcon.FinishSketchTool,
new Vector2( _finishRect.Position.x + 13f, Height * 0.5f - 2f ),
EffigyToolChrome.ConfirmColor, 0.85f );
Paint.SetDefaultFont( EffigyToolChrome.TabFontSize, 600 );
Paint.SetPen( EffigyToolChrome.ConfirmColor );
Paint.DrawText( new Rect( _finishRect.Position.x + 24f, _finishRect.Position.y, textWidth + 6f, _finishRect.Size.y ),
FinishLabel, TextFlag.LeftCenter );
x -= width + 10f;
}
Paint.SetDefaultFont( EffigyToolChrome.TabFontSize - 1f, 600 );
var modeWidth = Paint.MeasureText( Mode ).x;
Paint.SetPen( Theme.Blue.WithAlpha( 0.9f ) );
Paint.DrawText( new Rect( x - modeWidth, 0f, modeWidth, Height ), Mode, TextFlag.LeftCenter );
}
private int TabAt( Vector2 local )
{
for ( var i = 0; i < _tabRects.Count; i++ )
{
if ( _tabRects[i].IsInside( local ) )
return i;
}
return -1;
}
protected override void OnMouseMove( MouseEvent e )
{
var tab = TabAt( e.LocalPosition );
var finish = _finishRect.Size.x > 0f && _finishRect.IsInside( e.LocalPosition );
if ( tab == _hovered && finish == _hoveredFinish )
return;
_hovered = tab;
_hoveredFinish = finish;
// The locked reason IS the tooltip. It is the only place the rule gets explained, and a
// dimmed tab with no explanation is the strip's silent disappearing act with extra steps.
ToolTip = tab >= 0 && Bar is not null && tab < Bar.Stages.Count
? Bar.Stages[tab].LockedReason ?? Bar.Stages[tab].Name
: "";
Update();
}
protected override void OnMouseLeave()
{
base.OnMouseLeave();
_hovered = -1;
_hoveredFinish = false;
Update();
}
protected override void OnMousePress( MouseEvent e )
{
if ( !e.LeftMouseButton )
return;
e.Accepted = true;
if ( _finishRect.Size.x > 0f && _finishRect.IsInside( e.LocalPosition ) )
{
FinishClicked?.Invoke();
return;
}
var tab = TabAt( e.LocalPosition );
if ( tab >= 0 )
Bar?.Select( tab );
}
}
/// <summary>
/// The current stage's tools, as icon-and-name buttons.
///
/// THE NAMES ARE THE WHOLE POINT. One button in fifty carried a label on the strips this replaced,
/// and the tooltip that stood in for the other forty-nine cost a cursor park and half a second
/// each. Showing only one stage at a time is what makes the room for them.
/// </summary>
internal sealed class EffigyStageToolRow : Widget
{
public EffigyStageBar Bar { get; set; }
public Color ChromeColor { get; set; } = Theme.ControlBackground;
/// <summary>Where each tool was drawn. Same cache-what-was-painted arrangement as the tab row.
/// Parallel to the current stage's Tools list.</summary>
private readonly List<Rect> _rects = new();
private int _hovered = -1;
private int _pressed = -1;
private bool _pressedChevron;
private const float Pad = 9f;
private const float IconWidth = 21f;
private const float IconGap = 6f;
private const float ChevronWidth = 16f;
private const float Gap = 3f;
public EffigyStageToolRow( Widget parent ) : base( parent )
{
Cursor = CursorShape.Finger;
MouseTracking = true;
TranslucentBackground = true;
NoSystemBackground = true;
FixedHeight = EffigyToolChrome.ToolRowHeight;
}
private IReadOnlyList<EffigyStageTool> Tools
{
get
{
var stage = Bar?.Current;
return stage is null ? Array.Empty<EffigyStageTool>() : stage.Tools;
}
}
protected override void OnPaint()
{
Paint.Antialiasing = true;
Paint.ClearPen();
Paint.SetBrush( ChromeColor );
Paint.DrawRect( new Rect( 0f, 0f, Width, Height ) );
Paint.SetPen( Theme.Text.WithAlpha( 0.10f ), 1f );
Paint.DrawLine( new Vector2( 0f, Height - 0.5f ), new Vector2( Width, Height - 0.5f ) );
_rects.Clear();
var tools = Tools;
var x = 7f;
var top = (Height - EffigyToolChrome.ButtonHeight) * 0.5f;
for ( var i = 0; i < tools.Count; i++ )
{
var tool = tools[i];
Paint.SetDefaultFont( EffigyToolChrome.LabelFontSize, 450 );
var label = tool.FaceLabel ?? "";
var textWidth = Paint.MeasureText( label ).x;
var width = Pad + IconWidth + IconGap + textWidth + Pad
+ (tool.HasVariants ? ChevronWidth : 0f);
var rect = new Rect( x, top, width, EffigyToolChrome.ButtonHeight );
_rects.Add( rect );
PaintTool( tool, rect, i );
x += width + Gap;
}
}
private void PaintTool( EffigyStageTool tool, Rect rect, int index )
{
// Order matters: attention under the armed ring under the hover glow, so hovering a
// highlighted armed button still reads as hovering it rather than as a fourth state.
if ( tool.Attention )
EffigyToolChrome.PaintAttentionRing( rect );
if ( tool.Checked )
EffigyToolChrome.PaintEdgeRing( rect );
if ( _pressed == index || _hovered == index )
EffigyToolChrome.PaintEdgeGlow( rect, _pressed == index ? 1.4f : 1f );
var color = tool.IconColor ?? Theme.Text;
Paint.SetPen( color );
EffigyIcons.Draw( tool.FaceIcon,
new Vector2( rect.Position.x + Pad + IconWidth * 0.5f, rect.Position.y + rect.Size.y * 0.5f ),
color, EffigyToolChrome.IconScale );
Paint.SetDefaultFont( EffigyToolChrome.LabelFontSize, 450 );
Paint.SetPen( tool.IconColor ?? Theme.TextControl );
Paint.DrawText(
new Rect( rect.Position.x + Pad + IconWidth + IconGap, rect.Position.y,
rect.Size.x - Pad * 2f - IconWidth - IconGap, rect.Size.y ),
tool.FaceLabel ?? "", TextFlag.LeftCenter );
if ( !tool.HasVariants )
return;
Paint.SetPen( Theme.TextLight.WithAlpha( _hovered == index ? 0.9f : 0.5f ) );
Paint.DrawIcon( ChevronRect( rect ), "arrow_drop_down", 15, TextFlag.Center );
}
private static Rect ChevronRect( Rect rect ) =>
new( rect.Position.x + rect.Size.x - ChevronWidth - 2f, rect.Position.y, ChevronWidth, rect.Size.y );
private int ToolAt( Vector2 local )
{
for ( var i = 0; i < _rects.Count; i++ )
{
if ( _rects[i].IsInside( local ) )
return i;
}
return -1;
}
/// <summary>
/// The variant list, opened from the chevron.
///
/// NO ICONS ON THE OPTIONS. EffigyIcon is a DRAWN glyph — EffigyIcons.Draw paints into a
/// widget's paint context — while a Menu option takes a Material Icon NAME, which is the very
/// lookup these icons exist to get away from. The label and the check mark carry the variant.
/// </summary>
private void OpenVariantMenu( EffigyStageTool tool )
{
var menu = new Menu( this );
for ( var i = 0; i < tool.Variants.Length; i++ )
{
var index = i;
var variant = tool.Variants[i];
var option = menu.AddOption( variant.Label, null, () =>
{
tool.Current = index;
variant.Chosen?.Invoke();
Update();
} );
option.Checkable = true;
option.Checked = i == tool.Current;
}
menu.OpenAtCursor();
}
protected override void OnMouseMove( MouseEvent e )
{
var index = ToolAt( e.LocalPosition );
if ( index == _hovered )
return;
_hovered = index;
var tools = Tools;
ToolTip = index >= 0 && index < tools.Count ? tools[index].FaceTip ?? "" : "";
Update();
}
protected override void OnMouseLeave()
{
base.OnMouseLeave();
_hovered = -1;
_pressed = -1;
_pressedChevron = false;
Update();
}
protected override void OnMousePress( MouseEvent e )
{
if ( !e.LeftMouseButton )
return;
e.Accepted = true;
var index = ToolAt( e.LocalPosition );
if ( index < 0 )
return;
var tools = Tools;
if ( index >= tools.Count )
return;
// Which half was pressed decides what the release does. Recorded on PRESS so a drag that
// starts on the chevron and ends over the glyph cannot arm a tool you did not ask for.
_pressedChevron = tools[index].HasVariants && ChevronRect( _rects[index] ).IsInside( e.LocalPosition );
_pressed = index;
Update();
}
protected override void OnMouseReleased( MouseEvent e )
{
var index = _pressed;
var chevron = _pressedChevron;
_pressed = -1;
_pressedChevron = false;
Update();
if ( index < 0 )
return;
var tools = Tools;
// The row may have been repainted with a different stage between the press and the
// release — a tool that swaps the stage set does exactly that — so the index is checked
// against BOTH lists rather than trusted from the press.
if ( index >= tools.Count || index >= _rects.Count || !_rects[index].IsInside( e.LocalPosition ) )
return;
var tool = tools[index];
if ( chevron )
{
OpenVariantMenu( tool );
return;
}
if ( tool.Checkable )
tool.Checked = !tool.Checked;
tool.Run();
// The run may have swapped the whole stage set out from under us — finishing a sketch
// does exactly that — so this is a repaint of whatever is there NOW, not of what was
// clicked. Update on a widget whose row has been rebuilt is harmless.
Update();
}
}