Browse Source

Merge branch 'master' into experimental/itemsrepeater

Steven Kirk 6 years ago
parent
commit
cf070f2ee7
32 changed files with 1819 additions and 443 deletions
  1. 2 0
      src/Avalonia.Base/Data/BindingOperations.cs
  2. 46 0
      src/Avalonia.Base/Data/Converters/StringFormatMultiValueConverter.cs
  3. 31 0
      src/Avalonia.Base/Data/Core/BindingExpression.cs
  4. 17 7
      src/Avalonia.Controls/AppBuilderBase.cs
  5. 1 1
      src/Avalonia.Controls/ApplicationLifetimes/ControlledApplicationLifetimeExitEventArgs.cs
  6. 1 1
      src/Avalonia.Controls/ApplicationLifetimes/StartupEventArgs.cs
  7. 27 6
      src/Avalonia.Controls/ColumnDefinition.cs
  8. 1 1
      src/Avalonia.Controls/DesktopApplicationExtensions.cs
  9. 104 69
      src/Avalonia.Controls/DockPanel.cs
  10. 12 2
      src/Avalonia.Controls/ItemsControl.cs
  11. 12 1
      src/Avalonia.Controls/ListBox.cs
  12. 43 22
      src/Avalonia.Controls/Primitives/RangeBase.cs
  13. 409 235
      src/Avalonia.Controls/Primitives/SelectingItemsControl.cs
  14. 31 10
      src/Avalonia.Controls/RowDefinition.cs
  15. 3 1
      src/Avalonia.Controls/ShutdownMode.cs
  16. 24 2
      src/Avalonia.Controls/TreeView.cs
  17. 4 6
      src/Avalonia.Controls/WrapPanel.cs
  18. 87 0
      src/Avalonia.ReactiveUI/AutoSuspendHelper.cs
  19. 9 1
      src/Avalonia.Styling/StyledElement.cs
  20. 3 7
      src/Avalonia.X11/X11KeyTransform.cs
  21. 13 2
      src/Avalonia.X11/X11Window.cs
  22. 16 15
      src/Markup/Avalonia.Markup/Data/MultiBinding.cs
  23. 37 0
      tests/Avalonia.Controls.UnitTests/Primitives/RangeBaseTests.cs
  24. 113 2
      tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs
  25. 493 30
      tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs
  26. 31 9
      tests/Avalonia.Markup.UnitTests/Data/MultiBindingTests_Converters.cs
  27. 71 0
      tests/Avalonia.Markup.Xaml.UnitTests/Converters/ValueConverterTests.cs
  28. 28 11
      tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests.cs
  29. 19 0
      tests/Avalonia.Markup.Xaml.UnitTests/Xaml/BasicTests.cs
  30. 31 0
      tests/Avalonia.Markup.Xaml.UnitTests/Xaml/BindingTests.cs
  31. 98 0
      tests/Avalonia.ReactiveUI.UnitTests/AutoSuspendHelperTest.cs
  32. 2 2
      tests/Avalonia.ReactiveUI.UnitTests/AvaloniaActivationForViewFetcherTest.cs

+ 2 - 0
src/Avalonia.Base/Data/BindingOperations.cs

