Editor/EffigyEditor/EffigyStageBar.cs

Editor UI code for Marionette's Effigy editor. Implements a docked stage bar made of a tab row and a tool row, data classes for stages, tools and variants, painting logic, hit-testing and mouse handling, menus and scrolling behavior.

Native Interop
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,
	Paint,
	Rig,

	/// <summary>Edit mode in the Model workspace: direct vertex / edge / face editing of one body
	/// through a <see cref="MeshEditSession"/>.</summary>
	MeshEdit,

	/// <summary>The Clothing workspace: bring a wearer in, cut a garment from it, grow fur on it.
	/// Like <see cref="Rig"/> it is a plain mode with nothing to commit - every tool on its bar
	/// adds an ordinary feature, so the tree holds the work rather than a session.</summary>
	Clothing,
}

/// <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>
	/// "This tool will use what you have selected." A short bar down the button's left edge, in the
	/// reference green.
	///
	/// THE POSITIVE FORM, AND THAT IS THE WHOLE DESIGN DECISION. The obvious version of this
	/// affordance is to DIM what does not apply, and it is wrong: a tool that ignores your selection
	/// is not unavailable — pressing Primitive with a face selected is perfectly fine — so dimming
	/// most of the bar most of the time would teach a rule that is not true, and would train people
	/// to ignore dimming everywhere else in the editor. Marking what WILL use the selection says the
	/// same thing without lying about the rest.
	///
	/// QUIET ON PURPOSE, BECAUSE IT IS THE COMMON CASE. Select one face and five or six tools light
	/// at once — Fillet, Chamfer, Draft, Hole, Face Material, Extrude, Move Face — so anything with
	/// a wash or a ring behind it turns a row of names into a row of boxes. It is a THIRD SHAPE
	/// rather than a third colour of the same shape: hover is a halo at the edge, armed is a ring
	/// around the edge, and this is a mark ON one edge. All three can be true at once and still be
	/// read apart.
	///
	/// THE COLOUR IS ALREADY SPOKEN FOR AND THAT IS WHY IT WAS CHOSEN. ReferenceColor means "this is
	/// about the part underneath" and matches SketchReferenceColor in the viewport. A selection IS
	/// the part underneath, so this is the same statement the viewport is already making, in the
	/// place the answer gets pressed.
	/// </summary>
	public static void PaintTakesMark( Rect rect )
	{
		Paint.ClearPen();
		Paint.SetBrush( ReferenceColor.WithAlpha( 0.85f ) );
		Paint.DrawRect(
			new Rect( rect.Position.x + 1f, rect.Position.y + rect.Size.y * 0.25f,
				2f, rect.Size.y * 0.5f ), 1f );
	}
}

/// <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>
	/// Whether the tool can be run right now.
	///
	/// THE SAME CONTRACT EffigyStage.Locked HAS, one level down: a dimmed button whose tooltip is
	/// the REASON, rather than a button that vanishes or one that looks live and does nothing. The
	/// stage-level lock already made that argument for the CAD tabs — the strip it replaced hid
	/// seventeen buttons until a sketch existed, which is how a studio started with a Primitive
	/// ended up with no fillet and no explanation. A per-tool version was needed as soon as the rig
	/// stages arrived, where three of the four tools act on "the selected bone" and there is often
	/// no selection.
	/// </summary>
	public bool Enabled = true;

	/// <summary>Why it is disabled. Becomes the tooltip, because a dimmed button with no
	/// explanation is the disappearing act with extra steps.</summary>
	public string DisabledReason;

	/// <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>The key that runs this, as the Model palette and catalog print it in the corner —
	/// "E", "Ctrl+R", "Shift+D". Null for none. The tips carry it in prose too; this is the
	/// short form for the tile.</summary>
	public string Key;

	/// <summary>What a plain click does. Ignored when there are variants, UNLESS
	/// <see cref="VariantsAreSettings"/> says otherwise: those carry their own.</summary>
	public Action Clicked;

	public EffigyStageVariant[] Variants;

	/// <summary>
	/// The variants are OPTIONS, not alternate tools — the note colour behind the pen's chevron,
	/// not the sketch shapes behind Primitive's. A click on the face still runs <see cref="Clicked"/>
	/// every time (arm or put down the pen), and only the chevron reaches the variant list; without
	/// this flag a click would re-run the CURRENT colour's Chosen instead, which turns "put the pen
	/// down" into "redraw it in the colour it was already in" the moment a colour has ever been
	/// picked.
	/// </summary>
	public bool VariantsAreSettings;

	// --- live state, owned here so a stage switch cannot lose it ---------------------------

	public bool Checked;
	public bool Attention;

	/// <summary>
	/// This tool's <c>Accepts</c> intersects what is currently selected, so pressing it now would
	/// consume that selection rather than starting from nothing.
	///
	/// SET FROM THE OUTSIDE, never worked out here. The window recomputes it when the selection
	/// changes, by asking a fresh feature per tool — see EffigyWindow.AcceptedBy for why that is
	/// on demand and not a cached map. A paint pass must never be the thing that asks.
	/// </summary>
	public bool Takes;

	/// <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 ( !VariantsAreSettings && Face is { } variant )
			variant.Chosen?.Invoke();
		else
			Clicked?.Invoke();
	}
}

