Editor/Widgets/SuiHierarchyWidget.cs
using System;
using System.Collections.Generic;
using Editor;
using Sandbox;
using SboxUiDesigner.Runtime;
namespace SboxUiDesigner.EditorUi.Widgets;
/// <summary>
/// Hierarchy panel — left side, below palette. Renders the document tree with
/// 100% custom paint: tree-lines connecting parents to children, type icon,
/// element name, and right-side eye (designer visibility) + lock (no-edit)
/// buttons.
///
/// Eye / lock are designer-only:
/// - Eye toggles whether the element is rendered in the canvas. Code
/// generation and runtime preview ignore this flag entirely.
/// - Lock prevents the element from being clicked in the canvas and from
/// having its properties edited in Details. Code/runtime ignore it too.
/// </summary>
public class SuiHierarchyWidget : Widget
{
private SuiDocument _document;
private SuiElement _selected;
private HashSet<string> _selectedIds = new();
private LineEdit _search;
private string _filter = "";
private SuiTreeView _tree;
public event Action<SuiElement> ElementSelected;
public event Action<SuiElement, SuiElementType> AddChildRequested;
public event Action<SuiElement, string> RenameRequested;
public event Action<SuiElement> DeleteRequested;
public event Action<SuiElement> DuplicateRequested;
public event Action<SuiElement> MoveUpRequested;
public event Action<SuiElement> MoveDownRequested;
public event Action<SuiElement, SuiElement, int> ReparentRequested;
public event Action FlagsChanged;
public SuiHierarchyWidget( Widget parent = null ) : base( parent )
{
WindowTitle = "Hierarchy";
Name = "SuiHierarchy";
MinimumSize = new Vector2( 200, 200 );
SetStyles( "background-color: transparent; border: none;" );
Layout = Layout.Column();
Layout.Margin = new Sandbox.UI.Margin( 8, 8, 8, 8 );
Layout.Spacing = 0;
// Search input — same look as Palette.
_search = new LineEdit( this );
_search.PlaceholderText = "Search Hierarchy";
_search.FixedHeight = 28;
_search.SetStyles(
"background-color: rgb(20,20,19);" +
"border: 1px solid rgba(255,255,255,0.06);" +
"border-radius: 3px;" +
"color: rgb(220,224,230);" +
"padding: 0 8px;" +
"font-size: 11px;" );
_search.TextEdited += s =>
{
_filter = (s ?? "").Trim().ToLowerInvariant();
_tree?.SetFilter( _filter );
};
Layout.Add( _search );
// 8px gap between search and tree.
var spc = new Widget( this ) { FixedHeight = 8 };
spc.SetStyles( "background-color: transparent; border: none;" );
Layout.Add( spc );
// ScrollArea + plain Widget canvas — same shape as the Palette which
// scrolls correctly. SuiTreeView is no longer a Widget itself; it's a
// state holder that adds rows directly to this canvas.
var scroll = new ScrollArea( this );
SuiScrollStyle.ApplyTo( scroll );
_scrollContent = new Widget( scroll );
_scrollContent.SetStyles( "background-color: transparent; border: none;" );
_scrollContent.Layout = Layout.Column();
_scrollContent.Layout.Margin = 0;
_scrollContent.Layout.Spacing = 0;
scroll.Canvas = _scrollContent;
Layout.Add( scroll, 1 );
_tree = new SuiTreeView( _scrollContent );
_tree.OnElementSelected = el =>
{
_selected = el;
ElementSelected?.Invoke( el );
};
_tree.OnElementContextMenu = el => ShowContextMenuFor( el );
_tree.OnElementToggleVisibility = el =>
{
if ( el?.Flags == null ) return;
el.Flags.HiddenInDesigner = !el.Flags.HiddenInDesigner;
_tree.RebuildIfNeeded();
FlagsChanged?.Invoke();
};
_tree.OnElementToggleLock = el =>
{
if ( el?.Flags == null ) return;
el.Flags.Locked = !el.Flags.Locked;
_tree.RebuildIfNeeded();
FlagsChanged?.Invoke();
};
_tree.OnElementReparent = ( src, dst, idx ) => ReparentRequested?.Invoke( src, dst, idx );
_tree.OnElementRenamed = ( el, newName ) => RenameRequested?.Invoke( el, newName );
Refresh();
}
private Widget _scrollContent;
public void SetDocument( SuiDocument document )
{
_document = document;
_selected = null;
_tree?.SetDocument( document );
}
public void SetSelected( SuiElement element )
{
_selected = element;
_tree?.SetSelectedSet( _selectedIds );
}
public void SetSelectedSet( IReadOnlyCollection<SuiElement> elements )
{
_selectedIds = new HashSet<string>();
if ( elements != null )
{
foreach ( var e in elements )
{
if ( e?.Id != null ) _selectedIds.Add( e.Id );
}
}
_tree?.SetSelectedSet( _selectedIds );
}
public void Refresh() => _tree?.RebuildIfNeeded();
/// <summary>
/// Begin an inline rename on the currently selected element. Triggered by
/// F2 keyboard shortcut and the right-click → Rename / Edit menu → Rename
/// flows. Implementation lives in <see cref="SuiTreeView.BeginRenameRow"/>:
/// transforms the row's painted label into a focused LineEdit; commit on
/// Enter / blur fires <see cref="RenameRequested"/> with the new name.
/// </summary>
public void BeginRenameSelected()
{
if ( _selected == null ) return;
_tree?.BeginRenameRow( _selected );
}
private void ShowContextMenuFor( SuiElement element )
{
if ( element == null || _document == null ) return;
_selected = element;
ElementSelected?.Invoke( element );
var isContainer = IsContainerType( element.Type );
var canDelete = !string.IsNullOrEmpty( element.ParentId );
var siblingIdx = -1;
var siblingCount = 0;
if ( !string.IsNullOrEmpty( element.ParentId ) )
{
var parent = _document.GetElement( element.ParentId );
if ( parent != null )
{
siblingIdx = parent.Children.IndexOf( element.Id );
siblingCount = parent.Children.Count;
}
}
var m = new Menu( this );
var addLabel = isContainer ? "Add Child" : "Add Sibling";
var addTarget = isContainer ? element : (_document.GetElement( element.ParentId ) ?? element);
var addMenu = m.AddMenu( addLabel, "add" );
AddTypeOptions( addMenu, new[] { SuiElementType.Panel, SuiElementType.Text, SuiElementType.Image, SuiElementType.Button }, addTarget );
addMenu.AddSeparator();
AddTypeOptions( addMenu, new[] { SuiElementType.HorizontalBox, SuiElementType.VerticalBox, SuiElementType.Grid, SuiElementType.Overlay }, addTarget );
addMenu.AddSeparator();
AddTypeOptions( addMenu, new[] { SuiElementType.ProgressBar, SuiElementType.ScrollPanel, SuiElementType.InventoryGrid, SuiElementType.InventorySlot, SuiElementType.ItemIcon, SuiElementType.Tooltip, SuiElementType.Hotbar }, addTarget );
m.AddSeparator();
if ( element.Flags != null )
{
var visLabel = element.Flags.HiddenInDesigner ? "Show in designer" : "Hide in designer";
var visIcon = element.Flags.HiddenInDesigner ? "visibility" : "visibility_off";
m.AddOption( visLabel, visIcon, () =>
{
element.Flags.HiddenInDesigner = !element.Flags.HiddenInDesigner;
_tree.RebuildIfNeeded();
FlagsChanged?.Invoke();
} );
var lockLabel = element.Flags.Locked ? "Unlock" : "Lock";
var lockIcon = element.Flags.Locked ? "lock_open" : "lock";
m.AddOption( lockLabel, lockIcon, () =>
{
element.Flags.Locked = !element.Flags.Locked;
_tree.RebuildIfNeeded();
FlagsChanged?.Invoke();
} );
m.AddSeparator();
}
var rename = m.AddOption( "Rename", "edit", () => RenameRequested?.Invoke( element, null ) );
rename.Enabled = canDelete;
m.AddOption( "Duplicate", "content_copy", () => DuplicateRequested?.Invoke( element ) );
var moveUp = m.AddOption( "Move Up", "arrow_upward", () => MoveUpRequested?.Invoke( element ) );
moveUp.Enabled = siblingIdx > 0;
var moveDown = m.AddOption( "Move Down", "arrow_downward", () => MoveDownRequested?.Invoke( element ) );
moveDown.Enabled = siblingIdx >= 0 && siblingIdx < siblingCount - 1;
var del = m.AddOption( "Delete", "delete", () => DeleteRequested?.Invoke( element ) );
del.Enabled = canDelete;
m.OpenAtCursor( true );
}
private void AddTypeOptions( Menu menu, IEnumerable<SuiElementType> types, SuiElement target )
{
foreach ( var type in types )
{
var captured = type;
menu.AddOption( type.ToString(), IconForType( type ), () => AddChildRequested?.Invoke( target, captured ) );
}
}
internal static bool IsContainerType( SuiElementType type ) => type switch
{
SuiElementType.Canvas
or SuiElementType.Panel
or SuiElementType.Overlay
or SuiElementType.HorizontalBox
or SuiElementType.VerticalBox
or SuiElementType.Grid
or SuiElementType.ScrollPanel
or SuiElementType.InventoryGrid
or SuiElementType.Hotbar => true,
_ => false,
};
internal static string IconForType( SuiElementType type ) => type switch
{
SuiElementType.Canvas => "crop_free",
SuiElementType.Panel => "crop_square",
SuiElementType.Overlay => "layers",
SuiElementType.Text => "title",
SuiElementType.Image => "image",
SuiElementType.Button => "smart_button",
SuiElementType.HorizontalBox => "view_week",
SuiElementType.VerticalBox => "view_agenda",
SuiElementType.Grid => "grid_on",
SuiElementType.ScrollPanel => "swap_vert",
SuiElementType.ProgressBar => "linear_scale",
SuiElementType.InventoryGrid => "grid_view",
SuiElementType.InventorySlot => "check_box_outline_blank",
SuiElementType.ItemIcon => "category",
SuiElementType.Tooltip => "info",
SuiElementType.Hotbar => "view_carousel",
_ => "extension",
};
}
/// <summary>
/// Tree state + row builder. Not a Widget — it adds <see cref="SuiTreeRow"/>
/// instances directly into an external container (the ScrollArea's canvas).
/// This is the same shape Palette uses, which scrolls correctly because the
/// canvas's intrinsic size is derived from the child rows.
/// </summary>
public sealed class SuiTreeView
{
private readonly Widget _container;
private SuiDocument _document;
private string _filter = "";
// COLLAPSED ids — anything not in this set is expanded by default.
private readonly HashSet<string> _collapsed = new();
private HashSet<string> _selectedIds = new();
private readonly List<SuiTreeRow> _rows = new();
public Action<SuiElement> OnElementSelected;
public Action<SuiElement> OnElementContextMenu;
public Action<SuiElement> OnElementToggleVisibility;
public Action<SuiElement> OnElementToggleLock;
public Action<SuiElement, SuiElement, int> OnElementReparent;
public Action<SuiElement, string> OnElementRenamed;
/// <summary>
/// True while any row in the tree is currently in inline-rename mode.
/// Used to suppress destructive rebuilds while editing.
/// </summary>
public bool IsAnyRowEditing
{
get
{
foreach ( var r in _rows )
if ( r != null && r.IsValid() && r.IsEditing ) return true;
return false;
}
}
/// <summary>
/// Begin inline rename on the row matching <paramref name="el"/>. The row's
/// painted label is replaced by a focused LineEdit. Commits via Enter or
/// blur; cancels via Escape (handled inside the row). The committed value
/// flows through <see cref="OnElementRenamed"/>.
/// </summary>
public void BeginRenameRow( SuiElement el )
{
if ( el == null ) return;
foreach ( var r in _rows )
{
if ( r?.Element?.Id == el.Id )
{
r.BeginEdit( newName => OnElementRenamed?.Invoke( el, newName ) );
return;
}
}
}
public SuiTreeView( Widget container )
{
_container = container;
}
public void SetDocument( SuiDocument doc )
{
_document = doc;
_collapsed.Clear();
Rebuild();
}
public void SetFilter( string f )
{
_filter = f ?? "";
Rebuild();
}
public void SetSelectedSet( HashSet<string> ids )
{
_selectedIds = ids ?? new();
foreach ( var row in _rows )
{
row.IsSelected = _selectedIds.Contains( row.Element.Id );
row.Update();
}
}
public bool IsExpanded( string id ) => !_collapsed.Contains( id );
public void ToggleExpanded( string id )
{
if ( _collapsed.Contains( id ) ) _collapsed.Remove( id );
else _collapsed.Add( id );
Rebuild();
}
public void RebuildIfNeeded() => Rebuild();
private bool _isRebuilding;
private void Rebuild()
{
if ( _container == null || !_container.IsValid() ) return;
// Re-entrancy guard. If a row's inline-rename commit fires
// OnElementRenamed → controller mutates the document → DocumentChanged
// → Refresh → Rebuild — and we're already mid-Rebuild — bail.
if ( _isRebuilding ) return;
// Force-commit any in-flight inline rename before tearing down rows.
// If an external refresh (DocumentChanged from a non-rename action) hits
// while the user is mid-edit, capture the current text as their intent.
foreach ( var r in _rows )
{
if ( r != null && r.IsValid() && r.IsEditing )
r.CommitEdit();
}
_isRebuilding = true;
try
{
RebuildInternal();
}
finally
{
_isRebuilding = false;
}
}
private void RebuildInternal()
{
_container.Layout.Clear( true );
_rows.Clear();
if ( _document == null ) return;
var root = _document.GetRoot();
if ( root == null ) return;
var byId = new Dictionary<string, SuiElement>();
foreach ( var e in _document.Elements )
{
if ( !string.IsNullOrEmpty( e.Id ) ) byId[e.Id] = e;
}
// Search filter: show matches + their ancestors.
HashSet<string> visibleIds = null;
if ( !string.IsNullOrEmpty( _filter ) )
{
visibleIds = new HashSet<string>();
foreach ( var el in _document.Elements )
{
if ( el?.Name == null ) continue;
if ( !el.Name.ToLowerInvariant().Contains( _filter ) ) continue;
var current = el;
while ( current != null )
{
visibleIds.Add( current.Id );
if ( string.IsNullOrEmpty( current.ParentId ) ) break;
byId.TryGetValue( current.ParentId, out current );
}
}
}
Walk( root, byId, depth: 0, ancestorIsLast: Array.Empty<bool>(), visibleIds );
// Stretch cell at the bottom keeps the rows packed at the top when
// the canvas is taller than the row stack (e.g. after collapsing a
// branch). Without it Qt distributes the slack across rows.
_container.Layout.AddStretchCell();
_container.Update();
}
private void Walk( SuiElement el, Dictionary<string, SuiElement> byId, int depth, bool[] ancestorIsLast, HashSet<string> visibleIds )
{
if ( el == null ) return;
if ( visibleIds != null && !visibleIds.Contains( el.Id ) ) return;
bool hasChildren = el.Children != null && el.Children.Count > 0;
bool expanded = !_collapsed.Contains( el.Id );
var row = new SuiTreeRow( this, el, depth, ancestorIsLast, hasChildren, expanded );
row.Parent = _container;
row.IsSelected = _selectedIds.Contains( el.Id );
_rows.Add( row );
_container.Layout.Add( row );
if ( hasChildren && expanded )
{
for ( int i = 0; i < el.Children.Count; i++ )
{
var childId = el.Children[i];
if ( !byId.TryGetValue( childId, out var child ) ) continue;
bool isLastChild = (i == el.Children.Count - 1);
var childAncestors = new bool[ancestorIsLast.Length + 1];
Array.Copy( ancestorIsLast, childAncestors, ancestorIsLast.Length );
childAncestors[ancestorIsLast.Length] = isLastChild;
Walk( child, byId, depth + 1, childAncestors, visibleIds );
}
}
}
internal void OnRowClicked( SuiTreeRow row )
{
OnElementSelected?.Invoke( row.Element );
}
internal void OnRowContextMenu( SuiTreeRow row )
{
OnElementContextMenu?.Invoke( row.Element );
}
internal void OnRowToggleVisibility( SuiTreeRow row )
{
OnElementToggleVisibility?.Invoke( row.Element );
}
internal void OnRowToggleLock( SuiTreeRow row )
{
OnElementToggleLock?.Invoke( row.Element );
}
internal void OnRowToggleExpanded( SuiTreeRow row )
{
ToggleExpanded( row.Element.Id );
}
internal void OnRowDrop( SuiElement source, SuiElement target, float relativeY, float rowHeight )
{
if ( source == null || target == null || source.Id == target.Id ) return;
if ( _document == null ) return;
if ( string.IsNullOrEmpty( source.ParentId ) ) return;
// Refuse cycle: target must not be a descendant of source.
var safety = 1024;
var cur = target;
while ( cur != null && --safety > 0 )
{
if ( cur.Id == source.Id ) return;
if ( string.IsNullOrEmpty( cur.ParentId ) ) break;
cur = _document.GetElement( cur.ParentId );
}
// Drop edge — top 5px = sibling-above, bottom 5px = sibling-below,
// middle = child-of-target (if container) or sibling-after-target.
const float edgeThreshold = 5f;
bool topEdge = relativeY < edgeThreshold;
bool bottomEdge = relativeY > rowHeight - edgeThreshold;
if ( topEdge || bottomEdge )
{
var parent = _document.GetElement( target.ParentId );
if ( parent == null ) return;
var idx = parent.Children.IndexOf( target.Id );
if ( idx < 0 ) idx = parent.Children.Count;
if ( bottomEdge ) idx += 1;
OnElementReparent?.Invoke( source, parent, idx );
return;
}
// Middle drop — child of target if container, else sibling-after.
if ( SuiHierarchyWidget.IsContainerType( target.Type ) )
{
OnElementReparent?.Invoke( source, target, target.Children.Count );
return;
}
var leafParent = _document.GetElement( target.ParentId );
if ( leafParent == null ) return;
var siblingIdx = leafParent.Children.IndexOf( target.Id );
siblingIdx = siblingIdx < 0 ? leafParent.Children.Count : siblingIdx + 1;
OnElementReparent?.Invoke( source, leafParent, siblingIdx );
}
}
/// <summary>
/// Single row in the tree — paint-only widget rendering tree-lines + chevron
/// + type icon + element name + right-side eye / lock toggle icons.
/// </summary>
public sealed class SuiTreeRow : Widget
{
private const int IndentPx = 18;
private const int RowHeight = 24;
public SuiElement Element { get; }
public int Depth { get; }
public bool[] AncestorIsLast { get; }
public bool HasChildren { get; }
public bool IsExpandedNow { get; set; }
public bool IsSelected { get; set; }
private readonly SuiTreeView _tree;
private bool _hover;
private Rect _chevronRect;
private Rect _eyeRect;
private Rect _lockRect;
// Inline rename state. When _editor is non-null the row's painted label
// is suppressed and the LineEdit overlays the label region via Layout.
private LineEdit _editor;
private Action<string> _onEditCommit;
private bool _isEditing;
public bool IsEditing => _isEditing;
public SuiTreeRow( SuiTreeView tree, SuiElement el, int depth, bool[] ancestorIsLast, bool hasChildren, bool isExpanded )
: base( null )
{
_tree = tree;
Element = el;
Depth = depth;
AncestorIsLast = ancestorIsLast;
HasChildren = hasChildren;
IsExpandedNow = isExpanded;
FixedHeight = RowHeight;
Cursor = CursorShape.Finger;
IsDraggable = !string.IsNullOrEmpty( el.ParentId );
AcceptDrops = true;
SetStyles( "background-color: transparent; border: none;" );
// Layout exists from the start but is unused until BeginEdit. When the
// row is in normal (paint-only) mode, the empty Layout doesn't affect
// OnPaint output. When editing, the Layout positions a LineEdit at the
// label region via per-edit Margin (configured in BeginEdit below).
Layout = Layout.Row();
Layout.Margin = 0;
}
/// <summary>
/// Replace the painted label with a focused LineEdit. Commit on Enter or
/// blur fires <paramref name="onCommit"/> with the trimmed text (only if
/// non-empty and different from the current Element.Name). Cancel by
/// committing an unchanged value (or via external rebuild).
/// </summary>
public void BeginEdit( Action<string> onCommit )
{
if ( _isEditing ) return;
_onEditCommit = onCommit;
_isEditing = true;
// Compute the label region's left offset to mirror what OnPaint paints:
// indent (Depth * IndentPx + 2)
// + chevron column (16)
// + type icon column (20)
// And the right offset to leave room for eye + lock icons:
// rightPad (12) + lock (14) + gap (6) + eye (14) + small gap (4)
var leftPad = Depth * IndentPx + 2 + 16 + 20;
var rightPad = 12 + 14 + 6 + 14 + 4;
Layout.Margin = new Sandbox.UI.Margin( leftPad, 2, rightPad, 2 );
_editor = new LineEdit( this );
_editor.Text = Element.Name ?? "";
_editor.FixedHeight = RowHeight - 4;
_editor.SetStyles( "background-color: rgba(255,255,255,0.10); color: rgb(235,238,242); border: 1px solid rgba(59,130,246,0.85); border-radius: 2px; padding: 0 4px; font-size: 11px;" );
_editor.EditingFinished += OnEditorEditingFinished;
Layout.Add( _editor, 1 );
_editor.Focus();
_editor.SelectAll();
Update();
}
private void OnEditorEditingFinished()
{
CommitEdit();
}
/// <summary>
/// Commit the current editor text and tear down inline edit state. Safe
/// to call when not editing (no-op). Invoked by Enter / blur / external
/// rebuild — see <see cref="SuiTreeView.Rebuild"/>.
/// </summary>
public void CommitEdit()
{
if ( !_isEditing ) return;
var newName = _editor?.Text?.Trim() ?? "";
var cb = _onEditCommit;
DestroyEditor();
if ( cb != null && !string.IsNullOrEmpty( newName ) && newName != Element.Name )
{
cb( newName );
}
Update();
}
/// <summary>
/// Abandon the inline edit without firing the commit callback. Used when
/// external rebuilds need to wipe state. (User-facing cancel via Escape
/// is not yet implemented in V1.0.1; Ctrl+Z undoes a committed rename.)
/// </summary>
public void CancelEdit()
{
if ( !_isEditing ) return;
DestroyEditor();
Update();
}
private void DestroyEditor()
{
if ( _editor != null && _editor.IsValid() )
{
_editor.EditingFinished -= OnEditorEditingFinished;
}
Layout.Clear( true );
Layout.Margin = 0;
_editor = null;
_onEditCommit = null;
_isEditing = false;
}
protected override void OnPaint()
{
var rect = LocalRect;
// Selection / hover background.
if ( IsSelected )
{
Paint.SetBrushAndPen( new Color( 15 / 255f, 63 / 255f, 121 / 255f, 0.55f ) );
Paint.DrawRect( rect );
}
else if ( _hover )
{
Paint.SetBrushAndPen( Color.White.WithAlpha( 0.04f ) );
Paint.DrawRect( rect );
}
Paint.SetDefaultFont( 11 );
// Tree lines for ancestors that still have continuations below.
var lineColor = Color.White.WithAlpha( 0.10f );
Paint.SetPen( lineColor, 1f );
for ( int d = 0; d < AncestorIsLast.Length - 1; d++ )
{
if ( !AncestorIsLast[d] )
{
float lx = (d + 1) * IndentPx - IndentPx / 2f + 2;
Paint.DrawLine( new Vector2( lx, 0 ), new Vector2( lx, Height ) );
}
}
// Own L-shape connector (only if not root depth).
if ( Depth > 0 )
{
float xVert = Depth * IndentPx - IndentPx / 2f + 2;
float yMid = Height / 2f;
bool selfIsLast = AncestorIsLast.Length > 0 && AncestorIsLast[AncestorIsLast.Length - 1];
// Vertical from top to mid (or full height if not last).
Paint.DrawLine( new Vector2( xVert, 0 ), new Vector2( xVert, selfIsLast ? yMid : Height ) );
// Horizontal from xVert to chevron/icon area.
Paint.DrawLine( new Vector2( xVert, yMid ), new Vector2( xVert + IndentPx / 2f - 1, yMid ) );
}
float x = Depth * IndentPx + 2;
// Chevron — only painted when has children.
if ( HasChildren )
{
_chevronRect = new Rect( x, (Height - 14) / 2f, 14, 14 );
var chevColor = new Color( 165 / 255f, 172 / 255f, 182 / 255f );
Paint.SetPen( chevColor, 1.5f );
float cx = x + 7;
float cy = Height / 2f;
if ( IsExpandedNow )
{
// ▾ down
Paint.DrawLine( new Vector2( cx - 3, cy - 1 ), new Vector2( cx, cy + 2 ) );
Paint.DrawLine( new Vector2( cx, cy + 2 ), new Vector2( cx + 3, cy - 1 ) );
}
else
{
// ▸ right
Paint.DrawLine( new Vector2( cx - 1, cy - 3 ), new Vector2( cx + 2, cy ) );
Paint.DrawLine( new Vector2( cx + 2, cy ), new Vector2( cx - 1, cy + 3 ) );
}
}
else
{
_chevronRect = new Rect( 0, 0, 0, 0 );
}
x += 16;
// Type icon.
var textColor = IsSelected ? Color.White : new Color( 220 / 255f, 224 / 255f, 230 / 255f );
Paint.SetPen( textColor );
Paint.DrawIcon( new Rect( x, (Height - 14) / 2f, 14, 14 ), SuiHierarchyWidget.IconForType( Element.Type ), 14 );
x += 20;
// Eye + Lock — right-aligned with 12px padding from the right edge so
// they don't collide with the panel border / scrollbar.
var hidden = Element.Flags?.HiddenInDesigner == true;
var locked = Element.Flags?.Locked == true;
const int rightPad = 12;
_lockRect = new Rect( Width - rightPad - 14, (Height - 14) / 2f, 14, 14 );
_eyeRect = new Rect( _lockRect.Left - 6 - 14, (Height - 14) / 2f, 14, 14 );
// Eye: bright white when visible, dim grey when hidden + thin diagonal line.
var eyeColor = hidden
? new Color( 130 / 255f, 134 / 255f, 140 / 255f )
: new Color( 235 / 255f, 238 / 255f, 242 / 255f );
Paint.SetPen( eyeColor );
Paint.DrawIcon( _eyeRect, "visibility", 14 );
if ( hidden )
{
// Soft diagonal slash across the eye to indicate "off".
Paint.SetPen( eyeColor, 1.2f );
Paint.DrawLine(
new Vector2( _eyeRect.Left + 2, _eyeRect.Bottom - 3 ),
new Vector2( _eyeRect.Right - 2, _eyeRect.Top + 3 ) );
}
// Lock: amber when locked, dim grey otherwise.
var lockColor = locked
? new Color( 235 / 255f, 178 / 255f, 96 / 255f )
: new Color( 130 / 255f, 134 / 255f, 140 / 255f );
Paint.SetPen( lockColor );
Paint.DrawIcon( _lockRect, locked ? "lock" : "lock_open", 14 );
// Name label — leaves room for the right icons. Suppressed while the
// inline-rename LineEdit overlay is active; the editor paints itself.
if ( !_isEditing )
{
Paint.SetPen( textColor );
var labelRect = new Rect( x, 0, _eyeRect.Left - x - 4, Height );
Paint.DrawText( labelRect, Element.Name ?? "(unnamed)", TextFlag.LeftCenter );
}
}
protected override void OnMouseEnter() { _hover = true; Update(); }
protected override void OnMouseLeave() { _hover = false; Update(); }
protected override void OnMousePress( MouseEvent e )
{
// When the inline-rename LineEdit is active, let it handle clicks
// within itself. Outside-clicks blur the editor → EditingFinished
// fires → row commits. Either way, the row's normal click behavior
// (select / toggle chevron / toggle eye-lock / drag) is suppressed.
if ( _isEditing ) return;
// Each callback may rebuild the tree (Refresh) which destroys this
// row mid-handler. We early-return immediately after dispatch so no
// further code touches the now-zombie `this`. Qt's mouse pipeline
// tolerates that, but anything we'd call after (Update, field access)
// would crash with "QWidget was null".
if ( e.LeftMouseButton )
{
if ( HasChildren && _chevronRect.IsInside( e.LocalPosition ) )
{
_tree.OnRowToggleExpanded( this );
return;
}
if ( _eyeRect.IsInside( e.LocalPosition ) )
{
_tree.OnRowToggleVisibility( this );
return;
}
if ( _lockRect.IsInside( e.LocalPosition ) )
{
_tree.OnRowToggleLock( this );
return;
}
_tree.OnRowClicked( this );
return;
}
if ( e.RightMouseButton )
{
_tree.OnRowContextMenu( this );
return;
}
}
protected override void OnDragStart()
{
if ( !IsDraggable ) return;
if ( _isEditing ) return; // suppress drag-to-reparent while inline rename is active
var drag = new Drag( this );
drag.Data.Object = Element;
drag.Execute();
}
public override void OnDragHover( DragEvent ev )
{
ev.Action = DropAction.Copy;
}
public override void OnDragDrop( DragEvent ev )
{
if ( ev.Data.Object is SuiElement source )
{
_tree.OnRowDrop( source, Element, ev.LocalPosition.y, Height );
}
}
}