From 3a923e2614009a6690eac98f122bd46779e47910 Mon Sep 17 00:00:00 2001 From: bluepilledgreat <97983689+bluepilledgreat@users.noreply.github.com> Date: Sun, 20 Oct 2024 15:51:21 +0100 Subject: [PATCH] add auto complete --- Bloxstrap/Bloxstrap.csproj | 1 + Bloxstrap/Resource.cs | 5 + .../Resources/CustomBootstrapperSchema.json | 251 ++++++++++++ .../Editor/BootstrapperEditorWindow.xaml.cs | 385 +++++++++++++++++- 4 files changed, 641 insertions(+), 1 deletion(-) create mode 100644 Bloxstrap/Resources/CustomBootstrapperSchema.json diff --git a/Bloxstrap/Bloxstrap.csproj b/Bloxstrap/Bloxstrap.csproj index d92799b..6176869 100644 --- a/Bloxstrap/Bloxstrap.csproj +++ b/Bloxstrap/Bloxstrap.csproj @@ -28,6 +28,7 @@ + diff --git a/Bloxstrap/Resource.cs b/Bloxstrap/Resource.cs index 46c8e82..612d846 100644 --- a/Bloxstrap/Resource.cs +++ b/Bloxstrap/Resource.cs @@ -21,5 +21,10 @@ namespace Bloxstrap await stream.CopyToAsync(memoryStream); return memoryStream.ToArray(); } + + public static async Task GetString(string name) + { + return Encoding.UTF8.GetString(await Get(name)); + } } } diff --git a/Bloxstrap/Resources/CustomBootstrapperSchema.json b/Bloxstrap/Resources/CustomBootstrapperSchema.json new file mode 100644 index 0000000..c00d3c5 --- /dev/null +++ b/Bloxstrap/Resources/CustomBootstrapperSchema.json @@ -0,0 +1,251 @@ +{ + "Elements": { + "FrameworkElement": { + "IsCreatable": false, + "Attributes": { + "Name": "string", + "Visibility": "Visibility", + "IsEnabled": "bool", + "Margin": "Thickness", + "Height": "double", + "Width": "double", + "HorizontalAlignment": "HorizontalAlignment", + "VerticalAlignment": "VerticalAlignment", + "ZIndex": "int" + } + }, + "Control": { + "SuperClass": "FrameworkElement", + "IsCreatable": false, + "Attributes": { + "Padding": "Thickness", + "BorderThickness": "Thickness", + "Foreground": "Brush", + "Background": "Brush", + "BorderBrush": "Brush" + } + }, + "BloxstrapCustomBootstrapper": { + "SuperClass": "Control", + "IsCreatable": true, + "Attributes": { + "Theme": "Theme" + } + }, + "TitleBar": { + "SuperClass": "Control", + "IsCreatable": true, + "Attributes": { + "ShowMinimize": "bool", + "ShowMaximize": "bool", + "Title": "string" + } + }, + "Button": { + "SuperClass": "Control", + "IsCreatable": true, + "Attributes": { + "Content": "Content" + } + }, + "ProgressBar": { + "SuperClass": "Control", + "IsCreatable": true, + "Attributes": { + "IsIndeterminate": "bool", + "Value": "double", + "Maximum": "double" + } + }, + "TextBlock": { + "SuperClass": "FrameworkElement", + "IsCreatable": true, + "Attributes": { + "Text": "string", + "Foreground": "Brush", + "Background": "Brush", + "FontSize": "double", + "FontWeight": "FontWeight", + "FontStyle": "FontStyle", + "LineHeight": "double", + "LineStackingStrategy": "LineStackingStrategy", + "TextAlignment": "TextAlignment", + "TextTrimming": "TextTrimming", + "TextWrapping": "TextWrapping", + "TextDecorations": "TextDecorations", + "IsHyphenationEnabled": "bool", + "BaselineOffset": "double", + "Padding": "Thickness" + } + }, + "MarkdownTextBlock": { + "SuperClass": "TextBlock", + "IsCreatable": true, + "Attributes": {} + }, + "Image": { + "SuperClass": "FrameworkElement", + "IsCreatable": true, + "Attributes": { + "Stretch": "Stretch", + "StretchDirection": "StretchDirection", + "Source": "string", + "IsAnimated": "bool" + } + }, + "ScaleTransform": { + "IsCreatable": true, + "Attributes": { + "ScaleX": "double", + "ScaleY": "double", + "CenterX": "double", + "CenterY": "double" + } + }, + "SkewTransform": { + "IsCreatable": true, + "Attributes": { + "AngleX": "double", + "AngleY": "double", + "CenterX": "double", + "CenterY": "double" + } + }, + "RotateTransform": { + "IsCreatable": true, + "Attributes": { + "Angle": "double", + "CenterX": "double", + "CenterY": "double" + } + }, + "TranslateTransform": { + "IsCreatable": true, + "Attributes": { + "X": "double", + "Y": "double" + } + }, + "RenderTransform": { + "IsCreatable": true, + "Attributes": {} + }, + "Content": { + "IsCreatable": true, + "Attributes": {} + } + }, + "Types": { + "string": {}, + "bool": { + "Values": [ + "True", + "False" + ] + }, + "int": {}, + "double": {}, + "Thickness": {}, + "Brush": {}, + "Content": {}, + "Visibility": { + "Values": [ + "Visible", + "Hidden", + "Collapsed" + ] + }, + "HorizontalAlignment": { + "Values": [ + "Left", + "Center", + "Right", + "Stretch" + ] + }, + "VerticalAlignment": { + "Values": [ + "Top", + "Center", + "Bottom", + "Stretch" + ] + }, + "Theme": { + "Values": [ + "Default", + "Dark", + "Light" + ] + }, + "FontWeight": { + "Values": [ + "Thin", + "ExtraLight", + "UltraLight", + "Medium", + "Normal", + "Regular", + "DemiBold", + "SemiBold", + "Bold", + "ExtraBold", + "UltraBold", + "Black", + "Heavy", + "ExtraBlack", + "ExtraHeavy" + ] + }, + "FontStyle": { + "Values": [ + "Normal", + "Italic", + "Oblique" + ] + }, + "LineStackingStrategy": { + "Values": [ + "BlockLineHeight", + "MaxHeight" + ] + }, + "TextTrimming": { + "Values": [ + "None", + "CharacterEllipsis", + "WordEllipsis" + ] + }, + "TextWrapping": { + "Values": [ + "WrapWithOverflow", + "NoWrap", + "Wrap" + ] + }, + "TextDecorations": { + "Values": [ + "Baseline", + "OverLine", + "Strikethrough", + "Underline" + ] + }, + "Stretch": { + "Values": [ + "None", + "Fill", + "Uniform", + "UniformToFill" + ] + }, + "StretchDirection": { + "Values": [ + "UpOnly", + "DownOnly", + "Both" + ] + } + } +} \ No newline at end of file diff --git a/Bloxstrap/UI/Elements/Editor/BootstrapperEditorWindow.xaml.cs b/Bloxstrap/UI/Elements/Editor/BootstrapperEditorWindow.xaml.cs index df75b5e..03987ea 100644 --- a/Bloxstrap/UI/Elements/Editor/BootstrapperEditorWindow.xaml.cs +++ b/Bloxstrap/UI/Elements/Editor/BootstrapperEditorWindow.xaml.cs @@ -1,4 +1,10 @@ -using Bloxstrap.UI.Elements.Base; +using System.Windows.Input; + +using ICSharpCode.AvalonEdit.CodeCompletion; +using ICSharpCode.AvalonEdit.Document; +using ICSharpCode.AvalonEdit.Editing; + +using Bloxstrap.UI.Elements.Base; using Bloxstrap.UI.ViewModels.Editor; namespace Bloxstrap.UI.Elements.Editor @@ -8,8 +14,98 @@ namespace Bloxstrap.UI.Elements.Editor /// 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 List? Values { get; set; } = null; + } + + private static Schema? _schema; + + /// + /// Elements and their attributes + /// + public static Dictionary> ElementInfo { get; set; } = new(); + + /// + /// All type info + /// + public static Dictionary 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"); + + Types = _schema.Types; + PopulateElementInfo(); + } + + private static Dictionary GetElementAttributes(string name, Element element) + { + if (ElementInfo.ContainsKey(name)) + return ElementInfo[name]; + + Dictionary attributes = new(); + + foreach (var attribute in element.Attributes) + attributes.Add(attribute.Key, attribute.Value); + + if (element.SuperClass != null) + { + foreach (var attribute in GetElementAttributes(element.SuperClass, _schema!.Elements[element.SuperClass])) + attributes.Add(attribute.Key, attribute.Value); + } + + return attributes; + } + + private static void PopulateElementInfo() + { + List toRemove = new List(); + + foreach (var element in _schema!.Elements) + { + ElementInfo[element.Key] = 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); + } + } + } + + CompletionWindow? _completionWindow = null; + public BootstrapperEditorWindow(string name) { + CustomBootstrapperSchema.ParseSchema(); + var viewModel = new BootstrapperEditorWindowViewModel(); viewModel.Name = name; viewModel.Title = $"Editing \"{name}\""; @@ -19,6 +115,7 @@ namespace Bloxstrap.UI.Elements.Editor InitializeComponent(); UIXML.Text = viewModel.Code; + UIXML.TextArea.TextEntered += OnTextAreaTextEntered; } private void OnCodeChanged(object sender, EventArgs e) @@ -27,5 +124,291 @@ namespace Bloxstrap.UI.Elements.Editor viewModel.Code = UIXML.Text; viewModel.OnPropertyChanged(nameof(viewModel.Code)); } + + private void OnTextAreaTextEntered(object sender, TextCompositionEventArgs e) + { + switch (e.Text) + { + case "<": + OpenElementAutoComplete(); + break; + case " ": + OpenAttributeAutoComplete(); + break; + case "/": + CloseCompletionWindow(); + break; + case ">": + CloseCompletionWindow(); + break; + } + } + + private (string, int) GetLineAndPosAtCaretPosition() + { + // this assumes the file was saved as CSLF (\r\n newlines) + int lineStartIdx = UIXML.Text.LastIndexOf('\n', UIXML.CaretOffset); + int lineEndIdx = UIXML.Text.IndexOf('\n', UIXML.CaretOffset); + + string line; + int pos; + if (lineStartIdx == -1 && lineEndIdx == -1) + { + line = UIXML.Text; + pos = UIXML.CaretOffset; + } + else if (lineStartIdx == -1) + { + line = UIXML.Text[..(lineEndIdx - 1)]; + pos = UIXML.CaretOffset; + } + else if (lineEndIdx == -1) + { + line = UIXML.Text[(lineStartIdx + 1)..]; + pos = UIXML.CaretOffset - lineStartIdx - 2; + } + else + { + line = UIXML.Text[(lineStartIdx + 1)..(lineEndIdx - 1)]; + pos = UIXML.CaretOffset - lineStartIdx - 2; + } + + return (line, pos); + } + + /// + /// Source: https://xsemmel.codeplex.com + /// + /// + /// + /// + public static string? GetElementAtCursor(string xml, int offset) + { + 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 (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) + { + return xml.Substring(startIdx + 1, endIdx - startIdx - 1); + } + else + { + 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; + while (idx != -1) + { + count++; + idx = line.IndexOf('"', 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); + } + + 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 CloseCompletionWindow() + { + if (_completionWindow != null) + { + _completionWindow.Close(); + _completionWindow = null; + } + } + + private void ShowCompletionWindow(List completionData) + { + CloseCompletionWindow(); + + _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); + } } }