Pārlūkot izejas kodu

Merge branch 'master' into fixes/1447-alignment

Steven Kirk 7 gadi atpakaļ
vecāks
revīzija
4efd268788

+ 6 - 0
samples/ControlCatalog/ControlCatalog.csproj

@@ -78,6 +78,9 @@
     <EmbeddedResource Include="Pages\MenuPage.xaml">
       <SubType>Designer</SubType>
     </EmbeddedResource>
+    <EmbeddedResource Include="Pages\NumericUpDownPage.xaml">
+      <SubType>Designer</SubType>
+    </EmbeddedResource>
     <EmbeddedResource Include="Pages\ProgressBarPage.xaml">
       <SubType>Designer</SubType>
     </EmbeddedResource>
@@ -169,6 +172,9 @@
     </Compile>
     <Compile Include="Pages\ButtonSpinnerPage.xaml.cs">
       <DependentUpon>ButtonSpinnerPage.xaml</DependentUpon>
+    </Compile>
+	<Compile Include="Pages\NumericUpDownPage.xaml.cs">
+      <DependentUpon>NumericUpDownPage.xaml</DependentUpon>
     </Compile>
     <Compile Include="Pages\ScreenPage.cs" />
     <Compile Include="Properties\AssemblyInfo.cs" />

+ 1 - 0
samples/ControlCatalog/MainView.xaml

@@ -19,6 +19,7 @@
     <TabItem Header="Image"><pages:ImagePage/></TabItem>
     <TabItem Header="LayoutTransformControl"><pages:LayoutTransformControlPage/></TabItem>
     <TabItem Header="Menu"><pages:MenuPage/></TabItem>
+	  <TabItem Header="NumericUpDown"><pages:NumericUpDownPage/></TabItem>
     <TabItem Header="ProgressBar"><pages:ProgressBarPage/></TabItem>
     <TabItem Header="RadioButton"><pages:RadioButtonPage/></TabItem>
     <TabItem Header="Slider"><pages:SliderPage/></TabItem>

+ 80 - 0
samples/ControlCatalog/Pages/NumericUpDownPage.xaml

@@ -0,0 +1,80 @@
+<UserControl xmlns="https://github.com/avaloniaui"
+             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
+  <StackPanel Orientation="Vertical" Gap="4">
+    <TextBlock Margin="2" Classes="h1">Numeric up-down control</TextBlock>
+    <TextBlock Margin="2" Classes="h2" TextWrapping="Wrap">Numeric up-down control provides a TextBox with button spinners that allow incrementing and decrementing numeric values by using the spinner buttons, keyboard up/down arrows, or mouse wheel.</TextBlock>
+
+    <TextBlock Margin="2,5,2,2" FontSize="14" FontWeight="Bold">Features:</TextBlock>
+    <Grid Margin="2" ColumnDefinitions="Auto,Auto,Auto,Auto" RowDefinitions="Auto,Auto">
+      <Grid Grid.Row="0" Grid.Column="0" ColumnDefinitions="Auto, Auto" RowDefinitions="35,35,35,35,35">
+        <TextBlock Grid.Row="0" Grid.Column="0" VerticalAlignment="Center" Margin="2">ShowButtonSpinner:</TextBlock>
+        <CheckBox Grid.Row="0" Grid.Column="1" IsChecked="{Binding #upDown.ShowButtonSpinner}" VerticalAlignment="Center" Margin="2"/>
+
+        <TextBlock Grid.Row="1" Grid.Column="0" VerticalAlignment="Center" Margin="2">IsReadOnly:</TextBlock>
+        <CheckBox Grid.Row="1" Grid.Column="1" IsChecked="{Binding #upDown.IsReadOnly}" VerticalAlignment="Center" Margin="2"/>
+
+        <TextBlock Grid.Row="2" Grid.Column="0" VerticalAlignment="Center" Margin="2">AllowSpin:</TextBlock>
+        <CheckBox Grid.Row="2" Grid.Column="1" IsChecked="{Binding #upDown.AllowSpin}" IsEnabled="{Binding #upDown.!IsReadOnly}" VerticalAlignment="Center" Margin="2"/>
+
+        <TextBlock Grid.Row="3" Grid.Column="0" VerticalAlignment="Center" Margin="2">ClipValueToMinMax:</TextBlock>
+        <CheckBox Grid.Row="3" Grid.Column="1" IsChecked="{Binding #upDown.ClipValueToMinMax}" VerticalAlignment="Center" Margin="2"/>
+
+      </Grid>
+      <Grid Grid.Row="0" Grid.Column="1" Margin="10,2,2,2" ColumnDefinitions="Auto, 120" RowDefinitions="35,35,35,35,35">
+        <TextBlock Grid.Row="0" Grid.Column="0" VerticalAlignment="Center" Margin="2">FormatString:</TextBlock>
+        <DropDown Grid.Row="0" Grid.Column="1" Items="{Binding Formats}" SelectedItem="{Binding SelectedFormat}"
+                  VerticalAlignment="Center" Margin="2">
+          <DropDown.ItemTemplate>
+            <DataTemplate>
+              <StackPanel Orientation="Horizontal" Gap="2">
+                <TextBlock Text="{Binding Name}"/>
+                <TextBlock Text="-"/>
+                <TextBlock Text="{Binding Value}"/>
+              </StackPanel>
+            </DataTemplate>
+          </DropDown.ItemTemplate>
+        </DropDown>
+
+        <TextBlock Grid.Row="1" Grid.Column="0" VerticalAlignment="Center" Margin="2">ButtonSpinnerLocation:</TextBlock>
+        <DropDown Grid.Row="1" Grid.Column="1" Items="{Binding SpinnerLocations}" SelectedItem="{Binding #upDown.ButtonSpinnerLocation}"
+                  VerticalAlignment="Center" Margin="2"/>
+
+        <TextBlock Grid.Row="2" Grid.Column="0" VerticalAlignment="Center" Margin="2">CultureInfo:</TextBlock>
+        <DropDown Grid.Row="2" Grid.Column="1" Items="{Binding Cultures}" SelectedItem="{Binding #upDown.CultureInfo}"
+                  VerticalAlignment="Center" Margin="2"/>
+
+        <TextBlock Grid.Row="3" Grid.Column="0" VerticalAlignment="Center" Margin="2">Watermark:</TextBlock>
+        <TextBox Grid.Row="3" Grid.Column="1" Text="{Binding #upDown.Watermark}" VerticalAlignment="Center" Margin="2" />
+
+        <TextBlock Grid.Row="4" Grid.Column="0" VerticalAlignment="Center" Margin="2">Text:</TextBlock>
+        <TextBox Grid.Row="4" Grid.Column="1" Text="{Binding #upDown.Text}" VerticalAlignment="Center" Margin="2" />
+      </Grid>
+      <Grid Grid.Row="0" Grid.Column="2" Margin="10,2,2,2" RowDefinitions="35,35,35,35,35" ColumnDefinitions="Auto, 120">
+        <TextBlock Grid.Row="0" Grid.Column="0" VerticalAlignment="Center" Margin="10,2,2,2">Minimum:</TextBlock>
+        <NumericUpDown Grid.Row="0" Grid.Column="1" Value="{Binding #upDown.Minimum}"
+                       CultureInfo="{Binding #upDown.CultureInfo}" VerticalAlignment="Center" Height="25" Margin="2" Width="70" HorizontalAlignment="Center"/>
+
+        <TextBlock Grid.Row="1" Grid.Column="0" VerticalAlignment="Center" Margin="10,2,2,2">Maximum:</TextBlock>
+        <NumericUpDown Grid.Row="1" Grid.Column="1" Value="{Binding #upDown.Maximum}"
+                       CultureInfo="{Binding #upDown.CultureInfo}" VerticalAlignment="Center" Height="25" Margin="2" Width="70" HorizontalAlignment="Center"/>
+
+        <TextBlock Grid.Row="2" Grid.Column="0" VerticalAlignment="Center" Margin="10,2,2,2">Increment:</TextBlock>
+        <NumericUpDown Grid.Row="2" Grid.Column="1" Value="{Binding #upDown.Increment}" VerticalAlignment="Center"
+                       Height="25" Margin="2" Width="70" HorizontalAlignment="Center"/>
+
+        <TextBlock Grid.Row="3" Grid.Column="0" VerticalAlignment="Center" Margin="10,2,2,2">Value:</TextBlock>
+        <NumericUpDown Grid.Row="3" Grid.Column="1" Value="{Binding #upDown.Value}" VerticalAlignment="Center"
+                       Height="25" Margin="2" Width="70" HorizontalAlignment="Center"/>
+
+      </Grid>
+    </Grid>
+
+    <StackPanel Margin="2,10,2,2" Orientation="Horizontal" Gap="10">
+      <TextBlock FontSize="14" FontWeight="Bold" VerticalAlignment="Center">Usage of NumericUpDown:</TextBlock>
+      <NumericUpDown Name="upDown" Minimum="0" Maximum="10" Increment="0.5"
+                     CultureInfo="en-US" VerticalAlignment="Center" Height="25" Width="100"
+                     Watermark="Enter text" FormatString="{Binding SelectedFormat.Value}"/>
+    </StackPanel>
+
+  </StackPanel>
+</UserControl>

