Przeglądaj źródła

Merge branch 'master' into vscode

Nikita Tsukanov 5 lat temu
rodzic
commit
a7814a3816
100 zmienionych plików z 5730 dodań i 1275 usunięć
  1. 2 2
      build/SkiaSharp.props
  2. 2 1
      native/Avalonia.Native/inc/avalonia-native.h
  3. 4 0
      native/Avalonia.Native/src/OSX/window.h
  4. 271 38
      native/Avalonia.Native/src/OSX/window.mm
  5. 2 2
      samples/BindingDemo/MainWindow.xaml
  6. 3 2
      samples/BindingDemo/ViewModels/MainWindowViewModel.cs
  7. 3 2
      samples/ControlCatalog/MainView.xaml
  8. 1 1
      samples/ControlCatalog/MainWindow.xaml
  9. 1 0
      samples/ControlCatalog/Pages/DialogsPage.xaml
  10. 14 0
      samples/ControlCatalog/Pages/DialogsPage.xaml.cs
  11. 1 1
      samples/ControlCatalog/Pages/ListBoxPage.xaml
  12. 1 1
      samples/ControlCatalog/Pages/TreeViewPage.xaml
  13. 9 8
      samples/ControlCatalog/Pages/TreeViewPage.xaml.cs
  14. 26 1
      samples/ControlCatalog/ViewModels/MainWindowViewModel.cs
  15. 1 1
      samples/VirtualizationDemo/MainWindow.xaml
  16. 6 8
      samples/VirtualizationDemo/ViewModels/MainWindowViewModel.cs
  17. 1 1
      src/Avalonia.Controls.DataGrid/DataGridCell.cs
  18. 1 1
      src/Avalonia.Controls.DataGrid/DataGridColumnHeader.cs
  19. 1 1
      src/Avalonia.Controls.DataGrid/DataGridRow.cs
  20. 1 1
      src/Avalonia.Controls.DataGrid/DataGridRowGroupHeader.cs
  21. 1 1
      src/Avalonia.Controls.DataGrid/DataGridRowHeader.cs
  22. 1 1
      src/Avalonia.Controls/Button.cs
  23. 2 1
      src/Avalonia.Controls/Calendar/CalendarButton.cs
  24. 1 1
      src/Avalonia.Controls/Calendar/CalendarDayButton.cs
  25. 1 1
      src/Avalonia.Controls/Calendar/DatePicker.cs
  26. 2 2
      src/Avalonia.Controls/ComboBox.cs
  27. 14 6
      src/Avalonia.Controls/Generators/TreeContainerIndex.cs
  28. 249 0
      src/Avalonia.Controls/ISelectionModel.cs
  29. 2 1
      src/Avalonia.Controls/Image.cs
  30. 180 0
      src/Avalonia.Controls/IndexPath.cs
  31. 232 0
      src/Avalonia.Controls/IndexRange.cs
  32. 17 2
      src/Avalonia.Controls/ListBox.cs
  33. 4 2
      src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs
  34. 1 1
      src/Avalonia.Controls/Presenters/IItemsPresenter.cs
  35. 2 2
      src/Avalonia.Controls/Presenters/ItemVirtualizer.cs
  36. 5 10
      src/Avalonia.Controls/Presenters/ItemVirtualizerNone.cs
  37. 5 10
      src/Avalonia.Controls/Presenters/ItemVirtualizerSimple.cs
  38. 2 2
      src/Avalonia.Controls/Presenters/ItemsPresenter.cs
  39. 1 1
      src/Avalonia.Controls/Presenters/ItemsPresenterBase.cs
  40. 22 24
      src/Avalonia.Controls/Presenters/TextPresenter.cs
  41. 246 593
      src/Avalonia.Controls/Primitives/SelectingItemsControl.cs
  42. 1 1
      src/Avalonia.Controls/RepeatButton.cs
  43. 2 0
      src/Avalonia.Controls/Repeater/ItemsSourceView.cs
  44. 49 0
      src/Avalonia.Controls/SelectedItems.cs
  45. 848 0
      src/Avalonia.Controls/SelectionModel.cs
  46. 170 0
      src/Avalonia.Controls/SelectionModelChangeSet.cs
  47. 83 0
      src/Avalonia.Controls/SelectionModelChildrenRequestedEventArgs.cs
  48. 47 0
      src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs
  49. 966 0
      src/Avalonia.Controls/SelectionNode.cs
  50. 110 0
      src/Avalonia.Controls/SelectionNodeOperation.cs
  51. 1 1
      src/Avalonia.Controls/TabControl.cs
  52. 297 366
      src/Avalonia.Controls/TreeView.cs
  53. 227 0
      src/Avalonia.Controls/Utils/SelectedItemsSync.cs
  54. 189 0
      src/Avalonia.Controls/Utils/SelectionTreeHelper.cs
  55. 2 0
      src/Avalonia.Controls/Window.cs
  56. 5 0
      src/Avalonia.Controls/WindowState.cs
  57. 2 2
      src/Avalonia.Dialogs/ManagedFileChooser.xaml.cs
  58. 9 5
      src/Avalonia.Dialogs/ManagedFileChooserViewModel.cs
  59. 18 2
      src/Avalonia.Dialogs/ManagedFileDialogExtensions.cs
  60. 7 0
      src/Avalonia.Dialogs/ManagedFileDialogOptions.cs
  61. 3 1
      src/Avalonia.Input/DragEventArgs.cs
  62. 2 1
      src/Avalonia.Input/FocusManager.cs
  63. 3 1
      src/Avalonia.Input/Gestures.cs
  64. 3 3
      src/Avalonia.Input/PointerEventArgs.cs
  65. 3 1
      src/Avalonia.Input/Raw/RawDragEvent.cs
  66. 3 3
      src/Avalonia.Native/SystemDialogs.cs
  67. 1 1
      src/Avalonia.Native/WindowImpl.cs
  68. 145 3
      src/Avalonia.Styling/LogicalTree/LogicalExtensions.cs
  69. 6 0
      src/Avalonia.Visuals/Media/FormattedText.cs
  70. 22 4
      src/Avalonia.Visuals/Rendering/DeferredRenderer.cs
  71. 7 2
      src/Avalonia.Visuals/Rendering/RendererBase.cs
  72. 14 4
      src/Avalonia.Visuals/Rendering/SceneGraph/Scene.cs
  73. 16 4
      src/Avalonia.Visuals/Rendering/SceneGraph/SceneLayers.cs
  74. 19 6
      src/Avalonia.Visuals/Rendering/SceneGraph/VisualNode.cs
  75. 13 1
      src/Avalonia.Visuals/VisualTree/VisualExtensions.cs
  76. 2 2
      src/Avalonia.X11/NativeDialogs/GtkNativeFileDialogs.cs
  77. 1 0
      src/Avalonia.X11/X11Atoms.cs
  78. 24 12
      src/Avalonia.X11/X11Window.cs
  79. 11 1
      src/Skia/Avalonia.Skia/FormattedTextImpl.cs
  80. 9 1
      src/Skia/Avalonia.Skia/PlatformRenderInterface.cs
  81. 9 0
      src/Skia/Avalonia.Skia/SkiaOptions.cs
  82. 1 1
      src/Skia/Avalonia.Skia/SkiaPlatform.cs
  83. 55 0
      src/Windows/Avalonia.Win32/Interop/TaskBarList.cs
  84. 23 1
      src/Windows/Avalonia.Win32/Interop/UnmanagedMethods.cs
  85. 4 7
      src/Windows/Avalonia.Win32/ScreenImpl.cs
  86. 9 9
      src/Windows/Avalonia.Win32/SystemDialogImpl.cs
  87. 13 0
      src/Windows/Avalonia.Win32/Win32TypeExtensions.cs
  88. 166 26
      src/Windows/Avalonia.Win32/WindowImpl.cs
  89. 3 8
      tests/Avalonia.Controls.UnitTests/AppBuilderTests.cs
  90. 1 0
      tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.csproj
  91. 1 0
      tests/Avalonia.Controls.UnitTests/ButtonTests.cs
  92. 1 1
      tests/Avalonia.Controls.UnitTests/CalendarTests.cs
  93. 3 3
      tests/Avalonia.Controls.UnitTests/CarouselTests.cs
  94. 1 1
      tests/Avalonia.Controls.UnitTests/DatePickerTests.cs
  95. 95 0
      tests/Avalonia.Controls.UnitTests/IndexPathTests.cs
  96. 307 0
      tests/Avalonia.Controls.UnitTests/IndexRangeTests.cs
  97. 4 4
      tests/Avalonia.Controls.UnitTests/Presenters/ItemsPresenterTests_Virtualization_Simple.cs
  98. 11 29
      tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs
  99. 2 2
      tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_AutoSelect.cs
  100. 340 22
      tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs

+ 2 - 2
build/SkiaSharp.props

@@ -1,6 +1,6 @@
 <Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
   <ItemGroup>
-    <PackageReference Include="SkiaSharp" Version="1.68.2" />
-    <PackageReference Condition="'$(IncludeLinuxSkia)' == 'true'" Include="SkiaSharp.NativeAssets.Linux" Version="1.68.2" />
+    <PackageReference Include="SkiaSharp" Version="1.68.2.1" />
+    <PackageReference Condition="'$(IncludeLinuxSkia)' == 'true'" Include="SkiaSharp.NativeAssets.Linux" Version="1.68.2.1" />
   </ItemGroup>
 </Project>

+ 2 - 1
native/Avalonia.Native/inc/avalonia-native.h

@@ -135,6 +135,7 @@ enum AvnWindowState
     Normal,
     Minimized,
     Maximized,
+    FullScreen,
 };
 
 enum AvnStandardCursorType
@@ -246,7 +247,7 @@ AVNCOM(IAvnWindow, 04) : virtual IAvnWindowBase
 {
     virtual HRESULT ShowDialog (IAvnWindow* parent) = 0;
     virtual HRESULT SetCanResize(bool value) = 0;
-    virtual HRESULT SetHasDecorations(SystemDecorations value) = 0;
+    virtual HRESULT SetDecorations(SystemDecorations value) = 0;
     virtual HRESULT SetTitle (void* utf8Title) = 0;
     virtual HRESULT SetTitleBarColor (AvnColor color) = 0;
     virtual HRESULT SetWindowState(AvnWindowState state) = 0;

+ 4 - 0
native/Avalonia.Native/src/OSX/window.h

@@ -35,6 +35,10 @@ struct INSWindowHolder
 struct IWindowStateChanged
 {
     virtual void WindowStateChanged () = 0;
+    virtual void StartStateTransition () = 0;
+    virtual void EndStateTransition () = 0;
+    virtual SystemDecorations Decorations () = 0;
+    virtual AvnWindowState WindowState () = 0;
 };
 
 #endif /* window_h */

+ 271 - 38
native/Avalonia.Native/src/OSX/window.mm

@@ -391,7 +391,7 @@ protected:
     
     void UpdateStyle()
     {
-        [Window setStyleMask:GetStyle()];
+        [Window setStyleMask: GetStyle()];
     }
     
 public:
@@ -404,10 +404,13 @@ public:
 class WindowImpl : public virtual WindowBaseImpl, public virtual IAvnWindow, public IWindowStateChanged
 {
 private:
-    bool _canResize = true;
-    SystemDecorations _hasDecorations = SystemDecorationsFull;
-    CGRect _lastUndecoratedFrame;
+    bool _canResize;
+    bool _fullScreenActive;
+    SystemDecorations _decorations;
     AvnWindowState _lastWindowState;
+    bool _inSetWindowState;
+    NSRect _preZoomSize;
+    bool _transitioningWindowState;
     
     FORWARD_IUNKNOWN()
     BEGIN_INTERFACE_MAP()
@@ -421,6 +424,11 @@ private:
     ComPtr<IAvnWindowEvents> WindowEvents;
     WindowImpl(IAvnWindowEvents* events, IAvnGlContext* gl) : WindowBaseImpl(events, gl)
     {
+        _fullScreenActive = false;
+        _canResize = true;
+        _decorations = SystemDecorationsFull;
+        _transitioningWindowState = false;
+        _inSetWindowState = false;
         _lastWindowState = Normal;
         WindowEvents = events;
         [Window setCanBecomeKeyAndMain];
@@ -428,6 +436,20 @@ private:
         [Window setTabbingMode:NSWindowTabbingModeDisallowed];
     }
     
+    void HideOrShowTrafficLights ()
+    {
+        for (id subview in Window.contentView.superview.subviews) {
+            if ([subview isKindOfClass:NSClassFromString(@"NSTitlebarContainerView")]) {
+                NSView *titlebarView = [subview subviews][0];
+                for (id button in titlebarView.subviews) {
+                    if ([button isKindOfClass:[NSButton class]]) {
+                        [button setHidden: (_decorations != SystemDecorationsFull)];
+                    }
+                }
+            }
+        }
+    }
+    
     virtual HRESULT Show () override
     {
         @autoreleasepool
@@ -439,6 +461,8 @@ private:
             
             WindowBaseImpl::Show();
             
+            HideOrShowTrafficLights();
+            
             return SetWindowState(_lastWindowState);
         }
     }
@@ -459,41 +483,69 @@ private:
             [cparent->Window addChildWindow:Window ordered:NSWindowAbove];
             WindowBaseImpl::Show();
             
+            HideOrShowTrafficLights();
+            
             return S_OK;
         }
     }
     
+    void StartStateTransition () override
+    {
+        _transitioningWindowState = true;
+    }
+    
+    void EndStateTransition () override
+    {
+        _transitioningWindowState = false;
+    }
+    
+    SystemDecorations Decorations () override
+    {
+        return _decorations;
+    }
+    
+    AvnWindowState WindowState () override
+    {
+        return _lastWindowState;
+    }
+    
     void WindowStateChanged () override
     {
-        AvnWindowState state;
-        GetWindowState(&state);
-        WindowEvents->WindowStateChanged(state);
+        if(!_inSetWindowState && !_transitioningWindowState)
+        {
+            AvnWindowState state;
+            GetWindowState(&state);
+            
+            if(_lastWindowState != state)
+            {
+                _lastWindowState = state;
+                WindowEvents->WindowStateChanged(state);
+            }
+        }
     }
     
     bool UndecoratedIsMaximized ()
     {
-        return CGRectEqualToRect([Window frame], [Window screen].visibleFrame);
+        auto windowSize = [Window frame];
+        auto available = [Window screen].visibleFrame;
+        return CGRectEqualToRect(windowSize, available);
     }
     
     bool IsZoomed ()
     {
-        return _hasDecorations != SystemDecorationsNone ? [Window isZoomed] : UndecoratedIsMaximized();
+        return _decorations == SystemDecorationsFull ? [Window isZoomed] : UndecoratedIsMaximized();
     }
     
     void DoZoom()
     {
-        switch (_hasDecorations)
+        switch (_decorations)
         {
             case SystemDecorationsNone:
-                if (!UndecoratedIsMaximized())
-                {
-                    _lastUndecoratedFrame = [Window frame];
-                }
-                
-                [Window zoom:Window];
+            case SystemDecorationsBorderOnly:
+                [Window setFrame:[Window screen].visibleFrame display:true];
                 break;
 
-            case SystemDecorationsBorderOnly:
+            
             case SystemDecorationsFull:
                 [Window performZoom:Window];
                 break;
@@ -510,25 +562,52 @@ private:
         }
     }
     
-    virtual HRESULT SetHasDecorations(SystemDecorations value) override
+    virtual HRESULT SetDecorations(SystemDecorations value) override
     {
         @autoreleasepool
         {
-            _hasDecorations = value;
+            auto currentWindowState = _lastWindowState;
+            _decorations = value;
+            
+            if(_fullScreenActive)
+            {
+                return S_OK;
+            }
+            
+            auto currentFrame = [Window frame];
+            
             UpdateStyle();
+            
+            HideOrShowTrafficLights();
 
-            switch (_hasDecorations)
+            switch (_decorations)
             {
                 case SystemDecorationsNone:
                     [Window setHasShadow:NO];
                     [Window setTitleVisibility:NSWindowTitleHidden];
                     [Window setTitlebarAppearsTransparent:YES];
+                    
+                    if(currentWindowState == Maximized)
+                    {
+                        if(!UndecoratedIsMaximized())
+                        {
+                            DoZoom();
+                        }
+                    }
                     break;
 
                 case SystemDecorationsBorderOnly:
                     [Window setHasShadow:YES];
                     [Window setTitleVisibility:NSWindowTitleHidden];
                     [Window setTitlebarAppearsTransparent:YES];
+                    
+                    if(currentWindowState == Maximized)
+                    {
+                        if(!UndecoratedIsMaximized())
+                        {
+                            DoZoom();
+                        }
+                    }
                     break;
 
                 case SystemDecorationsFull:
@@ -536,6 +615,13 @@ private:
                     [Window setTitleVisibility:NSWindowTitleVisible];
                     [Window setTitlebarAppearsTransparent:NO];
                     [Window setTitle:_lastTitle];
+                    
+                    if(currentWindowState == Maximized)
+                    {
+                        auto newFrame = [Window contentRectForFrameRect:[Window frame]].size;
+                        
+                        [View setFrameSize:newFrame];
+                    }
                     break;
             }
 
@@ -592,13 +678,19 @@ private:
                 return E_POINTER;
             }
             
+            if(([Window styleMask] & NSWindowStyleMaskFullScreen) == NSWindowStyleMaskFullScreen)
+            {
+                *ret = FullScreen;
+                return S_OK;
+            }
+            
             if([Window isMiniaturized])
             {
                 *ret = Minimized;
                 return S_OK;
             }
             
-            if([Window isZoomed])
+            if(IsZoomed())
             {
                 *ret = Maximized;
                 return S_OK;
@@ -610,16 +702,57 @@ private:
         }
     }
     
+    void EnterFullScreenMode ()
+    {
+        _fullScreenActive = true;
+        
+        [Window setHasShadow:YES];
+        [Window setTitleVisibility:NSWindowTitleVisible];
+        [Window setTitlebarAppearsTransparent:NO];
+        [Window setTitle:_lastTitle];
+        
+        [Window setStyleMask:NSWindowStyleMaskTitled | NSWindowStyleMaskResizable];
+        
+        [Window toggleFullScreen:nullptr];
+    }
+    
+    void ExitFullScreenMode ()
+    {
+        [Window toggleFullScreen:nullptr];
+        
+        _fullScreenActive = false;
+        
+        SetDecorations(_decorations);
+    }
+    
     virtual HRESULT SetWindowState (AvnWindowState state) override
     {
         @autoreleasepool
         {
+            if(_lastWindowState == state)
+            {
+                return S_OK;
+            }
+            
+            _inSetWindowState = true;
+            
+            auto currentState = _lastWindowState;
             _lastWindowState = state;
             
+            if(currentState == Normal)
+            {
+                _preZoomSize = [Window frame];
+            }
+            
             if(_shown)
             {
                 switch (state) {
                     case Maximized:
+                        if(currentState == FullScreen)
+                        {
+                            ExitFullScreenMode();
+                        }
+                        
                         lastPositionSet.X = 0;
                         lastPositionSet.Y = 0;
                         
@@ -635,40 +768,66 @@ private:
                         break;
                         
                     case Minimized:
-                        [Window miniaturize:Window];
+                        if(currentState == FullScreen)
+                        {
+                            ExitFullScreenMode();
+                        }
+                        else
+                        {
+                            [Window miniaturize:Window];
+                        }
+                        break;
+                        
+                    case FullScreen:
+                        if([Window isMiniaturized])
+                        {
+                            [Window deminiaturize:Window];
+                        }
+                        
+                        EnterFullScreenMode();
                         break;
                         
-                    default:
+                    case Normal:
                         if([Window isMiniaturized])
                         {
                             [Window deminiaturize:Window];
                         }
                         
+                        if(currentState == FullScreen)
+                        {
+                            ExitFullScreenMode();
+                        }
+                        
                         if(IsZoomed())
                         {
-                            DoZoom();
+                            if(_decorations == SystemDecorationsFull)
+                            {
+                                DoZoom();
+                            }
+                            else
+                            {
+                                [Window setFrame:_preZoomSize display:true];
+                                auto newFrame = [Window contentRectForFrameRect:[Window frame]].size;
+                                
+                                [View setFrameSize:newFrame];
+                            }
+                            
                         }
                         break;
                 }
             }
             
+            _inSetWindowState = false;
+            
             return S_OK;
         }
     }
 
     virtual void OnResized () override
     {
-        if(_shown)
+        if(_shown && !_inSetWindowState && !_transitioningWindowState)
         {
-            auto windowState = [Window isMiniaturized] ? Minimized
-            : (IsZoomed() ? Maximized : Normal);
-            
-            if (windowState != _lastWindowState)
-            {
-                _lastWindowState = windowState;
-                
-                WindowEvents->WindowStateChanged(windowState);
-            }
+            WindowStateChanged();
         }
     }
     
@@ -677,22 +836,23 @@ protected:
     {
         unsigned long s = NSWindowStyleMaskBorderless;
 
-        switch (_hasDecorations)
+        switch (_decorations)
         {
             case SystemDecorationsNone:
+                s = s | NSWindowStyleMaskFullSizeContentView | NSWindowStyleMaskMiniaturizable;
                 break;
 
             case SystemDecorationsBorderOnly:
-                s = s | NSWindowStyleMaskTitled | NSWindowStyleMaskFullSizeContentView;
+                s = s | NSWindowStyleMaskTitled | NSWindowStyleMaskFullSizeContentView | NSWindowStyleMaskMiniaturizable;
                 break;
 
             case SystemDecorationsFull:
                 s = s | NSWindowStyleMaskTitled | NSWindowStyleMaskClosable | NSWindowStyleMaskMiniaturizable | NSWindowStyleMaskBorderless;
+                
                 if(_canResize)
                 {
                     s = s | NSWindowStyleMaskResizable;
                 }
-
                 break;
         }
 
@@ -1171,6 +1331,20 @@ NSArray* AllLoopModes = [NSArray arrayWithObjects: NSDefaultRunLoopMode, NSEvent
     }
 }
 
+- (void)performClose:(id)sender
+{
+    if([[self delegate] respondsToSelector:@selector(windowShouldClose:)])
+    {
+        if(![[self delegate] windowShouldClose:self]) return;
+    }
+    else if([self respondsToSelector:@selector(windowShouldClose:)])
+    {
+        if(![self windowShouldClose:self]) return;
+    }
+    
+    [self close];
+}
+
 - (void)pollModalSession:(nonnull NSModalSession)session
 {
     auto response = [NSApp runModalSession:session];
@@ -1399,7 +1573,66 @@ NSArray* AllLoopModes = [NSArray arrayWithObjects: NSDefaultRunLoopMode, NSEvent
 
 - (void)windowDidResize:(NSNotification *)notification
 {
-    _parent->OnResized();
+    auto parent = dynamic_cast<IWindowStateChanged*>(_parent.operator->());
+    
+    if(parent != nullptr)
+    {
+        parent->WindowStateChanged();
+    }
+}
+
+- (void)windowWillExitFullScreen:(NSNotification *)notification
+{
+    auto parent = dynamic_cast<IWindowStateChanged*>(_parent.operator->());
+    
+    if(parent != nullptr)
+    {
+        parent->StartStateTransition();
+    }
+}
+
+- (void)windowDidExitFullScreen:(NSNotification *)notification
+{
+    auto parent = dynamic_cast<IWindowStateChanged*>(_parent.operator->());
+    
+    if(parent != nullptr)
+    {
+        parent->EndStateTransition();
+        
+        if(parent->Decorations() != SystemDecorationsFull && parent->WindowState() == Maximized)
+        {
+            NSRect screenRect = [[self screen] visibleFrame];
+            [self setFrame:screenRect display:YES];
+        }
+        
+        if(parent->WindowState() == Minimized)
+        {
+            [self miniaturize:nullptr];
+        }
+        
+        parent->WindowStateChanged();
+    }
+}
+
+- (void)windowWillEnterFullScreen:(NSNotification *)notification
+{
+    auto parent = dynamic_cast<IWindowStateChanged*>(_parent.operator->());
+    
+    if(parent != nullptr)
+    {
+        parent->StartStateTransition();
+    }
+}
+
+- (void)windowDidEnterFullScreen:(NSNotification *)notification
+{
+    auto parent = dynamic_cast<IWindowStateChanged*>(_parent.operator->());
+    
+    if(parent != nullptr)
+    {
+        parent->EndStateTransition();
+        parent->WindowStateChanged();
+    }
 }
 
 - (BOOL)windowShouldZoom:(NSWindow *)window toFrame:(NSRect)newFrame

+ 2 - 2
samples/BindingDemo/MainWindow.xaml

@@ -74,11 +74,11 @@
         </StackPanel.DataTemplates>
         <StackPanel Margin="18" Spacing="4" Width="200">
           <TextBlock FontSize="16" Text="Multiple"/>
-          <ListBox Items="{Binding Items}" SelectionMode="Multiple" SelectedItems="{Binding SelectedItems}"/>
+          <ListBox Items="{Binding Items}" SelectionMode="Multiple" Selection="{Binding Selection}"/>
         </StackPanel>
         <StackPanel Margin="18" Spacing="4" Width="200">
           <TextBlock FontSize="16" Text="Multiple"/>
-          <ListBox Items="{Binding Items}" SelectionMode="Multiple" SelectedItems="{Binding SelectedItems}"/>
+          <ListBox Items="{Binding Items}" SelectionMode="Multiple" Selection="{Binding Selection}"/>
         </StackPanel>
         <ContentControl Content="{Binding SelectedItems[0]}">
           <ContentControl.DataTemplates>

+ 3 - 2
samples/BindingDemo/ViewModels/MainWindowViewModel.cs

@@ -6,6 +6,7 @@ using System.Reactive.Linq;
 using System.Threading.Tasks;
 using System.Threading;
 using ReactiveUI;
+using Avalonia.Controls;
 
 namespace BindingDemo.ViewModels
 {
@@ -27,7 +28,7 @@ namespace BindingDemo.ViewModels
                     Detail = "Item " + x + " details",
                 }));
 
-            SelectedItems = new ObservableCollection<TestItem>();
+            Selection = new SelectionModel();
 
             ShuffleItems = ReactiveCommand.Create(() =>
             {
@@ -56,7 +57,7 @@ namespace BindingDemo.ViewModels
         }
 
         public ObservableCollection<TestItem> Items { get; }
-        public ObservableCollection<TestItem> SelectedItems { get; }
+        public SelectionModel Selection { get; }
         public ReactiveCommand<Unit, Unit> ShuffleItems { get; }
 
         public string BooleanString

+ 3 - 2
samples/ControlCatalog/MainView.xaml

@@ -59,8 +59,8 @@
       <TabItem Header="TreeView"><pages:TreeViewPage/></TabItem>
       <TabItem Header="Viewbox"><pages:ViewboxPage/></TabItem>
       <TabControl.Tag>
-        <StackPanel Width="115" Margin="8" HorizontalAlignment="Right" VerticalAlignment="Bottom">
-          <ComboBox x:Name="Decorations" SelectedIndex="0" Margin="0,0,0,8">
+        <StackPanel Width="115" Spacing="4" HorizontalAlignment="Right" VerticalAlignment="Bottom" Margin="8">
+          <ComboBox x:Name="Decorations" SelectedIndex="0">
             <ComboBoxItem>No Decorations</ComboBoxItem>
             <ComboBoxItem>Border Only</ComboBoxItem>
             <ComboBoxItem>Full Decorations</ComboBoxItem>
@@ -69,6 +69,7 @@
             <ComboBoxItem>Light</ComboBoxItem>
             <ComboBoxItem>Dark</ComboBoxItem>
           </ComboBox>
+          <ComboBox Items="{Binding WindowStates}" SelectedItem="{Binding WindowState}" />
         </StackPanel>
       </TabControl.Tag>
     </TabControl>

+ 1 - 1
samples/ControlCatalog/MainWindow.xaml

@@ -7,7 +7,7 @@
         xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
         xmlns:vm="clr-namespace:ControlCatalog.ViewModels"
         xmlns:v="clr-namespace:ControlCatalog.Views"
-        x:Class="ControlCatalog.MainWindow">
+        x:Class="ControlCatalog.MainWindow" WindowState="{Binding WindowState, Mode=TwoWay}">
 
   <NativeMenu.Menu>
     <NativeMenu>

+ 1 - 0
samples/ControlCatalog/Pages/DialogsPage.xaml

@@ -6,6 +6,7 @@
       <Button Name="OpenFile">Open File</Button>
       <Button Name="SaveFile">Save File</Button>
       <Button Name="SelectFolder">Select Folder</Button>
+      <Button Name="OpenBoth">Select Both</Button>
       <Button Name="DecoratedWindow">Decorated window</Button>
       <Button Name="DecoratedWindowDialog">Decorated window (dialog)</Button>
       <Button Name="Dialog">Dialog</Button>

+ 14 - 0
samples/ControlCatalog/Pages/DialogsPage.xaml.cs

@@ -3,6 +3,7 @@ using System.Collections.Generic;
 using System.Linq;
 using System.Reflection;
 using Avalonia.Controls;
+using Avalonia.Dialogs;
 using Avalonia.Markup.Xaml;
 #pragma warning disable 4014
 
@@ -58,6 +59,19 @@ namespace ControlCatalog.Pages
                     Title = "Select folder",
                 }.ShowAsync(GetWindow());
             };
+            this.FindControl<Button>("OpenBoth").Click += async delegate
+            {
+                var res = await new OpenFileDialog()
+                {
+                    Title = "Select both",
+                    AllowMultiple = true
+                }.ShowManagedAsync(GetWindow(), new ManagedFileDialogOptions
+                {
+                    AllowDirectorySelection = true
+                });
+                if (res != null)
+                    Console.WriteLine("Selected: \n" + string.Join("\n", res));
+            };
             this.FindControl<Button>("DecoratedWindow").Click += delegate
             {
                 new DecoratedWindow().Show();

+ 1 - 1
samples/ControlCatalog/Pages/ListBoxPage.xaml

@@ -10,7 +10,7 @@
               HorizontalAlignment="Center"
               Spacing="16">
       <StackPanel Orientation="Vertical" Spacing="8">
-        <ListBox Items="{Binding Items}" SelectedItem="{Binding SelectedItem}" AutoScrollToSelectedItem="True"  SelectedItems="{Binding SelectedItems}" SelectionMode="{Binding SelectionMode}" Width="250" Height="350"></ListBox>
+        <ListBox Items="{Binding Items}" SelectedItem="{Binding SelectedItem}" AutoScrollToSelectedItem="True"  SelectionMode="{Binding SelectionMode}" Width="250" Height="350"></ListBox>
 
         <Button Command="{Binding AddItemCommand}">Add</Button>
 

+ 1 - 1
samples/ControlCatalog/Pages/TreeViewPage.xaml

@@ -10,7 +10,7 @@
                 HorizontalAlignment="Center"
                 Spacing="16">
       <StackPanel Orientation="Vertical" Spacing="8">
-        <TreeView Items="{Binding Items}" SelectedItems="{Binding SelectedItems}" SelectionMode="{Binding SelectionMode}" Width="250" Height="350">
+        <TreeView Items="{Binding Items}" Selection="{Binding Selection}" SelectionMode="{Binding SelectionMode}" Width="250" Height="350">
           <TreeView.ItemTemplate>
             <TreeDataTemplate ItemsSource="{Binding Children}">
               <TextBlock Text="{Binding Header}"/>

+ 9 - 8
samples/ControlCatalog/Pages/TreeViewPage.xaml.cs

@@ -28,21 +28,22 @@ namespace ControlCatalog.Pages
             {
                 Node root = new Node();
                 Items = root.Children;
-                SelectedItems = new ObservableCollection<Node>();
+                Selection = new SelectionModel();
 
                 AddItemCommand = ReactiveCommand.Create(() =>
                 {
-                    Node parentItem = SelectedItems.Count > 0 ? SelectedItems[0] : root;
+                    Node parentItem = Selection.SelectedItems.Count > 0 ?
+                        (Node)Selection.SelectedItems[0] : root;
                     parentItem.AddNewItem();
                 });
 
                 RemoveItemCommand = ReactiveCommand.Create(() =>
                 {
-                    while (SelectedItems.Count > 0)
+                    while (Selection.SelectedItems.Count > 0)
                     {
-                        Node lastItem = SelectedItems[0];
+                        Node lastItem = (Node)Selection.SelectedItems[0];
                         RecursiveRemove(Items, lastItem);
-                        SelectedItems.Remove(lastItem);
+                        Selection.DeselectAt(Selection.SelectedIndices[0]);
                     }
 
                     bool RecursiveRemove(ObservableCollection<Node> items, Node selectedItem)
@@ -67,7 +68,7 @@ namespace ControlCatalog.Pages
 
             public ObservableCollection<Node> Items { get; }
 
-            public ObservableCollection<Node> SelectedItems { get; }
+            public SelectionModel Selection { get; }
 
             public ReactiveCommand<Unit, Unit> AddItemCommand { get; }
 
@@ -78,7 +79,7 @@ namespace ControlCatalog.Pages
                 get => _selectionMode;
                 set
                 {
-                    SelectedItems.Clear();
+                    Selection.ClearSelection();
                     this.RaiseAndSetIfChanged(ref _selectionMode, value);
                 }
             }
@@ -109,7 +110,7 @@ namespace ControlCatalog.Pages
 
             public override string ToString() => Header;
 
-            private Node CreateNewNode() => new Node {Header = $"Item {_counter++}"};
+            private Node CreateNewNode() => new Node { Header = $"Item {_counter++}" };
         }
     }
 }

+ 26 - 1
samples/ControlCatalog/ViewModels/MainWindowViewModel.cs

@@ -1,4 +1,5 @@
 using System.Reactive;
+using Avalonia.Controls;
 using Avalonia.Controls.ApplicationLifetimes;
 using Avalonia.Controls.Notifications;
 using Avalonia.Dialogs;
@@ -11,6 +12,8 @@ namespace ControlCatalog.ViewModels
         private IManagedNotificationManager _notificationManager;
 
         private bool _isMenuItemChecked = true;
+        private WindowState _windowState;
+        private WindowState[] _windowStates;
 
         public MainWindowViewModel(IManagedNotificationManager notificationManager)
         {
@@ -45,10 +48,32 @@ namespace ControlCatalog.ViewModels
                 (App.Current.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime).Shutdown();
             });
 
-            ToggleMenuItemCheckedCommand = ReactiveCommand.Create(() => 
+            ToggleMenuItemCheckedCommand = ReactiveCommand.Create(() =>
             {
                 IsMenuItemChecked = !IsMenuItemChecked;
             });
+
+            WindowState = WindowState.Normal;
+
+            WindowStates = new WindowState[]
+            {
+                WindowState.Minimized,
+                WindowState.Normal,
+                WindowState.Maximized,
+                WindowState.FullScreen,
+            };
+        }
+
+        public WindowState WindowState
+        {
+            get { return _windowState; }
+            set { this.RaiseAndSetIfChanged(ref _windowState, value); }
+        }
+
+        public WindowState[] WindowStates
+        {
+            get { return _windowStates; }
+            set { this.RaiseAndSetIfChanged(ref _windowStates, value); }
         }
 
         public IManagedNotificationManager NotificationManager

+ 1 - 1
samples/VirtualizationDemo/MainWindow.xaml

@@ -45,7 +45,7 @@
 
         <ListBox Name="listBox" 
                  Items="{Binding Items}" 
-                 SelectedItems="{Binding SelectedItems}"
+                 Selection="{Binding Selection}"
                  SelectionMode="Multiple"
                  VirtualizationMode="{Binding VirtualizationMode}"
                  ScrollViewer.HorizontalScrollBarVisibility="{Binding HorizontalScrollBarVisibility, Mode=TwoWay}"

+ 6 - 8
samples/VirtualizationDemo/ViewModels/MainWindowViewModel.cs

@@ -48,8 +48,7 @@ namespace VirtualizationDemo.ViewModels
             set { this.RaiseAndSetIfChanged(ref _itemCount, value); }
         }
 
-        public AvaloniaList<ItemViewModel> SelectedItems { get; } 
-            = new AvaloniaList<ItemViewModel>();
+        public SelectionModel Selection { get; } = new SelectionModel();
 
         public AvaloniaList<ItemViewModel> Items
         {
@@ -138,9 +137,9 @@ namespace VirtualizationDemo.ViewModels
         {
             var index = Items.Count;
 
-            if (SelectedItems.Count > 0)
+            if (Selection.SelectedIndices.Count > 0)
             {
-                index = Items.IndexOf(SelectedItems[0]);
+                index = Selection.SelectedIndex.GetAt(0);
             }
 
             Items.Insert(index, new ItemViewModel(_newItemIndex++, NewItemString));
@@ -148,9 +147,9 @@ namespace VirtualizationDemo.ViewModels
 
         private void Remove()
         {
-            if (SelectedItems.Count > 0)
+            if (Selection.SelectedItems.Count > 0)
             {
-                Items.RemoveAll(SelectedItems);
+                Items.RemoveAll(Selection.SelectedItems.Cast<ItemViewModel>().ToList());
             }
         }
 
@@ -164,8 +163,7 @@ namespace VirtualizationDemo.ViewModels
 
         private void SelectItem(int index)
         {
-            SelectedItems.Clear();
-            SelectedItems.Add(Items[index]);
+            Selection.SelectedIndex = new IndexPath(index);
         }
     }
 }

+ 1 - 1
src/Avalonia.Controls.DataGrid/DataGridCell.cs

