Editor/SubHierarchyDock.cs
namespace SubHierarchy;
/// <summary>
/// Separate dock with Hierarchy helpers: collapse/expand, hide/show, row colors.
/// </summary>
[Dock( "Editor", "Sub Hierarchy", "account_tree" )]
public sealed class SubHierarchyDock : Widget
{
public const string DockTitle = "Sub Hierarchy";
const string CustomColorsCookie = "SubHierarchy.CustomColors";
const int MaxCustomColors = 24;
const float ColorMatchEpsilon = 0.02f;
static readonly Color[] Presets =
[
new Color( 0.85f, 0.25f, 0.25f ), // red
new Color( 0.90f, 0.50f, 0.15f ), // orange
new Color( 0.90f, 0.80f, 0.20f ), // yellow
new Color( 0.55f, 0.85f, 0.20f ), // lime
new Color( 0.30f, 0.75f, 0.35f ), // green
new Color( 0.15f, 0.70f, 0.65f ), // teal
new Color( 0.20f, 0.75f, 0.90f ), // cyan
new Color( 0.25f, 0.55f, 0.95f ), // blue
new Color( 0.40f, 0.35f, 0.90f ), // indigo
new Color( 0.65f, 0.35f, 0.90f ), // purple
new Color( 0.90f, 0.35f, 0.70f ), // magenta
new Color( 0.90f, 0.45f, 0.55f ), // pink
new Color( 0.70f, 0.50f, 0.30f ), // brown
];
Color _draftColor = new Color( 0.25f, 0.55f, 0.95f );
ColorSwatchButton _swatch;
bool _colorChildrenRecursively;
Layout _content;
ColorSwatchPalette _palette;
List<Color> _customColors = new();
public SubHierarchyDock( Widget parent ) : base( parent )
{
Layout = Layout.Column();
var scroller = Layout.Add( new ScrollArea( this ), 1 );
scroller.Canvas = new Widget( scroller )
{
HorizontalSizeMode = SizeMode.Flexible,
VerticalSizeMode = SizeMode.CanGrow
};
scroller.Canvas.Layout = Layout.Column();
scroller.Canvas.Layout.Margin = 8;
scroller.Canvas.Layout.Spacing = 8;
_content = scroller.Canvas.Layout;
LoadCustomColors();
BuildUi();
HierarchyColorPainter.EnsureHooked();
}
public static void Open()
{
EditorWindow.DockManager.SetDockState( DockTitle, true );
EditorWindow.DockManager.RaiseDock( DockTitle );
}
void BuildUi()
{
_content.Clear( true );
AddSectionLabel( "Tree" );
AddButtonRow(
( "Collapse All", "unfold_less", HierarchyTreeActions.CollapseAll ),
( "Expand All", "unfold_more", HierarchyTreeActions.ExpandAll )
);
AddSectionLabel( "Visibility" );
AddButtonRow(
( "Hide All", "visibility_off", HierarchyTreeActions.HideAll ),
( "Show All", "visibility", HierarchyTreeActions.ShowAll )
);
AddButtonRow(
( "Isolate Selection", "filter_center_focus", HierarchyTreeActions.IsolateSelection )
);
AddSectionLabel( "Hierarchy Color" );
AddColorControls();
RebuildColorSwatches();
AddSectionLabel( "Reorganize" );
AddButtonRow(
( "Sort Alphabetically", "sort_by_alpha", HierarchyTreeActions.ReorganizeAlphabetically ),
( "Sort by Color", "palette", HierarchyTreeActions.ReorganizeByColor )
);
_content.AddStretchCell( 1 );
}
void AddSectionLabel( string text )
{
var label = _content.Add( new Label( text ) );
label.Color = Theme.TextControl.WithAlpha( 0.85f );
}
void AddButtonRow( params (string Title, string Icon, Action Action)[] buttons )
{
var row = _content.AddRow();
row.Spacing = 6;
foreach ( var button in buttons )
{
var btn = row.Add( new Button( button.Title, button.Icon ), 1 );
btn.Clicked = button.Action;
}
}
void AddColorControls()
{
var row = _content.AddRow();
row.Spacing = 6;
row.Alignment = TextFlag.LeftCenter;
_swatch = row.Add( new ColorSwatchButton( () => _draftColor, OnCustomColorPicked )
{
RememberOnClose = true,
OnRemember = RememberCustomColor
} );
_swatch.ToolTip = "Pick a color and apply it to the selection";
_swatch.FixedWidth = Theme.RowHeight * 2;
_swatch.FixedHeight = Theme.RowHeight;
var clear = row.Add( new Button( "Clear", "format_color_reset" ), 1 );
clear.ToolTip = "Remove Hierarchy Color from selection";
clear.Clicked = () => HierarchyTreeActions.ClearColorFromSelection( _colorChildrenRecursively );
var clearAll = row.Add( new Button( "Clear All", "delete_sweep" ), 1 );
clearAll.ToolTip = "Remove Hierarchy Color from every object in the scene";
clearAll.Clicked = HierarchyTreeActions.ClearAllColors;
var recursiveRow = _content.AddRow();
recursiveRow.Spacing = 6;
recursiveRow.Alignment = TextFlag.LeftCenter;
var recursive = recursiveRow.Add( new Checkbox( "Color children recursively" ) );
recursive.State = _colorChildrenRecursively ? CheckState.On : CheckState.Off;
recursive.ToolTip = "When applying or clearing, include all descendants of the selection";
recursive.Clicked = () => _colorChildrenRecursively = recursive.State == CheckState.On;
_palette = _content.Add( new ColorSwatchPalette( OnCustomColorPicked, OnColorSwatchRemoved ) );
_palette.HorizontalSizeMode = SizeMode.Flexible;
}
void RebuildColorSwatches()
{
if ( _palette is null || !_palette.IsValid() )
return;
var entries = new List<(Color Color, string ToolTip, bool Removable)>();
foreach ( var preset in Presets )
entries.Add( (preset, "Left-click: apply", false) );
foreach ( var custom in _customColors )
entries.Add( (custom, "Left-click: apply. Right-click: remove from palette and all objects.", true) );
_palette.SetColors( entries );
}
void OnCustomColorPicked( Color color )
{
_draftColor = color.WithAlpha( 1f );
_swatch?.Update();
HierarchyTreeActions.ApplyColorToSelection( _draftColor, _colorChildrenRecursively );
}
void OnColorSwatchRemoved( Color color )
{
color = color.WithAlpha( 1f );
// Built-in presets stay in the palette permanently.
if ( Presets.Any( p => ColorsMatch( p, color ) ) )
return;
var removedCustom = _customColors.RemoveAll( c => ColorsMatch( c, color ) ) > 0;
if ( !removedCustom )
return;
HierarchyTreeActions.ClearColorsMatching( color, ColorMatchEpsilon );
SaveCustomColors();
if ( ColorsMatch( _draftColor, color ) )
_draftColor = Presets[0];
_swatch?.Update();
RebuildColorSwatches();
}
void RememberCustomColor( Color color )
{
color = color.WithAlpha( 1f );
if ( Presets.Any( p => ColorsMatch( p, color ) ) )
return;
if ( _customColors.Any( c => ColorsMatch( c, color ) ) )
return;
_customColors.Add( color );
while ( _customColors.Count > MaxCustomColors )
_customColors.RemoveAt( 0 );
SaveCustomColors();
RebuildColorSwatches();
}
void LoadCustomColors()
{
_customColors = EditorCookie.Get( CustomColorsCookie, new List<Color>() ) ?? new List<Color>();
}
void SaveCustomColors()
{
EditorCookie.Set( CustomColorsCookie, _customColors );
}
static bool ColorsMatch( Color a, Color b )
{
return MathF.Abs( a.r - b.r ) < ColorMatchEpsilon
&& MathF.Abs( a.g - b.g ) < ColorMatchEpsilon
&& MathF.Abs( a.b - b.b ) < ColorMatchEpsilon;
}
/// <summary>
/// Wraps color swatches to the available width, with at most 10 per row.
/// </summary>
sealed class ColorSwatchPalette : Widget
{
const int MaxPerRow = 10;
const float Spacing = 4f;
readonly Action<Color> _onPick;
readonly Action<Color> _onRemove;
readonly List<(Color Color, string ToolTip, bool Removable)> _entries = new();
int _builtPerRow = -1;
public ColorSwatchPalette( Action<Color> onPick, Action<Color> onRemove ) : base( null )
{
_onPick = onPick;
_onRemove = onRemove;
HorizontalSizeMode = SizeMode.Flexible;
VerticalSizeMode = SizeMode.CanGrow;
Layout = Layout.Column();
Layout.Spacing = Spacing;
}
public void SetColors( IEnumerable<(Color Color, string ToolTip, bool Removable)> entries )
{
_entries.Clear();
_entries.AddRange( entries );
_builtPerRow = -1;
Rebuild();
}
protected override void DoLayout()
{
base.DoLayout();
var perRow = CalcPerRow();
if ( perRow != _builtPerRow )
Rebuild();
}
int CalcPerRow()
{
var cell = Theme.RowHeight + Spacing;
if ( cell <= 0f || Width <= 1f )
return MaxPerRow;
var fit = Math.Max( 1, (int)( (Width + Spacing) / cell ) );
return Math.Min( MaxPerRow, fit );
}
void Rebuild()
{
var perRow = CalcPerRow();
_builtPerRow = perRow;
Layout.Clear( true );
Layout.Spacing = Spacing;
Layout row = null;
var countInRow = 0;
foreach ( var entry in _entries )
{
if ( row is null || countInRow >= perRow )
{
row = Layout.AddRow();
row.Spacing = Spacing;
row.Alignment = TextFlag.LeftCenter;
countInRow = 0;
}
var color = entry.Color;
var removable = entry.Removable;
var swatch = row.Add( new ColorSwatchButton(
() => color,
c => _onPick( c ),
removable ? () => _onRemove( color ) : null ) );
swatch.FixedWidth = Theme.RowHeight;
swatch.FixedHeight = Theme.RowHeight;
swatch.ToolTip = entry.ToolTip;
swatch.OpenPickerOnClick = false;
countInRow++;
}
}
}
/// <summary>Simple color swatch; left-click opens the engine color picker (optional).</summary>
sealed class ColorSwatchButton : Widget
{
readonly Func<Color> _getColor;
readonly Action<Color> _setColor;
readonly Action _onRemove;
public bool OpenPickerOnClick { get; set; } = true;
public bool RememberOnClose { get; set; }
public Action<Color> OnRemember { get; set; }
public ColorSwatchButton( Func<Color> getColor, Action<Color> setColor, Action onRemove = null ) : base( null )
{
_getColor = getColor;
_setColor = setColor;
_onRemove = onRemove;
Cursor = CursorShape.Finger;
MouseLeftPress = OnClicked;
MouseRightPress = OnRightClicked;
}
void OnClicked()
{
if ( !OpenPickerOnClick )
{
_setColor( _getColor() );
return;
}
var popup = new ColorPopup( null );
popup.FixedWidth = 233;
popup.Layout = Layout.Column();
popup.Position = Editor.Application.CursorPosition;
var colorPicker = popup.Layout.Add( new ColorPicker( popup, _getColor() ), 1 );
colorPicker.OnChildValuesChanged += _ =>
{
_setColor( colorPicker.Value );
Update();
};
if ( RememberOnClose )
{
popup.Closed = () => OnRemember?.Invoke( colorPicker.Value );
}
popup.Show();
popup.ConstrainToScreen();
}
void OnRightClicked()
{
_onRemove?.Invoke();
}
protected override void OnPaint()
{
var color = _getColor();
const float radius = 3f;
const float border = 1.25f;
Paint.Antialiasing = true;
// Solid white rounded plate first so color can't bleed past the corners.
Paint.ClearPen();
Paint.SetBrush( Color.White );
Paint.DrawRect( LocalRect, radius );
var inner = LocalRect.Shrink( border );
var innerRadius = Math.Max( radius - border * 0.5f, 0f );
Paint.SetBrush( color );
Paint.DrawRect( inner, innerRadius );
}
}
/// <summary>Popup that notifies when closed so custom colors can be saved once.</summary>
sealed class ColorPopup : PopupWidget
{
public Action Closed { get; set; }
public ColorPopup( Widget parent ) : base( parent )
{
}
protected override void OnClosed()
{
Closed?.Invoke();
base.OnClosed();
}
}
}
/// <summary>Menu + View registration for Sub Hierarchy.</summary>
public static class SubHierarchyMenus
{
[Menu( "Editor", "Sub Hierarchy/Open", "account_tree" )]
public static void Open() => SubHierarchyDock.Open();
[Event( "tools.editorwindow.postcreateview" )]
static void AddViewMenu( Menu menu )
{
menu.AddSeparator();
menu.AddOption( "Sub Hierarchy", "account_tree", () => SubHierarchyDock.Open() );
}
[Event( "editor.created" )]
static void OnEditorCreated( object _ ) => HierarchyColorPainter.EnsureHooked();
}