+ 94 - 0
samples/ControlCatalog/Pages/NumericUpDownPage.xaml.cs

@@ -0,0 +1,94 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Markup.Xaml;
+using ReactiveUI;
+
+namespace ControlCatalog.Pages
+{
+    public class NumericUpDownPage : UserControl
+    {
+        public NumericUpDownPage()
+        {
+            this.InitializeComponent();
+            var viewModel = new NumbersPageViewModel();
+            DataContext = viewModel;
+        }
+
+        private void InitializeComponent()
+        {
+            AvaloniaXamlLoader.Load(this);
+        }
+
+    }
+
+    public class NumbersPageViewModel : ReactiveObject
+    {
+        private IList<FormatObject> _formats;
+        private FormatObject _selectedFormat;
+        private IList<Location> _spinnerLocations;
+
+        public NumbersPageViewModel()
+        {
+            SelectedFormat = Formats.FirstOrDefault();
+        }
+
+        public IList<FormatObject> Formats
+        {
+            get
+            {
+                return _formats ?? (_formats = new List<FormatObject>()
+                {
+                    new FormatObject() {Name = "Currency", Value = "C2"},
+                    new FormatObject() {Name = "Fixed point", Value = "F2"},
+                    new FormatObject() {Name = "General", Value = "G"},
+                    new FormatObject() {Name = "Number", Value = "N"},
+                    new FormatObject() {Name = "Percent", Value = "P"},
+                    new FormatObject() {Name = "Degrees", Value = "{0:N2} °"},
+                });
+            }
+        }
+
+        public IList<Location> SpinnerLocations
+        {
+            get
+            {
+                if (_spinnerLocations == null)
+                {
+                    _spinnerLocations = new List<Location>();
+                    foreach (Location value in Enum.GetValues(typeof(Location)))
+                    {
+                        _spinnerLocations.Add(value);
+                    }
+                }
+                return _spinnerLocations ;
+            }
+        }
+
+        public IList<CultureInfo> Cultures { get; } = new List<CultureInfo>()
+        {
+            new CultureInfo("en-US"),
+            new CultureInfo("en-GB"),
+            new CultureInfo("fr-FR"),
+            new CultureInfo("ar-DZ"),
+            new CultureInfo("zh-CN"),
+            new CultureInfo("cs-CZ")
+        };
+
+        public FormatObject SelectedFormat
+        {
+            get { return _selectedFormat; }
+            set { this.RaiseAndSetIfChanged(ref _selectedFormat, value); }
+        }
+    }
+
+    public class FormatObject
+    {
+        public string Value { get; set; }
+        public string Name { get; set; }
+    }
+}

+ 2 - 2
src/Avalonia.Base/Collections/AvaloniaDictionary.cs

@@ -117,7 +117,7 @@ namespace Avalonia.Collections
             _inner = new Dictionary<TKey, TValue>();
 
             PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("Count"));
-            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs($"Item[]"));
+            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("Item[]"));
             
 
             if (CollectionChanged != null)
@@ -222,4 +222,4 @@ namespace Avalonia.Collections
             }
         }
     }
-}
+}

+ 5 - 0
src/Avalonia.Controls/ButtonSpinner.cs

@@ -201,6 +201,11 @@ namespace Avalonia.Controls
             }
         }
 
+        protected override void OnValidSpinDirectionChanged(ValidSpinDirections oldValue, ValidSpinDirections newValue)
+        {
+            SetButtonUsage();
+        }
+
         /// <summary>
         /// Called when the <see cref="AllowSpin"/> property value changed.
         /// </summary>

+ 35 - 0
src/Avalonia.Controls/MenuItem.cs

@@ -93,6 +93,7 @@ namespace Avalonia.Controls
         static MenuItem()
         {
             SelectableMixin.Attach<MenuItem>(IsSelectedProperty);
+            CommandProperty.Changed.Subscribe(CommandChanged);
             FocusableProperty.OverrideDefaultValue<MenuItem>(true);
             IconProperty.Changed.AddClassHandler<MenuItem>(x => x.IconChanged);
             ItemsPanelProperty.OverrideDefaultValue<MenuItem>(DefaultPanel);
@@ -424,6 +425,40 @@ namespace Avalonia.Controls
             }
         }
 
+        /// <summary>
+        /// Called when the <see cref="Command"/> property changes.
+        /// </summary>
+        /// <param name="e">The event args.</param>
+        private static void CommandChanged(AvaloniaPropertyChangedEventArgs e)
+        {
+            if (e.Sender is MenuItem menuItem)
+            {
+                if (e.OldValue is ICommand oldCommand)
+                {
+                    oldCommand.CanExecuteChanged -= menuItem.CanExecuteChanged;
+                }
+
+                if (e.NewValue is ICommand newCommand)
+                {
+                    newCommand.CanExecuteChanged += menuItem.CanExecuteChanged;
+                }
+
+                menuItem.CanExecuteChanged(menuItem, EventArgs.Empty);
+            }
+        }
+
+        /// <summary>
+        /// Called when the <see cref="ICommand.CanExecuteChanged"/> event fires.
+        /// </summary>
+        /// <param name="sender">The event sender.</param>
+        /// <param name="e">The event args.</param>
+        private void CanExecuteChanged(object sender, EventArgs e)
+        {
+            // HACK: Just set the IsEnabled property for the moment. This needs to be changed to
+            // use IsEnabledCore etc. but it will do for now.
+            IsEnabled = Command == null || Command.CanExecute(CommandParameter);
+        }
+
         /// <summary>
         /// Called when the <see cref="Icon"/> property changes.
         /// </summary>

+ 998 - 0
src/Avalonia.Controls/NumericUpDown/NumericUpDown.cs

