using System.Windows.Input; using System.Xml; using ICSharpCode.AvalonEdit.CodeCompletion; using ICSharpCode.AvalonEdit.Document; using ICSharpCode.AvalonEdit.Editing; using ICSharpCode.AvalonEdit.Highlighting.Xshd; using ICSharpCode.AvalonEdit.Highlighting; using Bloxstrap.UI.Elements.Base; using Bloxstrap.UI.ViewModels.Editor; using System.Windows; namespace Bloxstrap.UI.Elements.Editor { /// /// Interaction logic for BootstrapperEditorWindow.xaml /// public partial class BootstrapperEditorWindow : WpfUiWindow { private static class CustomBootstrapperSchema { private class Schema { public Dictionary Elements { get; set; } = new Dictionary(); public Dictionary Types { get; set; } = new Dictionary(); } private class Element { public string? SuperClass { get; set; } = null; public bool IsCreatable { get; set; } = false; // [AttributeName] = [TypeName] public Dictionary Attributes { get; set; } = new Dictionary(); } public class Type { public bool CanHaveElement { get; set; } = false; public List? Values { get; set; } = null; } private static Schema? _schema; /// /// Elements and their attributes /// public static SortedDictionary> ElementInfo { get; set; } = new(); /// /// Attributes of elements that can have property elements /// public static Dictionary> PropertyElements { get; set; } = new(); /// /// All type info /// public static SortedDictionary Types { get; set; } = new(); public static void ParseSchema() { if (_schema != null) return; _schema = JsonSerializer.Deserialize(Resource.GetString("CustomBootstrapperSchema.json").Result); if (_schema == null) throw new Exception("Deserialised CustomBootstrapperSchema is null"); foreach (var type in _schema.Types) Types.Add(type.Key, type.Value); PopulateElementInfo(); } private static (SortedDictionary, List) GetElementAttributes(string name, Element element) { if (ElementInfo.ContainsKey(name)) return (ElementInfo[name], PropertyElements[name]); List properties = new List(); SortedDictionary attributes = new(); foreach (var attribute in element.Attributes) { attributes.Add(attribute.Key, attribute.Value); if (!Types.ContainsKey(attribute.Value)) throw new Exception($"Schema for type {attribute.Value} is missing. Blame Matt!"); Type type = Types[attribute.Value]; if (type.CanHaveElement) properties.Add(attribute.Key); } if (element.SuperClass != null) { (SortedDictionary superAttributes, List superProperties) = GetElementAttributes(element.SuperClass, _schema!.Elements[element.SuperClass]); foreach (var attribute in superAttributes) attributes.Add(attribute.Key, attribute.Value); foreach (var property in superProperties) properties.Add(property); } properties.Sort(); ElementInfo[name] = attributes; PropertyElements[name] = properties; return (attributes, properties); } private static void PopulateElementInfo() { List toRemove = new List(); foreach (var element in _schema!.Elements) { GetElementAttributes(element.Key, element.Value); if (!element.Value.IsCreatable) toRemove.Add(element.Key); } // remove non-creatable from list now that everything is done foreach (var name in toRemove) { ElementInfo.Remove(name); } } } private BootstrapperEditorWindowViewModel _viewModel; private CompletionWindow? _completionWindow = null; public BootstrapperEditorWindow(string name) { CustomBootstrapperSchema.ParseSchema(); string directory = Path.Combine(Paths.CustomThemes, name); string themeContents = File.ReadAllText(Path.Combine(directory, "Theme.xml")); themeContents = ToCRLF(themeContents); // make sure the theme is in CRLF. a function expects CRLF. _viewModel = new BootstrapperEditorWindowViewModel(); _viewModel.ThemeSavedCallback = ThemeSavedCallback; _viewModel.Directory = directory; _viewModel.Name = name; _viewModel.Title = string.Format(Strings.CustomTheme_Editor_Title, name); _viewModel.Code = themeContents; DataContext = _viewModel; InitializeComponent(); UIXML.Text = _viewModel.Code; UIXML.TextChanged += OnCodeChanged; UIXML.TextArea.TextEntered += OnTextAreaTextEntered; LoadHighlightingTheme(); } private void LoadHighlightingTheme() { string name = $"Editor-Theme-{App.Settings.Prop.Theme.GetFinal()}.xshd"; using Stream xmlStream = Resource.GetStream(name); using XmlReader reader = XmlReader.Create(xmlStream); UIXML.SyntaxHighlighting = HighlightingLoader.Load(reader, HighlightingManager.Instance); UIXML.TextArea.TextView.SetResourceReference(ICSharpCode.AvalonEdit.Rendering.TextView.LinkTextForegroundBrushProperty, "NewTextEditorLink"); } private void ThemeSavedCallback(bool success, string message) { if (success) Snackbar.Show(Strings.CustomTheme_Editor_Save_Success, message, Wpf.Ui.Common.SymbolRegular.CheckmarkCircle32, Wpf.Ui.Common.ControlAppearance.Success); else Snackbar.Show(Strings.CustomTheme_Editor_Save_Error, message, Wpf.Ui.Common.SymbolRegular.ErrorCircle24, Wpf.Ui.Common.ControlAppearance.Danger); } private static string ToCRLF(string text) { return text.Replace("\r\n", "\n").Replace("\r", "\n").Replace("\n", "\r\n"); } private void OnCodeChanged(object? sender, EventArgs e) { _viewModel.Code = UIXML.Text; _viewModel.CodeChanged = true; } private void OnClosing(object sender, System.ComponentModel.CancelEventArgs e) { if (!_viewModel.CodeChanged) return; var result = Frontend.ShowMessageBox(string.Format(Strings.CustomTheme_Editor_ConfirmSave, _viewModel.Name), MessageBoxImage.Information, MessageBoxButton.YesNoCancel); if (result == MessageBoxResult.Cancel) { e.Cancel = true; } else if (result == MessageBoxResult.Yes) { _viewModel.SaveCommand.Execute(null); } } private void OnTextAreaTextEntered(object sender, TextCompositionEventArgs e) { switch (e.Text) { case "<": OpenElementAutoComplete(); break; case " ": OpenAttributeAutoComplete(); break; case ".": OpenPropertyElementAutoComplete(); break; case "/": AddEndTag(); break; case ">": CloseCompletionWindow(); break; case "!": CloseCompletionWindow(); break; } } private (string, int) GetLineAndPosAtCaretPosition() { // this assumes the file was saved as CSLF (\r\n newlines) int offset = UIXML.CaretOffset - 1; int lineStartIdx = UIXML.Text.LastIndexOf('\n', offset); int lineEndIdx = UIXML.Text.IndexOf('\n', offset); string line; int pos; if (lineStartIdx == -1 && lineEndIdx == -1) { line = UIXML.Text; pos = offset; } else if (lineStartIdx == -1) { line = UIXML.Text[..(lineEndIdx - 1)]; pos = offset; } else if (lineEndIdx == -1) { line = UIXML.Text[(lineStartIdx + 1)..]; pos = offset - lineStartIdx - 2; } else { line = UIXML.Text[(lineStartIdx + 1)..(lineEndIdx - 1)]; pos = offset - lineStartIdx - 2; } return (line, pos); } /// /// Source: https://xsemmel.codeplex.com /// /// /// /// public static string? GetElementAtCursor(string xml, int offset, bool onlyAllowInside = false) { if (offset == xml.Length) { offset--; } int startIdx = xml.LastIndexOf('<', offset); if (startIdx < 0) return null; if (startIdx < xml.Length && xml[startIdx + 1] == '/') { startIdx = startIdx + 1; } int endIdx1 = xml.IndexOf(' ', startIdx); if (endIdx1 == -1 /*|| endIdx1 > offset*/) endIdx1 = int.MaxValue; int endIdx2 = xml.IndexOf('>', startIdx); if (endIdx2 == -1 /*|| endIdx2 > offset*/) { endIdx2 = int.MaxValue; } else { if (onlyAllowInside && endIdx2 < offset) return null; // we dont want attribute auto complete to show outside of elements if (endIdx2 < xml.Length && xml[endIdx2 - 1] == '/') { endIdx2 = endIdx2 - 1; } } int endIdx = Math.Min(endIdx1, endIdx2); if (endIdx2 > 0 && endIdx2 < int.MaxValue && endIdx > startIdx) { string element = xml.Substring(startIdx + 1, endIdx - startIdx - 1); return element == "!--" ? null : element; // dont treat comments as elements } else { return null; } } /// /// A space between the cursor and the element will completely cancel this function /// private string? GetElementAtCursorNoSpaces(string xml, int offset) { (string line, int pos) = GetLineAndPosAtCaretPosition(); string curr = ""; while (pos != -1) { char c = line[pos]; if (c == ' ' || c == '\t') return null; if (c == '<') return curr; curr = c + curr; pos--; } return null; } /// /// Returns null if not eligible to auto complete there. /// Returns the name of the element to show the attributes for /// /// private string? ShowAttributesForElementName() { (string line, int pos) = GetLineAndPosAtCaretPosition(); // check if theres an even number of speech marks on the line int numSpeech = line.Count(x => x == '"'); if (numSpeech % 2 == 0) { // we have an equal number, let's check if pos is in between the speech marks int count = -1; int idx = pos; int size = line.Length - 1; while (idx != -1) { count++; if (size > idx + 1) idx = line.IndexOf('"', idx + 1); else idx = -1; } if (count % 2 != 0) { // odd number of speech marks means we're inside a string right now // we dont want to display attribute auto complete while we're inside a string return null; } } return GetElementAtCursor(UIXML.Text, UIXML.CaretOffset, true); } private void AddEndTag() { CloseCompletionWindow(); if (UIXML.Text.Length > 2 && UIXML.Text[UIXML.CaretOffset - 2] == '<') { var elementName = GetElementAtCursor(UIXML.Text, UIXML.CaretOffset - 3); if (elementName == null) return; UIXML.TextArea.Document.Insert(UIXML.CaretOffset, $"{elementName}>"); } else { if (UIXML.Text.Length > UIXML.CaretOffset && UIXML.Text[UIXML.CaretOffset] == '>') return; var elementName = ShowAttributesForElementName(); // re-using functions :) if (elementName != null) UIXML.TextArea.Document.Insert(UIXML.CaretOffset, ">"); } } private void OpenElementAutoComplete() { var data = new List(); foreach (var element in CustomBootstrapperSchema.ElementInfo.Keys) data.Add(new ElementCompletionData(element)); ShowCompletionWindow(data); } private void OpenAttributeAutoComplete() { string? element = ShowAttributesForElementName(); if (element == null) { CloseCompletionWindow(); return; } if (!CustomBootstrapperSchema.ElementInfo.ContainsKey(element)) { CloseCompletionWindow(); return; } var attributes = CustomBootstrapperSchema.ElementInfo[element]; var data = new List(); foreach (var attribute in attributes) data.Add(new AttributeCompletionData(attribute.Key, () => OpenTypeValueAutoComplete(attribute.Value))); ShowCompletionWindow(data); } private void OpenTypeValueAutoComplete(string typeName) { var typeValues = CustomBootstrapperSchema.Types[typeName].Values; if (typeValues == null) return; var data = new List(); foreach (var value in typeValues) data.Add(new TypeValueCompletionData(value)); ShowCompletionWindow(data); } private void OpenPropertyElementAutoComplete() { string? element = GetElementAtCursorNoSpaces(UIXML.Text, UIXML.CaretOffset); if (element == null) { CloseCompletionWindow(); return; } if (!CustomBootstrapperSchema.PropertyElements.ContainsKey(element)) { CloseCompletionWindow(); return; } var properties = CustomBootstrapperSchema.PropertyElements[element]; var data = new List(); foreach (var property in properties) data.Add(new TypeValueCompletionData(property)); ShowCompletionWindow(data); } private void CloseCompletionWindow() { if (_completionWindow != null) { _completionWindow.Close(); _completionWindow = null; } } private void ShowCompletionWindow(List completionData) { CloseCompletionWindow(); if (!completionData.Any()) return; _completionWindow = new CompletionWindow(UIXML.TextArea); IList data = _completionWindow.CompletionList.CompletionData; foreach (var c in completionData) data.Add(c); _completionWindow.Show(); _completionWindow.Closed += (_, _) => _completionWindow = null; } } public class ElementCompletionData : ICompletionData { public ElementCompletionData(string text) { this.Text = text; } public System.Windows.Media.ImageSource? Image => null; public string Text { get; private set; } // Use this property if you want to show a fancy UIElement in the list. public object Content => Text; public object? Description => null; public double Priority { get; } public void Complete(TextArea textArea, ISegment completionSegment, EventArgs insertionRequestEventArgs) { textArea.Document.Replace(completionSegment, this.Text); } } public class AttributeCompletionData : ICompletionData { private Action _openValueAutoCompleteAction; public AttributeCompletionData(string text, Action openValueAutoCompleteAction) { _openValueAutoCompleteAction = openValueAutoCompleteAction; this.Text = text; } public System.Windows.Media.ImageSource? Image => null; public string Text { get; private set; } // Use this property if you want to show a fancy UIElement in the list. public object Content => Text; public object? Description => null; public double Priority { get; } public void Complete(TextArea textArea, ISegment completionSegment, EventArgs insertionRequestEventArgs) { textArea.Document.Replace(completionSegment, this.Text + "=\"\""); textArea.Caret.Offset = textArea.Caret.Offset - 1; _openValueAutoCompleteAction(); } } public class TypeValueCompletionData : ICompletionData { public TypeValueCompletionData(string text) { this.Text = text; } public System.Windows.Media.ImageSource? Image => null; public string Text { get; private set; } // Use this property if you want to show a fancy UIElement in the list. public object Content => Text; public object? Description => null; public double Priority { get; } public void Complete(TextArea textArea, ISegment completionSegment, EventArgs insertionRequestEventArgs) { textArea.Document.Replace(completionSegment, this.Text); } } }