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
This commit is contained in:
Mark van Renswoude 2021-12-25 10:46:59 +01:00
parent 78de8e5196
commit b549729bf5
13 changed files with 253 additions and 52 deletions

View File

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -19,7 +19,7 @@
</PropertyGroup>
<ItemGroup>
<None Remove="Images\Connecting.svg" />
<None Remove="Images\Busy.svg" />
<None Remove="Images\Dock.svg" />
<None Remove="Images\Error.svg" />
<None Remove="Images\Ok.svg" />
@ -40,6 +40,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="AvalonEdit" Version="6.1.2.30" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="SharpVectors" Version="1.7.7" />
<PackageReference Include="SimpleInjector" Version="5.3.2" />
@ -54,7 +55,7 @@
<ItemGroup>
<Resource Include="Images\Undock.svg" />
<Resource Include="Images\Connecting.svg" />
<Resource Include="Images\Busy.svg" />
</ItemGroup>
<ItemGroup>

View File

@ -1,6 +1,7 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="clr-namespace:PettingZoo.UI">
xmlns:ui="clr-namespace:PettingZoo.UI"
xmlns:avalonedit="http://icsharpcode.net/sharpdevelop/avalonedit">
<!-- Global styling -->
<Style x:Key="WindowStyle" TargetType="{x:Type Window}">
<Setter Property="Background" Value="{DynamicResource {x:Static SystemColors.ControlBrushKey}}"/>
@ -83,10 +84,11 @@
</Style>
<Style x:Key="Payload" TargetType="{x:Type TextBox}">
<Setter Property="AcceptsReturn" Value="True" />
<Setter Property="AcceptsTab" Value="True" />
<Setter Property="VerticalScrollBarVisibility" Value="Visible" />
<Style x:Key="Payload" TargetType="{x:Type avalonedit:TextEditor}">
<Setter Property="FontFamily" Value="Consolas,Courier New" />
</Style>
<Style x:Key="ControlBorder" TargetType="{x:Type Border}">
<Setter Property="BorderThickness" Value="1" />
</Style>
</ResourceDictionary>

View File

@ -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;
});
}
}
}

View File

@ -69,6 +69,8 @@ namespace PettingZoo.UI
public void Dispose()
{
BindingOperations.ClearBinding(this, ItemsSourceProperty);
GC.SuppressFinalize(this);
}

View File

@ -79,7 +79,7 @@
<StatusBar DockPanel.Dock="Bottom">
<StatusBarItem>
<StackPanel Orientation="Horizontal">
<Image Source="{svgc:SvgImage Source=/Images/Connecting.svg, AppName=PettingZoo}" Width="16" Height="16" Margin="4" Visibility="{Binding ConnectionStatusConnecting}" />
<Image Source="{svgc:SvgImage Source=/Images/Busy.svg, AppName=PettingZoo}" Width="16" Height="16" Margin="4" Visibility="{Binding ConnectionStatusConnecting}" />
<Image Source="{svgc:SvgImage Source=/Images/Ok.svg, AppName=PettingZoo}" Width="16" Height="16" Margin="4" Visibility="{Binding ConnectionStatusOk}" />
<Image Source="{svgc:SvgImage Source=/Images/Error.svg, AppName=PettingZoo}" Width="16" Height="16" Margin="4" Visibility="{Binding ConnectionStatusError}" />
<TextBlock Text="{Binding ConnectionStatus}" VerticalAlignment="Center"/>

View File

