Files
mrgameeng/src/MrGameEng.UI/EcsInspectorUi.cs
T
Leonid PershinandClaude Opus 4.8 44019a706e
CI / build-test (push) Successful in 1m7s
Inspector: keep field editing usable, clarify editable fields
Split the inspector's revision into a list revision and a detail
revision. The periodic refresh now only bumps the list revision, and only
when the entity count actually changes, so the field-editor TextBox in the
details pane is no longer recreated (losing focus) twice a second while you
type. Stats and the performance tab update as plain text every frame
without rebuilding widgets.

Editable fields now render with a background, border and padding so they
read as inputs; read-only values and enum hints are muted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 13:05:42 +03:00

315 lines
10 KiB
C#

using System;
using Microsoft.Xna.Framework;
using MrGameEng.Graphics;
using Myra.Graphics2D;
using Myra.Graphics2D.Brushes;
using Myra.Graphics2D.UI;
namespace MrGameEng.Inspector;
/// <summary>
/// Myra overlay of the ECS inspector — a Chrome-DevTools-style side panel. The top bar switches the
/// Entities/Performance tabs and arms world-pick; the Entities tab shows archetypes → entities →
/// the selected entity's components and editable fields; the Performance tab shows renderer
/// timings. A thin border tracks the selected entity in the world each frame. Rebuilds lazily on
/// <see cref="EcsInspector.ListRevision"/>/<see cref="EcsInspector.DetailRevision"/> changes.
/// </summary>
internal sealed class EcsInspectorUi
{
private const int PanelWidth = 540;
private static readonly Color Accent = new(120, 200, 255);
private readonly EcsInspector _inspector;
private readonly Renderer2D _renderer;
private readonly Desktop _desktop;
private readonly VerticalStackPanel _archetypeList = Stack();
private readonly VerticalStackPanel _entityList = Stack();
private readonly VerticalStackPanel _detailList = Stack();
private readonly Label _stats = new();
private readonly Label _perf = new() { Wrap = false };
private readonly Label _pickLabel = new() { Text = "Pick" };
private readonly Widget _entitiesSection;
private readonly Panel _highlight;
private readonly VerticalStackPanel _panel;
private int _lastListRevision = -1;
private int _lastDetailRevision = -1;
public EcsInspectorUi(EcsInspector inspector, Renderer2D renderer)
{
_inspector = inspector;
_renderer = renderer;
var tabEntities = MakeButton("Entities", () => _inspector.SetTab(InspectorTab.Entities));
var tabPerf = MakeButton("Performance", () => _inspector.SetTab(InspectorTab.Performance));
var pick = new Button { Content = _pickLabel };
pick.Click += (_, _) => _inspector.ArmPick(!_inspector.PickArmed);
var search = new TextBox { HintText = "filter components", Width = 160 };
search.TextChanged += (_, _) => _inspector.SetSearch(search.Text ?? "");
var bar = new HorizontalStackPanel { Spacing = 6 };
bar.Widgets.Add(tabEntities);
bar.Widgets.Add(tabPerf);
bar.Widgets.Add(pick);
bar.Widgets.Add(search);
_entitiesSection = BuildEntitiesSection();
_panel = new VerticalStackPanel
{
Spacing = 6,
Padding = new Thickness(8),
Width = PanelWidth,
HorizontalAlignment = HorizontalAlignment.Right,
VerticalAlignment = VerticalAlignment.Stretch,
Background = new SolidBrush(new Color(8, 10, 14, 235)),
};
_panel.Widgets.Add(bar);
_panel.Widgets.Add(_stats);
_panel.Widgets.Add(new HorizontalSeparator());
_panel.Widgets.Add(_entitiesSection);
_panel.Widgets.Add(_perf);
_highlight = new Panel
{
Border = new SolidBrush(Accent),
BorderThickness = new Thickness(2),
Visible = false,
};
var root = new Panel();
root.Widgets.Add(_highlight);
root.Widgets.Add(_panel);
_desktop = new Desktop { Root = root };
}
public void Render()
{
if (!_inspector.IsOpen)
{
return;
}
// Stats and perf are cheap text updates — refresh every frame so they stay live.
var perf = _inspector.Performance();
UpdateStats(perf);
if (_inspector.ActiveTab == InspectorTab.Performance)
{
UpdatePerf(perf);
}
if (_inspector.ListRevision != _lastListRevision)
{
_lastListRevision = _inspector.ListRevision;
RebuildLists();
}
if (_inspector.DetailRevision != _lastDetailRevision)
{
_lastDetailRevision = _inspector.DetailRevision;
RebuildDetails();
}
UpdateHighlight();
_desktop.Render();
}
/// <summary>True when the physical screen point is over the panel (so a pick click is ignored).</summary>
public bool IsOverPanel(Point screen) => _panel.Bounds.Contains(screen);
private Widget BuildEntitiesSection()
{
var section = new VerticalStackPanel { Spacing = 4 };
section.Widgets.Add(Header("Archetypes"));
section.Widgets.Add(new ScrollViewer { Content = _archetypeList, Height = 150 });
section.Widgets.Add(Header("Entities"));
section.Widgets.Add(new ScrollViewer { Content = _entityList, Height = 150 });
section.Widgets.Add(Header("Selected"));
section.Widgets.Add(new ScrollViewer { Content = _detailList, Height = 320 });
return section;
}
private void RebuildLists()
{
var entitiesTab = _inspector.ActiveTab == InspectorTab.Entities;
_entitiesSection.Visible = entitiesTab;
_perf.Visible = !entitiesTab;
_pickLabel.Text = _inspector.PickArmed ? "Pick*" : "Pick";
if (entitiesTab)
{
RebuildArchetypes();
RebuildEntities();
}
}
private void UpdateStats(in PerfSnapshot perf)
{
_stats.Text =
$"entities {perf.Entities} | archetypes {perf.Archetypes} | {perf.Fps} FPS"
+ (_inspector.PickArmed ? " | click an entity…" : "");
}
private void UpdatePerf(in PerfSnapshot perf)
{
_perf.Text =
$"FPS {perf.Fps}\n"
+ $"submit {perf.SubmitMs:0.00} ms\n"
+ $"sort {perf.SortMs:0.00} ms\n"
+ $"build {perf.BuildMs:0.00} ms\n"
+ $"upload {perf.UploadMs:0.00} ms\n"
+ $"draw {perf.DrawMs:0.00} ms\n"
+ $"draw calls {perf.DrawCalls}\n"
+ $"sprites {perf.SubmittedSprites} drawn, {perf.CulledSprites} culled\n"
+ $"entities {perf.Entities}";
}
private void RebuildArchetypes()
{
_archetypeList.Widgets.Clear();
foreach (var row in _inspector.Archetypes())
{
var index = row.Index;
var button = MakeButton(
$"[{row.Count}] {row.Signature}",
() => _inspector.SelectArchetype(index)
);
if (index == _inspector.SelectedArchetype)
{
Highlight(button);
}
_archetypeList.Widgets.Add(button);
}
}
private void RebuildEntities()
{
_entityList.Widgets.Clear();
if (_inspector.SelectedArchetype < 0)
{
return;
}
foreach (var id in _inspector.EntitiesOf(_inspector.SelectedArchetype))
{
var entityId = id;
var button = MakeButton($"entity {id}", () => _inspector.SelectEntity(entityId));
if (entityId == _inspector.SelectedEntityId)
{
Highlight(button);
}
_entityList.Widgets.Add(button);
}
}
private void RebuildDetails()
{
_detailList.Widgets.Clear();
if (_inspector.SelectedEntityId < 0)
{
return;
}
foreach (var component in _inspector.Inspect(_inspector.SelectedEntityId))
{
_detailList.Widgets.Add(new Label { Text = component.TypeName, TextColor = Accent });
foreach (var field in component.Fields)
{
_detailList.Widgets.Add(BuildFieldRow(component.Type, field));
}
}
}
private Widget BuildFieldRow(Type componentType, FieldRow field)
{
var row = new HorizontalStackPanel { Spacing = 8 };
row.Widgets.Add(
new Label
{
Text = field.Name,
Width = 140,
TextColor = new Color(170, 178, 190),
}
);
if (!field.Editable)
{
row.Widgets.Add(new Label { Text = field.Value, TextColor = new Color(150, 156, 168) });
return row;
}
var editor = new TextBox
{
Text = field.Value,
Width = 150,
Padding = new Thickness(4, 2),
Background = new SolidBrush(new Color(28, 33, 42)),
Border = new SolidBrush(new Color(70, 82, 100)),
BorderThickness = new Thickness(1),
};
var entityId = _inspector.SelectedEntityId;
editor.KeyDown += (_, args) =>
{
if (args.Data == Microsoft.Xna.Framework.Input.Keys.Enter)
{
_inspector.SetField(entityId, componentType, field.Name, editor.Text ?? "");
}
};
row.Widgets.Add(editor);
if (field.Kind == FieldKind.Enum && field.EnumOptions is { Length: > 0 })
{
row.Widgets.Add(
new Label
{
Text = string.Join(" / ", field.EnumOptions),
TextColor = new Color(120, 128, 140),
}
);
}
return row;
}
private void UpdateHighlight()
{
if (!_inspector.TryGetSelectedBounds(out var center, out var radius))
{
_highlight.Visible = false;
return;
}
var screenCenter = _renderer.WorldToScreen(center);
var edge = _renderer.WorldToScreen(center + new Vector2(radius, 0f));
var screenRadius = MathF.Max(4f, MathF.Abs(edge.X - screenCenter.X));
_highlight.Visible = true;
_highlight.Left = (int)(screenCenter.X - screenRadius);
_highlight.Top = (int)(screenCenter.Y - screenRadius);
_highlight.Width = (int)(screenRadius * 2f);
_highlight.Height = (int)(screenRadius * 2f);
}
private static void Highlight(Button button)
{
if (button.Content is Label label)
{
label.TextColor = Accent;
}
}
private static VerticalStackPanel Stack() => new() { Spacing = 2 };
private static Label Header(string text) =>
new() { Text = text, TextColor = new Color(150, 160, 175) };
private static Button MakeButton(string text, Action onClick)
{
var button = new Button { Content = new Label { Text = text } };
button.Click += (_, _) => onClick();
return button;
}
}