@@ -10,6 +10,8 @@ namespace Avalonia.Data
 {
     public static class BindingOperations
     {
+        public static readonly object DoNothing = new object();
+
         /// <summary>
         /// Applies an <see cref="InstancedBinding"/> a property on an <see cref="IAvaloniaObject"/>.
         /// </summary>

+ 46 - 0
src/Avalonia.Base/Data/Converters/StringFormatMultiValueConverter.cs

@@ -0,0 +1,46 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+
+namespace Avalonia.Data.Converters
+{
+    /// <summary>
+    /// A multi-value converter which calls <see cref="string.Format(string, object)"/>
+    /// </summary>
+    public class StringFormatMultiValueConverter : IMultiValueConverter
+    {
+        /// <summary>
+        /// Initializes a new instance of the <see cref="StringFormatMultiValueConverter"/> class.
+        /// </summary>
+        /// <param name="format">The format string.</param>
+        /// <param name="inner">
+        /// An optional inner converter to be called before the format takes place.
+        /// </param>
+        public StringFormatMultiValueConverter(string format, IMultiValueConverter inner)
+        {
+            Contract.Requires<ArgumentNullException>(format != null);
+
+            Format = format;
+            Inner = inner;
+        }
+
+        /// <summary>
+        /// Gets an inner value converter which will be called before the string format takes place.
+        /// </summary>
+        public IMultiValueConverter Inner { get; }
+
+        /// <summary>
+        /// Gets the format string.
+        /// </summary>
+        public string Format { get; }
+
+        /// <inheritdoc/>
+        public object Convert(IList<object> values, Type targetType, object parameter, CultureInfo culture)
+        {
+            return Inner == null
+                       ? string.Format(culture, Format, values.ToArray())
+                       : string.Format(culture, Format, Inner.Convert(values, targetType, parameter, culture));
+        }
+    }
+}

+ 31 - 0
src/Avalonia.Base/Data/Core/BindingExpression.cs

@@ -114,6 +114,11 @@ namespace Avalonia.Data.Core
         /// <inheritdoc/>
         public void OnNext(object value)
         {
+            if (value == BindingOperations.DoNothing)
+            {
+                return;
+            }
+
             using (_inner.Subscribe(_ => { }))
             {
                 var type = _inner.ResultType;
@@ -126,6 +131,11 @@ namespace Avalonia.Data.Core
                         ConverterParameter,
                         CultureInfo.CurrentCulture);
 
+                    if (converted == BindingOperations.DoNothing)
+                    {
+                        return;
+                    }
+
                     if (converted == AvaloniaProperty.UnsetValue)
                     {
                         converted = TypeUtilities.Default(type);
@@ -186,6 +196,11 @@ namespace Avalonia.Data.Core
         /// <inheritdoc/>
         private object ConvertValue(object value)
         {
+            if (value == BindingOperations.DoNothing)
+            {
+                return value;
+            }
+
             var notification = value as BindingNotification;
 
             if (notification == null)
@@ -196,6 +211,11 @@ namespace Avalonia.Data.Core
                     ConverterParameter,
                     CultureInfo.CurrentCulture);
 
+                if (converted == BindingOperations.DoNothing)
+                {
+                    return converted;
+                }
+
                 notification = converted as BindingNotification;
 
                 if (notification?.ErrorType == BindingErrorType.None)
@@ -327,7 +347,18 @@ namespace Avalonia.Data.Core
 
             public void OnNext(object value)
             {
+                if (value == BindingOperations.DoNothing)
+                {
+                    return;
+                }
+
                 var converted = _owner.ConvertValue(value);
+
+                if (converted == BindingOperations.DoNothing)
+                {
+                    return;
+                }
+
                 _owner._value = new WeakReference<object>(converted);
                 _owner.PublishNext(converted);
             }

+ 17 - 7
src/Avalonia.Controls/AppBuilderBase.cs

@@ -4,6 +4,7 @@
 using System;
 using System.Reflection;
 using System.Linq;
+using Avalonia.Controls.ApplicationLifetimes;
 using Avalonia.Platform;
 
 namespace Avalonia.Controls
@@ -106,19 +107,28 @@ namespace Avalonia.Controls
         public void Start<TMainWindow>(Func<object> dataContextProvider = null)
             where TMainWindow : Window, new()
         {
-            var window = new TMainWindow();
-            if (dataContextProvider != null)
-                window.DataContext = dataContextProvider();
-            Instance.Run(window);
+            AfterSetup(builder =>
+            {
+                var window = new TMainWindow();
+                if (dataContextProvider != null)
+                    window.DataContext = dataContextProvider();
+                ((IClassicDesktopStyleApplicationLifetime)builder.Instance.ApplicationLifetime)
+                    .MainWindow = window;
+            });
+            
+            // Copy-pasted because we can't call extension methods due to generic constraints
+            var lifetime = new ClassicDesktopStyleApplicationLifetime(Instance) {ShutdownMode = ShutdownMode.OnMainWindowClose};
+            Instance.ApplicationLifetime = lifetime;
+            SetupWithoutStarting();
+            lifetime.Start(Array.Empty<string>());
         }
 
         public delegate void AppMainDelegate(Application app, string[] args);
 
-        [Obsolete("Use either lifetimes or AppMain overload. See see https://github.com/AvaloniaUI/Avalonia/wiki/Application-lifetimes for details")]
+        [Obsolete("Use either lifetimes or AppMain overload. See see https://github.com/AvaloniaUI/Avalonia/wiki/Application-lifetimes for details", true)]
         public void Start()
         {
-            Setup();
-            Instance.Run();
+            throw new NotSupportedException();
         }
 
         public void Start(AppMainDelegate main, string[] args)

+ 1 - 1
src/Avalonia.Controls/ApplicationLifetimes/ControlledApplicationLifetimeExitEventArgs.cs

@@ -6,7 +6,7 @@ using System;
 namespace Avalonia.Controls.ApplicationLifetimes
 {
     /// <summary>
-    /// Contains the arguments for the <see cref="IClassicDesktopStyleApplicationLifetime.Exit"/> event.
+    /// Contains the arguments for the <see cref="IControlledApplicationLifetime.Exit"/> event.
     /// </summary>
     public class ControlledApplicationLifetimeExitEventArgs : EventArgs
     {

+ 1 - 1
src/Avalonia.Controls/ApplicationLifetimes/StartupEventArgs.cs

@@ -8,7 +8,7 @@ using System.Linq;
 namespace Avalonia.Controls.ApplicationLifetimes
 {
     /// <summary>
-    /// Contains the arguments for the <see cref="IClassicDesktopStyleApplicationLifetime.Startup"/> event.
+    /// Contains the arguments for the <see cref="IControlledApplicationLifetime.Startup"/> event.
     /// </summary>
     public class ControlledApplicationLifetimeStartupEventArgs : EventArgs
     {

+ 27 - 6
src/Avalonia.Controls/ColumnDefinition.cs

@@ -62,8 +62,15 @@ namespace Avalonia.Controls
         /// </summary>
         public double MaxWidth
         {
-            get { return GetValue(MaxWidthProperty); }
-            set { SetValue(MaxWidthProperty, value); }
+            get
+            {
+                return GetValue(MaxWidthProperty);
+            }
+            set
+            {
+                Parent?.InvalidateMeasure();
+                SetValue(MaxWidthProperty, value);
+            }
         }
 
         /// <summary>
@@ -71,8 +78,15 @@ namespace Avalonia.Controls
         /// </summary>
         public double MinWidth
         {
-            get { return GetValue(MinWidthProperty); }
-            set { SetValue(MinWidthProperty, value); }
+            get
+            {
+                return GetValue(MinWidthProperty);
+            }
+            set
+            {
+                Parent?.InvalidateMeasure();
+                SetValue(MinWidthProperty, value);
+            }
         }
 
         /// <summary>
@@ -80,8 +94,15 @@ namespace Avalonia.Controls
         /// </summary>
         public GridLength Width
         {
-            get { return GetValue(WidthProperty); }
-            set { SetValue(WidthProperty, value); }
+            get
+            {
+                return GetValue(WidthProperty);
+            }
+            set
+            {
+                Parent?.InvalidateMeasure();
+                SetValue(WidthProperty, value);
+            }
         }
 
         internal override GridLength UserSizeValueCache => this.Width;

+ 1 - 1
src/Avalonia.Controls/DesktopApplicationExtensions.cs

@@ -8,7 +8,7 @@ namespace Avalonia.Controls
 {
     public static class DesktopApplicationExtensions
     {
-        [Obsolete("Running application without a cancellation token and a lifetime is no longer supported, see https://github.com/AvaloniaUI/Avalonia/wiki/Application-lifetimes for details")]
+        [Obsolete("Running application without a cancellation token and a lifetime is no longer supported, see https://github.com/AvaloniaUI/Avalonia/wiki/Application-lifetimes for details", true)]
         public static void Run(this Application app) => throw new NotSupportedException();
 
         /// <summary>

+ 104 - 69
src/Avalonia.Controls/DockPanel.cs

@@ -1,7 +1,12 @@
+// This source file is adapted from the Windows Presentation Foundation project. 
+// (https://github.com/dotnet/wpf/) 
+// 
+// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation.
+
+using System;
+
 namespace Avalonia.Controls
 {
-    using System;
-
     /// <summary>
     /// Defines the available docking modes for a control in a <see cref="DockPanel"/>.
     /// </summary>
@@ -70,107 +75,137 @@ namespace Avalonia.Controls
             set { SetValue(LastChildFillProperty, value); }
         }
 
-        /// <inheritdoc/>
+        /// <summary>
+        /// Updates DesiredSize of the DockPanel.  Called by parent Control.  This is the first pass of layout.
+        /// </summary>
+        /// <remarks>
+        /// Children are measured based on their sizing properties and <see cref="Dock" />.  
+        /// Each child is allowed to consume all of the space on the side on which it is docked; Left/Right docked
+        /// children are granted all vertical space for their entire width, and Top/Bottom docked children are
+        /// granted all horizontal space for their entire height.
+        /// </remarks>
+        /// <param name="constraint">Constraint size is an "upper limit" that the return value should not exceed.</param>
+        /// <returns>The Panel's desired size.</returns>
         protected override Size MeasureOverride(Size constraint)
         {
-            double usedWidth = 0.0;
-            double usedHeight = 0.0;
-            double maximumWidth = 0.0;
-            double maximumHeight = 0.0;
+            var children = Children;
+
+            double parentWidth = 0;   // Our current required width due to children thus far.
+            double parentHeight = 0;   // Our current required height due to children thus far.
+            double accumulatedWidth = 0;   // Total width consumed by children.
+            double accumulatedHeight = 0;   // Total height consumed by children.
 
-            // Measure each of the Children
-            foreach (Control element in Children)
+            for (int i = 0, count = children.Count; i < count; ++i)
             {
-                // Get the child's desired size
-                Size remainingSize = new Size(
-                    Math.Max(0.0, constraint.Width - usedWidth),
-                    Math.Max(0.0, constraint.Height - usedHeight));
-                element.Measure(remainingSize);
-                Size desiredSize = element.DesiredSize;
-
-                // Decrease the remaining space for the rest of the children
-                switch (GetDock(element))
+                var child = children[i];
+                Size childConstraint;             // Contains the suggested input constraint for this child.
+                Size childDesiredSize;            // Contains the return size from child measure.
+
+                if (child == null)
+                { continue; }
+
+                // Child constraint is the remaining size; this is total size minus size consumed by previous children.
+                childConstraint = new Size(Math.Max(0.0, constraint.Width - accumulatedWidth),
+                                           Math.Max(0.0, constraint.Height - accumulatedHeight));
+
+                // Measure child.
+                child.Measure(childConstraint);
+                childDesiredSize = child.DesiredSize;
+
+                // Now, we adjust:
+                // 1. Size consumed by children (accumulatedSize).  This will be used when computing subsequent
+                //    children to determine how much space is remaining for them.
+                // 2. Parent size implied by this child (parentSize) when added to the current children (accumulatedSize).
+                //    This is different from the size above in one respect: A Dock.Left child implies a height, but does
+                //    not actually consume any height for subsequent children.
+                // If we accumulate size in a given dimension, the next child (or the end conditions after the child loop)
+                // will deal with computing our minimum size (parentSize) due to that accumulation.
+                // Therefore, we only need to compute our minimum size (parentSize) in dimensions that this child does
+                //   not accumulate: Width for Top/Bottom, Height for Left/Right.
+                switch (DockPanel.GetDock((Control)child))
                 {
                     case Dock.Left:
                     case Dock.Right:
-                        maximumHeight = Math.Max(maximumHeight, usedHeight + desiredSize.Height);
-                        usedWidth += desiredSize.Width;
+                        parentHeight = Math.Max(parentHeight, accumulatedHeight + childDesiredSize.Height);
+                        accumulatedWidth += childDesiredSize.Width;
                         break;
+
                     case Dock.Top:
                     case Dock.Bottom:
-                        maximumWidth = Math.Max(maximumWidth, usedWidth + desiredSize.Width);
-                        usedHeight += desiredSize.Height;
+                        parentWidth = Math.Max(parentWidth, accumulatedWidth + childDesiredSize.Width);
+                        accumulatedHeight += childDesiredSize.Height;
                         break;
                 }
             }
 
-            maximumWidth = Math.Max(maximumWidth, usedWidth);
-            maximumHeight = Math.Max(maximumHeight, usedHeight);
-            return new Size(maximumWidth, maximumHeight);
+            // Make sure the final accumulated size is reflected in parentSize.
+            parentWidth = Math.Max(parentWidth, accumulatedWidth);
+            parentHeight = Math.Max(parentHeight, accumulatedHeight);
+
+            return (new Size(parentWidth, parentHeight));
         }
 
-        /// <inheritdoc/>
+        /// <summary>
+        /// DockPanel computes a position and final size for each of its children based upon their
+        /// <see cref="Dock" /> enum and sizing properties.
+        /// </summary>
+        /// <param name="arrangeSize">Size that DockPanel will assume to position children.</param>
         protected override Size ArrangeOverride(Size arrangeSize)
         {
-            double left = 0.0;
-            double top = 0.0;
-            double right = 0.0;
-            double bottom = 0.0;
-
-            // Arrange each of the Children
             var children = Children;
-            int dockedCount = children.Count - (LastChildFill ? 1 : 0);
-            int index = 0;
+            int totalChildrenCount = children.Count;
+            int nonFillChildrenCount = totalChildrenCount - (LastChildFill ? 1 : 0);
+
+            double accumulatedLeft = 0;
+            double accumulatedTop = 0;
+            double accumulatedRight = 0;
+            double accumulatedBottom = 0;
 
-            foreach (Control element in children)
+            for (int i = 0; i < totalChildrenCount; ++i)
             {
-                // Determine the remaining space left to arrange the element
-                Rect remainingRect = new Rect(
-                    left,
-                    top,
-                    Math.Max(0.0, arrangeSize.Width - left - right),
-                    Math.Max(0.0, arrangeSize.Height - top - bottom));
-
-                // Trim the remaining Rect to the docked size of the element
-                // (unless the element should fill the remaining space because
-                // of LastChildFill)
-                if (index < dockedCount)
+                var child = children[i];
+                if (child == null)
+                { continue; }
+
+                Size childDesiredSize = child.DesiredSize;
+                Rect rcChild = new Rect(
+                    accumulatedLeft,
+                    accumulatedTop,
+                    Math.Max(0.0, arrangeSize.Width - (accumulatedLeft + accumulatedRight)),
+                    Math.Max(0.0, arrangeSize.Height - (accumulatedTop + accumulatedBottom)));
+
+                if (i < nonFillChildrenCount)
                 {
-                    Size desiredSize = element.DesiredSize;
-                    switch (GetDock(element))
+                    switch (DockPanel.GetDock((Control)child))
                     {
                         case Dock.Left:
-                            left += desiredSize.Width;
-                            remainingRect = remainingRect.WithWidth(desiredSize.Width);
-                            break;
-                        case Dock.Top:
-                            top += desiredSize.Height;
-                            remainingRect = remainingRect.WithHeight(desiredSize.Height);
+                            accumulatedLeft += childDesiredSize.Width;
+                            rcChild = rcChild.WithWidth(childDesiredSize.Width);
                             break;
+
                         case Dock.Right:
-                            right += desiredSize.Width;
-                            remainingRect = new Rect(
-                                Math.Max(0.0, arrangeSize.Width - right),
-                                remainingRect.Y,
-                                desiredSize.Width,
-                                remainingRect.Height);
+                            accumulatedRight += childDesiredSize.Width;
+                            rcChild = rcChild.WithX(Math.Max(0.0, arrangeSize.Width - accumulatedRight));
+                            rcChild = rcChild.WithWidth(childDesiredSize.Width);
                             break;
+
+                        case Dock.Top:
+                            accumulatedTop += childDesiredSize.Height;
+                            rcChild = rcChild.WithHeight(childDesiredSize.Height);
+                            break;
+
                         case Dock.Bottom:
-                            bottom += desiredSize.Height;
-                            remainingRect = new Rect(
-                                remainingRect.X,
-                                Math.Max(0.0, arrangeSize.Height - bottom),
-                                remainingRect.Width,
-                                desiredSize.Height);
+                            accumulatedBottom += childDesiredSize.Height;
+                            rcChild = rcChild.WithY(Math.Max(0.0, arrangeSize.Height - accumulatedBottom));
+                            rcChild = rcChild.WithHeight(childDesiredSize.Height);
                             break;
                     }
                 }
 
-                element.Arrange(remainingRect);
-                index++;
+                child.Arrange(rcChild);
             }
 
-            return arrangeSize;
+            return (arrangeSize);
         }
     }
 }

+ 12 - 2
src/Avalonia.Controls/ItemsControl.cs

@@ -36,6 +36,12 @@ namespace Avalonia.Controls
         public static readonly DirectProperty<ItemsControl, IEnumerable> ItemsProperty =
             AvaloniaProperty.RegisterDirect<ItemsControl, IEnumerable>(nameof(Items), o => o.Items, (o, v) => o.Items = v);
 
+        /// <summary>
+        /// Defines the <see cref="ItemCount"/> property.
+        /// </summary>
+        public static readonly DirectProperty<ItemsControl, int> ItemCountProperty =
+            AvaloniaProperty.RegisterDirect<ItemsControl, int>(nameof(ItemCount), o => o.ItemCount);
+
         /// <summary>
         /// Defines the <see cref="ItemsPanel"/> property.
         /// </summary>
@@ -55,6 +61,7 @@ namespace Avalonia.Controls
             AvaloniaProperty.Register<ItemsControl, IMemberSelector>(nameof(MemberSelector));
 
         private IEnumerable _items = new AvaloniaList<object>();
+        private int _itemCount;
         private IItemContainerGenerator _itemContainerGenerator;
         private IDisposable _itemsCollectionChangedSubscription;
 
@@ -110,10 +117,13 @@ namespace Avalonia.Controls
             set { SetAndRaise(ItemsProperty, ref _items, value); }
         }
 
+        /// <summary>
+        /// Gets the number of items in <see cref="Items"/>.
+        /// </summary>
         public int ItemCount
         {
-            get;
-            private set;
+            get => _itemCount;
+            private set => SetAndRaise(ItemCountProperty, ref _itemCount, value);
         }
 
         /// <summary>

+ 12 - 1
src/Avalonia.Controls/ListBox.cs

@@ -84,6 +84,16 @@ namespace Avalonia.Controls
             set { SetValue(VirtualizationModeProperty, value); }
         }
 
+        /// <summary>
+        /// Selects all items in the <see cref="ListBox"/>.
+        /// </summary>
+        public new void SelectAll() => base.SelectAll();
+
+        /// <summary>
+        /// Deselects all items in the <see cref="ListBox"/>.
+        /// </summary>
+        public new void UnselectAll() => base.UnselectAll();
+
         /// <inheritdoc/>
         protected override IItemContainerGenerator CreateItemContainerGenerator()
         {
@@ -118,7 +128,8 @@ namespace Avalonia.Controls
                     e.Source,
                     true,
                     (e.InputModifiers & InputModifiers.Shift) != 0,
-                    (e.InputModifiers & InputModifiers.Control) != 0);
+                    (e.InputModifiers & InputModifiers.Control) != 0,
+                    e.MouseButton == MouseButton.Right);
             }
         }
 

+ 43 - 22
src/Avalonia.Controls/Primitives/RangeBase.cs

@@ -75,10 +75,18 @@ namespace Avalonia.Controls.Primitives
 
             set
             {
-                value = ValidateMinimum(value);
-                SetAndRaise(MinimumProperty, ref _minimum, value);
-                Maximum = ValidateMaximum(Maximum);
-                Value = ValidateValue(Value);
+                ValidateDouble(value, "Minimum");
+
+                if (IsInitialized)
+                {
+                    SetAndRaise(MinimumProperty, ref _minimum, value);
+                    Maximum = ValidateMaximum(Maximum);
+                    Value = ValidateValue(Value);
+                }
+                else
+                {
+                    SetAndRaise(MinimumProperty, ref _minimum, value);
+                }
             }
         }
 
@@ -94,9 +102,18 @@ namespace Avalonia.Controls.Primitives
 
             set
             {
-                value = ValidateMaximum(value);
-                SetAndRaise(MaximumProperty, ref _maximum, value);
-                Value = ValidateValue(Value);
+                ValidateDouble(value, "Maximum");
+
+                if (IsInitialized)
+                {
+                    value = ValidateMaximum(value);
+                    SetAndRaise(MaximumProperty, ref _maximum, value);
+                    Value = ValidateValue(Value);
+                }
+                else
+                {
+                    SetAndRaise(MaximumProperty, ref _maximum, value);
+                }
             }
         }
 
@@ -112,8 +129,17 @@ namespace Avalonia.Controls.Primitives
 
             set
             {
-                value = ValidateValue(value);
-                SetAndRaise(ValueProperty, ref _value, value);
+                ValidateDouble(value, "Value");
+
+                if (IsInitialized)
+                {
+                    value = ValidateValue(value);
+                    SetAndRaise(ValueProperty, ref _value, value);
+                }
+                else
+                {
+                    SetAndRaise(ValueProperty, ref _value, value);
+                }
             }
         }
 
@@ -129,6 +155,14 @@ namespace Avalonia.Controls.Primitives
             set => SetValue(LargeChangeProperty, value);
         }
 
+        protected override void OnInitialized()
+        {
+            base.OnInitialized();
+
+            Maximum = ValidateMaximum(Maximum);
+            Value = ValidateValue(Value);
+        }
+
         /// <summary>
         /// Throws an exception if the double value is NaN or Inf.
         /// </summary>
@@ -142,17 +176,6 @@ namespace Avalonia.Controls.Primitives
             }
         }
 
-        /// <summary>
-        /// Validates the <see cref="Minimum"/> property.
-        /// </summary>
-        /// <param name="value">The value.</param>
-        /// <returns>The coerced value.</returns>
-        private double ValidateMinimum(double value)
-        {
-            ValidateDouble(value, "Minimum");
-            return value;
-        }
-
         /// <summary>
         /// Validates/coerces the <see cref="Maximum"/> property.
         /// </summary>
@@ -160,7 +183,6 @@ namespace Avalonia.Controls.Primitives
         /// <returns>The coerced value.</returns>
         private double ValidateMaximum(double value)
         {
-            ValidateDouble(value, "Maximum");
             return Math.Max(value, Minimum);
         }
 
@@ -171,7 +193,6 @@ namespace Avalonia.Controls.Primitives
         /// <returns>The coerced value.</returns>
         private double ValidateValue(double value)
         {
-            ValidateDouble(value, "Value");
             return MathUtilities.Clamp(value, Minimum, Maximum);
         }
     }

+ 409 - 235
src/Avalonia.Controls/Primitives/SelectingItemsControl.cs

@@ -12,6 +12,7 @@ using Avalonia.Data;
 using Avalonia.Input;
 using Avalonia.Input.Platform;
 using Avalonia.Interactivity;
+using Avalonia.Logging;
 using Avalonia.Styling;
 using Avalonia.VisualTree;
 
@@ -103,6 +104,7 @@ namespace Avalonia.Controls.Primitives
 
         private static readonly IList Empty = Array.Empty<object>();
 