@@ -0,0 +1,998 @@
+using System;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using Avalonia.Controls.Primitives;
+using Avalonia.Data;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using Avalonia.Threading;
+using Avalonia.Utilities;
+
+namespace Avalonia.Controls
+{
+    /// <summary>
+    /// Control that represents a TextBox with button spinners that allow incrementing and decrementing numeric values.
+    /// </summary>
+    public class NumericUpDown : TemplatedControl
+    {
+        /// <summary>
+        /// Defines the <see cref="AllowSpin"/> property.
+        /// </summary>
+        public static readonly StyledProperty<bool> AllowSpinProperty =
+            ButtonSpinner.AllowSpinProperty.AddOwner<NumericUpDown>();
+
+        /// <summary>
+        /// Defines the <see cref="ButtonSpinnerLocation"/> property.
+        /// </summary>
+        public static readonly StyledProperty<Location> ButtonSpinnerLocationProperty =
+            ButtonSpinner.ButtonSpinnerLocationProperty.AddOwner<NumericUpDown>();
+
+        /// <summary>
+        /// Defines the <see cref="ShowButtonSpinner"/> property.
+        /// </summary>
+        public static readonly StyledProperty<bool> ShowButtonSpinnerProperty =
+            ButtonSpinner.ShowButtonSpinnerProperty.AddOwner<NumericUpDown>();
+
+        /// <summary>
+        /// Defines the <see cref="ClipValueToMinMax"/> property.
+        /// </summary>
+        public static readonly DirectProperty<NumericUpDown, bool> ClipValueToMinMaxProperty =
+            AvaloniaProperty.RegisterDirect<NumericUpDown, bool>(nameof(ClipValueToMinMax),
+                updown => updown.ClipValueToMinMax, (updown, b) => updown.ClipValueToMinMax = b);
+
+        /// <summary>
+        /// Defines the <see cref="CultureInfo"/> property.
+        /// </summary>
+        public static readonly DirectProperty<NumericUpDown, CultureInfo> CultureInfoProperty =
+            AvaloniaProperty.RegisterDirect<NumericUpDown, CultureInfo>(nameof(CultureInfo), o => o.CultureInfo,
+                (o, v) => o.CultureInfo = v, CultureInfo.CurrentCulture);
+
+        /// <summary>
+        /// Defines the <see cref="FormatString"/> property.
+        /// </summary>
+        public static readonly StyledProperty<string> FormatStringProperty =
+            AvaloniaProperty.Register<NumericUpDown, string>(nameof(FormatString), string.Empty);
+
+        /// <summary>
+        /// Defines the <see cref="Increment"/> property.
+        /// </summary>
+        public static readonly StyledProperty<double> IncrementProperty =
+            AvaloniaProperty.Register<NumericUpDown, double>(nameof(Increment), 1.0d, validate: OnCoerceIncrement);
+
+        /// <summary>
+        /// Defines the <see cref="IsReadOnly"/> property.
+        /// </summary>
+        public static readonly StyledProperty<bool> IsReadOnlyProperty =
+            AvaloniaProperty.Register<NumericUpDown, bool>(nameof(IsReadOnly));
+
+        /// <summary>
+        /// Defines the <see cref="Maximum"/> property.
+        /// </summary>
+        public static readonly StyledProperty<double> MaximumProperty =
+            AvaloniaProperty.Register<NumericUpDown, double>(nameof(Maximum), double.MaxValue, validate: OnCoerceMaximum);
+
+        /// <summary>
+        /// Defines the <see cref="Minimum"/> property.
+        /// </summary>
+        public static readonly StyledProperty<double> MinimumProperty =
+            AvaloniaProperty.Register<NumericUpDown, double>(nameof(Minimum), double.MinValue, validate: OnCoerceMinimum);
+
+        /// <summary>
+        /// Defines the <see cref="ParsingNumberStyle"/> property.
+        /// </summary>
+        public static readonly DirectProperty<NumericUpDown, NumberStyles> ParsingNumberStyleProperty =
+            AvaloniaProperty.RegisterDirect<NumericUpDown, NumberStyles>(nameof(ParsingNumberStyle),
+                updown => updown.ParsingNumberStyle, (updown, style) => updown.ParsingNumberStyle = style);
+
+        /// <summary>
+        /// Defines the <see cref="Text"/> property.
+        /// </summary>
+        public static readonly DirectProperty<NumericUpDown, string> TextProperty =
+            AvaloniaProperty.RegisterDirect<NumericUpDown, string>(nameof(Text), o => o.Text, (o, v) => o.Text = v,
+                defaultBindingMode: BindingMode.TwoWay);
+
+        /// <summary>
+        /// Defines the <see cref="Value"/> property.
+        /// </summary>
+        public static readonly DirectProperty<NumericUpDown, double> ValueProperty =
+            AvaloniaProperty.RegisterDirect<NumericUpDown, double>(nameof(Value), updown => updown.Value,
+                (updown, v) => updown.Value = v, defaultBindingMode: BindingMode.TwoWay);
+
+        /// <summary>
+        /// Defines the <see cref="Watermark"/> property.
+        /// </summary>
+        public static readonly StyledProperty<string> WatermarkProperty =
+            AvaloniaProperty.Register<NumericUpDown, string>(nameof(Watermark));
+
+        private IDisposable _textBoxTextChangedSubscription;
+
+        private double _value;
+        private string _text;
+        private bool _internalValueSet;
+        private bool _clipValueToMinMax;
+        private bool _isSyncingTextAndValueProperties;
+        private bool _isTextChangedFromUI;
+        private CultureInfo _cultureInfo;
+        private NumberStyles _parsingNumberStyle = NumberStyles.Any;
+        
+        /// <summary>
+        /// Gets the Spinner template part.
+        /// </summary>
+        private Spinner Spinner { get; set; }
+
+        /// <summary>
+        /// Gets the TextBox template part.
+        /// </summary>
+        private TextBox TextBox { get; set; }
+
+        /// <summary>
+        /// Gets or sets the ability to perform increment/decrement operations via the keyboard, button spinners, or mouse wheel.
+        /// </summary>
+        public bool AllowSpin
+        {
+            get { return GetValue(AllowSpinProperty); }
+            set { SetValue(AllowSpinProperty, value); }
+        }
+
+        /// <summary>
+        /// Gets or sets current location of the <see cref="ButtonSpinner"/>.
+        /// </summary>
+        public Location ButtonSpinnerLocation
+        {
+            get { return GetValue(ButtonSpinnerLocationProperty); }
+            set { SetValue(ButtonSpinnerLocationProperty, value); }
+        }
+
+        /// <summary>
+        /// Gets or sets a value indicating whether the spin buttons should be shown.
+        /// </summary>
+        public bool ShowButtonSpinner
+        {
+            get { return GetValue(ShowButtonSpinnerProperty); }
+            set { SetValue(ShowButtonSpinnerProperty, value); }
+        }
+
+        /// <summary>
+        /// Gets or sets if the value should be clipped when minimum/maximum is reached.
+        /// </summary>
+        public bool ClipValueToMinMax
+        {
+            get { return _clipValueToMinMax; }
+            set { SetAndRaise(ClipValueToMinMaxProperty, ref _clipValueToMinMax, value); }
+        }
+
+        /// <summary>
+        /// Gets or sets the current CultureInfo.
+        /// </summary>
+        public CultureInfo CultureInfo
+        {
+            get { return _cultureInfo; }
+            set { SetAndRaise(CultureInfoProperty, ref _cultureInfo, value); }
+        }
+
+        /// <summary>
+        /// Gets or sets the display format of the <see cref="Value"/>.
+        /// </summary>
+        public string FormatString
+        {
+            get { return GetValue(FormatStringProperty); }
+            set { SetValue(FormatStringProperty, value); }
+        }
+
+        /// <summary>
+        /// Gets or sets the amount in which to increment the <see cref="Value"/>.
+        /// </summary>
+        public double Increment
+        {
+            get { return GetValue(IncrementProperty); }
+            set { SetValue(IncrementProperty, value); }
+        }
+
+        /// <summary>
+        /// Gets or sets if the control is read only.
+        /// </summary>
+        public bool IsReadOnly
+        {
+            get { return GetValue(IsReadOnlyProperty); }
+            set { SetValue(IsReadOnlyProperty, value); }
+        }
+
+        /// <summary>
+        /// Gets or sets the maximum allowed value.
+        /// </summary>
+        public double Maximum
+        {
+            get { return GetValue(MaximumProperty); }
+            set { SetValue(MaximumProperty, value); }
+        }
+
+        /// <summary>
+        /// Gets or sets the minimum allowed value.
+        /// </summary>
+        public double Minimum
+        {
+            get { return GetValue(MinimumProperty); }
+            set { SetValue(MinimumProperty, value); }
+        }
+
+        /// <summary>
+        /// Gets or sets the parsing style (AllowLeadingWhite, Float, AllowHexSpecifier, ...). By default, Any.
+        /// </summary>
+        public NumberStyles ParsingNumberStyle
+        {
+            get { return _parsingNumberStyle; }
+            set { SetAndRaise(ParsingNumberStyleProperty, ref _parsingNumberStyle, value); }
+        }
+
+        /// <summary>
+        /// Gets or sets the formatted string representation of the value.
+        /// </summary>
+        public string Text
+        {
+            get { return _text; }
+            set { SetAndRaise(TextProperty, ref _text, value); }
+        }
+
+        /// <summary>
+        /// Gets or sets the value.
+        /// </summary>
+        public double Value
+        {
+            get { return _value; }
+            set
+            {
+                value = OnCoerceValue(value);
+                SetAndRaise(ValueProperty, ref _value, value);
+            }
+        }
+
+        /// <summary>
+        /// Gets or sets the object to use as a watermark if the <see cref="Value"/> is null.
+        /// </summary>
+        public string Watermark
+        {
+            get { return GetValue(WatermarkProperty); }
+            set { SetValue(WatermarkProperty, value); }
+        }
+
+        /// <summary>
+        /// Initializes new instance of <see cref="NumericUpDown"/> class.
+        /// </summary>
+        public NumericUpDown()
+        {
+            Initialized += (sender, e) =>
+            {
+                if (!_internalValueSet && IsInitialized)
+                {
+                    SyncTextAndValueProperties(false, null, true);
+                }
+
+                SetValidSpinDirection();
+            };
+        }
+
+        /// <summary>
+        /// Initializes static members of the <see cref="NumericUpDown"/> class.
+        /// </summary>
+        static NumericUpDown()
+        {
+            CultureInfoProperty.Changed.Subscribe(OnCultureInfoChanged);
+            FormatStringProperty.Changed.Subscribe(FormatStringChanged);
+            IncrementProperty.Changed.Subscribe(IncrementChanged);
+            IsReadOnlyProperty.Changed.Subscribe(OnIsReadOnlyChanged);
+            MaximumProperty.Changed.Subscribe(OnMaximumChanged);
+            MinimumProperty.Changed.Subscribe(OnMinimumChanged);
+            TextProperty.Changed.Subscribe(OnTextChanged);
+            ValueProperty.Changed.Subscribe(OnValueChanged);
+        }
+
+        /// <inheritdoc />
+        protected override void OnTemplateApplied(TemplateAppliedEventArgs e)
+        {
+            if (TextBox != null)
+            {
+                TextBox.PointerPressed -= TextBoxOnPointerPressed;
+                _textBoxTextChangedSubscription?.Dispose();
+            }
+            TextBox = e.NameScope.Find<TextBox>("PART_TextBox");
+            if (TextBox != null)
+            {
+                TextBox.Text = Text;
+                TextBox.PointerPressed += TextBoxOnPointerPressed;
+                _textBoxTextChangedSubscription = TextBox.GetObservable(TextBox.TextProperty).Subscribe(txt => TextBoxOnTextChanged());
+            }
+
+            if (Spinner != null)
+            {
+                Spinner.Spin -= OnSpinnerSpin;
+            }
+
+            Spinner = e.NameScope.Find<Spinner>("PART_Spinner");
+
+            if (Spinner != null)
+            {
+                Spinner.Spin += OnSpinnerSpin;
+            }
+
+            SetValidSpinDirection();
+        }
+
+        /// <inheritdoc />
+        protected override void OnKeyDown(KeyEventArgs e)
+        {
+            switch (e.Key)
+            {
+                case Key.Enter:
+                    var commitSuccess = CommitInput();
+                    e.Handled = !commitSuccess;
+                    break;
+            }
+        }
+
+        /// <summary>
+        /// Called when the <see cref="CultureInfo"/> property value changed.
+        /// </summary>
+        /// <param name="oldValue">The old value.</param>
+        /// <param name="newValue">The new value.</param>
+        protected virtual void OnCultureInfoChanged(CultureInfo oldValue, CultureInfo newValue)
+        {
+            if (IsInitialized)
+            {
+                SyncTextAndValueProperties(false, null);
+            }
+        }
+
+        /// <summary>
+        /// Called when the <see cref="FormatString"/> property value changed.
+        /// </summary>
+        /// <param name="oldValue">The old value.</param>
+        /// <param name="newValue">The new value.</param>
+        protected virtual void OnFormatStringChanged(string oldValue, string newValue)
+        {
+            if (IsInitialized)
+            {
+                SyncTextAndValueProperties(false, null);
+            }
+        }
+
+        /// <summary>
+        /// Called when the <see cref="Increment"/> property value changed.
+        /// </summary>
+        /// <param name="oldValue">The old value.</param>
+        /// <param name="newValue">The new value.</param>
+        protected virtual void OnIncrementChanged(double oldValue, double newValue)
+        {
+            if (IsInitialized)
+            {
+                SetValidSpinDirection();
+            }
+        }
+
+        /// <summary>
+        /// Called when the <see cref="IsReadOnly"/> property value changed.
+        /// </summary>
+        /// <param name="oldValue">The old value.</param>
+        /// <param name="newValue">The new value.</param>
+        protected virtual void OnIsReadOnlyChanged(bool oldValue, bool newValue)
+        {
+            SetValidSpinDirection();
+        }
+
+        /// <summary>
+        /// Called when the <see cref="Maximum"/> property value changed.
+        /// </summary>
+        /// <param name="oldValue">The old value.</param>
+        /// <param name="newValue">The new value.</param>
+        protected virtual void OnMaximumChanged(double oldValue, double newValue)
+        {
+            if (IsInitialized)
+            {
+                SetValidSpinDirection();
+            }
+            if (ClipValueToMinMax)
+            {
+                Value = MathUtilities.Clamp(Value, Minimum, Maximum);
+            }
+        }
+
+        /// <summary>
+        /// Called when the <see cref="Minimum"/> property value changed.
+        /// </summary>
+        /// <param name="oldValue">The old value.</param>
+        /// <param name="newValue">The new value.</param>
+        protected virtual void OnMinimumChanged(double oldValue, double newValue)
+        {
+            if (IsInitialized)
+            {
+                SetValidSpinDirection();
+            }
+            if (ClipValueToMinMax)
+            {
+                Value = MathUtilities.Clamp(Value, Minimum, Maximum);
+            }
+        }
+
+        /// <summary>
+        /// Called when the <see cref="Text"/> property value changed.
+        /// </summary>
+        /// <param name="oldValue">The old value.</param>
+        /// <param name="newValue">The new value.</param>
+        protected virtual void OnTextChanged(string oldValue, string newValue)
+        {
+            if (IsInitialized)
+            {
+                SyncTextAndValueProperties(true, Text);
+            }
+        }
+
+        /// <summary>
+        /// Called when the <see cref="Value"/> property value changed.
+        /// </summary>
+        /// <param name="oldValue">The old value.</param>
+        /// <param name="newValue">The new value.</param>
+        protected virtual void OnValueChanged(double oldValue, double newValue)
+        {
+            if (!_internalValueSet && IsInitialized)
+            {
+                SyncTextAndValueProperties(false, null, true);
+            }
+
+            SetValidSpinDirection();
+
+            RaiseValueChangedEvent(oldValue, newValue);
+        }
+
+        /// <summary>
+        /// Called when the <see cref="Increment"/> property has to be coerced.
+        /// </summary>
+        /// <param name="baseValue">The value.</param>
+        protected virtual double OnCoerceIncrement(double baseValue)
+        {
+            return baseValue;
+        }
+
+        /// <summary>
+        /// Called when the <see cref="Maximum"/> property has to be coerced.
+        /// </summary>
+        /// <param name="baseValue">The value.</param>
+        protected virtual double OnCoerceMaximum(double baseValue)
+        {
+            return Math.Max(baseValue, Minimum);
+        }
+
+        /// <summary>
+        /// Called when the <see cref="Minimum"/> property has to be coerced.
+        /// </summary>
+        /// <param name="baseValue">The value.</param>
+        protected virtual double OnCoerceMinimum(double baseValue)
+        {
+            return Math.Min(baseValue, Maximum);
+        }
+
+        /// <summary>
+        /// Called when the <see cref="Value"/> property has to be coerced.
+        /// </summary>
+        /// <param name="baseValue">The value.</param>
+        protected virtual double OnCoerceValue(double baseValue)
+        {
+            return baseValue;
+        }
+
+        /// <summary>
+        /// Raises the OnSpin event when spinning is initiated by the end-user.
+        /// </summary>
+        /// <param name="e">The event args.</param>
+        protected virtual void OnSpin(SpinEventArgs e)
+        {
+            if (e == null)
+            {
+                throw new ArgumentNullException("e");
+            }
+
+            var handler = Spinned;
+            handler?.Invoke(this, e);
+
+            if (e.Direction == SpinDirection.Increase)
+            {
+                DoIncrement();
+            }
+            else
+            {
+                DoDecrement();
+            }
+        }
+
+        /// <summary>
+        /// Raises the <see cref="ValueChanged"/> event.
+        /// </summary>
+        /// <param name="oldValue">The old value.</param>
+        /// <param name="newValue">The new value.</param>
+        protected virtual void RaiseValueChangedEvent(double oldValue, double newValue)
+        {
+            var e = new NumericUpDownValueChangedEventArgs(ValueChangedEvent, oldValue, newValue);
+            RaiseEvent(e);
+        }
+
+        /// <summary>
+        /// Converts the formatted text to a value.
+        /// </summary>
+        private double ConvertTextToValue(string text)
+        {
+            double result = 0;
+
+            if (string.IsNullOrEmpty(text))
+            {
+                return result;
+            }
+
+            // Since the conversion from Value to text using a FormartString may not be parsable,
+            // we verify that the already existing text is not the exact same value.
+            var currentValueText = ConvertValueToText();
+            if (Equals(currentValueText, text))
+            {
+                return Value;
+            }
+
+            result = ConvertTextToValueCore(currentValueText, text);
+
+            if (ClipValueToMinMax)
+            {
+                return MathUtilities.Clamp(result, Minimum, Maximum);
+            }
+
+            ValidateMinMax(result);
+
+            return result;
+        }
+
+        /// <summary>
+        /// Converts the value to formatted text.
+        /// </summary>
+        /// <returns></returns>
+        private string ConvertValueToText()
+        {
+            //Manage FormatString of type "{}{0:N2} °" (in xaml) or "{0:N2} °" in code-behind.
+            if (FormatString.Contains("{0"))
+            {
+                return string.Format(CultureInfo, FormatString, Value);
+            }
+
+            return Value.ToString(FormatString, CultureInfo);
+        }
+
+        /// <summary>
+        /// Called by OnSpin when the spin direction is SpinDirection.Increase.
+        /// </summary>
+        private void OnIncrement()
+        {
+            var result = Value + Increment;
+            Value = MathUtilities.Clamp(result, Minimum, Maximum);
+        }
+
+        /// <summary>
+        /// Called by OnSpin when the spin direction is SpinDirection.Descrease.
+        /// </summary>
+        private void OnDecrement()
+        {
+            var result = Value - Increment;
+            Value = MathUtilities.Clamp(result, Minimum, Maximum);
+        }
+
+        /// <summary>
+        /// Sets the valid spin directions.
+        /// </summary>
+        private void SetValidSpinDirection()
+        {
+            var validDirections = ValidSpinDirections.None;
+
+            // Zero increment always prevents spin.
+            if (Increment != 0 && !IsReadOnly)
+            {
+                if (Value < Maximum)
+                {
+                    validDirections = validDirections | ValidSpinDirections.Increase;
+                }
+
+                if (Value > Minimum)
+                {
+                    validDirections = validDirections | ValidSpinDirections.Decrease;
+                }
+            }
+
+            if (Spinner != null)
+            {
+                Spinner.ValidSpinDirection = validDirections;
+            }
+        }
+
+        /// <summary>
+        /// Called when the <see cref="CultureInfo"/> property value changed.
+        /// </summary>
+        /// <param name="e">The event args.</param>
+        private static void OnCultureInfoChanged(AvaloniaPropertyChangedEventArgs e)
+        {
+            if (e.Sender is NumericUpDown upDown)
+            {
+                var oldValue = (CultureInfo)e.OldValue;
+                var newValue = (CultureInfo)e.NewValue;
+                upDown.OnCultureInfoChanged(oldValue, newValue);
+            }
+        }
+
+        /// <summary>
+        /// Called when the <see cref="Increment"/> property value changed.
+        /// </summary>
+        /// <param name="e">The event args.</param>
+        private static void IncrementChanged(AvaloniaPropertyChangedEventArgs e)
+        {
+            if (e.Sender is NumericUpDown upDown)
+            {
+                var oldValue = (double)e.OldValue;
+                var newValue = (double)e.NewValue;
+                upDown.OnIncrementChanged(oldValue, newValue);
+            }
+        }
+
+        /// <summary>
+        /// Called when the <see cref="FormatString"/> property value changed.
+        /// </summary>
+        /// <param name="e">The event args.</param>
+        private static void FormatStringChanged(AvaloniaPropertyChangedEventArgs e)
+        {
+            if (e.Sender is NumericUpDown upDown)
+            {
+                var oldValue = (string)e.OldValue;
+                var newValue = (string)e.NewValue;
+                upDown.OnFormatStringChanged(oldValue, newValue);
+            }
+        }
+
+        /// <summary>
+        /// Called when the <see cref="IsReadOnly"/> property value changed.
+        /// </summary>
+        /// <param name="e">The event args.</param>
+        private static void OnIsReadOnlyChanged(AvaloniaPropertyChangedEventArgs e)
+        {
+            if (e.Sender is NumericUpDown upDown)
+            {
+                var oldValue = (bool)e.OldValue;
+                var newValue = (bool)e.NewValue;
+                upDown.OnIsReadOnlyChanged(oldValue, newValue);
+            }
+        }
+
+        /// <summary>
+        /// Called when the <see cref="Maximum"/> property value changed.
+        /// </summary>
+        /// <param name="e">The event args.</param>
+        private static void OnMaximumChanged(AvaloniaPropertyChangedEventArgs e)
+        {
+            if (e.Sender is NumericUpDown upDown)
+            {
+                var oldValue = (double)e.OldValue;
+                var newValue = (double)e.NewValue;
+                upDown.OnMaximumChanged(oldValue, newValue);
+            }
+        }
+
+        /// <summary>
+        /// Called when the <see cref="Minimum"/> property value changed.
+        /// </summary>
+        /// <param name="e">The event args.</param>
+        private static void OnMinimumChanged(AvaloniaPropertyChangedEventArgs e)
+        {
+            if (e.Sender is NumericUpDown upDown)
+            {
+                var oldValue = (double)e.OldValue;
+                var newValue = (double)e.NewValue;
+                upDown.OnMinimumChanged(oldValue, newValue);
+            }
+        }
+
+        /// <summary>
+        /// Called when the <see cref="Text"/> property value changed.
+        /// </summary>
+        /// <param name="e">The event args.</param>
+        private static void OnTextChanged(AvaloniaPropertyChangedEventArgs e)
+        {
+            if (e.Sender is NumericUpDown upDown)
+            {
+                var oldValue = (string)e.OldValue;
+                var newValue = (string)e.NewValue;
+                upDown.OnTextChanged(oldValue, newValue);
+            }
+        }
+
+        /// <summary>
+        /// Called when the <see cref="Value"/> property value changed.
+        /// </summary>
+        /// <param name="e">The event args.</param>
+        private static void OnValueChanged(AvaloniaPropertyChangedEventArgs e)
+        {
+            if (e.Sender is NumericUpDown upDown)
+            {
+                var oldValue = (double)e.OldValue;
+                var newValue = (double)e.NewValue;
+                upDown.OnValueChanged(oldValue, newValue);
+            }
+        }
+
+        private void SetValueInternal(double value)
+        {
+            _internalValueSet = true;
+            try
+            {
+                Value = value;
+            }
+            finally
+            {
+                _internalValueSet = false;
+            }
+        }
+
+        private static double OnCoerceMaximum(NumericUpDown upDown, double value)
+        {
+            return upDown.OnCoerceMaximum(value);
+        }
+
+        private static double OnCoerceMinimum(NumericUpDown upDown, double value)
+        {
+            return upDown.OnCoerceMinimum(value);
+        }
+
+        private static double OnCoerceIncrement(NumericUpDown upDown, double value)
+        {
+            return upDown.OnCoerceIncrement(value);
+        }
+
+        private void TextBoxOnTextChanged()
+        {
+            try
+            {
+                _isTextChangedFromUI = true;
+                if (TextBox != null)
+                {
+                    Text = TextBox.Text;
+                }
+            }
+            finally
+            {
+                _isTextChangedFromUI = false;
+            }
+        }
+
+        private void OnSpinnerSpin(object sender, SpinEventArgs e)
+        {
+            if (AllowSpin && !IsReadOnly)
+            {
+                var spin = !e.UsingMouseWheel;
+                spin |= ((TextBox != null) && TextBox.IsFocused);
+
+                if (spin)
+                {
+                    e.Handled = true;
+                    OnSpin(e);
+                }
+            }
+        }
+
+        private void DoDecrement()
+        {
+            if (Spinner == null || (Spinner.ValidSpinDirection & ValidSpinDirections.Decrease) == ValidSpinDirections.Decrease)
+            {
+                OnDecrement();
+            }
+        }
+
+        private void DoIncrement()
+        {
+            if (Spinner == null || (Spinner.ValidSpinDirection & ValidSpinDirections.Increase) == ValidSpinDirections.Increase)
+            {
+                OnIncrement();
+            }
+        }
+
+        public event EventHandler<SpinEventArgs> Spinned;
+
+        private void TextBoxOnPointerPressed(object sender, PointerPressedEventArgs e)
+        {
+            if (e.Device.Captured != Spinner)
+            {
+                Dispatcher.UIThread.InvokeAsync(() => { e.Device.Capture(Spinner); }, DispatcherPriority.Input);
+            }
+        }
+
+        /// <summary>
+        /// Defines the <see cref="ValueChanged"/> event.
+        /// </summary>
+        public static readonly RoutedEvent<NumericUpDownValueChangedEventArgs> ValueChangedEvent =
+            RoutedEvent.Register<NumericUpDown, NumericUpDownValueChangedEventArgs>(nameof(ValueChanged), RoutingStrategies.Bubble);
+
+        /// <summary>
+        /// Raised when the <see cref="Value"/> changes.
+        /// </summary>
+        public event EventHandler<SpinEventArgs> ValueChanged
+        {
+            add { AddHandler(ValueChangedEvent, value); }
+            remove { RemoveHandler(ValueChangedEvent, value); }
+        }
+
+        private bool CommitInput()
+        {
+            return SyncTextAndValueProperties(true, Text);
+        }
+
+        /// <summary>
+        /// Synchronize <see cref="Text"/> and <see cref="Value"/> properties.
+        /// </summary>
+        /// <param name="updateValueFromText">If value should be updated from text.</param>
+        /// <param name="text">The text.</param>
+        private bool SyncTextAndValueProperties(bool updateValueFromText, string text)
+        {
+            return SyncTextAndValueProperties(updateValueFromText, text, false);
+        }
+
+        /// <summary>
+        /// Synchronize <see cref="Text"/> and <see cref="Value"/> properties.
+        /// </summary>
+        /// <param name="updateValueFromText">If value should be updated from text.</param>
+        /// <param name="text">The text.</param>
+        /// <param name="forceTextUpdate">Force text update.</param>
+        private bool SyncTextAndValueProperties(bool updateValueFromText, string text, bool forceTextUpdate)
+        {
+            if (_isSyncingTextAndValueProperties)
+                return true;
+
+            _isSyncingTextAndValueProperties = true;
+            var parsedTextIsValid = true;
+            try
+            {
+                if (updateValueFromText)
+                {
+                    if (!string.IsNullOrEmpty(text))
+                    {
+                        try
+                        {
+                            var newValue = ConvertTextToValue(text);
+                            if (!Equals(newValue, Value))
+                            {
+                                SetValueInternal(newValue);
+                            }
+                        }
+                        catch
+                        {
+                            parsedTextIsValid = false;
+                        }
+                    }
+                }
+
+                // Do not touch the ongoing text input from user.
+                if (!_isTextChangedFromUI)
+                {
+                    var keepEmpty = !forceTextUpdate && string.IsNullOrEmpty(Text);
+                    if (!keepEmpty)
+                    {
+                        var newText = ConvertValueToText();
+                        if (!Equals(Text, newText))
+                        {
+                            Text = newText;
+                        }
+                    }
+
+                    // Sync Text and textBox
+                    if (TextBox != null)
+                    {
+                        TextBox.Text = Text;
+                    }
+                }
+
+                if (_isTextChangedFromUI && !parsedTextIsValid)
+                {
+                    // Text input was made from the user and the text
+                    // repesents an invalid value. Disable the spinner in this case.
+                    if (Spinner != null)
+                    {
+                        Spinner.ValidSpinDirection = ValidSpinDirections.None;
+                    }
+                }
+                else
+                {
+                    SetValidSpinDirection();
+                }
+            }
+            finally
+            {
+                _isSyncingTextAndValueProperties = false;
+            }
+            return parsedTextIsValid;
+        }
+
+        private double ConvertTextToValueCore(string currentValueText, string text)
+        {
+            double result;
+
+            if (IsPercent(FormatString))
+            {
+                result = decimal.ToDouble(ParsePercent(text, CultureInfo));
+            }
+            else
+            {
+                // Problem while converting new text
+                if (!double.TryParse(text, ParsingNumberStyle, CultureInfo, out var outputValue))
+                {
+                    var shouldThrow = true;
+
+                    // Check if CurrentValueText is also failing => it also contains special characters. ex : 90°
+                    if (!double.TryParse(currentValueText, ParsingNumberStyle, CultureInfo, out var _))
+                    {
+                        // extract non-digit characters
+                        var currentValueTextSpecialCharacters = currentValueText.Where(c => !char.IsDigit(c));
+                        var textSpecialCharacters = text.Where(c => !char.IsDigit(c));
+                        // same non-digit characters on currentValueText and new text => remove them on new Text to parse it again.
+                        if (currentValueTextSpecialCharacters.Except(textSpecialCharacters).ToList().Count == 0)
+                        {
+                            foreach (var character in textSpecialCharacters)
+                            {
+                                text = text.Replace(character.ToString(), string.Empty);
+                            }
+                            // if without the special characters, parsing is good, do not throw
+                            if (double.TryParse(text, ParsingNumberStyle, CultureInfo, out outputValue))
+                            {
+                                shouldThrow = false;
+                            }
+                        }
+                    }
+
+                    if (shouldThrow)
+                    {
+                        throw new InvalidDataException("Input string was not in a correct format.");
+                    }
+                }
+                result = outputValue;
+            }
+            return result;
+        }
+
+        private void ValidateMinMax(double value)
+        {
+            if (value < Minimum)
+            {
+                throw new ArgumentOutOfRangeException(nameof(Minimum), string.Format("Value must be greater than Minimum value of {0}", Minimum));
+            }
+            else if (value > Maximum)
+            {
+                throw new ArgumentOutOfRangeException(nameof(Maximum), string.Format("Value must be less than Maximum value of {0}", Maximum));
+            }
+        }
+
+        /// <summary>
+        /// Parse percent format text
+        /// </summary>
+        /// <param name="text">Text to parse.</param>
+        /// <param name="cultureInfo">The culture info.</param>
+        private static decimal ParsePercent(string text, IFormatProvider cultureInfo)
+        {
+            var info = NumberFormatInfo.GetInstance(cultureInfo);
+            text = text.Replace(info.PercentSymbol, null);
+            var result = decimal.Parse(text, NumberStyles.Any, info);
+            result = result / 100;
+            return result;
+        }
+
+
+        private bool IsPercent(string stringToTest)
+        {
+            var PIndex = stringToTest.IndexOf("P", StringComparison.Ordinal);
+            if (PIndex >= 0)
+            {
+                //stringToTest contains a "P" between 2 "'", it's considered as text, not percent
+                var isText = stringToTest.Substring(0, PIndex).Contains("'")
+                             && stringToTest.Substring(PIndex, FormatString.Length - PIndex).Contains("'");
+
+                return !isText;
+            }
+            return false;
+        }
+    }
+}