@@ -164,7 +164,7 @@ namespace Avalonia.Controls
             if (OwningGrid != null)
             {
                 OwningGrid.OnCellPointerPressed(new DataGridCellPointerPressedEventArgs(this, OwningRow, OwningColumn, e));
-                if (e.MouseButton == MouseButton.Left)
+                if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
                 {
                     if (!e.Handled)
                     //if (!e.Handled && OwningGrid.IsTabStop)

+ 1 - 1
src/Avalonia.Controls.DataGrid/DataGridColumnHeader.cs

@@ -457,7 +457,7 @@ namespace Avalonia.Controls
 
         private void DataGridColumnHeader_PointerPressed(object sender, PointerPressedEventArgs e)
         {
-            if (OwningColumn == null || e.Handled || !IsEnabled || e.MouseButton != MouseButton.Left)
+            if (OwningColumn == null || e.Handled || !IsEnabled || e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
             {
                 return;
             }

+ 1 - 1
src/Avalonia.Controls.DataGrid/DataGridRow.cs

@@ -786,7 +786,7 @@ namespace Avalonia.Controls
 
         private void DataGridRow_PointerPressed(PointerPressedEventArgs e)
         {
-            if(e.MouseButton != MouseButton.Left)
+            if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
             {
                 return;
             }

+ 1 - 1
src/Avalonia.Controls.DataGrid/DataGridRowGroupHeader.cs

@@ -277,7 +277,7 @@ namespace Avalonia.Controls
         //TODO TabStop
         private void DataGridRowGroupHeader_PointerPressed(PointerPressedEventArgs e)
         {
-            if (OwningGrid != null && e.MouseButton == MouseButton.Left)
+            if (OwningGrid != null && e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
             {
                 if (OwningGrid.IsDoubleClickRecordsClickOnCall(this) && !e.Handled)
                 {

+ 1 - 1
src/Avalonia.Controls.DataGrid/DataGridRowHeader.cs

@@ -164,7 +164,7 @@ namespace Avalonia.Controls.Primitives
         //TODO TabStop
         private void DataGridRowHeader_PointerPressed(object sender, PointerPressedEventArgs e)
         {
-            if(e.MouseButton != MouseButton.Left)
+            if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
             {
                 return;
             }

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

@@ -278,7 +278,7 @@ namespace Avalonia.Controls
         {
             base.OnPointerPressed(e);
 
-            if (e.MouseButton == MouseButton.Left)
+            if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
             {
                 IsPressed = true;
                 e.Handled = true;

+ 2 - 1
src/Avalonia.Controls/Calendar/CalendarButton.cs

@@ -149,7 +149,8 @@ namespace Avalonia.Controls.Primitives
         protected override void OnPointerPressed(PointerPressedEventArgs e)
         {
             base.OnPointerPressed(e);
-            if (e.MouseButton == MouseButton.Left)
+
+            if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
                 CalendarLeftMouseButtonDown?.Invoke(this, e);
         }
 

+ 1 - 1
src/Avalonia.Controls/Calendar/CalendarDayButton.cs

@@ -206,7 +206,7 @@ namespace Avalonia.Controls.Primitives
         {
             base.OnPointerPressed(e);
 
-            if (e.MouseButton == MouseButton.Left)
+            if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
                 CalendarDayButtonMouseDown?.Invoke(this, e);
         }
 

+ 1 - 1
src/Avalonia.Controls/Calendar/DatePicker.cs

@@ -839,7 +839,7 @@ namespace Avalonia.Controls
         }
         private void Calendar_PointerPressed(object sender, PointerPressedEventArgs e)
         {
-            if (e.MouseButton == MouseButton.Left)
+            if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
             {
                 e.Handled = true;
             }

+ 2 - 2
src/Avalonia.Controls/ComboBox.cs

@@ -306,9 +306,9 @@ namespace Avalonia.Controls
             {
                 var container = ItemContainerGenerator.ContainerFromIndex(selectedIndex);
 
-                if (container == null && SelectedItems.Count > 0)
+                if (container == null && SelectedIndex != -1)
                 {
-                    ScrollIntoView(SelectedItems[0]);
+                    ScrollIntoView(Selection.SelectedIndex);
                     container = ItemContainerGenerator.ContainerFromIndex(selectedIndex);
                 }
 

+ 14 - 6
src/Avalonia.Controls/Generators/TreeContainerIndex.cs

@@ -94,9 +94,13 @@ namespace Avalonia.Controls.Generators
         /// <returns>The container, or null of not found.</returns>
         public IControl ContainerFromItem(object item)
         {
-            IControl result;
-            _itemToContainer.TryGetValue(item, out result);
-            return result;
+            if (item != null)
+            {
+                _itemToContainer.TryGetValue(item, out var result);
+                return result;
+            }
+
+            return null;
         }
 
         /// <summary>
@@ -106,9 +110,13 @@ namespace Avalonia.Controls.Generators
         /// <returns>The item, or null of not found.</returns>
         public object ItemFromContainer(IControl container)
         {
-            object result;
-            _containerToItem.TryGetValue(container, out result);
-            return result;
+            if (container != null)
+            {
+                _containerToItem.TryGetValue(container, out var result);
+                return result;
+            }
+
+            return null;
         }
     }
 }

+ 249 - 0
src/Avalonia.Controls/ISelectionModel.cs

@@ -0,0 +1,249 @@
+// This source file is adapted from the WinUI project.
+// (https://github.com/microsoft/microsoft-ui-xaml)
+//
+// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation.
+
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+
+namespace Avalonia.Controls
+{
+    /// <summary>
+    /// Holds the selected items for a control.
+    /// </summary>
+    public interface ISelectionModel : INotifyPropertyChanged
+    {
+        /// <summary>
+        /// Gets or sets the anchor index.
+        /// </summary>
+        IndexPath AnchorIndex { get; set; }
+
+        /// <summary>
+        /// Gets or set the index of the first selected item.
+        /// </summary>
+        IndexPath SelectedIndex { get; set; }
+
+        /// <summary>
+        /// Gets or set the indexes of the selected items.
+        /// </summary>
+        IReadOnlyList<IndexPath> SelectedIndices { get; }
+
+        /// <summary>
+        /// Gets the first selected item.
+        /// </summary>
+        object SelectedItem { get; }
+
+        /// <summary>
+        /// Gets the selected items.
+        /// </summary>
+        IReadOnlyList<object> SelectedItems { get; }
+        
+        /// <summary>
+        /// Gets a value indicating whether the model represents a single or multiple selection.
+        /// </summary>
+        bool SingleSelect { get; set; }
+
+        /// <summary>
+        /// Gets a value indicating whether to always keep an item selected where possible.
+        /// </summary>
+        bool AutoSelect { get; set; }
+
+        /// <summary>
+        /// Gets or sets the collection that contains the items that can be selected.
+        /// </summary>
+        object Source { get; set; }
+
+        /// <summary>
+        /// Raised when the children of a selection are required.
+        /// </summary>
+        event EventHandler<SelectionModelChildrenRequestedEventArgs> ChildrenRequested;
+
+        /// <summary>
+        /// Raised when the selection has changed.
+        /// </summary>
+        event EventHandler<SelectionModelSelectionChangedEventArgs> SelectionChanged;
+
+        /// <summary>
+        /// Clears the selection.
+        /// </summary>
+        void ClearSelection();
+
+        /// <summary>
+        /// Deselects an item.
+        /// </summary>
+        /// <param name="index">The index of the item.</param>
+        void Deselect(int index);
+
+        /// <summary>
+        /// Deselects an item.
+        /// </summary>
+        /// <param name="groupIndex">The index of the item group.</param>
+        /// <param name="itemIndex">The index of the item in the group.</param>
+        void Deselect(int groupIndex, int itemIndex);
+
+        /// <summary>
+        /// Deselects an item.
+        /// </summary>
+        /// <param name="index">The index of the item.</param>
+        void DeselectAt(IndexPath index);
+
+        /// <summary>
+        /// Deselects a range of items.
+        /// </summary>
+        /// <param name="start">The start index of the range.</param>
+        /// <param name="end">The end index of the range.</param>
+        void DeselectRange(IndexPath start, IndexPath end);
+
+        /// <summary>
+        /// Deselects a range of items, starting at <see cref="AnchorIndex"/>.
+        /// </summary>
+        /// <param name="index">The end index of the range.</param>
+        void DeselectRangeFromAnchor(int index);
+
+        /// <summary>
+        /// Deselects a range of items, starting at <see cref="AnchorIndex"/>.
+        /// </summary>
+        /// <param name="endGroupIndex">
+        /// The index of the item group that represents the end of the selection.
+        /// </param>
+        /// <param name="endItemIndex">
+        /// The index of the item in the group that represents the end of the selection.
+        /// </param>
+        void DeselectRangeFromAnchor(int endGroupIndex, int endItemIndex);
+
+        /// <summary>
+        /// Deselects a range of items, starting at <see cref="AnchorIndex"/>.
+        /// </summary>
+        /// <param name="index">The end index of the range.</param>
+        void DeselectRangeFromAnchorTo(IndexPath index);
+
+        /// <summary>
+        /// Disposes the object and clears the selection.
+        /// </summary>
+        void Dispose();
+
+        /// <summary>
+        /// Checks whether an item is selected.
+        /// </summary>
+        /// <param name="index">The index of the item</param>
+        bool IsSelected(int index);
+
+        /// <summary>
+        /// Checks whether an item is selected.
+        /// </summary>
+        /// <param name="groupIndex">The index of the item group.</param>
+        /// <param name="itemIndex">The index of the item in the group.</param>
+        bool IsSelected(int groupIndex, int itemIndex);
+
+        /// <summary>
+        /// Checks whether an item is selected.
+        /// </summary>
+        /// <param name="index">The index of the item</param>
+        public bool IsSelectedAt(IndexPath index);
+
+        /// <summary>
+        /// Checks whether an item or its descendents are selected.
+        /// </summary>
+        /// <param name="index">The index of the item</param>
+        /// <returns>
+        /// True if the item and all its descendents are selected, false if the item and all its
+        /// descendents are deselected, or null if a combination of selected and deselected.
+        /// </returns>
+        bool? IsSelectedWithPartial(int index);
+
+        /// <summary>
+        /// Checks whether an item or its descendents are selected.
+        /// </summary>
+        /// <param name="groupIndex">The index of the item group.</param>
+        /// <param name="itemIndex">The index of the item in the group.</param>
+        /// <returns>
+        /// True if the item and all its descendents are selected, false if the item and all its
+        /// descendents are deselected, or null if a combination of selected and deselected.
+        /// </returns>
+        bool? IsSelectedWithPartial(int groupIndex, int itemIndex);
+
+        /// <summary>
+        /// Checks whether an item or its descendents are selected.
+        /// </summary>
+        /// <param name="index">The index of the item</param>
+        /// <returns>
+        /// True if the item and all its descendents are selected, false if the item and all its
+        /// descendents are deselected, or null if a combination of selected and deselected.
+        /// </returns>
+        bool? IsSelectedWithPartialAt(IndexPath index);
+
+        /// <summary>
+        /// Selects an item.
+        /// </summary>
+        /// <param name="index">The index of the item</param>
+        void Select(int index);
+
+        /// <summary>
+        /// Selects an item.
+        /// </summary>
+        /// <param name="groupIndex">The index of the item group.</param>
+        /// <param name="itemIndex">The index of the item in the group.</param>
+        void Select(int groupIndex, int itemIndex);
+
+        /// <summary>
+        /// Selects an item.
+        /// </summary>
+        /// <param name="index">The index of the item</param>
+        void SelectAt(IndexPath index);
+
+        /// <summary>
+        /// Selects all items.
+        /// </summary>
+        void SelectAll();
+
+        /// <summary>
+        /// Selects a range of items.
+        /// </summary>
+        /// <param name="start">The start index of the range.</param>
+        /// <param name="end">The end index of the range.</param>
+        void SelectRange(IndexPath start, IndexPath end);
+
+        /// <summary>
+        /// Selects a range of items, starting at <see cref="AnchorIndex"/>.
+        /// </summary>
+        /// <param name="index">The end index of the range.</param>
+        void SelectRangeFromAnchor(int index);
+
+        /// <summary>
+        /// Selects a range of items, starting at <see cref="AnchorIndex"/>.
+        /// </summary>
+        /// <param name="endGroupIndex">
+        /// The index of the item group that represents the end of the selection.
+        /// </param>
+        /// <param name="endItemIndex">
+        /// The index of the item in the group that represents the end of the selection.
+        /// </param>
+        void SelectRangeFromAnchor(int endGroupIndex, int endItemIndex);
+
+        /// <summary>
+        /// Selects a range of items, starting at <see cref="AnchorIndex"/>.
+        /// </summary>
+        /// <param name="index">The end index of the range.</param>
+        void SelectRangeFromAnchorTo(IndexPath index);
+
+        /// <summary>
+        /// Sets the <see cref="AnchorIndex"/>.
+        /// </summary>
+        /// <param name="index">The anchor index.</param>
+        void SetAnchorIndex(int index);
+
+        /// <summary>
+        /// Sets the <see cref="AnchorIndex"/>.
+        /// </summary>
+        /// <param name="groupIndex">The index of the item group.</param>
+        /// <param name="index">The index of the item in the group.</param>
+        void SetAnchorIndex(int groupIndex, int index);
+
+        /// <summary>
+        /// Begins a batch update of the selection.
+        /// </summary>
+        /// <returns>An <see cref="IDisposable"/> that finishes the batch update.</returns>
+        IDisposable Update();
+    }
+}

+ 2 - 1
src/Avalonia.Controls/Image.cs

@@ -69,10 +69,11 @@ namespace Avalonia.Controls
         {
             var source = Source;
 
-            if (source != null)
+            if (source != null && Bounds.Width > 0 && Bounds.Height > 0)
             {
                 Rect viewPort = new Rect(Bounds.Size);
                 Size sourceSize = source.Size;
+
                 Vector scale = Stretch.CalculateScaling(Bounds.Size, sourceSize, StretchDirection);
                 Size scaledSize = sourceSize * scale;
                 Rect destRect = viewPort

+ 180 - 0
src/Avalonia.Controls/IndexPath.cs

@@ -0,0 +1,180 @@
+// This source file is adapted from the WinUI project.
+// (https://github.com/microsoft/microsoft-ui-xaml)
+//
+// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+#nullable enable
+
+namespace Avalonia.Controls
+{
+    public readonly struct IndexPath : IComparable<IndexPath>, IEquatable<IndexPath>
+    {
+        public static readonly IndexPath Unselected = default;
+
+        private readonly int _index;
+        private readonly int[]? _path;
+
+        public IndexPath(int index)
+        {
+            _index = index + 1;
+            _path = null;
+        }
+
+        public IndexPath(int groupIndex, int itemIndex)
+        {
+            _index = 0;
+            _path = new[] { groupIndex, itemIndex };
+        }
+
+        public IndexPath(IEnumerable<int>? indices)
+        {
+            if (indices != null)
+            {
+                _index = 0;
+                _path = indices.ToArray();
+            }
+            else
+            {
+                _index = 0;
+                _path = null;
+            }
+        }
+
+        private IndexPath(int[] basePath, int index)
+        {
+            basePath = basePath ?? throw new ArgumentNullException(nameof(basePath));
+            
+            _index = 0;
+            _path = new int[basePath.Length + 1];
+            Array.Copy(basePath, _path, basePath.Length);
+            _path[basePath.Length] = index;
+        }
+
+        public int GetSize() => _path?.Length ?? (_index == 0 ? 0 : 1);
+
+        public int GetAt(int index)
+        {
+            if (index >= GetSize())
+            {
+                throw new IndexOutOfRangeException();
+            }
+
+            return _path?[index] ?? (_index - 1);
+        }
+
+        public int CompareTo(IndexPath other)
+        {
+            var rhsPath = other;
+            int compareResult = 0;
+            int lhsCount = GetSize();
+            int rhsCount = rhsPath.GetSize();
+
+            if (lhsCount == 0 || rhsCount == 0)
+            {
+                // one of the paths are empty, compare based on size
+                compareResult = (lhsCount - rhsCount);
+            }
+            else
+            {
+                // both paths are non-empty, but can be of different size
+                for (int i = 0; i < Math.Min(lhsCount, rhsCount); i++)
+                {
+                    if (GetAt(i) < rhsPath.GetAt(i))
+                    {
+                        compareResult = -1;
+                        break;
+                    }
+                    else if (GetAt(i) > rhsPath.GetAt(i))
+                    {
+                        compareResult = 1;
+                        break;
+                    }
+                }
+
+                // if both match upto min(lhsCount, rhsCount), compare based on size
+                compareResult = compareResult == 0 ? (lhsCount - rhsCount) : compareResult;
+            }
+
+            if (compareResult != 0)
+            {
+                compareResult = compareResult > 0 ? 1 : -1;
+            }
+
+            return compareResult;
+        }
+
+        public IndexPath CloneWithChildIndex(int childIndex)
+        {
+            if (_path != null)
+            {
+                return new IndexPath(_path, childIndex);
+            }
+            else if (_index != 0)
+            {
+                return new IndexPath(_index - 1, childIndex);
+            }
+            else
+            {
+                return new IndexPath(childIndex);
+            }
+        }
+
+        public override string ToString()
+        {
+            if (_path != null)
+            {
+                return "R" + string.Join(".", _path);
+            }
+            else if (_index != 0)
+            {
+                return "R" + (_index - 1);
+            }
+            else
+            {
+                return "R";
+            }
+        }
+
+        public static IndexPath CreateFrom(int index) => new IndexPath(index);
+
+        public static IndexPath CreateFrom(int groupIndex, int itemIndex) => new IndexPath(groupIndex, itemIndex);
+
+        public static IndexPath CreateFromIndices(IList<int> indices) => new IndexPath(indices);
+
+        public override bool Equals(object obj) => obj is IndexPath other && Equals(other);
+
+        public bool Equals(IndexPath other) => CompareTo(other) == 0;
+
+        public override int GetHashCode()
+        {
+            var hashCode = -504981047;
+
+            if (_path != null)
+            {
+                foreach (var i in _path)
+                {
+                    hashCode = hashCode * -1521134295 + i.GetHashCode();
+                }
+            }
+            else
+            {
+                hashCode = hashCode * -1521134295 + _index.GetHashCode();
+            }
+
+            return hashCode;
+        }
+
+        public static bool operator <(IndexPath x, IndexPath y) { return x.CompareTo(y) < 0; }
+        public static bool operator >(IndexPath x, IndexPath y) { return x.CompareTo(y) > 0; }
+        public static bool operator <=(IndexPath x, IndexPath y) { return x.CompareTo(y) <= 0; }
+        public static bool operator >=(IndexPath x, IndexPath y) { return x.CompareTo(y) >= 0; }
+        public static bool operator ==(IndexPath x, IndexPath y) { return x.CompareTo(y) == 0; }
+        public static bool operator !=(IndexPath x, IndexPath y) { return x.CompareTo(y) != 0; }
+        public static bool operator ==(IndexPath? x, IndexPath? y) { return (x ?? default).CompareTo(y ?? default) == 0; }
+        public static bool operator !=(IndexPath? x, IndexPath? y) { return (x ?? default).CompareTo(y ?? default) != 0; }
+    }
+}

+ 232 - 0
src/Avalonia.Controls/IndexRange.cs

@@ -0,0 +1,232 @@
+// This source file is adapted from the WinUI project.
+// (https://github.com/microsoft/microsoft-ui-xaml)
+//
+// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation.
+
+using System;
+using System.Collections.Generic;
+
+#nullable enable
+
+namespace Avalonia.Controls
+{
+    internal readonly struct IndexRange : IEquatable<IndexRange>
+    {
+        private static readonly IndexRange s_invalid = new IndexRange(int.MinValue, int.MinValue);
+
+        public IndexRange(int begin, int end)
+        {
+            // Accept out of order begin/end pairs, just swap them.
+            if (begin > end)
+            {
+                int temp = begin;
+                begin = end;
+                end = temp;
+            }
+
+            Begin = begin;
+            End = end;
+        }
+
+        public int Begin { get; }
+        public int End { get; }
+        public int Count => (End - Begin) + 1;
+
+        public bool Contains(int index) => index >= Begin && index <= End;
+
+        public bool Split(int splitIndex, out IndexRange before, out IndexRange after)
+        {
+            bool afterIsValid;
+
+            before = new IndexRange(Begin, splitIndex);
+
+            if (splitIndex < End)
+            {
+                after = new IndexRange(splitIndex + 1, End);
+                afterIsValid = true;
+            }
+            else
+            {
+                after = new IndexRange();
+                afterIsValid = false;
+            }
+
+            return afterIsValid;
+        }
+
+        public bool Intersects(IndexRange other)
+        {
+            return (Begin <= other.End) && (End >= other.Begin);
+        }
+
+        public bool Adjacent(IndexRange other)
+        {
+            return Begin == other.End + 1 || End == other.Begin - 1;
+        }
+
+        public override bool Equals(object? obj)
+        {
+            return obj is IndexRange range && Equals(range);
+        }
+
+        public bool Equals(IndexRange other)
+        {
+            return Begin == other.Begin && End == other.End;
+        }
+
+        public override int GetHashCode()
+        {
+            var hashCode = 1903003160;
+            hashCode = hashCode * -1521134295 + Begin.GetHashCode();
+            hashCode = hashCode * -1521134295 + End.GetHashCode();
+            return hashCode;
+        }
+
+        public override string ToString() => $"[{Begin}..{End}]";
+
+        public static bool operator ==(IndexRange left, IndexRange right) => left.Equals(right);
+        public static bool operator !=(IndexRange left, IndexRange right) => !(left == right);
+
+        public static int Add(
+            IList<IndexRange> ranges,
+            IndexRange range,
+            IList<IndexRange>? added = null)
+        {
+            var result = 0;
+
+            for (var i = 0; i < ranges.Count && range != s_invalid; ++i)
+            {
+                var existing = ranges[i];
+
+                if (range.Intersects(existing) || range.Adjacent(existing))
+                {
+                    if (range.Begin < existing.Begin)
+                    {
+                        var add = new IndexRange(range.Begin, existing.Begin - 1);
+                        ranges[i] = new IndexRange(range.Begin, existing.End);
+                        added?.Add(add);
+                        result += add.Count;
+                    }
+
+                    range = range.End <= existing.End ?
+                        s_invalid :
+                        new IndexRange(existing.End + 1, range.End);
+                }
+                else if (range.End < existing.Begin)
+                {
+                    ranges.Insert(i, range);
+                    added?.Add(range);
+                    result += range.Count;
+                    range = s_invalid;
+                }
+            }
+
+            if (range != s_invalid)
+            {
+                ranges.Add(range);
+                added?.Add(range);
+                result += range.Count;
+            }
+
+            MergeRanges(ranges);
+            return result;
+        }
+
+        public static int Remove(
+            IList<IndexRange> ranges,
+            IndexRange range,
+            IList<IndexRange>? removed = null)
+        {
+            var result = 0;
+
+            for (var i = 0; i < ranges.Count; ++i)
+            {
+                var existing = ranges[i];
+
+                if (range.Intersects(existing))
+                {
+                    if (range.Begin <= existing.Begin && range.End >= existing.End)
+                    {
+                        ranges.RemoveAt(i--);
+                        removed?.Add(existing);
+                        result += existing.Count;
+                    }
+                    else if (range.Begin > existing.Begin && range.End >= existing.End)
+                    {
+                        ranges[i] = new IndexRange(existing.Begin, range.Begin - 1);
+                        removed?.Add(new IndexRange(range.Begin, existing.End));
+                        result += existing.End - (range.Begin - 1);
+                    }
+                    else if (range.Begin > existing.Begin && range.End < existing.End)
+                    {
+                        ranges[i] = new IndexRange(existing.Begin, range.Begin - 1);
+                        ranges.Insert(++i, new IndexRange(range.End + 1, existing.End));
+                        removed?.Add(range);
+                        result += range.Count;
+                    }
+                    else if (range.End <= existing.End)
+                    {
+                        var remove = new IndexRange(existing.Begin, range.End);
+                        ranges[i] = new IndexRange(range.End + 1, existing.End);
+                        removed?.Add(remove);
+                        result += remove.Count;
+                    }
+                }
+            }
+
+            return result;
+        }
+
+        public static IEnumerable<IndexRange> Subtract(
+            IndexRange lhs,
+            IEnumerable<IndexRange> rhs)
+        {
+            var result = new List<IndexRange> { lhs };
+            
+            foreach (var range in rhs)
+            {
+                Remove(result, range);
+            }
+
+            return result;
+        }
+
+        public static IEnumerable<int> EnumerateIndices(IEnumerable<IndexRange> ranges)
+        {
+            foreach (var range in ranges)
+            {
+                for (var i = range.Begin; i <= range.End; ++i)
+                {
+                    yield return i;
+                }
+            }
+        }
+
+        public static int GetCount(IEnumerable<IndexRange> ranges)
+        {
+            var result = 0;
+
+            foreach (var range in ranges)
+            {
+                result += (range.End - range.Begin) + 1;
+            }
+
+            return result;
+        }
+
+        private static void MergeRanges(IList<IndexRange> ranges)
+        {
+            for (var i = ranges.Count - 2; i >= 0; --i)
+            {
+                var r = ranges[i];
+                var r1 = ranges[i + 1];
+
+                if (r.Intersects(r1) || r.End == r1.Begin - 1)
+                {
+                    ranges[i] = new IndexRange(r.Begin, r1.End);
+                    ranges.RemoveAt(i + 1);
+                }
+            }
+        }
+    }
+}

+ 17 - 2
src/Avalonia.Controls/ListBox.cs

@@ -31,6 +31,12 @@ namespace Avalonia.Controls
         public static readonly new DirectProperty<SelectingItemsControl, IList> SelectedItemsProperty =
             SelectingItemsControl.SelectedItemsProperty;
 
+        /// <summary>
+        /// Defines the <see cref="Selection"/> property.
+        /// </summary>
+        public static readonly new DirectProperty<SelectingItemsControl, ISelectionModel> SelectionProperty =
+            SelectingItemsControl.SelectionProperty;
+
         /// <summary>
         /// Defines the <see cref="SelectionMode"/> property.
         /// </summary>
@@ -70,6 +76,15 @@ namespace Avalonia.Controls
             set => base.SelectedItems = value;
         }
 
+        /// <summary>
+        /// Gets or sets a model holding the current selection.
+        /// </summary>
+        public new ISelectionModel Selection
+        {
+            get => base.Selection;
+            set => base.Selection = value;
+        }
+
         /// <summary>
         /// Gets or sets the selection mode.
         /// </summary>
@@ -95,12 +110,12 @@ namespace Avalonia.Controls
         /// <summary>
         /// Selects all items in the <see cref="ListBox"/>.
         /// </summary>
-        public new void SelectAll() => base.SelectAll();
+        public void SelectAll() => Selection.SelectAll();
 
         /// <summary>
         /// Deselects all items in the <see cref="ListBox"/>.
         /// </summary>
-        public new void UnselectAll() => base.UnselectAll();
+        public void UnselectAll() => Selection.ClearSelection();
 
         /// <inheritdoc/>
         protected override IItemContainerGenerator CreateItemContainerGenerator()

+ 4 - 2
src/Avalonia.Controls/Platform/DefaultMenuInteractionHandler.cs

@@ -5,6 +5,7 @@ using Avalonia.Interactivity;
 using Avalonia.LogicalTree;
 using Avalonia.Rendering;
 using Avalonia.Threading;
+using Avalonia.VisualTree;
 
 #nullable enable
 
@@ -338,8 +339,9 @@ namespace Avalonia.Controls.Platform
         protected internal virtual void PointerPressed(object sender, PointerPressedEventArgs e)
         {
             var item = GetMenuItem(e.Source as IControl);
+            var visual = (IVisual)sender;
 
-            if (e.MouseButton == MouseButton.Left && item?.HasSubMenu == true)
+            if (e.GetCurrentPoint(visual).Properties.IsLeftButtonPressed && item?.HasSubMenu == true)
             {
                 if (item.IsSubMenuOpen)
                 {
@@ -392,7 +394,7 @@ namespace Avalonia.Controls.Platform
             {
                 var control = e.Source as ILogical;
 
-                if (!Menu.IsLogicalParentOf(control))
+                if (!Menu.IsLogicalAncestorOf(control))
                 {
                     Menu.Close();
                 }

+ 1 - 1
src/Avalonia.Controls/Presenters/IItemsPresenter.cs

@@ -11,6 +11,6 @@ namespace Avalonia.Controls.Presenters
 
         void ItemsChanged(NotifyCollectionChangedEventArgs e);
 
-        void ScrollIntoView(object item);
+        void ScrollIntoView(int index);
     }
 }

+ 2 - 2
src/Avalonia.Controls/Presenters/ItemVirtualizer.cs

@@ -275,8 +275,8 @@ namespace Avalonia.Controls.Presenters
         /// <summary>
         /// Scrolls the specified item into view.
         /// </summary>
-        /// <param name="item">The item.</param>
-        public virtual void ScrollIntoView(object item)
+        /// <param name="index">The index of the item.</param>
+        public virtual void ScrollIntoView(int index)
         {
         }
 

+ 5 - 10
src/Avalonia.Controls/Presenters/ItemVirtualizerNone.cs

@@ -64,18 +64,13 @@ namespace Avalonia.Controls.Presenters
         /// <summary>
         /// Scrolls the specified item into view.
         /// </summary>
-        /// <param name="item">The item.</param>
-        public override void ScrollIntoView(object item)
+        /// <param name="index">The index of the item.</param>
+        public override void ScrollIntoView(int index)
         {
-            if (Items != null)
+            if (index != -1)
             {
-                var index = Items.IndexOf(item);
-
-                if (index != -1)
-                {
-                    var container = Owner.ItemContainerGenerator.ContainerFromIndex(index);
-                    container?.BringIntoView();
-                }
+                var container = Owner.ItemContainerGenerator.ContainerFromIndex(index);
+                container?.BringIntoView();
             }
         }
 

+ 5 - 10
src/Avalonia.Controls/Presenters/ItemVirtualizerSimple.cs

@@ -286,20 +286,15 @@ namespace Avalonia.Controls.Presenters
                     break;
             }
 
-            return ScrollIntoView(newItemIndex);
+            return ScrollIntoViewCore(newItemIndex);
         }
 
         /// <inheritdoc/>
-        public override void ScrollIntoView(object item)
+        public override void ScrollIntoView(int index)
         {
-            if (Items != null)
+            if (index != -1)
             {
-                var index = Items.IndexOf(item);
-
-                if (index != -1)
-                {
-                    ScrollIntoView(index);
-                }
+                ScrollIntoViewCore(index);
             }
         }
 
@@ -511,7 +506,7 @@ namespace Avalonia.Controls.Presenters
         /// </summary>
         /// <param name="index">The item index.</param>
         /// <returns>The container that was brought into view.</returns>
-        private IControl ScrollIntoView(int index)
+        private IControl ScrollIntoViewCore(int index)
         {
             var panel = VirtualizingPanel;
             var generator = Owner.ItemContainerGenerator;

+ 2 - 2
src/Avalonia.Controls/Presenters/ItemsPresenter.cs

@@ -128,9 +128,9 @@ namespace Avalonia.Controls.Presenters
             _scrollInvalidated?.Invoke(this, e);
         }
 
-        public override void ScrollIntoView(object item)
+        public override void ScrollIntoView(int index)
         {
-            Virtualizer?.ScrollIntoView(item);
+            Virtualizer?.ScrollIntoView(index);
         }
 
         /// <inheritdoc/>

+ 1 - 1
src/Avalonia.Controls/Presenters/ItemsPresenterBase.cs

@@ -139,7 +139,7 @@ namespace Avalonia.Controls.Presenters
         }
 
         /// <inheritdoc/>
-        public virtual void ScrollIntoView(object item)
+        public virtual void ScrollIntoView(int index)
         {
         }
 

+ 22 - 24
src/Avalonia.Controls/Presenters/TextPresenter.cs

@@ -74,16 +74,15 @@ namespace Avalonia.Controls.Presenters
 
         static TextPresenter()
         {
-            AffectsRender<TextPresenter>(PasswordCharProperty,
-                SelectionBrushProperty, SelectionForegroundBrushProperty,
-                SelectionStartProperty, SelectionEndProperty);
+            AffectsRender<TextPresenter>(SelectionBrushProperty);
 
-            Observable.Merge(
-                TextProperty.Changed,
-                SelectionStartProperty.Changed,
-                SelectionEndProperty.Changed,
-                PasswordCharProperty.Changed
-            ).AddClassHandler<TextPresenter>((x,_) => x.InvalidateFormattedText());
+            Observable.Merge(TextProperty.Changed, TextBlock.ForegroundProperty.Changed,
+                TextAlignmentProperty.Changed, TextWrappingProperty.Changed,
+                TextBlock.FontSizeProperty.Changed, TextBlock.FontStyleProperty.Changed, 
+                TextBlock.FontWeightProperty.Changed, TextBlock.FontFamilyProperty.Changed,
+                SelectionStartProperty.Changed, SelectionEndProperty.Changed,
+                SelectionForegroundBrushProperty.Changed, PasswordCharProperty.Changed
+            ).AddClassHandler<TextPresenter>((x, _) => x.InvalidateFormattedText());
 
             CaretIndexProperty.Changed.AddClassHandler<TextPresenter>((x, e) => x.CaretIndexChanged((int)e.NewValue));
         }
@@ -184,7 +183,7 @@ namespace Avalonia.Controls.Presenters
         {
             get
             {
-                return _formattedText ?? (_formattedText = CreateFormattedText(Bounds.Size, Text));
+                return _formattedText ?? (_formattedText = CreateFormattedText());
             }
         }
 
@@ -219,7 +218,7 @@ namespace Avalonia.Controls.Presenters
             get => GetValue(SelectionForegroundBrushProperty);
             set => SetValue(SelectionForegroundBrushProperty, value);
         }
-        
+
         public IBrush CaretBrush
         {
             get => GetValue(CaretBrushProperty);
@@ -284,13 +283,9 @@ namespace Avalonia.Controls.Presenters
         /// </summary>
         protected void InvalidateFormattedText()
         {
-            if (_formattedText != null)
-            {
-                _constraint = _formattedText.Constraint;
-                _formattedText = null;
-            }
+            _formattedText = null;
 
-            InvalidateVisual();
+            InvalidateMeasure();
         }
 
         /// <summary>
@@ -307,6 +302,7 @@ namespace Avalonia.Controls.Presenters
             }
 
             FormattedText.Constraint = Bounds.Size;
+
             context.DrawText(Foreground, new Point(), FormattedText);
         }
 
@@ -424,20 +420,20 @@ namespace Avalonia.Controls.Presenters
         /// <summary>
         /// Creates the <see cref="FormattedText"/> used to render the text.
         /// </summary>
-        /// <param name="constraint">The constraint of the text.</param>
-        /// <param name="text">The text to generated the <see cref="FormattedText"/> for.</param>
         /// <returns>A <see cref="FormattedText"/> object.</returns>
-        protected virtual FormattedText CreateFormattedText(Size constraint, string text)
+        protected virtual FormattedText CreateFormattedText()
         {
             FormattedText result = null;
 
+            var text = Text;
+
             if (PasswordChar != default(char))
             {
-                result = CreateFormattedTextInternal(constraint, new string(PasswordChar, text?.Length ?? 0));
+                result = CreateFormattedTextInternal(_constraint, new string(PasswordChar, text?.Length ?? 0));
             }
             else
             {
-                result = CreateFormattedTextInternal(constraint, text);
+                result = CreateFormattedTextInternal(_constraint, text);
             }
 
             var selectionStart = SelectionStart;
@@ -467,13 +463,15 @@ namespace Avalonia.Controls.Presenters
             {
                 if (TextWrapping == TextWrapping.Wrap)
                 {
-                    FormattedText.Constraint = new Size(availableSize.Width, double.PositiveInfinity);
+                    _constraint = new Size(availableSize.Width, double.PositiveInfinity);
                 }
                 else
                 {
-                    FormattedText.Constraint = Size.Infinity;
+                    _constraint = Size.Infinity;
                 }
 
+                _formattedText = null;
+
                 return FormattedText.Bounds.Size;
             }
 

+ 246 - 593
src/Avalonia.Controls/Primitives/SelectingItemsControl.cs

@@ -2,15 +2,15 @@ using System;
 using System.Collections;
 using System.Collections.Generic;
 using System.Collections.Specialized;
+using System.ComponentModel;
 using System.Diagnostics;
 using System.Linq;
-using Avalonia.Collections;
 using Avalonia.Controls.Generators;
+using Avalonia.Controls.Utils;
 using Avalonia.Data;
 using Avalonia.Input;
 using Avalonia.Input.Platform;
 using Avalonia.Interactivity;
-using Avalonia.Logging;
 using Avalonia.VisualTree;
 
 namespace Avalonia.Controls.Primitives
@@ -23,9 +23,9 @@ namespace Avalonia.Controls.Primitives
     /// <see cref="SelectingItemsControl"/> provides a base class for <see cref="ItemsControl"/>s
     /// that maintain a selection (single or multiple). By default only its 
     /// <see cref="SelectedIndex"/> and <see cref="SelectedItem"/> properties are visible; the
-    /// current multiple selection <see cref="SelectedItems"/> together with the 
-    /// <see cref="SelectionMode"/> properties are protected, however a derived  class can expose 
-    /// these if it wishes to support multiple selection.
+    /// current multiple <see cref="Selection"/> and <see cref="SelectedItems"/> together with the
+    /// <see cref="SelectionMode"/> and properties are protected, however a derived  class can
+    /// expose these if it wishes to support multiple selection.
     /// </para>
     /// <para>
     /// <see cref="SelectingItemsControl"/> maintains a selection respecting the current 
@@ -74,6 +74,15 @@ namespace Avalonia.Controls.Primitives
                 o => o.SelectedItems,
                 (o, v) => o.SelectedItems = v);
 
+        /// <summary>
+        /// Defines the <see cref="Selection"/> property.
+        /// </summary>
+        public static readonly DirectProperty<SelectingItemsControl, ISelectionModel> SelectionProperty =
+            AvaloniaProperty.RegisterDirect<SelectingItemsControl, ISelectionModel>(
+                nameof(Selection),
+                o => o.Selection,
+                (o, v) => o.Selection = v);
+
         /// <summary>
         /// Defines the <see cref="SelectionMode"/> property.
         /// </summary>
@@ -100,17 +109,22 @@ namespace Avalonia.Controls.Primitives
                 RoutingStrategies.Bubble);
 
         private static readonly IList Empty = Array.Empty<object>();
-
-        private readonly Selection _selection = new Selection();
+        private readonly SelectedItemsSync _selectedItems;
+        private ISelectionModel _selection;
         private int _selectedIndex = -1;
         private object _selectedItem;
-        private IList _selectedItems;
         private bool _ignoreContainerSelectionChanged;
-        private bool _syncingSelectedItems;
         private int _updateCount;
         private int _updateSelectedIndex;
         private object _updateSelectedItem;
 
+        public SelectingItemsControl()
+        {
+            // Setting Selection to null causes a default SelectionModel to be created.
+            Selection = null;
+            _selectedItems = new SelectedItemsSync(Selection);
+        }
+
         /// <summary>
         /// Initializes static members of the <see cref="SelectingItemsControl"/> class.
         /// </summary>
@@ -142,17 +156,15 @@ namespace Avalonia.Controls.Primitives
         /// </summary>
         public int SelectedIndex
         {
-            get
-            {
-                return _selectedIndex;
-            }
-
+            get => Selection.SelectedIndex != default ? Selection.SelectedIndex.GetAt(0) : -1;
             set
             {
                 if (_updateCount == 0)
                 {
-                    var effective = (value >= 0 && value < ItemCount) ? value : -1;
-                    UpdateSelectedItem(effective);
+                    if (value != SelectedIndex)
+                    {
+                        Selection.SelectedIndex = new IndexPath(value);
+                    }
                 }
                 else
                 {
@@ -167,16 +179,12 @@ namespace Avalonia.Controls.Primitives
         /// </summary>
         public object SelectedItem
         {
-            get
-            {
-                return _selectedItem;
-            }
-
+            get => Selection.SelectedItem;
             set
             {
                 if (_updateCount == 0)
                 {
-                    UpdateSelectedItem(IndexOf(Items, value));
+                    SelectedIndex = IndexOf(Items, value);
                 }
                 else
                 {
@@ -187,32 +195,110 @@ namespace Avalonia.Controls.Primitives
         }
 
         /// <summary>
-        /// Gets the selected items.
+        /// Gets or sets the selected items.
         /// </summary>
         protected IList SelectedItems
         {
-            get
-            {
-                if (_selectedItems == null)
-                {
-                    _selectedItems = new AvaloniaList<object>();
-                    SubscribeToSelectedItems();
-                }
-
-                return _selectedItems;
-            }
+            get => _selectedItems.GetOrCreateItems();
+            set => _selectedItems.SetItems(value);
+        }
 
+        /// <summary>
+        /// Gets or sets a model holding the current selection.
+        /// </summary>
+        protected ISelectionModel Selection 
+        {
+            get => _selection;
             set
             {
-                if (value?.IsFixedSize == true || value?.IsReadOnly == true)
+                value ??= new SelectionModel
                 {
-                    throw new NotSupportedException(
-                        "Cannot use a fixed size or read-only collection as SelectedItems.");
-                }
+                    SingleSelect = !SelectionMode.HasFlagCustom(SelectionMode.Multiple),
+                    AutoSelect = SelectionMode.HasFlagCustom(SelectionMode.AlwaysSelected),
+                    RetainSelectionOnReset = true,
+                };
+
+                if (_selection != value)
+                {
+                    if (value == null)
+                    {
+                        throw new ArgumentNullException(nameof(value), "Cannot set Selection to null.");
+                    }
+                    else if (value.Source != null && value.Source != Items)
+                    {
+                        throw new ArgumentException("Selection has invalid Source.");
+                    }
+
+                    List<object> oldSelection = null;
+
+                    if (_selection != null)
+                    {
+                        oldSelection = Selection.SelectedItems.ToList();
+                        _selection.PropertyChanged -= OnSelectionModelPropertyChanged;
+                        _selection.SelectionChanged -= OnSelectionModelSelectionChanged;
+                        MarkContainersUnselected();
+                    }
+
+                    _selection = value;
+
+                    if (oldSelection?.Count > 0)
+                    {
+                        RaiseEvent(new SelectionChangedEventArgs(
+                            SelectionChangedEvent,
+                            oldSelection,
+                            Array.Empty<object>()));
+                    }
+
+                    if (_selection != null)
+                    {
+                        _selection.Source = Items;
+                        _selection.PropertyChanged += OnSelectionModelPropertyChanged;
+                        _selection.SelectionChanged += OnSelectionModelSelectionChanged;
+
+                        if (_selection.SingleSelect)
+                        {
+                            SelectionMode &= ~SelectionMode.Multiple;
+                        }
+                        else
+                        {
+                            SelectionMode |= SelectionMode.Multiple;
+                        }
+
+                        if (_selection.AutoSelect)
+                        {
+                            SelectionMode |= SelectionMode.AlwaysSelected;
+                        }
+                        else
+                        {
+                            SelectionMode &= ~SelectionMode.AlwaysSelected;
+                        }
+
+                        UpdateContainerSelection();
+
+                        var selectedIndex = SelectedIndex;
+                        var selectedItem = SelectedItem;
 
-                UnsubscribeFromSelectedItems();
-                _selectedItems = value ?? new AvaloniaList<object>();
-                SubscribeToSelectedItems();
+                        if (_selectedIndex != selectedIndex)
+                        {
+                            RaisePropertyChanged(SelectedIndexProperty, _selectedIndex, selectedIndex);
+                            _selectedIndex = selectedIndex;
+                        }
+
+                        if (_selectedItem != selectedItem)
+                        {
+                            RaisePropertyChanged(SelectedItemProperty, _selectedItem, selectedItem);
+                            _selectedItem = selectedItem;
+                        }
+                        
+                        if (selectedIndex != -1)
+                        {
+                            RaiseEvent(new SelectionChangedEventArgs(
+                                SelectionChangedEvent,
+                                Array.Empty<object>(),
+                                Selection.SelectedItems.ToList()));
+                        }
+                    }
+                }
             }
         }
 
@@ -250,11 +336,17 @@ namespace Avalonia.Controls.Primitives
             base.EndInit();
         }
 
+        /// <summary>
+        /// Scrolls the specified item into view.
+        /// </summary>
+        /// <param name="index">The index of the item.</param>
+        public void ScrollIntoView(int index) => Presenter?.ScrollIntoView(index);
+
         /// <summary>
         /// Scrolls the specified item into view.
         /// </summary>
         /// <param name="item">The item.</param>
-        public void ScrollIntoView(object item) => Presenter?.ScrollIntoView(item);
+        public void ScrollIntoView(object item) => ScrollIntoView(IndexOf(Items, item));
 
         /// <summary>
         /// Tries to get the container that was the source of an event.
@@ -282,81 +374,18 @@ namespace Avalonia.Controls.Primitives
         /// <inheritdoc/>
         protected override void ItemsChanged(AvaloniaPropertyChangedEventArgs e)
         {
-            base.ItemsChanged(e);
-
             if (_updateCount == 0)
             {
-                var newIndex = -1;
-
-                if (SelectedIndex != -1)
-                {
-                    newIndex = IndexOf((IEnumerable)e.NewValue, SelectedItem);
-                }
-
-                if (AlwaysSelected && Items != null && Items.Cast<object>().Any())
-                {
-                    newIndex = 0;
-                }
-
-                SelectedIndex = newIndex;
+                Selection.Source = e.NewValue;
             }
+
+            base.ItemsChanged(e);
         }
 
         /// <inheritdoc/>
         protected override void ItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
         {
-            if (_updateCount > 0)
-            {
-                base.ItemsCollectionChanged(sender, e);
-                return;
-            }
-
-            switch (e.Action)
-            {
-                case NotifyCollectionChangedAction.Add:
-                    _selection.ItemsInserted(e.NewStartingIndex, e.NewItems.Count);
-                    break;
-                case NotifyCollectionChangedAction.Remove:
-                    _selection.ItemsRemoved(e.OldStartingIndex, e.OldItems.Count);
-                    break;
-            }
-
             base.ItemsCollectionChanged(sender, e);
-
-            switch (e.Action)
-            {
-                case NotifyCollectionChangedAction.Add:
-                    if (AlwaysSelected && SelectedIndex == -1)
-                    {
-                        SelectedIndex = 0;
-                    }
-                    else
-                    {
-                        UpdateSelectedItem(_selection.First(), false);
-                    }
-
-                    break;
-
-                case NotifyCollectionChangedAction.Remove:
-                    UpdateSelectedItem(_selection.First(), false);
-                    ResetSelectedItems();
-                    break;
-
-                case NotifyCollectionChangedAction.Replace:
-                    UpdateSelectedItem(SelectedIndex, false);
-                    ResetSelectedItems();
-                    break;
-
-                case NotifyCollectionChangedAction.Move:
-                case NotifyCollectionChangedAction.Reset:
-                    SelectedIndex = IndexOf(Items, SelectedItem);
-
-                    if (AlwaysSelected && SelectedIndex == -1 && ItemCount > 0)
-                    {
-                        SelectedIndex = 0;
-                    }
-                    break;
-            }
         }
 
         /// <inheritdoc/>
@@ -364,36 +393,18 @@ namespace Avalonia.Controls.Primitives
         {
             base.OnContainersMaterialized(e);
 
-            var resetSelectedItems = false;
-
             foreach (var container in e.Containers)
             {
                 if ((container.ContainerControl as ISelectable)?.IsSelected == true)
                 {
-                    if (SelectionMode.HasFlag(SelectionMode.Multiple))
-                    {
-                        if (_selection.Add(container.Index))
-                        {
-                            resetSelectedItems = true;
-                        }
-                    }
-                    else
-                    {
-                        SelectedIndex = container.Index;
-                    }
-
+                    Selection.Select(container.Index);
                     MarkContainerSelected(container.ContainerControl, true);
                 }
-                else if (_selection.Contains(container.Index))
+                else if (Selection.IsSelected(container.Index) == true)
                 {
                     MarkContainerSelected(container.ContainerControl, true);
                 }
             }
-
-            if (resetSelectedItems)
-            {
-                ResetSelectedItems();
-            }
         }
 
         /// <inheritdoc/>
@@ -422,7 +433,7 @@ namespace Avalonia.Controls.Primitives
             {
                 if (i.ContainerControl != null && i.Item != null)
                 {
-                    bool selected = _selection.Contains(i.Index);
+                    bool selected = Selection.IsSelected(i.Index) == true;
                     MarkContainerSelected(i.ContainerControl, selected);
                 }
             }
@@ -444,6 +455,18 @@ namespace Avalonia.Controls.Primitives
             InternalEndInit();
         }
 
+        protected override void OnPropertyChanged<T>(AvaloniaProperty<T> property, Optional<T> oldValue, BindingValue<T> newValue, BindingPriority priority)
+        {
+            base.OnPropertyChanged(property, oldValue, newValue, priority);
+
+            if (property == SelectionModeProperty)
+            {
+                var mode = newValue.GetValueOrDefault<SelectionMode>();
+                Selection.SingleSelect = !mode.HasFlagCustom(SelectionMode.Multiple);
+                Selection.AutoSelect = mode.HasFlagCustom(SelectionMode.AlwaysSelected);
+            }
+        }
+
         protected override void OnKeyDown(KeyEventArgs e)
         {
             base.OnKeyDown(e);
@@ -458,7 +481,7 @@ namespace Avalonia.Controls.Primitives
                     (((SelectionMode & SelectionMode.Multiple) != 0) ||
                       (SelectionMode & SelectionMode.Toggle) != 0))
                 {
-                    SelectAll();
+                    Selection.SelectAll();
                     e.Handled = true;
                 }
             }
@@ -500,36 +523,6 @@ namespace Avalonia.Controls.Primitives
             return false;
         }
 
-        /// <summary>
-        /// Selects all items in the control.
-        /// </summary>
-        protected void SelectAll()
-        {
-            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>
@@ -556,63 +549,35 @@ namespace Avalonia.Controls.Primitives
 
                     if (rightButton)
                     {
-                        if (!_selection.Contains(index))
+                        if (Selection.IsSelected(index) == false)
                         {
-                            UpdateSelectedItem(index);
+                            SelectedIndex = index;
                         }
                     }
                     else if (range)
                     {
-                        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);
+                        using var operation = Selection.Update();
+                        var anchor = Selection.AnchorIndex;
 
-                            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);
-                            }
+                        if (anchor.GetSize() == 0)
+                        {
+                            anchor = new IndexPath(0);
+                        }
 
-                            ResetSelectedItems();
-                        });
+                        Selection.ClearSelection();
+                        Selection.AnchorIndex = anchor;
+                        Selection.SelectRangeFromAnchor(index);
                     }
                     else if (multi && toggle)
                     {
-                        UpdateSelectedItems(() =>
+                        if (Selection.IsSelected(index) == true)
                         {
-                            if (!_selection.Contains(index))
-                            {
-                                _selection.Add(index);
-                                MarkItemSelected(index, true);
-                                SelectedItems.Add(ElementAt(Items, index));
-                            }
-                            else
-                            {
-                                _selection.Remove(index);
-                                MarkItemSelected(index, false);
-
-                                if (index == _selectedIndex)
-                                {
-                                    UpdateSelectedItem(_selection.First(), false);
-                                }
-
-                                SelectedItems.Remove(ElementAt(Items, index));
-                            }
-                        });
+                            Selection.Deselect(index);
+                        }
+                        else
+                        {
+                            Selection.Select(index);
+                        }
                     }
                     else if (toggle)
                     {
@@ -620,7 +585,9 @@ namespace Avalonia.Controls.Primitives
                     }
                     else
                     {
-                        UpdateSelectedItem(index);
+                        using var operation = Selection.Update();
+                        Selection.ClearSelection();
+                        Selection.Select(index);
                     }
 
                     if (Presenter?.Panel != null)
@@ -693,25 +660,68 @@ namespace Avalonia.Controls.Primitives
         }
 
         /// <summary>
-        /// Gets a range of items from an IEnumerable.
+        /// Called when <see cref="SelectionModel.PropertyChanged"/> is raised.
         /// </summary>
-        /// <param name="items">The items.</param>
-        /// <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 List<object> GetRange(IEnumerable items, int first, int last)
+        /// <param name="sender">The sender.</param>
+        /// <param name="e">The event args.</param>
+        private void OnSelectionModelPropertyChanged(object sender, PropertyChangedEventArgs e)
         {
-            var list = (items as IList) ?? items.Cast<object>().ToList();
-            var step = first > last ? -1 : 1;
-            var result = new List<object>();
+            if (e.PropertyName == nameof(SelectionModel.AnchorIndex) && AutoScrollToSelectedItem)
+            {
+                if (Selection.AnchorIndex.GetSize() > 0)
+                {
+                    ScrollIntoView(Selection.AnchorIndex.GetAt(0));
+                }
+            }
+        }
+
+        /// <summary>
+        /// Called when <see cref="SelectionModel.SelectionChanged"/> is raised.
+        /// </summary>
+        /// <param name="sender">The sender.</param>
+        /// <param name="e">The event args.</param>
+        private void OnSelectionModelSelectionChanged(object sender, SelectionModelSelectionChangedEventArgs e)
+        {
+            void Mark(int index, bool selected)
+            {
+                var container = ItemContainerGenerator.ContainerFromIndex(index);
+
+                if (container != null)
+                {
+                    MarkContainerSelected(container, selected);
+                }
+            }
+
+            foreach (var i in e.SelectedIndices)
+            {
+                Mark(i.GetAt(0), true);
+            }
+
+            foreach (var i in e.DeselectedIndices)
+            {
+                Mark(i.GetAt(0), false);
+            }
+
+            var newSelectedIndex = SelectedIndex;
+            var newSelectedItem = SelectedItem;
 
-            for (int i = first; i != last; i += step)
+            if (newSelectedIndex != _selectedIndex)
             {
-                result.Add(list[i]);
+                RaisePropertyChanged(SelectedIndexProperty, _selectedIndex, newSelectedIndex);
+                _selectedIndex = newSelectedIndex;
             }
 
-            result.Add(list[last]);
-            return result;
+            if (newSelectedItem != _selectedItem)
+            {
+                RaisePropertyChanged(SelectedItemProperty, _selectedItem, newSelectedItem);
+                _selectedItem = newSelectedItem;
+            }
+
+            var ev = new SelectionChangedEventArgs(
+                SelectionChangedEvent,
+                e.DeselectedItems.ToList(),
+                e.SelectedItems.ToList());
+            RaiseEvent(ev);
         }
 
         /// <summary>
@@ -791,301 +801,43 @@ namespace Avalonia.Controls.Primitives
             }
         }
 