+        private readonly Selection _selection = new Selection();
         private int _selectedIndex = -1;
         private object _selectedItem;
         private IList _selectedItems;
@@ -152,23 +154,8 @@ namespace Avalonia.Controls.Primitives
             {
                 if (_updateCount == 0)
                 {
-                    SetAndRaise(SelectedIndexProperty, ref _selectedIndex, (int val, ref int backing, Action<Action> notifyWrapper) =>
-                    {
-                        var old = backing;
-                        var effective = (val >= 0 && val < Items?.Cast<object>().Count()) ? val : -1;
-
-                        if (old != effective)
-                        {
-                            backing = effective;
-                            notifyWrapper(() =>
-                                RaisePropertyChanged(
-                                    SelectedIndexProperty,
-                                    old,
-                                    effective,
-                                    BindingPriority.LocalValue));
-                            SelectedItem = ElementAt(Items, effective);
-                        }
-                    }, value);
+                    var effective = (value >= 0 && value < ItemCount) ? value : -1;
+                    UpdateSelectedItem(effective);
                 }
                 else
                 {
@@ -192,41 +179,7 @@ namespace Avalonia.Controls.Primitives
             {
                 if (_updateCount == 0)
                 {
-                    SetAndRaise(SelectedItemProperty, ref _selectedItem, (object val, ref object backing, Action<Action> notifyWrapper) =>
-                    {
-                        var old = backing;
-                        var index = IndexOf(Items, val);
-                        var effective = index != -1 ? val : null;
-
-                        if (!object.Equals(effective, old))
-                        {
-                            backing = effective;
-
-                            notifyWrapper(() =>
-                                RaisePropertyChanged(
-                                    SelectedItemProperty,
-                                    old,
-                                    effective,
-                                    BindingPriority.LocalValue));
-
-                            SelectedIndex = index;
-
-                            if (effective != null)
-                            {
-                                if (SelectedItems.Count != 1 || SelectedItems[0] != effective)
-                                {
-                                    _syncingSelectedItems = true;
-                                    SelectedItems.Clear();
-                                    SelectedItems.Add(effective);
-                                    _syncingSelectedItems = false;
-                                }
-                            }
-                            else if (SelectedItems.Count > 0)
-                            {
-                                SelectedItems.Clear();
-                            }
-                        }
-                    }, value);
+                    UpdateSelectedItem(IndexOf(Items, value));
                 }
                 else
                 {
@@ -354,31 +307,23 @@ namespace Avalonia.Controls.Primitives
                     {
                         SelectedIndex = 0;
                     }
+                    else
+                    {
+                        _selection.ItemsInserted(e.NewStartingIndex, e.NewItems.Count);
+                        UpdateSelectedItem(_selection.First(), false);
+                    }
 
                     break;
 
                 case NotifyCollectionChangedAction.Remove:
-                case NotifyCollectionChangedAction.Replace:
-                    var selectedIndex = SelectedIndex;
-
-                    if (selectedIndex >= e.OldStartingIndex &&
-                        selectedIndex < e.OldStartingIndex + e.OldItems.Count)
-                    {
-                        if (!AlwaysSelected)
-                        {
-                            selectedIndex = SelectedIndex = -1;
-                        }
-                        else
-                        {
-                            LostSelection();
-                        }
-                    }
+                    _selection.ItemsRemoved(e.OldStartingIndex, e.OldItems.Count);
+                    UpdateSelectedItem(_selection.First(), false);
+                    ResetSelectedItems();
+                    break;
 
-                    var items = Items?.Cast<object>();
-                    if (selectedIndex >= items.Count())
-                    {
-                        selectedIndex = SelectedIndex = items.Count() - 1;
-                    }
+                case NotifyCollectionChangedAction.Replace:
+                    UpdateSelectedItem(SelectedIndex, false);
+                    ResetSelectedItems();
                     break;
 
                 case NotifyCollectionChangedAction.Move:
@@ -439,11 +384,7 @@ namespace Avalonia.Controls.Primitives
             {
                 if (i.ContainerControl != null && i.Item != null)
                 {
-                    var ms = MemberSelector;
-                    bool selected = ms == null ? 
-                        SelectedItems.Contains(i.Item) : 
-                        SelectedItems.OfType<object>().Any(v => Equals(ms.Select(v), i.Item));
-
+                    bool selected = _selection.Contains(i.Index);
                     MarkContainerSelected(i.ContainerControl, selected);
                 }
             }
@@ -476,9 +417,12 @@ namespace Avalonia.Controls.Primitives
                 var keymap = AvaloniaLocator.Current.GetService<PlatformHotkeyConfiguration>();
                 bool Match(List<KeyGesture> gestures) => gestures.Any(g => g.Matches(e));
 
-                if (this.SelectionMode == SelectionMode.Multiple && Match(keymap.SelectAll))
+                if (ItemCount > 0 &&
+                    Match(keymap.SelectAll) &&
+                    (((SelectionMode & SelectionMode.Multiple) != 0) ||
+                      (SelectionMode & SelectionMode.Toggle) != 0))
                 {
-                    SynchronizeItems(SelectedItems, Items?.Cast<object>());
+                    SelectAll();
                     e.Handled = true;
                 }
             }
@@ -520,6 +464,41 @@ namespace Avalonia.Controls.Primitives
             return false;
         }
 
+        /// <summary>
+        /// Selects all items in the control.
+        /// </summary>
+        protected void SelectAll()
+        {
+            if ((SelectionMode & (SelectionMode.Multiple | SelectionMode.Toggle)) == 0)
+            {
+                throw new NotSupportedException("Multiple selection is not enabled on this control.");
+            }
+
+            UpdateSelectedItems(() =>
+            {
+                _selection.Clear();
+
+                for (var i = 0; i < ItemCount; ++i)
+                {
+                    _selection.Add(i);
+                }
+
+                UpdateSelectedItem(0, false);
+
+                foreach (var container in ItemContainerGenerator.Containers)
+                {
+                    MarkItemSelected(container.Index, true);
+                }
+
+                ResetSelectedItems();
+            });
+        }
+
+        /// <summary>
+        /// Deselects all items in the control.
+        /// </summary>
+        protected void UnselectAll() => UpdateSelectedItem(-1);
+
         /// <summary>
         /// Updates the selection for an item based on user interaction.
         /// </summary>
@@ -527,51 +506,83 @@ namespace Avalonia.Controls.Primitives
         /// <param name="select">Whether the item should be selected or unselected.</param>
         /// <param name="rangeModifier">Whether the range modifier is enabled (i.e. shift key).</param>
         /// <param name="toggleModifier">Whether the toggle modifier is enabled (i.e. ctrl key).</param>
+        /// <param name="rightButton">Whether the event is a right-click.</param>
         protected void UpdateSelection(
             int index,
             bool select = true,
             bool rangeModifier = false,
-            bool toggleModifier = false)
+            bool toggleModifier = false,
+            bool rightButton = false)
         {
             if (index != -1)
             {
                 if (select)
                 {
                     var mode = SelectionMode;
-                    var toggle = toggleModifier || (mode & SelectionMode.Toggle) != 0;
                     var multi = (mode & SelectionMode.Multiple) != 0;
-                    var range = multi && SelectedIndex != -1 && rangeModifier;
+                    var toggle = (toggleModifier || (mode & SelectionMode.Toggle) != 0);
+                    var range = multi && rangeModifier;
 
-                    if (!toggle && !range)
+                    if (range)
                     {
-                        SelectedIndex = index;
-                    }
-                    else if (multi && range)
-                    {
-                        SynchronizeItems(
-                            SelectedItems,
-                            GetRange(Items, SelectedIndex, index));
+                        UpdateSelectedItems(() =>
+                        {
+                            var start = SelectedIndex != -1 ? SelectedIndex : 0;
+                            var step = start < index ? 1 : -1;
+
+                            _selection.Clear();
+
+                            for (var i = start; i != index; i += step)
+                            {
+                                _selection.Add(i);
+                            }
+
+                            _selection.Add(index);
+
+                            var first = Math.Min(start, index);
+                            var last = Math.Max(start, index);
+
+                            foreach (var container in ItemContainerGenerator.Containers)
+                            {
+                                MarkItemSelected(
+                                    container.Index,
+                                    container.Index >= first && container.Index <= last);
+                            }
+
+                            ResetSelectedItems();
+                        });
                     }
-                    else
+                    else if (multi && toggle)
                     {
-                        var item = ElementAt(Items, index);
-                        var i = SelectedItems.IndexOf(item);
-
-                        if (i != -1 && (!AlwaysSelected || SelectedItems.Count > 1))
-                        {
-                            SelectedItems.Remove(item);
-                        }
-                        else
+                        UpdateSelectedItems(() =>
                         {
-                            if (multi)
+                            if (!_selection.Contains(index))
                             {
-                                SelectedItems.Add(item);
+                                _selection.Add(index);
+                                MarkItemSelected(index, true);
+                                SelectedItems.Add(ElementAt(Items, index));
                             }
                             else
                             {
-                                SelectedIndex = index;
+                                _selection.Remove(index);
+                                MarkItemSelected(index, false);
+
+                                if (index == _selectedIndex)
+                                {
+                                    UpdateSelectedItem(_selection.First(), false);
+                                }
+
+                                SelectedItems.Remove(ElementAt(Items, index));
                             }
-                        }
+                        });
+                    }
+                    else if (toggle)
+                    {
+                        SelectedIndex = (SelectedIndex == index) ? -1 : index;
+                    }
+                    else
+                    {
+                        UpdateSelectedItem(index, !(rightButton && _selection.Contains(index)));
                     }
 
                     if (Presenter?.Panel != null)
@@ -596,17 +607,19 @@ namespace Avalonia.Controls.Primitives
         /// <param name="select">Whether the container should be selected or unselected.</param>
         /// <param name="rangeModifier">Whether the range modifier is enabled (i.e. shift key).</param>
         /// <param name="toggleModifier">Whether the toggle modifier is enabled (i.e. ctrl key).</param>
+        /// <param name="rightButton">Whether the event is a right-click.</param>
         protected void UpdateSelection(
             IControl container,
             bool select = true,
             bool rangeModifier = false,
-            bool toggleModifier = false)
+            bool toggleModifier = false,
+            bool rightButton = false)
         {
             var index = ItemContainerGenerator?.IndexFromContainer(container) ?? -1;
 
             if (index != -1)
             {
-                UpdateSelection(index, select, rangeModifier, toggleModifier);
+                UpdateSelection(index, select, rangeModifier, toggleModifier, rightButton);
             }
         }
 
@@ -618,6 +631,7 @@ namespace Avalonia.Controls.Primitives
         /// <param name="select">Whether the container should be selected or unselected.</param>
         /// <param name="rangeModifier">Whether the range modifier is enabled (i.e. shift key).</param>
         /// <param name="toggleModifier">Whether the toggle modifier is enabled (i.e. ctrl key).</param>
+        /// <param name="rightButton">Whether the event is a right-click.</param>
         /// <returns>
         /// True if the event originated from a container that belongs to the control; otherwise
         /// false.
@@ -626,51 +640,20 @@ namespace Avalonia.Controls.Primitives
             IInteractive eventSource,
             bool select = true,
             bool rangeModifier = false,
-            bool toggleModifier = false)
+            bool toggleModifier = false,
+            bool rightButton = false)
         {
             var container = GetContainerFromEventSource(eventSource);
 
             if (container != null)
             {
-                UpdateSelection(container, select, rangeModifier, toggleModifier);
+                UpdateSelection(container, select, rangeModifier, toggleModifier, rightButton);
                 return true;
             }
 
             return false;
         }
 
-        /// <summary>
-        /// Makes a list of objects equal another.
-        /// </summary>
-        /// <param name="items">The items collection.</param>
-        /// <param name="desired">The desired items.</param>
-        internal static void SynchronizeItems(IList items, IEnumerable<object> desired)
-        {
-            var index = 0;
-
-            foreach (object item in desired)
-            {
-                int itemIndex = items.IndexOf(item);
-
-                if (itemIndex == -1)
-                {
-                    items.Insert(index, item);
-                }
-                else if(itemIndex != index)
-                {
-                    items.RemoveAt(itemIndex);
-                    items.Insert(index, item);
-                }
-
-                ++index;
-            }
-
-            while (index < items.Count)
-            {
-                items.RemoveAt(items.Count - 1);
-            }
-        }
-
         /// <summary>
         /// Gets a range of items from an IEnumerable.
         /// </summary>
@@ -678,17 +661,19 @@ namespace Avalonia.Controls.Primitives
         /// <param name="first">The index of the first item.</param>
         /// <param name="last">The index of the last item.</param>
         /// <returns>The items.</returns>
-        private static IEnumerable<object> GetRange(IEnumerable items, int first, int last)
+        private static List<object> GetRange(IEnumerable items, int first, int last)
         {
             var list = (items as IList) ?? items.Cast<object>().ToList();
-            int step = first > last ? -1 : 1;
+            var step = first > last ? -1 : 1;
+            var result = new List<object>();
 
             for (int i = first; i != last; i += step)
             {
-                yield return list[i];
+                result.Add(list[i]);
             }
 
-            yield return list[last];
+            result.Add(list[last]);
+            return result;
         }
 
         /// <summary>
@@ -724,19 +709,14 @@ namespace Avalonia.Controls.Primitives
         private void LostSelection()
         {
             var items = Items?.Cast<object>();
+            var index = -1;
 
             if (items != null && AlwaysSelected)
             {
-                var index = Math.Min(SelectedIndex, items.Count() - 1);
-
-                if (index > -1)
-                {
-                    SelectedItem = items.ElementAt(index);
-                    return;
-                }
+                index = Math.Min(SelectedIndex, items.Count() - 1);
             }
 
-            SelectedIndex = -1;
+            SelectedIndex = index;
         }
 
         /// <summary>
