Code/ImXGUI/ImXGUI.cs
using Sandbox;
using Sandbox.UI;
using System;
using System.Collections.Generic;
using System.Linq;
namespace XGUI.ImmediateMode;
public enum IMXGUIWindowFlags
{
None = 0,
NoTitleBar = 1 << 0,
NoResize = 1 << 1,
NoMove = 1 << 2,
NoScrollbar = 1 << 3,
NoScrollWithMouse = 1 << 4,
NoCollapse = 1 << 5,
AlwaysAutoResize = 1 << 6,
NoSavedSettings = 1 << 7,
NoInputs = 1 << 8,
MenuBar = 1 << 9,
HorizontalScrollbar = 1 << 10,
NoFocusOnAppearing = 1 << 11,
NoBringToFrontOnFocus = 1 << 12,
AlwaysVerticalScrollbar = 1 << 13,
AlwaysHorizontalScrollbar = 1 << 14,
AlwaysUseWindowPadding = 1 << 15,
NoNavInputs = 1 << 16,
NoNavFocus = 1 << 17,
UnsavedDocument = 1 << 18,
NoNav = NoNavInputs | NoNavFocus
}
/// <summary>
/// Stores state for UI elements between frames
/// </summary>
public class IMXGUIState
{
// Widget state
public Dictionary<string, object> Values { get; set; } = new();
public bool Changed { get; set; }
}
/// <summary>
/// ImGui Clone for s&box using Razor UI components.
/// </summary>
public partial class ImXGUI // Todo, partial to move things out into seperate files.
{
public static string CurrentStyle = "/XGUI/DefaultStyles/OliveGreen.scss";
// --- Global State ---
private static Dictionary<string, Window> _windows = new(); // All windows that exist
private static Dictionary<string, List<Panel>> _windowElements = new(); // Elements per window
private static Dictionary<string, Dictionary<string, IMXGUIState>> _elementState = new(); // State per element per window
private static bool _initialized = false;
private static Panel _rootPanel;
// --- Context-Specific State ---
private static string _currentContext = null; // Identifies the currently active frame context ("Update", "FixedUpdate", etc.)
private static Scene _currentScene = null; // Identifies the currently active frame context ("Update", "FixedUpdate", etc.)
private static Dictionary<string, HashSet<string>> _activeWindowsPerContext = new(); // Windows active *this frame* for each context
private static Dictionary<string, Dictionary<string, int>> _elementCountersPerContext = new(); // Element counters per window *within each context*
// --- Current Processing State (scoped by _currentContext) ---
private static Window _currentWindow;
private static Panel _currentPanel;
private static string _currentWindowId;
private static string _idStack;
// Initialize the system
public static bool IsInitialized() => _initialized && _rootPanel != null && _rootPanel.IsValid;
public static void Initialize()
{
if ( IsInitialized() ) return;
if ( _rootPanel == null )
{
return;
}
//_rootPanel = XGUIRootPanel.Current;
_windows.Clear();
_windowElements.Clear();
_elementState.Clear();
_activeWindowsPerContext.Clear();
_elementCountersPerContext.Clear();
_initialized = true;
Log.Info( "ImXGUI Initialized." );
}
// Start a new frame for a specific context
public static void NewFrame( string context, Scene scene = null )
{
if ( scene == null ) scene = Game.ActiveScene;
_currentScene = scene;
_rootPanel = scene.GetSystem<XGUISystem>()?.Panel;
if ( string.IsNullOrWhiteSpace( context ) )
{
Log.Error( "ImXGUI.NewFrame: Context cannot be null or empty." );
return;
}
if ( !IsInitialized() ) Initialize();
if ( !IsInitialized() ) return; // Check again if Initialize failed
_currentContext = context;
_idStack = ""; // Reset ID stack for the new frame context
// Ensure context exists in tracking dictionaries
if ( !_activeWindowsPerContext.ContainsKey( context ) )
{
_activeWindowsPerContext[context] = new HashSet<string>();
}
else
{
// Clear active windows *only for this context*
_activeWindowsPerContext[context].Clear();
}
if ( !_elementCountersPerContext.ContainsKey( context ) )
{
_elementCountersPerContext[context] = new Dictionary<string, int>();
}
// else: Don't clear counters here, they get reset in Begin() per window
}
// Begin a window (operates within the _currentContext)
public static bool Begin( string title, ref bool open, IMXGUIWindowFlags flags = IMXGUIWindowFlags.None )
{
if ( _currentContext == null )
{
Log.Error( $"ImXGUI.Begin('{title}'): Must be called between NewFrame(context) and EndFrame(context)." );
return false;
}
if ( !open ) return false;
if ( !IsInitialized() ) Initialize();
if ( !IsInitialized() ) return false;
_currentWindowId = title;
_activeWindowsPerContext[_currentContext].Add( title ); // Add to the current context's active set
// Ensure element counter dictionary exists for this context
if ( !_elementCountersPerContext.ContainsKey( _currentContext ) )
{
_elementCountersPerContext[_currentContext] = new Dictionary<string, int>();
}
// Check if window exists and is valid globally
if ( _windows.TryGetValue( title, out _currentWindow ) )
{
if ( !_currentWindow.IsValid || _currentWindow.Parent == null )
{
// Clean up if invalid (might happen if deleted externally)
CleanupWindowReferences( title );
_currentWindow = null;
}
}
// Create new window if needed (global creation)
if ( _currentWindow == null )
{
_currentWindow = new Window();
_currentWindow.Title = title;
ApplyWindowFlags( _currentWindow, flags );
_currentWindow.StyleSheet.Load( CurrentStyle );
_rootPanel.AddChild( _currentWindow );
_windows[title] = _currentWindow; // Add to global list
// Setup content panel
_currentPanel = new Panel();
_currentPanel.ElementName = $"{title}_ContentPanel"; // Give it a name for debugging
_currentPanel.SetClass( "window-content-panel", true ); // Add a class for styling
_currentPanel.Style.FlexDirection = FlexDirection.Column;
_currentPanel.Style.OverflowY = OverflowMode.Scroll; // Default scroll
_currentPanel.Style.FlexGrow = 1;
_currentWindow.AddChild( _currentPanel );
// Initialize global tracking collections for this window
_windowElements[title] = new List<Panel>();
_elementState[title] = new Dictionary<string, IMXGUIState>();
_currentWindow.FocusWindow();
}
else // Window already exists
{
_currentWindow = _windows[title];
// Find the content panel (assuming it's the main panel that's not titlebar elements)
_currentPanel = _currentWindow.Children.FirstOrDefault( x =>
x is Panel && !(x is Label || x is Button || x.HasClass( "titlebar" )) ) as Panel;
if ( _currentPanel == null )
{
Log.Error( $"ImXGUI.Begin('{title}'): Could not find content panel for existing window." );
// Attempt recovery: Create a new one? Or just fail? Failing is safer.
return false;
}
// Ensure global tracking lists exist (might have been cleared?)
if ( !_windowElements.ContainsKey( title ) )
_windowElements[title] = new List<Panel>();
if ( !_elementState.ContainsKey( title ) )
_elementState[title] = new Dictionary<string, IMXGUIState>();
// Hide all existing elements *associated with this window globally*.
// They will be re-shown if GetOrCreateElement is called for them in this frame.
foreach ( var element in _windowElements[title] )
{
if ( element.IsValid() ) // Check validity before accessing style
{
element.Style.Display = DisplayMode.None;
}
}
}
// Reset element counter *for this window within the current context*
_elementCountersPerContext[_currentContext][title] = 0;
_idStack = title; // Set ID stack root for this window
return true;
}
/// <summary>
/// Apply window flags
/// </summary>
/// <param name="window"></param>
/// <param name="flags"></param>
private static void ApplyWindowFlags( Window window, IMXGUIWindowFlags flags )
{
if ( (flags & IMXGUIWindowFlags.NoTitleBar) != 0 )
window.TitleBar.Style.Display = DisplayMode.None;
if ( (flags & IMXGUIWindowFlags.NoResize) != 0 )
window.IsResizable = false;
if ( (flags & IMXGUIWindowFlags.NoMove) != 0 )
window.IsDraggable = false;
if ( (flags & IMXGUIWindowFlags.NoInputs) != 0 )
window.Style.PointerEvents = PointerEvents.None;
window.Style.Width = 320;
window.Style.Height = 300;
}
// End a window (operates within the _currentContext)
public static void End()
{
if ( _currentContext == null )
{
Log.Error( "ImXGUI.End(): Must be called between NewFrame(context) and EndFrame(context)." );
return;
}
if ( _currentWindow == null || !_windowElements.ContainsKey( _currentWindowId ) )
{
// Likely called End() without a matching Begin() or window got deleted improperly
Log.Warning( "ImXGUI.End(): No current window or element list found. Skipping cleanup for this frame." );
_currentWindow = null;
_currentPanel = null;
_idStack = "";
_currentWindowId = null;
return;
}
// Clean up unused elements *within this window* based on their display state
List<Panel> elementsToRemove = new List<Panel>();
foreach ( var element in _windowElements[_currentWindowId] )
{
// Check IsValid before accessing properties
if ( !element.IsValid() || element.Style.Display == DisplayMode.None )
{
elementsToRemove.Add( element );
}
}
foreach ( var element in elementsToRemove )
{
if ( element.IsValid() ) element.Delete(); // Delete the panel itself
_windowElements[_currentWindowId].Remove( element ); // Remove from tracking
// TODO: Optionally remove state from _elementState here if elements are truly temporary?
// For now, state persists even if element is removed temporarily.
}
// Reset current window state for the *next* Begin() call in this context
_currentWindow = null;
_currentPanel = null;
_idStack = "";
_currentWindowId = null;
}
/// <summary>
/// Push ID to stack (for hierarchical controls)
/// </summary>
/// <param name="id"></param>
public static void PushId( string id )
{
_idStack = string.IsNullOrEmpty( _idStack ) ? id : $"{_idStack}/{id}";
}
/// <summary>
/// Pop ID from stack
/// </summary>
public static void PopId()
{
int lastSlash = _idStack.LastIndexOf( '/' );
if ( lastSlash >= 0 )
_idStack = _idStack.Substring( 0, lastSlash );
else
_idStack = "";
}
// Generate a unique ID (uses _currentContext)
private static string GenerateId<T>( string label = null ) where T : Panel
{
if ( _currentContext == null || _currentWindowId == null )
{
Log.Error( "ImXGUI.GenerateId: Cannot generate ID outside of a Begin/End block or NewFrame/EndFrame context." );
return $"ERROR_NO_CONTEXT_{Guid.NewGuid()}"; // Return something unique but indicative of error
}
string typeKey = typeof( T ).Name;
// Use _idStack which is reset per-window in Begin()
string baseId = string.IsNullOrEmpty( _idStack ) ? typeKey : $"{_idStack}/{typeKey}";
// Get the element counter dictionary for the current context
var contextCounters = _elementCountersPerContext[_currentContext];
// Get the counter for the current window *within the current context*
if ( !contextCounters.TryGetValue( _currentWindowId, out int counter ) )
{
counter = 0; // Should have been initialized in Begin, but safety first
}
// Generate the unique ID (Context/Window/ElementPath/Counter)
// Adding context helps debugging but might make IDs very long. Let's omit for now.
// string uniqueId = $"{_currentContext}/{_currentWindowId}/{baseId}_{counter}";
string uniqueId = $"{_currentWindowId}/{baseId}_{counter}"; // Keep it simpler
// Increment the counter for the current window in the current context
contextCounters[_currentWindowId] = counter + 1;
return uniqueId;
}
// Get or create state (operates on global state, keyed by window/element ID)
private static IMXGUIState GetState( string elementId ) // Renamed param for clarity
{
// Element state is tied to the window ID, which is global
if ( _currentWindowId == null ) return null; // Safety check
// Ensure the window dictionary exists
if ( !_elementState.ContainsKey( _currentWindowId ) )
{
_elementState[_currentWindowId] = new Dictionary<string, IMXGUIState>();
}
// Get or create the specific element's state
if ( !_elementState[_currentWindowId].TryGetValue( elementId, out var state ) )
{
state = new IMXGUIState();
_elementState[_currentWindowId][elementId] = state;
}
return state;
}
// Get or create element (no fundamental change, uses context indirectly via GenerateId)
private static T GetOrCreateElement<T>( string label, Action<T> setupAction ) where T : Panel, new()
{
if ( _currentPanel == null || !_currentPanel.IsValid() ) // Check validity
{
Log.Warning( $"ImXGUI.GetOrCreateElement<{typeof( T ).Name}>('{label}'): Current panel is null or invalid. Cannot create element." );
return null;
}
// Generate a unique ID for this element *within the current context and window*
string id = GenerateId<T>( label );
if ( id.StartsWith( "ERROR_" ) ) return null; // Don't proceed if ID generation failed
// Ensure element list exists for the current window *globally*
EnsureWindowElementsListExists( _currentWindowId );
// Try to find an existing element with this ID within the current window's global list
// Use ElementName which we set during creation
T element = _windowElements[_currentWindowId]
.OfType<T>()
.FirstOrDefault( e => e.IsValid() && e.ElementName == id ); // Add IsValid check
// If an element with the same ID *and* type exists and is valid, reuse it.
if ( element != null )
{
element.Style.Display = DisplayMode.Flex; // Make sure it's visible
element.Parent = _currentPanel; // Ensure correct parent if panel changed
setupAction?.Invoke( element ); // Re-apply setup (e.g., update text)
return element;
}
else
{
// Element might exist in the list but be invalid, remove the invalid ref
_windowElements[_currentWindowId].RemoveAll( p => !p.IsValid() );
}
// --- Create a new element ---
element = new T();
element.ElementName = id; // Assign the generated ID
setupAction?.Invoke( element );
_currentPanel.AddChild( element ); // Add to the current content panel
_windowElements[_currentWindowId].Add( element ); // Add to the window's global element list
return element;
}
// Ensure window element list exists
private static void EnsureWindowElementsListExists( string windowId )
{
if ( !_windowElements.ContainsKey( windowId ) )
{
_windowElements[windowId] = new List<Panel>();
}
}
// End the frame for a specific context and perform cleanup
public static void EndFrame( string context )
{
if ( _currentContext != context && _currentContext != null ) // Allow calling EndFrame if no context was active
{
Log.Error( $"ImXGUI.EndFrame: Mismatched context. Expected '{_currentContext ?? "null"}', got '{context}'. Make sure NewFrame/EndFrame calls are balanced for each context." );
// Don't proceed with cleanup for the wrong context, but reset current context maybe?
_currentContext = null; // Attempt to recover state
return;
}
if ( !IsInitialized() ) return; // Nothing to do if not initialized
// --- Window Cleanup Logic ---
// A window should only be deleted if it wasn't active in *any* context during this cycle.
// This requires a more complex cleanup strategy, usually done once per game frame AFTER all contexts have run.
// For now, let's stick to removing windows that were *globally* tracked but *not* activated
// in the context that just finished. This might prematurely delete windows intended
// for other contexts if called out of order. A dedicated system is better.
// **** TEMPORARY / SIMPLIFIED CLEANUP ****
// This might remove windows needed by other contexts if EndFrame calls are interleaved incorrectly.
// A better approach uses a separate global cleanup step (like in the new system below).
/*
var globallyKnownWindows = _windows.Keys.ToList();
var activeInThisContext = _activeWindowsPerContext.ContainsKey(context) ? _activeWindowsPerContext[context] : new HashSet<string>();
foreach ( var windowId in globallyKnownWindows )
{
if ( !activeInThisContext.Contains( windowId ) )
{
// Before deleting, maybe check if it's active in *other* contexts?
bool activeElsewhere = false;
foreach(var kvp in _activeWindowsPerContext)
{
if (kvp.Key != context && kvp.Value.Contains(windowId))
{
activeElsewhere = true;
break;
}
}
if (!activeElsewhere)
{
CleanupWindowReferences( windowId ); // Delete and remove refs
}
}
}
*/
// **** END TEMPORARY CLEANUP ****
// Reset the current context indicator
_currentContext = null;
}
/// <summary>
/// Cleans up all global references to a specific window ID.
/// Should be called when a window is determined to be truly inactive across all contexts.
/// </summary>
private static void CleanupWindowReferences( string windowId )
{
if ( _windows.TryGetValue( windowId, out var window ) )
{
if ( window.IsValid() )
{
window.Delete();
}
_windows.Remove( windowId );
}
_windowElements.Remove( windowId );
_elementState.Remove( windowId );
// Also remove from context-specific counters
foreach ( var contextCounters in _elementCountersPerContext.Values )
{
contextCounters.Remove( windowId );
}
// Also remove from context-specific active lists (though they should be clear already)
foreach ( var activeSet in _activeWindowsPerContext.Values )
{
activeSet.Remove( windowId );
}
Log.Info( $"ImXGUI Cleanup: Removed window '{windowId}'" );
}
/// <summary>
/// Performs a global cleanup pass, removing windows that were not active in *any* context
/// during the last full update cycle. Designed to be called once per game frame after all
/// ImXGUI contexts have finished (e.g., in PostRender).
/// </summary>
public static void PerformGlobalCleanup()
{
if ( !IsInitialized() ) return;
if ( _currentContext != null )
{
Log.Warning( $"ImXGUI.PerformGlobalCleanup: Called while context '{_currentContext}' is still active. EndFrame may not have been called properly." );
// Optionally force EndFrame? Or just skip cleanup this cycle.
// return;
}
// Combine all windows that were active in *any* context during this cycle
HashSet<string> allActiveWindowsThisCycle = new HashSet<string>();
foreach ( var activeSet in _activeWindowsPerContext.Values )
{
allActiveWindowsThisCycle.UnionWith( activeSet );
}
var windowsToRemove = new List<string>();
// Find globally known windows that were NOT active in any context
foreach ( var windowId in _windows.Keys )
{
if ( !allActiveWindowsThisCycle.Contains( windowId ) )
{
windowsToRemove.Add( windowId );
}
}
// Perform the actual cleanup
foreach ( var windowId in windowsToRemove )
{
CleanupWindowReferences( windowId );
}
// Clear the context-specific active lists for the *next* cycle AFTER cleanup
// foreach(var kvp in _activeWindowsPerContext)
// {
// kvp.Value.Clear(); // Clearing here might be wrong if NewFrame hasn't run yet for next cycle
// }
// NewFrame already handles clearing its own context's list.
}
}