-        /// <summary>
-        /// Sets an item container's 'selected' class or <see cref="ISelectable.IsSelected"/>.
-        /// </summary>
-        /// <param name="index">The index of the item.</param>
-        /// <param name="selected">Whether the item should be selected or deselected.</param>
-        private void MarkItemSelected(int index, bool selected)
+        private void MarkContainersUnselected()
         {
-            var container = ItemContainerGenerator?.ContainerFromIndex(index);
-
-            if (container != null)
+            foreach (var container in ItemContainerGenerator.Containers)
             {
-                MarkContainerSelected(container, selected);
+                MarkContainerSelected(container.ContainerControl, false);
             }
         }
 
-        /// <summary>
-        /// Sets an item container's 'selected' class or <see cref="ISelectable.IsSelected"/>.
-        /// </summary>
-        /// <param name="item">The item.</param>
-        /// <param name="selected">Whether the item should be selected or deselected.</param>
-        private int MarkItemSelected(object item, bool selected)
+        private void UpdateContainerSelection()
         {
-            var index = IndexOf(Items, item);
-
-            if (index != -1)
+            foreach (var container in ItemContainerGenerator.Containers)
             {
-                MarkItemSelected(index, selected);
+                MarkContainerSelected(
+                    container.ContainerControl,
+                    Selection.IsSelected(container.Index) != false);
             }
-
-            return index;
-        }
-
-        private void ResetSelectedItems()
-        {
-            UpdateSelectedItems(() =>
-            {
-                SelectedItems.Clear();
-
-                foreach (var i in _selection)
-                {
-                    SelectedItems.Add(ElementAt(Items, i));
-                }
-            });
         }
 
         /// <summary>
-        /// Called when the <see cref="SelectedItems"/> CollectionChanged event is raised.
-        /// </summary>
-        /// <param name="sender">The event sender.</param>
-        /// <param name="e">The event args.</param>
-        private void SelectedItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
-        {
-            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);
-                }
-            }
-
-            IList added = null;
-            IList removed = null;
-
-            switch (e.Action)
-            {
-                case NotifyCollectionChangedAction.Add:
-                    {
-                        Add(e.NewItems);
-                        UpdateSelection();
-                        added = e.NewItems;
-                    }
-
-                    break;
-
-                case NotifyCollectionChangedAction.Remove:
-                    if (SelectedItems.Count == 0)
-                    {
-                        SelectedIndex = -1;
-                    }
-
-                    foreach (var item in e.OldItems)
-                    {
-                        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:
-                    {
-                        removed = new List<object>();
-                        added = new List<object>();
-
-                        foreach (var index in _selection.ToList())
-                        {
-                            var item = ElementAt(Items, index);
-
-                            if (!SelectedItems.Contains(item))
-                            {
-                                MarkItemSelected(index, false);
-                                removed.Add(item);
-                                _selection.Remove(index);
-                            }
-                        }
-
-                        Add(SelectedItems, added);
-                        UpdateSelection();
-                    }
-
-                    break;
-            }
-
-            if (added?.Count > 0 || removed?.Count > 0)
-            {
-                var changed = new SelectionChangedEventArgs(
-                    SelectionChangedEvent,
-                    removed ?? Empty,
-                    added ?? Empty);
-                RaiseEvent(changed);
-            }
-        }
-
-        /// <summary>
-        /// Subscribes to the <see cref="SelectedItems"/> CollectionChanged event, if any.
-        /// </summary>
-        private void SubscribeToSelectedItems()
-        {
-            var incc = _selectedItems as INotifyCollectionChanged;
-
-            if (incc != null)
-            {
-                incc.CollectionChanged += SelectedItemsCollectionChanged;
-            }
-
-            SelectedItemsCollectionChanged(
-                _selectedItems,
-                new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
-        }
-
-        /// <summary>
-        /// Unsubscribes from the <see cref="SelectedItems"/> CollectionChanged event, if any.
-        /// </summary>
-        private void UnsubscribeFromSelectedItems()
-        {
-            var incc = _selectedItems as INotifyCollectionChanged;
-
-            if (incc != null)
-            {
-                incc.CollectionChanged -= SelectedItemsCollectionChanged;
-            }
-        }
-
-        /// <summary>
-        /// Updates the selection due to a change to <see cref="SelectedIndex"/> or
-        /// <see cref="SelectedItem"/>.
+        /// Sets an item container's 'selected' class or <see cref="ISelectable.IsSelected"/>.
         /// </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)
+        /// <param name="index">The index of the item.</param>
+        /// <param name="selected">Whether the item should be selected or deselected.</param>
+        private void MarkItemSelected(int index, bool selected)
         {
-            var oldIndex = _selectedIndex;
-            var oldItem = _selectedItem;
-
-            if (index == -1 && AlwaysSelected)
-            {
-                index = Math.Min(SelectedIndex, ItemCount - 1);
-            }
-
-            var item = ElementAt(Items, index);
-            var itemChanged = !Equals(item, oldItem);
-            var added = -1;
-            HashSet<int> removed = null;
-
-            _selectedIndex = index;
-            _selectedItem = item;
-
-            if (oldIndex != index || itemChanged || _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 (itemChanged)
-            {
-                RaisePropertyChanged(
-                    SelectedItemProperty,
-                    oldItem,
-                    item);
-            }
-
-            if (removed != null && index != -1)
-            {
-                removed.Remove(index);
-            }
-
-            if (added != -1 || removed?.Count > 0)
-            {
-                ResetSelectedItems();
-
-                var e = new SelectionChangedEventArgs(
-                    SelectionChangedEvent,
-                    removed?.Select(x => ElementAt(Items, x)).ToArray() ?? Array.Empty<object>(),
-                    added != -1 ? new[] { ElementAt(Items, added) } : Array.Empty<object>());
-                RaiseEvent(e);
-            }
-
-            if (AutoScrollToSelectedItem && _selectedIndex != -1)
-            {
-                ScrollIntoView(_selectedItem);
-            }
-        }
+            var container = ItemContainerGenerator?.ContainerFromIndex(index);
 
-        private void UpdateSelectedItems(Action action)
-        {
-            try
-            {
-                _syncingSelectedItems = true;
-                action();
-            }
-            catch (Exception ex)
-            {
-                Logger.TryGet(LogEventLevel.Error)?.Log(
-                    LogArea.Property,
-                    this,
-                    "Error thrown updating SelectedItems: {Error}",
-                    ex);
-            }
-            finally
+            if (container != null)
             {
-                _syncingSelectedItems = false;
+                MarkContainerSelected(container, selected);
             }
         }
 
         private void UpdateFinished()
         {
+            Selection.Source = Items;
+
             if (_updateSelectedItem != null)
             {
                 SelectedItem = _updateSelectedItem;
@@ -1130,104 +882,5 @@ namespace Avalonia.Controls.Primitives
                 UpdateFinished();
             }
         }
-
-        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();
-        }
     }
 }

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

