Editor/XGUIDesigner.cs
using Editor;
using Sandbox;
using Sandbox.UI;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
namespace XGUI.XGUIEditor
{
public static class XGUIMenu
{
[Menu( "Editor", "XGUI/Designer" )]
public static void OpenMyMenu()
{
var b = new XGUIDesigner();
}
}
public class XGUIDesigner : DockWindow
{
// UI Elements
public XGUIOverlayWidget OverlayWidget; // do overlays here.
private XGUIView _view;
private XGUIHierarchyWidget _hierarchy;
private PanelInspector _inspector;
private Widget _componentPalette;
private Widget _codeView;
private TextEdit _codeTextEditor;
// State
private string _currentFilePath;
private bool _isUpdatingUIFromCode = false; // Flag to prevent update loops
private bool _isUpdatingCodeFromUI = false;
private Menu _recentFilesMenu;
private readonly List<string> _recentFiles = new();
// Razor Content Cache
private string _fullRazorContentCache = ""; // Cache of the full content for modification
public string CurrentTheme = "/XGUI/DefaultStyles/OliveGreen.scss";
// Parsed Document State
private List<MarkupNode> _rootMarkupNodes = new();
private Dictionary<Panel, MarkupNode> _panelToMarkupNodeMap = new();
private Dictionary<MarkupNode, Panel> _markupNodeToPanelMap = new();
// Regex for extracting <root>...</root>
private static readonly Regex _rootContentRegex = new( @"(<root[^>]*>)([\s\S]*?)(</root>)", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.Multiline );
public Window Window => _view?.Window;
public XGUIDesigner()
{
DeleteOnClose = true;
Title = "XGUI Razor Designer";
Size = new Vector2( 1280, 720 );
CreateUI();
Show();
New();
}
public void CreateUI()
{
BuildMenuBar();
// --- Views ---
_view = new XGUIView { OnElementSelected = OnDesignViewElementSelected, OwnerDesigner = this };
_view.SetSizeMode( SizeMode.Flexible, SizeMode.Flexible );
_codeView = new Widget( null );
_codeView.SetSizeMode( SizeMode.Expand, SizeMode.Expand );
CreateCodeViewInternal(); // Creates _codeTextEditor
// --- Panels ---
_hierarchy = new XGUIHierarchyWidget( this, this );
_inspector = new PanelInspector() { OnPropertyChanged = OnInspectorPropertyChanged, OwnerDesigner = this }; // Hook up delegate
_componentPalette = new Widget( null ) { Layout = Layout.Column() };
CreateComponentPaletteInternal();
// --- Docking ---
_view.WindowTitle = "Design View";
_view.SetWindowIcon( "visibility" );
DockManager.AddDock( null, _view );
// Create and add the overlay widget
OverlayWidget = new XGUIOverlayWidget( _view.Parent );
OverlayWidget.ConnectToView( _view );
_hierarchy.WindowTitle = "Hierarchy";
_hierarchy.SetWindowIcon( "view_list" );
DockManager.AddDock( null, _hierarchy, dockArea: DockArea.Left, split: 0.10f );
_inspector.WindowTitle = "Inspector";
_inspector.SetWindowIcon( "info" );
DockManager.AddDock( null, _inspector, dockArea: DockArea.Right, split: 0.10f );
_componentPalette.WindowTitle = "Component Palette";
_componentPalette.SetWindowIcon( "view_module" );
_codeView.WindowTitle = "Code View";
_codeView.SetWindowIcon( "code" );
DockManager.AddDock( null, _componentPalette, DockArea.TopOuter, split: 0.05f );
DockManager.AddDock( null, _codeView, dockArea: DockArea.Bottom, split: 0.30f );
_view.Setup();
}
public override void OnDestroyed()
{
base.OnDestroyed();
OverlayWidget?.Destroy();
}
//---------------------------------------------------------------------
// UI Construction & Updates (Internal Helpers)
//---------------------------------------------------------------------
private void CreateCodeViewInternal()
{
_codeView.Layout = Layout.Column();
_codeTextEditor = _codeView.Layout.Add( new XGUIRazorTextEdit( null ), 1 );
_codeTextEditor.TextChanged = OnCodeTextChanged;
// Configure TextEdit (e.g., font, line numbers) if needed
}
private void CreateComponentPaletteInternal()
{
var container = _componentPalette.Layout.Add( new Widget( null ) );
container.SetSizeMode( SizeMode.Expand, SizeMode.Expand );
container.Layout = Layout.Row();
container.Layout.Spacing = 8;
var layoutsCategory = CreateComponentCategory( container.Layout, "Layouts:" );
var controlsCategory = CreateComponentCategory( container.Layout, "Controls:" );
var containersCategory = CreateComponentCategory( container.Layout, "Containers:" );
AddComponentButton( layoutsCategory, "Div", "div" );
AddComponentButton( layoutsCategory, "Row", "div", "class=\"self-layout self-layout-row\"" );
AddComponentButton( layoutsCategory, "Column", "div", "class=\"self-layout self-layout-column\"" );
AddComponentButton( controlsCategory, "Button", "button" );
AddComponentButton( controlsCategory, "Checkbox", "check" );
AddComponentButton( controlsCategory, "Label", "label" );
AddComponentButton( controlsCategory, "Text Entry", "textentry" );
AddComponentButton( controlsCategory, "Slider", "sliderscale", "min=\"0\" max=\"100\" step=\"1\"" ); // Ensure quotes for parser
AddComponentButton( containersCategory, "Group Box", "groupbox", "title=\"Group\"" );
AddComponentButton( containersCategory, "Tab Control", "tabcontainer" );
AddComponentButton( containersCategory, "Combo Box", "combobox", "default=\"Select...\"" );
}
// Helper for Palette
private Layout CreateComponentCategory( Layout parentLayout, string categoryName )
{
var categoryWidget = parentLayout.Add( new Widget( null ) );
//border qt
categoryWidget.ContentMargins = new Margin( 8, 8, 8, 8 );
categoryWidget.SetStyles( "border: 1px solid #555; border-radius: 4px;" );
categoryWidget.Layout = Layout.Row();
categoryWidget.Layout.Spacing = 4;
var label = new Editor.Label( categoryName );
label.SetStyles( "font-size: 11px; font-weight: bold; color: #fff; border: none;" );
categoryWidget.Layout.Add( label );
return categoryWidget.Layout;
}
public MarkupNode LookupNodeByPanel( Panel panel )
{
if ( panel == null ) return null;
if ( _panelToMarkupNodeMap.TryGetValue( panel, out var node ) ) return node;
return null;
}
public Panel LookupPanelByNode( MarkupNode node )
{
if ( node == null ) return null;
if ( _markupNodeToPanelMap.TryGetValue( node, out var panel ) ) return panel;
return null;
}
private void OnDesignViewElementSelected( Panel selectedPanel )
{
if ( selectedPanel != null && _panelToMarkupNodeMap.TryGetValue( selectedPanel, out var node ) )
SelectAndInspect( node, selectedPanel );
else
SelectAndInspect( null, null );
}
private void OnInspectorPropertyChanged( MarkupNode node, string propertyOrAttributeName, object newValue )
{
if ( node == null || node.Type != NodeType.Element ) return;
// Update the MarkupNode tree directly
if ( propertyOrAttributeName == "innertext" )
{
var textNode = node.Children.FirstOrDefault( c => c.Type == NodeType.Text );
if ( textNode != null ) textNode.TextContent = newValue?.ToString() ?? "";
else node.Children.Insert( 0, new MarkupNode { Type = NodeType.Text, TextContent = newValue?.ToString() ?? "" } );
}
else
{
if ( string.IsNullOrWhiteSpace( newValue?.ToString() ) )
node.Attributes.Remove( propertyOrAttributeName );
else
node.Attributes[propertyOrAttributeName] = newValue.ToString();
}
// Serialize tree back to markup and update code view
UpdateCodeFromTree();
}
private void OnCodeTextChanged( string newContent )
{
if ( _isUpdatingCodeFromUI ) return; // Prevent update loops
_isUpdatingUIFromCode = true;
try
{
// Save currently selected panel/node before updating
var backupNode = LookupNodeByPanel( _view.SelectedPanel );
// Parse and update the UI from the code
ParseAndUpdateUI( newContent );
// Restore selection after update
if ( backupNode != null )
{
var restoredPanel = LookupPanelByNode( backupNode );
if ( restoredPanel != null )
{
_view.SelectedPanel = restoredPanel;
_inspector.SetTarget( restoredPanel, backupNode, false );
}
}
}
finally
{
_isUpdatingUIFromCode = false;
}
}
private void ParseAndUpdateTheme( string fullRazorContent )
{
// Regular expression to find @attribute [StyleSheet("path")] pattern
var styleSheetRegex = new Regex( @"@attribute\s*\[\s*StyleSheet\s*\(\s*""([^""]+)""\s*\)\s*\]", RegexOptions.IgnoreCase | RegexOptions.Compiled );
var match = styleSheetRegex.Match( fullRazorContent );
if ( match.Success && match.Groups.Count > 1 )
{
string stylePath = match.Groups[1].Value;
if ( !string.IsNullOrWhiteSpace( stylePath ) )
{
CurrentTheme = stylePath;
//Log.Info( $"Theme updated to: {CurrentTheme}" );
}
}
else
{
Log.Info( "No StyleSheet attribute found, using default theme" );
}
}
private void ParseAndUpdateUI( string fullRazorContent, bool rebuildMarkupTree = true )
{
ParseAndUpdateTheme( fullRazorContent );
_panelToMarkupNodeMap.Clear();
_markupNodeToPanelMap.Clear();
if ( rebuildMarkupTree ) _rootMarkupNodes.Clear();
if ( _view?.WindowContent == null )
{
_view?.CreateBlankWindow(); // Ensure base window exists
}
// run Window.SetTheme if theme changes
if ( _view?.Window != null )
{
_view.Window.SetTheme( CurrentTheme );
_view.Window.Style.Width = Length.Auto;
_view.Window.Style.Height = Length.Auto;
}
// Get or create the window node - reuse existing one if available
_windowNode = _windowNode ?? GetOrCreateWindowNode();
// Clear previous attributes but preserve the node instance itself
_windowNode.Attributes.Clear();
// Extract the <root> tag with its attributes
var rootTagMatch = _rootContentRegex.Match( fullRazorContent );
if ( rootTagMatch.Success )
{
string rootOpenTag = rootTagMatch.Groups[1].Value; // This captures <root attr1="val1" attr2="val2">
// Extract attributes from the root tag using regex
var attrRegex = new Regex( @"(\w+)=""([^""]*)""|(\w+)=\'([^\']*)\'", RegexOptions.Compiled );
var matches = attrRegex.Matches( rootOpenTag );
foreach ( Match match in matches )
{
string key = match.Groups[1].Success ? match.Groups[1].Value : match.Groups[3].Value;
string value = match.Groups[2].Success ? match.Groups[2].Value : match.Groups[4].Value;
_windowNode.Attributes[key] = value;
}
// Apply window attributes to the actual window if available
if ( _view?.Window != null )
{
ApplyWindowAttributes( _view.Window, _windowNode.Attributes );
}
}
else
{
// No root tag found, set default attributes
if ( !_windowNode.Attributes.ContainsKey( "width" ) ) _windowNode.Attributes["width"] = "240";
if ( !_windowNode.Attributes.ContainsKey( "height" ) ) _windowNode.Attributes["height"] = "240";
if ( !_windowNode.Attributes.ContainsKey( "title" ) ) _windowNode.Attributes["title"] = "My New XGUI Window";
Log.Warning( "No <root> tag found in Razor content." );
}
_view?.WindowContent?.DeleteChildren( true );
// Extract <root>...</root>
var rootContentMatch = _rootContentRegex.Match( fullRazorContent );
string htmlContent = rootContentMatch.Success ? rootContentMatch.Groups[2].Value : "";
if ( rebuildMarkupTree ) _rootMarkupNodes = SimpleMarkupParser.Parse( htmlContent );
// Find the <div class="window-content"> node
MarkupNode windowContentNode = null;
foreach ( var node in _rootMarkupNodes )
{
if ( node.Type == NodeType.Element && node.TagName.Equals( "div", StringComparison.OrdinalIgnoreCase ) &&
node.Attributes.TryGetValue( "class", out var cls ) && cls.Contains( "window-content" ) )
{
windowContentNode = node;
_panelToMarkupNodeMap[_view.WindowContent] = windowContentNode;
_markupNodeToPanelMap[windowContentNode] = _view.WindowContent;
break;
}
// Optionally: search recursively if not always top-level
}
// Clear and rebuild the design surface
_view?.WindowContent?.DeleteChildren( true );
if ( windowContentNode != null && _view?.WindowContent != null )
{
foreach ( var child in windowContentNode.Children )
CreatePanelsRecursive( child, _view.WindowContent );
}
UpdateHierarchyPanelInternal();
}/// <summary>
/// Apply attributes from the root tag to the actual window
/// </summary>
private void ApplyWindowAttributes( Panel window, Dictionary<string, string> attributes )
{
if ( window == null || attributes == null || !attributes.Any() )
return;
var uiWindow = window as XGUI.Window;
foreach ( var attr in attributes )
{
string name = attr.Key.ToLowerInvariant();
string value = attr.Value;
if ( string.IsNullOrWhiteSpace( value ) )
continue;
switch ( name )
{
case "title":
if ( uiWindow != null )
{
uiWindow.Title = value;
// Force refresh the title bar if it exists
if ( uiWindow.TitleBar != null )
{
// Attempt to find and update the text label in the title bar
var titleLabel = uiWindow.TitleBar.Children.OfType<Sandbox.UI.Label>().FirstOrDefault();
if ( titleLabel != null )
titleLabel.Text = value;
}
}
break;
case "width":
if ( float.TryParse( value, NumberStyles.Float, CultureInfo.InvariantCulture, out var width ) )
window.Style.Width = Length.Pixels( width );
break;
case "height":
if ( float.TryParse( value, NumberStyles.Float, CultureInfo.InvariantCulture, out var height ) )
window.Style.Height = Length.Pixels( height );
break;
case "minwidth":
case "min-width":
if ( float.TryParse( value, NumberStyles.Float, CultureInfo.InvariantCulture, out var minWidth ) )
window.Style.MinWidth = Length.Pixels( minWidth );
break;
case "minheight":
case "min-height":
if ( float.TryParse( value, NumberStyles.Float, CultureInfo.InvariantCulture, out var minHeight ) )
window.Style.MinHeight = Length.Pixels( minHeight );
break;
case "maxwidth":
case "max-width":
if ( float.TryParse( value, NumberStyles.Float, CultureInfo.InvariantCulture, out var maxWidth ) )
window.Style.MaxWidth = Length.Pixels( maxWidth );
break;
case "maxheight":
case "max-height":
if ( float.TryParse( value, NumberStyles.Float, CultureInfo.InvariantCulture, out var maxHeight ) )
window.Style.MaxHeight = Length.Pixels( maxHeight );
break;
case "resizable":
if ( uiWindow != null && bool.TryParse( value, out var resizable ) )
uiWindow.IsResizable = resizable;
break;
case "movable":
//if ( uiWindow != null && bool.TryParse( value, out var movable ) )
//uiWindow. = movable;
break;
case "hasclose":
if ( uiWindow != null && bool.TryParse( value, out var hasclose ) )
uiWindow.HasClose = hasclose;
break;
case "hasminimise":
if ( uiWindow != null && bool.TryParse( value, out var hasminimise ) )
uiWindow.HasMinimise = hasminimise;
break;
case "hasmaximise":
if ( uiWindow != null && bool.TryParse( value, out var hasmaximise ) )
uiWindow.HasMaximise = hasmaximise;
break;
case "y":
if ( float.TryParse( value, NumberStyles.Float, CultureInfo.InvariantCulture, out var y ) )
window.Style.Top = Length.Pixels( y );
break;
case "x":
if ( float.TryParse( value, NumberStyles.Float, CultureInfo.InvariantCulture, out var x ) )
window.Style.Left = Length.Pixels( x );
break;
case "position":
//ApplyWindowPosition( window, value );
break;
case "theme":
//if ( uiWindow != null )
//uiWindow.Theme = value;
break;
case "backgroundcolor":
case "background-color":
try
{
window.Style.BackgroundColor = ParseColor( value );
}
catch ( Exception ex )
{
Log.Warning( $"Failed to parse window background color '{value}': {ex.Message}" );
}
break;
case "class":
foreach ( var cls in value.Split( ' ', StringSplitOptions.RemoveEmptyEntries ) )
window.AddClass( cls );
break;
case "style":
ApplyInlineStyles( window, value );
break;
case "icon":
//if ( uiWindow != null )
//uiWindow.Icon = value;
break;
default:
// Store unknown attributes as custom data or tags if needed
Log.Info( $"Unknown window attribute '{name}' with value '{value}'" );
break;
}
}
// Ensure all changes are applied
window.Style.Dirty();
_panelToMarkupNodeMap[_view.Window] = _windowNode;
_markupNodeToPanelMap[_windowNode] = _view.Window;
}
private void CreatePanelsRecursive( MarkupNode node, Panel parentPanel )
{
if ( node.Type == NodeType.Element )
{
Panel newElement = CreateElementFromTag( node.TagName );
if ( newElement != null )
{
parentPanel.AddChild( newElement );
ApplyAttributesToElement( newElement, node.Attributes );
_panelToMarkupNodeMap[newElement] = node;
_markupNodeToPanelMap[node] = newElement;
// Special handling for tab container elements
bool isTabContainer = newElement is Sandbox.UI.TabContainer;
bool isTabElement = node.TagName.Equals( "tab", StringComparison.OrdinalIgnoreCase );
if ( isTabContainer )
{
// Process children of TabContainer - they should be tab elements
foreach ( var childNode in node.Children )
{
if ( childNode.Type == NodeType.Element &&
childNode.TagName.Equals( "tab", StringComparison.OrdinalIgnoreCase ) )
{
var tabContainer = newElement as Sandbox.UI.TabContainer;
// Create the tab content panel first
var tabContentPanel = new Panel();
tabContentPanel.ElementName = "tab";
// Extract tab attributes
string tabName = childNode.Attributes.TryGetValue( "tabname", out var tn ) ? tn : $"tab{tabContainer.Tabs.Count}";
string tabText = childNode.Attributes.TryGetValue( "tabtext", out var tt ) ? tt : tabName;
string tabIcon = childNode.Attributes.TryGetValue( "tabicon", out var ti ) ? ti : null;
// Add tab content children
foreach ( var tabChild in childNode.Children )
CreatePanelsRecursive( tabChild, tabContentPanel );
// Register in mapping first
_panelToMarkupNodeMap[tabContentPanel] = childNode;
_markupNodeToPanelMap[childNode] = tabContentPanel;
// Now add the tab with content to the tab container
var tab = tabContainer.AddTab( tabContentPanel, tabName, tabText, tabIcon );
}
}
}
else if ( !isTabElement ) // Skip recursive processing of tab elements as they're handled by the parent
{
// Normal recursive child creation
foreach ( var childNode in node.Children )
CreatePanelsRecursive( childNode, newElement );
}
ApplyTextContent( newElement, node );
}
}
}
private Panel GetPanelAt( Vector2 screenPosition )
{
// Recursively search all panels in the design view for one containing the point
Panel found = null;
void Search( Panel panel )
{
if ( panel == null ) return;
if ( panel.Box.Rect.IsInside( screenPosition ) )
found = panel;
foreach ( var child in panel.Children.OfType<Panel>() )
Search( child );
}
Search( _view.WindowContent );
return found;
}
public void ForceUpdate( bool rebuildMarkupTree = true )
{
var backup = LookupNodeByPanel( _view.SelectedPanel );
UpdateCodeFromTree();
ParseAndUpdateUI( _codeTextEditor.PlainText, rebuildMarkupTree );
_view.SelectedPanel = LookupPanelByNode( backup );
if ( _view.SelectedPanel == null )
{
Log.Warning( "_view.SelectedPanel is null after UI rebuild!" );
}
else
{
_inspector.SetTarget( _view.SelectedPanel, backup, false );
}
}
private void UpdateCodeFromTree()
{
// 1. Extract existing code sections from the old code
string oldCode = _codeTextEditor?.PlainText ?? _fullRazorContentCache;
ExtractRazorCodeSections( oldCode, out var codeDirectives, out var codeBody );
UpdateStyleSheetAttribute( ref codeDirectives );
// 2. Serialize the <root> content
var rootMarkup = SimpleMarkupParser.Serialize( _rootMarkupNodes );
// 3. Indent the <root> markup
var indentedMarkup = new StringBuilder();
using ( var reader = new StringReader( rootMarkup ) )
{
string line;
while ( (line = reader.ReadLine()) != null )
{
indentedMarkup.AppendLine( "\t" + line );
}
}
// 4. Collect the <root> tag attributes from our window node
var rootWindowNode = GetOrCreateWindowNode();
string rootAttrs = "";
if ( rootWindowNode != null && rootWindowNode.Attributes.Count > 0 )
{
foreach ( var attr in rootWindowNode.Attributes )
{
rootAttrs += $" {attr.Key}=\"{attr.Value}\"";
}
}
// 5. Rebuild the final Razor source
string newCode = $@"{codeDirectives}
<root{rootAttrs}>
{indentedMarkup}
</root>
@code {{
{codeBody}
}}";
SetCodeEditorText( newCode );
}
private void UpdateStyleSheetAttribute( ref string codeDirectives )
{
// Matches any existing @attribute [StyleSheet("...")]
var styleSheetRegex = new Regex(
@"^@attribute\s*\[\s*StyleSheet\s*\(\s*""[^""]*""\s*\)\s*\]\s*$",
RegexOptions.IgnoreCase | RegexOptions.Multiline
);
// The updated line we always want
string styleSheetLine = $@"@attribute [StyleSheet(""{CurrentTheme}"")]";
if ( styleSheetRegex.IsMatch( codeDirectives ) )
{
// Replace existing line if present
codeDirectives = styleSheetRegex.Replace( codeDirectives, styleSheetLine );
}
else
{
// Insert the line at the top of the directives
// codeDirectives = styleSheetLine + Environment.NewLine + codeDirectives;
}
}
/// <summary>
/// Pulls out any top-level directives (like @using, @inherits, etc.) and the code in @code { } blocks.
/// </summary>
private void ExtractRazorCodeSections( string source, out string codeDirectives, out string codeBody )
{
codeDirectives = "";
codeBody = "";
if ( string.IsNullOrWhiteSpace( source ) ) return;
// 1. Grab everything before <root> or @code (whichever comes first)
// (You might refine these expressions for more robust parsing)
var rootIndex = source.IndexOf( "<root", StringComparison.OrdinalIgnoreCase );
var codeIndex = source.IndexOf( "@code", StringComparison.OrdinalIgnoreCase );
int endOfDirectives = -1;
if ( (rootIndex == -1 && codeIndex == -1) || (rootIndex == -1 && codeIndex >= 0) )
{
endOfDirectives = codeIndex; // no <root> found, just use @code as boundary
}
else if ( (rootIndex >= 0 && codeIndex == -1) || (rootIndex >= 0 && rootIndex < codeIndex) )
{
endOfDirectives = rootIndex; // <root> comes before @code
}
else if ( codeIndex >= 0 && codeIndex < rootIndex )
{
endOfDirectives = codeIndex; // @code comes before <root>
}
if ( endOfDirectives > 0 )
{
codeDirectives = source[..endOfDirectives].Trim();
}
else
{
// If there's no <root> or @code, treat the entire source as directives
codeDirectives = source.Trim();
return;
}
// 2. Grab the code body from @code { ... }. This is a quick pass using simple bracket counting.
var startCodeBlock = source.IndexOf( "@code", StringComparison.OrdinalIgnoreCase );
if ( startCodeBlock < 0 ) return;
// Move index to the first '{'
var openBraceIndex = source.IndexOf( '{', startCodeBlock );
if ( openBraceIndex < 0 ) return;
int braceCount = 1;
int i = openBraceIndex + 1;
for ( ; i < source.Length; i++ )
{
if ( source[i] == '{' ) braceCount++;
else if ( source[i] == '}' ) braceCount--;
if ( braceCount == 0 ) break;
}
if ( i <= openBraceIndex ) return; // malformed
// Everything between the braces is code body
int length = i - (openBraceIndex + 1);
if ( length > 0 )
{
codeBody = source.Substring( openBraceIndex + 1, length ).Trim();
}
}
// Helper for Palette
private void AddComponentButton( Layout layout, string displayName, string tagName, string attributes = "" )
{
var button = layout.Add( new Editor.Button( displayName ) );
button.Clicked = () => AddComponentToSource( tagName, attributes );
button.IsDraggable = true;
}
/// <summary>
/// Adds a component by modifying the source code.
/// </summary>
private void AddComponentToSource( string tagName, string attributes = "" )
{
Log.Info( $"Adding component <{tagName}> to window-content" );
if ( _isUpdatingUIFromCode ) return;
// Find the window-content node in the markup tree
MarkupNode windowContentNode = null;
foreach ( var node in _rootMarkupNodes )
{
if ( node.Type == NodeType.Element && node.TagName.Equals( "div", StringComparison.OrdinalIgnoreCase ) &&
node.Attributes.TryGetValue( "class", out var cls ) && cls.Contains( "window-content" ) )
{
windowContentNode = node;
break;
}
// Optionally: search recursively if not always top-level
}
if ( windowContentNode == null )
{
Log.Warning( "Cannot add component: <div class=\"window-content\"> not found." );
return;
}
// Special handling for TabContainer - add with sample tabs
if ( tagName.Equals( "tabcontainer", StringComparison.OrdinalIgnoreCase ) )
{
var tabContainerNode = new MarkupNode
{
Type = NodeType.Element,
TagName = tagName,
Attributes = SimpleMarkupParser.ParseAttributes( attributes ),
Children = new List<MarkupNode>()
};
// Add two sample tabs
var tab1 = new MarkupNode
{
Type = NodeType.Element,
TagName = "tab",
Attributes = new Dictionary<string, string>
{
{ "tabName", "tab1" },
{ "slot", "tab" },
{ "tabtext", "Tab 1" }
},
Children = new List<MarkupNode>
{
new MarkupNode
{
Type = NodeType.Element,
TagName = "div",
Children = new List<MarkupNode>
{
new MarkupNode
{
Type = NodeType.Text,
TextContent = "Tab 1 Content"
}
}
}
}
};
var tab2 = new MarkupNode
{
Type = NodeType.Element,
TagName = "tab",
Attributes = new Dictionary<string, string>
{
{ "tabName", "tab2" },
{ "slot", "tab" },
{ "tabtext", "Tab 2" }
},
Children = new List<MarkupNode>
{
new MarkupNode
{
Type = NodeType.Element,
TagName = "div",
Children = new List<MarkupNode>
{
new MarkupNode
{
Type = NodeType.Text,
TextContent = "Tab 2 Content"
}
}
}
}
};
tabContainerNode.Children.Add( tab1 );
tabContainerNode.Children.Add( tab2 );
// Add to window-content node
windowContentNode.Children.Add( tabContainerNode );
}
else
{
// Create the new MarkupNode for the component (normal case)
var newNode = new MarkupNode
{
Type = NodeType.Element,
TagName = tagName,
Attributes = SimpleMarkupParser.ParseAttributes( attributes ),
Children = new List<MarkupNode>()
};
// Default text for button
if ( tagName == "button" )
{
newNode.Children.Add( new MarkupNode { Type = NodeType.Text, TextContent = "Button" } );
}
if ( tagName == "label" )
{
newNode.Children.Add( new MarkupNode { Type = NodeType.Text, TextContent = "Label" } );
}
// Add to window-content node
windowContentNode.Children.Add( newNode );
}
// Serialize tree back to markup and update code view/UI
UpdateCodeFromTree();
ParseAndUpdateUI( _codeTextEditor.PlainText );
}
/// <summary>
/// Updates the Hierarchy TreeView based on the _rootMarkupNodes.
/// </summary>
private void UpdateHierarchyPanelInternal()
{
// save currently expanded nodes
/*var expandedNodes = new List<TreeNode>();
if ( HierarchyTree != null )
{
foreach ( var node in HierarchyTree.
}*/
// can't figure out how to get the expanded nodes :(
_hierarchy.UpdateHierarchy( _rootMarkupNodes );
// Find existing TreeView or create a new one
/* if ( HierarchyTree == null )
{
// Clear any old non-TreeView widgets if necessary before adding
// _heirarchy.Layout.Clear(true); // Use if layout needs full reset
HierarchyTree = new TreeView( _heirarchy );
_heirarchy.Layout.Add( HierarchyTree );
}
else
{
HierarchyTree.Clear(); // Clear existing items efficiently
}
HierarchyTree.MultiSelect = false;
HierarchyTree.ExpandForSelection = true;
HierarchyTree.ItemSelected = OnHierarchyNodeSelected; // Use specific handler
// Build tree from the root MarkupNodes
foreach ( var rootNode in _rootMarkupNodes )
{
BuildTreeForMarkupNodeRecursive( rootNode, null, HierarchyTree ); // Pass treeview for root items
}*/
}
/// <summary>
/// Recursively builds the TreeView structure from MarkupNodes.
/// </summary>
private void BuildTreeForMarkupNodeRecursive( MarkupNode node, TreeNode parentTreeNode, Editor.TreeView treeView )
{
// (Identical to previous implementation)
if ( node.Type == NodeType.Element )
{
string displayName = $"{node.TagName}";
if ( node.Attributes.TryGetValue( "class", out var cls ) && !string.IsNullOrWhiteSpace( cls ) ) displayName += $" .{cls.Split( ' ' )[0]}";
if ( node.Attributes.TryGetValue( "id", out var id ) && !string.IsNullOrWhiteSpace( id ) ) displayName += $" #{id}";
var treeNode = new TreeNode( node ) { Name = displayName, /*ToolTip = $"Pos: {node.SourceStart}-{node.SourceEnd}",*/ Value = node };
if ( parentTreeNode != null ) parentTreeNode.AddItem( treeNode );
else treeView.AddItem( treeNode );
foreach ( var childNode in node.Children )
{
BuildTreeForMarkupNodeRecursive( childNode, treeNode, treeView );
}
}
// Optionally add text nodes here if desired
}
/// <summary>
/// Safely sets the text of the code editor without triggering its TextChanged event.
/// </summary>
private void SetCodeEditorText( string text )
{
if ( _codeTextEditor == null ) return;
var originalHandler = _codeTextEditor.TextChanged;
try
{
_codeTextEditor.TextChanged = null;
_codeTextEditor.PlainText = text;
}
finally
{
_codeTextEditor.TextChanged = originalHandler;
}
}
//---------------------------------------------------------------------
// Menu Actions & File I/O
//---------------------------------------------------------------------
public void BuildMenuBar()
{
var file = MenuBar.AddMenu( "File" );
file.AddOption( "New", "common/new.png", New, "Ctrl+N" ).StatusTip = "New Razor File";
file.AddOption( "Open", "common/open.png", Open, "Ctrl+O" ).StatusTip = "Open Razor File";
file.AddOption( "Save", "common/save.png", () => Save(), "Ctrl+S" ).StatusTip = "Save Razor File";
file.AddOption( "Save As...", "common/save.png", () => Save( true ), "Ctrl+Shift+S" ).StatusTip = "Save Razor File As...";
file.AddSeparator();
_recentFilesMenu = file.AddMenu( "Recent Files" );
file.AddSeparator();
file.AddOption( "Quit", null, Close, "Ctrl+Q" ).StatusTip = "Quit";
var edit = MenuBar.AddMenu( "Edit" );
edit.AddSeparator();
edit.AddOption( "Cut", "common/cut.png", CutSelection, "Ctrl+X" );
edit.AddOption( "Copy", "common/copy.png", CopySelection, "Ctrl+C" );
edit.AddOption( "Paste", "common/paste.png", PasteSelection, "Ctrl+V" );
edit.AddOption( "Select All", "select_all", SelectAll, "Ctrl+A" );
var view = MenuBar.AddMenu( "View" );
view.AddOption( "Design View", null, ShowDesignView, "F7" );
view.AddOption( "Code View", null, ShowCodeView, "F8" );
view.AddOption( "Split View", null, ShowSplitView, "F9" );
}
void New()
{
string template = @"@using Sandbox;
@using Sandbox.UI;
@using XGUI;
@attribute [StyleSheet( ""/XGUI/DefaultStyles/OliveGreen.scss"" )]
@inherits Window
<root title=""My New XGUI Window"" width=""320"" height=""240"">
<div class=""window-content"">
</div>
</root>
@code {
// Add your code here
}";
SetCodeEditorText( template ); // Set initial text
_fullRazorContentCache = template; // Prime the cache
ParseAndUpdateUI( template ); // Parse initial state
_currentFilePath = null;
Title = "XGUI Razor Designer - Untitled";
}
void Open()
{
var fd = new FileDialog( null );
fd.Title = "Open Razor File";
fd.SetNameFilter( "Razor Files (*.razor)" );
fd.SetFindFile();
fd.SetModeOpen();
if ( !fd.Execute() ) return;
OpenFile( fd.SelectedFile );
}
void OpenFile( string path )
{
if ( !File.Exists( path ) ) return;
try
{
string content = File.ReadAllText( path );
SetCodeEditorText( content ); // Update editor
_fullRazorContentCache = content; // Update cache
ParseAndUpdateUI( content ); // Parse new file
_currentFilePath = path;
Title = $"XGUI Razor Designer - {Path.GetFileName( path )}";
AddRecentFile( path );
}
catch ( System.Exception ex ) { Log.Error( $"Error opening file: {ex.Message}" ); }
}
void AddRecentFile( string path )
{
_recentFiles.Remove( path ); // Remove if exists to move to top
_recentFiles.Insert( 0, path );
if ( _recentFiles.Count > 10 ) _recentFiles.RemoveAt( 10 );
UpdateRecentFilesMenu();
}
void UpdateRecentFilesMenu()
{
// Clear menu items properly since Menu.Items doesn't exist
_recentFilesMenu.Clear();
foreach ( var path in _recentFiles )
{
string fileName = Path.GetFileName( path );
_recentFilesMenu.AddOption( fileName, null, () => OpenFile( path ) );
}
}
void Save( bool saveas = false )
{
if ( saveas || string.IsNullOrEmpty( _currentFilePath ) )
{
// Use FileDialog instead of SaveFileDialog
var fd = new FileDialog( null );
fd.Title = "Save Razor File";
fd.SetNameFilter( "Razor Files (*.razor)" );
fd.SetFindFile();
fd.SetModeSave();
fd.DefaultSuffix = ".razor";
if ( !fd.Execute() ) return;
SaveFile( fd.SelectedFile );
}
else
{
SaveFile( _currentFilePath );
}
}
void SaveFile( string path )
{
// Find TextEdit directly instead of using ChildrenOfType
if ( _codeTextEditor == null ) return;
try
{
File.WriteAllText( path, _codeTextEditor.PlainText );
_currentFilePath = path;
Title = $"XGUI Razor Designer - {Path.GetFileName( path )}";
// Add to recent files
if ( !_recentFiles.Contains( path ) )
{
_recentFiles.Insert( 0, path );
if ( _recentFiles.Count > 10 )
_recentFiles.RemoveAt( 10 );
UpdateRecentFilesMenu();
}
Log.Info( $"File saved: {path}" );
}
catch ( System.Exception ex )
{
Log.Error( $"Error saving file: {ex.Message}" );
}
}
/// <summary>
/// Central method to update inspector and potentially highlight selection.
/// </summary>
public void SelectAndInspect( MarkupNode node, Panel panel )
{
_inspector.SetTarget( panel, node );
_view.SelectedPanel = panel;
// if node is code or textcontent node, find the text and jump to it in the code editor
if ( node != null && (node.Type == NodeType.RazorBlock || node.Type == NodeType.Text) )
{
// Find the text start of the node's text content in the code editor
var text = node.TextContent;
var source = _codeTextEditor?.PlainText;
// lookup the text in the code editor
if ( source != null )
{
int index = source.IndexOf( text, StringComparison.OrdinalIgnoreCase );
if ( index >= 0 )
{
var cursor = _codeTextEditor?.GetTextCursor();
cursor.Position = index;
_codeTextEditor.SetTextCursor( cursor );
_codeTextEditor.Focus();
}
}
}
}
MarkupNode _windowNode;
/// <summary>
/// A fake node for the window root, used for inspector, you probably want to use window-content instead.
/// </summary>
/// <returns></returns>
internal MarkupNode GetOrCreateWindowNode()
{
// create a new node for the window root, used for inspector.
if ( _windowNode == null )
{
_windowNode = new MarkupNode
{
Type = NodeType.Element,
TagName = "root",
Attributes = new Dictionary<string, string>(),
Children = new List<MarkupNode>()
};
// default width, height, and title
_windowNode.Attributes["width"] = "240";
_windowNode.Attributes["height"] = "240";
_windowNode.Attributes["title"] = "My New XGUI Window";
// insert into the markup tree mapping
// _rootMarkupNodes.Add(_windowNode);
_panelToMarkupNodeMap[_view.Window] = _windowNode;
_markupNodeToPanelMap[_windowNode] = _view.Window;
}
return _windowNode;
}
//---------------------------------------------------------------------
// MarkupNode to Panel Creation Helpers (Refined from previous)
//---------------------------------------------------------------------
// Base element creation (no attributes/content applied here)
/// <summary>
/// Creates a panel based on the HTML tag name, similar to how s&box internally processes tags.
/// For known components, returns the appropriate panel type.
/// For unknown tags, creates a generic Panel with the tag name applied for CSS targeting.
/// </summary>
private Panel CreateElementFromTag( string tagName )
{
if ( string.IsNullOrEmpty( tagName ) )
{
Log.Warning( "CreateElementFromTag: Empty tag name provided" );
return new Panel();
}
// Normalize tag name to lowercase for consistent lookup
string normalizedTagName = tagName.ToLowerInvariant();
try
{
// First attempt: Try the TypeLibrary to find the exact component
if ( TypeLibrary != null )
{
// 1. Try direct name match first (most likely to succeed)
var typesByName = TypeLibrary.GetTypes()
.Where( t => t != null &&
t.TargetType != null &&
t.TargetType.IsSubclassOf( typeof( Panel ) ) &&
t.Name.Equals( normalizedTagName, StringComparison.OrdinalIgnoreCase ) )
.ToList();
if ( typesByName.Count > 0 )
{
// Direct name match found, create the specific component
return typesByName[0].Create<Panel>();
}
// 2. Try Library attribute
var libraryMatches = TypeLibrary.GetTypesWithAttribute<LibraryAttribute>()
.Where( a => a.Type != null &&
a.Attribute != null &&
a.Type.TargetType.IsSubclassOf( typeof( Panel ) ) &&
a.Attribute.Name.Equals( normalizedTagName, StringComparison.OrdinalIgnoreCase ) )
.ToList();
if ( libraryMatches.Count > 0 )
{
return libraryMatches[0].Type.Create<Panel>();
}
// 3. Try Alias attribute
var aliasTypes = TypeLibrary.GetTypesWithAttribute<AliasAttribute>()
.Where( a => a.Type != null &&
a.Attribute != null &&
a.Type.TargetType != null &&
a.Type.TargetType.IsSubclassOf( typeof( Panel ) ) &&
a.Attribute.Value != null &&
a.Attribute.Value.Any( x => x.Equals( normalizedTagName, StringComparison.OrdinalIgnoreCase ) ) )
.ToList();
if ( aliasTypes.Count > 0 )
{
return aliasTypes[0].Type.Create<Panel>();
}
}
}
catch ( Exception ex )
{
Log.Error( $"Error in TypeLibrary lookup for '{tagName}': {ex.Message}" );
}
// At this point, we're dealing with an unknown tag
// The s&box approach is to create a generic Panel but apply special properties
Panel panel = new Panel();
// Most reliable approach: Set a data attribute AND add the tag as a class
panel.ElementName = normalizedTagName; // Set the element name for CSS targeting
return panel;
}
private void ApplyAttributesToElement( Panel element, Dictionary<string, string> attributes )
{
// (Identical to previous implementation - uses switch statement)
if ( element == null || attributes == null ) return;
foreach ( var kvp in attributes )
{
string name = kvp.Key; // Already lowercased if dictionary uses OrdinalIgnoreCase
string value = kvp.Value; // Already decoded by parser
switch ( name )
{
case "class": if ( value != null ) foreach ( var cls in value.Split( ' ', StringSplitOptions.RemoveEmptyEntries ) ) element.AddClass( cls ); break;
case "style": if ( value != null ) ApplyInlineStyles( element, value ); break;
case "title": if ( element is XGUI.GroupBox gb && value != null ) gb.Title = value; break;
case "min": if ( element is XGUI.SliderScale sl && float.TryParse( value, CultureInfo.InvariantCulture, out var v ) ) sl.MinValue = v; break;
case "max": if ( element is XGUI.SliderScale slm && float.TryParse( value, CultureInfo.InvariantCulture, out var v2 ) ) slm.MaxValue = v2; break;
case "checked": if ( element is XGUI.CheckBox cb ) cb.Checked = true; break; // Valueless implies true
case "tabname":
if ( element.Parent != null && element.Parent.Parent is Sandbox.UI.TabContainer tc )
{
// For tab elements - store the tabName for later tab setup
element.SetAttribute( "tabname", value );
}
break;
case "tabtext":
if ( element.Parent != null && element.Parent.Parent is Sandbox.UI.TabContainer tc1 )
{
// For tab elements - store the tabText for later tab setup
element.SetAttribute( "tabtext", value );
}
break;
case "tabicon":
if ( element.Parent != null && element.Parent.Parent is Sandbox.UI.TabContainer tc2 )
{
// For tab elements - store the tabIcon for later tab setup
element.SetAttribute( "tabicon", value );
}
break;
case "default": if ( element is XGUI.ComboBox combob && value != null ) combob.Selected = combob.Options.Where( x => x.Value is string && (x.Value as string) == value ).FirstOrDefault(); break;
// Add other attributes (id, src, disabled, etc.)
default: /* Log unknown? Store in Tags? */ break;
}
}
}
// Apply text content after attributes/children are processed
private void ApplyTextContent( Panel element, MarkupNode node )
{
if ( node == null || node.Type != NodeType.Element ) return;
// Find first direct child Text node
var textNode = node.Children.FirstOrDefault( c => c.Type == NodeType.Text && !string.IsNullOrWhiteSpace( c.TextContent ) );
if ( textNode != null )
{
string text = textNode.TextContent; // Already decoded
if ( element is Sandbox.UI.Button btn ) btn.Text = text;
else if ( element is Sandbox.UI.Label lbl ) lbl.Text = text;
else if ( element is XGUI.CheckBox chk ) chk.LabelText = text;
// Add other elements that take direct text content
}
}
private void ApplyInlineStyles( Panel panel, string styleText )
{
// (Implementation identical to previous response - uses ParseLength/ParseColor)
if ( panel == null || string.IsNullOrWhiteSpace( styleText ) ) return;
var styles = styleText.Split( ';', StringSplitOptions.RemoveEmptyEntries );
foreach ( var style in styles )
{
var parts = style.Split( ':', 2 ); // Split only on the first colon
if ( parts.Length != 2 ) continue;
string property = parts[0].Trim().ToLowerInvariant();
string value = parts[1].Trim();
try
{
switch ( property )
{
case "width": panel.Style.Width = ParseLength( value ); break;
case "height": panel.Style.Height = ParseLength( value ); break;
case "min-width": panel.Style.MinWidth = ParseLength( value ); break;
case "min-height": panel.Style.MinHeight = ParseLength( value ); break;
case "max-width": panel.Style.MaxWidth = ParseLength( value ); break;
case "max-height": panel.Style.MaxHeight = ParseLength( value ); break;
case "margin": /* TODO: Handle shorthand */ panel.Style.Margin = ParseLength( value ); break;
case "margin-top": panel.Style.MarginTop = ParseLength( value ); break;
case "margin-right": panel.Style.MarginRight = ParseLength( value ); break;
case "margin-bottom": panel.Style.MarginBottom = ParseLength( value ); break;
case "margin-left": panel.Style.MarginLeft = ParseLength( value ); break;
case "padding": /* TODO: Handle shorthand */ panel.Style.Padding = ParseLength( value ); break;
case "padding-top": panel.Style.PaddingTop = ParseLength( value ); break;
case "padding-right": panel.Style.PaddingRight = ParseLength( value ); break;
case "padding-bottom": panel.Style.PaddingBottom = ParseLength( value ); break;
case "padding-left": panel.Style.PaddingLeft = ParseLength( value ); break;
case "background-color": panel.Style.BackgroundColor = ParseColor( value ); break;
case "color": panel.Style.FontColor = ParseColor( value ); break;
case "font-size": panel.Style.FontSize = ParseLength( value ); break;
case "top": panel.Style.Top = ParseLength( value ); break;
case "left": panel.Style.Left = ParseLength( value ); break;
case "right": panel.Style.Right = ParseLength( value ); break;
case "bottom": panel.Style.Bottom = ParseLength( value ); break;
case "position": panel.Style.Position = value.ToLowerInvariant() == "absolute" ? PositionMode.Absolute : PositionMode.Relative; break;
// Add other CSS properties you want to parse
// e.g., flex-direction, align-items, justify-content, border, border-radius, etc.
default: break; // Ignore unknown styles
}
}
catch ( Exception ex )
{
Log.Warning( $"Failed to apply style '{property}:{value}'. Error: {ex.Message}" );
}
}
panel.Style.Dirty(); // Ensure styles are applied visually
}
// Parses CSS length (px, %) string
private Length? ParseLength( string value ) { return Length.Parse( value ); }
// Parses CSS color string (#rgb, #rrggbb, rgb(), rgba())
private Color ParseColor( string colorValue ) { return Color.Parse( colorValue ).Value; }
// Optional helpers from ParseColor
/// <summary>
/// Helper for ParseColor: Parses a single R, G, or B component (number or percentage).
/// </summary>
private float ParseColorComponent( string component )
{
component = component.Trim();
if ( component.EndsWith( '%' ) )
{
if ( float.TryParse( component.Substring( 0, component.Length - 1 ), NumberStyles.Float, CultureInfo.InvariantCulture, out float percent ) )
{
return Math.Clamp( percent / 100.0f, 0f, 1f );
}
}
else
{
if ( int.TryParse( component, NumberStyles.Integer, CultureInfo.InvariantCulture, out int value ) )
{
return Math.Clamp( value / 255.0f, 0f, 1f );
}
// Handle float values 0-1 directly? CSS Color 4 allows this.
else if ( float.TryParse( component, NumberStyles.Float, CultureInfo.InvariantCulture, out float floatVal ) )
{
return Math.Clamp( floatVal, 0f, 1f );
}
}
throw new FormatException( $"Invalid color component format: {component}" );
}
/// <summary>
/// Helper for ParseColor: Parses the alpha component (number 0-1 or percentage).
/// </summary>
private float ParseAlphaComponent( string alpha )
{
alpha = alpha.Trim();
if ( alpha.EndsWith( '%' ) )
{
if ( float.TryParse( alpha.Substring( 0, alpha.Length - 1 ), NumberStyles.Float, CultureInfo.InvariantCulture, out float percent ) )
{
return Math.Clamp( percent / 100.0f, 0f, 1f );
}
}
else
{
if ( float.TryParse( alpha, NumberStyles.Float, CultureInfo.InvariantCulture, out float value ) )
{
return Math.Clamp( value, 0f, 1f );
}
}
throw new FormatException( $"Invalid alpha component format: {alpha}" );
}
//---------------------------------------------------------------------
// Source Code Generation / Manipulation Helpers (Mostly Placeholders)
//---------------------------------------------------------------------
// Adjusts SourceStart/SourceEnd after parsing only a fragment
private void AdjustSourcePositions( IEnumerable<MarkupNode> nodes, int offset ) { /* (Identical to previous) */ }
/// <summary>
/// Basic HTML entity decoder.
/// </summary>
private string DecodeHtml( string text )
{
if ( string.IsNullOrEmpty( text ) ) return "";
// Manual basic version: Order matters! Ampersand must be last.
return text.Replace( "<", "<" )
.Replace( ">", ">" )
.Replace( """, "\"" )
.Replace( "'", "'" ) // Handle apostrophe
.Replace( "'", "'" ) // Handle apostrophe XML entity
.Replace( "&", "&" ); // Ampersand last
}
/// <summary>
/// Basic HTML entity encoder.
/// </summary>
private string EncodeHtml( string text )
{
if ( string.IsNullOrEmpty( text ) ) return "";
// Manual basic version: Order matters! Ampersand must be first.
return text.Replace( "&", "&" )
.Replace( "<", "<" )
.Replace( ">", ">" )
.Replace( "\"", """ )
.Replace( "'", "'" ); // Use numeric entity for apostrophe for broader compatibility
} /// <summary>
/// Checks if a tag name typically represents a self-closing element in HTML/Razor context.
/// Customize this list based on the elements you commonly use.
/// </summary>
private bool IsSelfClosingTag( string tagName )
{
if ( string.IsNullOrEmpty( tagName ) ) return false;
return tagName.ToLowerInvariant() switch
{
"textentry" => true,
"input" => true,
"img" => true,
"br" => true,
"hr" => true,
// Add others like <meta>, <link> if relevant, though unlikely in UI markup
_ => false,
};
}/// <summary>
/// Converts a Sandbox.Color to a CSS hex string (#RRGGBB or #RRGGBBAA).
/// </summary>
private string ColorToHex( Color color )
{
// Include Alpha only if it's not fully opaque (or nearly opaque due to float precision)
if ( color.a < 0.999f )
return $"#{color.r:X2}{color.g:X2}{color.b:X2}{color.a:X2}";
else
return $"#{color.r:X2}{color.g:X2}{color.b:X2}";
}
void ShowDesignView()
{
_codeView.Visible = false;
_view.Visible = true;
Log.Info( "Switched to design view" );
}
void ShowCodeView()
{
_view.Visible = false;
_codeView.Visible = true;
Log.Info( "Switched to code view" );
}
void ShowSplitView()
{
_view.Visible = true;
_codeView.Visible = true;
Log.Info( "Switched to split view" );
}
void CutSelection()
{
if ( _codeTextEditor == null ) return;
//textEditor.Cut();
}
void CopySelection()
{
if ( _codeTextEditor == null ) return;
//textEditor.Copy();
}
void PasteSelection()
{
;
if ( _codeTextEditor == null ) return;
//textEditor.Paste();
}
void SelectAll()
{
if ( _codeTextEditor == null ) return;
_codeTextEditor.SelectAll();
}
private static void SerializeNode( MarkupNode node, StringBuilder sb, int indentLevel )
{
// The current indentation string
string indent = new string( '\t', indentLevel );
if ( node.Type == NodeType.Text )
{
// Instead of trimming everything to one line, we split on existing newline boundaries
// and indent each line.
if ( !string.IsNullOrWhiteSpace( node.TextContent ) )
{
var lines = node.TextContent
.Replace( "\r", "" ) // unify line endings
.Split( '\n', StringSplitOptions.None );
foreach ( var line in lines )
{
// If it's not empty, indent it
if ( !string.IsNullOrWhiteSpace( line ) )
{
sb.Append( indent ).AppendLine( line.TrimEnd() );
}
else
{
// Still preserve blank lines
sb.AppendLine();
}
}
}
}
else if ( node.Type == NodeType.Element )
{
// Element opening tag
sb.Append( indent ).Append( '<' ).Append( node.TagName );
// Add element attributes
foreach ( var attr in node.Attributes )
{
sb.Append( ' ' ).Append( attr.Key );
if ( !string.IsNullOrEmpty( attr.Value ) )
{
// Replace any quotes with " to avoid invalid markup
string safeValue = attr.Value.Replace( "\"", """ );
sb.Append( "=\"" ).Append( safeValue ).Append( '"' );
}
}
if ( node.Children.Count == 0 )
{
// Self-closing tag
sb.AppendLine( " />" );
}
else
{
sb.AppendLine( ">" );
// Recurse into children, increasing indent
foreach ( var child in node.Children )
{
SerializeNode( child, sb, indentLevel + 1 );
}
// Closing tag
sb.Append( indent ).Append( "</" ).Append( node.TagName ).AppendLine( ">" );
}
}
else if ( node.Type == NodeType.RazorBlock )
{
// A block of Razor code is inserted as-is, often with newlines intact
var lines = node.TextContent
.Replace( "\r", "" )
.Split( '\n', StringSplitOptions.None );
foreach ( var line in lines )
{
if ( !string.IsNullOrWhiteSpace( line ) )
{
sb.Append( indent ).AppendLine( line.TrimEnd() );
}
else
{
sb.AppendLine();
}
}
}
}
}
}