@@ -793,7 +773,7 @@ namespace Avalonia.Controls.Primitives
         /// </summary>
         /// <param name="item">The item.</param>
         /// <param name="selected">Whether the item should be selected or deselected.</param>
-        private void MarkItemSelected(object item, bool selected)
+        private int MarkItemSelected(object item, bool selected)
         {
             var index = IndexOf(Items, item);
 
@@ -801,6 +781,21 @@ namespace Avalonia.Controls.Primitives
             {
                 MarkItemSelected(index, selected);
             }
+
+            return index;
+        }
+
+        private void ResetSelectedItems()
+        {
+            UpdateSelectedItems(() =>
+            {
+                SelectedItems.Clear();
+
+                foreach (var i in _selection)
+                {
+                    SelectedItems.Add(ElementAt(Items, i));
+                }
+            });
         }
 
         /// <summary>
@@ -810,95 +805,97 @@ namespace Avalonia.Controls.Primitives
         /// <param name="e">The event args.</param>
         private void SelectedItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
         {
-            var generator = ItemContainerGenerator;
+            if (_syncingSelectedItems)
+            {
+                return;
+            }
+
+            void Add(IList newItems, IList addedItems = null)
+            {
+                foreach (var item in newItems)
+                {
+                    var index = MarkItemSelected(item, true);
+
+                    if (index != -1 && _selection.Add(index) && addedItems != null)
+                    {
+                        addedItems.Add(item);
+                    }
+                }
+            }
+
+            void UpdateSelection()
+            {
+                if ((SelectedIndex != -1 && !_selection.Contains(SelectedIndex)) ||
+                    (SelectedIndex == -1 && _selection.HasItems))
+                {
+                    _selectedIndex = _selection.First();
+                    _selectedItem = ElementAt(Items, _selectedIndex);
+                    RaisePropertyChanged(SelectedIndexProperty, -1, _selectedIndex, BindingPriority.LocalValue);
+                    RaisePropertyChanged(SelectedItemProperty, null, _selectedItem, BindingPriority.LocalValue);
+
+                    if (AutoScrollToSelectedItem)
+                    {
+                        ScrollIntoView(_selectedIndex);
+                    }
+                }
+            }
+
             IList added = null;
             IList removed = null;
 
             switch (e.Action)
             {
                 case NotifyCollectionChangedAction.Add:
-                    SelectedItemsAdded(e.NewItems.Cast<object>().ToList());
-
-                    if (AutoScrollToSelectedItem)
                     {
-                        ScrollIntoView(e.NewItems[0]);
+                        Add(e.NewItems);
+                        UpdateSelection();
+                        added = e.NewItems;
                     }
 
-                    added = e.NewItems;
                     break;
 
                 case NotifyCollectionChangedAction.Remove:
                     if (SelectedItems.Count == 0)
                     {
-                        if (!_syncingSelectedItems)
-                        {
-                            SelectedIndex = -1;
-                        }
+                        SelectedIndex = -1;
                     }
 
                     foreach (var item in e.OldItems)
                     {
-                        MarkItemSelected(item, false);
+                        var index = MarkItemSelected(item, false);
+                        _selection.Remove(index);
                     }
 
                     removed = e.OldItems;
                     break;
 
+                case NotifyCollectionChangedAction.Replace:
+                    throw new NotSupportedException("Replacing items in a SelectedItems collection is not supported.");
+
+                case NotifyCollectionChangedAction.Move:
+                    throw new NotSupportedException("Moving items in a SelectedItems collection is not supported.");
+
                 case NotifyCollectionChangedAction.Reset:
-                    if (generator != null)
                     {
                         removed = new List<object>();
+                        added = new List<object>();
 
-                        foreach (var item in generator.Containers)
+                        foreach (var index in _selection.ToList())
                         {
-                            if (item?.ContainerControl != null)
+                            var item = ElementAt(Items, index);
+
+                            if (!SelectedItems.Contains(item))
                             {
-                                if (MarkContainerSelected(item.ContainerControl, false))
-                                {
-                                    removed.Add(item.Item);
-                                }
+                                MarkItemSelected(index, false);
+                                removed.Add(item);
+                                _selection.Remove(index);
                             }
                         }
-                    }
 
-                    if (SelectedItems.Count > 0)
-                    {
-                        _selectedItem = null;
-                        SelectedItemsAdded(SelectedItems);
-                        added = SelectedItems;
-                    }
-                    else if (!_syncingSelectedItems)
-                    {
-                        SelectedIndex = -1;
-                    }
-
-                    break;
-
-                case NotifyCollectionChangedAction.Replace:
-                    foreach (var item in e.OldItems)
-                    {
-                        MarkItemSelected(item, false);
-                    }
-
-                    foreach (var item in e.NewItems)
-                    {
-                        MarkItemSelected(item, true);
+                        Add(SelectedItems, added);
+                        UpdateSelection();
                     }
 
-                    if (SelectedItem != SelectedItems[0] && !_syncingSelectedItems)
-                    {
-                        var oldItem = SelectedItem;
-                        var oldIndex = SelectedIndex;
-                        var item = SelectedItems[0];
-                        var index = IndexOf(Items, item);
-                        _selectedIndex = index;
-                        _selectedItem = item;
-                        RaisePropertyChanged(SelectedIndexProperty, oldIndex, index, BindingPriority.LocalValue);
-                        RaisePropertyChanged(SelectedItemProperty, oldItem, item, BindingPriority.LocalValue);
-                    }
-
-                    added = e.NewItems;
-                    removed = e.OldItems;
                     break;
             }
 
@@ -912,34 +909,6 @@ namespace Avalonia.Controls.Primitives
             }
         }
 
-        /// <summary>
-        /// Called when items are added to the <see cref="SelectedItems"/> collection.
-        /// </summary>
-        /// <param name="items">The added items.</param>
-        private void SelectedItemsAdded(IList items)
-        {
-            if (items.Count > 0)
-            {
-                foreach (var item in items)
-                {
-                    MarkItemSelected(item, true);
-                }
-
-                if (SelectedItem == null && !_syncingSelectedItems)
-                {
-                    var index = IndexOf(Items, items[0]);
-
-                    if (index != -1)
-                    {
-                        _selectedItem = items[0];
-                        _selectedIndex = index;
-                        RaisePropertyChanged(SelectedIndexProperty, -1, index, BindingPriority.LocalValue);
-                        RaisePropertyChanged(SelectedItemProperty, null, items[0], BindingPriority.LocalValue);
-                    }
-                }
-            }
-        }
-
         /// <summary>
         /// Subscribes to the <see cref="SelectedItems"/> CollectionChanged event, if any.
         /// </summary>
@@ -970,6 +939,112 @@ namespace Avalonia.Controls.Primitives
             }
         }
 
+        /// <summary>
+        /// Updates the selection due to a change to <see cref="SelectedIndex"/> or
+        /// <see cref="SelectedItem"/>.
+        /// </summary>
+        /// <param name="index">The new selected index.</param>
+        /// <param name="clear">Whether to clear existing selection.</param>
+        private void UpdateSelectedItem(int index, bool clear = true)
+        {
+            var oldIndex = _selectedIndex;
+            var oldItem = _selectedItem;
+
+            if (index == -1 && AlwaysSelected)
+            {
+                index = Math.Min(SelectedIndex, ItemCount - 1);
+            }
+
+            var item = ElementAt(Items, index);
+            var added = -1;
+            HashSet<int> removed = null;
+
+            _selectedIndex = index;
+            _selectedItem = item;
+
+            if (oldIndex != index || _selection.HasMultiple)
+            {
+                if (clear)
+                {
+                    removed = _selection.Clear();
+                }
+
+                if (index != -1)
+                {
+                    if (_selection.Add(index))
+                    {
+                        added = index;
+                    }
+
+                    if (removed?.Contains(index) == true)
+                    {
+                        removed.Remove(index);
+                        added = -1;
+                    }
+                }
+
+                if (removed != null)
+                {
+                    foreach (var i in removed)
+                    {
+                        MarkItemSelected(i, false);
+                    }
+                }
+
+                MarkItemSelected(index, true);
+
+                RaisePropertyChanged(
+                    SelectedIndexProperty,
+                    oldIndex,
+                    index);
+            }
+
+            if (!Equals(item, oldItem))
+            {
+                RaisePropertyChanged(
+                    SelectedItemProperty,
+                    oldItem,
+                    item);
+            }
+
+            if (removed != null && index != -1)
+            {
+                removed.Remove(index);
+            }
+
+            if (added != -1 || removed?.Count > 0)
+            {
+                ResetSelectedItems();
+
+                var e = new SelectionChangedEventArgs(
+                    SelectionChangedEvent,
+                    added != -1 ? new[] { ElementAt(Items, added) } : Array.Empty<object>(),
+                    removed?.Select(x => ElementAt(Items, x)).ToArray() ?? Array.Empty<object>());
+                RaiseEvent(e);
+            }
+        }
+
+        private void UpdateSelectedItems(Action action)
+        {
+            try
+            {
+                _syncingSelectedItems = true;
+                action();
+            }
+            catch (Exception ex)
+            {
+                Logger.Error(
+                    LogArea.Property,
+                    this,
+                    "Error thrown updating SelectedItems: {Error}",
+                    ex);
+            }
+            finally
+            {
+                _syncingSelectedItems = false;
+            }
+        }
+
         private void UpdateFinished()
         {
             if (_updateSelectedIndex != int.MinValue)
@@ -981,5 +1056,104 @@ namespace Avalonia.Controls.Primitives
                 SelectedItems = _updateSelectedItems;
             }
         }
+
+        private class Selection : IEnumerable<int>
+        {
+            private readonly List<int> _list = new List<int>();
+            private HashSet<int> _set = new HashSet<int>();
+
+            public bool HasItems => _set.Count > 0;
+            public bool HasMultiple => _set.Count > 1;
+
+            public bool Add(int index)
+            {
+                if (index == -1)
+                {
+                    throw new ArgumentException("Invalid index", "index");
+                }
+
+                if (_set.Add(index))
+                {
+                    _list.Add(index);
+                    return true;
+                }
+
+                return false;
+            }
+
+            public bool Remove(int index)
+            {
+                if (_set.Remove(index))
+                {
+                    _list.RemoveAll(x => x == index);
+                    return true;
+                }
+
+                return false;
+            }
+
+            public HashSet<int> Clear()
+            {
+                var result = _set;
+                _list.Clear();
+                _set = new HashSet<int>();
+                return result;
+            }
+
+            public void ItemsInserted(int index, int count)
+            {
+                _set = new HashSet<int>();
+
+                for (var i = 0; i < _list.Count; ++i)
+                {
+                    var ix = _list[i];
+
+                    if (ix >= index)
+                    {
+                        var newIndex = ix + count;
+                        _list[i] = newIndex;
+                        _set.Add(newIndex);
+                    }
+                    else
+                    {
+                        _set.Add(ix);
+                    }
+                }
+            }
+
+            public void ItemsRemoved(int index, int count)
+            {
+                var last = (index + count) - 1;
+
+                _set = new HashSet<int>();
+
+                for (var i = 0; i < _list.Count; ++i)
+                {
+                    var ix = _list[i];
+
+                    if (ix >= index && ix <= last)
+                    {
+                        _list.RemoveAt(i--);
+                    }
+                    else if (ix > last)
+                    {
+                        var newIndex = ix - count;
+                        _list[i] = newIndex;
+                        _set.Add(newIndex);
+                    }
+                    else
+                    {
+                        _set.Add(ix);
+                    }
+                }
+            }
+
+            public bool Contains(int index) => _set.Contains(index);
+
+            public int First() => HasItems ? _list[0] : -1;
+
+            public IEnumerator<int> GetEnumerator() => _set.GetEnumerator();
+            IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+        }
     }
 }

+ 31 - 10
src/Avalonia.Controls/RowDefinition.cs

@@ -29,7 +29,7 @@ namespace Avalonia.Controls
         /// <summary>
         /// Initializes a new instance of the <see cref="RowDefinition"/> class.
         /// </summary>
-        public RowDefinition() 
+        public RowDefinition()
         {
         }
 
@@ -38,7 +38,7 @@ namespace Avalonia.Controls
         /// </summary>
         /// <param name="value">The height of the row.</param>
         /// <param name="type">The height unit of the column.</param>
-        public RowDefinition(double value, GridUnitType type) 
+        public RowDefinition(double value, GridUnitType type)
         {
             Height = new GridLength(value, type);
         }
@@ -47,7 +47,7 @@ namespace Avalonia.Controls
         /// Initializes a new instance of the <see cref="RowDefinition"/> class.
         /// </summary>
         /// <param name="height">The height of the column.</param>
-        public RowDefinition(GridLength height) 
+        public RowDefinition(GridLength height)
         {
             Height = height;
         }
@@ -62,8 +62,15 @@ namespace Avalonia.Controls
         /// </summary>
         public double MaxHeight
         {
-            get { return GetValue(MaxHeightProperty); }
-            set { SetValue(MaxHeightProperty, value); }
+            get
+            {
+                return GetValue(MaxHeightProperty);
+            }
+            set
+            {
+                Parent?.InvalidateMeasure();
+                SetValue(MaxHeightProperty, value);
+            }
         }
 
         /// <summary>
@@ -71,8 +78,15 @@ namespace Avalonia.Controls
         /// </summary>
         public double MinHeight
         {
-            get { return GetValue(MinHeightProperty); }
-            set { SetValue(MinHeightProperty, value); }
+            get
+            {
+                return GetValue(MinHeightProperty);
+            }
+            set
+            {
+                Parent?.InvalidateMeasure();
+                SetValue(MinHeightProperty, value);
+            }
         }
 
         /// <summary>