+ 16 - 0
src/Avalonia.Controls/NumericUpDown/NumericUpDownValueChangedEventArgs.cs

@@ -0,0 +1,16 @@
+using Avalonia.Interactivity;
+
+namespace Avalonia.Controls
+{
+    public class NumericUpDownValueChangedEventArgs : RoutedEventArgs
+    {
+        public NumericUpDownValueChangedEventArgs(RoutedEvent routedEvent, double oldValue,  double newValue) : base(routedEvent)
+        {
+            OldValue = oldValue;
+            NewValue = newValue;
+        }
+
+        public double OldValue { get; }
+        public double NewValue { get; }
+    }
+}

+ 6 - 0
src/Avalonia.Controls/Platform/IWindowImpl.cs

@@ -44,5 +44,11 @@ namespace Avalonia.Platform
         /// Enables or disables the taskbar icon
         /// </summary>
         void ShowTaskbarIcon(bool value);
+
+        /// <summary>
+        /// Gets or sets a method called before the underlying implementation is destroyed.
+        /// Return true to prevent the underlying implementation from closing.
+        /// </summary>
+        Func<bool> Closing { get; set; }
     }
 }

+ 3 - 0
src/Avalonia.Controls/TextBox.cs

@@ -256,6 +256,8 @@ namespace Avalonia.Controls
             {
                 _presenter?.ShowCaret();
             }