/// <summary>A named handful of tools, and whether they can be used yet.</summary>
/// <summary>One button of a mode switch: its label, whether it is the current mode, and what
/// picking it does.</summary>
internal sealed class EffigyModeSegment
{
	public string Label;
	public string Tip;
	public bool Active;
	public Action Clicked;
}

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;
	}

	/// <summary>Park a widget at the right-hand end of the TOOL row - the row with the tool buttons
	/// on it, not the stage tabs above. Built by the window, which owns it and decides when it is
	/// visible; the bar only says where it goes.</summary>
	public void SetToolRowTrailing( Widget widget )
	{
		widget.Parent = _tools;
		_tools.Trailing = widget;
	}

	public IReadOnlyList<EffigyStage> Stages => _stages;

	/// <summary>
	/// Menus instead of tabs, or null. When set, the top row is a menu bar — each entry opens a
	/// dropdown of its tools — and the tool row keeps showing <see cref="Stages"/>, which the owner
	/// sets to the handful of tools that are held rather than looked up.
	///
	/// This is how Model's Edit mode gets from ten tabs of over a hundred buttons, which ran off the
	/// edge of the window, to a toolbar plus menus: the same tool objects, so every enabled state,
	/// tick and reason stays exactly as live as it was on a tab.
	/// </summary>
	public IReadOnlyList<EffigyStage> Menus => _menus;

	private List<EffigyStage> _menus;

	/// <summary>Called just before a menu opens, so the owner can bring every tool's enabled state
	/// up to date first.</summary>
	public Action MenuOpening { get; set; }

	/// <summary>
	/// A mode switch at the right-hand end — Object | Edit | Retopo — in place of a mode label and
	/// a Finish button. Leaving a mode is picking another one, as in Blender, so there is no
	/// separate "finish" to find. Null for none.
	/// </summary>
	public void SetSegments( IEnumerable<EffigyModeSegment> segments )
	{
		_tabs.Segments = segments is null ? null : new List<EffigyModeSegment>( segments );
		_tabs.Update();
	}

	public void SetMenus( IEnumerable<EffigyStage> menus )
	{
		_menus = menus is null ? null : new List<EffigyStage>( menus );
		Refresh();
	}

	/// <summary>Open menu <paramref name="index"/> with its top-left at <paramref name="screen"/>.</summary>
	public void OpenMenu( int index, Vector2 screen )
	{
		if ( _menus is null || index < 0 || index >= _menus.Count )
			return;

		MenuOpening?.Invoke();

		var menu = new Menu( this );
		AddTools( menu, _menus[index].Tools );
		menu.OpenAt( screen );
	}

	/// <summary>
	/// Put tools on a menu as options: the tool's own label, its tip (or the reason it cannot run)
	/// as the tooltip, a tick for a mode that is on, and dimmed when it cannot run now. Shared by
	/// the menu bar and the viewport's right-click menu, so a tool reads the same in both.
	/// </summary>
	public static void AddTools( Menu menu, IEnumerable<EffigyStageTool> tools )
	{
		foreach ( var tool in tools )
		{
			if ( tool is null )
			{
				menu.AddSeparator();
				continue;
			}

			var run = tool.Clicked;
			var option = menu.AddOption( tool.Label, null, () => run?.Invoke() );
			option.Checkable = tool.Checkable;
			option.Checked = tool.Checked;
			option.Enabled = tool.Enabled;
			option.ToolTip = tool.Enabled ? tool.Tip : tool.DisabledReason ?? tool.Tip;
			option.StatusTip = option.ToolTip;
		}
	}

	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 )
	{
		// A new stage set is a new mode; its menus and mode switch, if it has any, are set after this.
		_menus = null;
		_tabs.Segments = null;
		_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;

	/// <summary>The mode switch, when there is one; see <see cref="EffigyStageBar.SetSegments"/>.</summary>
	public List<EffigyModeSegment> Segments { get; set; }

	private readonly List<Rect> _segmentRects = new();
	private int _hoveredSegment = -1;
	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;

		if ( Bar.Menus is { } menus )
		{
			PaintMenus( menus );
			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>A menu bar: each name with a small chevron, no counts and no selected tab — a menu
	/// is a place you open, not a page you are on.</summary>
	private void PaintMenus( IReadOnlyList<EffigyStage> menus )
	{
		var x = Pad * 0.5f;

		for ( var i = 0; i < menus.Count; i++ )
		{
			Paint.SetDefaultFont( EffigyToolChrome.TabFontSize, 450 );

			var nameWidth = Paint.MeasureText( menus[i].Name ).x;
			var rect = new Rect( x, 0f, nameWidth + Pad + 12f, Height );
			_tabRects.Add( rect );

			if ( _hovered == i )
			{
				Paint.ClearPen();
				Paint.SetBrush( Theme.Text.WithAlpha( 0.08f ) );
				Paint.DrawRect( rect.Shrink( 0f, 3f, 0f, 3f ), 3f );
			}

			Paint.SetPen( _hovered == i ? Theme.Text : Theme.TextControl.WithAlpha( 0.85f ) );
			Paint.DrawText( new Rect( rect.Position.x + Pad * 0.5f, 0f, nameWidth, Height ), menus[i].Name, TextFlag.LeftCenter );

			// The chevron: two short strokes, drawn rather than a glyph so it sits on the text's
			// centre line whatever the font does.
			var c = new Vector2( rect.Position.x + Pad * 0.5f + nameWidth + 7f, Height * 0.5f + 1f );
			Paint.SetPen( Theme.TextControl.WithAlpha( 0.55f ), 1.3f );
			Paint.DrawLine( c + new Vector2( -3f, -1.5f ), c );
			Paint.DrawLine( c, c + new Vector2( 3f, -1.5f ) );

			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;
		_segmentRects.Clear();

		if ( Segments is { Count: > 0 } segments )
		{
			PaintSegments( segments );
			return;
		}

		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 void PaintSegments( List<EffigyModeSegment> segments )
	{
		Paint.SetDefaultFont( EffigyToolChrome.TabFontSize - 1f, 500 );

		var widths = new float[segments.Count];
		var total = 0f;
		for ( var i = 0; i < segments.Count; i++ )
		{
			widths[i] = Paint.MeasureText( segments[i].Label ).x + 20f;
			total += widths[i];
		}

		var x = Width - Pad * 0.5f - total;
		var box = new Rect( x, 4f, total, Height - 9f );

		Paint.ClearPen();
		Paint.SetBrush( Theme.Text.WithAlpha( 0.05f ) );
		Paint.DrawRect( box, 3f );

		for ( var i = 0; i < segments.Count; i++ )
		{
			var rect = new Rect( x, box.Position.y, widths[i], box.Size.y );
			_segmentRects.Add( rect );

			if ( segments[i].Active )
			{
				Paint.ClearPen();
				Paint.SetBrush( Theme.Blue.WithAlpha( 0.28f ) );
				Paint.DrawRect( rect, 3f );
			}
			else if ( _hoveredSegment == i )
			{
				Paint.ClearPen();
				Paint.SetBrush( Theme.Text.WithAlpha( 0.08f ) );
				Paint.DrawRect( rect, 3f );
			}

			Paint.SetDefaultFont( EffigyToolChrome.TabFontSize - 1f, segments[i].Active ? 600 : 500 );
			Paint.SetPen( segments[i].Active ? Theme.Text : Theme.TextControl.WithAlpha( 0.75f ) );
			Paint.DrawText( rect, segments[i].Label, TextFlag.Center );

			x += widths[i];
		}
	}

	private int SegmentAt( Vector2 local )
	{
		for ( var i = 0; i < _segmentRects.Count; i++ )
			if ( _segmentRects[i].IsInside( local ) )
				return i;

		return -1;
	}

	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 );
		var segment = SegmentAt( e.LocalPosition );

		if ( tab == _hovered && finish == _hoveredFinish && segment == _hoveredSegment )
			return;

		_hoveredSegment = segment;

		if ( segment >= 0 && Segments is not null && segment < Segments.Count )
		{
			ToolTip = Segments[segment].Tip ?? "";
			Update();
			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 = Bar?.Menus is not null
			? ""
			: 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;
		_hoveredSegment = -1;

		Update();
	}

	protected override void OnMousePress( MouseEvent e )
	{
		if ( !e.LeftMouseButton )
			return;

		e.Accepted = true;

		if ( SegmentAt( e.LocalPosition ) is var segment and >= 0 && Segments is not null && segment < Segments.Count )
		{
			if ( !Segments[segment].Active )
				Segments[segment].Clicked?.Invoke();
			return;
		}

		if ( _finishRect.Size.x > 0f && _finishRect.IsInside( e.LocalPosition ) )
		{
			FinishClicked?.Invoke();
			return;
		}

		var tab = TabAt( e.LocalPosition );

		if ( tab >= 0 && Bar?.Menus is not null )
		{
			Bar.OpenMenu( tab, ScreenPosition + new Vector2( _tabRects[tab].Position.x, Height ) );
			return;
		}

		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>
	/// A real widget parked at the right-hand end of the row, or null.
	///
	/// The row is painted rather than laid out — every tool on it is a rect this widget draws and
	/// hit-tests itself — so a control that has to be a genuine Qt widget (a dropdown that opens a
	/// popup, say) cannot be one of them. It gets flush-right instead, where the tools growing and
	/// shrinking with the stage never reaches it.
	///
	/// Positioned from the paint pass because that is the one place this row reliably learns its
	/// own width; it is also the only mutation, so a repaint that changes nothing costs an
	/// assignment.
	/// </summary>
	public Widget Trailing { get; set; }

	/// <summary>Gap between the trailing widget and the right edge, matching the row's own inset
	/// on the left.</summary>
	private const float TrailingMargin = 7f;

	private void PlaceTrailing()
	{
		if ( Trailing is not { IsValid: true } trailing || !trailing.Visible )
			return;

		trailing.Position = new Vector2(
			Width - trailing.Width - TrailingMargin,
			(Height - trailing.Height) * 0.5f );
	}

	/// <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;

	/// <summary>How far the row has been panned left, in pixels. Zero is the first tool flush
	/// with the left inset. Clamped in the paint pass against the width last measured, so a
	/// stage switch that shortens the row cannot leave the view parked in empty space.</summary>
	private float _scroll;

	/// <summary>Which stage <see cref="_scroll"/> belongs to. A new stage starts at the left
	/// rather than inheriting the previous stage's pan — the tools are a different set.</summary>
	private int _scrollStage = int.MinValue;

	/// <summary>Total width of the tools, including gaps, as of the last paint. Wheel and the
	/// edge arrows read this; they cannot measure text themselves (Paint.MeasureText is paint-
	/// pass only).</summary>
	private float _contentWidth;

	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;
	private const float Inset = 7f;
	private const float ArrowWidth = 18f;

	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 ) );

		PlaceTrailing();

		_rects.Clear();

		var tools = Tools;
		var stageIndex = Bar?.SelectedIndex ?? -1;

		if ( stageIndex != _scrollStage )
		{
			_scroll = 0f;
			_scrollStage = stageIndex;
		}

		var viewLeft = Inset;
		var viewRight = ToolsRight();
		var viewWidth = MathF.Max( viewRight - viewLeft, 1f );

		// First pass: widths only, so overflow can be known before anything is drawn. The
		// painted rects have to include the scroll, and the arrows take a bite out of the view
		// only when there is somewhere to pan.
		var widths = new float[tools.Count];
		var content = 0f;

		for ( var i = 0; i < tools.Count; i++ )
		{
			Paint.SetDefaultFont( EffigyToolChrome.LabelFontSize, 450 );

			var label = tools[i].FaceLabel ?? "";
			var textWidth = Paint.MeasureText( label ).x;
			var width = Pad + IconWidth + IconGap + textWidth + Pad
				+ (tools[i].HasVariants ? ChevronWidth : 0f);

			widths[i] = width;
			content += width + (i + 1 < tools.Count ? Gap : 0f);
		}

		_contentWidth = content;

		var overflow = content > viewWidth + 0.5f;
		var maxScroll = overflow ? MathF.Max( content - viewWidth, 0f ) : 0f;
		_scroll = Math.Clamp( _scroll, 0f, maxScroll );

		BringAttentionIntoView( widths, viewWidth, maxScroll );

		var top = (Height - EffigyToolChrome.ButtonHeight) * 0.5f;
		var x = viewLeft - _scroll;

		for ( var i = 0; i < tools.Count; i++ )
		{
			var rect = new Rect( x, top, widths[i], EffigyToolChrome.ButtonHeight );
			_rects.Add( rect );

			// Skip anything wholly off the usable strip so a scrolled-away button cannot paint
			// over the trailing control or the edge arrows.
			if ( rect.Right > viewLeft && rect.Left < viewRight )
				PaintTool( tools[i], rect, i );

			x += widths[i] + Gap;
		}

		if ( overflow )
			PaintScrollEdges( viewLeft, viewRight, maxScroll );
	}

	/// <summary>The right edge of the tool strip, short of the trailing widget when one is up.</summary>
	private float ToolsRight()
	{
		if ( Trailing is { IsValid: true, Visible: true } trailing )
			return MathF.Max( trailing.Position.x - Gap, Inset + 1f );

		return Width - Inset;
	}

	private float MaxScroll()
	{
		var view = MathF.Max( ToolsRight() - Inset, 1f );
		return MathF.Max( _contentWidth - view, 0f );
	}

	/// <summary>
	/// If the tutorial (or anything else) has marked a tool with Attention, pan just far enough
	/// that the button is fully in view. A highlight on a button you have to scroll to find is
	/// a highlight on nothing.
	/// </summary>
	private void BringAttentionIntoView( float[] widths, float viewWidth, float maxScroll )
	{
		if ( maxScroll <= 0f )
			return;

		var tools = Tools;
		var x = 0f;

		for ( var i = 0; i < tools.Count && i < widths.Length; i++ )
		{
			if ( tools[i].Attention )
			{
				var left = x;
				var right = x + widths[i];

				if ( left < _scroll )
					_scroll = left;
				else if ( right > _scroll + viewWidth )
					_scroll = right - viewWidth;

				_scroll = Math.Clamp( _scroll, 0f, maxScroll );
				return;
			}

			x += widths[i] + Gap;
		}
	}

	private void PaintScrollEdges( float viewLeft, float viewRight, float maxScroll )
	{
		var top = 4f;
		var h = Height - 8f;

		if ( _scroll > 0.5f )
		{
			var left = new Rect( viewLeft, top, ArrowWidth, h );

			Paint.ClearPen();
			Paint.SetBrush( ChromeColor.WithAlpha( 0.92f ) );
			Paint.DrawRect( left );

			Paint.SetPen( Theme.TextControl.WithAlpha( 0.75f ) );
			Paint.DrawText( left, "‹", TextFlag.Center );
		}

		if ( _scroll < maxScroll - 0.5f )
		{
			var right = new Rect( viewRight - ArrowWidth, top, ArrowWidth, h );

			Paint.ClearPen();
			Paint.SetBrush( ChromeColor.WithAlpha( 0.92f ) );
			Paint.DrawRect( right );

			Paint.SetPen( Theme.TextControl.WithAlpha( 0.75f ) );
			Paint.DrawText( right, "›", TextFlag.Center );
		}
	}

	private bool ScrollArrowAt( Vector2 local, out int direction )
	{
		direction = 0;

		if ( MaxScroll() <= 0.5f )
			return false;

		var viewLeft = Inset;
		var viewRight = ToolsRight();
		var top = 4f;
		var h = Height - 8f;

		if ( _scroll > 0.5f && new Rect( viewLeft, top, ArrowWidth, h ).IsInside( local ) )
		{
			direction = -1;
			return true;
		}

		if ( _scroll < MaxScroll() - 0.5f && new Rect( viewRight - ArrowWidth, top, ArrowWidth, h ).IsInside( local ) )
		{
			direction = 1;
			return true;
		}

		return false;
	}

	private void NudgeScroll( int direction )
	{
		var step = 80f;
		_scroll = Math.Clamp( _scroll + direction * step, 0f, MaxScroll() );
		Update();
	}

	private void PaintTool( EffigyStageTool tool, Rect rect, int index )
	{
		// Order matters: the selection mark under 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. The selection mark is first because it is the quietest and the most often
		// true — it should never be what you notice about a button you are already pointing at.
		// A disabled tool gets NONE of the decoration. Every mark below says something about what
		// the button would do if pressed, and a button that will not be pressed has nothing to say
		// — an armed ring on a dead tool is the worst of the four, since it claims the tool is on.
		if ( !tool.Enabled )
		{
			var dim = (tool.IconColor ?? Theme.Text).WithAlpha( 0.30f );

			Paint.SetPen( dim );
			EffigyIcons.Draw( tool.FaceIcon,
				new Vector2( rect.Position.x + Pad + IconWidth * 0.5f, rect.Position.y + rect.Size.y * 0.5f ),
				dim, EffigyToolChrome.IconScale );

			Paint.SetDefaultFont( EffigyToolChrome.LabelFontSize, 450 );
			Paint.SetPen( Theme.TextControl.WithAlpha( 0.35f ) );
			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 );

			return;
		}

		if ( tool.Takes )
			EffigyToolChrome.PaintTakesMark( rect );

		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 OnMouseWheel( WheelEvent e )
	{
		var max = MaxScroll();

		if ( max <= 0.5f )
		{
			base.OnMouseWheel( e );
			return;
		}

		// The row has no vertical overflow, so the wheel pans it. One notch is roughly a
		// button's width; Shift takes a bigger bite for getting across a long sketch strip.
		var step = e.HasShift ? 160f : 72f;
		_scroll = Math.Clamp( _scroll + ( e.Delta > 0 ? -step : step ), 0f, max );
		e.Accept();
		Update();
	}

	protected override void OnMouseMove( MouseEvent e )
	{
		if ( ScrollArrowAt( e.LocalPosition, out var direction ) )
		{
			_hovered = -1;
			ToolTip = direction < 0 ? "Scroll tools left" : "Scroll tools right";
			Update();
			return;
		}

		var index = ToolAt( e.LocalPosition );

		if ( index == _hovered )
			return;

		_hovered = index;

		var tools = Tools;

		// The reason a tool is unavailable displaces its ordinary tip — same rule the stage tabs
		// follow with LockedReason, and the reason is the more useful of the two exactly when it
		// applies.
		ToolTip = index >= 0 && index < tools.Count
			? (tools[index].Enabled ? tools[index].FaceTip : tools[index].DisabledReason ?? 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;

		if ( ScrollArrowAt( e.LocalPosition, out var direction ) )
		{
			NudgeScroll( direction );
			return;
		}

		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.
		if ( !tools[index].Enabled )
			return;

		_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];

		// Checked again rather than trusted from the press: a tool's availability is recomputed
		// from live state (UpdateRigChecks runs off the rig panel's own callbacks), so it can go
		// dead between the two halves of one click.
		if ( !tool.Enabled )
			return;

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