@@ -80,12 +94,19 @@ namespace Avalonia.Controls
         /// </summary>
         public GridLength Height
         {
-            get { return GetValue(HeightProperty); }
-            set { SetValue(HeightProperty, value); }
+            get
+            {
+                return GetValue(HeightProperty);
+            }
+            set
+            {
+                Parent?.InvalidateMeasure();
+                SetValue(HeightProperty, value);
+            }
         }
 
         internal override GridLength UserSizeValueCache => this.Height;
         internal override double UserMinSizeValueCache => this.MinHeight;
         internal override double UserMaxSizeValueCache => this.MaxHeight;
     }
-}
+}

+ 3 - 1
src/Avalonia.Controls/ShutdownMode.cs

@@ -1,10 +1,12 @@
 // 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 Avalonia.Controls.ApplicationLifetimes;
+
 namespace Avalonia.Controls
 {
     /// <summary>
-    /// Describes the possible values for <see cref="Application.ShutdownMode"/>.
+    /// Describes the possible values for <see cref="IClassicDesktopStyleApplicationLifetime.ShutdownMode"/>.
     /// </summary>
     public enum ShutdownMode
     {

+ 24 - 2
src/Avalonia.Controls/TreeView.cs

@@ -409,7 +409,7 @@ namespace Avalonia.Controls
 
                 if (this.SelectionMode == SelectionMode.Multiple && Match(keymap.SelectAll))
                 {
-                    SelectingItemsControl.SynchronizeItems(SelectedItems, ItemContainerGenerator.Index.Items);
+                    SynchronizeItems(SelectedItems, ItemContainerGenerator.Index.Items);
                     e.Handled = true;
                 }
             }
@@ -521,7 +521,7 @@ namespace Avalonia.Controls
             }
             else if (multi && range)
             {
-                SelectingItemsControl.SynchronizeItems(
+                SynchronizeItems(
                     SelectedItems,
                     GetItemsInRange(selectedContainer as TreeViewItem, container as TreeViewItem));
             }
@@ -778,5 +778,27 @@ namespace Avalonia.Controls
                 container.Classes.Set(":selected", selected);
             }
         }
+
+        /// <summary>
+        /// Makes a list of objects equal another (though doesn't preserve order).
+        /// </summary>
+        /// <param name="items">The items collection.</param>
+        /// <param name="desired">The desired items.</param>
+        private static void SynchronizeItems(IList items, IEnumerable<object> desired)
+        {
+            var list = items.Cast<object>().ToList();
+            var toRemove = list.Except(desired).ToList();
+            var toAdd = desired.Except(list).ToList();
+
+            foreach (var i in toRemove)
+            {
+                items.Remove(i);
+            }
+
+            foreach (var i in toAdd)
+            {
+                items.Add(i);
+            }
+        }
     }
 }

+ 4 - 6
src/Avalonia.Controls/WrapPanel.cs

@@ -1,9 +1,7 @@
-// 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.Collections.Generic;
-using System.Diagnostics;
-using System.Linq;
+// This source file is adapted from the Windows Presentation Foundation project. 
+// (https://github.com/dotnet/wpf/) 
+// 
+// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation.
 
 using Avalonia.Input;
 using Avalonia.Layout;

+ 87 - 0
src/Avalonia.ReactiveUI/AutoSuspendHelper.cs

@@ -0,0 +1,87 @@
+// 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 Avalonia;
+using Avalonia.VisualTree;
+using Avalonia.Controls;
+using System.Threading;
+using System.Reactive.Disposables;
+using System.Reactive.Subjects;
+using System.Reactive.Linq;
+using System.Reactive;
+using ReactiveUI;
+using System;
+using Avalonia.Controls.ApplicationLifetimes;
+using Splat;
+
+namespace Avalonia.ReactiveUI
+{
+    /// <summary>
+    /// A ReactiveUI AutoSuspendHelper which initializes suspension hooks for
+    /// Avalonia applications. Call its constructor in your app's composition root,
+    /// before calling the RxApp.SuspensionHost.SetupDefaultSuspendResume method.
+    /// </summary>
+    public sealed class AutoSuspendHelper : IEnableLogger, IDisposable
+    {
+        private readonly Subject<IDisposable> _shouldPersistState = new Subject<IDisposable>();
+        private readonly Subject<Unit> _isLaunchingNew = new Subject<Unit>();
+        
+        /// <summary>
+        /// Initializes a new instance of the <see cref="AutoSuspendHelper"/> class.
+        /// </summary>
+        /// <param name="lifetime">Pass in the Application.ApplicationLifetime property.</param>
+        public AutoSuspendHelper(IApplicationLifetime lifetime)
+        {
+            RxApp.SuspensionHost.IsResuming = Observable.Never<Unit>();
+            RxApp.SuspensionHost.IsLaunchingNew = _isLaunchingNew;
+
+            if (lifetime is IControlledApplicationLifetime controlled)
+            {
+                this.Log().Debug("Using IControlledApplicationLifetime events to handle app exit.");
+                controlled.Exit += (sender, args) => OnControlledApplicationLifetimeExit();
+                RxApp.SuspensionHost.ShouldPersistState = _shouldPersistState;
+            }
+            else if (lifetime != null)
+            {
+                var type = lifetime.GetType().FullName;
+                var message = $"Don't know how to detect app exit event for {type}.";
+                throw new NotSupportedException(message);
+            }
+            else 
+            {
+                var message = "ApplicationLifetime is null. "
+                            + "Ensure you are initializing AutoSuspendHelper "
+                            + "when Avalonia application initialization is completed.";
+                throw new ArgumentNullException(message);
+            }
+            
+            var errored = new Subject<Unit>();
+            AppDomain.CurrentDomain.UnhandledException += (o, e) => errored.OnNext(Unit.Default);
+            RxApp.SuspensionHost.ShouldInvalidateState = errored;
+        }
+
+        /// <summary>
+        /// Call this method in your App.OnFrameworkInitializationCompleted method.
+        /// </summary>
+        public void OnFrameworkInitializationCompleted() => _isLaunchingNew.OnNext(Unit.Default);
+
+        /// <summary>
+        /// Disposes internally stored observers.
+        /// </summary>
+        public void Dispose()
+        {
+            _shouldPersistState.Dispose();
+            _isLaunchingNew.Dispose();
+        }
+
+        private void OnControlledApplicationLifetimeExit()
+        {
+            this.Log().Debug("Received IControlledApplicationLifetime exit event.");
+            var manual = new ManualResetEvent(false);
+            _shouldPersistState.OnNext(Disposable.Create(() => manual.Set()));
+                    
+            manual.WaitOne();
+            this.Log().Debug("Completed actions on IControlledApplicationLifetime exit event.");
+        }
+    }
+}

+ 9 - 1
src/Avalonia.Styling/StyledElement.cs

@@ -392,6 +392,7 @@ namespace Avalonia
             if (_initCount == 0 && !IsInitialized)
             {
                 IsInitialized = true;
+                OnInitialized();
                 Initialized?.Invoke(this, EventArgs.Empty);
             }
         }
@@ -608,7 +609,14 @@ namespace Avalonia
         protected virtual void OnDataContextEndUpdate()
         {
         }
-        
+
+        /// <summary>
+        /// Called when the control finishes initialization.
+        /// </summary>
+        protected virtual void OnInitialized()
+        {
+        }
+
         private static void DataContextNotifying(IAvaloniaObject o, bool updateStarted)
         {
             if (o is StyledElement element)

+ 3 - 7
src/Avalonia.X11/X11KeyTransform.cs

@@ -221,12 +221,8 @@ namespace Avalonia.X11
             //{ X11Key.?, Key.DeadCharProcessed }
         };
 
-        public static Key ConvertKey(IntPtr key)
-        {
-            var ikey = key.ToInt32();
-            Key result;
-            return KeyDic.TryGetValue((X11Key)ikey, out result) ? result : Key.None;
-        }
-}
+        public static Key ConvertKey(X11Key key) 
+            => KeyDic.TryGetValue(key, out var result) ? result : Key.None;
+    }
     
 }

+ 13 - 2
src/Avalonia.X11/X11Window.cs

@@ -419,10 +419,21 @@ namespace Avalonia.X11
                     return;
                 var buffer = stackalloc byte[40];
 
-                var latinKeysym = XKeycodeToKeysym(_x11.Display, ev.KeyEvent.keycode, 0);
+                var index = ev.KeyEvent.state.HasFlag(XModifierMask.ShiftMask);
+                
+                // We need the latin key, since it's mainly used for hotkeys, we use a different API for text anyway
+                var key = (X11Key)XKeycodeToKeysym(_x11.Display, ev.KeyEvent.keycode, index ? 1 : 0).ToInt32();
+                
+                // Manually switch the Shift index for the keypad,
+                // there should be a proper way to do this
+                if (ev.KeyEvent.state.HasFlag(XModifierMask.Mod2Mask)
+                    && key > X11Key.Num_Lock && key <= X11Key.KP_9)
+                    key = (X11Key)XKeycodeToKeysym(_x11.Display, ev.KeyEvent.keycode, index ? 0 : 1).ToInt32();
+                
+                
                 ScheduleInput(new RawKeyEventArgs(_keyboard, (ulong)ev.KeyEvent.time.ToInt64(),
                     ev.type == XEventName.KeyPress ? RawKeyEventType.KeyDown : RawKeyEventType.KeyUp,
-                    X11KeyTransform.ConvertKey(latinKeysym), TranslateModifiers(ev.KeyEvent.state)), ref ev);
+                    X11KeyTransform.ConvertKey(key), TranslateModifiers(ev.KeyEvent.state)), ref ev);
 
                 if (ev.type == XEventName.KeyPress)
                 {

+ 16 - 15
src/Markup/Avalonia.Markup/Data/MultiBinding.cs

@@ -64,14 +64,19 @@ namespace Avalonia.Data
             object anchor = null,
             bool enableDataValidation = false)
         {
-            if (Converter == null)
+            var targetType = targetProperty?.PropertyType ?? typeof(object);
+            var converter = Converter;
+            // We only respect `StringFormat` if the type of the property we're assigning to will
+            // accept a string. Note that this is slightly different to WPF in that WPF only applies
+            // `StringFormat` for target type `string` (not `object`).
+            if (!string.IsNullOrWhiteSpace(StringFormat) && 
+                (targetType == typeof(string) || targetType == typeof(object)))
             {
-                throw new NotSupportedException("MultiBinding without Converter not currently supported.");
+                converter = new StringFormatMultiValueConverter(StringFormat, converter);
             }
-
-            var targetType = targetProperty?.PropertyType ?? typeof(object);
+            
             var children = Bindings.Select(x => x.Initiate(target, null));
-            var input = children.Select(x => x.Observable).CombineLatest().Select(x => ConvertValue(x, targetType));
+            var input = children.Select(x => x.Observable).CombineLatest().Select(x => ConvertValue(x, targetType, converter));
             var mode = Mode == BindingMode.Default ?
                 targetProperty?.GetMetadata(target.GetType()).DefaultBindingMode : Mode;
 
@@ -87,23 +92,19 @@ namespace Avalonia.Data
             }
         }
 
-        private object ConvertValue(IList<object> values, Type targetType)
+        private object ConvertValue(IList<object> values, Type targetType, IMultiValueConverter converter)
         {
             var culture = CultureInfo.CurrentCulture;
-            var converted = Converter.Convert(values, targetType, ConverterParameter, culture);
+            var converted = converter.Convert(values, targetType, ConverterParameter, culture);
 
-            if (converted == AvaloniaProperty.UnsetValue && FallbackValue != null)
+            if (converted == BindingOperations.DoNothing)
             {
-                converted = FallbackValue;
+                return converted;
             }
 
-            // We only respect `StringFormat` if the type of the property we're assigning to will
-            // accept a string. Note that this is slightly different to WPF in that WPF only applies
-            // `StringFormat` for target type `string` (not `object`).
-            if (!string.IsNullOrWhiteSpace(StringFormat) && 
-                (targetType == typeof(string) || targetType == typeof(object)))
+            if (converted == AvaloniaProperty.UnsetValue)
             {
-                converted = string.Format(culture, StringFormat, converted);
+                converted = FallbackValue;
             }
 
             return converted;

+ 37 - 0
tests/Avalonia.Controls.UnitTests/Primitives/RangeBaseTests.cs

@@ -9,6 +9,7 @@ using Avalonia.Data;
 using Avalonia.Layout;
 using Avalonia.Markup.Data;
 using Avalonia.Styling;
+using Avalonia.UnitTests;
 using Xunit;
 
 namespace Avalonia.Controls.UnitTests.Primitives
@@ -23,6 +24,7 @@ namespace Avalonia.Controls.UnitTests.Primitives
                 Minimum = 100,
                 Maximum = 50,
             };
+            var root = new TestRoot(target);
 
             Assert.Equal(100, target.Minimum);
             Assert.Equal(100, target.Maximum);
@@ -37,6 +39,7 @@ namespace Avalonia.Controls.UnitTests.Primitives
                 Maximum = 50,
                 Value = 100,
             };
+            var root = new TestRoot(target);
 
             Assert.Equal(0, target.Minimum);
             Assert.Equal(50, target.Maximum);
@@ -52,6 +55,7 @@ namespace Avalonia.Controls.UnitTests.Primitives
                 Maximum = 100,
                 Value = 50,
             };
+            var root = new TestRoot(target);
 
             target.Minimum = 200;
 
@@ -69,6 +73,7 @@ namespace Avalonia.Controls.UnitTests.Primitives
                 Maximum = 100,
                 Value = 100,
             };
+            var root = new TestRoot(target);
 
             target.Maximum = 50;
 