+
+            e.Handled = true;
         }
 
         protected override void OnLostFocus(RoutedEventArgs e)
@@ -269,6 +271,7 @@ namespace Avalonia.Controls
         protected override void OnTextInput(TextInputEventArgs e)
         {
             HandleTextInput(e.Text);
+            e.Handled = true;
         }
 
         private void HandleTextInput(string input)

+ 38 - 5
src/Avalonia.Controls/Window.cs

@@ -13,6 +13,7 @@ using Avalonia.Styling;
 using System.Collections.Generic;
 using System.Linq;
 using JetBrains.Annotations;
+using System.ComponentModel;
 
 namespace Avalonia.Controls
 {
@@ -129,6 +130,7 @@ namespace Avalonia.Controls
         public Window(IWindowImpl impl)
             : base(impl)
         {
+            impl.Closing = HandleClosing;
             _maxPlatformClientSize = PlatformImpl?.MaxClientSize ?? default(Size);
             Screens = new Screens(PlatformImpl?.Screen);
         }
@@ -230,20 +232,23 @@ namespace Avalonia.Controls
         /// <inheritdoc/>
         Type IStyleable.StyleKey => typeof(Window);
 
+        /// <summary>
+        /// Fired before a window is closed.
+        /// </summary>
+        public event EventHandler<CancelEventArgs> Closing;
+
         /// <summary>
         /// Closes the window.
         /// </summary>
         public void Close()
         {
-            s_windows.Remove(this);
-            PlatformImpl?.Dispose();
-            IsVisible = false;
+            Close(false);
         }
 
         protected override void HandleApplicationExiting()
         {
             base.HandleApplicationExiting();
-            Close();
+            Close(true);
         }
 
         /// <summary>
@@ -258,7 +263,35 @@ namespace Avalonia.Controls
         public void Close(object dialogResult)
         {
             _dialogResult = dialogResult;
-            Close();
+            Close(false);
+        }
+
+        internal void Close(bool ignoreCancel)
+        {
+            var cancelClosing = false;
+            try
+            {
+                cancelClosing = HandleClosing();
+            }
+            finally
+            {
+                if (ignoreCancel || !cancelClosing)
+                {
+                    s_windows.Remove(this);
+                    PlatformImpl?.Dispose();
+                    IsVisible = false;
+                }
+            }
+        }
+
+        /// <summary>
+        /// Handles a closing notification from <see cref="IWindowImpl.Closing"/>.
+        /// </summary>
+        protected virtual bool HandleClosing()
+        {
+            var args = new CancelEventArgs();
+            Closing?.Invoke(this, args);
+            return args.Cancel;
         }
 
         /// <summary>

+ 1 - 0
src/Avalonia.DesignerSupport/Remote/PreviewerWindowImpl.cs

@@ -39,6 +39,7 @@ namespace Avalonia.DesignerSupport.Remote
         public Action<Point> PositionChanged { get; set; }
         public Action Deactivated { get; set; }
         public Action Activated { get; set; }
+        public Func<bool> Closing { get; set; }
         public IPlatformHandle Handle { get; }
         public WindowState WindowState { get; set; }
         public Size MaxClientSize { get; } = new Size(4096, 4096);

+ 1 - 0
src/Avalonia.DesignerSupport/Remote/Stubs.cs

@@ -26,6 +26,7 @@ namespace Avalonia.DesignerSupport.Remote
         public Action<Rect> Paint { get; set; }
         public Action<Size> Resized { get; set; }
         public Action<double> ScalingChanged { get; set; }
+        public Func<bool> Closing { get; set; }
         public Action Closed { get; set; }
         public IMouseDevice MouseDevice { get; } = new MouseDevice();
         public Point Position { get; set; }

+ 1 - 0
src/Avalonia.Themes.Default/DefaultTheme.xaml

@@ -43,4 +43,5 @@
   <StyleInclude Source="resm:Avalonia.Themes.Default.Calendar.xaml?assembly=Avalonia.Themes.Default"/>
   <StyleInclude Source="resm:Avalonia.Themes.Default.DatePicker.xaml?assembly=Avalonia.Themes.Default"/>
   <StyleInclude Source="resm:Avalonia.Themes.Default.ButtonSpinner.xaml?assembly=Avalonia.Themes.Default"/>
+  <StyleInclude Source="resm:Avalonia.Themes.Default.NumericUpDown.xaml?assembly=Avalonia.Themes.Default"/>
 </Styles>

+ 4 - 0
src/Avalonia.Themes.Default/MenuItem.xaml

@@ -133,4 +133,8 @@
   <Style Selector="MenuItem:empty /template/ Path#rightArrow">
     <Setter Property="IsVisible" Value="False"/>
   </Style>
+
+  <Style Selector="MenuItem:disabled">
+    <Setter Property="Opacity" Value="{DynamicResource ThemeDisabledOpacity}"/>
+  </Style>
 </Styles>

+ 41 - 0
src/Avalonia.Themes.Default/NumericUpDown.xaml

@@ -0,0 +1,41 @@
+<Styles xmlns="https://github.com/avaloniaui">
+  <Style Selector="NumericUpDown">
+    <Setter Property="TemplatedControl.BorderBrush" Value="{DynamicResource ThemeBorderLightBrush}"/>
+    <Setter Property="TemplatedControl.BorderThickness" Value="{DynamicResource ThemeBorderThickness}"/>
+    <Setter Property="TemplatedControl.Background" Value="{DynamicResource ThemeBackgroundBrush}" />
+    <Setter Property="TemplatedControl.Foreground" Value="{DynamicResource ThemeForegroundBrush}" />
+    <Setter Property="TemplatedControl.Template">
+      <ControlTemplate>
+        <ButtonSpinner Name="PART_Spinner"
+                       Background="{TemplateBinding Background}"
+                       BorderThickness="{TemplateBinding BorderThickness}"
+                       BorderBrush="{TemplateBinding BorderBrush}"
+                       HorizontalContentAlignment="Stretch"
+                       VerticalContentAlignment="Stretch"
+                       AllowSpin="{TemplateBinding AllowSpin}"
+                       ShowButtonSpinner="{TemplateBinding ShowButtonSpinner}"
+                       ButtonSpinnerLocation="{TemplateBinding ButtonSpinnerLocation}">
+          <TextBox Name="PART_TextBox"
+                   BorderThickness="0"
+                   Background="Transparent"
+                   ContextMenu="{TemplateBinding ContextMenu}"
+                   FontFamily="{TemplateBinding FontFamily}"
+                   FontSize="{TemplateBinding FontSize}"
+                   FontStyle="{TemplateBinding FontStyle}"
+                   FontWeight="{TemplateBinding FontWeight}"
+                   Foreground="{TemplateBinding Foreground}"
+                   Watermark="{TemplateBinding Watermark}"
+                   IsReadOnly="{TemplateBinding IsReadOnly}"
+                   Text="{TemplateBinding Text}"
+                   Padding="{TemplateBinding Padding}"
+                   TextAlignment="Left"
+                   Margin="1"
+                   MinWidth="20"
+                   AcceptsReturn="False"
+                   TextWrapping="NoWrap">
+          </TextBox>
+        </ButtonSpinner>
+      </ControlTemplate>
+    </Setter>
+  </Style>
+</Styles>

+ 13 - 1
src/Avalonia.Visuals/Matrix.cs

@@ -150,6 +150,19 @@ namespace Avalonia
             return new Matrix(cos, sin, -sin, cos, 0, 0);
         }
 
