Parcourir la source

Merge pull request #10368 from pr8x/devtools-property-editor

DevTools: type-based property editors
Max Katz il y a 2 ans
Parent
commit
e2a1311e30

+ 90 - 0
src/Avalonia.Diagnostics/Diagnostics/Controls/BrushEditor.cs

@@ -0,0 +1,90 @@
+using System.Globalization;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Input;
+using Avalonia.Media;
+using Avalonia.Media.Immutable;
+
+namespace Avalonia.Diagnostics.Controls
+{
+    internal sealed class BrushEditor : Control
+    {
+        /// <summary>
+        ///     Defines the <see cref="Brush" /> property.
+        /// </summary>
+        public static readonly DirectProperty<BrushEditor, IBrush?> BrushProperty =
+            AvaloniaProperty.RegisterDirect<BrushEditor, IBrush?>(
+                nameof(Brush), o => o.Brush, (o, v) => o.Brush = v);
+
+        private IBrush? _brush;
+
+        public IBrush? Brush
+        {
+            get => _brush;
+            set => SetAndRaise(BrushProperty, ref _brush, value);
+        }
+
+        protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+        {
+            base.OnPropertyChanged(change);
+
+            if (change.Property == BrushProperty)
+            {
+                switch (Brush)
+                {
+                    case ISolidColorBrush scb:
+                    {
+                        var colorView = new ColorView { Color = scb.Color };
+
+                        colorView.ColorChanged += (_, e) => Brush = new ImmutableSolidColorBrush(e.NewColor);
+
+                        FlyoutBase.SetAttachedFlyout(this, new Flyout { Content = colorView });
+                        ToolTip.SetTip(this, $"{scb.Color} ({Brush.GetType().Name})");
+
+                        break;
+                    }
+
+                    default:
+
+                        FlyoutBase.SetAttachedFlyout(this, null);
+                        ToolTip.SetTip(this, Brush?.GetType().Name ?? "(null)");
+
+                        break;
+                }
+
+                InvalidateVisual();
+            }
+        }
+
+        protected override void OnPointerPressed(PointerPressedEventArgs e)
+        {
+            base.OnPointerPressed(e);
+
+            FlyoutBase.ShowAttachedFlyout(this);
+        }
+
+        public override void Render(DrawingContext context)
+        {
+            base.Render(context);
+
+            if (Brush != null)
+            {
+                context.FillRectangle(Brush, Bounds);
+            }
+            else
+            {
+                context.FillRectangle(Brushes.Black, Bounds);
+
+                var ft = new FormattedText("(null)",
+                    CultureInfo.CurrentCulture,
+                    FlowDirection.LeftToRight,
+                    Typeface.Default,
+                    10,
+                    Brushes.White);
+
+                context.DrawText(ft, 
+                    new Point(Bounds.Width / 2 - ft.Width / 2, Bounds.Height / 2 - ft.Height / 2));
+            }
+        }
+    }
+}

+ 89 - 0
src/Avalonia.Diagnostics/Diagnostics/Controls/CommitTextBox.cs

@@ -0,0 +1,89 @@
+using System;
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Styling;
+
+namespace Avalonia.Diagnostics.Controls
+{
+    //TODO: UpdateSourceTrigger & Binding.ValidationRules could help removing the need for this control.
+    internal sealed class CommitTextBox : TextBox, IStyleable
+    {
+        Type IStyleable.StyleKey => typeof(TextBox);
+
+        /// <summary>
+        ///     Defines the <see cref="CommittedText" /> property.
+        /// </summary>
+        public static readonly DirectProperty<CommitTextBox, string?> CommittedTextProperty =
+            AvaloniaProperty.RegisterDirect<CommitTextBox, string?>(
+                nameof(CommittedText), o => o.CommittedText, (o, v) => o.CommittedText = v);
+
+        private string? _committedText;
+
+        public string? CommittedText
+        {
+            get => _committedText;
+            set => SetAndRaise(CommittedTextProperty, ref _committedText, value);
+        }
+
+        protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
+        {
+            base.OnPropertyChanged(change);
+
+            if (change.Property == CommittedTextProperty)
+            {
+                Text = CommittedText;
+            }
+        }
+
+        protected override void OnKeyUp(KeyEventArgs e)
+        {
+            base.OnKeyUp(e);
+
+            switch (e.Key)
+            {
+                case Key.Enter:
+
+                    TryCommit();
+
+                    e.Handled = true;
+
+                    break;
+
+                case Key.Escape:
+
+                    Cancel();
+
+                    e.Handled = true;
+
+                    break;
+            }
+        }
+
+        protected override void OnLostFocus(RoutedEventArgs e)
+        {
+            base.OnLostFocus(e);
+
+            TryCommit();
+        }
+
+        private void Cancel()
+        {
+            Text = CommittedText;
+            DataValidationErrors.ClearErrors(this);
+        }
+
+        private void TryCommit()
+        {
+            if (!DataValidationErrors.GetHasErrors(this))
+            {
+                CommittedText = Text;
+            }
+            else
+            {
+                Text = CommittedText;
+                DataValidationErrors.ClearErrors(this);
+            }
+        }
+    }
+}