@@ -161,6 +166,38 @@ namespace Avalonia.Controls.UnitTests.Primitives
             Assert.Equal(expected, track.Value);
         }
 
+        [Fact]
+        public void Coercion_Should_Not_Be_Done_During_Initialization()
+        {
+            var target = new TestRange();
+
+            target.BeginInit();
+
+            var root = new TestRoot(target);
+            target.Minimum = 1;
+            Assert.Equal(0, target.Value);
+
+            target.Value = 50;
+            target.EndInit();
+
+            Assert.Equal(50, target.Value);
+        }
+
+        [Fact]
+        public void Coercion_Should_Be_Done_After_Initialization()
+        {
+            var target = new TestRange();
+
+            target.BeginInit();
+
+            var root = new TestRoot(target);
+            target.Minimum = 1;
+
+            target.EndInit();
+
+            Assert.Equal(1, target.Value);
+        }
+
         private class TestRange : RangeBase
         {
         }

+ 113 - 2
tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs

@@ -536,6 +536,9 @@ namespace Avalonia.Controls.UnitTests.Primitives
                 SelectedIndex = 1,
             };
 
+            target.ApplyTemplate();
+            target.Presenter.ApplyTemplate();
+
             var called = false;
 
             target.SelectionChanged += (s, e) =>
@@ -545,8 +548,6 @@ namespace Avalonia.Controls.UnitTests.Primitives
                 called = true;
             };
 
-            target.ApplyTemplate();
-            target.Presenter.ApplyTemplate();
             target.SelectedIndex = -1;
 
             Assert.True(called);
@@ -783,6 +784,116 @@ namespace Avalonia.Controls.UnitTests.Primitives
             Assert.Equal(2, vm.Child.SelectedIndex);
         }
 