@@ -88,7 +88,7 @@ namespace Avalonia.Controls
         {
             base.OnPointerPressed(e);
 
-            if (e.MouseButton == MouseButton.Left)
+            if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
             {
                 StartTimer();
             }

+ 2 - 0
src/Avalonia.Controls/Repeater/ItemsSourceView.cs

@@ -96,6 +96,8 @@ namespace Avalonia.Controls
         /// <returns>the item.</returns>
         public object GetAt(int index) => _inner[index];
 
+        public int IndexOf(object item) => _inner.IndexOf(item);
+
         /// <summary>
         /// Retrieves the index of the item that has the specified unique identifier (key).
         /// </summary>

+ 49 - 0
src/Avalonia.Controls/SelectedItems.cs

@@ -0,0 +1,49 @@
+// This source file is adapted from the WinUI project.
+// (https://github.com/microsoft/microsoft-ui-xaml)
+//
+// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation.
+
+using System;
+using System.Collections;
+using System.Collections.Generic;
+
+#nullable enable
+
+namespace Avalonia.Controls
+{
+    public interface ISelectedItemInfo
+    {
+        public IndexPath Path { get; }
+    }
+
+    internal class SelectedItems<TValue, Tinfo> : IReadOnlyList<TValue>
+        where Tinfo : ISelectedItemInfo
+    {
+        private readonly List<Tinfo> _infos;
+        private readonly Func<List<Tinfo>, int, TValue> _getAtImpl;
+
+        public SelectedItems(
+            List<Tinfo> infos,
+            int count,
+            Func<List<Tinfo>, int, TValue> getAtImpl)
+        {
+            _infos = infos;
+            _getAtImpl = getAtImpl;
+            Count = count;
+        }
+
+        public TValue this[int index] => _getAtImpl(_infos, index);
+
+        public int Count { get; }
+
+        public IEnumerator<TValue> GetEnumerator()
+        {
+            for (var i = 0; i < Count; ++i)
+            {
+                yield return this[i];
+            }
+        }
+
+        IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+    }
+}

+ 848 - 0
src/Avalonia.Controls/SelectionModel.cs

@@ -0,0 +1,848 @@
+// This source file is adapted from the WinUI project.
+// (https://github.com/microsoft/microsoft-ui-xaml)
+//
+// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation.
+
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Linq;
+using System.Reactive.Linq;
+using Avalonia.Controls.Utils;
+
+#nullable enable
+
+namespace Avalonia.Controls
+{
+    public class SelectionModel : ISelectionModel, IDisposable
+    {
+        private readonly SelectionNode _rootNode;
+        private bool _singleSelect;
+        private bool _autoSelect;
+        private int _operationCount;
+        private IReadOnlyList<IndexPath>? _selectedIndicesCached;
+        private IReadOnlyList<object?>? _selectedItemsCached;
+        private SelectionModelChildrenRequestedEventArgs? _childrenRequestedEventArgs;
+
+        public event EventHandler<SelectionModelChildrenRequestedEventArgs>? ChildrenRequested;
+        public event PropertyChangedEventHandler? PropertyChanged;
+        public event EventHandler<SelectionModelSelectionChangedEventArgs>? SelectionChanged;
+
+        public SelectionModel()
+        {
+            _rootNode = new SelectionNode(this, null);
+            SharedLeafNode = new SelectionNode(this, null);
+        }
+
+        public object? Source
+        {
+            get => _rootNode.Source;
+            set
+            {
+                if (_rootNode.Source != value)
+                {
+                    var raiseChanged = _rootNode.Source == null && SelectedIndices.Count > 0;
+
+                    if (_rootNode.Source != null)
+                    {
+                        if (_rootNode.Source != null)
+                        {
+                            using (var operation = new Operation(this))
+                            {
+                                ClearSelection(resetAnchor: true);
+                            }
+                        }
+                    }
+
+                    _rootNode.Source = value;
+                    ApplyAutoSelect();
+
+                    RaisePropertyChanged("Source");
+
+                    if (raiseChanged)
+                    {
+                        var e = new SelectionModelSelectionChangedEventArgs(
+                            null,
+                            SelectedIndices,
+                            null,
+                            SelectedItems);
+                        OnSelectionChanged(e);
+                    }
+                }
+            }
+        }
+
+        public bool SingleSelect
+        {
+            get => _singleSelect;
+            set
+            {
+                if (_singleSelect != value)
+                {
+                    _singleSelect = value;
+                    var selectedIndices = SelectedIndices;
+
+                    if (value && selectedIndices != null && selectedIndices.Count > 0)
+                    {
+                        using var operation = new Operation(this);
+
+                        // We want to be single select, so make sure there is only 
+                        // one selected item.
+                        var firstSelectionIndexPath = selectedIndices[0];
+                        ClearSelection(resetAnchor: true);
+                        SelectWithPathImpl(firstSelectionIndexPath, select: true);
+                        SelectedIndex = firstSelectionIndexPath;
+                    }
+
+                    RaisePropertyChanged("SingleSelect");
+                }
+            }
+        }
+
+        public bool RetainSelectionOnReset
+        {
+            get => _rootNode.RetainSelectionOnReset;
+            set => _rootNode.RetainSelectionOnReset = value;
+        }
+
+        public bool AutoSelect 
+        {
+            get => _autoSelect;
+            set
+            {
+                if (_autoSelect != value)
+                {
+                    _autoSelect = value;
+                    ApplyAutoSelect();
+                }
+            }
+        }
+
+        public IndexPath AnchorIndex
+        {
+            get
+            {
+                IndexPath anchor = default;
+
+                if (_rootNode.AnchorIndex >= 0)
+                {
+                    var path = new List<int>();
+                    SelectionNode? current = _rootNode;
+
+                    while (current?.AnchorIndex >= 0)
+                    {
+                        path.Add(current.AnchorIndex);
+                        current = current.GetAt(current.AnchorIndex, false);
+                    }
+
+                    anchor = new IndexPath(path);
+                }
+
+                return anchor;
+            }
+            set
+            {
+                if (value != null)
+                {
+                    SelectionTreeHelper.TraverseIndexPath(
+                        _rootNode,
+                        value,
+                        realizeChildren: true,
+                        (currentNode, path, depth, childIndex) => currentNode.AnchorIndex = path.GetAt(depth));
+                }
+                else
+                {
+                    _rootNode.AnchorIndex = -1;
+                }
+
+                RaisePropertyChanged("AnchorIndex");
+            }
+        }
+
+        public IndexPath SelectedIndex
+        {
+            get
+            {
+                IndexPath selectedIndex = default;
+                var selectedIndices = SelectedIndices;
+
+                if (selectedIndices?.Count > 0)
+                {
+                    selectedIndex = selectedIndices[0];
+                }
+
+                return selectedIndex;
+            }
+            set
+            {
+                var isSelected = IsSelectedWithPartialAt(value);
+
+                if (!IsSelectedAt(value) || SelectedItems.Count > 1)
+                {
+                    using var operation = new Operation(this);
+                    ClearSelection(resetAnchor: true);
+                    SelectWithPathImpl(value, select: true);
+                    ApplyAutoSelect();
+                }
+            }
+        }
+
+        public object? SelectedItem
+        {
+            get
+            {
+                object? item = null;
+                var selectedItems = SelectedItems;
+
+                if (selectedItems?.Count > 0)
+                {
+                    item = selectedItems[0];
+                }
+
+                return item;
+            }
+        }
+
+        public IReadOnlyList<object?> SelectedItems
+        {
+            get
+            {
+                if (_selectedItemsCached == null)
+                {
+                    var selectedInfos = new List<SelectedItemInfo>();
+                    var count = 0;
+
+                    if (_rootNode.Source != null)
+                    {
+                        SelectionTreeHelper.Traverse(
+                            _rootNode,
+                            realizeChildren: false,
+                            currentInfo =>
+                            {
+                                if (currentInfo.Node.SelectedCount > 0)
+                                {
+                                    selectedInfos.Add(new SelectedItemInfo(currentInfo.Node, currentInfo.Path));
+                                    count += currentInfo.Node.SelectedCount;
+                                }
+                            });
+                    }
+
+                    // Instead of creating a dumb vector that takes up the space for all the selected items,
+                    // we create a custom IReadOnlyList implementation that calls back using a delegate to find 
+                    // the selected item at a particular index. This avoid having to create the storage and copying
+                    // needed in a dumb vector. This also allows us to expose a tree of selected nodes into an 
+                    // easier to consume flat vector view of objects.
+                    var selectedItems = new SelectedItems<object?, SelectedItemInfo> (
+                        selectedInfos,
+                        count,
+                        (infos, index) =>
+                        {
+                            var currentIndex = 0;
+                            object? item = null;
+
+                            foreach (var info in infos)
+                            {
+                                var node = info.Node;
+
+                                if (node != null)
+                                {
+                                    var currentCount = node.SelectedCount;
+
+                                    if (index >= currentIndex && index < currentIndex + currentCount)
+                                    {
+                                        var targetIndex = node.SelectedIndices[index - currentIndex];
+                                        item = node.ItemsSourceView!.GetAt(targetIndex);
+                                        break;
+                                    }
+
+                                    currentIndex += currentCount;
+                                }
+                                else
+                                {
+                                    throw new InvalidOperationException(
+                                        "Selection has changed since SelectedItems property was read.");
+                                }
+                            }
+
+                            return item;
+                        });
+
+                    _selectedItemsCached = selectedItems;
+                }
+
+                return _selectedItemsCached;
+            }
+        }
+
+        public IReadOnlyList<IndexPath> SelectedIndices
+        {
+            get
+            {
+                if (_selectedIndicesCached == null)
+                {
+                    var selectedInfos = new List<SelectedItemInfo>();
+                    var count = 0;
+
+                    SelectionTreeHelper.Traverse(
+                        _rootNode,
+                        false,
+                        currentInfo =>
+                        {
+                            if (currentInfo.Node.SelectedCount > 0)
+                            {
+                                selectedInfos.Add(new SelectedItemInfo(currentInfo.Node, currentInfo.Path));
+                                count += currentInfo.Node.SelectedCount;
+                            }
+                        });
+
+                    // Instead of creating a dumb vector that takes up the space for all the selected indices,
+                    // we create a custom VectorView implimentation that calls back using a delegate to find 
+                    // the IndexPath at a particular index. This avoid having to create the storage and copying
+                    // needed in a dumb vector. This also allows us to expose a tree of selected nodes into an 
+                    // easier to consume flat vector view of IndexPaths.
+                    var indices = new SelectedItems<IndexPath, SelectedItemInfo>(
+                        selectedInfos,
+                        count,
+                        (infos, index) => // callback for GetAt(index)
+                        {
+                            var currentIndex = 0;
+                            IndexPath path = default;
+
+                            foreach (var info in infos)
+                            {
+                                var node = info.Node;
+
+                                if (node != null)
+                                {
+                                    var currentCount = node.SelectedCount;
+                                    if (index >= currentIndex && index < currentIndex + currentCount)
+                                    {
+                                        int targetIndex = node.SelectedIndices[index - currentIndex];
+                                        path = info.Path.CloneWithChildIndex(targetIndex);
+                                        break;
+                                    }
+
+                                    currentIndex += currentCount;
+                                }
+                                else
+                                {
+                                    throw new InvalidOperationException(
+                                        "Selection has changed since SelectedIndices property was read.");
+                                }
+                            }
+
+                            return path;
+                        });
+
+                    _selectedIndicesCached = indices;
+                }
+
+                return _selectedIndicesCached;
+            }
+        }
+
+        internal SelectionNode SharedLeafNode { get; private set; }
+
+        public void Dispose()
+        {
+            ClearSelection(resetAnchor: false);
+            _rootNode.Cleanup();
+            _rootNode.Dispose();
+            _selectedIndicesCached = null;
+            _selectedItemsCached = null;
+        }
+
+        public void SetAnchorIndex(int index) => AnchorIndex = new IndexPath(index);
+
+        public void SetAnchorIndex(int groupIndex, int index) => AnchorIndex = new IndexPath(groupIndex, index);
+
+        public void Select(int index)
+        {
+            using var operation = new Operation(this);
+            SelectImpl(index, select: true);
+        }
+
+        public void Select(int groupIndex, int itemIndex)
+        {
+            using var operation = new Operation(this);
+            SelectWithGroupImpl(groupIndex, itemIndex, select: true);
+        }
+
+        public void SelectAt(IndexPath index)
+        {
+            using var operation = new Operation(this);
+            SelectWithPathImpl(index, select: true);
+        }
+
+        public void Deselect(int index)
+        {
+            using var operation = new Operation(this);
+            SelectImpl(index, select: false);
+            ApplyAutoSelect();
+        }
+
+        public void Deselect(int groupIndex, int itemIndex)
+        {
+            using var operation = new Operation(this);
+            SelectWithGroupImpl(groupIndex, itemIndex, select: false);
+            ApplyAutoSelect();
+        }
+
+        public void DeselectAt(IndexPath index)
+        {
+            using var operation = new Operation(this);
+            SelectWithPathImpl(index, select: false);
+            ApplyAutoSelect();
+        }
+
+        public bool IsSelected(int index) => _rootNode.IsSelected(index);
+
+        public bool IsSelected(int grouIndex, int itemIndex)
+        {
+            return IsSelectedAt(new IndexPath(grouIndex, itemIndex));
+        }
+
+        public bool IsSelectedAt(IndexPath index)
+        {
+            var path = index;
+            SelectionNode? node = _rootNode;
+
+            for (int i = 0; i < path.GetSize() - 1; i++)
+            {
+                var childIndex = path.GetAt(i);
+                node = node.GetAt(childIndex, realizeChild: false);
+
+                if (node == null)
+                {
+                    return false;
+                }
+            }
+
+            return node.IsSelected(index.GetAt(index.GetSize() - 1));
+        }
+
+        public bool? IsSelectedWithPartial(int index)
+        {
+            if (index < 0)
+            {
+                throw new ArgumentException("Index must be >= 0", nameof(index));
+            }
+
+            var isSelected = _rootNode.IsSelectedWithPartial(index);
+            return isSelected;
+        }
+
+        public bool? IsSelectedWithPartial(int groupIndex, int itemIndex)
+        {
+            if (groupIndex < 0)
+            {
+                throw new ArgumentException("Group index must be >= 0", nameof(groupIndex));
+            }
+
+            if (itemIndex < 0)
+            {
+                throw new ArgumentException("Item index must be >= 0", nameof(itemIndex));
+            }
+
+            var isSelected = (bool?)false;
+            var childNode = _rootNode.GetAt(groupIndex, realizeChild: false);
+
+            if (childNode != null)
+            {
+                isSelected = childNode.IsSelectedWithPartial(itemIndex);
+            }
+
+            return isSelected;
+        }
+
+        public bool? IsSelectedWithPartialAt(IndexPath index)
+        {
+            var path = index;
+            var isRealized = true;
+            SelectionNode? node = _rootNode;
+
+            for (int i = 0; i < path.GetSize() - 1; i++)
+            {
+                var childIndex = path.GetAt(i);
+                node = node.GetAt(childIndex, realizeChild: false);
+
+                if (node == null)
+                {
+                    isRealized = false;
+                    break;
+                }
+            }
+
+            var isSelected = (bool?)false;
+
+            if (isRealized)
+            {
+                var size = path.GetSize();
+                if (size == 0)
+                {
+                    isSelected = SelectionNode.ConvertToNullableBool(node!.EvaluateIsSelectedBasedOnChildrenNodes());
+                }
+                else
+                {
+                    isSelected = node!.IsSelectedWithPartial(path.GetAt(size - 1));
+                }
+            }
+
+            return isSelected;
+        }
+
+        public void SelectRangeFromAnchor(int index)
+        {
+            using var operation = new Operation(this);
+            SelectRangeFromAnchorImpl(index, select: true);
+        }
+
+        public void SelectRangeFromAnchor(int endGroupIndex, int endItemIndex)
+        {
+            using var operation = new Operation(this);
+            SelectRangeFromAnchorWithGroupImpl(endGroupIndex, endItemIndex, select: true);
+        }
+
+        public void SelectRangeFromAnchorTo(IndexPath index)
+        {
+            using var operation = new Operation(this);
+            SelectRangeImpl(AnchorIndex, index, select: true);
+        }
+
+        public void DeselectRangeFromAnchor(int index)
+        {
+            using var operation = new Operation(this);
+            SelectRangeFromAnchorImpl(index, select: false);
+        }
+
+        public void DeselectRangeFromAnchor(int endGroupIndex, int endItemIndex)
+        {
+            using var operation = new Operation(this);
+            SelectRangeFromAnchorWithGroupImpl(endGroupIndex, endItemIndex, false /* select */);
+        }
+
+        public void DeselectRangeFromAnchorTo(IndexPath index)
+        {
+            using var operation = new Operation(this);
+            SelectRangeImpl(AnchorIndex, index, select: false);
+        }
+
+        public void SelectRange(IndexPath start, IndexPath end)
+        {
+            using var operation = new Operation(this);
+            SelectRangeImpl(start, end, select: true);
+        }
+
+        public void DeselectRange(IndexPath start, IndexPath end)
+        {
+            using var operation = new Operation(this);
+            SelectRangeImpl(start, end, select: false);
+        }
+
+        public void SelectAll()
+        {
+            using var operation = new Operation(this);
+
+            SelectionTreeHelper.Traverse(
+                _rootNode,
+                realizeChildren: true,
+                info =>
+                {
+                    if (info.Node.DataCount > 0)
+                    {
+                        info.Node.SelectAll();
+                    }
+                });
+        }
+
+        public void ClearSelection()
+        {
+            using var operation = new Operation(this);
+            ClearSelection(resetAnchor: true);
+            ApplyAutoSelect();
+        }
+
+        public IDisposable Update() => new Operation(this);
+
+        protected void OnPropertyChanged(string propertyName)
+        {
+            RaisePropertyChanged(propertyName);
+        }
+
+        private void RaisePropertyChanged(string propertyName)
+        {
+            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+        }
+
+        public void OnSelectionInvalidatedDueToCollectionChange(
+            bool selectionInvalidated,
+            IReadOnlyList<object?>? removedItems)
+        {
+            SelectionModelSelectionChangedEventArgs? e = null;
+
+            if (selectionInvalidated)
+            {
+                e = new SelectionModelSelectionChangedEventArgs(null, null, removedItems, null);
+            }
+
+            OnSelectionChanged(e);
+            ApplyAutoSelect();
+        }
+
+        internal IObservable<object?>? ResolvePath(object data, IndexPath dataIndexPath)
+        {
+            IObservable<object?>? resolved = null;
+
+            // Raise ChildrenRequested event if there is a handler
+            if (ChildrenRequested != null)
+            {
+                if (_childrenRequestedEventArgs == null)
+                {
+                    _childrenRequestedEventArgs = new SelectionModelChildrenRequestedEventArgs(data, dataIndexPath, false);
+                }
+                else
+                {
+                    _childrenRequestedEventArgs.Initialize(data, dataIndexPath, false);
+                }
+
+                ChildrenRequested(this, _childrenRequestedEventArgs);
+                resolved = _childrenRequestedEventArgs.Children;
+
+                // Clear out the values in the args so that it cannot be used after the event handler call.
+                _childrenRequestedEventArgs.Initialize(null, default, true);
+            }
+
+            return resolved;
+        }
+
+        private void ClearSelection(bool resetAnchor)
+        {
+            SelectionTreeHelper.Traverse(
+                _rootNode,
+                realizeChildren: false,
+                info => info.Node.Clear());
+
+            if (resetAnchor)
+            {
+                AnchorIndex = default;
+            }
+        }
+
+        private void OnSelectionChanged(SelectionModelSelectionChangedEventArgs? e = null)
+        {
+            _selectedIndicesCached = null;
+            _selectedItemsCached = null;
+
+            // Raise SelectionChanged event
+            if (e != null)
+            {
+                SelectionChanged?.Invoke(this, e);
+            }
+
+            RaisePropertyChanged(nameof(SelectedIndex));
+            RaisePropertyChanged(nameof(SelectedIndices));
+
+            if (_rootNode.Source != null)
+            {
+                RaisePropertyChanged(nameof(SelectedItem));
+                RaisePropertyChanged(nameof(SelectedItems));
+            }
+        }
+
+        private void SelectImpl(int index, bool select)
+        {
+            if (_singleSelect)
+            {
+                ClearSelection(resetAnchor: true);
+            }
+
+            var selected = _rootNode.Select(index, select);
+
+            if (selected)
+            {
+                AnchorIndex = new IndexPath(index);
+            }
+        }
+
+        private void SelectWithGroupImpl(int groupIndex, int itemIndex, bool select)
+        {
+            if (_singleSelect)
+            {
+                ClearSelection(resetAnchor: true);
+            }
+
+            var childNode = _rootNode.GetAt(groupIndex, realizeChild: true);
+            var selected = childNode!.Select(itemIndex, select);
+
+            if (selected)
+            {
+                AnchorIndex = new IndexPath(groupIndex, itemIndex);
+            }
+        }
+
+        private void SelectWithPathImpl(IndexPath index, bool select)
+        {
+            bool selected = false;
+
+            if (_singleSelect)
+            {
+                ClearSelection(resetAnchor: true);
+            }
+
+            SelectionTreeHelper.TraverseIndexPath(
+                _rootNode,
+                index,
+                true,
+                (currentNode, path, depth, childIndex) =>
+                {
+                    if (depth == path.GetSize() - 1)
+                    {
+                        selected = currentNode.Select(childIndex, select);
+                    }
+                }
+            );
+
+            if (selected)
+            {
+                AnchorIndex = index;
+            }
+        }
+
+        private void SelectRangeFromAnchorImpl(int index, bool select)
+        {
+            int anchorIndex = 0;
+            var anchor = AnchorIndex;
+
+            if (anchor != null)
+            {
+                anchorIndex = anchor.GetAt(0);
+            }
+
+            _rootNode.SelectRange(new IndexRange(anchorIndex, index), select);
+        }
+
+        private void SelectRangeFromAnchorWithGroupImpl(int endGroupIndex, int endItemIndex, bool select)
+        {
+            var startGroupIndex = 0;
+            var startItemIndex = 0;
+            var anchorIndex = AnchorIndex;
+
+            if (anchorIndex != null)
+            {
+                startGroupIndex = anchorIndex.GetAt(0);
+                startItemIndex = anchorIndex.GetAt(1);
+            }
+
+            // Make sure start > end
+            if (startGroupIndex > endGroupIndex ||
+                (startGroupIndex == endGroupIndex && startItemIndex > endItemIndex))
+            {
+                int temp = startGroupIndex;
+                startGroupIndex = endGroupIndex;
+                endGroupIndex = temp;
+                temp = startItemIndex;
+                startItemIndex = endItemIndex;
+                endItemIndex = temp;
+            }
+
+            for (int groupIdx = startGroupIndex; groupIdx <= endGroupIndex; groupIdx++)
+            {
+                var groupNode = _rootNode.GetAt(groupIdx, realizeChild: true)!;
+                int startIndex = groupIdx == startGroupIndex ? startItemIndex : 0;
+                int endIndex = groupIdx == endGroupIndex ? endItemIndex : groupNode.DataCount - 1;
+                groupNode.SelectRange(new IndexRange(startIndex, endIndex), select);
+            }
+        }
+
+        private void SelectRangeImpl(IndexPath start, IndexPath end, bool select)
+        {
+            var winrtStart = start;
+            var winrtEnd = end;
+
+            // Make sure start <= end 
+            if (winrtEnd.CompareTo(winrtStart) == -1)
+            {
+                var temp = winrtStart;
+                winrtStart = winrtEnd;
+                winrtEnd = temp;
+            }
+
+            // Note: Since we do not know the depth of the tree, we have to walk to each leaf
+            SelectionTreeHelper.TraverseRangeRealizeChildren(
+                _rootNode,
+                winrtStart,
+                winrtEnd,
+                info =>
+                {
+                    info.ParentNode!.Select(info.Path.GetAt(info.Path.GetSize() - 1), select);
+                });
+        }
+
+        private void BeginOperation()
+        {
+            if (_operationCount++ == 0)
+            {
+                _rootNode.BeginOperation();
+            }
+        }
+
+        private void EndOperation()
+        {
+            if (_operationCount == 0)
+            {
+                throw new AvaloniaInternalException("No selection operation in progress.");
+            }
+
+            SelectionModelSelectionChangedEventArgs? e = null;
+
+            if (--_operationCount == 0)
+            {
+                var changes = new List<SelectionNodeOperation>();
+                _rootNode.EndOperation(changes);
+
+                if (changes.Count > 0)
+                {
+                    var changeSet = new SelectionModelChangeSet(changes);
+                    e = changeSet.CreateEventArgs();
+                }
+            }
+
+            OnSelectionChanged(e);
+            _rootNode.Cleanup();
+        }
+
+        private void ApplyAutoSelect()
+        {
+            if (AutoSelect)
+            {
+                _selectedIndicesCached = null;
+
+                if (SelectedIndex == default && _rootNode.ItemsSourceView?.Count > 0)
+                {
+                    using var operation = new Operation(this);
+                    SelectImpl(0, true);
+                }
+            }
+        }
+
+        internal class SelectedItemInfo : ISelectedItemInfo
+        {
+            public SelectedItemInfo(SelectionNode node, IndexPath path)
+            {
+                Node = node;
+                Path = path;
+            }
+
+            public SelectionNode Node { get; }
+            public IndexPath Path { get; }
+            public int Count => Node.SelectedCount;
+        }
+
+        private struct Operation : IDisposable
+        {
+            private readonly SelectionModel _manager;
+            public Operation(SelectionModel manager) => (_manager = manager).BeginOperation();
+            public void Dispose() => _manager.EndOperation();
+        }
+    }
+}

+ 170 - 0
src/Avalonia.Controls/SelectionModelChangeSet.cs

@@ -0,0 +1,170 @@
+using System;
+using System.Collections.Generic;
+
+#nullable enable
+
+namespace Avalonia.Controls
+{
+    internal class SelectionModelChangeSet
+    {
+        private readonly List<SelectionNodeOperation> _changes;
+
+        public SelectionModelChangeSet(List<SelectionNodeOperation> changes)
+        {
+            _changes = changes;
+        }
+
+        public SelectionModelSelectionChangedEventArgs CreateEventArgs()
+        {
+            var deselectedIndexCount = 0;
+            var selectedIndexCount = 0;
+            var deselectedItemCount = 0;
+            var selectedItemCount = 0;
+
+            foreach (var change in _changes)
+            {
+                deselectedIndexCount += change.DeselectedCount;
+                selectedIndexCount += change.SelectedCount;
+
+                if (change.Items != null)
+                {
+                    deselectedItemCount += change.DeselectedCount;
+                    selectedItemCount += change.SelectedCount;
+                }
+            }
+
+            var deselectedIndices = new SelectedItems<IndexPath, SelectionNodeOperation>(
+                _changes,
+                deselectedIndexCount,
+                GetDeselectedIndexAt);
+            var selectedIndices = new SelectedItems<IndexPath, SelectionNodeOperation>(
+                _changes,
+                selectedIndexCount,
+                GetSelectedIndexAt);
+            var deselectedItems = new SelectedItems<object?, SelectionNodeOperation>(
+                _changes,
+                deselectedItemCount,
+                GetDeselectedItemAt);
+            var selectedItems = new SelectedItems<object?, SelectionNodeOperation>(
+                _changes,
+                selectedItemCount,
+                GetSelectedItemAt);
+
+            return new SelectionModelSelectionChangedEventArgs(
+                deselectedIndices,
+                selectedIndices,
+                deselectedItems,
+                selectedItems);
+        }
+
+        private IndexPath GetDeselectedIndexAt(
+            List<SelectionNodeOperation> infos,
+            int index)
+        {
+            static int GetCount(SelectionNodeOperation info) => info.DeselectedCount;
+            static List<IndexRange>? GetRanges(SelectionNodeOperation info) => info.DeselectedRanges;
+            return GetIndexAt(infos, index, x => GetCount(x), x => GetRanges(x));
+        }
+
+        private IndexPath GetSelectedIndexAt(
+            List<SelectionNodeOperation> infos,
+            int index)
+        {
+            static int GetCount(SelectionNodeOperation info) => info.SelectedCount;
+            static List<IndexRange>? GetRanges(SelectionNodeOperation info) => info.SelectedRanges;
+            return GetIndexAt(infos, index, x => GetCount(x), x => GetRanges(x));
+        }
+
+        private object? GetDeselectedItemAt(
+            List<SelectionNodeOperation> infos,
+            int index)
+        {
+            static int GetCount(SelectionNodeOperation info) => info.Items != null ? info.DeselectedCount : 0;
+            static List<IndexRange>? GetRanges(SelectionNodeOperation info) => info.DeselectedRanges;
+            return GetItemAt(infos, index, x => GetCount(x), x => GetRanges(x));
+        }
+
+        private object? GetSelectedItemAt(
+            List<SelectionNodeOperation> infos,
+            int index)
+        {
+            static int GetCount(SelectionNodeOperation info) => info.Items != null ? info.SelectedCount : 0;
+            static List<IndexRange>? GetRanges(SelectionNodeOperation info) => info.SelectedRanges;
+            return GetItemAt(infos, index, x => GetCount(x), x => GetRanges(x));
+        }
+
+        private IndexPath GetIndexAt(
+            List<SelectionNodeOperation> infos,
+            int index,
+            Func<SelectionNodeOperation, int> getCount,
+            Func<SelectionNodeOperation, List<IndexRange>?> getRanges)
+        {
+            var currentIndex = 0;
+            IndexPath path = default;
+
+            foreach (var info in infos)
+            {
+                var currentCount = getCount(info);
+
+                if (index >= currentIndex && index < currentIndex + currentCount)
+                {
+                    int targetIndex = GetIndexAt(getRanges(info), index - currentIndex);
+                    path = info.Path.CloneWithChildIndex(targetIndex);
+                    break;
+                }
+
+                currentIndex += currentCount;
+            }
+
+            return path;
+        }
+
+        private object? GetItemAt(
+            List<SelectionNodeOperation> infos,
+            int index,
+            Func<SelectionNodeOperation, int> getCount,
+            Func<SelectionNodeOperation, List<IndexRange>?> getRanges)
+        {
+            var currentIndex = 0;
+            object? item = null;
+
+            foreach (var info in infos)
+            {
+                var currentCount = getCount(info);
+
+                if (index >= currentIndex && index < currentIndex + currentCount)
+                {
+                    int targetIndex = GetIndexAt(getRanges(info), index - currentIndex);
+                    item = info.Items?.GetAt(targetIndex);
+                    break;
+                }
+
+                currentIndex += currentCount;
+            }
+
+            return item;
+        }
+
+        private int GetIndexAt(List<IndexRange>? ranges, int index)
+        {
+            var currentIndex = 0;
+
+            if (ranges != null)
+            {
+                foreach (var range in ranges)
+                {
+                    var currentCount = (range.End - range.Begin) + 1;
+
+                    if (index >= currentIndex && index < currentIndex + currentCount)
+                    {
+                        return range.Begin + (index - currentIndex);
+                    }
+
+                    currentIndex += currentCount;
+                }
+            }
+
+            throw new IndexOutOfRangeException();
+        }
+    }
+}

+ 83 - 0
src/Avalonia.Controls/SelectionModelChildrenRequestedEventArgs.cs

@@ -0,0 +1,83 @@
+// This source file is adapted from the WinUI project.
+// (https://github.com/microsoft/microsoft-ui-xaml)
+//
+// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation.
+
+using System;
+
+#nullable enable
+
+namespace Avalonia.Controls
+{
+    /// <summary>
+    /// Provides data for the <see cref="SelectionModel.ChildrenRequested"/> event.
+    /// </summary>
+    public class SelectionModelChildrenRequestedEventArgs : EventArgs
+    {
+        private object? _source;
+        private IndexPath _sourceIndexPath;
+        private bool _throwOnAccess;
+        
+        internal SelectionModelChildrenRequestedEventArgs(
+            object source,
+            IndexPath sourceIndexPath,
+            bool throwOnAccess)
+        {
+            source = source ?? throw new ArgumentNullException(nameof(source));
+            Initialize(source, sourceIndexPath, throwOnAccess);
+        }
+
+        /// <summary>
+        /// Gets or sets an observable which produces the children of the <see cref="Source"/>
+        /// object.
+        /// </summary>
+        public IObservable<object?>? Children { get; set; }
+
+        /// <summary>
+        /// Gets the object whose children are being requested.
+        /// </summary>        
+        public object Source
+        {
+            get
+            {
+                if (_throwOnAccess)
+                {
+                    throw new ObjectDisposedException(nameof(SelectionModelChildrenRequestedEventArgs));
+                }
+
+                return _source!;
+            }
+        }
+
+        /// <summary>
+        /// Gets the index of the object whose children are being requested.
+        /// </summary>        
+        public IndexPath SourceIndex
+        {
+            get
+            {
+                if (_throwOnAccess)
+                {
+                    throw new ObjectDisposedException(nameof(SelectionModelChildrenRequestedEventArgs));
+                }
+
+                return _sourceIndexPath;
+            }
+        }
+
+        internal void Initialize(
+            object? source,
+            IndexPath sourceIndexPath,
+            bool throwOnAccess)
+        {
+            if (!throwOnAccess && source == null)
+            {
+                throw new ArgumentNullException(nameof(source));
+            }
+
+            _source = source;
+            _sourceIndexPath = sourceIndexPath;
+            _throwOnAccess = throwOnAccess;
+        }
+    }
+}

+ 47 - 0
src/Avalonia.Controls/SelectionModelSelectionChangedEventArgs.cs

@@ -0,0 +1,47 @@
+// This source file is adapted from the WinUI project.
+// (https://github.com/microsoft/microsoft-ui-xaml)
+//
+// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation.
+
+using System;
+using System.Collections.Generic;
+
+#nullable enable
+
+namespace Avalonia.Controls
+{
+    public class SelectionModelSelectionChangedEventArgs : EventArgs
+    {
+        public SelectionModelSelectionChangedEventArgs(
+            IReadOnlyList<IndexPath>? deselectedIndices,
+            IReadOnlyList<IndexPath>? selectedIndices,
+            IReadOnlyList<object?>? deselectedItems,
+            IReadOnlyList<object?>? selectedItems)
+        {
+            DeselectedIndices = deselectedIndices ?? Array.Empty<IndexPath>();
+            SelectedIndices = selectedIndices ?? Array.Empty<IndexPath>();
+            DeselectedItems = deselectedItems ?? Array.Empty<object?>();
+            SelectedItems= selectedItems ?? Array.Empty<object?>();
+        }
+
+        /// <summary>
+        /// Gets the indices of the items that were removed from the selection.
+        /// </summary>
+        public IReadOnlyList<IndexPath> DeselectedIndices { get; }
+
+        /// <summary>
+        /// Gets the indices of the items that were added to the selection.
+        /// </summary>
+        public IReadOnlyList<IndexPath> SelectedIndices { get; }
+
+        /// <summary>
+        /// Gets the items that were removed from the selection.
+        /// </summary>
+        public IReadOnlyList<object?> DeselectedItems { get; }
+
+        /// <summary>
+        /// Gets the items that were added to the selection.
+        /// </summary>
+        public IReadOnlyList<object?> SelectedItems { get; }
+    }
+}

+ 966 - 0
src/Avalonia.Controls/SelectionNode.cs

@@ -0,0 +1,966 @@
+// This source file is adapted from the WinUI project.
+// (https://github.com/microsoft/microsoft-ui-xaml)
+//
+// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation.
+
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Collections.Specialized;
+using System.Linq;
+
+#nullable enable
+
+namespace Avalonia.Controls
+{
+    /// <summary>
+    /// Tracks nested selection.
+    /// </summary>
+    /// <remarks>
+    /// SelectionNode is the internal tree data structure that we keep track of for selection in 
+    /// a nested scenario. This would map to one ItemsSourceView/Collection. This node reacts to
+    /// collection changes and keeps the selected indices up to date. This can either be a leaf
+    /// node or a non leaf node.
+    /// </remarks>
+    internal class SelectionNode : IDisposable
+    {
+        private readonly SelectionModel _manager;
+        private readonly List<SelectionNode?> _childrenNodes = new List<SelectionNode?>();
+        private readonly SelectionNode? _parent;
+        private readonly List<IndexRange> _selected = new List<IndexRange>();
+        private readonly List<int> _selectedIndicesCached = new List<int>();
+        private IDisposable? _childrenSubscription;
+        private SelectionNodeOperation? _operation;
+        private object? _source;
+        private bool _selectedIndicesCacheIsValid;
+        private bool _retainSelectionOnReset;
+        private List<object?>? _selectedItems;
+
+        public SelectionNode(SelectionModel manager, SelectionNode? parent)
+        {
+            _manager = manager;
+            _parent = parent;
+        }
+
+        public int AnchorIndex { get; set; } = -1;
+
+        public bool RetainSelectionOnReset 
+        {
+            get => _retainSelectionOnReset;
+            set
+            {
+                if (_retainSelectionOnReset != value)
+                {
+                    _retainSelectionOnReset = value;
+
+                    if (_retainSelectionOnReset)
+                    {
+                        _selectedItems = new List<object?>();
+                        PopulateSelectedItemsFromSelectedIndices();
+                    }
+                    else
+                    {
+                        _selectedItems = null;
+                    }
+
+                    foreach (var child in _childrenNodes)
+                    {
+                        if (child != null)
+                        {
+                            child.RetainSelectionOnReset = value;
+                        }
+                    }
+                }
+            }
+        }
+
+        public object? Source
+        {
+            get => _source;
+            set
+            {
+                if (_source != value)
+                {
+                    if (_source != null)
+                    {
+                        ClearSelection();
+                        ClearChildNodes();
+                        UnhookCollectionChangedHandler();
+                    }
+
+                    _source = value;
+
+                    // Setup ItemsSourceView
+                    var newDataSource = value as ItemsSourceView;
+
+                    if (value != null && newDataSource == null)
+                    {
+                        newDataSource = new ItemsSourceView((IEnumerable)value);
+                    }
+
+                    ItemsSourceView = newDataSource;
+
+                    PopulateSelectedItemsFromSelectedIndices();
+                    HookupCollectionChangedHandler();
+                    OnSelectionChanged();
+                }
+            }
+        }
+
+        public ItemsSourceView? ItemsSourceView { get; private set; }
+        public int DataCount => ItemsSourceView?.Count ?? 0;
+        public int ChildrenNodeCount => _childrenNodes.Count;
+        public int RealizedChildrenNodeCount { get; private set; }
+
+        public IndexPath IndexPath
+        {
+            get
+            {
+                var path = new List<int>(); ;
+                var parent = _parent;
+                var child = this;
+                
+                while (parent != null)
+                {
+                    var childNodes = parent._childrenNodes;
+                    var index = childNodes.IndexOf(child);
+
+                    // We are walking up to the parent, so the path will be backwards
+                    path.Insert(0, index);
+                    child = parent;
+                    parent = parent._parent;
+                }
+
+                return new IndexPath(path);
+            }
+        }
+
+        // For a genuine tree view, we dont know which node is leaf until we 
+        // actually walk to it, so currently the tree builds up to the leaf. I don't 
+        // create a bunch of leaf node instances - instead i use the same instance m_leafNode to avoid 
+        // an explosion of node objects. However, I'm still creating the m_childrenNodes 
+        // collection unfortunately.
+        public SelectionNode? GetAt(int index, bool realizeChild)
+        {
+            SelectionNode? child = null;
+            
+            if (realizeChild)
+            {
+                if (ItemsSourceView == null || index < 0 || index >= ItemsSourceView.Count)
+                {
+                    throw new IndexOutOfRangeException();
+                }
+
+                if (_childrenNodes.Count == 0)
+                {
+                    if (ItemsSourceView != null)
+                    {
+                        for (int i = 0; i < ItemsSourceView.Count; i++)
+                        {
+                            _childrenNodes.Add(null);
+                        }
+                    }
+                }
+
+                if (_childrenNodes[index] == null)
+                {
+                    var childData = ItemsSourceView!.GetAt(index);
+                    IObservable<object?>? resolver = null;
+                    
+                    if (childData != null)
+                    {
+                        var childDataIndexPath = IndexPath.CloneWithChildIndex(index);
+                        resolver = _manager.ResolvePath(childData, childDataIndexPath);
+                    }
+
+                    if (resolver != null)
+                    {
+                        child = new SelectionNode(_manager, parent: this);
+                        child.SetChildrenObservable(resolver);
+                    }
+                    else if (childData is IEnumerable<object> || childData is IList)
+                    {
+                        child = new SelectionNode(_manager, parent: this);
+                        child.Source = childData;
+                    }
+                    else
+                    { 
+                        child = _manager.SharedLeafNode;
+                    }
+
+                    if (_operation != null && child != _manager.SharedLeafNode)
+                    {
+                        child.BeginOperation();
+                    }
+
+                    _childrenNodes[index] = child;
+                    RealizedChildrenNodeCount++;
+                }
+                else
+                {
+                    child = _childrenNodes[index];
+                }
+            }
+            else
+            {
+                if (_childrenNodes.Count > 0)
+                {
+                    child = _childrenNodes[index];
+                }
+            }
+
+            return child;
+        }
+
+        public void SetChildrenObservable(IObservable<object?> resolver)
+        {
+            _childrenSubscription = resolver.Subscribe(x => Source = x);
+        }
+
+        public int SelectedCount { get; private set; }
+
+        public bool IsSelected(int index)
+        {
+            var isSelected = false;
+
+            foreach (var range in _selected)
+            {
+                if (range.Contains(index))
+                {
+                    isSelected = true;
+                    break;
+                }
+            }
+
+            return isSelected;
+        }
+
+        // True  -> Selected
+        // False -> Not Selected
+        // Null  -> Some descendents are selected and some are not
+        public bool? IsSelectedWithPartial()
+        {
+            var isSelected = (bool?)false;
+
+            if (_parent != null)
+            {
+                var parentsChildren = _parent._childrenNodes;
+
+                var myIndexInParent = parentsChildren.IndexOf(this);
+                
+                if (myIndexInParent != -1)
+                {
+                    isSelected = _parent.IsSelectedWithPartial(myIndexInParent);
+                }
+            }
+
+            return isSelected;
+        }
+
+        // True  -> Selected
+        // False -> Not Selected
+        // Null  -> Some descendents are selected and some are not
+        public bool? IsSelectedWithPartial(int index)
+        {
+            SelectionState selectionState;
+
+            if (_childrenNodes.Count == 0 || // no nodes realized
+                _childrenNodes.Count <= index || // target node is not realized 
+                _childrenNodes[index] == null || // target node is not realized
+                _childrenNodes[index] == _manager.SharedLeafNode)  // target node is a leaf node.
+            {
+                // Ask parent if the target node is selected.
+                selectionState = IsSelected(index) ? SelectionState.Selected : SelectionState.NotSelected;
+            }
+            else
+            {
+                // targetNode is the node representing the index. This node is the parent. 
+                // targetNode is a non-leaf node, containing one or many children nodes. Evaluate 
+                // based on children of targetNode.
+                var targetNode = _childrenNodes[index];
+                selectionState = targetNode!.EvaluateIsSelectedBasedOnChildrenNodes();
+            }
+
+            return ConvertToNullableBool(selectionState);
+        }
+
+        public int SelectedIndex
+        {
+            get => SelectedCount > 0 ? SelectedIndices[0] : -1;
+            set
+            {
+                if (IsValidIndex(value) && (SelectedCount != 1 || !IsSelected(value)))
+                {
+                    ClearSelection();
+
+                    if (value != -1)
+                    {
+                        Select(value, true);
+                    }
+                }
+            }
+        }
+
+        public List<int> SelectedIndices
+        {
+            get
+            {
+                if (!_selectedIndicesCacheIsValid)
+                {
+                    _selectedIndicesCacheIsValid = true;
+                    
+                    foreach (var range in _selected)
+                    {
+                        for (int index = range.Begin; index <= range.End; index++)
+                        {
+                            // Avoid duplicates
+                            if (!_selectedIndicesCached.Contains(index))
+                            {
+                                _selectedIndicesCached.Add(index);
+                            }
+                        }
+                    }
+
+                    // Sort the list for easy consumption
+                    _selectedIndicesCached.Sort();
+                }
+
+                return _selectedIndicesCached;
+            }
+        }
+
+        public IEnumerable<object> SelectedItems
+        {
+            get => SelectedIndices.Select(x => ItemsSourceView!.GetAt(x));
+        }
+
+        public void Dispose()
+        {
+            _childrenSubscription?.Dispose();
+            ItemsSourceView?.Dispose();
+            ClearChildNodes();
+            UnhookCollectionChangedHandler();
+        }
+
+        public void BeginOperation()
+        {
+            if (_operation != null)
+            {
+                throw new AvaloniaInternalException("Selection operation already in progress.");
+            }
+
+            _operation = new SelectionNodeOperation(this);
+
+            for (var i = 0; i < _childrenNodes.Count; ++i)
+            {
+                var child = _childrenNodes[i];
+
+                if (child != null && child != _manager.SharedLeafNode)
+                {
+                    child.BeginOperation();
+                }
+            }
+        }
+
+        public void EndOperation(List<SelectionNodeOperation> changes)
+        {
+            if (_operation == null)
+            {
+                throw new AvaloniaInternalException("No selection operation in progress.");
+            }
+
+            if (_operation.HasChanges)
+            {
+                changes.Add(_operation);
+            }
+
+            _operation = null;
+
+            for (var i = 0; i < _childrenNodes.Count; ++i)
+            {
+                var child = _childrenNodes[i];
+
+                if (child != null && child != _manager.SharedLeafNode)
+                {
+                    child.EndOperation(changes);
+                }
+            }
+        }
+
+        public bool Cleanup()
+        {
+            var result = SelectedCount == 0;
+
+            for (var i = 0; i < _childrenNodes.Count; ++i)
+            {
+                var child = _childrenNodes[i];
+
+                if (child != null)
+                {
+                    if (child.Cleanup())
+                    {
+                        child.Dispose();
+                        _childrenNodes[i] = null;
+                    }
+                    else
+                    {
+                        result = false;
+                    }
+                }
+            }
+
+            return result;
+        }
+
+        public bool Select(int index, bool select)
+        {
+            return Select(index, select, raiseOnSelectionChanged: true);
+        }
+
+        public bool ToggleSelect(int index)
+        {
+            return Select(index, !IsSelected(index));
+        }
+
+        public void SelectAll()
+        {
+            if (ItemsSourceView != null)
+            {
+                var size = ItemsSourceView.Count;
+                
+                if (size > 0)
+                {
+                    SelectRange(new IndexRange(0, size - 1), select: true);
+                }
+            }
+        }
+
+        public void Clear() => ClearSelection();
+
+        public bool SelectRange(IndexRange range, bool select)
+        {
+            if (IsValidIndex(range.Begin) && IsValidIndex(range.End))
+            {
+                if (select)
+                {
+                    AddRange(range, raiseOnSelectionChanged: true);
+                }
+                else
+                {
+                    RemoveRange(range, raiseOnSelectionChanged: true);
+                }
+
+                return true;
+            }
+
+            return false;
+        }
+
+        private void HookupCollectionChangedHandler()
+        {
+            if (ItemsSourceView != null)
+            {
+                ItemsSourceView.CollectionChanged += OnSourceListChanged;
+            }
+        }
+
+        private void UnhookCollectionChangedHandler()
+        {
+            if (ItemsSourceView != null)
+            {
+                ItemsSourceView.CollectionChanged -= OnSourceListChanged;
+            }
+        }
+
+        private bool IsValidIndex(int index)
+        {
+            return ItemsSourceView == null || (index >= 0 && index < ItemsSourceView.Count);
+        }
+
+        private void AddRange(IndexRange addRange, bool raiseOnSelectionChanged)
+        {
+            var selected = new List<IndexRange>();
+
+            SelectedCount += IndexRange.Add(_selected, addRange, selected);
+
+            if (selected.Count > 0)
+            {
+                _operation?.Selected(selected);
+
+                if (_selectedItems != null && ItemsSourceView != null)
+                {
+                    for (var i = addRange.Begin; i <= addRange.End; ++i)
+                    {
+                        _selectedItems.Add(ItemsSourceView!.GetAt(i));
+                    }
+                }
+
+                if (raiseOnSelectionChanged)
+                {
+                    OnSelectionChanged();
+                }
+            }
+        }
+
+        private void RemoveRange(IndexRange removeRange, bool raiseOnSelectionChanged)
+        {
+            var removed = new List<IndexRange>();
+
+            SelectedCount -= IndexRange.Remove(_selected, removeRange, removed);
+
+            if (removed.Count > 0)
+            {
+                _operation?.Deselected(removed);
+
+                if (_selectedItems != null)
+                {
+                    for (var i = removeRange.Begin; i <= removeRange.End; ++i)
+                    {
+                        _selectedItems.Remove(ItemsSourceView!.GetAt(i));
+                    }
+                }
+
+                if (raiseOnSelectionChanged)
+                {
+                    OnSelectionChanged();
+                }
+            }
+        }
+
+        private void ClearSelection()
+        {
+            // Deselect all items
+            if (_selected.Count > 0)
+            {
+                _operation?.Deselected(_selected);
+                _selected.Clear();
+                OnSelectionChanged();
+            }
+
+            _selectedItems?.Clear();
+            SelectedCount = 0;
+            AnchorIndex = -1;
+        }
+
+        private void ClearChildNodes()
+        {
+            foreach (var child in _childrenNodes)
+            {
+                if (child != null && child != _manager.SharedLeafNode)
+                {
+                    child.Dispose();
+                }
+            }
+
+            RealizedChildrenNodeCount = 0;
+        }
+
+        private bool Select(int index, bool select, bool raiseOnSelectionChanged)
+        {
+            if (IsValidIndex(index))
+            {
+                // Ignore duplicate selection calls
+                if (IsSelected(index) == select)
+                {
+                    return true;
+                }
+
+                var range = new IndexRange(index, index);
+
+                if (select)
+                {
+                    AddRange(range, raiseOnSelectionChanged);
+                }
+                else
+                {
+                    RemoveRange(range, raiseOnSelectionChanged);
+                }
+
+                return true;
+            }
+
+            return false;
+        }
+
+        private void OnSourceListChanged(object dataSource, NotifyCollectionChangedEventArgs args)
+        {
+            bool selectionInvalidated = false;
+            List<object?>? removed = null;
+
+            switch (args.Action)
+            {
+                case NotifyCollectionChangedAction.Add:
+                {
+                    selectionInvalidated = OnItemsAdded(args.NewStartingIndex, args.NewItems.Count);
+                    break;
+                }
+
+                case NotifyCollectionChangedAction.Remove:
+                {
+                    (selectionInvalidated, removed) = OnItemsRemoved(args.OldStartingIndex, args.OldItems);
+                    break;
+                }
+
+                case NotifyCollectionChangedAction.Reset:
+                    {
+                        if (_selectedItems == null)
+                        {
+                            ClearSelection();
+                        }
+                        else
+                        {
+                            removed = RecreateSelectionFromSelectedItems();
+                        }
+
+                        selectionInvalidated = true;
+                        break;
+                    }
+
+                case NotifyCollectionChangedAction.Replace:
+                {
+                    (selectionInvalidated, removed) = OnItemsRemoved(args.OldStartingIndex, args.OldItems);
+                    selectionInvalidated |= OnItemsAdded(args.NewStartingIndex, args.NewItems.Count);
+                    break;
+                }
+            }
+
+            if (selectionInvalidated)
+            {
+                OnSelectionChanged();
+            }
+
+            _manager.OnSelectionInvalidatedDueToCollectionChange(selectionInvalidated, removed);
+        }
+
+        private bool OnItemsAdded(int index, int count)
+        {
+            var selectionInvalidated = false;
+            
+            // Update ranges for leaf items
+            var toAdd = new List<IndexRange>();
+
+            for (int i = 0; i < _selected.Count; i++)
+            {
+                var range = _selected[i];
+
+                // The range is after the inserted items, need to shift the range right
+                if (range.End >= index)
+                {
+                    int begin = range.Begin;
+                    
+                    // If the index left of newIndex is inside the range,
+                    // Split the range and remember the left piece to add later
+                    if (range.Contains(index - 1))
+                    {
+                        range.Split(index - 1, out var before, out _);
+                        toAdd.Add(before);
+                        begin = index;
+                    }
+
+                    // Shift the range to the right
+                    _selected[i] = new IndexRange(begin + count, range.End + count);
+                    selectionInvalidated = true;
+                }
+            }
+
+            // Add the left sides of the split ranges
+            _selected.AddRange(toAdd);
+
+            // Update for non-leaf if we are tracking non-leaf nodes
+            if (_childrenNodes.Count > 0)
+            {
+                selectionInvalidated = true;
+                for (int i = 0; i < count; i++)
+                {
+                    _childrenNodes.Insert(index, null);
+                }
+            }
+
+            // Adjust the anchor
+            if (AnchorIndex >= index)
+            {
+                AnchorIndex += count;
+            }
+
+            // Check if adding a node invalidated an ancestors
+            // selection state. For example if parent was selected before
+            // adding a new item makes the parent partially selected now.
+            if (!selectionInvalidated)
+            {
+                var parent = _parent;
+                
+                while (parent != null)
+                {
+                    var isSelected = parent.IsSelectedWithPartial();
+                    
+                    // If a parent is selected, then it will become partially selected.
+                    // If it is not selected or partially selected - there is no change.
+                    if (isSelected == true)
+                    {
+                        selectionInvalidated = true;
+                        break;
+                    }
+
+                    parent = parent._parent;
+                }
+            }
+
+            return selectionInvalidated;
+        }
+
+        private (bool, List<object?>) OnItemsRemoved(int index, IList items)
+        {
+            var selectionInvalidated = false;
+            var removed = new List<object?>();
+            var count = items.Count;
+            
+            // Remove the items from the selection for leaf
+            if (ItemsSourceView!.Count > 0)
+            {
+                bool isSelected = false;
+
+                for (int i = 0; i <= count - 1; i++)
+                {
+                    if (IsSelected(index + i))
+                    {
+                        isSelected = true;
+                        removed.Add(items[i]);
+                    }
+                }
+
+                if (isSelected)
+                {
+                    var removeRange = new IndexRange(index, index + count - 1);
+                    SelectedCount -= IndexRange.Remove(_selected, removeRange);
+                    selectionInvalidated = true;
+
+                    if (_selectedItems != null)
+                    {
+                        foreach (var i in items)
+                        {
+                            _selectedItems.Remove(i);
+                        }
+                    }
+                }
+
+                for (int i = 0; i < _selected.Count; i++)
+                {
+                    var range = _selected[i];
+
+                    // The range is after the removed items, need to shift the range left
+                    if (range.End > index)
+                    {
+                        // Shift the range to the left
+                        _selected[i] = new IndexRange(range.Begin - count, range.End - count);
+                        selectionInvalidated = true;
+                    }
+                }
+
+                // Update for non-leaf if we are tracking non-leaf nodes
+                if (_childrenNodes.Count > 0)
+                {
+                    selectionInvalidated = true;
+                    for (int i = 0; i < count; i++)
+                    {
+                        if (_childrenNodes[index] != null)
+                        {
+                            removed.AddRange(_childrenNodes[index]!.SelectedItems);
+                            RealizedChildrenNodeCount--;
+                            _childrenNodes[index]!.Dispose();
+                        }
+                        _childrenNodes.RemoveAt(index);
+                    }
+                }
+
+                //Adjust the anchor
+                if (AnchorIndex >= index)
+                {
+                    AnchorIndex -= count;
+                }
+            }
+            else
+            {
+                // No more items in the list, clear
+                ClearSelection();
+                RealizedChildrenNodeCount = 0;
+                selectionInvalidated = true;
+            }
+
+            // Check if removing a node invalidated an ancestors
+            // selection state. For example if parent was partially selected before
+            // removing an item, it could be selected now.
+            if (!selectionInvalidated)
+            {
+                var parent = _parent;
+                
+                while (parent != null)
+                {
+                    var isSelected = parent.IsSelectedWithPartial();
+                    // If a parent is partially selected, then it will become selected.
+                    // If it is selected or not selected - there is no change.
+                    if (!isSelected.HasValue)
+                    {
+                        selectionInvalidated = true;
+                        break;
+                    }
+
+                    parent = parent._parent;
+                }
+            }
+
+            return (selectionInvalidated, removed);
+        }
+
+        private void OnSelectionChanged()
+        {
+            _selectedIndicesCacheIsValid = false;
+            _selectedIndicesCached.Clear();
+        }
+
+        public static bool? ConvertToNullableBool(SelectionState isSelected)
+        {
+            bool? result = null; // PartialySelected
+
+            if (isSelected == SelectionState.Selected)
+            {
+                result = true;
+            }
+            else if (isSelected == SelectionState.NotSelected)
+            {
+                result = false;
+            }
+
+            return result;
+        }
+
+        public SelectionState EvaluateIsSelectedBasedOnChildrenNodes()
+        {
+            var selectionState = SelectionState.NotSelected;
+            int realizedChildrenNodeCount = RealizedChildrenNodeCount;
+            int selectedCount = SelectedCount;
+
+            if (realizedChildrenNodeCount != 0 || selectedCount != 0)
+            {
+                // There are realized children or some selected leaves.
+                int dataCount = DataCount;
+                if (realizedChildrenNodeCount == 0 && selectedCount > 0)
+                {
+                    // All nodes are leaves under it - we didn't create children nodes as an optimization.
+                    // See if all/some or none of the leaves are selected.
+                    selectionState = dataCount != selectedCount ?
+                        SelectionState.PartiallySelected :
+                        dataCount == selectedCount ? SelectionState.Selected : SelectionState.NotSelected;
+                }
+                else
+                {
+                    // There are child nodes, walk them individually and evaluate based on each child
+                    // being selected/not selected or partially selected.
+                    selectedCount = 0;
+                    int notSelectedCount = 0;
+                    for (int i = 0; i < ChildrenNodeCount; i++)
+                    {
+                        var child = GetAt(i, realizeChild: false);
+
+                        if (child != null)
+                        {
+                            // child is realized, ask it.
+                            var isChildSelected = IsSelectedWithPartial(i);
+                            if (isChildSelected == null)
+                            {
+                                selectionState = SelectionState.PartiallySelected;
+                                break;
+                            }
+                            else if (isChildSelected == true)
+                            {
+                                selectedCount++;
+                            }
+                            else
+                            {
+                                notSelectedCount++;
+                            }
+                        }
+                        else
+                        {
+                            // not realized.
+                            if (IsSelected(i))
+                            {
+                                selectedCount++;
+                            }
+                            else
+                            {
+                                notSelectedCount++;
+                            }
+                        }
+
+                        if (selectedCount > 0 && notSelectedCount > 0)
+                        {
+                            selectionState = SelectionState.PartiallySelected;
+                            break;
+                        }
+                    }
+
+                    if (selectionState != SelectionState.PartiallySelected)
+                    {
+                        if (selectedCount != 0 && selectedCount != dataCount)
+                        {
+                            selectionState = SelectionState.PartiallySelected;
+                        }
+                        else
+                        {
+                            selectionState = selectedCount == dataCount ? SelectionState.Selected : SelectionState.NotSelected;
+                        }
+                    }
+                }
+            }
+
+            return selectionState;
+        }
+
+        private void PopulateSelectedItemsFromSelectedIndices()
+        {
+            if (_selectedItems != null)
+            {
+                _selectedItems.Clear();
+
+                foreach (var i in SelectedIndices)
+                {
+                    _selectedItems.Add(ItemsSourceView!.GetAt(i));
+                }
+            }
+        }
+
+        private List<object?> RecreateSelectionFromSelectedItems()
+        {
+            var removed = new List<object?>();
+
+            _selected.Clear();
+            SelectedCount = 0;
+
+            for (var i = 0; i < _selectedItems!.Count; ++i)
+            {
+                var item = _selectedItems[i];
+                var index = ItemsSourceView!.IndexOf(item);
+
+                if (index != -1)
+                {
+                    IndexRange.Add(_selected, new IndexRange(index, index));
+                    ++SelectedCount;
+                }
+                else
+                {
+                    removed.Add(item);
+                    _selectedItems.RemoveAt(i--);
+                }
+            }
+
+            return removed;
+        }
+
+        public enum SelectionState
+        {
+            Selected,
+            NotSelected,
+            PartiallySelected
+        }
+    }
+}

+ 110 - 0
src/Avalonia.Controls/SelectionNodeOperation.cs

@@ -0,0 +1,110 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+#nullable enable
+
+namespace Avalonia.Controls
+{
+    internal class SelectionNodeOperation : ISelectedItemInfo
+    {
+        private readonly SelectionNode _owner;
+        private List<IndexRange>? _selected;
+        private List<IndexRange>? _deselected;
+        private int _selectedCount = -1;
+        private int _deselectedCount = -1;
+
+        public SelectionNodeOperation(SelectionNode owner)
+        {
+            _owner = owner;
+        }
+
+        public bool HasChanges => _selected?.Count > 0 || _deselected?.Count > 0;
+        public List<IndexRange>? SelectedRanges => _selected;
+        public List<IndexRange>? DeselectedRanges => _deselected;
+        public IndexPath Path => _owner.IndexPath;
+        public ItemsSourceView? Items => _owner.ItemsSourceView;
+
+        public int SelectedCount
+        {
+            get
+            {
+                if (_selectedCount == -1)
+                {
+                    _selectedCount = (_selected != null) ? IndexRange.GetCount(_selected) : 0;
+                }
+
+                return _selectedCount;
+            }
+        }
+
+        public int DeselectedCount
+        {
+            get
+            {
+                if (_deselectedCount == -1)
+                {
+                    _deselectedCount = (_deselected != null) ? IndexRange.GetCount(_deselected) : 0;
+                }
+
+                return _deselectedCount;
+            }
+        }
+
+        public void Selected(IndexRange range)
+        {
+            Add(range, ref _selected, _deselected);
+            _selectedCount = -1;
+        }
+
+        public void Selected(IEnumerable<IndexRange> ranges)
+        {
+            foreach (var range in ranges)
+            {
+                Selected(range);
+            }
+        }
+
+        public void Deselected(IndexRange range)
+        {
+            Add(range, ref _deselected, _selected);
+            _deselectedCount = -1;
+        }
+
+        public void Deselected(IEnumerable<IndexRange> ranges)
+        {
+            foreach (var range in ranges)
+            {
+                Deselected(range);
+            }
+        }
+
+        private static void Add(
+            IndexRange range,
+            ref List<IndexRange>? add,
+            List<IndexRange>? remove)
+        {
+            if (remove != null)
+            {
+                var removed = new List<IndexRange>();
+                IndexRange.Remove(remove, range, removed);
+                var selected = IndexRange.Subtract(range, removed);
+
+                if (selected.Any())
+                {
+                    add ??= new List<IndexRange>();
+
+                    foreach (var r in selected)
+                    {
+                        IndexRange.Add(add, r);
+                    }
+                }
+            }
+            else
+            {
+                add ??= new List<IndexRange>();
+                IndexRange.Add(add, range);
+            }
+        }
+    }
+}

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

@@ -240,7 +240,7 @@ namespace Avalonia.Controls
         {
             base.OnPointerPressed(e);
 
-            if (e.MouseButton == MouseButton.Left && e.Pointer.Type == PointerType.Mouse)
+            if (e.GetCurrentPoint(this).Properties.IsLeftButtonPressed && e.Pointer.Type == PointerType.Mouse)
             {
                 e.Handled = UpdateSelectionFromEventSource(e.Source);
             }

+ 297 - 366
src/Avalonia.Controls/TreeView.cs

@@ -2,11 +2,12 @@
 using System;
 using System.Collections;
 using System.Collections.Generic;
-using System.Collections.Specialized;
+using System.ComponentModel;
 using System.Linq;
-using Avalonia.Collections;
 using Avalonia.Controls.Generators;
 using Avalonia.Controls.Primitives;
+using Avalonia.Controls.Utils;
+using Avalonia.Data;
 using Avalonia.Input;
 using Avalonia.Input.Platform;
 using Avalonia.Interactivity;
@@ -42,15 +43,29 @@ namespace Avalonia.Controls
                 o => o.SelectedItems,
                 (o, v) => o.SelectedItems = v);
 
+        /// <summary>
+        /// Defines the <see cref="Selection"/> property.
+        /// </summary>
+        public static readonly DirectProperty<TreeView, ISelectionModel> SelectionProperty =
+            SelectingItemsControl.SelectionProperty.AddOwner<TreeView>(
+                o => o.Selection,
+                (o, v) => o.Selection = v);
+
         /// <summary>
         /// Defines the <see cref="SelectionMode"/> property.
         /// </summary>
         public static readonly StyledProperty<SelectionMode> SelectionModeProperty =
             ListBox.SelectionModeProperty.AddOwner<TreeView>();
 
-        private static readonly IList Empty = Array.Empty<object>();
+        /// <summary>
+        /// Defines the <see cref="SelectionChanged"/> property.
+        /// </summary>
+        public static RoutedEvent<SelectionChangedEventArgs> SelectionChangedEvent =
+            SelectingItemsControl.SelectionChangedEvent;
+
         private object _selectedItem;
-        private IList _selectedItems;
+        private ISelectionModel _selection;
+        private readonly SelectedItemsSync _selectedItems;
 
         /// <summary>
         /// Initializes static members of the <see cref="TreeView"/> class.
@@ -60,6 +75,13 @@ namespace Avalonia.Controls
             // HACK: Needed or SelectedItem property will not be found in Release build.
         }
 