+        /// <summary>
+        /// Creates a skew matrix from the given axis skew angles in radians.
+        /// </summary>
+        /// <param name="xAngle">The amount of skew along the X-axis, in radians.</param>
+        /// <param name="yAngle">The amount of skew along the Y-axis, in radians.</param>
+        /// <returns>A rotation matrix.</returns>
+        public static Matrix CreateSkew(double xAngle, double yAngle)
+        {
+            double tanX = Math.Tan(xAngle);
+            double tanY = Math.Tan(yAngle);
+            return new Matrix(1.0, tanY, tanX, 1.0, 0.0, 0.0);
+        }
+
         /// <summary>
         /// Creates a scale matrix from the given X and Y components.
         /// </summary>
@@ -215,7 +228,6 @@ namespace Avalonia
             return (_m11 * _m22) - (_m12 * _m21);
         }
 
-
         /// <summary>
         /// Returns a boolean indicating whether the matrix is equal to the other given matrix.
         /// </summary>

+ 69 - 0
src/Avalonia.Visuals/Media/SkewTransform.cs

@@ -0,0 +1,69 @@
+// Copyright (c) The Avalonia Project. All rights reserved.
+// Licensed under the MIT license. See licence.md file in the project root for full license information.
+
+using System;
+using Avalonia.VisualTree;
+
+namespace Avalonia.Media
+{
+    /// <summary>
+    /// Skews an <see cref="IVisual"/>.
+    /// </summary>
+    public class SkewTransform : Transform
+    {
+        /// <summary>
+        /// Defines the <see cref="AngleX"/> property.
+        /// </summary>
+        public static readonly StyledProperty<double> AngleXProperty =
+                    AvaloniaProperty.Register<SkewTransform, double>(nameof(AngleX));
+
+        /// <summary>
+        /// Defines the <see cref="AngleY"/> property.
+        /// </summary>
+        public static readonly StyledProperty<double> AngleYProperty =
+                    AvaloniaProperty.Register<SkewTransform, double>(nameof(AngleY));
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="SkewTransform"/> class.
+        /// </summary>
+        public SkewTransform()
+        {
+            this.GetObservable(AngleXProperty).Subscribe(_ => RaiseChanged());
+            this.GetObservable(AngleYProperty).Subscribe(_ => RaiseChanged());
+        }
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="SkewTransform"/> class.
+        /// </summary>
+        /// <param name="angleX">The skew angle of X-axis, in degrees.</param>
+        /// <param name="angleY">The skew angle of Y-axis, in degrees.</param>
+        public SkewTransform(double angleX, double angleY) : this()
+        {
+            AngleX = angleX;
+            AngleY = angleY;
+        }
+
+        /// <summary>
+        /// Gets or sets the AngleX property.
+        /// </summary>
+        public double AngleX
+        {
+            get { return GetValue(AngleXProperty); }
+            set { SetValue(AngleXProperty, value); }
+        }
+
+        /// <summary>
+        /// Gets or sets the AngleY property.
+        /// </summary>
+        public double AngleY
+        {
+            get { return GetValue(AngleYProperty); }
+            set { SetValue(AngleYProperty, value); }
+        }
+
+        /// <summary>
+        /// Gets the tranform's <see cref="Matrix"/>.
+        /// </summary>
+        public override Matrix Value => Matrix.CreateSkew(Matrix.ToRadians(AngleX), Matrix.ToRadians(AngleY));
+    }
+}