+        [Fact]
+        public void Should_Select_Correct_Item_When_Duplicate_Items_Are_Present()
+        {
+            var target = new ListBox
+            {
+                Template = Template(),
+                Items = new[] { "Foo", "Bar", "Baz", "Foo", "Bar", "Baz"},
+            };
+
+            target.ApplyTemplate();
+            target.Presenter.ApplyTemplate();
+            _helper.Down((Interactive)target.Presenter.Panel.Children[3]);
+
+            Assert.Equal(3, target.SelectedIndex);
+        }
+
+        [Fact]
+        public void Should_Apply_Selected_Pseudoclass_To_Correct_Item_When_Duplicate_Items_Are_Present()
+        {
+            var target = new ListBox
+            {
+                Template = Template(),
+                Items = new[] { "Foo", "Bar", "Baz", "Foo", "Bar", "Baz" },
+            };
+
+            target.ApplyTemplate();
+            target.Presenter.ApplyTemplate();
+            _helper.Down((Interactive)target.Presenter.Panel.Children[3]);
+
+            Assert.Equal(new[] { ":selected" }, target.Presenter.Panel.Children[3].Classes);
+        }
+
+        [Fact]
+        public void Adding_Item_Before_SelectedItem_Should_Update_SelectedIndex()
+        {
+            var items = new ObservableCollection<string>
+            {
+               "Foo",
+               "Bar",
+               "Baz"
+            };
+
+            var target = new ListBox
+            {
+                Template = Template(),
+                Items = items,
+                SelectedIndex = 1,
+            };
+
+            target.ApplyTemplate();
+            target.Presenter.ApplyTemplate();
+
+            items.Insert(0, "Qux");
+
+            Assert.Equal(2, target.SelectedIndex);
+            Assert.Equal("Bar", target.SelectedItem);
+        }
+
+        [Fact]
+        public void Removing_Item_Before_SelectedItem_Should_Update_SelectedIndex()
+        {
+            var items = new ObservableCollection<string>
+            {
+               "Foo",
+               "Bar",
+               "Baz"
+            };
+
+            var target = new ListBox
+            {
+                Template = Template(),
+                Items = items,
+                SelectedIndex = 1,
+            };
+
+            target.ApplyTemplate();
+            target.Presenter.ApplyTemplate();
+
+            items.RemoveAt(0);
+
+            Assert.Equal(0, target.SelectedIndex);
+            Assert.Equal("Bar", target.SelectedItem);
+        }
+
+        [Fact]
+        public void Replacing_Selected_Item_Should_Update_SelectedItem()
+        {
+            var items = new ObservableCollection<string>
+            {
+               "Foo",
+               "Bar",
+               "Baz"
+            };
+
+            var target = new ListBox
+            {
+                Template = Template(),
+                Items = items,
+                SelectedIndex = 1,
+            };
+
+            target.ApplyTemplate();
+            target.Presenter.ApplyTemplate();
+
+            items[1] = "Qux";
+
+            Assert.Equal(1, target.SelectedIndex);
+            Assert.Equal("Qux", target.SelectedItem);
+        }
+
         private FuncControlTemplate Template()
         {
             return new FuncControlTemplate<SelectingItemsControl>(control =>

+ 493 - 30
tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs

@@ -4,12 +4,15 @@
 using System;
 using System.Collections;
 using System.Collections.Generic;
+using System.Collections.ObjectModel;
 using System.Linq;
 using Avalonia.Collections;
 using Avalonia.Controls.Presenters;
 using Avalonia.Controls.Primitives;
 using Avalonia.Controls.Templates;
 using Avalonia.Data;
+using Avalonia.Input;
+using Avalonia.Interactivity;
 using Avalonia.Markup.Data;
 using Xunit;
 
@@ -17,6 +20,8 @@ namespace Avalonia.Controls.UnitTests.Primitives
 {
     public class SelectingItemsControlTests_Multiple
     {
+        private MouseTestHelper _helper = new MouseTestHelper();
+
         [Fact]
         public void Setting_SelectedIndex_Should_Add_To_SelectedItems()
         {
@@ -258,31 +263,25 @@ namespace Avalonia.Controls.UnitTests.Primitives
         }
 
         [Fact]
-        public void Replacing_First_SelectedItem_Should_Update_SelectedItem_SelectedIndex()
+        public void Setting_SelectedIndex_Should_Unmark_Previously_Selected_Containers()
         {
-            var items = new[]
-            {
-                new ListBoxItem(),
-                new ListBoxItem(),
-                new ListBoxItem(),
-            };
-
             var target = new TestSelector
             {
-                Items = items,
+                Items = new[] { "foo", "bar", "baz" },
                 Template = Template(),
             };
 
             target.ApplyTemplate();
             target.Presenter.ApplyTemplate();
-            target.SelectedIndex = 1;
-            target.SelectedItems[0] = items[2];
 
-            Assert.Equal(2, target.SelectedIndex);
-            Assert.Equal(items[2], target.SelectedItem);
-            Assert.False(items[0].IsSelected);
-            Assert.False(items[1].IsSelected);
-            Assert.True(items[2].IsSelected);
+            target.SelectedItems.Add("foo");
+            target.SelectedItems.Add("bar");
+
+            Assert.Equal(new[] { 0, 1 }, SelectedContainers(target));
+
+            target.SelectedIndex = 2;
+
+            Assert.Equal(new[] { 2 }, SelectedContainers(target));
         }
 
         [Fact]
@@ -361,6 +360,52 @@ namespace Avalonia.Controls.UnitTests.Primitives
             Assert.Equal(new[] { "baz", "qux", "qiz" }, target.SelectedItems.Cast<object>().ToList());
         }
 
+        [Fact]
+        public void Setting_SelectedIndex_After_Range_Should_Unmark_Previously_Selected_Containers()
+        {
+            var target = new TestSelector
+            {
+                Items = new[] { "foo", "bar", "baz", "qux" },
+                Template = Template(),
+                SelectedIndex = 0,
+                SelectionMode = SelectionMode.Multiple,
+            };
+
+            target.ApplyTemplate();
+            target.Presenter.ApplyTemplate();
+
+            target.SelectRange(2);
+
+            Assert.Equal(new[] { 0, 1, 2 }, SelectedContainers(target));
+
+            target.SelectedIndex = 3;
+
+            Assert.Equal(new[] { 3 }, SelectedContainers(target));
+        }
+
+        [Fact]
+        public void Toggling_Selection_After_Range_Should_Work()
+        {
+            var target = new TestSelector
+            {
+                Items = new[] { "foo", "bar", "baz", "foo", "bar", "baz" },
+                Template = Template(),
+                SelectedIndex = 0,
+                SelectionMode = SelectionMode.Multiple,
+            };
+
+            target.ApplyTemplate();
+            target.Presenter.ApplyTemplate();
+
+            target.SelectRange(3);
+
+            Assert.Equal(new[] { 0, 1, 2, 3 }, SelectedContainers(target));
+
+            target.Toggle(4);
+
+            Assert.Equal(new[] { 0, 1, 2, 3, 4 }, SelectedContainers(target));
+        }
+
         [Fact]
         public void Suprious_SelectedIndex_Changes_Should_Not_Be_Triggered()
         {
@@ -382,6 +427,40 @@ namespace Avalonia.Controls.UnitTests.Primitives
             Assert.Equal(new[] { -1, 1, 0 }, selectedIndexes);
         }
 
+        [Fact]
+        public void Can_Set_SelectedIndex_To_Another_Selected_Item()
+        {
+            var target = new TestSelector
+            {
+                Items = new[] { "foo", "bar", "baz" },
+                Template = Template(),
+            };
+
+            target.ApplyTemplate();
+            target.Presenter.ApplyTemplate();
+            target.SelectedItems.Add("foo");
+            target.SelectedItems.Add("bar");
+
+            Assert.Equal(0, target.SelectedIndex);
+            Assert.Equal(new[] { "foo", "bar" }, target.SelectedItems);
+            Assert.Equal(new[] { 0, 1 }, SelectedContainers(target));
+
+            var raised = false;
+            target.SelectionChanged += (s, e) =>
+            {
+                raised = true;
+                Assert.Empty(e.AddedItems);
+                Assert.Equal(new[] { "foo" }, e.RemovedItems);
+            };
+
+            target.SelectedIndex = 1;
+
+            Assert.True(raised);
+            Assert.Equal(1, target.SelectedIndex);
+            Assert.Equal(new[] { "bar" }, target.SelectedItems);
+            Assert.Equal(new[] { 1 }, SelectedContainers(target));
+        }
+
         /// <summary>
         /// Tests a problem discovered with ListBox with selection.
         /// </summary>
@@ -471,6 +550,7 @@ namespace Avalonia.Controls.UnitTests.Primitives
             {
                 DataContext = items,
                 Template = Template(),
+                Items = items,
             };
 
             var called = false;
@@ -540,35 +620,418 @@ namespace Avalonia.Controls.UnitTests.Primitives
 
             Assert.True(called);
         }
+        
+        [Fact]
+        public void Shift_Selecting_From_No_Selection_Selects_From_Start()
+        {
+            var target = new ListBox
+            {
+                Template = Template(),
+                Items = new[] { "Foo", "Bar", "Baz" },
+                SelectionMode = SelectionMode.Multiple,
+            };
+
+            target.ApplyTemplate();
+            target.Presenter.ApplyTemplate();
+            _helper.Click((Interactive)target.Presenter.Panel.Children[2], modifiers: InputModifiers.Shift);
+
+            var panel = target.Presenter.Panel;
+
+            Assert.Equal(new[] { "Foo", "Bar", "Baz" }, target.SelectedItems);
+            Assert.Equal(new[] { 0, 1, 2 }, SelectedContainers(target));
+        }
 
         [Fact]
-        public void Replacing_SelectedItems_Should_Raise_SelectionChanged_With_CorrectItems()
+        public void Ctrl_Selecting_SelectedItem_With_Multiple_Selection_Active_Sets_SelectedItem_To_Next_Selection()
         {
-            var items = new[] { "foo", "bar", "baz" };
+            var target = new ListBox
+            {
+                Template = Template(),
+                Items = new[] { "Foo", "Bar", "Baz", "Qux" },
+                SelectionMode = SelectionMode.Multiple,
+            };
+
+            target.ApplyTemplate();
+            target.Presenter.ApplyTemplate();
+            _helper.Click((Interactive)target.Presenter.Panel.Children[1]);
+            _helper.Click((Interactive)target.Presenter.Panel.Children[2], modifiers: InputModifiers.Control);
+            _helper.Click((Interactive)target.Presenter.Panel.Children[3], modifiers: InputModifiers.Control);
+
+            Assert.Equal(1, target.SelectedIndex);
+            Assert.Equal("Bar", target.SelectedItem);
+            Assert.Equal(new[] { "Bar", "Baz", "Qux" }, target.SelectedItems);
+
+            _helper.Click((Interactive)target.Presenter.Panel.Children[1], modifiers: InputModifiers.Control);
+
+            Assert.Equal(2, target.SelectedIndex);
+            Assert.Equal("Baz", target.SelectedItem);
+            Assert.Equal(new[] { "Baz", "Qux" }, target.SelectedItems);
+        }
+
+        [Fact]
+        public void Ctrl_Selecting_Non_SelectedItem_With_Multiple_Selection_Active_Leaves_SelectedItem_The_Same()
+        {
+            var target = new ListBox
+            {
+                Template = Template(),
+                Items = new[] { "Foo", "Bar", "Baz" },
+                SelectionMode = SelectionMode.Multiple,
+            };
+
+            target.ApplyTemplate();
+            target.Presenter.ApplyTemplate();
+            _helper.Click((Interactive)target.Presenter.Panel.Children[1]);
+            _helper.Click((Interactive)target.Presenter.Panel.Children[2], modifiers: InputModifiers.Control);
+
+            Assert.Equal(1, target.SelectedIndex);
+            Assert.Equal("Bar", target.SelectedItem);
+
+            _helper.Click((Interactive)target.Presenter.Panel.Children[2], modifiers: InputModifiers.Control);
+
+            Assert.Equal(1, target.SelectedIndex);
+            Assert.Equal("Bar", target.SelectedItem);
+        }
+
+        [Fact]
+        public void Should_Ctrl_Select_Correct_Item_When_Duplicate_Items_Are_Present()
+        {
+            var target = new ListBox
+            {
+                Template = Template(),
+                Items = new[] { "Foo", "Bar", "Baz", "Foo", "Bar", "Baz" },
+                SelectionMode = SelectionMode.Multiple,
+            };
+
+            target.ApplyTemplate();
+            target.Presenter.ApplyTemplate();
+            _helper.Click((Interactive)target.Presenter.Panel.Children[3]);
+            _helper.Click((Interactive)target.Presenter.Panel.Children[4], modifiers: InputModifiers.Control);
+
+            var panel = target.Presenter.Panel;
+
+            Assert.Equal(new[] { "Foo", "Bar" }, target.SelectedItems);
+            Assert.Equal(new[] { 3, 4 }, SelectedContainers(target));
+        }
+
+        [Fact]
+        public void Should_Shift_Select_Correct_Item_When_Duplicates_Are_Present()
+        {
+            var target = new ListBox
+            {
+                Template = Template(),
+                Items = new[] { "Foo", "Bar", "Baz", "Foo", "Bar", "Baz" },
+                SelectionMode = SelectionMode.Multiple,
+            };
+
+            target.ApplyTemplate();
+            target.Presenter.ApplyTemplate();
+            _helper.Click((Interactive)target.Presenter.Panel.Children[3]);
+            _helper.Click((Interactive)target.Presenter.Panel.Children[5], modifiers: InputModifiers.Shift);
+
+            var panel = target.Presenter.Panel;
 
+            Assert.Equal(new[] { "Foo", "Bar", "Baz" }, target.SelectedItems);
+            Assert.Equal(new[] { 3, 4, 5 }, SelectedContainers(target));
+        }
+
+        [Fact]
+        public void Can_Shift_Select_All_Items_When_Duplicates_Are_Present()
+        {
+            var target = new ListBox
+            {
+                Template = Template(),
+                Items = new[] { "Foo", "Bar", "Baz", "Foo", "Bar", "Baz" },
+                SelectionMode = SelectionMode.Multiple,
+            };
+
+            target.ApplyTemplate();
+            target.Presenter.ApplyTemplate();
+            _helper.Click((Interactive)target.Presenter.Panel.Children[0]);
+            _helper.Click((Interactive)target.Presenter.Panel.Children[5], modifiers: InputModifiers.Shift);
+
+            var panel = target.Presenter.Panel;
+
+            Assert.Equal(new[] { "Foo", "Bar", "Baz", "Foo", "Bar", "Baz" }, target.SelectedItems);
+            Assert.Equal(new[] { 0, 1, 2, 3, 4, 5 }, SelectedContainers(target));
+        }
+
+        [Fact]
+        public void Duplicate_Items_Are_Added_To_SelectedItems_In_Order()
+        {
+            var target = new ListBox
+            {
+                Template = Template(),
+                Items = new[] { "Foo", "Bar", "Baz", "Foo", "Bar", "Baz" },
+                SelectionMode = SelectionMode.Multiple,
+            };
+
+            target.ApplyTemplate();
+            target.Presenter.ApplyTemplate();
+            _helper.Click((Interactive)target.Presenter.Panel.Children[0]);
+
+            Assert.Equal(new[] { "Foo" }, target.SelectedItems);
+
+            _helper.Click((Interactive)target.Presenter.Panel.Children[4], modifiers: InputModifiers.Control);
+
+            Assert.Equal(new[] { "Foo", "Bar" }, target.SelectedItems);
+
+            _helper.Click((Interactive)target.Presenter.Panel.Children[3], modifiers: InputModifiers.Control);
+
+            Assert.Equal(new[] { "Foo", "Bar", "Foo" }, target.SelectedItems);
+
+            _helper.Click((Interactive)target.Presenter.Panel.Children[1], modifiers: InputModifiers.Control);
+
+            Assert.Equal(new[] { "Foo", "Bar", "Foo", "Bar" }, target.SelectedItems);
+        }
+
+        [Fact]
+        public void SelectAll_Sets_SelectedIndex_And_SelectedItem()
+        {
+            var target = new TestSelector
+            {
+                Template = Template(),
+                Items = new[] { "Foo", "Bar", "Baz" },
+                SelectionMode = SelectionMode.Multiple,
+            };
+
+            target.ApplyTemplate();
+            target.Presenter.ApplyTemplate();
+
+            target.SelectAll();
+
+            Assert.Equal(0, target.SelectedIndex);
+            Assert.Equal("Foo", target.SelectedItem);
+        }
+
+        [Fact]
+        public void UnselectAll_Clears_SelectedIndex_And_SelectedItem()
+        {
+            var target = new TestSelector
+            {
+                Template = Template(),
+                Items = new[] { "Foo", "Bar", "Baz" },
+                SelectionMode = SelectionMode.Multiple,
+                SelectedIndex = 0,
+            };
+
+            target.ApplyTemplate();
+            target.Presenter.ApplyTemplate();
+
+            target.UnselectAll();
+
+            Assert.Equal(-1, target.SelectedIndex);
+            Assert.Equal(null, target.SelectedItem);
+        }
+
+        [Fact]
+        public void SelectAll_Handles_Duplicate_Items()
+        {
             var target = new TestSelector
             {
+                Template = Template(),
+                Items = new[] { "Foo", "Bar", "Baz", "Foo", "Bar", "Baz" },
+                SelectionMode = SelectionMode.Multiple,
+            };
+
+            target.ApplyTemplate();
+            target.Presenter.ApplyTemplate();
+            target.SelectAll();
+
+            Assert.Equal(new[] { "Foo", "Bar", "Baz", "Foo", "Bar", "Baz" }, target.SelectedItems);
+        }
+
+        [Fact]
+        public void Adding_Item_Before_SelectedItems_Should_Update_Selection()
+        {
+            var items = new ObservableCollection<string>
+            {
+               "Foo",
+               "Bar",
+               "Baz"
+            };
+
+            var target = new ListBox
+            {
+                Template = Template(),
                 Items = items,
+                SelectionMode = SelectionMode.Multiple,
+            };
+
+            target.ApplyTemplate();
+            target.Presenter.ApplyTemplate();
+
+            target.SelectAll();
+            items.Insert(0, "Qux");
+
+            Assert.Equal(1, target.SelectedIndex);
+            Assert.Equal("Foo", target.SelectedItem);
+            Assert.Equal(new[] { "Foo", "Bar", "Baz" }, target.SelectedItems);
+            Assert.Equal(new[] { 1, 2, 3 }, SelectedContainers(target));
+        }
+
+        [Fact]
+        public void Removing_Item_Before_SelectedItem_Should_Update_Selection()
+        {
+            var items = new ObservableCollection<string>
+            {
+               "Foo",
+               "Bar",
+               "Baz"
+            };
+
+            var target = new TestSelector
+            {
                 Template = Template(),
-                SelectedItem = "bar",
+                Items = items,
+                SelectionMode = SelectionMode.Multiple,
             };
 
-            var called = false;
+            target.ApplyTemplate();
+            target.Presenter.ApplyTemplate();
 
-            target.SelectionChanged += (s, e) =>
+            target.SelectedIndex = 1;
+            target.SelectRange(2);
+
+            Assert.Equal(new[] { "Bar", "Baz" }, target.SelectedItems);
+
+            items.RemoveAt(0);
+
+            Assert.Equal(0, target.SelectedIndex);
+            Assert.Equal("Bar", target.SelectedItem);
+            Assert.Equal(new[] { "Bar", "Baz" }, target.SelectedItems);
+            Assert.Equal(new[] { 0, 1 }, SelectedContainers(target));
+        }
+
+        [Fact]
+        public void Removing_SelectedItem_With_Multiple_Selection_Active_Should_Update_Selection()
+        {
+            var items = new ObservableCollection<string>
             {
-                Assert.Equal(new[] { "foo",}, e.AddedItems.Cast<object>());
-                Assert.Equal(new[] { "bar" }, e.RemovedItems.Cast<object>());
-                called = true;
+               "Foo",
+               "Bar",
+               "Baz"
+            };
+
+            var target = new ListBox
+            {
+                Template = Template(),
+                Items = items,
+                SelectionMode = SelectionMode.Multiple,
             };
 
             target.ApplyTemplate();
             target.Presenter.ApplyTemplate();
-            target.SelectedItems[0] = "foo";
 
-            Assert.True(called);
+            target.SelectAll();
+            items.RemoveAt(0);
+
+            Assert.Equal(0, target.SelectedIndex);
+            Assert.Equal("Bar", target.SelectedItem);
+            Assert.Equal(new[] { "Bar", "Baz" }, target.SelectedItems);
+            Assert.Equal(new[] { 0, 1 }, SelectedContainers(target));
         }
 
+        [Fact]
+        public void Replacing_Selected_Item_Should_Update_SelectedItems()
+        {
+            var items = new ObservableCollection<string>
+            {
+               "Foo",
+               "Bar",
+               "Baz"
+            };
+
+            var target = new ListBox
+            {
+                Template = Template(),
+                Items = items,
+                SelectionMode = SelectionMode.Multiple,
+            };
+
+            target.ApplyTemplate();
+            target.Presenter.ApplyTemplate();
+
+            target.SelectAll();
+            items[1] = "Qux";
+
+            Assert.Equal(new[] { "Foo", "Qux", "Baz" }, target.SelectedItems);
+        }
+
+        [Fact]
+        public void Left_Click_On_SelectedItem_Should_Clear_Existing_Selection()
+        {
+            var target = new ListBox
+            {
+                Template = Template(),
+                Items = new[] { "Foo", "Bar", "Baz" },
+                ItemTemplate = new FuncDataTemplate<string>(x => new TextBlock { Width = 20, Height = 10 }),
+                SelectionMode = SelectionMode.Multiple,
+            };
+
+            target.ApplyTemplate();
+            target.Presenter.ApplyTemplate();
+            target.SelectAll();
+
+            Assert.Equal(3, target.SelectedItems.Count);
+
+            _helper.Click((Interactive)target.Presenter.Panel.Children[0]);
+
+            Assert.Equal(1, target.SelectedItems.Count);
+            Assert.Equal(new[] { "Foo", }, target.SelectedItems);
+            Assert.Equal(new[] { 0 }, SelectedContainers(target));
+        }
+
+        [Fact]
+        public void Right_Click_On_SelectedItem_Should_Not_Clear_Existing_Selection()
+        {
+            var target = new ListBox
+            {
+                Template = Template(),
+                Items = new[] { "Foo", "Bar", "Baz" },
+                ItemTemplate = new FuncDataTemplate<string>(x => new TextBlock { Width = 20, Height = 10 }),
+                SelectionMode = SelectionMode.Multiple,
+            };
+
+            target.ApplyTemplate();
+            target.Presenter.ApplyTemplate();
+            target.SelectAll();
+
+            Assert.Equal(3, target.SelectedItems.Count);
+
+            _helper.Click((Interactive)target.Presenter.Panel.Children[0], MouseButton.Right);
+
+            Assert.Equal(3, target.SelectedItems.Count);
+        }
+
+        [Fact]
+        public void Right_Click_On_UnselectedItem_Should_Clear_Existing_Selection()
+        {
+            var target = new ListBox
+            {
+                Template = Template(),
+                Items = new[] { "Foo", "Bar", "Baz" },
+                ItemTemplate = new FuncDataTemplate<string>(x => new TextBlock { Width = 20, Height = 10 }),
+                SelectionMode = SelectionMode.Multiple,
+            };
+
+            target.ApplyTemplate();
+            target.Presenter.ApplyTemplate();
+            _helper.Click((Interactive)target.Presenter.Panel.Children[0]);
+            _helper.Click((Interactive)target.Presenter.Panel.Children[1], modifiers: InputModifiers.Shift);
+
+            Assert.Equal(2, target.SelectedItems.Count);
+
+            _helper.Click((Interactive)target.Presenter.Panel.Children[2], MouseButton.Right);
+
+            Assert.Equal(1, target.SelectedItems.Count);
+        }
+
+        private IEnumerable<int> SelectedContainers(SelectingItemsControl target)
+        {
+            return target.Presenter.Panel.Children
+                .Select((x, i) => x.Classes.Contains(":selected") ? i : -1)
+                .Where(x => x != -1);
+        }
 
         private FuncControlTemplate Template()
         {
@@ -598,10 +1061,10 @@ namespace Avalonia.Controls.UnitTests.Primitives
                 set { base.SelectionMode = value; }
             }
 
-            public void SelectRange(int index)
-            {
-                UpdateSelection(index, true, true);
-            }
+            public new void SelectAll() => base.SelectAll();
+            public new void UnselectAll() => base.UnselectAll();
+            public void SelectRange(int index) => UpdateSelection(index, true, true);
+            public void Toggle(int index) => UpdateSelection(index, true, false, true);
         }
 
         private class OldDataContextViewModel

+ 31 - 9
tests/Avalonia.Markup.UnitTests/Data/MultiBindingTests_Converters.cs

@@ -5,11 +5,10 @@ using System;
 using System.Collections.Generic;
 using System.Globalization;
 using System.Linq;
-using System.Text;
 using Avalonia.Controls;
 using Avalonia.Data;
 using Avalonia.Data.Converters;
-using Avalonia.Data.Core;
+using Avalonia.Layout;
 using Xunit;
 
 namespace Avalonia.Markup.UnitTests.Data
@@ -21,7 +20,30 @@ namespace Avalonia.Markup.UnitTests.Data
         {
             var textBlock = new TextBlock
             {
-                DataContext = new MultiBindingTests_Converters.Class1(),
+                DataContext = new Class1(),
+            };
+
+            var target = new MultiBinding
+            {
+                StringFormat = "{0:0.0} + {1:00}",
+                Bindings =
+                {
+                    new Binding(nameof(Class1.Foo)),
+                    new Binding(nameof(Class1.Bar)),
+                }
+            };
+
+            textBlock.Bind(TextBlock.TextProperty, target);
+
+            Assert.Equal("1.0 + 02", textBlock.Text);
+        }
+
+        [Fact]
+        public void StringFormat_Should_Be_Applied_After_Converter()
+        {
+            var textBlock = new TextBlock
+            {
+                DataContext = new Class1(),
             };
 
             var target = new MultiBinding
@@ -30,8 +52,8 @@ namespace Avalonia.Markup.UnitTests.Data
                 Converter = new SumOfDoublesConverter(),
                 Bindings =
                 {
-                    new Binding(nameof(MultiBindingTests_Converters.Class1.Foo)),
-                    new Binding(nameof(MultiBindingTests_Converters.Class1.Bar)),
+                    new Binding(nameof(Class1.Foo)),
+                    new Binding(nameof(Class1.Bar)),
                 }
             };
 
@@ -45,7 +67,7 @@ namespace Avalonia.Markup.UnitTests.Data
         {
             var textBlock = new TextBlock
             {
-                DataContext = new MultiBindingTests_Converters.Class1(),
+                DataContext = new Class1(),
             };
             
             var target = new MultiBinding
@@ -54,12 +76,12 @@ namespace Avalonia.Markup.UnitTests.Data
                 Converter = new SumOfDoublesConverter(),
                 Bindings =
                 {
-                    new Binding(nameof(MultiBindingTests_Converters.Class1.Foo)),
-                    new Binding(nameof(MultiBindingTests_Converters.Class1.Bar)),
+                    new Binding(nameof(Class1.Foo)),
+                    new Binding(nameof(Class1.Bar)),
                 }
             };
 
-            textBlock.Bind(TextBlock.WidthProperty, target);
+            textBlock.Bind(Layoutable.WidthProperty, target);
             
             Assert.Equal(3.0, textBlock.Width);
         }