+        public TreeView()
+        {
+            // Setting Selection to null causes a default SelectionModel to be created.
+            Selection = null;
+            _selectedItems = new SelectedItemsSync(Selection);
+        }
+
         /// <summary>
         /// Occurs when the control's selection changes.
         /// </summary>
@@ -84,8 +106,6 @@ namespace Avalonia.Controls
             set => SetValue(AutoScrollToSelectedItemProperty, value);
         }
 
-        private bool _syncingSelectedItems;
-
         /// <summary>
         /// Gets or sets the selection mode.
         /// </summary>
@@ -95,61 +115,102 @@ namespace Avalonia.Controls
             set => SetValue(SelectionModeProperty, value);
         }
 
+        /// <summary>
+        /// Gets or sets the selected item.
+        /// </summary>
         /// <summary>
         /// Gets or sets the selected item.
         /// </summary>
         public object SelectedItem
         {
-            get => _selectedItem;
-            set
-            {
-                var selectedItems = SelectedItems;
-
-                SetAndRaise(SelectedItemProperty, ref _selectedItem, value);
+            get => Selection.SelectedItem;
+            set => Selection.SelectedIndex = IndexFromItem(value);
+        }
 
-                if (value != null)
-                {
-                    if (selectedItems.Count != 1 || selectedItems[0] != value)
-                    {
-                        _syncingSelectedItems = true;
-                        SelectSingleItem(value);
-                        _syncingSelectedItems = false;
-                    }
-                }
-                else if (SelectedItems.Count > 0)
-                {
-                    SelectedItems.Clear();
-                }
-            }
+        /// <summary>
+        /// Gets or sets the selected items.
+        /// </summary>
+        protected IList SelectedItems
+        {
+            get => _selectedItems.GetOrCreateItems();
+            set => _selectedItems.SetItems(value);
         }
 
         /// <summary>
-        /// Gets the selected items.
+        /// Gets or sets a model holding the current selection.
         /// </summary>
-        public IList SelectedItems
+        public ISelectionModel Selection
         {
-            get
+            get => _selection;
+            set
             {
-                if (_selectedItems == null)
+                value ??= new SelectionModel
                 {
-                    _selectedItems = new AvaloniaList<object>();
-                    SubscribeToSelectedItems();
-                }
-
-                return _selectedItems;
-            }
+                    SingleSelect = !SelectionMode.HasFlagCustom(SelectionMode.Multiple),
+                    AutoSelect = SelectionMode.HasFlagCustom(SelectionMode.AlwaysSelected),
+                    RetainSelectionOnReset = true,
+                };
 
-            set
-            {
-                if (value?.IsFixedSize == true || value?.IsReadOnly == true)
+                if (_selection != value)
                 {
-                    throw new NotSupportedException(
-                        "Cannot use a fixed size or read-only collection as SelectedItems.");
-                }
+                    if (value == null)
+                    {
+                        throw new ArgumentNullException(nameof(value), "Cannot set Selection to null.");
+                    }
+                    else if (value.Source != null && value.Source != Items)
+                    {
+                        throw new ArgumentException("Selection has invalid Source.");
+                    }
+
+                    List<object> oldSelection = null;
+
+                    if (_selection != null)
+                    {
+                        oldSelection = Selection.SelectedItems.ToList();
+                        _selection.PropertyChanged -= OnSelectionModelPropertyChanged;
+                        _selection.SelectionChanged -= OnSelectionModelSelectionChanged;
+                        _selection.ChildrenRequested -= OnSelectionModelChildrenRequested;
+                        MarkContainersUnselected();
+                    }
+
+                    _selection = value;
+
+                    if (_selection != null)
+                    {
+                        _selection.Source = Items;
+                        _selection.PropertyChanged += OnSelectionModelPropertyChanged;
+                        _selection.SelectionChanged += OnSelectionModelSelectionChanged;
+                        _selection.ChildrenRequested += OnSelectionModelChildrenRequested;
 
-                UnsubscribeFromSelectedItems();
-                _selectedItems = value ?? new AvaloniaList<object>();
-                SubscribeToSelectedItems();
+                        if (_selection.SingleSelect)
+                        {
+                            SelectionMode &= ~SelectionMode.Multiple;
+                        }
+                        else
+                        {
+                            SelectionMode |= SelectionMode.Multiple;
+                        }
+
+                        if (_selection.AutoSelect)
+                        {
+                            SelectionMode |= SelectionMode.AlwaysSelected;
+                        }
+                        else
+                        {
+                            SelectionMode &= ~SelectionMode.AlwaysSelected;
+                        }
+
+                        UpdateContainerSelection();
+
+                        var selectedItem = SelectedItem;
+
+                        if (_selectedItem != selectedItem)
+                        {
+                            RaisePropertyChanged(SelectedItemProperty, _selectedItem, selectedItem);
+                            _selectedItem = selectedItem;
+                        }
+                    }
+                }
             }
         }
 
@@ -182,186 +243,12 @@ namespace Avalonia.Controls
         /// Note that this method only selects nodes currently visible due to their parent nodes
         /// being expanded: it does not expand nodes.
         /// </remarks>
-        public void SelectAll()
-        {
-            SynchronizeItems(SelectedItems, ItemContainerGenerator.Index.Items);
-        }
+        public void SelectAll() => Selection.SelectAll();
 
         /// <summary>
         /// Deselects all items in the <see cref="TreeView"/>.
         /// </summary>
-        public void UnselectAll()
-        {
-            SelectedItems.Clear();
-        }
-
-        /// <summary>
-        /// Subscribes to the <see cref="SelectedItems"/> CollectionChanged event, if any.
-        /// </summary>
-        private void SubscribeToSelectedItems()
-        {
-            if (_selectedItems is INotifyCollectionChanged incc)
-            {
-                incc.CollectionChanged += SelectedItemsCollectionChanged;
-            }
-
-            SelectedItemsCollectionChanged(
-                _selectedItems,
-                new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
-        }
-
-        private void SelectSingleItem(object item)
-        {
-            SelectedItems.Clear();
-            SelectedItems.Add(item);
-        }
-
-        /// <summary>
-        /// Called when the <see cref="SelectedItems"/> CollectionChanged event is raised.
-        /// </summary>
-        /// <param name="sender">The event sender.</param>
-        /// <param name="e">The event args.</param>
-        private void SelectedItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
-        {
-            IList added = null;
-            IList removed = null;
-
-            switch (e.Action)
-            {
-                case NotifyCollectionChangedAction.Add:
-
-                    SelectedItemsAdded(e.NewItems.Cast<object>().ToArray());
-
-                    if (AutoScrollToSelectedItem)
-                    {
-                        var container = (TreeViewItem)ItemContainerGenerator.Index.ContainerFromItem(e.NewItems[0]);
-
-                        container?.BringIntoView();
-                    }
-
-                    added = e.NewItems;
-
-                    break;
-                case NotifyCollectionChangedAction.Remove:
-
-                    if (!_syncingSelectedItems)
-                    {
-                        if (SelectedItems.Count == 0)
-                        {
-                            SelectedItem = null;
-                        }
-                        else
-                        {
-                            var selectedIndex = SelectedItems.IndexOf(_selectedItem);
-
-                            if (selectedIndex == -1)
-                            {
-                                var old = _selectedItem;
-                                _selectedItem = SelectedItems[0];
-
-                                RaisePropertyChanged(SelectedItemProperty, old, _selectedItem);
-                            }
-                        }
-                    }
-
-                    foreach (var item in e.OldItems)
-                    {
-                        MarkItemSelected(item, false);
-                    }
-
-                    removed = e.OldItems;
-
-                    break;
-                case NotifyCollectionChangedAction.Reset:
-
-                    foreach (IControl container in ItemContainerGenerator.Index.Containers)
-                    {
-                        MarkContainerSelected(container, false);
-                    }
-
-                    if (SelectedItems.Count > 0)
-                    {
-                        SelectedItemsAdded(SelectedItems);
-
-                        added = SelectedItems;
-                    }
-                    else if (!_syncingSelectedItems)
-                    {
-                        SelectedItem = null;
-                    }
-
-                    break;
-                case NotifyCollectionChangedAction.Replace:
-
-                    foreach (var item in e.OldItems)
-                    {
-                        MarkItemSelected(item, false);
-                    }
-
-                    foreach (var item in e.NewItems)
-                    {
-                        MarkItemSelected(item, true);
-                    }
-
-                    if (SelectedItem != SelectedItems[0] && !_syncingSelectedItems)
-                    {
-                        var oldItem = SelectedItem;
-                        var item = SelectedItems[0];
-                        _selectedItem = item;
-                        RaisePropertyChanged(SelectedItemProperty, oldItem, item);
-                    }
-
-                    added = e.NewItems;
-                    removed = e.OldItems;
-
-                    break;
-            }
-
-            if (added?.Count > 0 || removed?.Count > 0)
-            {
-                var changed = new SelectionChangedEventArgs(
-                    SelectingItemsControl.SelectionChangedEvent,
-                    removed ?? Empty,
-                    added ?? Empty);
-                RaiseEvent(changed);
-            }
-        }
-
-        private void MarkItemSelected(object item, bool selected)
-        {
-            var container = ItemContainerGenerator.Index.ContainerFromItem(item);
-
-            MarkContainerSelected(container, selected);
-        }
-
-        private void SelectedItemsAdded(IList items)
-        {
-            if (items.Count == 0)
-            {
-                return;
-            }
-
-            foreach (object item in items)
-            {
-                MarkItemSelected(item, true);
-            }
-
-            if (SelectedItem == null && !_syncingSelectedItems)
-            {
-                SetAndRaise(SelectedItemProperty, ref _selectedItem, items[0]);
-            }
-        }
-
-        /// <summary>
-        /// Unsubscribes from the <see cref="SelectedItems"/> CollectionChanged event, if any.
-        /// </summary>
-        private void UnsubscribeFromSelectedItems()
-        {
-            if (_selectedItems is INotifyCollectionChanged incc)
-            {
-                incc.CollectionChanged -= SelectedItemsCollectionChanged;
-            }
-        }
+        public void UnselectAll() => Selection.ClearSelection();
 
         (bool handled, IInputElement next) ICustomKeyboardNavigation.GetNext(IInputElement element,
             NavigationDirection direction)
@@ -445,6 +332,72 @@ namespace Avalonia.Controls
             }
         }
 
+        /// <summary>
+        /// Called when <see cref="SelectionModel.PropertyChanged"/> is raised.
+        /// </summary>
+        /// <param name="sender">The sender.</param>
+        /// <param name="e">The event args.</param>
+        private void OnSelectionModelPropertyChanged(object sender, PropertyChangedEventArgs e)
+        {
+            if (e.PropertyName == nameof(SelectionModel.AnchorIndex) && AutoScrollToSelectedItem)
+            {
+                var container = ContainerFromIndex(Selection.AnchorIndex);
+
+                if (container != null)
+                {
+                    container.BringIntoView();
+                }
+            }
+        }
+
+        /// <summary>
+        /// Called when <see cref="SelectionModel.SelectionChanged"/> is raised.
+        /// </summary>
+        /// <param name="sender">The sender.</param>
+        /// <param name="e">The event args.</param>
+        private void OnSelectionModelSelectionChanged(object sender, SelectionModelSelectionChangedEventArgs e)
+        {
+            void Mark(IndexPath index, bool selected)
+            {
+                var container = ContainerFromIndex(index);
+
+                if (container != null)
+                {
+                    MarkContainerSelected(container, selected);
+                }
+            }
+
+            foreach (var i in e.SelectedIndices)
+            {
+                Mark(i, true);
+            }
+
+            foreach (var i in e.DeselectedIndices)
+            {
+                Mark(i, false);
+            }
+
+            var newSelectedItem = SelectedItem;
+
+            if (newSelectedItem != _selectedItem)
+            {
+                RaisePropertyChanged(SelectedItemProperty, _selectedItem, newSelectedItem);
+                _selectedItem = newSelectedItem;
+            }
+
+            var ev = new SelectionChangedEventArgs(
+                SelectionChangedEvent,
+                e.DeselectedItems.ToList(),
+                e.SelectedItems.ToList());
+            RaiseEvent(ev);
+        }
+
+        private void OnSelectionModelChildrenRequested(object sender, SelectionModelChildrenRequestedEventArgs e)
+        {
+            var container = ItemContainerGenerator.Index.ContainerFromItem(e.Source) as ItemsControl;
+            e.Children = container?.GetObservable(ItemsProperty);
+        }
+
         private TreeViewItem GetContainerInDirection(
             TreeViewItem from,
             NavigationDirection direction,
@@ -498,6 +451,12 @@ namespace Avalonia.Controls
             return result;
         }
 
+        protected override void ItemsChanged(AvaloniaPropertyChangedEventArgs e)
+        {
+            Selection.Source = Items;
+            base.ItemsChanged(e);
+        }
+
         /// <inheritdoc/>
         protected override void OnPointerPressed(PointerPressedEventArgs e)
         {
@@ -519,6 +478,18 @@ namespace Avalonia.Controls
             }
         }
 
+        protected override void OnPropertyChanged<T>(AvaloniaProperty<T> property, Optional<T> oldValue, BindingValue<T> newValue, BindingPriority priority)
+        {
+            base.OnPropertyChanged(property, oldValue, newValue, priority);
+
+            if (property == SelectionModeProperty)
+            {
+                var mode = newValue.GetValueOrDefault<SelectionMode>();
+                Selection.SingleSelect = !mode.HasFlagCustom(SelectionMode.Multiple);
+                Selection.AutoSelect = mode.HasFlagCustom(SelectionMode.AlwaysSelected);
+            }
+        }
+
         /// <summary>
         /// Updates the selection for an item based on user interaction.
         /// </summary>
@@ -534,9 +505,9 @@ namespace Avalonia.Controls
             bool toggleModifier = false,
             bool rightButton = false)
         {
-            var item = ItemContainerGenerator.Index.ItemFromContainer(container);
+            var index = IndexFromContainer((TreeViewItem)container);
 
-            if (item == null)
+            if (index.GetSize() == 0)
             {
                 return;
             }
@@ -553,41 +524,48 @@ namespace Avalonia.Controls
             var multi = (mode & SelectionMode.Multiple) != 0;
             var range = multi && selectedContainer != null && rangeModifier;
 
-            if (rightButton)
+            if (!select)
+            {
+                Selection.DeselectAt(index);
+            }
+            else if (rightButton)
             {
-                if (!SelectedItems.Contains(item))
+                if (!Selection.IsSelectedAt(index))
                 {
-                    SelectSingleItem(item);
+                    Selection.SelectedIndex = index;
                 }
             }
             else if (!toggle && !range)
             {
-                SelectSingleItem(item);
+                Selection.SelectedIndex = index;
             }
             else if (multi && range)
             {
-                SynchronizeItems(
-                    SelectedItems,
-                    GetItemsInRange(selectedContainer as TreeViewItem, container as TreeViewItem));
+                using var operation = Selection.Update();
+                var anchor = Selection.AnchorIndex;
+
+                if (anchor.GetSize() == 0)
+                {
+                    anchor = new IndexPath(0);
+                }
+
+                Selection.ClearSelection();
+                Selection.AnchorIndex = anchor;
+                Selection.SelectRangeFromAnchorTo(index);
             }
             else
             {
-                var i = SelectedItems.IndexOf(item);
-
-                if (i != -1)
+                if (Selection.IsSelectedAt(index))
                 {
-                    SelectedItems.Remove(item);
+                    Selection.DeselectAt(index);
+                }
+                else if (multi)
+                {
+                    Selection.SelectAt(index);
                 }
                 else
                 {
-                    if (multi)
-                    {
-                        SelectedItems.Add(item);
-                    }
-                    else
-                    {
-                        SelectedItem = item;
-                    }
+                    Selection.SelectedIndex = index;
                 }
             }
         }
@@ -610,117 +588,6 @@ namespace Avalonia.Controls
             }
         }
 
-        /// <summary>
-        /// Find which node is first in hierarchy.
-        /// </summary>
-        /// <param name="treeView">Search root.</param>
-        /// <param name="nodeA">Nodes to find.</param>
-        /// <param name="nodeB">Node to find.</param>
-        /// <returns>Found first node.</returns>
-        private static TreeViewItem FindFirstNode(TreeView treeView, TreeViewItem nodeA, TreeViewItem nodeB)
-        {
-            return FindInContainers(treeView.ItemContainerGenerator, nodeA, nodeB);
-        }
-
-        private static TreeViewItem FindInContainers(ITreeItemContainerGenerator containerGenerator,
-            TreeViewItem nodeA,
-            TreeViewItem nodeB)
-        {
-            IEnumerable<ItemContainerInfo> containers = containerGenerator.Containers;
-
-            foreach (ItemContainerInfo container in containers)
-            {
-                TreeViewItem node = FindFirstNode(container.ContainerControl as TreeViewItem, nodeA, nodeB);
-
-                if (node != null)
-                {
-                    return node;
-                }
-            }
-
-            return null;
-        }
-
-        private static TreeViewItem FindFirstNode(TreeViewItem node, TreeViewItem nodeA, TreeViewItem nodeB)
-        {
-            if (node == null)
-            {
-                return null;
-            }
-
-            TreeViewItem match = node == nodeA ? nodeA : node == nodeB ? nodeB : null;
-
-            if (match != null)
-            {
-                return match;
-            }
-
-            return FindInContainers(node.ItemContainerGenerator, nodeA, nodeB);
-        }
-
-        /// <summary>
-        /// Returns all items that belong to containers between <paramref name="from"/> and <paramref name="to"/>.
-        /// The range is inclusive.
-        /// </summary>
-        /// <param name="from">From container.</param>
-        /// <param name="to">To container.</param>
-        private List<object> GetItemsInRange(TreeViewItem from, TreeViewItem to)
-        {
-            var items = new List<object>();
-
-            if (from == null || to == null)
-            {
-                return items;
-            }
-
-            TreeViewItem firstItem = FindFirstNode(this, from, to);
-
-            if (firstItem == null)
-            {
-                return items;
-            }
-
-            bool wasReversed = false;
-
-            if (firstItem == to)
-            {
-                var temp = from;
-
-                from = to;
-                to = temp;
-
-                wasReversed = true;
-            }
-
-            TreeViewItem node = from;
-
-            while (node != to)
-            {
-                var item = ItemContainerGenerator.Index.ItemFromContainer(node);
-
-                if (item != null)
-                {
-                    items.Add(item);
-                }
-
-                node = GetContainerInDirection(node, NavigationDirection.Down, true);
-            }
-
-            var toItem = ItemContainerGenerator.Index.ItemFromContainer(to);
-
-            if (toItem != null)
-            {
-                items.Add(toItem);
-            }
-
-            if (wasReversed)
-            {
-                items.Reverse();
-            }
-
-            return items;
-        }
-
         /// <summary>
         /// Updates the selection based on an event that may have originated in a container that 
         /// belongs to the control.
@@ -826,26 +693,90 @@ namespace Avalonia.Controls
             }
         }
 
-        /// <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)
+        private void MarkContainersUnselected()
         {
-            var list = items.Cast<object>().ToList();
-            var toRemove = list.Except(desired).ToList();
-            var toAdd = desired.Except(list).ToList();
+            foreach (var container in ItemContainerGenerator.Index.Containers)
+            {
+                MarkContainerSelected(container, false);
+            }
+        }
+
+        private void UpdateContainerSelection()
+        {
+            var index = ItemContainerGenerator.Index;
+
+            foreach (var container in index.Containers)
+            {
+                var i = IndexFromContainer((TreeViewItem)container);
+
+                MarkContainerSelected(
+                    container,
+                    Selection.IsSelectedAt(i) != false);
+            }
+        }
+
+        private static IndexPath IndexFromContainer(TreeViewItem container)
+        {
+            var result = new List<int>();
+
+            while (true)
+            {
+                if (container.Level == 0)
+                {
+                    var treeView = container.FindAncestorOfType<TreeView>();
+
+                    if (treeView == null)
+                    {
+                        return default;
+                    }
+
+                    result.Add(treeView.ItemContainerGenerator.IndexFromContainer(container));
+                    result.Reverse();
+                    return new IndexPath(result);
+                }
+                else
+                {
+                    var parent = container.FindAncestorOfType<TreeViewItem>();
+
+                    if (parent == null)
+                    {
+                        return default;
+                    }
+
+                    result.Add(parent.ItemContainerGenerator.IndexFromContainer(container));
+                    container = parent;
+                }
+            }
+        }
+
+        private IndexPath IndexFromItem(object item)
+        {
+            var container = ItemContainerGenerator.Index.ContainerFromItem(item) as TreeViewItem;
 
-            foreach (var i in toRemove)
+            if (container != null)
             {
-                items.Remove(i);
+                return IndexFromContainer(container);
             }
 
-            foreach (var i in toAdd)
+            return default;
+        }
+
+        private TreeViewItem ContainerFromIndex(IndexPath index)
+        {
+            TreeViewItem treeViewItem = null;
+
+            for (var i = 0; i < index.GetSize(); ++i)
             {
-                items.Add(i);
+                var generator = treeViewItem?.ItemContainerGenerator ?? ItemContainerGenerator;
+                treeViewItem = generator.ContainerFromIndex(index.GetAt(i)) as TreeViewItem;
+
+                if (treeViewItem == null)
+                {
+                    return null;
+                }
             }
+
+            return treeViewItem;
         }
     }
 }

+ 227 - 0
src/Avalonia.Controls/Utils/SelectedItemsSync.cs

@@ -0,0 +1,227 @@
+using System;
+using System.Collections;
+using System.Collections.Specialized;
+using System.Linq;
+using Avalonia.Collections;
+
+#nullable enable
+
+namespace Avalonia.Controls.Utils
+{
+    /// <summary>
+    /// Synchronizes an <see cref="ISelectionModel"/> with a list of SelectedItems.
+    /// </summary>
+    internal class SelectedItemsSync
+    {
+        private IList? _items;
+        private bool _updatingItems;
+        private bool _updatingModel;
+
+        public SelectedItemsSync(ISelectionModel model)
+        {
+            model = model ?? throw new ArgumentNullException(nameof(model));
+            Model = model;
+        }
+
+        public ISelectionModel Model { get; private set; }
+
+        public IList GetOrCreateItems()
+        {
+            if (_items == null)
+            {
+                var items = new AvaloniaList<object>(Model.SelectedItems);
+                items.CollectionChanged += ItemsCollectionChanged;
+                Model.SelectionChanged += SelectionModelSelectionChanged;
+                _items = items;
+            }
+
+            return _items;
+        }
+
+        public void SetItems(IList? items)
+        {
+            items ??= new AvaloniaList<object>();
+
+            if (items.IsFixedSize)
+            {
+                throw new NotSupportedException(
+                    "Cannot assign fixed size selection to SelectedItems.");
+            }
+
+            if (_items is INotifyCollectionChanged incc)
+            {
+                incc.CollectionChanged -= ItemsCollectionChanged;
+            }
+
+            if (_items == null)
+            {
+                Model.SelectionChanged += SelectionModelSelectionChanged;
+            }
+
+            try
+            {
+                _updatingModel = true;
+                _items = items;
+
+                using (Model.Update())
+                {
+                    Model.ClearSelection();
+                    Add(items);
+                }
+
+                if (_items is INotifyCollectionChanged incc2)
+                {
+                    incc2.CollectionChanged += ItemsCollectionChanged;
+                }
+            }
+            finally
+            {
+                _updatingModel = false;
+            }
+        }
+
+        public void SetModel(ISelectionModel model)
+        {
+            model = model ?? throw new ArgumentNullException(nameof(model));
+
+            if (_items != null)
+            {
+                Model.SelectionChanged -= SelectionModelSelectionChanged;
+                Model = model;
+                Model.SelectionChanged += SelectionModelSelectionChanged;
+
+                try
+                {
+                    _updatingItems = true;
+                    _items.Clear();
+
+                    foreach (var i in model.SelectedItems)
+                    {
+                        _items.Add(i);
+                    }
+                }
+                finally
+                {
+                    _updatingItems = false;
+                }
+            }
+        }
+
+        private void ItemsCollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
+        {
+            if (_updatingItems)
+            {
+                return;
+            }
+
+            if (_items == null)
+            {
+                throw new AvaloniaInternalException("CollectionChanged raised but we don't have items.");
+            }
+
+            void Remove()
+            {
+                foreach (var i in e.OldItems)
+                {
+                    var index = IndexOf(Model.Source, i);
+
+                    if (index != -1)
+                    {
+                        Model.Deselect(index);
+                    }
+                }
+            }
+
+            try
+            {
+                using var operation = Model.Update();
+
+                _updatingModel = true;
+
+                switch (e.Action)
+                {
+                    case NotifyCollectionChangedAction.Add:
+                        Add(e.NewItems);
+                        break;
+                    case NotifyCollectionChangedAction.Remove:
+                        Remove();
+                        break;
+                    case NotifyCollectionChangedAction.Replace:
+                        Remove();
+                        Add(e.NewItems);
+                        break;
+                    case NotifyCollectionChangedAction.Reset:
+                        Model.ClearSelection();
+                        Add(_items);
+                        break;
+                }
+            }
+            finally
+            {
+                _updatingModel = false;
+            }
+        }
+
+        private void Add(IList newItems)
+        {
+            foreach (var i in newItems)
+            {
+                var index = IndexOf(Model.Source, i);
+
+                if (index != -1)
+                {
+                    Model.Select(index);
+                }
+            }
+        }
+
+        private void SelectionModelSelectionChanged(object sender, SelectionModelSelectionChangedEventArgs e)
+        {
+            if (_updatingModel)
+            {
+                return;
+            }
+
+            if (_items == null)
+            {
+                throw new AvaloniaInternalException("SelectionModelChanged raised but we don't have items.");
+            }
+
+            try
+            {
+                var deselected = e.DeselectedItems.ToList();
+                var selected = e.SelectedItems.ToList();
+
+                _updatingItems = true;
+
+                foreach (var i in deselected)
+                {
+                    _items.Remove(i);
+                }
+
+                foreach (var i in selected)
+                {
+                    _items.Add(i);
+                }
+            }
+            finally
+            {
+                _updatingItems = false;
+            }
+        }
+
+        private static int IndexOf(object source, object item)
+        {
+            if (source is IList l)
+            {
+                return l.IndexOf(item);
+            }
+            else if (source is ItemsSourceView v)
+            {
+                return v.IndexOf(item);
+            }
+
+            return -1;
+        }
+    }
+}