+ 8 - 8
src/Gtk/Avalonia.Gtk3/KeyTransform.cs

@@ -151,8 +151,8 @@ namespace Avalonia.Gtk.Common
             { GdkKey.R2, Key.F22 },
             { GdkKey.F23, Key.F23 },
             { GdkKey.R4, Key.F24 },
-            //{ GdkKey.?, Key.NumLock }
-            //{ GdkKey.?, Key.Scroll }
+            { GdkKey.Num_Lock, Key.NumLock },
+            { GdkKey.Scroll_Lock, Key.Scroll },
             //{ GdkKey.?, Key.LeftShift }
             //{ GdkKey.?, Key.RightShift }
             //{ GdkKey.?, Key.LeftCtrl }
@@ -177,12 +177,12 @@ namespace Avalonia.Gtk.Common
             //{ GdkKey.?, Key.SelectMedia }
             //{ GdkKey.?, Key.LaunchApplication1 }
             //{ GdkKey.?, Key.LaunchApplication2 }
-            //{ GdkKey.?, Key.OemSemicolon }
-            //{ GdkKey.?, Key.OemPlus }
-            //{ GdkKey.?, Key.OemComma }
-            //{ GdkKey.?, Key.OemMinus }
-            //{ GdkKey.?, Key.OemPeriod }
-            //{ GdkKey.?, Key.Oem2 }
+            { GdkKey.semicolon, Key.OemSemicolon },
+            { GdkKey.plus, Key.OemPlus },
+            { GdkKey.comma, Key.OemComma },
+            { GdkKey.minus, Key.OemMinus },
+            { GdkKey.period, Key.OemPeriod },
+            { GdkKey.slash, Key.Oem2 }
             //{ GdkKey.?, Key.OemTilde }
             //{ GdkKey.?, Key.AbntC1 }
             //{ GdkKey.?, Key.AbntC2 }