@ -115,6 +115,8 @@ namespace PettingZoo.UI.Main
private static T? GetParent<T>(object originalSource) where T : DependencyObject
{
var current = originalSource as DependencyObject;
if (current is not Visual)
return null;
while (current != null)
{

View File

@ -17,15 +17,23 @@
<RadioButton Content="JSON" Style="{StaticResource TypeSelection}" IsChecked="{Binding ContentTypeSelection, Converter={StaticResource EnumBooleanConverter}, ConverterParameter={x:Static publisher:PayloadEditorContentType.Json}}" />
<RadioButton Content="Plain text" Style="{StaticResource TypeSelection}" IsChecked="{Binding ContentTypeSelection, Converter={StaticResource EnumBooleanConverter}, ConverterParameter={x:Static publisher:PayloadEditorContentType.Plain}}" />
<RadioButton Content="Other" Style="{StaticResource TypeSelection}" IsChecked="{Binding ContentTypeSelection, Converter={StaticResource EnumBooleanConverter}, ConverterParameter={x:Static publisher:PayloadEditorContentType.Other}}" />
<TextBox Width="200" Text="{Binding ContentType, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Name="TextBoxForBorder" Width="200" Text="{Binding ContentType, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
<StackPanel Orientation="Horizontal" DockPanel.Dock="Bottom" Visibility="{Binding JsonValidationVisibility}" Margin="0,8,0,0">
<Image Source="{svgc:SvgImage Source=/Images/Ok.svg, AppName=PettingZoo}" Width="16" Height="16" Margin="4" Visibility="{Binding JsonValidationOk}" />
<Image Source="{svgc:SvgImage Source=/Images/Error.svg, AppName=PettingZoo}" Width="16" Height="16" Margin="4" Visibility="{Binding JsonValidationError}" />
<TextBlock Text="{Binding JsonValidationMessage}" Margin="4" />
<StackPanel Orientation="Horizontal" DockPanel.Dock="Bottom" Visibility="{Binding ValidationVisibility}" Margin="0,8,0,0">
<Image Source="{svgc:SvgImage Source=/Images/Ok.svg, AppName=PettingZoo}" Width="16" Height="16" Margin="4" Visibility="{Binding ValidationOk}" />
<Image Source="{svgc:SvgImage Source=/Images/Error.svg, AppName=PettingZoo}" Width="16" Height="16" Margin="4" Visibility="{Binding ValidationError}" />
<Image Source="{svgc:SvgImage Source=/Images/Busy.svg, AppName=PettingZoo}" Width="16" Height="16" Margin="4" Visibility="{Binding ValidationValidating}" />
<TextBlock Text="{Binding ValidationMessage}" Margin="4" />
</StackPanel>
<TextBox Text="{Binding Payload, UpdateSourceTrigger=PropertyChanged}" Style="{StaticResource Payload}" />
<Border Style="{StaticResource ControlBorder}" Name="EditorBorder">
<avalonedit:TextEditor
xmlns:avalonedit="http://icsharpcode.net/sharpdevelop/avalonedit"
Name="Editor"
SyntaxHighlighting="{Binding SyntaxHighlighting}"
Style="{StaticResource Payload}"
/>
</Border>
</DockPanel>
</UserControl>

View File

@ -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. </rant>
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;

View File

@ -88,20 +88,29 @@ namespace PettingZoo.UI.Tab.Publisher {
}
/// <summary>
/// Looks up a localized string similar to Invalid JSON: {0}.
/// Looks up a localized string similar to Invalid: {0}.
/// </summary>
internal static string JsonValidationError {
internal static string ValidationError {
get {
return ResourceManager.GetString("JsonValidationError", resourceCulture);
return ResourceManager.GetString("ValidationError", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Valid JSON.
/// Looks up a localized string similar to Valid.
/// </summary>
internal static string JsonValidationOk {
internal static string ValidationOk {
get {
return ResourceManager.GetString("JsonValidationOk", resourceCulture);
return ResourceManager.GetString("ValidationOk", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Validating....
/// </summary>
internal static string ValidationValidating {
get {
return ResourceManager.GetString("ValidationValidating", resourceCulture);
}
}
}

View File

@ -112,10 +112,10 @@
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=6.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=6.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="ContentTypeJson" xml:space="preserve">
<value>JSON</value>
@ -126,10 +126,13 @@
<data name="ContentTypePlain" xml:space="preserve">
<value>Plain text</value>
</data>
<data name="JsonValidationError" xml:space="preserve">
<value>Invalid JSON: {0}</value>
<data name="ValidationError" xml:space="preserve">
<value>Invalid: {0}</value>
</data>
<data name="JsonValidationOk" xml:space="preserve">
<value>Valid JSON</value>
<data name="ValidationOk" xml:space="preserve">
<value>Valid</value>
</data>
<data name="ValidationValidating" xml:space="preserve">
<value>Validating...</value>
</data>
</root>

View File

@ -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<PropertyChangedEventHandler, PropertyChangedEventArgs>(
var observable = Observable.FromEventPattern<PropertyChangedEventHandler, PropertyChangedEventArgs>(
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);
}
}
}

View File

@ -0,0 +1,43 @@
using System;
namespace PettingZoo.UI
{
public readonly struct TextPosition : IEquatable<TextPosition>
{
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);
}
}
}