From b549729bf5b73508ef6f75a1451c6d9944ee2296 Mon Sep 17 00:00:00 2001 From: Mark van Renswoude Date: Sat, 25 Dec 2021 10:46:59 +0100 Subject: [PATCH] Changed payload editor to Avalon - Syntax highlighting - Highlight for JSON validation errors - Much improved tab handling and all the other advantages of using a proper editor --- .../Images/{Connecting.svg => Busy.svg} | 0 PettingZoo/PettingZoo.csproj | 5 +- PettingZoo/Style.xaml | 12 +- PettingZoo/UI/ErrorHighlightingTransformer.cs | 37 ++++++ PettingZoo/UI/ListBoxAutoScroll.cs | 2 + PettingZoo/UI/Main/MainWindow.xaml | 2 +- PettingZoo/UI/Main/MainWindow.xaml.cs | 2 + .../Tab/Publisher/PayloadEditorControl.xaml | 20 +++- .../Publisher/PayloadEditorControl.xaml.cs | 39 +++++++ .../PayloadEditorStrings.Designer.cs | 21 +++- .../Tab/Publisher/PayloadEditorStrings.resx | 15 ++- .../Tab/Publisher/PayloadEditorViewModel.cs | 107 +++++++++++++----- PettingZoo/UI/TextPosition.cs | 43 +++++++ 13 files changed, 253 insertions(+), 52 deletions(-) rename PettingZoo/Images/{Connecting.svg => Busy.svg} (100%) create mode 100644 PettingZoo/UI/ErrorHighlightingTransformer.cs create mode 100644 PettingZoo/UI/TextPosition.cs diff --git a/PettingZoo/Images/Connecting.svg b/PettingZoo/Images/Busy.svg similarity index 100% rename from PettingZoo/Images/Connecting.svg rename to PettingZoo/Images/Busy.svg diff --git a/PettingZoo/PettingZoo.csproj b/PettingZoo/PettingZoo.csproj index cc761f0..d500398 100644 --- a/PettingZoo/PettingZoo.csproj +++ b/PettingZoo/PettingZoo.csproj @@ -19,7 +19,7 @@ - + @@ -40,6 +40,7 @@ + @@ -54,7 +55,7 @@ - + diff --git a/PettingZoo/Style.xaml b/PettingZoo/Style.xaml index f26734e..a59e8d4 100644 --- a/PettingZoo/Style.xaml +++ b/PettingZoo/Style.xaml @@ -1,6 +1,7 @@  + xmlns:ui="clr-namespace:PettingZoo.UI" + xmlns:avalonedit="http://icsharpcode.net/sharpdevelop/avalonedit"> - + + \ No newline at end of file diff --git a/PettingZoo/UI/ErrorHighlightingTransformer.cs b/PettingZoo/UI/ErrorHighlightingTransformer.cs new file mode 100644 index 0000000..400337a --- /dev/null +++ b/PettingZoo/UI/ErrorHighlightingTransformer.cs @@ -0,0 +1,37 @@ +using System; +using System.Windows.Media; +using ICSharpCode.AvalonEdit.Document; +using ICSharpCode.AvalonEdit.Rendering; + +namespace PettingZoo.UI +{ + public class ErrorHighlightingTransformer : DocumentColorizingTransformer + { + public Brush BackgroundBrush { get; set; } + public TextPosition? ErrorPosition { get; set; } + + + public ErrorHighlightingTransformer() + { + BackgroundBrush = new SolidColorBrush(Color.FromRgb(255, 230, 230)); + } + + + protected override void ColorizeLine(DocumentLine line) + { + if (ErrorPosition == null) + return; + + if (line.LineNumber != Math.Max(ErrorPosition.Value.Row, 1)) + return; + + var lineStartOffset = line.Offset; + + ChangeLinePart(lineStartOffset, lineStartOffset + line.Length, + element => + { + element.BackgroundBrush = BackgroundBrush; + }); + } + } +} diff --git a/PettingZoo/UI/ListBoxAutoScroll.cs b/PettingZoo/UI/ListBoxAutoScroll.cs index b378e3b..2cbba52 100644 --- a/PettingZoo/UI/ListBoxAutoScroll.cs +++ b/PettingZoo/UI/ListBoxAutoScroll.cs @@ -69,6 +69,8 @@ namespace PettingZoo.UI public void Dispose() { BindingOperations.ClearBinding(this, ItemsSourceProperty); + + GC.SuppressFinalize(this); } diff --git a/PettingZoo/UI/Main/MainWindow.xaml b/PettingZoo/UI/Main/MainWindow.xaml index a5898da..b8efb72 100644 --- a/PettingZoo/UI/Main/MainWindow.xaml +++ b/PettingZoo/UI/Main/MainWindow.xaml @@ -79,7 +79,7 @@ - + diff --git a/PettingZoo/UI/Main/MainWindow.xaml.cs b/PettingZoo/UI/Main/MainWindow.xaml.cs index fda439b..10a1e0e 100644 --- a/PettingZoo/UI/Main/MainWindow.xaml.cs +++ b/PettingZoo/UI/Main/MainWindow.xaml.cs @@ -115,6 +115,8 @@ namespace PettingZoo.UI.Main private static T? GetParent(object originalSource) where T : DependencyObject { var current = originalSource as DependencyObject; + if (current is not Visual) + return null; while (current != null) { diff --git a/PettingZoo/UI/Tab/Publisher/PayloadEditorControl.xaml b/PettingZoo/UI/Tab/Publisher/PayloadEditorControl.xaml index 9e45427..74e5dee 100644 --- a/PettingZoo/UI/Tab/Publisher/PayloadEditorControl.xaml +++ b/PettingZoo/UI/Tab/Publisher/PayloadEditorControl.xaml @@ -17,15 +17,23 @@ - + - - - - + + + + + - + + + diff --git a/PettingZoo/UI/Tab/Publisher/PayloadEditorControl.xaml.cs b/PettingZoo/UI/Tab/Publisher/PayloadEditorControl.xaml.cs index 8fd4b99..68094b9 100644 --- a/PettingZoo/UI/Tab/Publisher/PayloadEditorControl.xaml.cs +++ b/PettingZoo/UI/Tab/Publisher/PayloadEditorControl.xaml.cs @@ -86,6 +86,9 @@ namespace PettingZoo.UI.Tab.Publisher } } + + private readonly ErrorHighlightingTransformer errorHighlightingTransformer = new(); + public PayloadEditorControl() { // Keep the exposed properties in sync with the ViewModel @@ -133,6 +136,42 @@ namespace PettingZoo.UI.Tab.Publisher InitializeComponent(); + // I'm not sure how to get a standard control border, all I could find were workaround: + // https://social.msdn.microsoft.com/Forums/en-US/5e007497-8d5a-401d-ac5b-9e1356fe9b64/default-borderbrush-for-textbox-listbox-etc + // So I'll just copy it from another TextBox. I truly hate WPF some times for making standard things so complicated. + EditorBorder.BorderBrush = TextBoxForBorder.BorderBrush; + + Editor.Options.IndentationSize = 2; + Editor.TextArea.TextView.LineTransformers.Add(errorHighlightingTransformer); + + // Avalon doesn't play nice with bindings it seems: + // https://stackoverflow.com/questions/18964176/two-way-binding-to-avalonedit-document-text-using-mvvm + Editor.Document.Text = Payload; + + Editor.TextChanged += (_, _) => + { + Payload = Editor.Document.Text; + }; + + + viewModel.PropertyChanged += (_, args) => + { + if (args.PropertyName != nameof(viewModel.ValidationInfo)) + return; + + Dispatcher.Invoke(() => + { + if (errorHighlightingTransformer.ErrorPosition == viewModel.ValidationInfo.ErrorPosition) + return; + + errorHighlightingTransformer.ErrorPosition = viewModel.ValidationInfo.ErrorPosition; + + // TODO this can probably be optimized to only redraw the affected line + Editor.TextArea.TextView.Redraw(); + }); + }; + + // Setting the DataContext for the UserControl is a major PITA when binding the control's properties, // so I've moved the ViewModel one level down to get the best of both worlds... DataContextContainer.DataContext = viewModel; diff --git a/PettingZoo/UI/Tab/Publisher/PayloadEditorStrings.Designer.cs b/PettingZoo/UI/Tab/Publisher/PayloadEditorStrings.Designer.cs index aff3adc..24b2de1 100644 --- a/PettingZoo/UI/Tab/Publisher/PayloadEditorStrings.Designer.cs +++ b/PettingZoo/UI/Tab/Publisher/PayloadEditorStrings.Designer.cs @@ -88,20 +88,29 @@ namespace PettingZoo.UI.Tab.Publisher { } /// - /// Looks up a localized string similar to Invalid JSON: {0}. + /// Looks up a localized string similar to Invalid: {0}. /// - internal static string JsonValidationError { + internal static string ValidationError { get { - return ResourceManager.GetString("JsonValidationError", resourceCulture); + return ResourceManager.GetString("ValidationError", resourceCulture); } } /// - /// Looks up a localized string similar to Valid JSON. + /// Looks up a localized string similar to Valid. /// - internal static string JsonValidationOk { + internal static string ValidationOk { get { - return ResourceManager.GetString("JsonValidationOk", resourceCulture); + return ResourceManager.GetString("ValidationOk", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Validating.... + /// + internal static string ValidationValidating { + get { + return ResourceManager.GetString("ValidationValidating", resourceCulture); } } } diff --git a/PettingZoo/UI/Tab/Publisher/PayloadEditorStrings.resx b/PettingZoo/UI/Tab/Publisher/PayloadEditorStrings.resx index 0ae56b9..5523515 100644 --- a/PettingZoo/UI/Tab/Publisher/PayloadEditorStrings.resx +++ b/PettingZoo/UI/Tab/Publisher/PayloadEditorStrings.resx @@ -112,10 +112,10 @@ 2.0 - System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceReader, System.Windows.Forms, Version=6.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=6.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 JSON @@ -126,10 +126,13 @@ Plain text - - Invalid JSON: {0} + + Invalid: {0} - - Valid JSON + + Valid + + + Validating... \ No newline at end of file diff --git a/PettingZoo/UI/Tab/Publisher/PayloadEditorViewModel.cs b/PettingZoo/UI/Tab/Publisher/PayloadEditorViewModel.cs index 1eb0149..fb4ed5a 100644 --- a/PettingZoo/UI/Tab/Publisher/PayloadEditorViewModel.cs +++ b/PettingZoo/UI/Tab/Publisher/PayloadEditorViewModel.cs @@ -1,8 +1,9 @@ using System; using System.ComponentModel; using System.Reactive.Linq; -using System.Reactive.Subjects; using System.Windows; +using ICSharpCode.AvalonEdit.Highlighting; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; namespace PettingZoo.UI.Tab.Publisher @@ -15,6 +16,38 @@ namespace PettingZoo.UI.Tab.Publisher }; + public enum ValidationStatus + { + NotSupported, + Validating, + Ok, + Error + } + + + public readonly struct ValidationInfo + { + public ValidationStatus Status { get; } + public string Message { get; } + public TextPosition? ErrorPosition { get; } + + + public ValidationInfo(ValidationStatus status, string? message = null, TextPosition? errorPosition = null) + { + Status = status; + Message = message ?? status switch + { + ValidationStatus.NotSupported => "", + ValidationStatus.Validating => PayloadEditorStrings.ValidationValidating, + ValidationStatus.Ok => PayloadEditorStrings.ValidationOk, + ValidationStatus.Error => throw new InvalidOperationException(@"Message required for Error validation status"), + _ => throw new ArgumentException(@"Unsupported validation status", nameof(status)) + }; + ErrorPosition = errorPosition; + } + } + + public class PayloadEditorViewModel : BaseViewModel { private const string ContentTypeJson = "application/json"; @@ -24,8 +57,7 @@ namespace PettingZoo.UI.Tab.Publisher private PayloadEditorContentType contentTypeSelection = PayloadEditorContentType.Json; private bool fixedJson; - private bool jsonValid = true; - private string jsonValidationMessage; + private ValidationInfo validationInfo = new(ValidationStatus.Ok); private string payload = ""; @@ -59,7 +91,7 @@ namespace PettingZoo.UI.Tab.Publisher get => contentTypeSelection; set { - if (!SetField(ref contentTypeSelection, value, otherPropertiesChanged: new [] { nameof(JsonValidationVisibility) })) + if (!SetField(ref contentTypeSelection, value, otherPropertiesChanged: new [] { nameof(ValidationVisibility), nameof(SyntaxHighlighting) })) return; ContentType = ContentTypeSelection switch @@ -80,23 +112,22 @@ namespace PettingZoo.UI.Tab.Publisher set => SetField(ref fixedJson, value); } - public Visibility JsonValidationVisibility => ContentTypeSelection == PayloadEditorContentType.Json ? Visibility.Visible : Visibility.Collapsed; - public Visibility JsonValidationOk => JsonValid ? Visibility.Visible : Visibility.Collapsed; - public Visibility JsonValidationError => !JsonValid ? Visibility.Visible : Visibility.Collapsed; - - public string JsonValidationMessage + public ValidationInfo ValidationInfo { - get => jsonValidationMessage; - private set => SetField(ref jsonValidationMessage, value); + get => validationInfo; + private set => SetField(ref validationInfo, value, otherPropertiesChanged: new[] { nameof(ValidationOk), nameof(ValidationError), nameof(ValidationValidating), nameof(ValidationMessage) }); } - public bool JsonValid - { - get => jsonValid; - private set => SetField(ref jsonValid, value, otherPropertiesChanged: new[] { nameof(JsonValidationOk), nameof(JsonValidationError) }); - } + public Visibility ValidationVisibility => ContentTypeSelection == PayloadEditorContentType.Json ? Visibility.Visible : Visibility.Collapsed; + + public string ValidationMessage => ValidationInfo.Message; + + public Visibility ValidationOk => ValidationInfo.Status == ValidationStatus.Ok ? Visibility.Visible : Visibility.Collapsed; + public Visibility ValidationError => ValidationInfo.Status == ValidationStatus.Error ? Visibility.Visible : Visibility.Collapsed; + public Visibility ValidationValidating => ValidationInfo.Status == ValidationStatus.Validating ? Visibility.Visible : Visibility.Collapsed; + public Visibility ContentTypeVisibility => FixedJson ? Visibility.Collapsed : Visibility.Visible; @@ -108,26 +139,48 @@ namespace PettingZoo.UI.Tab.Publisher } + public IHighlightingDefinition? SyntaxHighlighting => ContentTypeSelection == PayloadEditorContentType.Json + ? HighlightingManager.Instance.GetDefinition(@"Json") + : null; + + public PayloadEditorViewModel() { - jsonValidationMessage = PayloadEditorStrings.JsonValidationOk; - - Observable.FromEventPattern( + var observable = Observable.FromEventPattern( h => PropertyChanged += h, h => PropertyChanged -= h) - .Where(e => e.EventArgs.PropertyName == nameof(Payload)) + .Where(e => e.EventArgs.PropertyName == nameof(Payload)); + + observable + .Subscribe(_ => ValidatingPayload()); + + observable .Throttle(TimeSpan.FromMilliseconds(500)) .Subscribe(_ => ValidatePayload()); } + private void ValidatingPayload() + { + if (ValidationInfo.Status == ValidationStatus.Validating) + return; + + if (ContentTypeSelection != PayloadEditorContentType.Json) + { + ValidationInfo = new ValidationInfo(ValidationStatus.NotSupported); + return; + } + + ValidationInfo = new ValidationInfo(ValidationStatus.Validating); + } + + private void ValidatePayload() { if (ContentTypeSelection != PayloadEditorContentType.Json) { - JsonValid = true; - JsonValidationMessage = PayloadEditorStrings.JsonValidationOk; + ValidationInfo = new ValidationInfo(ValidationStatus.NotSupported); return; } @@ -136,13 +189,15 @@ namespace PettingZoo.UI.Tab.Publisher if (!string.IsNullOrEmpty(Payload)) JToken.Parse(Payload); - JsonValid = true; - JsonValidationMessage = PayloadEditorStrings.JsonValidationOk; + ValidationInfo = new ValidationInfo(ValidationStatus.Ok); + } + catch (JsonReaderException e) + { + ValidationInfo = new ValidationInfo(ValidationStatus.Error, e.Message, new TextPosition(e.LineNumber, e.LinePosition)); } catch (Exception e) { - JsonValid = false; - JsonValidationMessage = string.Format(PayloadEditorStrings.JsonValidationError, e.Message); + ValidationInfo = new ValidationInfo(ValidationStatus.Error, e.Message); } } } diff --git a/PettingZoo/UI/TextPosition.cs b/PettingZoo/UI/TextPosition.cs new file mode 100644 index 0000000..ed679eb --- /dev/null +++ b/PettingZoo/UI/TextPosition.cs @@ -0,0 +1,43 @@ +using System; + +namespace PettingZoo.UI +{ + public readonly struct TextPosition : IEquatable + { + public int Row { get; } + public int Column { get; } + + + public TextPosition(int row, int column) + { + Row = row; + Column = column; + } + + + public bool Equals(TextPosition other) + { + return Row == other.Row && Column == other.Column; + } + + public override bool Equals(object? obj) + { + return obj is TextPosition other && Equals(other); + } + + public override int GetHashCode() + { + return HashCode.Combine(Row, Column); + } + + public static bool operator ==(TextPosition left, TextPosition right) + { + return left.Equals(right); + } + + public static bool operator !=(TextPosition left, TextPosition right) + { + return !(left == right); + } + } +}