+ 189 - 0
src/Avalonia.Controls/Utils/SelectionTreeHelper.cs

@@ -0,0 +1,189 @@
+// This source file is adapted from the WinUI project.
+// (https://github.com/microsoft/microsoft-ui-xaml)
+//
+// Licensed to The Avalonia Project under MIT License, courtesy of The .NET Foundation.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+#nullable enable
+
+namespace Avalonia.Controls.Utils
+{
+    internal static class SelectionTreeHelper
+    {
+        public static void TraverseIndexPath(
+            SelectionNode root,
+            IndexPath path,
+            bool realizeChildren,
+            Action<SelectionNode, IndexPath, int, int> nodeAction)
+        {
+            var node = root;
+
+            for (int depth = 0; depth < path.GetSize(); depth++)
+            {
+                int childIndex = path.GetAt(depth);
+                nodeAction(node, path, depth, childIndex);
+
+                if (depth < path.GetSize() - 1)
+                {
+                    node = node.GetAt(childIndex, realizeChildren)!;
+                }
+            }
+        }
+
+        public static void Traverse(
+            SelectionNode root,
+            bool realizeChildren,
+            Action<TreeWalkNodeInfo> nodeAction)
+        {
+            var pendingNodes = new List<TreeWalkNodeInfo>();
+            var current = new IndexPath(null);
+
+            pendingNodes.Add(new TreeWalkNodeInfo(root, current));
+
+            while (pendingNodes.Count > 0)
+            {
+                var nextNode = pendingNodes.Last();
+                pendingNodes.RemoveAt(pendingNodes.Count - 1);
+                int count = realizeChildren ? nextNode.Node.DataCount : nextNode.Node.ChildrenNodeCount;
+                for (int i = count - 1; i >= 0; i--)
+                {
+                    var child = nextNode.Node.GetAt(i, realizeChildren);
+                    var childPath = nextNode.Path.CloneWithChildIndex(i);
+                    if (child != null)
+                    {
+                        pendingNodes.Add(new TreeWalkNodeInfo(child, childPath, nextNode.Node));
+                    }
+                }
+
+                // Queue the children first and then perform the action. This way
+                // the action can remove the children in the action if necessary
+                nodeAction(nextNode);
+            }
+        }
+
+        public static void TraverseRangeRealizeChildren(
+            SelectionNode root,
+            IndexPath start,
+            IndexPath end,
+            Action<TreeWalkNodeInfo> nodeAction)
+        {
+            var pendingNodes = new List<TreeWalkNodeInfo>();
+            var current = start;
+
+            // Build up the stack to account for the depth first walk up to the 
+            // start index path.
+            TraverseIndexPath(
+                root,
+                start,
+                true,
+                (node, path, depth, childIndex) =>
+                {
+                    var currentPath = StartPath(path, depth);
+                    bool isStartPath = IsSubSet(start, currentPath);
+                    bool isEndPath = IsSubSet(end, currentPath);
+
+                    int startIndex = depth < start.GetSize() && isStartPath ? start.GetAt(depth) : 0;
+                    int endIndex = depth < end.GetSize() && isEndPath ? end.GetAt(depth) : node.DataCount - 1;
+
+                    for (int i = endIndex; i >= startIndex; i--)
+                    {
+                        var child = node.GetAt(i, realizeChild: true);
+                        if (child != null)
+                        {
+                            var childPath = currentPath.CloneWithChildIndex(i);
+                            pendingNodes.Add(new TreeWalkNodeInfo(child, childPath, node));
+                        }
+                    }
+                });
+
+            // From the start index path, do a depth first walk as long as the
+            // current path is less than the end path.
+            while (pendingNodes.Count > 0)
+            {
+                var info = pendingNodes.Last();
+                pendingNodes.RemoveAt(pendingNodes.Count - 1);
+                int depth = info.Path.GetSize();
+                bool isStartPath = IsSubSet(start, info.Path);
+                bool isEndPath = IsSubSet(end, info.Path);
+                int startIndex = depth < start.GetSize() && isStartPath ? start.GetAt(depth) : 0;
+                int endIndex = depth < end.GetSize() && isEndPath ? end.GetAt(depth) : info.Node.DataCount - 1;
+                for (int i = endIndex; i >= startIndex; i--)
+                {
+                    var child = info.Node.GetAt(i, realizeChild: true);
+                    if (child != null)
+                    {
+                        var childPath = info.Path.CloneWithChildIndex(i);
+                        pendingNodes.Add(new TreeWalkNodeInfo(child, childPath, info.Node));
+                    }
+                }
+
+                nodeAction(info);
+
+                if (info.Path.CompareTo(end) == 0)
+                {
+                    // We reached the end index path. stop iterating.
+                    break;
+                }
+            }
+        }
+
+        private static bool IsSubSet(IndexPath path, IndexPath subset)
+        {
+            var subsetSize = subset.GetSize();
+            if (path.GetSize() < subsetSize)
+            {
+                return false;
+            }
+
+            for (int i = 0; i < subsetSize; i++)
+            {
+                if (path.GetAt(i) != subset.GetAt(i))
+                {
+                    return false;
+                }
+            }
+
+            return true;
+        }
+
+        private static IndexPath StartPath(IndexPath path, int length)
+        {
+            var subPath = new List<int>();
+            for (int i = 0; i < length; i++)
+            {
+                subPath.Add(path.GetAt(i));
+            }
+
+            return new IndexPath(subPath);
+        }
+
+        public struct TreeWalkNodeInfo
+        {
+            public TreeWalkNodeInfo(SelectionNode node, IndexPath indexPath, SelectionNode? parent)
+            {
+                node = node ?? throw new ArgumentNullException(nameof(node));
+
+                Node = node;
+                Path = indexPath;
+                ParentNode = parent;
+            }
+
+            public TreeWalkNodeInfo(SelectionNode node, IndexPath indexPath)
+            {
+                node = node ?? throw new ArgumentNullException(nameof(node));
+
+                Node = node;
+                Path = indexPath;
+                ParentNode = null;
+            }
+
+            public SelectionNode Node { get; }
+            public IndexPath Path { get; }
+            public SelectionNode? ParentNode { get; }
+        };
+
+    }
+}

+ 2 - 0
src/Avalonia.Controls/Window.cs

@@ -675,7 +675,9 @@ namespace Avalonia.Controls
 
                 if (o != n)
                 {
+#pragma warning disable CS0618 // Type or member is obsolete
                     RaisePropertyChanged(HasSystemDecorationsProperty, o, n);
+#pragma warning restore CS0618 // Type or member is obsolete
                 }
             }
         }

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

@@ -19,5 +19,10 @@ namespace Avalonia.Controls
         /// The window is maximized.
         /// </summary>
         Maximized,
+
+        /// <summary>
+        /// The window is fullscreen.
+        /// </summary>
+        FullScreen,
     }
 }

+ 2 - 2
src/Avalonia.Dialogs/ManagedFileChooser.xaml.cs

@@ -34,7 +34,7 @@ namespace Avalonia.Dialogs
                 return;
             }
 
-            var isQuickLink = _quickLinksRoot.IsLogicalParentOf(e.Source as Control);
+            var isQuickLink = _quickLinksRoot.IsLogicalAncestorOf(e.Source as Control);
             if (e.ClickCount == 2 || isQuickLink)
             {
                 if (model.ItemType == ManagedFileChooserItemType.File)
@@ -81,7 +81,7 @@ namespace Avalonia.Dialogs
 
             if (indexOfPreselected > 1)
             {
-                _filesView.ScrollIntoView(model.Items[indexOfPreselected - 1]);
+                _filesView.ScrollIntoView(indexOfPreselected - 1);
             }
         }
     }

+ 9 - 5
src/Avalonia.Dialogs/ManagedFileChooserViewModel.cs

@@ -14,6 +14,7 @@ namespace Avalonia.Dialogs
 {
     internal class ManagedFileChooserViewModel : InternalViewModelBase
     {
+        private readonly ManagedFileDialogOptions _options;
         public event Action CancelRequested;
         public event Action<string[]> CompleteRequested;
 
@@ -103,8 +104,9 @@ namespace Avalonia.Dialogs
             QuickLinks.AddRange(quickSources.GetAllItems().Select(i => new ManagedFileChooserItemViewModel(i)));
         }
 
-        public ManagedFileChooserViewModel(FileSystemDialog dialog)
+        public ManagedFileChooserViewModel(FileSystemDialog dialog, ManagedFileDialogOptions options)
         {
+            _options = options;
             _disposables = new CompositeDisposable();
 
             var quickSources = AvaloniaLocator.Current
@@ -134,7 +136,7 @@ namespace Avalonia.Dialogs
                         : dialog is OpenFolderDialog ? "Select directory"
                         : throw new ArgumentException(nameof(dialog)));
 
-            var directory = dialog.InitialDirectory;
+            var directory = dialog.Directory;
 
             if (directory == null || !Directory.Exists(directory))
             {
@@ -202,10 +204,12 @@ namespace Avalonia.Dialogs
                     }
                     else
                     {
-                        var invalidItems = SelectedItems.Where(i => i.ItemType == ManagedFileChooserItemType.Folder).ToList();
-                        foreach (var item in invalidItems)
+                        if (!_options.AllowDirectorySelection)
                         {
-                            SelectedItems.Remove(item);
+                            var invalidItems = SelectedItems.Where(i => i.ItemType == ManagedFileChooserItemType.Folder)
+                                .ToList();
+                            foreach (var item in invalidItems) 
+                                SelectedItems.Remove(item);
                         }
 
                         if (!_selectingDirectory)

+ 18 - 2
src/Avalonia.Dialogs/ManagedFileDialogExtensions.cs

@@ -9,13 +9,15 @@ namespace Avalonia.Dialogs
     {
         private class ManagedSystemDialogImpl<T> : ISystemDialogImpl where T : Window, new()
         {
-            async Task<string[]> Show(SystemDialog d, Window parent)
+            async Task<string[]> Show(SystemDialog d, Window parent, ManagedFileDialogOptions options = null)
             {
-                var model = new ManagedFileChooserViewModel((FileSystemDialog)d);
+                var model = new ManagedFileChooserViewModel((FileSystemDialog)d,
+                    options ?? new ManagedFileDialogOptions());
 
                 var dialog = new T
                 {
                     Content = new ManagedFileChooser(),
+                    Title = d.Title,
                     DataContext = model
                 };
 
@@ -44,6 +46,11 @@ namespace Avalonia.Dialogs
             {
                 return (await Show(dialog, parent))?.FirstOrDefault();
             }
+            
+            public async Task<string[]> ShowFileDialogAsync(FileDialog dialog, Window parent, ManagedFileDialogOptions options)
+            {
+                return await Show(dialog, parent, options);
+            }
         }
 
         public static TAppBuilder UseManagedSystemDialogs<TAppBuilder>(this TAppBuilder builder)
@@ -61,5 +68,14 @@ namespace Avalonia.Dialogs
                 AvaloniaLocator.CurrentMutable.Bind<ISystemDialogImpl>().ToSingleton<ManagedSystemDialogImpl<TWindow>>());
             return builder;
         }
+
+        public static Task<string[]> ShowManagedAsync(this OpenFileDialog dialog, Window parent,
+            ManagedFileDialogOptions options = null) => ShowManagedAsync<Window>(dialog, parent, options);
+        
+        public static Task<string[]> ShowManagedAsync<TWindow>(this OpenFileDialog dialog, Window parent,
+            ManagedFileDialogOptions options = null) where TWindow : Window, new()
+        {
+            return new ManagedSystemDialogImpl<TWindow>().ShowFileDialogAsync(dialog, parent, options);
+        }
     }
 }

+ 7 - 0
src/Avalonia.Dialogs/ManagedFileDialogOptions.cs

@@ -0,0 +1,7 @@
+namespace Avalonia.Dialogs
+{
+    public class ManagedFileDialogOptions
+    {
+        public bool AllowDirectorySelection { get; set; }
+    }
+}

+ 3 - 1
src/Avalonia.Input/DragEventArgs.cs

@@ -52,8 +52,10 @@ namespace Avalonia.Input
             Data = data;
             _target = target;
             _targetLocation = targetLocation;
-            Modifiers = (InputModifiers)keyModifiers;
             KeyModifiers = keyModifiers;
+#pragma warning disable CS0618 // Type or member is obsolete
+            Modifiers = (InputModifiers)keyModifiers;
+#pragma warning restore CS0618 // Type or member is obsolete
         }
 
     }

+ 2 - 1
src/Avalonia.Input/FocusManager.cs

@@ -186,8 +186,9 @@ namespace Avalonia.Input
         private static void OnPreviewPointerPressed(object sender, RoutedEventArgs e)
         {
             var ev = (PointerPressedEventArgs)e;
+            var visual = (IVisual)sender;
 
-            if (sender == e.Source && ev.MouseButton == MouseButton.Left)
+            if (sender == e.Source && ev.GetCurrentPoint(visual).Properties.IsLeftButtonPressed)
             {
                 IVisual element = ev.Pointer?.Captured ?? e.Source as IInputElement;
 

+ 3 - 1
src/Avalonia.Input/Gestures.cs

@@ -1,5 +1,6 @@
 using System;
 using Avalonia.Interactivity;
+using Avalonia.VisualTree;
 
 namespace Avalonia.Input
 {
@@ -71,12 +72,13 @@ namespace Avalonia.Input
             if (ev.Route == RoutingStrategies.Bubble)
             {
                 var e = (PointerPressedEventArgs)ev;
+                var visual = (IVisual)ev.Source;
 
                 if (e.ClickCount <= 1)
                 {
                     s_lastPress = new WeakReference<IInteractive>(e.Source);
                 }
-                else if (s_lastPress != null && e.ClickCount == 2 && e.MouseButton == MouseButton.Left)
+                else if (s_lastPress != null && e.ClickCount == 2 && e.GetCurrentPoint(visual).Properties.IsLeftButtonPressed)
                 {
                     if (s_lastPress.TryGetTarget(out var target) && target == e.Source)
                     {

+ 3 - 3
src/Avalonia.Input/PointerEventArgs.cs

@@ -128,10 +128,10 @@ namespace Avalonia.Input
             _obsoleteClickCount = obsoleteClickCount;
         }
 
-        [Obsolete("Use DoubleTapped or DoubleRightTapped event instead")]
+        [Obsolete("Use DoubleTapped event or Gestures.DoubleRightTapped attached event")]
         public int ClickCount => _obsoleteClickCount;
 
-        [Obsolete("Use PointerUpdateKind")]
+        [Obsolete("Use PointerPressedEventArgs.GetCurrentPoint(this).Properties")]
         public MouseButton MouseButton => Properties.PointerUpdateKind.GetMouseButton();
     }
 
@@ -153,7 +153,7 @@ namespace Avalonia.Input
         /// </summary>
         public MouseButton InitialPressMouseButton { get; }
 
-        [Obsolete("Either use GetCurrentPoint(this).Properties.PointerUpdateKind or InitialPressMouseButton, see https://github.com/AvaloniaUI/Avalonia/wiki/Pointer-events-in-0.9 for more details", true)]
+        [Obsolete("Use InitialPressMouseButton")]
         public MouseButton MouseButton => InitialPressMouseButton;
     }
 

+ 3 - 1
src/Avalonia.Input/Raw/RawDragEvent.cs

@@ -20,8 +20,10 @@ namespace Avalonia.Input.Raw
             Location = location;
             Data = data;
             Effects = effects;
-            Modifiers = (InputModifiers)modifiers;
             KeyModifiers = KeyModifiersUtils.ConvertToKey(modifiers);
+#pragma warning disable CS0618 // Type or member is obsolete
+            Modifiers = (InputModifiers)modifiers;
+#pragma warning restore CS0618 // Type or member is obsolete
         }
     }
 }

+ 3 - 3
src/Avalonia.Native/SystemDialogs.cs

@@ -28,7 +28,7 @@ namespace Avalonia.Native
                 _native.OpenFileDialog(nativeParent,
                                         events, ofd.AllowMultiple,
                                         ofd.Title ?? "",
-                                        ofd.InitialDirectory ?? "",
+                                        ofd.Directory ?? "",
                                         ofd.InitialFileName ?? "",
                                         string.Join(";", dialog.Filters.SelectMany(f => f.Extensions)));
             }
@@ -37,7 +37,7 @@ namespace Avalonia.Native
                 _native.SaveFileDialog(nativeParent,
                                         events,
                                         dialog.Title ?? "",
-                                        dialog.InitialDirectory ?? "",
+                                        dialog.Directory ?? "",
                                         dialog.InitialFileName ?? "",
                                         string.Join(";", dialog.Filters.SelectMany(f => f.Extensions)));
             }
@@ -51,7 +51,7 @@ namespace Avalonia.Native
 
             var nativeParent = GetNativeWindow(parent);
 
-            _native.SelectFolderDialog(nativeParent, events, dialog.Title ?? "", dialog.InitialDirectory ?? "");
+            _native.SelectFolderDialog(nativeParent, events, dialog.Title ?? "", dialog.Directory ?? "");
 
             return events.Task.ContinueWith(t => { events.Dispose(); return t.Result.FirstOrDefault(); });
         }

+ 1 - 1
src/Avalonia.Native/WindowImpl.cs

@@ -67,7 +67,7 @@ namespace Avalonia.Native
 
         public void SetSystemDecorations(Controls.SystemDecorations enabled)
         {
-            _native.HasDecorations = (Interop.SystemDecorations)enabled;
+            _native.Decorations = (Interop.SystemDecorations)enabled;
         }
 
         public void SetTitleBarColor (Avalonia.Media.Color color)

+ 145 - 3
src/Avalonia.Styling/LogicalTree/LogicalExtensions.cs

@@ -1,11 +1,18 @@
 using System;
 using System.Collections.Generic;
-using System.Linq;
 
 namespace Avalonia.LogicalTree
 {
+    /// <summary>
+    /// Provides extension methods for working with the logical tree.
+    /// </summary>
     public static class LogicalExtensions
     {
+        /// <summary>
+        /// Enumerates the ancestors of an <see cref="ILogical"/> in the logical tree.
+        /// </summary>
+        /// <param name="logical">The logical.</param>
+        /// <returns>The logical's ancestors.</returns>
         public static IEnumerable<ILogical> GetLogicalAncestors(this ILogical logical)
         {
             Contract.Requires<ArgumentNullException>(logical != null);
@@ -19,6 +26,11 @@ namespace Avalonia.LogicalTree
             }
         }
 
+        /// <summary>
+        /// Enumerates an <see cref="ILogical"/> and its ancestors in the logical tree.
+        /// </summary>
+        /// <param name="logical">The logical.</param>
+        /// <returns>The logical and its ancestors.</returns>
         public static IEnumerable<ILogical> GetSelfAndLogicalAncestors(this ILogical logical)
         {
             yield return logical;
@@ -29,11 +41,50 @@ namespace Avalonia.LogicalTree
             }
         }
 
+        /// <summary>
+        /// Finds first ancestor of given type.
+        /// </summary>
+        /// <typeparam name="T">Ancestor type.</typeparam>
+        /// <param name="logical">The logical.</param>
+        /// <param name="includeSelf">If given logical should be included in search.</param>
+        /// <returns>First ancestor of given type.</returns>
+        public static T FindLogicalAncestorOfType<T>(this ILogical logical, bool includeSelf = false) where T : class
+        {
+            if (logical is null)
+            {
+                return null;
+            }
+
+            ILogical parent = includeSelf ? logical : logical.LogicalParent;
+
+            while (parent != null)
+            {
+                if (parent is T result)
+                {
+                    return result;
+                }
+
+                parent = parent.LogicalParent;
+            }
+
+            return null;
+        }
+
+        /// <summary>
+        /// Enumerates the children of an <see cref="ILogical"/> in the logical tree.
+        /// </summary>
+        /// <param name="logical">The logical.</param>
+        /// <returns>The logical children.</returns>
         public static IEnumerable<ILogical> GetLogicalChildren(this ILogical logical)
         {
             return logical.LogicalChildren;
         }
 
+        /// <summary>
+        /// Enumerates the descendants of an <see cref="ILogical"/> in the logical tree.
+        /// </summary>
+        /// <param name="logical">The logical.</param>
+        /// <returns>The logical's ancestors.</returns>
         public static IEnumerable<ILogical> GetLogicalDescendants(this ILogical logical)
         {
             foreach (ILogical child in logical.LogicalChildren)
@@ -47,6 +98,11 @@ namespace Avalonia.LogicalTree
             }
         }
 
+        /// <summary>
+        /// Enumerates an <see cref="ILogical"/> and its descendants in the logical tree.
+        /// </summary>
+        /// <param name="logical">The logical.</param>
+        /// <returns>The logical and its ancestors.</returns>
         public static IEnumerable<ILogical> GetSelfAndLogicalDescendants(this ILogical logical)
         {
             yield return logical;
@@ -57,16 +113,56 @@ namespace Avalonia.LogicalTree
             }
         }
 
+        /// <summary>
+        /// Finds first descendant of given type.
+        /// </summary>
+        /// <typeparam name="T">Descendant type.</typeparam>
+        /// <param name="logical">The logical.</param>
+        /// <param name="includeSelf">If given logical should be included in search.</param>
+        /// <returns>First descendant of given type.</returns>
+        public static T FindLogicalDescendantOfType<T>(this ILogical logical, bool includeSelf = false) where T : class
+        {
+            if (logical is null)
+            {
+                return null;
+            }
+
+            if (includeSelf && logical is T result)
+            {
+                return result;
+            }
+
+            return FindDescendantOfTypeCore<T>(logical);
+        }
+
+        /// <summary>
+        /// Gets the logical parent of an <see cref="ILogical"/>.
+        /// </summary>
+        /// <param name="logical">The logical.</param>
+        /// <returns>The parent, or null if the logical is unparented.</returns>
         public static ILogical GetLogicalParent(this ILogical logical)
         {
             return logical.LogicalParent;
         }
 
+        /// <summary>
+        /// Gets the logical parent of an <see cref="ILogical"/>.
+        /// </summary>
+        /// <typeparam name="T">The type of the logical parent.</typeparam>
+        /// <param name="logical">The logical.</param>
+        /// <returns>
+        /// The parent, or null if the logical is unparented or its parent is not of type <typeparamref name="T"/>.
+        /// </returns>
         public static T GetLogicalParent<T>(this ILogical logical) where T : class
         {
             return logical.LogicalParent as T;
         }
 
+        /// <summary>
+        /// Enumerates the siblings of an <see cref="ILogical"/> in the logical tree.
+        /// </summary>
+        /// <param name="logical">The logical.</param>
+        /// <returns>The logical siblings.</returns>
         public static IEnumerable<ILogical> GetLogicalSiblings(this ILogical logical)
         {
             ILogical parent = logical.LogicalParent;
@@ -80,9 +176,55 @@ namespace Avalonia.LogicalTree
             }
         }
 
-        public static bool IsLogicalParentOf(this ILogical logical, ILogical target)
+        /// <summary>
+        /// Tests whether an <see cref="ILogical"/> is an ancestor of another logical.
+        /// </summary>
+        /// <param name="logical">The logical.</param>
+        /// <param name="target">The potential descendant.</param>
+        /// <returns>
+        /// True if <paramref name="logical"/> is an ancestor of <paramref name="target"/>;
+        /// otherwise false.
+        /// </returns>
+        public static bool IsLogicalAncestorOf(this ILogical logical, ILogical target)
         {
-            return target.GetLogicalAncestors().Any(x => x == logical);
+            ILogical current = target?.LogicalParent;
+
+            while (current != null)
+            {
+                if (current == logical)
+                {
+                    return true;
+                }
+
+                current = current.LogicalParent;
+            }
+
+            return false;
+        }
+
+        private static T FindDescendantOfTypeCore<T>(ILogical logical) where T : class
+        {
+            var logicalChildren = logical.LogicalChildren;
+            var logicalChildrenCount = logicalChildren.Count;
+
+            for (var i = 0; i < logicalChildrenCount; i++)
+            {
+                ILogical child = logicalChildren[i];
+
+                if (child is T result)
+                {
+                    return result;
+                }
+
+                var childResult = FindDescendantOfTypeCore<T>(child);
+
+                if (!(childResult is null))
+                {
+                    return childResult;
+                }
+            }
+
+            return null;
         }
     }
 }

+ 6 - 0
src/Avalonia.Visuals/Media/FormattedText.cs

@@ -200,7 +200,13 @@ namespace Avalonia.Media
 
         private void Set<T>(ref T field, T value)
         {
+            if (field != null && field.Equals(value))
+            {
+                return;
+            }
+
             field = value;
+
             _platformImpl = null;
         }
     }

+ 22 - 4
src/Avalonia.Visuals/Rendering/DeferredRenderer.cs

@@ -35,6 +35,7 @@ namespace Avalonia.Rendering
         private IRef<IDrawOperation> _currentDraw;
         private readonly IDeferredRendererLock _lock;
         private readonly object _sceneLock = new object();
+        private readonly Action _updateSceneIfNeededDelegate;
 
         /// <summary>
         /// Initializes a new instance of the <see cref="DeferredRenderer"/> class.
@@ -49,7 +50,7 @@ namespace Avalonia.Rendering
             IRenderLoop renderLoop,
             ISceneBuilder sceneBuilder = null,
             IDispatcher dispatcher = null,
-            IDeferredRendererLock rendererLock = null)
+            IDeferredRendererLock rendererLock = null) : base(true)
         {
             Contract.Requires<ArgumentNullException>(root != null);
 
@@ -59,6 +60,7 @@ namespace Avalonia.Rendering
             Layers = new RenderLayers();
             _renderLoop = renderLoop;
             _lock = rendererLock ?? new ManagedDeferredRendererLock();
+            _updateSceneIfNeededDelegate = UpdateSceneIfNeeded;
         }
 
         /// <summary>
@@ -73,7 +75,7 @@ namespace Avalonia.Rendering
         public DeferredRenderer(
             IVisual root,
             IRenderTarget renderTarget,
-            ISceneBuilder sceneBuilder = null)
+            ISceneBuilder sceneBuilder = null) : base(true)
         {
             Contract.Requires<ArgumentNullException>(root != null);
             Contract.Requires<ArgumentNullException>(renderTarget != null);
@@ -83,6 +85,7 @@ namespace Avalonia.Rendering
             _sceneBuilder = sceneBuilder ?? new SceneBuilder();
             Layers = new RenderLayers();
             _lock = new ManagedDeferredRendererLock();
+            _updateSceneIfNeededDelegate = UpdateSceneIfNeeded;
         }
 
         /// <inheritdoc/>
@@ -261,7 +264,8 @@ namespace Avalonia.Rendering
                     try
                     {
                         var (scene, updated) = UpdateRenderLayersAndConsumeSceneIfNeeded(ref context);
-
+                        if (updated)
+                            FpsTick();
                         using (scene)
                         {
                             if (scene?.Item != null)
@@ -318,17 +322,25 @@ namespace Avalonia.Rendering
                         _lastSceneId = scene.Generation;
 
 
+                    var isUiThread = Dispatcher.UIThread.CheckAccess();
                     // We have consumed the previously available scene, but there might be some dirty 
                     // rects since the last update. *If* we are on UI thread, we can force immediate scene
                     // rebuild before rendering anything on-screen
                     // We are calling the same method recursively here 
-                    if (!recursiveCall && Dispatcher.UIThread.CheckAccess() && NeedsUpdate)
+                    if (!recursiveCall && isUiThread && NeedsUpdate)
                     {
                         UpdateScene();
                         var (rs, _) = UpdateRenderLayersAndConsumeSceneIfNeeded(ref context, true);
                         return (rs, true);
                     }
 
+                    // We are rendering a new scene version, so it's highly likely
+                    // that there is already a pending update for animations
+                    // So we are scheduling an update call so UI thread could prepare a scene before
+                    // the next render timer tick
+                    if (!recursiveCall && !isUiThread)
+                        Dispatcher.UIThread.Post(_updateSceneIfNeededDelegate, DispatcherPriority.Render);
+
                     // Indicate that we have updated the layers
                     return (sceneRef.Clone(), true);
                 }
@@ -534,6 +546,12 @@ namespace Avalonia.Rendering
             context = RenderTarget.CreateDrawingContext(this);
         }
 