+ 71 - 0
tests/Avalonia.Markup.Xaml.UnitTests/Converters/ValueConverterTests.cs

@@ -0,0 +1,71 @@
+using System;
+using System.Globalization;
+using Avalonia.Controls;
+using Avalonia.Data;
+using Avalonia.Data.Converters;
+using Avalonia.UnitTests;
+using Xunit;
+
+namespace Avalonia.Markup.Xaml.UnitTests.Converters
+{
+    public class ValueConverterTests
+    {
+        [Fact]
+        public void ValueConverter_Special_Values_Work()
+        {
+            using (UnitTestApplication.Start(TestServices.StyledWindow))
+            {
+                var xaml = @"
+<Window xmlns='https://github.com/avaloniaui'
+        xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'
+        xmlns:c='clr-namespace:Avalonia.Markup.Xaml.UnitTests.Converters;assembly=Avalonia.Markup.Xaml.UnitTests'>
+    <TextBlock Name='textBlock' Text='{Binding Converter={x:Static c:TestConverter.Instance}, FallbackValue=bar}'/>
+</Window>";
+                var loader = new AvaloniaXamlLoader();
+                var window = (Window)loader.Load(xaml);
+                var textBlock = window.FindControl<TextBlock>("textBlock");
+
+                window.ApplyTemplate();
+
+                window.DataContext = 2;
+                Assert.Equal("foo", textBlock.Text);
+
+                window.DataContext = -3;
+                Assert.Equal("foo", textBlock.Text);
+
+                window.DataContext = 0;
+                Assert.Equal("bar", textBlock.Text);
+            }
+        }
+    }
+
+    public class TestConverter : IValueConverter
+    {
+        public static readonly TestConverter Instance = new TestConverter();
+
+        public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+        {
+            if (value is int i)
+            {
+                if (i > 0)
+                {
+                    return "foo";
+                }
+
+                if (i == 0)
+                {
+                    return AvaloniaProperty.UnsetValue;
+                }
+
+                return BindingOperations.DoNothing;
+            }
+
+            return "(default)";
+        }
+
+        public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+        {
+            throw new NotImplementedException();
+        }
+    }
+}

+ 28 - 11
tests/Avalonia.Markup.Xaml.UnitTests/Data/BindingTests.cs

@@ -1,20 +1,10 @@
 // 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 System.Collections.Generic;
-using System.Linq;
-using System.Reactive.Linq;
 using Avalonia.Controls;
 using Avalonia.Data;
-using Avalonia.Markup.Data;
-using Moq;
-using Xunit;
-using System.ComponentModel;
-using System.Runtime.CompilerServices;
 using Avalonia.UnitTests;
-using Avalonia.Data.Converters;
-using Avalonia.Data.Core;
+using Xunit;
 
 namespace Avalonia.Markup.Xaml.UnitTests.Data
 {
@@ -40,5 +30,32 @@ namespace Avalonia.Markup.Xaml.UnitTests.Data
                 Assert.Equal("foo", textBlock.Text);
             }
         }
+
+        [Fact]
+        public void Binding_To_DoNothing_Works()
+        {
+            using (UnitTestApplication.Start(TestServices.StyledWindow))
+            {
+                var xaml = @"
+<Window xmlns='https://github.com/avaloniaui'
+        xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'>
+    <TextBlock Name='textBlock' Text='{Binding}'/>
+</Window>";
+                var loader = new AvaloniaXamlLoader();
+                var window = (Window)loader.Load(xaml);
+                var textBlock = window.FindControl<TextBlock>("textBlock");
+
+                window.ApplyTemplate();
+
+                window.DataContext = "foo";
+                Assert.Equal("foo", textBlock.Text);
+
+                window.DataContext = BindingOperations.DoNothing;
+                Assert.Equal("foo", textBlock.Text);
+
+                window.DataContext = "bar";
+                Assert.Equal("bar", textBlock.Text);
+            }
+        }
     }
 }

+ 19 - 0
tests/Avalonia.Markup.Xaml.UnitTests/Xaml/BasicTests.cs

@@ -907,6 +907,25 @@ do we need it?")]
             }
         }
 
+        [Fact]
+        public void Slider_Properties_Can_Be_Set_In_Any_Order()
+        {
+            using (UnitTestApplication.Start(TestServices.MockWindowingPlatform))
+            {
+                var xaml = @"
+<Window xmlns='https://github.com/avaloniaui'>
+    <Slider Width='400' Value='500' Minimum='0' Maximum='1000'/>
+</Window>";
+
+                var window = AvaloniaXamlLoader.Parse<Window>(xaml);
+                var slider = (Slider)window.Content;
+
+                Assert.Equal(0, slider.Minimum);
+                Assert.Equal(1000, slider.Maximum);
+                Assert.Equal(500, slider.Value);
+            }
+        }
+
         private class SelectedItemsViewModel : INotifyPropertyChanged
         {
             public string[] Items { get; set; }

+ 31 - 0
tests/Avalonia.Markup.Xaml.UnitTests/Xaml/BindingTests.cs

@@ -331,6 +331,35 @@ namespace Avalonia.Markup.Xaml.UnitTests.Xaml
             }
         }
 
+        [Fact(Skip="Issue #2592")]
+        public void MultiBinding_To_TextBlock_Text_With_StringConverter_Works()
+        {
+            using (UnitTestApplication.Start(TestServices.StyledWindow))
+            {
+                var xaml = @"
+<Window xmlns='https://github.com/avaloniaui'
+        xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'
+        xmlns:local='clr-namespace:Avalonia.Markup.Xaml.UnitTests.Xaml;assembly=Avalonia.Markup.Xaml.UnitTests'>
+    <TextBlock Name='textBlock'>
+        <TextBlock.Text>
+            <MultiBinding StringFormat='\{0\} \{1\}!'>
+                <Binding Path='Greeting1'/>
+                <Binding Path='Greeting2'/>
+            </MultiBinding>
+        </TextBlock.Text>
+    </TextBlock> 
+</Window>";
+                var loader = new AvaloniaXamlLoader();
+                var window = (Window)loader.Load(xaml);
+                var textBlock = window.FindControl<TextBlock>("textBlock");
+
+                textBlock.DataContext = new WindowViewModel();
+                window.ApplyTemplate();
+
+                Assert.Equal("Hello World!", textBlock.Text);
+            }
+        }
+
         [Fact]
         public void Binding_OneWayToSource_Works()
         {
@@ -356,6 +385,8 @@ namespace Avalonia.Markup.Xaml.UnitTests.Xaml
         private class WindowViewModel
         {
             public bool ShowInTaskbar { get; set; }
+            public string Greeting1 { get; set; } = "Hello";
+            public string Greeting2 { get; set; } = "World";
         }
     }
 }

+ 98 - 0
tests/Avalonia.ReactiveUI.UnitTests/AutoSuspendHelperTest.cs

@@ -0,0 +1,98 @@
+// 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 System.Reactive.Concurrency;
+using System.Reactive.Disposables;
+using System.ComponentModel;
+using System.Threading.Tasks;
+using System.Reactive;
+using System.Reactive.Subjects;
+using System.Reactive.Linq;
+using System.Collections.Generic;
+using System.Runtime.Serialization;
+using System.Threading;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Controls;
+using Avalonia.Rendering;
+using Avalonia.Platform;
+using Avalonia.UnitTests;
+using Avalonia.Markup.Xaml;
+using Avalonia.ReactiveUI;
+using Avalonia;
+using ReactiveUI;
+using DynamicData;
+using Xunit;
+using Splat;
+
+namespace Avalonia.ReactiveUI.UnitTests
+{
+    public class AutoSuspendHelperTest
+    {
+        [DataContract]
+        public class AppState
+        {
+            [DataMember]
+            public string Example { get; set; }
+        }
+
+        public class ExoticApplicationLifetimeWithoutLifecycleEvents : IDisposable, IApplicationLifetime
+        {
+            public void Dispose() { }
+        }
+
+        [Fact]
+        public void AutoSuspendHelper_Should_Immediately_Fire_IsLaunchingNew() 
+        {
+            using (UnitTestApplication.Start(TestServices.MockWindowingPlatform)) 
+            using (var lifetime = new ClassicDesktopStyleApplicationLifetime(Application.Current))
+            {
+                var isLaunchingReceived = false;
+                var application = AvaloniaLocator.Current.GetService<Application>();
+                application.ApplicationLifetime = lifetime;
+
+                // Initialize ReactiveUI Suspension as in real-world scenario.
+                var suspension = new AutoSuspendHelper(application.ApplicationLifetime);
+                RxApp.SuspensionHost.IsLaunchingNew.Subscribe(_ => isLaunchingReceived = true);
+                suspension.OnFrameworkInitializationCompleted();
+
+                Assert.True(isLaunchingReceived);
+            }
+        }
+
+        [Fact]
+        public void ShouldPersistState_Should_Fire_On_App_Exit_When_SuspensionDriver_Is_Initialized() 
+        {
+            using (UnitTestApplication.Start(TestServices.MockWindowingPlatform))
+            using (var lifetime = new ClassicDesktopStyleApplicationLifetime(Application.Current)) 
+            {
+                var shouldPersistReceived = false;
+                var application = AvaloniaLocator.Current.GetService<Application>();
+                application.ApplicationLifetime = lifetime;
+
+                // Initialize ReactiveUI Suspension as in real-world scenario.
+                var suspension = new AutoSuspendHelper(application.ApplicationLifetime);
+                RxApp.SuspensionHost.CreateNewAppState = () => new AppState { Example = "Foo" };
+                RxApp.SuspensionHost.ShouldPersistState.Subscribe(_ => shouldPersistReceived = true);
+                RxApp.SuspensionHost.SetupDefaultSuspendResume(new DummySuspensionDriver());
+                suspension.OnFrameworkInitializationCompleted();
+
+                lifetime.Shutdown();
+                Assert.True(shouldPersistReceived);
+                Assert.Equal("Foo", RxApp.SuspensionHost.GetAppState<AppState>().Example);
+            }
+        }
+
+        [Fact]
+        public void AutoSuspendHelper_Should_Throw_For_Not_Supported_Lifetimes()
+        {
+            using (UnitTestApplication.Start(TestServices.MockWindowingPlatform))
+            using (var lifetime = new ExoticApplicationLifetimeWithoutLifecycleEvents()) 
+            {
+                var application = AvaloniaLocator.Current.GetService<Application>();
+                application.ApplicationLifetime = lifetime;
+                Assert.Throws<NotSupportedException>(() => new AutoSuspendHelper(application.ApplicationLifetime));
+            }
+        }
+    }
+}

+ 2 - 2
tests/Avalonia.ReactiveUI.UnitTests/AvaloniaActivationForViewFetcherTest.cs

@@ -171,7 +171,7 @@ namespace Avalonia.ReactiveUI.UnitTests
         [Fact]
         public void Activation_For_View_Fetcher_Should_Support_Windows() 
         {
-            using (var application = UnitTestApplication.Start(TestServices.MockWindowingPlatform)) 
+            using (UnitTestApplication.Start(TestServices.MockWindowingPlatform)) 
             {
                 var window = new TestWindowWithWhenActivated();
                 Assert.False(window.Active);
@@ -187,7 +187,7 @@ namespace Avalonia.ReactiveUI.UnitTests
         [Fact]
         public void Activatable_Window_View_Model_Is_Activated_And_Deactivated() 
         {
-            using (var application = UnitTestApplication.Start(TestServices.MockWindowingPlatform)) 
+            using (UnitTestApplication.Start(TestServices.MockWindowingPlatform)) 
             {
                 var viewModel = new ActivatableViewModel();
                 var window = new ActivatableWindow { ViewModel = viewModel };