+ 4 - 4
src/Avalonia.Diagnostics/Diagnostics/ViewModels/AvaloniaPropertyViewModel.cs

@@ -35,15 +35,14 @@ namespace Avalonia.Diagnostics.ViewModels
         public override string Priority => _priority;
         public override Type AssignedType => _assignedType;
 
-        public override string? Value
+        public override object? Value
         {
-            get => ConvertToString(_value);
+            get => _value;
             set
             {
                 try
                 {
-                    var convertedValue = ConvertFromString(value, Property.PropertyType);
-                    _target.SetValue(Property, convertedValue);
+                    _target.SetValue(Property, value);
                     Update();
                 }
                 catch { }
@@ -54,6 +53,7 @@ namespace Avalonia.Diagnostics.ViewModels
 
         public override Type? DeclaringType { get; }
         public override Type PropertyType => _propertyType;
+        public override bool IsReadonly => Property.IsReadOnly;
 
         // [MemberNotNull(nameof(_type), nameof(_group), nameof(_priority))]
         public override void Update()

+ 4 - 4
src/Avalonia.Diagnostics/Diagnostics/ViewModels/ClrPropertyViewModel.cs

@@ -40,16 +40,16 @@ namespace Avalonia.Diagnostics.ViewModels
 
         public override Type AssignedType => _assignedType;
         public override Type PropertyType => _propertyType;
+        public override bool IsReadonly => !Property.CanWrite;
 
-        public override string? Value
+        public override object? Value
         {
-            get => ConvertToString(_value);
+            get => _value;
             set
             {
                 try
                 {
-                    var convertedValue = ConvertFromString(value, Property.PropertyType);
-                    Property.SetValue(_target, convertedValue);
+                    Property.SetValue(_target, value);
                     Update();
                 }
                 catch { }

+ 5 - 62
src/Avalonia.Diagnostics/Diagnostics/ViewModels/PropertyViewModel.cs

@@ -7,78 +7,21 @@ namespace Avalonia.Diagnostics.ViewModels
 {
     internal abstract class PropertyViewModel : ViewModelBase
     {
-        private const BindingFlags PublicStatic = BindingFlags.Public | BindingFlags.Static;
-        private static readonly Type[] StringParameter = { typeof(string) };
-        private static readonly Type[] StringIFormatProviderParameters = { typeof(string), typeof(IFormatProvider) };
-
         public abstract object Key { get; }
         public abstract string Name { get; }
         public abstract string Group { get; }
         public abstract Type AssignedType { get; }
         public abstract Type? DeclaringType { get; }
-        public abstract string? Value { get; set; }
+        public abstract object? Value { get; set; }
         public abstract string Priority { get; }
         public abstract bool? IsAttached { get; }
         public abstract void Update();
         public abstract Type PropertyType { get; }
-        public string Type => PropertyType == AssignedType
-            ? PropertyType.GetTypeName()
-            : $"{PropertyType.GetTypeName()} {{{AssignedType.GetTypeName()}}}";
-
-
-        protected static string? ConvertToString(object? value)
-        {
-            if (value is null)
-            {
-                return "(null)";
-            }
-
-            var converter = TypeDescriptor.GetConverter(value);
-
-            //CollectionConverter does not deliver any important information. It just displays "(Collection)".
-            if (!converter.CanConvertTo(typeof(string)) ||
-                converter.GetType() == typeof(CollectionConverter))
-            {
-                return value.ToString() ?? "(null)";
-            }
-
-            return converter.ConvertToString(value);
-        }
-
-        private static object? InvokeParse(string s, Type targetType)
-        {
-            var method = targetType.GetMethod("Parse", PublicStatic, null, StringIFormatProviderParameters, null);
-
-            if (method != null)
-            {
-                return method.Invoke(null, new object[] { s, CultureInfo.InvariantCulture });
-            }
-
-            method = targetType.GetMethod("Parse", PublicStatic, null, StringParameter, null);
-
-            if (method != null)
-            {
-                return method.Invoke(null, new object[] { s });
-            }
-
-            throw new InvalidCastException("Unable to convert value.");
-        }
-
-        protected static object? ConvertFromString(string? s, Type targetType)
-        {
-            if (s is null)
-            {
-                return null;
-            }
-
-            var converter = TypeDescriptor.GetConverter(targetType);
 
-            if (converter.CanConvertFrom(typeof(string)))
-            {
-                return converter.ConvertFrom(null, CultureInfo.InvariantCulture, s);
-            }
+        public string Type => PropertyType == AssignedType ?
+            PropertyType.GetTypeName() :
+            $"{PropertyType.GetTypeName()} {{{AssignedType.GetTypeName()}}}";
 
-            return InvokeParse(s, targetType);
-        }
+        public abstract bool IsReadonly { get; }
     }
 }

+ 57 - 0
src/Avalonia.Diagnostics/Diagnostics/ViewModels/ReactiveExtensions.cs

@@ -0,0 +1,57 @@
+using System;
+using System.ComponentModel;
+using System.Linq.Expressions;
+using System.Reflection;
+using Avalonia.Reactive;
+
+namespace Avalonia.Diagnostics.ViewModels
+{
+    internal static class ReactiveExtensions
+    {
+        public static IObservable<TValue> GetObservable<TOwner, TValue>(
+            this TOwner vm,
+            Expression<Func<TOwner, TValue>> property,
+            bool fireImmediately = true)
+            where TOwner : INotifyPropertyChanged
+        {
+            return Observable.Create<TValue>(o =>
+            {
+                var propertyInfo = GetPropertyInfo(property);
+
+                void Fire()
+                {
+                    o.OnNext((TValue) propertyInfo.GetValue(vm)!);
+                }
+
+                void OnPropertyChanged(object? sender, PropertyChangedEventArgs e)
+                {
+                    if (e.PropertyName == propertyInfo.Name)
+                    {
+                        Fire();
+                    }
+                }
+
+                if (fireImmediately)
+                {
+                    Fire();
+                }
+
+                vm.PropertyChanged += OnPropertyChanged;
+
+                return Disposable.Create(() => vm.PropertyChanged -= OnPropertyChanged);
+            });
+        }
+
+        private static PropertyInfo GetPropertyInfo<TOwner, TValue>(this Expression<Func<TOwner, TValue>> property)
+        {
+            if (property.Body is UnaryExpression unaryExpression)
+            {
+                return (PropertyInfo)((MemberExpression)unaryExpression.Operand).Member;
+            }
+
+            var memExpr = (MemberExpression)property.Body;
+
+            return (PropertyInfo)memExpr.Member;
+        }
+    }
+}

+ 5 - 1
src/Avalonia.Diagnostics/Diagnostics/Views/ControlDetailsView.xaml

@@ -60,7 +60,11 @@
                 DoubleTapped="PropertiesGrid_OnDoubleTapped">
         <DataGrid.Columns>
           <DataGridTextColumn Header="Property" Binding="{Binding Name}" IsReadOnly="True" x:DataType="vm:PropertyViewModel" />
-          <DataGridTextColumn Header="Value" Binding="{Binding Value}" x:DataType="vm:PropertyViewModel" />
+          <DataGridTemplateColumn Header="Value" Width="100">
+              <DataTemplate>
+                <local:PropertyValueEditorView />
+              </DataTemplate>
+          </DataGridTemplateColumn>
           <DataGridTextColumn Header="Type" Binding="{Binding Type}"
                               IsReadOnly="True"
                               IsVisible="{Binding !$parent[UserControl;2].((vm:MainViewModel)DataContext).ShowDetailsPropertyType}"

+ 2 - 3
src/Avalonia.Diagnostics/Diagnostics/Views/MainView.xaml.cs

@@ -1,7 +1,6 @@
 using Avalonia.Controls;
 using Avalonia.Diagnostics.ViewModels;
 using Avalonia.Input;
-using Avalonia.Interactivity;
 using Avalonia.Markup.Xaml;
 using Avalonia.Threading;
 
@@ -18,7 +17,7 @@ namespace Avalonia.Diagnostics.Views
         public MainView()
         {
             InitializeComponent();
-            AddHandler(KeyDownEvent, PreviewKeyDown, RoutingStrategies.Tunnel);
+            AddHandler(KeyUpEvent, PreviewKeyUp);
             _console = this.GetControl<ConsoleView>("console");
             _consoleSplitter = this.GetControl<GridSplitter>("consoleSplitter");
             _rootGrid = this.GetControl<Grid>("rootGrid");
@@ -58,7 +57,7 @@ namespace Avalonia.Diagnostics.Views
             AvaloniaXamlLoader.Load(this);
         }
 
-        private void PreviewKeyDown(object? sender, KeyEventArgs e)
+        private void PreviewKeyUp(object? sender, KeyEventArgs e)
         {
             if (e.Key == Key.Escape)
             {

+ 1 - 0
src/Avalonia.Diagnostics/Diagnostics/Views/MainWindow.xaml

@@ -15,6 +15,7 @@
     <StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Simple.xaml"/>
     <StyleInclude Source="avares://Avalonia.Diagnostics/Diagnostics/Controls/ThicknessEditor.axaml" />
     <StyleInclude Source="avares://Avalonia.Diagnostics/Diagnostics/Controls/FilterTextBox.axaml" />
+    <StyleInclude Source="avares://Avalonia.Controls.ColorPicker/Themes/Simple/Simple.xaml" />
   </Window.Styles>
   <Window.KeyBindings>
     <KeyBinding Gesture="F8" Command="{Binding Shot}"/>

+ 404 - 0
src/Avalonia.Diagnostics/Diagnostics/Views/PropertyValueEditorView.cs

@@ -0,0 +1,404 @@
+using System;
+using System.ComponentModel;
+using System.Globalization;
+using System.Reflection;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Controls.Shapes;
+using Avalonia.Data;
+using Avalonia.Data.Converters;
+using Avalonia.Diagnostics.Controls;
+using Avalonia.Diagnostics.ViewModels;
+using Avalonia.Input;
+using Avalonia.Layout;
+using Avalonia.Markup.Xaml.Converters;
+using Avalonia.Media;
+using Avalonia.Reactive;
+
+namespace Avalonia.Diagnostics.Views
+{
+    internal class PropertyValueEditorView : UserControl
+    {
+        private static readonly Geometry ImageIcon = Geometry.Parse(
+            "M12.25 6C8.79822 6 6 8.79822 6 12.25V35.75C6 37.1059 6.43174 38.3609 7.16525 39.3851L21.5252 25.0251C22.8921 23.6583 25.1081 23.6583 26.475 25.0251L40.8348 39.385C41.5683 38.3608 42 37.1058 42 35.75V12.25C42 8.79822 39.2018 6 35.75 6H12.25ZM34.5 17.5C34.5 19.7091 32.7091 21.5 30.5 21.5C28.2909 21.5 26.5 19.7091 26.5 17.5C26.5 15.2909 28.2909 13.5 30.5 13.5C32.7091 13.5 34.5 15.2909 34.5 17.5ZM39.0024 41.0881L24.7072 26.7929C24.3167 26.4024 23.6835 26.4024 23.293 26.7929L8.99769 41.0882C9.94516 41.6667 11.0587 42 12.25 42H35.75C36.9414 42 38.0549 41.6666 39.0024 41.0881Z");
+
+        private static readonly Geometry GeometryIcon = Geometry.Parse(
+            "M23.25 15.5H30.8529C29.8865 8.99258 24.2763 4 17.5 4C10.0442 4 4 10.0442 4 17.5C4 24.2763 8.99258 29.8865 15.5 30.8529V23.25C15.5 18.9698 18.9698 15.5 23.25 15.5ZM23.25 18C20.3505 18 18 20.3505 18 23.25V38.75C18 41.6495 20.3505 44 23.25 44H38.75C41.6495 44 44 41.6495 44 38.75V23.25C44 20.3505 41.6495 18 38.75 18H23.25Z");
+
+        private static readonly ColorToBrushConverter Color2Brush = new();
+
+        private readonly CompositeDisposable _cleanup = new();
+        private PropertyViewModel? Property => (PropertyViewModel?)DataContext;
+
+        protected override void OnDataContextChanged(EventArgs e)
+        {
+            base.OnDataContextChanged(e);
+
+            Content = UpdateControl();
+        }
+
+        protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
+        {
+            base.OnDetachedFromVisualTree(e);
+
+            _cleanup.Clear();
+        }
+
+        private static bool ImplementsInterface<TInterface>(Type type)
+        {
+            var interfaceType = typeof(TInterface);
+            return type == interfaceType || interfaceType.IsAssignableFrom(type);
+        }
+
+        private Control? UpdateControl()
+        {
+            _cleanup.Clear();
+
+            if (Property?.PropertyType is not { } propertyType)
+                return null;
+
+            TControl CreateControl<TControl>(AvaloniaProperty valueProperty,
+                IValueConverter? converter = null,
+                Action<TControl>? init = null,
+                AvaloniaProperty? readonlyProperty = null)
+                where TControl : Control, new()
+            {
+                var control = new TControl();
+
+                init?.Invoke(control);
+
+                control.Bind(valueProperty,
+                    new Binding(nameof(Property.Value), BindingMode.TwoWay)
+                    {
+                        Source = Property,
+                        Converter = converter ?? new ValueConverter(),
+                        ConverterParameter = propertyType
+                    }).DisposeWith(_cleanup);
+
+                if (readonlyProperty != null)
+                {
+                    control[readonlyProperty] = Property.IsReadonly;
+                }
+                else
+                {
+                    control.IsEnabled = !Property.IsReadonly;
+                }
+
+                return control;
+            }
+
+            if (propertyType == typeof(bool))
+                return CreateControl<CheckBox>(ToggleButton.IsCheckedProperty);
+
+            //TODO: Infinity, NaN not working with NumericUpDown
+            if (propertyType.IsPrimitive && propertyType != typeof(float) && propertyType != typeof(double))
+                return CreateControl<NumericUpDown>(
+                    NumericUpDown.ValueProperty,
+                    new ValueToDecimalConverter(),
+                    init: n =>
+                    {
+                        n.Increment = 1;
+                        n.NumberFormat = new NumberFormatInfo { NumberDecimalDigits = 0 };
+                        n.ParsingNumberStyle = NumberStyles.Integer;
+                    },
+                    readonlyProperty: NumericUpDown.IsReadOnlyProperty);
+
+            if (propertyType == typeof(Color))
+            {
+                var el = new Ellipse { Width = 12, Height = 12, VerticalAlignment = VerticalAlignment.Center };
+
+                el.Bind(
+                        Shape.FillProperty,
+                        new Binding(nameof(Property.Value)) { Source = Property, Converter = Color2Brush })
+                    .DisposeWith(_cleanup);
+
+                var tbl = new TextBlock { VerticalAlignment = VerticalAlignment.Center };
+
+                tbl.Bind(
+                        TextBlock.TextProperty,
+                        new Binding(nameof(Property.Value)) { Source = Property })
+                    .DisposeWith(_cleanup);
+
+                var sp = new StackPanel
+                {
+                    Orientation = Orientation.Horizontal,
+                    Spacing = 2,
+                    Children = { el, tbl },
+                    Background = Brushes.Transparent,
+                    Cursor = new Cursor(StandardCursorType.Hand),
+                    IsEnabled = !Property.IsReadonly
+                };
+
+                var cv = new ColorView();
+
+                cv.Bind(
+                        ColorView.ColorProperty,
+                        new Binding(nameof(Property.Value), BindingMode.TwoWay)
+                        {
+                            Source = Property, Converter = Color2Brush
+                        })
+                    .DisposeWith(_cleanup);
+
+                FlyoutBase.SetAttachedFlyout(sp, new Flyout { Content = cv });
+
+                sp.PointerPressed += (_, _) => FlyoutBase.ShowAttachedFlyout(sp);
+
+                return sp;
+            }
+
+            if (ImplementsInterface<IBrush>(propertyType))
+                return CreateControl<BrushEditor>(BrushEditor.BrushProperty);
+
+            var isImage = ImplementsInterface<IImage>(propertyType);
+            var isGeometry = propertyType == typeof(Geometry);
+
+            if (isImage || isGeometry)
+            {
+                var valueObservable = Property.GetObservable(x => x.Value);
+                var tbl = new TextBlock { VerticalAlignment = VerticalAlignment.Center };
+
+                tbl.Bind(TextBlock.TextProperty,
+                        valueObservable.Select(
+                            value => value switch
+                            {
+                                IImage img => $"{img.Size.Width} x {img.Size.Height}",
+                                Geometry geom => $"{geom.Bounds.Width} x {geom.Bounds.Height}",
+                                _ => "(null)"
+                            }))
+                    .DisposeWith(_cleanup);
+
+                var sp = new StackPanel
+                {
+                    Background = Brushes.Transparent,
+                    Orientation = Orientation.Horizontal,
+                    Spacing = 2,
+                    Children =
+                    {
+                        new Path
+                        {
+                            Data = isImage ? ImageIcon : GeometryIcon,
+                            Fill = Brushes.Gray,
+                            Width = 12,
+                            Height = 12,
+                            Stretch = Stretch.Uniform,
+                            VerticalAlignment = VerticalAlignment.Center
+                        },
+                        tbl
+                    }
+                };
+
+                if (isImage)
+                {
+                    var previewImage = new Image { Stretch = Stretch.Uniform, Width = 300, Height = 300 };
+
+                    previewImage
+                        .Bind(Image.SourceProperty, valueObservable)
+                        .DisposeWith(_cleanup);
+
+                    ToolTip.SetTip(sp, previewImage);
+                }
+                else
+                {
+                    var previewShape = new Path
+                    {
+                        Stretch = Stretch.Uniform,
+                        Fill = Brushes.White,
+                        VerticalAlignment = VerticalAlignment.Center,
+                        HorizontalAlignment = HorizontalAlignment.Center
+                    };
+
+                    previewShape
+                        .Bind(Path.DataProperty, valueObservable)
+                        .DisposeWith(_cleanup);
+
+                    ToolTip.SetTip(sp, new Border { Child = previewShape, Width = 300, Height = 300 });
+                }
+
+                return sp;
+            }
+
+            if (propertyType.IsEnum)
+                return CreateControl<ComboBox>(
+                    SelectingItemsControl.SelectedItemProperty, init: c =>
+                    {
+                        c.Items = Enum.GetValues(propertyType);
+                    });
+
+            var tb = CreateControl<CommitTextBox>(
+                CommitTextBox.CommittedTextProperty,
+                new TextToValueConverter(),
+                t =>
+                {
+                    t.Watermark = "(null)";
+                },
+                readonlyProperty: TextBox.IsReadOnlyProperty);
+
+            tb.IsReadOnly |= propertyType == typeof(object) ||
+                             !StringConversionHelper.CanConvertFromString(propertyType);
+
+            if (!tb.IsReadOnly)
+            {
+                tb.GetObservable(TextBox.TextProperty).Subscribe(t =>
+                {
+                    try
+                    {
+                        if (t != null)
+                        {
+                            StringConversionHelper.FromString(t, propertyType);
+                        }
+
+                        DataValidationErrors.ClearErrors(tb);
+                    }
+                    catch (Exception ex)
+                    {
+                        DataValidationErrors.SetError(tb, ex.GetBaseException());
+                    }
+                }).DisposeWith(_cleanup);
+            }
+
+            return tb;
+        }
+
+        //HACK: ValueConverter that skips first target update
+        //TODO: Would be nice to have some kind of "InitialBindingValue" option on TwoWay bindings to control
+        //if the first value comes from the source or target
+        private class ValueConverter : IValueConverter
+        {
+            private bool _firstUpdate = true;
+
+            object? IValueConverter.Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+            {
+                return Convert(value, targetType, parameter, culture);
+            }
+
+            object? IValueConverter.ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
+            {
+                if (_firstUpdate)
+                {
+                    _firstUpdate = false;
+
+                    return BindingOperations.DoNothing;
+                }
+
+                //Note: targetType provided by Converter is simply "object"
+                return ConvertBack(value, (Type)parameter!, parameter, culture);
+            }
+
+            protected virtual object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+            {
+                return value;
+            }
+
+            protected virtual object? ConvertBack(object? value, Type targetType, object? parameter,
+                CultureInfo culture)
+            {
+                return value;
+            }
+        }
+
+        private static class StringConversionHelper
+        {
+            private const BindingFlags PublicStatic = BindingFlags.Public | BindingFlags.Static;
+            private static readonly Type[] StringParameter = { typeof(string) };
+            private static readonly Type[] StringFormatProviderParameters = { typeof(string), typeof(IFormatProvider) };
+
+            public static bool CanConvertFromString(Type type)
+            {
+                var converter = TypeDescriptor.GetConverter(type);
+
+                if (converter.CanConvertFrom(typeof(string)))
+                    return true;
+
+                return GetParseMethod(type, out _) != null;
+            }
+
+            public static string? ToString(object o)
+            {
+                var converter = TypeDescriptor.GetConverter(o);
+
+                //CollectionConverter does not deliver any important information. It just displays "(Collection)".
+                if (!converter.CanConvertTo(typeof(string)) ||
+                    converter.GetType() == typeof(CollectionConverter))
+                    return o.ToString();
+
+                return converter.ConvertToInvariantString(o);
+            }
+
+            public static object? FromString(string str, Type type)
+            {
+                var converter = TypeDescriptor.GetConverter(type);
+
+                return converter.CanConvertFrom(typeof(string)) ?
+                    converter.ConvertFrom(null, CultureInfo.InvariantCulture, str) :
+                    InvokeParse(str, type);
+            }
+
+            private static object? InvokeParse(string s, Type targetType)
+            {
+                var m = GetParseMethod(targetType, out var hasFormat);
+
+                if (m == null)
+                    throw new InvalidOperationException();
+
+                return m.Invoke(null,
+                    hasFormat ?
+                        new object[] { s, CultureInfo.InvariantCulture } :
+                        new object[] { s });
+            }
+
+            private static MethodInfo? GetParseMethod(Type type, out bool hasFormat)
+            {
+                var m = type.GetMethod("Parse", PublicStatic, null, StringFormatProviderParameters, null);
+
+                if (m != null)
+                {
+                    hasFormat = true;
+
+                    return m;
+                }
+
+                hasFormat = false;
+
+                return type.GetMethod("Parse", PublicStatic, null, StringParameter, null);
+            }
+        }
+
+        private sealed class ValueToDecimalConverter : ValueConverter
+        {
+            protected override object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+            {
+                return System.Convert.ToDecimal(value);
+            }
+
+            protected override object? ConvertBack(object? value, Type targetType, object? parameter,
+                CultureInfo culture)
+            {
+                return System.Convert.ChangeType(value, targetType);
+            }
+        }
+
+        private sealed class TextToValueConverter : ValueConverter
+        {
+            protected override object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+            {
+                return value is null ? null : StringConversionHelper.ToString(value);
+            }
+
+            protected override object? ConvertBack(object? value, Type targetType, object? parameter,
+                CultureInfo culture)
+            {
+                if (value is not string s)
+                    return null;
+
+                try
+                {
+                    return StringConversionHelper.FromString(s, targetType);
+                }
+                catch
+                {
+                    return BindingOperations.DoNothing;
+                }
+            }
+        }
+    }
+}

+ 1 - 0
src/Avalonia.Themes.Simple/Controls/TextBox.xaml

@@ -92,6 +92,7 @@
     <Setter Property="CaretBrush" Value="{DynamicResource ThemeForegroundBrush}" />
     <Setter Property="Background" Value="{DynamicResource ThemeBackgroundBrush}" />
     <Setter Property="BorderBrush" Value="{DynamicResource ThemeBorderMidBrush}" />
+    <Setter Property="Foreground" Value="{DynamicResource ThemeForegroundBrush}" />
     <Setter Property="BorderThickness" Value="{DynamicResource ThemeBorderThickness}" />
     <Setter Property="SelectionBrush" Value="{DynamicResource HighlightBrush}" />
     <Setter Property="SelectionForegroundBrush" Value="{DynamicResource HighlightForegroundBrush}" />