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