+ 8 - 0
src/Gtk/Avalonia.Gtk3/WindowBaseImpl.cs

@@ -54,6 +54,7 @@ namespace Avalonia.Gtk3
             ConnectEvent("key-press-event", OnKeyEvent);
             ConnectEvent("key-release-event", OnKeyEvent);
             ConnectEvent("leave-notify-event", OnLeaveNotifyEvent);
+            ConnectEvent("delete-event", OnClosingEvent);
             Connect<Native.D.signal_generic>("destroy", OnDestroy);
             Native.GtkWidgetRealize(gtkWidget);
             GdkWindowHandle = this.Handle.Handle;
@@ -125,6 +126,12 @@ namespace Avalonia.Gtk3
             return rv;
         }
 
+        private unsafe bool OnClosingEvent(IntPtr w, IntPtr ev, IntPtr userdata)
+        {
+            bool? preventClosing = Closing?.Invoke();
+            return preventClosing ?? false;
+        }
+
         private unsafe bool OnButton(IntPtr w, IntPtr ev, IntPtr userdata)
         {
             var evnt = (GdkEventButton*)ev;
@@ -343,6 +350,7 @@ namespace Avalonia.Gtk3
         string IPlatformHandle.HandleDescriptor => "HWND";
 
         public Action Activated { get; set; }
+        public Func<bool> Closing { get; set; }
         public Action Closed { get; set; }
         public Action Deactivated { get; set; }
         public Action<RawInputEventArgs> Input { get; set; }

+ 8 - 0
src/OSX/Avalonia.MonoMac/WindowBaseImpl.cs

@@ -4,6 +4,7 @@ using Avalonia.Input.Raw;
 using Avalonia.Platform;
 using MonoMac.AppKit;
 using MonoMac.CoreGraphics;
+using MonoMac.Foundation;
 using MonoMac.ObjCRuntime;
 
 namespace Avalonia.MonoMac
@@ -69,6 +70,12 @@ namespace Avalonia.MonoMac
                 _impl.PositionChanged?.Invoke(_impl.Position);
             }
 
+            public override bool WindowShouldClose(NSObject sender)
+            {
+                bool? preventClose = _impl.Closing?.Invoke();
+                return preventClose != true;
+            }
+
             public override void WillClose(global::MonoMac.Foundation.NSNotification notification)
             {
                 _impl.Window.Dispose();
@@ -107,6 +114,7 @@ namespace Avalonia.MonoMac
         public Action<Point> PositionChanged { get; set; }
         public Action Deactivated { get; set; }
         public Action Activated { get; set; }
+        public Func<bool> Closing { get; set; }
 
         public override Size ClientSize => Window.ContentRectFor(Window.Frame).Size.ToAvaloniaSize();
 

+ 10 - 0
src/Windows/Avalonia.Win32/WindowImpl.cs

@@ -56,6 +56,8 @@ namespace Avalonia.Win32
 
         public Action Activated { get; set; }
 
+        public Func<bool> Closing { get; set; }
+
         public Action Closed { get; set; }
 
         public Action Deactivated { get; set; }
@@ -431,6 +433,14 @@ namespace Avalonia.Win32
 
                     return IntPtr.Zero;
 
+                case UnmanagedMethods.WindowsMessage.WM_CLOSE:
+                    bool? preventClosing = Closing?.Invoke();
+                    if (preventClosing == true)
+                    {
+                        return IntPtr.Zero;
+                    }
+                    break;
+
                 case UnmanagedMethods.WindowsMessage.WM_DESTROY:
                     //Window doesn't exist anymore
                     _hwnd = IntPtr.Zero;

+ 92 - 2
tests/Avalonia.Controls.UnitTests/LayoutTransformControlTests.cs

@@ -30,6 +30,52 @@ namespace Avalonia.Controls.UnitTests
                 new Size(50, 25));
         }
 
+        [Fact]
+        public void Measure_On_Skew_X_axis_45_degrees_Is_Correct()
+        {
+            TransformMeasureSizeTest(
+                new Size(100, 100),
+                new SkewTransform() { AngleX = 45 },
+                new Size(200, 100));
+
+        }
+
+        [Fact]
+        public void Measure_On_Skew_Y_axis_45_degrees_Is_Correct()
+        {
+            TransformMeasureSizeTest(
+                new Size(100, 100),
+                new SkewTransform() { AngleY = 45 },
+                new Size(100, 200));
+        }
+
+        [Fact]
+        public void Measure_On_Skew_X_axis_minus_45_degrees_Is_Correct()
+        {
+            TransformMeasureSizeTest(
+                new Size(100, 100),
+                new SkewTransform() { AngleX = -45 },
+                new Size(200, 100));
+        }
+
+        [Fact]
+        public void Measure_On_Skew_Y_axis_minus_45_degrees_Is_Correct()
+        {
+            TransformMeasureSizeTest(
+                new Size(100, 100),
+                new SkewTransform() { AngleY = -45 },
+                new Size(100, 200));
+        }
+
+        [Fact]
+        public void Measure_On_Skew_0_degrees_Is_Correct()
+        {
+            TransformMeasureSizeTest(
+                new Size(100, 100),
+                new SkewTransform() { AngleX = 0, AngleY = 0 },
+                new Size(100, 100));
+        }
+
         [Fact]
         public void Measure_On_Rotate_90_degrees_Is_Correct()
         {
@@ -125,7 +171,7 @@ namespace Avalonia.Controls.UnitTests
         }
 
         [Fact]
-        public void Should_Generate_RenderTransform_90_degrees()
+        public void Should_Generate_RotateTransform_90_degrees()
         {
             LayoutTransformControl lt = CreateWithChildAndMeasureAndTransform(
                                         100,
@@ -147,7 +193,7 @@ namespace Avalonia.Controls.UnitTests
         }
 
         [Fact]
-        public void Should_Generate_RenderTransform_minus_90_degrees()
+        public void Should_Generate_RotateTransform_minus_90_degrees()
         {
             LayoutTransformControl lt = CreateWithChildAndMeasureAndTransform(
                                         100,
@@ -189,6 +235,50 @@ namespace Avalonia.Controls.UnitTests
             Assert.Equal(m.M32, res.M32, 3);
         }
 
+        [Fact]
+        public void Should_Generate_SkewTransform_45_degrees()
+        {
+            LayoutTransformControl lt = CreateWithChildAndMeasureAndTransform(
+                                        100,
+                                        100,
+                                        new SkewTransform() { AngleX = 45, AngleY = 45 });
+
+            Assert.NotNull(lt.TransformRoot.RenderTransform);
+
+            Matrix m = lt.TransformRoot.RenderTransform.Value;
+
+            Matrix res = Matrix.CreateSkew(Matrix.ToRadians(45), Matrix.ToRadians(45));
+
+            Assert.Equal(m.M11, res.M11, 3);
+            Assert.Equal(m.M12, res.M12, 3);
+            Assert.Equal(m.M21, res.M21, 3);
+            Assert.Equal(m.M22, res.M22, 3);
+            Assert.Equal(m.M31, res.M31, 3);
+            Assert.Equal(m.M32, res.M32, 3);
+        }
+
+        [Fact]
+        public void Should_Generate_SkewTransform_minus_45_degrees()
+        {
+            LayoutTransformControl lt = CreateWithChildAndMeasureAndTransform(
+                                        100,
+                                        100,
+                                        new SkewTransform() { AngleX = -45, AngleY = -45 });
+
+            Assert.NotNull(lt.TransformRoot.RenderTransform);
+
+            Matrix m = lt.TransformRoot.RenderTransform.Value;
+
+            Matrix res = Matrix.CreateSkew(Matrix.ToRadians(-45), Matrix.ToRadians(-45));
+
+            Assert.Equal(m.M11, res.M11, 3);
+            Assert.Equal(m.M12, res.M12, 3);
+            Assert.Equal(m.M21, res.M21, 3);
+            Assert.Equal(m.M22, res.M22, 3);
+            Assert.Equal(m.M31, res.M31, 3);
+            Assert.Equal(m.M32, res.M32, 3);
+        }
+
         private static void TransformMeasureSizeTest(Size size, Transform transform, Size expectedSize)
         {
             LayoutTransformControl lt = CreateWithChildAndMeasureAndTransform(