+        private void UpdateSceneIfNeeded()
+        {
+            if(NeedsUpdate)
+                UpdateScene();
+        }
+        
         private void UpdateScene()
         {
             Dispatcher.UIThread.VerifyAccess();

+ 7 - 2
src/Avalonia.Visuals/Rendering/RendererBase.cs

@@ -7,6 +7,7 @@ namespace Avalonia.Rendering
 {
     public class RendererBase
     {
+        private readonly bool _useManualFpsCounting;
         private static int s_fontSize = 18;
         private readonly Stopwatch _stopwatch = Stopwatch.StartNew();
         private int _framesThisSecond;
@@ -14,8 +15,9 @@ namespace Avalonia.Rendering
         private FormattedText _fpsText;
         private TimeSpan _lastFpsUpdate;
 
-        public RendererBase()
+        public RendererBase(bool useManualFpsCounting = false)
         {
+            _useManualFpsCounting = useManualFpsCounting;
             _fpsText = new FormattedText
             {
                 Typeface = FontManager.Current?.GetOrAddTypeface(FontFamily.Default),
@@ -23,12 +25,15 @@ namespace Avalonia.Rendering
             };
         }
 
+        protected void FpsTick() => _framesThisSecond++;
+
         protected void RenderFps(IDrawingContextImpl context, Rect clientRect, int? layerCount)
         {
             var now = _stopwatch.Elapsed;
             var elapsed = now - _lastFpsUpdate;
 
-            ++_framesThisSecond;
+            if (!_useManualFpsCounting)
+                ++_framesThisSecond;
 
             if (elapsed.TotalSeconds > 1)
             {

+ 14 - 4
src/Avalonia.Visuals/Rendering/SceneGraph/Scene.cs

@@ -12,7 +12,7 @@ namespace Avalonia.Rendering.SceneGraph
     /// </summary>
     public class Scene : IDisposable
     {
-        private Dictionary<IVisual, IVisualNode> _index;
+        private readonly Dictionary<IVisual, IVisualNode> _index;
 
         /// <summary>
         /// Initializes a new instance of the <see cref="Scene"/> class.
@@ -83,7 +83,7 @@ namespace Avalonia.Rendering.SceneGraph
         /// <returns>The cloned scene.</returns>
         public Scene CloneScene()
         {
-            var index = new Dictionary<IVisual, IVisualNode>();
+            var index = new Dictionary<IVisual, IVisualNode>(_index.Count);
             var root = Clone((VisualNode)Root, null, index);
 
             var result = new Scene(root, index, Layers.Clone(), Generation + 1)
@@ -162,9 +162,19 @@ namespace Avalonia.Rendering.SceneGraph
 
             index.Add(result.Visual, result);
 
-            foreach (var child in source.Children)
+            var children = source.Children;
+            var childrenCount = children.Count;
+
+            if (childrenCount > 0)
             {
-                result.AddChild(Clone((VisualNode)child, result, index));
+                result.TryPreallocateChildren(childrenCount);
+
+                for (var i = 0; i < childrenCount; i++)
+                {
+                    var child = children[i];
+
+                    result.AddChild(Clone((VisualNode)child, result, index));
+                }
             }
 
             return result;

+ 16 - 4
src/Avalonia.Visuals/Rendering/SceneGraph/SceneLayers.cs

@@ -11,16 +11,28 @@ namespace Avalonia.Rendering.SceneGraph
     public class SceneLayers : IEnumerable<SceneLayer>
     {
         private readonly IVisual _root;
-        private readonly List<SceneLayer> _inner = new List<SceneLayer>();
-        private readonly Dictionary<IVisual, SceneLayer> _index = new Dictionary<IVisual, SceneLayer>();
+        private readonly List<SceneLayer> _inner;
+        private readonly Dictionary<IVisual, SceneLayer> _index;
 
         /// <summary>
         /// Initializes a new instance of the <see cref="SceneLayers"/> class.
         /// </summary>
         /// <param name="root">The scene's root visual.</param>
-        public SceneLayers(IVisual root)
+        public SceneLayers(IVisual root) : this(root, 0)
+        {
+        }
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="SceneLayers"/> class.
+        /// </summary>
+        /// <param name="root">The scene's root visual.</param>
+        /// <param name="capacity">Initial layer capacity.</param>
+        public SceneLayers(IVisual root, int capacity)
         {
             _root = root;
+
+            _inner = new List<SceneLayer>(capacity);
+            _index = new Dictionary<IVisual, SceneLayer>(capacity);
         }
 
         /// <summary>
@@ -84,7 +96,7 @@ namespace Avalonia.Rendering.SceneGraph
         /// <returns>The cloned layers.</returns>
         public SceneLayers Clone()
         {
-            var result = new SceneLayers(_root);
+            var result = new SceneLayers(_root, Count);
 
             foreach (var src in _inner)
             {

+ 19 - 6
src/Avalonia.Visuals/Rendering/SceneGraph/VisualNode.cs

@@ -1,7 +1,7 @@
 using System;
 using System.Collections.Generic;
-using System.Linq;
 using System.Reactive.Disposables;
+using Avalonia.Collections;
 using Avalonia.Media;
 using Avalonia.Platform;
 using Avalonia.Utilities;
@@ -349,6 +349,11 @@ namespace Avalonia.Rendering.SceneGraph
             context.Transform = transformRestore;
         }
 
+        internal void TryPreallocateChildren(int count)
+        {
+            EnsureChildrenCreated(count);
+        }
+
         private Rect CalculateBounds()
         {
             var result = new Rect();
@@ -362,11 +367,11 @@ namespace Avalonia.Rendering.SceneGraph
             return result;
         }
 
-        private void EnsureChildrenCreated()
+        private void EnsureChildrenCreated(int capacity = 0)
         {
             if (_children == null)
             {
-                _children = new List<IVisualNode>();
+                _children = new List<IVisualNode>(capacity);
             }
         }
 
@@ -383,7 +388,15 @@ namespace Avalonia.Rendering.SceneGraph
             }
             else if (_drawOperationsCloned)
             {
-                _drawOperations = new List<IRef<IDrawOperation>>(_drawOperations.Select(op => op.Clone()));
+                var oldDrawOperations = _drawOperations;
+
+                _drawOperations = new List<IRef<IDrawOperation>>(oldDrawOperations.Count);
+
+                foreach (var drawOperation in oldDrawOperations)
+                {
+                    _drawOperations.Add(drawOperation.Clone());
+                }
+
                 _drawOperationsRefCounter.Dispose();
                 _drawOperationsRefCounter = RefCountable.Create(CreateDisposeDrawOperations(_drawOperations));
                 _drawOperationsCloned = false;
@@ -399,9 +412,9 @@ namespace Avalonia.Rendering.SceneGraph
         /// <returns>Disposable for given draw operations.</returns>
         private static IDisposable CreateDisposeDrawOperations(List<IRef<IDrawOperation>> drawOperations)
         {
-            return Disposable.Create(() =>
+            return Disposable.Create(drawOperations, operations =>
             {
-                foreach (var operation in drawOperations)
+                foreach (var operation in operations)
                 {
                     operation.Dispose();
                 }

+ 13 - 1
src/Avalonia.Visuals/VisualTree/VisualExtensions.cs

@@ -377,7 +377,19 @@ namespace Avalonia.VisualTree
         /// </returns>
         public static bool IsVisualAncestorOf(this IVisual visual, IVisual target)
         {
-            return target.GetVisualAncestors().Any(x => x == visual);
+            IVisual current = target?.VisualParent;
+
+            while (current != null)
+            {
+                if (current == visual)
+                {
+                    return true;
+                }
+
+                current = current.VisualParent;
+            }
+
+            return false;
         }
 
         public static IEnumerable<IVisual> SortByZIndex(this IEnumerable<IVisual> elements)

+ 2 - 2
src/Avalonia.X11/NativeDialogs/GtkNativeFileDialogs.cs

@@ -112,7 +112,7 @@ namespace Avalonia.X11.NativeDialogs
                 () => ShowDialog(dialog.Title, platformImpl,
                     dialog is OpenFileDialog ? GtkFileChooserAction.Open : GtkFileChooserAction.Save,
                     (dialog as OpenFileDialog)?.AllowMultiple ?? false,
-                    Path.Combine(string.IsNullOrEmpty(dialog.InitialDirectory) ? "" : dialog.InitialDirectory,
+                    Path.Combine(string.IsNullOrEmpty(dialog.Directory) ? "" : dialog.Directory,
                         string.IsNullOrEmpty(dialog.InitialFileName) ? "" : dialog.InitialFileName), dialog.Filters));
         }
 
@@ -125,7 +125,7 @@ namespace Avalonia.X11.NativeDialogs
             return await await RunOnGlibThread(async () =>
             {
                 var res = await ShowDialog(dialog.Title, platformImpl,
-                    GtkFileChooserAction.SelectFolder, false, dialog.InitialDirectory, null);
+                    GtkFileChooserAction.SelectFolder, false, dialog.Directory, null);
                 return res?.FirstOrDefault();
             });
         }

+ 1 - 0
src/Avalonia.X11/X11Atoms.cs

@@ -156,6 +156,7 @@ namespace Avalonia.X11
         public readonly IntPtr _NET_SYSTEM_TRAY_OPCODE;
         public readonly IntPtr _NET_WM_STATE_MAXIMIZED_HORZ;
         public readonly IntPtr _NET_WM_STATE_MAXIMIZED_VERT;
+        public readonly IntPtr _NET_WM_STATE_FULLSCREEN;
         public readonly IntPtr _XEMBED;
         public readonly IntPtr _XEMBED_INFO;
         public readonly IntPtr _MOTIF_WM_HINTS;

+ 24 - 12
src/Avalonia.X11/X11Window.cs

@@ -220,16 +220,11 @@ namespace Avalonia.X11
             var decorations = MotifDecorations.Menu | MotifDecorations.Title | MotifDecorations.Border |
                               MotifDecorations.Maximize | MotifDecorations.Minimize | MotifDecorations.ResizeH;
 
-            if (_popup || _systemDecorations == SystemDecorations.None)
-            {
+            if (_popup 
+                || _systemDecorations == SystemDecorations.None) 
                 decorations = 0;
-            }
-            else if (_systemDecorations == SystemDecorations.BorderOnly)
-            {
-                decorations = MotifDecorations.Border;
-            }
 
-            if (!_canResize || _systemDecorations == SystemDecorations.BorderOnly)
+            if (!_canResize)
             {
                 functions &= ~(MotifFunctions.Resize | MotifFunctions.Maximize);
                 decorations &= ~(MotifDecorations.Maximize | MotifDecorations.ResizeH);
@@ -252,7 +247,7 @@ namespace Avalonia.X11
             var min = _minMaxSize.minSize;
             var max = _minMaxSize.maxSize;
 
-            if (!_canResize || _systemDecorations == SystemDecorations.BorderOnly)
+            if (!_canResize)
                 max = min = _realSize;
             
             if (preResize.HasValue)
@@ -333,7 +328,9 @@ namespace Avalonia.X11
             }
             else if (ev.type == XEventName.UnmapNotify)
                 _mapped = false;
-            else if (ev.type == XEventName.Expose)
+            else if (ev.type == XEventName.Expose ||
+                     (ev.type == XEventName.VisibilityNotify &&
+                      ev.VisibilityEvent.state < 2))
             {
                 if (!_triggeredExpose)
                 {
@@ -552,12 +549,21 @@ namespace Avalonia.X11
                 else if (value == WindowState.Maximized)
                 {
                     ChangeWMAtoms(false, _x11.Atoms._NET_WM_STATE_HIDDEN);
+                    ChangeWMAtoms(false, _x11.Atoms._NET_WM_STATE_FULLSCREEN);
                     ChangeWMAtoms(true, _x11.Atoms._NET_WM_STATE_MAXIMIZED_VERT,
                         _x11.Atoms._NET_WM_STATE_MAXIMIZED_HORZ);
                 }
+                else if (value == WindowState.FullScreen)
+                {
+                    ChangeWMAtoms(false, _x11.Atoms._NET_WM_STATE_HIDDEN);
+                    ChangeWMAtoms(true, _x11.Atoms._NET_WM_STATE_FULLSCREEN);
+                    ChangeWMAtoms(false, _x11.Atoms._NET_WM_STATE_MAXIMIZED_VERT,
+                        _x11.Atoms._NET_WM_STATE_MAXIMIZED_HORZ);
+                }
                 else
                 {
                     ChangeWMAtoms(false, _x11.Atoms._NET_WM_STATE_HIDDEN);
+                    ChangeWMAtoms(false, _x11.Atoms._NET_WM_STATE_FULLSCREEN);
                     ChangeWMAtoms(false, _x11.Atoms._NET_WM_STATE_MAXIMIZED_VERT,
                         _x11.Atoms._NET_WM_STATE_MAXIMIZED_HORZ);
                 }
@@ -585,6 +591,12 @@ namespace Avalonia.X11
                             break;
                         }
 
+                        if(pitems[c] == _x11.Atoms._NET_WM_STATE_FULLSCREEN)
+                        {
+                            state = WindowState.FullScreen;
+                            break;
+                        }
+
                         if (pitems[c] == _x11.Atoms._NET_WM_STATE_MAXIMIZED_HORZ ||
                             pitems[c] == _x11.Atoms._NET_WM_STATE_MAXIMIZED_VERT)
                         {
@@ -810,7 +822,7 @@ namespace Avalonia.X11
         
         public void SetSystemDecorations(SystemDecorations enabled)
         {
-            _systemDecorations = enabled;
+            _systemDecorations = enabled == SystemDecorations.Full ? SystemDecorations.Full : SystemDecorations.None;
             UpdateMotifHints();
             UpdateSizeHints(null);
         }
@@ -1052,7 +1064,7 @@ namespace Avalonia.X11
 
         void ChangeWMAtoms(bool enable, params IntPtr[] atoms)
         {
-            if (atoms.Length < 1 || atoms.Length > 4)
+            if (atoms.Length != 1 && atoms.Length != 2)
                 throw new ArgumentException();
 
             if (!_mapped)

+ 11 - 1
src/Skia/Avalonia.Skia/FormattedTextImpl.cs

@@ -149,7 +149,17 @@ namespace Avalonia.Skia
             if (index >= Text.Length || index < 0)
             {
                 var r = rects.LastOrDefault();
-                return new Rect(r.X + r.Width, r.Y, 0, _lineHeight);
+
+                var c = Text[Text.Length - 1];
+
+                switch (c)
+                {
+                    case '\n':
+                    case '\r':
+                        return new Rect(r.X, r.Y, 0, _lineHeight);
+                    default:
+                        return new Rect(r.X + r.Width, r.Y, 0, _lineHeight);
+                }
             }
             return rects[index];
         }

+ 9 - 1
src/Skia/Avalonia.Skia/PlatformRenderInterface.cs

@@ -18,7 +18,7 @@ namespace Avalonia.Skia
 
         private GRContext GrContext { get; }
 
-        public PlatformRenderInterface(ICustomSkiaGpu customSkiaGpu)
+        public PlatformRenderInterface(ICustomSkiaGpu customSkiaGpu, long maxResourceBytes = 100663296)
         {
             if (customSkiaGpu != null)
             {
@@ -26,6 +26,10 @@ namespace Avalonia.Skia
 
                 GrContext = _customSkiaGpu.GrContext;
 
+                GrContext.GetResourceCacheLimits(out var maxResources, out _);
+
+                GrContext.SetResourceCacheLimits(maxResources, maxResourceBytes);
+
                 return;
             }
 
@@ -39,6 +43,10 @@ namespace Avalonia.Skia
                     : GRGlInterface.AssembleGlesInterface((_, proc) => display.GlInterface.GetProcAddress(proc)))
                 {
                     GrContext = GRContext.Create(GRBackend.OpenGL, iface);
+
+                    GrContext.GetResourceCacheLimits(out var maxResources, out _);
+
+                    GrContext.SetResourceCacheLimits(maxResources, maxResourceBytes);
                 }
                 display.ClearContext();
             }

+ 9 - 0
src/Skia/Avalonia.Skia/SkiaOptions.cs

@@ -8,9 +8,18 @@ namespace Avalonia
     /// </summary>
     public class SkiaOptions
     {
+        public SkiaOptions()
+        {
+            MaxGpuResourceSizeBytes = 100663296; // Value taken from skia.
+        }
         /// <summary>
         /// Custom gpu factory to use. Can be used to customize behavior of Skia renderer.
         /// </summary>
         public Func<ICustomSkiaGpu> CustomGpuFactory { get; set; }
+
+        /// <summary>
+        /// The maximum number of bytes for video memory to store textures and resources.
+        /// </summary>
+        public long MaxGpuResourceSizeBytes { get; set; }
     }
 }

+ 1 - 1
src/Skia/Avalonia.Skia/SkiaPlatform.cs

@@ -18,7 +18,7 @@ namespace Avalonia.Skia
         public static void Initialize(SkiaOptions options)
         {
             var customGpu = options.CustomGpuFactory?.Invoke();
-            var renderInterface = new PlatformRenderInterface(customGpu);
+            var renderInterface = new PlatformRenderInterface(customGpu, options.MaxGpuResourceSizeBytes);
 
             AvaloniaLocator.CurrentMutable
                 .Bind<IPlatformRenderInterface>().ToConstant(renderInterface)

+ 55 - 0
src/Windows/Avalonia.Win32/Interop/TaskBarList.cs

@@ -0,0 +1,55 @@
+using System;
+using System.Runtime.InteropServices;
+using static Avalonia.Win32.Interop.UnmanagedMethods;
+
+namespace Avalonia.Win32.Interop
+{
+    internal class TaskBarList
+    {
+        private static IntPtr s_taskBarList;
+        private static HrInit s_hrInitDelegate;
+        private static MarkFullscreenWindow s_markFullscreenWindowDelegate;
+
+        /// <summary>
+        /// Ported from https://github.com/chromium/chromium/blob/master/ui/views/win/fullscreen_handler.cc
+        /// </summary>
+        /// <param name="fullscreen">Fullscreen state.</param>
+        public static unsafe void MarkFullscreen(IntPtr hwnd, bool fullscreen)
+        {
+            if (s_taskBarList == IntPtr.Zero)
+            {
+                Guid clsid = ShellIds.TaskBarList;
+                Guid iid = ShellIds.ITaskBarList2;
+
+                int result = CoCreateInstance(ref clsid, IntPtr.Zero, 1, ref iid, out s_taskBarList);
+
+                if (s_taskBarList != IntPtr.Zero)
+                {
+                    var ptr = (ITaskBarList2VTable**)s_taskBarList.ToPointer();
+
+                    if (s_hrInitDelegate is null)
+                    {
+                        s_hrInitDelegate = Marshal.GetDelegateForFunctionPointer<HrInit>((*ptr)->HrInit);
+                    }
+
+                    if (s_hrInitDelegate(s_taskBarList) != HRESULT.S_OK)
+                    {
+                        s_taskBarList = IntPtr.Zero;
+                    }
+                }
+            }
+
+            if (s_taskBarList != IntPtr.Zero)
+            {
+                var ptr = (ITaskBarList2VTable**)s_taskBarList.ToPointer();
+
+                if (s_markFullscreenWindowDelegate is null)
+                {
+                    s_markFullscreenWindowDelegate = Marshal.GetDelegateForFunctionPointer<MarkFullscreenWindow>((*ptr)->MarkFullscreenWindow);
+                }
+
+                s_markFullscreenWindowDelegate(s_taskBarList, hwnd, fullscreen);
+            }
+        }
+    }
+}

+ 23 - 1
src/Windows/Avalonia.Win32/Interop/UnmanagedMethods.cs

@@ -460,6 +460,7 @@ namespace Avalonia.Win32.Interop
             WS_SIZEFRAME = 0x40000,
             WS_SYSMENU = 0x80000,
             WS_TABSTOP = 0x10000,
+            WS_THICKFRAME = 0x40000,
             WS_VISIBLE = 0x10000000,
             WS_VSCROLL = 0x200000,
             WS_EX_DLGMODALFRAME = 0x00000001,
@@ -1146,7 +1147,10 @@ namespace Avalonia.Win32.Interop
         internal static extern int CoCreateInstance(ref Guid clsid,
             IntPtr ignore1, int ignore2, ref Guid iid, [MarshalAs(UnmanagedType.IUnknown), Out] out object pUnkOuter);
 
-        
+        [DllImport("ole32.dll", PreserveSig = true)]
+        internal static extern int CoCreateInstance(ref Guid clsid,
+            IntPtr ignore1, int ignore2, ref Guid iid, [Out] out IntPtr pUnkOuter);
+
         [DllImport("shell32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
         internal static extern int SHCreateItemFromParsingName([MarshalAs(UnmanagedType.LPWStr)] string pszPath, IntPtr pbc, ref Guid riid, [MarshalAs(UnmanagedType.Interface)] out IShellItem ppv);
 
@@ -1642,6 +1646,8 @@ namespace Avalonia.Win32.Interop
             public static readonly Guid SaveFileDialog = Guid.Parse("C0B4E2F3-BA21-4773-8DBA-335EC946EB8B");
             public static readonly Guid IFileDialog = Guid.Parse("42F85136-DB7E-439C-85F1-E4075D135FC8");
             public static readonly Guid IShellItem = Guid.Parse("43826D1E-E718-42EE-BC55-A1E261C37BFE");
+            public static readonly Guid TaskBarList = Guid.Parse("56FDF344-FD6D-11D0-958A-006097C9A090");
+            public static readonly Guid ITaskBarList2 = Guid.Parse("ea1afb91-9e28-4b86-90e9-9e9f8a5eefaf");
         }
 
         [ComImport(), Guid("42F85136-DB7E-439C-85F1-E4075D135FC8"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
@@ -1874,6 +1880,22 @@ namespace Avalonia.Win32.Interop
             [MarshalAs(UnmanagedType.LPWStr)]
             public string pszSpec;
         }
+
+        public delegate void MarkFullscreenWindow(IntPtr This, IntPtr hwnd, [MarshalAs(UnmanagedType.Bool)] bool fullscreen);
+        public delegate HRESULT HrInit(IntPtr This);
+
+        public struct ITaskBarList2VTable
+        {
+            public IntPtr IUnknown1;
+            public IntPtr IUnknown2;
+            public IntPtr IUnknown3;
+            public IntPtr HrInit;
+            public IntPtr AddTab;
+            public IntPtr DeleteTab;
+            public IntPtr ActivateTab;
+            public IntPtr SetActiveAlt;
+            public IntPtr MarkFullscreenWindow;
+        }
     }
 
     [Flags]

+ 4 - 7
src/Windows/Avalonia.Win32/ScreenImpl.cs

@@ -8,7 +8,7 @@ namespace Avalonia.Win32
 {
     public class ScreenImpl : IScreenImpl
     {
-        public  int ScreenCount
+        public int ScreenCount
         {
             get => GetSystemMetrics(SystemMetric.SM_CMONITORS);
         }
@@ -33,7 +33,7 @@ namespace Avalonia.Win32
                                 var shcore = LoadLibrary("shcore.dll");
                                 var method = GetProcAddress(shcore, nameof(GetDpiForMonitor));
                                 if (method != IntPtr.Zero)
-                                { 
+                                {
                                     GetDpiForMonitor(monitor, MONITOR_DPI_TYPE.MDT_EFFECTIVE_DPI, out var x, out _);
                                     dpi = (double)x;
                                 }
@@ -51,11 +51,8 @@ namespace Avalonia.Win32
 
                                 RECT bounds = monitorInfo.rcMonitor;
                                 RECT workingArea = monitorInfo.rcWork;
-                                PixelRect avaloniaBounds = new PixelRect(bounds.left, bounds.top, bounds.right - bounds.left,
-                                    bounds.bottom - bounds.top);
-                                PixelRect avaloniaWorkArea =
-                                    new PixelRect(workingArea.left, workingArea.top, workingArea.right - workingArea.left,
-                                        workingArea.bottom - workingArea.top);
+                                PixelRect avaloniaBounds = bounds.ToPixelRect();
+                                PixelRect avaloniaWorkArea = workingArea.ToPixelRect();
                                 screens[index] =
                                     new WinScreen(dpi / 96.0d, avaloniaBounds, avaloniaWorkArea, monitorInfo.dwFlags == 1,
                                         monitor);

+ 9 - 9
src/Windows/Avalonia.Win32/SystemDialogImpl.cs

@@ -24,7 +24,7 @@ namespace Avalonia.Win32
 
                 Guid clsid = dialog is OpenFileDialog ? UnmanagedMethods.ShellIds.OpenFileDialog : UnmanagedMethods.ShellIds.SaveFileDialog;
                 Guid iid = UnmanagedMethods.ShellIds.IFileDialog;
-                UnmanagedMethods.CoCreateInstance(ref clsid, IntPtr.Zero, 1, ref iid, out var unk);
+                UnmanagedMethods.CoCreateInstance(ref clsid, IntPtr.Zero, 1, ref iid, out object unk);
                 var frm = (UnmanagedMethods.IFileDialog)unk;
 
                 var openDialog = dialog as OpenFileDialog;
@@ -56,11 +56,11 @@ namespace Avalonia.Win32
                 frm.SetFileTypes((uint)filters.Count, filters.ToArray());
                 frm.SetFileTypeIndex(0);
 
-                if (dialog.InitialDirectory != null)
+                if (dialog.Directory != null)
                 {
                     UnmanagedMethods.IShellItem directoryShellItem;
                     Guid riid = UnmanagedMethods.ShellIds.IShellItem;
-                    if (UnmanagedMethods.SHCreateItemFromParsingName(dialog.InitialDirectory, IntPtr.Zero, ref riid, out directoryShellItem) == (uint)UnmanagedMethods.HRESULT.S_OK)
+                    if (UnmanagedMethods.SHCreateItemFromParsingName(dialog.Directory, IntPtr.Zero, ref riid, out directoryShellItem) == (uint)UnmanagedMethods.HRESULT.S_OK)
                     {
                         frm.SetFolder(directoryShellItem);
                         frm.SetDefaultFolder(directoryShellItem);
@@ -105,30 +105,30 @@ namespace Avalonia.Win32
 
                 var hWnd = parent?.PlatformImpl?.Handle?.Handle ?? IntPtr.Zero;
                 Guid clsid = UnmanagedMethods.ShellIds.OpenFileDialog;
-                Guid iid  = UnmanagedMethods.ShellIds.IFileDialog;
+                Guid iid = UnmanagedMethods.ShellIds.IFileDialog;
 
-                UnmanagedMethods.CoCreateInstance(ref clsid, IntPtr.Zero, 1, ref iid, out var unk);
+                UnmanagedMethods.CoCreateInstance(ref clsid, IntPtr.Zero, 1, ref iid, out object unk);
                 var frm = (UnmanagedMethods.IFileDialog)unk;
                 uint options;
                 frm.GetOptions(out options);
                 options |= (uint)(UnmanagedMethods.FOS.FOS_PICKFOLDERS | DefaultDialogOptions);
                 frm.SetOptions(options);
 
-                if (dialog.InitialDirectory != null)
+                if (dialog.Directory != null)
                 {
                     UnmanagedMethods.IShellItem directoryShellItem;
                     Guid riid = UnmanagedMethods.ShellIds.IShellItem;
-                    if (UnmanagedMethods.SHCreateItemFromParsingName(dialog.InitialDirectory, IntPtr.Zero, ref riid, out directoryShellItem) == (uint)UnmanagedMethods.HRESULT.S_OK)
+                    if (UnmanagedMethods.SHCreateItemFromParsingName(dialog.Directory, IntPtr.Zero, ref riid, out directoryShellItem) == (uint)UnmanagedMethods.HRESULT.S_OK)
                     {
                         frm.SetFolder(directoryShellItem);
                     }
                 }
 
-                if (dialog.DefaultDirectory != null)
+                if (dialog.Directory != null)
                 {
                     UnmanagedMethods.IShellItem directoryShellItem;
                     Guid riid = UnmanagedMethods.ShellIds.IShellItem;
-                    if (UnmanagedMethods.SHCreateItemFromParsingName(dialog.DefaultDirectory, IntPtr.Zero, ref riid, out directoryShellItem) == (uint)UnmanagedMethods.HRESULT.S_OK)
+                    if (UnmanagedMethods.SHCreateItemFromParsingName(dialog.Directory, IntPtr.Zero, ref riid, out directoryShellItem) == (uint)UnmanagedMethods.HRESULT.S_OK)
                     {
                         frm.SetDefaultFolder(directoryShellItem);
                     }

+ 13 - 0
src/Windows/Avalonia.Win32/Win32TypeExtensions.cs

@@ -0,0 +1,13 @@
+using static Avalonia.Win32.Interop.UnmanagedMethods;
+
+namespace Avalonia.Win32
+{
+    internal static class Win32TypeExtensions
+    {
+        public static PixelRect ToPixelRect(this RECT rect)
+        {
+            return new PixelRect(rect.left, rect.top, rect.right - rect.left,
+                    rect.bottom - rect.top);
+        }
+    }
+}

+ 166 - 26
src/Windows/Avalonia.Win32/WindowImpl.cs

@@ -37,10 +37,14 @@ namespace Avalonia.Win32
                 { WindowEdge.West, HitTestValues.HTLEFT }
             };
 
+        private SavedWindowInfo _savedWindowInfo;
+        private bool _isFullScreenActive;
+
 #if USE_MANAGED_DRAG
         private readonly ManagedWindowResizeDragHelper _managedDrag;
 #endif
 
+        private const WindowStyles WindowStateMask = (WindowStyles.WS_MAXIMIZE | WindowStyles.WS_MINIMIZE);
         private readonly List<WindowImpl> _disabledBy;
         private readonly TouchDevice _touchDevice;
         private readonly MouseDevice _mouseDevice;
@@ -82,7 +86,9 @@ namespace Avalonia.Win32
 
             _windowProperties = new WindowProperties
             {
-                ShowInTaskbar = false, IsResizable = true, Decorations = SystemDecorations.Full
+                ShowInTaskbar = false,
+                IsResizable = true,
+                Decorations = SystemDecorations.Full
             };
             _rendererLock = new ManagedDeferredRendererLock();
 
@@ -538,27 +544,98 @@ namespace Avalonia.Win32
             }
         }
 
+        /// <summary>
+        /// Ported from https://github.com/chromium/chromium/blob/master/ui/views/win/fullscreen_handler.cc
+        /// Method must only be called from inside UpdateWindowProperties.
+        /// </summary>
+        /// <param name="fullscreen"></param>
+        private void SetFullScreen(bool fullscreen)
+        {
+            if (fullscreen)
+            {
+                GetWindowRect(_hwnd, out var windowRect);
+                _savedWindowInfo.WindowRect = windowRect;
+
+                var current = GetStyle();
+                var currentEx = GetExtendedStyle();
+
+                _savedWindowInfo.Style = current;
+                _savedWindowInfo.ExStyle = currentEx;
+
+                // Set new window style and size.
+                SetStyle(current & ~(WindowStyles.WS_CAPTION | WindowStyles.WS_THICKFRAME), false);
+                SetExtendedStyle(currentEx & ~(WindowStyles.WS_EX_DLGMODALFRAME | WindowStyles.WS_EX_WINDOWEDGE | WindowStyles.WS_EX_CLIENTEDGE | WindowStyles.WS_EX_STATICEDGE), false);
+
+                // On expand, if we're given a window_rect, grow to it, otherwise do
+                // not resize.
+                MONITORINFO monitor_info = MONITORINFO.Create();
+                GetMonitorInfo(MonitorFromWindow(_hwnd, MONITOR.MONITOR_DEFAULTTONEAREST), ref monitor_info);
+
+                var window_rect = monitor_info.rcMonitor.ToPixelRect();
+
+                SetWindowPos(_hwnd, IntPtr.Zero, window_rect.X, window_rect.Y,
+                             window_rect.Width, window_rect.Height,
+                             SetWindowPosFlags.SWP_NOZORDER | SetWindowPosFlags.SWP_NOACTIVATE | SetWindowPosFlags.SWP_FRAMECHANGED);
+
+                _isFullScreenActive = true;
+            }
+            else
+            {
+                // Reset original window style and size.  The multiple window size/moves
+                // here are ugly, but if SetWindowPos() doesn't redraw, the taskbar won't be
+                // repainted.  Better-looking methods welcome.
+                _isFullScreenActive = false;
+
+                var windowStates = GetWindowStateStyles();
+                SetStyle((_savedWindowInfo.Style & ~WindowStateMask) | windowStates, false);
+                SetExtendedStyle(_savedWindowInfo.ExStyle, false);
+
+                // On restore, resize to the previous saved rect size.
+                var new_rect = _savedWindowInfo.WindowRect.ToPixelRect();
+
+                SetWindowPos(_hwnd, IntPtr.Zero, new_rect.X, new_rect.Y, new_rect.Width,
+                             new_rect.Height,
+                            SetWindowPosFlags.SWP_NOZORDER | SetWindowPosFlags.SWP_NOACTIVATE | SetWindowPosFlags.SWP_FRAMECHANGED);
+
+                UpdateWindowProperties(_windowProperties, true);
+            }
+
+            TaskBarList.MarkFullscreen(_hwnd, fullscreen);
+        }
+
         private void ShowWindow(WindowState state)
         {
             ShowWindowCommand command;
 
+            var newWindowProperties = _windowProperties;
+
             switch (state)
             {
                 case WindowState.Minimized:
+                    newWindowProperties.IsFullScreen = false;
                     command = ShowWindowCommand.Minimize;
                     break;
                 case WindowState.Maximized:
+                    newWindowProperties.IsFullScreen = false;
                     command = ShowWindowCommand.Maximize;
                     break;
 
                 case WindowState.Normal:
+                    newWindowProperties.IsFullScreen = false;
                     command = ShowWindowCommand.Restore;
                     break;
 
+                case WindowState.FullScreen:
+                    newWindowProperties.IsFullScreen = true;
+                    UpdateWindowProperties(newWindowProperties);
+                    return;
+
                 default:
                     throw new ArgumentException("Invalid WindowState.");
             }
 
+            UpdateWindowProperties(newWindowProperties);
+
             UnmanagedMethods.ShowWindow(_hwnd, command);
 
             if (state == WindowState.Maximized)
@@ -590,22 +667,69 @@ namespace Avalonia.Win32
                     SetWindowPos(_hwnd, WindowPosZOrder.HWND_NOTOPMOST, x, y, cx, cy, SetWindowPosFlags.SWP_SHOWWINDOW);
                 }
             }
+        }        
+
+        private WindowStyles GetWindowStateStyles ()
+        {
+            return GetStyle() & WindowStateMask;
         }
 
-        private WindowStyles GetStyle() => (WindowStyles)GetWindowLong(_hwnd, (int)WindowLongParam.GWL_STYLE);
+        private WindowStyles GetStyle()
+        {
+            if (_isFullScreenActive)
+            {
+                return _savedWindowInfo.Style;
+            }
+            else
+            {
+                return (WindowStyles)GetWindowLong(_hwnd, (int)WindowLongParam.GWL_STYLE);
+            }
+        }
 
-        private WindowStyles GetExtendedStyle() => (WindowStyles)GetWindowLong(_hwnd, (int)WindowLongParam.GWL_EXSTYLE);
+        private WindowStyles GetExtendedStyle()
+        {
+            if (_isFullScreenActive)
+            {
+                return _savedWindowInfo.ExStyle;
+            }
+            else
+            {
+                return (WindowStyles)GetWindowLong(_hwnd, (int)WindowLongParam.GWL_EXSTYLE);
+            }
+        }
 
-        private void SetStyle(WindowStyles style) => SetWindowLong(_hwnd, (int)WindowLongParam.GWL_STYLE, (uint)style);
+        private void SetStyle(WindowStyles style, bool save = true)
+        {
+            if (save)
+            {
+                _savedWindowInfo.Style = style;
+            }
 
-        private void SetExtendedStyle(WindowStyles style) => SetWindowLong(_hwnd, (int)WindowLongParam.GWL_EXSTYLE, (uint)style);
+            if (!_isFullScreenActive)
+            {
+                SetWindowLong(_hwnd, (int)WindowLongParam.GWL_STYLE, (uint)style);
+            }
+        }
+
+        private void SetExtendedStyle(WindowStyles style, bool save = true)
+        {
+            if (save)
+            {
+                _savedWindowInfo.ExStyle = style;
+            }
+
+            if (!_isFullScreenActive)
+            {
+                SetWindowLong(_hwnd, (int)WindowLongParam.GWL_EXSTYLE, (uint)style);
+            }
+        }
 
         private void UpdateEnabled()
         {
             EnableWindow(_hwnd, _disabledBy.Count == 0);
         }
 
-        private void UpdateWindowProperties(WindowProperties newProperties)
+        private void UpdateWindowProperties(WindowProperties newProperties, bool forceChanges = false)
         {
             var oldProperties = _windowProperties;
 
@@ -613,7 +737,7 @@ namespace Avalonia.Win32
             // according to the new values already.
             _windowProperties = newProperties;
 
-            if (oldProperties.ShowInTaskbar != newProperties.ShowInTaskbar)
+            if ((oldProperties.ShowInTaskbar != newProperties.ShowInTaskbar) || forceChanges)
             {
                 var exStyle = GetExtendedStyle();
 
@@ -632,7 +756,7 @@ namespace Avalonia.Win32
                 // Otherwise it will still show in the taskbar.
             }
 
-            if (oldProperties.IsResizable != newProperties.IsResizable)
+            if ((oldProperties.IsResizable != newProperties.IsResizable) || forceChanges)
             {
                 var style = GetStyle();
 
@@ -648,7 +772,12 @@ namespace Avalonia.Win32
                 SetStyle(style);
             }
 
-            if (oldProperties.Decorations != newProperties.Decorations)
+            if (oldProperties.IsFullScreen != newProperties.IsFullScreen)
+            {
+                SetFullScreen(newProperties.IsFullScreen);
+            }
+
+            if ((oldProperties.Decorations != newProperties.Decorations) || forceChanges)
             {
                 var style = GetStyle();
 
@@ -663,30 +792,33 @@ namespace Avalonia.Win32
                     style &= ~fullDecorationFlags;
                 }
 
-                var margins = new MARGINS
+                SetStyle(style);
+
+                if (!_isFullScreenActive)
                 {
-                    cyBottomHeight = newProperties.Decorations == SystemDecorations.BorderOnly ? 1 : 0
-                };
+                    var margins = new MARGINS
+                    {
+                        cyBottomHeight = newProperties.Decorations == SystemDecorations.BorderOnly ? 1 : 0
+                    };
 
-                DwmExtendFrameIntoClientArea(_hwnd, ref margins);
+                    DwmExtendFrameIntoClientArea(_hwnd, ref margins);
 
-                GetClientRect(_hwnd, out var oldClientRect);
-                var oldClientRectOrigin = new POINT();
-                ClientToScreen(_hwnd, ref oldClientRectOrigin);
-                oldClientRect.Offset(oldClientRectOrigin);
+                    GetClientRect(_hwnd, out var oldClientRect);
+                    var oldClientRectOrigin = new POINT();
+                    ClientToScreen(_hwnd, ref oldClientRectOrigin);
+                    oldClientRect.Offset(oldClientRectOrigin);
 
-                SetStyle(style);
+                    var newRect = oldClientRect;
 
-                var newRect = oldClientRect;
+                    if (newProperties.Decorations == SystemDecorations.Full)
+                    {
+                        AdjustWindowRectEx(ref newRect, (uint)style, false, (uint)GetExtendedStyle());
+                    }
 
-                if (newProperties.Decorations == SystemDecorations.Full)
-                {
-                    AdjustWindowRectEx(ref newRect, (uint)style, false, (uint)GetExtendedStyle());
+                    SetWindowPos(_hwnd, IntPtr.Zero, newRect.left, newRect.top, newRect.Width, newRect.Height,
+                        SetWindowPosFlags.SWP_NOZORDER | SetWindowPosFlags.SWP_NOACTIVATE |
+                        SetWindowPosFlags.SWP_FRAMECHANGED);
                 }
-
-                SetWindowPos(_hwnd, IntPtr.Zero, newRect.left, newRect.top, newRect.Width, newRect.Height,
-                    SetWindowPosFlags.SWP_NOZORDER | SetWindowPosFlags.SWP_NOACTIVATE |
-                    SetWindowPosFlags.SWP_FRAMECHANGED);
             }
         }
 
@@ -713,11 +845,19 @@ namespace Avalonia.Win32
 
         IntPtr EglGlPlatformSurface.IEglWindowGlPlatformSurfaceInfo.Handle => Handle.Handle;
 
+        private struct SavedWindowInfo
+        {
+            public WindowStyles Style { get; set; }
+            public WindowStyles ExStyle { get; set; }
+            public RECT WindowRect { get; set; }
+        };
+
         private struct WindowProperties
         {
             public bool ShowInTaskbar;
             public bool IsResizable;
             public SystemDecorations Decorations;
+            public bool IsFullScreen;
         }
     }
 }

+ 3 - 8
tests/Avalonia.Controls.UnitTests/AppBuilderTests.cs

@@ -1,12 +1,6 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Text;
-using System.Threading.Tasks;
-using Xunit;
-using Avalonia.Controls.UnitTests;
+using Avalonia.Controls.UnitTests;
 using Avalonia.Platform;
-using Avalonia.UnitTests;
+using Xunit;
 
 [assembly: ExportAvaloniaModule("DefaultModule", typeof(AppBuilderTests.DefaultModule))]
 [assembly: ExportAvaloniaModule("RenderingModule", typeof(AppBuilderTests.Direct2DModule), ForRenderingSubsystem = "Direct2D1")]
@@ -16,6 +10,7 @@ using Avalonia.UnitTests;
 
 namespace Avalonia.Controls.UnitTests
 {
+    using AppBuilder = Avalonia.UnitTests.AppBuilder;
 
     public class AppBuilderTests
     {

+ 1 - 0
tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.csproj

@@ -21,6 +21,7 @@
     <ProjectReference Include="..\..\src\Avalonia.Input\Avalonia.Input.csproj" />
     <ProjectReference Include="..\..\src\Avalonia.Interactivity\Avalonia.Interactivity.csproj" />
     <ProjectReference Include="..\..\src\Avalonia.Layout\Avalonia.Layout.csproj" />
+    <ProjectReference Include="..\..\src\Avalonia.ReactiveUI\Avalonia.ReactiveUI.csproj" />
     <ProjectReference Include="..\..\src\Avalonia.Visuals\Avalonia.Visuals.csproj" />
     <ProjectReference Include="..\..\src\Avalonia.Styling\Avalonia.Styling.csproj" />
     <ProjectReference Include="..\Avalonia.UnitTests\Avalonia.UnitTests.csproj" />

+ 1 - 0
tests/Avalonia.Controls.UnitTests/ButtonTests.cs

@@ -9,6 +9,7 @@ using Avalonia.UnitTests;
 using Avalonia.VisualTree;
 using Moq;
 using Xunit;
+using MouseButton = Avalonia.Input.MouseButton;
 
 namespace Avalonia.Controls.UnitTests
 {

+ 1 - 1
tests/Avalonia.Controls.UnitTests/CalendarTests.cs

@@ -15,7 +15,7 @@ namespace Avalonia.Controls.UnitTests
                 first.Day == second.Day;
         }
 
-        [Fact]
+        [Fact(Skip ="FIX ME ASAP")]
         public void SelectedDatesChanged_Should_Fire_When_SelectedDate_Set()
         {
             bool handled = false;

+ 3 - 3
tests/Avalonia.Controls.UnitTests/CarouselTests.cs

@@ -265,7 +265,7 @@ namespace Avalonia.Controls.UnitTests
         }
 
         [Fact]
-        public void Selected_Item_Changes_To_NextAvailable_Item_If_SelectedItem_Is_Removed_From_Middle()
+        public void Selected_Item_Changes_To_First_Item_If_SelectedItem_Is_Removed_From_Middle()
         {
             var items = new ObservableCollection<string>
             {
@@ -288,8 +288,8 @@ namespace Avalonia.Controls.UnitTests
 
             items.RemoveAt(1);
 
-            Assert.Equal(1, target.SelectedIndex);
-            Assert.Equal("FooBar", target.SelectedItem);
+            Assert.Equal(0, target.SelectedIndex);
+            Assert.Equal("Foo", target.SelectedItem);
         }
 
         private Control CreateTemplate(Carousel control, INameScope scope)

+ 1 - 1
tests/Avalonia.Controls.UnitTests/DatePickerTests.cs

@@ -24,7 +24,7 @@ namespace Avalonia.Controls.UnitTests
                 first.Day == second.Day;
         }
 
-        [Fact]
+        [Fact(Skip = "FIX ME ASAP")]
         public void SelectedDateChanged_Should_Fire_When_SelectedDate_Set()
         {
             using (UnitTestApplication.Start(Services))

+ 95 - 0
tests/Avalonia.Controls.UnitTests/IndexPathTests.cs

@@ -0,0 +1,95 @@
+using Xunit;
+
+namespace Avalonia.Controls.UnitTests
+{
+    public class IndexPathTests
+    {
+        [Fact]
+        public void Simple_Index()
+        {
+            var a = new IndexPath(1);
+
+            Assert.Equal(1, a.GetSize());
+            Assert.Equal(1, a.GetAt(0));
+        }
+
+        [Fact]
+        public void Equal_Paths()
+        {
+            var a = new IndexPath(1);
+            var b = new IndexPath(1);
+
+            Assert.True(a == b);
+            Assert.False(a != b);
+            Assert.True(a.Equals(b));
+            Assert.Equal(0, a.CompareTo(b));
+            Assert.Equal(a.GetHashCode(), b.GetHashCode());
+        }
+
+        [Fact]
+        public void Unequal_Paths()
+        {
+            var a = new IndexPath(1);
+            var b = new IndexPath(2);
+
+            Assert.False(a == b);
+            Assert.True(a != b);
+            Assert.False(a.Equals(b));
+            Assert.Equal(-1, a.CompareTo(b));
+            Assert.NotEqual(a.GetHashCode(), b.GetHashCode());
+        }
+
+        [Fact]
+        public void Equal_Null_Path()
+        {
+            var a = new IndexPath(null);
+            var b = new IndexPath(null);
+
+            Assert.True(a == b);
+            Assert.False(a != b);
+            Assert.True(a.Equals(b));
+            Assert.Equal(0, a.CompareTo(b));
+            Assert.Equal(a.GetHashCode(), b.GetHashCode());
+        }
+
+        [Fact]
+        public void Unequal_Null_Path()
+        {
+            var a = new IndexPath(null);
+            var b = new IndexPath(2);
+
+            Assert.False(a == b);
+            Assert.True(a != b);
+            Assert.False(a.Equals(b));
+            Assert.Equal(-1, a.CompareTo(b));
+            Assert.NotEqual(a.GetHashCode(), b.GetHashCode());
+        }
+
+        [Fact]
+        public void Default_Is_Null_Path()
+        {
+            var a = new IndexPath(null);
+            var b = default(IndexPath);
+
+            Assert.True(a == b);
+            Assert.False(a != b);
+            Assert.True(a.Equals(b));
+            Assert.Equal(0, a.CompareTo(b));
+            Assert.Equal(a.GetHashCode(), b.GetHashCode());
+        }
+
+        [Fact]
+        public void Null_Equality()
+        {
+            var a = new IndexPath(null);
+            var b = new IndexPath(1);
+
+            // Implementing operator == on a struct automatically implements an operator which
+            // accepts null, so make sure this does something useful.
+            Assert.True(a == null);
+            Assert.False(a != null);
+            Assert.False(b == null);
+            Assert.True(b != null);
+        }
+    }
+}

+ 307 - 0
tests/Avalonia.Controls.UnitTests/IndexRangeTests.cs

@@ -0,0 +1,307 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Xunit;
+
+namespace Avalonia.Controls.UnitTests
+{
+    public class IndexRangeTests
+    {
+        [Fact]
+        public void Add_Should_Add_Range_To_Empty_List()
+        {
+            var ranges = new List<IndexRange>();
+            var selected = new List<IndexRange>();
+            var result = IndexRange.Add(ranges, new IndexRange(0, 4), selected);
+
+            Assert.Equal(5, result);
+            Assert.Equal(new[] { new IndexRange(0, 4) }, ranges);
+            Assert.Equal(new[] { new IndexRange(0, 4) }, selected);
+        }
+
+        [Fact]
+        public void Add_Should_Add_Non_Intersecting_Range_At_End()
+        {
+            var ranges = new List<IndexRange> { new IndexRange(0, 4) };
+            var selected = new List<IndexRange>();
+            var result = IndexRange.Add(ranges, new IndexRange(8, 10), selected);
+
+            Assert.Equal(3, result);
+            Assert.Equal(new[] { new IndexRange(0, 4), new IndexRange(8, 10) }, ranges);
+            Assert.Equal(new[] { new IndexRange(8, 10) }, selected);
+        }
+
+        [Fact]
+        public void Add_Should_Add_Non_Intersecting_Range_At_Beginning()
+        {
+            var ranges = new List<IndexRange> { new IndexRange(8, 10) };
+            var selected = new List<IndexRange>();
+            var result = IndexRange.Add(ranges, new IndexRange(0, 4), selected);
+
+            Assert.Equal(5, result);
+            Assert.Equal(new[] { new IndexRange(0, 4), new IndexRange(8, 10) }, ranges);
+            Assert.Equal(new[] { new IndexRange(0, 4) }, selected);
+        }
+
+        [Fact]
+        public void Add_Should_Add_Non_Intersecting_Range_In_Middle()
+        {
+            var ranges = new List<IndexRange> { new IndexRange(0, 4), new IndexRange(14, 16) };
+            var selected = new List<IndexRange>();
+            var result = IndexRange.Add(ranges, new IndexRange(8, 10), selected);
+
+            Assert.Equal(3, result);
+            Assert.Equal(new[] { new IndexRange(0, 4), new IndexRange(8, 10), new IndexRange(14, 16) }, ranges);
+            Assert.Equal(new[] { new IndexRange(8, 10) }, selected);
+        }
+
+        [Fact]
+        public void Add_Should_Add_Intersecting_Range_Start()
+        {
+            var ranges = new List<IndexRange> { new IndexRange(8, 10) };
+            var selected = new List<IndexRange>();
+            var result = IndexRange.Add(ranges, new IndexRange(6, 9), selected);
+
+            Assert.Equal(2, result);
+            Assert.Equal(new[] { new IndexRange(6, 10) }, ranges);
+            Assert.Equal(new[] { new IndexRange(6, 7) }, selected);
+        }
+
+        [Fact]
+        public void Add_Should_Add_Intersecting_Range_End()
+        {
+            var ranges = new List<IndexRange> { new IndexRange(8, 10) };
+            var selected = new List<IndexRange>();
+            var result = IndexRange.Add(ranges, new IndexRange(9, 12), selected);
+
+            Assert.Equal(2, result);
+            Assert.Equal(new[] { new IndexRange(8, 12) }, ranges);
+            Assert.Equal(new[] { new IndexRange(11, 12) }, selected);
+        }
+
+        [Fact]
+        public void Add_Should_Add_Intersecting_Range_Both()
+        {
+            var ranges = new List<IndexRange> { new IndexRange(8, 10) };
+            var selected = new List<IndexRange>();
+            var result = IndexRange.Add(ranges, new IndexRange(6, 12), selected);
+
+            Assert.Equal(4, result);
+            Assert.Equal(new[] { new IndexRange(6, 12) }, ranges);
+            Assert.Equal(new[] { new IndexRange(6, 7), new IndexRange(11, 12) }, selected);
+        }
+
+        [Fact]
+        public void Add_Should_Join_Two_Intersecting_Ranges()
+        {
+            var ranges = new List<IndexRange> { new IndexRange(8, 10), new IndexRange(12, 14) };
+            var selected = new List<IndexRange>();
+            var result = IndexRange.Add(ranges, new IndexRange(8, 14), selected);
+
+            Assert.Equal(1, result);
+            Assert.Equal(new[] { new IndexRange(8, 14) }, ranges);
+            Assert.Equal(new[] { new IndexRange(11, 11) }, selected);
+        }
+
+        [Fact]
+        public void Add_Should_Join_Two_Intersecting_Ranges_And_Add_Ranges()
+        {
+            var ranges = new List<IndexRange> { new IndexRange(8, 10), new IndexRange(12, 14) };
+            var selected = new List<IndexRange>();
+            var result = IndexRange.Add(ranges, new IndexRange(6, 18), selected);
+
+            Assert.Equal(7, result);
+            Assert.Equal(new[] { new IndexRange(6, 18) }, ranges);
+            Assert.Equal(new[] { new IndexRange(6, 7), new IndexRange(11, 11), new IndexRange(15, 18) }, selected);
+        }
+
+        [Fact]
+        public void Add_Should_Not_Add_Already_Selected_Range()
+        {
+            var ranges = new List<IndexRange> { new IndexRange(8, 10) };
+            var selected = new List<IndexRange>();
+            var result = IndexRange.Add(ranges, new IndexRange(9, 10), selected);
+
+            Assert.Equal(0, result);
+            Assert.Equal(new[] { new IndexRange(8, 10) }, ranges);
+            Assert.Empty(selected);
+        }
+
+        [Fact]
+        public void Remove_Should_Remove_Entire_Range()
+        {
+            var ranges = new List<IndexRange> { new IndexRange(8, 10) };
+            var deselected = new List<IndexRange>();
+            var result = IndexRange.Remove(ranges, new IndexRange(8, 10), deselected);
+
+            Assert.Equal(3, result);
+            Assert.Empty(ranges);
+            Assert.Equal(new[] { new IndexRange(8, 10) }, deselected);
+        }
+
+        [Fact]
+        public void Remove_Should_Remove_Start_Of_Range()
+        {
+            var ranges = new List<IndexRange> { new IndexRange(8, 12) };
+            var deselected = new List<IndexRange>();
+            var result = IndexRange.Remove(ranges, new IndexRange(8, 10), deselected);
+
+            Assert.Equal(3, result);
+            Assert.Equal(new[] { new IndexRange(11, 12) }, ranges);
+            Assert.Equal(new[] { new IndexRange(8, 10) }, deselected);
+        }
+
+        [Fact]
+        public void Remove_Should_Remove_End_Of_Range()
+        {
+            var ranges = new List<IndexRange> { new IndexRange(8, 12) };
+            var deselected = new List<IndexRange>();
+            var result = IndexRange.Remove(ranges, new IndexRange(10, 12), deselected);
+
+            Assert.Equal(3, result);
+            Assert.Equal(new[] { new IndexRange(8, 9) }, ranges);
+            Assert.Equal(new[] { new IndexRange(10, 12) }, deselected);
+        }
+
+        [Fact]
+        public void Remove_Should_Remove_Overlapping_End_Of_Range()
+        {
+            var ranges = new List<IndexRange> { new IndexRange(8, 12) };
+            var deselected = new List<IndexRange>();
+            var result = IndexRange.Remove(ranges, new IndexRange(10, 14), deselected);
+
+            Assert.Equal(3, result);
+            Assert.Equal(new[] { new IndexRange(8, 9) }, ranges);
+            Assert.Equal(new[] { new IndexRange(10, 12) }, deselected);
+        }
+
+        [Fact]
+        public void Remove_Should_Remove_Middle_Of_Range()
+        {
+            var ranges = new List<IndexRange> { new IndexRange(10, 20) };
+            var deselected = new List<IndexRange>();
+            var result = IndexRange.Remove(ranges, new IndexRange(12, 16), deselected);
+
+            Assert.Equal(5, result);
+            Assert.Equal(new[] { new IndexRange(10, 11), new IndexRange(17, 20) }, ranges);
+            Assert.Equal(new[] { new IndexRange(12, 16) }, deselected);
+        }
+
+        [Fact]
+        public void Remove_Should_Remove_Multiple_Ranges()
+        {
+            var ranges = new List<IndexRange> { new IndexRange(8, 10), new IndexRange(12, 14), new IndexRange(16, 18) };
+            var deselected = new List<IndexRange>();
+            var result = IndexRange.Remove(ranges, new IndexRange(6, 15), deselected);
+
+            Assert.Equal(6, result);
+            Assert.Equal(new[] { new IndexRange(16, 18) }, ranges);
+            Assert.Equal(new[] { new IndexRange(8, 10), new IndexRange(12, 14) }, deselected);
+        }
+
+        [Fact]
+        public void Remove_Should_Remove_Multiple_And_Partial_Ranges_1()
+        {
+            var ranges = new List<IndexRange> { new IndexRange(8, 10), new IndexRange(12, 14), new IndexRange(16, 18) };
+            var deselected = new List<IndexRange>();
+            var result = IndexRange.Remove(ranges, new IndexRange(9, 15), deselected);
+
+            Assert.Equal(5, result);
+            Assert.Equal(new[] { new IndexRange(8, 8), new IndexRange(16, 18) }, ranges);
+            Assert.Equal(new[] { new IndexRange(9, 10), new IndexRange(12, 14) }, deselected);
+        }
+
+        [Fact]
+        public void Remove_Should_Remove_Multiple_And_Partial_Ranges_2()
+        {
+            var ranges = new List<IndexRange> { new IndexRange(8, 10), new IndexRange(12, 14), new IndexRange(16, 18) };
+            var deselected = new List<IndexRange>();
+            var result = IndexRange.Remove(ranges, new IndexRange(8, 13), deselected);
+
+            Assert.Equal(5, result);
+            Assert.Equal(new[] { new IndexRange(14, 14), new IndexRange(16, 18) }, ranges);
+            Assert.Equal(new[] { new IndexRange(8, 10), new IndexRange(12, 13) }, deselected);
+        }
+
+        [Fact]
+        public void Remove_Should_Remove_Multiple_And_Partial_Ranges_3()
+        {
+            var ranges = new List<IndexRange> { new IndexRange(8, 10), new IndexRange(12, 14), new IndexRange(16, 18) };
+            var deselected = new List<IndexRange>();
+            var result = IndexRange.Remove(ranges, new IndexRange(9, 13), deselected);
+
+            Assert.Equal(4, result);
+            Assert.Equal(new[] { new IndexRange(8, 8), new IndexRange(14, 14), new IndexRange(16, 18) }, ranges);
+            Assert.Equal(new[] { new IndexRange(9, 10), new IndexRange(12, 13) }, deselected);
+        }
+
+        [Fact]
+        public void Remove_Should_Do_Nothing_For_Unselected_Range()
+        {
+            var ranges = new List<IndexRange> { new IndexRange(8, 10) };
+            var deselected = new List<IndexRange>();
+            var result = IndexRange.Remove(ranges, new IndexRange(2, 4), deselected);
+
+            Assert.Equal(0, result);
+            Assert.Equal(new[] { new IndexRange(8, 10) }, ranges);
+            Assert.Empty(deselected);
+        }
+
+        [Fact]
+        public void Stress_Test()
+        {
+            const int iterations = 100;
+            var random = new Random(0);
+            var selection = new List<IndexRange>();
+            var expected = new List<int>();
+
+            IndexRange Generate()
+            {
+                var start = random.Next(100);
+                return new IndexRange(start, start + random.Next(20));
+            }
+
+            for (var i = 0; i < iterations; ++i)
+            {
+                var toAdd = random.Next(5);
+
+                for (var j = 0; j < toAdd; ++j)
+                {
+                    var range = Generate();
+                    IndexRange.Add(selection, range);
+
+                    for (var k = range.Begin; k <= range.End; ++k)
+                    {
+                        if (!expected.Contains(k))
+                        {
+                            expected.Add(k);
+                        }
+                    }
+
+                    var actual = IndexRange.EnumerateIndices(selection).ToList();
+                    expected.Sort();
+                    Assert.Equal(expected, actual);
+                }
+
+                var toRemove = random.Next(5);
+
+                for (var j = 0; j < toRemove; ++j)
+                {
+                    var range = Generate();
+                    IndexRange.Remove(selection, range);
+
+                    for (var k = range.Begin; k <= range.End; ++k)
+                    {
+                        expected.Remove(k);
+                    }
+
+                    var actual = IndexRange.EnumerateIndices(selection).ToList();
+                    Assert.Equal(expected, actual);
+                }
+
+                selection.Clear();
+                expected.Clear();
+            }
+        }
+    }
+}

+ 4 - 4
tests/Avalonia.Controls.UnitTests/Presenters/ItemsPresenterTests_Virtualization_Simple.cs

@@ -573,7 +573,7 @@ namespace Avalonia.Controls.UnitTests.Presenters
                 target.Arrange(Rect.Empty);
 
                 // Check for issue #591: this should not throw.
-                target.ScrollIntoView(items[0]);
+                target.ScrollIntoView(0);
             }
         }
 
@@ -727,7 +727,7 @@ namespace Avalonia.Controls.UnitTests.Presenters
 
             var last = (target.Items as IList)[10];
 
-            target.ScrollIntoView(last);
+            target.ScrollIntoView(10);
 
             Assert.Equal(new Vector(0, 1), ((ILogicalScrollable)target).Offset);
             Assert.Same(target.Panel.Children[9].DataContext, last);
@@ -744,12 +744,12 @@ namespace Avalonia.Controls.UnitTests.Presenters
 
             var last = (target.Items as IList)[10];
 
-            target.ScrollIntoView(last);
+            target.ScrollIntoView(10);
 
             Assert.Equal(new Vector(0, 1), ((ILogicalScrollable)target).Offset);
             Assert.Same(target.Panel.Children[9].DataContext, last);
 
-            target.ScrollIntoView(last);
+            target.ScrollIntoView(10);
 
             Assert.Equal(new Vector(0, 1), ((ILogicalScrollable)target).Offset);
             Assert.Same(target.Panel.Children[9].DataContext, last);

+ 11 - 29
tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests.cs

@@ -536,37 +536,19 @@ namespace Avalonia.Controls.UnitTests.Primitives
             Assert.Equal(items[1], target.SelectedItem);
             Assert.Equal(1, target.SelectedIndex);
 
-            items.RemoveAt(1);
-
-            Assert.Null(target.SelectedItem);
-            Assert.Equal(-1, target.SelectedIndex);
-        }
-
-        [Fact]
-        public void Moving_Selected_Item_Should_Update_Selection()
-        {
-            var items = new AvaloniaList<Item>
-            {
-                new Item(),
-                new Item(),
-            };
-
-            var target = new SelectingItemsControl
-            {
-                Items = items,
-                Template = Template(),
-            };
+            SelectionChangedEventArgs receivedArgs = null;
 
-            target.ApplyTemplate();
-            target.SelectedIndex = 0;
+            target.SelectionChanged += (_, args) => receivedArgs = args;
 
-            Assert.Equal(items[0], target.SelectedItem);
-            Assert.Equal(0, target.SelectedIndex);
+            var removed = items[1];
 
-            items.Move(0, 1);
+            items.RemoveAt(1);
 
-            Assert.Equal(items[1], target.SelectedItem);
-            Assert.Equal(1, target.SelectedIndex);
+            Assert.Null(target.SelectedItem);
+            Assert.Equal(-1, target.SelectedIndex);
+            Assert.NotNull(receivedArgs);
+            Assert.Empty(receivedArgs.AddedItems);
+            Assert.Equal(new[] { removed }, receivedArgs.RemovedItems);
         }
 
         [Fact]
@@ -1089,8 +1071,8 @@ namespace Avalonia.Controls.UnitTests.Primitives
 
             items[1] = "Qux";
 
-            Assert.Equal(1, target.SelectedIndex);
-            Assert.Equal("Qux", target.SelectedItem);
+            Assert.Equal(-1, target.SelectedIndex);
+            Assert.Null(target.SelectedItem);
         }
 
         [Fact]

+ 2 - 2
tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_AutoSelect.cs

@@ -75,8 +75,8 @@ namespace Avalonia.Controls.UnitTests.Primitives
             target.SelectedIndex = 2;
             items.RemoveAt(2);
 
-            Assert.Equal(2, target.SelectedIndex);
-            Assert.Equal("qux", target.SelectedItem);
+            Assert.Equal(0, target.SelectedIndex);
+            Assert.Equal("foo", target.SelectedItem);
         }
 
         [Fact]

+ 340 - 22
tests/Avalonia.Controls.UnitTests/Primitives/SelectingItemsControlTests_Multiple.cs

@@ -70,8 +70,6 @@ namespace Avalonia.Controls.UnitTests.Primitives
         [Fact]
         public void Assigning_Multiple_SelectedItems_Should_Set_SelectedIndex()
         {
-            // Note that we don't need SelectionMode = Multiple here. Multiple selections can always
-            // be made in code.
             var target = new TestSelector
             {
                 Items = new[] { "foo", "bar", "baz" },
@@ -337,7 +335,6 @@ namespace Avalonia.Controls.UnitTests.Primitives
                     "qiz",
                     "lol",
                 },
-                SelectionMode = SelectionMode.Multiple,
                 Template = Template(),
             };
 
@@ -370,7 +367,7 @@ namespace Avalonia.Controls.UnitTests.Primitives
             target.SelectedIndex = 3;
             target.SelectRange(1);
 
-            Assert.Equal(new[] { "qux", "baz", "bar" }, target.SelectedItems.Cast<object>().ToList());
+            Assert.Equal(new[] { "bar", "baz", "qux" }, target.SelectedItems.Cast<object>().ToList());
         }
 
         [Fact]
@@ -672,7 +669,7 @@ namespace Avalonia.Controls.UnitTests.Primitives
 
             target.ApplyTemplate();
             target.Presenter.ApplyTemplate();
-            _helper.Click((Interactive)target.Presenter.Panel.Children[2], modifiers: InputModifiers.Shift);
+            _helper.Click((Interactive)target.Presenter.Panel.Children[2], modifiers: KeyModifiers.Shift);
 
             var panel = target.Presenter.Panel;
 
@@ -680,6 +677,57 @@ namespace Avalonia.Controls.UnitTests.Primitives
             Assert.Equal(new[] { 0, 1, 2 }, SelectedContainers(target));
         }
 
+        [Fact]
+        public void Ctrl_Selecting_Raises_SelectionChanged_Events()
+        {
+            var target = new ListBox
+            {
+                Template = Template(),
+                Items = new[] { "Foo", "Bar", "Baz", "Qux" },
+                SelectionMode = SelectionMode.Multiple,
+            };
+
+            target.ApplyTemplate();
+            target.Presenter.ApplyTemplate();
+
+            SelectionChangedEventArgs receivedArgs = null;
+
+            target.SelectionChanged += (_, args) => receivedArgs = args;
+
+            void VerifyAdded(string selection)
+            {
+                Assert.NotNull(receivedArgs);
+                Assert.Equal(new[] { selection }, receivedArgs.AddedItems);
+                Assert.Empty(receivedArgs.RemovedItems);
+            }
+
+            void VerifyRemoved(string selection)
+            {
+                Assert.NotNull(receivedArgs);
+                Assert.Equal(new[] { selection }, receivedArgs.RemovedItems);
+                Assert.Empty(receivedArgs.AddedItems);
+            }
+
+            _helper.Click((Interactive)target.Presenter.Panel.Children[1]);
+
+            VerifyAdded("Bar");
+
+            receivedArgs = null;
+            _helper.Click((Interactive)target.Presenter.Panel.Children[2], modifiers: KeyModifiers.Control);
+
+            VerifyAdded("Baz");
+
+            receivedArgs = null;
+            _helper.Click((Interactive)target.Presenter.Panel.Children[3], modifiers: KeyModifiers.Control);
+
+            VerifyAdded("Qux");
+
+            receivedArgs = null;
+            _helper.Click((Interactive)target.Presenter.Panel.Children[1], modifiers: KeyModifiers.Control);
+
+            VerifyRemoved("Bar");
+        }
+
         [Fact]
         public void Ctrl_Selecting_SelectedItem_With_Multiple_Selection_Active_Sets_SelectedItem_To_Next_Selection()
         {
@@ -693,14 +741,14 @@ namespace Avalonia.Controls.UnitTests.Primitives
             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);
+            _helper.Click((Interactive)target.Presenter.Panel.Children[2], modifiers: KeyModifiers.Control);
+            _helper.Click((Interactive)target.Presenter.Panel.Children[3], modifiers: KeyModifiers.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);
+            _helper.Click((Interactive)target.Presenter.Panel.Children[1], modifiers: KeyModifiers.Control);
 
             Assert.Equal(2, target.SelectedIndex);
             Assert.Equal("Baz", target.SelectedItem);
@@ -720,12 +768,12 @@ namespace Avalonia.Controls.UnitTests.Primitives
             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[2], modifiers: KeyModifiers.Control);
 
             Assert.Equal(1, target.SelectedIndex);
             Assert.Equal("Bar", target.SelectedItem);
 
-            _helper.Click((Interactive)target.Presenter.Panel.Children[2], modifiers: InputModifiers.Control);
+            _helper.Click((Interactive)target.Presenter.Panel.Children[2], modifiers: KeyModifiers.Control);
 
             Assert.Equal(1, target.SelectedIndex);
             Assert.Equal("Bar", target.SelectedItem);
@@ -744,7 +792,7 @@ namespace Avalonia.Controls.UnitTests.Primitives
             target.ApplyTemplate();
             target.Presenter.ApplyTemplate();
             _helper.Click((Interactive)target.Presenter.Panel.Children[3]);
-            _helper.Click((Interactive)target.Presenter.Panel.Children[4], modifiers: InputModifiers.Control);
+            _helper.Click((Interactive)target.Presenter.Panel.Children[4], modifiers: KeyModifiers.Control);
 
             var panel = target.Presenter.Panel;
 
@@ -765,7 +813,7 @@ namespace Avalonia.Controls.UnitTests.Primitives
             target.ApplyTemplate();
             target.Presenter.ApplyTemplate();
             _helper.Click((Interactive)target.Presenter.Panel.Children[3]);
-            _helper.Click((Interactive)target.Presenter.Panel.Children[5], modifiers: InputModifiers.Shift);
+            _helper.Click((Interactive)target.Presenter.Panel.Children[5], modifiers: KeyModifiers.Shift);
 
             var panel = target.Presenter.Panel;
 
@@ -786,7 +834,7 @@ namespace Avalonia.Controls.UnitTests.Primitives
             target.ApplyTemplate();
             target.Presenter.ApplyTemplate();
             _helper.Click((Interactive)target.Presenter.Panel.Children[0]);
-            _helper.Click((Interactive)target.Presenter.Panel.Children[5], modifiers: InputModifiers.Shift);
+            _helper.Click((Interactive)target.Presenter.Panel.Children[5], modifiers: KeyModifiers.Shift);
 
             var panel = target.Presenter.Panel;
 
@@ -794,6 +842,52 @@ namespace Avalonia.Controls.UnitTests.Primitives
             Assert.Equal(new[] { 0, 1, 2, 3, 4, 5 }, SelectedContainers(target));
         }
 
+        [Fact]
+        public void Shift_Selecting_Raises_SelectionChanged_Events()
+        {
+            var target = new ListBox
+            {
+                Template = Template(),
+                Items = new[] { "Foo", "Bar", "Baz", "Qux" },
+                SelectionMode = SelectionMode.Multiple,
+            };
+
+            target.ApplyTemplate();
+            target.Presenter.ApplyTemplate();
+
+            SelectionChangedEventArgs receivedArgs = null;
+
+            target.SelectionChanged += (_, args) => receivedArgs = args;
+
+            void VerifyAdded(params string[] selection)
+            {
+                Assert.NotNull(receivedArgs);
+                Assert.Equal(selection, receivedArgs.AddedItems);
+                Assert.Empty(receivedArgs.RemovedItems);
+            }
+
+            void VerifyRemoved(string selection)
+            {
+                Assert.NotNull(receivedArgs);
+                Assert.Equal(new[] { selection }, receivedArgs.RemovedItems);
+                Assert.Empty(receivedArgs.AddedItems);
+            }
+
+            _helper.Click((Interactive)target.Presenter.Panel.Children[1]);
+
+            VerifyAdded("Bar");
+
+            receivedArgs = null;
+            _helper.Click((Interactive)target.Presenter.Panel.Children[3], modifiers: KeyModifiers.Shift);
+
+            VerifyAdded("Baz" ,"Qux");
+
+            receivedArgs = null;
+            _helper.Click((Interactive)target.Presenter.Panel.Children[2], modifiers: KeyModifiers.Shift);
+
+            VerifyRemoved("Qux");
+        }
+
         [Fact]
         public void Duplicate_Items_Are_Added_To_SelectedItems_In_Order()
         {
@@ -810,15 +904,15 @@ namespace Avalonia.Controls.UnitTests.Primitives
 
             Assert.Equal(new[] { "Foo" }, target.SelectedItems);
 
-            _helper.Click((Interactive)target.Presenter.Panel.Children[4], modifiers: InputModifiers.Control);
+            _helper.Click((Interactive)target.Presenter.Panel.Children[4], modifiers: KeyModifiers.Control);
 
             Assert.Equal(new[] { "Foo", "Bar" }, target.SelectedItems);
 
-            _helper.Click((Interactive)target.Presenter.Panel.Children[3], modifiers: InputModifiers.Control);
+            _helper.Click((Interactive)target.Presenter.Panel.Children[3], modifiers: KeyModifiers.Control);
 
             Assert.Equal(new[] { "Foo", "Bar", "Foo" }, target.SelectedItems);
 
-            _helper.Click((Interactive)target.Presenter.Panel.Children[1], modifiers: InputModifiers.Control);
+            _helper.Click((Interactive)target.Presenter.Panel.Children[1], modifiers: KeyModifiers.Control);
 
             Assert.Equal(new[] { "Foo", "Bar", "Foo", "Bar" }, target.SelectedItems);
         }
@@ -842,6 +936,30 @@ namespace Avalonia.Controls.UnitTests.Primitives
             Assert.Equal("Foo", target.SelectedItem);
         }
 
+        [Fact]
+        public void SelectAll_Raises_SelectionChanged_Event()
+        {
+            var target = new TestSelector
+            {
+                Template = Template(),
+                Items = new[] { "Foo", "Bar", "Baz" },
+                SelectionMode = SelectionMode.Multiple,
+            };
+
+            target.ApplyTemplate();
+            target.Presenter.ApplyTemplate();
+
+            SelectionChangedEventArgs receivedArgs = null;
+
+            target.SelectionChanged += (_, args) => receivedArgs = args;
+
+            target.SelectAll();
+
+            Assert.NotNull(receivedArgs);
+            Assert.Equal(target.Items, receivedArgs.AddedItems);
+            Assert.Empty(receivedArgs.RemovedItems);
+        }
+
         [Fact]
         public void UnselectAll_Clears_SelectedIndex_And_SelectedItem()
         {
@@ -993,7 +1111,7 @@ namespace Avalonia.Controls.UnitTests.Primitives
             target.SelectAll();
             items[1] = "Qux";
 
-            Assert.Equal(new[] { "Foo", "Qux", "Baz" }, target.SelectedItems);
+            Assert.Equal(new[] { "Foo", "Baz" }, target.SelectedItems);
         }
 
         [Fact]
@@ -1056,7 +1174,7 @@ namespace Avalonia.Controls.UnitTests.Primitives
             target.ApplyTemplate();
             target.Presenter.ApplyTemplate();
             _helper.Click((Interactive)target.Presenter.Panel.Children[0]);
-            _helper.Click((Interactive)target.Presenter.Panel.Children[1], modifiers: InputModifiers.Shift);
+            _helper.Click((Interactive)target.Presenter.Panel.Children[1], modifiers: KeyModifiers.Shift);
 
             Assert.Equal(2, target.SelectedItems.Count);
 
@@ -1106,7 +1224,7 @@ namespace Avalonia.Controls.UnitTests.Primitives
             target.Presenter.ApplyTemplate();
 
             _helper.Click((Interactive)target.Presenter.Panel.Children[0]);
-            _helper.Click((Interactive)target.Presenter.Panel.Children[2], MouseButton.Right, modifiers: InputModifiers.Shift);
+            _helper.Click((Interactive)target.Presenter.Panel.Children[2], MouseButton.Right, modifiers: KeyModifiers.Shift);
 
             Assert.Equal(1, target.SelectedItems.Count);
         }
@@ -1126,11 +1244,200 @@ namespace Avalonia.Controls.UnitTests.Primitives
             target.Presenter.ApplyTemplate();
 
             _helper.Click((Interactive)target.Presenter.Panel.Children[0]);
-            _helper.Click((Interactive)target.Presenter.Panel.Children[2], MouseButton.Right, modifiers: InputModifiers.Control);
+            _helper.Click((Interactive)target.Presenter.Panel.Children[2], MouseButton.Right, modifiers: KeyModifiers.Control);
 
             Assert.Equal(1, target.SelectedItems.Count);
         }
 
+        [Fact]
+        public void Adding_To_Selection_Should_Set_SelectedIndex()
+        {
+            var target = new TestSelector
+            {
+                Items = new[] { "foo", "bar" },
+                Template = Template(),
+            };
+
+            target.ApplyTemplate();
+            target.Selection.Select(1);
+
+            Assert.Equal(1, target.SelectedIndex);
+        }
+
+        [Fact]
+        public void Assigning_Null_To_Selection_Should_Create_New_SelectionModel()
+        {
+            var target = new TestSelector
+            {
+                Items = new[] { "foo", "bar" },
+                Template = Template(),
+            };
+
+            var oldSelection = target.Selection;
+
+            target.Selection = null;
+
+            Assert.NotNull(target.Selection);
+            Assert.NotSame(oldSelection, target.Selection);
+        }
+
+        [Fact]
+        public void Assigning_SelectionModel_With_Different_Source_To_Selection_Should_Fail()
+        {
+            var target = new TestSelector
+            {
+                Items = new[] { "foo", "bar" },
+                Template = Template(),
+            };
+
+            var selection = new SelectionModel { Source = new[] { "baz" } };
+            Assert.Throws<ArgumentException>(() => target.Selection = selection);
+        }
+
+        [Fact]
+        public void Assigning_SelectionModel_With_Null_Source_To_Selection_Should_Set_Source()
+        {
+            var target = new TestSelector
+            {
+                Items = new[] { "foo", "bar" },
+                Template = Template(),
+            };
+
+            var selection = new SelectionModel();
+            target.Selection = selection;
+
+            Assert.Same(target.Items, selection.Source);
+        }
+
+        [Fact]
+        public void Assigning_Single_Selected_Item_To_Selection_Should_Set_SelectedIndex()
+        {
+            var target = new TestSelector
+            {
+                Items = new[] { "foo", "bar" },
+                Template = Template(),
+            };
+
+            target.ApplyTemplate();
+            target.Presenter.ApplyTemplate();
+
+            var selection = new SelectionModel { Source = target.Items };
+            selection.Select(1);
+            target.Selection = selection;
+
+            Assert.Equal(1, target.SelectedIndex);
+            Assert.Equal(new[] { "bar" }, target.Selection.SelectedItems);
+            Assert.Equal(new[] { 1 }, SelectedContainers(target));
+        }
+
+        [Fact]
+        public void Assigning_Multiple_Selected_Items_To_Selection_Should_Set_SelectedIndex()
+        {
+            var target = new TestSelector
+            {
+                Items = new[] { "foo", "bar", "baz" },
+                Template = Template(),
+            };
+
+            target.ApplyTemplate();
+            target.Presenter.ApplyTemplate();
+
+            var selection = new SelectionModel { Source = target.Items };
+            selection.SelectRange(new IndexPath(0), new IndexPath(2));
+            target.Selection = selection;
+
+            Assert.Equal(0, target.SelectedIndex);
+            Assert.Equal(new[] { "foo", "bar", "baz" }, target.Selection.SelectedItems);
+            Assert.Equal(new[] { 0, 1, 2 }, SelectedContainers(target));
+        }
+
+        [Fact]
+        public void Reassigning_Selection_Should_Clear_Selection()
+        {
+            var target = new TestSelector
+            {
+                Items = new[] { "foo", "bar" },
+                Template = Template(),
+            };
+
+            target.ApplyTemplate();
+            target.Selection.Select(1);
+            target.Selection = new SelectionModel();
+
+            Assert.Equal(-1, target.SelectedIndex);
+            Assert.Null(target.SelectedItem);
+        }
+
+        [Fact]
+        public void Assigning_Selection_Should_Set_Item_IsSelected()
+        {
+            var items = new[]
+            {
+                new ListBoxItem(),
+                new ListBoxItem(),
+                new ListBoxItem(),
+            };
+
+            var target = new TestSelector
+            {
+                Items = items,
+                Template = Template(),
+            };
+
+            target.ApplyTemplate();
+            target.Presenter.ApplyTemplate();
+
+            var selection = new SelectionModel { Source = items };
+            selection.SelectRange(new IndexPath(0), new IndexPath(1));
+            target.Selection = selection;
+
+            Assert.True(items[0].IsSelected);
+            Assert.True(items[1].IsSelected);
+            Assert.False(items[2].IsSelected);
+        }
+
+        [Fact]
+        public void Assigning_Selection_Should_Raise_SelectionChanged()
+        {
+            var items = new[] { "foo", "bar", "baz" };
+
+            var target = new TestSelector
+            {
+                Items = items,
+                Template = Template(),
+                SelectedItem = "bar",
+            };
+
+            var raised = 0;
+
+            target.SelectionChanged += (s, e) =>
+            {
+                if (raised == 0)
+                {
+                    Assert.Empty(e.AddedItems.Cast<object>());
+                    Assert.Equal(new[] { "bar" }, e.RemovedItems.Cast<object>());
+                }
+                else
+                {
+                    Assert.Equal(new[] { "foo", "baz" }, e.AddedItems.Cast<object>());
+                    Assert.Empty(e.RemovedItems.Cast<object>());
+                }
+
+                ++raised;
+            };
+
+            target.ApplyTemplate();
+            target.Presenter.ApplyTemplate();
+
+
+            var selection = new SelectionModel { Source = items };
+            selection.Select(0);
+            selection.Select(2);
+            target.Selection = selection;
+
+            Assert.Equal(2, raised);
+        }
+
         private IEnumerable<int> SelectedContainers(SelectingItemsControl target)
         {
             return target.Presenter.Panel.Children
@@ -1154,20 +1461,31 @@ namespace Avalonia.Controls.UnitTests.Primitives
             public static readonly new AvaloniaProperty<IList> SelectedItemsProperty = 
                 SelectingItemsControl.SelectedItemsProperty;
 
+            public TestSelector()
+            {
+                SelectionMode = SelectionMode.Multiple;
+            }
+
             public new IList SelectedItems
             {
                 get { return base.SelectedItems; }
                 set { base.SelectedItems = value; }
             }
 
+            public new ISelectionModel Selection
+            {
+                get => base.Selection;
+                set => base.Selection = value;
+            }
+
             public new SelectionMode SelectionMode
             {
                 get { return base.SelectionMode; }
                 set { base.SelectionMode = value; }
             }
 
-            public new void SelectAll() => base.SelectAll();
-            public new void UnselectAll() => base.UnselectAll();
+            public void SelectAll() => Selection.SelectAll();
+            public void UnselectAll() => Selection.ClearSelection();
             public void SelectRange(int index) => UpdateSelection(index, true, true);
             public void Toggle(int index) => UpdateSelection(index, true, false, true);
         }

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików